diff --git a/.devcontainer/.devcontainer.json b/.devcontainer/.devcontainer.json new file mode 100644 index 0000000..e92ed3b --- /dev/null +++ b/.devcontainer/.devcontainer.json @@ -0,0 +1,51 @@ +// For format details, see https://aka.ms/devcontainer.json. For config options, see the +// README at: https://github.com/devcontainers/templates/tree/main/src/java +{ + "name": "Microprofile", + // Or use a Dockerfile or Docker Compose file. More info: https://containers.dev/guide/dockerfile + "image": "mcr.microsoft.com/devcontainers/java:1-17-bookworm", + + "features": { + "ghcr.io/devcontainers/features/java:1": { + "installMaven": true, + "version": "17", + "jdkDistro": "ms", + "gradleVersion": "latest", + "mavenVersion": "latest", + "antVersion": "latest", + "groovyVersion": "latest" + }, + "ghcr.io/ebaskoro/devcontainer-features/sdkman:1": { + "candidate": "java", + "version": "latest" + }, + "ghcr.io/devcontainers/features/docker-in-docker:2": { + "moby": true, + "azureDnsAutoDetection": true, + "installDockerBuildx": true, + "installDockerComposeSwitch": true, + "version": "latest", + "dockerDashComposeVersion": "none" + } + }, + + // Use 'forwardPorts' to make a list of ports inside the container available locally. + // "forwardPorts": [], + + // Use 'postCreateCommand' to run commands after the container is created. + // "postCreateCommand": "java -version", + + // Configure tool-specific properties. + "customizations": { + "vscode": { + "extensions": [ + "vscjava.vscode-java-pack", + "github.copilot", + "microprofile-community.vscode-microprofile-pack" + ] + } + } + + // Uncomment to connect as root instead. More info: https://aka.ms/dev-containers-non-root. + // "remoteUser": "root" +} diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..08e9b4f --- /dev/null +++ b/.gitignore @@ -0,0 +1,71 @@ +# Compiled class file +*.class + +# Log file +*.log + +# BlueJ files +*.ctxt + +# Mobile Tools for Java (J2ME) +.mtj.tmp/ + +# Package Files # +*.jar +*.war +*.nar +*.ear +*.zip +*.tar.gz +*.rar + +# virtual machine crash logs, see http://www.java.com/en/download/help/error_hotspot.xml +hs_err_pid* +replay_pid* + +# Maven +log/ +target/ +release/ +dependency-reduced-pom.xml +buildNumber.properties +.mvn/timing.properties + +# Gradle +.gradle/ +build/ +!gradle/wrapper/gradle-wrapper.jar + +# Eclipse +.classpath +.project +.settings/ + +# IntelliJ +*.iml +.idea/ +out/ + +# VS Code +.vscode/ + +# OS Files +.DS_Store +Thumbs.db + +# Others +*.mf +*.swp +*.cs +*~ + +# Liberty +wlp/usr/servers/*/logs/ +wlp/usr/servers/*/workarea/ +wlp/usr/servers/*/dropins/ +wlp/usr/servers/*/apps/ +wlp/usr/servers/*/configDropins/ + +# Specific project directories +liberty-rest-app/target/ +mp-ecomm-store/target/ diff --git a/code/.devcontainer/devcontainer.json b/code/.devcontainer/devcontainer.json new file mode 100644 index 0000000..bf94e83 --- /dev/null +++ b/code/.devcontainer/devcontainer.json @@ -0,0 +1,53 @@ +// For format details, see https://aka.ms/devcontainer.json. For config options, see the +// README at: https://github.com/devcontainers/templates/tree/main/src/java +{ + "name": "Microprofile", + // Or use a Dockerfile or Docker Compose file. More info: https://containers.dev/guide/dockerfile + "image": "mcr.microsoft.com/devcontainers/java:1-17-bookworm", + + "features": { + "ghcr.io/devcontainers/features/java:1": { + "installMaven": true, + "version": "17", + "jdkDistro": "ms", + "gradleVersion": "latest", + "mavenVersion": "latest", + "antVersion": "latest", + "groovyVersion": "latest" + }, + "ghcr.io/ebaskoro/devcontainer-features/sdkman:1": { + "candidate": "java", + "version": "latest" + }, + "ghcr.io/devcontainers/features/docker-in-docker:2": { + "moby": true, + "azureDnsAutoDetection": true, + "installDockerBuildx": true, + "installDockerComposeSwitch": true, + "version": "latest", + "dockerDashComposeVersion": "none" + } + }, + + // Use 'forwardPorts' to make a list of ports inside the container available locally. + // "forwardPorts": [], + + // Use 'postCreateCommand' to run commands after the container is created. + // "postCreateCommand": "java -version", + + // Configure tool-specific properties. + "customizations": { + "vscode": { + "extensions": [ + "vscjava.vscode-java-pack", + "github.copilot", + "microprofile-community.vscode-microprofile-pack", + "asciidoctor.asciidoctor-vscode", + "ms-vscode-remote.remote-containers" + ] + } + } + + // Uncomment to connect as root instead. More info: https://aka.ms/dev-containers-non-root. + // "remoteUser": "root" +} diff --git a/code/.gitignore b/code/.gitignore new file mode 100644 index 0000000..66b3aae --- /dev/null +++ b/code/.gitignore @@ -0,0 +1,83 @@ +# Compiled class files +*.class + +# Log files +*.log + +# BlueJ files +*.ctxt + +# Mobile Tools for Java (J2ME) +.mtj.tmp/ + +# Package Files +*.jar +*.war +*.nar +*.ear +*.zip +*.tar.gz +*.rar + +# Virtual machine crash logs +hs_err_pid* +replay_pid* + +# Maven +target/ +pom.xml.tag +pom.xml.releaseBackup +pom.xml.versionsBackup +pom.xml.next +release.properties +dependency-reduced-pom.xml +buildNumber.properties +.mvn/timing.properties +.mvn/wrapper/maven-wrapper.jar + +# Liberty +.liberty/ +.factorypath +wlp/ +liberty/ + +# IntelliJ IDEA +.idea/ +*.iws +*.iml +*.ipr + +# Eclipse +.apt_generated/ +.settings/ +.project +.classpath +.factorypath +.metadata/ +bin/ +tmp/ + +# VS Code +.vscode/* +!.vscode/settings.json +!.vscode/tasks.json +!.vscode/launch.json +!.vscode/extensions.json + +# Test reports (keeping XML reports) +target/surefire-reports/junitreports/ +target/surefire-reports/old/ +target/site/ +target/failsafe-reports/ + +# JaCoCo coverage reports except the main report +target/jacoco/ +!target/site/jacoco/index.html + +# Miscellaneous +*.swp +*.bak +*.tmp +*~ +.DS_Store +Thumbs.db diff --git a/code/chapter02/mp-ecomm-store/README.adoc b/code/chapter02/mp-ecomm-store/README.adoc new file mode 100644 index 0000000..90556dc --- /dev/null +++ b/code/chapter02/mp-ecomm-store/README.adoc @@ -0,0 +1,149 @@ += MicroProfile E-Commerce Store +:toc: +:icons: font +:source-highlighter: highlight.js +:experimental: + +== Overview + +This project demonstrates a MicroProfile-based e-commerce microservice. It provides REST endpoints for product management and showcases Jakarta EE 10 and MicroProfile 6.1 features in a practical application. + +== Development Environment + +This project includes a GitHub Codespace configuration with: + +* JDK 17 via SDKMAN +* Maven build tools +* VS Code extensions for Java and MicroProfile development + +=== Installing SDKMAN + +If SDKMAN is not already installed, follow the official instructions at https://sdkman.io/install/ or run: + +[source,bash] +---- +curl -s "https://get.sdkman.io" | bash +source "$HOME/.sdkman/bin/sdkman-init.sh" +---- + +=== Switching Java Versions + +SDKMAN allows easy switching between Java versions: + +[source,bash] +---- +# List installed Java versions +sdk list java | grep -E ".*\s+installed" + +# Switch to JDK 17 for the current shell session +sdk use java 17.0.15-ms + +# Make JDK 17 the default for all sessions +sdk default java 17.0.15-ms + +# Verify the current Java version +java -version +---- + +== Project Structure + +[source] +---- +mp-ecomm-store/ +├── src/ +│ ├── main/ +│ │ ├── java/ +│ │ │ └── io/microprofile/tutorial/store/product/ +│ │ │ ├── entity/ +│ │ │ │ └── Product.java +│ │ │ ├── resource/ +│ │ │ │ └── ProductResource.java +│ │ │ └── ProductRestApplication.java +│ │ ├── liberty/config/ +│ │ │ └── server.xml +│ │ └── webapp/ +│ │ └── WEB-INF/ +│ │ └── web.xml +│ └── test/ +└── pom.xml +---- + +== REST Endpoints + +[cols="2,1,3"] +|=== +|Endpoint |Method |Description + +|`/mp-ecomm-store/api/products` +|GET +|Returns a list of products in JSON format +|=== + +== Technology Stack + +* Jakarta EE 10 +* MicroProfile 6.1 +* Open Liberty +* Lombok +* JUnit 5 + +== Model Class + +The Product model demonstrates the use of Lombok annotations for boilerplate code reduction: + +[source,java] +---- +@Data +@NoArgsConstructor +@AllArgsConstructor +public class Product { + private Long id; + private String name; + private String description; + private Double price; +} +---- + +== Building the Application + +[source,bash] +---- +mvn clean package +---- + +== Running the Application + +=== Using Liberty Maven Plugin + +[source,bash] +---- +mvn liberty:dev +---- + +This starts Open Liberty server in development mode with hot reloading enabled, allowing for rapid development cycles. + +=== Accessing the Application + +Once the server is running, you can access: + +* Products Endpoint: http://:5050/mp-ecomm-store/api/products + +Replace with _localhost_ or the hostname of the system, where you are running this code. + +=== Running Unit Tests + +To run the `ProductResourceTest.java` unit test, use the following Maven command from the project root: + +[source,bash] +---- +mvn test -Dtest=io.microprofile.tutorial.store.product.resource.ProductResourceTest +---- + +This will execute the test and display the results in the terminal. + +== Key Features + +Current features: + +* Basic product listing functionality +* JSON-B serialization diff --git a/code/chapter02/mp-ecomm-store/pom.xml b/code/chapter02/mp-ecomm-store/pom.xml new file mode 100644 index 0000000..498133a --- /dev/null +++ b/code/chapter02/mp-ecomm-store/pom.xml @@ -0,0 +1,113 @@ + + + + 4.0.0 + + io.microprofile.tutorial + mp-ecomm-store + 1.0-SNAPSHOT + war + + + 3.10.1 + + UTF-8 + UTF-8 + + + 17 + 17 + + + 5050 + 5051 + + mp-ecomm-store + + + + + + + org.projectlombok + lombok + 1.18.26 + provided + + + + + jakarta.platform + jakarta.jakartaee-api + 10.0.0 + provided + + + + + org.eclipse.microprofile + microprofile + 6.1 + pom + provided + + + + + org.junit.jupiter + junit-jupiter-api + 5.8.2 + test + + + + + org.junit.jupiter + junit-jupiter-engine + 5.8.2 + test + + + + + ${project.artifactId} + + + + io.openliberty.tools + liberty-maven-plugin + 3.10.1 + + mpServer + + + + + org.apache.maven.plugins + maven-war-plugin + 3.3.2 + + + + + org.apache.maven.plugins + maven-surefire-plugin + 3.0.0 + + + + + org.apache.maven.plugins + maven-failsafe-plugin + 3.0.0 + + + ${liberty.var.default.http.port} + ${liberty.var.app.context.root} + + + + + + diff --git a/code/chapter02/mp-ecomm-store/src/main/java/io/microprofile/tutorial/store/product/ProductRestApplication.java b/code/chapter02/mp-ecomm-store/src/main/java/io/microprofile/tutorial/store/product/ProductRestApplication.java new file mode 100644 index 0000000..68ca99c --- /dev/null +++ b/code/chapter02/mp-ecomm-store/src/main/java/io/microprofile/tutorial/store/product/ProductRestApplication.java @@ -0,0 +1,9 @@ +package io.microprofile.tutorial.store.product; + +import jakarta.ws.rs.ApplicationPath; +import jakarta.ws.rs.core.Application; + +@ApplicationPath("/api") +public class ProductRestApplication extends Application{ + +} diff --git a/code/chapter02/mp-ecomm-store/src/main/java/io/microprofile/tutorial/store/product/entity/Product.java b/code/chapter02/mp-ecomm-store/src/main/java/io/microprofile/tutorial/store/product/entity/Product.java new file mode 100644 index 0000000..15580e9 --- /dev/null +++ b/code/chapter02/mp-ecomm-store/src/main/java/io/microprofile/tutorial/store/product/entity/Product.java @@ -0,0 +1,13 @@ +package io.microprofile.tutorial.store.product.entity; + +import lombok.AllArgsConstructor; +import lombok.Data; + +@Data +@AllArgsConstructor +public class Product { + private Long id; + private String name; + private String description; + private Double price; +} \ No newline at end of file diff --git a/code/chapter02/mp-ecomm-store/src/main/java/io/microprofile/tutorial/store/product/resource/ProductResource.java b/code/chapter02/mp-ecomm-store/src/main/java/io/microprofile/tutorial/store/product/resource/ProductResource.java new file mode 100644 index 0000000..feb492a --- /dev/null +++ b/code/chapter02/mp-ecomm-store/src/main/java/io/microprofile/tutorial/store/product/resource/ProductResource.java @@ -0,0 +1,33 @@ +package io.microprofile.tutorial.store.product.resource; + +import java.util.ArrayList; +import java.util.List; + +import io.microprofile.tutorial.store.product.entity.Product; + +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.ws.rs.GET; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.Produces; +import jakarta.ws.rs.core.MediaType; + +@Path("/products") +@ApplicationScoped +public class ProductResource { + private List products; + + public ProductResource() { + products = new ArrayList<>(); + + products.add(new Product(Long.valueOf(1L), "iPhone", "Apple iPhone 15", Double.valueOf(999.99))); + products.add(new Product(Long.valueOf(2L), "MacBook", "Apple MacBook Air", Double.valueOf(1299.99))); + } + + @GET + @Produces(MediaType.APPLICATION_JSON) + public List getProducts() { + // Return a list of products + return products; + } +} + diff --git a/code/chapter02/mp-ecomm-store/src/main/liberty/config/server.xml b/code/chapter02/mp-ecomm-store/src/main/liberty/config/server.xml new file mode 100644 index 0000000..c5ee0f9 --- /dev/null +++ b/code/chapter02/mp-ecomm-store/src/main/liberty/config/server.xml @@ -0,0 +1,10 @@ + + + restfulWS-3.1 + jsonb-3.0 + + + + + diff --git a/code/chapter02/mp-ecomm-store/src/test/java/io/microprofile/tutorial/store/product/resource/ProductResourceTest.java b/code/chapter02/mp-ecomm-store/src/test/java/io/microprofile/tutorial/store/product/resource/ProductResourceTest.java new file mode 100644 index 0000000..3e957c0 --- /dev/null +++ b/code/chapter02/mp-ecomm-store/src/test/java/io/microprofile/tutorial/store/product/resource/ProductResourceTest.java @@ -0,0 +1,34 @@ +package io.microprofile.tutorial.store.product.resource; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; + +import java.util.List; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import io.microprofile.tutorial.store.product.entity.Product; + +public class ProductResourceTest { + private ProductResource productResource; + + @BeforeEach + void setUp() { + productResource = new ProductResource(); + } + + @AfterEach + void tearDown() { + productResource = null; + } + + @Test + void testGetProducts() { + List products = productResource.getProducts(); + + assertNotNull(products); + assertEquals(2, products.size()); + } +} \ No newline at end of file diff --git a/code/chapter03/catalog/README.adoc b/code/chapter03/catalog/README.adoc new file mode 100644 index 0000000..4252a6d --- /dev/null +++ b/code/chapter03/catalog/README.adoc @@ -0,0 +1,361 @@ += MicroProfile Catalog Service +:toc: +:icons: font +:source-highlighter: highlight.js +:imagesdir: images +:url-quickstart: https://openliberty.io/guides/ + +== Overview + +The MicroProfile Catalog Service is a Jakarta EE 10 and MicroProfile 6.1 application that provides a RESTful API for managing product catalog information in an e-commerce platform. It demonstrates the use of modern Jakarta EE features including CDI, Jakarta Persistence, Jakarta RESTful Web Services, and Bean Validation. + +== Features + +* RESTful API using Jakarta RESTful Web Services +* Persistence with Jakarta Persistence API and Derby embedded database +* CDI (Contexts and Dependency Injection) for component management +* Bean Validation for input validation +* Running on Open Liberty for lightweight deployment + +== Project Structure + +[source] +---- +catalog/ +├── src/ +│ ├── main/ +│ │ ├── java/ +│ │ │ └── io/microprofile/tutorial/store/product/ +│ │ │ ├── entity/ # Domain models (Product) +│ │ │ ├── repository/ # Data access layer +│ │ │ ├── resource/ # REST endpoints +│ │ │ ├── service/ # Business logic +│ │ │ └── ProductRestApplication.java +│ │ ├── liberty/ +│ │ │ └── config/ +│ │ │ └── server.xml # Liberty server configuration +│ │ └── resources/ +│ │ └── META-INF/ # Persistence configuration +│ └── test/ +│ └── java/ # Unit and integration tests +└── pom.xml # Project build configuration +---- + +== Architecture + +This application follows a layered architecture: + +1. *REST Resources* (`/resource`) - Provides HTTP endpoints for clients +2. *Services* (`/service`) - Implements business logic and transaction management +3. *Repositories* (`/repository`) - Data access objects for database operations +4. *Entities* (`/entity`) - Domain models with JPA annotations + +== Database Configuration + +The application uses an embedded Derby database that is automatically provisioned by Open Liberty. The database configuration is defined in the `server.xml` file: + +[source,xml] +---- + + + + +---- + +== Liberty Server Configuration + +The Open Liberty server is configured in `src/main/liberty/config/server.xml`: + +[source,xml] +---- + + + jakartaEE-10.0 + microProfile-6.1 + restfulWS + jsonp + jsonb + cdi + persistence + jdbc + + + + + + + + + + + + + + + + +---- + +== Building and Running + +=== Prerequisites + +* JDK 17 or higher +* Maven 3.8.x or higher +* Docker (optional, for containerization) + +=== Development Mode + +To run the application in development mode with hot reload: + +[source,bash] +---- +mvn liberty:dev +---- + +This will start the server on port 5050 (configured in pom.xml). + +=== Building the Application + +To build the application: + +[source,bash] +---- +mvn clean package +---- + +This will create a WAR file in the `target/` directory. + +=== Running the Tests + +To run the tests: + +[source,bash] +---- +mvn test +---- + +=== Deployment + +The application can be deployed to any Jakarta EE 10 compliant server. With Liberty: + +[source,bash] +---- +mvn liberty:run +---- + +== API Endpoints + +The API is accessible at the base path `/catalog/api`. + +=== Products API + +|=== +| Method | Path | Description | Status Codes + +| GET | `/products` | List all products | 200 OK +| GET | `/products/{id}` | Get product by ID | 200 OK, 404 Not Found +| POST | `/products` | Create a product | 201 Created +| PUT | `/products/{id}` | Update a product | 200 OK, 404 Not Found +| DELETE | `/products/{id}` | Delete a product | 204 No Content, 404 Not Found +|=== + +== Request/Response Examples + +=== Get all products + +Request: +[source] +---- +GET /catalog/api/products +Accept: application/json +---- + +Response: +[source,json] +---- +[ + { + "id": 1, + "name": "Laptop", + "description": "High-performance laptop", + "price": 999.99 + }, + { + "id": 2, + "name": "Smartphone", + "description": "Latest model smartphone", + "price": 699.99 + } +] +---- + +=== Create a product + +Request: +[source] +---- +POST /catalog/api/products +Content-Type: application/json +---- + +[source,json] +---- +{ + "name": "Tablet", + "description": "10-inch tablet with high resolution display", + "price": 499.99 +} +---- + +Response: +[source] +---- +HTTP/1.1 201 Created +Location: /catalog/api/products/3 +Content-Type: application/json +---- + +[source,json] +---- +{ + "id": 3, + "name": "Tablet", + "description": "10-inch tablet with high resolution display", + "price": 499.99 +} +---- + +=== Common Issues + +* *404 Not Found*: Ensure you're using the correct context root (`/catalog`) and API base path (`/api`). +* *500 Internal Server Error*: Check server logs for exceptions. +* *Database issues*: Check if Derby is properly configured and the `productjpadatasource` is available. +* *EntityManager is null*: This can happen due to constructor-related issues with CDI. Make sure your repositories are properly injected and not manually instantiated. +* *SQL errors*: Ensure SQL statements in `import.sql` end with semicolons. Each INSERT statement must end with a semicolon (;) to be properly executed. + +=== SQL Script Format + +When writing SQL scripts for initialization, ensure each statement ends with a semicolon: + +[source,sql] +---- +-- Correct format +INSERT INTO Product (id, name, description, price) VALUES (1, 'iPhone', 'Apple iPhone 15', 999.99) +INSERT INTO Product (id, name, description, price) VALUES (2, 'MacBook', 'Apple MacBook Air', 1299.0) +---- + +=== JPA and CDI Pitfalls + +* *Manual instantiation*: Never use `new ProductRepository()` - always let CDI handle injection +* *Scope mismatch*: Ensure your beans have appropriate scopes (@ApplicationScoped for repositories) +* *Missing constructor*: Provide a no-args constructor for CDI beans with injected fields +* *Transaction boundaries*: Use @Transactional on methods that interact with the database + +=== Logs + +Server logs are available at: + +[source] +---- +target/liberty/wlp/usr/servers/mpServer/logs/ +---- + +== Derby Database Details + +The application uses an embedded Derby database, which is initialized on startup. Here are some important details: + +=== Database Schema + +The database schema is automatically generated based on JPA entity annotations using the following configuration in persistence.xml: + +[source,xml] +---- + + +---- + +=== Initial Data Loading + +Initial product data is loaded from `META-INF/sql/import.sql`. This script is executed after the schema is created. + +[source,sql] +---- +-- Initial product data +INSERT INTO Product (id, name, description, price) VALUES (1, 'iPhone', 'Apple iPhone 15', 999.99); +INSERT INTO Product (id, name, description, price) VALUES (2, 'MacBook', 'Apple MacBook Air', 1299.0); +INSERT INTO Product (id, name, description, price) VALUES (3, 'iPad', 'Apple iPad Pro', 799.99); +INSERT INTO Product (id, name, description, price) VALUES (4, 'AirPods', 'Apple AirPods Pro', 249.99); +INSERT INTO Product (id, name, description, price) VALUES (5, 'Apple Watch', 'Apple Watch Series 8', 399.99); +---- + +=== Database Location + +The Derby database is created in the Liberty server working directory. The location depends on the server configuration, but it's typically under: + +[source] +---- +target/liberty/wlp/usr/servers/mpServer/ +---- + +=== Connecting to Derby Database + +For debugging purposes, you can use the Derby ij tool to connect to the database: + +[source,bash] +---- +java -cp target/liberty/wlp/usr/shared/resources/derby-10.16.1.1.jar:target/liberty/wlp/usr/shared/resources/derbytools-10.16.1.1.jar org.apache.derby.tools-10.16.1.1.ij +---- + +Once connected, you can execute SQL commands: + +[source,sql] +---- +CONNECT 'jdbc:derby:CatalogDB'; +SELECT * FROM PRODUCTS; +DESCRIBE PRODUCTS; +---- + +Note: The database name is `CatalogDB` and the table name is `PRODUCTS` as configured in our persistence.xml and entity mapping. + +== Performance Considerations + +=== Connection Pooling + +Liberty automatically provides connection pooling for JDBC datasources. You can configure the pool size in server.xml: + +[source,xml] +---- + + + + + +---- + +=== JPA Optimization + +To optimize JPA performance: + +* Use fetch type LAZY for collections and relationships +* Enable second-level caching when appropriate +* Use named queries for frequently used operations +* Consider pagination for large result sets + +== Development Workflow + +=== Hot Reload with Liberty Dev Mode + +Liberty dev mode provides hot reloading capabilities. When you make changes to your code, they are automatically detected and applied without restarting the server. + +[source,bash] +---- +mvn liberty:dev +---- + +While in dev mode, you can: + +* Press Enter to see available commands +* Type `r` to manually trigger a reload +* Type `h` to see a list of available commands diff --git a/code/chapter03/catalog/pom.xml b/code/chapter03/catalog/pom.xml new file mode 100644 index 0000000..1951188 --- /dev/null +++ b/code/chapter03/catalog/pom.xml @@ -0,0 +1,170 @@ + + + + 4.0.0 + + io.microprofile.tutorial + catalog + 1.0-SNAPSHOT + war + + + + + 17 + 17 + + UTF-8 + UTF-8 + + + 5050 + 5051 + + catalog + + + + + + + org.projectlombok + lombok + 1.18.26 + provided + + + + + jakarta.platform + jakarta.jakartaee-api + 10.0.0 + provided + + + + + org.eclipse.microprofile + microprofile + 6.1 + pom + provided + + + + + + org.junit.jupiter + junit-jupiter + 5.10.0 + test + + + + + org.jboss.resteasy + resteasy-client + 6.2.12.Final + test + + + org.jboss.resteasy + resteasy-json-binding-provider + 6.2.12.Final + test + + + org.glassfish + jakarta.json + 2.0.1 + test + + + + + org.mockito + mockito-core + 5.3.1 + test + + + org.mockito + mockito-junit-jupiter + 5.3.1 + test + + + + + org.apache.derby + derby + 10.16.1.1 + provided + + + org.apache.derby + derbyshared + 10.16.1.1 + provided + + + org.apache.derby + derbytools + 10.16.1.1 + provided + + + + + ${project.artifactId} + + + + io.openliberty.tools + liberty-maven-plugin + 3.11.2 + + + ${project.build.directory}/liberty/wlp/usr/shared/resources + + org.apache.derby + derby + + + org.apache.derby + derbyshared + + + org.apache.derby + derbytools + + + + + + + org.apache.maven.plugins + maven-war-plugin + 3.4.0 + + + + org.apache.maven.plugins + maven-surefire-plugin + 3.5.3 + + + + org.apache.maven.plugins + maven-failsafe-plugin + 3.5.3 + + + ${backend.service.http.port} + + + + + + \ No newline at end of file diff --git a/code/chapter03/catalog/src/main/java/io/microprofile/tutorial/store/product/ProductRestApplication.java b/code/chapter03/catalog/src/main/java/io/microprofile/tutorial/store/product/ProductRestApplication.java new file mode 100644 index 0000000..9759e1f --- /dev/null +++ b/code/chapter03/catalog/src/main/java/io/microprofile/tutorial/store/product/ProductRestApplication.java @@ -0,0 +1,9 @@ +package io.microprofile.tutorial.store.product; + +import jakarta.ws.rs.ApplicationPath; +import jakarta.ws.rs.core.Application; + +@ApplicationPath("/api") +public class ProductRestApplication extends Application { + // No additional configuration is needed here +} \ No newline at end of file diff --git a/code/chapter03/catalog/src/main/java/io/microprofile/tutorial/store/product/entity/Product.java b/code/chapter03/catalog/src/main/java/io/microprofile/tutorial/store/product/entity/Product.java new file mode 100644 index 0000000..fdba9ef --- /dev/null +++ b/code/chapter03/catalog/src/main/java/io/microprofile/tutorial/store/product/entity/Product.java @@ -0,0 +1,113 @@ +package io.microprofile.tutorial.store.product.entity; + +import jakarta.persistence.*; +import jakarta.validation.constraints.*; +import java.math.BigDecimal; + +/** + * Product entity representing a product in the catalog. + * This entity is mapped to the PRODUCTS table in the database. + */ +@Entity +@Table(name = "PRODUCTS") +@NamedQueries({ + @NamedQuery(name = "Product.findAll", query = "SELECT p FROM Product p"), + @NamedQuery(name = "Product.findById", query = "SELECT p FROM Product p WHERE p.id = :id") +}) +public class Product { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "ID") + private Long id; + + @NotNull(message = "Product name cannot be null") + @NotBlank(message = "Product name cannot be blank") + @Size(min = 1, max = 100, message = "Product name must be between 1 and 100 characters") + @Column(name = "NAME", nullable = false, length = 100) + private String name; + + @Size(max = 500, message = "Product description cannot exceed 500 characters") + @Column(name = "DESCRIPTION", length = 500) + private String description; + + @NotNull(message = "Product price cannot be null") + @DecimalMin(value = "0.0", inclusive = false, message = "Product price must be greater than 0") + @Column(name = "PRICE", nullable = false, precision = 10, scale = 2) + private Double price; + + // Default constructor + public Product() { + } + + // Constructor with all fields + public Product(Long id, String name, String description, Double price) { + this.id = id; + this.name = name; + this.description = description; + this.price = price; + } + + // Constructor without ID (for new entities) + public Product(String name, String description, Double price) { + this.name = name; + this.description = description; + this.price = price; + } + + // Getters and setters + public Long getId() { + return id; + } + + public void setId(Long id) { + this.id = id; + } + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + + public String getDescription() { + return description; + } + + public void setDescription(String description) { + this.description = description; + } + + public Double getPrice() { + return price; + } + + public void setPrice(Double price) { + this.price = price; + } + + @Override + public String toString() { + return "Product{" + + "id=" + id + + ", name='" + name + '\'' + + ", description='" + description + '\'' + + ", price=" + price + + '}'; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (!(o instanceof Product)) return false; + Product product = (Product) o; + return id != null && id.equals(product.id); + } + + @Override + public int hashCode() { + return getClass().hashCode(); + } +} diff --git a/code/chapter03/catalog/src/main/java/io/microprofile/tutorial/store/product/repository/InMemory.java b/code/chapter03/catalog/src/main/java/io/microprofile/tutorial/store/product/repository/InMemory.java new file mode 100644 index 0000000..e49833e --- /dev/null +++ b/code/chapter03/catalog/src/main/java/io/microprofile/tutorial/store/product/repository/InMemory.java @@ -0,0 +1,16 @@ +package io.microprofile.tutorial.store.product.repository; + +import jakarta.inject.Qualifier; +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** + * CDI qualifier for in-memory repository implementation. + */ +@Qualifier +@Retention(RetentionPolicy.RUNTIME) +@Target({ElementType.FIELD, ElementType.METHOD, ElementType.TYPE, ElementType.PARAMETER}) +public @interface InMemory { +} \ No newline at end of file diff --git a/code/chapter03/catalog/src/main/java/io/microprofile/tutorial/store/product/repository/JPA.java b/code/chapter03/catalog/src/main/java/io/microprofile/tutorial/store/product/repository/JPA.java new file mode 100644 index 0000000..fd4a6bd --- /dev/null +++ b/code/chapter03/catalog/src/main/java/io/microprofile/tutorial/store/product/repository/JPA.java @@ -0,0 +1,16 @@ +package io.microprofile.tutorial.store.product.repository; + +import jakarta.inject.Qualifier; +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** + * CDI qualifier for JPA repository implementation. + */ +@Qualifier +@Retention(RetentionPolicy.RUNTIME) +@Target({ElementType.FIELD, ElementType.METHOD, ElementType.TYPE, ElementType.PARAMETER}) +public @interface JPA { +} diff --git a/code/chapter03/catalog/src/main/java/io/microprofile/tutorial/store/product/repository/ProductInMemoryRepository.java b/code/chapter03/catalog/src/main/java/io/microprofile/tutorial/store/product/repository/ProductInMemoryRepository.java new file mode 100644 index 0000000..a15ef9a --- /dev/null +++ b/code/chapter03/catalog/src/main/java/io/microprofile/tutorial/store/product/repository/ProductInMemoryRepository.java @@ -0,0 +1,140 @@ +package io.microprofile.tutorial.store.product.repository; + +import io.microprofile.tutorial.store.product.entity.Product; +import jakarta.enterprise.context.ApplicationScoped; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.atomic.AtomicLong; +import java.util.logging.Logger; +import java.util.stream.Collectors; + +/** + * In-memory repository implementation for Product entity. + * Provides in-memory persistence operations using ConcurrentHashMap. + * This is used as a fallback when JPA is not available. + */ +@ApplicationScoped +@InMemory +public class ProductInMemoryRepository implements ProductRepositoryInterface { + + private static final Logger LOGGER = Logger.getLogger(ProductInMemoryRepository.class.getName()); + + // In-memory storage using ConcurrentHashMap for thread safety + private final Map productsMap = new ConcurrentHashMap<>(); + + // ID generator + private final AtomicLong idGenerator = new AtomicLong(1); + + /** + * Constructor with sample data initialization. + */ + public ProductInMemoryRepository() { + // Initialize with sample products using the Double constructor for compatibility + createProduct(new Product(null, "iPhone", "Apple iPhone 15", 999.99)); + createProduct(new Product(null, "MacBook", "Apple MacBook Air", 1299.0)); + createProduct(new Product(null, "iPad", "Apple iPad Pro", 799.0)); + LOGGER.info("ProductInMemoryRepository initialized with sample products"); + } + + /** + * Retrieves all products. + * + * @return List of all products + */ + public List findAllProducts() { + LOGGER.fine("Repository: Finding all products"); + return new ArrayList<>(productsMap.values()); + } + + /** + * Retrieves a product by ID. + * + * @param id Product ID + * @return The product or null if not found + */ + public Product findProductById(Long id) { + LOGGER.fine("Repository: Finding product with ID: " + id); + return productsMap.get(id); + } + + /** + * Creates a new product. + * + * @param product Product data to create + * @return The created product with ID + */ + public Product createProduct(Product product) { + // Generate ID if not provided + if (product.getId() == null) { + product.setId(idGenerator.getAndIncrement()); + } else { + // Update idGenerator if the provided ID is greater than current + long nextId = product.getId() + 1; + while (true) { + long currentId = idGenerator.get(); + if (nextId <= currentId || idGenerator.compareAndSet(currentId, nextId)) { + break; + } + } + } + + LOGGER.fine("Repository: Creating product with ID: " + product.getId()); + productsMap.put(product.getId(), product); + return product; + } + + /** + * Updates an existing product. + * + * @param product Updated product data + * @return The updated product or null if not found + */ + public Product updateProduct(Product product) { + Long id = product.getId(); + if (id != null && productsMap.containsKey(id)) { + LOGGER.fine("Repository: Updating product with ID: " + id); + productsMap.put(id, product); + return product; + } + LOGGER.warning("Repository: Product not found for update, ID: " + id); + return null; + } + + /** + * Deletes a product by ID. + * + * @param id ID of the product to delete + * @return true if deleted, false if not found + */ + public boolean deleteProduct(Long id) { + if (productsMap.containsKey(id)) { + LOGGER.fine("Repository: Deleting product with ID: " + id); + productsMap.remove(id); + return true; + } + LOGGER.warning("Repository: Product not found for deletion, ID: " + id); + return false; + } + + /** + * Searches for products by criteria. + * + * @param name Product name (optional) + * @param description Product description (optional) + * @param minPrice Minimum price (optional) + * @param maxPrice Maximum price (optional) + * @return List of matching products + */ + public List searchProducts(String name, String description, Double minPrice, Double maxPrice) { + LOGGER.fine("Repository: Searching for products with criteria"); + + return productsMap.values().stream() + .filter(p -> name == null || p.getName().toLowerCase().contains(name.toLowerCase())) + .filter(p -> description == null || p.getDescription().toLowerCase().contains(description.toLowerCase())) + .filter(p -> minPrice == null || (p.getPrice() != null && p.getPrice().doubleValue() >= minPrice)) + .filter(p -> maxPrice == null || (p.getPrice() != null && p.getPrice().doubleValue() <= maxPrice)) + .collect(Collectors.toList()); + } +} \ No newline at end of file diff --git a/code/chapter03/catalog/src/main/java/io/microprofile/tutorial/store/product/repository/ProductJpaRepository.java b/code/chapter03/catalog/src/main/java/io/microprofile/tutorial/store/product/repository/ProductJpaRepository.java new file mode 100644 index 0000000..1bf4343 --- /dev/null +++ b/code/chapter03/catalog/src/main/java/io/microprofile/tutorial/store/product/repository/ProductJpaRepository.java @@ -0,0 +1,150 @@ +package io.microprofile.tutorial.store.product.repository; + +import io.microprofile.tutorial.store.product.entity.Product; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.persistence.EntityManager; +import jakarta.persistence.PersistenceContext; +import jakarta.persistence.TypedQuery; +import jakarta.transaction.Transactional; +import java.math.BigDecimal; +import java.util.List; +import java.util.logging.Logger; + +/** + * JPA-based repository implementation for Product entity. + * Provides database persistence operations using Apache Derby. + */ +@ApplicationScoped +@JPA +@Transactional +public class ProductJpaRepository implements ProductRepositoryInterface { + + private static final Logger LOGGER = Logger.getLogger(ProductJpaRepository.class.getName()); + + @PersistenceContext(unitName = "catalogPU") + private EntityManager entityManager; + + @Override + public List findAllProducts() { + LOGGER.fine("JPA Repository: Finding all products"); + TypedQuery query = entityManager.createNamedQuery("Product.findAll", Product.class); + return query.getResultList(); + } + + @Override + public Product findProductById(Long id) { + LOGGER.fine("JPA Repository: Finding product with ID: " + id); + if (id == null) { + return null; + } + return entityManager.find(Product.class, id); + } + + @Override + public Product createProduct(Product product) { + LOGGER.info("JPA Repository: Creating new product: " + product); + if (product == null) { + throw new IllegalArgumentException("Product cannot be null"); + } + + // Ensure ID is null for new products + product.setId(null); + + entityManager.persist(product); + entityManager.flush(); // Force the insert to get the generated ID + + LOGGER.info("JPA Repository: Created product with ID: " + product.getId()); + return product; + } + + @Override + public Product updateProduct(Product product) { + LOGGER.info("JPA Repository: Updating product: " + product); + if (product == null || product.getId() == null) { + throw new IllegalArgumentException("Product and ID cannot be null for update"); + } + + Product existingProduct = entityManager.find(Product.class, product.getId()); + if (existingProduct == null) { + LOGGER.warning("JPA Repository: Product not found for update: " + product.getId()); + return null; + } + + // Update fields + existingProduct.setName(product.getName()); + existingProduct.setDescription(product.getDescription()); + existingProduct.setPrice(product.getPrice()); + + Product updatedProduct = entityManager.merge(existingProduct); + entityManager.flush(); + + LOGGER.info("JPA Repository: Updated product with ID: " + updatedProduct.getId()); + return updatedProduct; + } + + @Override + public boolean deleteProduct(Long id) { + LOGGER.info("JPA Repository: Deleting product with ID: " + id); + if (id == null) { + return false; + } + + Product product = entityManager.find(Product.class, id); + if (product != null) { + entityManager.remove(product); + entityManager.flush(); + LOGGER.info("JPA Repository: Deleted product with ID: " + id); + return true; + } + + LOGGER.warning("JPA Repository: Product not found for deletion: " + id); + return false; + } + + @Override + public List searchProducts(String name, String description, Double minPrice, Double maxPrice) { + LOGGER.info("JPA Repository: Searching for products with criteria"); + + StringBuilder jpql = new StringBuilder("SELECT p FROM Product p WHERE 1=1"); + + if (name != null && !name.trim().isEmpty()) { + jpql.append(" AND LOWER(p.name) LIKE :namePattern"); + } + + if (description != null && !description.trim().isEmpty()) { + jpql.append(" AND LOWER(p.description) LIKE :descriptionPattern"); + } + + if (minPrice != null) { + jpql.append(" AND p.price >= :minPrice"); + } + + if (maxPrice != null) { + jpql.append(" AND p.price <= :maxPrice"); + } + + TypedQuery query = entityManager.createQuery(jpql.toString(), Product.class); + + // Set parameters only if they are provided + if (name != null && !name.trim().isEmpty()) { + query.setParameter("namePattern", "%" + name.toLowerCase() + "%"); + } + + if (description != null && !description.trim().isEmpty()) { + query.setParameter("descriptionPattern", "%" + description.toLowerCase() + "%"); + } + + if (minPrice != null) { + query.setParameter("minPrice", BigDecimal.valueOf(minPrice)); + } + + if (maxPrice != null) { + query.setParameter("maxPrice", BigDecimal.valueOf(maxPrice)); + } + + List results = query.getResultList(); + LOGGER.info("JPA Repository: Found " + results.size() + " products matching criteria"); + + return results; + } +} diff --git a/code/chapter03/catalog/src/main/java/io/microprofile/tutorial/store/product/repository/ProductRepositoryInterface.java b/code/chapter03/catalog/src/main/java/io/microprofile/tutorial/store/product/repository/ProductRepositoryInterface.java new file mode 100644 index 0000000..4b981b2 --- /dev/null +++ b/code/chapter03/catalog/src/main/java/io/microprofile/tutorial/store/product/repository/ProductRepositoryInterface.java @@ -0,0 +1,61 @@ +package io.microprofile.tutorial.store.product.repository; + +import io.microprofile.tutorial.store.product.entity.Product; +import java.util.List; + +/** + * Repository interface for Product entity operations. + * Defines the contract for product data access. + */ +public interface ProductRepositoryInterface { + + /** + * Retrieves all products. + * + * @return List of all products + */ + List findAllProducts(); + + /** + * Retrieves a product by ID. + * + * @param id Product ID + * @return The product or null if not found + */ + Product findProductById(Long id); + + /** + * Creates a new product. + * + * @param product Product data to create + * @return The created product with ID + */ + Product createProduct(Product product); + + /** + * Updates an existing product. + * + * @param product Updated product data + * @return The updated product + */ + Product updateProduct(Product product); + + /** + * Deletes a product by ID. + * + * @param id ID of the product to delete + * @return true if deleted, false if not found + */ + boolean deleteProduct(Long id); + + /** + * Searches for products by criteria. + * + * @param name Product name (optional) + * @param description Product description (optional) + * @param minPrice Minimum price (optional) + * @param maxPrice Maximum price (optional) + * @return List of matching products + */ + List searchProducts(String name, String description, Double minPrice, Double maxPrice); +} diff --git a/code/chapter03/catalog/src/main/java/io/microprofile/tutorial/store/product/repository/RepositoryType.java b/code/chapter03/catalog/src/main/java/io/microprofile/tutorial/store/product/repository/RepositoryType.java new file mode 100644 index 0000000..e2bf8c9 --- /dev/null +++ b/code/chapter03/catalog/src/main/java/io/microprofile/tutorial/store/product/repository/RepositoryType.java @@ -0,0 +1,29 @@ +package io.microprofile.tutorial.store.product.repository; + +import jakarta.inject.Qualifier; +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** + * CDI qualifier to distinguish between different repository implementations. + */ +@Qualifier +@Retention(RetentionPolicy.RUNTIME) +@Target({ElementType.TYPE, ElementType.METHOD, ElementType.FIELD, ElementType.PARAMETER}) +public @interface RepositoryType { + + /** + * Repository implementation type. + */ + Type value(); + + /** + * Enumeration of repository types. + */ + enum Type { + JPA, + IN_MEMORY + } +} diff --git a/code/chapter03/catalog/src/main/java/io/microprofile/tutorial/store/product/resource/ProductResource.java b/code/chapter03/catalog/src/main/java/io/microprofile/tutorial/store/product/resource/ProductResource.java new file mode 100644 index 0000000..5ec7232 --- /dev/null +++ b/code/chapter03/catalog/src/main/java/io/microprofile/tutorial/store/product/resource/ProductResource.java @@ -0,0 +1,116 @@ +package io.microprofile.tutorial.store.product.resource; + +import java.util.List; +import java.util.logging.Logger; +import java.util.logging.Level; + +import org.eclipse.microprofile.config.inject.ConfigProperty; + +import io.microprofile.tutorial.store.product.entity.Product; +import io.microprofile.tutorial.store.product.service.ProductService; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.inject.Inject; + +import jakarta.ws.rs.Consumes; +import jakarta.ws.rs.DELETE; +import jakarta.ws.rs.GET; +import jakarta.ws.rs.POST; +import jakarta.ws.rs.PUT; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.PathParam; +import jakarta.ws.rs.Produces; +import jakarta.ws.rs.QueryParam; +import jakarta.ws.rs.core.MediaType; +import jakarta.ws.rs.core.Response; + +@ApplicationScoped +@Path("/products") +public class ProductResource { + + private static final Logger LOGGER = Logger.getLogger(ProductResource.class.getName()); + + @Inject + private ProductService productService; + + @GET + @Produces(MediaType.APPLICATION_JSON) + public Response getAllProducts() { + LOGGER.log(Level.INFO, "REST: Fetching all products"); + List products = productService.findAllProducts(); + + if (products != null && !products.isEmpty()) { + return Response + .status(Response.Status.OK) + .entity(products).build(); + } else { + return Response + .status(Response.Status.NOT_FOUND) + .entity("No products found") + .build(); + } + } + + @GET + @Path("/{id}") + @Produces(MediaType.APPLICATION_JSON) + public Response getProductById(@PathParam("id") Long id) { + LOGGER.log(Level.INFO, "REST: Fetching product with id: {0}", id); + + Product product = productService.findProductById(id); + if (product != null) { + return Response.ok(product).build(); + } else { + return Response.status(Response.Status.NOT_FOUND).build(); + } + } + + @POST + @Consumes(MediaType.APPLICATION_JSON) + @Produces(MediaType.APPLICATION_JSON) + public Response createProduct(Product product) { + LOGGER.info("REST: Creating product: " + product); + Product createdProduct = productService.createProduct(product); + return Response.status(Response.Status.CREATED).entity(createdProduct).build(); + } + + @PUT + @Path("/{id}") + @Consumes(MediaType.APPLICATION_JSON) + @Produces(MediaType.APPLICATION_JSON) + public Response updateProduct(@PathParam("id") Long id, Product updatedProduct) { + LOGGER.info("REST: Updating product with id: " + id); + Product updated = productService.updateProduct(id, updatedProduct); + if (updated != null) { + return Response.ok(updated).build(); + } else { + return Response.status(Response.Status.NOT_FOUND).build(); + } + } + + @DELETE + @Path("/{id}") + @Produces(MediaType.APPLICATION_JSON) + + public Response deleteProduct(@PathParam("id") Long id) { + LOGGER.info("REST: Deleting product with id: " + id); + boolean deleted = productService.deleteProduct(id); + if (deleted) { + return Response.noContent().build(); + } else { + return Response.status(Response.Status.NOT_FOUND).build(); + } + } + + @GET + @Path("/search") + @Produces(MediaType.APPLICATION_JSON) + public Response searchProducts( + @QueryParam("name") String name, + @QueryParam("description") String description, + @QueryParam("minPrice") Double minPrice, + @QueryParam("maxPrice") Double maxPrice) { + LOGGER.info("REST: Searching products with criteria"); + List results = productService.searchProducts(name, description, minPrice, maxPrice); + return Response.ok(results).build(); + } +} \ No newline at end of file diff --git a/code/chapter03/catalog/src/main/java/io/microprofile/tutorial/store/product/service/ProductService.java b/code/chapter03/catalog/src/main/java/io/microprofile/tutorial/store/product/service/ProductService.java new file mode 100644 index 0000000..11d9bf7 --- /dev/null +++ b/code/chapter03/catalog/src/main/java/io/microprofile/tutorial/store/product/service/ProductService.java @@ -0,0 +1,99 @@ +package io.microprofile.tutorial.store.product.service; + +import io.microprofile.tutorial.store.product.entity.Product; +import io.microprofile.tutorial.store.product.repository.JPA; +import io.microprofile.tutorial.store.product.repository.ProductRepositoryInterface; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.inject.Inject; +import java.util.List; +import java.util.logging.Logger; + +/** + * Service class for Product operations. + * Contains business logic for product management. + */ +@ApplicationScoped +public class ProductService { + + private static final Logger LOGGER = Logger.getLogger(ProductService.class.getName()); + + @Inject + @JPA + private ProductRepositoryInterface repository; + + /** + * Retrieves all products. + * + * @return List of all products + */ + public List findAllProducts() { + LOGGER.info("Service: Finding all products"); + return repository.findAllProducts(); + } + + /** + * Retrieves a product by ID. + * + * @param id Product ID + * @return The product or null if not found + */ + public Product findProductById(Long id) { + LOGGER.info("Service: Finding product with ID: " + id); + return repository.findProductById(id); + } + + /** + * Creates a new product. + * + * @param product Product data to create + * @return The created product with ID + */ + public Product createProduct(Product product) { + LOGGER.info("Service: Creating new product: " + product); + return repository.createProduct(product); + } + + /** + * Updates an existing product. + * + * @param id ID of the product to update + * @param updatedProduct Updated product data + * @return The updated product or null if not found + */ + public Product updateProduct(Long id, Product updatedProduct) { + LOGGER.info("Service: Updating product with ID: " + id); + + Product existingProduct = repository.findProductById(id); + if (existingProduct != null) { + // Set the ID to ensure correct update + updatedProduct.setId(id); + return repository.updateProduct(updatedProduct); + } + return null; + } + + /** + * Deletes a product by ID. + * + * @param id ID of the product to delete + * @return true if deleted, false if not found + */ + public boolean deleteProduct(Long id) { + LOGGER.info("Service: Deleting product with ID: " + id); + return repository.deleteProduct(id); + } + + /** + * Searches for products by criteria. + * + * @param name Product name (optional) + * @param description Product description (optional) + * @param minPrice Minimum price (optional) + * @param maxPrice Maximum price (optional) + * @return List of matching products + */ + public List searchProducts(String name, String description, Double minPrice, Double maxPrice) { + LOGGER.info("Service: Searching for products with criteria"); + return repository.searchProducts(name, description, minPrice, maxPrice); + } +} diff --git a/code/chapter03/catalog/src/main/liberty/config/server.xml b/code/chapter03/catalog/src/main/liberty/config/server.xml new file mode 100644 index 0000000..a17abc0 --- /dev/null +++ b/code/chapter03/catalog/src/main/liberty/config/server.xml @@ -0,0 +1,29 @@ + + + jakartaEE-10.0 + microProfile-6.1 + restfulWS + jsonp + jsonb + cdi + persistence + jdbc + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/code/chapter03/catalog/src/main/resources/META-INF/create-schema.sql b/code/chapter03/catalog/src/main/resources/META-INF/create-schema.sql new file mode 100644 index 0000000..6e72eed --- /dev/null +++ b/code/chapter03/catalog/src/main/resources/META-INF/create-schema.sql @@ -0,0 +1,6 @@ +-- Schema creation script for Product catalog +-- This file is referenced by persistence.xml for schema generation + +-- Note: This is a placeholder file. +-- The actual schema is auto-generated by JPA using @Entity annotations. +-- You can add custom DDL statements here if needed. diff --git a/code/chapter03/catalog/src/main/resources/META-INF/load-data.sql b/code/chapter03/catalog/src/main/resources/META-INF/load-data.sql new file mode 100644 index 0000000..e9fbd9b --- /dev/null +++ b/code/chapter03/catalog/src/main/resources/META-INF/load-data.sql @@ -0,0 +1,8 @@ +INSERT INTO PRODUCTS (NAME, DESCRIPTION, PRICE) VALUES ('iPhone 15', 'Apple iPhone 15 with advanced features', 999.99) +INSERT INTO PRODUCTS (NAME, DESCRIPTION, PRICE) VALUES ('MacBook Air', 'Apple MacBook Air M2 chip', 1299.00) +INSERT INTO PRODUCTS (NAME, DESCRIPTION, PRICE) VALUES ('iPad Pro', 'Apple iPad Pro 12.9-inch', 799.00) +INSERT INTO PRODUCTS (NAME, DESCRIPTION, PRICE) VALUES ('Samsung Galaxy S24', 'Samsung Galaxy S24 Ultra smartphone', 1199.99) +INSERT INTO PRODUCTS (NAME, DESCRIPTION, PRICE) VALUES ('Dell XPS 13', 'Dell XPS 13 laptop with Intel i7', 1099.00) +INSERT INTO PRODUCTS (NAME, DESCRIPTION, PRICE) VALUES ('Sony WH-1000XM4', 'Sony noise-canceling headphones', 299.99) +INSERT INTO PRODUCTS (NAME, DESCRIPTION, PRICE) VALUES ('Nintendo Switch', 'Nintendo Switch gaming console', 299.00) +INSERT INTO PRODUCTS (NAME, DESCRIPTION, PRICE) VALUES ('Google Pixel 8', 'Google Pixel 8 smartphone', 699.99) diff --git a/code/chapter03/catalog/src/main/resources/META-INF/microprofile-config.properties b/code/chapter03/catalog/src/main/resources/META-INF/microprofile-config.properties new file mode 100644 index 0000000..e69de29 diff --git a/code/chapter03/catalog/src/main/resources/META-INF/persistence.xml b/code/chapter03/catalog/src/main/resources/META-INF/persistence.xml new file mode 100644 index 0000000..df53bc7 --- /dev/null +++ b/code/chapter03/catalog/src/main/resources/META-INF/persistence.xml @@ -0,0 +1,23 @@ + + + + + jdbc/catalogDB + io.microprofile.tutorial.store.product.entity.Product + + + + + + + + + + + + + + diff --git a/code/chapter03/catalog/src/main/test/java/io/microprofile/tutorial/store/product/resource/ProductResourceTest.java b/code/chapter03/catalog/src/main/test/java/io/microprofile/tutorial/store/product/resource/ProductResourceTest.java new file mode 100644 index 0000000..5687649 --- /dev/null +++ b/code/chapter03/catalog/src/main/test/java/io/microprofile/tutorial/store/product/resource/ProductResourceTest.java @@ -0,0 +1,76 @@ +package io.microprofile.tutorial.store.product.resource; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.mockito.Mockito.when; + +import java.util.ArrayList; +import java.util.List; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import jakarta.ws.rs.core.Response; + +import io.microprofile.tutorial.store.product.entity.Product; +import io.microprofile.tutorial.store.product.service.ProductService; + +@ExtendWith(MockitoExtension.class) +public class ProductResourceTest { + @Mock + private ProductService productService; + + @InjectMocks + private ProductResource productResource; + + @BeforeEach + void setUp() { + // Setup is handled by MockitoExtension + } + + @AfterEach + void tearDown() { + // Cleanup is handled by MockitoExtension + } + + @Test + void testGetProducts() { + // Prepare test data + List mockProducts = new ArrayList<>(); + + Product product1 = new Product(); + product1.setId(1L); + product1.setName("iPhone"); + product1.setDescription("Apple iPhone 15"); + product1.setPrice(999.99); + + Product product2 = new Product(); + product2.setId(2L); + product2.setName("MacBook"); + product2.setDescription("Apple MacBook Air"); + product2.setPrice(1299.0); + + mockProducts.add(product1); + mockProducts.add(product2); + + // Mock the service method + when(productService.findAllProducts()).thenReturn(mockProducts); + + // Call the method under test + Response response = productResource.getAllProducts(); + + // Assertions + assertNotNull(response); + assertEquals(Response.Status.OK.getStatusCode(), response.getStatus()); + + @SuppressWarnings("unchecked") + List products = (List) response.getEntity(); + assertNotNull(products); + assertEquals(2, products.size()); + } +} \ No newline at end of file diff --git a/code/chapter03/catalog/src/main/webapp/WEB-INF/web.xml b/code/chapter03/catalog/src/main/webapp/WEB-INF/web.xml new file mode 100644 index 0000000..1010516 --- /dev/null +++ b/code/chapter03/catalog/src/main/webapp/WEB-INF/web.xml @@ -0,0 +1,13 @@ + + + + Product Catalog Service + + + index.html + + + diff --git a/code/chapter03/catalog/src/main/webapp/index.html b/code/chapter03/catalog/src/main/webapp/index.html new file mode 100644 index 0000000..fac4a18 --- /dev/null +++ b/code/chapter03/catalog/src/main/webapp/index.html @@ -0,0 +1,497 @@ + + + + + + Product Catalog Service + + + +
+

Product Catalog Service

+

A microservice for managing product information in the e-commerce platform

+
+ +
+
+

Service Overview

+

The Product Catalog Service provides a REST API for managing product information, including:

+
    +
  • Creating new products
  • +
  • Retrieving product details
  • +
  • Updating existing products
  • +
  • Deleting products
  • +
  • Searching for products by various criteria
  • +
+
+

MicroProfile Config Implementation

+

This service implements configurability as per MicroProfile Config standards. Key configuration properties include:

+
    +
  • product.maintenanceMode - Controls whether the service is in maintenance mode (returns 503 responses)
  • +
  • mp.openapi.scan - Enables automatic OpenAPI documentation generation
  • +
+

MicroProfile Config allows these properties to be changed via environment variables, system properties, or configuration files without requiring application redeployment.

+
+
+ +
+

API Endpoints

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
OperationMethodURLDescription
List All ProductsGET/api/productsRetrieves a list of all products
Get Product by IDGET/api/products/{id}Returns a product by its ID
Create ProductPOST/api/productsCreates a new product
Update ProductPUT/api/products/{id}Updates an existing product by its ID
Delete ProductDELETE/api/products/{id}Deletes a product by its ID
Search ProductsGET/api/products/searchSearch products by criteria (name, description, price range)
+
+ +
+

API Documentation

+

The API is documented using MicroProfile OpenAPI. You can access the Swagger UI at:

+

/openapi/ui

+

The OpenAPI definition is available at:

+

/openapi

+
+ +
+

Health Checks

+

The service implements comprehensive MicroProfile Health monitoring with three types of health checks:

+ +

Health Check Endpoints

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
StatusEndpointPurposeDescriptionLink
/healthOverall HealthAggregated status of all health checksView
/health/startedStartup CheckValidates EntityManagerFactory initializationView
/health/readyReadiness CheckTests database connectivity via EntityManagerView
/health/liveLiveness CheckMonitors JVM memory usage (100MB threshold)View
+ +
+

Health Check Implementation Details

+ +

🚀 Startup Health Check

+

Class: ProductServiceStartupCheck

+

Purpose: Verifies that the Jakarta Persistence EntityManagerFactory is properly initialized during application startup.

+

Implementation: Uses @PersistenceUnit to inject EntityManagerFactory and checks if it's not null and open.

+ +

✅ Readiness Health Check

+

Class: ProductServiceHealthCheck

+

Purpose: Ensures the service is ready to handle requests by testing database connectivity.

+

Implementation: Performs a lightweight database query using entityManager.find(Product.class, 1L) to verify the database connection.

+ +

💓 Liveness Health Check

+

Class: ProductServiceLivenessCheck

+

Purpose: Monitors system resources to detect if the application needs to be restarted.

+

Implementation: Analyzes JVM memory usage with a 100MB available memory threshold, providing detailed memory diagnostics.

+
+ +
+

Sample Health Check Response

+

Example response from /health endpoint:

+
{
+  "status": "UP",
+  "checks": [
+    {
+      "name": "ProductServiceStartupCheck",
+      "status": "UP"
+    },
+    {
+      "name": "ProductServiceReadinessCheck", 
+      "status": "UP"
+    },
+    {
+      "name": "systemResourcesLiveness",
+      "status": "UP",
+      "data": {
+        "FreeMemory": 524288000,
+        "MaxMemory": 2147483648,
+        "AllocatedMemory": 1073741824,
+        "UsedMemory": 549453824,
+        "AvailableMemory": 1598029824
+      }
+    }
+  ]
+}
+
+ +
+

Testing Health Checks

+

You can test the health check endpoints using curl commands:

+
# Test overall health
+curl -X GET http://localhost:9080/health
+
+# Test startup health
+curl -X GET http://localhost:9080/health/started
+
+# Test readiness health  
+curl -X GET http://localhost:9080/health/ready
+
+# Test liveness health
+curl -X GET http://localhost:9080/health/live
+ +

Integration with Container Orchestration:

+

For Kubernetes deployments, you can configure probes in your deployment YAML:

+
livenessProbe:
+  httpGet:
+    path: /health/live
+    port: 9080
+  initialDelaySeconds: 30
+  periodSeconds: 10
+
+readinessProbe:
+  httpGet:
+    path: /health/ready
+    port: 9080
+  initialDelaySeconds: 5
+  periodSeconds: 5
+
+startupProbe:
+  httpGet:
+    path: /health/started
+    port: 9080
+  initialDelaySeconds: 10
+  periodSeconds: 10
+  failureThreshold: 30
+
+ +
+

Health Check Benefits

+
    +
  • Container Orchestration: Kubernetes and Docker can use these endpoints for health probes
  • +
  • Load Balancer Integration: Traffic routing based on readiness status
  • +
  • Operational Monitoring: Early detection of system issues
  • +
  • Startup Validation: Ensures all dependencies are initialized before serving traffic
  • +
  • Database Monitoring: Real-time database connectivity verification
  • +
  • Memory Management: Proactive detection of memory pressure
  • +
+
+
+ +
+

Sample Usage

+ +

List All Products

+
GET /api/products
+

Response:

+
[
+  {
+    "id": 1,
+    "name": "Smartphone X",
+    "description": "Latest smartphone with advanced features",
+    "price": 799.99
+  },
+  {
+    "id": 2,
+    "name": "Laptop Pro",
+    "description": "High-performance laptop for professionals",
+    "price": 1299.99
+  }
+]
+ +

Get Product by ID

+
GET /api/products/1
+

Response:

+
{
+  "id": 1,
+  "name": "Smartphone X",
+  "description": "Latest smartphone with advanced features",
+  "price": 799.99
+}
+ +

Create a New Product

+
POST /api/products
+Content-Type: application/json
+
+{
+  "name": "Wireless Earbuds",
+  "description": "Premium wireless earbuds with noise cancellation",
+  "price": 149.99
+}
+

Response:

+
{
+  "id": 3,
+  "name": "Wireless Earbuds",
+  "description": "Premium wireless earbuds with noise cancellation",
+  "price": 149.99
+}
+ +

Update a Product

+
PUT /api/products/3
+Content-Type: application/json
+
+{
+  "name": "Wireless Earbuds Pro",
+  "description": "Premium wireless earbuds with advanced noise cancellation",
+  "price": 179.99
+}
+

Response:

+
{
+  "id": 3,
+  "name": "Wireless Earbuds Pro",
+  "description": "Premium wireless earbuds with advanced noise cancellation",
+  "price": 179.99
+}
+ +

Delete a Product

+
DELETE /api/products/3
+

Response: No content (204)

+ +

Search for Products

+
GET /api/products/search?name=laptop&minPrice=1000&maxPrice=2000
+

Response:

+
[
+  {
+    "id": 2,
+    "name": "Laptop Pro",
+    "description": "High-performance laptop for professionals",
+    "price": 1299.99
+  }
+]
+
+
+ +
+

Product Catalog Service

+

© 2025 - MicroProfile APT Tutorial

+
+ + + + diff --git a/code/chapter03/catalog/test-api.sh b/code/chapter03/catalog/test-api.sh new file mode 100755 index 0000000..1763f30 --- /dev/null +++ b/code/chapter03/catalog/test-api.sh @@ -0,0 +1,103 @@ +#!/bin/bash +# filepath: /workspaces/microprofile-tutorial/code/chapter03/catalog/test-api.sh + +# MicroProfile E-Commerce Store API Test Script +# This script tests all CRUD operations for the Product API + +set -e # Exit on any error + +# Configuration +BASE_URL="http://localhost:5050/catalog/api/products" +CONTENT_TYPE="Content-Type: application/json" + +echo "==================================" +echo "MicroProfile E-Commerce Store API Test" +echo "==================================" +echo "Base URL: $BASE_URL" +echo "==================================" + +# Function to print section headers +print_section() { + echo "" + echo "--- $1 ---" +} + +# Function to pause between operations +pause() { + echo "Press Enter to continue..." + read -r +} + +print_section "1. GET ALL PRODUCTS (Initial State)" +echo "Command: curl -i -X GET $BASE_URL" +curl -i -X GET "$BASE_URL" +echo "" +pause + +print_section "2. GET PRODUCT BY ID (Existing Product)" +echo "Command: curl -i -X GET $BASE_URL/1" +curl -i -X GET "$BASE_URL/1" +echo "" +pause + +print_section "3. CREATE NEW PRODUCT" +echo "Command: curl -i -X POST $BASE_URL -H \"$CONTENT_TYPE\" -d '{\"id\": 3, \"name\": \"AirPods\", \"description\": \"Apple AirPods Pro\", \"price\": 249.99}'" +curl -i -X POST "$BASE_URL" \ + -H "$CONTENT_TYPE" \ + -d '{"id": 3, "name": "AirPods", "description": "Apple AirPods Pro", "price": 249.99}' +echo "" +pause + +print_section "4. GET ALL PRODUCTS (After Creation)" +echo "Command: curl -i -X GET $BASE_URL" +curl -i -X GET "$BASE_URL" +echo "" +pause + +print_section "5. GET NEW PRODUCT BY ID" +echo "Command: curl -i -X GET $BASE_URL/3" +curl -i -X GET "$BASE_URL/3" +echo "" +pause + +print_section "6. UPDATE EXISTING PRODUCT" +echo "Command: curl -i -X PUT $BASE_URL/1 -H \"$CONTENT_TYPE\" -d '{\"id\": 1, \"name\": \"iPhone Pro\", \"description\": \"Apple iPhone 15 Pro\", \"price\": 1199.99}'" +curl -i -X PUT "$BASE_URL/1" \ + -H "$CONTENT_TYPE" \ + -d '{"id": 1, "name": "iPhone Pro", "description": "Apple iPhone 15 Pro", "price": 1199.99}' +echo "" +pause + +print_section "7. GET UPDATED PRODUCT" +echo "Command: curl -i -X GET $BASE_URL/1" +curl -i -X GET "$BASE_URL/1" +echo "" +pause + +print_section "8. DELETE PRODUCT" +echo "Command: curl -i -X DELETE $BASE_URL/3" +curl -i -X DELETE "$BASE_URL/3" +echo "" +pause + +print_section "9. GET ALL PRODUCTS (After Deletion)" +echo "Command: curl -i -X GET $BASE_URL" +curl -i -X GET "$BASE_URL" +echo "" +pause + +print_section "10. TRY TO GET DELETED PRODUCT (Should return 404)" +echo "Command: curl -i -X GET $BASE_URL/3" +curl -i -X GET "$BASE_URL/3" || true +echo "" +pause + +print_section "11. TRY TO GET NON-EXISTENT PRODUCT (Should return 404)" +echo "Command: curl -i -X GET $BASE_URL/999" +curl -i -X GET "$BASE_URL/999" || true +echo "" + +echo "" +echo "==================================" +echo "API Testing Complete!" +echo "==================================" \ No newline at end of file diff --git a/code/chapter03/mp-ecomm-store/README.adoc b/code/chapter03/mp-ecomm-store/README.adoc new file mode 100644 index 0000000..016b29b --- /dev/null +++ b/code/chapter03/mp-ecomm-store/README.adoc @@ -0,0 +1,740 @@ += MicroProfile E-Commerce Store +:toc: macro +:toclevels: 3 +:icons: font + +toc::[] + +== Overview + +This project is a MicroProfile-based e-commerce application developed using Jakarta EE 10 and MicroProfile 6.1. It demonstrates modern Java enterprise development practices including REST API design, loose coupling, dependency injection, and unit testing strategies. + +The application follows a layered architecture with separate resource (controller) and service layers for products. It also includes a Logging Interceptor implemented with CDI, which automatically logs method entry, exit, execution time, and exceptions for annotated classes and methods. + +*Key Features:* +* Complete CRUD operations for product management +* Memory based data storage for demonstration purposes +* Comprehensive unit and integration testing +* Logging interceptor for logging entry/exit of methods + +== Technology Stack + +* *Jakarta EE 10*: Core enterprise Java platform +* *MicroProfile 6.1*: Microservices specifications +* *Open Liberty*: Lightweight application server +* *JUnit 5*: Testing framework +* *Maven*: Build and dependency management +* *Lombok*: Reduces boilerplate code + +== Project Structure + +[source] +---- +mp-ecomm-store/ +├── src/ +│ ├── main/ +│ │ ├── java/ +│ │ │ └── io/microprofile/tutorial/store/ +│ │ │ ├── product/ +│ │ │ │ ├── entity/ # Domain entities +│ │ │ │ ├── resource/ # REST endpoints +│ │ │ │ └── service/ # Business logic +│ │ │ ├── interceptor/ # Logging interceptor +│ │ │ └── demo/ # Logging interceptor demo classes +│ │ └── liberty/config/ # Liberty server configuration +│ └── test/ +│ └── java/ +│ └── io/microprofile/tutorial/store/ +│ └── product/ +│ ├── resource/ # Resource layer tests +│ └── service/ # Service layer tests +└── pom.xml # Maven project configuration +---- + +== Key Components + +=== Entity Layer + +The `Product` entity represents a product in the e-commerce system: + +[source,java] +---- +@Data +@NoArgsConstructor +@AllArgsConstructor +public class Product { + private Long id; + private String name; + private String description; + private Double price; +} +---- + +=== Service Layer + +The `ProductService` class encapsulates business logic for product management: + +[source,java] +---- +@ApplicationScoped +public class ProductService { + // Repository of products (in-memory list for demo purposes) + private List products = new ArrayList<>(); + + // Constructor initializes with sample data + public ProductService() { + products.add(new Product(1L, "iPhone", "Apple iPhone 15", 999.99)); + products.add(new Product(2L, "MacBook", "Apple MacBook Air", 1299.0)); + } + + // CRUD operations: getAllProducts(), getProductById(), createProduct(), + // updateProduct(), deleteProduct() + // ... +} +---- + +=== Resource Layer + +The `ProductResource` class exposes RESTful endpoints: + +[source,java] +---- +@ApplicationScoped +@Path("/products") +public class ProductResource { + private ProductService productService; + + @Inject + public ProductResource(ProductService productService) { + this.productService = productService; + } + + // REST endpoints for CRUD operations + // ... +} +---- + +== API Endpoints + +[cols="3,2,3,5"] +|=== +|HTTP Method |Endpoint |Request Body |Description + +|GET +|`/products` +|None +|Retrieve all products + +|GET +|`/products/{id}` +|None +|Retrieve a specific product by ID + +|POST +|`/products` +|Product JSON +|Create a new product + +|PUT +|`/products/{id}` +|Product JSON +|Update an existing product + +|DELETE +|`/products/{id}` +|None +|Delete a product +|=== + +=== Example Requests + +==== Create a product +[source,bash] +---- +curl -X POST http://localhost:5050/mp-ecomm-store/api/products \ + -H "Content-Type: application/json" \ + -d '{"id": 3, "name": "AirPods", "description": "Apple AirPods Pro", "price": 249.99}' +---- + +==== Get all products +[source,bash] +---- +curl http://localhost:5050/mp-ecomm-store/api/products +---- + +==== Get product by ID +[source,bash] +---- +curl http://localhost:5050/mp-ecomm-store/api/products/1 +---- + +==== Update a product +[source,bash] +---- +curl -X PUT http://localhost:5050/mp-ecomm-store/api/products/1 \ + -H "Content-Type: application/json" \ + -d '{"id": 1, "name": "iPhone Pro", "description": "Apple iPhone 15 Pro", "price": 1199.99}' +---- + +==== Delete a product +[source,bash] +---- +curl -X DELETE http://localhost:5050/mp-ecomm-store/api/products/1 +---- + +=== API Test Script + +A comprehensive test script `test-api.sh` is provided to test all CRUD operations automatically. This script demonstrates all API endpoints with proper error handling and validation. + +==== Using the Test Script + +1. **Make the script executable:** ++ +[source,bash] +---- +chmod +x test-api.sh +---- + +2. **Start your Liberty server:** ++ +[source,bash] +---- +mvn liberty:dev +---- + +3. **Run the test script in another terminal:** ++ +[source,bash] +---- +./test-api.sh +---- + +==== What the Script Tests + +The `test-api.sh` script performs the following operations in sequence: + +1. **Initial State**: Gets all products to show the starting data +2. **Get by ID**: Retrieves a specific product (ID: 1) +3. **Create Product**: Adds a new AirPods product (ID: 3) +4. **Verify Creation**: Gets all products to confirm the new product was added +5. **Get New Product**: Retrieves the newly created product by ID +6. **Update Product**: Updates an existing product (changes iPhone to iPhone Pro) +7. **Verify Update**: Gets the updated product to confirm changes +8. **Delete Product**: Removes the AirPods product (ID: 3) +9. **Verify Deletion**: Gets all products to confirm deletion +10. **Error Testing**: Tests 404 responses for non-existent products + +The script pauses between each operation, allowing you to review the response and understand the API behavior. + +==== Script Features + +* **Interactive**: Pauses between operations for review +* **Comprehensive**: Tests all CRUD operations and error scenarios +* **Educational**: Shows the exact curl commands being executed +* **Error Handling**: Demonstrates proper API error responses +* **Real-time Feedback**: Displays JSON responses for each operation + +== Testing + +The project includes comprehensive unit tests for both resource and service layers. + +=== Service Layer Testing + +Service layer tests directly verify the business logic: + +[source,java] +---- +@Test +void testGetAllProducts() { + List products = productService.getAllProducts(); + + assertNotNull(products); + assertEquals(2, products.size()); +} +---- + +=== Resource Layer Testing + +The project uses two approaches for testing the resource layer: + +==== Integration Testing + +This approach tests the resource layer with the actual service implementation: + +[source,java] +---- +@Test +void testGetAllProducts() { + Response response = productResource.getAllProducts(); + + assertNotNull(response); + assertEquals(Response.Status.OK.getStatusCode(), response.getStatus()); + + List products = (List) response.getEntity(); + assertNotNull(products); + assertEquals(2, products.size()); +} +---- + +== Running the Tests + +Run tests using Maven: + +[source,bash] +---- +mvn test +---- + +Run a specific test class: + +[source,bash] +---- +mvn test -Dtest=ProductResourceTest +---- + +Run a specific test method: + +[source,bash] +---- +mvn test -Dtest=ProductResourceTest#testGetAllProducts +---- + +== Building and Running + +=== Building the Application + +[source,bash] +---- +mvn clean package +---- + +=== Running with Liberty Maven Plugin + +[source,bash] +---- +mvn liberty:run +---- + +== Maven Configuration + +The project uses Maven for dependency management and build automation. Below is an overview of the key configurations in the `pom.xml` file: + +=== Properties + +[source,xml] +---- + + + 17 + 17 + + + 5050 + 5051 + mp-ecomm-store + +---- + +=== Dependencies + +The project includes several key dependencies: + +==== Runtime Dependencies + +[source,xml] +---- + + + jakarta.platform + jakarta.jakartaee-api + 10.0.0 + provided + + + + + org.eclipse.microprofile + microprofile + 6.1 + pom + provided + + + + + org.projectlombok + lombok + 1.18.26 + provided + +---- + +==== Testing Dependencies + +[source,xml] +---- + + + org.junit.jupiter + junit-jupiter + 5.9.3 + test + + + + + org.glassfish.jersey.core + jersey-common + 3.1.3 + test + +---- + +=== Build Plugins + +The project uses the following Maven plugins: + +[source,xml] +---- + + + io.openliberty.tools + liberty-maven-plugin + 3.11.2 + + mpServer + + + + + + org.apache.maven.plugins + maven-war-plugin + 3.4.0 + + + + + org.apache.maven.plugins + maven-surefire-plugin + 3.1.2 + +---- + +== Logging Interceptor + +The logging interceptor provides automatic method entry/exit logging with execution time tracking for your MicroProfile application. It's implemented using CDI interceptors. + +=== How to Use + +==== 1. Apply to Entire Class + +Add the `@Logged` annotation to any class that you want to have all methods logged: + +[source,java] +---- +import io.microprofile.tutorial.store.interceptor.Logged; + +@ApplicationScoped +@Logged +public class MyService { + // All methods in this class will be logged +} +---- + +==== 2. Apply to Individual Methods + +Add the `@Logged` annotation to specific methods: + +[source,java] +---- +import io.microprofile.tutorial.store.interceptor.Logged; + +@ApplicationScoped +public class MyService { + + @Logged + public void loggedMethod() { + // This method will be logged + } + + public void nonLoggedMethod() { + // This method will NOT be logged + } +} +---- + +=== Log Format + +The interceptor logs the following information: + +==== Method Entry +[listing] +---- +INFO: Entering method: com.example.MyService.myMethod with parameters: [param1, param2] +---- + +==== Method Exit (Success) +[listing] +---- +INFO: Exiting method: com.example.MyService.myMethod, execution time: 42ms, result: resultValue +---- + +==== Method Exit (Exception) +[listing] +---- +SEVERE: Exception in method: com.example.MyService.myMethod, execution time: 17ms, exception: Something went wrong +---- + +=== Configuration + +The logging interceptor uses the standard Java logging framework. You can configure the logging level and handlers in your project's `logging.properties` file. + +=== Configuration Files + +The logging interceptor requires proper configuration files for Jakarta EE CDI interceptors and Java Logging. This section describes the necessary configuration files and their contents. + +==== beans.xml + +This file is required to enable CDI interceptors in your application. It must be located in the `WEB-INF` directory. + +[source,xml] +---- + + + + io.microprofile.tutorial.store.interceptor.LoggingInterceptor + + +---- + +Key points about `beans.xml`: + +* The `` element registers our LoggingInterceptor class +* `bean-discovery-mode="all"` ensures that all beans are discovered +* Jakarta EE 10 uses version 4.0 of the beans schema + +==== logging.properties + +This file configures Java's built-in logging facility. It should be placed in the `src/main/resources` directory. + +[source,properties] +---- +# Global logging properties +handlers=java.util.logging.ConsoleHandler +.level=INFO + +# Configure the console handler +java.util.logging.ConsoleHandler.level=INFO +java.util.logging.ConsoleHandler.formatter=java.util.logging.SimpleFormatter + +# Simplified format for the logs +java.util.logging.SimpleFormatter.format=[%1$tF %1$tT] %4$s %2$s - %5$s %6$s%n + +# Set logging level for our application packages +io.microprofile.tutorial.store.level=INFO +---- + +Key points about `logging.properties`: + +* Sets up a console handler for logging output +* Configures a human-readable timestamp format +* Sets the application package logging level to INFO +* To see more detailed logs, change the package level to FINE or FINEST + +==== web.xml + +The `web.xml` file is the deployment descriptor for Jakarta EE web applications. While not directly required for the interceptor, it provides important context. + +[source,xml] +---- + + + MicroProfile E-Commerce Store + + + + java.util.logging.config.file + WEB-INF/classes/logging.properties + + +---- + +Key points about `web.xml`: + +* Jakarta EE 10 uses web-app version 6.0 +* You can optionally specify the logging configuration file location +* No special configuration is needed for CDI interceptors as they're managed by `beans.xml` + +==== Loading Configuration at Runtime + +To ensure your logging configuration is loaded at application startup, the application class loads it programmatically: + +[source,java] +---- +@ApplicationPath("/api") +public class ProductRestApplication extends Application { + private static final Logger LOGGER = Logger.getLogger(ProductRestApplication.class.getName()); + + public void init(@Observes @Initialized(ApplicationScoped.class) Object init) { + try { + // Load logging configuration + InputStream inputStream = ProductRestApplication.class + .getClassLoader() + .getResourceAsStream("logging.properties"); + + if (inputStream != null) { + LogManager.getLogManager().readConfiguration(inputStream); + LOGGER.info("Custom logging configuration loaded"); + } else { + LOGGER.warning("Could not find logging.properties file"); + } + } catch (Exception e) { + LOGGER.severe("Failed to load logging configuration: " + e.getMessage()); + } + } +} +---- + +=== LoggingDemoService Implementation + +A demonstration class `LoggingDemoService` is provided to showcase how the logging interceptor works. You can find this class in the `io.microprofile.tutorial.store.demo` package. + +==== Demo Features + +* Selective method logging with `@Logged` annotation +* Example of both logged and non-logged methods in the same class +* Exception handling demonstration + +[source,java] +---- +@ApplicationScoped +public class LoggingDemoService { + + // This method will be logged because of the @Logged annotation + @Logged + public String loggedMethod(String input) { + // Method logic + return "Processed: " + input; + } + + // This method will NOT be logged since it doesn't have the @Logged annotation + public String nonLoggedMethod(String input) { + // Method logic + return "Silently processed: " + input; + } + + /** + * Example of a method with exception that will be logged + */ + @Logged + public void methodWithException() throws Exception { + throw new Exception("This exception will be logged by the interceptor"); + } +} +---- + +=== Testing the Logging Interceptor + +A test class `LoggingInterceptorTest` is available in the test directory that demonstrates how to use the `LoggingDemoService`. Run the test to see how methods with the `@Logged` annotation have their execution logged, while methods without the annotation run silently. + +=== Running the Interceptor + +==== Unit Testing + +To run the interceptor in unit tests: + +[source,bash] +---- +mvn test -Dtest=io.microprofile.tutorial.store.interceptor.LoggingInterceptorTest +---- + +The test validates that: + +1. The logged method returns the expected result +2. The non-logged method also functions correctly +3. Exception handling and logging works as expected + +You can check the test results in: +[listing] +---- +/target/surefire-reports/io.microprofile.tutorial.store.interceptor.LoggingInterceptorTest.txt +---- + +==== In Production Environment + +For the interceptor to work in a real Liberty server environment: + +1. Make sure `beans.xml` is properly configured in `WEB-INF` directory: ++ +[source,xml] +---- + + + io.microprofile.tutorial.store.interceptor.LoggingInterceptor + + +---- + +2. Deploy your application to Liberty: ++ +[source,bash] +---- +mvn liberty:run +---- + +3. Access your REST endpoints (e.g., `/api/products`) to trigger the interceptor logging + +4. Check server logs: ++ +[source,bash] +---- +cat target/liberty/wlp/usr/servers/mpServer/logs/messages.log +---- + +==== Performance Considerations + +* Logging at INFO level for all method entries/exits can significantly increase log volume +* Consider using FINE or FINER level for detailed method logging in production +* For high-throughput methods, consider disabling the interceptor or using sampling + +==== Customizing the Interceptor + +You can customize the LoggingInterceptor by: + +1. Modifying the log format in the `logMethodCall` method +2. Changing the log level for different events +3. Adding filters to exclude certain parameter types or large return values +4. Adding MDC (Mapped Diagnostic Context) information for tracking requests across methods + +== Development Best Practices + +This project demonstrates several Java enterprise development best practices: + +* *Separation of Concerns*: Distinct layers for entities, business logic, and REST endpoints +* *Dependency Injection*: Using CDI for loose coupling between components +* *Unit Testing*: Comprehensive tests for business logic and API endpoints +* *RESTful API Design*: Following REST principles for resource naming and HTTP methods +* *Error Handling*: Proper HTTP status codes for different scenarios + +== Future Enhancements + +* Add persistence layer with a database +* Implement validation for request data +* Add OpenAPI documentation +* Implement MicroProfile Config for externalized configuration +* Add MicroProfile Health for health checks +* Implement MicroProfile Metrics for monitoring +* Implement MicroProfile Fault Tolerance for resilience +* Add authentication and authorization diff --git a/code/chapter03/mp-ecomm-store/pom.xml b/code/chapter03/mp-ecomm-store/pom.xml new file mode 100644 index 0000000..a936e05 --- /dev/null +++ b/code/chapter03/mp-ecomm-store/pom.xml @@ -0,0 +1,108 @@ + + + 4.0.0 + + io.microprofile.tutorial + mp-ecomm-store + 1.0-SNAPSHOT + war + + + + + 17 + 17 + + UTF-8 + UTF-8 + + + 5050 + 5051 + + mp-ecomm-store + + + + + + + org.projectlombok + lombok + 1.18.26 + provided + + + + + jakarta.platform + jakarta.jakartaee-api + 10.0.0 + provided + + + + + org.eclipse.microprofile + microprofile + 6.1 + pom + provided + + + + + org.junit.jupiter + junit-jupiter + 5.9.3 + test + + + org.junit.jupiter + junit-jupiter-api + 5.9.3 + test + + + org.junit.jupiter + junit-jupiter-engine + 5.9.3 + test + + + + + org.glassfish.jersey.core + jersey-common + 3.1.3 + test + + + + + ${project.artifactId} + + + + io.openliberty.tools + liberty-maven-plugin + 3.11.2 + + mpServer + + + + + org.apache.maven.plugins + maven-war-plugin + 3.4.0 + + + org.apache.maven.plugins + maven-surefire-plugin + 3.1.2 + + + + \ No newline at end of file diff --git a/code/chapter03/mp-ecomm-store/src/main/java/io/microprofile/tutorial/store/demo/LoggingDemoService.java b/code/chapter03/mp-ecomm-store/src/main/java/io/microprofile/tutorial/store/demo/LoggingDemoService.java new file mode 100644 index 0000000..946be2f --- /dev/null +++ b/code/chapter03/mp-ecomm-store/src/main/java/io/microprofile/tutorial/store/demo/LoggingDemoService.java @@ -0,0 +1,33 @@ +package io.microprofile.tutorial.store.demo; + +import io.microprofile.tutorial.store.interceptor.Logged; +import jakarta.enterprise.context.ApplicationScoped; + +/** + * A demonstration class showing how to apply the @Logged interceptor to individual methods + * instead of the entire class. + */ +@ApplicationScoped +public class LoggingDemoService { + + // This method will be logged because of the @Logged annotation + @Logged + public String loggedMethod(String input) { + // Method logic + return "Processed: " + input; + } + + // This method will NOT be logged since it doesn't have the @Logged annotation + public String nonLoggedMethod(String input) { + // Method logic + return "Silently processed: " + input; + } + + /** + * Example of a method with exception that will be logged + */ + @Logged + public void methodWithException() throws Exception { + throw new Exception("This exception will be logged by the interceptor"); + } +} diff --git a/code/chapter03/mp-ecomm-store/src/main/java/io/microprofile/tutorial/store/interceptor/Logged.java b/code/chapter03/mp-ecomm-store/src/main/java/io/microprofile/tutorial/store/interceptor/Logged.java new file mode 100644 index 0000000..d0037d0 --- /dev/null +++ b/code/chapter03/mp-ecomm-store/src/main/java/io/microprofile/tutorial/store/interceptor/Logged.java @@ -0,0 +1,17 @@ +package io.microprofile.tutorial.store.interceptor; + +import jakarta.interceptor.InterceptorBinding; +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** + * Interceptor binding annotation for method logging. + * Apply this annotation to methods or classes to log method entry and exit along with execution time. + */ +@InterceptorBinding +@Target({ElementType.TYPE, ElementType.METHOD}) +@Retention(RetentionPolicy.RUNTIME) +public @interface Logged { +} diff --git a/code/chapter03/mp-ecomm-store/src/main/java/io/microprofile/tutorial/store/interceptor/LoggingInterceptor.java b/code/chapter03/mp-ecomm-store/src/main/java/io/microprofile/tutorial/store/interceptor/LoggingInterceptor.java new file mode 100644 index 0000000..508dd19 --- /dev/null +++ b/code/chapter03/mp-ecomm-store/src/main/java/io/microprofile/tutorial/store/interceptor/LoggingInterceptor.java @@ -0,0 +1,54 @@ +package io.microprofile.tutorial.store.interceptor; + +import jakarta.annotation.Priority; +import jakarta.interceptor.AroundInvoke; +import jakarta.interceptor.Interceptor; +import jakarta.interceptor.InvocationContext; + +import java.util.Arrays; +import java.util.logging.Level; +import java.util.logging.Logger; + +/** + * Logging interceptor that logs method entry, exit, and execution time. + * This interceptor is applied to methods or classes annotated with @Logged. + */ +@Logged +@Interceptor +@Priority(Interceptor.Priority.APPLICATION) +public class LoggingInterceptor { + + @AroundInvoke + public Object logMethodCall(InvocationContext context) throws Exception { + final String className = context.getTarget().getClass().getName(); + final String methodName = context.getMethod().getName(); + final Logger logger = Logger.getLogger(className); + + // Log method entry with parameters + logger.log(Level.INFO, "Entering method: {0}.{1} with parameters: {2}", + new Object[]{className, methodName, Arrays.toString(context.getParameters())}); + + final long startTime = System.currentTimeMillis(); + + try { + // Execute the intercepted method + Object result = context.proceed(); + + final long executionTime = System.currentTimeMillis() - startTime; + + // Log method exit with execution time + logger.log(Level.INFO, "Exiting method: {0}.{1}, execution time: {2}ms, result: {3}", + new Object[]{className, methodName, executionTime, result}); + + return result; + } catch (Exception e) { + // Log exceptions + final long executionTime = System.currentTimeMillis() - startTime; + logger.log(Level.SEVERE, "Exception in method: {0}.{1}, execution time: {2}ms, exception: {3}", + new Object[]{className, methodName, executionTime, e.getMessage()}); + + // Re-throw the exception + throw e; + } + } +} diff --git a/code/chapter03/mp-ecomm-store/src/main/java/io/microprofile/tutorial/store/product/ProductRestApplication.java b/code/chapter03/mp-ecomm-store/src/main/java/io/microprofile/tutorial/store/product/ProductRestApplication.java new file mode 100644 index 0000000..4bb8cee --- /dev/null +++ b/code/chapter03/mp-ecomm-store/src/main/java/io/microprofile/tutorial/store/product/ProductRestApplication.java @@ -0,0 +1,37 @@ +package io.microprofile.tutorial.store.product; + +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.enterprise.event.Observes; +import jakarta.enterprise.context.Initialized; +import jakarta.ws.rs.ApplicationPath; +import jakarta.ws.rs.core.Application; + +import java.io.InputStream; +import java.util.logging.LogManager; +import java.util.logging.Logger; + +@ApplicationPath("/api") +public class ProductRestApplication extends Application { + private static final Logger LOGGER = Logger.getLogger(ProductRestApplication.class.getName()); + + /** + * Initializes the application and loads custom logging configuration + */ + public void init(@Observes @Initialized(ApplicationScoped.class) Object init) { + try { + // Load logging configuration + InputStream inputStream = ProductRestApplication.class + .getClassLoader() + .getResourceAsStream("logging.properties"); + + if (inputStream != null) { + LogManager.getLogManager().readConfiguration(inputStream); + LOGGER.info("Custom logging configuration loaded"); + } else { + LOGGER.warning("Could not find logging.properties file"); + } + } catch (Exception e) { + LOGGER.severe("Failed to load logging configuration: " + e.getMessage()); + } + } +} \ No newline at end of file diff --git a/code/chapter03/mp-ecomm-store/src/main/java/io/microprofile/tutorial/store/product/entity/Product.java b/code/chapter03/mp-ecomm-store/src/main/java/io/microprofile/tutorial/store/product/entity/Product.java new file mode 100644 index 0000000..84e3b23 --- /dev/null +++ b/code/chapter03/mp-ecomm-store/src/main/java/io/microprofile/tutorial/store/product/entity/Product.java @@ -0,0 +1,16 @@ +package io.microprofile.tutorial.store.product.entity; + +import lombok.Data; +import lombok.NoArgsConstructor; +import lombok.AllArgsConstructor; + +@Data +@NoArgsConstructor +@AllArgsConstructor +public class Product { + + private Long id; + private String name; + private String description; + private Double price; +} diff --git a/code/chapter03/mp-ecomm-store/src/main/java/io/microprofile/tutorial/store/product/resource/ProductResource.java b/code/chapter03/mp-ecomm-store/src/main/java/io/microprofile/tutorial/store/product/resource/ProductResource.java new file mode 100644 index 0000000..2dd8fad --- /dev/null +++ b/code/chapter03/mp-ecomm-store/src/main/java/io/microprofile/tutorial/store/product/resource/ProductResource.java @@ -0,0 +1,89 @@ +package io.microprofile.tutorial.store.product.resource; + +import io.microprofile.tutorial.store.product.entity.Product; +import io.microprofile.tutorial.store.product.service.ProductService; +import io.microprofile.tutorial.store.interceptor.Logged; +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.Optional; +import java.util.logging.Logger; + +@ApplicationScoped +@Path("/products") +@Logged +public class ProductResource { + + private static final Logger LOGGER = Logger.getLogger(ProductResource.class.getName()); + + @Inject + private ProductService productService; + + public ProductResource(ProductService productService) { + this.productService = productService; + } + + // No-args constructor for tests + public ProductResource() { + this.productService = new ProductService(); + } + + @GET + @Produces(MediaType.APPLICATION_JSON) + public Response getAllProducts() { + LOGGER.info("Fetching all products"); + return Response.ok(productService.getAllProducts()).build(); + } + + @GET + @Path("/{id}") + @Produces(MediaType.APPLICATION_JSON) + public Response getProductById(@PathParam("id") Long id) { + LOGGER.info("Fetching product with id: " + id); + Optional product = productService.getProductById(id); + if (product.isPresent()) { + return Response.ok(product.get()).build(); + } else { + return Response.status(Response.Status.NOT_FOUND).build(); + } + } + + @POST + @Consumes(MediaType.APPLICATION_JSON) + @Produces(MediaType.APPLICATION_JSON) + public Response createProduct(Product product) { + LOGGER.info("Creating product: " + product); + Product createdProduct = productService.createProduct(product); + return Response.status(Response.Status.CREATED).entity(createdProduct).build(); + } + + @PUT + @Path("/{id}") + @Consumes(MediaType.APPLICATION_JSON) + @Produces(MediaType.APPLICATION_JSON) + public Response updateProduct(@PathParam("id") Long id, Product updatedProduct) { + LOGGER.info("Updating product with id: " + id); + Optional product = productService.updateProduct(id, updatedProduct); + if (product.isPresent()) { + return Response.ok(product.get()).build(); + } else { + return Response.status(Response.Status.NOT_FOUND).build(); + } + } + + @DELETE + @Path("/{id}") + @Produces(MediaType.APPLICATION_JSON) + public Response deleteProduct(@PathParam("id") Long id) { + LOGGER.info("Deleting product with id: " + id); + boolean deleted = productService.deleteProduct(id); + if (deleted) { + return Response.noContent().build(); + } else { + return Response.status(Response.Status.NOT_FOUND).build(); + } + } +} \ No newline at end of file diff --git a/code/chapter03/mp-ecomm-store/src/main/java/io/microprofile/tutorial/store/product/service/ProductService.java b/code/chapter03/mp-ecomm-store/src/main/java/io/microprofile/tutorial/store/product/service/ProductService.java new file mode 100644 index 0000000..070c980 --- /dev/null +++ b/code/chapter03/mp-ecomm-store/src/main/java/io/microprofile/tutorial/store/product/service/ProductService.java @@ -0,0 +1,63 @@ +package io.microprofile.tutorial.store.product.service; + +import io.microprofile.tutorial.store.product.entity.Product; +import io.microprofile.tutorial.store.interceptor.Logged; +import jakarta.enterprise.context.ApplicationScoped; + +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; +import java.util.logging.Logger; + +@ApplicationScoped +@Logged +public class ProductService { + private static final Logger LOGGER = Logger.getLogger(ProductService.class.getName()); + private List products = new ArrayList<>(); + + public ProductService() { + // Initialize the list with some sample products + products.add(new Product(1L, "iPhone", "Apple iPhone 15", 999.99)); + products.add(new Product(2L, "MacBook", "Apple MacBook Air", 1299.0)); + } + + public List getAllProducts() { + LOGGER.info("Fetching all products"); + return products; + } + + public Optional getProductById(Long id) { + LOGGER.info("Fetching product with id: " + id); + return products.stream().filter(p -> p.getId().equals(id)).findFirst(); + } + + public Product createProduct(Product product) { + LOGGER.info("Creating product: " + product); + products.add(product); + return product; + } + + public Optional updateProduct(Long id, Product updatedProduct) { + LOGGER.info("Updating product with id: " + id); + for (int i = 0; i < products.size(); i++) { + Product product = products.get(i); + if (product.getId().equals(id)) { + product.setName(updatedProduct.getName()); + product.setDescription(updatedProduct.getDescription()); + product.setPrice(updatedProduct.getPrice()); + return Optional.of(product); + } + } + return Optional.empty(); + } + + public boolean deleteProduct(Long id) { + LOGGER.info("Deleting product with id: " + id); + Optional product = getProductById(id); + if (product.isPresent()) { + products.remove(product.get()); + return true; + } + return false; + } +} diff --git a/code/chapter03/mp-ecomm-store/src/main/liberty/config/server.xml b/code/chapter03/mp-ecomm-store/src/main/liberty/config/server.xml new file mode 100644 index 0000000..79fbaf3 --- /dev/null +++ b/code/chapter03/mp-ecomm-store/src/main/liberty/config/server.xml @@ -0,0 +1,14 @@ + + + jakartaEE-10.0 + microProfile-6.1 + restfulWS + jsonp + jsonb + cdi + + + + + \ No newline at end of file diff --git a/code/chapter03/mp-ecomm-store/src/main/resources/logging.properties b/code/chapter03/mp-ecomm-store/src/main/resources/logging.properties new file mode 100644 index 0000000..d3d0bc3 --- /dev/null +++ b/code/chapter03/mp-ecomm-store/src/main/resources/logging.properties @@ -0,0 +1,13 @@ +# Global logging properties +handlers=java.util.logging.ConsoleHandler +.level=INFO + +# Configure the console handler +java.util.logging.ConsoleHandler.level=INFO +java.util.logging.ConsoleHandler.formatter=java.util.logging.SimpleFormatter + +# Simplified format for the logs +java.util.logging.SimpleFormatter.format=[%1$tF %1$tT] %4$s %2$s - %5$s %6$s%n + +# Set logging level for our application packages +io.microprofile.tutorial.store.level=INFO diff --git a/code/chapter03/mp-ecomm-store/src/main/webapp/WEB-INF/beans.xml b/code/chapter03/mp-ecomm-store/src/main/webapp/WEB-INF/beans.xml new file mode 100644 index 0000000..d2b21f1 --- /dev/null +++ b/code/chapter03/mp-ecomm-store/src/main/webapp/WEB-INF/beans.xml @@ -0,0 +1,10 @@ + + + + io.microprofile.tutorial.store.interceptor.LoggingInterceptor + + diff --git a/code/chapter03/mp-ecomm-store/src/main/webapp/WEB-INF/web.xml b/code/chapter03/mp-ecomm-store/src/main/webapp/WEB-INF/web.xml new file mode 100644 index 0000000..633f72a --- /dev/null +++ b/code/chapter03/mp-ecomm-store/src/main/webapp/WEB-INF/web.xml @@ -0,0 +1,10 @@ + + + MicroProfile E-Commerce Store + + + + diff --git a/code/chapter03/mp-ecomm-store/src/test/java/io/microprofile/tutorial/store/interceptor/LoggingInterceptorTest.java b/code/chapter03/mp-ecomm-store/src/test/java/io/microprofile/tutorial/store/interceptor/LoggingInterceptorTest.java new file mode 100644 index 0000000..8a84503 --- /dev/null +++ b/code/chapter03/mp-ecomm-store/src/test/java/io/microprofile/tutorial/store/interceptor/LoggingInterceptorTest.java @@ -0,0 +1,33 @@ +package io.microprofile.tutorial.store.interceptor; + +import static org.junit.jupiter.api.Assertions.*; + +import org.junit.jupiter.api.Test; + +import io.microprofile.tutorial.store.demo.LoggingDemoService; + +class LoggingInterceptorTest { + + @Test + void testLoggingDemoService() { + // This is a simple test to demonstrate how to use the logging interceptor + // In a real scenario, you might use integration tests with Arquillian or similar + + LoggingDemoService service = new LoggingDemoService(); + + // Call the logged method (in real tests, you'd verify the log output) + String result = service.loggedMethod("test input"); + assertEquals("Processed: test input", result); + + // Call the non-logged method + String result2 = service.nonLoggedMethod("other input"); + assertEquals("Silently processed: other input", result2); + + // Test exception handling + Exception exception = assertThrows(Exception.class, () -> { + service.methodWithException(); + }); + + assertEquals("This exception will be logged by the interceptor", exception.getMessage()); + } +} diff --git a/code/chapter03/mp-ecomm-store/src/test/java/io/microprofile/tutorial/store/product/resource/ProductResourceTest.java b/code/chapter03/mp-ecomm-store/src/test/java/io/microprofile/tutorial/store/product/resource/ProductResourceTest.java new file mode 100644 index 0000000..23f5c8d --- /dev/null +++ b/code/chapter03/mp-ecomm-store/src/test/java/io/microprofile/tutorial/store/product/resource/ProductResourceTest.java @@ -0,0 +1,173 @@ +package io.microprofile.tutorial.store.product.resource; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.List; +import java.util.Optional; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import jakarta.ws.rs.core.Response; + +import io.microprofile.tutorial.store.product.entity.Product; +import io.microprofile.tutorial.store.product.service.ProductService; + +public class ProductResourceTest { + private ProductResource productResource; + private ProductService productService; + + @BeforeEach + void setUp() { + productService = new ProductService(); + productResource = new ProductResource(productService); + } + + @AfterEach + void tearDown() { + productResource = null; + productService = null; + } + + @Test + void testGetAllProducts() { + // Call the method to test + Response response = productResource.getAllProducts(); + + // Assert response properties + assertNotNull(response); + assertEquals(Response.Status.OK.getStatusCode(), response.getStatus()); + + // Assert the entity content + List products = (List) response.getEntity(); + assertNotNull(products); + assertEquals(2, products.size()); + } + + @Test + void testGetProductById_ExistingProduct() { + // Call the method to test + Response response = productResource.getProductById(1L); + + // Assert response properties + assertNotNull(response); + assertEquals(Response.Status.OK.getStatusCode(), response.getStatus()); + + // Assert the entity content + Product product = (Product) response.getEntity(); + assertNotNull(product); + assertEquals(1L, product.getId()); + assertEquals("iPhone", product.getName()); + } + + @Test + void testGetProductById_NonExistingProduct() { + // Call the method to test + Response response = productResource.getProductById(999L); + + // Assert response properties + assertNotNull(response); + assertEquals(Response.Status.NOT_FOUND.getStatusCode(), response.getStatus()); + } + + @Test + void testCreateProduct() { + // Create a product to add + Product newProduct = new Product(3L, "iPad", "Apple iPad Pro", 799.99); + + // Call the method to test + Response response = productResource.createProduct(newProduct); + + // Assert response properties + assertNotNull(response); + assertEquals(Response.Status.CREATED.getStatusCode(), response.getStatus()); + + // Assert the entity content + Product createdProduct = (Product) response.getEntity(); + assertNotNull(createdProduct); + assertEquals(3L, createdProduct.getId()); + assertEquals("iPad", createdProduct.getName()); + + // Verify the product was added to the list + Response getAllResponse = productResource.getAllProducts(); + List allProducts = (List) getAllResponse.getEntity(); + assertEquals(3, allProducts.size()); + } + + @Test + void testUpdateProduct_ExistingProduct() { + // Create an updated product + Product updatedProduct = new Product(1L, "iPhone Pro", "Apple iPhone 15 Pro", 1199.99); + + // Call the method to test + Response response = productResource.updateProduct(1L, updatedProduct); + + // Assert response properties + assertNotNull(response); + assertEquals(Response.Status.OK.getStatusCode(), response.getStatus()); + + // Assert the entity content + Product returnedProduct = (Product) response.getEntity(); + assertNotNull(returnedProduct); + assertEquals(1L, returnedProduct.getId()); + assertEquals("iPhone Pro", returnedProduct.getName()); + assertEquals("Apple iPhone 15 Pro", returnedProduct.getDescription()); + assertEquals(1199.99, returnedProduct.getPrice()); + + // Verify the product was updated in the list + Response getResponse = productResource.getProductById(1L); + Product retrievedProduct = (Product) getResponse.getEntity(); + assertEquals("iPhone Pro", retrievedProduct.getName()); + assertEquals(1199.99, retrievedProduct.getPrice()); + } + + @Test + void testUpdateProduct_NonExistingProduct() { + // Create a product with non-existent ID + Product nonExistentProduct = new Product(999L, "Nonexistent", "This product doesn't exist", 0.0); + + // Call the method to test + Response response = productResource.updateProduct(999L, nonExistentProduct); + + // Assert response properties + assertNotNull(response); + assertEquals(Response.Status.NOT_FOUND.getStatusCode(), response.getStatus()); + } + + @Test + void testDeleteProduct_ExistingProduct() { + // Call the method to test + Response response = productResource.deleteProduct(1L); + + // Assert response properties + assertNotNull(response); + assertEquals(Response.Status.NO_CONTENT.getStatusCode(), response.getStatus()); + + // Verify the product was deleted + Response getResponse = productResource.getProductById(1L); + assertEquals(Response.Status.NOT_FOUND.getStatusCode(), getResponse.getStatus()); + + // Verify the total count is reduced + Response getAllResponse = productResource.getAllProducts(); + List allProducts = (List) getAllResponse.getEntity(); + assertEquals(1, allProducts.size()); + } + + @Test + void testDeleteProduct_NonExistingProduct() { + // Call the method to test with non-existent ID + Response response = productResource.deleteProduct(999L); + + // Assert response properties + assertNotNull(response); + assertEquals(Response.Status.NOT_FOUND.getStatusCode(), response.getStatus()); + + // Verify list size remains unchanged + Response getAllResponse = productResource.getAllProducts(); + List allProducts = (List) getAllResponse.getEntity(); + assertEquals(2, allProducts.size()); + } +} \ No newline at end of file diff --git a/code/chapter03/mp-ecomm-store/src/test/java/io/microprofile/tutorial/store/product/service/ProductServiceTest.java b/code/chapter03/mp-ecomm-store/src/test/java/io/microprofile/tutorial/store/product/service/ProductServiceTest.java new file mode 100644 index 0000000..dffb4b8 --- /dev/null +++ b/code/chapter03/mp-ecomm-store/src/test/java/io/microprofile/tutorial/store/product/service/ProductServiceTest.java @@ -0,0 +1,137 @@ +package io.microprofile.tutorial.store.product.service; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.List; +import java.util.Optional; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import io.microprofile.tutorial.store.product.entity.Product; + +public class ProductServiceTest { + private ProductService productService; + + @BeforeEach + void setUp() { + productService = new ProductService(); + } + + @AfterEach + void tearDown() { + productService = null; + } + + @Test + void testGetAllProducts() { + // Call the method to test + List products = productService.getAllProducts(); + + // Assert the result + assertNotNull(products); + assertEquals(2, products.size()); + } + + @Test + void testGetProductById_ExistingProduct() { + // Call the method to test + Optional product = productService.getProductById(1L); + + // Assert the result + assertTrue(product.isPresent()); + assertEquals("iPhone", product.get().getName()); + assertEquals("Apple iPhone 15", product.get().getDescription()); + assertEquals(999.99, product.get().getPrice()); + } + + @Test + void testGetProductById_NonExistingProduct() { + // Call the method to test + Optional product = productService.getProductById(999L); + + // Assert the result + assertFalse(product.isPresent()); + } + + @Test + void testCreateProduct() { + // Create a product to add + Product newProduct = new Product(3L, "iPad", "Apple iPad Pro", 799.99); + + // Call the method to test + Product createdProduct = productService.createProduct(newProduct); + + // Assert the result + assertNotNull(createdProduct); + assertEquals(3L, createdProduct.getId()); + assertEquals("iPad", createdProduct.getName()); + + // Verify the product was added to the list + List allProducts = productService.getAllProducts(); + assertEquals(3, allProducts.size()); + } + + @Test + void testUpdateProduct_ExistingProduct() { + // Create an updated product + Product updatedProduct = new Product(1L, "iPhone Pro", "Apple iPhone 15 Pro", 1199.99); + + // Call the method to test + Optional result = productService.updateProduct(1L, updatedProduct); + + // Assert the result + assertTrue(result.isPresent()); + assertEquals("iPhone Pro", result.get().getName()); + assertEquals("Apple iPhone 15 Pro", result.get().getDescription()); + assertEquals(1199.99, result.get().getPrice()); + + // Verify the product was updated in the list + Optional updatedInList = productService.getProductById(1L); + assertTrue(updatedInList.isPresent()); + assertEquals("iPhone Pro", updatedInList.get().getName()); + } + + @Test + void testUpdateProduct_NonExistingProduct() { + // Create an updated product + Product updatedProduct = new Product(999L, "Nonexistent Product", "This product doesn't exist", 0.0); + + // Call the method to test + Optional result = productService.updateProduct(999L, updatedProduct); + + // Assert the result + assertFalse(result.isPresent()); + } + + @Test + void testDeleteProduct_ExistingProduct() { + // Call the method to test + boolean deleted = productService.deleteProduct(1L); + + // Assert the result + assertTrue(deleted); + + // Verify the product was removed from the list + List allProducts = productService.getAllProducts(); + assertEquals(1, allProducts.size()); + assertFalse(productService.getProductById(1L).isPresent()); + } + + @Test + void testDeleteProduct_NonExistingProduct() { + // Call the method to test + boolean deleted = productService.deleteProduct(999L); + + // Assert the result + assertFalse(deleted); + + // Verify the list is unchanged + List allProducts = productService.getAllProducts(); + assertEquals(2, allProducts.size()); + } +} diff --git a/code/chapter03/mp-ecomm-store/test-api.sh b/code/chapter03/mp-ecomm-store/test-api.sh new file mode 100755 index 0000000..4c5a183 --- /dev/null +++ b/code/chapter03/mp-ecomm-store/test-api.sh @@ -0,0 +1,103 @@ +#!/bin/bash +# filepath: /workspaces/microprofile-tutorial/code/chapter03/mp-ecomm-store/test-api.sh + +# MicroProfile E-Commerce Store API Test Script +# This script tests all CRUD operations for the Product API + +set -e # Exit on any error + +# Configuration +BASE_URL="http://localhost:5050/mp-ecomm-store/api/products" +CONTENT_TYPE="Content-Type: application/json" + +echo "==================================" +echo "MicroProfile E-Commerce Store API Test" +echo "==================================" +echo "Base URL: $BASE_URL" +echo "==================================" + +# Function to print section headers +print_section() { + echo "" + echo "--- $1 ---" +} + +# Function to pause between operations +pause() { + echo "Press Enter to continue..." + read -r +} + +print_section "1. GET ALL PRODUCTS (Initial State)" +echo "Command: curl -i -X GET $BASE_URL" +curl -i -X GET "$BASE_URL" +echo "" +pause + +print_section "2. GET PRODUCT BY ID (Existing Product)" +echo "Command: curl -i -X GET $BASE_URL/1" +curl -i -X GET "$BASE_URL/1" +echo "" +pause + +print_section "3. CREATE NEW PRODUCT" +echo "Command: curl -i -X POST $BASE_URL -H \"$CONTENT_TYPE\" -d '{\"id\": 3, \"name\": \"AirPods\", \"description\": \"Apple AirPods Pro\", \"price\": 249.99}'" +curl -i -X POST "$BASE_URL" \ + -H "$CONTENT_TYPE" \ + -d '{"id": 3, "name": "AirPods", "description": "Apple AirPods Pro", "price": 249.99}' +echo "" +pause + +print_section "4. GET ALL PRODUCTS (After Creation)" +echo "Command: curl -i -X GET $BASE_URL" +curl -i -X GET "$BASE_URL" +echo "" +pause + +print_section "5. GET NEW PRODUCT BY ID" +echo "Command: curl -i -X GET $BASE_URL/3" +curl -i -X GET "$BASE_URL/3" +echo "" +pause + +print_section "6. UPDATE EXISTING PRODUCT" +echo "Command: curl -i -X PUT $BASE_URL/1 -H \"$CONTENT_TYPE\" -d '{\"id\": 1, \"name\": \"iPhone Pro\", \"description\": \"Apple iPhone 15 Pro\", \"price\": 1199.99}'" +curl -i -X PUT "$BASE_URL/1" \ + -H "$CONTENT_TYPE" \ + -d '{"id": 1, "name": "iPhone Pro", "description": "Apple iPhone 15 Pro", "price": 1199.99}' +echo "" +pause + +print_section "7. GET UPDATED PRODUCT" +echo "Command: curl -i -X GET $BASE_URL/1" +curl -i -X GET "$BASE_URL/1" +echo "" +pause + +print_section "8. DELETE PRODUCT" +echo "Command: curl -i -X DELETE $BASE_URL/3" +curl -i -X DELETE "$BASE_URL/3" +echo "" +pause + +print_section "9. GET ALL PRODUCTS (After Deletion)" +echo "Command: curl -i -X GET $BASE_URL" +curl -i -X GET "$BASE_URL" +echo "" +pause + +print_section "10. TRY TO GET DELETED PRODUCT (Should return 404)" +echo "Command: curl -i -X GET $BASE_URL/3" +curl -i -X GET "$BASE_URL/3" || true +echo "" +pause + +print_section "11. TRY TO GET NON-EXISTENT PRODUCT (Should return 404)" +echo "Command: curl -i -X GET $BASE_URL/999" +curl -i -X GET "$BASE_URL/999" || true +echo "" + +echo "" +echo "==================================" +echo "API Testing Complete!" +echo "==================================" \ No newline at end of file diff --git a/code/chapter04/catalog/README.adoc b/code/chapter04/catalog/README.adoc new file mode 100644 index 0000000..2ac3cc5 --- /dev/null +++ b/code/chapter04/catalog/README.adoc @@ -0,0 +1,699 @@ += MicroProfile Catalog Service +:toc: macro +:toclevels: 3 +:icons: font +:source-highlighter: highlight.js +:experimental: + +toc::[] + +== Overview + +The MicroProfile Catalog Service is a modern Jakarta EE 10 application built with MicroProfile 6.1 specifications. This service provides a RESTful API for product catalog management with enhanced MicroProfile features and flexible persistence options. + +This project demonstrates the key capabilities of MicroProfile OpenAPI, CDI qualifiers, and dual persistence architecture with both JPA/Derby database and in-memory implementations. + +== Features + +* *RESTful API* using Jakarta RESTful Web Services +* *OpenAPI Documentation* with Swagger UI +* *Dual Persistence Implementation* with configurable JPA/Derby database and in-memory storage options +* *CDI Qualifiers* for dependency injection and implementation switching +* *Derby Database Support* with automatic JAR deployment via Liberty Maven plugin + +== MicroProfile Features Implemented + +=== MicroProfile OpenAPI + +The application provides OpenAPI documentation for its REST endpoints. API documentation is generated automatically from annotations in the code: + +[source,java] +---- +@GET +@Produces(MediaType.APPLICATION_JSON) +@Operation(summary = "Get all products", description = "Returns a list of all products") +@APIResponses({ + @APIResponse(responseCode = "200", description = "List of products", + content = @Content(mediaType = "application/json", + schema = @Schema(implementation = Product.class))) +}) +public Response getAllProducts() { + // Implementation +} +---- + +The OpenAPI documentation is available at: `/openapi` (in various formats) and `/openapi/ui` (Swagger UI) + +=== Dual Persistence Architecture + +The application implements a flexible persistence layer with two implementations that can be switched via CDI qualifiers: + +1. *JPA/Derby Database Implementation* (Default) - For production use with persistent data storage +2. *In-Memory Implementation* - For development, testing, and scenarios where persistence across restarts is not required + +==== CDI Qualifiers for Implementation Switching + +The application uses CDI qualifiers to select between different persistence implementations: + +[source,java] +---- +// JPA Qualifier +@Qualifier +@Retention(RUNTIME) +@Target({METHOD, FIELD, PARAMETER, TYPE}) +public @interface JPA { +} + +// In-Memory Qualifier +@Qualifier +@Retention(RUNTIME) +@Target({METHOD, FIELD, PARAMETER, TYPE}) +public @interface InMemory { +} +---- + +==== Service Layer with CDI Injection + +The service layer uses CDI qualifiers to inject the appropriate repository implementation: + +[source,java] +---- +@ApplicationScoped +public class ProductService { + + @Inject + @JPA // Uses Derby database implementation by default + private ProductRepositoryInterface repository; + + public List findAllProducts() { + return repository.findAllProducts(); + } + // ... other service methods +} +---- + +==== JPA/Derby Database Implementation + +The JPA implementation provides persistent data storage using Apache Derby database: + +[source,java] +---- +@ApplicationScoped +@JPA +@Transactional +public class ProductJpaRepository implements ProductRepositoryInterface { + + @PersistenceContext(unitName = "catalogPU") + private EntityManager entityManager; + + @Override + public List findAllProducts() { + TypedQuery query = entityManager.createNamedQuery("Product.findAll", Product.class); + return query.getResultList(); + } + + @Override + public Product createProduct(Product product) { + entityManager.persist(product); + return product; + } + // ... other JPA operations +} +---- + +*Key Features of JPA Implementation:* +* Persistent data storage across application restarts +* ACID transactions with @Transactional annotation +* Named queries for efficient database operations +* Automatic schema generation and data loading +* Derby embedded database for simplified deployment + +==== In-Memory Implementation + +The in-memory implementation uses thread-safe collections for fast data access: + +[source,java] +---- +@ApplicationScoped +@InMemory +public class ProductInMemoryRepository implements ProductRepositoryInterface { + + // Thread-safe storage using ConcurrentHashMap + private final Map productsMap = new ConcurrentHashMap<>(); + private final AtomicLong idGenerator = new AtomicLong(1); + + @Override + public List findAllProducts() { + return new ArrayList<>(productsMap.values()); + } + + @Override + public Product createProduct(Product product) { + if (product.getId() == null) { + product.setId(idGenerator.getAndIncrement()); + } + productsMap.put(product.getId(), product); + return product; + } + // ... other in-memory operations +} +---- + +*Key Features of In-Memory Implementation:* +* Fast in-memory access without database I/O +* Thread-safe operations using ConcurrentHashMap and AtomicLong +* No external dependencies or database configuration +* Suitable for development, testing, and stateless deployments +* Data is lost on application restart (not persistent) + +=== Database Configuration and Setup + +==== Derby Database Configuration + +The Derby database is automatically configured through the Liberty Maven plugin and server.xml: + +*Maven Dependencies and Plugin Configuration:* +[source,xml] +---- + + + + org.apache.derby + derby + 10.16.1.1 + + + + org.apache.derby + derbyshared + 10.16.1.1 + + + + org.apache.derby + derbytools + 10.16.1.1 + + + + + io.openliberty.tools + liberty-maven-plugin + + mpServer + + ${project.build.directory}/liberty/wlp/usr/servers/mpServer/derby + + org.apache.derby + derby + + + org.apache.derby + derbyshared + + + org.apache.derby + derbytools + + + + +---- + +*Server.xml Configuration:* +[source,xml] +---- + + + + + + + + + + + + + + +---- + +*JPA Configuration (persistence.xml):* +[source,xml] +---- + + jdbc/catalogDB + io.microprofile.tutorial.store.product.entity.Product + + + + + + + + + + + + +---- + +==== Implementation Comparison + +[cols="1,1,1", options="header"] +|=== +| Feature | JPA/Derby Implementation | In-Memory Implementation +| Data Persistence | Survives application restarts | Lost on restart +| Performance | Database I/O overhead | Fastest access (memory) +| Configuration | Requires datasource setup | No configuration needed +| Dependencies | Derby JARs, JPA provider | None (Java built-ins) +| Threading | JPA managed transactions | ConcurrentHashMap + AtomicLong +| Development Setup | Database initialization | Immediate startup +| Production Use | Recommended for production | Development/testing only +| Scalability | Database connection limits | Memory limitations +| Data Integrity | ACID transactions | Thread-safe operations +| Error Handling | Database exceptions | Simple validation +|=== + +[source,java] +---- +@ApplicationScoped +public class ProductRepository { + // In-memory storage using ConcurrentHashMap for thread safety + private final Map productsMap = new ConcurrentHashMap<>(); + + // ID generator + private final AtomicLong idGenerator = new AtomicLong(1); + + // CRUD operations... +} +---- + +==== Atomic ID Generation with AtomicLong + +The repository uses `java.util.concurrent.atomic.AtomicLong` for thread-safe ID generation: + +[source,java] +---- +// ID generation in createProduct method +if (product.getId() == null) { + product.setId(idGenerator.getAndIncrement()); +} +---- + +`AtomicLong` provides several key benefits: + +* *Thread Safety*: Guarantees atomic operations without explicit locking +* *Performance*: Uses efficient compare-and-swap (CAS) operations instead of locks +* *Consistency*: Ensures unique, sequential IDs even under concurrent access +* *No Synchronization*: Avoids the overhead of synchronized blocks + +===== Advanced AtomicLong Operations + +The ProductRepository implements an advanced pattern for handling both system-generated and client-provided IDs: + +[source,java] +---- +public Product createProduct(Product product) { + // Generate ID if not provided + if (product.getId() == null) { + product.setId(idGenerator.getAndIncrement()); + } else { + // Update idGenerator if the provided ID is greater than current + long nextId = product.getId() + 1; + while (true) { + long currentId = idGenerator.get(); + if (nextId <= currentId || idGenerator.compareAndSet(currentId, nextId)) { + break; + } + } + } + + productsMap.put(product.getId(), product); + return product; +} +---- + +This implementation demonstrates several key AtomicLong patterns: + +1. *Initialization*: `AtomicLong` is initialized with a starting value of 1 to avoid using 0 as a valid ID +2. *getAndIncrement*: Atomically returns the current value and increments it in one operation +3. *compareAndSet*: Safely updates the ID generator if a client provides a higher ID value, preventing ID collisions +4. *Retry Logic*: Uses a spinlock pattern for handling concurrent updates to the AtomicLong when needed + +The initialization of the idGenerator with a specific starting value ensures the IDs begin at a predictable value: + +[source,java] +---- +private final AtomicLong idGenerator = new AtomicLong(1); // Start IDs at 1 +---- + +This approach ensures that each product receives a unique ID without risk of duplicate IDs in a concurrent environment. + +Key benefits of this in-memory persistence approach: + +* *Simplicity*: No need for database configuration or ORM mapping +* *Performance*: Fast in-memory access without network or disk I/O +* *Thread Safety*: ConcurrentHashMap provides thread-safe operations without blocking +* *Scalability*: Suitable for containerized deployments + +==== Thread Safety Implementation Details + +The implementation ensures thread safety through multiple mechanisms: + +1. *ConcurrentHashMap*: Uses lock striping to allow concurrent reads and thread-safe writes +2. *AtomicLong*: Provides atomic operations for ID generation +3. *Immutable Returns*: Returns new collections rather than internal references: ++ +[source,java] +---- +// Returns a copy of the collection to prevent concurrent modification issues +public List findAllProducts() { + return new ArrayList<>(productsMap.values()); +} +---- + +4. *Atomic Operations*: Uses atomic map operations like `putIfAbsent` and `compute` where appropriate + +NOTE: This implementation is suitable for development, testing, and scenarios where persistence across restarts is not required. + +== Architecture + +The application follows a layered architecture pattern: + +* *REST Layer* (`ProductResource`) - Handles HTTP requests and responses +* *Service Layer* (`ProductService`) - Contains business logic +* *Repository Layer* (`ProductRepository`) - Manages data access with in-memory storage +* *Model Layer* (`Product`) - Represents the business entities + +== Development Workflow + +=== Running Locally + +To run the application in development mode: + +[source,bash] +---- +mvn clean liberty:dev +---- + +This starts the server in development mode, which: + +* Automatically deploys your code changes +* Provides hot reload capability +* Enables a debugger on port 7777 + +== Project Structure + +[source] +---- +catalog/ +├── src/ +│ ├── main/ +│ │ ├── java/ +│ │ │ └── io/microprofile/tutorial/store/ +│ │ │ └── product/ +│ │ │ ├── entity/ # Domain entities +│ │ │ ├── resource/ # REST resources +│ │ │ └── ProductRestApplication.java +│ │ ├── liberty/ +│ │ │ └── config/ +│ │ │ └── server.xml # Liberty server configuration +│ │ ├── resources/ +│ │ │ └── META-INF/ +│ │ │ └── microprofile-config.properties +│ │ └── webapp/ # Web resources +│ │ ├── index.html # Landing page with API documentation +│ │ └── WEB-INF/ +│ │ └── web.xml # Web application configuration +│ └── test/ # Test classes +└── pom.xml # Maven build file +---- + +== Getting Started + +=== Prerequisites + +* JDK 17+ +* Maven 3.8+ + +=== Building and Running + +To build and run the application: + +[source,bash] +---- +# Clone the repository +git clone https://github.com/yourusername/liberty-rest-app.git +cd code/catalog + +# Build the application +mvn clean package + +# Run the application +mvn liberty:run +---- + +=== Testing the Application + +==== Testing MicroProfile Features + +[source,bash] +---- +# OpenAPI documentation +curl -X GET http://localhost:5050/openapi +---- + + +To view the Swagger UI, open the following URL in your browser: +http://localhost:5050/openapi/ui + +== Server Configuration + +The application uses the following Liberty server configuration: + +[source,xml] +---- + + + jakartaEE-10.0 + microProfile-6.1 + restfulWS + jsonp + jsonb + cdi + mpConfig + mpOpenAPI + + + + + +---- + +== Development + +=== Adding a New Endpoint + +To add a new endpoint: + +1. Create a new method in the `ProductResource` class +2. Add appropriate Jakarta Restful Web Service annotations +3. Add OpenAPI annotations for documentation +4. Implement the business logic + +Example: + +[source,java] +---- +@GET +@Path("/search") +@Produces(MediaType.APPLICATION_JSON) +@Operation(summary = "Search products", description = "Search products by name") +@APIResponses({ + @APIResponse(responseCode = "200", description = "Products matching search criteria") +}) +public Response searchProducts(@QueryParam("name") String name) { + List matchingProducts = products.stream() + .filter(p -> p.getName().toLowerCase().contains(name.toLowerCase())) + .collect(Collectors.toList()); + return Response.ok(matchingProducts).build(); +} +---- + +=== Performance Considerations + +The in-memory data store provides excellent performance for read operations, but there are important considerations: + +* *Memory Usage*: Large data sets may consume significant memory +* *Persistence*: Data is lost when the application restarts +* *Scalability*: In a multi-instance deployment, each instance will have its own data store + +For production scenarios requiring data persistence, consider: + +1. Adding a database layer (PostgreSQL, MongoDB, etc.) +2. Implementing a distributed cache (Hazelcast, Redis, etc.) +3. Adding data synchronization between instances + +=== Concurrency Implementation Details + +==== AtomicLong vs Synchronized Counter + +The repository uses `AtomicLong` rather than traditional synchronized counters: + +[cols="1,1", options="header"] +|=== +| Traditional Approach | AtomicLong Approach +| `private long counter = 0;` | `private final AtomicLong idGenerator = new AtomicLong(1);` +| `synchronized long getNextId() { return ++counter; }` | `long nextId = idGenerator.getAndIncrement();` +| Locks entire method | Lock-free operation +| Subject to contention | Uses CPU compare-and-swap +| Performance degrades with multiple threads | Maintains performance under concurrency +|=== + +==== AtomicLong vs Other Concurrency Options + +[cols="1,1,1,1", options="header"] +|=== +| Feature | AtomicLong | Synchronized | java.util.concurrent.locks.Lock +| Type | Non-blocking | Intrinsic lock | Explicit lock +| Granularity | Single variable | Method/block | Customizable +| Performance under contention | High | Lower | Medium +| Visibility guarantee | Yes | Yes | Yes +| Atomicity guarantee | Yes | Yes | Yes +| Fairness policy | No | No | Optional +| Try/timeout support | Yes (compareAndSet) | No | Yes +| Multiple operations atomicity | Limited | Yes | Yes +| Implementation complexity | Simple | Simple | Complex +|=== + +===== When to Choose AtomicLong + +* *High-Contention Scenarios*: When many threads need to access/modify a counter +* *Single Variable Operations*: When only one variable needs atomic operations +* *Performance-Critical Code*: When minimizing lock contention is essential +* *Read-Heavy Workloads*: When reads significantly outnumber writes + +For this in-memory product repository, AtomicLong provides an optimal balance of safety and performance. + +==== Implementation in createProduct Method + +The ID generation logic handles both automatic and manual ID assignment: + +[source,java] +---- +public Product createProduct(Product product) { + // Generate ID if not provided + if (product.getId() == null) { + product.setId(idGenerator.getAndIncrement()); + } else { + // Update idGenerator if the provided ID is greater than current + long nextId = product.getId() + 1; + while (true) { + long currentId = idGenerator.get(); + if (nextId <= currentId || idGenerator.compareAndSet(currentId, nextId)) { + break; + } + } + } + + productsMap.put(product.getId(), product); + return product; +} +---- + +This implementation ensures ID integrity while supporting both system-generated and client-provided IDs. + +This enables scanning of OpenAPI annotations in the application. + +== Troubleshooting + +=== Common Issues + +* *OpenAPI documentation not available*: Make sure `mp.openapi.scan=true` is set in the properties file +* *Concurrent modification exceptions*: Ensure proper use of thread-safe collections and operations +* *Service always in maintenance mode*: Check the `product.maintenanceMode` property in `microprofile-config.properties` +* *API returning 503 responses*: The service is likely in maintenance mode; set `product.maintenanceMode=false` in configuration +* *OpenAPI documentation not available*: Make sure `mp.openapi.scan=true` is set in the properties file +* *Concurrent modification exceptions*: Ensure proper use of thread-safe collections and operations + +=== Thread Safety Troubleshooting + +If experiencing concurrency issues: + +1. *Verify AtomicLong Usage*: Ensure all ID generation uses `AtomicLong.getAndIncrement()` instead of manual increment +2. *Check Collection Returns*: Always return copies of collections, not direct references: ++ +[source,java] +---- +public List findAllProducts() { + return new ArrayList<>(productsMap.values()); // Correct: returns a new copy +} +---- + +3. *Use ConcurrentHashMap Methods*: Prefer atomic methods like `compute`, `computeIfAbsent`, or `computeIfPresent` for complex operations +4. *Avoid Iteration + Modification*: Don't modify the map while iterating over it + +=== Understanding AtomicLong Internals + +If you need to debug issues with AtomicLong, understanding its internal mechanisms is helpful: + +==== Compare-And-Swap Operation + +AtomicLong relies on hardware-level atomic instructions, specifically Compare-And-Swap (CAS): + +[source,text] +---- +function CAS(address, expected, new): + atomically: + if memory[address] == expected: + memory[address] = new + return true + else: + return false +---- + +The implementation of `getAndIncrement()` uses this mechanism: + +[source,java] +---- +// Simplified implementation of getAndIncrement +public long getAndIncrement() { + while (true) { + long current = get(); + long next = current + 1; + if (compareAndSet(current, next)) + return current; + } +} +---- + +==== Memory Ordering and Visibility + +AtomicLong ensures that memory visibility follows the Java Memory Model: + +* All writes to the AtomicLong by one thread are visible to reads from other threads +* Memory barriers are established when performing atomic operations +* Volatile semantics are guaranteed without using the volatile keyword + +==== Diagnosing AtomicLong Issues + +1. *Unexpected ID Values*: Check for manual ID assignment bypassing the AtomicLong +2. *Duplicate IDs*: Verify the initialization value and ensure all ID assignments go through AtomicLong +3. *Performance Issues*: Look for excessive contention (many threads updating simultaneously) + +=== Logs + +Server logs can be found at: + +[source] +---- +target/liberty/wlp/usr/servers/defaultServer/logs/ +---- + +== Resources + +* https://microprofile.io/[MicroProfile] \ No newline at end of file diff --git a/code/chapter04/catalog/pom.xml b/code/chapter04/catalog/pom.xml new file mode 100644 index 0000000..1951188 --- /dev/null +++ b/code/chapter04/catalog/pom.xml @@ -0,0 +1,170 @@ + + + + 4.0.0 + + io.microprofile.tutorial + catalog + 1.0-SNAPSHOT + war + + + + + 17 + 17 + + UTF-8 + UTF-8 + + + 5050 + 5051 + + catalog + + + + + + + org.projectlombok + lombok + 1.18.26 + provided + + + + + jakarta.platform + jakarta.jakartaee-api + 10.0.0 + provided + + + + + org.eclipse.microprofile + microprofile + 6.1 + pom + provided + + + + + + org.junit.jupiter + junit-jupiter + 5.10.0 + test + + + + + org.jboss.resteasy + resteasy-client + 6.2.12.Final + test + + + org.jboss.resteasy + resteasy-json-binding-provider + 6.2.12.Final + test + + + org.glassfish + jakarta.json + 2.0.1 + test + + + + + org.mockito + mockito-core + 5.3.1 + test + + + org.mockito + mockito-junit-jupiter + 5.3.1 + test + + + + + org.apache.derby + derby + 10.16.1.1 + provided + + + org.apache.derby + derbyshared + 10.16.1.1 + provided + + + org.apache.derby + derbytools + 10.16.1.1 + provided + + + + + ${project.artifactId} + + + + io.openliberty.tools + liberty-maven-plugin + 3.11.2 + + + ${project.build.directory}/liberty/wlp/usr/shared/resources + + org.apache.derby + derby + + + org.apache.derby + derbyshared + + + org.apache.derby + derbytools + + + + + + + org.apache.maven.plugins + maven-war-plugin + 3.4.0 + + + + org.apache.maven.plugins + maven-surefire-plugin + 3.5.3 + + + + org.apache.maven.plugins + maven-failsafe-plugin + 3.5.3 + + + ${backend.service.http.port} + + + + + + \ No newline at end of file diff --git a/code/chapter04/catalog/src/main/java/io/microprofile/tutorial/store/product/ProductRestApplication.java b/code/chapter04/catalog/src/main/java/io/microprofile/tutorial/store/product/ProductRestApplication.java new file mode 100644 index 0000000..9759e1f --- /dev/null +++ b/code/chapter04/catalog/src/main/java/io/microprofile/tutorial/store/product/ProductRestApplication.java @@ -0,0 +1,9 @@ +package io.microprofile.tutorial.store.product; + +import jakarta.ws.rs.ApplicationPath; +import jakarta.ws.rs.core.Application; + +@ApplicationPath("/api") +public class ProductRestApplication extends Application { + // No additional configuration is needed here +} \ No newline at end of file diff --git a/code/chapter04/catalog/src/main/java/io/microprofile/tutorial/store/product/entity/Product.java b/code/chapter04/catalog/src/main/java/io/microprofile/tutorial/store/product/entity/Product.java new file mode 100644 index 0000000..fdba9ef --- /dev/null +++ b/code/chapter04/catalog/src/main/java/io/microprofile/tutorial/store/product/entity/Product.java @@ -0,0 +1,113 @@ +package io.microprofile.tutorial.store.product.entity; + +import jakarta.persistence.*; +import jakarta.validation.constraints.*; +import java.math.BigDecimal; + +/** + * Product entity representing a product in the catalog. + * This entity is mapped to the PRODUCTS table in the database. + */ +@Entity +@Table(name = "PRODUCTS") +@NamedQueries({ + @NamedQuery(name = "Product.findAll", query = "SELECT p FROM Product p"), + @NamedQuery(name = "Product.findById", query = "SELECT p FROM Product p WHERE p.id = :id") +}) +public class Product { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "ID") + private Long id; + + @NotNull(message = "Product name cannot be null") + @NotBlank(message = "Product name cannot be blank") + @Size(min = 1, max = 100, message = "Product name must be between 1 and 100 characters") + @Column(name = "NAME", nullable = false, length = 100) + private String name; + + @Size(max = 500, message = "Product description cannot exceed 500 characters") + @Column(name = "DESCRIPTION", length = 500) + private String description; + + @NotNull(message = "Product price cannot be null") + @DecimalMin(value = "0.0", inclusive = false, message = "Product price must be greater than 0") + @Column(name = "PRICE", nullable = false, precision = 10, scale = 2) + private Double price; + + // Default constructor + public Product() { + } + + // Constructor with all fields + public Product(Long id, String name, String description, Double price) { + this.id = id; + this.name = name; + this.description = description; + this.price = price; + } + + // Constructor without ID (for new entities) + public Product(String name, String description, Double price) { + this.name = name; + this.description = description; + this.price = price; + } + + // Getters and setters + public Long getId() { + return id; + } + + public void setId(Long id) { + this.id = id; + } + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + + public String getDescription() { + return description; + } + + public void setDescription(String description) { + this.description = description; + } + + public Double getPrice() { + return price; + } + + public void setPrice(Double price) { + this.price = price; + } + + @Override + public String toString() { + return "Product{" + + "id=" + id + + ", name='" + name + '\'' + + ", description='" + description + '\'' + + ", price=" + price + + '}'; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (!(o instanceof Product)) return false; + Product product = (Product) o; + return id != null && id.equals(product.id); + } + + @Override + public int hashCode() { + return getClass().hashCode(); + } +} diff --git a/code/chapter04/catalog/src/main/java/io/microprofile/tutorial/store/product/repository/InMemory.java b/code/chapter04/catalog/src/main/java/io/microprofile/tutorial/store/product/repository/InMemory.java new file mode 100644 index 0000000..e49833e --- /dev/null +++ b/code/chapter04/catalog/src/main/java/io/microprofile/tutorial/store/product/repository/InMemory.java @@ -0,0 +1,16 @@ +package io.microprofile.tutorial.store.product.repository; + +import jakarta.inject.Qualifier; +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** + * CDI qualifier for in-memory repository implementation. + */ +@Qualifier +@Retention(RetentionPolicy.RUNTIME) +@Target({ElementType.FIELD, ElementType.METHOD, ElementType.TYPE, ElementType.PARAMETER}) +public @interface InMemory { +} \ No newline at end of file diff --git a/code/chapter04/catalog/src/main/java/io/microprofile/tutorial/store/product/repository/JPA.java b/code/chapter04/catalog/src/main/java/io/microprofile/tutorial/store/product/repository/JPA.java new file mode 100644 index 0000000..fd4a6bd --- /dev/null +++ b/code/chapter04/catalog/src/main/java/io/microprofile/tutorial/store/product/repository/JPA.java @@ -0,0 +1,16 @@ +package io.microprofile.tutorial.store.product.repository; + +import jakarta.inject.Qualifier; +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** + * CDI qualifier for JPA repository implementation. + */ +@Qualifier +@Retention(RetentionPolicy.RUNTIME) +@Target({ElementType.FIELD, ElementType.METHOD, ElementType.TYPE, ElementType.PARAMETER}) +public @interface JPA { +} diff --git a/code/chapter04/catalog/src/main/java/io/microprofile/tutorial/store/product/repository/ProductInMemoryRepository.java b/code/chapter04/catalog/src/main/java/io/microprofile/tutorial/store/product/repository/ProductInMemoryRepository.java new file mode 100644 index 0000000..a15ef9a --- /dev/null +++ b/code/chapter04/catalog/src/main/java/io/microprofile/tutorial/store/product/repository/ProductInMemoryRepository.java @@ -0,0 +1,140 @@ +package io.microprofile.tutorial.store.product.repository; + +import io.microprofile.tutorial.store.product.entity.Product; +import jakarta.enterprise.context.ApplicationScoped; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.atomic.AtomicLong; +import java.util.logging.Logger; +import java.util.stream.Collectors; + +/** + * In-memory repository implementation for Product entity. + * Provides in-memory persistence operations using ConcurrentHashMap. + * This is used as a fallback when JPA is not available. + */ +@ApplicationScoped +@InMemory +public class ProductInMemoryRepository implements ProductRepositoryInterface { + + private static final Logger LOGGER = Logger.getLogger(ProductInMemoryRepository.class.getName()); + + // In-memory storage using ConcurrentHashMap for thread safety + private final Map productsMap = new ConcurrentHashMap<>(); + + // ID generator + private final AtomicLong idGenerator = new AtomicLong(1); + + /** + * Constructor with sample data initialization. + */ + public ProductInMemoryRepository() { + // Initialize with sample products using the Double constructor for compatibility + createProduct(new Product(null, "iPhone", "Apple iPhone 15", 999.99)); + createProduct(new Product(null, "MacBook", "Apple MacBook Air", 1299.0)); + createProduct(new Product(null, "iPad", "Apple iPad Pro", 799.0)); + LOGGER.info("ProductInMemoryRepository initialized with sample products"); + } + + /** + * Retrieves all products. + * + * @return List of all products + */ + public List findAllProducts() { + LOGGER.fine("Repository: Finding all products"); + return new ArrayList<>(productsMap.values()); + } + + /** + * Retrieves a product by ID. + * + * @param id Product ID + * @return The product or null if not found + */ + public Product findProductById(Long id) { + LOGGER.fine("Repository: Finding product with ID: " + id); + return productsMap.get(id); + } + + /** + * Creates a new product. + * + * @param product Product data to create + * @return The created product with ID + */ + public Product createProduct(Product product) { + // Generate ID if not provided + if (product.getId() == null) { + product.setId(idGenerator.getAndIncrement()); + } else { + // Update idGenerator if the provided ID is greater than current + long nextId = product.getId() + 1; + while (true) { + long currentId = idGenerator.get(); + if (nextId <= currentId || idGenerator.compareAndSet(currentId, nextId)) { + break; + } + } + } + + LOGGER.fine("Repository: Creating product with ID: " + product.getId()); + productsMap.put(product.getId(), product); + return product; + } + + /** + * Updates an existing product. + * + * @param product Updated product data + * @return The updated product or null if not found + */ + public Product updateProduct(Product product) { + Long id = product.getId(); + if (id != null && productsMap.containsKey(id)) { + LOGGER.fine("Repository: Updating product with ID: " + id); + productsMap.put(id, product); + return product; + } + LOGGER.warning("Repository: Product not found for update, ID: " + id); + return null; + } + + /** + * Deletes a product by ID. + * + * @param id ID of the product to delete + * @return true if deleted, false if not found + */ + public boolean deleteProduct(Long id) { + if (productsMap.containsKey(id)) { + LOGGER.fine("Repository: Deleting product with ID: " + id); + productsMap.remove(id); + return true; + } + LOGGER.warning("Repository: Product not found for deletion, ID: " + id); + return false; + } + + /** + * Searches for products by criteria. + * + * @param name Product name (optional) + * @param description Product description (optional) + * @param minPrice Minimum price (optional) + * @param maxPrice Maximum price (optional) + * @return List of matching products + */ + public List searchProducts(String name, String description, Double minPrice, Double maxPrice) { + LOGGER.fine("Repository: Searching for products with criteria"); + + return productsMap.values().stream() + .filter(p -> name == null || p.getName().toLowerCase().contains(name.toLowerCase())) + .filter(p -> description == null || p.getDescription().toLowerCase().contains(description.toLowerCase())) + .filter(p -> minPrice == null || (p.getPrice() != null && p.getPrice().doubleValue() >= minPrice)) + .filter(p -> maxPrice == null || (p.getPrice() != null && p.getPrice().doubleValue() <= maxPrice)) + .collect(Collectors.toList()); + } +} \ No newline at end of file diff --git a/code/chapter04/catalog/src/main/java/io/microprofile/tutorial/store/product/repository/ProductJpaRepository.java b/code/chapter04/catalog/src/main/java/io/microprofile/tutorial/store/product/repository/ProductJpaRepository.java new file mode 100644 index 0000000..1bf4343 --- /dev/null +++ b/code/chapter04/catalog/src/main/java/io/microprofile/tutorial/store/product/repository/ProductJpaRepository.java @@ -0,0 +1,150 @@ +package io.microprofile.tutorial.store.product.repository; + +import io.microprofile.tutorial.store.product.entity.Product; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.persistence.EntityManager; +import jakarta.persistence.PersistenceContext; +import jakarta.persistence.TypedQuery; +import jakarta.transaction.Transactional; +import java.math.BigDecimal; +import java.util.List; +import java.util.logging.Logger; + +/** + * JPA-based repository implementation for Product entity. + * Provides database persistence operations using Apache Derby. + */ +@ApplicationScoped +@JPA +@Transactional +public class ProductJpaRepository implements ProductRepositoryInterface { + + private static final Logger LOGGER = Logger.getLogger(ProductJpaRepository.class.getName()); + + @PersistenceContext(unitName = "catalogPU") + private EntityManager entityManager; + + @Override + public List findAllProducts() { + LOGGER.fine("JPA Repository: Finding all products"); + TypedQuery query = entityManager.createNamedQuery("Product.findAll", Product.class); + return query.getResultList(); + } + + @Override + public Product findProductById(Long id) { + LOGGER.fine("JPA Repository: Finding product with ID: " + id); + if (id == null) { + return null; + } + return entityManager.find(Product.class, id); + } + + @Override + public Product createProduct(Product product) { + LOGGER.info("JPA Repository: Creating new product: " + product); + if (product == null) { + throw new IllegalArgumentException("Product cannot be null"); + } + + // Ensure ID is null for new products + product.setId(null); + + entityManager.persist(product); + entityManager.flush(); // Force the insert to get the generated ID + + LOGGER.info("JPA Repository: Created product with ID: " + product.getId()); + return product; + } + + @Override + public Product updateProduct(Product product) { + LOGGER.info("JPA Repository: Updating product: " + product); + if (product == null || product.getId() == null) { + throw new IllegalArgumentException("Product and ID cannot be null for update"); + } + + Product existingProduct = entityManager.find(Product.class, product.getId()); + if (existingProduct == null) { + LOGGER.warning("JPA Repository: Product not found for update: " + product.getId()); + return null; + } + + // Update fields + existingProduct.setName(product.getName()); + existingProduct.setDescription(product.getDescription()); + existingProduct.setPrice(product.getPrice()); + + Product updatedProduct = entityManager.merge(existingProduct); + entityManager.flush(); + + LOGGER.info("JPA Repository: Updated product with ID: " + updatedProduct.getId()); + return updatedProduct; + } + + @Override + public boolean deleteProduct(Long id) { + LOGGER.info("JPA Repository: Deleting product with ID: " + id); + if (id == null) { + return false; + } + + Product product = entityManager.find(Product.class, id); + if (product != null) { + entityManager.remove(product); + entityManager.flush(); + LOGGER.info("JPA Repository: Deleted product with ID: " + id); + return true; + } + + LOGGER.warning("JPA Repository: Product not found for deletion: " + id); + return false; + } + + @Override + public List searchProducts(String name, String description, Double minPrice, Double maxPrice) { + LOGGER.info("JPA Repository: Searching for products with criteria"); + + StringBuilder jpql = new StringBuilder("SELECT p FROM Product p WHERE 1=1"); + + if (name != null && !name.trim().isEmpty()) { + jpql.append(" AND LOWER(p.name) LIKE :namePattern"); + } + + if (description != null && !description.trim().isEmpty()) { + jpql.append(" AND LOWER(p.description) LIKE :descriptionPattern"); + } + + if (minPrice != null) { + jpql.append(" AND p.price >= :minPrice"); + } + + if (maxPrice != null) { + jpql.append(" AND p.price <= :maxPrice"); + } + + TypedQuery query = entityManager.createQuery(jpql.toString(), Product.class); + + // Set parameters only if they are provided + if (name != null && !name.trim().isEmpty()) { + query.setParameter("namePattern", "%" + name.toLowerCase() + "%"); + } + + if (description != null && !description.trim().isEmpty()) { + query.setParameter("descriptionPattern", "%" + description.toLowerCase() + "%"); + } + + if (minPrice != null) { + query.setParameter("minPrice", BigDecimal.valueOf(minPrice)); + } + + if (maxPrice != null) { + query.setParameter("maxPrice", BigDecimal.valueOf(maxPrice)); + } + + List results = query.getResultList(); + LOGGER.info("JPA Repository: Found " + results.size() + " products matching criteria"); + + return results; + } +} diff --git a/code/chapter04/catalog/src/main/java/io/microprofile/tutorial/store/product/repository/ProductRepositoryInterface.java b/code/chapter04/catalog/src/main/java/io/microprofile/tutorial/store/product/repository/ProductRepositoryInterface.java new file mode 100644 index 0000000..4b981b2 --- /dev/null +++ b/code/chapter04/catalog/src/main/java/io/microprofile/tutorial/store/product/repository/ProductRepositoryInterface.java @@ -0,0 +1,61 @@ +package io.microprofile.tutorial.store.product.repository; + +import io.microprofile.tutorial.store.product.entity.Product; +import java.util.List; + +/** + * Repository interface for Product entity operations. + * Defines the contract for product data access. + */ +public interface ProductRepositoryInterface { + + /** + * Retrieves all products. + * + * @return List of all products + */ + List findAllProducts(); + + /** + * Retrieves a product by ID. + * + * @param id Product ID + * @return The product or null if not found + */ + Product findProductById(Long id); + + /** + * Creates a new product. + * + * @param product Product data to create + * @return The created product with ID + */ + Product createProduct(Product product); + + /** + * Updates an existing product. + * + * @param product Updated product data + * @return The updated product + */ + Product updateProduct(Product product); + + /** + * Deletes a product by ID. + * + * @param id ID of the product to delete + * @return true if deleted, false if not found + */ + boolean deleteProduct(Long id); + + /** + * Searches for products by criteria. + * + * @param name Product name (optional) + * @param description Product description (optional) + * @param minPrice Minimum price (optional) + * @param maxPrice Maximum price (optional) + * @return List of matching products + */ + List searchProducts(String name, String description, Double minPrice, Double maxPrice); +} diff --git a/code/chapter04/catalog/src/main/java/io/microprofile/tutorial/store/product/repository/RepositoryType.java b/code/chapter04/catalog/src/main/java/io/microprofile/tutorial/store/product/repository/RepositoryType.java new file mode 100644 index 0000000..e2bf8c9 --- /dev/null +++ b/code/chapter04/catalog/src/main/java/io/microprofile/tutorial/store/product/repository/RepositoryType.java @@ -0,0 +1,29 @@ +package io.microprofile.tutorial.store.product.repository; + +import jakarta.inject.Qualifier; +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** + * CDI qualifier to distinguish between different repository implementations. + */ +@Qualifier +@Retention(RetentionPolicy.RUNTIME) +@Target({ElementType.TYPE, ElementType.METHOD, ElementType.FIELD, ElementType.PARAMETER}) +public @interface RepositoryType { + + /** + * Repository implementation type. + */ + Type value(); + + /** + * Enumeration of repository types. + */ + enum Type { + JPA, + IN_MEMORY + } +} diff --git a/code/chapter04/catalog/src/main/java/io/microprofile/tutorial/store/product/resource/ProductResource.java b/code/chapter04/catalog/src/main/java/io/microprofile/tutorial/store/product/resource/ProductResource.java new file mode 100644 index 0000000..b10f589 --- /dev/null +++ b/code/chapter04/catalog/src/main/java/io/microprofile/tutorial/store/product/resource/ProductResource.java @@ -0,0 +1,162 @@ +package io.microprofile.tutorial.store.product.resource; + +import java.util.List; +import java.util.logging.Level; +import java.util.logging.Logger; + +import org.eclipse.microprofile.openapi.annotations.Operation; +import org.eclipse.microprofile.openapi.annotations.media.Content; +import org.eclipse.microprofile.openapi.annotations.media.Schema; +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.product.entity.Product; +import io.microprofile.tutorial.store.product.service.ProductService; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.inject.Inject; +import jakarta.ws.rs.Consumes; +import jakarta.ws.rs.DELETE; +import jakarta.ws.rs.GET; +import jakarta.ws.rs.POST; +import jakarta.ws.rs.PUT; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.PathParam; +import jakarta.ws.rs.Produces; +import jakarta.ws.rs.QueryParam; +import jakarta.ws.rs.core.MediaType; +import jakarta.ws.rs.core.Response; + +@ApplicationScoped +@Path("/products") +@Tag(name = "Product Resource", description = "CRUD operations for products") +public class ProductResource { + + private static final Logger LOGGER = Logger.getLogger(ProductResource.class.getName()); + + @Inject + private ProductService productService; + + @GET + @Produces(MediaType.APPLICATION_JSON) + @Operation(summary = "List all products", description = "Retrieves a list of all products") + @APIResponses({ + @APIResponse( + responseCode = "200", + description = "Successful, list of products found", + content = @Content(mediaType = "application/json", + schema = @Schema(implementation = Product.class))), + @APIResponse( + responseCode = "400", + description = "Unsuccessful, no products found", + content = @Content(mediaType = "application/json") + ), + @APIResponse( + responseCode = "503", + description = "Service is under maintenance", + content = @Content(mediaType = "application/json") + ) + }) + public Response getAllProducts() { + LOGGER.log(Level.INFO, "REST: Fetching all products"); + List products = productService.findAllProducts(); + + if (products != null && !products.isEmpty()) { + return Response + .status(Response.Status.OK) + .entity(products).build(); + } else { + return Response + .status(Response.Status.NOT_FOUND) + .entity("No products found") + .build(); + } + } + + @GET + @Path("/{id}") + @Produces(MediaType.APPLICATION_JSON) + @Operation(summary = "Get product by ID", description = "Returns a product by its ID") + @APIResponses({ + @APIResponse(responseCode = "200", description = "Product found", content = @Content(mediaType = "application/json", schema = @Schema(implementation = Product.class))), + @APIResponse(responseCode = "404", description = "Product not found"), + @APIResponse(responseCode = "503", description = "Service is under maintenance") + }) + public Response getProductById(@PathParam("id") Long id) { + LOGGER.log(Level.INFO, "REST: Fetching product with id: {0}", id); + + Product product = productService.findProductById(id); + if (product != null) { + return Response.ok(product).build(); + } else { + return Response.status(Response.Status.NOT_FOUND).build(); + } + } + + @POST + @Consumes(MediaType.APPLICATION_JSON) + @Produces(MediaType.APPLICATION_JSON) + @Operation(summary = "Create a new product", description = "Creates a new product") + @APIResponses({ + @APIResponse(responseCode = "201", description = "Product created", content = @Content(mediaType = "application/json", schema = @Schema(implementation = Product.class))) + }) + public Response createProduct(Product product) { + LOGGER.info("REST: Creating product: " + product); + Product createdProduct = productService.createProduct(product); + return Response.status(Response.Status.CREATED).entity(createdProduct).build(); + } + + @PUT + @Path("/{id}") + @Consumes(MediaType.APPLICATION_JSON) + @Produces(MediaType.APPLICATION_JSON) + @Operation(summary = "Update a product", description = "Updates an existing product by its ID") + @APIResponses({ + @APIResponse(responseCode = "200", description = "Product updated", content = @Content(mediaType = "application/json", schema = @Schema(implementation = Product.class))), + @APIResponse(responseCode = "404", description = "Product not found") + }) + public Response updateProduct(@PathParam("id") Long id, Product updatedProduct) { + LOGGER.info("REST: Updating product with id: " + id); + Product updated = productService.updateProduct(id, updatedProduct); + if (updated != null) { + return Response.ok(updated).build(); + } else { + return Response.status(Response.Status.NOT_FOUND).build(); + } + } + +@DELETE + @Path("/{id}") + @Produces(MediaType.APPLICATION_JSON) + @Operation(summary = "Delete a product", description = "Deletes a product by its ID") + @APIResponses({ + @APIResponse(responseCode = "204", description = "Product deleted"), + @APIResponse(responseCode = "404", description = "Product not found") + }) + public Response deleteProduct(@PathParam("id") Long id) { + LOGGER.info("REST: Deleting product with id: " + id); + boolean deleted = productService.deleteProduct(id); + if (deleted) { + return Response.noContent().build(); + } else { + return Response.status(Response.Status.NOT_FOUND).build(); + } + } + + @GET + @Path("/search") + @Produces(MediaType.APPLICATION_JSON) + @Operation(summary = "Search products", description = "Search products by criteria") + @APIResponses({ + @APIResponse(responseCode = "200", description = "Search results", content = @Content(mediaType = "application/json", schema = @Schema(implementation = Product.class))) + }) + public Response searchProducts( + @QueryParam("name") String name, + @QueryParam("description") String description, + @QueryParam("minPrice") Double minPrice, + @QueryParam("maxPrice") Double maxPrice) { + LOGGER.info("REST: Searching products with criteria"); + List results = productService.searchProducts(name, description, minPrice, maxPrice); + return Response.ok(results).build(); + } +} \ No newline at end of file diff --git a/code/chapter04/catalog/src/main/java/io/microprofile/tutorial/store/product/service/ProductService.java b/code/chapter04/catalog/src/main/java/io/microprofile/tutorial/store/product/service/ProductService.java new file mode 100644 index 0000000..11d9bf7 --- /dev/null +++ b/code/chapter04/catalog/src/main/java/io/microprofile/tutorial/store/product/service/ProductService.java @@ -0,0 +1,99 @@ +package io.microprofile.tutorial.store.product.service; + +import io.microprofile.tutorial.store.product.entity.Product; +import io.microprofile.tutorial.store.product.repository.JPA; +import io.microprofile.tutorial.store.product.repository.ProductRepositoryInterface; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.inject.Inject; +import java.util.List; +import java.util.logging.Logger; + +/** + * Service class for Product operations. + * Contains business logic for product management. + */ +@ApplicationScoped +public class ProductService { + + private static final Logger LOGGER = Logger.getLogger(ProductService.class.getName()); + + @Inject + @JPA + private ProductRepositoryInterface repository; + + /** + * Retrieves all products. + * + * @return List of all products + */ + public List findAllProducts() { + LOGGER.info("Service: Finding all products"); + return repository.findAllProducts(); + } + + /** + * Retrieves a product by ID. + * + * @param id Product ID + * @return The product or null if not found + */ + public Product findProductById(Long id) { + LOGGER.info("Service: Finding product with ID: " + id); + return repository.findProductById(id); + } + + /** + * Creates a new product. + * + * @param product Product data to create + * @return The created product with ID + */ + public Product createProduct(Product product) { + LOGGER.info("Service: Creating new product: " + product); + return repository.createProduct(product); + } + + /** + * Updates an existing product. + * + * @param id ID of the product to update + * @param updatedProduct Updated product data + * @return The updated product or null if not found + */ + public Product updateProduct(Long id, Product updatedProduct) { + LOGGER.info("Service: Updating product with ID: " + id); + + Product existingProduct = repository.findProductById(id); + if (existingProduct != null) { + // Set the ID to ensure correct update + updatedProduct.setId(id); + return repository.updateProduct(updatedProduct); + } + return null; + } + + /** + * Deletes a product by ID. + * + * @param id ID of the product to delete + * @return true if deleted, false if not found + */ + public boolean deleteProduct(Long id) { + LOGGER.info("Service: Deleting product with ID: " + id); + return repository.deleteProduct(id); + } + + /** + * Searches for products by criteria. + * + * @param name Product name (optional) + * @param description Product description (optional) + * @param minPrice Minimum price (optional) + * @param maxPrice Maximum price (optional) + * @return List of matching products + */ + public List searchProducts(String name, String description, Double minPrice, Double maxPrice) { + LOGGER.info("Service: Searching for products with criteria"); + return repository.searchProducts(name, description, minPrice, maxPrice); + } +} diff --git a/code/chapter04/catalog/src/main/resources/META-INF/create-schema.sql b/code/chapter04/catalog/src/main/resources/META-INF/create-schema.sql new file mode 100644 index 0000000..6e72eed --- /dev/null +++ b/code/chapter04/catalog/src/main/resources/META-INF/create-schema.sql @@ -0,0 +1,6 @@ +-- Schema creation script for Product catalog +-- This file is referenced by persistence.xml for schema generation + +-- Note: This is a placeholder file. +-- The actual schema is auto-generated by JPA using @Entity annotations. +-- You can add custom DDL statements here if needed. diff --git a/code/chapter04/catalog/src/main/resources/META-INF/load-data.sql b/code/chapter04/catalog/src/main/resources/META-INF/load-data.sql new file mode 100644 index 0000000..e9fbd9b --- /dev/null +++ b/code/chapter04/catalog/src/main/resources/META-INF/load-data.sql @@ -0,0 +1,8 @@ +INSERT INTO PRODUCTS (NAME, DESCRIPTION, PRICE) VALUES ('iPhone 15', 'Apple iPhone 15 with advanced features', 999.99) +INSERT INTO PRODUCTS (NAME, DESCRIPTION, PRICE) VALUES ('MacBook Air', 'Apple MacBook Air M2 chip', 1299.00) +INSERT INTO PRODUCTS (NAME, DESCRIPTION, PRICE) VALUES ('iPad Pro', 'Apple iPad Pro 12.9-inch', 799.00) +INSERT INTO PRODUCTS (NAME, DESCRIPTION, PRICE) VALUES ('Samsung Galaxy S24', 'Samsung Galaxy S24 Ultra smartphone', 1199.99) +INSERT INTO PRODUCTS (NAME, DESCRIPTION, PRICE) VALUES ('Dell XPS 13', 'Dell XPS 13 laptop with Intel i7', 1099.00) +INSERT INTO PRODUCTS (NAME, DESCRIPTION, PRICE) VALUES ('Sony WH-1000XM4', 'Sony noise-canceling headphones', 299.99) +INSERT INTO PRODUCTS (NAME, DESCRIPTION, PRICE) VALUES ('Nintendo Switch', 'Nintendo Switch gaming console', 299.00) +INSERT INTO PRODUCTS (NAME, DESCRIPTION, PRICE) VALUES ('Google Pixel 8', 'Google Pixel 8 smartphone', 699.99) diff --git a/code/chapter04/catalog/src/main/resources/META-INF/microprofile-config.properties b/code/chapter04/catalog/src/main/resources/META-INF/microprofile-config.properties new file mode 100644 index 0000000..365e471 --- /dev/null +++ b/code/chapter04/catalog/src/main/resources/META-INF/microprofile-config.properties @@ -0,0 +1,2 @@ +# Enable OpenAPI scanning +mp.openapi.scan=true \ No newline at end of file diff --git a/code/chapter04/catalog/src/main/resources/META-INF/persistence.xml b/code/chapter04/catalog/src/main/resources/META-INF/persistence.xml new file mode 100644 index 0000000..df53bc7 --- /dev/null +++ b/code/chapter04/catalog/src/main/resources/META-INF/persistence.xml @@ -0,0 +1,23 @@ + + + + + jdbc/catalogDB + io.microprofile.tutorial.store.product.entity.Product + + + + + + + + + + + + + + diff --git a/code/chapter04/catalog/src/main/test/java/io/microprofile/tutorial/store/product/resource/ProductResourceTest.java b/code/chapter04/catalog/src/main/test/java/io/microprofile/tutorial/store/product/resource/ProductResourceTest.java new file mode 100644 index 0000000..5687649 --- /dev/null +++ b/code/chapter04/catalog/src/main/test/java/io/microprofile/tutorial/store/product/resource/ProductResourceTest.java @@ -0,0 +1,76 @@ +package io.microprofile.tutorial.store.product.resource; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.mockito.Mockito.when; + +import java.util.ArrayList; +import java.util.List; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import jakarta.ws.rs.core.Response; + +import io.microprofile.tutorial.store.product.entity.Product; +import io.microprofile.tutorial.store.product.service.ProductService; + +@ExtendWith(MockitoExtension.class) +public class ProductResourceTest { + @Mock + private ProductService productService; + + @InjectMocks + private ProductResource productResource; + + @BeforeEach + void setUp() { + // Setup is handled by MockitoExtension + } + + @AfterEach + void tearDown() { + // Cleanup is handled by MockitoExtension + } + + @Test + void testGetProducts() { + // Prepare test data + List mockProducts = new ArrayList<>(); + + Product product1 = new Product(); + product1.setId(1L); + product1.setName("iPhone"); + product1.setDescription("Apple iPhone 15"); + product1.setPrice(999.99); + + Product product2 = new Product(); + product2.setId(2L); + product2.setName("MacBook"); + product2.setDescription("Apple MacBook Air"); + product2.setPrice(1299.0); + + mockProducts.add(product1); + mockProducts.add(product2); + + // Mock the service method + when(productService.findAllProducts()).thenReturn(mockProducts); + + // Call the method under test + Response response = productResource.getAllProducts(); + + // Assertions + assertNotNull(response); + assertEquals(Response.Status.OK.getStatusCode(), response.getStatus()); + + @SuppressWarnings("unchecked") + List products = (List) response.getEntity(); + assertNotNull(products); + assertEquals(2, products.size()); + } +} \ No newline at end of file diff --git a/code/chapter04/catalog/src/main/webapp/WEB-INF/web.xml b/code/chapter04/catalog/src/main/webapp/WEB-INF/web.xml new file mode 100644 index 0000000..e5bce4d --- /dev/null +++ b/code/chapter04/catalog/src/main/webapp/WEB-INF/web.xml @@ -0,0 +1,13 @@ + + + + Product Catalog Service + + + index.html + + + diff --git a/code/chapter04/catalog/src/main/webapp/index.html b/code/chapter04/catalog/src/main/webapp/index.html new file mode 100644 index 0000000..fac4a18 --- /dev/null +++ b/code/chapter04/catalog/src/main/webapp/index.html @@ -0,0 +1,497 @@ + + + + + + Product Catalog Service + + + +
+

Product Catalog Service

+

A microservice for managing product information in the e-commerce platform

+
+ +
+
+

Service Overview

+

The Product Catalog Service provides a REST API for managing product information, including:

+
    +
  • Creating new products
  • +
  • Retrieving product details
  • +
  • Updating existing products
  • +
  • Deleting products
  • +
  • Searching for products by various criteria
  • +
+
+

MicroProfile Config Implementation

+

This service implements configurability as per MicroProfile Config standards. Key configuration properties include:

+
    +
  • product.maintenanceMode - Controls whether the service is in maintenance mode (returns 503 responses)
  • +
  • mp.openapi.scan - Enables automatic OpenAPI documentation generation
  • +
+

MicroProfile Config allows these properties to be changed via environment variables, system properties, or configuration files without requiring application redeployment.

+
+
+ +
+

API Endpoints

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
OperationMethodURLDescription
List All ProductsGET/api/productsRetrieves a list of all products
Get Product by IDGET/api/products/{id}Returns a product by its ID
Create ProductPOST/api/productsCreates a new product
Update ProductPUT/api/products/{id}Updates an existing product by its ID
Delete ProductDELETE/api/products/{id}Deletes a product by its ID
Search ProductsGET/api/products/searchSearch products by criteria (name, description, price range)
+
+ +
+

API Documentation

+

The API is documented using MicroProfile OpenAPI. You can access the Swagger UI at:

+

/openapi/ui

+

The OpenAPI definition is available at:

+

/openapi

+
+ +
+

Health Checks

+

The service implements comprehensive MicroProfile Health monitoring with three types of health checks:

+ +

Health Check Endpoints

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
StatusEndpointPurposeDescriptionLink
/healthOverall HealthAggregated status of all health checksView
/health/startedStartup CheckValidates EntityManagerFactory initializationView
/health/readyReadiness CheckTests database connectivity via EntityManagerView
/health/liveLiveness CheckMonitors JVM memory usage (100MB threshold)View
+ +
+

Health Check Implementation Details

+ +

🚀 Startup Health Check

+

Class: ProductServiceStartupCheck

+

Purpose: Verifies that the Jakarta Persistence EntityManagerFactory is properly initialized during application startup.

+

Implementation: Uses @PersistenceUnit to inject EntityManagerFactory and checks if it's not null and open.

+ +

✅ Readiness Health Check

+

Class: ProductServiceHealthCheck

+

Purpose: Ensures the service is ready to handle requests by testing database connectivity.

+

Implementation: Performs a lightweight database query using entityManager.find(Product.class, 1L) to verify the database connection.

+ +

💓 Liveness Health Check

+

Class: ProductServiceLivenessCheck

+

Purpose: Monitors system resources to detect if the application needs to be restarted.

+

Implementation: Analyzes JVM memory usage with a 100MB available memory threshold, providing detailed memory diagnostics.

+
+ +
+

Sample Health Check Response

+

Example response from /health endpoint:

+
{
+  "status": "UP",
+  "checks": [
+    {
+      "name": "ProductServiceStartupCheck",
+      "status": "UP"
+    },
+    {
+      "name": "ProductServiceReadinessCheck", 
+      "status": "UP"
+    },
+    {
+      "name": "systemResourcesLiveness",
+      "status": "UP",
+      "data": {
+        "FreeMemory": 524288000,
+        "MaxMemory": 2147483648,
+        "AllocatedMemory": 1073741824,
+        "UsedMemory": 549453824,
+        "AvailableMemory": 1598029824
+      }
+    }
+  ]
+}
+
+ +
+

Testing Health Checks

+

You can test the health check endpoints using curl commands:

+
# Test overall health
+curl -X GET http://localhost:9080/health
+
+# Test startup health
+curl -X GET http://localhost:9080/health/started
+
+# Test readiness health  
+curl -X GET http://localhost:9080/health/ready
+
+# Test liveness health
+curl -X GET http://localhost:9080/health/live
+ +

Integration with Container Orchestration:

+

For Kubernetes deployments, you can configure probes in your deployment YAML:

+
livenessProbe:
+  httpGet:
+    path: /health/live
+    port: 9080
+  initialDelaySeconds: 30
+  periodSeconds: 10
+
+readinessProbe:
+  httpGet:
+    path: /health/ready
+    port: 9080
+  initialDelaySeconds: 5
+  periodSeconds: 5
+
+startupProbe:
+  httpGet:
+    path: /health/started
+    port: 9080
+  initialDelaySeconds: 10
+  periodSeconds: 10
+  failureThreshold: 30
+
+ +
+

Health Check Benefits

+
    +
  • Container Orchestration: Kubernetes and Docker can use these endpoints for health probes
  • +
  • Load Balancer Integration: Traffic routing based on readiness status
  • +
  • Operational Monitoring: Early detection of system issues
  • +
  • Startup Validation: Ensures all dependencies are initialized before serving traffic
  • +
  • Database Monitoring: Real-time database connectivity verification
  • +
  • Memory Management: Proactive detection of memory pressure
  • +
+
+
+ +
+

Sample Usage

+ +

List All Products

+
GET /api/products
+

Response:

+
[
+  {
+    "id": 1,
+    "name": "Smartphone X",
+    "description": "Latest smartphone with advanced features",
+    "price": 799.99
+  },
+  {
+    "id": 2,
+    "name": "Laptop Pro",
+    "description": "High-performance laptop for professionals",
+    "price": 1299.99
+  }
+]
+ +

Get Product by ID

+
GET /api/products/1
+

Response:

+
{
+  "id": 1,
+  "name": "Smartphone X",
+  "description": "Latest smartphone with advanced features",
+  "price": 799.99
+}
+ +

Create a New Product

+
POST /api/products
+Content-Type: application/json
+
+{
+  "name": "Wireless Earbuds",
+  "description": "Premium wireless earbuds with noise cancellation",
+  "price": 149.99
+}
+

Response:

+
{
+  "id": 3,
+  "name": "Wireless Earbuds",
+  "description": "Premium wireless earbuds with noise cancellation",
+  "price": 149.99
+}
+ +

Update a Product

+
PUT /api/products/3
+Content-Type: application/json
+
+{
+  "name": "Wireless Earbuds Pro",
+  "description": "Premium wireless earbuds with advanced noise cancellation",
+  "price": 179.99
+}
+

Response:

+
{
+  "id": 3,
+  "name": "Wireless Earbuds Pro",
+  "description": "Premium wireless earbuds with advanced noise cancellation",
+  "price": 179.99
+}
+ +

Delete a Product

+
DELETE /api/products/3
+

Response: No content (204)

+ +

Search for Products

+
GET /api/products/search?name=laptop&minPrice=1000&maxPrice=2000
+

Response:

+
[
+  {
+    "id": 2,
+    "name": "Laptop Pro",
+    "description": "High-performance laptop for professionals",
+    "price": 1299.99
+  }
+]
+
+
+ +
+

Product Catalog Service

+

© 2025 - MicroProfile APT Tutorial

+
+ + + + diff --git a/code/chapter04/catalog/test-api.sh b/code/chapter04/catalog/test-api.sh new file mode 100755 index 0000000..1763f30 --- /dev/null +++ b/code/chapter04/catalog/test-api.sh @@ -0,0 +1,103 @@ +#!/bin/bash +# filepath: /workspaces/microprofile-tutorial/code/chapter03/catalog/test-api.sh + +# MicroProfile E-Commerce Store API Test Script +# This script tests all CRUD operations for the Product API + +set -e # Exit on any error + +# Configuration +BASE_URL="http://localhost:5050/catalog/api/products" +CONTENT_TYPE="Content-Type: application/json" + +echo "==================================" +echo "MicroProfile E-Commerce Store API Test" +echo "==================================" +echo "Base URL: $BASE_URL" +echo "==================================" + +# Function to print section headers +print_section() { + echo "" + echo "--- $1 ---" +} + +# Function to pause between operations +pause() { + echo "Press Enter to continue..." + read -r +} + +print_section "1. GET ALL PRODUCTS (Initial State)" +echo "Command: curl -i -X GET $BASE_URL" +curl -i -X GET "$BASE_URL" +echo "" +pause + +print_section "2. GET PRODUCT BY ID (Existing Product)" +echo "Command: curl -i -X GET $BASE_URL/1" +curl -i -X GET "$BASE_URL/1" +echo "" +pause + +print_section "3. CREATE NEW PRODUCT" +echo "Command: curl -i -X POST $BASE_URL -H \"$CONTENT_TYPE\" -d '{\"id\": 3, \"name\": \"AirPods\", \"description\": \"Apple AirPods Pro\", \"price\": 249.99}'" +curl -i -X POST "$BASE_URL" \ + -H "$CONTENT_TYPE" \ + -d '{"id": 3, "name": "AirPods", "description": "Apple AirPods Pro", "price": 249.99}' +echo "" +pause + +print_section "4. GET ALL PRODUCTS (After Creation)" +echo "Command: curl -i -X GET $BASE_URL" +curl -i -X GET "$BASE_URL" +echo "" +pause + +print_section "5. GET NEW PRODUCT BY ID" +echo "Command: curl -i -X GET $BASE_URL/3" +curl -i -X GET "$BASE_URL/3" +echo "" +pause + +print_section "6. UPDATE EXISTING PRODUCT" +echo "Command: curl -i -X PUT $BASE_URL/1 -H \"$CONTENT_TYPE\" -d '{\"id\": 1, \"name\": \"iPhone Pro\", \"description\": \"Apple iPhone 15 Pro\", \"price\": 1199.99}'" +curl -i -X PUT "$BASE_URL/1" \ + -H "$CONTENT_TYPE" \ + -d '{"id": 1, "name": "iPhone Pro", "description": "Apple iPhone 15 Pro", "price": 1199.99}' +echo "" +pause + +print_section "7. GET UPDATED PRODUCT" +echo "Command: curl -i -X GET $BASE_URL/1" +curl -i -X GET "$BASE_URL/1" +echo "" +pause + +print_section "8. DELETE PRODUCT" +echo "Command: curl -i -X DELETE $BASE_URL/3" +curl -i -X DELETE "$BASE_URL/3" +echo "" +pause + +print_section "9. GET ALL PRODUCTS (After Deletion)" +echo "Command: curl -i -X GET $BASE_URL" +curl -i -X GET "$BASE_URL" +echo "" +pause + +print_section "10. TRY TO GET DELETED PRODUCT (Should return 404)" +echo "Command: curl -i -X GET $BASE_URL/3" +curl -i -X GET "$BASE_URL/3" || true +echo "" +pause + +print_section "11. TRY TO GET NON-EXISTENT PRODUCT (Should return 404)" +echo "Command: curl -i -X GET $BASE_URL/999" +curl -i -X GET "$BASE_URL/999" || true +echo "" + +echo "" +echo "==================================" +echo "API Testing Complete!" +echo "==================================" \ No newline at end of file diff --git a/code/chapter05/catalog/README.adoc b/code/chapter05/catalog/README.adoc new file mode 100644 index 0000000..cffee0b --- /dev/null +++ b/code/chapter05/catalog/README.adoc @@ -0,0 +1,622 @@ += MicroProfile Catalog Service +:toc: macro +:toclevels: 3 +:icons: font +:source-highlighter: highlight.js +:experimental: + +toc::[] + +== Overview + +The MicroProfile Catalog Service is a modern Jakarta EE 10 application built with MicroProfile 6.1 specifications and running on Open Liberty. This service provides a RESTful API for product catalog management with enhanced MicroProfile features. + +This project demonstrates the key capabilities of MicroProfile OpenAPI and in-memory persistence architecture. + +== Features + +* *RESTful API* using Jakarta RESTful Web Services +* *OpenAPI Documentation* with Swagger UI +* *In-Memory Persistence* using ConcurrentHashMap for thread-safe data storage +* *HTML Landing Page* with API documentation and service status +* *Maintenance Mode* support with configuration-based toggles + +== MicroProfile Features Implemented + +=== MicroProfile OpenAPI + +The application provides OpenAPI documentation for its REST endpoints. API documentation is generated automatically from annotations in the code: + +[source,java] +---- +@GET +@Produces(MediaType.APPLICATION_JSON) +@Operation(summary = "Get all products", description = "Returns a list of all products") +@APIResponses({ + @APIResponse(responseCode = "200", description = "List of products", + content = @Content(mediaType = "application/json", + schema = @Schema(implementation = Product.class))) +}) +public Response getAllProducts() { + // Implementation +} +---- + +The OpenAPI documentation is available at: `/openapi` (in various formats) and `/openapi/ui` (Swagger UI) + +=== In-Memory Persistence Architecture + +The application implements a thread-safe in-memory persistence layer using `ConcurrentHashMap`: + +[source,java] +---- +@ApplicationScoped +public class ProductRepository { + // In-memory storage using ConcurrentHashMap for thread safety + private final Map productsMap = new ConcurrentHashMap<>(); + + // ID generator + private final AtomicLong idGenerator = new AtomicLong(1); + + // CRUD operations... +} +---- + +==== Atomic ID Generation with AtomicLong + +The repository uses `java.util.concurrent.atomic.AtomicLong` for thread-safe ID generation: + +[source,java] +---- +// ID generation in createProduct method +if (product.getId() == null) { + product.setId(idGenerator.getAndIncrement()); +} +---- + +`AtomicLong` provides several key benefits: + +* *Thread Safety*: Guarantees atomic operations without explicit locking +* *Performance*: Uses efficient compare-and-swap (CAS) operations instead of locks +* *Consistency*: Ensures unique, sequential IDs even under concurrent access +* *No Synchronization*: Avoids the overhead of synchronized blocks + +===== Advanced AtomicLong Operations + +The ProductRepository implements an advanced pattern for handling both system-generated and client-provided IDs: + +[source,java] +---- +public Product createProduct(Product product) { + // Generate ID if not provided + if (product.getId() == null) { + product.setId(idGenerator.getAndIncrement()); + } else { + // Update idGenerator if the provided ID is greater than current + long nextId = product.getId() + 1; + while (true) { + long currentId = idGenerator.get(); + if (nextId <= currentId || idGenerator.compareAndSet(currentId, nextId)) { + break; + } + } + } + + productsMap.put(product.getId(), product); + return product; +} +---- + +This implementation demonstrates several key AtomicLong patterns: + +1. *Initialization*: `AtomicLong` is initialized with a starting value of 1 to avoid using 0 as a valid ID +2. *getAndIncrement*: Atomically returns the current value and increments it in one operation +3. *compareAndSet*: Safely updates the ID generator if a client provides a higher ID value, preventing ID collisions +4. *Retry Logic*: Uses a spinlock pattern for handling concurrent updates to the AtomicLong when needed + +The initialization of the idGenerator with a specific starting value ensures the IDs begin at a predictable value: + +[source,java] +---- +private final AtomicLong idGenerator = new AtomicLong(1); // Start IDs at 1 +---- + +This approach ensures that each product receives a unique ID without risk of duplicate IDs in a concurrent environment. + +Key benefits of this in-memory persistence approach: + +* *Simplicity*: No need for database configuration or ORM mapping +* *Performance*: Fast in-memory access without network or disk I/O +* *Thread Safety*: ConcurrentHashMap provides thread-safe operations without blocking +* *Scalability*: Suitable for containerized deployments + +==== Thread Safety Implementation Details + +The implementation ensures thread safety through multiple mechanisms: + +1. *ConcurrentHashMap*: Uses lock striping to allow concurrent reads and thread-safe writes +2. *AtomicLong*: Provides atomic operations for ID generation +3. *Immutable Returns*: Returns new collections rather than internal references: ++ +[source,java] +---- +// Returns a copy of the collection to prevent concurrent modification issues +public List findAllProducts() { + return new ArrayList<>(productsMap.values()); +} +---- + +4. *Atomic Operations*: Uses atomic map operations like `putIfAbsent` and `compute` where appropriate + +NOTE: This implementation is suitable for development, testing, and scenarios where persistence across restarts is not required. + +=== MicroProfile Config + +The application uses MicroProfile Config to externalize configuration: + +[source,properties] +---- +# Enable OpenAPI scanning +mp.openapi.scan=true + +# Maintenance mode configuration +product.maintenanceMode=false +product.maintenanceMessage=The product catalog service is currently in maintenance mode. Please try again later. +---- + +The maintenance mode configuration allows dynamic control of service availability: + +* `product.maintenanceMode` - When set to `true`, the service returns a 503 Service Unavailable response +* `product.maintenanceMessage` - Customizable message displayed when the service is in maintenance mode + +==== Maintenance Mode Implementation + +The service checks the maintenance mode configuration before processing requests: + +[source,java] +---- +@Inject +@ConfigProperty(name="product.maintenanceMode", defaultValue="false") +private boolean maintenanceMode; + +@Inject +@ConfigProperty(name="product.maintenanceMessage", + defaultValue="The product catalog service is currently in maintenance mode. Please try again later.") +private String maintenanceMessage; + +// In request handling method +if (maintenance.isMaintenanceMode()) { + return Response + .status(Response.Status.SERVICE_UNAVAILABLE) + .entity(maintenance.getMaintenanceMessage()) + .build(); +} +---- + +This pattern enables: + +* Graceful service degradation during maintenance periods +* Dynamic control without redeployment (when using external configuration sources) +* Clear communication to API consumers + +== Architecture + +The application follows a layered architecture pattern: + +* *REST Layer* (`ProductResource`) - Handles HTTP requests and responses +* *Service Layer* (`ProductService`) - Contains business logic +* *Repository Layer* (`ProductRepository`) - Manages data access with in-memory storage +* *Model Layer* (`Product`) - Represents the business entities + +=== Persistence Evolution + +This application originally used JPA with Derby for persistence, but has been refactored to use an in-memory implementation: + +[cols="1,1", options="header"] +|=== +| Original JPA/Derby | Current In-Memory Implementation +| Required database configuration | No database configuration needed +| Persistence across restarts | Data reset on restart +| Used EntityManager and transactions | Uses ConcurrentHashMap and AtomicLong +| Required datasource in server.xml | No datasource configuration required +| Complex error handling | Simplified error handling +|=== + +Key architectural benefits of this change: + +* *Simplified Deployment*: No external database required +* *Faster Startup*: No database initialization delay +* *Reduced Dependencies*: Fewer libraries and configurations +* *Easier Testing*: No test database setup needed +* *Consistent Development Environment*: Same behavior across all development machines + +=== Containerization with Docker + +The application can be packaged into a Docker container: + +[source,bash] +---- +# Build the application +mvn clean package + +# Build the Docker image +docker build -t catalog-service . + +# Run the container +docker run -d -p 5050:5050 --name catalog-service catalog-service +---- + +==== AtomicLong in Containerized Environments + +When running the application in Docker or Kubernetes, some important considerations about AtomicLong behavior: + +1. *Per-Container State*: Each container has its own AtomicLong instance and state +2. *ID Collisions in Scaling*: When running multiple replicas, IDs are only unique within each container +3. *Persistence and Restarts*: AtomicLong resets on container restart, potentially causing ID reuse + +To handle these issues in production multi-container environments: + +* *External ID Generation*: Consider using a distributed ID generator service +* *Database Sequences*: For database implementations, use database sequences +* *Universally Unique IDs*: Consider UUIDs instead of sequential numeric IDs +* *Centralized Counter Service*: Use Redis or other distributed counter + +Example of adapting the code for distributed environments: + +[source,java] +---- +// Using UUIDs for distributed environments +private String generateId() { + return UUID.randomUUID().toString(); +} +---- + +== Development Workflow + +=== Running Locally + +To run the application in development mode: + +[source,bash] +---- +mvn clean liberty:dev +---- + +This starts the server in development mode, which: + +* Automatically deploys your code changes +* Provides hot reload capability +* Enables a debugger on port 7777 + +== Project Structure + +[source] +---- +catalog/ +├── src/ +│ ├── main/ +│ │ ├── java/ +│ │ │ └── io/microprofile/tutorial/store/ +│ │ │ └── product/ +│ │ │ ├── entity/ # Domain entities +│ │ │ ├── resource/ # REST resources +│ │ │ └── ProductRestApplication.java +│ │ ├── liberty/ +│ │ │ └── config/ +│ │ │ └── server.xml # Liberty server configuration +│ │ ├── resources/ +│ │ │ └── META-INF/ +│ │ │ └── microprofile-config.properties +│ │ └── webapp/ # Web resources +│ │ ├── index.html # Landing page with API documentation +│ │ └── WEB-INF/ +│ │ └── web.xml # Web application configuration +│ └── test/ # Test classes +└── pom.xml # Maven build file +---- + +== Getting Started + +=== Prerequisites + +* JDK 17+ +* Maven 3.8+ + +=== Building and Running + +To build and run the application: + +[source,bash] +---- +# Clone the repository +git clone https://github.com/yourusername/liberty-rest-app.git +cd code/catalog + +# Build the application +mvn clean package + +# Run the application +mvn liberty:run +---- + +=== Testing the Application + +==== Testing MicroProfile Features + +[source,bash] +---- +# OpenAPI documentation +curl -X GET http://localhost:5050/openapi + +# Check if service is in maintenance mode +curl -X GET http://localhost:5050/api/products +---- + +To view the Swagger UI, open the following URL in your browser: +http://localhost:5050/openapi/ui + +To view the landing page with API documentation: +http://localhost:5050/ + +== Server Configuration + +The application uses the following Liberty server configuration: + +[source,xml] +---- + + + jakartaEE-10.0 + microProfile-6.1 + restfulWS + jsonp + jsonb + cdi + mpConfig + mpOpenAPI + + + + + +---- + +== Development + +=== Adding a New Endpoint + +To add a new endpoint: + +1. Create a new method in the `ProductResource` class +2. Add appropriate Jakarta Restful Web Service annotations +3. Add OpenAPI annotations for documentation +4. Implement the business logic + +Example: + +[source,java] +---- +@GET +@Path("/search") +@Produces(MediaType.APPLICATION_JSON) +@Operation(summary = "Search products", description = "Search products by name") +@APIResponses({ + @APIResponse(responseCode = "200", description = "Products matching search criteria") +}) +public Response searchProducts(@QueryParam("name") String name) { + List matchingProducts = products.stream() + .filter(p -> p.getName().toLowerCase().contains(name.toLowerCase())) + .collect(Collectors.toList()); + return Response.ok(matchingProducts).build(); +} +---- + +=== Performance Considerations + +The in-memory data store provides excellent performance for read operations, but there are important considerations: + +* *Memory Usage*: Large data sets may consume significant memory +* *Persistence*: Data is lost when the application restarts +* *Scalability*: In a multi-instance deployment, each instance will have its own data store + +For production scenarios requiring data persistence, consider: + +1. Adding a database layer (PostgreSQL, MongoDB, etc.) +2. Implementing a distributed cache (Hazelcast, Redis, etc.) +3. Adding data synchronization between instances + +=== Concurrency Implementation Details + +==== AtomicLong vs Synchronized Counter + +The repository uses `AtomicLong` rather than traditional synchronized counters: + +[cols="1,1", options="header"] +|=== +| Traditional Approach | AtomicLong Approach +| `private long counter = 0;` | `private final AtomicLong idGenerator = new AtomicLong(1);` +| `synchronized long getNextId() { return ++counter; }` | `long nextId = idGenerator.getAndIncrement();` +| Locks entire method | Lock-free operation +| Subject to contention | Uses CPU compare-and-swap +| Performance degrades with multiple threads | Maintains performance under concurrency +|=== + +==== AtomicLong vs Other Concurrency Options + +[cols="1,1,1,1", options="header"] +|=== +| Feature | AtomicLong | Synchronized | java.util.concurrent.locks.Lock +| Type | Non-blocking | Intrinsic lock | Explicit lock +| Granularity | Single variable | Method/block | Customizable +| Performance under contention | High | Lower | Medium +| Visibility guarantee | Yes | Yes | Yes +| Atomicity guarantee | Yes | Yes | Yes +| Fairness policy | No | No | Optional +| Try/timeout support | Yes (compareAndSet) | No | Yes +| Multiple operations atomicity | Limited | Yes | Yes +| Implementation complexity | Simple | Simple | Complex +|=== + +===== When to Choose AtomicLong + +* *High-Contention Scenarios*: When many threads need to access/modify a counter +* *Single Variable Operations*: When only one variable needs atomic operations +* *Performance-Critical Code*: When minimizing lock contention is essential +* *Read-Heavy Workloads*: When reads significantly outnumber writes + +For this in-memory product repository, AtomicLong provides an optimal balance of safety and performance. + +==== Implementation in createProduct Method + +The ID generation logic handles both automatic and manual ID assignment: + +[source,java] +---- +public Product createProduct(Product product) { + // Generate ID if not provided + if (product.getId() == null) { + product.setId(idGenerator.getAndIncrement()); + } else { + // Update idGenerator if the provided ID is greater than current + long nextId = product.getId() + 1; + while (true) { + long currentId = idGenerator.get(); + if (nextId <= currentId || idGenerator.compareAndSet(currentId, nextId)) { + break; + } + } + } + + productsMap.put(product.getId(), product); + return product; +} +---- + +This implementation ensures ID integrity while supporting both system-generated and client-provided IDs. + +This enables scanning of OpenAPI annotations in the application. + +== Troubleshooting + +=== Common Issues + +* *OpenAPI documentation not available*: Make sure `mp.openapi.scan=true` is set in the properties file +* *Concurrent modification exceptions*: Ensure proper use of thread-safe collections and operations +* *Service always in maintenance mode*: Check the `product.maintenanceMode` property in `microprofile-config.properties` +* *API returning 503 responses*: The service is likely in maintenance mode; set `product.maintenanceMode=false` in configuration +* *OpenAPI documentation not available*: Make sure `mp.openapi.scan=true` is set in the properties file +* *Concurrent modification exceptions*: Ensure proper use of thread-safe collections and operations + +=== Thread Safety Troubleshooting + +If experiencing concurrency issues: + +1. *Verify AtomicLong Usage*: Ensure all ID generation uses `AtomicLong.getAndIncrement()` instead of manual increment +2. *Check Collection Returns*: Always return copies of collections, not direct references: ++ +[source,java] +---- +public List findAllProducts() { + return new ArrayList<>(productsMap.values()); // Correct: returns a new copy +} +---- + +3. *Use ConcurrentHashMap Methods*: Prefer atomic methods like `compute`, `computeIfAbsent`, or `computeIfPresent` for complex operations +4. *Avoid Iteration + Modification*: Don't modify the map while iterating over it + +=== Understanding AtomicLong Internals + +If you need to debug issues with AtomicLong, understanding its internal mechanisms is helpful: + +==== Compare-And-Swap Operation + +AtomicLong relies on hardware-level atomic instructions, specifically Compare-And-Swap (CAS): + +[source,text] +---- +function CAS(address, expected, new): + atomically: + if memory[address] == expected: + memory[address] = new + return true + else: + return false +---- + +The implementation of `getAndIncrement()` uses this mechanism: + +[source,java] +---- +// Simplified implementation of getAndIncrement +public long getAndIncrement() { + while (true) { + long current = get(); + long next = current + 1; + if (compareAndSet(current, next)) + return current; + } +} +---- + +==== Memory Ordering and Visibility + +AtomicLong ensures that memory visibility follows the Java Memory Model: + +* All writes to the AtomicLong by one thread are visible to reads from other threads +* Memory barriers are established when performing atomic operations +* Volatile semantics are guaranteed without using the volatile keyword + +==== Diagnosing AtomicLong Issues + +1. *Unexpected ID Values*: Check for manual ID assignment bypassing the AtomicLong +2. *Duplicate IDs*: Verify the initialization value and ensure all ID assignments go through AtomicLong +3. *Performance Issues*: Look for excessive contention (many threads updating simultaneously) + +=== Logs + +Server logs can be found at: + +[source] +---- +target/liberty/wlp/usr/servers/defaultServer/logs/ +---- + +== Resources + +* https://microprofile.io/[MicroProfile] + +=== HTML Landing Page + +The application includes a user-friendly HTML landing page (`index.html`) that provides: + +* Service overview with comprehensive documentation +* API endpoints documentation with methods and descriptions +* Interactive examples for all API operations +* Links to OpenAPI/Swagger documentation + +==== Maintenance Mode Configuration in the UI + +The index.html page is designed to work seamlessly with the maintenance mode configuration. When maintenance mode is enabled via the `product.maintenanceMode` property, all API endpoints return a 503 Service Unavailable response with the configured maintenance message. + +The landing page displays comprehensive documentation about the API regardless of the maintenance state, allowing developers to continue learning about the API even when the service is undergoing maintenance. + +Key features of the landing page: + +* *Responsive Design*: Works well on desktop and mobile devices +* *Comprehensive API Documentation*: All endpoints with sample requests and responses +* *Interactive Examples*: Detailed sample requests and responses for each endpoint +* *Modern Styling*: Clean, professional appearance with card-based layout + +The landing page is configured as the welcome file in `web.xml`: + +[source,xml] +---- + + index.html + +---- + +This provides a user-friendly entry point for API consumers and developers. + + diff --git a/code/chapter05/catalog/pom.xml b/code/chapter05/catalog/pom.xml new file mode 100644 index 0000000..853bfdc --- /dev/null +++ b/code/chapter05/catalog/pom.xml @@ -0,0 +1,75 @@ + + + 4.0.0 + + io.microprofile.tutorial + catalog + 1.0-SNAPSHOT + war + + + + + 17 + 17 + + UTF-8 + UTF-8 + + + 5050 + 5051 + + catalog + + + + + + + org.projectlombok + lombok + 1.18.26 + provided + + + + + jakarta.platform + jakarta.jakartaee-api + 10.0.0 + provided + + + + + org.eclipse.microprofile + microprofile + 6.1 + pom + provided + + + + + ${project.artifactId} + + + + io.openliberty.tools + liberty-maven-plugin + 3.11.2 + + mpServer + + + + + org.apache.maven.plugins + maven-war-plugin + 3.4.0 + + + + \ No newline at end of file diff --git a/code/chapter05/catalog/src/main/java/io/microprofile/tutorial/store/product/ProductRestApplication.java b/code/chapter05/catalog/src/main/java/io/microprofile/tutorial/store/product/ProductRestApplication.java new file mode 100644 index 0000000..9759e1f --- /dev/null +++ b/code/chapter05/catalog/src/main/java/io/microprofile/tutorial/store/product/ProductRestApplication.java @@ -0,0 +1,9 @@ +package io.microprofile.tutorial.store.product; + +import jakarta.ws.rs.ApplicationPath; +import jakarta.ws.rs.core.Application; + +@ApplicationPath("/api") +public class ProductRestApplication extends Application { + // No additional configuration is needed here +} \ No newline at end of file diff --git a/code/chapter05/catalog/src/main/java/io/microprofile/tutorial/store/product/entity/Product.java b/code/chapter05/catalog/src/main/java/io/microprofile/tutorial/store/product/entity/Product.java new file mode 100644 index 0000000..84e3b23 --- /dev/null +++ b/code/chapter05/catalog/src/main/java/io/microprofile/tutorial/store/product/entity/Product.java @@ -0,0 +1,16 @@ +package io.microprofile.tutorial.store.product.entity; + +import lombok.Data; +import lombok.NoArgsConstructor; +import lombok.AllArgsConstructor; + +@Data +@NoArgsConstructor +@AllArgsConstructor +public class Product { + + private Long id; + private String name; + private String description; + private Double price; +} diff --git a/code/chapter05/catalog/src/main/java/io/microprofile/tutorial/store/product/repository/ProductRepository.java b/code/chapter05/catalog/src/main/java/io/microprofile/tutorial/store/product/repository/ProductRepository.java new file mode 100644 index 0000000..6631fde --- /dev/null +++ b/code/chapter05/catalog/src/main/java/io/microprofile/tutorial/store/product/repository/ProductRepository.java @@ -0,0 +1,138 @@ +package io.microprofile.tutorial.store.product.repository; + +import io.microprofile.tutorial.store.product.entity.Product; +import jakarta.enterprise.context.ApplicationScoped; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.atomic.AtomicLong; +import java.util.logging.Logger; +import java.util.stream.Collectors; + +/** + * Repository class for Product entity. + * Provides in-memory persistence operations using ConcurrentHashMap. + */ +@ApplicationScoped +public class ProductRepository { + + private static final Logger LOGGER = Logger.getLogger(ProductRepository.class.getName()); + + // In-memory storage using ConcurrentHashMap for thread safety + private final Map productsMap = new ConcurrentHashMap<>(); + + // ID generator + private final AtomicLong idGenerator = new AtomicLong(1); + + /** + * Constructor with sample data initialization. + */ + public ProductRepository() { + // Initialize with sample products + createProduct(new Product(null, "iPhone", "Apple iPhone 15", 999.99)); + createProduct(new Product(null, "MacBook", "Apple MacBook Air", 1299.0)); + createProduct(new Product(null, "iPad", "Apple iPad Pro", 799.0)); + LOGGER.info("ProductRepository initialized with sample products"); + } + + /** + * Retrieves all products. + * + * @return List of all products + */ + public List findAllProducts() { + LOGGER.fine("Repository: Finding all products"); + return new ArrayList<>(productsMap.values()); + } + + /** + * Retrieves a product by ID. + * + * @param id Product ID + * @return The product or null if not found + */ + public Product findProductById(Long id) { + LOGGER.fine("Repository: Finding product with ID: " + id); + return productsMap.get(id); + } + + /** + * Creates a new product. + * + * @param product Product data to create + * @return The created product with ID + */ + public Product createProduct(Product product) { + // Generate ID if not provided + if (product.getId() == null) { + product.setId(idGenerator.getAndIncrement()); + } else { + // Update idGenerator if the provided ID is greater than current + long nextId = product.getId() + 1; + while (true) { + long currentId = idGenerator.get(); + if (nextId <= currentId || idGenerator.compareAndSet(currentId, nextId)) { + break; + } + } + } + + LOGGER.fine("Repository: Creating product with ID: " + product.getId()); + productsMap.put(product.getId(), product); + return product; + } + + /** + * Updates an existing product. + * + * @param product Updated product data + * @return The updated product or null if not found + */ + public Product updateProduct(Product product) { + Long id = product.getId(); + if (id != null && productsMap.containsKey(id)) { + LOGGER.fine("Repository: Updating product with ID: " + id); + productsMap.put(id, product); + return product; + } + LOGGER.warning("Repository: Product not found for update, ID: " + id); + return null; + } + + /** + * Deletes a product by ID. + * + * @param id ID of the product to delete + * @return true if deleted, false if not found + */ + public boolean deleteProduct(Long id) { + if (productsMap.containsKey(id)) { + LOGGER.fine("Repository: Deleting product with ID: " + id); + productsMap.remove(id); + return true; + } + LOGGER.warning("Repository: Product not found for deletion, ID: " + id); + return false; + } + + /** + * Searches for products by criteria. + * + * @param name Product name (optional) + * @param description Product description (optional) + * @param minPrice Minimum price (optional) + * @param maxPrice Maximum price (optional) + * @return List of matching products + */ + public List searchProducts(String name, String description, Double minPrice, Double maxPrice) { + LOGGER.fine("Repository: Searching for products with criteria"); + + return productsMap.values().stream() + .filter(p -> name == null || p.getName().toLowerCase().contains(name.toLowerCase())) + .filter(p -> description == null || p.getDescription().toLowerCase().contains(description.toLowerCase())) + .filter(p -> minPrice == null || p.getPrice() >= minPrice) + .filter(p -> maxPrice == null || p.getPrice() <= maxPrice) + .collect(Collectors.toList()); + } +} \ No newline at end of file diff --git a/code/chapter05/catalog/src/main/java/io/microprofile/tutorial/store/product/resource/ProductResource.java b/code/chapter05/catalog/src/main/java/io/microprofile/tutorial/store/product/resource/ProductResource.java new file mode 100644 index 0000000..3e3d3ce --- /dev/null +++ b/code/chapter05/catalog/src/main/java/io/microprofile/tutorial/store/product/resource/ProductResource.java @@ -0,0 +1,182 @@ +package io.microprofile.tutorial.store.product.resource; + +import java.util.List; +import java.util.logging.Logger; +import java.util.logging.Level; + +import org.eclipse.microprofile.openapi.annotations.Operation; +import org.eclipse.microprofile.openapi.annotations.media.Content; +import org.eclipse.microprofile.openapi.annotations.media.Schema; +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 org.eclipse.microprofile.config.inject.ConfigProperty; + +import io.microprofile.tutorial.store.product.entity.Product; +import io.microprofile.tutorial.store.product.service.ProductService; +import jakarta.enterprise.context.RequestScoped; +import jakarta.inject.Inject; + +import jakarta.ws.rs.Consumes; +import jakarta.ws.rs.DELETE; +import jakarta.ws.rs.GET; +import jakarta.ws.rs.POST; +import jakarta.ws.rs.PUT; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.PathParam; +import jakarta.ws.rs.Produces; +import jakarta.ws.rs.QueryParam; +import jakarta.ws.rs.core.MediaType; +import jakarta.ws.rs.core.Response; + +@RequestScoped +@Path("/products") +@Tag(name = "Product Resource", description = "CRUD operations for products") +public class ProductResource { + + private static final Logger LOGGER = Logger.getLogger(ProductResource.class.getName()); + + @Inject + @ConfigProperty(name="product.maintenanceMode") + private boolean maintenanceMode; + + @Inject + private ProductService productService; + + @GET + @Produces(MediaType.APPLICATION_JSON) + @Operation(summary = "List all products", description = "Retrieves a list of all products") + @APIResponses({ + @APIResponse( + responseCode = "200", + description = "Successful, list of products found", + content = @Content(mediaType = "application/json", + schema = @Schema(implementation = Product.class))), + @APIResponse( + responseCode = "400", + description = "Unsuccessful, no products found", + content = @Content(mediaType = "application/json") + ), + @APIResponse( + responseCode = "503", + description = "Service is under maintenance", + content = @Content(mediaType = "application/json") + ) + }) + public Response getAllProducts() { + LOGGER.log(Level.INFO, "REST: Fetching all products"); + List products = productService.findAllProducts(); + + if (maintenanceMode) { + return Response + .status(Response.Status.SERVICE_UNAVAILABLE) + .entity("Service is under maintenance") + .build(); + } + + if (products != null && !products.isEmpty()) { + return Response + .status(Response.Status.OK) + .entity(products).build(); + } else { + return Response + .status(Response.Status.NOT_FOUND) + .entity("No products found") + .build(); + } + } + + @GET + @Path("/{id}") + @Produces(MediaType.APPLICATION_JSON) + @Operation(summary = "Get product by ID", description = "Returns a product by its ID") + @APIResponses({ + @APIResponse(responseCode = "200", description = "Product found", content = @Content(mediaType = "application/json", schema = @Schema(implementation = Product.class))), + @APIResponse(responseCode = "404", description = "Product not found"), + @APIResponse(responseCode = "503", description = "Service is under maintenance") + }) + public Response getProductById(@PathParam("id") Long id) { + LOGGER.log(Level.INFO, "REST: Fetching product with id: {0}", id); + + if (maintenanceMode) { + return Response + .status(Response.Status.SERVICE_UNAVAILABLE) + .entity("Service is under maintenance") + .build(); + } + + Product product = productService.findProductById(id); + if (product != null) { + return Response.ok(product).build(); + } else { + return Response.status(Response.Status.NOT_FOUND).build(); + } + } + + @POST + @Consumes(MediaType.APPLICATION_JSON) + @Produces(MediaType.APPLICATION_JSON) + @Operation(summary = "Create a new product", description = "Creates a new product") + @APIResponses({ + @APIResponse(responseCode = "201", description = "Product created", content = @Content(mediaType = "application/json", schema = @Schema(implementation = Product.class))) + }) + public Response createProduct(Product product) { + LOGGER.info("REST: Creating product: " + product); + Product createdProduct = productService.createProduct(product); + return Response.status(Response.Status.CREATED).entity(createdProduct).build(); + } + + @PUT + @Path("/{id}") + @Consumes(MediaType.APPLICATION_JSON) + @Produces(MediaType.APPLICATION_JSON) + @Operation(summary = "Update a product", description = "Updates an existing product by its ID") + @APIResponses({ + @APIResponse(responseCode = "200", description = "Product updated", content = @Content(mediaType = "application/json", schema = @Schema(implementation = Product.class))), + @APIResponse(responseCode = "404", description = "Product not found") + }) + public Response updateProduct(@PathParam("id") Long id, Product updatedProduct) { + LOGGER.info("REST: Updating product with id: " + id); + Product updated = productService.updateProduct(id, updatedProduct); + if (updated != null) { + return Response.ok(updated).build(); + } else { + return Response.status(Response.Status.NOT_FOUND).build(); + } + } + + @DELETE + @Path("/{id}") + @Produces(MediaType.APPLICATION_JSON) + @Operation(summary = "Delete a product", description = "Deletes a product by its ID") + @APIResponses({ + @APIResponse(responseCode = "204", description = "Product deleted"), + @APIResponse(responseCode = "404", description = "Product not found") + }) + public Response deleteProduct(@PathParam("id") Long id) { + LOGGER.info("REST: Deleting product with id: " + id); + boolean deleted = productService.deleteProduct(id); + if (deleted) { + return Response.noContent().build(); + } else { + return Response.status(Response.Status.NOT_FOUND).build(); + } + } + + @GET + @Path("/search") + @Produces(MediaType.APPLICATION_JSON) + @Operation(summary = "Search products", description = "Search products by criteria") + @APIResponses({ + @APIResponse(responseCode = "200", description = "Search results", content = @Content(mediaType = "application/json", schema = @Schema(implementation = Product.class))) + }) + public Response searchProducts( + @QueryParam("name") String name, + @QueryParam("description") String description, + @QueryParam("minPrice") Double minPrice, + @QueryParam("maxPrice") Double maxPrice) { + LOGGER.info("REST: Searching products with criteria"); + List results = productService.searchProducts(name, description, minPrice, maxPrice); + return Response.ok(results).build(); + } +} \ No newline at end of file diff --git a/code/chapter05/catalog/src/main/java/io/microprofile/tutorial/store/product/service/ProductService.java b/code/chapter05/catalog/src/main/java/io/microprofile/tutorial/store/product/service/ProductService.java new file mode 100644 index 0000000..804fd92 --- /dev/null +++ b/code/chapter05/catalog/src/main/java/io/microprofile/tutorial/store/product/service/ProductService.java @@ -0,0 +1,97 @@ +package io.microprofile.tutorial.store.product.service; + +import io.microprofile.tutorial.store.product.entity.Product; +import io.microprofile.tutorial.store.product.repository.ProductRepository; +import jakarta.enterprise.context.RequestScoped; +import jakarta.inject.Inject; +import java.util.List; +import java.util.logging.Logger; + +/** + * Service class for Product operations. + * Contains business logic for product management. + */ +@RequestScoped +public class ProductService { + + private static final Logger LOGGER = Logger.getLogger(ProductService.class.getName()); + + @Inject + private ProductRepository repository; + + /** + * Retrieves all products. + * + * @return List of all products + */ + public List findAllProducts() { + LOGGER.info("Service: Finding all products"); + return repository.findAllProducts(); + } + + /** + * Retrieves a product by ID. + * + * @param id Product ID + * @return The product or null if not found + */ + public Product findProductById(Long id) { + LOGGER.info("Service: Finding product with ID: " + id); + return repository.findProductById(id); + } + + /** + * Creates a new product. + * + * @param product Product data to create + * @return The created product with ID + */ + public Product createProduct(Product product) { + LOGGER.info("Service: Creating new product: " + product); + return repository.createProduct(product); + } + + /** + * Updates an existing product. + * + * @param id ID of the product to update + * @param updatedProduct Updated product data + * @return The updated product or null if not found + */ + public Product updateProduct(Long id, Product updatedProduct) { + LOGGER.info("Service: Updating product with ID: " + id); + + Product existingProduct = repository.findProductById(id); + if (existingProduct != null) { + // Set the ID to ensure correct update + updatedProduct.setId(id); + return repository.updateProduct(updatedProduct); + } + return null; + } + + /** + * Deletes a product by ID. + * + * @param id ID of the product to delete + * @return true if deleted, false if not found + */ + public boolean deleteProduct(Long id) { + LOGGER.info("Service: Deleting product with ID: " + id); + return repository.deleteProduct(id); + } + + /** + * Searches for products by criteria. + * + * @param name Product name (optional) + * @param description Product description (optional) + * @param minPrice Minimum price (optional) + * @param maxPrice Maximum price (optional) + * @return List of matching products + */ + public List searchProducts(String name, String description, Double minPrice, Double maxPrice) { + LOGGER.info("Service: Searching for products with criteria"); + return repository.searchProducts(name, description, minPrice, maxPrice); + } +} diff --git a/code/chapter05/catalog/src/main/liberty/config/server.xml b/code/chapter05/catalog/src/main/liberty/config/server.xml new file mode 100644 index 0000000..007b5fc --- /dev/null +++ b/code/chapter05/catalog/src/main/liberty/config/server.xml @@ -0,0 +1,17 @@ + + + jakartaEE-10.0 + microProfile-6.1 + restfulWS + jsonp + jsonb + cdi + mpConfig + mpOpenAPI + + + + + + \ No newline at end of file diff --git a/code/chapter05/catalog/src/main/resources/META-INF/microprofile-config.properties b/code/chapter05/catalog/src/main/resources/META-INF/microprofile-config.properties new file mode 100644 index 0000000..532979e --- /dev/null +++ b/code/chapter05/catalog/src/main/resources/META-INF/microprofile-config.properties @@ -0,0 +1,5 @@ +# microprofile-config.properties +product.maintenanceMode=false + +# Enable OpenAPI scanning +mp.openapi.scan=true \ No newline at end of file diff --git a/code/chapter05/catalog/src/main/webapp/WEB-INF/web.xml b/code/chapter05/catalog/src/main/webapp/WEB-INF/web.xml new file mode 100644 index 0000000..1010516 --- /dev/null +++ b/code/chapter05/catalog/src/main/webapp/WEB-INF/web.xml @@ -0,0 +1,13 @@ + + + + Product Catalog Service + + + index.html + + + diff --git a/code/chapter05/catalog/src/main/webapp/index.html b/code/chapter05/catalog/src/main/webapp/index.html new file mode 100644 index 0000000..54622a4 --- /dev/null +++ b/code/chapter05/catalog/src/main/webapp/index.html @@ -0,0 +1,281 @@ + + + + + + Product Catalog Service + + + +
+

Product Catalog Service

+

A microservice for managing product information in the e-commerce platform

+
+ +
+
+

Service Overview

+

The Product Catalog Service provides a REST API for managing product information, including:

+
    +
  • Creating new products
  • +
  • Retrieving product details
  • +
  • Updating existing products
  • +
  • Deleting products
  • +
  • Searching for products by various criteria
  • +
+
+

MicroProfile Config Implementation

+

This service implements configurability as per MicroProfile Config standards. Key configuration properties include:

+
    +
  • product.maintenanceMode - Controls whether the service is in maintenance mode (returns 503 responses)
  • +
  • mp.openapi.scan - Enables automatic OpenAPI documentation generation
  • +
+

MicroProfile Config allows these properties to be changed via environment variables, system properties, or configuration files without requiring application redeployment.

+
+
+ +
+

API Endpoints

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
OperationMethodURLDescription
List All ProductsGET/api/productsRetrieves a list of all products
Get Product by IDGET/api/products/{id}Returns a product by its ID
Create ProductPOST/api/productsCreates a new product
Update ProductPUT/api/products/{id}Updates an existing product by its ID
Delete ProductDELETE/api/products/{id}Deletes a product by its ID
Search ProductsGET/api/products/searchSearch products by criteria (name, description, price range)
+
+ +
+

API Documentation

+

The API is documented using MicroProfile OpenAPI. You can access the Swagger UI at:

+

/openapi/ui

+

The OpenAPI definition is available at:

+

/openapi

+
+ +
+

Sample Usage

+ +

List All Products

+
GET /api/products
+

Response:

+
[
+  {
+    "id": 1,
+    "name": "Smartphone X",
+    "description": "Latest smartphone with advanced features",
+    "price": 799.99
+  },
+  {
+    "id": 2,
+    "name": "Laptop Pro",
+    "description": "High-performance laptop for professionals",
+    "price": 1299.99
+  }
+]
+ +

Get Product by ID

+
GET /api/products/1
+

Response:

+
{
+  "id": 1,
+  "name": "Smartphone X",
+  "description": "Latest smartphone with advanced features",
+  "price": 799.99
+}
+ +

Create a New Product

+
POST /api/products
+Content-Type: application/json
+
+{
+  "name": "Wireless Earbuds",
+  "description": "Premium wireless earbuds with noise cancellation",
+  "price": 149.99
+}
+

Response:

+
{
+  "id": 3,
+  "name": "Wireless Earbuds",
+  "description": "Premium wireless earbuds with noise cancellation",
+  "price": 149.99
+}
+ +

Update a Product

+
PUT /api/products/3
+Content-Type: application/json
+
+{
+  "name": "Wireless Earbuds Pro",
+  "description": "Premium wireless earbuds with advanced noise cancellation",
+  "price": 179.99
+}
+

Response:

+
{
+  "id": 3,
+  "name": "Wireless Earbuds Pro",
+  "description": "Premium wireless earbuds with advanced noise cancellation",
+  "price": 179.99
+}
+ +

Delete a Product

+
DELETE /api/products/3
+

Response: No content (204)

+ +

Search for Products

+
GET /api/products/search?name=laptop&minPrice=1000&maxPrice=2000
+

Response:

+
[
+  {
+    "id": 2,
+    "name": "Laptop Pro",
+    "description": "High-performance laptop for professionals",
+    "price": 1299.99
+  }
+]
+
+
+ +
+

Product Catalog Service

+

© 2025 - MicroProfile APT Tutorial

+
+ + + + diff --git a/code/chapter05/payment/Dockerfile b/code/chapter05/payment/Dockerfile new file mode 100644 index 0000000..77e6dde --- /dev/null +++ b/code/chapter05/payment/Dockerfile @@ -0,0 +1,20 @@ +FROM icr.io/appcafe/open-liberty:full-java17-openj9-ubi + +# Copy configuration files +COPY --chown=1001:0 src/main/liberty/config/ /config/ + +# Create the apps directory and copy the application +COPY --chown=1001:0 target/payment.war /config/apps/ + +# Configure the server to run in production mode +RUN configure.sh + +# Expose the default port +EXPOSE 9050 9443 + +# Set the health check +HEALTHCHECK --start-period=60s --interval=10s --timeout=5s --retries=3 \ + CMD curl -f http://localhost:9050/health || exit 1 + +# Run the server +CMD ["/opt/ol/wlp/bin/server", "run", "defaultServer"] diff --git a/code/chapter05/payment/README.adoc b/code/chapter05/payment/README.adoc new file mode 100644 index 0000000..7b4e250 --- /dev/null +++ b/code/chapter05/payment/README.adoc @@ -0,0 +1,276 @@ += Payment Service + +This microservice is part of the Jakarta EE 10 and MicroProfile 6.1-based e-commerce application. It handles payment processing and transaction management. + +== Features + +* Payment transaction processing +* Dynamic configuration management via MicroProfile Config +* RESTful API endpoints with JSON support +* Custom ConfigSource implementation +* OpenAPI documentation + +== Endpoints + +=== GET /payment/api/payment-config +* Returns all current payment configuration values +* Example: `GET http://localhost:9080/payment/api/payment-config` +* Response: `{"gateway.endpoint":"https://api.paymentgateway.com"}` + +=== POST /payment/api/payment-config +* Updates a payment configuration value +* Example: `POST http://localhost:9080/payment/api/payment-config` +* Request body: `{"key": "payment.gateway.endpoint", "value": "https://new-api.paymentgateway.com"}` +* Response: `{"key":"payment.gateway.endpoint","value":"https://new-api.paymentgateway.com","message":"Configuration updated successfully"}` + +=== POST /payment/api/authorize +* Processes a payment +* Example: `POST http://localhost:9080/payment/api/authorize` +* Response: `{"status":"success", "message":"Payment processed successfully."}` + +=== POST /payment/api/payment-config/process-example +* Example endpoint demonstrating payment processing with configuration +* Example: `POST http://localhost:9080/payment/api/payment-config/process-example` +* Request body: `{"cardNumber":"4111111111111111", "cardHolderName":"Test User", "expiryDate":"12/25", "securityCode":"123", "amount":100.00}` +* Response: `{"amount":100.00,"message":"Payment processed successfully","status":"success","configUsed":{"gatewayEndpoint":"https://new-api.paymentgateway.com"}}` + +== Building and Running the Service + +=== Prerequisites + +* JDK 17 or higher +* Maven 3.6.0 or higher + +=== Local Development + +[source,bash] +---- +# Build the application +mvn clean package + +# Run the application with Liberty +mvn liberty:run +---- + +The server will start on port 9080 (HTTP) and 9081 (HTTPS). + +=== Docker + +[source,bash] +---- +# Build and run with Docker +./run-docker.sh +---- + +== Project Structure + +* `src/main/java/io/microprofile/tutorial/PaymentRestApplication.java` - Jakarta Restful web service application class +* `src/main/java/io/microprofile/tutorial/store/payment/config/` - Configuration classes +* `src/main/java/io/microprofile/tutorial/store/payment/resource/` - REST resource endpoints +* `src/main/java/io/microprofile/tutorial/store/payment/service/` - Business logic services +* `src/main/java/io/microprofile/tutorial/store/payment/entity/` - Data models +* `src/main/resources/META-INF/services/` - Service provider configuration +* `src/main/liberty/config/` - Liberty server configuration + +== Custom ConfigSource + +The Payment Service implements a custom MicroProfile ConfigSource named `PaymentServiceConfigSource` that provides payment-specific configuration with high priority (ordinal: 600). + +=== Available Configuration Properties + +[cols="1,2,2", options="header"] +|=== +|Property +|Description +|Default Value + +|payment.gateway.endpoint +|Payment gateway endpoint URL +|https://api.paymentgateway.com +|=== + +=== Testing ConfigSource Endpoints + +You can test the ConfigSource endpoints using curl or any REST client: + +[source,bash] +---- +# Get current configuration +curl -s http://localhost:9080/payment/api/payment-config | json_pp + +# Update configuration property +curl -s -X POST -H "Content-Type: application/json" \ + -d '{"key":"payment.gateway.endpoint", "value":"https://new-api.paymentgateway.com"}' \ + http://localhost:9080/payment/api/payment-config | json_pp + +# Test payment processing with the configuration +curl -s -X POST -H "Content-Type: application/json" \ + -d '{"cardNumber":"4111111111111111", "cardHolderName":"Test User", "expiryDate":"12/25", "securityCode":"123", "amount":100.00}' \ + http://localhost:9080/payment/api/payment-config/process-example | json_pp + +# Test basic payment authorization +curl -s -X POST -H "Content-Type: application/json" \ + http://localhost:9080/payment/api/authorize | json_pp +---- + +=== Implementation Details + +The custom ConfigSource is implemented in the following classes: + +* `PaymentServiceConfigSource.java` - Implements the MicroProfile ConfigSource interface +* `PaymentConfig.java` - Utility class for accessing configuration properties + +Example usage in application code: + +[source,java] +---- +// Inject standard MicroProfile Config +@Inject +@ConfigProperty(name="payment.gateway.endpoint") +private String endpoint; + +// Or use the utility class +String gatewayUrl = PaymentConfig.getConfigProperty("payment.gateway.endpoint"); +---- + +The custom ConfigSource provides a higher priority (ordinal: 600) than system properties and environment variables, allowing for service-specific defaults while still enabling override via standard mechanisms. + +=== MicroProfile Config Sources + +MicroProfile Config uses a prioritized set of configuration sources. The payment service uses the following configuration sources in order of priority (highest to lowest): + +1. Custom ConfigSource (`PaymentServiceConfigSource`) - Ordinal: 600 +2. System properties - Ordinal: 400 +3. Environment variables - Ordinal: 300 +4. microprofile-config.properties file - Ordinal: 100 + +==== Updating Configuration Values + +You can update configuration properties through different methods: + +===== 1. Using the REST API (runtime) + +This uses the custom ConfigSource and persists only for the current server session: + +[source,bash] +---- +curl -X POST -H "Content-Type: application/json" \ + -d '{"key":"payment.gateway.endpoint", "value":"https://test-api.paymentgateway.com"}' \ + http://localhost:9080/payment/api/payment-config +---- + +===== 2. Using System Properties (startup) + +[source,bash] +---- +# Linux/macOS +mvn liberty:run -Dpayment.gateway.endpoint=https://sys-api.paymentgateway.com + +# Windows +mvn liberty:run "-Dpayment.gateway.endpoint=https://sys-api.paymentgateway.com" +---- + +===== 3. Using Environment Variables (startup) + +Environment variable names must follow the MicroProfile Config convention (uppercase with underscores): + +[source,bash] +---- +# Linux/macOS +export PAYMENT_GATEWAY_ENDPOINT=https://env-api.paymentgateway.com +mvn liberty:run + +# Windows PowerShell +$env:PAYMENT_GATEWAY_ENDPOINT="https://env-api.paymentgateway.com" +mvn liberty:run + +# Windows CMD +set PAYMENT_GATEWAY_ENDPOINT=https://env-api.paymentgateway.com +mvn liberty:run + +# To clear the environment variable: +# Linux/macOS +unset PAYMENT_GATEWAY_ENDPOINT + +# Windows PowerShell +Remove-Item Env:PAYMENT_GATEWAY_ENDPOINT + +# Windows CMD +set PAYMENT_GATEWAY_ENDPOINT= +---- + +===== 4. Using microprofile-config.properties File (build time) + +Edit the file at `src/main/resources/META-INF/microprofile-config.properties`: + +[source,properties] +---- +# Update the endpoint +payment.gateway.endpoint=https://config-api.paymentgateway.com +---- + +Then rebuild and restart the application: + +[source,bash] +---- +mvn clean package liberty:run +---- + +==== Testing Configuration Changes + +After changing a configuration property, you can verify it was updated by calling: + +[source,bash] +---- +curl http://localhost:9080/payment/api/payment-config +---- + +== Documentation + +=== OpenAPI + +The payment service automatically generates OpenAPI documentation using MicroProfile OpenAPI annotations. + +* OpenAPI UI: `http://localhost:9080/payment/api/openapi-ui/` +* OpenAPI JSON: `http://localhost:9080/payment/api/openapi` + +=== MicroProfile Config Specification + +For more information about MicroProfile Config, refer to the official documentation: + +* https://download.eclipse.org/microprofile/microprofile-config-3.1/microprofile-config-spec-3.1.html + +=== Related Resources + +* MicroProfile: https://microprofile.io/ +* Jakarta EE: https://jakarta.ee/ +* Open Liberty: https://openliberty.io/ + +== Troubleshooting + +=== Common Issues + +==== Port Conflicts + +If you encounter a port conflict when starting the server, you can change the ports in the `pom.xml` file: + +[source,xml] +---- +9080 +9081 +---- + +==== ConfigSource Not Loading + +If the custom ConfigSource is not loading, check the following: + +1. Verify the service provider configuration file exists at: + `src/main/resources/META-INF/services/org.eclipse.microprofile.config.spi.ConfigSource` + +2. Ensure it contains the correct fully qualified class name: + `io.microprofile.tutorial.store.payment.config.PaymentServiceConfigSource` + +==== Deployment Errors + +For CWWKZ0004E deployment errors, check the server logs at: +`target/liberty/wlp/usr/servers/mpServer/logs/messages.log` diff --git a/code/chapter05/payment/README.md b/code/chapter05/payment/README.md new file mode 100644 index 0000000..9793e06 --- /dev/null +++ b/code/chapter05/payment/README.md @@ -0,0 +1,106 @@ + + +## Endpoints + +### GET /payment/api/payments +- Returns all payments in the system + +### GET /payment/api/payments/{id} +- Returns a specific payment by ID + +### GET /payment/api/payments/user/{userId} +- Returns all payments for a specific user + +### GET /payment/api/payments/order/{orderId} +- Returns all payments for a specific order + +### GET /payment/api/payments/status/{status} +- Returns all payments with a specific status + +### POST /payment/api/payments +- Creates a new payment +- Request body: Payment JSON + +### PUT /payment/api/payments/{id} +- Updates an existing payment +- Request body: Updated Payment JSON + +### PATCH /payment/api/payments/{id}/status/{status} +- Updates the status of an existing payment + +### POST /payment/api/payments/{id}/process +- Processes a pending payment + +### DELETE /payment/api/payments/{id} +- Deletes a payment + +## Payment Flow + +1. Create a payment with status `PENDING` +2. Process the payment to change status to `PROCESSING` +3. Payment will automatically be updated to either `COMPLETED` or `FAILED` +4. If needed, payments can be `REFUNDED` or `CANCELLED` + +## Running the Service + +### Local Development + +```bash +./run.sh +``` + +### Docker + +```bash +./run-docker.sh +``` + +## Integration with Other Services + +The Payment Service integrates with: + +- **Order Service**: Updates order status based on payment status +- **User Service**: Validates user information for payment processing + +## Testing + +For testing purposes, payments with amounts ending in `.00` will fail, all others will succeed. + +## Custom ConfigSource + +The Payment Service implements a custom MicroProfile ConfigSource named `PaymentServiceConfigSource` that provides payment-specific configuration with high priority (ordinal: 500). + +### Available Configuration Properties + +| Property | Description | Default Value | +|----------|-------------|---------------| +| payment.gateway.endpoint | Payment gateway endpoint URL | https://secure-payment-gateway.example.com/api/v1 | + +### ConfigSource Endpoints + +The custom ConfigSource can be accessed and modified via the following endpoints: + +#### GET /payment/api/payment-config +- Returns all current payment configuration values + +#### POST /payment/api/payment-config +- Updates a payment configuration value +- Request body: `{"key": "payment.property.name", "value": "new-value"}` + +### Example Usage + +```java +// Inject standard MicroProfile Config +@Inject +@ConfigProperty(name="payment.gateway.endpoint") +String gatewayUrl; + +// Or use the utility class +String url = PaymentConfig.getConfigProperty("payment.gateway.endpoint"); +``` + +The custom ConfigSource provides a higher priority than system properties and environment variables, allowing for service-specific defaults while still enabling override via standard mechanisms. + +## Swagger UI + +OpenAPI documentation is available at: `http://localhost:9050/payment/api/openapi-ui/` diff --git a/code/chapter05/payment/pom.xml b/code/chapter05/payment/pom.xml new file mode 100644 index 0000000..12b8fad --- /dev/null +++ b/code/chapter05/payment/pom.xml @@ -0,0 +1,85 @@ + + + + 4.0.0 + + io.microprofile.tutorial + payment + 1.0-SNAPSHOT + war + + + + UTF-8 + 17 + 17 + + UTF-8 + UTF-8 + + + 9080 + 9081 + + payment + + + + + + + + + org.projectlombok + lombok + 1.18.26 + provided + + + + + jakarta.platform + jakarta.jakartaee-api + 10.0.0 + provided + + + + + org.eclipse.microprofile + microprofile + 6.1 + pom + provided + + + + junit + junit + 4.11 + test + + + + + ${project.artifactId} + + + + io.openliberty.tools + liberty-maven-plugin + 3.11.2 + + mpServer + + + + + org.apache.maven.plugins + maven-war-plugin + 3.4.0 + + + + \ No newline at end of file diff --git a/code/chapter05/payment/run-docker.sh b/code/chapter05/payment/run-docker.sh new file mode 100755 index 0000000..e027baf --- /dev/null +++ b/code/chapter05/payment/run-docker.sh @@ -0,0 +1,23 @@ +#!/bin/bash + +# Script to build and run the Payment service in Docker + +# Stop execution on any error +set -e + +# Navigate to the payment service directory +cd "$(dirname "$0")" + +# Build the project with Maven +echo "Building with Maven..." +mvn clean package + +# Build the Docker image +echo "Building Docker image..." +docker build -t payment-service . + +# Run the Docker container +echo "Starting Docker container..." +docker run -d --name payment-service -p 9050:9050 payment-service + +echo "Payment service is running on http://localhost:9050/payment" diff --git a/code/chapter05/payment/run.sh b/code/chapter05/payment/run.sh new file mode 100755 index 0000000..75fc5f2 --- /dev/null +++ b/code/chapter05/payment/run.sh @@ -0,0 +1,19 @@ +#!/bin/bash + +# Script to build and run the Payment service + +# Stop execution on any error +set -e + +echo "Building and running Payment service..." + +# Navigate to the payment service directory +cd "$(dirname "$0")" + +# Build the project with Maven +echo "Building with Maven..." +mvn clean package + +# Run the application using Liberty Maven plugin +echo "Starting Liberty server..." +mvn liberty:run diff --git a/code/chapter05/payment/src/main/java/io/microprofile/tutorial/PaymentRestApplication.java b/code/chapter05/payment/src/main/java/io/microprofile/tutorial/PaymentRestApplication.java new file mode 100644 index 0000000..9ffd751 --- /dev/null +++ b/code/chapter05/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/chapter05/payment/src/main/java/io/microprofile/tutorial/store/payment/config/PaymentConfig.java b/code/chapter05/payment/src/main/java/io/microprofile/tutorial/store/payment/config/PaymentConfig.java new file mode 100644 index 0000000..c4df4d6 --- /dev/null +++ b/code/chapter05/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/chapter05/payment/src/main/java/io/microprofile/tutorial/store/payment/config/PaymentServiceConfigSource.java b/code/chapter05/payment/src/main/java/io/microprofile/tutorial/store/payment/config/PaymentServiceConfigSource.java new file mode 100644 index 0000000..7d45400 --- /dev/null +++ b/code/chapter05/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 = 50; // 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/chapter05/payment/src/main/java/io/microprofile/tutorial/store/payment/entity/PaymentDetails.java b/code/chapter05/payment/src/main/java/io/microprofile/tutorial/store/payment/entity/PaymentDetails.java new file mode 100644 index 0000000..4b62460 --- /dev/null +++ b/code/chapter05/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/chapter05/payment/src/main/java/io/microprofile/tutorial/store/payment/resource/PaymentConfigResource.java b/code/chapter05/payment/src/main/java/io/microprofile/tutorial/store/payment/resource/PaymentConfigResource.java new file mode 100644 index 0000000..6a4002f --- /dev/null +++ b/code/chapter05/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/chapter05/payment/src/main/java/io/microprofile/tutorial/store/payment/service/PaymentService.java b/code/chapter05/payment/src/main/java/io/microprofile/tutorial/store/payment/service/PaymentService.java new file mode 100644 index 0000000..7e7c6d2 --- /dev/null +++ b/code/chapter05/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/chapter05/payment/src/main/java/io/microprofile/tutorial/store/payment/service/payment.http b/code/chapter05/payment/src/main/java/io/microprofile/tutorial/store/payment/service/payment.http new file mode 100644 index 0000000..98ae2e5 --- /dev/null +++ b/code/chapter05/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/chapter05/payment/src/main/liberty/config/server.xml b/code/chapter05/payment/src/main/liberty/config/server.xml new file mode 100644 index 0000000..707ddda --- /dev/null +++ b/code/chapter05/payment/src/main/liberty/config/server.xml @@ -0,0 +1,18 @@ + + + jakartaEE-10.0 + microProfile-6.1 + restfulWS + jsonp + jsonb + cdi + mpConfig + mpOpenAPI + + + + + + + \ No newline at end of file diff --git a/code/chapter05/payment/src/main/resources/META-INF/microprofile-config.properties b/code/chapter05/payment/src/main/resources/META-INF/microprofile-config.properties new file mode 100644 index 0000000..1a38f24 --- /dev/null +++ b/code/chapter05/payment/src/main/resources/META-INF/microprofile-config.properties @@ -0,0 +1,6 @@ +# microprofile-config.properties +mp.openapi.scan=true +product.maintenanceMode=false + +# Product Service Configuration +payment.gateway.endpoint=https://api.paymentgateway.com/v1 \ No newline at end of file diff --git a/code/chapter05/payment/src/main/resources/META-INF/services/org.eclipse.microprofile.config.spi.ConfigSource b/code/chapter05/payment/src/main/resources/META-INF/services/org.eclipse.microprofile.config.spi.ConfigSource new file mode 100644 index 0000000..9870717 --- /dev/null +++ b/code/chapter05/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/chapter05/payment/src/main/webapp/WEB-INF/web.xml b/code/chapter05/payment/src/main/webapp/WEB-INF/web.xml new file mode 100644 index 0000000..9e4411b --- /dev/null +++ b/code/chapter05/payment/src/main/webapp/WEB-INF/web.xml @@ -0,0 +1,12 @@ + + + Payment Service + + + index.html + index.jsp + + diff --git a/code/chapter05/payment/src/main/webapp/index.html b/code/chapter05/payment/src/main/webapp/index.html new file mode 100644 index 0000000..33086f2 --- /dev/null +++ b/code/chapter05/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/chapter05/payment/src/main/webapp/index.jsp b/code/chapter05/payment/src/main/webapp/index.jsp new file mode 100644 index 0000000..d5de5cb --- /dev/null +++ b/code/chapter05/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/chapter05/run-all-services.sh b/code/chapter05/run-all-services.sh new file mode 100755 index 0000000..6b99326 --- /dev/null +++ b/code/chapter05/run-all-services.sh @@ -0,0 +1,13 @@ +#!/bin/bash + +# Build all projects + +echo "Building Catalog Service..." +cd catalog && mvn clean package && cd .. + +echo "Building Payment Service..." +cd payment && mvn clean package && cd .. + +echo "All services are running:" +echo "- Catalog Service: http://localhost:5050/catalog" +echo "- Payment Service: http://localhost:9050/payment" \ No newline at end of file diff --git a/code/chapter06/catalog/README.adoc b/code/chapter06/catalog/README.adoc new file mode 100644 index 0000000..8af4033 --- /dev/null +++ b/code/chapter06/catalog/README.adoc @@ -0,0 +1,1090 @@ += MicroProfile Catalog Service +:toc: macro +:toclevels: 3 +:icons: font +:source-highlighter: highlight.js +:experimental: + +toc::[] + +== Overview + +The MicroProfile Catalog Service is a modern Jakarta EE 10 application built with MicroProfile 6.1 specifications. This service provides a RESTful API for product catalog management with enhanced MicroProfile features and flexible persistence options. This project demonstrates the key capabilities of MicroProfile OpenAPI, MicroProfile Health, CDI qualifiers, and dual persistence architecture with both JPA/Derby database and in-memory implementations. + +== Features + +* *RESTful API* using Jakarta RESTful Web Services +* *OpenAPI Documentation* with Swagger UI +* *MicroProfile Health* with comprehensive startup, readiness, and liveness checks +* *Dual Persistence Implementation* with configurable JPA/Derby database and in-memory storage options +* *CDI Qualifiers* for dependency injection and implementation switching +* *Derby Database Support* with automatic JAR deployment via Liberty Maven plugin +* *Maintenance Mode* support with configuration-based toggles + +== MicroProfile Features Implemented + +=== MicroProfile OpenAPI + +The application provides OpenAPI documentation for its REST endpoints. API documentation is generated automatically from annotations in the code: + +[source,java] +---- +@GET +@Produces(MediaType.APPLICATION_JSON) +@Operation(summary = "Get all products", description = "Returns a list of all products") +@APIResponses({ + @APIResponse(responseCode = "200", description = "List of products", + content = @Content(mediaType = "application/json", + schema = @Schema(implementation = Product.class))) +}) +public Response getAllProducts() { + // Implementation +} +---- + +The OpenAPI documentation is available at: `/openapi` (in various formats) and `/openapi/ui` (Swagger UI) + +=== Dual Persistence Architecture + +The application implements a flexible persistence layer with two implementations that can be switched via CDI qualifiers: + +1. *JPA/Derby Database Implementation* (Default) - For production use with persistent data storage +2. *In-Memory Implementation* - For development, testing, and scenarios where persistence across restarts is not required + +==== CDI Qualifiers for Implementation Switching + +The application uses CDI qualifiers to select between different persistence implementations: + +[source,java] +---- +// JPA Qualifier +@Qualifier +@Retention(RUNTIME) +@Target({METHOD, FIELD, PARAMETER, TYPE}) +public @interface JPA { +} + +// In-Memory Qualifier +@Qualifier +@Retention(RUNTIME) +@Target({METHOD, FIELD, PARAMETER, TYPE}) +public @interface InMemory { +} +---- + +==== Service Layer with CDI Injection + +The service layer uses CDI qualifiers to inject the appropriate repository implementation: + +[source,java] +---- +@ApplicationScoped +public class ProductService { + + @Inject + @JPA // Uses Derby database implementation by default + private ProductRepositoryInterface repository; + + public List findAllProducts() { + return repository.findAllProducts(); + } + // ... other service methods +} +---- + +==== JPA/Derby Database Implementation + +The JPA implementation provides persistent data storage using Apache Derby database: + +[source,java] +---- +@ApplicationScoped +@JPA +@Transactional +public class ProductJpaRepository implements ProductRepositoryInterface { + + @PersistenceContext(unitName = "catalogPU") + private EntityManager entityManager; + + @Override + public List findAllProducts() { + TypedQuery query = entityManager.createNamedQuery("Product.findAll", Product.class); + return query.getResultList(); + } + + @Override + public Product createProduct(Product product) { + entityManager.persist(product); + return product; + } + // ... other JPA operations +} +---- + +*Key Features of JPA Implementation:* +* Persistent data storage across application restarts +* ACID transactions with @Transactional annotation +* Named queries for efficient database operations +* Automatic schema generation and data loading +* Derby embedded database for simplified deployment + +==== In-Memory Implementation + +The in-memory implementation uses thread-safe collections for fast data access: + +[source,java] +---- +@ApplicationScoped +@InMemory +public class ProductInMemoryRepository implements ProductRepositoryInterface { + + // Thread-safe storage using ConcurrentHashMap + private final Map productsMap = new ConcurrentHashMap<>(); + private final AtomicLong idGenerator = new AtomicLong(1); + + @Override + public List findAllProducts() { + return new ArrayList<>(productsMap.values()); + } + + @Override + public Product createProduct(Product product) { + if (product.getId() == null) { + product.setId(idGenerator.getAndIncrement()); + } + productsMap.put(product.getId(), product); + return product; + } + // ... other in-memory operations +} +---- + +*Key Features of In-Memory Implementation:* +* Fast in-memory access without database I/O +* Thread-safe operations using ConcurrentHashMap and AtomicLong +* No external dependencies or database configuration +* Suitable for development, testing, and stateless deployments + +=== How to Switch Between Implementations + +You can switch between JPA and In-Memory implementations in several ways: + +==== Method 1: CDI Qualifier Injection (Compile Time) + +Change the qualifier in the service class: + +[source,java] +---- +@ApplicationScoped +public class ProductService { + + // For JPA/Derby implementation (default) + @Inject + @JPA + private ProductRepositoryInterface repository; + + // OR for In-Memory implementation + // @Inject + // @InMemory + // private ProductRepositoryInterface repository; +} +---- + +==== Method 2: MicroProfile Config (Runtime) + +Configure the implementation type in `microprofile-config.properties`: + +[source,properties] +---- +# Repository configuration +product.repository.type=JPA # Use JPA/Derby implementation +# product.repository.type=InMemory # Use In-Memory implementation + +# Database configuration +product.database.enabled=true +product.database.name=catalogDB +---- + +The application can use the configuration to determine which implementation to inject. + +==== Method 3: Alternative Annotations (Deployment Time) + +Use CDI @Alternative annotation to enable/disable implementations via beans.xml: + +[source,xml] +---- + + + io.microprofile.tutorial.store.product.repository.ProductInMemoryRepository + +---- + +=== Database Configuration and Setup + +==== Derby Database Configuration + +The Derby database is automatically configured through the Liberty Maven plugin and server.xml: + +*Maven Dependencies and Plugin Configuration:* +[source,xml] +---- + + + + org.apache.derby + derby + 10.16.1.1 + + + + org.apache.derby + derbyshared + 10.16.1.1 + + + + org.apache.derby + derbytools + 10.16.1.1 + + + + + io.openliberty.tools + liberty-maven-plugin + + mpServer + + ${project.build.directory}/liberty/wlp/usr/servers/mpServer/derby + + org.apache.derby + derby + + + org.apache.derby + derbyshared + + + org.apache.derby + derbytools + + + + +---- + +*Server.xml Configuration:* +[source,xml] +---- + + + + + + + + + + + + + + +---- + +*JPA Configuration (persistence.xml):* +[source,xml] +---- + + jdbc/catalogDB + io.microprofile.tutorial.store.product.entity.Product + + + + + + + + + + + + +---- + +==== Implementation Comparison + +[cols="1,1,1", options="header"] +|=== +| Feature | JPA/Derby Implementation | In-Memory Implementation +| Data Persistence | Survives application restarts | Lost on restart +| Performance | Database I/O overhead | Fastest access (memory) +| Configuration | Requires datasource setup | No configuration needed +| Dependencies | Derby JARs, JPA provider | None (Java built-ins) +| Threading | JPA managed transactions | ConcurrentHashMap + AtomicLong +| Development Setup | Database initialization | Immediate startup +| Production Use | Recommended for production | Development/testing only +| Scalability | Database connection limits | Memory limitations +| Data Integrity | ACID transactions | Thread-safe operations +| Error Handling | Database exceptions | Simple validation +|=== + +[source,java] +---- +@ApplicationScoped +public class ProductRepository { + // In-memory storage using ConcurrentHashMap for thread safety + private final Map productsMap = new ConcurrentHashMap<>(); + + // ID generator + private final AtomicLong idGenerator = new AtomicLong(1); + + // CRUD operations... +} +---- + +==== Atomic ID Generation with AtomicLong + +The repository uses `java.util.concurrent.atomic.AtomicLong` for thread-safe ID generation: + +[source,java] +---- +// ID generation in createProduct method +if (product.getId() == null) { + product.setId(idGenerator.getAndIncrement()); +} +---- + +`AtomicLong` provides several key benefits: + +* *Thread Safety*: Guarantees atomic operations without explicit locking +* *Performance*: Uses efficient compare-and-swap (CAS) operations instead of locks +* *Consistency*: Ensures unique, sequential IDs even under concurrent access +* *No Synchronization*: Avoids the overhead of synchronized blocks + +===== Advanced AtomicLong Operations + +The ProductRepository implements an advanced pattern for handling both system-generated and client-provided IDs: + +[source,java] +---- +public Product createProduct(Product product) { + // Generate ID if not provided + if (product.getId() == null) { + product.setId(idGenerator.getAndIncrement()); + } else { + // Update idGenerator if the provided ID is greater than current + long nextId = product.getId() + 1; + while (true) { + long currentId = idGenerator.get(); + if (nextId <= currentId || idGenerator.compareAndSet(currentId, nextId)) { + break; + } + } + } + + productsMap.put(product.getId(), product); + return product; +} +---- + +This implementation demonstrates several key AtomicLong patterns: + +1. *Initialization*: `AtomicLong` is initialized with a starting value of 1 to avoid using 0 as a valid ID +2. *getAndIncrement*: Atomically returns the current value and increments it in one operation +3. *compareAndSet*: Safely updates the ID generator if a client provides a higher ID value, preventing ID collisions +4. *Retry Logic*: Uses a spinlock pattern for handling concurrent updates to the AtomicLong when needed + +The initialization of the idGenerator with a specific starting value ensures the IDs begin at a predictable value: + +[source,java] +---- +private final AtomicLong idGenerator = new AtomicLong(1); // Start IDs at 1 +---- + +This approach ensures that each product receives a unique ID without risk of duplicate IDs in a concurrent environment. + +Key benefits of this in-memory persistence approach: + +* *Simplicity*: No need for database configuration or ORM mapping +* *Performance*: Fast in-memory access without network or disk I/O +* *Thread Safety*: ConcurrentHashMap provides thread-safe operations without blocking +* *Scalability*: Suitable for containerized deployments + +==== Thread Safety Implementation Details + +The implementation ensures thread safety through multiple mechanisms: + +1. *ConcurrentHashMap*: Uses lock striping to allow concurrent reads and thread-safe writes +2. *AtomicLong*: Provides atomic operations for ID generation +3. *Immutable Returns*: Returns new collections rather than internal references: ++ +[source,java] +---- +// Returns a copy of the collection to prevent concurrent modification issues +public List findAllProducts() { + return new ArrayList<>(productsMap.values()); +} +---- + +4. *Atomic Operations*: Uses atomic map operations like `putIfAbsent` and `compute` where appropriate + +NOTE: This implementation is suitable for development, testing, and scenarios where persistence across restarts is not required. + +=== MicroProfile Health + +The application implements comprehensive health monitoring using MicroProfile Health specifications with three types of health checks: + +==== Startup Health Check + +The startup health check verifies that the JPA EntityManagerFactory is properly initialized during application startup: + +[source,java] +---- +@Startup +@ApplicationScoped +public class ProductServiceStartupCheck implements HealthCheck { + + @PersistenceUnit + private EntityManagerFactory emf; + + @Override + public HealthCheckResponse call() { + if (emf != null && emf.isOpen()) { + return HealthCheckResponse.up("ProductServiceStartupCheck"); + } else { + return HealthCheckResponse.down("ProductServiceStartupCheck"); + } + } +} +---- + +*Key Features:* +* Validates EntityManagerFactory initialization +* Ensures JPA persistence layer is ready +* Runs during application startup phase +* Critical for database-dependent applications + +==== Readiness Health Check + +The readiness health check verifies database connectivity and ensures the service is ready to handle requests: + +[source,java] +---- +@Readiness +@ApplicationScoped +public class ProductServiceHealthCheck implements HealthCheck { + + @PersistenceContext + EntityManager entityManager; + + @Override + public HealthCheckResponse call() { + if (isDatabaseConnectionHealthy()) { + return HealthCheckResponse.named("ProductServiceReadinessCheck") + .up() + .build(); + } else { + return HealthCheckResponse.named("ProductServiceReadinessCheck") + .down() + .build(); + } + } + + private boolean isDatabaseConnectionHealthy(){ + try { + // Perform a lightweight query to check the database connection + entityManager.find(Product.class, 1L); + return true; + } catch (Exception e) { + System.err.println("Database connection is not healthy: " + e.getMessage()); + return false; + } + } +} +---- + +*Key Features:* +* Tests actual database connectivity via EntityManager +* Performs lightweight database query +* Indicates service readiness to receive traffic +* Essential for load balancer health routing + +==== Liveness Health Check + +The liveness health check monitors system resources and memory availability: + +[source,java] +---- +@Liveness +@ApplicationScoped +public class ProductServiceLivenessCheck implements HealthCheck { + + @Override + public HealthCheckResponse call() { + Runtime runtime = Runtime.getRuntime(); + long maxMemory = runtime.maxMemory(); + long allocatedMemory = runtime.totalMemory(); + long freeMemory = runtime.freeMemory(); + long usedMemory = allocatedMemory - freeMemory; + long availableMemory = maxMemory - usedMemory; + + long threshold = 100 * 1024 * 1024; // threshold: 100MB + + HealthCheckResponseBuilder responseBuilder = HealthCheckResponse.named("systemResourcesLiveness") + .withData("FreeMemory", freeMemory) + .withData("MaxMemory", maxMemory) + .withData("AllocatedMemory", allocatedMemory) + .withData("UsedMemory", usedMemory) + .withData("AvailableMemory", availableMemory); + + if (availableMemory > threshold) { + responseBuilder = responseBuilder.up(); + } else { + responseBuilder = responseBuilder.down(); + } + + return responseBuilder.build(); + } +} +---- + +*Key Features:* +* Monitors JVM memory usage and availability +* Uses fixed 100MB threshold for available memory +* Provides comprehensive memory diagnostics +* Indicates if application should be restarted + +==== Health Check Endpoints + +The health checks are accessible via standard MicroProfile Health endpoints: + +* `/health` - Overall health status (all checks) +* `/health/live` - Liveness checks only +* `/health/ready` - Readiness checks only +* `/health/started` - Startup checks only + +Example health check response: +[source,json] +---- +{ + "status": "UP", + "checks": [ + { + "name": "ProductServiceStartupCheck", + "status": "UP" + }, + { + "name": "ProductServiceReadinessCheck", + "status": "UP" + }, + { + "name": "systemResourcesLiveness", + "status": "UP", + "data": { + "FreeMemory": 524288000, + "MaxMemory": 2147483648, + "AllocatedMemory": 1073741824, + "UsedMemory": 549453824, + "AvailableMemory": 1598029824 + } + } + ] +} +---- + +==== Health Check Benefits + +The comprehensive health monitoring provides: + +* *Startup Validation*: Ensures all dependencies are initialized before serving traffic +* *Readiness Monitoring*: Validates service can handle requests (database connectivity) +* *Liveness Detection*: Identifies when application needs restart (memory issues) +* *Operational Visibility*: Detailed diagnostics for troubleshooting +* *Container Orchestration*: Kubernetes/Docker health probe integration +* *Load Balancer Integration*: Traffic routing based on health status + +=== MicroProfile Config + +The application uses MicroProfile Config to externalize configuration: + +[source,properties] +---- +# Enable OpenAPI scanning +mp.openapi.scan=true + +# Maintenance mode configuration +product.maintenanceMode=false +product.maintenanceMessage=The product catalog service is currently in maintenance mode. Please try again later. +---- + +The maintenance mode configuration allows dynamic control of service availability: + +* `product.maintenanceMode` - When set to `true`, the service returns a 503 Service Unavailable response +* `product.maintenanceMessage` - Customizable message displayed when the service is in maintenance mode + +==== Maintenance Mode Implementation + +The service checks the maintenance mode configuration before processing requests: + +[source,java] +---- +@Inject +@ConfigProperty(name="product.maintenanceMode", defaultValue="false") +private boolean maintenanceMode; + +@Inject +@ConfigProperty(name="product.maintenanceMessage", + defaultValue="The product catalog service is currently in maintenance mode. Please try again later.") +private String maintenanceMessage; + +// In request handling method +if (maintenance.isMaintenanceMode()) { + return Response + .status(Response.Status.SERVICE_UNAVAILABLE) + .entity(maintenance.getMaintenanceMessage()) + .build(); +} +---- + +This pattern enables: + +* Graceful service degradation during maintenance periods +* Dynamic control without redeployment (when using external configuration sources) +* Clear communication to API consumers + +== Architecture + +The application follows a layered architecture pattern: + +* *REST Layer* (`ProductResource`) - Handles HTTP requests and responses +* *Service Layer* (`ProductService`) - Contains business logic +* *Repository Layer* (`ProductRepository`) - Manages data access with in-memory storage +* *Model Layer* (`Product`) - Represents the business entities + +=== Persistence Evolution + +This application originally used JPA with Derby for persistence, but has been refactored to use an in-memory implementation: + +[cols="1,1", options="header"] +|=== +| Original JPA/Derby | Current In-Memory Implementation +| Required database configuration | No database configuration needed +| Persistence across restarts | Data reset on restart +| Used EntityManager and transactions | Uses ConcurrentHashMap and AtomicLong +| Required datasource in server.xml | No datasource configuration required +| Complex error handling | Simplified error handling +|=== + +Key architectural benefits of this change: + +* *Simplified Deployment*: No external database required +* *Faster Startup*: No database initialization delay +* *Reduced Dependencies*: Fewer libraries and configurations +* *Easier Testing*: No test database setup needed +* *Consistent Development Environment*: Same behavior across all development machines + +=== Containerization with Docker + +The application can be packaged into a Docker container: + +[source,bash] +---- +# Build the application +mvn clean package + +# Build the Docker image +docker build -t catalog-service . + +# Run the container +docker run -d -p 5050:5050 --name catalog-service catalog-service +---- + +==== AtomicLong in Containerized Environments + +When running the application in Docker or Kubernetes, some important considerations about AtomicLong behavior: + +1. *Per-Container State*: Each container has its own AtomicLong instance and state +2. *ID Collisions in Scaling*: When running multiple replicas, IDs are only unique within each container +3. *Persistence and Restarts*: AtomicLong resets on container restart, potentially causing ID reuse + +To handle these issues in production multi-container environments: + +* *External ID Generation*: Consider using a distributed ID generator service +* *Database Sequences*: For database implementations, use database sequences +* *Universally Unique IDs*: Consider UUIDs instead of sequential numeric IDs +* *Centralized Counter Service*: Use Redis or other distributed counter + +Example of adapting the code for distributed environments: + +[source,java] +---- +// Using UUIDs for distributed environments +private String generateId() { + return UUID.randomUUID().toString(); +} +---- + +== Development Workflow + +=== Running Locally + +To run the application in development mode: + +[source,bash] +---- +mvn clean liberty:dev +---- + +This starts the server in development mode, which: + +* Automatically deploys your code changes +* Provides hot reload capability +* Enables a debugger on port 7777 + +== Project Structure + +[source] +---- +catalog/ +├── src/ +│ ├── main/ +│ │ ├── java/ +│ │ │ └── io/microprofile/tutorial/store/ +│ │ │ └── product/ +│ │ │ ├── entity/ # Domain entities +│ │ │ ├── resource/ # REST resources +│ │ │ └── ProductRestApplication.java +│ │ ├── liberty/ +│ │ │ └── config/ +│ │ │ └── server.xml # Liberty server configuration +│ │ ├── resources/ +│ │ │ └── META-INF/ +│ │ │ └── microprofile-config.properties +│ │ └── webapp/ # Web resources +│ │ ├── index.html # Landing page with API documentation +│ │ └── WEB-INF/ +│ │ └── web.xml # Web application configuration +│ └── test/ # Test classes +└── pom.xml # Maven build file +---- + +== Getting Started + +=== Prerequisites + +* JDK 17+ +* Maven 3.8+ + +=== Building and Running + +To build and run the application: + +[source,bash] +---- +# Clone the repository +git clone https://github.com/microprofile/microprofile-tutorial.git +cd code/catalog + +# Build the application +mvn clean package + +# Run the application +mvn liberty:run +---- + +=== Testing the Application + +==== Testing MicroProfile Features + +[source,bash] +---- +# OpenAPI documentation +curl -X GET http://localhost:5050/openapi + +# Check if service is in maintenance mode +curl -X GET http://localhost:5050/api/products + +# Health check endpoints +curl -X GET http://localhost:5050/health +curl -X GET http://localhost:5050/health/live +curl -X GET http://localhost:5050/health/ready +curl -X GET http://localhost:5050/health/started +---- + +*Health Check Testing:* +* `/health` - Overall health status with all checks +* `/health/live` - Liveness checks (memory monitoring) +* `/health/ready` - Readiness checks (database connectivity) +* `/health/started` - Startup checks (EntityManagerFactory initialization) + +To view the Swagger UI, open the following URL in your browser: +http://localhost:5050/openapi/ui + +To view the landing page with API documentation: +http://localhost:5050/ + +== Server Configuration + +The application uses the following Liberty server configuration: + +[source,xml] +---- + + + jakartaEE-10.0 + microProfile-6.1 + restfulWS + jsonp + jsonb + cdi + mpConfig + mpOpenAPI + mpHealth + + + + + +---- + +== Development + +=== Adding a New Endpoint + +To add a new endpoint: + +1. Create a new method in the `ProductResource` class +2. Add appropriate Jakarta Restful Web Service annotations +3. Add OpenAPI annotations for documentation +4. Implement the business logic + +Example: + +[source,java] +---- +@GET +@Path("/search") +@Produces(MediaType.APPLICATION_JSON) +@Operation(summary = "Search products", description = "Search products by name") +@APIResponses({ + @APIResponse(responseCode = "200", description = "Products matching search criteria") +}) +public Response searchProducts(@QueryParam("name") String name) { + List matchingProducts = products.stream() + .filter(p -> p.getName().toLowerCase().contains(name.toLowerCase())) + .collect(Collectors.toList()); + return Response.ok(matchingProducts).build(); +} +---- + +=== Performance Considerations + +The in-memory data store provides excellent performance for read operations, but there are important considerations: + +* *Memory Usage*: Large data sets may consume significant memory +* *Persistence*: Data is lost when the application restarts +* *Scalability*: In a multi-instance deployment, each instance will have its own data store + +For production scenarios requiring data persistence, consider: + +1. Adding a database layer (PostgreSQL, MongoDB, etc.) +2. Implementing a distributed cache (Hazelcast, Redis, etc.) +3. Adding data synchronization between instances + +=== Concurrency Implementation Details + +==== AtomicLong vs Synchronized Counter + +The repository uses `AtomicLong` rather than traditional synchronized counters: + +[cols="1,1", options="header"] +|=== +| Traditional Approach | AtomicLong Approach +| `private long counter = 0;` | `private final AtomicLong idGenerator = new AtomicLong(1);` +| `synchronized long getNextId() { return ++counter; }` | `long nextId = idGenerator.getAndIncrement();` +| Locks entire method | Lock-free operation +| Subject to contention | Uses CPU compare-and-swap +| Performance degrades with multiple threads | Maintains performance under concurrency +|=== + +==== AtomicLong vs Other Concurrency Options + +[cols="1,1,1,1", options="header"] +|=== +| Feature | AtomicLong | Synchronized | java.util.concurrent.locks.Lock +| Type | Non-blocking | Intrinsic lock | Explicit lock +| Granularity | Single variable | Method/block | Customizable +| Performance under contention | High | Lower | Medium +| Visibility guarantee | Yes | Yes | Yes +| Atomicity guarantee | Yes | Yes | Yes +| Fairness policy | No | No | Optional +| Try/timeout support | Yes (compareAndSet) | No | Yes +| Multiple operations atomicity | Limited | Yes | Yes +| Implementation complexity | Simple | Simple | Complex +|=== + +===== When to Choose AtomicLong + +* *High-Contention Scenarios*: When many threads need to access/modify a counter +* *Single Variable Operations*: When only one variable needs atomic operations +* *Performance-Critical Code*: When minimizing lock contention is essential +* *Read-Heavy Workloads*: When reads significantly outnumber writes + +For this in-memory product repository, AtomicLong provides an optimal balance of safety and performance. + +==== Implementation in createProduct Method + +The ID generation logic handles both automatic and manual ID assignment: + +[source,java] +---- +public Product createProduct(Product product) { + // Generate ID if not provided + if (product.getId() == null) { + product.setId(idGenerator.getAndIncrement()); + } else { + // Update idGenerator if the provided ID is greater than current + long nextId = product.getId() + 1; + while (true) { + long currentId = idGenerator.get(); + if (nextId <= currentId || idGenerator.compareAndSet(currentId, nextId)) { + break; + } + } + } + + productsMap.put(product.getId(), product); + return product; +} +---- + +This implementation ensures ID integrity while supporting both system-generated and client-provided IDs. + +This enables scanning of OpenAPI annotations in the application. + +== Troubleshooting + +=== Common Issues + +* *OpenAPI documentation not available*: Make sure `mp.openapi.scan=true` is set in the properties file +* *Concurrent modification exceptions*: Ensure proper use of thread-safe collections and operations +* *Service always in maintenance mode*: Check the `product.maintenanceMode` property in `microprofile-config.properties` +* *API returning 503 responses*: The service is likely in maintenance mode; set `product.maintenanceMode=false` in configuration +* *OpenAPI documentation not available*: Make sure `mp.openapi.scan=true` is set in the properties file +* *Concurrent modification exceptions*: Ensure proper use of thread-safe collections and operations + +=== Thread Safety Troubleshooting + +If experiencing concurrency issues: + +1. *Verify AtomicLong Usage*: Ensure all ID generation uses `AtomicLong.getAndIncrement()` instead of manual increment +2. *Check Collection Returns*: Always return copies of collections, not direct references: ++ +[source,java] +---- +public List findAllProducts() { + return new ArrayList<>(productsMap.values()); // Correct: returns a new copy +} +---- + +3. *Use ConcurrentHashMap Methods*: Prefer atomic methods like `compute`, `computeIfAbsent`, or `computeIfPresent` for complex operations +4. *Avoid Iteration + Modification*: Don't modify the map while iterating over it + +=== Understanding AtomicLong Internals + +If you need to debug issues with AtomicLong, understanding its internal mechanisms is helpful: + +==== Compare-And-Swap Operation + +AtomicLong relies on hardware-level atomic instructions, specifically Compare-And-Swap (CAS): + +[source,text] +---- +function CAS(address, expected, new): + atomically: + if memory[address] == expected: + memory[address] = new + return true + else: + return false +---- + +The implementation of `getAndIncrement()` uses this mechanism: + +[source,java] +---- +// Simplified implementation of getAndIncrement +public long getAndIncrement() { + while (true) { + long current = get(); + long next = current + 1; + if (compareAndSet(current, next)) + return current; + } +} +---- + +==== Memory Ordering and Visibility + +AtomicLong ensures that memory visibility follows the Java Memory Model: + +* All writes to the AtomicLong by one thread are visible to reads from other threads +* Memory barriers are established when performing atomic operations +* Volatile semantics are guaranteed without using the volatile keyword + +==== Diagnosing AtomicLong Issues + +1. *Unexpected ID Values*: Check for manual ID assignment bypassing the AtomicLong +2. *Duplicate IDs*: Verify the initialization value and ensure all ID assignments go through AtomicLong +3. *Performance Issues*: Look for excessive contention (many threads updating simultaneously) + +=== Logs + +Server logs can be found at: + +[source] +---- +target/liberty/wlp/usr/servers/defaultServer/logs/ +---- + +== Resources + +* https://microprofile.io/[MicroProfile] + +=== HTML Landing Page + +The application includes a user-friendly HTML landing page (`index.html`) that provides: + +* Service overview with comprehensive documentation +* API endpoints documentation with methods and descriptions +* Interactive examples for all API operations +* Links to OpenAPI/Swagger documentation + +==== Maintenance Mode Configuration in the UI + +The index.html page is designed to work seamlessly with the maintenance mode configuration. When maintenance mode is enabled via the `product.maintenanceMode` property, all API endpoints return a 503 Service Unavailable response with the configured maintenance message. + +The landing page displays comprehensive documentation about the API regardless of the maintenance state, allowing developers to continue learning about the API even when the service is undergoing maintenance. + +Key features of the landing page: + +* *Responsive Design*: Works well on desktop and mobile devices +* *Comprehensive API Documentation*: All endpoints with sample requests and responses +* *Interactive Examples*: Detailed sample requests and responses for each endpoint +* *Modern Styling*: Clean, professional appearance with card-based layout + +The landing page is configured as the welcome file in `web.xml`: + +[source,xml] +---- + + index.html + +---- + +This provides a user-friendly entry point for API consumers and developers. + + diff --git a/code/chapter06/catalog/pom.xml b/code/chapter06/catalog/pom.xml new file mode 100644 index 0000000..65fc473 --- /dev/null +++ b/code/chapter06/catalog/pom.xml @@ -0,0 +1,111 @@ + + + 4.0.0 + + io.microprofile.tutorial + catalog + 1.0-SNAPSHOT + war + + + + + 17 + 17 + + UTF-8 + UTF-8 + + + 5050 + 5051 + + catalog + + + + + + + org.projectlombok + lombok + 1.18.26 + provided + + + + + jakarta.platform + jakarta.jakartaee-api + 10.0.0 + provided + + + + + org.eclipse.microprofile + microprofile + 6.1 + pom + provided + + + + + org.apache.derby + derby + 10.16.1.1 + + + + + org.apache.derby + derbyshared + 10.16.1.1 + + + + + org.apache.derby + derbytools + 10.16.1.1 + + + + + ${project.artifactId} + + + + io.openliberty.tools + liberty-maven-plugin + 3.11.2 + + mpServer + + ${project.build.directory}/liberty/wlp/usr/servers/mpServer/derby + + org.apache.derby + derby + + + org.apache.derby + derbyshared + + + org.apache.derby + derbytools + + + + + + + org.apache.maven.plugins + maven-war-plugin + 3.4.0 + + + + \ No newline at end of file diff --git a/code/chapter06/catalog/src/main/java/io/microprofile/tutorial/store/product/ProductRestApplication.java b/code/chapter06/catalog/src/main/java/io/microprofile/tutorial/store/product/ProductRestApplication.java new file mode 100644 index 0000000..9759e1f --- /dev/null +++ b/code/chapter06/catalog/src/main/java/io/microprofile/tutorial/store/product/ProductRestApplication.java @@ -0,0 +1,9 @@ +package io.microprofile.tutorial.store.product; + +import jakarta.ws.rs.ApplicationPath; +import jakarta.ws.rs.core.Application; + +@ApplicationPath("/api") +public class ProductRestApplication extends Application { + // No additional configuration is needed here +} \ No newline at end of file diff --git a/code/chapter06/catalog/src/main/java/io/microprofile/tutorial/store/product/entity/Product.java b/code/chapter06/catalog/src/main/java/io/microprofile/tutorial/store/product/entity/Product.java new file mode 100644 index 0000000..c6fe0f3 --- /dev/null +++ b/code/chapter06/catalog/src/main/java/io/microprofile/tutorial/store/product/entity/Product.java @@ -0,0 +1,144 @@ +package io.microprofile.tutorial.store.product.entity; + +import jakarta.persistence.*; +import jakarta.validation.constraints.*; +import java.math.BigDecimal; + +/** + * Product entity representing a product in the catalog. + * This entity is mapped to the PRODUCTS table in the database. + */ +@Entity +@Table(name = "PRODUCTS") +@NamedQueries({ + @NamedQuery(name = "Product.findAll", query = "SELECT p FROM Product p"), + @NamedQuery(name = "Product.findById", query = "SELECT p FROM Product p WHERE p.id = :id"), + @NamedQuery(name = "Product.findByName", query = "SELECT p FROM Product p WHERE p.name LIKE :name"), + @NamedQuery(name = "Product.searchProducts", + query = "SELECT p FROM Product p WHERE " + + "(:name IS NULL OR LOWER(p.name) LIKE :namePattern) AND " + + "(:description IS NULL OR LOWER(p.description) LIKE :descriptionPattern) AND " + + "(:minPrice IS NULL OR p.price >= :minPrice) AND " + + "(:maxPrice IS NULL OR p.price <= :maxPrice)") +}) +public class Product { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "ID") + private Long id; + + @NotNull(message = "Product name cannot be null") + @NotBlank(message = "Product name cannot be blank") + @Size(min = 1, max = 100, message = "Product name must be between 1 and 100 characters") + @Column(name = "NAME", nullable = false, length = 100) + private String name; + + @Size(max = 500, message = "Product description cannot exceed 500 characters") + @Column(name = "DESCRIPTION", length = 500) + private String description; + + @NotNull(message = "Product price cannot be null") + @DecimalMin(value = "0.0", inclusive = false, message = "Product price must be greater than 0") + @Column(name = "PRICE", nullable = false, precision = 10, scale = 2) + private BigDecimal price; + + // Default constructor + public Product() { + } + + // Constructor with all fields + public Product(Long id, String name, String description, BigDecimal price) { + this.id = id; + this.name = name; + this.description = description; + this.price = price; + } + + // Constructor without ID (for new entities) + public Product(String name, String description, BigDecimal price) { + this.name = name; + this.description = description; + this.price = price; + } + + // Getters and setters + public Long getId() { + return id; + } + + public void setId(Long id) { + this.id = id; + } + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + + public String getDescription() { + return description; + } + + public void setDescription(String description) { + this.description = description; + } + + public BigDecimal getPrice() { + return price; + } + + public void setPrice(BigDecimal price) { + this.price = price; + } + + @Override + public String toString() { + return "Product{" + + "id=" + id + + ", name='" + name + '\'' + + ", description='" + description + '\'' + + ", price=" + price + + '}'; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (!(o instanceof Product)) return false; + Product product = (Product) o; + return id != null && id.equals(product.id); + } + + @Override + public int hashCode() { + return getClass().hashCode(); + } + + /** + * Constructor that accepts Double price for backward compatibility + */ + public Product(Long id, String name, String description, Double price) { + this.id = id; + this.name = name; + this.description = description; + this.price = price != null ? BigDecimal.valueOf(price) : null; + } + + /** + * Get price as Double for backward compatibility + */ + public Double getPriceAsDouble() { + return price != null ? price.doubleValue() : null; + } + + /** + * Set price from Double for backward compatibility + */ + public void setPriceFromDouble(Double price) { + this.price = price != null ? BigDecimal.valueOf(price) : null; + } +} diff --git a/code/chapter06/catalog/src/main/java/io/microprofile/tutorial/store/product/health/ProductServiceHealthCheck.java b/code/chapter06/catalog/src/main/java/io/microprofile/tutorial/store/product/health/ProductServiceHealthCheck.java new file mode 100644 index 0000000..fd94761 --- /dev/null +++ b/code/chapter06/catalog/src/main/java/io/microprofile/tutorial/store/product/health/ProductServiceHealthCheck.java @@ -0,0 +1,45 @@ +package io.microprofile.tutorial.store.product.health; + +import io.microprofile.tutorial.store.product.entity.Product; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.persistence.EntityManager; +import jakarta.persistence.PersistenceContext; +import org.eclipse.microprofile.health.HealthCheck; +import org.eclipse.microprofile.health.HealthCheckResponse; +import org.eclipse.microprofile.health.Readiness; + +/** + * Readiness health check for the Product Catalog service. + * Provides database connection health checks to monitor service health and availability. + */ +@Readiness +@ApplicationScoped +public class ProductServiceHealthCheck implements HealthCheck { + + @PersistenceContext + EntityManager entityManager; + + @Override + public HealthCheckResponse call() { + if (isDatabaseConnectionHealthy()) { + return HealthCheckResponse.named("ProductServiceReadinessCheck") + .up() + .build(); + } else { + return HealthCheckResponse.named("ProductServiceReadinessCheck") + .down() + .build(); + } + } + + private boolean isDatabaseConnectionHealthy(){ + try { + // Perform a lightweight query to check the database connection + entityManager.find(Product.class, 1L); + return true; + } catch (Exception e) { + System.err.println("Database connection is not healthy: " + e.getMessage()); + return false; + } + } +} diff --git a/code/chapter06/catalog/src/main/java/io/microprofile/tutorial/store/product/health/ProductServiceLivenessCheck.java b/code/chapter06/catalog/src/main/java/io/microprofile/tutorial/store/product/health/ProductServiceLivenessCheck.java new file mode 100644 index 0000000..c7d6e65 --- /dev/null +++ b/code/chapter06/catalog/src/main/java/io/microprofile/tutorial/store/product/health/ProductServiceLivenessCheck.java @@ -0,0 +1,47 @@ +package io.microprofile.tutorial.store.product.health; + +import jakarta.enterprise.context.ApplicationScoped; +import org.eclipse.microprofile.health.HealthCheck; +import org.eclipse.microprofile.health.HealthCheckResponse; +import org.eclipse.microprofile.health.HealthCheckResponseBuilder; +import org.eclipse.microprofile.health.Liveness; + +/** + * Liveness health check for the Product Catalog service. + * Verifies that the application is running and not in a failed state. + * This check should only fail if the application is completely broken. + */ +@Liveness +@ApplicationScoped +public class ProductServiceLivenessCheck implements HealthCheck { + + @Override + public HealthCheckResponse call() { + Runtime runtime = Runtime.getRuntime(); + long maxMemory = runtime.maxMemory(); // Maximum amount of memory the JVM will attempt to use + long allocatedMemory = runtime.totalMemory(); // Total memory currently allocated to the JVM + long freeMemory = runtime.freeMemory(); // Amount of free memory within the allocated memory + long usedMemory = allocatedMemory - freeMemory; // Actual memory used + long availableMemory = maxMemory - usedMemory; // Total available memory + + long threshold = 100 * 1024 * 1024; // threshold: 100MB + + // Including diagnostic data in the response + HealthCheckResponseBuilder responseBuilder = HealthCheckResponse.named("systemResourcesLiveness") + .withData("FreeMemory", freeMemory) + .withData("MaxMemory", maxMemory) + .withData("AllocatedMemory", allocatedMemory) + .withData("UsedMemory", usedMemory) + .withData("AvailableMemory", availableMemory); + + if (availableMemory > threshold) { + // The system is considered live + responseBuilder = responseBuilder.up(); + } else { + // The system is not live. + responseBuilder = responseBuilder.down(); + } + + return responseBuilder.build(); + } +} diff --git a/code/chapter06/catalog/src/main/java/io/microprofile/tutorial/store/product/health/ProductServiceStartupCheck.java b/code/chapter06/catalog/src/main/java/io/microprofile/tutorial/store/product/health/ProductServiceStartupCheck.java new file mode 100644 index 0000000..84f22b1 --- /dev/null +++ b/code/chapter06/catalog/src/main/java/io/microprofile/tutorial/store/product/health/ProductServiceStartupCheck.java @@ -0,0 +1,29 @@ +package io.microprofile.tutorial.store.product.health; + +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.persistence.EntityManagerFactory; +import jakarta.persistence.PersistenceUnit; +import org.eclipse.microprofile.health.HealthCheck; +import org.eclipse.microprofile.health.HealthCheckResponse; +import org.eclipse.microprofile.health.Startup; + +/** + * Startup health check for the Product Catalog service. + * Verifies that the EntityManagerFactory is properly initialized during application startup. + */ +@Startup +@ApplicationScoped +public class ProductServiceStartupCheck implements HealthCheck{ + + @PersistenceUnit + private EntityManagerFactory emf; + + @Override + public HealthCheckResponse call() { + if (emf != null && emf.isOpen()) { + return HealthCheckResponse.up("ProductServiceStartupCheck"); + } else { + return HealthCheckResponse.down("ProductServiceStartupCheck"); + } + } +} diff --git a/code/chapter06/catalog/src/main/java/io/microprofile/tutorial/store/product/repository/InMemory.java b/code/chapter06/catalog/src/main/java/io/microprofile/tutorial/store/product/repository/InMemory.java new file mode 100644 index 0000000..b322ccf --- /dev/null +++ b/code/chapter06/catalog/src/main/java/io/microprofile/tutorial/store/product/repository/InMemory.java @@ -0,0 +1,16 @@ +package io.microprofile.tutorial.store.product.repository; + +import jakarta.inject.Qualifier; +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** + * CDI qualifier for in-memory repository implementation. + */ +@Qualifier +@Retention(RetentionPolicy.RUNTIME) +@Target({ElementType.FIELD, ElementType.METHOD, ElementType.TYPE, ElementType.PARAMETER}) +public @interface InMemory { +} diff --git a/code/chapter06/catalog/src/main/java/io/microprofile/tutorial/store/product/repository/JPA.java b/code/chapter06/catalog/src/main/java/io/microprofile/tutorial/store/product/repository/JPA.java new file mode 100644 index 0000000..fd4a6bd --- /dev/null +++ b/code/chapter06/catalog/src/main/java/io/microprofile/tutorial/store/product/repository/JPA.java @@ -0,0 +1,16 @@ +package io.microprofile.tutorial.store.product.repository; + +import jakarta.inject.Qualifier; +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** + * CDI qualifier for JPA repository implementation. + */ +@Qualifier +@Retention(RetentionPolicy.RUNTIME) +@Target({ElementType.FIELD, ElementType.METHOD, ElementType.TYPE, ElementType.PARAMETER}) +public @interface JPA { +} diff --git a/code/chapter06/catalog/src/main/java/io/microprofile/tutorial/store/product/repository/ProductInMemoryRepository.java b/code/chapter06/catalog/src/main/java/io/microprofile/tutorial/store/product/repository/ProductInMemoryRepository.java new file mode 100644 index 0000000..a15ef9a --- /dev/null +++ b/code/chapter06/catalog/src/main/java/io/microprofile/tutorial/store/product/repository/ProductInMemoryRepository.java @@ -0,0 +1,140 @@ +package io.microprofile.tutorial.store.product.repository; + +import io.microprofile.tutorial.store.product.entity.Product; +import jakarta.enterprise.context.ApplicationScoped; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.atomic.AtomicLong; +import java.util.logging.Logger; +import java.util.stream.Collectors; + +/** + * In-memory repository implementation for Product entity. + * Provides in-memory persistence operations using ConcurrentHashMap. + * This is used as a fallback when JPA is not available. + */ +@ApplicationScoped +@InMemory +public class ProductInMemoryRepository implements ProductRepositoryInterface { + + private static final Logger LOGGER = Logger.getLogger(ProductInMemoryRepository.class.getName()); + + // In-memory storage using ConcurrentHashMap for thread safety + private final Map productsMap = new ConcurrentHashMap<>(); + + // ID generator + private final AtomicLong idGenerator = new AtomicLong(1); + + /** + * Constructor with sample data initialization. + */ + public ProductInMemoryRepository() { + // Initialize with sample products using the Double constructor for compatibility + createProduct(new Product(null, "iPhone", "Apple iPhone 15", 999.99)); + createProduct(new Product(null, "MacBook", "Apple MacBook Air", 1299.0)); + createProduct(new Product(null, "iPad", "Apple iPad Pro", 799.0)); + LOGGER.info("ProductInMemoryRepository initialized with sample products"); + } + + /** + * Retrieves all products. + * + * @return List of all products + */ + public List findAllProducts() { + LOGGER.fine("Repository: Finding all products"); + return new ArrayList<>(productsMap.values()); + } + + /** + * Retrieves a product by ID. + * + * @param id Product ID + * @return The product or null if not found + */ + public Product findProductById(Long id) { + LOGGER.fine("Repository: Finding product with ID: " + id); + return productsMap.get(id); + } + + /** + * Creates a new product. + * + * @param product Product data to create + * @return The created product with ID + */ + public Product createProduct(Product product) { + // Generate ID if not provided + if (product.getId() == null) { + product.setId(idGenerator.getAndIncrement()); + } else { + // Update idGenerator if the provided ID is greater than current + long nextId = product.getId() + 1; + while (true) { + long currentId = idGenerator.get(); + if (nextId <= currentId || idGenerator.compareAndSet(currentId, nextId)) { + break; + } + } + } + + LOGGER.fine("Repository: Creating product with ID: " + product.getId()); + productsMap.put(product.getId(), product); + return product; + } + + /** + * Updates an existing product. + * + * @param product Updated product data + * @return The updated product or null if not found + */ + public Product updateProduct(Product product) { + Long id = product.getId(); + if (id != null && productsMap.containsKey(id)) { + LOGGER.fine("Repository: Updating product with ID: " + id); + productsMap.put(id, product); + return product; + } + LOGGER.warning("Repository: Product not found for update, ID: " + id); + return null; + } + + /** + * Deletes a product by ID. + * + * @param id ID of the product to delete + * @return true if deleted, false if not found + */ + public boolean deleteProduct(Long id) { + if (productsMap.containsKey(id)) { + LOGGER.fine("Repository: Deleting product with ID: " + id); + productsMap.remove(id); + return true; + } + LOGGER.warning("Repository: Product not found for deletion, ID: " + id); + return false; + } + + /** + * Searches for products by criteria. + * + * @param name Product name (optional) + * @param description Product description (optional) + * @param minPrice Minimum price (optional) + * @param maxPrice Maximum price (optional) + * @return List of matching products + */ + public List searchProducts(String name, String description, Double minPrice, Double maxPrice) { + LOGGER.fine("Repository: Searching for products with criteria"); + + return productsMap.values().stream() + .filter(p -> name == null || p.getName().toLowerCase().contains(name.toLowerCase())) + .filter(p -> description == null || p.getDescription().toLowerCase().contains(description.toLowerCase())) + .filter(p -> minPrice == null || (p.getPrice() != null && p.getPrice().doubleValue() >= minPrice)) + .filter(p -> maxPrice == null || (p.getPrice() != null && p.getPrice().doubleValue() <= maxPrice)) + .collect(Collectors.toList()); + } +} \ No newline at end of file diff --git a/code/chapter06/catalog/src/main/java/io/microprofile/tutorial/store/product/repository/ProductJpaRepository.java b/code/chapter06/catalog/src/main/java/io/microprofile/tutorial/store/product/repository/ProductJpaRepository.java new file mode 100644 index 0000000..1bf4343 --- /dev/null +++ b/code/chapter06/catalog/src/main/java/io/microprofile/tutorial/store/product/repository/ProductJpaRepository.java @@ -0,0 +1,150 @@ +package io.microprofile.tutorial.store.product.repository; + +import io.microprofile.tutorial.store.product.entity.Product; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.persistence.EntityManager; +import jakarta.persistence.PersistenceContext; +import jakarta.persistence.TypedQuery; +import jakarta.transaction.Transactional; +import java.math.BigDecimal; +import java.util.List; +import java.util.logging.Logger; + +/** + * JPA-based repository implementation for Product entity. + * Provides database persistence operations using Apache Derby. + */ +@ApplicationScoped +@JPA +@Transactional +public class ProductJpaRepository implements ProductRepositoryInterface { + + private static final Logger LOGGER = Logger.getLogger(ProductJpaRepository.class.getName()); + + @PersistenceContext(unitName = "catalogPU") + private EntityManager entityManager; + + @Override + public List findAllProducts() { + LOGGER.fine("JPA Repository: Finding all products"); + TypedQuery query = entityManager.createNamedQuery("Product.findAll", Product.class); + return query.getResultList(); + } + + @Override + public Product findProductById(Long id) { + LOGGER.fine("JPA Repository: Finding product with ID: " + id); + if (id == null) { + return null; + } + return entityManager.find(Product.class, id); + } + + @Override + public Product createProduct(Product product) { + LOGGER.info("JPA Repository: Creating new product: " + product); + if (product == null) { + throw new IllegalArgumentException("Product cannot be null"); + } + + // Ensure ID is null for new products + product.setId(null); + + entityManager.persist(product); + entityManager.flush(); // Force the insert to get the generated ID + + LOGGER.info("JPA Repository: Created product with ID: " + product.getId()); + return product; + } + + @Override + public Product updateProduct(Product product) { + LOGGER.info("JPA Repository: Updating product: " + product); + if (product == null || product.getId() == null) { + throw new IllegalArgumentException("Product and ID cannot be null for update"); + } + + Product existingProduct = entityManager.find(Product.class, product.getId()); + if (existingProduct == null) { + LOGGER.warning("JPA Repository: Product not found for update: " + product.getId()); + return null; + } + + // Update fields + existingProduct.setName(product.getName()); + existingProduct.setDescription(product.getDescription()); + existingProduct.setPrice(product.getPrice()); + + Product updatedProduct = entityManager.merge(existingProduct); + entityManager.flush(); + + LOGGER.info("JPA Repository: Updated product with ID: " + updatedProduct.getId()); + return updatedProduct; + } + + @Override + public boolean deleteProduct(Long id) { + LOGGER.info("JPA Repository: Deleting product with ID: " + id); + if (id == null) { + return false; + } + + Product product = entityManager.find(Product.class, id); + if (product != null) { + entityManager.remove(product); + entityManager.flush(); + LOGGER.info("JPA Repository: Deleted product with ID: " + id); + return true; + } + + LOGGER.warning("JPA Repository: Product not found for deletion: " + id); + return false; + } + + @Override + public List searchProducts(String name, String description, Double minPrice, Double maxPrice) { + LOGGER.info("JPA Repository: Searching for products with criteria"); + + StringBuilder jpql = new StringBuilder("SELECT p FROM Product p WHERE 1=1"); + + if (name != null && !name.trim().isEmpty()) { + jpql.append(" AND LOWER(p.name) LIKE :namePattern"); + } + + if (description != null && !description.trim().isEmpty()) { + jpql.append(" AND LOWER(p.description) LIKE :descriptionPattern"); + } + + if (minPrice != null) { + jpql.append(" AND p.price >= :minPrice"); + } + + if (maxPrice != null) { + jpql.append(" AND p.price <= :maxPrice"); + } + + TypedQuery query = entityManager.createQuery(jpql.toString(), Product.class); + + // Set parameters only if they are provided + if (name != null && !name.trim().isEmpty()) { + query.setParameter("namePattern", "%" + name.toLowerCase() + "%"); + } + + if (description != null && !description.trim().isEmpty()) { + query.setParameter("descriptionPattern", "%" + description.toLowerCase() + "%"); + } + + if (minPrice != null) { + query.setParameter("minPrice", BigDecimal.valueOf(minPrice)); + } + + if (maxPrice != null) { + query.setParameter("maxPrice", BigDecimal.valueOf(maxPrice)); + } + + List results = query.getResultList(); + LOGGER.info("JPA Repository: Found " + results.size() + " products matching criteria"); + + return results; + } +} diff --git a/code/chapter06/catalog/src/main/java/io/microprofile/tutorial/store/product/repository/ProductRepositoryInterface.java b/code/chapter06/catalog/src/main/java/io/microprofile/tutorial/store/product/repository/ProductRepositoryInterface.java new file mode 100644 index 0000000..4b981b2 --- /dev/null +++ b/code/chapter06/catalog/src/main/java/io/microprofile/tutorial/store/product/repository/ProductRepositoryInterface.java @@ -0,0 +1,61 @@ +package io.microprofile.tutorial.store.product.repository; + +import io.microprofile.tutorial.store.product.entity.Product; +import java.util.List; + +/** + * Repository interface for Product entity operations. + * Defines the contract for product data access. + */ +public interface ProductRepositoryInterface { + + /** + * Retrieves all products. + * + * @return List of all products + */ + List findAllProducts(); + + /** + * Retrieves a product by ID. + * + * @param id Product ID + * @return The product or null if not found + */ + Product findProductById(Long id); + + /** + * Creates a new product. + * + * @param product Product data to create + * @return The created product with ID + */ + Product createProduct(Product product); + + /** + * Updates an existing product. + * + * @param product Updated product data + * @return The updated product + */ + Product updateProduct(Product product); + + /** + * Deletes a product by ID. + * + * @param id ID of the product to delete + * @return true if deleted, false if not found + */ + boolean deleteProduct(Long id); + + /** + * Searches for products by criteria. + * + * @param name Product name (optional) + * @param description Product description (optional) + * @param minPrice Minimum price (optional) + * @param maxPrice Maximum price (optional) + * @return List of matching products + */ + List searchProducts(String name, String description, Double minPrice, Double maxPrice); +} diff --git a/code/chapter06/catalog/src/main/java/io/microprofile/tutorial/store/product/repository/RepositoryType.java b/code/chapter06/catalog/src/main/java/io/microprofile/tutorial/store/product/repository/RepositoryType.java new file mode 100644 index 0000000..e2bf8c9 --- /dev/null +++ b/code/chapter06/catalog/src/main/java/io/microprofile/tutorial/store/product/repository/RepositoryType.java @@ -0,0 +1,29 @@ +package io.microprofile.tutorial.store.product.repository; + +import jakarta.inject.Qualifier; +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** + * CDI qualifier to distinguish between different repository implementations. + */ +@Qualifier +@Retention(RetentionPolicy.RUNTIME) +@Target({ElementType.TYPE, ElementType.METHOD, ElementType.FIELD, ElementType.PARAMETER}) +public @interface RepositoryType { + + /** + * Repository implementation type. + */ + Type value(); + + /** + * Enumeration of repository types. + */ + enum Type { + JPA, + IN_MEMORY + } +} diff --git a/code/chapter06/catalog/src/main/java/io/microprofile/tutorial/store/product/resource/ProductResource.java b/code/chapter06/catalog/src/main/java/io/microprofile/tutorial/store/product/resource/ProductResource.java new file mode 100644 index 0000000..97e04ca --- /dev/null +++ b/code/chapter06/catalog/src/main/java/io/microprofile/tutorial/store/product/resource/ProductResource.java @@ -0,0 +1,182 @@ +package io.microprofile.tutorial.store.product.resource; + +import java.util.List; +import java.util.logging.Logger; +import java.util.logging.Level; + +import org.eclipse.microprofile.openapi.annotations.Operation; +import org.eclipse.microprofile.openapi.annotations.media.Content; +import org.eclipse.microprofile.openapi.annotations.media.Schema; +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 org.eclipse.microprofile.config.inject.ConfigProperty; + +import io.microprofile.tutorial.store.product.entity.Product; +import io.microprofile.tutorial.store.product.service.ProductService; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.inject.Inject; + +import jakarta.ws.rs.Consumes; +import jakarta.ws.rs.DELETE; +import jakarta.ws.rs.GET; +import jakarta.ws.rs.POST; +import jakarta.ws.rs.PUT; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.PathParam; +import jakarta.ws.rs.Produces; +import jakarta.ws.rs.QueryParam; +import jakarta.ws.rs.core.MediaType; +import jakarta.ws.rs.core.Response; + +@ApplicationScoped +@Path("/products") +@Tag(name = "Product Service", description = "CRUD operations for products") +public class ProductResource { + + private static final Logger LOGGER = Logger.getLogger(ProductResource.class.getName()); + + @Inject + @ConfigProperty(name="product.maintenanceMode", defaultValue="false") + private boolean maintenanceMode; + + @Inject + private ProductService productService; + + @GET + @Produces(MediaType.APPLICATION_JSON) + @Operation(summary = "List all products", description = "Retrieves a list of all products") + @APIResponses({ + @APIResponse( + responseCode = "200", + description = "Successful, list of products found", + content = @Content(mediaType = "application/json", + schema = @Schema(implementation = Product.class))), + @APIResponse( + responseCode = "400", + description = "Unsuccessful, no products found", + content = @Content(mediaType = "application/json") + ), + @APIResponse( + responseCode = "503", + description = "Service is under maintenance", + content = @Content(mediaType = "application/json") + ) + }) + public Response getAllProducts() { + LOGGER.log(Level.INFO, "REST: Fetching all products"); + List products = productService.findAllProducts(); + + if (maintenanceMode) { + return Response + .status(Response.Status.SERVICE_UNAVAILABLE) + .entity("Service is under maintenance") + .build(); + } + + if (products != null && !products.isEmpty()) { + return Response + .status(Response.Status.OK) + .entity(products).build(); + } else { + return Response + .status(Response.Status.NOT_FOUND) + .entity("No products found") + .build(); + } + } + + @GET + @Path("/{id}") + @Produces(MediaType.APPLICATION_JSON) + @Operation(summary = "Get product by ID", description = "Returns a product by its ID") + @APIResponses({ + @APIResponse(responseCode = "200", description = "Product found", content = @Content(mediaType = "application/json", schema = @Schema(implementation = Product.class))), + @APIResponse(responseCode = "404", description = "Product not found"), + @APIResponse(responseCode = "503", description = "Service is under maintenance") + }) + public Response getProductById(@PathParam("id") Long id) { + LOGGER.log(Level.INFO, "REST: Fetching product with id: {0}", id); + + if (maintenanceMode) { + return Response + .status(Response.Status.SERVICE_UNAVAILABLE) + .entity("Service is under maintenance") + .build(); + } + + Product product = productService.findProductById(id); + if (product != null) { + return Response.ok(product).build(); + } else { + return Response.status(Response.Status.NOT_FOUND).build(); + } + } + + @POST + @Consumes(MediaType.APPLICATION_JSON) + @Produces(MediaType.APPLICATION_JSON) + @Operation(summary = "Create a new product", description = "Creates a new product") + @APIResponses({ + @APIResponse(responseCode = "201", description = "Product created", content = @Content(mediaType = "application/json", schema = @Schema(implementation = Product.class))) + }) + public Response createProduct(Product product) { + LOGGER.info("REST: Creating product: " + product); + Product createdProduct = productService.createProduct(product); + return Response.status(Response.Status.CREATED).entity(createdProduct).build(); + } + + @PUT + @Path("/{id}") + @Consumes(MediaType.APPLICATION_JSON) + @Produces(MediaType.APPLICATION_JSON) + @Operation(summary = "Update a product", description = "Updates an existing product by its ID") + @APIResponses({ + @APIResponse(responseCode = "200", description = "Product updated", content = @Content(mediaType = "application/json", schema = @Schema(implementation = Product.class))), + @APIResponse(responseCode = "404", description = "Product not found") + }) + public Response updateProduct(@PathParam("id") Long id, Product updatedProduct) { + LOGGER.info("REST: Updating product with id: " + id); + Product updated = productService.updateProduct(id, updatedProduct); + if (updated != null) { + return Response.ok(updated).build(); + } else { + return Response.status(Response.Status.NOT_FOUND).build(); + } + } + + @DELETE + @Path("/{id}") + @Produces(MediaType.APPLICATION_JSON) + @Operation(summary = "Delete a product", description = "Deletes a product by its ID") + @APIResponses({ + @APIResponse(responseCode = "204", description = "Product deleted"), + @APIResponse(responseCode = "404", description = "Product not found") + }) + public Response deleteProduct(@PathParam("id") Long id) { + LOGGER.info("REST: Deleting product with id: " + id); + boolean deleted = productService.deleteProduct(id); + if (deleted) { + return Response.noContent().build(); + } else { + return Response.status(Response.Status.NOT_FOUND).build(); + } + } + + @GET + @Path("/search") + @Produces(MediaType.APPLICATION_JSON) + @Operation(summary = "Search products", description = "Search products by criteria") + @APIResponses({ + @APIResponse(responseCode = "200", description = "Search results", content = @Content(mediaType = "application/json", schema = @Schema(implementation = Product.class))) + }) + public Response searchProducts( + @QueryParam("name") String name, + @QueryParam("description") String description, + @QueryParam("minPrice") Double minPrice, + @QueryParam("maxPrice") Double maxPrice) { + LOGGER.info("REST: Searching products with criteria"); + List results = productService.searchProducts(name, description, minPrice, maxPrice); + return Response.ok(results).build(); + } +} \ No newline at end of file diff --git a/code/chapter06/catalog/src/main/java/io/microprofile/tutorial/store/product/service/ProductService.java b/code/chapter06/catalog/src/main/java/io/microprofile/tutorial/store/product/service/ProductService.java new file mode 100644 index 0000000..11d9bf7 --- /dev/null +++ b/code/chapter06/catalog/src/main/java/io/microprofile/tutorial/store/product/service/ProductService.java @@ -0,0 +1,99 @@ +package io.microprofile.tutorial.store.product.service; + +import io.microprofile.tutorial.store.product.entity.Product; +import io.microprofile.tutorial.store.product.repository.JPA; +import io.microprofile.tutorial.store.product.repository.ProductRepositoryInterface; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.inject.Inject; +import java.util.List; +import java.util.logging.Logger; + +/** + * Service class for Product operations. + * Contains business logic for product management. + */ +@ApplicationScoped +public class ProductService { + + private static final Logger LOGGER = Logger.getLogger(ProductService.class.getName()); + + @Inject + @JPA + private ProductRepositoryInterface repository; + + /** + * Retrieves all products. + * + * @return List of all products + */ + public List findAllProducts() { + LOGGER.info("Service: Finding all products"); + return repository.findAllProducts(); + } + + /** + * Retrieves a product by ID. + * + * @param id Product ID + * @return The product or null if not found + */ + public Product findProductById(Long id) { + LOGGER.info("Service: Finding product with ID: " + id); + return repository.findProductById(id); + } + + /** + * Creates a new product. + * + * @param product Product data to create + * @return The created product with ID + */ + public Product createProduct(Product product) { + LOGGER.info("Service: Creating new product: " + product); + return repository.createProduct(product); + } + + /** + * Updates an existing product. + * + * @param id ID of the product to update + * @param updatedProduct Updated product data + * @return The updated product or null if not found + */ + public Product updateProduct(Long id, Product updatedProduct) { + LOGGER.info("Service: Updating product with ID: " + id); + + Product existingProduct = repository.findProductById(id); + if (existingProduct != null) { + // Set the ID to ensure correct update + updatedProduct.setId(id); + return repository.updateProduct(updatedProduct); + } + return null; + } + + /** + * Deletes a product by ID. + * + * @param id ID of the product to delete + * @return true if deleted, false if not found + */ + public boolean deleteProduct(Long id) { + LOGGER.info("Service: Deleting product with ID: " + id); + return repository.deleteProduct(id); + } + + /** + * Searches for products by criteria. + * + * @param name Product name (optional) + * @param description Product description (optional) + * @param minPrice Minimum price (optional) + * @param maxPrice Maximum price (optional) + * @return List of matching products + */ + public List searchProducts(String name, String description, Double minPrice, Double maxPrice) { + LOGGER.info("Service: Searching for products with criteria"); + return repository.searchProducts(name, description, minPrice, maxPrice); + } +} diff --git a/code/chapter06/catalog/src/main/liberty/config/server.xml b/code/chapter06/catalog/src/main/liberty/config/server.xml new file mode 100644 index 0000000..45bfe88 --- /dev/null +++ b/code/chapter06/catalog/src/main/liberty/config/server.xml @@ -0,0 +1,37 @@ + + + jakartaEE-10.0 + microProfile-6.1 + restfulWS + jsonp + jsonb + cdi + mpConfig + mpOpenAPI + mpHealth + persistence + jdbc + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/code/chapter06/catalog/src/main/resources/META-INF/create-schema.sql b/code/chapter06/catalog/src/main/resources/META-INF/create-schema.sql new file mode 100644 index 0000000..6e72eed --- /dev/null +++ b/code/chapter06/catalog/src/main/resources/META-INF/create-schema.sql @@ -0,0 +1,6 @@ +-- Schema creation script for Product catalog +-- This file is referenced by persistence.xml for schema generation + +-- Note: This is a placeholder file. +-- The actual schema is auto-generated by JPA using @Entity annotations. +-- You can add custom DDL statements here if needed. diff --git a/code/chapter06/catalog/src/main/resources/META-INF/load-data.sql b/code/chapter06/catalog/src/main/resources/META-INF/load-data.sql new file mode 100644 index 0000000..e9fbd9b --- /dev/null +++ b/code/chapter06/catalog/src/main/resources/META-INF/load-data.sql @@ -0,0 +1,8 @@ +INSERT INTO PRODUCTS (NAME, DESCRIPTION, PRICE) VALUES ('iPhone 15', 'Apple iPhone 15 with advanced features', 999.99) +INSERT INTO PRODUCTS (NAME, DESCRIPTION, PRICE) VALUES ('MacBook Air', 'Apple MacBook Air M2 chip', 1299.00) +INSERT INTO PRODUCTS (NAME, DESCRIPTION, PRICE) VALUES ('iPad Pro', 'Apple iPad Pro 12.9-inch', 799.00) +INSERT INTO PRODUCTS (NAME, DESCRIPTION, PRICE) VALUES ('Samsung Galaxy S24', 'Samsung Galaxy S24 Ultra smartphone', 1199.99) +INSERT INTO PRODUCTS (NAME, DESCRIPTION, PRICE) VALUES ('Dell XPS 13', 'Dell XPS 13 laptop with Intel i7', 1099.00) +INSERT INTO PRODUCTS (NAME, DESCRIPTION, PRICE) VALUES ('Sony WH-1000XM4', 'Sony noise-canceling headphones', 299.99) +INSERT INTO PRODUCTS (NAME, DESCRIPTION, PRICE) VALUES ('Nintendo Switch', 'Nintendo Switch gaming console', 299.00) +INSERT INTO PRODUCTS (NAME, DESCRIPTION, PRICE) VALUES ('Google Pixel 8', 'Google Pixel 8 smartphone', 699.99) diff --git a/code/chapter06/catalog/src/main/resources/META-INF/microprofile-config.properties b/code/chapter06/catalog/src/main/resources/META-INF/microprofile-config.properties new file mode 100644 index 0000000..740ae2e --- /dev/null +++ b/code/chapter06/catalog/src/main/resources/META-INF/microprofile-config.properties @@ -0,0 +1,12 @@ +# microprofile-config.properties +product.maintainenceMode=false + +# Repository configuration +product.repository.type=JPA + +# Database configuration +product.database.enabled=true +product.database.name=catalogDB + +# Enable OpenAPI scanning +mp.openapi.scan=true \ No newline at end of file diff --git a/code/chapter06/catalog/src/main/resources/META-INF/persistence.xml b/code/chapter06/catalog/src/main/resources/META-INF/persistence.xml new file mode 100644 index 0000000..b569476 --- /dev/null +++ b/code/chapter06/catalog/src/main/resources/META-INF/persistence.xml @@ -0,0 +1,27 @@ + + + + + jdbc/catalogDB + io.microprofile.tutorial.store.product.entity.Product + + + + + + + + + + + + + + + + + + diff --git a/code/chapter06/catalog/src/main/webapp/WEB-INF/web.xml b/code/chapter06/catalog/src/main/webapp/WEB-INF/web.xml new file mode 100644 index 0000000..1010516 --- /dev/null +++ b/code/chapter06/catalog/src/main/webapp/WEB-INF/web.xml @@ -0,0 +1,13 @@ + + + + Product Catalog Service + + + index.html + + + diff --git a/code/chapter06/catalog/src/main/webapp/index.html b/code/chapter06/catalog/src/main/webapp/index.html new file mode 100644 index 0000000..fac4a18 --- /dev/null +++ b/code/chapter06/catalog/src/main/webapp/index.html @@ -0,0 +1,497 @@ + + + + + + Product Catalog Service + + + +
+

Product Catalog Service

+

A microservice for managing product information in the e-commerce platform

+
+ +
+
+

Service Overview

+

The Product Catalog Service provides a REST API for managing product information, including:

+
    +
  • Creating new products
  • +
  • Retrieving product details
  • +
  • Updating existing products
  • +
  • Deleting products
  • +
  • Searching for products by various criteria
  • +
+
+

MicroProfile Config Implementation

+

This service implements configurability as per MicroProfile Config standards. Key configuration properties include:

+
    +
  • product.maintenanceMode - Controls whether the service is in maintenance mode (returns 503 responses)
  • +
  • mp.openapi.scan - Enables automatic OpenAPI documentation generation
  • +
+

MicroProfile Config allows these properties to be changed via environment variables, system properties, or configuration files without requiring application redeployment.

+
+
+ +
+

API Endpoints

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
OperationMethodURLDescription
List All ProductsGET/api/productsRetrieves a list of all products
Get Product by IDGET/api/products/{id}Returns a product by its ID
Create ProductPOST/api/productsCreates a new product
Update ProductPUT/api/products/{id}Updates an existing product by its ID
Delete ProductDELETE/api/products/{id}Deletes a product by its ID
Search ProductsGET/api/products/searchSearch products by criteria (name, description, price range)
+
+ +
+

API Documentation

+

The API is documented using MicroProfile OpenAPI. You can access the Swagger UI at:

+

/openapi/ui

+

The OpenAPI definition is available at:

+

/openapi

+
+ +
+

Health Checks

+

The service implements comprehensive MicroProfile Health monitoring with three types of health checks:

+ +

Health Check Endpoints

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
StatusEndpointPurposeDescriptionLink
/healthOverall HealthAggregated status of all health checksView
/health/startedStartup CheckValidates EntityManagerFactory initializationView
/health/readyReadiness CheckTests database connectivity via EntityManagerView
/health/liveLiveness CheckMonitors JVM memory usage (100MB threshold)View
+ +
+

Health Check Implementation Details

+ +

🚀 Startup Health Check

+

Class: ProductServiceStartupCheck

+

Purpose: Verifies that the Jakarta Persistence EntityManagerFactory is properly initialized during application startup.

+

Implementation: Uses @PersistenceUnit to inject EntityManagerFactory and checks if it's not null and open.

+ +

✅ Readiness Health Check

+

Class: ProductServiceHealthCheck

+

Purpose: Ensures the service is ready to handle requests by testing database connectivity.

+

Implementation: Performs a lightweight database query using entityManager.find(Product.class, 1L) to verify the database connection.

+ +

💓 Liveness Health Check

+

Class: ProductServiceLivenessCheck

+

Purpose: Monitors system resources to detect if the application needs to be restarted.

+

Implementation: Analyzes JVM memory usage with a 100MB available memory threshold, providing detailed memory diagnostics.

+
+ +
+

Sample Health Check Response

+

Example response from /health endpoint:

+
{
+  "status": "UP",
+  "checks": [
+    {
+      "name": "ProductServiceStartupCheck",
+      "status": "UP"
+    },
+    {
+      "name": "ProductServiceReadinessCheck", 
+      "status": "UP"
+    },
+    {
+      "name": "systemResourcesLiveness",
+      "status": "UP",
+      "data": {
+        "FreeMemory": 524288000,
+        "MaxMemory": 2147483648,
+        "AllocatedMemory": 1073741824,
+        "UsedMemory": 549453824,
+        "AvailableMemory": 1598029824
+      }
+    }
+  ]
+}
+
+ +
+

Testing Health Checks

+

You can test the health check endpoints using curl commands:

+
# Test overall health
+curl -X GET http://localhost:9080/health
+
+# Test startup health
+curl -X GET http://localhost:9080/health/started
+
+# Test readiness health  
+curl -X GET http://localhost:9080/health/ready
+
+# Test liveness health
+curl -X GET http://localhost:9080/health/live
+ +

Integration with Container Orchestration:

+

For Kubernetes deployments, you can configure probes in your deployment YAML:

+
livenessProbe:
+  httpGet:
+    path: /health/live
+    port: 9080
+  initialDelaySeconds: 30
+  periodSeconds: 10
+
+readinessProbe:
+  httpGet:
+    path: /health/ready
+    port: 9080
+  initialDelaySeconds: 5
+  periodSeconds: 5
+
+startupProbe:
+  httpGet:
+    path: /health/started
+    port: 9080
+  initialDelaySeconds: 10
+  periodSeconds: 10
+  failureThreshold: 30
+
+ +
+

Health Check Benefits

+
    +
  • Container Orchestration: Kubernetes and Docker can use these endpoints for health probes
  • +
  • Load Balancer Integration: Traffic routing based on readiness status
  • +
  • Operational Monitoring: Early detection of system issues
  • +
  • Startup Validation: Ensures all dependencies are initialized before serving traffic
  • +
  • Database Monitoring: Real-time database connectivity verification
  • +
  • Memory Management: Proactive detection of memory pressure
  • +
+
+
+ +
+

Sample Usage

+ +

List All Products

+
GET /api/products
+

Response:

+
[
+  {
+    "id": 1,
+    "name": "Smartphone X",
+    "description": "Latest smartphone with advanced features",
+    "price": 799.99
+  },
+  {
+    "id": 2,
+    "name": "Laptop Pro",
+    "description": "High-performance laptop for professionals",
+    "price": 1299.99
+  }
+]
+ +

Get Product by ID

+
GET /api/products/1
+

Response:

+
{
+  "id": 1,
+  "name": "Smartphone X",
+  "description": "Latest smartphone with advanced features",
+  "price": 799.99
+}
+ +

Create a New Product

+
POST /api/products
+Content-Type: application/json
+
+{
+  "name": "Wireless Earbuds",
+  "description": "Premium wireless earbuds with noise cancellation",
+  "price": 149.99
+}
+

Response:

+
{
+  "id": 3,
+  "name": "Wireless Earbuds",
+  "description": "Premium wireless earbuds with noise cancellation",
+  "price": 149.99
+}
+ +

Update a Product

+
PUT /api/products/3
+Content-Type: application/json
+
+{
+  "name": "Wireless Earbuds Pro",
+  "description": "Premium wireless earbuds with advanced noise cancellation",
+  "price": 179.99
+}
+

Response:

+
{
+  "id": 3,
+  "name": "Wireless Earbuds Pro",
+  "description": "Premium wireless earbuds with advanced noise cancellation",
+  "price": 179.99
+}
+ +

Delete a Product

+
DELETE /api/products/3
+

Response: No content (204)

+ +

Search for Products

+
GET /api/products/search?name=laptop&minPrice=1000&maxPrice=2000
+

Response:

+
[
+  {
+    "id": 2,
+    "name": "Laptop Pro",
+    "description": "High-performance laptop for professionals",
+    "price": 1299.99
+  }
+]
+
+
+ +
+

Product Catalog Service

+

© 2025 - MicroProfile APT Tutorial

+
+ + + + diff --git a/code/chapter07/catalog/README.adoc b/code/chapter07/catalog/README.adoc new file mode 100644 index 0000000..b3476d4 --- /dev/null +++ b/code/chapter07/catalog/README.adoc @@ -0,0 +1,1300 @@ += MicroProfile Catalog Service +:toc: macro +:toclevels: 3 +:icons: font +:source-highlighter: highlight.js +:experimental: + +toc::[] + +== Overview + +The MicroProfile Catalog Service is a modern Jakarta EE 10 application built with MicroProfile 6.1 specifications and running on Open Liberty. This service provides a RESTful API for product catalog management with enhanced MicroProfile features and flexible persistence options. + +This project demonstrates the key capabilities of MicroProfile OpenAPI, MicroProfile Health, CDI qualifiers, and dual persistence architecture with both JPA/Derby database and in-memory implementations. + +== Features + +* *RESTful API* using Jakarta RESTful Web Services +* *OpenAPI Documentation* with Swagger UI +* *MicroProfile Health* with comprehensive startup, readiness, and liveness checks +* *MicroProfile Metrics* with Timer, Counter, and Gauge metrics for performance monitoring +* *Dual Persistence Implementation* with configurable JPA/Derby database and in-memory storage options +* *CDI Qualifiers* for dependency injection and implementation switching +* *Derby Database Support* with automatic JAR deployment via Liberty Maven plugin +* *Maintenance Mode* support with configuration-based toggles + +== MicroProfile Features Implemented + +=== MicroProfile OpenAPI + +The application provides OpenAPI documentation for its REST endpoints. API documentation is generated automatically from annotations in the code: + +[source,java] +---- +@GET +@Produces(MediaType.APPLICATION_JSON) +@Operation(summary = "Get all products", description = "Returns a list of all products") +@APIResponses({ + @APIResponse(responseCode = "200", description = "List of products", + content = @Content(mediaType = "application/json", + schema = @Schema(implementation = Product.class))) +}) +public Response getAllProducts() { + // Implementation +} +---- + +The OpenAPI documentation is available at: `/openapi` (in various formats) and `/openapi/ui` (Swagger UI) + +=== MicroProfile Metrics + +The application implements comprehensive performance monitoring using MicroProfile Metrics with three types of metrics: + +* *Timer Metrics* (`@Timed`) - Track response times for product lookups +* *Counter Metrics* (`@Counted`) - Monitor API usage frequency +* *Gauge Metrics* (`@Gauge`) - Real-time catalog size monitoring + +Metrics are available at `/metrics` endpoints in Prometheus format for integration with monitoring systems. + +=== Dual Persistence Architecture + +The application implements a flexible persistence layer with two implementations that can be switched via CDI qualifiers: + +1. *JPA/Derby Database Implementation* (Default) - For production use with persistent data storage +2. *In-Memory Implementation* - For development, testing, and scenarios where persistence across restarts is not required + +==== CDI Qualifiers for Implementation Switching + +The application uses CDI qualifiers to select between different persistence implementations: + +[source,java] +---- +// JPA Qualifier +@Qualifier +@Retention(RUNTIME) +@Target({METHOD, FIELD, PARAMETER, TYPE}) +public @interface JPA { +} + +// In-Memory Qualifier +@Qualifier +@Retention(RUNTIME) +@Target({METHOD, FIELD, PARAMETER, TYPE}) +public @interface InMemory { +} +---- + +==== Service Layer with CDI Injection + +The service layer uses CDI qualifiers to inject the appropriate repository implementation: + +[source,java] +---- +@ApplicationScoped +public class ProductService { + + @Inject + @JPA // Uses Derby database implementation by default + private ProductRepositoryInterface repository; + + public List findAllProducts() { + return repository.findAllProducts(); + } + // ... other service methods +} +---- + +==== JPA/Derby Database Implementation + +The JPA implementation provides persistent data storage using Apache Derby database: + +[source,java] +---- +@ApplicationScoped +@JPA +@Transactional +public class ProductJpaRepository implements ProductRepositoryInterface { + + @PersistenceContext(unitName = "catalogPU") + private EntityManager entityManager; + + @Override + public List findAllProducts() { + TypedQuery query = entityManager.createNamedQuery("Product.findAll", Product.class); + return query.getResultList(); + } + + @Override + public Product createProduct(Product product) { + entityManager.persist(product); + return product; + } + // ... other JPA operations +} +---- + +*Key Features of JPA Implementation:* +* Persistent data storage across application restarts +* ACID transactions with @Transactional annotation +* Named queries for efficient database operations +* Automatic schema generation and data loading +* Derby embedded database for simplified deployment + +==== In-Memory Implementation + +The in-memory implementation uses thread-safe collections for fast data access: + +[source,java] +---- +@ApplicationScoped +@InMemory +public class ProductInMemoryRepository implements ProductRepositoryInterface { + + // Thread-safe storage using ConcurrentHashMap + private final Map productsMap = new ConcurrentHashMap<>(); + private final AtomicLong idGenerator = new AtomicLong(1); + + @Override + public List findAllProducts() { + return new ArrayList<>(productsMap.values()); + } + + @Override + public Product createProduct(Product product) { + if (product.getId() == null) { + product.setId(idGenerator.getAndIncrement()); + } + productsMap.put(product.getId(), product); + return product; + } + // ... other in-memory operations +} +---- + +*Key Features of In-Memory Implementation:* +* Fast in-memory access without database I/O +* Thread-safe operations using ConcurrentHashMap and AtomicLong +* No external dependencies or database configuration +* Suitable for development, testing, and stateless deployments + +=== How to Switch Between Implementations + +You can switch between JPA and In-Memory implementations in several ways: + +==== Method 1: CDI Qualifier Injection (Compile Time) + +Change the qualifier in the service class: + +[source,java] +---- +@ApplicationScoped +public class ProductService { + + // For JPA/Derby implementation (default) + @Inject + @JPA + private ProductRepositoryInterface repository; + + // OR for In-Memory implementation + // @Inject + // @InMemory + // private ProductRepositoryInterface repository; +} +---- + +==== Method 2: MicroProfile Config (Runtime) + +Configure the implementation type in `microprofile-config.properties`: + +[source,properties] +---- +# Repository configuration +product.repository.type=JPA # Use JPA/Derby implementation +# product.repository.type=InMemory # Use In-Memory implementation + +# Database configuration +product.database.enabled=true +product.database.name=catalogDB +---- + +The application can use the configuration to determine which implementation to inject. + +==== Method 3: Alternative Annotations (Deployment Time) + +Use CDI @Alternative annotation to enable/disable implementations via beans.xml: + +[source,xml] +---- + + + io.microprofile.tutorial.store.product.repository.ProductInMemoryRepository + +---- + +=== Database Configuration and Setup + +==== Derby Database Configuration + +The Derby database is automatically configured through the Liberty Maven plugin and server.xml: + +*Maven Dependencies and Plugin Configuration:* +[source,xml] +---- + + + + org.apache.derby + derby + 10.16.1.1 + + + + org.apache.derby + derbyshared + 10.16.1.1 + + + + org.apache.derby + derbytools + 10.16.1.1 + + + + + io.openliberty.tools + liberty-maven-plugin + + mpServer + + ${project.build.directory}/liberty/wlp/usr/servers/mpServer/derby + + org.apache.derby + derby + + + org.apache.derby + derbyshared + + + org.apache.derby + derbytools + + + + +---- + +*Server.xml Configuration:* +[source,xml] +---- + + + + + + + + + + + + + + +---- + +*JPA Configuration (persistence.xml):* +[source,xml] +---- + + jdbc/catalogDB + io.microprofile.tutorial.store.product.entity.Product + + + + + + + + + + + + +---- + +==== Implementation Comparison + +[cols="1,1,1", options="header"] +|=== +| Feature | JPA/Derby Implementation | In-Memory Implementation +| Data Persistence | Survives application restarts | Lost on restart +| Performance | Database I/O overhead | Fastest access (memory) +| Configuration | Requires datasource setup | No configuration needed +| Dependencies | Derby JARs, JPA provider | None (Java built-ins) +| Threading | JPA managed transactions | ConcurrentHashMap + AtomicLong +| Development Setup | Database initialization | Immediate startup +| Production Use | Recommended for production | Development/testing only +| Scalability | Database connection limits | Memory limitations +| Data Integrity | ACID transactions | Thread-safe operations +| Error Handling | Database exceptions | Simple validation +|=== + +[source,java] +---- +@ApplicationScoped +public class ProductRepository { + // In-memory storage using ConcurrentHashMap for thread safety + private final Map productsMap = new ConcurrentHashMap<>(); + + // ID generator + private final AtomicLong idGenerator = new AtomicLong(1); + + // CRUD operations... +} +---- + +==== Atomic ID Generation with AtomicLong + +The repository uses `java.util.concurrent.atomic.AtomicLong` for thread-safe ID generation: + +[source,java] +---- +// ID generation in createProduct method +if (product.getId() == null) { + product.setId(idGenerator.getAndIncrement()); +} +---- + +`AtomicLong` provides several key benefits: + +* *Thread Safety*: Guarantees atomic operations without explicit locking +* *Performance*: Uses efficient compare-and-swap (CAS) operations instead of locks +* *Consistency*: Ensures unique, sequential IDs even under concurrent access +* *No Synchronization*: Avoids the overhead of synchronized blocks + +===== Advanced AtomicLong Operations + +The ProductRepository implements an advanced pattern for handling both system-generated and client-provided IDs: + +[source,java] +---- +public Product createProduct(Product product) { + // Generate ID if not provided + if (product.getId() == null) { + product.setId(idGenerator.getAndIncrement()); + } else { + // Update idGenerator if the provided ID is greater than current + long nextId = product.getId() + 1; + while (true) { + long currentId = idGenerator.get(); + if (nextId <= currentId || idGenerator.compareAndSet(currentId, nextId)) { + break; + } + } + } + + productsMap.put(product.getId(), product); + return product; +} +---- + +This implementation demonstrates several key AtomicLong patterns: + +1. *Initialization*: `AtomicLong` is initialized with a starting value of 1 to avoid using 0 as a valid ID +2. *getAndIncrement*: Atomically returns the current value and increments it in one operation +3. *compareAndSet*: Safely updates the ID generator if a client provides a higher ID value, preventing ID collisions +4. *Retry Logic*: Uses a spinlock pattern for handling concurrent updates to the AtomicLong when needed + +The initialization of the idGenerator with a specific starting value ensures the IDs begin at a predictable value: + +[source,java] +---- +private final AtomicLong idGenerator = new AtomicLong(1); // Start IDs at 1 +---- + +This approach ensures that each product receives a unique ID without risk of duplicate IDs in a concurrent environment. + +Key benefits of this in-memory persistence approach: + +* *Simplicity*: No need for database configuration or ORM mapping +* *Performance*: Fast in-memory access without network or disk I/O +* *Thread Safety*: ConcurrentHashMap provides thread-safe operations without blocking +* *Scalability*: Suitable for containerized deployments + +==== Thread Safety Implementation Details + +The implementation ensures thread safety through multiple mechanisms: + +1. *ConcurrentHashMap*: Uses lock striping to allow concurrent reads and thread-safe writes +2. *AtomicLong*: Provides atomic operations for ID generation +3. *Immutable Returns*: Returns new collections rather than internal references: ++ +[source,java] +---- +// Returns a copy of the collection to prevent concurrent modification issues +public List findAllProducts() { + return new ArrayList<>(productsMap.values()); +} +---- + +4. *Atomic Operations*: Uses atomic map operations like `putIfAbsent` and `compute` where appropriate + +NOTE: This implementation is suitable for development, testing, and scenarios where persistence across restarts is not required. + +=== MicroProfile Health + +The application implements comprehensive health monitoring using MicroProfile Health specifications with three types of health checks: + +==== Startup Health Check + +The startup health check verifies that the JPA EntityManagerFactory is properly initialized during application startup: + +[source,java] +---- +@Startup +@ApplicationScoped +public class ProductServiceStartupCheck implements HealthCheck { + + @PersistenceUnit + private EntityManagerFactory emf; + + @Override + public HealthCheckResponse call() { + if (emf != null && emf.isOpen()) { + return HealthCheckResponse.up("ProductServiceStartupCheck"); + } else { + return HealthCheckResponse.down("ProductServiceStartupCheck"); + } + } +} +---- + +*Key Features:* +* Validates EntityManagerFactory initialization +* Ensures JPA persistence layer is ready +* Runs during application startup phase +* Critical for database-dependent applications + +==== Readiness Health Check + +The readiness health check verifies database connectivity and ensures the service is ready to handle requests: + +[source,java] +---- +@Readiness +@ApplicationScoped +public class ProductServiceHealthCheck implements HealthCheck { + + @PersistenceContext + EntityManager entityManager; + + @Override + public HealthCheckResponse call() { + if (isDatabaseConnectionHealthy()) { + return HealthCheckResponse.named("ProductServiceReadinessCheck") + .up() + .build(); + } else { + return HealthCheckResponse.named("ProductServiceReadinessCheck") + .down() + .build(); + } + } + + private boolean isDatabaseConnectionHealthy(){ + try { + // Perform a lightweight query to check the database connection + entityManager.find(Product.class, 1L); + return true; + } catch (Exception e) { + System.err.println("Database connection is not healthy: " + e.getMessage()); + return false; + } + } +} +---- + +*Key Features:* +* Tests actual database connectivity via EntityManager +* Performs lightweight database query +* Indicates service readiness to receive traffic +* Essential for load balancer health routing + +==== Liveness Health Check + +The liveness health check monitors system resources and memory availability: + +[source,java] +---- +@Liveness +@ApplicationScoped +public class ProductServiceLivenessCheck implements HealthCheck { + + @Override + public HealthCheckResponse call() { + Runtime runtime = Runtime.getRuntime(); + long maxMemory = runtime.maxMemory(); + long allocatedMemory = runtime.totalMemory(); + long freeMemory = runtime.freeMemory(); + long usedMemory = allocatedMemory - freeMemory; + long availableMemory = maxMemory - usedMemory; + + long threshold = 100 * 1024 * 1024; // threshold: 100MB + + HealthCheckResponseBuilder responseBuilder = HealthCheckResponse.named("systemResourcesLiveness") + .withData("FreeMemory", freeMemory) + .withData("MaxMemory", maxMemory) + .withData("AllocatedMemory", allocatedMemory) + .withData("UsedMemory", usedMemory) + .withData("AvailableMemory", availableMemory); + + if (availableMemory > threshold) { + responseBuilder = responseBuilder.up(); + } else { + responseBuilder = responseBuilder.down(); + } + + return responseBuilder.build(); + } +} +---- + +*Key Features:* +* Monitors JVM memory usage and availability +* Uses fixed 100MB threshold for available memory +* Provides comprehensive memory diagnostics +* Indicates if application should be restarted + +==== Health Check Endpoints + +The health checks are accessible via standard MicroProfile Health endpoints: + +* `/health` - Overall health status (all checks) +* `/health/live` - Liveness checks only +* `/health/ready` - Readiness checks only +* `/health/started` - Startup checks only + +Example health check response: +[source,json] +---- +{ + "status": "UP", + "checks": [ + { + "name": "ProductServiceStartupCheck", + "status": "UP" + }, + { + "name": "ProductServiceReadinessCheck", + "status": "UP" + }, + { + "name": "systemResourcesLiveness", + "status": "UP", + "data": { + "FreeMemory": 524288000, + "MaxMemory": 2147483648, + "AllocatedMemory": 1073741824, + "UsedMemory": 549453824, + "AvailableMemory": 1598029824 + } + } + ] +} +---- + +==== Health Check Benefits + +The comprehensive health monitoring provides: + +* *Startup Validation*: Ensures all dependencies are initialized before serving traffic +* *Readiness Monitoring*: Validates service can handle requests (database connectivity) +* *Liveness Detection*: Identifies when application needs restart (memory issues) +* *Operational Visibility*: Detailed diagnostics for troubleshooting +* *Container Orchestration*: Kubernetes/Docker health probe integration +* *Load Balancer Integration*: Traffic routing based on health status + +=== MicroProfile Metrics + +The application implements comprehensive monitoring using MicroProfile Metrics to track application performance and usage patterns. Three types of metrics are implemented to provide insights into the product catalog service behavior. + +==== Metrics Implementation + +The metrics are implemented using MicroProfile Metrics annotations directly on the REST resource methods: + +[source,java] +---- +@Path("/products") +@ApplicationScoped +public class ProductResource { + + @GET + @Path("/{id}") + @Timed(name = "productLookupTime", + description = "Time spent looking up products") + public Response getProductById(@PathParam("id") Long id) { + // Processing delay to demonstrate timing metrics + try { + Thread.sleep(100); // 100ms processing time + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + // Product lookup logic... + } + + @GET + @Counted(name = "productAccessCount", absolute = true, + description = "Number of times list of products is requested") + public Response getAllProducts() { + // Product listing logic... + } + + @GET + @Path("/count") + @Gauge(name = "productCatalogSize", unit = "none", + description = "Current number of products in catalog") + public int getProductCount() { + // Return current product count... + } +} +---- + +==== Metric Types Implemented + +*1. Timer Metrics (@Timed)* + +The `productLookupTime` timer measures the time spent retrieving individual products: + +* *Purpose*: Track performance of product lookup operations +* *Method*: `getProductById(Long id)` +* *Use Case*: Identify performance bottlenecks in product retrieval +* *Processing Delay*: Includes a 100ms processing delay to demonstrate measurable timing + +*2. Counter Metrics (@Counted)* + +The `productAccessCount` counter tracks how frequently the product list is accessed: + +* *Purpose*: Monitor usage patterns and API call frequency +* *Method*: `getAllProducts()` +* *Configuration*: Uses `absolute = true` for consistent naming +* *Use Case*: Understanding service load and usage patterns + +*3. Gauge Metrics (@Gauge)* + +The `productCatalogSize` gauge provides real-time catalog size information: + +* *Purpose*: Monitor the current state of the product catalog +* *Method*: `getProductCount()` +* *Unit*: Specified as "none" for simple count values +* *Use Case*: Track catalog growth and current inventory levels + +==== Metrics Configuration + +The metrics feature is configured in the Liberty `server.xml`: + +[source,xml] +---- + + + + + + +---- + +*Key Configuration Features:* +* *Authentication Disabled*: Allows easy access to metrics endpoints for development +* *Default Endpoints*: Standard MicroProfile Metrics endpoints are automatically enabled + +==== Metrics Endpoints + +The metrics are accessible via standard MicroProfile Metrics endpoints: + +* `/metrics` - All metrics in Prometheus format +* `/metrics?scope=application` - Application-specific metrics only +* `/metrics?scope=vendor` - Vendor-specific metrics (Open Liberty) +* `/metrics?scope=base` - Base system metrics (JVM, memory, etc.) +* `/metrics?name=` - Specific metric by name +* `/metrics?scope=&name=` - Specific metric from specific scope + +==== Sample Metrics Output + +Example metrics output from `/metrics?scope=application`: + +[source,prometheus] +---- +# TYPE application_productLookupTime_rate_per_second gauge +application_productLookupTime_rate_per_second 0.0 + +# TYPE application_productLookupTime_one_min_rate_per_second gauge +application_productLookupTime_one_min_rate_per_second 0.0 + +# TYPE application_productLookupTime_five_min_rate_per_second gauge +application_productLookupTime_five_min_rate_per_second 0.0 + +# TYPE application_productLookupTime_fifteen_min_rate_per_second gauge +application_productLookupTime_fifteen_min_rate_per_second 0.0 + +# TYPE application_productLookupTime_seconds summary +application_productLookupTime_seconds_count 5 +application_productLookupTime_seconds_sum 0.52487 +application_productLookupTime_seconds{quantile="0.5"} 0.1034 +application_productLookupTime_seconds{quantile="0.75"} 0.1089 +application_productLookupTime_seconds{quantile="0.95"} 0.1123 +application_productLookupTime_seconds{quantile="0.98"} 0.1123 +application_productLookupTime_seconds{quantile="0.99"} 0.1123 +application_productLookupTime_seconds{quantile="0.999"} 0.1123 + +# TYPE application_productAccessCount_total counter +application_productAccessCount_total 15 + +# TYPE application_productCatalogSize gauge +application_productCatalogSize 3 +---- + +==== Testing Metrics + +You can test the metrics implementation using curl commands: + +[source,bash] +---- +# View all metrics +curl -X GET http://localhost:5050/metrics + +# View only application metrics +curl -X GET http://localhost:5050/metrics?scope=application + +# View specific metric by name +curl -X GET "http://localhost:5050/metrics?name=productAccessCount" + +# View specific metric from application scope +curl -X GET "http://localhost:5050/metrics?scope=application&name=productLookupTime" + +# Generate some metric data by calling endpoints +curl -X GET http://localhost:5050/api/products # Increments counter +curl -X GET http://localhost:5050/api/products/1 # Records timing +curl -X GET http://localhost:5050/api/products/count # Updates gauge +---- + +==== Metrics Benefits + +The metrics implementation provides: + +* *Performance Monitoring*: Track response times and identify slow operations +* *Usage Analytics*: Understand API usage patterns and frequency +* *Capacity Planning*: Monitor catalog size and growth trends +* *Operational Insights*: Real-time visibility into service behavior +* *Integration Ready*: Prometheus-compatible format for monitoring systems +* *Troubleshooting*: Correlation of performance issues with usage patterns + +=== MicroProfile Config + +The application uses MicroProfile Config to externalize configuration: + +[source,properties] +---- +# Enable OpenAPI scanning +mp.openapi.scan=true + +# Maintenance mode configuration +product.maintenanceMode=false +product.maintenanceMessage=The product catalog service is currently in maintenance mode. Please try again later. +---- + +The maintenance mode configuration allows dynamic control of service availability: + +* `product.maintenanceMode` - When set to `true`, the service returns a 503 Service Unavailable response +* `product.maintenanceMessage` - Customizable message displayed when the service is in maintenance mode + +==== Maintenance Mode Implementation + +The service checks the maintenance mode configuration before processing requests: + +[source,java] +---- +@Inject +@ConfigProperty(name="product.maintenanceMode", defaultValue="false") +private boolean maintenanceMode; + +@Inject +@ConfigProperty(name="product.maintenanceMessage", + defaultValue="The product catalog service is currently in maintenance mode. Please try again later.") +private String maintenanceMessage; + +// In request handling method +if (maintenance.isMaintenanceMode()) { + return Response + .status(Response.Status.SERVICE_UNAVAILABLE) + .entity(maintenance.getMaintenanceMessage()) + .build(); +} +---- + +This pattern enables: + +* Graceful service degradation during maintenance periods +* Dynamic control without redeployment (when using external configuration sources) +* Clear communication to API consumers + +== Architecture + +The application follows a layered architecture pattern: + +* *REST Layer* (`ProductResource`) - Handles HTTP requests and responses +* *Service Layer* (`ProductService`) - Contains business logic +* *Repository Layer* (`ProductRepository`) - Manages data access with in-memory storage +* *Model Layer* (`Product`) - Represents the business entities + +=== Persistence Evolution + +This application originally used JPA with Derby for persistence, but has been refactored to use an in-memory implementation: + +[cols="1,1", options="header"] +|=== +| Original JPA/Derby | Current In-Memory Implementation +| Required database configuration | No database configuration needed +| Persistence across restarts | Data reset on restart +| Used EntityManager and transactions | Uses ConcurrentHashMap and AtomicLong +| Required datasource in server.xml | No datasource configuration required +| Complex error handling | Simplified error handling +|=== + +Key architectural benefits of this change: + +* *Simplified Deployment*: No external database required +* *Faster Startup*: No database initialization delay +* *Reduced Dependencies*: Fewer libraries and configurations +* *Easier Testing*: No test database setup needed +* *Consistent Development Environment*: Same behavior across all development machines + +=== Containerization with Docker + +The application can be packaged into a Docker container: + +[source,bash] +---- +# Build the application +mvn clean package + +# Build the Docker image +docker build -t catalog-service . + +# Run the container +docker run -d -p 5050:5050 --name catalog-service catalog-service +---- + +==== AtomicLong in Containerized Environments + +When running the application in Docker or Kubernetes, some important considerations about AtomicLong behavior: + +1. *Per-Container State*: Each container has its own AtomicLong instance and state +2. *ID Collisions in Scaling*: When running multiple replicas, IDs are only unique within each container +3. *Persistence and Restarts*: AtomicLong resets on container restart, potentially causing ID reuse + +To handle these issues in production multi-container environments: + +* *External ID Generation*: Consider using a distributed ID generator service +* *Database Sequences*: For database implementations, use database sequences +* *Universally Unique IDs*: Consider UUIDs instead of sequential numeric IDs +* *Centralized Counter Service*: Use Redis or other distributed counter + +Example of adapting the code for distributed environments: + +[source,java] +---- +// Using UUIDs for distributed environments +private String generateId() { + return UUID.randomUUID().toString(); +} +---- + +== Development Workflow + +=== Running Locally + +To run the application in development mode: + +[source,bash] +---- +mvn clean liberty:dev +---- + +This starts the server in development mode, which: + +* Automatically deploys your code changes +* Provides hot reload capability +* Enables a debugger on port 7777 + +== Project Structure + +[source] +---- +catalog/ +├── src/ +│ ├── main/ +│ │ ├── java/ +│ │ │ └── io/microprofile/tutorial/store/ +│ │ │ └── product/ +│ │ │ ├── entity/ # Domain entities +│ │ │ ├── resource/ # REST resources +│ │ │ └── ProductRestApplication.java +│ │ ├── liberty/ +│ │ │ └── config/ +│ │ │ └── server.xml # Liberty server configuration +│ │ ├── resources/ +│ │ │ └── META-INF/ +│ │ │ └── microprofile-config.properties +│ │ └── webapp/ # Web resources +│ │ ├── index.html # Landing page with API documentation +│ │ └── WEB-INF/ +│ │ └── web.xml # Web application configuration +│ └── test/ # Test classes +└── pom.xml # Maven build file +---- + +== Getting Started + +=== Prerequisites + +* JDK 17+ +* Maven 3.8+ + +=== Building and Running + +To build and run the application: + +[source,bash] +---- +# Clone the repository +git clone https://github.com/yourusername/liberty-rest-app.git +cd code/catalog + +# Build the application +mvn clean package + +# Run the application +mvn liberty:run +---- + +=== Testing the Application + +==== Testing MicroProfile Features + +[source,bash] +---- +# OpenAPI documentation +curl -X GET http://localhost:5050/openapi + +# Check if service is in maintenance mode +curl -X GET http://localhost:5050/api/products + +# Health check endpoints +curl -X GET http://localhost:5050/health +curl -X GET http://localhost:5050/health/live +curl -X GET http://localhost:5050/health/ready +curl -X GET http://localhost:5050/health/started +---- + +*Health Check Testing:* +* `/health` - Overall health status with all checks +* `/health/live` - Liveness checks (memory monitoring) +* `/health/ready` - Readiness checks (database connectivity) +* `/health/started` - Startup checks (EntityManagerFactory initialization) + +*Metrics Testing:* +[source,bash] +---- +# View all metrics +curl -X GET http://localhost:5050/metrics + +# View only application metrics +curl -X GET http://localhost:5050/metrics?scope=application + +# View specific metrics by name +curl -X GET "http://localhost:5050/metrics?name=productAccessCount" + +# Generate metric data by calling endpoints +curl -X GET http://localhost:5050/api/products # Increments counter +curl -X GET http://localhost:5050/api/products/1 # Records timing +curl -X GET http://localhost:5050/api/products/count # Updates gauge +---- + +* `/metrics` - All metrics (application + vendor + base) +* `/metrics?scope=application` - Application-specific metrics only +* `/metrics?scope=vendor` - Open Liberty vendor metrics +* `/metrics?scope=base` - Base JVM and system metrics +* `/metrics/base` - Base JVM and system metrics + +To view the Swagger UI, open the following URL in your browser: +http://localhost:5050/openapi/ui + +To view the landing page with API documentation: +http://localhost:5050/ + +== Server Configuration + +The application uses the following Liberty server configuration: + +[source,xml] +---- + + + jakartaEE-10.0 + microProfile-6.1 + restfulWS + jsonp + jsonb + cdi + mpConfig + mpOpenAPI + mpHealth + + + + + +---- + +== Development + +=== Adding a New Endpoint + +To add a new endpoint: + +1. Create a new method in the `ProductResource` class +2. Add appropriate Jakarta Restful Web Service annotations +3. Add OpenAPI annotations for documentation +4. Implement the business logic + +Example: + +[source,java] +---- +@GET +@Path("/search") +@Produces(MediaType.APPLICATION_JSON) +@Operation(summary = "Search products", description = "Search products by name") +@APIResponses({ + @APIResponse(responseCode = "200", description = "Products matching search criteria") +}) +public Response searchProducts(@QueryParam("name") String name) { + List matchingProducts = products.stream() + .filter(p -> p.getName().toLowerCase().contains(name.toLowerCase())) + .collect(Collectors.toList()); + return Response.ok(matchingProducts).build(); +} +---- + +=== Performance Considerations + +The in-memory data store provides excellent performance for read operations, but there are important considerations: + +* *Memory Usage*: Large data sets may consume significant memory +* *Persistence*: Data is lost when the application restarts +* *Scalability*: In a multi-instance deployment, each instance will have its own data store + +For production scenarios requiring data persistence, consider: + +1. Adding a database layer (PostgreSQL, MongoDB, etc.) +2. Implementing a distributed cache (Hazelcast, Redis, etc.) +3. Adding data synchronization between instances + +=== Concurrency Implementation Details + +==== AtomicLong vs Synchronized Counter + +The repository uses `AtomicLong` rather than traditional synchronized counters: + +[cols="1,1", options="header"] +|=== +| Traditional Approach | AtomicLong Approach +| `private long counter = 0;` | `private final AtomicLong idGenerator = new AtomicLong(1);` +| `synchronized long getNextId() { return ++counter; }` | `long nextId = idGenerator.getAndIncrement();` +| Locks entire method | Lock-free operation +| Subject to contention | Uses CPU compare-and-swap +| Performance degrades with multiple threads | Maintains performance under concurrency +|=== + +==== AtomicLong vs Other Concurrency Options + +[cols="1,1,1,1", options="header"] +|=== +| Feature | AtomicLong | Synchronized | java.util.concurrent.locks.Lock +| Type | Non-blocking | Intrinsic lock | Explicit lock +| Granularity | Single variable | Method/block | Customizable +| Performance under contention | High | Lower | Medium +| Visibility guarantee | Yes | Yes | Yes +| Atomicity guarantee | Yes | Yes | Yes +| Fairness policy | No | No | Optional +| Try/timeout support | Yes (compareAndSet) | No | Yes +| Multiple operations atomicity | Limited | Yes | Yes +| Implementation complexity | Simple | Simple | Complex +|=== + +===== When to Choose AtomicLong + +* *High-Contention Scenarios*: When many threads need to access/modify a counter +* *Single Variable Operations*: When only one variable needs atomic operations +* *Performance-Critical Code*: When minimizing lock contention is essential +* *Read-Heavy Workloads*: When reads significantly outnumber writes + +For this in-memory product repository, AtomicLong provides an optimal balance of safety and performance. + +==== Implementation in createProduct Method + +The ID generation logic handles both automatic and manual ID assignment: + +[source,java] +---- +public Product createProduct(Product product) { + // Generate ID if not provided + if (product.getId() == null) { + product.setId(idGenerator.getAndIncrement()); + } else { + // Update idGenerator if the provided ID is greater than current + long nextId = product.getId() + 1; + while (true) { + long currentId = idGenerator.get(); + if (nextId <= currentId || idGenerator.compareAndSet(currentId, nextId)) { + break; + } + } + } + + productsMap.put(product.getId(), product); + return product; +} +---- + +This implementation ensures ID integrity while supporting both system-generated and client-provided IDs. + +This enables scanning of OpenAPI annotations in the application. + +== Troubleshooting + +=== Common Issues + +* *OpenAPI documentation not available*: Make sure `mp.openapi.scan=true` is set in the properties file +* *Concurrent modification exceptions*: Ensure proper use of thread-safe collections and operations +* *Service always in maintenance mode*: Check the `product.maintenanceMode` property in `microprofile-config.properties` +* *API returning 503 responses*: The service is likely in maintenance mode; set `product.maintenanceMode=false` in configuration +* *OpenAPI documentation not available*: Make sure `mp.openapi.scan=true` is set in the properties file +* *Concurrent modification exceptions*: Ensure proper use of thread-safe collections and operations + +=== Thread Safety Troubleshooting + +If experiencing concurrency issues: + +1. *Verify AtomicLong Usage*: Ensure all ID generation uses `AtomicLong.getAndIncrement()` instead of manual increment +2. *Check Collection Returns*: Always return copies of collections, not direct references: ++ +[source,java] +---- +public List findAllProducts() { + return new ArrayList<>(productsMap.values()); // Correct: returns a new copy +} +---- + +3. *Use ConcurrentHashMap Methods*: Prefer atomic methods like `compute`, `computeIfAbsent`, or `computeIfPresent` for complex operations +4. *Avoid Iteration + Modification*: Don't modify the map while iterating over it + +=== Understanding AtomicLong Internals + +If you need to debug issues with AtomicLong, understanding its internal mechanisms is helpful: + +==== Compare-And-Swap Operation + +AtomicLong relies on hardware-level atomic instructions, specifically Compare-And-Swap (CAS): + +[source,text] +---- +function CAS(address, expected, new): + atomically: + if memory[address] == expected: + memory[address] = new + return true + else: + return false +---- + +The implementation of `getAndIncrement()` uses this mechanism: + +[source,java] +---- +// Simplified implementation of getAndIncrement +public long getAndIncrement() { + while (true) { + long current = get(); + long next = current + 1; + if (compareAndSet(current, next)) + return current; + } +} +---- + +==== Memory Ordering and Visibility + +AtomicLong ensures that memory visibility follows the Java Memory Model: + +* All writes to the AtomicLong by one thread are visible to reads from other threads +* Memory barriers are established when performing atomic operations +* Volatile semantics are guaranteed without using the volatile keyword + +==== Diagnosing AtomicLong Issues + +1. *Unexpected ID Values*: Check for manual ID assignment bypassing the AtomicLong +2. *Duplicate IDs*: Verify the initialization value and ensure all ID assignments go through AtomicLong +3. *Performance Issues*: Look for excessive contention (many threads updating simultaneously) + +=== Logs + +Server logs can be found at: + +[source] +---- +target/liberty/wlp/usr/servers/defaultServer/logs/ +---- + +== Resources + +* https://microprofile.io/[MicroProfile] + +=== HTML Landing Page + +The application includes a user-friendly HTML landing page (`index.html`) that provides: + +* Service overview with comprehensive documentation +* API endpoints documentation with methods and descriptions +* Interactive examples for all API operations +* Links to OpenAPI/Swagger documentation + +==== Maintenance Mode Configuration in the UI + +The index.html page is designed to work seamlessly with the maintenance mode configuration. When maintenance mode is enabled via the `product.maintenanceMode` property, all API endpoints return a 503 Service Unavailable response with the configured maintenance message. + +The landing page displays comprehensive documentation about the API regardless of the maintenance state, allowing developers to continue learning about the API even when the service is undergoing maintenance. + +Key features of the landing page: + +* *Responsive Design*: Works well on desktop and mobile devices +* *Comprehensive API Documentation*: All endpoints with sample requests and responses +* *Interactive Examples*: Detailed sample requests and responses for each endpoint +* *Modern Styling*: Clean, professional appearance with card-based layout + +The landing page is configured as the welcome file in `web.xml`: + +[source,xml] +---- + + index.html + +---- + +This provides a user-friendly entry point for API consumers and developers. + + diff --git a/code/chapter07/catalog/pom.xml b/code/chapter07/catalog/pom.xml new file mode 100644 index 0000000..65fc473 --- /dev/null +++ b/code/chapter07/catalog/pom.xml @@ -0,0 +1,111 @@ + + + 4.0.0 + + io.microprofile.tutorial + catalog + 1.0-SNAPSHOT + war + + + + + 17 + 17 + + UTF-8 + UTF-8 + + + 5050 + 5051 + + catalog + + + + + + + org.projectlombok + lombok + 1.18.26 + provided + + + + + jakarta.platform + jakarta.jakartaee-api + 10.0.0 + provided + + + + + org.eclipse.microprofile + microprofile + 6.1 + pom + provided + + + + + org.apache.derby + derby + 10.16.1.1 + + + + + org.apache.derby + derbyshared + 10.16.1.1 + + + + + org.apache.derby + derbytools + 10.16.1.1 + + + + + ${project.artifactId} + + + + io.openliberty.tools + liberty-maven-plugin + 3.11.2 + + mpServer + + ${project.build.directory}/liberty/wlp/usr/servers/mpServer/derby + + org.apache.derby + derby + + + org.apache.derby + derbyshared + + + org.apache.derby + derbytools + + + + + + + org.apache.maven.plugins + maven-war-plugin + 3.4.0 + + + + \ No newline at end of file diff --git a/code/chapter07/catalog/src/main/java/io/microprofile/tutorial/store/product/ProductRestApplication.java b/code/chapter07/catalog/src/main/java/io/microprofile/tutorial/store/product/ProductRestApplication.java new file mode 100644 index 0000000..9759e1f --- /dev/null +++ b/code/chapter07/catalog/src/main/java/io/microprofile/tutorial/store/product/ProductRestApplication.java @@ -0,0 +1,9 @@ +package io.microprofile.tutorial.store.product; + +import jakarta.ws.rs.ApplicationPath; +import jakarta.ws.rs.core.Application; + +@ApplicationPath("/api") +public class ProductRestApplication extends Application { + // No additional configuration is needed here +} \ No newline at end of file diff --git a/code/chapter07/catalog/src/main/java/io/microprofile/tutorial/store/product/entity/Product.java b/code/chapter07/catalog/src/main/java/io/microprofile/tutorial/store/product/entity/Product.java new file mode 100644 index 0000000..c6fe0f3 --- /dev/null +++ b/code/chapter07/catalog/src/main/java/io/microprofile/tutorial/store/product/entity/Product.java @@ -0,0 +1,144 @@ +package io.microprofile.tutorial.store.product.entity; + +import jakarta.persistence.*; +import jakarta.validation.constraints.*; +import java.math.BigDecimal; + +/** + * Product entity representing a product in the catalog. + * This entity is mapped to the PRODUCTS table in the database. + */ +@Entity +@Table(name = "PRODUCTS") +@NamedQueries({ + @NamedQuery(name = "Product.findAll", query = "SELECT p FROM Product p"), + @NamedQuery(name = "Product.findById", query = "SELECT p FROM Product p WHERE p.id = :id"), + @NamedQuery(name = "Product.findByName", query = "SELECT p FROM Product p WHERE p.name LIKE :name"), + @NamedQuery(name = "Product.searchProducts", + query = "SELECT p FROM Product p WHERE " + + "(:name IS NULL OR LOWER(p.name) LIKE :namePattern) AND " + + "(:description IS NULL OR LOWER(p.description) LIKE :descriptionPattern) AND " + + "(:minPrice IS NULL OR p.price >= :minPrice) AND " + + "(:maxPrice IS NULL OR p.price <= :maxPrice)") +}) +public class Product { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "ID") + private Long id; + + @NotNull(message = "Product name cannot be null") + @NotBlank(message = "Product name cannot be blank") + @Size(min = 1, max = 100, message = "Product name must be between 1 and 100 characters") + @Column(name = "NAME", nullable = false, length = 100) + private String name; + + @Size(max = 500, message = "Product description cannot exceed 500 characters") + @Column(name = "DESCRIPTION", length = 500) + private String description; + + @NotNull(message = "Product price cannot be null") + @DecimalMin(value = "0.0", inclusive = false, message = "Product price must be greater than 0") + @Column(name = "PRICE", nullable = false, precision = 10, scale = 2) + private BigDecimal price; + + // Default constructor + public Product() { + } + + // Constructor with all fields + public Product(Long id, String name, String description, BigDecimal price) { + this.id = id; + this.name = name; + this.description = description; + this.price = price; + } + + // Constructor without ID (for new entities) + public Product(String name, String description, BigDecimal price) { + this.name = name; + this.description = description; + this.price = price; + } + + // Getters and setters + public Long getId() { + return id; + } + + public void setId(Long id) { + this.id = id; + } + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + + public String getDescription() { + return description; + } + + public void setDescription(String description) { + this.description = description; + } + + public BigDecimal getPrice() { + return price; + } + + public void setPrice(BigDecimal price) { + this.price = price; + } + + @Override + public String toString() { + return "Product{" + + "id=" + id + + ", name='" + name + '\'' + + ", description='" + description + '\'' + + ", price=" + price + + '}'; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (!(o instanceof Product)) return false; + Product product = (Product) o; + return id != null && id.equals(product.id); + } + + @Override + public int hashCode() { + return getClass().hashCode(); + } + + /** + * Constructor that accepts Double price for backward compatibility + */ + public Product(Long id, String name, String description, Double price) { + this.id = id; + this.name = name; + this.description = description; + this.price = price != null ? BigDecimal.valueOf(price) : null; + } + + /** + * Get price as Double for backward compatibility + */ + public Double getPriceAsDouble() { + return price != null ? price.doubleValue() : null; + } + + /** + * Set price from Double for backward compatibility + */ + public void setPriceFromDouble(Double price) { + this.price = price != null ? BigDecimal.valueOf(price) : null; + } +} diff --git a/code/chapter07/catalog/src/main/java/io/microprofile/tutorial/store/product/health/ProductServiceHealthCheck.java b/code/chapter07/catalog/src/main/java/io/microprofile/tutorial/store/product/health/ProductServiceHealthCheck.java new file mode 100644 index 0000000..fd94761 --- /dev/null +++ b/code/chapter07/catalog/src/main/java/io/microprofile/tutorial/store/product/health/ProductServiceHealthCheck.java @@ -0,0 +1,45 @@ +package io.microprofile.tutorial.store.product.health; + +import io.microprofile.tutorial.store.product.entity.Product; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.persistence.EntityManager; +import jakarta.persistence.PersistenceContext; +import org.eclipse.microprofile.health.HealthCheck; +import org.eclipse.microprofile.health.HealthCheckResponse; +import org.eclipse.microprofile.health.Readiness; + +/** + * Readiness health check for the Product Catalog service. + * Provides database connection health checks to monitor service health and availability. + */ +@Readiness +@ApplicationScoped +public class ProductServiceHealthCheck implements HealthCheck { + + @PersistenceContext + EntityManager entityManager; + + @Override + public HealthCheckResponse call() { + if (isDatabaseConnectionHealthy()) { + return HealthCheckResponse.named("ProductServiceReadinessCheck") + .up() + .build(); + } else { + return HealthCheckResponse.named("ProductServiceReadinessCheck") + .down() + .build(); + } + } + + private boolean isDatabaseConnectionHealthy(){ + try { + // Perform a lightweight query to check the database connection + entityManager.find(Product.class, 1L); + return true; + } catch (Exception e) { + System.err.println("Database connection is not healthy: " + e.getMessage()); + return false; + } + } +} diff --git a/code/chapter07/catalog/src/main/java/io/microprofile/tutorial/store/product/health/ProductServiceLivenessCheck.java b/code/chapter07/catalog/src/main/java/io/microprofile/tutorial/store/product/health/ProductServiceLivenessCheck.java new file mode 100644 index 0000000..c7d6e65 --- /dev/null +++ b/code/chapter07/catalog/src/main/java/io/microprofile/tutorial/store/product/health/ProductServiceLivenessCheck.java @@ -0,0 +1,47 @@ +package io.microprofile.tutorial.store.product.health; + +import jakarta.enterprise.context.ApplicationScoped; +import org.eclipse.microprofile.health.HealthCheck; +import org.eclipse.microprofile.health.HealthCheckResponse; +import org.eclipse.microprofile.health.HealthCheckResponseBuilder; +import org.eclipse.microprofile.health.Liveness; + +/** + * Liveness health check for the Product Catalog service. + * Verifies that the application is running and not in a failed state. + * This check should only fail if the application is completely broken. + */ +@Liveness +@ApplicationScoped +public class ProductServiceLivenessCheck implements HealthCheck { + + @Override + public HealthCheckResponse call() { + Runtime runtime = Runtime.getRuntime(); + long maxMemory = runtime.maxMemory(); // Maximum amount of memory the JVM will attempt to use + long allocatedMemory = runtime.totalMemory(); // Total memory currently allocated to the JVM + long freeMemory = runtime.freeMemory(); // Amount of free memory within the allocated memory + long usedMemory = allocatedMemory - freeMemory; // Actual memory used + long availableMemory = maxMemory - usedMemory; // Total available memory + + long threshold = 100 * 1024 * 1024; // threshold: 100MB + + // Including diagnostic data in the response + HealthCheckResponseBuilder responseBuilder = HealthCheckResponse.named("systemResourcesLiveness") + .withData("FreeMemory", freeMemory) + .withData("MaxMemory", maxMemory) + .withData("AllocatedMemory", allocatedMemory) + .withData("UsedMemory", usedMemory) + .withData("AvailableMemory", availableMemory); + + if (availableMemory > threshold) { + // The system is considered live + responseBuilder = responseBuilder.up(); + } else { + // The system is not live. + responseBuilder = responseBuilder.down(); + } + + return responseBuilder.build(); + } +} diff --git a/code/chapter07/catalog/src/main/java/io/microprofile/tutorial/store/product/health/ProductServiceStartupCheck.java b/code/chapter07/catalog/src/main/java/io/microprofile/tutorial/store/product/health/ProductServiceStartupCheck.java new file mode 100644 index 0000000..84f22b1 --- /dev/null +++ b/code/chapter07/catalog/src/main/java/io/microprofile/tutorial/store/product/health/ProductServiceStartupCheck.java @@ -0,0 +1,29 @@ +package io.microprofile.tutorial.store.product.health; + +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.persistence.EntityManagerFactory; +import jakarta.persistence.PersistenceUnit; +import org.eclipse.microprofile.health.HealthCheck; +import org.eclipse.microprofile.health.HealthCheckResponse; +import org.eclipse.microprofile.health.Startup; + +/** + * Startup health check for the Product Catalog service. + * Verifies that the EntityManagerFactory is properly initialized during application startup. + */ +@Startup +@ApplicationScoped +public class ProductServiceStartupCheck implements HealthCheck{ + + @PersistenceUnit + private EntityManagerFactory emf; + + @Override + public HealthCheckResponse call() { + if (emf != null && emf.isOpen()) { + return HealthCheckResponse.up("ProductServiceStartupCheck"); + } else { + return HealthCheckResponse.down("ProductServiceStartupCheck"); + } + } +} diff --git a/code/chapter07/catalog/src/main/java/io/microprofile/tutorial/store/product/repository/InMemory.java b/code/chapter07/catalog/src/main/java/io/microprofile/tutorial/store/product/repository/InMemory.java new file mode 100644 index 0000000..b322ccf --- /dev/null +++ b/code/chapter07/catalog/src/main/java/io/microprofile/tutorial/store/product/repository/InMemory.java @@ -0,0 +1,16 @@ +package io.microprofile.tutorial.store.product.repository; + +import jakarta.inject.Qualifier; +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** + * CDI qualifier for in-memory repository implementation. + */ +@Qualifier +@Retention(RetentionPolicy.RUNTIME) +@Target({ElementType.FIELD, ElementType.METHOD, ElementType.TYPE, ElementType.PARAMETER}) +public @interface InMemory { +} diff --git a/code/chapter07/catalog/src/main/java/io/microprofile/tutorial/store/product/repository/JPA.java b/code/chapter07/catalog/src/main/java/io/microprofile/tutorial/store/product/repository/JPA.java new file mode 100644 index 0000000..fd4a6bd --- /dev/null +++ b/code/chapter07/catalog/src/main/java/io/microprofile/tutorial/store/product/repository/JPA.java @@ -0,0 +1,16 @@ +package io.microprofile.tutorial.store.product.repository; + +import jakarta.inject.Qualifier; +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** + * CDI qualifier for JPA repository implementation. + */ +@Qualifier +@Retention(RetentionPolicy.RUNTIME) +@Target({ElementType.FIELD, ElementType.METHOD, ElementType.TYPE, ElementType.PARAMETER}) +public @interface JPA { +} diff --git a/code/chapter07/catalog/src/main/java/io/microprofile/tutorial/store/product/repository/ProductInMemoryRepository.java b/code/chapter07/catalog/src/main/java/io/microprofile/tutorial/store/product/repository/ProductInMemoryRepository.java new file mode 100644 index 0000000..a15ef9a --- /dev/null +++ b/code/chapter07/catalog/src/main/java/io/microprofile/tutorial/store/product/repository/ProductInMemoryRepository.java @@ -0,0 +1,140 @@ +package io.microprofile.tutorial.store.product.repository; + +import io.microprofile.tutorial.store.product.entity.Product; +import jakarta.enterprise.context.ApplicationScoped; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.atomic.AtomicLong; +import java.util.logging.Logger; +import java.util.stream.Collectors; + +/** + * In-memory repository implementation for Product entity. + * Provides in-memory persistence operations using ConcurrentHashMap. + * This is used as a fallback when JPA is not available. + */ +@ApplicationScoped +@InMemory +public class ProductInMemoryRepository implements ProductRepositoryInterface { + + private static final Logger LOGGER = Logger.getLogger(ProductInMemoryRepository.class.getName()); + + // In-memory storage using ConcurrentHashMap for thread safety + private final Map productsMap = new ConcurrentHashMap<>(); + + // ID generator + private final AtomicLong idGenerator = new AtomicLong(1); + + /** + * Constructor with sample data initialization. + */ + public ProductInMemoryRepository() { + // Initialize with sample products using the Double constructor for compatibility + createProduct(new Product(null, "iPhone", "Apple iPhone 15", 999.99)); + createProduct(new Product(null, "MacBook", "Apple MacBook Air", 1299.0)); + createProduct(new Product(null, "iPad", "Apple iPad Pro", 799.0)); + LOGGER.info("ProductInMemoryRepository initialized with sample products"); + } + + /** + * Retrieves all products. + * + * @return List of all products + */ + public List findAllProducts() { + LOGGER.fine("Repository: Finding all products"); + return new ArrayList<>(productsMap.values()); + } + + /** + * Retrieves a product by ID. + * + * @param id Product ID + * @return The product or null if not found + */ + public Product findProductById(Long id) { + LOGGER.fine("Repository: Finding product with ID: " + id); + return productsMap.get(id); + } + + /** + * Creates a new product. + * + * @param product Product data to create + * @return The created product with ID + */ + public Product createProduct(Product product) { + // Generate ID if not provided + if (product.getId() == null) { + product.setId(idGenerator.getAndIncrement()); + } else { + // Update idGenerator if the provided ID is greater than current + long nextId = product.getId() + 1; + while (true) { + long currentId = idGenerator.get(); + if (nextId <= currentId || idGenerator.compareAndSet(currentId, nextId)) { + break; + } + } + } + + LOGGER.fine("Repository: Creating product with ID: " + product.getId()); + productsMap.put(product.getId(), product); + return product; + } + + /** + * Updates an existing product. + * + * @param product Updated product data + * @return The updated product or null if not found + */ + public Product updateProduct(Product product) { + Long id = product.getId(); + if (id != null && productsMap.containsKey(id)) { + LOGGER.fine("Repository: Updating product with ID: " + id); + productsMap.put(id, product); + return product; + } + LOGGER.warning("Repository: Product not found for update, ID: " + id); + return null; + } + + /** + * Deletes a product by ID. + * + * @param id ID of the product to delete + * @return true if deleted, false if not found + */ + public boolean deleteProduct(Long id) { + if (productsMap.containsKey(id)) { + LOGGER.fine("Repository: Deleting product with ID: " + id); + productsMap.remove(id); + return true; + } + LOGGER.warning("Repository: Product not found for deletion, ID: " + id); + return false; + } + + /** + * Searches for products by criteria. + * + * @param name Product name (optional) + * @param description Product description (optional) + * @param minPrice Minimum price (optional) + * @param maxPrice Maximum price (optional) + * @return List of matching products + */ + public List searchProducts(String name, String description, Double minPrice, Double maxPrice) { + LOGGER.fine("Repository: Searching for products with criteria"); + + return productsMap.values().stream() + .filter(p -> name == null || p.getName().toLowerCase().contains(name.toLowerCase())) + .filter(p -> description == null || p.getDescription().toLowerCase().contains(description.toLowerCase())) + .filter(p -> minPrice == null || (p.getPrice() != null && p.getPrice().doubleValue() >= minPrice)) + .filter(p -> maxPrice == null || (p.getPrice() != null && p.getPrice().doubleValue() <= maxPrice)) + .collect(Collectors.toList()); + } +} \ No newline at end of file diff --git a/code/chapter07/catalog/src/main/java/io/microprofile/tutorial/store/product/repository/ProductJpaRepository.java b/code/chapter07/catalog/src/main/java/io/microprofile/tutorial/store/product/repository/ProductJpaRepository.java new file mode 100644 index 0000000..1bf4343 --- /dev/null +++ b/code/chapter07/catalog/src/main/java/io/microprofile/tutorial/store/product/repository/ProductJpaRepository.java @@ -0,0 +1,150 @@ +package io.microprofile.tutorial.store.product.repository; + +import io.microprofile.tutorial.store.product.entity.Product; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.persistence.EntityManager; +import jakarta.persistence.PersistenceContext; +import jakarta.persistence.TypedQuery; +import jakarta.transaction.Transactional; +import java.math.BigDecimal; +import java.util.List; +import java.util.logging.Logger; + +/** + * JPA-based repository implementation for Product entity. + * Provides database persistence operations using Apache Derby. + */ +@ApplicationScoped +@JPA +@Transactional +public class ProductJpaRepository implements ProductRepositoryInterface { + + private static final Logger LOGGER = Logger.getLogger(ProductJpaRepository.class.getName()); + + @PersistenceContext(unitName = "catalogPU") + private EntityManager entityManager; + + @Override + public List findAllProducts() { + LOGGER.fine("JPA Repository: Finding all products"); + TypedQuery query = entityManager.createNamedQuery("Product.findAll", Product.class); + return query.getResultList(); + } + + @Override + public Product findProductById(Long id) { + LOGGER.fine("JPA Repository: Finding product with ID: " + id); + if (id == null) { + return null; + } + return entityManager.find(Product.class, id); + } + + @Override + public Product createProduct(Product product) { + LOGGER.info("JPA Repository: Creating new product: " + product); + if (product == null) { + throw new IllegalArgumentException("Product cannot be null"); + } + + // Ensure ID is null for new products + product.setId(null); + + entityManager.persist(product); + entityManager.flush(); // Force the insert to get the generated ID + + LOGGER.info("JPA Repository: Created product with ID: " + product.getId()); + return product; + } + + @Override + public Product updateProduct(Product product) { + LOGGER.info("JPA Repository: Updating product: " + product); + if (product == null || product.getId() == null) { + throw new IllegalArgumentException("Product and ID cannot be null for update"); + } + + Product existingProduct = entityManager.find(Product.class, product.getId()); + if (existingProduct == null) { + LOGGER.warning("JPA Repository: Product not found for update: " + product.getId()); + return null; + } + + // Update fields + existingProduct.setName(product.getName()); + existingProduct.setDescription(product.getDescription()); + existingProduct.setPrice(product.getPrice()); + + Product updatedProduct = entityManager.merge(existingProduct); + entityManager.flush(); + + LOGGER.info("JPA Repository: Updated product with ID: " + updatedProduct.getId()); + return updatedProduct; + } + + @Override + public boolean deleteProduct(Long id) { + LOGGER.info("JPA Repository: Deleting product with ID: " + id); + if (id == null) { + return false; + } + + Product product = entityManager.find(Product.class, id); + if (product != null) { + entityManager.remove(product); + entityManager.flush(); + LOGGER.info("JPA Repository: Deleted product with ID: " + id); + return true; + } + + LOGGER.warning("JPA Repository: Product not found for deletion: " + id); + return false; + } + + @Override + public List searchProducts(String name, String description, Double minPrice, Double maxPrice) { + LOGGER.info("JPA Repository: Searching for products with criteria"); + + StringBuilder jpql = new StringBuilder("SELECT p FROM Product p WHERE 1=1"); + + if (name != null && !name.trim().isEmpty()) { + jpql.append(" AND LOWER(p.name) LIKE :namePattern"); + } + + if (description != null && !description.trim().isEmpty()) { + jpql.append(" AND LOWER(p.description) LIKE :descriptionPattern"); + } + + if (minPrice != null) { + jpql.append(" AND p.price >= :minPrice"); + } + + if (maxPrice != null) { + jpql.append(" AND p.price <= :maxPrice"); + } + + TypedQuery query = entityManager.createQuery(jpql.toString(), Product.class); + + // Set parameters only if they are provided + if (name != null && !name.trim().isEmpty()) { + query.setParameter("namePattern", "%" + name.toLowerCase() + "%"); + } + + if (description != null && !description.trim().isEmpty()) { + query.setParameter("descriptionPattern", "%" + description.toLowerCase() + "%"); + } + + if (minPrice != null) { + query.setParameter("minPrice", BigDecimal.valueOf(minPrice)); + } + + if (maxPrice != null) { + query.setParameter("maxPrice", BigDecimal.valueOf(maxPrice)); + } + + List results = query.getResultList(); + LOGGER.info("JPA Repository: Found " + results.size() + " products matching criteria"); + + return results; + } +} diff --git a/code/chapter07/catalog/src/main/java/io/microprofile/tutorial/store/product/repository/ProductRepositoryInterface.java b/code/chapter07/catalog/src/main/java/io/microprofile/tutorial/store/product/repository/ProductRepositoryInterface.java new file mode 100644 index 0000000..4b981b2 --- /dev/null +++ b/code/chapter07/catalog/src/main/java/io/microprofile/tutorial/store/product/repository/ProductRepositoryInterface.java @@ -0,0 +1,61 @@ +package io.microprofile.tutorial.store.product.repository; + +import io.microprofile.tutorial.store.product.entity.Product; +import java.util.List; + +/** + * Repository interface for Product entity operations. + * Defines the contract for product data access. + */ +public interface ProductRepositoryInterface { + + /** + * Retrieves all products. + * + * @return List of all products + */ + List findAllProducts(); + + /** + * Retrieves a product by ID. + * + * @param id Product ID + * @return The product or null if not found + */ + Product findProductById(Long id); + + /** + * Creates a new product. + * + * @param product Product data to create + * @return The created product with ID + */ + Product createProduct(Product product); + + /** + * Updates an existing product. + * + * @param product Updated product data + * @return The updated product + */ + Product updateProduct(Product product); + + /** + * Deletes a product by ID. + * + * @param id ID of the product to delete + * @return true if deleted, false if not found + */ + boolean deleteProduct(Long id); + + /** + * Searches for products by criteria. + * + * @param name Product name (optional) + * @param description Product description (optional) + * @param minPrice Minimum price (optional) + * @param maxPrice Maximum price (optional) + * @return List of matching products + */ + List searchProducts(String name, String description, Double minPrice, Double maxPrice); +} diff --git a/code/chapter07/catalog/src/main/java/io/microprofile/tutorial/store/product/repository/RepositoryType.java b/code/chapter07/catalog/src/main/java/io/microprofile/tutorial/store/product/repository/RepositoryType.java new file mode 100644 index 0000000..e2bf8c9 --- /dev/null +++ b/code/chapter07/catalog/src/main/java/io/microprofile/tutorial/store/product/repository/RepositoryType.java @@ -0,0 +1,29 @@ +package io.microprofile.tutorial.store.product.repository; + +import jakarta.inject.Qualifier; +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** + * CDI qualifier to distinguish between different repository implementations. + */ +@Qualifier +@Retention(RetentionPolicy.RUNTIME) +@Target({ElementType.TYPE, ElementType.METHOD, ElementType.FIELD, ElementType.PARAMETER}) +public @interface RepositoryType { + + /** + * Repository implementation type. + */ + Type value(); + + /** + * Enumeration of repository types. + */ + enum Type { + JPA, + IN_MEMORY + } +} diff --git a/code/chapter07/catalog/src/main/java/io/microprofile/tutorial/store/product/resource/ProductResource.java b/code/chapter07/catalog/src/main/java/io/microprofile/tutorial/store/product/resource/ProductResource.java new file mode 100644 index 0000000..5f40f00 --- /dev/null +++ b/code/chapter07/catalog/src/main/java/io/microprofile/tutorial/store/product/resource/ProductResource.java @@ -0,0 +1,203 @@ +package io.microprofile.tutorial.store.product.resource; + +import java.util.List; +import java.util.logging.Logger; +import java.util.logging.Level; + +import org.eclipse.microprofile.openapi.annotations.Operation; +import org.eclipse.microprofile.openapi.annotations.media.Content; +import org.eclipse.microprofile.openapi.annotations.media.Schema; +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 org.eclipse.microprofile.config.inject.ConfigProperty; +import org.eclipse.microprofile.metrics.annotation.Counted; +import org.eclipse.microprofile.metrics.annotation.Gauge; +import org.eclipse.microprofile.metrics.annotation.Timed; + +import io.microprofile.tutorial.store.product.entity.Product; +import io.microprofile.tutorial.store.product.service.ProductService; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.inject.Inject; + +import jakarta.ws.rs.Consumes; +import jakarta.ws.rs.DELETE; +import jakarta.ws.rs.GET; +import jakarta.ws.rs.POST; +import jakarta.ws.rs.PUT; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.PathParam; +import jakarta.ws.rs.Produces; +import jakarta.ws.rs.QueryParam; +import jakarta.ws.rs.core.MediaType; +import jakarta.ws.rs.core.Response; + +@ApplicationScoped +@Path("/products") +@Tag(name = "Product Resource", description = "CRUD operations for products") +public class ProductResource { + + private static final Logger LOGGER = Logger.getLogger(ProductResource.class.getName()); + + @Inject + @ConfigProperty(name="product.maintenanceMode", defaultValue="false") + private boolean maintenanceMode; + + @Inject + private ProductService productService; + + @GET + @Produces(MediaType.APPLICATION_JSON) + @Operation(summary = "List all products", description = "Retrieves a list of all products") + @APIResponses({ + @APIResponse( + responseCode = "200", + description = "Successful, list of products found", + content = @Content(mediaType = "application/json", + schema = @Schema(implementation = Product.class))), + @APIResponse( + responseCode = "400", + description = "Unsuccessful, no products found", + content = @Content(mediaType = "application/json") + ), + @APIResponse( + responseCode = "503", + description = "Service is under maintenance", + content = @Content(mediaType = "application/json") + ) + }) + // Expose the invocation count as a counter metric + @Counted(name = "productAccessCount", + absolute = true, + description = "Number of times the list of products is requested") + public Response getAllProducts() { + LOGGER.log(Level.INFO, "REST: Fetching all products"); + List products = productService.findAllProducts(); + + if (maintenanceMode) { + return Response + .status(Response.Status.SERVICE_UNAVAILABLE) + .entity("Service is under maintenance") + .build(); + } + + if (products != null && !products.isEmpty()) { + return Response + .status(Response.Status.OK) + .entity(products).build(); + } else { + return Response + .status(Response.Status.NOT_FOUND) + .entity("No products found") + .build(); + } + } + + @GET + @Path("/count") + @Produces(MediaType.APPLICATION_JSON) + @Gauge(name = "productCatalogSize", + unit = "none", + description = "Current number of products in the catalog") + public long getProductCount() { + return productService.findAllProducts().size(); + } + + @GET + @Path("/{id}") + @Produces(MediaType.APPLICATION_JSON) + @Timed(name = "productLookupTime", + tags = {"method=getProduct"}, + absolute = true, + description = "Time spent looking up products") + @Operation(summary = "Get product by ID", description = "Returns a product by its ID") + @APIResponses({ + @APIResponse(responseCode = "200", description = "Product found", content = @Content(mediaType = "application/json", schema = @Schema(implementation = Product.class))), + @APIResponse(responseCode = "404", description = "Product not found"), + @APIResponse(responseCode = "503", description = "Service is under maintenance") + }) + public Response getProductById(@PathParam("id") Long id) { + LOGGER.log(Level.INFO, "REST: Fetching product with id: {0}", id); + + if (maintenanceMode) { + return Response + .status(Response.Status.SERVICE_UNAVAILABLE) + .entity("Service is under maintenance") + .build(); + } + + Product product = productService.findProductById(id); + if (product != null) { + return Response.ok(product).build(); + } else { + return Response.status(Response.Status.NOT_FOUND).build(); + } + } + + @POST + @Consumes(MediaType.APPLICATION_JSON) + @Produces(MediaType.APPLICATION_JSON) + @Operation(summary = "Create a new product", description = "Creates a new product") + @APIResponses({ + @APIResponse(responseCode = "201", description = "Product created", content = @Content(mediaType = "application/json", schema = @Schema(implementation = Product.class))) + }) + public Response createProduct(Product product) { + LOGGER.info("REST: Creating product: " + product); + Product createdProduct = productService.createProduct(product); + return Response.status(Response.Status.CREATED).entity(createdProduct).build(); + } + + @PUT + @Path("/{id}") + @Consumes(MediaType.APPLICATION_JSON) + @Produces(MediaType.APPLICATION_JSON) + @Operation(summary = "Update a product", description = "Updates an existing product by its ID") + @APIResponses({ + @APIResponse(responseCode = "200", description = "Product updated", content = @Content(mediaType = "application/json", schema = @Schema(implementation = Product.class))), + @APIResponse(responseCode = "404", description = "Product not found") + }) + public Response updateProduct(@PathParam("id") Long id, Product updatedProduct) { + LOGGER.info("REST: Updating product with id: " + id); + Product updated = productService.updateProduct(id, updatedProduct); + if (updated != null) { + return Response.ok(updated).build(); + } else { + return Response.status(Response.Status.NOT_FOUND).build(); + } + } + + @DELETE + @Path("/{id}") + @Produces(MediaType.APPLICATION_JSON) + @Operation(summary = "Delete a product", description = "Deletes a product by its ID") + @APIResponses({ + @APIResponse(responseCode = "204", description = "Product deleted"), + @APIResponse(responseCode = "404", description = "Product not found") + }) + public Response deleteProduct(@PathParam("id") Long id) { + LOGGER.info("REST: Deleting product with id: " + id); + boolean deleted = productService.deleteProduct(id); + if (deleted) { + return Response.noContent().build(); + } else { + return Response.status(Response.Status.NOT_FOUND).build(); + } + } + + @GET + @Path("/search") + @Produces(MediaType.APPLICATION_JSON) + @Operation(summary = "Search products", description = "Search products by criteria") + @APIResponses({ + @APIResponse(responseCode = "200", description = "Search results", content = @Content(mediaType = "application/json", schema = @Schema(implementation = Product.class))) + }) + public Response searchProducts( + @QueryParam("name") String name, + @QueryParam("description") String description, + @QueryParam("minPrice") Double minPrice, + @QueryParam("maxPrice") Double maxPrice) { + LOGGER.info("REST: Searching products with criteria"); + List results = productService.searchProducts(name, description, minPrice, maxPrice); + return Response.ok(results).build(); + } +} \ No newline at end of file diff --git a/code/chapter07/catalog/src/main/java/io/microprofile/tutorial/store/product/service/ProductService.java b/code/chapter07/catalog/src/main/java/io/microprofile/tutorial/store/product/service/ProductService.java new file mode 100644 index 0000000..11d9bf7 --- /dev/null +++ b/code/chapter07/catalog/src/main/java/io/microprofile/tutorial/store/product/service/ProductService.java @@ -0,0 +1,99 @@ +package io.microprofile.tutorial.store.product.service; + +import io.microprofile.tutorial.store.product.entity.Product; +import io.microprofile.tutorial.store.product.repository.JPA; +import io.microprofile.tutorial.store.product.repository.ProductRepositoryInterface; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.inject.Inject; +import java.util.List; +import java.util.logging.Logger; + +/** + * Service class for Product operations. + * Contains business logic for product management. + */ +@ApplicationScoped +public class ProductService { + + private static final Logger LOGGER = Logger.getLogger(ProductService.class.getName()); + + @Inject + @JPA + private ProductRepositoryInterface repository; + + /** + * Retrieves all products. + * + * @return List of all products + */ + public List findAllProducts() { + LOGGER.info("Service: Finding all products"); + return repository.findAllProducts(); + } + + /** + * Retrieves a product by ID. + * + * @param id Product ID + * @return The product or null if not found + */ + public Product findProductById(Long id) { + LOGGER.info("Service: Finding product with ID: " + id); + return repository.findProductById(id); + } + + /** + * Creates a new product. + * + * @param product Product data to create + * @return The created product with ID + */ + public Product createProduct(Product product) { + LOGGER.info("Service: Creating new product: " + product); + return repository.createProduct(product); + } + + /** + * Updates an existing product. + * + * @param id ID of the product to update + * @param updatedProduct Updated product data + * @return The updated product or null if not found + */ + public Product updateProduct(Long id, Product updatedProduct) { + LOGGER.info("Service: Updating product with ID: " + id); + + Product existingProduct = repository.findProductById(id); + if (existingProduct != null) { + // Set the ID to ensure correct update + updatedProduct.setId(id); + return repository.updateProduct(updatedProduct); + } + return null; + } + + /** + * Deletes a product by ID. + * + * @param id ID of the product to delete + * @return true if deleted, false if not found + */ + public boolean deleteProduct(Long id) { + LOGGER.info("Service: Deleting product with ID: " + id); + return repository.deleteProduct(id); + } + + /** + * Searches for products by criteria. + * + * @param name Product name (optional) + * @param description Product description (optional) + * @param minPrice Minimum price (optional) + * @param maxPrice Maximum price (optional) + * @return List of matching products + */ + public List searchProducts(String name, String description, Double minPrice, Double maxPrice) { + LOGGER.info("Service: Searching for products with criteria"); + return repository.searchProducts(name, description, minPrice, maxPrice); + } +} diff --git a/code/chapter07/catalog/src/main/liberty/config/server.xml b/code/chapter07/catalog/src/main/liberty/config/server.xml new file mode 100644 index 0000000..fb819f2 --- /dev/null +++ b/code/chapter07/catalog/src/main/liberty/config/server.xml @@ -0,0 +1,41 @@ + + + jakartaEE-10.0 + microProfile-6.1 + restfulWS + jsonp + jsonb + cdi + mpConfig + mpOpenAPI + mpHealth + mpMetrics + persistence + jdbc + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/code/chapter07/catalog/src/main/resources/META-INF/create-schema.sql b/code/chapter07/catalog/src/main/resources/META-INF/create-schema.sql new file mode 100644 index 0000000..6e72eed --- /dev/null +++ b/code/chapter07/catalog/src/main/resources/META-INF/create-schema.sql @@ -0,0 +1,6 @@ +-- Schema creation script for Product catalog +-- This file is referenced by persistence.xml for schema generation + +-- Note: This is a placeholder file. +-- The actual schema is auto-generated by JPA using @Entity annotations. +-- You can add custom DDL statements here if needed. diff --git a/code/chapter07/catalog/src/main/resources/META-INF/load-data.sql b/code/chapter07/catalog/src/main/resources/META-INF/load-data.sql new file mode 100644 index 0000000..e9fbd9b --- /dev/null +++ b/code/chapter07/catalog/src/main/resources/META-INF/load-data.sql @@ -0,0 +1,8 @@ +INSERT INTO PRODUCTS (NAME, DESCRIPTION, PRICE) VALUES ('iPhone 15', 'Apple iPhone 15 with advanced features', 999.99) +INSERT INTO PRODUCTS (NAME, DESCRIPTION, PRICE) VALUES ('MacBook Air', 'Apple MacBook Air M2 chip', 1299.00) +INSERT INTO PRODUCTS (NAME, DESCRIPTION, PRICE) VALUES ('iPad Pro', 'Apple iPad Pro 12.9-inch', 799.00) +INSERT INTO PRODUCTS (NAME, DESCRIPTION, PRICE) VALUES ('Samsung Galaxy S24', 'Samsung Galaxy S24 Ultra smartphone', 1199.99) +INSERT INTO PRODUCTS (NAME, DESCRIPTION, PRICE) VALUES ('Dell XPS 13', 'Dell XPS 13 laptop with Intel i7', 1099.00) +INSERT INTO PRODUCTS (NAME, DESCRIPTION, PRICE) VALUES ('Sony WH-1000XM4', 'Sony noise-canceling headphones', 299.99) +INSERT INTO PRODUCTS (NAME, DESCRIPTION, PRICE) VALUES ('Nintendo Switch', 'Nintendo Switch gaming console', 299.00) +INSERT INTO PRODUCTS (NAME, DESCRIPTION, PRICE) VALUES ('Google Pixel 8', 'Google Pixel 8 smartphone', 699.99) diff --git a/code/chapter07/catalog/src/main/resources/META-INF/microprofile-config.properties b/code/chapter07/catalog/src/main/resources/META-INF/microprofile-config.properties new file mode 100644 index 0000000..740ae2e --- /dev/null +++ b/code/chapter07/catalog/src/main/resources/META-INF/microprofile-config.properties @@ -0,0 +1,12 @@ +# microprofile-config.properties +product.maintainenceMode=false + +# Repository configuration +product.repository.type=JPA + +# Database configuration +product.database.enabled=true +product.database.name=catalogDB + +# Enable OpenAPI scanning +mp.openapi.scan=true \ No newline at end of file diff --git a/code/chapter07/catalog/src/main/resources/META-INF/persistence.xml b/code/chapter07/catalog/src/main/resources/META-INF/persistence.xml new file mode 100644 index 0000000..b569476 --- /dev/null +++ b/code/chapter07/catalog/src/main/resources/META-INF/persistence.xml @@ -0,0 +1,27 @@ + + + + + jdbc/catalogDB + io.microprofile.tutorial.store.product.entity.Product + + + + + + + + + + + + + + + + + + diff --git a/code/chapter07/catalog/src/main/webapp/WEB-INF/web.xml b/code/chapter07/catalog/src/main/webapp/WEB-INF/web.xml new file mode 100644 index 0000000..1010516 --- /dev/null +++ b/code/chapter07/catalog/src/main/webapp/WEB-INF/web.xml @@ -0,0 +1,13 @@ + + + + Product Catalog Service + + + index.html + + + diff --git a/code/chapter07/catalog/src/main/webapp/index.html b/code/chapter07/catalog/src/main/webapp/index.html new file mode 100644 index 0000000..5845c55 --- /dev/null +++ b/code/chapter07/catalog/src/main/webapp/index.html @@ -0,0 +1,622 @@ + + + + + + Product Catalog Service + + + +
+

Product Catalog Service

+

A microservice for managing product information in the e-commerce platform

+
+ +
+
+

Service Overview

+

The Product Catalog Service provides a REST API for managing product information, including:

+
    +
  • Creating new products
  • +
  • Retrieving product details
  • +
  • Updating existing products
  • +
  • Deleting products
  • +
  • Searching for products by various criteria
  • +
+
+

MicroProfile Config Implementation

+

This service implements configurability as per MicroProfile Config standards. Key configuration properties include:

+
    +
  • product.maintenanceMode - Controls whether the service is in maintenance mode (returns 503 responses)
  • +
  • mp.openapi.scan - Enables automatic OpenAPI documentation generation
  • +
+

MicroProfile Config allows these properties to be changed via environment variables, system properties, or configuration files without requiring application redeployment.

+
+
+ +
+

API Endpoints

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
OperationMethodURLDescription
List All ProductsGET/api/productsRetrieves a list of all products
Get Product by IDGET/api/products/{id}Returns a product by its ID
Create ProductPOST/api/productsCreates a new product
Update ProductPUT/api/products/{id}Updates an existing product by its ID
Delete ProductDELETE/api/products/{id}Deletes a product by its ID
Search ProductsGET/api/products/searchSearch products by criteria (name, description, price range)
Product CountGET/api/products/countReturns the current number of products in catalog (used for metrics)
+
+ +
+

API Documentation

+

The API is documented using MicroProfile OpenAPI. You can access the Swagger UI at:

+

/openapi/ui

+

The OpenAPI definition is available at:

+

/openapi

+
+ +
+

Health Checks

+

The service implements comprehensive MicroProfile Health monitoring with three types of health checks:

+ +

Health Check Endpoints

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
StatusEndpointPurposeDescriptionLink
/healthOverall HealthAggregated status of all health checksView
/health/startedStartup CheckValidates EntityManagerFactory initializationView
/health/readyReadiness CheckTests database connectivity via EntityManagerView
/health/liveLiveness CheckMonitors JVM memory usage (100MB threshold)View
+ +
+

Health Check Implementation Details

+ +

🚀 Startup Health Check

+

Class: ProductServiceStartupCheck

+

Purpose: Verifies that the Jakarta Persistence EntityManagerFactory is properly initialized during application startup.

+

Implementation: Uses @PersistenceUnit to inject EntityManagerFactory and checks if it's not null and open.

+ +

✅ Readiness Health Check

+

Class: ProductServiceHealthCheck

+

Purpose: Ensures the service is ready to handle requests by testing database connectivity.

+

Implementation: Performs a lightweight database query using entityManager.find(Product.class, 1L) to verify the database connection.

+ +

💓 Liveness Health Check

+

Class: ProductServiceLivenessCheck

+

Purpose: Monitors system resources to detect if the application needs to be restarted.

+

Implementation: Analyzes JVM memory usage with a 100MB available memory threshold, providing detailed memory diagnostics.

+
+ +
+

Sample Health Check Response

+

Example response from /health endpoint:

+
{
+  "status": "UP",
+  "checks": [
+    {
+      "name": "ProductServiceStartupCheck",
+      "status": "UP"
+    },
+    {
+      "name": "ProductServiceReadinessCheck", 
+      "status": "UP"
+    },
+    {
+      "name": "systemResourcesLiveness",
+      "status": "UP",
+      "data": {
+        "FreeMemory": 524288000,
+        "MaxMemory": 2147483648,
+        "AllocatedMemory": 1073741824,
+        "UsedMemory": 549453824,
+        "AvailableMemory": 1598029824
+      }
+    }
+  ]
+}
+
+ +
+

Testing Health Checks

+

You can test the health check endpoints using curl commands:

+
# Test overall health
+curl -X GET http://localhost:9080/health
+
+# Test startup health
+curl -X GET http://localhost:9080/health/started
+
+# Test readiness health  
+curl -X GET http://localhost:9080/health/ready
+
+# Test liveness health
+curl -X GET http://localhost:9080/health/live
+ +

Integration with Container Orchestration:

+

For Kubernetes deployments, you can configure probes in your deployment YAML:

+
livenessProbe:
+  httpGet:
+    path: /health/live
+    port: 9080
+  initialDelaySeconds: 30
+  periodSeconds: 10
+
+readinessProbe:
+  httpGet:
+    path: /health/ready
+    port: 9080
+  initialDelaySeconds: 5
+  periodSeconds: 5
+
+startupProbe:
+  httpGet:
+    path: /health/started
+    port: 9080
+  initialDelaySeconds: 10
+  periodSeconds: 10
+  failureThreshold: 30
+
+ +
+

Health Check Benefits

+
    +
  • Container Orchestration: Kubernetes and Docker can use these endpoints for health probes
  • +
  • Load Balancer Integration: Traffic routing based on readiness status
  • +
  • Operational Monitoring: Early detection of system issues
  • +
  • Startup Validation: Ensures all dependencies are initialized before serving traffic
  • +
  • Database Monitoring: Real-time database connectivity verification
  • +
  • Memory Management: Proactive detection of memory pressure
  • +
+
+
+ +
+

MicroProfile Metrics

+

The service implements comprehensive monitoring using MicroProfile Metrics to track application performance and usage patterns. Three types of metrics are configured to provide insights into service behavior:

+ +

Metrics Endpoints

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
EndpointFormatDescriptionLink
/metricsPrometheusAll metrics (application + vendor + base)View
/metrics?scope=applicationPrometheusApplication-specific metrics onlyView
/metrics?scope=vendorPrometheusOpen Liberty vendor metricsView
/metrics?scope=basePrometheusBase JVM and system metricsView
+ +
+

Implemented Metrics

+
+ +
+

⏱️ Timer Metrics

+

Metric: productLookupTime

+

Endpoint: GET /api/products/{id}

+

Purpose: Measures time spent retrieving individual products

+

Includes: Response times, rates, percentiles

+
+ +
+

🔢 Counter Metrics

+

Metric: productAccessCount

+

Endpoint: GET /api/products

+

Purpose: Counts how many times product list is accessed

+

Use Case: API usage patterns and load monitoring

+
+ +
+

📊 Gauge Metrics

+

Metric: productCatalogSize

+

Endpoint: GET /api/products/count

+

Purpose: Real-time count of products in catalog

+

Use Case: Inventory monitoring and capacity planning

+
+
+
+ +
+

Testing Metrics

+

You can test the metrics endpoints using curl commands:

+
# View all metrics
+curl -X GET http://localhost:5050/metrics
+
+# View only application metrics  
+curl -X GET http://localhost:5050/metrics?scope=application
+
+# View specific metric by name
+curl -X GET "http://localhost:5050/metrics?name=productAccessCount"
+
+# Generate metric data by calling endpoints
+curl -X GET http://localhost:5050/api/products        # Increments counter
+curl -X GET http://localhost:5050/api/products/1      # Records timing
+curl -X GET http://localhost:5050/api/products/count  # Updates gauge
+
+ +
+

Sample Metrics Output

+

Example response from /metrics?scope=application endpoint:

+
# TYPE application_productLookupTime_seconds summary
+application_productLookupTime_seconds_count 5
+application_productLookupTime_seconds_sum 0.52487
+application_productLookupTime_seconds{quantile="0.5"} 0.1034
+application_productLookupTime_seconds{quantile="0.95"} 0.1123
+
+# TYPE application_productAccessCount_total counter
+application_productAccessCount_total 15
+
+# TYPE application_productCatalogSize gauge
+application_productCatalogSize 3
+
+ +
+

Metrics Benefits

+
    +
  • Performance Monitoring: Track response times and identify slow operations
  • +
  • Usage Analytics: Understand API usage patterns and frequency
  • +
  • Capacity Planning: Monitor catalog size and growth trends
  • +
  • Operational Insights: Real-time visibility into service behavior
  • +
  • Integration Ready: Prometheus-compatible format for monitoring systems
  • +
  • Troubleshooting: Correlation of performance issues with usage patterns
  • +
+
+
+ +
+

Sample Usage

+ +

List All Products

+
GET /api/products
+

Response:

+
[
+  {
+    "id": 1,
+    "name": "Smartphone X",
+    "description": "Latest smartphone with advanced features",
+    "price": 799.99
+  },
+  {
+    "id": 2,
+    "name": "Laptop Pro",
+    "description": "High-performance laptop for professionals",
+    "price": 1299.99
+  }
+]
+ +

Get Product by ID

+
GET /api/products/1
+

Response:

+
{
+  "id": 1,
+  "name": "Smartphone X",
+  "description": "Latest smartphone with advanced features",
+  "price": 799.99
+}
+ +

Create a New Product

+
POST /api/products
+Content-Type: application/json
+
+{
+  "name": "Wireless Earbuds",
+  "description": "Premium wireless earbuds with noise cancellation",
+  "price": 149.99
+}
+

Response:

+
{
+  "id": 3,
+  "name": "Wireless Earbuds",
+  "description": "Premium wireless earbuds with noise cancellation",
+  "price": 149.99
+}
+ +

Update a Product

+
PUT /api/products/3
+Content-Type: application/json
+
+{
+  "name": "Wireless Earbuds Pro",
+  "description": "Premium wireless earbuds with advanced noise cancellation",
+  "price": 179.99
+}
+

Response:

+
{
+  "id": 3,
+  "name": "Wireless Earbuds Pro",
+  "description": "Premium wireless earbuds with advanced noise cancellation",
+  "price": 179.99
+}
+ +

Delete a Product

+
DELETE /api/products/3
+

Response: No content (204)

+ +

Search for Products

+
GET /api/products/search?name=laptop&minPrice=1000&maxPrice=2000
+

Response:

+
[
+  {
+    "id": 2,
+    "name": "Laptop Pro",
+    "description": "High-performance laptop for professionals",
+    "price": 1299.99
+  }
+]
+
+
+ +
+

Product Catalog Service

+

© 2025 - MicroProfile APT Tutorial

+
+ + + + diff --git a/code/chapter08/payment/Dockerfile b/code/chapter08/payment/Dockerfile new file mode 100644 index 0000000..77e6dde --- /dev/null +++ b/code/chapter08/payment/Dockerfile @@ -0,0 +1,20 @@ +FROM icr.io/appcafe/open-liberty:full-java17-openj9-ubi + +# Copy configuration files +COPY --chown=1001:0 src/main/liberty/config/ /config/ + +# Create the apps directory and copy the application +COPY --chown=1001:0 target/payment.war /config/apps/ + +# Configure the server to run in production mode +RUN configure.sh + +# Expose the default port +EXPOSE 9050 9443 + +# Set the health check +HEALTHCHECK --start-period=60s --interval=10s --timeout=5s --retries=3 \ + CMD curl -f http://localhost:9050/health || exit 1 + +# Run the server +CMD ["/opt/ol/wlp/bin/server", "run", "defaultServer"] diff --git a/code/chapter08/payment/README.adoc b/code/chapter08/payment/README.adoc new file mode 100644 index 0000000..eb53e9d --- /dev/null +++ b/code/chapter08/payment/README.adoc @@ -0,0 +1,920 @@ += Payment Service +:toc: macro +:toclevels: 3 +:icons: font +:source-highlighter: highlight.js +:experimental: + +toc::[] + +This microservice is part of the Jakarta EE 10 and MicroProfile 6.1-based e-commerce application. It handles payment processing and transaction management. + +== Features + +* Payment transaction processing +* Dynamic configuration management via MicroProfile Config +* RESTful API endpoints with JSON support +* Custom ConfigSource implementation +* OpenAPI documentation +* **MicroProfile Fault Tolerance with Retry Policies** +* **Circuit Breaker protection for external services** +* **Fallback mechanisms for service resilience** +* **Bulkhead pattern for concurrency control** +* **Timeout protection for long-running operations** + +== MicroProfile Fault Tolerance Implementation + +The Payment Service implements comprehensive fault tolerance patterns using MicroProfile Fault Tolerance annotations: + +=== Retry Policies + +The service implements different retry strategies based on operation criticality: + +==== Payment Authorization Retry (@Retry) +* **Max Retries**: 3 attempts +* **Delay**: 1000ms with 500ms jitter +* **Max Duration**: 10 seconds +* **Retry On**: RuntimeException, WebApplicationException +* **Use Case**: Standard payment authorization with exponential backoff + +[source,java] +---- +@Retry( + maxRetries = 3, + delay = 2000, + maxDuration = 10000 + jitter = 500, + retryOn = {RuntimeException.class, WebApplicationException.class} +) +---- + +=== Circuit Breaker Protection + +Payment capture operations use circuit breaker pattern: + +[source,java] +---- +@CircuitBreaker( + failureRatio = 0.5, + requestVolumeThreshold = 4, + delay = 5000 +) +---- + +* **Failure Ratio**: 50% failure rate triggers circuit opening +* **Request Volume**: Minimum 4 requests for evaluation +* **Recovery Delay**: 5 seconds before attempting recovery + +=== Timeout Protection + +Operations with potential long delays are protected with timeouts: + +[source,java] +---- +@Timeout(value = 3000) +---- + +=== Bulkhead Pattern + +The bulkhead pattern limits concurrent requests to prevent system overload: + +[source,java] +---- +@Bulkhead(value = 5) +---- + +* **Concurrent Requests**: Limited to 5 concurrent requests +* **Excess Requests**: Rejected immediately instead of queuing +* **Use Case**: Protect service from traffic spikes and cascading failures + +=== Fallback Mechanisms + +All critical operations have fallback methods that provide graceful degradation: + +* **Payment Authorization Fallback**: Returns service unavailable with retry instructions + +== Endpoints + +=== GET /payment/api/payment-config +* Returns all current payment configuration values +* Example: `GET http://localhost:9080/payment/api/payment-config` +* Response: `{"gateway.endpoint":"https://api.paymentgateway.com"}` + +=== POST /payment/api/payment-config +* Updates a payment configuration value +* Example: `POST http://localhost:9080/payment/api/payment-config` +* Request body: `{"key": "payment.gateway.endpoint", "value": "https://new-api.paymentgateway.com"}` +* Response: `{"key":"payment.gateway.endpoint","value":"https://new-api.paymentgateway.com","message":"Configuration updated successfully"}` + +=== POST /payment/api/authorize +* Processes a payment authorization with retry policy +* **Retry Configuration**: 3 attempts, 1s delay, 500ms jitter +* **Fallback**: Service unavailable response +* Example: `POST http://localhost:9080/payment/api/authorize` +* Request body: `{"cardNumber":"4111111111111111", "cardHolderName":"Test User", "expiryDate":"12/25", "securityCode":"123", "amount":100.00}` +* Response: `{"status":"success", "message":"Payment authorized successfully", "transactionId":"TXN1234567890", "amount":100.00}` +* Fallback Response: `{"status":"failed", "message":"Payment gateway unavailable. Please try again later.", "fallback":true}` + +=== POST /payment/api/payment-config/process-example +* Example endpoint demonstrating payment processing with configuration +* Example: `POST http://localhost:9080/payment/api/payment-config/process-example` +* Request body: `{"cardNumber":"4111111111111111", "cardHolderName":"Test User", "expiryDate":"12/25", "securityCode":"123", "amount":100.00}` +* Response: `{"amount":100.00,"message":"Payment processed successfully","status":"success","configUsed":{"gatewayEndpoint":"https://new-api.paymentgateway.com"}}` + +== Building and Running the Service + +=== Prerequisites + +* JDK 17 or higher +* Maven 3.6.0 or higher + +=== Local Development + +[source,bash] +---- +# Build the application +mvn clean package + +# Run the application with Liberty +mvn liberty:run +---- + +The server will start on port 9080 (HTTP) and 9081 (HTTPS). + +=== Docker + +[source,bash] +---- +# Build and run with Docker +./run-docker.sh +---- + +== Project Structure + +* `src/main/java/io/microprofile/tutorial/PaymentRestApplication.java` - Jakarta Restful web service application class +* `src/main/java/io/microprofile/tutorial/store/payment/config/` - Configuration classes +* `src/main/java/io/microprofile/tutorial/store/payment/resource/` - REST resource endpoints +* `src/main/java/io/microprofile/tutorial/store/payment/service/` - Business logic services +* `src/main/java/io/microprofile/tutorial/store/payment/entity/` - Data models +* `src/main/resources/META-INF/services/` - Service provider configuration +* `src/main/liberty/config/` - Liberty server configuration + +== Custom ConfigSource + +The Payment Service implements a custom MicroProfile ConfigSource named `PaymentServiceConfigSource` that provides payment-specific configuration with high priority (ordinal: 600). + +=== Available Configuration Properties + +[cols="1,2,2", options="header"] +|=== +|Property +|Description +|Default Value + +|payment.gateway.endpoint +|Payment gateway endpoint URL +|https://api.paymentgateway.com +|=== + +=== Testing ConfigSource Endpoints + +You can test the ConfigSource endpoints using curl or any REST client: + +[source,bash] +---- +# Get current configuration +curl -s http://localhost:9080/payment/api/payment-config | json_pp + +# Update configuration property +curl -s -X POST -H "Content-Type: application/json" \ + -d '{"key":"payment.gateway.endpoint", "value":"https://new-api.paymentgateway.com"}' \ + http://localhost:9080/payment/api/payment-config | json_pp + +# Test payment processing with the configuration +curl -s -X POST -H "Content-Type: application/json" \ + -d '{"cardNumber":"4111111111111111", "cardHolderName":"Test User", "expiryDate":"12/25", "securityCode":"123", "amount":100.00}' \ + http://localhost:9080/payment/api/payment-config/process-example | json_pp + +# Test basic payment authorization +curl -s -X POST -H "Content-Type: application/json" \ + http://localhost:9080/payment/api/authorize | json_pp +---- + +=== Implementation Details + +The custom ConfigSource is implemented in the following classes: + +* `PaymentServiceConfigSource.java` - Implements the MicroProfile ConfigSource interface +* `PaymentConfig.java` - Utility class for accessing configuration properties + +Example usage in application code: + +[source,java] +---- +// Inject standard MicroProfile Config +@Inject +@ConfigProperty(name="payment.gateway.endpoint") +private String endpoint; + +// Or use the utility class +String gatewayUrl = PaymentConfig.getConfigProperty("payment.gateway.endpoint"); +---- + +The custom ConfigSource provides a higher priority (ordinal: 600) than system properties and environment variables, allowing for service-specific defaults while still enabling override via standard mechanisms. + +=== MicroProfile Config Sources + +MicroProfile Config uses a prioritized set of configuration sources. The payment service uses the following configuration sources in order of priority (highest to lowest): + +1. Custom ConfigSource (`PaymentServiceConfigSource`) - Ordinal: 600 +2. System properties - Ordinal: 400 +3. Environment variables - Ordinal: 300 +4. microprofile-config.properties file - Ordinal: 100 + +==== Updating Configuration Values + +You can update configuration properties through different methods: + +===== 1. Using the REST API (runtime) + +This uses the custom ConfigSource and persists only for the current server session: + +[source,bash] +---- +curl -X POST -H "Content-Type: application/json" \ + -d '{"key":"payment.gateway.endpoint", "value":"https://test-api.paymentgateway.com"}' \ + http://localhost:9080/payment/api/payment-config +---- + +===== 2. Using System Properties (startup) + +[source,bash] +---- +# Linux/macOS +mvn liberty:run -Dpayment.gateway.endpoint=https://sys-api.paymentgateway.com + +# Windows +mvn liberty:run "-Dpayment.gateway.endpoint=https://sys-api.paymentgateway.com" +---- + +===== 3. Using Environment Variables (startup) + +Environment variable names must follow the MicroProfile Config convention (uppercase with underscores): + +[source,bash] +---- +# Linux/macOS +export PAYMENT_GATEWAY_ENDPOINT=https://env-api.paymentgateway.com +mvn liberty:run + +# Windows PowerShell +$env:PAYMENT_GATEWAY_ENDPOINT="https://env-api.paymentgateway.com" +mvn liberty:run + +# Windows CMD +set PAYMENT_GATEWAY_ENDPOINT=https://env-api.paymentgateway.com +mvn liberty:run +---- + +===== 4. Using microprofile-config.properties File + +Edit the file at `src/main/resources/META-INF/microprofile-config.properties`: + +[source,properties] +---- +# Update the endpoint +payment.gateway.endpoint=https://config-api.paymentgateway.com +---- + +Then rebuild and restart the application: + +[source,bash] +---- +mvn clean package liberty:run +---- + +==== Testing Configuration Changes + +After changing a configuration property, you can verify it was updated by calling: + +[source,bash] +---- +curl http://localhost:9080/payment/api/payment-config +---- + +== Documentation + +=== OpenAPI + +The payment service automatically generates OpenAPI documentation using MicroProfile OpenAPI annotations. + +* OpenAPI UI: `http://localhost:9080/payment/api/openapi-ui/` +* OpenAPI JSON: `http://localhost:9080/payment/api/openapi` + +=== MicroProfile Config Specification + +For more information about MicroProfile Config, refer to the official documentation: + +* https://download.eclipse.org/microprofile/microprofile-config-3.1/microprofile-config-spec-3.1.html + +=== Related Resources + +* MicroProfile: https://microprofile.io/ +* Jakarta EE: https://jakarta.ee/ +* Open Liberty: https://openliberty.io/ + +== Troubleshooting + +=== Common Issues + +==== Port Conflicts + +If you encounter a port conflict when starting the server, you can change the ports in the `pom.xml` file: + +[source,xml] +---- +9080 +9081 +---- + +==== ConfigSource Not Loading + +If the custom ConfigSource is not loading, check the following: + +1. Verify the service provider configuration file exists at: + `src/main/resources/META-INF/services/org.eclipse.microprofile.config.spi.ConfigSource` + +2. Ensure it contains the correct fully qualified class name: + `io.microprofile.tutorial.store.payment.config.PaymentServiceConfigSource` + +==== Deployment Errors + +For CWWKZ0004E deployment errors, check the server logs at: +`target/liberty/wlp/usr/servers/mpServer/logs/messages.log` + +== Testing Fault Tolerance Features + +=== Automated Test Scripts + +The Payment Service includes several test scripts to demonstrate and validate fault tolerance features: + +==== test-payment-basic.sh + +Basic functionality test to verify core payment operations: + +* Configuration retrieval +* Simple payment processing +* Error handling + +[source,bash] +---- +# Test basic payment operations +chmod +x test-payment-basic.sh +./test-payment-basic.sh +---- + +==== test-payment-retry.sh +Tests various retry scenarios with different triggers: + +* Normal payment processing (successful) +* Failed payment with retry (card ending in "0000") +* Verification with random failures +* Invalid input handling + +[source,bash] +---- +# Test retry scenarios +chmod +x test-payment-retry.sh +./test-payment-retry.sh +---- + +==== test-payment-concurrent-load.sh + +Tests the service under concurrent load: + +* Multiple simultaneous requests +* Observing thread handling +* Response time analysis + +[source,bash] +---- +# Test service under concurrent load +chmod +x test-payment-concurrent-load.sh +./test-payment-concurrent-load.sh +---- + +==== test-payment-async.sh + +Analyzes asynchronous processing behavior: + +* Response time measurement +* Thread utilization +* Future completion patterns + +[source,bash] +---- +# Analyze asynchronous processing +chmod +x test-payment-async.sh +./test-payment-async.sh +---- + +==== test-payment-bulkhead.sh +Demonstrates the bulkhead pattern by sending concurrent requests: + +* Concurrent request handling +* Bulkhead limit verification (5 requests) +* Rejection of excess requests +* Service recovery after load reduction + +[source,bash] +---- +# Test bulkhead functionality with concurrent requests +chmod +x test-payment-bulkhead.sh +./test-payment-bulkhead.sh +---- + +=== Running the Tests + +To run any of these test scripts: + +[source,bash] +---- +# Make the script executable +chmod +x test-payment-bulkhead.sh + +# Run the script +./test-payment-bulkhead.sh +---- + +You can also run all test scripts in sequence: + +[source,bash] +---- +# Run all test scripts +for script in test-payment-*.sh; do + echo "Running $script..." + chmod +x $script + ./$script + echo "----------------------------------------" + sleep 2 +done +---- + +== Configuration Properties + +=== Fault Tolerance Configuration + +The following properties can be configured via MicroProfile Config: + +[cols="1,2,2", options="header"] +|=== +|Property +|Description +|Default Value + +|payment.gateway.endpoint +|Payment gateway endpoint URL +|https://api.paymentgateway.com + +|payment.retry.maxRetries +|Maximum retry attempts for payment operations +|3 + +|payment.retry.delay +|Delay between retry attempts (milliseconds) +|1000 + +|payment.circuitbreaker.failureRatio +|Circuit breaker failure ratio threshold +|0.5 + +|payment.circuitbreaker.requestVolumeThreshold +|Minimum requests for circuit breaker evaluation +|4 + +|payment.timeout.duration +|Timeout duration for payment operations (milliseconds) +|3000 + +|payment.bulkhead.value +|Maximum concurrent requests for bulkhead +|5 +|=== + +== Fault Tolerance Implementation Details + +=== Server Configuration + +The MicroProfile Fault Tolerance feature is enabled in the Liberty server configuration: + +[source,xml] +---- +mpFaultTolerance +---- + +=== Code Implementation + +==== PaymentService Class + +The PaymentService class is annotated with `@ApplicationScoped` to ensure proper fault tolerance behavior: + +[source,java] +---- +@ApplicationScoped +public class PaymentService { + // ... +} +---- + +==== Authorization Method + +[source,java] +---- +@Retry(maxRetries = 3, + delay = 2000, + jitter = 500, + retryOn = PaymentProcessingException.class, + abortOn = CriticalPaymentException.class) + @Fallback(fallbackMethod = "fallbackProcessPayment") +public PaymentResponse processPayment(PaymentRequest request) { + // Payment processing logic +} + +public PaymentResponse fallbackPaymentAuthorization(PaymentRequest request) { + // Fallback logic for payment authorization + return new PaymentResponse("failed", "Payment gateway unavailable. Please try again later.", true); +} +---- + +=== Key Implementation Benefits + +==== 1. Resilience +- Service continues operating despite external service failures +- Automatic recovery from transient failures +- Protection against cascading failures + +==== 2. User Experience +- Reduced timeout errors through retry mechanisms +- Graceful degradation with meaningful error messages +- Improved service availability + +==== 3. Operational Excellence +- Configurable fault tolerance parameters +- Comprehensive logging and monitoring +- Clear separation of concerns between business logic and resilience + +==== 4. Enterprise Readiness +- Production-ready fault tolerance patterns +- Compliance with microservices best practices +- Integration with MicroProfile ecosystem + +== MicroProfile Fault Tolerance Patterns + +=== Retry Pattern + +The retry pattern allows the service to automatically retry failed operations: + +* **@Retry**: Automatically retries failed operations +* **Parameters**: maxRetries, delay, jitter, maxDuration, retryOn, abortOn +* **Use Case**: Transient failures in external service calls + +=== Circuit Breaker Pattern + +The circuit breaker pattern prevents cascading failures: + +* **@CircuitBreaker**: Tracks failure rates and opens circuit when threshold is reached +* **Parameters**: failureRatio, requestVolumeThreshold, delay +* **States**: Closed (normal), Open (failing), Half-Open (testing recovery) +* **Use Case**: Protect against downstream service failures + +=== Timeout Pattern + +The timeout pattern prevents operations from hanging indefinitely: + +* **@Timeout**: Sets maximum duration for operations +* **Parameters**: value, unit +* **Use Case**: Prevent indefinite waiting for slow external services + +=== Bulkhead Pattern + +The bulkhead pattern limits concurrent requests: + +* **@Bulkhead**: Sets maximum concurrent executions +* **Parameters**: value, waitingTaskQueue (for async) +* **Use Case**: Prevent system overload during traffic spikes + +=== Fallback Pattern + +The fallback pattern provides alternatives when operations fail: + +* **@Fallback**: Specifies alternative method when operation fails +* **Parameters**: fallbackMethod, applyOn, skipOn +* **Use Case**: Graceful degradation for failed operations + +== Fault Tolerance Best Practices + +=== Configuring Retry Policies + +When configuring retry policies, consider these best practices: + +* **Operation Criticality**: Use more aggressive retry policies for critical operations +* **Retry Delay**: Implement exponential backoff for external service calls +* **Jitter**: Add random jitter to prevent thundering herd problems +* **Max Duration**: Set an overall timeout to prevent excessive retries +* **Abort Conditions**: Define specific exceptions that should abort retry attempts + +=== Circuit Breaker Configuration + +For effective circuit breaker implementation: + +* **Failure Ratio**: Set appropriate threshold based on expected error rates (typically 0.3-0.5) +* **Request Volume**: Set minimum request count to prevent premature circuit opening +* **Recovery Delay**: Allow sufficient time for downstream services to recover +* **Monitoring**: Track circuit state transitions for operational visibility + +=== Bulkhead Strategies + +Choose the appropriate bulkhead strategy: + +* **Synchronous Bulkhead**: Limits concurrent executions for thread-constrained systems +* **Asynchronous Bulkhead**: Provides a waiting queue for manageable load spikes +* **Isolation Levels**: Consider using separate bulkheads for different types of operations + +=== Fallback Implementation + +Implement effective fallback mechanisms: + +* **Graceful Degradation**: Return partial results when possible +* **Meaningful Responses**: Provide clear error messages to clients +* **Operation Queuing**: Queue failed operations for later processing +* **Fallback Chain**: Implement multiple fallback levels for critical operations + +=== Combining Fault Tolerance Annotations + +When combining multiple fault tolerance annotations: + +* **Execution Order**: Understand the execution order (Fallback → Retry → CircuitBreaker → Timeout → Bulkhead) +* **Compatibility**: Ensure annotations work together as expected +* **Resource Impact**: Consider the resource impact of combined annotations +* **Testing**: Test all combinations of annotation behaviors + +== Troubleshooting Fault Tolerance Issues + +=== Common Fault Tolerance Issues + +==== 1. Ineffective Retry Policies + +**Symptoms**: +* Operations fail without retrying +* Excessive retries causing performance issues + +**Solutions**: +* Verify exceptions match retryOn parameter +* Check that delay and jitter are appropriate +* Ensure maxDuration allows sufficient time for retries + +==== 2. Circuit Breaker Problems + +**Symptoms**: +* Circuit opens too frequently +* Circuit never opens despite failures +* Circuit remains open indefinitely + +**Solutions**: +* Adjust failureRatio based on expected error rates +* Increase requestVolumeThreshold if premature opening occurs +* Verify that delay allows sufficient recovery time +* Ensure exceptions are properly handled + +==== 3. Timeout Issues + +**Symptoms**: +* Operations timeout too quickly +* Timeouts not triggering as expected + +**Solutions**: +* Adjust timeout duration based on operation complexity +* Ensure timeout is shorter than upstream timeouts +* Verify that timeout unit is properly specified + +==== 4. Bulkhead Restrictions + +**Symptoms**: +* Too many rejections during normal load +* Service overloaded despite bulkhead + +**Solutions**: +* Adjust bulkhead value based on resource capacity +* Consider using asynchronous bulkheads with waiting queue +* Implement client-side load balancing for better distribution + +==== 5. Fallback Failures + +**Symptoms**: +* Fallbacks not triggering despite failures +* Fallbacks throwing unexpected exceptions + +**Solutions**: +* Verify fallback method signature matches original method +* Ensure fallback method handles exceptions properly +* Check that fallback logic is fully tested + +=== Diagnosing with Metrics + +MicroProfile Metrics provides valuable insight into fault tolerance behavior: + +=== Diagnosing with Metrics + +MicroProfile Metrics provides valuable insight into fault tolerance behavior: + +[source,bash] +---- +# Total number of retry attempts +curl https://localhost:9080/metrics?name=ft_retry_retries_total + +# Bulkhead calls total +curl http://localhost:9080/metrics?name=ft_bulkhead_calls_total + +# Timeout execution duration +curl http://localhost:9080/payment/metrics/application?name=ft_timeout_executionDuration_nanoseconds +---- + +=== Server Log Analysis + +Liberty server logs provide detailed information about fault tolerance operations: + +[source,bash] +---- +tail -f target/liberty/wlp/usr/servers/mpServer/logs/messages.log | grep -E "Retry|CircuitBreaker|Timeout|Bulkhead|Fallback" +---- + +Look for messages indicating: +* Retry attempts and success/failure +* Circuit breaker state transitions +* Timeout exceptions +* Bulkhead rejections +* Fallback method invocations + +== Resources and References + +=== MicroProfile Fault Tolerance Specification + +For detailed information about MicroProfile Fault Tolerance, refer to: + +* https://download.eclipse.org/microprofile/microprofile-fault-tolerance-4.0/microprofile-fault-tolerance-spec-4.0.html + +=== API Documentation + +* https://download.eclipse.org/microprofile/microprofile-fault-tolerance-4.0/apidocs/ + +=== Fault Tolerance Guides + +* https://openliberty.io/guides/microprofile-fallback.html +* https://openliberty.io/guides/retry-timeout.html +* https://openliberty.io/guides/circuit-breaker.html +* https://openliberty.io/guides/bulkhead.html + +=== Best Practices Resources + +* https://microprofile.io/ +* https://www.ibm.com/docs/en/was-liberty/base?topic=liberty-microprofile-fault-tolerance + +== Payment Flow + +The Payment Service implements a complete payment processing flow: + +[plantuml,payment-flow,png] +---- +@startuml +skinparam backgroundColor transparent +skinparam handwritten true + +state "PENDING" as pending +state "PROCESSING" as processing +state "COMPLETED" as completed +state "FAILED" as failed +state "REFUNDED" as refunded +state "CANCELLED" as cancelled + +[*] --> pending : Create payment +pending --> processing : Process payment +processing --> completed : Success +processing --> failed : Error +completed --> refunded : Refund request +pending --> cancelled : Cancel +failed --> [*] +refunded --> [*] +cancelled --> [*] +completed --> [*] +@enduml +---- + +1. **Create a payment** with status `PENDING` (POST /api/payments) +2. **Process the payment** to change status to `PROCESSING` (POST /api/payments/{id}/process) +3. Payment will automatically be updated to either: + * `COMPLETED` - Successful payment processing + * `FAILED` - Payment rejected or processing error +4. If needed, payments can be: + * `REFUNDED` - For returning funds to the customer + * `CANCELLED` - For stopping a pending payment + +=== Payment Status Rules + +[cols="1,2,2", options="header"] +|=== +|Status +|Description +|Available Actions + +|PENDING +|Payment created but not yet processed +|Process, Cancel + +|PROCESSING +|Payment being processed by payment gateway +|None (transitional state) + +|COMPLETED +|Payment successfully processed +|Refund + +|FAILED +|Payment processing unsuccessful +|Create new payment + +|REFUNDED +|Payment returned to customer +|None (terminal state) + +|CANCELLED +|Payment cancelled before processing +|Create new payment +|=== + +=== Test Scenarios + +For testing purposes, the following scenarios are simulated: + +* Payments with amounts ending in `.00` will fail +* Payments with card numbers ending in `0000` trigger retry mechanisms +* Verification has a 50% random failure rate to demonstrate retry capabilities +* Empty amount values in refund requests trigger abort conditions + +== Integration with Other Services + +The Payment Service integrates with several other microservices in the application: + +=== Order Service Integration + +* **Direction**: Bi-directional +* **Endpoints Used**: + - `GET /order/api/orders/{orderId}` - Get order details before payment + - `PATCH /order/api/orders/{orderId}/status` - Update order status after payment +* **Integration Flow**: + 1. Payment Service receives payment request with orderId + 2. Payment Service validates order exists and status is valid for payment + 3. After payment processing, Payment Service updates Order status + 4. Payment status `COMPLETED` → Order status `PAID` + 5. Payment status `FAILED` → Order status `PAYMENT_FAILED` + +=== User Service Integration + +* **Direction**: Outbound only +* **Endpoints Used**: + - `GET /user/api/users/{userId}` - Validate user exists + - `GET /user/api/users/{userId}/payment-methods` - Get saved payment methods +* **Integration Flow**: + 1. Payment Service validates user exists before processing payment + 2. Payment Service can retrieve saved payment methods for user + 3. User payment history is updated after successful payment + +=== Inventory Service Integration + +* **Direction**: Indirect via Order Service +* **Purpose**: Ensure inventory is reserved during payment processing +* **Flow**: + 1. Order Service has already reserved inventory + 2. Successful payment confirms inventory allocation + 3. Failed payment may release inventory (via Order Service) + +=== Authentication Integration + +* **Security**: Secured endpoints require valid JWT token +* **Claims Required**: + - `sub` - Subject identifier (user ID) + - `roles` - User roles for authorization +* **Authorization Rules**: + - View payment history: Authenticated user or admin + - Process payments: Authenticated user + - Refund payments: Admin role only + - View all payments: Admin role only + +=== Integration Testing + +Integration tests are available that validate the complete payment flow across services: + +[source,bash] +---- +# Test complete order-to-payment flow +./test-payment-integration.sh +---- diff --git a/code/chapter08/payment/pom.xml b/code/chapter08/payment/pom.xml new file mode 100644 index 0000000..12b8fad --- /dev/null +++ b/code/chapter08/payment/pom.xml @@ -0,0 +1,85 @@ + + + + 4.0.0 + + io.microprofile.tutorial + payment + 1.0-SNAPSHOT + war + + + + UTF-8 + 17 + 17 + + UTF-8 + UTF-8 + + + 9080 + 9081 + + payment + + + + + + + + + org.projectlombok + lombok + 1.18.26 + provided + + + + + jakarta.platform + jakarta.jakartaee-api + 10.0.0 + provided + + + + + org.eclipse.microprofile + microprofile + 6.1 + pom + provided + + + + junit + junit + 4.11 + test + + + + + ${project.artifactId} + + + + io.openliberty.tools + liberty-maven-plugin + 3.11.2 + + mpServer + + + + + org.apache.maven.plugins + maven-war-plugin + 3.4.0 + + + + \ No newline at end of file diff --git a/code/chapter08/payment/run-docker.sh b/code/chapter08/payment/run-docker.sh new file mode 100755 index 0000000..2b4155b --- /dev/null +++ b/code/chapter08/payment/run-docker.sh @@ -0,0 +1,45 @@ +#!/bin/bash + +# Script to build and run the Payment service in Docker + +# Stop execution on any error +set -e + +# Navigate to the payment service directory +cd "$(dirname "$0")" + +# Build the project with Maven +echo "Building with Maven..." +mvn clean package + +# Build the Docker image +echo "Building Docker image..." +docker build -t payment-service . + +# Run the Docker container +echo "Starting Docker container..." +docker run -d --name payment-service -p 9050:9050 payment-service + +# Dynamically determine the service URL +if [ -n "$CODESPACE_NAME" ] && [ -n "$GITHUB_CODESPACES_PORT_FORWARDING_DOMAIN" ]; then + # GitHub Codespaces environment + SERVICE_URL="https://$CODESPACE_NAME-9050.$GITHUB_CODESPACES_PORT_FORWARDING_DOMAIN/payment" + echo "Payment service is running in GitHub Codespaces" +elif [ -n "$GITPOD_WORKSPACE_URL" ]; then + # Gitpod environment + GITPOD_HOST=$(echo $GITPOD_WORKSPACE_URL | sed 's|https://||' | sed 's|/||') + SERVICE_URL="https://9050-$GITPOD_HOST/payment" + echo "Payment service is running in Gitpod" +else + # Local or other environment + HOSTNAME=$(hostname) + SERVICE_URL="http://$HOSTNAME:9050/payment" + echo "Payment service is running locally" +fi + +echo "Service URL: $SERVICE_URL" +echo "" +echo "Available endpoints:" +echo " - Health check: $SERVICE_URL/health" +echo " - API documentation: $SERVICE_URL/openapi" +echo " - Configuration: $SERVICE_URL/api/payment-config" diff --git a/code/chapter08/payment/run.sh b/code/chapter08/payment/run.sh new file mode 100755 index 0000000..75fc5f2 --- /dev/null +++ b/code/chapter08/payment/run.sh @@ -0,0 +1,19 @@ +#!/bin/bash + +# Script to build and run the Payment service + +# Stop execution on any error +set -e + +echo "Building and running Payment service..." + +# Navigate to the payment service directory +cd "$(dirname "$0")" + +# Build the project with Maven +echo "Building with Maven..." +mvn clean package + +# Run the application using Liberty Maven plugin +echo "Starting Liberty server..." +mvn liberty:run diff --git a/code/chapter08/payment/src/main/java/io/microprofile/tutorial/store/payment/PaymentRestApplication.java b/code/chapter08/payment/src/main/java/io/microprofile/tutorial/store/payment/PaymentRestApplication.java new file mode 100644 index 0000000..bbdcf96 --- /dev/null +++ b/code/chapter08/payment/src/main/java/io/microprofile/tutorial/store/payment/PaymentRestApplication.java @@ -0,0 +1,9 @@ +package io.microprofile.tutorial.store.payment; + +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/chapter08/payment/src/main/java/io/microprofile/tutorial/store/payment/WebAppConfig.java b/code/chapter08/payment/src/main/java/io/microprofile/tutorial/store/payment/WebAppConfig.java new file mode 100644 index 0000000..0bbe20b --- /dev/null +++ b/code/chapter08/payment/src/main/java/io/microprofile/tutorial/store/payment/WebAppConfig.java @@ -0,0 +1,14 @@ +package io.microprofile.tutorial.store.payment; + +import jakarta.enterprise.context.ApplicationScoped; + +/** + * Configuration class for the payment application. + * This class configures CDI beans and other application-wide settings. + */ +@ApplicationScoped +public class WebAppConfig { + + // You can add CDI producer methods here if needed + +} diff --git a/code/chapter08/payment/src/main/java/io/microprofile/tutorial/store/payment/config/PaymentConfig.java b/code/chapter08/payment/src/main/java/io/microprofile/tutorial/store/payment/config/PaymentConfig.java new file mode 100644 index 0000000..c4df4d6 --- /dev/null +++ b/code/chapter08/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/chapter08/payment/src/main/java/io/microprofile/tutorial/store/payment/config/PaymentServiceConfigSource.java b/code/chapter08/payment/src/main/java/io/microprofile/tutorial/store/payment/config/PaymentServiceConfigSource.java new file mode 100644 index 0000000..1a5609d --- /dev/null +++ b/code/chapter08/payment/src/main/java/io/microprofile/tutorial/store/payment/config/PaymentServiceConfigSource.java @@ -0,0 +1,67 @@ +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"); + + // Fault Tolerance Configuration + properties.put("payment.retry.maxRetries", "3"); + properties.put("payment.retry.delay", "2000"); + properties.put("payment.circuitbreaker.failureRatio", "0.5"); + properties.put("payment.circuitbreaker.requestVolumeThreshold", "4"); + properties.put("payment.timeout.duration", "3000"); + } + + @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/chapter08/payment/src/main/java/io/microprofile/tutorial/store/payment/entity/PaymentDetails.java b/code/chapter08/payment/src/main/java/io/microprofile/tutorial/store/payment/entity/PaymentDetails.java new file mode 100644 index 0000000..6474223 --- /dev/null +++ b/code/chapter08/payment/src/main/java/io/microprofile/tutorial/store/payment/entity/PaymentDetails.java @@ -0,0 +1,62 @@ +package io.microprofile.tutorial.store.payment.entity; + +import java.math.BigDecimal; + +public class PaymentDetails { + private String cardNumber; + private String cardHolderName; + private String expiryDate; // Format MM/YY + private String securityCode; + private BigDecimal amount; + + public PaymentDetails() { + } + + public PaymentDetails(String cardNumber, String cardHolderName, String expiryDate, String securityCode, BigDecimal amount) { + this.cardNumber = cardNumber; + this.cardHolderName = cardHolderName; + this.expiryDate = expiryDate; + this.securityCode = securityCode; + this.amount = amount; + } + + public String getCardNumber() { + return cardNumber; + } + + public void setCardNumber(String cardNumber) { + this.cardNumber = cardNumber; + } + + public String getCardHolderName() { + return cardHolderName; + } + + public void setCardHolderName(String cardHolderName) { + this.cardHolderName = cardHolderName; + } + + public String getExpiryDate() { + return expiryDate; + } + + public void setExpiryDate(String expiryDate) { + this.expiryDate = expiryDate; + } + + public String getSecurityCode() { + return securityCode; + } + + public void setSecurityCode(String securityCode) { + this.securityCode = securityCode; + } + + public BigDecimal getAmount() { + return amount; + } + + public void setAmount(BigDecimal amount) { + this.amount = amount; + } +} diff --git a/code/chapter08/payment/src/main/java/io/microprofile/tutorial/store/payment/exception/CriticalPaymentException.java b/code/chapter08/payment/src/main/java/io/microprofile/tutorial/store/payment/exception/CriticalPaymentException.java new file mode 100644 index 0000000..cb7157a --- /dev/null +++ b/code/chapter08/payment/src/main/java/io/microprofile/tutorial/store/payment/exception/CriticalPaymentException.java @@ -0,0 +1,7 @@ +package io.microprofile.tutorial.store.payment.exception; + +public class CriticalPaymentException extends Exception { + public CriticalPaymentException(String message) { + super(message); + } +} diff --git a/code/chapter08/payment/src/main/java/io/microprofile/tutorial/store/payment/exception/PaymentProcessingException.java b/code/chapter08/payment/src/main/java/io/microprofile/tutorial/store/payment/exception/PaymentProcessingException.java new file mode 100644 index 0000000..5a72d4b --- /dev/null +++ b/code/chapter08/payment/src/main/java/io/microprofile/tutorial/store/payment/exception/PaymentProcessingException.java @@ -0,0 +1,7 @@ +package io.microprofile.tutorial.store.payment.exception; + +public class PaymentProcessingException extends Exception { + public PaymentProcessingException(String message) { + super(message); + } +} diff --git a/code/chapter08/payment/src/main/java/io/microprofile/tutorial/store/payment/resource/PaymentConfigResource.java b/code/chapter08/payment/src/main/java/io/microprofile/tutorial/store/payment/resource/PaymentConfigResource.java new file mode 100644 index 0000000..6a4002f --- /dev/null +++ b/code/chapter08/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/chapter08/payment/src/main/java/io/microprofile/tutorial/store/payment/resource/PaymentResource.java b/code/chapter08/payment/src/main/java/io/microprofile/tutorial/store/payment/resource/PaymentResource.java new file mode 100644 index 0000000..675862b --- /dev/null +++ b/code/chapter08/payment/src/main/java/io/microprofile/tutorial/store/payment/resource/PaymentResource.java @@ -0,0 +1,80 @@ +package io.microprofile.tutorial.store.payment.resource; + +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 io.microprofile.tutorial.store.payment.entity.PaymentDetails; +import io.microprofile.tutorial.store.payment.exception.CriticalPaymentException; +import io.microprofile.tutorial.store.payment.exception.PaymentProcessingException; +import io.microprofile.tutorial.store.payment.service.PaymentService; +import jakarta.enterprise.context.RequestScoped; +import jakarta.inject.Inject; +import jakarta.ws.rs.POST; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.Produces; +import jakarta.ws.rs.QueryParam; +import jakarta.ws.rs.core.MediaType; +import jakarta.ws.rs.core.Response; + +import java.math.BigDecimal; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionStage; + +@RequestScoped +@Path("/") +public class PaymentResource { + + @Inject + @ConfigProperty(name = "payment.gateway.endpoint") + private String endpoint; + + @Inject + private PaymentService paymentService; + + @POST + @Path("/authorize") + @Produces(MediaType.APPLICATION_JSON) + @Operation(summary = "Process payment", description = "Process payment using the payment gateway API with fault tolerance") + @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(@QueryParam("amount") Double amount) + throws PaymentProcessingException, CriticalPaymentException { + + // Input validation + if (amount == null || amount <= 0) { + throw new CriticalPaymentException("Invalid payment amount: " + amount); + } + + try { + // Create PaymentDetails using constructor + PaymentDetails paymentDetails = new PaymentDetails( + "****-****-****-1111", // cardNumber - placeholder for demo + "Demo User", // cardHolderName + "12/25", // expiryDate + "***", // securityCode + BigDecimal.valueOf(amount) // amount + ); + + // Use PaymentService with full fault tolerance features + CompletionStage result = paymentService.processPayment(paymentDetails); + + // Wait for async result (in production, consider different patterns) + String paymentResult = result.toCompletableFuture().get(); + + return Response.ok(paymentResult, MediaType.APPLICATION_JSON).build(); + + } catch (PaymentProcessingException e) { + // Re-throw to let fault tolerance handle it + throw e; + } catch (Exception e) { + // Handle other exceptions + throw new PaymentProcessingException("Payment processing failed: " + e.getMessage()); + } + } + +} diff --git a/code/chapter08/payment/src/main/java/io/microprofile/tutorial/store/payment/service/PaymentService.java b/code/chapter08/payment/src/main/java/io/microprofile/tutorial/store/payment/service/PaymentService.java new file mode 100644 index 0000000..db3ba2a --- /dev/null +++ b/code/chapter08/payment/src/main/java/io/microprofile/tutorial/store/payment/service/PaymentService.java @@ -0,0 +1,87 @@ +package io.microprofile.tutorial.store.payment.service; + +import io.microprofile.tutorial.store.payment.exception.PaymentProcessingException; +import io.microprofile.tutorial.store.payment.entity.PaymentDetails; +import io.microprofile.tutorial.store.payment.exception.CriticalPaymentException; + +import org.eclipse.microprofile.faulttolerance.Asynchronous; +import org.eclipse.microprofile.faulttolerance.Bulkhead; +import org.eclipse.microprofile.faulttolerance.Fallback; +import org.eclipse.microprofile.faulttolerance.Retry; +import org.eclipse.microprofile.faulttolerance.Timeout; +import org.eclipse.microprofile.faulttolerance.CircuitBreaker; + +import jakarta.enterprise.context.ApplicationScoped; +import org.eclipse.microprofile.config.inject.ConfigProperty; + +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionStage; + +@ApplicationScoped +public class PaymentService { + + @ConfigProperty(name = "payment.gateway.endpoint", defaultValue = "https://defaultapi.paymentgateway.com") + private String endpoint; + + /** + * Process the payment request. + * + * @param paymentDetails details of the payment + * @return response message indicating success or failure + * @throws PaymentProcessingException if a transient issue occurs + */ + @Asynchronous + @Timeout(3000) + @Retry(maxRetries = 3, + delay = 2000, + jitter = 500, + retryOn = PaymentProcessingException.class, + abortOn = CriticalPaymentException.class) + @Fallback(fallbackMethod = "fallbackProcessPayment") + @Bulkhead(value=5) + @CircuitBreaker( + failureRatio = 0.5, + requestVolumeThreshold = 4, + delay = 3000 + ) + public CompletionStage processPayment(PaymentDetails paymentDetails) throws PaymentProcessingException { + + // Example logic to call the payment gateway API + System.out.println("Calling payment gateway API at: " + endpoint); + + simulateDelay(); + + System.out.println("Processing payment for amount: " + paymentDetails.getAmount()); + + // Simulating a transient failure + if (Math.random() > 0.7) { + throw new PaymentProcessingException("Temporary payment processing failure"); + } + + // Simulating successful processing + return CompletableFuture.completedFuture("{\"status\":\"success\", \"message\":\"Payment processed successfully.\"}"); + } + + /** + * Fallback method when payment processing fails. + * + * @param paymentDetails details of the payment + * @return response message for fallback + */ + public CompletionStage fallbackProcessPayment(PaymentDetails paymentDetails) { + System.out.println("Fallback invoked for payment of amount: " + paymentDetails.getAmount()); + return CompletableFuture.completedFuture("{\"status\":\"failed\", \"message\":\"Payment service is currently unavailable.\"}"); + } + + /** + * Simulate a delay in processing to demonstrate timeout. + */ + private void simulateDelay() { + try { + Thread.sleep(1500); // Simulated long-running task + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new RuntimeException("Processing interrupted"); + } + } +} diff --git a/code/chapter08/payment/src/main/java/io/microprofile/tutorial/store/payment/service/payment.http b/code/chapter08/payment/src/main/java/io/microprofile/tutorial/store/payment/service/payment.http new file mode 100644 index 0000000..98ae2e5 --- /dev/null +++ b/code/chapter08/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/chapter08/payment/src/main/liberty/config/server.xml b/code/chapter08/payment/src/main/liberty/config/server.xml new file mode 100644 index 0000000..9d5f15b --- /dev/null +++ b/code/chapter08/payment/src/main/liberty/config/server.xml @@ -0,0 +1,22 @@ + + + jakartaEE-10.0 + microProfile-6.1 + restfulWS + jsonp + jsonb + cdi + mpConfig + mpOpenAPI + mpHealth + mpMetrics + mpFaultTolerance + + + + + + + + \ No newline at end of file diff --git a/code/chapter08/payment/src/main/resources/META-INF/microprofile-config.properties b/code/chapter08/payment/src/main/resources/META-INF/microprofile-config.properties new file mode 100644 index 0000000..8ae0ee1 --- /dev/null +++ b/code/chapter08/payment/src/main/resources/META-INF/microprofile-config.properties @@ -0,0 +1,11 @@ +# microprofile-config.properties +mp.openapi.scan=true +product.maintenanceMode=false + +# Product Service Configuration +payment.gateway.endpoint=https://api.paymentgateway.com/v1 + +# Payment Service Configuration +io.microprofile.tutorial.store.payment.service.PaymentService/processPayment/Retry/maxRetries=3 +io.microprofile.tutorial.store.payment.service.PaymentService/processPayment/Retry/delay=2000 +io.microprofile.tutorial.store.payment.service.PaymentService/processPayment/Retry/jitter=500 \ No newline at end of file diff --git a/code/chapter08/payment/src/main/resources/META-INF/services/org.eclipse.microprofile.config.spi.ConfigSource b/code/chapter08/payment/src/main/resources/META-INF/services/org.eclipse.microprofile.config.spi.ConfigSource new file mode 100644 index 0000000..9870717 --- /dev/null +++ b/code/chapter08/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/chapter08/payment/src/main/webapp/WEB-INF/beans.xml b/code/chapter08/payment/src/main/webapp/WEB-INF/beans.xml new file mode 100644 index 0000000..b708636 --- /dev/null +++ b/code/chapter08/payment/src/main/webapp/WEB-INF/beans.xml @@ -0,0 +1,7 @@ + + + diff --git a/code/chapter08/payment/src/main/webapp/WEB-INF/web.xml b/code/chapter08/payment/src/main/webapp/WEB-INF/web.xml new file mode 100644 index 0000000..9e4411b --- /dev/null +++ b/code/chapter08/payment/src/main/webapp/WEB-INF/web.xml @@ -0,0 +1,12 @@ + + + Payment Service + + + index.html + index.jsp + + diff --git a/code/chapter08/payment/src/main/webapp/index.html b/code/chapter08/payment/src/main/webapp/index.html new file mode 100644 index 0000000..6e55c95 --- /dev/null +++ b/code/chapter08/payment/src/main/webapp/index.html @@ -0,0 +1,212 @@ + + + + + + Payment Service - MicroProfile Config Demo + + + +
+

Payment Service

+

MicroProfile Config & Fault Tolerance Demo

+
+ +
+
+

About this Service

+

The Payment Service demonstrates MicroProfile Config integration with custom ConfigSource implementation and comprehensive Fault Tolerance patterns.

+

It provides endpoints for managing payment configuration and processing payments with retry policies, circuit breakers, and fallback mechanisms.

+

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
  • +
  • MicroProfile Fault Tolerance with Retry Policies
  • +
  • Circuit Breaker protection for external services
  • +
  • Timeout protection and Fallback mechanisms
  • +
  • Bulkhead pattern for concurrency control
  • +
+
+ +
+

API Endpoints

+
    +
  • GET /api/payment-config - Get current payment configuration
  • +
  • POST /api/payment-config - Update payment configuration property
  • +
  • POST /api/authorize - Process payment authorization (with retry)
  • +
  • POST /api/verify - Verify payment transaction (aggressive retry)
  • +
  • POST /api/capture - Capture payment (circuit breaker + timeout)
  • +
  • POST /api/refund - Process payment refund (conservative retry)
  • +
  • POST /api/payment-config/process-example - Example payment processing with config
  • +
+
+ +
+

Fault Tolerance Features

+

The Payment Service implements comprehensive fault tolerance patterns:

+
+
+

🔄 Retry Policies

+
    +
  • Authorization: 3 retries, 1s delay
  • +
  • Verification: 5 retries, 500ms delay
  • +
  • Capture: 2 retries, 2s delay
  • +
  • Refund: 1 retry, 3s delay
  • +
+
+
+

⚡ Circuit Breaker

+
    +
  • Failure Ratio: 50%
  • +
  • Request Threshold: 4 requests
  • +
  • Recovery Delay: 5 seconds
  • +
  • Applied to: Payment capture
  • +
+
+
+

⏱️ Timeout Protection

+
    +
  • Capture Timeout: 3 seconds
  • +
  • Max Retry Duration: 10-15 seconds
  • +
  • Jitter: 200-500ms randomization
  • +
+
+
+

🛟 Fallback Mechanisms

+
    +
  • Authorization: Service unavailable
  • +
  • Verification: Queue for retry
  • +
  • Capture: Defer operation
  • +
  • Refund: Manual processing
  • +
+
+
+

🧱 Bulkhead Pattern

+
    +
  • Concurrent Requests: 5 maximum
  • +
  • Excess Requests: Rejected immediately
  • +
  • Recovery: Automatic when load decreases
  • +
  • Applied to: Payment operations
  • +
+
+
+
+ +
+

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)
  • +
  • Payment Properties: payment.gateway.endpoint, payment.retry.*, payment.circuitbreaker.*, payment.timeout.*, payment.bulkhead.*
  • +
  • Update Method: POST to /api/payment-config with {"key": "payment.property.name", "value": "new-value"}
  • +
+
+ +
+

Testing Fault Tolerance

+

Test the fault tolerance features with these examples:

+
    +
  • Trigger Retries: Use card number ending in "0000" for authorization failures
  • +
  • Circuit Breaker: Make multiple capture requests to trigger circuit opening
  • +
  • Timeouts: Capture operations may timeout randomly for testing
  • +
  • Fallbacks: All operations provide graceful degradation responses
  • +
  • Bulkhead: Generate >5 concurrent requests to see request rejection in action
  • +
+

Monitor logs: tail -f target/liberty/wlp/usr/servers/mpServer/logs/messages.log

+

Run tests: ./test-payment-fault-tolerance-suite.sh or ./test-payment-bulkhead.sh

+
+ + +
+ +
+

MicroProfile Config & Fault Tolerance Demo | Payment Service

+

Powered by Open Liberty, MicroProfile Config 3.0 & MicroProfile Fault Tolerance 4.0

+
+ + diff --git a/code/chapter08/payment/src/main/webapp/index.jsp b/code/chapter08/payment/src/main/webapp/index.jsp new file mode 100644 index 0000000..d5de5cb --- /dev/null +++ b/code/chapter08/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/chapter08/payment/test-payment-async.sh b/code/chapter08/payment/test-payment-async.sh new file mode 100755 index 0000000..2803b66 --- /dev/null +++ b/code/chapter08/payment/test-payment-async.sh @@ -0,0 +1,121 @@ +#!/bin/bash + +# Enhanced test script for verifying asynchronous processing +# This script shows the asynchronous behavior by: +# 1. Checking for concurrent processing +# 2. Monitoring response times to verify non-blocking behavior +# 3. Analyzing the server logs to confirm retry patterns + +# Color definitions +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +BLUE='\033[0;34m' +PURPLE='\033[0;35m' +CYAN='\033[0;36m' +NC='\033[0m' # No Color + +# Check if bc is installed and install it if not +if ! command -v bc &> /dev/null; then + echo -e "${YELLOW}The 'bc' command is not found. Installing bc...${NC}" + sudo apt-get update && sudo apt-get install -y bc + if [ $? -ne 0 ]; then + echo -e "${RED}Failed to install bc. Please install it manually.${NC}" + exit 1 + fi + echo -e "${GREEN}bc installed successfully.${NC}" +fi + +# Set payment endpoint URL +HOST="localhost" +PAYMENT_URL="http://${HOST}:9080/payment/api/authorize" + +echo -e "${BLUE}=== Enhanced Asynchronous Testing ====${NC}" +echo -e "${CYAN}This test will send a series of requests to demonstrate asynchronous processing${NC}" +echo "" + +# First, let's check server logs before test +echo -e "${YELLOW}Checking server logs before test...${NC}" +echo -e "${CYAN}(This establishes a baseline for comparison)${NC}" +cd /workspaces/liberty-rest-app/payment +MESSAGES_LOG="target/liberty/wlp/usr/servers/mpServer/logs/messages.log" + +if [ -f "$MESSAGES_LOG" ]; then + echo -e "${PURPLE}Server log file exists at: $MESSAGES_LOG${NC}" + # Count initial payment processing messages + INITIAL_PROCESSING_COUNT=$(grep -c "Processing payment for amount" "$MESSAGES_LOG") + INITIAL_FALLBACK_COUNT=$(grep -c "Fallback invoked for payment" "$MESSAGES_LOG") + + echo -e "${CYAN}Initial payment processing count: $INITIAL_PROCESSING_COUNT${NC}" + echo -e "${CYAN}Initial fallback count: $INITIAL_FALLBACK_COUNT${NC}" +else + echo -e "${RED}Server log file not found at: $MESSAGES_LOG${NC}" + INITIAL_PROCESSING_COUNT=0 + INITIAL_FALLBACK_COUNT=0 +fi + +echo "" +echo -e "${YELLOW}Now sending 3 requests in rapid succession...${NC}" + +# Function to send request and measure time +send_request() { + local id=$1 + local amount=$2 + local start_time=$(date +%s.%N) + + response=$(curl -s -X POST "${PAYMENT_URL}?amount=${amount}") + + local end_time=$(date +%s.%N) + local duration=$(echo "$end_time - $start_time" | bc) + + echo -e "${GREEN}[Request $id] Completed in ${duration}s${NC}" + echo -e "${CYAN}[Request $id] Response: $response${NC}" + + return 0 +} + +# Send 3 requests in rapid succession +for i in {1..3}; do + # Use a fixed amount for consistency + amount=25.99 + echo -e "${PURPLE}[Request $i] Sending request for \$$amount...${NC}" + send_request $i $amount & + # Sleep briefly to ensure log messages are distinguishable + sleep 0.1 +done + +# Wait for all background processes to complete +wait + +echo "" +echo -e "${YELLOW}Waiting 5 seconds for processing to complete...${NC}" +sleep 5 + +# Check the server logs after test +echo -e "${YELLOW}Checking server logs after test...${NC}" + +if [ -f "$MESSAGES_LOG" ]; then + # Count final payment processing messages + FINAL_PROCESSING_COUNT=$(grep -c "Processing payment for amount" "$MESSAGES_LOG") + FINAL_FALLBACK_COUNT=$(grep -c "Fallback invoked for payment" "$MESSAGES_LOG") + + NEW_PROCESSING=$(($FINAL_PROCESSING_COUNT - $INITIAL_PROCESSING_COUNT)) + NEW_FALLBACKS=$(($FINAL_FALLBACK_COUNT - $INITIAL_FALLBACK_COUNT)) + + echo -e "${CYAN}New payment processing events: $NEW_PROCESSING${NC}" + echo -e "${CYAN}New fallback events: $NEW_FALLBACKS${NC}" + + # Extract the latest log entries + echo "" + echo -e "${BLUE}Latest server log entries related to payment processing:${NC}" + grep "Processing payment for amount\|Fallback invoked for payment" "$MESSAGES_LOG" | tail -10 +else + echo -e "${RED}Server log file not found after test${NC}" +fi + +echo "" +echo -e "${BLUE}=== Asynchronous Behavior Analysis ====${NC}" +echo -e "${CYAN}1. Rapid response times indicate non-blocking behavior${NC}" +echo -e "${CYAN}2. Multiple processing entries in logs show concurrent execution${NC}" +echo -e "${CYAN}3. Fallbacks demonstrate the fault tolerance mechanism${NC}" +echo -e "${CYAN}4. All @Asynchronous methods return quickly while processing continues in background${NC}" diff --git a/code/chapter08/payment/test-payment-basic.sh b/code/chapter08/payment/test-payment-basic.sh new file mode 100755 index 0000000..1eecb6f --- /dev/null +++ b/code/chapter08/payment/test-payment-basic.sh @@ -0,0 +1,55 @@ +#!/bin/bash + +# Simple test script for payment service + +# Color definitions +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +NC='\033[0m' # No Color + +# Check if bc is installed and install it if not +if ! command -v bc &> /dev/null; then + echo -e "${YELLOW}The 'bc' command is not found. Installing bc...${NC}" + sudo apt-get update && sudo apt-get install -y bc + if [ $? -ne 0 ]; then + echo -e "${RED}Failed to install bc. Please install it manually.${NC}" + exit 1 + fi + echo -e "${GREEN}bc installed successfully.${NC}" +fi + +HOST="localhost" +PAYMENT_URL="http://${HOST}:9080/payment/api/authorize" + +# Hardcoded amount for testing +AMOUNT=25.99 + +echo -e "${YELLOW}Sending payment request for \$$AMOUNT...${NC}" +echo "" + +# Capture start time +start_time=$(date +%s.%N) + +# Send request +response=$(curl -s -X POST "${PAYMENT_URL}?amount=${AMOUNT}") + +# Capture end time +end_time=$(date +%s.%N) + +# Calculate duration +duration=$(echo "$end_time - $start_time" | bc) + +echo "" +echo -e "${GREEN}✓ Request completed in ${duration} seconds${NC}" +echo -e "${YELLOW}Response:${NC} $response" +echo "" + +# Show if the response indicates success or fallback +if echo "$response" | grep -q "success"; then + echo -e "${GREEN}✓ SUCCESS: Payment processed successfully${NC}" +elif echo "$response" | grep -q "failed"; then + echo -e "${RED}✗ FALLBACK: Payment service unavailable${NC}" +else + echo -e "${RED}✗ ERROR: Unexpected response${NC}" +fi diff --git a/code/chapter08/payment/test-payment-bulkhead.sh b/code/chapter08/payment/test-payment-bulkhead.sh new file mode 100755 index 0000000..86199af --- /dev/null +++ b/code/chapter08/payment/test-payment-bulkhead.sh @@ -0,0 +1,263 @@ +#!/bin/bash + +# Test script for Payment Service Bulkhead functionality +# This script demonstrates the @Bulkhead annotation by sending many concurrent requests +# and observing how the service handles concurrent load up to its configured limit (5) + +# Color definitions +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +BLUE='\033[0;34m' +PURPLE='\033[0;35m' +CYAN='\033[0;36m' +NC='\033[0m' # No Color + +# Check if bc is installed and install it if not +if ! command -v bc &> /dev/null; then + echo -e "${YELLOW}The 'bc' command is not found. Installing bc...${NC}" + sudo apt-get update && sudo apt-get install -y bc + if [ $? -ne 0 ]; then + echo -e "${RED}Failed to install bc. Please install it manually.${NC}" + exit 1 + fi + echo -e "${GREEN}bc installed successfully.${NC}" +fi + +# Header +echo -e "${BLUE}==============================================${NC}" +echo -e "${BLUE} Payment Service Bulkhead Test ${NC}" +echo -e "${BLUE}==============================================${NC}" +echo "" + +# Dynamically determine the base URL +if [ -n "$CODESPACE_NAME" ] && [ -n "$GITHUB_CODESPACES_PORT_FORWARDING_DOMAIN" ]; then + # GitHub Codespaces environment - use localhost for internal testing + BASE_URL="http://localhost:9080/payment/api" + echo -e "${CYAN}Detected GitHub Codespaces environment (using localhost)${NC}" + echo -e "${CYAN}Note: External access would be https://$CODESPACE_NAME-9080.$GITHUB_CODESPACES_PORT_FORWARDING_DOMAIN/payment/api${NC}" +elif [ -n "$GITPOD_WORKSPACE_URL" ]; then + # Gitpod environment + GITPOD_HOST=$(echo $GITPOD_WORKSPACE_URL | sed 's|https://||' | sed 's|/||') + BASE_URL="https://9080-$GITPOD_HOST/payment/api" + echo -e "${CYAN}Detected Gitpod environment${NC}" +else + # Local or other environment + BASE_URL="http://localhost:9080/payment/api" + echo -e "${CYAN}Using local environment${NC}" +fi + +echo -e "${CYAN}Base URL: $BASE_URL${NC}" +echo "" + +# Set up log monitoring +LOG_FILE="/workspaces/liberty-rest-app/payment/target/liberty/wlp/usr/servers/mpServer/logs/messages.log" + +# Get initial log position for later analysis +if [ -f "$LOG_FILE" ]; then + LOG_POSITION=$(wc -l < "$LOG_FILE") + echo -e "${CYAN}Found server log file at: $LOG_FILE${NC}" + echo -e "${CYAN}Current log position: $LOG_POSITION${NC}" +else + LOG_POSITION=0 + echo -e "${YELLOW}Warning: Server log file not found at: $LOG_FILE${NC}" + echo -e "${YELLOW}Will continue without log analysis${NC}" +fi + +echo "" + +# ==================================== +# Bulkhead Configuration +# ==================================== +echo -e "${BLUE}=== PaymentService Bulkhead Configuration ===${NC}" +echo -e "${YELLOW}Your PaymentService has these bulkhead settings:${NC}" +echo -e "${CYAN}• Maximum Concurrent Requests: 5${NC}" +echo -e "${CYAN}• Asynchronous Processing: Yes${NC}" +echo -e "${CYAN}• Retry on failure: Yes (3 retries)${NC}" +echo -e "${CYAN}• Processing delay: ~1.5 seconds per attempt${NC}" +echo "" + +echo -e "${YELLOW}🔍 WHAT TO EXPECT WITH BULKHEAD:${NC}" +echo -e "${CYAN}• Only 5 concurrent requests will be processed at a time${NC}" +echo -e "${CYAN}• Additional requests beyond the limit will be rejected${NC}" +echo -e "${CYAN}• Rejected requests will receive a 'Bulkhead full' message${NC}" +echo -e "${CYAN}• Successfully queued requests complete in ~1.5-10 seconds${NC}" +echo "" + +echo -e "${YELLOW}Make sure the Payment Service is running on port 9080${NC}" +echo -e "${YELLOW}You can start it with: cd payment && mvn liberty:run${NC}" +echo "" +read -p "Press Enter to continue..." +echo "" + +# ==================================== +# Test 1: Single Request (Baseline) +# ==================================== +echo -e "${BLUE}=== Test 1: Single Request (Baseline) ===${NC}" +echo -e "${YELLOW}This test sends a single request to establish baseline performance${NC}" +echo "" + +# Function to send a request and measure time +send_request() { + local id=$1 + local amount=$2 + local start_time=$(date +%s.%N) + + response=$(curl -s -X POST "${BASE_URL}/authorize?amount=${amount}") + status=$? + + local end_time=$(date +%s.%N) + local duration=$(echo "$end_time - $start_time" | bc) + duration=$(printf "%.2f" $duration) + + if [ $status -eq 0 ]; then + if echo "$response" | grep -q "success"; then + echo -e "${GREEN}[Request $id] SUCCESS: Payment processed in ${duration}s${NC}" + echo -e "${GREEN}[Request $id] Response: $response${NC}" + elif echo "$response" | grep -q "Bulkhead"; then + echo -e "${YELLOW}[Request $id] REJECTED: Bulkhead full (took ${duration}s)${NC}" + echo -e "${YELLOW}[Request $id] Response: $response${NC}" + elif echo "$response" | grep -q "fallback"; then + echo -e "${PURPLE}[Request $id] FALLBACK: Service used fallback (took ${duration}s)${NC}" + echo -e "${PURPLE}[Request $id] Response: $response${NC}" + else + echo -e "${RED}[Request $id] ERROR: Unexpected response (took ${duration}s)${NC}" + echo -e "${RED}[Request $id] Response: $response${NC}" + fi + else + echo -e "${RED}[Request $id] ERROR: Failed to connect (took ${duration}s)${NC}" + fi + + echo "$duration" +} + +# Send a single request +echo -e "${CYAN}Sending single baseline request...${NC}" +send_request "Baseline" "50.00" + +echo "" +echo -e "${BLUE}----------------------------------------${NC}" +echo "" + +# ==================================== +# Test 2: Bulkhead Testing (10 concurrent requests) +# ==================================== +echo -e "${BLUE}=== Test 2: Bulkhead Testing (10 concurrent requests) ===${NC}" +echo -e "${YELLOW}This test sends 10 concurrent requests to test bulkhead limits (5)${NC}" +echo -e "${YELLOW}Expected: 5 should be processed, others should be rejected or queued${NC}" +echo "" + +# Function to generate a random amount between 10 and 200 +random_amount() { + echo "$(( (RANDOM % 190) + 10 )).99" +} + +# Send concurrent requests to test bulkhead +echo -e "${CYAN}Sending 10 concurrent requests...${NC}" +pids=() +results=() + +for i in {1..10}; do + amount=$(random_amount) + echo -e "${PURPLE}[Request $i/10] Initiating payment request for \$$amount...${NC}" + send_request "$i" "$amount" > /tmp/bulkhead_result_$i.txt & + pids+=($!) +done + +# Wait for all requests to complete +for pid in "${pids[@]}"; do + wait $pid +done + +# Collect results +echo "" +echo -e "${BLUE}=== Results Summary ===${NC}" +success_count=0 +rejected_count=0 +fallback_count=0 +error_count=0 + +for i in {1..10}; do + result=$(cat /tmp/bulkhead_result_$i.txt) + rm /tmp/bulkhead_result_$i.txt + results+=($result) + + # Count results by parsing the output log + log_entry=$(grep "\[Request $i\]" /tmp/bulkhead.log 2>/dev/null || echo "") + if echo "$log_entry" | grep -q "SUCCESS"; then + success_count=$((success_count + 1)) + elif echo "$log_entry" | grep -q "REJECTED"; then + rejected_count=$((rejected_count + 1)) + elif echo "$log_entry" | grep -q "FALLBACK"; then + fallback_count=$((fallback_count + 1)) + else + error_count=$((error_count + 1)) + fi +done + +echo -e "${CYAN}Successful requests: $success_count${NC}" +echo -e "${CYAN}Rejected requests: $rejected_count${NC}" +echo -e "${CYAN}Fallback requests: $fallback_count${NC}" +echo -e "${CYAN}Error requests: $error_count${NC}" + +# ==================================== +# Log Analysis +# ==================================== +if [ -f "$LOG_FILE" ] && [ $LOG_POSITION -gt 0 ]; then + echo "" + echo -e "${BLUE}=== Server Log Analysis ===${NC}" + + # Extract relevant log entries + echo -e "${CYAN}Latest log entries related to payment processing:${NC}" + tail -n +$LOG_POSITION "$LOG_FILE" | grep -E "Processing payment for amount|Bulkhead|concurrent|reject" | head -20 +fi + +# ==================================== +# Test 3: Sequential Requests (After Bulkhead Test) +# ==================================== +echo "" +echo -e "${BLUE}=== Test 3: Sequential Requests After Bulkhead Test ===${NC}" +echo -e "${YELLOW}This test sends 3 sequential requests to verify service recovery${NC}" +echo -e "${YELLOW}Expected: All should succeed now that the bulkhead pressure is released${NC}" +echo "" + +# Wait a moment for bulkhead to clear +echo -e "${CYAN}Waiting 5 seconds for bulkhead to clear...${NC}" +sleep 5 + +# Send 3 sequential requests +for i in {1..3}; do + amount=$(random_amount) + echo -e "${CYAN}Sending sequential request $i/3 (\$$amount)...${NC}" + send_request "Sequential-$i" "$amount" + echo "" + sleep 1 +done + +# ==================================== +# Summary and Conclusion +# ==================================== +echo "" +echo -e "${BLUE}==============================================${NC}" +echo -e "${BLUE} Bulkhead Testing Summary ${NC}" +echo -e "${BLUE}==============================================${NC}" +echo "" + +echo -e "${GREEN}=== Bulkhead Testing Complete ===${NC}" +echo "" +echo -e "${YELLOW}Key observations:${NC}" +echo -e "${CYAN}1. The PaymentService uses a bulkhead of 5 concurrent requests${NC}" +echo -e "${CYAN}2. Requests beyond the bulkhead limit are rejected with an error${NC}" +echo -e "${CYAN}3. Successful requests complete even under concurrent load${NC}" +echo -e "${CYAN}4. The service recovers quickly once load decreases${NC}" +echo -e "${CYAN}5. This protects the system from being overwhelmed${NC}" +echo "" +echo -e "${YELLOW}This demonstrates how the @Bulkhead annotation:${NC}" +echo -e "${CYAN}• Limits concurrent requests to prevent system overload${NC}" +echo -e "${CYAN}• Rejects excess requests instead of queuing them indefinitely${NC}" +echo -e "${CYAN}• Protects the system during traffic spikes${NC}" +echo -e "${CYAN}• Works with other fault tolerance annotations (@Retry, @Fallback, etc.)${NC}" +echo "" +echo -e "${YELLOW}For more details, see:${NC}" +echo -e "${CYAN}• MicroProfile Fault Tolerance Documentation${NC}" +echo -e "${CYAN}• https://download.eclipse.org/microprofile/microprofile-fault-tolerance-3.0/apidocs/org/eclipse/microprofile/faulttolerance/Bulkhead.html${NC}" diff --git a/code/chapter08/payment/test-payment-concurrent-load.sh b/code/chapter08/payment/test-payment-concurrent-load.sh new file mode 100755 index 0000000..94452a4 --- /dev/null +++ b/code/chapter08/payment/test-payment-concurrent-load.sh @@ -0,0 +1,123 @@ +#!/bin/bash + +# Test script for asynchronous payment processing +# This script sends multiple concurrent requests to test: +# 1. Asynchronous processing (@Asynchronous) +# 2. Resource isolation (@Bulkhead) +# 3. Retry behavior (@Retry) + +# Color definitions +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +BLUE='\033[0;34m' +PURPLE='\033[0;35m' +CYAN='\033[0;36m' +NC='\033[0m' # No Color + +# Check if bc is installed and install it if not +if ! command -v bc &> /dev/null; then + echo -e "${YELLOW}The 'bc' command is not found. Installing bc...${NC}" + sudo apt-get update && sudo apt-get install -y bc + if [ $? -ne 0 ]; then + echo -e "${RED}Failed to install bc. Please install it manually.${NC}" + exit 1 + fi + echo -e "${GREEN}bc installed successfully.${NC}" +fi + +# Set payment endpoint URL - automatically detect if running in GitHub Codespaces +if [ -n "$CODESPACES" ]; then + HOST="localhost" +else + HOST="localhost" +fi + +PAYMENT_URL="http://${HOST}:9080/payment/api/authorize" +NUM_REQUESTS=10 +CONCURRENCY=5 + +echo -e "${BLUE}=== Testing Asynchronous Payment Processing ===${NC}" +echo -e "${CYAN}Endpoint: ${PAYMENT_URL}${NC}" +echo -e "${CYAN}Sending ${NUM_REQUESTS} requests with concurrency ${CONCURRENCY}${NC}" +echo "" + +# Function to send a single request and capture timing +send_request() { + local id=$1 + local amount=$2 + local start_time=$(date +%s) + + echo -e "${YELLOW}[Request $id] Sending payment request for \$$amount...${NC}" + + # Send request and capture response + response=$(curl -s -X POST "${PAYMENT_URL}?amount=${amount}") + + local end_time=$(date +%s) + local duration=$((end_time - start_time)) + + # Check if response contains "success" or "failed" + if echo "$response" | grep -q "success"; then + echo -e "${GREEN}[Request $id] SUCCESS: Payment processed in ${duration}s${NC}" + echo -e "${GREEN}[Request $id] Response: $response${NC}" + elif echo "$response" | grep -q "failed"; then + echo -e "${RED}[Request $id] FALLBACK: Service unavailable (took ${duration}s)${NC}" + echo -e "${RED}[Request $id] Response: $response${NC}" + else + echo -e "${RED}[Request $id] ERROR: Unexpected response (took ${duration}s)${NC}" + echo -e "${RED}[Request $id] Response: $response${NC}" + fi + + # Return the duration for analysis + echo "$duration" +} + +# Run concurrent requests using GNU Parallel if available, or background processes if not +if command -v parallel > /dev/null; then + echo -e "${PURPLE}Using GNU Parallel for concurrent requests${NC}" + export -f send_request + export PAYMENT_URL RED GREEN YELLOW BLUE PURPLE CYAN NC + + # Use predefined amounts instead of bc calculation + amounts=("15.99" "24.50" "19.95" "32.75" "12.99" "22.50" "18.75" "29.99" "14.50" "27.25") + for i in $(seq 1 $NUM_REQUESTS); do + amount_index=$((i-1 % 10)) + amount=${amounts[$amount_index]} + send_request $i $amount & + done +else + echo -e "${PURPLE}Running concurrent requests using background processes${NC}" + # Store PIDs + pids=() + + # Predefined amounts + amounts=("15.99" "24.50" "19.95" "32.75" "12.99" "22.50" "18.75" "29.99" "14.50" "27.25") + + # Launch requests in background + for i in $(seq 1 $NUM_REQUESTS); do + # Get amount from predefined list + amount_index=$((i-1 % 10)) + amount=${amounts[$amount_index]} + send_request $i $amount & + pids+=($!) + + # Control concurrency + if [ ${#pids[@]} -ge $CONCURRENCY ]; then + # Wait for one process to finish before starting more + wait "${pids[0]}" + pids=("${pids[@]:1}") + fi + done + + # Wait for remaining processes + for pid in "${pids[@]}"; do + wait $pid + done +fi + +echo "" +echo -e "${BLUE}=== Test Complete ===${NC}" +echo -e "${CYAN}Analyze the responses to verify:${NC}" +echo -e "${CYAN}1. Asynchronous processing (@Asynchronous)${NC}" +echo -e "${CYAN}2. Resource isolation (@Bulkhead)${NC}" +echo -e "${CYAN}3. Retry behavior on failures (@Retry)${NC}" diff --git a/code/chapter08/payment/test-payment-retry.sh b/code/chapter08/payment/test-payment-retry.sh new file mode 100755 index 0000000..dcbb62c --- /dev/null +++ b/code/chapter08/payment/test-payment-retry.sh @@ -0,0 +1,180 @@ +#!/bin/bash + +# Test script for Payment Service Retry functionality +# Tests the @Retry annotation on the processPayment method + +set -e + +echo "=== Payment Service Retry Test ===" +echo "" + +# Dynamically determine the base URL +if [ -n "$CODESPACE_NAME" ] && [ -n "$GITHUB_CODESPACES_PORT_FORWARDING_DOMAIN" ]; then + # GitHub Codespaces environment - use localhost for internal testing + BASE_URL="http://localhost:9080/payment/api" + echo "Detected GitHub Codespaces environment (using localhost)" + echo "Note: External access would be https://$CODESPACE_NAME-9080.$GITHUB_CODESPACES_PORT_FORWARDING_DOMAIN/payment/api" +elif [ -n "$GITPOD_WORKSPACE_URL" ]; then + # Gitpod environment + GITPOD_HOST=$(echo $GITPOD_WORKSPACE_URL | sed 's|https://||' | sed 's|/||') + BASE_URL="https://9080-$GITPOD_HOST/payment/api" + echo "Detected Gitpod environment" +else + # Local or other environment + BASE_URL="http://localhost:9080/payment/api" + echo "Using local environment" +fi + +echo "Base URL: $BASE_URL" +echo "" + +# Colors for output +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +BLUE='\033[0;34m' +NC='\033[0m' # No Color + +# Function to make HTTP requests and display results +make_request() { + local method=$1 + local url=$2 + local description=$3 + + echo -e "${BLUE}Testing: $description${NC}" + echo "Request: $method $url" + echo "" + + # Capture start time + start_time=$(date +%s%3N) + + response=$(curl -s -w "\nHTTP_STATUS:%{http_code}\nTIME_TOTAL:%{time_total}" -X $method "$url" 2>/dev/null || echo "HTTP_STATUS:000") + + # Capture end time + end_time=$(date +%s%3N) + total_time=$((end_time - start_time)) + + http_code=$(echo "$response" | grep "HTTP_STATUS:" | cut -d: -f2) + curl_time=$(echo "$response" | grep "TIME_TOTAL:" | cut -d: -f2) + body=$(echo "$response" | sed '/HTTP_STATUS:/d' | sed '/TIME_TOTAL:/d') + + if [ "$http_code" -ge 200 ] && [ "$http_code" -lt 300 ]; then + # Analyze timing to determine retry behavior + # Convert curl_time to integer for comparison (multiply by 10 to handle decimals) + curl_time_int=$(echo "$curl_time" | awk '{printf "%.0f", $1 * 10}') + + if [ "$curl_time_int" -lt 20 ]; then # < 2.0 seconds + echo -e "${GREEN}✓ Success (HTTP $http_code) - First attempt! ⚡${NC}" + elif [ "$curl_time_int" -lt 55 ]; then # < 5.5 seconds + echo -e "${GREEN}✓ Success (HTTP $http_code) - After 1 retry 🔄${NC}" + elif [ "$curl_time_int" -lt 80 ]; then # < 8.0 seconds + echo -e "${GREEN}✓ Success (HTTP $http_code) - After 2 retries 🔄🔄${NC}" + else + echo -e "${GREEN}✓ Success (HTTP $http_code) - After 3 retries 🔄🔄🔄${NC}" + fi + elif [ "$http_code" -ge 400 ] && [ "$http_code" -lt 500 ]; then + echo -e "${YELLOW}⚠ Client Error (HTTP $http_code)${NC}" + else + echo -e "${RED}✗ Server Error (HTTP $http_code)${NC}" + fi + + echo "Response: $body" + echo "Total time: ${total_time}ms (curl: ${curl_time}s)" + echo "" + echo "----------------------------------------" + echo "" +} + +echo "Starting Payment Service Retry Tests..." +echo "" +echo "Your PaymentService has these retry settings:" +echo "• Max Retries: 3" +echo "• Delay: 2000ms" +echo "• Jitter: 500ms" +echo "• Retry on: PaymentProcessingException" +echo "• Abort on: CriticalPaymentException" +echo "• Simulated failure rate: 30% (Math.random() > 0.7)" +echo "• Processing delay: 1500ms per attempt" +echo "" +echo "🔍 HOW TO IDENTIFY RETRY BEHAVIOR:" +echo "• ⚡ Fast response (~1.5s) = Succeeded on 1st attempt" +echo "• 🔄 Medium response (~4s) = Needed 1 retry" +echo "• 🔄🔄 Slow response (~6.5s) = Needed 2 retries" +echo "• 🔄🔄🔄 Very slow response (~9-12s) = Needed 3 retries" +echo "" + +echo "Make sure the Payment Service is running on port 9080" +echo "You can start it with: cd payment && mvn liberty:run" +echo "" +read -p "Press Enter to continue..." +echo "" + +# Test 1: Valid payment (should succeed, may need retries due to random failures) +echo -e "${BLUE}=== Test 1: Valid Payment Authorization ===${NC}" +echo "This test uses a valid amount and may succeed immediately or after retries" +echo "Expected: Success after 1-4 attempts (due to 30% failure simulation)" +echo "" +make_request "POST" "$BASE_URL/authorize?amount=100.50" \ + "Valid payment amount (100.50) - may trigger retries due to random failures" + +# Test 2: Another valid payment to see retry behavior +echo -e "${BLUE}=== Test 2: Another Valid Payment ===${NC}" +echo "Running another test to demonstrate retry variability" +echo "" +make_request "POST" "$BASE_URL/authorize?amount=250.00" \ + "Valid payment amount (250.00) - testing retry behavior" + +# Test 3: Invalid payment amount (should abort immediately) +echo -e "${BLUE}=== Test 3: Invalid Payment Amount (Abort Condition) ===${NC}" +echo "This test uses an invalid amount which should trigger CriticalPaymentException" +echo "Expected: Immediate failure with no retries" +echo "" +make_request "POST" "$BASE_URL/authorize?amount=0" \ + "Invalid payment amount (0) - should abort immediately with CriticalPaymentException" + +# Test 4: Negative amount (should abort immediately) +echo -e "${BLUE}=== Test 4: Negative Payment Amount ===${NC}" +echo "Expected: Immediate failure with no retries" +echo "" +make_request "POST" "$BASE_URL/authorize?amount=-50" \ + "Negative payment amount (-50) - should abort immediately" + +# Test 5: No amount parameter (should abort immediately) +echo -e "${BLUE}=== Test 5: Missing Payment Amount ===${NC}" +echo "Expected: Immediate failure with no retries" +echo "" +make_request "POST" "$BASE_URL/authorize" \ + "Missing payment amount - should abort immediately" + +# Test 6: Multiple requests to observe retry patterns +echo -e "${BLUE}=== Test 6: Multiple Requests to Observe Retry Patterns ===${NC}" +echo "Running 5 valid payment requests to observe retry behavior patterns" +echo "" + +for i in {1..5}; do + echo "Request $i/5:" + amount=$((100 + i * 25)) + make_request "POST" "$BASE_URL/authorize?amount=$amount" \ + "Payment request $i with amount $amount" + + # Small delay between requests + sleep 2 +done + +echo -e "${GREEN}=== Retry Testing Complete ===${NC}" +echo "" +echo "Key observations to look for:" +echo -e "• ${GREEN}Successful requests:${NC} Should complete in ~1.5-12 seconds depending on retries" +echo -e "• ${YELLOW}Retry behavior:${NC} Failed attempts will retry up to 3 times with 2-2.5 second delays" +echo -e "• ${RED}Abort conditions:${NC} Invalid amounts should fail immediately (~1.5 seconds)" +echo -e "• ${BLUE}Random failures:${NC} ~30% of valid requests may need retries" +echo "" +echo "To see detailed retry logs, monitor the server logs:" +echo "tail -f target/liberty/wlp/usr/servers/mpServer/logs/messages.log" +echo "" +echo "Expected timing patterns:" +echo "• Success on 1st attempt: ~1.5 seconds" +echo "• Success on 2nd attempt: ~4-4.5 seconds" +echo "• Success on 3rd attempt: ~6.5-7.5 seconds" +echo "• Success on 4th attempt: ~9-10.5 seconds" +echo "• Abort conditions: ~1.5 seconds (no retries)" diff --git a/code/chapter09/payment/README.adoc b/code/chapter09/payment/README.adoc new file mode 100644 index 0000000..e3b6558 --- /dev/null +++ b/code/chapter09/payment/README.adoc @@ -0,0 +1,943 @@ += Payment Service +:toc: macro +:toclevels: 3 +:icons: font +:source-highlighter: highlight.js +:experimental: + +toc::[] + +This microservice is part of the Jakarta EE 10 and MicroProfile 6.1-based e-commerce application. It handles payment processing and transaction management. + +== Features + +* Payment transaction processing +* Dynamic configuration management via MicroProfile Config +* RESTful API endpoints with JSON support +* Custom ConfigSource implementation +* OpenAPI documentation +* **MicroProfile Fault Tolerance with Retry Policies** +* **Circuit Breaker protection for external services** +* **Fallback mechanisms for service resilience** +* **Bulkhead pattern for concurrency control** +* **Timeout protection for long-running operations** + +== MicroProfile Fault Tolerance Implementation + +The Payment Service implements comprehensive fault tolerance patterns using MicroProfile Fault Tolerance annotations: + +=== Retry Policies + +The service implements different retry strategies based on operation criticality: + +==== Payment Authorization Retry (@Retry) +* **Max Retries**: 3 attempts +* **Delay**: 1000ms with 500ms jitter +* **Max Duration**: 10 seconds +* **Retry On**: RuntimeException, WebApplicationException +* **Use Case**: Standard payment authorization with exponential backoff + +[source,java] +---- +@Retry( + maxRetries = 3, + delay = 2000, + maxDuration = 10000 + jitter = 500, + retryOn = {RuntimeException.class, WebApplicationException.class} +) +---- + +=== Circuit Breaker Protection + +Payment capture operations use circuit breaker pattern: + +[source,java] +---- +@CircuitBreaker( + failureRatio = 0.5, + requestVolumeThreshold = 4, + delay = 5000 +) +---- + +* **Failure Ratio**: 50% failure rate triggers circuit opening +* **Request Volume**: Minimum 4 requests for evaluation +* **Recovery Delay**: 5 seconds before attempting recovery + +=== Timeout Protection + +Operations with potential long delays are protected with timeouts: + +[source,java] +---- +@Timeout(value = 3000) +---- + +=== Bulkhead Pattern + +The bulkhead pattern limits concurrent requests to prevent system overload: + +[source,java] +---- +@Bulkhead(value = 5) +---- + +* **Concurrent Requests**: Limited to 5 concurrent requests +* **Excess Requests**: Rejected immediately instead of queuing +* **Use Case**: Protect service from traffic spikes and cascading failures + +=== Fallback Mechanisms + +All critical operations have fallback methods that provide graceful degradation: + +* **Payment Authorization Fallback**: Returns service unavailable with retry instructions + +== Endpoints + +=== GET /payment/api/payment-config +* Returns all current payment configuration values +* Example: `GET http://localhost:9080/payment/api/payment-config` +* Response: `{"gateway.endpoint":"https://api.paymentgateway.com"}` + +=== POST /payment/api/payment-config +* Updates a payment configuration value +* Example: `POST http://localhost:9080/payment/api/payment-config` +* Request body: `{"key": "payment.gateway.endpoint", "value": "https://new-api.paymentgateway.com"}` +* Response: `{"key":"payment.gateway.endpoint","value":"https://new-api.paymentgateway.com","message":"Configuration updated successfully"}` + +=== POST /payment/api/authorize +* Processes a payment authorization with retry policy +* **Retry Configuration**: 3 attempts, 1s delay, 500ms jitter +* **Fallback**: Service unavailable response +* Example: `POST http://localhost:9080/payment/api/authorize` +* Request body: `{"cardNumber":"4111111111111111", "cardHolderName":"Test User", "expiryDate":"12/25", "securityCode":"123", "amount":100.00}` +* Response: `{"status":"success", "message":"Payment authorized successfully", "transactionId":"TXN1234567890", "amount":100.00}` +* Fallback Response: `{"status":"failed", "message":"Payment gateway unavailable. Please try again later.", "fallback":true}` + +=== POST /payment/api/payment-config/process-example +* Example endpoint demonstrating payment processing with configuration +* Example: `POST http://localhost:9080/payment/api/payment-config/process-example` +* Request body: `{"cardNumber":"4111111111111111", "cardHolderName":"Test User", "expiryDate":"12/25", "securityCode":"123", "amount":100.00}` +* Response: `{"amount":100.00,"message":"Payment processed successfully","status":"success","configUsed":{"gatewayEndpoint":"https://new-api.paymentgateway.com"}}` + +== Building and Running the Service + +=== Prerequisites + +* JDK 17 or higher +* Maven 3.6.0 or higher + +=== Local Development + +[source,bash] +---- +# Build the application +mvn clean package + +# Run the application with Liberty +mvn liberty:run +---- + +The server will start on port 9080 (HTTP) and 9081 (HTTPS). + +=== Docker + +[source,bash] +---- +# Build and run with Docker +./run-docker.sh +---- + +== Project Structure + +* `src/main/java/io/microprofile/tutorial/PaymentRestApplication.java` - Jakarta Restful web service application class +* `src/main/java/io/microprofile/tutorial/store/payment/config/` - Configuration classes +* `src/main/java/io/microprofile/tutorial/store/payment/resource/` - REST resource endpoints +* `src/main/java/io/microprofile/tutorial/store/payment/service/` - Business logic services +* `src/main/java/io/microprofile/tutorial/store/payment/entity/` - Data models +* `src/main/resources/META-INF/services/` - Service provider configuration +* `src/main/liberty/config/` - Liberty server configuration + +== Custom ConfigSource + +The Payment Service implements a custom MicroProfile ConfigSource named `PaymentServiceConfigSource` that provides payment-specific configuration with high priority (ordinal: 600). + +=== Available Configuration Properties + +[cols="1,2,2", options="header"] +|=== +|Property +|Description +|Default Value + +|payment.gateway.endpoint +|Payment gateway endpoint URL +|https://api.paymentgateway.com +|=== + +=== Testing ConfigSource Endpoints + +You can test the ConfigSource endpoints using curl or any REST client: + +[source,bash] +---- +# Get current configuration +curl -s http://localhost:9080/payment/api/payment-config | json_pp + +# Update configuration property +curl -s -X POST -H "Content-Type: application/json" \ + -d '{"key":"payment.gateway.endpoint", "value":"https://new-api.paymentgateway.com"}' \ + http://localhost:9080/payment/api/payment-config | json_pp + +# Test payment processing with the configuration +curl -s -X POST -H "Content-Type: application/json" \ + -d '{"cardNumber":"4111111111111111", "cardHolderName":"Test User", "expiryDate":"12/25", "securityCode":"123", "amount":100.00}' \ + http://localhost:9080/payment/api/payment-config/process-example | json_pp + +# Test basic payment authorization +curl -s -X POST -H "Content-Type: application/json" \ + http://localhost:9080/payment/api/authorize | json_pp +---- + +=== Implementation Details + +The custom ConfigSource is implemented in the following classes: + +* `PaymentServiceConfigSource.java` - Implements the MicroProfile ConfigSource interface +* `PaymentConfig.java` - Utility class for accessing configuration properties + +Example usage in application code: + +[source,java] +---- +// Inject standard MicroProfile Config +@Inject +@ConfigProperty(name="payment.gateway.endpoint") +private String endpoint; + +// Or use the utility class +String gatewayUrl = PaymentConfig.getConfigProperty("payment.gateway.endpoint"); +---- + +The custom ConfigSource provides a higher priority (ordinal: 600) than system properties and environment variables, allowing for service-specific defaults while still enabling override via standard mechanisms. + +=== MicroProfile Config Sources + +MicroProfile Config uses a prioritized set of configuration sources. The payment service uses the following configuration sources in order of priority (highest to lowest): + +1. Custom ConfigSource (`PaymentServiceConfigSource`) - Ordinal: 600 +2. System properties - Ordinal: 400 +3. Environment variables - Ordinal: 300 +4. microprofile-config.properties file - Ordinal: 100 + +==== Updating Configuration Values + +You can update configuration properties through different methods: + +===== 1. Using the REST API (runtime) + +This uses the custom ConfigSource and persists only for the current server session: + +[source,bash] +---- +curl -X POST -H "Content-Type: application/json" \ + -d '{"key":"payment.gateway.endpoint", "value":"https://test-api.paymentgateway.com"}' \ + http://localhost:9080/payment/api/payment-config +---- + +===== 2. Using System Properties (startup) + +[source,bash] +---- +# Linux/macOS +mvn liberty:run -Dpayment.gateway.endpoint=https://sys-api.paymentgateway.com + +# Windows +mvn liberty:run "-Dpayment.gateway.endpoint=https://sys-api.paymentgateway.com" +---- + +===== 3. Using Environment Variables (startup) + +Environment variable names must follow the MicroProfile Config convention (uppercase with underscores): + +[source,bash] +---- +# Linux/macOS +export PAYMENT_GATEWAY_ENDPOINT=https://env-api.paymentgateway.com +mvn liberty:run + +# Windows PowerShell +$env:PAYMENT_GATEWAY_ENDPOINT="https://env-api.paymentgateway.com" +mvn liberty:run + +# Windows CMD +set PAYMENT_GATEWAY_ENDPOINT=https://env-api.paymentgateway.com +mvn liberty:run +---- + +===== 4. Using microprofile-config.properties File + +Edit the file at `src/main/resources/META-INF/microprofile-config.properties`: + +[source,properties] +---- +# Update the endpoint +payment.gateway.endpoint=https://config-api.paymentgateway.com +---- + +Then rebuild and restart the application: + +[source,bash] +---- +mvn clean package liberty:run +---- + +==== Testing Configuration Changes + +After changing a configuration property, you can verify it was updated by calling: + +[source,bash] +---- +curl http://localhost:9080/payment/api/payment-config +---- + +== Documentation + +=== OpenAPI + +The payment service automatically generates OpenAPI documentation using MicroProfile OpenAPI annotations. + +* OpenAPI UI: `http://localhost:9080/payment/api/openapi-ui/` +* OpenAPI JSON: `http://localhost:9080/payment/api/openapi` + +=== MicroProfile Config Specification + +For more information about MicroProfile Config, refer to the official documentation: + +* https://download.eclipse.org/microprofile/microprofile-config-3.1/microprofile-config-spec-3.1.html + +=== Related Resources + +* MicroProfile: https://microprofile.io/ +* Jakarta EE: https://jakarta.ee/ +* Open Liberty: https://openliberty.io/ + +== Troubleshooting + +=== Common Issues + +==== Port Conflicts + +If you encounter a port conflict when starting the server, you can change the ports in the `pom.xml` file: + +[source,xml] +---- +9080 +9081 +---- + +==== ConfigSource Not Loading + +If the custom ConfigSource is not loading, check the following: + +1. Verify the service provider configuration file exists at: + `src/main/resources/META-INF/services/org.eclipse.microprofile.config.spi.ConfigSource` + +2. Ensure it contains the correct fully qualified class name: + `io.microprofile.tutorial.store.payment.config.PaymentServiceConfigSource` + +==== Deployment Errors + +For CWWKZ0004E deployment errors, check the server logs at: +`target/liberty/wlp/usr/servers/mpServer/logs/messages.log` + +== Testing Fault Tolerance Features + +=== Automated Test Scripts + +The Payment Service includes several test scripts to demonstrate and validate fault tolerance features: + +==== test-payment-basic.sh + +Basic functionality test to verify core payment operations: + +* Configuration retrieval +* Simple payment processing +* Error handling + +[source,bash] +---- +# Test basic payment operations +chmod +x test-payment-basic.sh +./test-payment-basic.sh +---- + +==== test-payment-retry.sh +Tests various retry scenarios with different triggers: + +* Normal payment processing (successful) +* Failed payment with retry (card ending in "0000") +* Verification with random failures +* Invalid input handling + +[source,bash] +---- +# Test retry scenarios +chmod +x test-payment-retry.sh +./test-payment-retry.sh +---- + +==== test-payment-concurrent-load.sh + +Tests the service under concurrent load: + +* Multiple simultaneous requests +* Observing thread handling +* Response time analysis + +[source,bash] +---- +# Test service under concurrent load +chmod +x test-payment-concurrent-load.sh +./test-payment-concurrent-load.sh +---- + +==== test-payment-async.sh + +Analyzes asynchronous processing behavior: + +* Response time measurement +* Thread utilization +* Future completion patterns + +[source,bash] +---- +# Analyze asynchronous processing +chmod +x test-payment-async.sh +./test-payment-async.sh +---- + +==== test-payment-bulkhead.sh +Demonstrates the bulkhead pattern by sending concurrent requests: + +* Concurrent request handling +* Bulkhead limit verification (5 requests) +* Rejection of excess requests +* Service recovery after load reduction + +[source,bash] +---- +# Test bulkhead functionality with concurrent requests +chmod +x test-payment-bulkhead.sh +./test-payment-bulkhead.sh +---- + +==== test-payment-async-analysis.sh + +Analyzes asynchronous processing behavior: + +* Response time measurement +* Thread utilization +* Future completion patterns + +[source,bash] +---- +# Analyze asynchronous processing +chmod +x test-payment-async-analysis.sh +./test-payment-async-analysis.sh +---- + +=== Running the Tests + +To run any of these test scripts: + +[source,bash] +---- +# Make the script executable +chmod +x test-payment-bulkhead.sh + +# Run the script +./test-payment-bulkhead.sh +---- + +You can also run all test scripts in sequence: + +[source,bash] +---- +# Run all test scripts +for script in test-payment-*.sh; do + echo "Running $script..." + chmod +x $script + ./$script + echo "----------------------------------------" + sleep 2 +done +---- + +== Configuration Properties + +=== Fault Tolerance Configuration + +The following properties can be configured via MicroProfile Config: + +[cols="1,2,2", options="header"] +|=== +|Property +|Description +|Default Value + +|payment.gateway.endpoint +|Payment gateway endpoint URL +|https://api.paymentgateway.com + +|payment.retry.maxRetries +|Maximum retry attempts for payment operations +|3 + +|payment.retry.delay +|Delay between retry attempts (milliseconds) +|1000 + +|payment.circuitbreaker.failureRatio +|Circuit breaker failure ratio threshold +|0.5 + +|payment.circuitbreaker.requestVolumeThreshold +|Minimum requests for circuit breaker evaluation +|4 + +|payment.timeout.duration +|Timeout duration for payment operations (milliseconds) +|3000 + +|payment.bulkhead.value +|Maximum concurrent requests for bulkhead +|5 +|=== + +== Fault Tolerance Implementation Details + +=== Server Configuration + +The MicroProfile Fault Tolerance feature is enabled in the Liberty server configuration: + +[source,xml] +---- +mpFaultTolerance +---- + +=== Code Implementation + +==== PaymentService Class + +The PaymentService class is annotated with `@ApplicationScoped` to ensure proper fault tolerance behavior: + +[source,java] +---- +@ApplicationScoped +public class PaymentService { + // ... +} +---- + +==== Authorization Method + +[source,java] +---- +@Retry( + maxRetries = 3, + delay = 1000, + jitter = 500, + maxDuration = 10000, + retryOn = {RuntimeException.class, WebApplicationException.class} +) +@Fallback(fallbackMethod = "fallbackPaymentAuthorization") +public PaymentResponse processPayment(PaymentRequest request) { + // Payment processing logic +} + +public PaymentResponse fallbackPaymentAuthorization(PaymentRequest request) { + // Fallback logic for payment authorization + return new PaymentResponse("failed", "Payment gateway unavailable. Please try again later.", true); +} +---- + +=== Key Implementation Benefits + +==== 1. Resilience +- Service continues operating despite external service failures +- Automatic recovery from transient failures +- Protection against cascading failures + +==== 2. User Experience +- Reduced timeout errors through retry mechanisms +- Graceful degradation with meaningful error messages +- Improved service availability + +==== 3. Operational Excellence +- Configurable fault tolerance parameters +- Comprehensive logging and monitoring +- Clear separation of concerns between business logic and resilience + +==== 4. Enterprise Readiness +- Production-ready fault tolerance patterns +- Compliance with microservices best practices +- Integration with MicroProfile ecosystem + +== MicroProfile Fault Tolerance Patterns + +=== Retry Pattern + +The retry pattern allows the service to automatically retry failed operations: + +* **@Retry**: Automatically retries failed operations +* **Parameters**: maxRetries, delay, jitter, maxDuration, retryOn, abortOn +* **Use Case**: Transient failures in external service calls + +=== Circuit Breaker Pattern + +The circuit breaker pattern prevents cascading failures: + +* **@CircuitBreaker**: Tracks failure rates and opens circuit when threshold is reached +* **Parameters**: failureRatio, requestVolumeThreshold, delay +* **States**: Closed (normal), Open (failing), Half-Open (testing recovery) +* **Use Case**: Protect against downstream service failures + +=== Timeout Pattern + +The timeout pattern prevents operations from hanging indefinitely: + +* **@Timeout**: Sets maximum duration for operations +* **Parameters**: value, unit +* **Use Case**: Prevent indefinite waiting for slow external services + +=== Bulkhead Pattern + +The bulkhead pattern limits concurrent requests: + +* **@Bulkhead**: Sets maximum concurrent executions +* **Parameters**: value, waitingTaskQueue (for async) +* **Use Case**: Prevent system overload during traffic spikes + +=== Fallback Pattern + +The fallback pattern provides alternatives when operations fail: + +* **@Fallback**: Specifies alternative method when operation fails +* **Parameters**: fallbackMethod, applyOn, skipOn +* **Use Case**: Graceful degradation for failed operations + +== Fault Tolerance Best Practices + +=== Configuring Retry Policies + +When configuring retry policies, consider these best practices: + +* **Operation Criticality**: Use more aggressive retry policies for critical operations +* **Retry Delay**: Implement exponential backoff for external service calls +* **Jitter**: Add random jitter to prevent thundering herd problems +* **Max Duration**: Set an overall timeout to prevent excessive retries +* **Abort Conditions**: Define specific exceptions that should abort retry attempts + +=== Circuit Breaker Configuration + +For effective circuit breaker implementation: + +* **Failure Ratio**: Set appropriate threshold based on expected error rates (typically 0.3-0.5) +* **Request Volume**: Set minimum request count to prevent premature circuit opening +* **Recovery Delay**: Allow sufficient time for downstream services to recover +* **Monitoring**: Track circuit state transitions for operational visibility + +=== Bulkhead Strategies + +Choose the appropriate bulkhead strategy: + +* **Synchronous Bulkhead**: Limits concurrent executions for thread-constrained systems +* **Asynchronous Bulkhead**: Provides a waiting queue for manageable load spikes +* **Isolation Levels**: Consider using separate bulkheads for different types of operations + +=== Fallback Implementation + +Implement effective fallback mechanisms: + +* **Graceful Degradation**: Return partial results when possible +* **Meaningful Responses**: Provide clear error messages to clients +* **Operation Queuing**: Queue failed operations for later processing +* **Fallback Chain**: Implement multiple fallback levels for critical operations + +=== Combining Fault Tolerance Annotations + +When combining multiple fault tolerance annotations: + +* **Execution Order**: Understand the execution order (Fallback → Retry → CircuitBreaker → Timeout → Bulkhead) +* **Compatibility**: Ensure annotations work together as expected +* **Resource Impact**: Consider the resource impact of combined annotations +* **Testing**: Test all combinations of annotation behaviors + +== Troubleshooting Fault Tolerance Issues + +=== Common Fault Tolerance Issues + +==== 1. Ineffective Retry Policies + +**Symptoms**: +* Operations fail without retrying +* Excessive retries causing performance issues + +**Solutions**: +* Verify exceptions match retryOn parameter +* Check that delay and jitter are appropriate +* Ensure maxDuration allows sufficient time for retries + +==== 2. Circuit Breaker Problems + +**Symptoms**: +* Circuit opens too frequently +* Circuit never opens despite failures +* Circuit remains open indefinitely + +**Solutions**: +* Adjust failureRatio based on expected error rates +* Increase requestVolumeThreshold if premature opening occurs +* Verify that delay allows sufficient recovery time +* Ensure exceptions are properly handled + +==== 3. Timeout Issues + +**Symptoms**: +* Operations timeout too quickly +* Timeouts not triggering as expected + +**Solutions**: +* Adjust timeout duration based on operation complexity +* Ensure timeout is shorter than upstream timeouts +* Verify that timeout unit is properly specified + +==== 4. Bulkhead Restrictions + +**Symptoms**: +* Too many rejections during normal load +* Service overloaded despite bulkhead + +**Solutions**: +* Adjust bulkhead value based on resource capacity +* Consider using asynchronous bulkheads with waiting queue +* Implement client-side load balancing for better distribution + +==== 5. Fallback Failures + +**Symptoms**: +* Fallbacks not triggering despite failures +* Fallbacks throwing unexpected exceptions + +**Solutions**: +* Verify fallback method signature matches original method +* Ensure fallback method handles exceptions properly +* Check that fallback logic is fully tested + +=== Diagnosing with Metrics + +MicroProfile Metrics provides valuable insight into fault tolerance behavior: + +[source,bash] +---- +# Total number of retry attempts +curl https://localhost:9080/metrics?name=ft_retry_retries_total + +# Bulkhead calls total +curl http://localhost:9080/metrics?name=ft_bulkhead_calls_total + +# Timeout execution duration +curl http://localhost:9080/payment/metrics/application?name=ft_timeout_executionDuration_nanoseconds +---- + +=== Server Log Analysis + +Liberty server logs provide detailed information about fault tolerance operations: + +[source,bash] +---- +tail -f target/liberty/wlp/usr/servers/mpServer/logs/messages.log | grep -E "Retry|CircuitBreaker|Timeout|Bulkhead|Fallback" +---- + +Look for messages indicating: +* Retry attempts and success/failure +* Circuit breaker state transitions +* Timeout exceptions +* Bulkhead rejections +* Fallback method invocations + +== Resources and References + +=== MicroProfile Fault Tolerance Specification + +For detailed information about MicroProfile Fault Tolerance, refer to: + +* https://download.eclipse.org/microprofile/microprofile-fault-tolerance-4.0/microprofile-fault-tolerance-spec-4.0.html + +=== API Documentation + +* https://download.eclipse.org/microprofile/microprofile-fault-tolerance-4.0/apidocs/ + +=== Fault Tolerance Guides + +* https://openliberty.io/guides/microprofile-fallback.html +* https://openliberty.io/guides/retry-timeout.html +* https://openliberty.io/guides/circuit-breaker.html +* https://openliberty.io/guides/bulkhead.html + +=== Best Practices Resources + +* https://microprofile.io/ +* https://www.ibm.com/docs/en/was-liberty/base?topic=liberty-microprofile-fault-tolerance + +== MicroProfile Telemetry Implementation + +The Payment Service implements distributed tracing using MicroProfile Telemetry 1.1, which is based on OpenTelemetry standards. This enables end-to-end visibility of payment transactions across microservices and external dependencies. + +=== Telemetry Configuration + +The service is configured to send telemetry data to Jaeger, enabling comprehensive transaction monitoring: + +==== Application Configuration (microprofile-config.properties) + +[source,properties] +---- +# MicroProfile Telemetry Configuration +otel.service.name=payment-service +otel.sdk.disabled=false +otel.metrics.exporter=none +otel.logs.exporter=none +---- + +=== Automatic Instrumentation + +MicroProfile Telemetry provides automatic instrumentation for: + +* Jakarta Restful Web Services endpoints (inbound and outbound HTTP requests) +* CDI method invocations +* MicroProfile Rest Client calls + +This enables tracing without modifying application code, capturing: + +* HTTP request information (method, URL, status code) +* Transaction timing and duration +* Service dependencies and call hierarchy + +=== Manual Instrumentation + +For enhanced visibility, the Payment Service also implements manual instrumentation: + +[source,java] +---- +private Tracer tracer; // Injected tracer for OpenTelemetry + +@PostConstruct +public void init() { + // Programmatic tracer access - the correct approach + this.tracer = GlobalOpenTelemetry.getTracer("payment-service", "1.0.0"); + logger.info("Tracer initialized successfully"); +} + +// Create explicit span with business context +Span span = tracer.spanBuilder("payment.process") + .setAttribute("payment.amount", paymentDetails.getAmount().toString()) + .setAttribute("payment.method", "credit_card") + .setAttribute("payment.service", "payment-service") + .startSpan(); + +try (io.opentelemetry.context.Scope scope = span.makeCurrent()) { + // Business logic here + span.addEvent("Starting payment processing"); + + // Add result information + span.setStatus(StatusCode.OK); +} catch (Exception e) { + // Record error details + span.recordException(e); + span.setStatus(StatusCode.ERROR, e.getMessage()); + throw e; +} finally { + span.end(); // Always end the span +} +---- + +=== Key Telemetry Points + +The service captures telemetry at critical transaction points: + +1. **Payment Authorization**: Complete trace of payment authorization flow +2. **Payment Verification**: Detailed verification steps with fraud check results +3. **External Service Calls**: Timing of gateway communications +4. **Retry Operations**: Visibility into retry attempts and fallbacks +5. **Error Handling**: Detailed error context and fault tolerance behavior + +=== Business Context Enrichment + +Traces are enriched with business context to enable business-oriented analysis: + +* **Payment Amounts**: Track transaction values for business insights +* **Payment Methods**: Categorize by payment method for pattern analysis +* **Transaction IDs**: Correlate with order management systems +* **Processing Time**: Measure critical business SLAs +* **Error Categories**: Classify errors for targeted improvements + +=== Viewing Telemetry Data + +Telemetry data can be viewed in Jaeger UI: + +[source,bash] +---- +# Start Jaeger container (if not already running) +docker run --rm --name jaeger \ + -p 16686:16686 \ + -p 4317:4317 \ + -p 4318:4318 \ + -p 5778:5778 \ + -p 9411:9411 \ + jaegertracing/jaeger:2.7.0 + +# Access Jaeger UI +open http://localhost:16686 +---- + +In the Jaeger UI: +1. Select "payment-service" from the Service dropdown +2. Choose an operation or search by transaction attributes +3. Explore the full transaction trace across services + +=== Troubleshooting Telemetry + +If telemetry data is not appearing in Jaeger: + +1. **Verify Jaeger is running** with OTLP ports exposed (4317, 4318) +2. **Check Liberty server configuration** in server.xml +3. **Validate application configuration** in microprofile-config.properties +4. **Ensure trace application is enabled** with `` +5. **Check network connectivity** between the service and Jaeger +6. **Inspect Liberty server logs** for telemetry-related messages + +=== Testing Telemetry + +To generate and verify telemetry data: + +[source,bash] +---- +# Generate sample telemetry with payment request +curl -X POST -H "Content-Type: application/json" \ + -d '{"cardNumber":"4111-1111-1111-1111", "cardHolderName":"Test User", "expiryDate":"12/25", "securityCode":"123", "amount":75.50}' \ + http://localhost:9080/payment/api/payments + +# Check for payment service in Jaeger UI services dropdown +curl -s http://localhost:16686/api/services +---- + +=== Benefits of Telemetry Implementation + +1. **End-to-End Transaction Visibility**: Follow payment flows across services +2. **Performance Monitoring**: Identify bottlenecks and optimization opportunities +3. **Error Detection**: Quickly locate and diagnose failures +4. **Dependency Analysis**: Understand service dependencies and impacts +5. **Business Insights**: Correlate technical metrics with business outcomes +6. **Operational Excellence**: Improve MTTR and system reliability \ No newline at end of file diff --git a/code/chapter09/payment/pom.xml b/code/chapter09/payment/pom.xml new file mode 100644 index 0000000..65a1c5b --- /dev/null +++ b/code/chapter09/payment/pom.xml @@ -0,0 +1,93 @@ + + + + 4.0.0 + + io.microprofile.tutorial + payment + 1.0-SNAPSHOT + war + + + + UTF-8 + 17 + 17 + + UTF-8 + UTF-8 + + + 9080 + 9081 + + payment + + + + + + + + + org.projectlombok + lombok + 1.18.26 + provided + + + + + jakarta.platform + jakarta.jakartaee-api + 10.0.0 + provided + + + + + org.eclipse.microprofile + microprofile + 6.1 + pom + provided + + + + + io.opentelemetry + opentelemetry-api + 1.32.0 + compile + + + + junit + junit + 4.11 + test + + + + + ${project.artifactId} + + + + io.openliberty.tools + liberty-maven-plugin + 3.11.2 + + mpServer + + + + + org.apache.maven.plugins + maven-war-plugin + 3.4.0 + + + + \ No newline at end of file diff --git a/code/chapter09/payment/src/main/java/io/microprofile/tutorial/store/payment/PaymentRestApplication.java b/code/chapter09/payment/src/main/java/io/microprofile/tutorial/store/payment/PaymentRestApplication.java new file mode 100644 index 0000000..bbdcf96 --- /dev/null +++ b/code/chapter09/payment/src/main/java/io/microprofile/tutorial/store/payment/PaymentRestApplication.java @@ -0,0 +1,9 @@ +package io.microprofile.tutorial.store.payment; + +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/chapter09/payment/src/main/java/io/microprofile/tutorial/store/payment/WebAppConfig.java b/code/chapter09/payment/src/main/java/io/microprofile/tutorial/store/payment/WebAppConfig.java new file mode 100644 index 0000000..0bbe20b --- /dev/null +++ b/code/chapter09/payment/src/main/java/io/microprofile/tutorial/store/payment/WebAppConfig.java @@ -0,0 +1,14 @@ +package io.microprofile.tutorial.store.payment; + +import jakarta.enterprise.context.ApplicationScoped; + +/** + * Configuration class for the payment application. + * This class configures CDI beans and other application-wide settings. + */ +@ApplicationScoped +public class WebAppConfig { + + // You can add CDI producer methods here if needed + +} diff --git a/code/chapter09/payment/src/main/java/io/microprofile/tutorial/store/payment/config/PaymentConfig.java b/code/chapter09/payment/src/main/java/io/microprofile/tutorial/store/payment/config/PaymentConfig.java new file mode 100644 index 0000000..c4df4d6 --- /dev/null +++ b/code/chapter09/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/chapter09/payment/src/main/java/io/microprofile/tutorial/store/payment/config/PaymentServiceConfigSource.java b/code/chapter09/payment/src/main/java/io/microprofile/tutorial/store/payment/config/PaymentServiceConfigSource.java new file mode 100644 index 0000000..1a5609d --- /dev/null +++ b/code/chapter09/payment/src/main/java/io/microprofile/tutorial/store/payment/config/PaymentServiceConfigSource.java @@ -0,0 +1,67 @@ +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"); + + // Fault Tolerance Configuration + properties.put("payment.retry.maxRetries", "3"); + properties.put("payment.retry.delay", "2000"); + properties.put("payment.circuitbreaker.failureRatio", "0.5"); + properties.put("payment.circuitbreaker.requestVolumeThreshold", "4"); + properties.put("payment.timeout.duration", "3000"); + } + + @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/chapter09/payment/src/main/java/io/microprofile/tutorial/store/payment/entity/PaymentDetails.java b/code/chapter09/payment/src/main/java/io/microprofile/tutorial/store/payment/entity/PaymentDetails.java new file mode 100644 index 0000000..6474223 --- /dev/null +++ b/code/chapter09/payment/src/main/java/io/microprofile/tutorial/store/payment/entity/PaymentDetails.java @@ -0,0 +1,62 @@ +package io.microprofile.tutorial.store.payment.entity; + +import java.math.BigDecimal; + +public class PaymentDetails { + private String cardNumber; + private String cardHolderName; + private String expiryDate; // Format MM/YY + private String securityCode; + private BigDecimal amount; + + public PaymentDetails() { + } + + public PaymentDetails(String cardNumber, String cardHolderName, String expiryDate, String securityCode, BigDecimal amount) { + this.cardNumber = cardNumber; + this.cardHolderName = cardHolderName; + this.expiryDate = expiryDate; + this.securityCode = securityCode; + this.amount = amount; + } + + public String getCardNumber() { + return cardNumber; + } + + public void setCardNumber(String cardNumber) { + this.cardNumber = cardNumber; + } + + public String getCardHolderName() { + return cardHolderName; + } + + public void setCardHolderName(String cardHolderName) { + this.cardHolderName = cardHolderName; + } + + public String getExpiryDate() { + return expiryDate; + } + + public void setExpiryDate(String expiryDate) { + this.expiryDate = expiryDate; + } + + public String getSecurityCode() { + return securityCode; + } + + public void setSecurityCode(String securityCode) { + this.securityCode = securityCode; + } + + public BigDecimal getAmount() { + return amount; + } + + public void setAmount(BigDecimal amount) { + this.amount = amount; + } +} diff --git a/code/chapter09/payment/src/main/java/io/microprofile/tutorial/store/payment/exception/CriticalPaymentException.java b/code/chapter09/payment/src/main/java/io/microprofile/tutorial/store/payment/exception/CriticalPaymentException.java new file mode 100644 index 0000000..cb7157a --- /dev/null +++ b/code/chapter09/payment/src/main/java/io/microprofile/tutorial/store/payment/exception/CriticalPaymentException.java @@ -0,0 +1,7 @@ +package io.microprofile.tutorial.store.payment.exception; + +public class CriticalPaymentException extends Exception { + public CriticalPaymentException(String message) { + super(message); + } +} diff --git a/code/chapter09/payment/src/main/java/io/microprofile/tutorial/store/payment/exception/PaymentProcessingException.java b/code/chapter09/payment/src/main/java/io/microprofile/tutorial/store/payment/exception/PaymentProcessingException.java new file mode 100644 index 0000000..5a72d4b --- /dev/null +++ b/code/chapter09/payment/src/main/java/io/microprofile/tutorial/store/payment/exception/PaymentProcessingException.java @@ -0,0 +1,7 @@ +package io.microprofile.tutorial.store.payment.exception; + +public class PaymentProcessingException extends Exception { + public PaymentProcessingException(String message) { + super(message); + } +} diff --git a/code/chapter09/payment/src/main/java/io/microprofile/tutorial/store/payment/resource/PaymentConfigResource.java b/code/chapter09/payment/src/main/java/io/microprofile/tutorial/store/payment/resource/PaymentConfigResource.java new file mode 100644 index 0000000..6a4002f --- /dev/null +++ b/code/chapter09/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/chapter09/payment/src/main/java/io/microprofile/tutorial/store/payment/resource/PaymentResource.java b/code/chapter09/payment/src/main/java/io/microprofile/tutorial/store/payment/resource/PaymentResource.java new file mode 100644 index 0000000..ae9258f --- /dev/null +++ b/code/chapter09/payment/src/main/java/io/microprofile/tutorial/store/payment/resource/PaymentResource.java @@ -0,0 +1,134 @@ +package io.microprofile.tutorial.store.payment.resource; + +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 io.microprofile.tutorial.store.payment.entity.PaymentDetails; +import io.microprofile.tutorial.store.payment.exception.CriticalPaymentException; +import io.microprofile.tutorial.store.payment.exception.PaymentProcessingException; +import io.microprofile.tutorial.store.payment.service.PaymentService; +import jakarta.enterprise.context.RequestScoped; +import jakarta.inject.Inject; +import jakarta.ws.rs.POST; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.Produces; +import jakarta.ws.rs.QueryParam; +import jakarta.ws.rs.Consumes; +import jakarta.ws.rs.core.MediaType; +import jakarta.ws.rs.core.Response; + +import java.math.BigDecimal; +import java.util.concurrent.CompletionStage; +import java.util.UUID; + +@RequestScoped +@Path("/") +public class PaymentResource { + + @Inject + @ConfigProperty(name = "payment.gateway.endpoint") + private String endpoint; + + @Inject + private PaymentService paymentService; + + @POST + @Path("/authorize") + @Produces(MediaType.APPLICATION_JSON) + @Operation(summary = "Process payment", description = "Process payment using the payment gateway API with fault tolerance") + @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(@QueryParam("amount") Double amount) + throws PaymentProcessingException, CriticalPaymentException { + + // Input validation + if (amount == null || amount <= 0) { + throw new CriticalPaymentException("Invalid payment amount: " + amount); + } + + try { + // Create PaymentDetails using constructor + PaymentDetails paymentDetails = new PaymentDetails( + "****-****-****-1111", // cardNumber - placeholder for demo + "Demo User", // cardHolderName + "12/25", // expiryDate + "***", // securityCode + BigDecimal.valueOf(amount) // amount + ); + + // Use PaymentService with full fault tolerance features + CompletionStage result = paymentService.processPayment(paymentDetails); + + // Wait for async result (in production, consider different patterns) + String paymentResult = result.toCompletableFuture().get(); + + return Response.ok(paymentResult, MediaType.APPLICATION_JSON).build(); + + } catch (PaymentProcessingException e) { + // Re-throw to let fault tolerance handle it + throw e; + } catch (Exception e) { + // Handle other exceptions + throw new PaymentProcessingException("Payment processing failed: " + e.getMessage()); + } + } + + @POST + @Path("/payments") + @Consumes(MediaType.APPLICATION_JSON) + @Produces(MediaType.APPLICATION_JSON) + @Operation(summary = "Process payment with full details", description = "Process payment with comprehensive telemetry tracing") + @APIResponses(value = { + @APIResponse(responseCode = "200", description = "Payment processed successfully"), + @APIResponse(responseCode = "400", description = "Invalid payment details"), + @APIResponse(responseCode = "500", description = "Payment processing failed") + }) + public Response processPaymentWithDetails(PaymentDetails paymentDetails) + throws PaymentProcessingException { + + try { + // Use PaymentService with full fault tolerance and telemetry + CompletionStage result = paymentService.processPayment(paymentDetails); + String paymentResult = result.toCompletableFuture().get(); + + return Response.ok(paymentResult, MediaType.APPLICATION_JSON).build(); + + } catch (Exception e) { + throw new PaymentProcessingException("Payment processing failed: " + e.getMessage()); + } + } + + @POST + @Path("/verify") + @Consumes(MediaType.APPLICATION_JSON) + @Produces(MediaType.APPLICATION_JSON) + @Operation(summary = "Verify payment with telemetry", description = "Comprehensive payment verification with distributed tracing") + @APIResponses(value = { + @APIResponse(responseCode = "200", description = "Payment verified successfully"), + @APIResponse(responseCode = "400", description = "Payment verification failed"), + @APIResponse(responseCode = "500", description = "Verification process error") + }) + public Response verifyPaymentWithTelemetry(PaymentDetails paymentDetails) + throws PaymentProcessingException { + + try { + // Generate a unique transaction ID for this verification + String transactionId = "TXN-" + UUID.randomUUID().toString().substring(0, 8).toUpperCase(); + + // Use the new telemetry-enabled verification method + CompletionStage result = paymentService.verifyPaymentWithTelemetry(paymentDetails, transactionId); + String verificationResult = result.toCompletableFuture().get(); + + return Response.ok(verificationResult, MediaType.APPLICATION_JSON).build(); + + } catch (Exception e) { + throw new PaymentProcessingException("Payment verification failed: " + e.getMessage()); + } + } + +} diff --git a/code/chapter09/payment/src/main/java/io/microprofile/tutorial/store/payment/service/PaymentService.java b/code/chapter09/payment/src/main/java/io/microprofile/tutorial/store/payment/service/PaymentService.java new file mode 100644 index 0000000..da3ed2b --- /dev/null +++ b/code/chapter09/payment/src/main/java/io/microprofile/tutorial/store/payment/service/PaymentService.java @@ -0,0 +1,263 @@ +package io.microprofile.tutorial.store.payment.service; + +import io.microprofile.tutorial.store.payment.exception.PaymentProcessingException; +import io.opentelemetry.api.GlobalOpenTelemetry; +import io.opentelemetry.api.trace.Tracer; +import io.opentelemetry.api.trace.Span; +import io.microprofile.tutorial.store.payment.entity.PaymentDetails; +import io.microprofile.tutorial.store.payment.exception.CriticalPaymentException; + +import org.eclipse.microprofile.faulttolerance.Asynchronous; +import org.eclipse.microprofile.faulttolerance.Bulkhead; +import org.eclipse.microprofile.faulttolerance.Fallback; +import org.eclipse.microprofile.faulttolerance.Retry; +import org.eclipse.microprofile.faulttolerance.Timeout; + +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.annotation.PostConstruct; + + +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionStage; +import java.util.logging.Logger; + +@ApplicationScoped +public class PaymentService { + + private static final Logger logger = Logger.getLogger(PaymentService.class.getName()); + + private Tracer tracer; // Injected tracer for OpenTelemetry + + @PostConstruct + public void init() { + // Programmatic tracer access - the correct approach + this.tracer = GlobalOpenTelemetry.getTracer("payment-service", "1.0.0"); + logger.info("Tracer initialized successfully"); + } + + /** + * Process the payment request with automatic tracing via MicroProfile Telemetry. + * The mpTelemetry feature automatically creates spans for this method. + * + * @param paymentDetails details of the payment + * @return response message indicating success or failure + * @throws PaymentProcessingException if a transient issue occurs + */ + @Asynchronous + @Timeout(3000) + @Retry(maxRetries = 3, delay = 2000, jitter = 500, retryOn = PaymentProcessingException.class, abortOn = CriticalPaymentException.class) + @Fallback(fallbackMethod = "fallbackProcessPayment") + @Bulkhead(value=5) + public CompletionStage processPayment(PaymentDetails paymentDetails) throws PaymentProcessingException { + // Create explicit span for payment processing to help with debugging + Span span = tracer.spanBuilder("payment.process") + .setAttribute("payment.amount", paymentDetails.getAmount().toString()) + .setAttribute("payment.method", "credit_card") + .setAttribute("payment.service", "payment-service") + .startSpan(); + + try (io.opentelemetry.context.Scope scope = span.makeCurrent()) { + // MicroProfile Telemetry automatically traces this method + String maskedCardNumber = maskCardNumber(paymentDetails.getCardNumber()); + + logger.info(String.format("Processing payment - Amount: %s, Card: %s", + paymentDetails.getAmount(), maskedCardNumber)); + + span.addEvent("Starting payment processing"); + simulateDelay(); + + // Simulating a transient failure + if (Math.random() > 0.7) { + span.setStatus(io.opentelemetry.api.trace.StatusCode.ERROR, "Payment processing failed"); + span.addEvent("Payment processing failed due to transient error"); + logger.warning("Payment processing failed due to transient error"); + throw new PaymentProcessingException("Temporary payment processing failure"); + } + + // Simulating successful processing + span.setStatus(io.opentelemetry.api.trace.StatusCode.OK); + span.addEvent("Payment processed successfully"); + logger.info("Payment processed successfully"); + return CompletableFuture.completedFuture("{\"status\":\"success\", \"message\":\"Payment processed successfully.\"}"); + } finally { + span.end(); + } + } + + /** + * Fallback method when payment processing fails. + * Automatically traced by MicroProfile Telemetry. + * + * @param paymentDetails details of the payment + * @return response message for fallback + */ + public CompletionStage fallbackProcessPayment(PaymentDetails paymentDetails) { + logger.warning(() -> String.format("Fallback invoked for payment - Amount: %s", + paymentDetails.getAmount())); + + return CompletableFuture.completedFuture("{\"status\":\"failed\", \"message\":\"Payment service is currently unavailable.\"}"); + } + + /** + * Masks a credit card number for security in logs and traces. + * Only the last 4 digits are shown, all others are replaced with 'X'. + * + * @param cardNumber The full card number + * @return A masked card number (e.g., "XXXXXXXXXXXX1234") + */ + private String maskCardNumber(String cardNumber) { + if (cardNumber == null || cardNumber.length() < 4) { + return "INVALID_CARD"; + } + int visibleDigits = 4; + int length = cardNumber.length(); + + StringBuilder masked = new StringBuilder(); + for (int i = 0; i < length - visibleDigits; i++) { + masked.append('X'); + } + masked.append(cardNumber.substring(length - visibleDigits)); + + return masked.toString(); + } + + /** + * Simulate a delay in processing to demonstrate timeout. + * This method will be automatically traced by MicroProfile Telemetry. + */ + private void simulateDelay() { + try { + logger.fine("Starting payment processing delay simulation"); + Thread.sleep(1500); // Simulated long-running task + logger.fine("Payment processing delay simulation completed"); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + logger.severe("Payment processing interrupted"); + throw new RuntimeException("Processing interrupted"); + } + } + + /** + * Processes a comprehensive payment verification with multiple steps. + * Each method call will be automatically traced by MicroProfile Telemetry. + * + * @param paymentDetails The payment details to verify + * @param transactionId The unique transaction ID + * @return A detailed verification result + * @throws PaymentProcessingException if verification fails + */ + @Asynchronous + public CompletionStage verifyPaymentWithTelemetry(PaymentDetails paymentDetails, String transactionId) + throws PaymentProcessingException { + + logger.info(() -> String.format("Starting payment verification - Transaction ID: %s", transactionId)); + + try { + // Step 1: Validate payment details + validatePaymentDetails(paymentDetails); + + // Step 2: Check for fraud indicators + performFraudCheck(paymentDetails, transactionId); + + // Step 3: Verify funds with bank + verifyFundsAvailability(paymentDetails); + + // Step 4: Record transaction + recordTransaction(paymentDetails, transactionId); + + logger.info("Payment verification completed successfully"); + return CompletableFuture.completedFuture( + String.format("{\"status\":\"verified\", \"transaction_id\":\"%s\", \"message\":\"Payment verification complete.\"}", + transactionId)); + } catch (Exception e) { + logger.severe(() -> String.format("Payment verification failed: %s", e.getMessage())); + throw e; + } + } + + /** + * Validates payment details - automatically traced + */ + private void validatePaymentDetails(PaymentDetails details) throws PaymentProcessingException { + logger.info("Validating payment details"); + + boolean isValid = details.getCardNumber() != null && + details.getCardNumber().length() >= 15 && + details.getExpiryDate() != null && + details.getAmount() != null && + details.getAmount().doubleValue() > 0; + + if (!isValid) { + logger.warning("Payment details validation failed"); + throw new PaymentProcessingException("Payment details validation failed"); + } + + logger.info("Payment details validation successful"); + } + + /** + * Performs fraud check - automatically traced + */ + private void performFraudCheck(PaymentDetails details, String transactionId) throws PaymentProcessingException { + logger.info(() -> String.format("Performing fraud check for transaction: %s", transactionId)); + + // Simulate external service call + simulateNetworkCall(300); + + // Simulate fraud detection (cards ending with "0000" are flagged) + boolean isSafe = !details.getCardNumber().endsWith("0000"); + + if (!isSafe) { + logger.warning("Potential fraud detected"); + throw new PaymentProcessingException("Fraud check failed"); + } + + logger.info("Fraud check passed"); + } + + /** + * Verifies funds availability - automatically traced + */ + private void verifyFundsAvailability(PaymentDetails details) throws PaymentProcessingException { + logger.info(() -> String.format("Verifying funds availability - Amount: %s", details.getAmount())); + + // Simulate banking service call + simulateNetworkCall(500); + + // Simulate funds verification (amounts over 1000 fail) + boolean hasFunds = details.getAmount().doubleValue() <= 1000; + + if (!hasFunds) { + logger.warning("Insufficient funds detected"); + throw new PaymentProcessingException("Insufficient funds"); + } + + logger.info("Sufficient funds verified"); + } + + /** + * Records transaction - automatically traced + */ + private void recordTransaction(PaymentDetails details, String transactionId) { + logger.info(() -> String.format("Recording transaction: %s", transactionId)); + + // Simulate database operation + simulateNetworkCall(200); + + logger.info("Transaction recorded successfully"); + } + + /** + * Simulates network calls or database operations - automatically traced + */ + private void simulateNetworkCall(int milliseconds) { + try { + logger.fine(() -> String.format("Simulating network call - Duration: %dms", milliseconds)); + Thread.sleep(milliseconds); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + logger.severe("Network call interrupted"); + throw new RuntimeException("Network call interrupted"); + } + } +} diff --git a/code/chapter09/payment/src/main/java/io/microprofile/tutorial/store/payment/service/payment.http b/code/chapter09/payment/src/main/java/io/microprofile/tutorial/store/payment/service/payment.http new file mode 100644 index 0000000..98ae2e5 --- /dev/null +++ b/code/chapter09/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/chapter09/payment/src/main/resources/META-INF/microprofile-config.properties b/code/chapter09/payment/src/main/resources/META-INF/microprofile-config.properties new file mode 100644 index 0000000..d41c5ed --- /dev/null +++ b/code/chapter09/payment/src/main/resources/META-INF/microprofile-config.properties @@ -0,0 +1,17 @@ +# microprofile-config.properties +mp.openapi.scan=true +product.maintenanceMode=false + +# Product Service Configuration +payment.gateway.endpoint=https://api.paymentgateway.com/v1 + +# Payment Service Configuration +io.microprofile.tutorial.store.payment.service.PaymentService/processPayment/Retry/maxRetries=3 +io.microprofile.tutorial.store.payment.service.PaymentService/processPayment/Retry/delay=2000 +io.microprofile.tutorial.store.payment.service.PaymentService/processPayment/Retry/jitter=500 + +# MicroProfile Telemetry Configuration +otel.service.name=payment-service +otel.sdk.disabled=false +otel.metrics.exporter=none +otel.logs.exporter=none \ No newline at end of file diff --git a/code/chapter09/payment/src/main/resources/META-INF/services/org.eclipse.microprofile.config.spi.ConfigSource b/code/chapter09/payment/src/main/resources/META-INF/services/org.eclipse.microprofile.config.spi.ConfigSource new file mode 100644 index 0000000..9870717 --- /dev/null +++ b/code/chapter09/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/chapter09/payment/src/main/webapp/WEB-INF/beans.xml b/code/chapter09/payment/src/main/webapp/WEB-INF/beans.xml new file mode 100644 index 0000000..b708636 --- /dev/null +++ b/code/chapter09/payment/src/main/webapp/WEB-INF/beans.xml @@ -0,0 +1,7 @@ + + + diff --git a/code/chapter09/payment/src/main/webapp/WEB-INF/web.xml b/code/chapter09/payment/src/main/webapp/WEB-INF/web.xml new file mode 100644 index 0000000..9e4411b --- /dev/null +++ b/code/chapter09/payment/src/main/webapp/WEB-INF/web.xml @@ -0,0 +1,12 @@ + + + Payment Service + + + index.html + index.jsp + + diff --git a/code/chapter09/payment/src/main/webapp/index.html b/code/chapter09/payment/src/main/webapp/index.html new file mode 100644 index 0000000..f7ba4ad --- /dev/null +++ b/code/chapter09/payment/src/main/webapp/index.html @@ -0,0 +1,268 @@ + + + + + + Payment Service - MicroProfile Config Demo + + + +
+

Payment Service

+

MicroProfile Config & Fault Tolerance Demo

+
+ +
+
+

About this Service

+

The Payment Service demonstrates MicroProfile Config integration with custom ConfigSource implementation and comprehensive Fault Tolerance patterns.

+

It provides endpoints for managing payment configuration and processing payments with retry policies, circuit breakers, and fallback mechanisms.

+

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
  • +
  • MicroProfile Fault Tolerance with Retry Policies
  • +
  • Circuit Breaker protection for external services
  • +
  • Timeout protection and Fallback mechanisms
  • +
  • Bulkhead pattern for concurrency control
  • +
+
+ +
+

API Endpoints

+
    +
  • GET /api/payment-config - Get current payment configuration
  • +
  • POST /api/payment-config - Update payment configuration property
  • +
  • POST /api/authorize - Process payment authorization (with retry)
  • +
  • POST /api/verify - Verify payment transaction (with telemetry tracing)
  • +
  • POST /api/capture - Capture payment (circuit breaker + timeout)
  • +
  • POST /api/refund - Process payment refund (conservative retry)
  • +
  • POST /api/payment-config/process-example - Example payment processing with config
  • +
+
+ +
+

Fault Tolerance Features

+

The Payment Service implements comprehensive fault tolerance patterns:

+
+
+

🔄 Retry Policies

+
    +
  • Authorization: 3 retries, 1s delay
  • +
  • Verification: 5 retries, 500ms delay
  • +
  • Capture: 2 retries, 2s delay
  • +
  • Refund: 1 retry, 3s delay
  • +
+
+
+

⚡ Circuit Breaker

+
    +
  • Failure Ratio: 50%
  • +
  • Request Threshold: 4 requests
  • +
  • Recovery Delay: 5 seconds
  • +
  • Applied to: Payment capture
  • +
+
+
+

⏱️ Timeout Protection

+
    +
  • Capture Timeout: 3 seconds
  • +
  • Max Retry Duration: 10-15 seconds
  • +
  • Jitter: 200-500ms randomization
  • +
+
+
+

🛟 Fallback Mechanisms

+
    +
  • Authorization: Service unavailable
  • +
  • Verification: Queue for retry
  • +
  • Capture: Defer operation
  • +
  • Refund: Manual processing
  • +
+
+
+

🧱 Bulkhead Pattern

+
    +
  • Concurrent Requests: 5 maximum
  • +
  • Excess Requests: Rejected immediately
  • +
  • Recovery: Automatic when load decreases
  • +
  • Applied to: Payment operations
  • +
+
+
+
+ +
+

MicroProfile Telemetry Features

+

The Payment Service implements comprehensive distributed tracing with MicroProfile Telemetry:

+
+
+

🔍 Tracing Architecture

+
    +
  • Implementation: OpenTelemetry API
  • +
  • Exporter: Zipkin integration
  • +
  • Service Name: payment-service
  • +
  • Trace Propagation: W3C format
  • +
+
+
+

📊 Span Hierarchy

+
    +
  • Parent Spans: Payment operations
  • +
  • Child Spans: Validation, fraud, etc.
  • +
  • Propagation: Context propagation
  • +
  • Visualization: Zipkin dashboard
  • +
+
+
+

📝 Span Attributes

+
    +
  • Payment Amounts: Tracked on spans
  • +
  • Transaction IDs: Correlated across spans
  • +
  • Masked Card Data: Secure PII handling
  • +
  • Error States: Detailed error tracking
  • +
+
+
+

⏲️ Performance Metrics

+
    +
  • Operation Timing: Detailed duration tracking
  • +
  • External Calls: Network latency monitoring
  • +
  • Processing Stages: Per-stage timing
  • +
  • Bottleneck Analysis: Visualization support
  • +
+
+
+
+ +
+

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)
  • +
  • Payment Properties: payment.gateway.endpoint, payment.retry.*, payment.circuitbreaker.*, payment.timeout.*, payment.bulkhead.*
  • +
  • Update Method: POST to /api/payment-config with {"key": "payment.property.name", "value": "new-value"}
  • +
+
+ +
+

Testing Fault Tolerance

+

Test the fault tolerance features with these examples:

+
    +
  • Trigger Retries: Use card number ending in "0000" for authorization failures
  • +
  • Circuit Breaker: Make multiple capture requests to trigger circuit opening
  • +
  • Timeouts: Capture operations may timeout randomly for testing
  • +
  • Fallbacks: All operations provide graceful degradation responses
  • +
  • Bulkhead: Generate >5 concurrent requests to see request rejection in action
  • +
+

Monitor logs: tail -f target/liberty/wlp/usr/servers/mpServer/logs/messages.log

+

Run tests: ./test-payment-fault-tolerance-suite.sh or ./test-payment-bulkhead.sh

+
+ +
+

Testing Telemetry

+

Test the MicroProfile Telemetry features with these examples:

+
    +
  • Successful Trace: curl -X POST "http://localhost:9080/payment/api/verify?amount=500&cardNumber=4111111111111111&cardHolder=Jane+Doe&expiryDate=12/25"
  • +
  • Fraud Detection: curl -X POST "http://localhost:9080/payment/api/verify?amount=250&cardNumber=4111111111110000&cardHolder=John+Smith&expiryDate=01/26"
  • +
  • Insufficient Funds: curl -X POST "http://localhost:9080/payment/api/verify?amount=1500&cardNumber=5555555555554444&cardHolder=Alice+Johnson&expiryDate=03/24"
  • +
+

Run test script: ./test-telemetry.sh - This script will run all examples and start a Zipkin container

+

View traces: Open Zipkin at http://localhost:9411/zipkin/ to see distributed traces

+

Analyze traces: Look for parent-child span relationships, error spans, timing information, and custom attributes

+
+ + +
+ +
+

MicroProfile Config, Fault Tolerance & Telemetry Demo | Payment Service

+

Powered by Open Liberty, MicroProfile 6.1 (Config 3.0, Fault Tolerance 4.0, Telemetry 1.1)

+
+ + diff --git a/code/chapter09/payment/src/main/webapp/index.jsp b/code/chapter09/payment/src/main/webapp/index.jsp new file mode 100644 index 0000000..d5de5cb --- /dev/null +++ b/code/chapter09/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/chapter10/order/README.adoc b/code/chapter10/order/README.adoc new file mode 100644 index 0000000..562bb97 --- /dev/null +++ b/code/chapter10/order/README.adoc @@ -0,0 +1,263 @@ += Order Service - MicroProfile E-Commerce Store + +== Overview + +The Order Service is a microservice in the MicroProfile E-Commerce Store application. It provides order management functionality with secure JWT-based authentication and role-based authorization. + +== Features + +* 🔐 *JWT Authentication & Authorization*: Role-based security using MicroProfile JWT +* 📋 *Order Management*: Create, read, update, and delete operations for orders +* 📖 *OpenAPI Documentation*: Comprehensive API documentation with Swagger UI +* 🏗️ *MicroProfile Compliance*: Built with Jakarta EE and MicroProfile standards + +== Technology Stack + +* *Runtime*: Open Liberty +* *Framework*: MicroProfile, Jakarta EE +* *Security*: MicroProfile JWT +* *API*: Jakarta RESTful Web Services, MicroProfile OpenAPI +* *Build*: Maven + +== API Endpoints + +=== Role-Based Secured Endpoints + +==== GET /api/orders/{id} +Returns order information for the specified ID. + +*Security*: Requires valid JWT Bearer token with `user` role + +*Response Example*: +---- +Order for user: user1@example.com, ID: 12345 +---- + +*HTTP Status Codes*: +* `200` - Order information returned successfully +* `401` - Unauthorized - JWT token is missing or invalid +* `403` - Forbidden - User lacks required permissions + +==== DELETE /api/orders/{id} +Deletes an order with the specified ID. + +*Security*: Requires valid JWT Bearer token with `admin` role + +*Response Example*: +---- +Order deleted by admin: admin@example.com, ID: 12345 +---- + +*HTTP Status Codes*: +* `200` - Order deleted successfully +* `401` - Unauthorized - JWT token is missing or invalid +* `403` - Forbidden - User lacks required permissions +* `404` - Not Found - Order does not exist + +== Authentication & Authorization + +=== JWT Token Requirements + +The service expects JWT tokens with the following claims: + +[cols="1,1,3", options="header"] +|=== +|Claim +|Required +|Description + +|`iss` +|Yes +|Issuer - must match `mp.jwt.verify.issuer` configuration + +|`sub` +|Yes +|Subject - unique user identifier + +|`groups` +|Yes +|Array containing user roles ("user" or "admin") + +|`upn` +|Yes +|User Principal Name - used as username + +|`exp` +|Yes +|Expiration timestamp + +|`iat` +|Yes +|Issued at timestamp +|=== + +=== Example JWT Payload + +[source,json] +---- +{ + "iss": "mp-ecomm-store", + "jti": "42", + "sub": "user1", + "upn": "user1@example.com", + "groups": ["user"], + "exp": 1748951611, + "iat": 1748950611 +} +---- + +For admin access: +[source,json] +---- +{ + "iss": "mp-ecomm-store", + "jti": "43", + "sub": "admin1", + "upn": "admin@example.com", + "groups": ["admin", "user"], + "exp": 1748951611, + "iat": 1748950611 +} +---- + +== Configuration + +=== MicroProfile Configuration + +The service uses the following MicroProfile configuration properties: + +[source,properties] +---- +# Enable OpenAPI scanning +mp.openapi.scan=true + +# JWT verification settings +mp.jwt.verify.publickey.location=/META-INF/publicKey.pem +mp.jwt.verify.issuer=mp-ecomm-store + +# OpenAPI UI configuration +mp.openapi.ui.enable=true +---- + +=== Security Configuration + +The service requires: + +1. *Public Key*: RSA public key in PEM format located at `/META-INF/publicKey.pem` +2. *Issuer Validation*: JWT tokens must have matching `iss` claim +3. *Role-Based Access*: Endpoints require specific roles in JWT `groups` claim: + - `/api/orders/{id}` (GET) - requires "user" role + - `/api/orders/{id}` (DELETE) - requires "admin" role + +== Development Setup + +=== Prerequisites + +* Java 17 or higher +* Maven 3.6+ +* Docker (optional, for containerized deployment) + +=== Building the Service + +[source,bash] +---- +# Build the project +mvn clean package + +# Run with Liberty dev mode +mvn liberty:dev +---- + +=== Running with Docker + +The Order Service can be run in a Docker container: + +[source,bash] +---- +# Make the script executable (if needed) +chmod +x run-docker.sh + +# Build and run using Docker +./run-docker.sh +---- + +This script will: +1. Build the application with Maven +2. Create a Docker image with Open Liberty +3. Run the container with ports mapped to host + +Or manually with Docker commands: + +[source,bash] +---- +# Build the application +mvn clean package + +# Build the Docker image +docker build -t mp-ecomm-store/order:latest . + +# Run the container +docker run -d --name order-service -p 8050:8050 -p 8051:8051 mp-ecomm-store/order:latest +---- + +=== Testing with JWT Tokens + +The Order Service uses JWT-based authentication with role-based authorization. To test the endpoints, you'll need valid JWT tokens with the appropriate roles. + +==== Generate JWT Tokens with jwtenizr + +The project includes a `jwtenizr` tool in the `/tools` directory: + +[source,bash] +---- +# Navigate to tools directory +cd tools/ + +# Generate token for user role (default) +java -jar jwtenizr.jar + +# Generate token and test endpoint +java -Dverbose -jar jwtenizr.jar http://localhost:8050/order/api/orders/12345 +---- + +==== Testing Different Roles + +For testing admin-only endpoints, you'll need to modify the JWT token payload to include the "admin" role: + +1. Edit the `jwt-token.json` file in the tools directory +2. Add "admin" to the groups array: `"groups": ["admin", "user"]` +3. Generate a new token: `java -jar jwtenizr.jar` +4. Test the admin endpoint: + [source,bash] + ---- + curl -X DELETE -H "Authorization: Bearer $(cat token.jwt)" \ + http://localhost:8050/order/api/orders/12345 + ---- + +== API Documentation + +The OpenAPI documentation is available at: + +* *OpenAPI Spec*: `http://localhost:8050/order/openapi` +* *Swagger UI*: `http://localhost:8050/order/openapi/ui` + +== Troubleshooting + +=== Common JWT Issues + +* *403 Forbidden*: Verify the JWT token contains the required role in the `groups` claim +* *401 Unauthorized*: Check that the token is valid and hasn't expired +* *Token Validation Errors*: Ensure the issuer (`iss`) matches the configuration + +=== Testing with curl + +[source,bash] +---- +# Test user role endpoint +curl -H "Authorization: Bearer $(cat tools/token.jwt)" \ + http://localhost:8050/order/api/orders/12345 + +# Test admin role endpoint +curl -X DELETE -H "Authorization: Bearer $(cat tools/token.jwt)" \ + http://localhost:8050/order/api/orders/12345 +---- diff --git a/code/chapter10/order/copy-jwt-key.sh b/code/chapter10/order/copy-jwt-key.sh new file mode 100755 index 0000000..9da968e --- /dev/null +++ b/code/chapter10/order/copy-jwt-key.sh @@ -0,0 +1,43 @@ +#!/bin/bash +# Script to copy JWT public key to Liberty server config directory and restart server + +LIBERTY_CONFIG_DIR="/workspaces/liberty-rest-app/order/target/liberty/wlp/usr/servers/orderServer" +PUBLIC_KEY_SOURCE="/workspaces/liberty-rest-app/order/src/main/resources/META-INF/publicKey.pem" + +echo "Copying JWT public key to Liberty server config directory..." + +# Make sure the Liberty server directory exists +if [ ! -d "$LIBERTY_CONFIG_DIR" ]; then + echo "Liberty server directory doesn't exist yet. Building project first..." + cd /workspaces/liberty-rest-app/order + mvn clean package +fi + +# Make sure the target directory exists +mkdir -p "$LIBERTY_CONFIG_DIR" + +# Copy the public key +cp "$PUBLIC_KEY_SOURCE" "$LIBERTY_CONFIG_DIR/publicKey.pem" + +# Verify the key was copied and has correct format +if [ -f "$LIBERTY_CONFIG_DIR/publicKey.pem" ]; then + echo "Public key copied successfully." + + # Display key format to verify it's correct + echo "Verifying key format..." + head -1 "$LIBERTY_CONFIG_DIR/publicKey.pem" + echo "..." + tail -1 "$LIBERTY_CONFIG_DIR/publicKey.pem" +else + echo "Error: Failed to copy public key" + exit 1 +fi + +# Restart the Liberty server +echo "Restarting Liberty server..." +cd /workspaces/liberty-rest-app/order +mvn liberty:stop +mvn liberty:start + +echo "Liberty server restarted. JWT authentication should now work correctly." +echo "You can test it with: ./test-jwt.sh" diff --git a/code/chapter10/order/pom.xml b/code/chapter10/order/pom.xml new file mode 100644 index 0000000..33c424b --- /dev/null +++ b/code/chapter10/order/pom.xml @@ -0,0 +1,181 @@ + + + + 4.0.0 + + io.microprofile + order + 1.0-SNAPSHOT + war + + order-management + https://microprofile.io + + + UTF-8 + 17 + 17 + 10.0.0 + 6.1 + 23.0.0.3 + 1.18.24 + 5.9.3 + 5.4.0 + 5.3.1 + + + + + + jakarta.platform + jakarta.jakartaee-api + ${jakarta.jakartaee-api.version} + provided + + + + org.eclipse.microprofile + microprofile + ${microprofile.version} + pom + provided + + + + org.projectlombok + lombok + ${lombok.version} + provided + + + + + + org.junit.jupiter + junit-jupiter-api + ${junit.version} + test + + + + + org.junit.jupiter + junit-jupiter-engine + ${junit.version} + test + + + + + org.junit.jupiter + junit-jupiter-params + ${junit.version} + test + + + + + org.mockito + mockito-core + ${mockito.version} + test + + + + + org.mockito + mockito-junit-jupiter + ${mockito.version} + test + + + + + io.rest-assured + rest-assured + ${restassured.version} + test + + + + + order + + + + io.openliberty.tools + liberty-maven-plugin + 3.11.3 + + orderServer + runnable + 120 + + /order + + + + + + + + + maven-clean-plugin + 3.1.0 + + + + maven-resources-plugin + 3.0.2 + + + maven-compiler-plugin + 3.8.0 + + + + org.projectlombok + lombok + ${lombok.version} + + + + + + maven-surefire-plugin + 2.22.1 + + + **/*Test.java + + + + + maven-war-plugin + 3.3.2 + + false + + + + maven-install-plugin + 2.5.2 + + + maven-deploy-plugin + 2.8.2 + + + + maven-site-plugin + 3.7.1 + + + maven-project-info-reports-plugin + 3.0.0 + + + + + diff --git a/code/chapter10/order/src/main/java/io/microprofile/tutorial/store/order/OrderApplication.java b/code/chapter10/order/src/main/java/io/microprofile/tutorial/store/order/OrderApplication.java new file mode 100644 index 0000000..0ee7e4f --- /dev/null +++ b/code/chapter10/order/src/main/java/io/microprofile/tutorial/store/order/OrderApplication.java @@ -0,0 +1,41 @@ +package io.microprofile.tutorial.store.order; + +import jakarta.ws.rs.ApplicationPath; +import jakarta.ws.rs.core.Application; + +import org.eclipse.microprofile.auth.LoginConfig; +import org.eclipse.microprofile.openapi.annotations.OpenAPIDefinition; +import org.eclipse.microprofile.openapi.annotations.info.Info; +import org.eclipse.microprofile.openapi.annotations.enums.SecuritySchemeType; +import org.eclipse.microprofile.openapi.annotations.security.SecurityScheme; +import org.eclipse.microprofile.openapi.annotations.servers.Server; + +/** + * JAX-RS Application class that defines the base path for all REST endpoints. + * Also contains OpenAPI annotations for API documentation and JWT security scheme. + */ +@ApplicationPath("/api") +@OpenAPIDefinition( + info = @Info( + title = "Order Management Service", + version = "1.0.0", + description = "A microservice for managing orders in the MicroProfile E-Commerce Store. " + + "Provides comprehensive order management including creation, updates, status tracking, " + + "and customer order retrieval with full CRUD operations." + ), + servers = { + @Server(url = "/order", description = "Order Management Service") + } +) +@LoginConfig(authMethod = "MP-JWT") +@SecurityScheme( + securitySchemeName = "jwt", + type = SecuritySchemeType.HTTP, + scheme = "bearer", + bearerFormat = "JWT", + description = "JWT authentication with bearer token" +) +public class OrderApplication extends Application { + // No additional configuration is needed here + // JAX-RS will automatically discover and register resources +} diff --git a/code/chapter10/order/src/main/java/io/microprofile/tutorial/store/order/entity/Order.java b/code/chapter10/order/src/main/java/io/microprofile/tutorial/store/order/entity/Order.java new file mode 100644 index 0000000..60fe02f --- /dev/null +++ b/code/chapter10/order/src/main/java/io/microprofile/tutorial/store/order/entity/Order.java @@ -0,0 +1,187 @@ +package io.microprofile.tutorial.store.order.entity; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; +import jakarta.json.bind.annotation.JsonbPropertyOrder; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Positive; +import jakarta.validation.constraints.PositiveOrZero; +import jakarta.validation.constraints.Size; +import java.math.BigDecimal; +import java.time.LocalDateTime; +import java.util.List; + +/** + * Order entity representing an order in the e-commerce system. + * This class uses Lombok annotations for boilerplate code generation. + */ +@Data +@NoArgsConstructor +@AllArgsConstructor +@JsonbPropertyOrder({"id", "customerId", "customerEmail", "status", "totalAmount", "currency", "items", "shippingAddress", "billingAddress", "orderDate", "lastModified"}) +public class Order { + + /** + * Unique identifier for the order + */ + private Long id; + + /** + * Customer identifier who placed the order + */ + @NotBlank(message = "Customer ID cannot be blank") + private String customerId; + + /** + * Customer email address + */ + @NotBlank(message = "Customer email cannot be blank") + private String customerEmail; + + /** + * Current status of the order + */ + @NotNull(message = "Order status cannot be null") + private OrderStatus status; + + /** + * Total amount for the order + */ + @NotNull(message = "Total amount cannot be null") + @PositiveOrZero(message = "Total amount must be zero or positive") + private BigDecimal totalAmount; + + /** + * Currency code (e.g., USD, EUR) + */ + @NotBlank(message = "Currency cannot be blank") + @Size(min = 3, max = 3, message = "Currency must be exactly 3 characters") + private String currency; + + /** + * List of items in the order + */ + @NotNull(message = "Order items cannot be null") + @Size(min = 1, message = "Order must contain at least one item") + private List items; + + /** + * Shipping address for the order + */ + @NotNull(message = "Shipping address cannot be null") + private Address shippingAddress; + + /** + * Billing address for the order (can be same as shipping) + */ + @NotNull(message = "Billing address cannot be null") + private Address billingAddress; + + /** + * Date and time when the order was created + */ + private LocalDateTime orderDate; + + /** + * Date and time when the order was last modified + */ + private LocalDateTime lastModified; + + /** + * Order status enumeration + */ + public enum OrderStatus { + PENDING, + CONFIRMED, + PROCESSING, + SHIPPED, + DELIVERED, + CANCELLED, + REFUNDED + } + + /** + * Order item representing a product in the order + */ + @Data + @NoArgsConstructor + @AllArgsConstructor + @JsonbPropertyOrder({"productId", "productName", "quantity", "unitPrice", "totalPrice"}) + public static class OrderItem { + + /** + * Product identifier + */ + @NotBlank(message = "Product ID cannot be blank") + private String productId; + + /** + * Product name + */ + @NotBlank(message = "Product name cannot be blank") + private String productName; + + /** + * Quantity ordered + */ + @NotNull(message = "Quantity cannot be null") + @Positive(message = "Quantity must be positive") + private Integer quantity; + + /** + * Unit price of the product + */ + @NotNull(message = "Unit price cannot be null") + @PositiveOrZero(message = "Unit price must be zero or positive") + private BigDecimal unitPrice; + + /** + * Total price for this item (quantity * unitPrice) + */ + @NotNull(message = "Total price cannot be null") + @PositiveOrZero(message = "Total price must be zero or positive") + private BigDecimal totalPrice; + } + + /** + * Address information for shipping/billing + */ + @Data + @NoArgsConstructor + @AllArgsConstructor + @JsonbPropertyOrder({"street", "city", "state", "postalCode", "country"}) + public static class Address { + + /** + * Street address + */ + @NotBlank(message = "Street address cannot be blank") + private String street; + + /** + * City name + */ + @NotBlank(message = "City cannot be blank") + private String city; + + /** + * State or province + */ + @NotBlank(message = "State cannot be blank") + private String state; + + /** + * Postal/ZIP code + */ + @NotBlank(message = "Postal code cannot be blank") + private String postalCode; + + /** + * Country name or code + */ + @NotBlank(message = "Country cannot be blank") + private String country; + } +} diff --git a/code/chapter10/order/src/main/java/io/microprofile/tutorial/store/order/resource/OrderResource.java b/code/chapter10/order/src/main/java/io/microprofile/tutorial/store/order/resource/OrderResource.java new file mode 100644 index 0000000..c2ac4bd --- /dev/null +++ b/code/chapter10/order/src/main/java/io/microprofile/tutorial/store/order/resource/OrderResource.java @@ -0,0 +1,360 @@ +package io.microprofile.tutorial.store.order.resource; + +import io.microprofile.tutorial.store.order.entity.Order; +import io.microprofile.tutorial.store.order.entity.Order.OrderStatus; +import jakarta.annotation.security.RolesAllowed; +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.SecurityContext; +import jakarta.ws.rs.core.UriBuilder; + +import org.eclipse.microprofile.openapi.annotations.Operation; +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.responses.APIResponses; +import org.eclipse.microprofile.openapi.annotations.security.SecurityRequirement; +import org.eclipse.microprofile.openapi.annotations.security.SecurityScheme; +import org.eclipse.microprofile.openapi.annotations.tags.Tag; +import org.eclipse.microprofile.openapi.annotations.enums.SchemaType; +import org.eclipse.microprofile.openapi.annotations.enums.SecuritySchemeType; + +import java.time.LocalDateTime; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.OptionalDouble; +import java.util.logging.Logger; +import java.util.stream.Collectors; +import java.math.BigDecimal; + +/** + * REST resource for managing orders in the e-commerce system. + * Provides CRUD operations and order management functionality. + */ +@Path("/orders") +@Tag(name = "Order Management", description = "CRUD operations and order management for the e-commerce store") +@Produces(MediaType.APPLICATION_JSON) +@Consumes(MediaType.APPLICATION_JSON) +@SecurityScheme( + securitySchemeName = "jwt", + type = SecuritySchemeType.HTTP, + scheme = "bearer", + bearerFormat = "JWT", + description = "JWT authentication with bearer token" +) +public class OrderResource { + + private static final Logger LOGGER = Logger.getLogger(OrderResource.class.getName()); + + // AtomicLong for generating unique order IDs + private static final java.util.concurrent.atomic.AtomicLong idGenerator = new java.util.concurrent.atomic.AtomicLong(1); + + // In-memory store for orders + private final java.util.Map orders = new java.util.concurrent.ConcurrentHashMap<>(); + + /** + * Get order by ID - Accessible only to users with the "user" role + */ + @RolesAllowed("user") // Only users can access this method + @Operation(summary = "Get order by ID", description = "Retrieve a specific order by its ID") + @SecurityRequirement(name = "jwt") + @APIResponses({ + @APIResponse( + responseCode = "200", + description = "Order found", + content = @Content( + mediaType = "application/json", + schema = @Schema(implementation = Order.class) + ) + ), + @APIResponse( + responseCode = "401", + description = "Unauthorized - JWT token is missing or invalid" + ), + @APIResponse(responseCode = "404", description = "Order not found") + }) + @GET + @Path("/{id}") + public Response getOrder( + @PathParam("id") + @Parameter(description = "Order ID") + Long id, + @Context SecurityContext ctx) { + + String user = ctx.getUserPrincipal().getName(); + LOGGER.info("User " + user + " fetching order with ID: " + id); + + // Fetch order from the map + Order order = orders.get(id); + if (order == null) { + LOGGER.warning("Order not found with ID: " + id); + return Response.status(Response.Status.NOT_FOUND) + .entity("{\"error\":\"Order not found with ID: " + id + "\"}") + .build(); + } + + // For a real application, verify that the user is allowed to access this order + // (e.g., it's their own order or they have appropriate permissions) + + return Response.ok(order).build(); + } + + /** + * Delete an order - Accessible only to users with the "admin" role + */ + @DELETE + @Path("/{id}") + @RolesAllowed("admin") // Only admins can access this method + @Operation(summary = "Delete an order", description = "Delete an order by its ID") + @APIResponses({ + @APIResponse(responseCode = "204", description = "Order deleted successfully"), + @APIResponse(responseCode = "404", description = "Order not found") + }) + @SecurityRequirement(name = "jwt") + public Response deleteOrder( + @PathParam("id") + @Parameter(description = "Order ID") + Long id, + @Context SecurityContext ctx) { + + String admin = ctx.getUserPrincipal().getName(); + LOGGER.info("Admin " + admin + " deleting order with ID: " + id); + + // Try to remove the order from the map + Order removedOrder = orders.remove(id); + if (removedOrder != null) { + LOGGER.info("Order deleted successfully with ID: " + id + " by admin: " + admin); + return Response.noContent().build(); + } else { + LOGGER.warning("Order not found with ID: " + id); + return Response.status(Response.Status.NOT_FOUND) + .entity("{\"error\":\"Order not found with ID: " + id + "\"}") + .build(); + } + } + + /** + * Create a new order + */ + @POST + @Operation(summary = "Create a new order", description = "Create a new order in the system") + @APIResponses({ + @APIResponse( + responseCode = "201", + description = "Order created successfully", + content = @Content( + mediaType = "application/json", + schema = @Schema(implementation = Order.class) + ) + ), + @APIResponse(responseCode = "400", description = "Invalid order data") + }) + public Response createOrder(@Valid @NotNull Order order) { + LOGGER.info("Creating new order for customer: " + order.getCustomerId()); + + // Set generated values + Long id = idGenerator.getAndIncrement(); + order.setId(id); + order.setOrderDate(LocalDateTime.now()); + order.setLastModified(LocalDateTime.now()); + + // Set default status if not provided + if (order.getStatus() == null) { + order.setStatus(OrderStatus.PENDING); + } + + orders.put(id, order); + + LOGGER.info("Order created successfully with ID: " + id); + return Response.created(UriBuilder.fromResource(OrderResource.class) + .path(String.valueOf(id)).build()) + .entity(order) + .build(); + } + + /** + * Update an existing order + */ + @PUT + @Path("/{id}") + @Operation(summary = "Update an order", description = "Update an existing order by its ID") + @APIResponses({ + @APIResponse( + responseCode = "200", + description = "Order updated successfully", + content = @Content( + mediaType = "application/json", + schema = @Schema(implementation = Order.class) + ) + ), + @APIResponse(responseCode = "404", description = "Order not found"), + @APIResponse(responseCode = "400", description = "Invalid order data") + }) + public Response updateOrder( + @PathParam("id") + @Parameter(description = "Order ID") + Long id, + @Valid @NotNull Order updatedOrder) { + + LOGGER.info("Updating order with ID: " + id); + + Order existingOrder = orders.get(id); + if (existingOrder == null) { + LOGGER.warning("Order not found with ID: " + id); + return Response.status(Response.Status.NOT_FOUND) + .entity("{\"error\":\"Order not found with ID: " + id + "\"}") + .build(); + } + + // Preserve ID and creation date + updatedOrder.setId(id); + updatedOrder.setOrderDate(existingOrder.getOrderDate()); + updatedOrder.setLastModified(LocalDateTime.now()); + + // Calculate and validate total amounts + validateAndCalculateTotals(updatedOrder); + + orders.put(id, updatedOrder); + + LOGGER.info("Order updated successfully with ID: " + id); + return Response.ok(updatedOrder).build(); + } + + /** + * Update order status + */ + @PATCH + @Path("/{id}/status") + @Operation(summary = "Update order status", description = "Update the status of an existing order") + @APIResponses({ + @APIResponse( + responseCode = "200", + description = "Order status updated successfully", + content = @Content( + mediaType = "application/json", + schema = @Schema(implementation = Order.class) + ) + ), + @APIResponse(responseCode = "404", description = "Order not found"), + @APIResponse(responseCode = "400", description = "Invalid status") + }) + public Response updateOrderStatus( + @PathParam("id") + @Parameter(description = "Order ID") + Long id, + + @QueryParam("status") + @Parameter(description = "New order status", required = true) + @NotNull OrderStatus newStatus) { + + LOGGER.info("Updating order status for ID: " + id + " to: " + newStatus); + + Order order = orders.get(id); + if (order == null) { + LOGGER.warning("Order not found with ID: " + id); + return Response.status(Response.Status.NOT_FOUND) + .entity("{\"error\":\"Order not found with ID: " + id + "\"}") + .build(); + } + + LOGGER.info("Order status updated successfully for ID: " + id); + return Response.ok(order).build(); + } + + /** + * This method was removed as it was a duplicate of the admin-only DELETE method above + */ + + /** + * Get orders by customer ID + */ + @GET + @Path("/customer/{customerId}") + @Operation(summary = "Get orders by customer ID", description = "Retrieve all orders for a specific customer") + @APIResponses({ + @APIResponse( + responseCode = "200", + description = "Customer orders retrieved successfully", + content = @Content( + schema = @Schema(implementation = Order.class, type = SchemaType.ARRAY) + ) + ) + }) + public Response getOrdersByCustomerId( + @PathParam("customerId") + @Parameter(description = "Customer ID") + String customerId) { + + LOGGER.info("Fetching orders for customer: " + customerId); + + List customerOrders = orders.values().stream() + .filter(order -> customerId.equals(order.getCustomerId())) + .collect(Collectors.toList()); + + return Response.ok(customerOrders).build(); + } + + /** + * Get order statistics + */ + @GET + @Path("/stats") + @Operation(summary = "Get order statistics", description = "Get statistical information about orders") + @APIResponses({ + @APIResponse( + responseCode = "200", + description = "Order statistics retrieved successfully", + content = @Content( + mediaType = "application/json", + schema = @Schema(implementation = Map.class) + ) + ) + }) + public Response getOrderStatistics() { + LOGGER.info("Fetching order statistics"); + + Map stats = new HashMap<>(); + stats.put("totalOrders", orders.size()); + + Map statusCounts = orders.values().stream() + .collect(Collectors.groupingBy(Order::getStatus, Collectors.counting())); + stats.put("ordersByStatus", statusCounts); + + OptionalDouble avgTotal = orders.values().stream() + .mapToDouble(order -> order.getTotalAmount().doubleValue()) + .average(); + stats.put("averageOrderValue", avgTotal.orElse(0.0)); + + Optional maxTotal = orders.values().stream() + .map(Order::getTotalAmount) + .max(java.util.Comparator.naturalOrder()); + stats.put("maxOrderValue", maxTotal.orElse(BigDecimal.ZERO)); + + return Response.ok(stats).build(); + } + + /** + * Validate and calculate order totals + */ + private void validateAndCalculateTotals(Order order) { + if (order.getItems() == null || order.getItems().isEmpty()) { + throw new BadRequestException("Order must contain at least one item"); + } + + BigDecimal calculatedTotal = BigDecimal.ZERO; + for (Order.OrderItem item : order.getItems()) { + BigDecimal itemTotal = item.getUnitPrice().multiply(new BigDecimal(item.getQuantity())); + item.setTotalPrice(itemTotal); + calculatedTotal = calculatedTotal.add(itemTotal); + } + + order.setTotalAmount(calculatedTotal); + } +} diff --git a/code/chapter10/order/src/main/resources/META-INF/microprofile-config.properties b/code/chapter10/order/src/main/resources/META-INF/microprofile-config.properties new file mode 100644 index 0000000..de83335 --- /dev/null +++ b/code/chapter10/order/src/main/resources/META-INF/microprofile-config.properties @@ -0,0 +1,7 @@ +# MicroProfile Configuration +mp.openapi.scan=true + +# JWT verification settings +mp.jwt.verify.publickey.location=/META-INF/publicKey.pem +mp.jwt.verify.issuer=mp-ecomm-store +mp.jwt.verify.roles=roles \ No newline at end of file diff --git a/code/chapter10/order/src/main/resources/META-INF/publicKey.pem b/code/chapter10/order/src/main/resources/META-INF/publicKey.pem new file mode 100644 index 0000000..7eb9485 --- /dev/null +++ b/code/chapter10/order/src/main/resources/META-INF/publicKey.pem @@ -0,0 +1,3 @@ +-----BEGIN PUBLIC KEY----- +MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAwC7rJ3FuawS8zJFBjqL8KH/ndDvpstZLk5VvnFS+cQsKIRlOjhJgzOaSTGuExh+XWUov8P0IOn0hfW8Cm0Bo2H09KlsMsbkdZUZRy5ENMXEhDNBc/APOeiTN5+NzViTm09H9zxG10lGwMEqxTOSwzDTn6xctHtvGyLTTbVQ0CF/Zk7EayBEAYj77hWflamJNCu1MuH/ZKuabjKdnpJ453Yzy4cIYymT01lbb8FCQOUM0rTyckkM07nBCf8eAsxb5dGkfs5TV8DUFso8ja7NOKbwSIfkq8X+jAdLBxhrl3LuFJXjjU2Pqj8pRmIcmVks9+Fk/DxUJjlAWFHZxaZZv0wIDAQAB +-----END PUBLIC KEY----- diff --git a/code/chapter10/order/src/main/webapp/index.html b/code/chapter10/order/src/main/webapp/index.html new file mode 100644 index 0000000..f9ffb09 --- /dev/null +++ b/code/chapter10/order/src/main/webapp/index.html @@ -0,0 +1,223 @@ + + + + + + Order Service - MicroProfile E-Commerce Store + + + +
+
+

Order Service

+

MicroProfile E-Commerce Store - Order Management Microservice

+
+ +
+

🏗️ Technology Stack

+
+ MicroProfile JWT + JAX-RS + OpenAPI 3.0 + Open Liberty + Jakarta EE + REST API +
+
+ +
+

🚀 API Endpoints

+ +
+ GET + /api/orders/{id} +

Returns the order information for the specified ID.

+

Security: Requires valid JWT Bearer token with "user" role

+
+Response Example: +"Order for user: user1@example.com, ID: 12345" +
+
+ +
+ DELETE + /api/orders/{id} +

Deletes an order with the specified ID.

+

Security: Requires valid JWT Bearer token with "admin" role

+
+Response Example: +"Order deleted by admin: admin@example.com, ID: 12345" +
+
+
+ +
+

🔐 JWT Authentication

+

This service uses MicroProfile JWT for authentication. Include the JWT token in the Authorization header:

+
Authorization: Bearer <your-jwt-token>
+ +

Required JWT Claims:

+
    +
  • iss: mp-ecomm-store (issuer must match service configuration)
  • +
  • sub: User identifier (e.g., "user1")
  • +
  • groups: Array containing roles (e.g., ["user"] or ["admin"])
  • +
  • upn: User Principal Name (e.g., "user1@example.com")
  • +
+
+ +
+

📖 OpenAPI Documentation

+

Interactive API documentation is available at:

+ +
+ +
+

🛠️ Development & Testing

+

To test the secured endpoints, you'll need a valid JWT token. You can generate one using the JWT tools in the tools/ directory.

+ +

Quick Test Commands:

+
+# Generate JWT token using jwtenizr +cd tools && java -jar jwtenizr.jar + +# Test user access endpoint +curl -H "Authorization: Bearer $(cat tools/token.jwt)" \ + http://localhost:8050/order/api/orders/12345 + +# Test admin access endpoint +# (requires token with admin role in groups claim) +curl -X DELETE -H "Authorization: Bearer $(cat tools/token.jwt)" \ + http://localhost:8050/order/api/orders/12345 + +# Generate token and test endpoint automatically +cd tools && java -Dverbose -jar jwtenizr.jar http://localhost:8050/order/api/orders/12345 +
+
+ + +
+ + diff --git a/code/chapter10/tools/jwt-token.json b/code/chapter10/tools/jwt-token.json new file mode 100644 index 0000000..ef41d52 --- /dev/null +++ b/code/chapter10/tools/jwt-token.json @@ -0,0 +1,10 @@ +{ + "iss": "mp-ecomm-store", + "jti": "42", + "sub": "user1", + "upn": "user1@example.com", + "groups": [ + "user" + ], + "tenant_id": "ecomm-tenant-1" +} \ No newline at end of file diff --git a/code/chapter10/tools/jwtenizr-config.json b/code/chapter10/tools/jwtenizr-config.json new file mode 100644 index 0000000..a4ba324 --- /dev/null +++ b/code/chapter10/tools/jwtenizr-config.json @@ -0,0 +1,6 @@ +{ + "mpConfigIssuer": "mp-ecomm-store", + "mpConfigurationFolder": ".", + "privateKey": "MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQDALusncW5rBLzMkUGOovwof+d0O+my1kuTlW+cVL5xCwohGU6OEmDM5pJMa4TGH5dZSi/w/Qg6fSF9bwKbQGjYfT0qWwyxuR1lRlHLkQ0xcSEM0Fz8A856JM3n43NWJObT0f3PEbXSUbAwSrFM5LDMNOfrFy0e28bItNNtVDQIX9mTsRrIEQBiPvuFZ+VqYk0K7Uy4f9kq5puMp2eknjndjPLhwhjKZPTWVtvwUJA5QzStPJySQzTucEJ/x4CzFvl0aR+zlNXwNQWyjyNrs04pvBIh+Srxf6MB0sHGGuXcu4UleONTY+qPylGYhyZWSz34WT8PFQmOUBYUdnFplm/TAgMBAAECggEANZ0hNxi68BobPYqMWml3pSjBfji0opKL9PkscNVnZ4vn4IH520KfRKpSSAV6vfbUNzGuHDHK2N5NuHt+o6cdWL/fj3BlIzN8UuOCMCMgJhnkWXnLZvb85DBeTQG0DGUxDAi6IMlVCv6FA4Pi4IuwEtfzly8ZBFHVq+peTVK/TVJLUtm8GtVv0mJdUVe5u/jsvZcG7fbvjcUE7j0hd/OorcNbQ3N2b2a5GNaAeVIwL/TflEjLlMT/WeIAbPlNNJmWfJHLIYoKkNTfg3FcWuafM7SS8iPSwFm8uayvF3Z+4NAh+rL7xa79D+a4TRgiQmRY6Yl8H4klW8hB5NMPi4f4YQKBgQDDUcCISym9JYtXHQ+uBSkcxhiS955t5lC9B5UTicVOBze5x2ryQ9d8ujfnyF98qKubBlL38BbmHrV08yFcMoZMNwG54q9zdaCRaKaLax8abEIDuNdZgiNJ9S7KIcwOBlHWhHtcWRhHjgj0dgYARNo1bt7ogQD7e9ASLV75p+Y/kQKBgQD74783LCG9ek7qWiHC39SS2a0ia0moQEddH3xUD8xEvplQlkppAmYWnpkPvhrPO46E9Givk0C1CtPZK+YA6QmciH49QuYG/bQGLAQRlCIVb2BW4BxqMfuXqTEnUR0IbzekQXRl9ymPKt5Uzl+4hcMmuiS/hz3Wse7fzd/jbJ5PIwKBgFnb168cnWRGzJdUaG1QNHznalDbGQlIp6Z/wYcOoDZovat74mj46z+X0LaTCdMpKmIVA8DLtU1DnYnjfVqUaBLST7n8X2nIGQos0kpcCyA15B0gQfsNEz0oTtFxwRZGtAn0Q2jWGIR7BQWq8tHW22kvy9+90fzhFnX2Z7aGFzjxAoGBAML+pdJiOaRjAKBvMd+YQwmDtYIFqDm1uQkgDLFOoYU+P5WhIu1zy/AKytbjBgITStsmEbyJs/fy79kZIK7nuGcTSxbFqSkUUb7NaEDreg8570yRpa2YD/pyIfkb0+vpnRttCFy/H88TEpZ4RKWl91MNmtEiMv73M8LRr1ZxiYQdAoGAM4MnLUBroXfyJ/t8SKhSuTC5LlKQYr/yyCFGsIL7/a05KLuAtjy19/C9jFGAix5SD4fe1aBhXSuHMgTpcJClkxk2J44rLJ0I7P90XUyPc/ONykfYhs1nL0VoFVvrORtE9pQ86Gt1E2bDE05rBrJf4LuSfu4umCtft4Rob5lKt84=", + "publicKey": "MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAwC7rJ3FuawS8zJFBjqL8KH/ndDvpstZLk5VvnFS+cQsKIRlOjhJgzOaSTGuExh+XWUov8P0IOn0hfW8Cm0Bo2H09KlsMsbkdZUZRy5ENMXEhDNBc/APOeiTN5+NzViTm09H9zxG10lGwMEqxTOSwzDTn6xctHtvGyLTTbVQ0CF/Zk7EayBEAYj77hWflamJNCu1MuH/ZKuabjKdnpJ453Yzy4cIYymT01lbb8FCQOUM0rTyckkM07nBCf8eAsxb5dGkfs5TV8DUFso8ja7NOKbwSIfkq8X+jAdLBxhrl3LuFJXjjU2Pqj8pRmIcmVks9+Fk/DxUJjlAWFHZxaZZv0wIDAQAB" +} \ No newline at end of file diff --git a/code/chapter10/tools/microprofile-config.properties b/code/chapter10/tools/microprofile-config.properties new file mode 100644 index 0000000..96dc127 --- /dev/null +++ b/code/chapter10/tools/microprofile-config.properties @@ -0,0 +1,4 @@ +#generated by jwtenizr +#Wed Jun 11 11:26:35 UTC 2025 +mp.jwt.verify.publickey=MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAwC7rJ3FuawS8zJFBjqL8KH/ndDvpstZLk5VvnFS+cQsKIRlOjhJgzOaSTGuExh+XWUov8P0IOn0hfW8Cm0Bo2H09KlsMsbkdZUZRy5ENMXEhDNBc/APOeiTN5+NzViTm09H9zxG10lGwMEqxTOSwzDTn6xctHtvGyLTTbVQ0CF/Zk7EayBEAYj77hWflamJNCu1MuH/ZKuabjKdnpJ453Yzy4cIYymT01lbb8FCQOUM0rTyckkM07nBCf8eAsxb5dGkfs5TV8DUFso8ja7NOKbwSIfkq8X+jAdLBxhrl3LuFJXjjU2Pqj8pRmIcmVks9+Fk/DxUJjlAWFHZxaZZv0wIDAQAB +mp.jwt.verify.issuer=mp-ecomm-store diff --git a/code/chapter10/tools/token.jwt b/code/chapter10/tools/token.jwt new file mode 100644 index 0000000..f1c9a20 --- /dev/null +++ b/code/chapter10/tools/token.jwt @@ -0,0 +1 @@ +eyJraWQiOiJqd3Qua2V5IiwidHlwIjoiSldUIiwiYWxnIjoiUlMyNTYifQ.eyJ0ZW5hbnRfaWQiOiJlY29tbS10ZW5hbnQtMSIsInN1YiI6InVzZXIxIiwidXBuIjoidXNlcjFAZXhhbXBsZS5jb20iLCJhdXRoX3RpbWUiOjE3NDk2NDExOTUsImlzcyI6Im1wLWVjb21tLXN0b3JlIiwiZ3JvdXBzIjpbInVzZXIiXSwiZXhwIjoxNzQ5NjQyMTk1LCJpYXQiOjE3NDk2NDExOTUsImp0aSI6IjQyIn0.WRpbTfgu4CEtd98gYk4r3yXOc7CZXpWaP2ALZCYW9sXzIgDOQlh0dNwuvMwntySJ-fexCDzetZLHkvxNKkUFA01E_QFEIUyINEJMlS47P9eF_Gof3qt-8o1Flq61CcNrCRGmOFN--Skh4jli1vECNxE6Mlu5KZjNBrfmFwmJiyiNF8cW3G0iIMwJSniewW4sXGCekTZM7-8nWXYoZfNvpkZE8f_XZVOnXzw9AIB0Afrs80V7LuIb-RR3Xo91QERbjqJ3HUhz99eIstRfl8oxiSkHKbPQrmdLKMSZ4YaGSNKBjIo9K2gLwOwUFPSTUBovWbYTkVTV-e9yQyF2kGs4lw \ No newline at end of file diff --git a/code/chapter10/user/README.adoc b/code/chapter10/user/README.adoc new file mode 100644 index 0000000..0ec65ec --- /dev/null +++ b/code/chapter10/user/README.adoc @@ -0,0 +1,632 @@ += User Service - MicroProfile E-Commerce Store +:toc: left +:icons: font +:source-highlighter: highlightjs +:sectnums: +:imagesdir: images + +This document provides information about the User Management Service, part of the MicroProfile tutorial store application. + +== Overview +The User Management Service is responsible for user operations including: + +* User registration and management +* User profile information +* Basic authentication + +This service demonstrates MicroProfile and Jakarta EE technologies in a microservice architecture. + +== Technology Stack + +The User Management Service uses the following technologies: + +* Jakarta EE 10 +** RESTful Web Services 3.1 +** Context and Dependency Injection 4.0 +** Bean Validation 3.0 +** JSON Binding 3.0 +* MicroProfile 6.1 +** OpenAPI 3.1 +* Maven + +== Features + +* 🔐 **JWT Authentication**: Secure endpoints using MicroProfile JWT +* 📋 **User Profile Management**: Extract and display user information from JWT claims +* 📖 **OpenAPI Documentation**: Comprehensive API documentation with Swagger UI +* 🏗️ **MicroProfile Compliance**: Built with Jakarta EE and MicroProfile standards + +== Technology Stack + +* **Framework**: MicroProfile, Jakarta EE +* **Security**: MicroProfile JWT +* **API**: Jakarta Restful Web Services, MicroProfile OpenAPI +* **Build**: Maven + +== Project Structure + +[source] +---- +user/ +├── src/ +│ ├── main/ +│ │ ├── java/ +│ │ │ └── io/microprofile/tutorial/store/user/ +│ │ │ ├── entity/ # Domain objects +│ │ │ ├── exception/ # Custom exceptions +│ │ │ ├── repository/ # Data access layer +│ │ │ ├── resource/ # REST endpoints +│ │ │ ├── service/ # Business logic +│ │ │ └── UserApplication.java +│ │ ├── liberty/ +│ │ │ └── config/ +│ │ │ └── server.xml # Liberty server configuration +│ │ ├── resources/ +│ │ │ └── META-INF/ +│ │ │ └── microprofile-config.properties +│ │ └── webapp/ +│ │ └── index.html # Welcome page +│ └── test/ # Unit and integration tests +└── pom.xml # Maven configuration +---- + +== API Endpoints + +The service exposes the following RESTful endpoints: + +[cols="2,1,4", options="header"] +|=== +| Endpoint | Method | Description + +| `/api/users` | GET | Retrieve all users +| `/api/users/{id}` | GET | Retrieve a specific user by ID +| `/api/users` | POST | Create a new user +| `/api/users/{id}` | PUT | Update an existing user +| `/api/users/{id}` | DELETE | Delete a user +| `/api/users/profile` | GET | Get authenticated user's profile (generic auth) +| `/api/users/user-profile` | GET | Simple JWT demo endpoint +| `/api/users/jwt` | GET | Get demo JWT token +|=== + +=== Secured Endpoints + +==== GET /users/user-profile +Returns the authenticated user's profile information extracted from the JWT token. + +**Security**: Requires valid JWT Bearer token with `user` role + +**Response Example**: +[source,text] +---- +User: user1, Roles: [user], Tenant: ecomm-tenant-1 +---- + +**HTTP Status Codes**: +* `200` - User profile returned successfully +* `401` - Unauthorized - JWT token is missing or invalid +* `403` - Forbidden - User lacks required permissions + +== Authentication & Authorization + +=== JWT Token Requirements + +The service expects JWT tokens with the following claims: + +[cols="1,2,3"] +|=== +|Claim |Required |Description + +|`iss` +|Yes +|Issuer - must match `mp.jwt.verify.issuer` configuration + +|`sub` +|Yes +|Subject - unique user identifier + +|`groups` +|Yes +|Array containing user roles (must include "user") + +|`tenant_id` +|No +|Custom claim for multi-tenant support + +|`exp` +|Yes +|Expiration timestamp + +|`iat` +|Yes +|Issued at timestamp +|=== + +=== Example JWT Payload + +[source,json] +---- +{ + "iss": "mp-ecomm-store", + "jti": "42", + "sub": "user1", + "upn": "user1@example.com", + "groups": ["user"], + "tenant_id": "ecomm-tenant-1", + "exp": 1748951611, + "iat": 1748950611 +} +---- + +== Configuration + +=== MicroProfile Configuration + +The service uses the following MicroProfile configuration properties: + +[source,properties] +---- +# Enable OpenAPI scanning +mp.openapi.scan=true + +# JWT verification settings +mp.jwt.verify.publickey.location=/META-INF/publicKey.pem +mp.jwt.verify.issuer=mp-ecomm-store + +# OpenAPI UI configuration +mp.openapi.ui.enable=true +---- + +**Security Note**: CORS should be properly configured for production environments. + +=== Security Configuration + +The service requires: + +1. **Public Key**: RSA public key in PEM format located at `/META-INF/publicKey.pem` +2. **Issuer Validation**: JWT tokens must have matching `iss` claim +3. **Role-Based Access**: Endpoints require `user` role in JWT `groups` claim + +== Development Setup + +==== Prerequisites + +* JDK 17 or later +* Maven 3.8+ +* Docker (optional, for containerized deployment) + +=== Building the Service + +==== Local Development + +1. Download the source code: ++ +[source,bash] +---- +# Download the code branch as ZIP and extract +curl -L https://github.com/ttelang/microprofile-tutorial/archive/refs/heads/code.zip -o microprofile-tutorial.zip +unzip microprofile-tutorial.zip +cd microprofile-tutorial/code/user +---- + +2. Build the project: ++ +[source,bash] +---- +mvn clean package +---- + +3. Run the service: ++ +[source,bash] +---- +mvn liberty:run +---- + +or for, development mode + +[source,bash] +---- +# Build the project +mvn clean package + +# Run with Liberty dev mode +mvn liberty:dev +---- + +4. The service will be available at: ++ +[source] +---- +http://localhost:6050/user/api/users +---- + +=== Docker Deployment + +To build and run using Docker: + +[source,bash] +---- +# Build the Docker image +docker build -t microprofile-tutorial/user-service . + +# Run the container +docker run -p 6050:6050 microprofile-tutorial/user-service +---- + +=== Testing with JWT Tokens + +The User Service uses JWT-based authentication, so testing requires valid JWT tokens. The project includes the **jwtenizr** tool for comprehensive token generation and endpoint testing. + +==== jwtenizr - JWT Token Generator & Testing Tool + +The project includes **jwtenizr**, a lightweight Java command-line utility for generating JWT tokens and testing endpoints. This tool is essential for creating properly signed tokens that match the service's security configuration. + +===== Key Features + +* Generates RSA-signed JWT tokens with automatic expiration (default: 300 seconds) +* Uses configurable payload and signing configuration +* Outputs tokens ready for use with the User Service +* Supports RS256 algorithm for token signing +* Can test endpoints directly with generated tokens +* Provides verbose output for debugging + +===== Quick Start Commands + +[source,bash] +---- +# Navigate to tools directory +cd tools/ + +# Generate token and test endpoint directly (recommended) +java -Dverbose -jar jwtenizr.jar http://localhost:6050/user/users/user-profile + +# Generate token silently +java -jar jwtenizr.jar + +# Generate with verbose output +java -Dverbose -jar jwtenizr.jar +---- + +**Command Options:** +- **Basic**: `java -jar jwtenizr.jar` - Generates token silently +- **Verbose**: `java -Dverbose -jar jwtenizr.jar` - Shows detailed token generation process +- **Test Endpoint**: `java -Dverbose -jar jwtenizr.jar ` - Generates token and tests the specified endpoint automatically + +===== Configuration Files + +The tool uses three main files located in the `tools/` directory: + +====== 1. jwtenizr-config.json (Signing Configuration) + +Contains the RSA private key and algorithm settings: + +[source,json] +---- +{ + "algorithm": "RS256", + "privateKey": "-----BEGIN PRIVATE KEY-----\n[RSA private key content]\n-----END PRIVATE KEY-----" +} +---- + +**Security Notes:** +- The private key must correspond to the public key in `/META-INF/publicKey.pem` +- Only RS256 algorithm is currently supported +- Keep private keys secure and out of public repositories + +====== 2. jwt-token.json (Token Payload) + +Defines the JWT claims and payload structure: + +[source,json] +---- +{ + "iss": "mp-ecomm-store", + "jti": "42", + "sub": "user1", + "upn": "user1@example.com", + "groups": ["user"], + "tenant_id": "ecomm-tenant-1", + "exp": 1748951611, + "iat": 1748950611 +} +---- + +**Required Claims:** +- `iss` (issuer): Must match `mp.jwt.verify.issuer` configuration ("mp-ecomm-store") +- `sub` (subject): Unique user identifier +- `groups`: Array containing user roles (must include "user" for endpoint access) +- `exp` (expiration): Unix timestamp (default: 300 seconds from generation) +- `iat` (issued at): Unix timestamp for token creation + +**Optional Claims:** +- `jti` (JWT ID): Unique identifier for the token +- `upn` (User Principal Name): User's email or principal name +- `tenant_id`: Custom claim for multi-tenant support + +====== 3. token.jwt (Generated Output) + +After running jwtenizr, this file contains the signed JWT token ready for use. + +===== Testing Methods + +====== Direct Endpoint Testing (Recommended) + +[source,bash] +---- +# Generate token and test endpoint in one command +java -Dverbose -jar jwtenizr.jar http://localhost:6050/user/api/users/user-profile +---- + +====== Manual Testing with curl + +[source,bash] +---- +# Generate token first +java -jar jwtenizr.jar + +# Test secured endpoint using generated token +curl -H "Authorization: Bearer $(cat token.jwt)" \ + http://localhost:6050/user/users/user-profile + +# Alternative: test with token variable +TOKEN=$(cat token.jwt) +curl -H "Authorization: Bearer $TOKEN" \ + http://localhost:6050/user/users/user-profile +---- + +**Expected Response**: +[source,text] +---- +User: user1, Roles: [user], Tenant: ecomm-tenant-1 +---- + +====== OpenAPI/Swagger UI Testing + +1. Navigate to `http://localhost:6050/user/openapi/ui` +2. Click **"Authorize"** button (lock icon) +3. Generate a token: `java -jar jwtenizr.jar` +4. Enter token in **Value** field (with or without "Bearer " prefix) +5. Click **"Authorize"** and test endpoints + +===== Advanced Configuration + +====== Custom Token Expiration + +[source,bash] +---- +# Set expiration to 1 hour from now +exp_time=$(date -d "+1 hour" +%s) +sed -i "s/\"exp\": [0-9]*/\"exp\": $exp_time/" jwt-token.json + +# Generate new token +java -jar jwtenizr.jar +---- + +====== Different User Profiles + +[source,bash] +---- +# Copy default payload +cp jwt-token.json jwt-token-admin.json + +# Modify for admin user +sed -i 's/"sub": "user1"/"sub": "admin1"/' jwt-token-admin.json +sed -i 's/"upn": "user1@example.com"/"upn": "admin1@example.com"/' jwt-token-admin.json +sed -i 's/"groups": \["user"\]/"groups": ["user", "admin"]/' jwt-token-admin.json +---- + +===== Security Best Practices + +- Use jwtenizr for development and testing only +- Never use development private keys in production +- Rotate keys regularly and use proper key management +- Consider proper identity providers (like Keycloak) for production +- Keep private keys secure and out of version control + +== API Documentation + +=== OpenAPI Specification + +The service provides comprehensive OpenAPI documentation: + +* **OpenAPI JSON**: `http://localhost:6050/user/openapi` +* **Swagger UI**: `http://localhost:6050/user/openapi/ui` + +=== Swagger UI Features + +* Interactive API testing +* Request/response examples +* Authentication configuration +* Schema documentation + +=== Testing JWT Authentication via OpenAPI UI + +The OpenAPI/Swagger UI provides built-in support for testing JWT authentication: + +==== Step 1: Access Swagger UI +Navigate to `http://localhost:6050/user/openapi/ui` in your browser. + +==== Step 2: Configure JWT Authentication + +1. Click the **"Authorize"** button (lock icon) at the top right of the Swagger UI +2. In the **"jwt (http, bearer)"** section: + - Enter your JWT token in the **Value** field + - Format: `Bearer ` or just `` + - Click **"Authorize"** + +==== Step 3: Test Secured Endpoints + +Once authenticated, you can test the secured endpoints: + +1. Expand the **GET /users/user-profile** endpoint +2. Click **"Try it out"** +3. Click **"Execute"** +4. View the response with user profile information + +**Note**: Generate JWT tokens using the jwtenizr tool in the `/tools/` directory as described in the Testing section. + +=== OpenAPI Security Configuration + +The service automatically configures OpenAPI security through annotations: + +[source,java] +---- +@SecurityScheme( + securitySchemeName = "jwt", + type = SecuritySchemeType.HTTP, + scheme = "bearer", + bearerFormat = "JWT", + description = "JWT authentication with bearer token" +) +---- + +This configuration enables the "Authorize" button in Swagger UI and provides proper security documentation. + +== Troubleshooting + +=== JWT Token Generation Issues (jwtenizr) + +==== Common jwtenizr Problems + +**Issue**: `java.security.InvalidKeyException: Invalid key format` + +**Solution**: Verify the private key format in `jwtenizr-config.json`: +- Ensure proper PEM formatting with `\n` line breaks +- Check that the key starts with `-----BEGIN PRIVATE KEY-----` +- Validate the key corresponds to the public key in the service + +**Issue**: `ClassNotFoundException` or `NoClassDefFoundError` + +**Solution**: Ensure Java runtime environment is properly configured: +[source,bash] +---- +# Check Java version (requires Java 11+) +java -version + +# Verify jwtenizr.jar exists and is executable +ls -la tools/jwtenizr.jar +---- + +**Issue**: Generated tokens fail service validation + +**Solution**: Check issuer and claims matching: +[source,bash] +---- +# Verify issuer matches service configuration +grep "mp.jwt.verify.issuer" src/main/resources/META-INF/microprofile-config.properties + +# Verify token payload structure +cat tools/jwt-token.json +---- + +=== Service Authentication Issues + +==== JWT Validation Errors + +**Error**: `CWWKS5523E: The MicroProfile JWT feature cannot authenticate the request` + +**Solutions**: +1. Verify the JWT issuer matches the configuration ("mp-ecomm-store") +2. Ensure the public key is correctly formatted and accessible +3. Check token expiration time (default: 300 seconds) +4. Validate token signature using corresponding public/private key pair + +==== Authorization Failures + +**Error**: `HTTP 403 Forbidden` + +**Solutions**: +1. Ensure JWT contains `groups` claim with "user" role +2. Verify token is not expired +3. Check that the user principal is properly extracted +4. Generate a fresh token using jwtenizr: `cd tools && java -jar jwtenizr.jar` + +==== Configuration Issues + +**Error**: `CWWKS6029E: Signing key cannot be found` + +**Solutions**: +1. Verify `publicKey.pem` exists in `/META-INF/` directory +2. Ensure the public key format is correct (PEM format) +3. Check file permissions and deployment +4. Validate the public key corresponds to the private key used in jwtenizr + +=== OpenAPI/Swagger UI Issues + +==== Authentication Problems in Swagger UI + +**Issue**: Swagger UI shows "Authorize" button but authentication fails + +**Solutions**: +1. Generate a fresh token: `cd tools && java -jar jwtenizr.jar` +2. Ensure correct token format in Swagger UI: + - Format: `Bearer ` or just `` + - Use the full token from `tools/token.jwt` +3. Verify the token includes all required claims (`iss`, `sub`, `groups`) +4. Check token expiration (tokens expire after 300 seconds by default) + +**Example Token Input in Swagger UI**: +``` +Bearer eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9... +``` +Or simply: +``` +eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9... +``` + +**Issue**: Valid token works in curl but fails in Swagger UI + +**Solutions**: +1. Check browser network tab for actual request headers +2. Verify Swagger UI is sending the Authorization header correctly +3. Clear browser cache and cookies +4. Try testing with a fresh JWT token + +==== CORS Issues + +**Issue**: Cross-origin requests blocked in browser + +**Solutions**: +1. Add CORS configuration to `microprofile-config.properties` +2. Use browser developer tools to check CORS headers +3. For development, consider disabling browser security features + +=== Token Validation Best Practices + +For comprehensive JWT token validation, use the jwtenizr tool to verify: + +* JWT token structure and claims +* Token signature validation +* Issuer and audience matching +* Expiration time settings + +**Quick Validation Commands**: +[source,bash] +---- +# Generate and test token in one command +cd tools && java -Dverbose -jar jwtenizr.jar http://localhost:6050/user/users/user-profile + +# Check token expiration +date -d @$(cat tools/jwt-token.json | grep -o '"exp": [0-9]*' | cut -d' ' -f2) + +# Verify service configuration +grep -E "(issuer|publickey)" src/main/resources/META-INF/microprofile-config.properties +---- + +== Security Considerations + +=== Production Deployment + +* Remove or secure debug endpoints +* Use proper certificate management for JWT keys +* Implement token revocation mechanisms +* Configure appropriate CORS policies +* Enable HTTPS/TLS encryption + +=== Best Practices + +* Regularly rotate JWT signing keys +* Implement proper token expiration policies +* Use strong RSA keys (2048-bit minimum) +* Validate all JWT claims server-side +* Log security events for monitoring \ No newline at end of file diff --git a/code/chapter10/user/pom.xml b/code/chapter10/user/pom.xml new file mode 100644 index 0000000..b3c56c3 --- /dev/null +++ b/code/chapter10/user/pom.xml @@ -0,0 +1,181 @@ + + + + 4.0.0 + + io.microprofile + user + 1.0-SNAPSHOT + war + + user-management + https://microprofile.io + + + UTF-8 + 17 + 17 + 10.0.0 + 6.1 + 23.0.0.3 + 1.18.24 + 5.9.3 + 5.4.0 + 5.3.1 + + + + + + jakarta.platform + jakarta.jakartaee-api + ${jakarta.jakartaee-api.version} + provided + + + + org.eclipse.microprofile + microprofile + ${microprofile.version} + pom + provided + + + + org.projectlombok + lombok + ${lombok.version} + provided + + + + + + org.junit.jupiter + junit-jupiter-api + ${junit.version} + test + + + + + org.junit.jupiter + junit-jupiter-engine + ${junit.version} + test + + + + + org.junit.jupiter + junit-jupiter-params + ${junit.version} + test + + + + + org.mockito + mockito-core + ${mockito.version} + test + + + + + org.mockito + mockito-junit-jupiter + ${mockito.version} + test + + + + + io.rest-assured + rest-assured + ${restassured.version} + test + + + + + user + + + + io.openliberty.tools + liberty-maven-plugin + 3.11.3 + + userServer + runnable + 120 + + /user + + + + + + + + + maven-clean-plugin + 3.1.0 + + + + maven-resources-plugin + 3.0.2 + + + maven-compiler-plugin + 3.8.0 + + + + org.projectlombok + lombok + ${lombok.version} + + + + + + maven-surefire-plugin + 2.22.1 + + + **/*Test.java + + + + + maven-war-plugin + 3.3.2 + + false + + + + maven-install-plugin + 2.5.2 + + + maven-deploy-plugin + 2.8.2 + + + + maven-site-plugin + 3.7.1 + + + maven-project-info-reports-plugin + 3.0.0 + + + + + diff --git a/code/chapter10/user/src/main/java/io/microprofile/tutorial/store/user/UserApplication.java b/code/chapter10/user/src/main/java/io/microprofile/tutorial/store/user/UserApplication.java new file mode 100644 index 0000000..f33de45 --- /dev/null +++ b/code/chapter10/user/src/main/java/io/microprofile/tutorial/store/user/UserApplication.java @@ -0,0 +1,38 @@ +package io.microprofile.tutorial.store.user; + +import jakarta.ws.rs.ApplicationPath; +import jakarta.ws.rs.core.Application; + +import org.eclipse.microprofile.auth.LoginConfig; +import org.eclipse.microprofile.openapi.annotations.OpenAPIDefinition; +import org.eclipse.microprofile.openapi.annotations.info.Info; +import org.eclipse.microprofile.openapi.annotations.servers.Server; +import org.eclipse.microprofile.openapi.annotations.enums.SecuritySchemeType; +import org.eclipse.microprofile.openapi.annotations.security.SecurityScheme; + +/** + * JAX-RS Application class that defines the base path for all REST endpoints. + * Also contains OpenAPI annotations for API documentation. + */ +@ApplicationPath("/api") +@OpenAPIDefinition( + info = @Info( + title = "User Management API", + version = "1.0.0", + description = "REST API for managing user profiles and authentication" + ), + servers = { + @Server(url = "/user", description = "User Management Service") + } +) +@SecurityScheme( + securitySchemeName = "jwt", + type = SecuritySchemeType.HTTP, + scheme = "bearer", + bearerFormat = "JWT", + description = "JWT token authentication" +) +@LoginConfig(authMethod = "MP-JWT") +public class UserApplication extends Application { + // Empty class body is sufficient for JAX-RS to work +} diff --git a/code/chapter10/user/src/main/java/io/microprofile/tutorial/store/user/entity/User.java b/code/chapter10/user/src/main/java/io/microprofile/tutorial/store/user/entity/User.java new file mode 100644 index 0000000..98c4f8e --- /dev/null +++ b/code/chapter10/user/src/main/java/io/microprofile/tutorial/store/user/entity/User.java @@ -0,0 +1,14 @@ +package io.microprofile.tutorial.store.user.entity; + +import lombok.Data; +import lombok.NoArgsConstructor; +import lombok.AllArgsConstructor; + +@Data +@NoArgsConstructor +@AllArgsConstructor +public class User { + private Long id; + private String name; + private String email; +} \ No newline at end of file diff --git a/code/chapter10/user/src/main/java/io/microprofile/tutorial/store/user/resource/UserResource.java b/code/chapter10/user/src/main/java/io/microprofile/tutorial/store/user/resource/UserResource.java new file mode 100644 index 0000000..9166fb2 --- /dev/null +++ b/code/chapter10/user/src/main/java/io/microprofile/tutorial/store/user/resource/UserResource.java @@ -0,0 +1,72 @@ +package io.microprofile.tutorial.store.user.resource; + +import java.util.Set; +import org.eclipse.microprofile.jwt.JsonWebToken; + +import jakarta.ws.rs.Consumes; +import jakarta.ws.rs.GET; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.Produces; +import jakarta.ws.rs.core.Context; +import jakarta.ws.rs.core.MediaType; +import jakarta.ws.rs.core.SecurityContext; + +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.media.Content; +import org.eclipse.microprofile.openapi.annotations.media.Schema; +import org.eclipse.microprofile.openapi.annotations.tags.Tag; +import org.eclipse.microprofile.openapi.annotations.security.SecurityRequirement; +import org.eclipse.microprofile.openapi.annotations.enums.SecuritySchemeType; +import org.eclipse.microprofile.openapi.annotations.security.SecurityScheme; + +/** + * REST resource for user management operations. + * Provides endpoints for creating, retrieving, updating, and deleting users. + * Implements standard RESTful practices with proper status codes and hypermedia links. + */ +@Tag(name = "User Management", description = "Operations for managing users") +@Path("/users") +@Consumes(MediaType.APPLICATION_JSON) +@Produces(MediaType.APPLICATION_JSON) +@SecurityScheme( + securitySchemeName = "jwt", + type = SecuritySchemeType.HTTP, + scheme = "bearer", + bearerFormat = "JWT", + description = "JWT authentication with bearer token" +) +public class UserResource { + + + @Operation( + summary = "Get user profile", + description = "Returns the authenticated user's profile information extracted from the JWT token." + ) + @SecurityRequirement(name = "jwt") + @APIResponses({ + @APIResponse( + responseCode = "200", + description = "User profile returned successfully", + content = @Content( + mediaType = "application/json", + schema = @Schema(implementation = String.class) + ) + ), + @APIResponse( + responseCode = "401", + description = "Unauthorized - JWT token is missing or invalid" + ) + }) + @GET + @Path("/user-profile") + public String getUserProfile(@Context SecurityContext ctx) { + JsonWebToken jwt = (JsonWebToken) ctx.getUserPrincipal(); + String userId = jwt.getName(); // Extracts the "sub" claim + Set roles = jwt.getGroups(); // Extracts the "groups" claim + String tenant = jwt.getClaim("tenant_id"); // Custom claim + + return "User: " + userId + ", Roles: " + roles + ", Tenant: " + tenant; + } +} diff --git a/code/chapter10/user/src/main/resources/META-INF/microprofile-config.properties b/code/chapter10/user/src/main/resources/META-INF/microprofile-config.properties new file mode 100644 index 0000000..3ce4b75 --- /dev/null +++ b/code/chapter10/user/src/main/resources/META-INF/microprofile-config.properties @@ -0,0 +1,4 @@ +mp.openapi.scan=true + +mp.jwt.verify.publickey.location=/META-INF/publicKey.pem +mp.jwt.verify.issuer=mp-ecomm-store \ No newline at end of file diff --git a/code/chapter10/user/src/main/resources/META-INF/publicKey.pem b/code/chapter10/user/src/main/resources/META-INF/publicKey.pem new file mode 100644 index 0000000..ac42d1d --- /dev/null +++ b/code/chapter10/user/src/main/resources/META-INF/publicKey.pem @@ -0,0 +1,3 @@ +-----BEGIN RSA PUBLIC KEY----- +MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAwC7rJ3FuawS8zJFBjqL8KH/ndDvpstZLk5VvnFS+cQsKIRlOjhJgzOaSTGuExh+XWUov8P0IOn0hfW8Cm0Bo2H09KlsMsbkdZUZRy5ENMXEhDNBc/APOeiTN5+NzViTm09H9zxG10lGwMEqxTOSwzDTn6xctHtvGyLTTbVQ0CF/Zk7EayBEAYj77hWflamJNCu1MuH/ZKuabjKdnpJ453Yzy4cIYymT01lbb8FCQOUM0rTyckkM07nBCf8eAsxb5dGkfs5TV8DUFso8ja7NOKbwSIfkq8X+jAdLBxhrl3LuFJXjjU2Pqj8pRmIcmVks9+Fk/DxUJjlAWFHZxaZZv0wIDAQAB +-----END RSA PUBLIC KEY----- \ No newline at end of file diff --git a/code/chapter10/user/src/main/webapp/index.html b/code/chapter10/user/src/main/webapp/index.html new file mode 100644 index 0000000..badf6ec --- /dev/null +++ b/code/chapter10/user/src/main/webapp/index.html @@ -0,0 +1,204 @@ + + + + + + User Service - MicroProfile E-Commerce Store + + + +
+
+

User Service

+

MicroProfile E-Commerce Store - User Management Microservice

+
+ +
+

🏗️ Technology Stack

+
+ MicroProfile JWT + Jakarta Restful Web Service + MicroProfile OpenAPI +
+
+ +
+

🚀 API Endpoints

+ +
+ GET + /api/users/user-profile +

Returns the authenticated user's profile information extracted from the JWT token.

+

Security: Requires valid JWT Bearer token with "user" role

+
+Response Example: +User: user1@example.com, Roles: [user], Tenant: ecomm-tenant-1 +
+
+
+ +
+

🔐 JWT Authentication

+

This service uses MicroProfile JWT for authentication. Include the JWT token in the Authorization header:

+
Authorization: Bearer <your-jwt-token>
+ +

Required JWT Claims:

+
    +
  • iss: mp-ecomm-store (issuer must match service configuration)
  • +
  • sub: User identifier (e.g., "user1")
  • +
  • groups: Array containing "user" role
  • +
  • tenant_id: Tenant identifier (custom claim)
  • +
  • upn: User Principal Name (e.g., "user1@example.com")
  • +
+
+ +
+

📖 OpenAPI Documentation

+

Interactive API documentation is available at:

+ +
+ +
+

🛠️ Development & Testing

+

To test the secured endpoints, you'll need a valid JWT token. You can generate one using the JWT tools in the tools/ directory.

+ +

Quick Test Commands:

+
+# Generate JWT token using jwtenizr +cd tools && java -jar jwtenizr.jar + +# Test secured endpoint (with JWT) +curl -H "Authorization: Bearer <your-jwt-token>" \ + http://localhost:6050/user/api/users/user-profile + +# Test with generated token directly +curl -H "Authorization: Bearer $(cat tools/token.jwt)" \ + http://localhost:6050/user/api/users/user-profile + +# Generate token and test endpoint automatically +cd tools && java -Dverbose -jar jwtenizr.jar http://localhost:6050/user/api/users/user-profile +
+
+ + +
+ + diff --git a/code/chapter11/README.adoc b/code/chapter11/README.adoc new file mode 100644 index 0000000..ce07afb --- /dev/null +++ b/code/chapter11/README.adoc @@ -0,0 +1,332 @@ += MicroProfile E-Commerce Store +:toc: left +:icons: font +:source-highlighter: highlightjs +:imagesdir: images +:experimental: + +== Overview + +This project demonstrates a microservices-based e-commerce application built with Jakarta EE and MicroProfile, running on Open Liberty runtime. The application is composed of multiple independent services that work together to provide a complete e-commerce solution. + +[.lead] +A practical demonstration of MicroProfile capabilities for building cloud-native Java microservices. + +[IMPORTANT] +==== +This project is part of the official MicroProfile API Tutorial. +==== + +== Services + +The application is split into the following microservices: + +[cols="1,4", options="header"] +|=== +|Service |Description + +|User Service +|Manages user accounts, authentication, and profile information + +|Inventory Service +|Tracks product inventory and stock levels + +|Order Service +|Manages customer orders, order items, and order status + +|Catalog Service +|Provides product information, categories, and search capabilities + +|Shopping Cart Service +|Manages user shopping cart items and temporary product storage + +|Shipment Service +|Handles shipping orders, tracking, and delivery status updates + +|Payment Service +|Processes payments and manages payment methods and transactions +|=== + +== Technology Stack + +* *Jakarta EE 10.0*: For enterprise Java standardization +* *MicroProfile 6.1*: For cloud-native APIs +* *Open Liberty*: Lightweight, flexible runtime for Java microservices +* *Maven*: For project management and builds + +== Quick Start + +=== Prerequisites + +* JDK 17 or later +* Maven 3.6 or later +* Docker (optional for containerized deployment) + +=== Running the Application + +1. Clone the repository: ++ +[source,bash] +---- +git clone https://github.com/your-username/liberty-rest-app.git +cd liberty-rest-app +---- + +2. Start each microservice individually: + +==== User Service +[source,bash] +---- +cd user +mvn liberty:run +---- +The service will be available at http://localhost:6050/user + +==== Inventory Service +[source,bash] +---- +cd inventory +mvn liberty:run +---- +The service will be available at http://localhost:7050/inventory + +==== Order Service +[source,bash] +---- +cd order +mvn liberty:run +---- +The service will be available at http://localhost:8050/order + +==== Catalog Service +[source,bash] +---- +cd catalog +mvn liberty:run +---- +The service will be available at http://localhost:9050/catalog + +=== Building the Application + +To build all services: + +[source,bash] +---- +mvn clean package +---- + +=== Docker Deployment + +You can also run all services together using Docker Compose: + +[source,bash] +---- +# Make the script executable (if needed) +chmod +x run-all-services.sh + +# Run the script to build and start all services +./run-all-services.sh +---- + +Or manually: + +[source,bash] +---- +# Build all projects first +cd user && mvn clean package && cd .. +cd inventory && mvn clean package && cd .. +cd order && mvn clean package && cd .. +cd catalog && mvn clean package && cd .. + +# Start all services +docker-compose up -d +---- + +This will start all services in Docker containers with the following endpoints: + +* User Service: http://localhost:6050/user +* Inventory Service: http://localhost:7050/inventory +* Order Service: http://localhost:8050/order +* Catalog Service: http://localhost:9050/catalog + +== API Documentation + +Each microservice provides its own OpenAPI documentation, available at: + +* User Service: http://localhost:6050/user/openapi +* Inventory Service: http://localhost:7050/inventory/openapi +* Order Service: http://localhost:8050/order/openapi +* Catalog Service: http://localhost:9050/catalog/openapi + +== Testing the Services + +=== User Service + +[source,bash] +---- +# Get all users +curl -X GET http://localhost:6050/user/api/users + +# Create a new user +curl -X POST http://localhost:6050/user/api/users \ + -H "Content-Type: application/json" \ + -d '{ + "name": "Jane Doe", + "email": "jane@example.com", + "passwordHash": "password123", + "address": "123 Main St", + "phoneNumber": "555-123-4567" + }' + +# Get a user by ID +curl -X GET http://localhost:6050/user/api/users/1 + +# Update a user +curl -X PUT http://localhost:6050/user/api/users/1 \ + -H "Content-Type: application/json" \ + -d '{ + "name": "Jane Smith", + "email": "jane@example.com", + "passwordHash": "password123", + "address": "456 Oak Ave", + "phoneNumber": "555-123-4567" + }' + +# Delete a user +curl -X DELETE http://localhost:6050/user/api/users/1 +---- + +=== Inventory Service + +[source,bash] +---- +# Get all inventory items +curl -X GET http://localhost:7050/inventory/api/inventories + +# Create a new inventory item +curl -X POST http://localhost:7050/inventory/api/inventories \ + -H "Content-Type: application/json" \ + -d '{ + "productId": 101, + "quantity": 25 + }' + +# Get inventory by ID +curl -X GET http://localhost:7050/inventory/api/inventories/1 + +# Get inventory by product ID +curl -X GET http://localhost:7050/inventory/api/inventories/product/101 + +# Update inventory +curl -X PUT http://localhost:7050/inventory/api/inventories/1 \ + -H "Content-Type: application/json" \ + -d '{ + "productId": 101, + "quantity": 50 + }' + +# Update product quantity +curl -X PATCH http://localhost:7050/inventory/api/inventories/product/101/quantity/75 + +# Delete inventory +curl -X DELETE http://localhost:7050/inventory/api/inventories/1 +---- + +=== Order Service + +[source,bash] +---- +# Get all orders +curl -X GET http://localhost:8050/order/api/orders + +# Create a new order +curl -X POST http://localhost:8050/order/api/orders \ + -H "Content-Type: application/json" \ + -d '{ + "userId": 1, + "totalPrice": 149.98, + "status": "CREATED", + "orderItems": [ + { + "productId": 101, + "quantity": 2, + "priceAtOrder": 49.99 + }, + { + "productId": 102, + "quantity": 1, + "priceAtOrder": 50.00 + } + ] + }' + +# Get order by ID +curl -X GET http://localhost:8050/order/api/orders/1 + +# Update order status +curl -X PATCH http://localhost:8050/order/api/orders/1/status/PAID + +# Get items for an order +curl -X GET http://localhost:8050/order/api/orders/1/items + +# Delete order +curl -X DELETE http://localhost:8050/order/api/orders/1 +---- + +=== Catalog Service + +[source,bash] +---- +# Get all products +curl -X GET http://localhost:9050/catalog/api/products + +# Get a product by ID +curl -X GET http://localhost:9050/catalog/api/products/1 + +# Search products +curl -X GET "http://localhost:9050/catalog/api/products/search?keyword=laptop" +---- + +== Project Structure + +[source] +---- +liberty-rest-app/ +├── user/ # User management service +├── inventory/ # Inventory management service +├── order/ # Order management service +└── catalog/ # Product catalog service +---- + +Each service follows a similar internal structure: + +[source] +---- +service/ +├── src/ +│ ├── main/ +│ │ ├── java/ # Java source code +│ │ ├── liberty/ # Liberty server configuration +│ │ └── webapp/ # Web resources +│ └── test/ # Test code +└── pom.xml # Maven configuration +---- + +== Key MicroProfile Features Demonstrated + +* *Config*: Externalized configuration +* *Fault Tolerance*: Circuit breakers, retries, fallbacks +* *Health Checks*: Application health monitoring +* *Metrics*: Performance monitoring +* *OpenAPI*: API documentation +* *Rest Client*: Type-safe REST clients + +== Development + +=== Adding a New Service + +1. Create a new directory for your service +2. Copy the basic structure from an existing service +3. Update the `pom.xml` file with appropriate details +4. Implement your service-specific functionality +5. Configure the Liberty server in `src/main/liberty/config/` diff --git a/code/chapter11/catalog/README.adoc b/code/chapter11/catalog/README.adoc new file mode 100644 index 0000000..cffee0b --- /dev/null +++ b/code/chapter11/catalog/README.adoc @@ -0,0 +1,622 @@ += MicroProfile Catalog Service +:toc: macro +:toclevels: 3 +:icons: font +:source-highlighter: highlight.js +:experimental: + +toc::[] + +== Overview + +The MicroProfile Catalog Service is a modern Jakarta EE 10 application built with MicroProfile 6.1 specifications and running on Open Liberty. This service provides a RESTful API for product catalog management with enhanced MicroProfile features. + +This project demonstrates the key capabilities of MicroProfile OpenAPI and in-memory persistence architecture. + +== Features + +* *RESTful API* using Jakarta RESTful Web Services +* *OpenAPI Documentation* with Swagger UI +* *In-Memory Persistence* using ConcurrentHashMap for thread-safe data storage +* *HTML Landing Page* with API documentation and service status +* *Maintenance Mode* support with configuration-based toggles + +== MicroProfile Features Implemented + +=== MicroProfile OpenAPI + +The application provides OpenAPI documentation for its REST endpoints. API documentation is generated automatically from annotations in the code: + +[source,java] +---- +@GET +@Produces(MediaType.APPLICATION_JSON) +@Operation(summary = "Get all products", description = "Returns a list of all products") +@APIResponses({ + @APIResponse(responseCode = "200", description = "List of products", + content = @Content(mediaType = "application/json", + schema = @Schema(implementation = Product.class))) +}) +public Response getAllProducts() { + // Implementation +} +---- + +The OpenAPI documentation is available at: `/openapi` (in various formats) and `/openapi/ui` (Swagger UI) + +=== In-Memory Persistence Architecture + +The application implements a thread-safe in-memory persistence layer using `ConcurrentHashMap`: + +[source,java] +---- +@ApplicationScoped +public class ProductRepository { + // In-memory storage using ConcurrentHashMap for thread safety + private final Map productsMap = new ConcurrentHashMap<>(); + + // ID generator + private final AtomicLong idGenerator = new AtomicLong(1); + + // CRUD operations... +} +---- + +==== Atomic ID Generation with AtomicLong + +The repository uses `java.util.concurrent.atomic.AtomicLong` for thread-safe ID generation: + +[source,java] +---- +// ID generation in createProduct method +if (product.getId() == null) { + product.setId(idGenerator.getAndIncrement()); +} +---- + +`AtomicLong` provides several key benefits: + +* *Thread Safety*: Guarantees atomic operations without explicit locking +* *Performance*: Uses efficient compare-and-swap (CAS) operations instead of locks +* *Consistency*: Ensures unique, sequential IDs even under concurrent access +* *No Synchronization*: Avoids the overhead of synchronized blocks + +===== Advanced AtomicLong Operations + +The ProductRepository implements an advanced pattern for handling both system-generated and client-provided IDs: + +[source,java] +---- +public Product createProduct(Product product) { + // Generate ID if not provided + if (product.getId() == null) { + product.setId(idGenerator.getAndIncrement()); + } else { + // Update idGenerator if the provided ID is greater than current + long nextId = product.getId() + 1; + while (true) { + long currentId = idGenerator.get(); + if (nextId <= currentId || idGenerator.compareAndSet(currentId, nextId)) { + break; + } + } + } + + productsMap.put(product.getId(), product); + return product; +} +---- + +This implementation demonstrates several key AtomicLong patterns: + +1. *Initialization*: `AtomicLong` is initialized with a starting value of 1 to avoid using 0 as a valid ID +2. *getAndIncrement*: Atomically returns the current value and increments it in one operation +3. *compareAndSet*: Safely updates the ID generator if a client provides a higher ID value, preventing ID collisions +4. *Retry Logic*: Uses a spinlock pattern for handling concurrent updates to the AtomicLong when needed + +The initialization of the idGenerator with a specific starting value ensures the IDs begin at a predictable value: + +[source,java] +---- +private final AtomicLong idGenerator = new AtomicLong(1); // Start IDs at 1 +---- + +This approach ensures that each product receives a unique ID without risk of duplicate IDs in a concurrent environment. + +Key benefits of this in-memory persistence approach: + +* *Simplicity*: No need for database configuration or ORM mapping +* *Performance*: Fast in-memory access without network or disk I/O +* *Thread Safety*: ConcurrentHashMap provides thread-safe operations without blocking +* *Scalability*: Suitable for containerized deployments + +==== Thread Safety Implementation Details + +The implementation ensures thread safety through multiple mechanisms: + +1. *ConcurrentHashMap*: Uses lock striping to allow concurrent reads and thread-safe writes +2. *AtomicLong*: Provides atomic operations for ID generation +3. *Immutable Returns*: Returns new collections rather than internal references: ++ +[source,java] +---- +// Returns a copy of the collection to prevent concurrent modification issues +public List findAllProducts() { + return new ArrayList<>(productsMap.values()); +} +---- + +4. *Atomic Operations*: Uses atomic map operations like `putIfAbsent` and `compute` where appropriate + +NOTE: This implementation is suitable for development, testing, and scenarios where persistence across restarts is not required. + +=== MicroProfile Config + +The application uses MicroProfile Config to externalize configuration: + +[source,properties] +---- +# Enable OpenAPI scanning +mp.openapi.scan=true + +# Maintenance mode configuration +product.maintenanceMode=false +product.maintenanceMessage=The product catalog service is currently in maintenance mode. Please try again later. +---- + +The maintenance mode configuration allows dynamic control of service availability: + +* `product.maintenanceMode` - When set to `true`, the service returns a 503 Service Unavailable response +* `product.maintenanceMessage` - Customizable message displayed when the service is in maintenance mode + +==== Maintenance Mode Implementation + +The service checks the maintenance mode configuration before processing requests: + +[source,java] +---- +@Inject +@ConfigProperty(name="product.maintenanceMode", defaultValue="false") +private boolean maintenanceMode; + +@Inject +@ConfigProperty(name="product.maintenanceMessage", + defaultValue="The product catalog service is currently in maintenance mode. Please try again later.") +private String maintenanceMessage; + +// In request handling method +if (maintenance.isMaintenanceMode()) { + return Response + .status(Response.Status.SERVICE_UNAVAILABLE) + .entity(maintenance.getMaintenanceMessage()) + .build(); +} +---- + +This pattern enables: + +* Graceful service degradation during maintenance periods +* Dynamic control without redeployment (when using external configuration sources) +* Clear communication to API consumers + +== Architecture + +The application follows a layered architecture pattern: + +* *REST Layer* (`ProductResource`) - Handles HTTP requests and responses +* *Service Layer* (`ProductService`) - Contains business logic +* *Repository Layer* (`ProductRepository`) - Manages data access with in-memory storage +* *Model Layer* (`Product`) - Represents the business entities + +=== Persistence Evolution + +This application originally used JPA with Derby for persistence, but has been refactored to use an in-memory implementation: + +[cols="1,1", options="header"] +|=== +| Original JPA/Derby | Current In-Memory Implementation +| Required database configuration | No database configuration needed +| Persistence across restarts | Data reset on restart +| Used EntityManager and transactions | Uses ConcurrentHashMap and AtomicLong +| Required datasource in server.xml | No datasource configuration required +| Complex error handling | Simplified error handling +|=== + +Key architectural benefits of this change: + +* *Simplified Deployment*: No external database required +* *Faster Startup*: No database initialization delay +* *Reduced Dependencies*: Fewer libraries and configurations +* *Easier Testing*: No test database setup needed +* *Consistent Development Environment*: Same behavior across all development machines + +=== Containerization with Docker + +The application can be packaged into a Docker container: + +[source,bash] +---- +# Build the application +mvn clean package + +# Build the Docker image +docker build -t catalog-service . + +# Run the container +docker run -d -p 5050:5050 --name catalog-service catalog-service +---- + +==== AtomicLong in Containerized Environments + +When running the application in Docker or Kubernetes, some important considerations about AtomicLong behavior: + +1. *Per-Container State*: Each container has its own AtomicLong instance and state +2. *ID Collisions in Scaling*: When running multiple replicas, IDs are only unique within each container +3. *Persistence and Restarts*: AtomicLong resets on container restart, potentially causing ID reuse + +To handle these issues in production multi-container environments: + +* *External ID Generation*: Consider using a distributed ID generator service +* *Database Sequences*: For database implementations, use database sequences +* *Universally Unique IDs*: Consider UUIDs instead of sequential numeric IDs +* *Centralized Counter Service*: Use Redis or other distributed counter + +Example of adapting the code for distributed environments: + +[source,java] +---- +// Using UUIDs for distributed environments +private String generateId() { + return UUID.randomUUID().toString(); +} +---- + +== Development Workflow + +=== Running Locally + +To run the application in development mode: + +[source,bash] +---- +mvn clean liberty:dev +---- + +This starts the server in development mode, which: + +* Automatically deploys your code changes +* Provides hot reload capability +* Enables a debugger on port 7777 + +== Project Structure + +[source] +---- +catalog/ +├── src/ +│ ├── main/ +│ │ ├── java/ +│ │ │ └── io/microprofile/tutorial/store/ +│ │ │ └── product/ +│ │ │ ├── entity/ # Domain entities +│ │ │ ├── resource/ # REST resources +│ │ │ └── ProductRestApplication.java +│ │ ├── liberty/ +│ │ │ └── config/ +│ │ │ └── server.xml # Liberty server configuration +│ │ ├── resources/ +│ │ │ └── META-INF/ +│ │ │ └── microprofile-config.properties +│ │ └── webapp/ # Web resources +│ │ ├── index.html # Landing page with API documentation +│ │ └── WEB-INF/ +│ │ └── web.xml # Web application configuration +│ └── test/ # Test classes +└── pom.xml # Maven build file +---- + +== Getting Started + +=== Prerequisites + +* JDK 17+ +* Maven 3.8+ + +=== Building and Running + +To build and run the application: + +[source,bash] +---- +# Clone the repository +git clone https://github.com/yourusername/liberty-rest-app.git +cd code/catalog + +# Build the application +mvn clean package + +# Run the application +mvn liberty:run +---- + +=== Testing the Application + +==== Testing MicroProfile Features + +[source,bash] +---- +# OpenAPI documentation +curl -X GET http://localhost:5050/openapi + +# Check if service is in maintenance mode +curl -X GET http://localhost:5050/api/products +---- + +To view the Swagger UI, open the following URL in your browser: +http://localhost:5050/openapi/ui + +To view the landing page with API documentation: +http://localhost:5050/ + +== Server Configuration + +The application uses the following Liberty server configuration: + +[source,xml] +---- + + + jakartaEE-10.0 + microProfile-6.1 + restfulWS + jsonp + jsonb + cdi + mpConfig + mpOpenAPI + + + + + +---- + +== Development + +=== Adding a New Endpoint + +To add a new endpoint: + +1. Create a new method in the `ProductResource` class +2. Add appropriate Jakarta Restful Web Service annotations +3. Add OpenAPI annotations for documentation +4. Implement the business logic + +Example: + +[source,java] +---- +@GET +@Path("/search") +@Produces(MediaType.APPLICATION_JSON) +@Operation(summary = "Search products", description = "Search products by name") +@APIResponses({ + @APIResponse(responseCode = "200", description = "Products matching search criteria") +}) +public Response searchProducts(@QueryParam("name") String name) { + List matchingProducts = products.stream() + .filter(p -> p.getName().toLowerCase().contains(name.toLowerCase())) + .collect(Collectors.toList()); + return Response.ok(matchingProducts).build(); +} +---- + +=== Performance Considerations + +The in-memory data store provides excellent performance for read operations, but there are important considerations: + +* *Memory Usage*: Large data sets may consume significant memory +* *Persistence*: Data is lost when the application restarts +* *Scalability*: In a multi-instance deployment, each instance will have its own data store + +For production scenarios requiring data persistence, consider: + +1. Adding a database layer (PostgreSQL, MongoDB, etc.) +2. Implementing a distributed cache (Hazelcast, Redis, etc.) +3. Adding data synchronization between instances + +=== Concurrency Implementation Details + +==== AtomicLong vs Synchronized Counter + +The repository uses `AtomicLong` rather than traditional synchronized counters: + +[cols="1,1", options="header"] +|=== +| Traditional Approach | AtomicLong Approach +| `private long counter = 0;` | `private final AtomicLong idGenerator = new AtomicLong(1);` +| `synchronized long getNextId() { return ++counter; }` | `long nextId = idGenerator.getAndIncrement();` +| Locks entire method | Lock-free operation +| Subject to contention | Uses CPU compare-and-swap +| Performance degrades with multiple threads | Maintains performance under concurrency +|=== + +==== AtomicLong vs Other Concurrency Options + +[cols="1,1,1,1", options="header"] +|=== +| Feature | AtomicLong | Synchronized | java.util.concurrent.locks.Lock +| Type | Non-blocking | Intrinsic lock | Explicit lock +| Granularity | Single variable | Method/block | Customizable +| Performance under contention | High | Lower | Medium +| Visibility guarantee | Yes | Yes | Yes +| Atomicity guarantee | Yes | Yes | Yes +| Fairness policy | No | No | Optional +| Try/timeout support | Yes (compareAndSet) | No | Yes +| Multiple operations atomicity | Limited | Yes | Yes +| Implementation complexity | Simple | Simple | Complex +|=== + +===== When to Choose AtomicLong + +* *High-Contention Scenarios*: When many threads need to access/modify a counter +* *Single Variable Operations*: When only one variable needs atomic operations +* *Performance-Critical Code*: When minimizing lock contention is essential +* *Read-Heavy Workloads*: When reads significantly outnumber writes + +For this in-memory product repository, AtomicLong provides an optimal balance of safety and performance. + +==== Implementation in createProduct Method + +The ID generation logic handles both automatic and manual ID assignment: + +[source,java] +---- +public Product createProduct(Product product) { + // Generate ID if not provided + if (product.getId() == null) { + product.setId(idGenerator.getAndIncrement()); + } else { + // Update idGenerator if the provided ID is greater than current + long nextId = product.getId() + 1; + while (true) { + long currentId = idGenerator.get(); + if (nextId <= currentId || idGenerator.compareAndSet(currentId, nextId)) { + break; + } + } + } + + productsMap.put(product.getId(), product); + return product; +} +---- + +This implementation ensures ID integrity while supporting both system-generated and client-provided IDs. + +This enables scanning of OpenAPI annotations in the application. + +== Troubleshooting + +=== Common Issues + +* *OpenAPI documentation not available*: Make sure `mp.openapi.scan=true` is set in the properties file +* *Concurrent modification exceptions*: Ensure proper use of thread-safe collections and operations +* *Service always in maintenance mode*: Check the `product.maintenanceMode` property in `microprofile-config.properties` +* *API returning 503 responses*: The service is likely in maintenance mode; set `product.maintenanceMode=false` in configuration +* *OpenAPI documentation not available*: Make sure `mp.openapi.scan=true` is set in the properties file +* *Concurrent modification exceptions*: Ensure proper use of thread-safe collections and operations + +=== Thread Safety Troubleshooting + +If experiencing concurrency issues: + +1. *Verify AtomicLong Usage*: Ensure all ID generation uses `AtomicLong.getAndIncrement()` instead of manual increment +2. *Check Collection Returns*: Always return copies of collections, not direct references: ++ +[source,java] +---- +public List findAllProducts() { + return new ArrayList<>(productsMap.values()); // Correct: returns a new copy +} +---- + +3. *Use ConcurrentHashMap Methods*: Prefer atomic methods like `compute`, `computeIfAbsent`, or `computeIfPresent` for complex operations +4. *Avoid Iteration + Modification*: Don't modify the map while iterating over it + +=== Understanding AtomicLong Internals + +If you need to debug issues with AtomicLong, understanding its internal mechanisms is helpful: + +==== Compare-And-Swap Operation + +AtomicLong relies on hardware-level atomic instructions, specifically Compare-And-Swap (CAS): + +[source,text] +---- +function CAS(address, expected, new): + atomically: + if memory[address] == expected: + memory[address] = new + return true + else: + return false +---- + +The implementation of `getAndIncrement()` uses this mechanism: + +[source,java] +---- +// Simplified implementation of getAndIncrement +public long getAndIncrement() { + while (true) { + long current = get(); + long next = current + 1; + if (compareAndSet(current, next)) + return current; + } +} +---- + +==== Memory Ordering and Visibility + +AtomicLong ensures that memory visibility follows the Java Memory Model: + +* All writes to the AtomicLong by one thread are visible to reads from other threads +* Memory barriers are established when performing atomic operations +* Volatile semantics are guaranteed without using the volatile keyword + +==== Diagnosing AtomicLong Issues + +1. *Unexpected ID Values*: Check for manual ID assignment bypassing the AtomicLong +2. *Duplicate IDs*: Verify the initialization value and ensure all ID assignments go through AtomicLong +3. *Performance Issues*: Look for excessive contention (many threads updating simultaneously) + +=== Logs + +Server logs can be found at: + +[source] +---- +target/liberty/wlp/usr/servers/defaultServer/logs/ +---- + +== Resources + +* https://microprofile.io/[MicroProfile] + +=== HTML Landing Page + +The application includes a user-friendly HTML landing page (`index.html`) that provides: + +* Service overview with comprehensive documentation +* API endpoints documentation with methods and descriptions +* Interactive examples for all API operations +* Links to OpenAPI/Swagger documentation + +==== Maintenance Mode Configuration in the UI + +The index.html page is designed to work seamlessly with the maintenance mode configuration. When maintenance mode is enabled via the `product.maintenanceMode` property, all API endpoints return a 503 Service Unavailable response with the configured maintenance message. + +The landing page displays comprehensive documentation about the API regardless of the maintenance state, allowing developers to continue learning about the API even when the service is undergoing maintenance. + +Key features of the landing page: + +* *Responsive Design*: Works well on desktop and mobile devices +* *Comprehensive API Documentation*: All endpoints with sample requests and responses +* *Interactive Examples*: Detailed sample requests and responses for each endpoint +* *Modern Styling*: Clean, professional appearance with card-based layout + +The landing page is configured as the welcome file in `web.xml`: + +[source,xml] +---- + + index.html + +---- + +This provides a user-friendly entry point for API consumers and developers. + + diff --git a/code/chapter11/catalog/pom.xml b/code/chapter11/catalog/pom.xml new file mode 100644 index 0000000..853bfdc --- /dev/null +++ b/code/chapter11/catalog/pom.xml @@ -0,0 +1,75 @@ + + + 4.0.0 + + io.microprofile.tutorial + catalog + 1.0-SNAPSHOT + war + + + + + 17 + 17 + + UTF-8 + UTF-8 + + + 5050 + 5051 + + catalog + + + + + + + org.projectlombok + lombok + 1.18.26 + provided + + + + + jakarta.platform + jakarta.jakartaee-api + 10.0.0 + provided + + + + + org.eclipse.microprofile + microprofile + 6.1 + pom + provided + + + + + ${project.artifactId} + + + + io.openliberty.tools + liberty-maven-plugin + 3.11.2 + + mpServer + + + + + org.apache.maven.plugins + maven-war-plugin + 3.4.0 + + + + \ No newline at end of file diff --git a/code/chapter11/catalog/src/main/java/io/microprofile/tutorial/store/product/ProductRestApplication.java b/code/chapter11/catalog/src/main/java/io/microprofile/tutorial/store/product/ProductRestApplication.java new file mode 100644 index 0000000..9759e1f --- /dev/null +++ b/code/chapter11/catalog/src/main/java/io/microprofile/tutorial/store/product/ProductRestApplication.java @@ -0,0 +1,9 @@ +package io.microprofile.tutorial.store.product; + +import jakarta.ws.rs.ApplicationPath; +import jakarta.ws.rs.core.Application; + +@ApplicationPath("/api") +public class ProductRestApplication extends Application { + // No additional configuration is needed here +} \ No newline at end of file diff --git a/code/chapter11/catalog/src/main/java/io/microprofile/tutorial/store/product/entity/Product.java b/code/chapter11/catalog/src/main/java/io/microprofile/tutorial/store/product/entity/Product.java new file mode 100644 index 0000000..84e3b23 --- /dev/null +++ b/code/chapter11/catalog/src/main/java/io/microprofile/tutorial/store/product/entity/Product.java @@ -0,0 +1,16 @@ +package io.microprofile.tutorial.store.product.entity; + +import lombok.Data; +import lombok.NoArgsConstructor; +import lombok.AllArgsConstructor; + +@Data +@NoArgsConstructor +@AllArgsConstructor +public class Product { + + private Long id; + private String name; + private String description; + private Double price; +} diff --git a/code/chapter11/catalog/src/main/java/io/microprofile/tutorial/store/product/repository/ProductRepository.java b/code/chapter11/catalog/src/main/java/io/microprofile/tutorial/store/product/repository/ProductRepository.java new file mode 100644 index 0000000..6631fde --- /dev/null +++ b/code/chapter11/catalog/src/main/java/io/microprofile/tutorial/store/product/repository/ProductRepository.java @@ -0,0 +1,138 @@ +package io.microprofile.tutorial.store.product.repository; + +import io.microprofile.tutorial.store.product.entity.Product; +import jakarta.enterprise.context.ApplicationScoped; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.atomic.AtomicLong; +import java.util.logging.Logger; +import java.util.stream.Collectors; + +/** + * Repository class for Product entity. + * Provides in-memory persistence operations using ConcurrentHashMap. + */ +@ApplicationScoped +public class ProductRepository { + + private static final Logger LOGGER = Logger.getLogger(ProductRepository.class.getName()); + + // In-memory storage using ConcurrentHashMap for thread safety + private final Map productsMap = new ConcurrentHashMap<>(); + + // ID generator + private final AtomicLong idGenerator = new AtomicLong(1); + + /** + * Constructor with sample data initialization. + */ + public ProductRepository() { + // Initialize with sample products + createProduct(new Product(null, "iPhone", "Apple iPhone 15", 999.99)); + createProduct(new Product(null, "MacBook", "Apple MacBook Air", 1299.0)); + createProduct(new Product(null, "iPad", "Apple iPad Pro", 799.0)); + LOGGER.info("ProductRepository initialized with sample products"); + } + + /** + * Retrieves all products. + * + * @return List of all products + */ + public List findAllProducts() { + LOGGER.fine("Repository: Finding all products"); + return new ArrayList<>(productsMap.values()); + } + + /** + * Retrieves a product by ID. + * + * @param id Product ID + * @return The product or null if not found + */ + public Product findProductById(Long id) { + LOGGER.fine("Repository: Finding product with ID: " + id); + return productsMap.get(id); + } + + /** + * Creates a new product. + * + * @param product Product data to create + * @return The created product with ID + */ + public Product createProduct(Product product) { + // Generate ID if not provided + if (product.getId() == null) { + product.setId(idGenerator.getAndIncrement()); + } else { + // Update idGenerator if the provided ID is greater than current + long nextId = product.getId() + 1; + while (true) { + long currentId = idGenerator.get(); + if (nextId <= currentId || idGenerator.compareAndSet(currentId, nextId)) { + break; + } + } + } + + LOGGER.fine("Repository: Creating product with ID: " + product.getId()); + productsMap.put(product.getId(), product); + return product; + } + + /** + * Updates an existing product. + * + * @param product Updated product data + * @return The updated product or null if not found + */ + public Product updateProduct(Product product) { + Long id = product.getId(); + if (id != null && productsMap.containsKey(id)) { + LOGGER.fine("Repository: Updating product with ID: " + id); + productsMap.put(id, product); + return product; + } + LOGGER.warning("Repository: Product not found for update, ID: " + id); + return null; + } + + /** + * Deletes a product by ID. + * + * @param id ID of the product to delete + * @return true if deleted, false if not found + */ + public boolean deleteProduct(Long id) { + if (productsMap.containsKey(id)) { + LOGGER.fine("Repository: Deleting product with ID: " + id); + productsMap.remove(id); + return true; + } + LOGGER.warning("Repository: Product not found for deletion, ID: " + id); + return false; + } + + /** + * Searches for products by criteria. + * + * @param name Product name (optional) + * @param description Product description (optional) + * @param minPrice Minimum price (optional) + * @param maxPrice Maximum price (optional) + * @return List of matching products + */ + public List searchProducts(String name, String description, Double minPrice, Double maxPrice) { + LOGGER.fine("Repository: Searching for products with criteria"); + + return productsMap.values().stream() + .filter(p -> name == null || p.getName().toLowerCase().contains(name.toLowerCase())) + .filter(p -> description == null || p.getDescription().toLowerCase().contains(description.toLowerCase())) + .filter(p -> minPrice == null || p.getPrice() >= minPrice) + .filter(p -> maxPrice == null || p.getPrice() <= maxPrice) + .collect(Collectors.toList()); + } +} \ No newline at end of file diff --git a/code/chapter11/catalog/src/main/java/io/microprofile/tutorial/store/product/resource/ProductResource.java b/code/chapter11/catalog/src/main/java/io/microprofile/tutorial/store/product/resource/ProductResource.java new file mode 100644 index 0000000..316ac88 --- /dev/null +++ b/code/chapter11/catalog/src/main/java/io/microprofile/tutorial/store/product/resource/ProductResource.java @@ -0,0 +1,182 @@ +package io.microprofile.tutorial.store.product.resource; + +import java.util.List; +import java.util.logging.Logger; +import java.util.logging.Level; + +import org.eclipse.microprofile.openapi.annotations.Operation; +import org.eclipse.microprofile.openapi.annotations.media.Content; +import org.eclipse.microprofile.openapi.annotations.media.Schema; +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 org.eclipse.microprofile.config.inject.ConfigProperty; + +import io.microprofile.tutorial.store.product.entity.Product; +import io.microprofile.tutorial.store.product.service.ProductService; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.inject.Inject; + +import jakarta.ws.rs.Consumes; +import jakarta.ws.rs.DELETE; +import jakarta.ws.rs.GET; +import jakarta.ws.rs.POST; +import jakarta.ws.rs.PUT; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.PathParam; +import jakarta.ws.rs.Produces; +import jakarta.ws.rs.QueryParam; +import jakarta.ws.rs.core.MediaType; +import jakarta.ws.rs.core.Response; + +@ApplicationScoped +@Path("/products") +@Tag(name = "Product Resource", description = "CRUD operations for products") +public class ProductResource { + + private static final Logger LOGGER = Logger.getLogger(ProductResource.class.getName()); + + @Inject + @ConfigProperty(name="product.maintenanceMode", defaultValue="false") + private boolean maintenanceMode; + + @Inject + private ProductService productService; + + @GET + @Produces(MediaType.APPLICATION_JSON) + @Operation(summary = "List all products", description = "Retrieves a list of all products") + @APIResponses({ + @APIResponse( + responseCode = "200", + description = "Successful, list of products found", + content = @Content(mediaType = "application/json", + schema = @Schema(implementation = Product.class))), + @APIResponse( + responseCode = "400", + description = "Unsuccessful, no products found", + content = @Content(mediaType = "application/json") + ), + @APIResponse( + responseCode = "503", + description = "Service is under maintenance", + content = @Content(mediaType = "application/json") + ) + }) + public Response getAllProducts() { + LOGGER.log(Level.INFO, "REST: Fetching all products"); + List products = productService.findAllProducts(); + + if (maintenanceMode) { + return Response + .status(Response.Status.SERVICE_UNAVAILABLE) + .entity("Service is under maintenance") + .build(); + } + + if (products != null && !products.isEmpty()) { + return Response + .status(Response.Status.OK) + .entity(products).build(); + } else { + return Response + .status(Response.Status.NOT_FOUND) + .entity("No products found") + .build(); + } + } + + @GET + @Path("/{id}") + @Produces(MediaType.APPLICATION_JSON) + @Operation(summary = "Get product by ID", description = "Returns a product by its ID") + @APIResponses({ + @APIResponse(responseCode = "200", description = "Product found", content = @Content(mediaType = "application/json", schema = @Schema(implementation = Product.class))), + @APIResponse(responseCode = "404", description = "Product not found"), + @APIResponse(responseCode = "503", description = "Service is under maintenance") + }) + public Response getProductById(@PathParam("id") Long id) { + LOGGER.log(Level.INFO, "REST: Fetching product with id: {0}", id); + + if (maintenanceMode) { + return Response + .status(Response.Status.SERVICE_UNAVAILABLE) + .entity("Service is under maintenance") + .build(); + } + + Product product = productService.findProductById(id); + if (product != null) { + return Response.ok(product).build(); + } else { + return Response.status(Response.Status.NOT_FOUND).build(); + } + } + + @POST + @Consumes(MediaType.APPLICATION_JSON) + @Produces(MediaType.APPLICATION_JSON) + @Operation(summary = "Create a new product", description = "Creates a new product") + @APIResponses({ + @APIResponse(responseCode = "201", description = "Product created", content = @Content(mediaType = "application/json", schema = @Schema(implementation = Product.class))) + }) + public Response createProduct(Product product) { + LOGGER.info("REST: Creating product: " + product); + Product createdProduct = productService.createProduct(product); + return Response.status(Response.Status.CREATED).entity(createdProduct).build(); + } + + @PUT + @Path("/{id}") + @Consumes(MediaType.APPLICATION_JSON) + @Produces(MediaType.APPLICATION_JSON) + @Operation(summary = "Update a product", description = "Updates an existing product by its ID") + @APIResponses({ + @APIResponse(responseCode = "200", description = "Product updated", content = @Content(mediaType = "application/json", schema = @Schema(implementation = Product.class))), + @APIResponse(responseCode = "404", description = "Product not found") + }) + public Response updateProduct(@PathParam("id") Long id, Product updatedProduct) { + LOGGER.info("REST: Updating product with id: " + id); + Product updated = productService.updateProduct(id, updatedProduct); + if (updated != null) { + return Response.ok(updated).build(); + } else { + return Response.status(Response.Status.NOT_FOUND).build(); + } + } + + @DELETE + @Path("/{id}") + @Produces(MediaType.APPLICATION_JSON) + @Operation(summary = "Delete a product", description = "Deletes a product by its ID") + @APIResponses({ + @APIResponse(responseCode = "204", description = "Product deleted"), + @APIResponse(responseCode = "404", description = "Product not found") + }) + public Response deleteProduct(@PathParam("id") Long id) { + LOGGER.info("REST: Deleting product with id: " + id); + boolean deleted = productService.deleteProduct(id); + if (deleted) { + return Response.noContent().build(); + } else { + return Response.status(Response.Status.NOT_FOUND).build(); + } + } + + @GET + @Path("/search") + @Produces(MediaType.APPLICATION_JSON) + @Operation(summary = "Search products", description = "Search products by criteria") + @APIResponses({ + @APIResponse(responseCode = "200", description = "Search results", content = @Content(mediaType = "application/json", schema = @Schema(implementation = Product.class))) + }) + public Response searchProducts( + @QueryParam("name") String name, + @QueryParam("description") String description, + @QueryParam("minPrice") Double minPrice, + @QueryParam("maxPrice") Double maxPrice) { + LOGGER.info("REST: Searching products with criteria"); + List results = productService.searchProducts(name, description, minPrice, maxPrice); + return Response.ok(results).build(); + } +} \ No newline at end of file diff --git a/code/chapter11/catalog/src/main/java/io/microprofile/tutorial/store/product/service/ProductService.java b/code/chapter11/catalog/src/main/java/io/microprofile/tutorial/store/product/service/ProductService.java new file mode 100644 index 0000000..804fd92 --- /dev/null +++ b/code/chapter11/catalog/src/main/java/io/microprofile/tutorial/store/product/service/ProductService.java @@ -0,0 +1,97 @@ +package io.microprofile.tutorial.store.product.service; + +import io.microprofile.tutorial.store.product.entity.Product; +import io.microprofile.tutorial.store.product.repository.ProductRepository; +import jakarta.enterprise.context.RequestScoped; +import jakarta.inject.Inject; +import java.util.List; +import java.util.logging.Logger; + +/** + * Service class for Product operations. + * Contains business logic for product management. + */ +@RequestScoped +public class ProductService { + + private static final Logger LOGGER = Logger.getLogger(ProductService.class.getName()); + + @Inject + private ProductRepository repository; + + /** + * Retrieves all products. + * + * @return List of all products + */ + public List findAllProducts() { + LOGGER.info("Service: Finding all products"); + return repository.findAllProducts(); + } + + /** + * Retrieves a product by ID. + * + * @param id Product ID + * @return The product or null if not found + */ + public Product findProductById(Long id) { + LOGGER.info("Service: Finding product with ID: " + id); + return repository.findProductById(id); + } + + /** + * Creates a new product. + * + * @param product Product data to create + * @return The created product with ID + */ + public Product createProduct(Product product) { + LOGGER.info("Service: Creating new product: " + product); + return repository.createProduct(product); + } + + /** + * Updates an existing product. + * + * @param id ID of the product to update + * @param updatedProduct Updated product data + * @return The updated product or null if not found + */ + public Product updateProduct(Long id, Product updatedProduct) { + LOGGER.info("Service: Updating product with ID: " + id); + + Product existingProduct = repository.findProductById(id); + if (existingProduct != null) { + // Set the ID to ensure correct update + updatedProduct.setId(id); + return repository.updateProduct(updatedProduct); + } + return null; + } + + /** + * Deletes a product by ID. + * + * @param id ID of the product to delete + * @return true if deleted, false if not found + */ + public boolean deleteProduct(Long id) { + LOGGER.info("Service: Deleting product with ID: " + id); + return repository.deleteProduct(id); + } + + /** + * Searches for products by criteria. + * + * @param name Product name (optional) + * @param description Product description (optional) + * @param minPrice Minimum price (optional) + * @param maxPrice Maximum price (optional) + * @return List of matching products + */ + public List searchProducts(String name, String description, Double minPrice, Double maxPrice) { + LOGGER.info("Service: Searching for products with criteria"); + return repository.searchProducts(name, description, minPrice, maxPrice); + } +} diff --git a/code/chapter11/catalog/src/main/resources/META-INF/microprofile-config.properties b/code/chapter11/catalog/src/main/resources/META-INF/microprofile-config.properties new file mode 100644 index 0000000..03fbb4d --- /dev/null +++ b/code/chapter11/catalog/src/main/resources/META-INF/microprofile-config.properties @@ -0,0 +1,5 @@ +# microprofile-config.properties +product.maintainenceMode=false + +# Enable OpenAPI scanning +mp.openapi.scan=true \ No newline at end of file diff --git a/code/chapter11/catalog/src/main/webapp/WEB-INF/web.xml b/code/chapter11/catalog/src/main/webapp/WEB-INF/web.xml new file mode 100644 index 0000000..1010516 --- /dev/null +++ b/code/chapter11/catalog/src/main/webapp/WEB-INF/web.xml @@ -0,0 +1,13 @@ + + + + Product Catalog Service + + + index.html + + + diff --git a/code/chapter11/catalog/src/main/webapp/index.html b/code/chapter11/catalog/src/main/webapp/index.html new file mode 100644 index 0000000..54622a4 --- /dev/null +++ b/code/chapter11/catalog/src/main/webapp/index.html @@ -0,0 +1,281 @@ + + + + + + Product Catalog Service + + + +
+

Product Catalog Service

+

A microservice for managing product information in the e-commerce platform

+
+ +
+
+

Service Overview

+

The Product Catalog Service provides a REST API for managing product information, including:

+
    +
  • Creating new products
  • +
  • Retrieving product details
  • +
  • Updating existing products
  • +
  • Deleting products
  • +
  • Searching for products by various criteria
  • +
+
+

MicroProfile Config Implementation

+

This service implements configurability as per MicroProfile Config standards. Key configuration properties include:

+
    +
  • product.maintenanceMode - Controls whether the service is in maintenance mode (returns 503 responses)
  • +
  • mp.openapi.scan - Enables automatic OpenAPI documentation generation
  • +
+

MicroProfile Config allows these properties to be changed via environment variables, system properties, or configuration files without requiring application redeployment.

+
+
+ +
+

API Endpoints

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
OperationMethodURLDescription
List All ProductsGET/api/productsRetrieves a list of all products
Get Product by IDGET/api/products/{id}Returns a product by its ID
Create ProductPOST/api/productsCreates a new product
Update ProductPUT/api/products/{id}Updates an existing product by its ID
Delete ProductDELETE/api/products/{id}Deletes a product by its ID
Search ProductsGET/api/products/searchSearch products by criteria (name, description, price range)
+
+ +
+

API Documentation

+

The API is documented using MicroProfile OpenAPI. You can access the Swagger UI at:

+

/openapi/ui

+

The OpenAPI definition is available at:

+

/openapi

+
+ +
+

Sample Usage

+ +

List All Products

+
GET /api/products
+

Response:

+
[
+  {
+    "id": 1,
+    "name": "Smartphone X",
+    "description": "Latest smartphone with advanced features",
+    "price": 799.99
+  },
+  {
+    "id": 2,
+    "name": "Laptop Pro",
+    "description": "High-performance laptop for professionals",
+    "price": 1299.99
+  }
+]
+ +

Get Product by ID

+
GET /api/products/1
+

Response:

+
{
+  "id": 1,
+  "name": "Smartphone X",
+  "description": "Latest smartphone with advanced features",
+  "price": 799.99
+}
+ +

Create a New Product

+
POST /api/products
+Content-Type: application/json
+
+{
+  "name": "Wireless Earbuds",
+  "description": "Premium wireless earbuds with noise cancellation",
+  "price": 149.99
+}
+

Response:

+
{
+  "id": 3,
+  "name": "Wireless Earbuds",
+  "description": "Premium wireless earbuds with noise cancellation",
+  "price": 149.99
+}
+ +

Update a Product

+
PUT /api/products/3
+Content-Type: application/json
+
+{
+  "name": "Wireless Earbuds Pro",
+  "description": "Premium wireless earbuds with advanced noise cancellation",
+  "price": 179.99
+}
+

Response:

+
{
+  "id": 3,
+  "name": "Wireless Earbuds Pro",
+  "description": "Premium wireless earbuds with advanced noise cancellation",
+  "price": 179.99
+}
+ +

Delete a Product

+
DELETE /api/products/3
+

Response: No content (204)

+ +

Search for Products

+
GET /api/products/search?name=laptop&minPrice=1000&maxPrice=2000
+

Response:

+
[
+  {
+    "id": 2,
+    "name": "Laptop Pro",
+    "description": "High-performance laptop for professionals",
+    "price": 1299.99
+  }
+]
+
+
+ +
+

Product Catalog Service

+

© 2025 - MicroProfile APT Tutorial

+
+ + + + diff --git a/code/chapter11/docker-compose.yml b/code/chapter11/docker-compose.yml new file mode 100644 index 0000000..bc6ba42 --- /dev/null +++ b/code/chapter11/docker-compose.yml @@ -0,0 +1,87 @@ +version: '3' + +services: + user-service: + build: ./user + ports: + - "6050:6050" + - "6051:6051" + networks: + - ecommerce-network + environment: + - INVENTORY_SERVICE_URL=http://inventory-service:7050 + - ORDER_SERVICE_URL=http://order-service:8050 + - CATALOG_SERVICE_URL=http://catalog-service:9050 + + inventory-service: + build: ./inventory + ports: + - "7050:7050" + - "7051:7051" + networks: + - ecommerce-network + depends_on: + - user-service + + order-service: + build: ./order + ports: + - "8050:8050" + - "8051:8051" + networks: + - ecommerce-network + depends_on: + - user-service + - inventory-service + + catalog-service: + build: ./catalog + ports: + - "5050:5050" + - "5051:5051" + networks: + - ecommerce-network + depends_on: + - inventory-service + + payment-service: + build: ./payment + ports: + - "9050:9050" + - "9051:9051" + networks: + - ecommerce-network + depends_on: + - user-service + - order-service + + shoppingcart-service: + build: ./shoppingcart + ports: + - "4050:4050" + - "4051:4051" + networks: + - ecommerce-network + depends_on: + - inventory-service + - catalog-service + environment: + - INVENTORY_SERVICE_URL=http://inventory-service:7050 + - CATALOG_SERVICE_URL=http://catalog-service:5050 + + shipment-service: + build: ./shipment + ports: + - "8060:8060" + - "9060:9060" + networks: + - ecommerce-network + depends_on: + - order-service + environment: + - ORDER_SERVICE_URL=http://order-service:8050/order + - MP_CONFIG_PROFILE=docker + +networks: + ecommerce-network: + driver: bridge diff --git a/code/chapter11/inventory/README.adoc b/code/chapter11/inventory/README.adoc new file mode 100644 index 0000000..5622b8a --- /dev/null +++ b/code/chapter11/inventory/README.adoc @@ -0,0 +1,389 @@ += Inventory Service +:toc: left +:icons: font +:source-highlighter: highlightjs + +A comprehensive Jakarta EE and MicroProfile-based REST service for inventory management demonstrating advanced MicroProfile Rest Client integration patterns. + +== Overview + +The Inventory Service is a production-ready microservice built with Jakarta EE 10.0 and MicroProfile 6.1, showcasing comprehensive REST client integration patterns with the Catalog Service. This service demonstrates three different approaches to MicroProfile Rest Client usage: + +* **Injected REST Client** (`@RestClient`) for standard operations +* **RestClientBuilder** with custom timeouts for availability checks +* **Advanced RestClientBuilder** with fine-tuned configuration for detailed operations + +== Key Features + +=== Core Functionality +* Complete CRUD operations for inventory management +* Product validation against catalog service +* Inventory reservation system with availability checks +* Bulk operations support +* Enriched inventory data with product information +* Pagination and filtering capabilities + +=== MicroProfile Rest Client Integration +* **Three distinct REST client approaches** for different use cases +* **Product validation** before inventory operations +* **Service integration** with catalog service on port 5050 +* **Error handling** for non-existent products and service failures +* **Timeout configurations** optimized for different operation types + +=== Advanced Features +* Bean validation for input data +* Comprehensive exception handling +* Transaction management for atomic operations +* OpenAPI documentation with Swagger UI +* Health checks and service monitoring + +== Running the Application + +To start the application, run: + +[source,bash] +---- +cd inventory +mvn liberty:run +---- + +This will start the Open Liberty server on port 7050 (HTTP) and 7051 (HTTPS). + +== MicroProfile Rest Client Implementations + +=== 1. Injected REST Client (`@RestClient`) +Used for standard product validation operations: + +[source,java] +---- +@Inject +@RestClient +private ProductServiceClient productServiceClient; +---- + +**Configuration** (microprofile-config.properties): +[source,properties] +---- +product-service/mp-rest/url=http://localhost:5050/catalog/api +product-service/mp-rest/scope=jakarta.inject.Singleton +product-service/mp-rest/connectTimeout=5000 +product-service/mp-rest/readTimeout=10000 +---- + +=== 2. RestClientBuilder (5s/10s timeout) +Used for lightweight availability checks during reservation: + +[source,java] +---- +ProductServiceClient dynamicClient = RestClientBuilder.newBuilder() + .baseUri(catalogServiceUri) + .connectTimeout(5, TimeUnit.SECONDS) + .readTimeout(10, TimeUnit.SECONDS) + .build(ProductServiceClient.class); +---- + +=== 3. Advanced RestClientBuilder (3s/8s timeout) +Used for detailed product information retrieval: + +[source,java] +---- +ProductServiceClient customClient = RestClientBuilder.newBuilder() + .baseUri(catalogServiceUri) + .connectTimeout(3, TimeUnit.SECONDS) + .readTimeout(8, TimeUnit.SECONDS) + .build(ProductServiceClient.class); +---- + +== Complete API Endpoints + +[cols="1,3,2,3", options="header"] +|=== +|Method |URL |MicroProfile Client |Description + +|GET +|/api/inventories +|None +|Get all inventory items with pagination/filtering + +|POST +|/api/inventories +|@RestClient +|Create new inventory (validates product exists) + +|GET +|/api/inventories/{id} +|None +|Get inventory by ID + +|PUT +|/api/inventories/{id} +|@RestClient +|Update inventory (validates product exists) + +|DELETE +|/api/inventories/{id} +|None +|Delete inventory + +|GET +|/api/inventories/product/{productId} +|None +|Get inventory by product ID + +|PATCH +|/api/inventories/product/{productId}/quantity/{quantity} +|None +|Update product quantity + +|PATCH +|/api/inventories/product/{productId}/reserve/{quantity} +|RestClientBuilder (5s/10s) +|Reserve inventory with availability check + +|GET +|/api/inventories/product-info/{productId} +|Advanced RestClientBuilder (3s/8s) +|Get product details using custom client + +|GET +|/api/inventories/{id}/with-product-info +|@RestClient +|Get enriched inventory with product information + +|POST +|/api/inventories/bulk +|@RestClient +|Bulk create inventories with validation +|=== + +== Service Integration + +=== Catalog Service Integration +The inventory service integrates with the catalog service running on port 5050 to: + +* **Validate products** before creating or updating inventory +* **Check product availability** during reservation operations +* **Enrich inventory data** with product details (name, description, price) +* **Handle service failures** gracefully with appropriate error responses + +=== Error Handling +* **404 responses** when products don't exist in catalog +* **Service timeout handling** with different timeout configurations per operation +* **Fallback behavior** for service communication failures +* **Validation errors** for invalid inventory data + +== Testing with cURL + +=== Basic Operations + +==== Get all inventory items +[source,bash] +---- +curl -X GET http://localhost:7050/inventory/api/inventories +---- + +==== Get inventory by ID +[source,bash] +---- +curl -X GET http://localhost:7050/inventory/api/inventories/1 +---- + +==== Create new inventory (with product validation) +[source,bash] +---- +curl -X POST http://localhost:7050/inventory/api/inventories \ + -H "Content-Type: application/json" \ + -d '{"productId": 1, "quantity": 50, "location": "Warehouse A"}' +---- + +==== Update inventory (with product validation) +[source,bash] +---- +curl -X PUT http://localhost:7050/inventory/api/inventories/1 \ + -H "Content-Type: application/json" \ + -d '{"productId": 1, "quantity": 75, "location": "Warehouse B"}' +---- + +==== Delete inventory +[source,bash] +---- +curl -X DELETE http://localhost:7050/inventory/api/inventories/1 +---- + +=== Advanced Operations + +==== Reserve inventory (uses RestClientBuilder) +[source,bash] +---- +curl -X PATCH http://localhost:7050/inventory/api/inventories/product/1/reserve/10 +---- + +==== Get product info (uses Advanced RestClientBuilder) +[source,bash] +---- +curl -X GET http://localhost:7050/inventory/api/inventories/product-info/1 +---- + +==== Get enriched inventory with product details +[source,bash] +---- +curl -X GET http://localhost:7050/inventory/api/inventories/1/with-product-info +---- + +==== Bulk create inventories +[source,bash] +---- +curl -X POST http://localhost:7050/inventory/api/inventories/bulk \ + -H "Content-Type: application/json" \ + -d '[ + {"productId": 1, "quantity": 50, "location": "Warehouse A"}, + {"productId": 2, "quantity": 30, "location": "Warehouse B"} + ]' +---- + +==== Get inventory with pagination and filtering +[source,bash] +---- +# Get page 1 with 5 items +curl -X GET "http://localhost:7050/inventory/api/inventories?page=1&size=5" + +# Filter by location +curl -X GET "http://localhost:7050/inventory/api/inventories?location=Warehouse%20A" + +# Filter by minimum quantity +curl -X GET "http://localhost:7050/inventory/api/inventories?minQuantity=10" +---- + +== Test Scripts + +Comprehensive test scripts are available to test all functionality: + +* **`test-inventory-endpoints.sh`** - Complete test suite covering all endpoints and MicroProfile Rest Client features +* **`quick-test-commands.sh`** - Quick reference commands for manual testing +* **`TEST-SCRIPTS-README.md`** - Detailed documentation of test scenarios and expected responses + +[source,bash] +---- +# Run comprehensive test suite +./test-inventory-endpoints.sh + +# View test documentation +cat TEST-SCRIPTS-README.md +---- + +== Configuration + +=== MicroProfile Config Properties + +**REST Client Configuration** (`microprofile-config.properties`): +[source,properties] +---- +# Injected REST Client configuration +product-service/mp-rest/url=http://localhost:5050/catalog/api +product-service/mp-rest/scope=jakarta.inject.Singleton +product-service/mp-rest/connectTimeout=5000 +product-service/mp-rest/readTimeout=10000 +product-service/mp-rest/followRedirects=true +---- + +**RestClientBuilder Configuration** (programmatic): +[source,java] +---- +# Availability check client (5s/10s timeout) +URI catalogServiceUri = URI.create("http://localhost:5050/catalog/api"); + +# Product info client (3s/8s timeout) +URI catalogServiceUri = URI.create("http://localhost:5050/catalog/api"); +---- + +== OpenAPI Documentation + +View the complete API documentation: + +* **Swagger UI**: http://localhost:7050/inventory/api/openapi-ui/ +* **OpenAPI JSON**: http://localhost:7050/inventory/api/openapi +* **Service Landing Page**: http://localhost:7050/inventory/ + +== Project Structure + +[source] +---- +inventory/ +├── src/ +│ └── main/ +│ ├── java/ # Java source files +│ │ └── io/microprofile/tutorial/store/inventory/ +│ │ ├── entity/ # Domain entities +│ │ ├── exception/ # Custom exceptions +│ │ ├── service/ # Business logic +│ │ └── resource/ # REST endpoints +│ ├── liberty/ +│ │ └── config/ # Liberty server configuration +│ └── webapp/ # Web resources +└── pom.xml # Project dependencies and build +---- + +== Exception Handling + +The service implements a robust exception handling mechanism: + +[cols="1,2", options="header"] +|=== +|Exception |Purpose + +|`InventoryNotFoundException` +|Thrown when requested inventory item does not exist (HTTP 404) + +|`InventoryConflictException` +|Thrown when attempting to create duplicate inventory (HTTP 409) +|=== + +Exceptions are handled globally using `@Provider`: + +[source,java] +---- +@Provider +public class InventoryExceptionMapper implements ExceptionMapper { + // Maps exceptions to appropriate HTTP responses +} +---- + +== Transaction Management + +The service includes the Jakarta Transactions feature (`transaction-1.3`) but does not use database persistence. In this context, `@Transactional` has limited use: + +* Can be used for transaction-like behavior in memory operations +* Useful when you need to ensure multiple operations are executed atomically +* Provides rollback capability for in-memory state changes +* Primarily used for maintaining consistency in distributed operations + +[NOTE] +==== +Since this service doesn't use database persistence, `@Transactional` mainly serves as a boundary for: + +* Coordinating multiple service method calls +* Managing concurrent access to shared resources +* Ensuring atomic operations across multiple steps +==== + +Example usage: + +[source,java] +---- +@ApplicationScoped +public class InventoryService { + private final ConcurrentHashMap inventoryStore; + + @Transactional + public void updateInventory(Long id, Inventory inventory) { + // Even without persistence, @Transactional can help manage + // atomic operations and coordinate multiple method calls + if (!inventoryStore.containsKey(id)) { + throw new InventoryNotFoundException(id); + } + // Multiple operations that need to be atomic + updateQuantity(id, inventory.getQuantity()); + notifyInventoryChange(id); + } +} +---- diff --git a/code/chapter11/inventory/TEST-SCRIPTS-README.md b/code/chapter11/inventory/TEST-SCRIPTS-README.md new file mode 100644 index 0000000..462a8d8 --- /dev/null +++ b/code/chapter11/inventory/TEST-SCRIPTS-README.md @@ -0,0 +1,191 @@ +# Inventory Service REST API Test Scripts + +This directory contains comprehensive test scripts for the Inventory Service REST API, including full MicroProfile Rest Client integration testing. + +## Test Scripts + +### 1. `test-inventory-endpoints.sh` - Complete Test Suite + +A comprehensive test script that covers all inventory endpoints and MicroProfile Rest Client features. + +#### Usage: +```bash +# Run all tests +./test-inventory-endpoints.sh + +# Run specific test suites +./test-inventory-endpoints.sh --basic # Basic CRUD operations only +./test-inventory-endpoints.sh --restclient # RestClient functionality only +./test-inventory-endpoints.sh --performance # Performance tests only +./test-inventory-endpoints.sh --help # Show help +``` + +#### Test Coverage: +- ✅ **Basic CRUD Operations**: Create, Read, Update, Delete inventory +- ✅ **MicroProfile Rest Client Integration**: Product validation using `@RestClient` injection +- ✅ **RestClientBuilder Functionality**: Programmatic client creation with custom timeouts +- ✅ **Error Handling**: Non-existent products, conflicts, validation errors +- ✅ **Pagination & Filtering**: Query parameters, count operations +- ✅ **Bulk Operations**: Batch create/delete +- ✅ **Advanced Features**: Inventory reservation, product enrichment +- ✅ **Performance Testing**: Response time comparison between different client approaches + +### 2. `quick-test-commands.sh` - Command Reference + +A quick reference showing all available curl commands for manual testing. + +#### Usage: +```bash +./quick-test-commands.sh # Display all available commands +``` + +## MicroProfile Rest Client Features Tested + +### 1. Injected REST Client (`@RestClient`) +Used for product validation in create/update operations: +```java +@Inject +@RestClient +private ProductServiceClient productServiceClient; +``` + +**Endpoints that use this:** +- `POST /inventories` - Create inventory +- `PUT /inventories/{id}` - Update inventory +- `POST /inventories/bulk` - Bulk create + +### 2. RestClientBuilder (5s/10s timeout) +Used for lightweight product availability checks: +```java +ProductServiceClient dynamicClient = RestClientBuilder.newBuilder() + .baseUri(catalogServiceUri) + .connectTimeout(5, TimeUnit.SECONDS) + .readTimeout(10, TimeUnit.SECONDS) + .build(ProductServiceClient.class); +``` + +**Endpoints that use this:** +- `PATCH /inventories/product/{productId}/reserve/{quantity}` - Reserve inventory + +### 3. Advanced RestClientBuilder (3s/8s timeout) +Used for detailed product information retrieval: +```java +ProductServiceClient customClient = RestClientBuilder.newBuilder() + .baseUri(catalogServiceUri) + .connectTimeout(3, TimeUnit.SECONDS) + .readTimeout(8, TimeUnit.SECONDS) + .build(ProductServiceClient.class); +``` + +**Endpoints that use this:** +- `GET /inventories/product-info/{productId}` - Get product details + +## Prerequisites + +### Required Services: +- **Catalog Service**: Running on `http://localhost:5050` +- **Inventory Service**: Running on `http://localhost:7050` + +### Required Tools: +- `curl` - For HTTP requests +- `jq` - For JSON formatting (install with `sudo apt-get install jq`) + +## Example Test Run + +```bash +# Make script executable +chmod +x test-inventory-endpoints.sh + +# Run RestClient tests only +./test-inventory-endpoints.sh --restclient +``` + +### Expected Output: +``` +============================================================================ +🔌 RESTCLIENTBUILDER - PRODUCT AVAILABILITY +============================================================================ + +TEST: Reserve 10 units of product 1 (uses RestClientBuilder) +COMMAND: curl -X PATCH 'http://localhost:7050/inventory/api/inventories/product/1/reserve/10' +{ + "inventoryId": 1, + "productId": 1, + "quantity": 100, + "reservedQuantity": 10 +} +``` + +## API Endpoints Summary + +| Method | Endpoint | Description | RestClient Type | +|--------|----------|-------------|-----------------| +| GET | `/inventories` | Get all inventories | None | +| POST | `/inventories` | Create inventory | @RestClient (validation) | +| GET | `/inventories/{id}` | Get inventory by ID | None | +| PUT | `/inventories/{id}` | Update inventory | @RestClient (validation) | +| DELETE | `/inventories/{id}` | Delete inventory | None | +| GET | `/inventories/product/{productId}` | Get inventory by product ID | None | +| PATCH | `/inventories/product/{productId}/reserve/{quantity}` | Reserve inventory | RestClientBuilder (5s/10s) | +| GET | `/inventories/product-info/{productId}` | Get product info | RestClientBuilder (3s/8s) | +| GET | `/inventories/{id}/with-product-info` | Get enriched inventory | @RestClient (enrichment) | +| POST | `/inventories/bulk` | Bulk create inventories | @RestClient (validation) | + +## Configuration + +The inventory service connects to the catalog service using these configurations: + +**MicroProfile Config** (`microprofile-config.properties`): +```properties +io.microprofile.tutorial.store.inventory.client.ProductServiceClient/mp-rest/url=http://localhost:5050/catalog/api +io.microprofile.tutorial.store.inventory.client.ProductServiceClient/mp-rest/scope=javax.inject.Singleton +``` + +**RestClientBuilder** (programmatic): +```java +URI catalogServiceUri = URI.create("http://localhost:5050/catalog/api"); +``` + +## Troubleshooting + +### Service Not Available +```bash +❌ Catalog Service is not available +❌ Inventory Service is not available +``` +**Solution**: Start both services: +```bash +# Terminal 1 - Catalog Service +cd /workspaces/liberty-rest-app/catalog && mvn liberty:run + +# Terminal 2 - Inventory Service +cd /workspaces/liberty-rest-app/inventory && mvn liberty:dev +``` + +### jq Not Found +```bash +❌ jq is required for JSON formatting +``` +**Solution**: Install jq: +```bash +sudo apt-get install jq # Ubuntu/Debian +brew install jq # macOS +``` + +### Inventory Not Found Errors +If you see "Inventory not found" errors, create some test inventory first: +```bash +curl -X POST 'http://localhost:7050/inventory/api/inventories' \ + -H 'Content-Type: application/json' \ + -d '{"productId": 1, "quantity": 100, "reservedQuantity": 0}' +``` + +## Success Criteria + +A successful test run should show: +- ✅ Both services responding +- ✅ Product validation working (catalog service integration) +- ✅ RestClientBuilder creating clients with custom timeouts +- ✅ Proper error handling for non-existent products +- ✅ All CRUD operations functioning +- ✅ Reservation system working with availability checks diff --git a/code/chapter11/inventory/pom.xml b/code/chapter11/inventory/pom.xml new file mode 100644 index 0000000..888e1c3 --- /dev/null +++ b/code/chapter11/inventory/pom.xml @@ -0,0 +1,149 @@ + + + + 4.0.0 + + io.microprofile + inventory + 1.0-SNAPSHOT + war + + inventory-management + https://microprofile.io + + + UTF-8 + 17 + 10.0.0 + 6.1 + 23.0.0.3 + 1.18.24 + + + + + + jakarta.platform + jakarta.jakartaee-api + ${jakarta.jakartaee-api.version} + provided + + + + org.eclipse.microprofile + microprofile + ${microprofile.version} + pom + provided + + + + org.projectlombok + lombok + ${lombok.version} + provided + + + + + org.junit.jupiter + junit-jupiter-engine + 5.9.2 + test + + + org.junit.jupiter + junit-jupiter-api + 5.9.2 + test + + + org.mockito + mockito-core + 4.11.0 + test + + + org.mockito + mockito-junit-jupiter + 4.11.0 + test + + + + + inventory + + + + io.openliberty.tools + liberty-maven-plugin + 3.8.2 + + inventoryServer + runnable + 120 + + /inventory + + + + + + + + + maven-clean-plugin + 3.1.0 + + + + maven-resources-plugin + 3.0.2 + + + maven-compiler-plugin + 3.8.0 + + + + org.projectlombok + lombok + ${lombok.version} + + + + + + maven-surefire-plugin + 2.22.1 + + + maven-war-plugin + 3.3.2 + + false + + + + maven-install-plugin + 2.5.2 + + + maven-deploy-plugin + 2.8.2 + + + + maven-site-plugin + 3.7.1 + + + maven-project-info-reports-plugin + 3.0.0 + + + + + diff --git a/code/chapter11/inventory/quick-test-commands.sh b/code/chapter11/inventory/quick-test-commands.sh new file mode 100755 index 0000000..0d4be28 --- /dev/null +++ b/code/chapter11/inventory/quick-test-commands.sh @@ -0,0 +1,62 @@ +#!/bin/bash + +# Quick Inventory Service API Test Script +# Simple curl commands for manual testing + +BASE_URL="http://localhost:7050/inventory/api" + +echo "=== QUICK INVENTORY API TESTS ===" +echo + +echo "1. Get all inventories:" +echo "curl -X GET '$BASE_URL/inventories' | jq" +echo + +echo "2. Create inventory for product 1:" +echo "curl -X POST '$BASE_URL/inventories' \\" +echo " -H 'Content-Type: application/json' \\" +echo " -d '{\"productId\": 1, \"quantity\": 100, \"reservedQuantity\": 0}' | jq" +echo + +echo "3. Get inventory by product ID:" +echo "curl -X GET '$BASE_URL/inventories/product/1' | jq" +echo + +echo "4. Reserve inventory (RestClientBuilder availability check):" +echo "curl -X PATCH '$BASE_URL/inventories/product/1/reserve/10' | jq" +echo + +echo "5. Get product info (Advanced RestClientBuilder):" +echo "curl -X GET '$BASE_URL/inventories/product-info/1' | jq" +echo + +echo "6. Update inventory:" +echo "curl -X PUT '$BASE_URL/inventories/1' \\" +echo " -H 'Content-Type: application/json' \\" +echo " -d '{\"productId\": 1, \"quantity\": 120, \"reservedQuantity\": 5}' | jq" +echo + +echo "7. Get inventory with product info:" +echo "curl -X GET '$BASE_URL/inventories/1/with-product-info' | jq" +echo + +echo "8. Filter inventories by quantity:" +echo "curl -X GET '$BASE_URL/inventories?minQuantity=50&maxQuantity=150' | jq" +echo + +echo "9. Bulk create inventories:" +echo "curl -X POST '$BASE_URL/inventories/bulk' \\" +echo " -H 'Content-Type: application/json' \\" +echo " -d '[{\"productId\": 2, \"quantity\": 50}, {\"productId\": 3, \"quantity\": 75}]' | jq" +echo + +echo "10. Delete inventory:" +echo "curl -X DELETE '$BASE_URL/inventories/1' | jq" +echo + +echo "=== MicroProfile Rest Client Features ===" +echo "• Product validation using @RestClient injection" +echo "• RestClientBuilder with custom timeouts (5s/10s for availability)" +echo "• Advanced RestClientBuilder with different timeouts (3s/8s for product info)" +echo "• Error handling for non-existent products" +echo "• Integration with catalog service on port 5050" diff --git a/code/chapter11/inventory/src/main/java/io/microprofile/tutorial/store/inventory/InventoryApplication.java b/code/chapter11/inventory/src/main/java/io/microprofile/tutorial/store/inventory/InventoryApplication.java new file mode 100644 index 0000000..e3c9881 --- /dev/null +++ b/code/chapter11/inventory/src/main/java/io/microprofile/tutorial/store/inventory/InventoryApplication.java @@ -0,0 +1,33 @@ +package io.microprofile.tutorial.store.inventory; + +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 inventory management. + */ +@ApplicationPath("/api") +@OpenAPIDefinition( + info = @Info( + title = "Inventory API", + version = "1.0.0", + description = "API for managing product inventory", + license = @License( + name = "Eclipse Public License 2.0", + url = "https://www.eclipse.org/legal/epl-2.0/"), + contact = @Contact( + name = "Inventory API Support", + email = "support@example.com")), + tags = { + @Tag(name = "Inventory", description = "Operations related to product inventory management") + } +) +public class InventoryApplication extends Application { + // The resources will be discovered automatically +} diff --git a/code/chapter11/inventory/src/main/java/io/microprofile/tutorial/store/inventory/client/ProductServiceClient.java b/code/chapter11/inventory/src/main/java/io/microprofile/tutorial/store/inventory/client/ProductServiceClient.java new file mode 100644 index 0000000..6aee3f2 --- /dev/null +++ b/code/chapter11/inventory/src/main/java/io/microprofile/tutorial/store/inventory/client/ProductServiceClient.java @@ -0,0 +1,23 @@ +package io.microprofile.tutorial.store.inventory.client; + +import jakarta.ws.rs.GET; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.PathParam; +import jakarta.ws.rs.QueryParam; +import java.util.List; + +import org.eclipse.microprofile.rest.client.inject.RegisterRestClient; + +import io.microprofile.tutorial.store.inventory.dto.Product; + +@RegisterRestClient(configKey = "product-service") +@Path("/products") +public interface ProductServiceClient extends AutoCloseable { + + @GET + @Path("/{id}") + Product getProductById(@PathParam("id") Long id); + + @GET + List getProductsByCategory(@QueryParam("category") String category); +} \ No newline at end of file diff --git a/code/chapter11/inventory/src/main/java/io/microprofile/tutorial/store/inventory/dto/InventoryWithProductInfo.java b/code/chapter11/inventory/src/main/java/io/microprofile/tutorial/store/inventory/dto/InventoryWithProductInfo.java new file mode 100644 index 0000000..6aed1f8 --- /dev/null +++ b/code/chapter11/inventory/src/main/java/io/microprofile/tutorial/store/inventory/dto/InventoryWithProductInfo.java @@ -0,0 +1,165 @@ +package io.microprofile.tutorial.store.inventory.dto; + +import io.microprofile.tutorial.store.inventory.entity.Inventory; +import jakarta.json.bind.annotation.JsonbProperty; + +/** + * DTO that combines inventory data with product information from the catalog service. + */ +public class InventoryWithProductInfo { + + @JsonbProperty("inventory") + private Inventory inventory; + + @JsonbProperty("product") + private Product product; + + /** + * Default constructor for JSON-B. + */ + public InventoryWithProductInfo() { + } + + /** + * Constructor with parameters. + * + * @param inventory The inventory information + * @param product The product information from catalog service + */ + public InventoryWithProductInfo(Inventory inventory, Product product) { + this.inventory = inventory; + this.product = product; + } + + /** + * Gets the inventory information. + * + * @return The inventory + */ + public Inventory getInventory() { + return inventory; + } + + /** + * Sets the inventory information. + * + * @param inventory The inventory to set + */ + public void setInventory(Inventory inventory) { + this.inventory = inventory; + } + + /** + * Gets the product information. + * + * @return The product + */ + public Product getProduct() { + return product; + } + + /** + * Sets the product information. + * + * @param product The product to set + */ + public void setProduct(Product product) { + this.product = product; + } + + /** + * Gets the inventory ID. + * + * @return The inventory ID + */ + @JsonbProperty("inventoryId") + public Long getInventoryId() { + return inventory != null ? inventory.getInventoryId() : null; + } + + /** + * Gets the product ID. + * + * @return The product ID + */ + @JsonbProperty("productId") + public Long getProductId() { + return inventory != null ? inventory.getProductId() : null; + } + + /** + * Gets the product name. + * + * @return The product name + */ + @JsonbProperty("productName") + public String getProductName() { + return product != null ? product.getName() : null; + } + + /** + * Gets the product price. + * + * @return The product price + */ + @JsonbProperty("productPrice") + public Double getProductPrice() { + return product != null ? product.getPrice() : null; + } + + /** + * Gets the product category. + * + * @return The product category + */ + @JsonbProperty("productCategory") + public String getProductCategory() { + return product != null ? product.getCategory() : null; + } + + /** + * Gets the inventory quantity. + * + * @return The quantity + */ + @JsonbProperty("quantity") + public Integer getQuantity() { + return inventory != null ? inventory.getQuantity() : null; + } + + /** + * Gets the reserved quantity. + * + * @return The reserved quantity + */ + @JsonbProperty("reservedQuantity") + public Integer getReservedQuantity() { + return inventory != null ? inventory.getReservedQuantity() : null; + } + + /** + * Gets the available quantity (quantity - reserved). + * + * @return The available quantity + */ + @JsonbProperty("availableQuantity") + public Integer getAvailableQuantity() { + if (inventory == null) { + return null; + } + return inventory.getQuantity() - inventory.getReservedQuantity(); + } + + @Override + public String toString() { + return "InventoryWithProductInfo{" + + "inventoryId=" + getInventoryId() + + ", productId=" + getProductId() + + ", productName='" + getProductName() + '\'' + + ", quantity=" + getQuantity() + + ", availableQuantity=" + getAvailableQuantity() + + ", price=" + getProductPrice() + + ", category='" + getProductCategory() + '\'' + + '}'; + } +} diff --git a/code/chapter11/inventory/src/main/java/io/microprofile/tutorial/store/inventory/dto/Product.java b/code/chapter11/inventory/src/main/java/io/microprofile/tutorial/store/inventory/dto/Product.java new file mode 100644 index 0000000..0e7c861 --- /dev/null +++ b/code/chapter11/inventory/src/main/java/io/microprofile/tutorial/store/inventory/dto/Product.java @@ -0,0 +1,69 @@ +package io.microprofile.tutorial.store.inventory.dto; + +import jakarta.json.bind.annotation.JsonbCreator; +import jakarta.json.bind.annotation.JsonbProperty; +import jakarta.json.bind.annotation.JsonbTransient; +import lombok.Data; +import lombok.NoArgsConstructor; +import lombok.AllArgsConstructor; + +/** + * Product DTO for the inventory service. + * This class represents product information received from the product service. + */ +@Data +@NoArgsConstructor +@AllArgsConstructor +public class Product { + + /** + * Unique identifier for the product. + */ + private Long id; + + /** + * Name of the product. + */ + private String name; + + /** + * Price of the product. + */ + private Double price; + + /** + * Category of the product. + */ + private String category; + + /** + * Description of the product. + */ + private String description; + + /** + * Availability status of the product. + */ + @JsonbTransient + private boolean isAvailable = true; + + @JsonbCreator + public Product( + @JsonbProperty("id") Long id, + @JsonbProperty("name") String name, + @JsonbProperty("price") Double price, + @JsonbProperty("category") String category, + @JsonbProperty("description") String description) { + this.id = id; + this.name = name; + this.price = price; + this.category = category; + this.description = description; + } + + @Override + public String toString() { + return String.format("Product{id=%d, name='%s', price=%.2f, category='%s', isAvailable=%b}", + id, name, price, category, isAvailable); + } +} diff --git a/code/chapter11/inventory/src/main/java/io/microprofile/tutorial/store/inventory/entity/Inventory.java b/code/chapter11/inventory/src/main/java/io/microprofile/tutorial/store/inventory/entity/Inventory.java new file mode 100644 index 0000000..6b7b969 --- /dev/null +++ b/code/chapter11/inventory/src/main/java/io/microprofile/tutorial/store/inventory/entity/Inventory.java @@ -0,0 +1,51 @@ +package io.microprofile.tutorial.store.inventory.entity; + +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotNull; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +/** + * Inventory class for the microprofile tutorial store application. + * This class represents inventory information for products in the system. + * We're using an in-memory data structure rather than a database. + */ +@Data +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class Inventory { + + /** + * Unique identifier for the inventory record. + * This can be null for new records before they are persisted. + */ + private Long inventoryId; + + /** + * Reference to the product this inventory record belongs to. + * Must not be null to maintain data integrity. + */ + @NotNull(message = "Product ID cannot be null") + private Long productId; + + /** + * Current quantity of the product available in inventory. + * Must not be null and must be non-negative. + */ + @NotNull(message = "Quantity cannot be null") + @Min(value = 0, message = "Quantity must be greater than or equal to 0") + private Integer quantity; + + /** + * Quantity of the product that is reserved (e.g., in pending orders). + * Must not be null and must be non-negative. + */ + @NotNull(message = "Reserved quantity cannot be null") + @Min(value = 0, message = "Reserved quantity must be greater than or equal to 0") + @Builder.Default + private Integer reservedQuantity = 0; +} diff --git a/code/chapter11/inventory/src/main/java/io/microprofile/tutorial/store/inventory/exception/ErrorResponse.java b/code/chapter11/inventory/src/main/java/io/microprofile/tutorial/store/inventory/exception/ErrorResponse.java new file mode 100644 index 0000000..c99ad4d --- /dev/null +++ b/code/chapter11/inventory/src/main/java/io/microprofile/tutorial/store/inventory/exception/ErrorResponse.java @@ -0,0 +1,103 @@ +package io.microprofile.tutorial.store.inventory.exception; + +import java.util.HashMap; +import java.util.Map; + +/** + * Represents an error response to be returned to the client. + * Used for formatting error messages in a consistent way. + */ +public class ErrorResponse { + private String errorCode; + private String message; + private Map details; + + /** + * Constructs a new ErrorResponse with the specified error code and message. + * + * @param errorCode a code identifying the error type + * @param message a human-readable error message + */ + public ErrorResponse(String errorCode, String message) { + this.errorCode = errorCode; + this.message = message; + this.details = new HashMap<>(); + } + + /** + * Constructs a new ErrorResponse with the specified error code, message, and details. + * + * @param errorCode a code identifying the error type + * @param message a human-readable error message + * @param details additional information about the error + */ + public ErrorResponse(String errorCode, String message, Map details) { + this.errorCode = errorCode; + this.message = message; + this.details = details; + } + + /** + * Gets the error code. + * + * @return the error code + */ + public String getErrorCode() { + return errorCode; + } + + /** + * Sets the error code. + * + * @param errorCode the error code to set + */ + public void setErrorCode(String errorCode) { + this.errorCode = errorCode; + } + + /** + * Gets the error message. + * + * @return the error message + */ + public String getMessage() { + return message; + } + + /** + * Sets the error message. + * + * @param message the error message to set + */ + public void setMessage(String message) { + this.message = message; + } + + /** + * Gets the error details. + * + * @return the error details + */ + public Map getDetails() { + return details; + } + + /** + * Sets the error details. + * + * @param details the error details to set + */ + public void setDetails(Map details) { + this.details = details; + } + + /** + * Adds a detail to the error response. + * + * @param key the detail key + * @param value the detail value + */ + public void addDetail(String key, Object value) { + this.details.put(key, value); + } +} diff --git a/code/chapter11/inventory/src/main/java/io/microprofile/tutorial/store/inventory/exception/InventoryConflictException.java b/code/chapter11/inventory/src/main/java/io/microprofile/tutorial/store/inventory/exception/InventoryConflictException.java new file mode 100644 index 0000000..2201034 --- /dev/null +++ b/code/chapter11/inventory/src/main/java/io/microprofile/tutorial/store/inventory/exception/InventoryConflictException.java @@ -0,0 +1,41 @@ +package io.microprofile.tutorial.store.inventory.exception; + +import jakarta.ws.rs.core.Response; + +/** + * Exception thrown when there is a conflict with an inventory operation, + * such as when trying to create an inventory for a product that already has one. + */ +public class InventoryConflictException extends RuntimeException { + private Response.Status status; + + /** + * Constructs a new InventoryConflictException with the specified message. + * + * @param message the detail message + */ + public InventoryConflictException(String message) { + super(message); + this.status = Response.Status.CONFLICT; + } + + /** + * Constructs a new InventoryConflictException with the specified message and status. + * + * @param message the detail message + * @param status the HTTP status code to return + */ + public InventoryConflictException(String message, Response.Status status) { + super(message); + this.status = status; + } + + /** + * Gets the HTTP status associated with this exception. + * + * @return the HTTP status + */ + public Response.Status getStatus() { + return status; + } +} diff --git a/code/chapter11/inventory/src/main/java/io/microprofile/tutorial/store/inventory/exception/InventoryExceptionMapper.java b/code/chapter11/inventory/src/main/java/io/microprofile/tutorial/store/inventory/exception/InventoryExceptionMapper.java new file mode 100644 index 0000000..224062e --- /dev/null +++ b/code/chapter11/inventory/src/main/java/io/microprofile/tutorial/store/inventory/exception/InventoryExceptionMapper.java @@ -0,0 +1,46 @@ +package io.microprofile.tutorial.store.inventory.exception; + +import jakarta.ws.rs.core.MediaType; +import jakarta.ws.rs.core.Response; +import jakarta.ws.rs.ext.ExceptionMapper; +import jakarta.ws.rs.ext.Provider; +import java.util.logging.Level; +import java.util.logging.Logger; + +/** + * Exception mapper for handling all runtime exceptions in the inventory service. + * Maps exceptions to appropriate HTTP responses with formatted error messages. + */ +@Provider +public class InventoryExceptionMapper implements ExceptionMapper { + + private static final Logger LOGGER = Logger.getLogger(InventoryExceptionMapper.class.getName()); + + @Override + public Response toResponse(RuntimeException exception) { + if (exception instanceof InventoryNotFoundException) { + InventoryNotFoundException notFoundException = (InventoryNotFoundException) exception; + LOGGER.log(Level.INFO, "Resource not found: {0}", exception.getMessage()); + + return Response.status(notFoundException.getStatus()) + .entity(new ErrorResponse("not_found", exception.getMessage())) + .type(MediaType.APPLICATION_JSON) + .build(); + } else if (exception instanceof InventoryConflictException) { + InventoryConflictException conflictException = (InventoryConflictException) exception; + LOGGER.log(Level.INFO, "Resource conflict: {0}", exception.getMessage()); + + return Response.status(conflictException.getStatus()) + .entity(new ErrorResponse("conflict", exception.getMessage())) + .type(MediaType.APPLICATION_JSON) + .build(); + } + + // Handle unexpected exceptions + LOGGER.log(Level.SEVERE, "Unexpected error", exception); + return Response.status(Response.Status.INTERNAL_SERVER_ERROR) + .entity(new ErrorResponse("server_error", "An unexpected error occurred")) + .type(MediaType.APPLICATION_JSON) + .build(); + } +} diff --git a/code/chapter11/inventory/src/main/java/io/microprofile/tutorial/store/inventory/exception/InventoryNotFoundException.java b/code/chapter11/inventory/src/main/java/io/microprofile/tutorial/store/inventory/exception/InventoryNotFoundException.java new file mode 100644 index 0000000..991d633 --- /dev/null +++ b/code/chapter11/inventory/src/main/java/io/microprofile/tutorial/store/inventory/exception/InventoryNotFoundException.java @@ -0,0 +1,40 @@ +package io.microprofile.tutorial.store.inventory.exception; + +import jakarta.ws.rs.core.Response; + +/** + * Exception thrown when an inventory item is not found. + */ +public class InventoryNotFoundException extends RuntimeException { + private Response.Status status; + + /** + * Constructs a new InventoryNotFoundException with the specified message. + * + * @param message the detail message + */ + public InventoryNotFoundException(String message) { + super(message); + this.status = Response.Status.NOT_FOUND; + } + + /** + * Constructs a new InventoryNotFoundException with the specified message and status. + * + * @param message the detail message + * @param status the HTTP status code to return + */ + public InventoryNotFoundException(String message, Response.Status status) { + super(message); + this.status = status; + } + + /** + * Gets the HTTP status associated with this exception. + * + * @return the HTTP status + */ + public Response.Status getStatus() { + return status; + } +} diff --git a/code/chapter11/inventory/src/main/java/io/microprofile/tutorial/store/inventory/package-info.java b/code/chapter11/inventory/src/main/java/io/microprofile/tutorial/store/inventory/package-info.java new file mode 100644 index 0000000..c776c7e --- /dev/null +++ b/code/chapter11/inventory/src/main/java/io/microprofile/tutorial/store/inventory/package-info.java @@ -0,0 +1,13 @@ +/** + * This package contains the Inventory Management application for the MicroProfile tutorial store. + * + * The application demonstrates a Jakarta EE and MicroProfile-based REST service + * for managing product inventory with CRUD operations. + * + * Main Components: + * - Entity class: Contains inventory data with inventory_id, product_id, and quantity + * - 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.inventory; diff --git a/code/chapter11/inventory/src/main/java/io/microprofile/tutorial/store/inventory/repository/InventoryRepository.java b/code/chapter11/inventory/src/main/java/io/microprofile/tutorial/store/inventory/repository/InventoryRepository.java new file mode 100644 index 0000000..05de869 --- /dev/null +++ b/code/chapter11/inventory/src/main/java/io/microprofile/tutorial/store/inventory/repository/InventoryRepository.java @@ -0,0 +1,168 @@ +package io.microprofile.tutorial.store.inventory.repository; + +import io.microprofile.tutorial.store.inventory.entity.Inventory; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.atomic.AtomicLong; +import java.util.logging.Logger; + +import jakarta.enterprise.context.ApplicationScoped; + +/** + * Thread-safe in-memory repository for Inventory objects. + * This class provides CRUD operations for Inventory entities to demonstrate MicroProfile concepts. + */ +@ApplicationScoped +public class InventoryRepository { + + private static final Logger LOGGER = Logger.getLogger(InventoryRepository.class.getName()); + + // Thread-safe map for inventory storage + private final Map inventories = new ConcurrentHashMap<>(); + + // Thread-safe ID generator + private final AtomicLong idGenerator = new AtomicLong(1); + + // Secondary index for faster lookups by productId + private final Map productToInventoryIndex = new ConcurrentHashMap<>(); + + /** + * Saves an inventory item to the repository. + * If the inventory has no ID, a new ID is assigned. + * + * @param inventory The inventory to save + * @return The saved inventory with ID assigned + */ + public Inventory save(Inventory inventory) { + // Generate ID if not provided + if (inventory.getInventoryId() == null) { + inventory.setInventoryId(idGenerator.getAndIncrement()); + } else { + // Update idGenerator if the provided ID is greater than current + long nextId = inventory.getInventoryId() + 1; + while (true) { + long currentId = idGenerator.get(); + if (nextId <= currentId || idGenerator.compareAndSet(currentId, nextId)) { + break; + } + } + } + + LOGGER.fine("Saving inventory with ID: " + inventory.getInventoryId()); + + // Update the inventory and secondary index + inventories.put(inventory.getInventoryId(), inventory); + productToInventoryIndex.put(inventory.getProductId(), inventory.getInventoryId()); + + return inventory; + } + + /** + * Finds an inventory item by ID. + * + * @param id The inventory ID + * @return An Optional containing the inventory if found, or empty if not found + */ + public Optional findById(Long id) { + if (id == null) { + LOGGER.warning("Attempted to find inventory with null ID"); + return Optional.empty(); + } + return Optional.ofNullable(inventories.get(id)); + } + + /** + * Finds inventory by product ID. + * + * @param productId The product ID + * @return An Optional containing the inventory if found, or empty if not found + */ + public Optional findByProductId(Long productId) { + if (productId == null) { + LOGGER.warning("Attempted to find inventory with null product ID"); + return Optional.empty(); + } + + // Use the secondary index for efficient lookup + Long inventoryId = productToInventoryIndex.get(productId); + if (inventoryId != null) { + return Optional.ofNullable(inventories.get(inventoryId)); + } + + // Fall back to scanning if not found in index (ensures consistency) + return inventories.values().stream() + .filter(inventory -> productId.equals(inventory.getProductId())) + .findFirst(); + } + + /** + * Retrieves all inventory items from the repository. + * + * @return A list of all inventory items + */ + public List findAll() { + return new ArrayList<>(inventories.values()); + } + + /** + * Deletes an inventory item by ID. + * + * @param id The ID of the inventory to delete + * @return true if the inventory was deleted, false if not found + */ + public boolean deleteById(Long id) { + if (id == null) { + LOGGER.warning("Attempted to delete inventory with null ID"); + return false; + } + + Inventory removed = inventories.remove(id); + if (removed != null) { + // Also remove from the secondary index + productToInventoryIndex.remove(removed.getProductId()); + LOGGER.fine("Deleted inventory with ID: " + id); + return true; + } + + LOGGER.fine("Failed to delete inventory with ID (not found): " + id); + return false; + } + + /** + * Updates an existing inventory item. + * + * @param id The ID of the inventory to update + * @param inventory The updated inventory information + * @return An Optional containing the updated inventory, or empty if not found + */ + public Optional update(Long id, Inventory inventory) { + if (id == null || inventory == null) { + LOGGER.warning("Attempted to update inventory with null ID or null inventory"); + return Optional.empty(); + } + + if (!inventories.containsKey(id)) { + LOGGER.fine("Failed to update inventory with ID (not found): " + id); + return Optional.empty(); + } + + // Get the existing inventory to update its product index if needed + Inventory existing = inventories.get(id); + if (existing != null && !existing.getProductId().equals(inventory.getProductId())) { + // Product ID changed, update the index + productToInventoryIndex.remove(existing.getProductId()); + } + + // Set ID and update the repository + inventory.setInventoryId(id); + inventories.put(id, inventory); + productToInventoryIndex.put(inventory.getProductId(), id); + + LOGGER.fine("Updated inventory with ID: " + id); + return Optional.of(inventory); + } +} diff --git a/code/chapter11/inventory/src/main/java/io/microprofile/tutorial/store/inventory/resource/InventoryResource.java b/code/chapter11/inventory/src/main/java/io/microprofile/tutorial/store/inventory/resource/InventoryResource.java new file mode 100644 index 0000000..32189b0 --- /dev/null +++ b/code/chapter11/inventory/src/main/java/io/microprofile/tutorial/store/inventory/resource/InventoryResource.java @@ -0,0 +1,266 @@ +package io.microprofile.tutorial.store.inventory.resource; + +import io.microprofile.tutorial.store.inventory.entity.Inventory; +import io.microprofile.tutorial.store.inventory.service.InventoryService; +import io.microprofile.tutorial.store.inventory.dto.Product; + +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 inventory operations. + */ +@Path("/inventories") +@RequestScoped +@Produces(MediaType.APPLICATION_JSON) +@Consumes(MediaType.APPLICATION_JSON) +@Tag(name = "Inventory", description = "Operations related to product inventory management") +public class InventoryResource { + + @Inject + private InventoryService inventoryService; + + @Context + private UriInfo uriInfo; + + @GET + @Operation(summary = "Get all inventory items", description = "Returns a paginated list of inventory items with optional filtering") + @APIResponse( + responseCode = "200", + description = "List of inventory items", + content = @Content( + mediaType = MediaType.APPLICATION_JSON, + schema = @Schema(type = SchemaType.ARRAY, implementation = Inventory.class) + ) + ) + public Response getAllInventories( + @Parameter(description = "Page number (zero-based)", schema = @Schema(defaultValue = "0")) + @QueryParam("page") @DefaultValue("0") int page, + + @Parameter(description = "Page size", schema = @Schema(defaultValue = "20")) + @QueryParam("size") @DefaultValue("20") int size, + + @Parameter(description = "Filter by minimum quantity") + @QueryParam("minQuantity") Integer minQuantity, + + @Parameter(description = "Filter by maximum quantity") + @QueryParam("maxQuantity") Integer maxQuantity) { + + List inventories = inventoryService.getAllInventories(page, size, minQuantity, maxQuantity); + long totalCount = inventoryService.countInventories(minQuantity, maxQuantity); + + return Response.ok(inventories) + .header("X-Total-Count", totalCount) + .header("X-Page-Number", page) + .header("X-Page-Size", size) + .build(); + } + + @GET + @Path("/{id}") + @Operation(summary = "Get inventory item by ID", description = "Returns a specific inventory item by ID") + @APIResponse( + responseCode = "200", + description = "Inventory item", + content = @Content( + mediaType = MediaType.APPLICATION_JSON, + schema = @Schema(implementation = Inventory.class) + ) + ) + @APIResponse( + responseCode = "404", + description = "Inventory not found" + ) + public Inventory getInventoryById( + @Parameter(description = "ID of the inventory item", required = true) + @PathParam("id") Long id) { + return inventoryService.getInventoryById(id); + } + + @GET + @Path("/product/{productId}") + @Operation(summary = "Get inventory item by product ID", description = "Returns inventory information for a specific product") + @APIResponse( + responseCode = "200", + description = "Inventory item", + content = @Content( + mediaType = MediaType.APPLICATION_JSON, + schema = @Schema(implementation = Inventory.class) + ) + ) + @APIResponse( + responseCode = "404", + description = "Inventory not found for product" + ) + public Inventory getInventoryByProductId( + @Parameter(description = "Product ID", required = true) + @PathParam("productId") Long productId) { + return inventoryService.getInventoryByProductId(productId); + } + + @POST + @Operation(summary = "Create new inventory item", description = "Creates a new inventory item") + @APIResponse( + responseCode = "201", + description = "Inventory created", + content = @Content( + mediaType = MediaType.APPLICATION_JSON, + schema = @Schema(implementation = Inventory.class) + ) + ) + @APIResponse( + responseCode = "409", + description = "Inventory for product already exists" + ) + public Response createInventory( + @Parameter(description = "Inventory details", required = true) + @NotNull @Valid Inventory inventory) { + Inventory createdInventory = inventoryService.createInventory(inventory); + URI location = uriInfo.getAbsolutePathBuilder().path(createdInventory.getInventoryId().toString()).build(); + return Response.created(location).entity(createdInventory).build(); + } + + @PUT + @Path("/{id}") + @Operation(summary = "Update inventory item", description = "Updates an existing inventory item") + @APIResponse( + responseCode = "200", + description = "Inventory updated", + content = @Content( + mediaType = MediaType.APPLICATION_JSON, + schema = @Schema(implementation = Inventory.class) + ) + ) + @APIResponse( + responseCode = "404", + description = "Inventory not found" + ) + @APIResponse( + responseCode = "409", + description = "Another inventory record already exists for this product" + ) + public Inventory updateInventory( + @Parameter(description = "ID of the inventory item", required = true) + @PathParam("id") Long id, + @Parameter(description = "Updated inventory details", required = true) + @NotNull @Valid Inventory inventory) { + return inventoryService.updateInventory(id, inventory); + } + + @DELETE + @Path("/{id}") + @Operation(summary = "Delete inventory item", description = "Deletes an inventory item") + @APIResponse( + responseCode = "204", + description = "Inventory deleted" + ) + @APIResponse( + responseCode = "404", + description = "Inventory not found" + ) + public Response deleteInventory( + @Parameter(description = "ID of the inventory item", required = true) + @PathParam("id") Long id) { + inventoryService.deleteInventory(id); + return Response.noContent().build(); + } + + @PATCH + @Path("/product/{productId}/quantity/{quantity}") + @Operation(summary = "Update product quantity", description = "Updates the quantity for a specific product") + @APIResponse( + responseCode = "200", + description = "Quantity updated", + content = @Content( + mediaType = MediaType.APPLICATION_JSON, + schema = @Schema(implementation = Inventory.class) + ) + ) + @APIResponse( + responseCode = "404", + description = "Inventory not found for product" + ) + public Inventory updateQuantity( + @Parameter(description = "Product ID", required = true) + @PathParam("productId") Long productId, + @Parameter(description = "New quantity", required = true) + @PathParam("quantity") int quantity) { + return inventoryService.updateQuantity(productId, quantity); + } + + @PATCH + @Path("/product/{productId}/reserve/{quantity}") + @Operation(summary = "Reserve inventory for a product", + description = "Reserves the specified quantity of inventory for a product if it's available in the catalog") + @APIResponse( + responseCode = "200", + description = "Inventory reserved successfully", + content = @Content( + mediaType = MediaType.APPLICATION_JSON, + schema = @Schema(implementation = Inventory.class) + ) + ) + @APIResponse( + responseCode = "400", + description = "Invalid quantity or insufficient inventory available" + ) + @APIResponse( + responseCode = "404", + description = "Product not found in catalog or inventory not found" + ) + public Inventory reserveInventory( + @Parameter(description = "Product ID", required = true) + @PathParam("productId") Long productId, + @Parameter(description = "Quantity to reserve", required = true) + @PathParam("quantity") int quantity) { + return inventoryService.reserveInventory(productId, quantity); + } + + @GET + @Path("/product-info/{productId}") + @Operation(summary = "Get product information using custom RestClientBuilder", + description = "Demonstrates advanced RestClientBuilder usage with custom timeout configuration") + @APIResponse( + responseCode = "200", + description = "Product information retrieved successfully", + content = @Content( + mediaType = MediaType.APPLICATION_JSON, + schema = @Schema(implementation = Product.class) + ) + ) + @APIResponse( + responseCode = "404", + description = "Product not found" + ) + public Response getProductInfo( + @Parameter(description = "Product ID", required = true) + @PathParam("productId") Long productId) { + + Product product = inventoryService.getProductWithCustomClient(productId); + if (product != null) { + return Response.ok(product).build(); + } else { + return Response.status(Response.Status.NOT_FOUND) + .entity("{\"message\": \"Product not found\"}") + .build(); + } + } +} diff --git a/code/chapter11/inventory/src/main/java/io/microprofile/tutorial/store/inventory/service/InventoryService.java b/code/chapter11/inventory/src/main/java/io/microprofile/tutorial/store/inventory/service/InventoryService.java new file mode 100644 index 0000000..8345d6d --- /dev/null +++ b/code/chapter11/inventory/src/main/java/io/microprofile/tutorial/store/inventory/service/InventoryService.java @@ -0,0 +1,492 @@ +package io.microprofile.tutorial.store.inventory.service; + +import io.microprofile.tutorial.store.inventory.entity.Inventory; +import io.microprofile.tutorial.store.inventory.exception.InventoryConflictException; +import io.microprofile.tutorial.store.inventory.exception.InventoryNotFoundException; +import io.microprofile.tutorial.store.inventory.repository.InventoryRepository; +import io.microprofile.tutorial.store.inventory.client.ProductServiceClient; +import io.microprofile.tutorial.store.inventory.dto.Product; +import io.microprofile.tutorial.store.inventory.dto.InventoryWithProductInfo; + +import java.net.URI; +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; +import java.util.concurrent.TimeUnit; +import java.util.logging.Level; +import java.util.logging.Logger; +import java.util.stream.Collectors; + +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.inject.Inject; +import jakarta.ws.rs.core.Response; +import jakarta.ws.rs.WebApplicationException; +import jakarta.transaction.Transactional; +import org.eclipse.microprofile.rest.client.RestClientBuilder; +import org.eclipse.microprofile.rest.client.inject.RestClient; + +/** + * Service class for Inventory management operations. + */ +@ApplicationScoped +public class InventoryService { + + private static final Logger LOGGER = Logger.getLogger(InventoryService.class.getName()); + + @Inject + private InventoryRepository inventoryRepository; + + @Inject + @RestClient + private ProductServiceClient productServiceClient; + + /** + * Checks if a product is available in the catalog service. + * This method demonstrates the use of RestClientBuilder for programmatic REST client creation. + * This is a lightweight check that returns only a boolean result. + * + * @param productId The product ID to check + * @return true if the product exists, false otherwise + */ + public boolean isProductAvailable(Long productId) { + LOGGER.fine("Checking product availability for ID: " + productId); + + try { + // Demonstrate RestClientBuilder usage - build a REST client programmatically + URI catalogServiceUri = URI.create("http://localhost:5050/catalog/api"); + + ProductServiceClient dynamicClient = RestClientBuilder.newBuilder() + .baseUri(catalogServiceUri) + .connectTimeout(5, TimeUnit.SECONDS) + .readTimeout(10, TimeUnit.SECONDS) + .build(ProductServiceClient.class); + + LOGGER.fine("Built dynamic REST client for catalog service at: " + catalogServiceUri); + + Product product = dynamicClient.getProductById(productId); + boolean available = product != null; + LOGGER.fine("Product " + productId + " availability check via RestClientBuilder: " + available); + return available; + + } catch (WebApplicationException e) { + if (e.getResponse().getStatus() == 404) { + LOGGER.fine("Product " + productId + " not found in catalog (via RestClientBuilder)"); + return false; + } + LOGGER.warning("Error checking product availability for ID " + productId + " via RestClientBuilder: " + e.getMessage()); + return false; + } catch (Exception e) { + LOGGER.log(Level.WARNING, "Unexpected error checking product availability for ID " + productId + " via RestClientBuilder", e); + return false; + } + } + + /** + * Validates that a product exists in the catalog service. + * + * @param productId The product ID to validate + * @return The product details if found + * @throws InventoryNotFoundException if the product is not found in the catalog + */ + private Product validateProductExists(Long productId) { + LOGGER.fine("Validating product existence for ID: " + productId); + + try { + Product product = productServiceClient.getProductById(productId); + if (product == null) { + throw new InventoryNotFoundException("Product not found in catalog with ID: " + productId); + } + LOGGER.fine("Product validated successfully: " + product.getName()); + return product; + } catch (WebApplicationException e) { + LOGGER.warning("Product validation failed for ID " + productId + ": " + e.getMessage()); + if (e.getResponse().getStatus() == 404) { + throw new InventoryNotFoundException("Product not found in catalog with ID: " + productId); + } + throw new RuntimeException("Failed to validate product with catalog service: " + e.getMessage(), e); + } catch (Exception e) { + LOGGER.log(Level.SEVERE, "Unexpected error validating product " + productId, e); + throw new RuntimeException("Failed to validate product with catalog service: " + e.getMessage(), e); + } + } + + /** + * Creates a new inventory item. + * + * @param inventory The inventory to create + * @return The created inventory + * @throws InventoryConflictException if inventory with the product ID already exists + */ + @Transactional + public Inventory createInventory(Inventory inventory) { + LOGGER.info("Creating inventory for product ID: " + inventory.getProductId()); + + // Validate that the product exists in the catalog service + Product product = validateProductExists(inventory.getProductId()); + LOGGER.info("Product validated: " + product.getName() + " (Price: $" + product.getPrice() + ")"); + + // Check if product ID already exists + Optional existingInventory = inventoryRepository.findByProductId(inventory.getProductId()); + if (existingInventory.isPresent()) { + LOGGER.warning("Conflict: Inventory already exists for product ID: " + inventory.getProductId()); + throw new InventoryConflictException("Inventory for product already exists", Response.Status.CONFLICT); + } + + Inventory result = inventoryRepository.save(inventory); + LOGGER.info("Created inventory ID: " + result.getInventoryId() + " for product ID: " + result.getProductId()); + return result; + } + + /** + * Creates new inventory items in bulk. + * + * @param inventories The list of inventories to create + * @return The list of created inventories + * @throws InventoryConflictException if any inventory with the same product ID already exists + */ + @Transactional + public List createBulkInventories(List inventories) { + LOGGER.info("Creating bulk inventories: " + inventories.size() + " items"); + + // Validate products exist in catalog and check for conflicts + for (Inventory inventory : inventories) { + // Validate product exists in catalog + Product product = validateProductExists(inventory.getProductId()); + LOGGER.fine("Product validated for bulk create: " + product.getName() + " (ID: " + inventory.getProductId() + ")"); + + // Check for existing inventory records + Optional existingInventory = inventoryRepository.findByProductId(inventory.getProductId()); + if (existingInventory.isPresent()) { + LOGGER.warning("Conflict detected during bulk create for product ID: " + inventory.getProductId()); + throw new InventoryConflictException("Inventory for product already exists: " + inventory.getProductId()); + } + } + + // Save all inventories + List created = new ArrayList<>(); + for (Inventory inventory : inventories) { + created.add(inventoryRepository.save(inventory)); + } + + LOGGER.info("Successfully created " + created.size() + " inventory items"); + return created; + } + + /** + * Gets an inventory item by ID. + * + * @param id The inventory ID + * @return The inventory + * @throws InventoryNotFoundException if the inventory is not found + */ + public Inventory getInventoryById(Long id) { + LOGGER.fine("Getting inventory by ID: " + id); + return inventoryRepository.findById(id) + .orElseThrow(() -> { + LOGGER.warning("Inventory not found with ID: " + id); + return new InventoryNotFoundException("Inventory not found with ID: " + id); + }); + } + + /** + * Gets inventory by product ID. + * + * @param productId The product ID + * @return The inventory + * @throws InventoryNotFoundException if the inventory is not found + */ + public Inventory getInventoryByProductId(Long productId) { + LOGGER.fine("Getting inventory by product ID: " + productId); + return inventoryRepository.findByProductId(productId) + .orElseThrow(() -> { + LOGGER.warning("Inventory not found for product ID: " + productId); + return new InventoryNotFoundException("Inventory not found for product", Response.Status.NOT_FOUND); + }); + } + + /** + * Gets all inventory items. + * + * @return A list of all inventory items + */ + public List getAllInventories() { + LOGGER.fine("Getting all inventory items"); + return inventoryRepository.findAll(); + } + + /** + * Gets inventory items with pagination and filtering. + * + * @param page Page number (zero-based) + * @param size Page size + * @param minQuantity Minimum quantity filter (optional) + * @param maxQuantity Maximum quantity filter (optional) + * @return A filtered and paginated list of inventory items + */ + public List getAllInventories(int page, int size, Integer minQuantity, Integer maxQuantity) { + LOGGER.fine("Getting inventory items with pagination: page=" + page + ", size=" + size + + ", minQuantity=" + minQuantity + ", maxQuantity=" + maxQuantity); + + // First, get all inventories + List allInventories = inventoryRepository.findAll(); + + // Apply filters if provided + List filteredInventories = allInventories.stream() + .filter(inv -> minQuantity == null || inv.getQuantity() >= minQuantity) + .filter(inv -> maxQuantity == null || inv.getQuantity() <= maxQuantity) + .collect(Collectors.toList()); + + // Apply pagination + int startIndex = page * size; + int endIndex = Math.min(startIndex + size, filteredInventories.size()); + + // Check if the start index is valid + if (startIndex >= filteredInventories.size()) { + return new ArrayList<>(); + } + + return filteredInventories.subList(startIndex, endIndex); + } + + /** + * Counts inventory items with filtering. + * + * @param minQuantity Minimum quantity filter (optional) + * @param maxQuantity Maximum quantity filter (optional) + * @return The count of inventory items that match the filters + */ + public long countInventories(Integer minQuantity, Integer maxQuantity) { + LOGGER.fine("Counting inventory items with filters: minQuantity=" + minQuantity + + ", maxQuantity=" + maxQuantity); + + List allInventories = inventoryRepository.findAll(); + + // Apply filters and count + return allInventories.stream() + .filter(inv -> minQuantity == null || inv.getQuantity() >= minQuantity) + .filter(inv -> maxQuantity == null || inv.getQuantity() <= maxQuantity) + .count(); + } + + /** + * Updates an inventory item. + * + * @param id The inventory ID + * @param inventory The updated inventory information + * @return The updated inventory + * @throws InventoryNotFoundException if the inventory is not found + * @throws InventoryConflictException if another inventory with the same product ID exists + */ + @Transactional + public Inventory updateInventory(Long id, Inventory inventory) { + LOGGER.info("Updating inventory ID: " + id + " for product ID: " + inventory.getProductId()); + + // Validate that the product exists in the catalog service + Product product = validateProductExists(inventory.getProductId()); + LOGGER.info("Product validated for update: " + product.getName() + " (ID: " + product.getId() + ")"); + + // Check if product ID exists in a different inventory record + Optional existingInventoryWithProductId = inventoryRepository.findByProductId(inventory.getProductId()); + if (existingInventoryWithProductId.isPresent() && + !existingInventoryWithProductId.get().getInventoryId().equals(id)) { + LOGGER.warning("Conflict: Another inventory record exists for product ID: " + inventory.getProductId()); + throw new InventoryConflictException("Another inventory record already exists for this product", + Response.Status.CONFLICT); + } + + return inventoryRepository.update(id, inventory) + .orElseThrow(() -> { + LOGGER.warning("Inventory not found with ID: " + id); + return new InventoryNotFoundException("Inventory not found", Response.Status.NOT_FOUND); + }); + } + + /** + * Deletes an inventory item. + * + * @param id The inventory ID + * @throws InventoryNotFoundException if the inventory is not found + */ + @Transactional + public void deleteInventory(Long id) { + LOGGER.info("Deleting inventory with ID: " + id); + boolean deleted = inventoryRepository.deleteById(id); + if (!deleted) { + LOGGER.warning("Inventory not found with ID: " + id); + throw new InventoryNotFoundException("Inventory not found", Response.Status.NOT_FOUND); + } + LOGGER.info("Successfully deleted inventory with ID: " + id); + } + + /** + * Updates the quantity for a product. + * + * @param productId The product ID + * @param quantity The new quantity + * @return The updated inventory + * @throws InventoryNotFoundException if the inventory is not found + */ + @Transactional + public Inventory updateQuantity(Long productId, int quantity) { + if (quantity < 0) { + LOGGER.warning("Invalid quantity: " + quantity + " for product ID: " + productId); + throw new IllegalArgumentException("Quantity cannot be negative"); + } + + LOGGER.info("Updating quantity to " + quantity + " for product ID: " + productId); + Inventory inventory = getInventoryByProductId(productId); + int oldQuantity = inventory.getQuantity(); + inventory.setQuantity(quantity); + + Inventory updated = inventoryRepository.save(inventory); + LOGGER.info("Updated quantity from " + oldQuantity + " to " + quantity + + " for product ID: " + productId + " (inventory ID: " + inventory.getInventoryId() + ")"); + + return updated; + } + + /** + * Gets product information for an inventory item. + * + * @param inventory The inventory item + * @return The product details + */ + public Product getProductInfo(Inventory inventory) { + return validateProductExists(inventory.getProductId()); + } + + /** + * Gets inventory with enriched product information. + * + * @param inventoryId The inventory ID + * @return Inventory with product details + */ + public InventoryWithProductInfo getInventoryWithProductInfo(Long inventoryId) { + Inventory inventory = getInventoryById(inventoryId); + Product product = validateProductExists(inventory.getProductId()); + + return new InventoryWithProductInfo(inventory, product); + } + + /** + * Gets all inventories with product information for a specific category. + * + * @param category The product category + * @return List of inventories for products in the specified category + */ + public List getInventoriesByCategory(String category) { + LOGGER.info("Getting inventories for category: " + category); + + try { + // Get products by category from catalog service + List productsInCategory = productServiceClient.getProductsByCategory(category); + + if (productsInCategory == null || productsInCategory.isEmpty()) { + LOGGER.info("No products found in category: " + category); + return new ArrayList<>(); + } + + // Find inventories for these products + List result = new ArrayList<>(); + for (Product product : productsInCategory) { + try { + Inventory inventory = inventoryRepository.findByProductId(product.getId()).orElse(null); + if (inventory != null) { + result.add(new InventoryWithProductInfo(inventory, product)); + } + } catch (Exception e) { + LOGGER.warning("Error getting inventory for product " + product.getId() + ": " + e.getMessage()); + } + } + + LOGGER.info("Found " + result.size() + " inventory items for category: " + category); + return result; + + } catch (WebApplicationException e) { + LOGGER.warning("Failed to get products by category from catalog service: " + e.getMessage()); + throw new RuntimeException("Failed to retrieve products by category: " + e.getMessage(), e); + } + } + + /** + * Reserves inventory for a product if it's available in the catalog. + * This method uses isProductAvailable for a lightweight check before reservation. + * + * @param productId The product ID + * @param quantityToReserve The quantity to reserve + * @return The updated inventory after reservation + * @throws InventoryNotFoundException if the inventory or product is not found + * @throws IllegalArgumentException if there's insufficient inventory + */ + @Transactional + public Inventory reserveInventory(Long productId, int quantityToReserve) { + if (quantityToReserve <= 0) { + throw new IllegalArgumentException("Quantity to reserve must be positive"); + } + + LOGGER.info("Attempting to reserve " + quantityToReserve + " units for product ID: " + productId); + + // Use isProductAvailable for a lightweight availability check + if (!isProductAvailable(productId)) { + LOGGER.warning("Cannot reserve inventory - product " + productId + " is not available in catalog"); + throw new InventoryNotFoundException("Product is not available in catalog: " + productId); + } + + // Get the current inventory + Inventory inventory = getInventoryByProductId(productId); + + // Check if we have enough inventory to reserve + int availableQuantity = inventory.getQuantity() - inventory.getReservedQuantity(); + if (availableQuantity < quantityToReserve) { + LOGGER.warning("Insufficient inventory to reserve " + quantityToReserve + + " units for product " + productId + ". Available: " + availableQuantity); + throw new IllegalArgumentException("Insufficient inventory available. Requested: " + + quantityToReserve + ", Available: " + availableQuantity); + } + + // Update reserved quantity + inventory.setReservedQuantity(inventory.getReservedQuantity() + quantityToReserve); + + Inventory updated = inventoryRepository.save(inventory); + LOGGER.info("Reserved " + quantityToReserve + " units for product " + productId + + ". New reserved quantity: " + updated.getReservedQuantity()); + + return updated; + } + + /** + * Demonstrates advanced RestClientBuilder usage with custom configuration. + * This method builds a REST client with specific timeout and error handling settings. + * + * @param productId The product ID to check + * @return Product details if found, null otherwise + */ + public Product getProductWithCustomClient(Long productId) { + LOGGER.info("Getting product details using custom RestClientBuilder for ID: " + productId); + + try { + // Build REST client with custom configuration + URI catalogServiceUri = URI.create("http://localhost:5050/catalog/api"); + + ProductServiceClient customClient = RestClientBuilder.newBuilder() + .baseUri(catalogServiceUri) + .connectTimeout(3, TimeUnit.SECONDS) // Custom connect timeout + .readTimeout(8, TimeUnit.SECONDS) // Custom read timeout + .build(ProductServiceClient.class); + + LOGGER.info("Built custom REST client with 3s connect and 8s read timeout"); + + Product product = customClient.getProductById(productId); + LOGGER.info("Retrieved product via custom client: " + (product != null ? product.getName() : "null")); + return product; + + } catch (WebApplicationException e) { + LOGGER.warning("WebApplicationException from custom client for product " + productId + + ": Status=" + e.getResponse().getStatus() + ", Message=" + e.getMessage()); + return null; + } catch (Exception e) { + LOGGER.log(Level.WARNING, "Unexpected error from custom REST client for product " + productId, e); + return null; + } + } +} diff --git a/code/chapter11/inventory/src/main/webapp/META-INF/microprofile-config.properties b/code/chapter11/inventory/src/main/webapp/META-INF/microprofile-config.properties new file mode 100644 index 0000000..26358a6 --- /dev/null +++ b/code/chapter11/inventory/src/main/webapp/META-INF/microprofile-config.properties @@ -0,0 +1,5 @@ +product-service/mp-rest/url=http://localhost:5050/catalog/api +product-service/mp-rest/scope=jakarta.inject.Singleton +product-service/mp-rest/connectTimeout=5000 +product-service/mp-rest/readTimeout=10000 +product-service/mp-rest/followRedirects=true \ No newline at end of file diff --git a/code/chapter11/inventory/src/main/webapp/WEB-INF/beans.xml b/code/chapter11/inventory/src/main/webapp/WEB-INF/beans.xml new file mode 100644 index 0000000..ba21c47 --- /dev/null +++ b/code/chapter11/inventory/src/main/webapp/WEB-INF/beans.xml @@ -0,0 +1,7 @@ + + + diff --git a/code/chapter11/inventory/src/main/webapp/WEB-INF/web.xml b/code/chapter11/inventory/src/main/webapp/WEB-INF/web.xml new file mode 100644 index 0000000..5a812df --- /dev/null +++ b/code/chapter11/inventory/src/main/webapp/WEB-INF/web.xml @@ -0,0 +1,10 @@ + + + Inventory Management + + index.html + + diff --git a/code/chapter11/inventory/src/main/webapp/index.html b/code/chapter11/inventory/src/main/webapp/index.html new file mode 100644 index 0000000..d476376 --- /dev/null +++ b/code/chapter11/inventory/src/main/webapp/index.html @@ -0,0 +1,350 @@ + + + + + + Inventory Management Service + + + +

Inventory Management Service

+

Welcome to the Inventory Management API, a Jakarta EE and MicroProfile demo featuring comprehensive MicroProfile Rest Client integration.

+ +
+

🔌 MicroProfile Rest Client Integration

+

This service demonstrates three different approaches to using MicroProfile Rest Client:

+
    +
  • CDI Injection (@RestClient) - For standard product validation
  • +
  • RestClientBuilder (5s/10s timeout) - For lightweight availability checks
  • +
  • Advanced RestClientBuilder (3s/8s timeout) - For detailed product information
  • +
+

Catalog Service Integration: http://localhost:5050/catalog/api

+
+ +
+

⏱️ Timeout Configuration Details

+

Our implementation demonstrates different timeout strategies for various use cases:

+ +
+
+

🔌 CDI Injection (@RestClient)

+

Configuration: Via microprofile-config.properties

+

Connect Timeout: Default (30s)

+

Read Timeout: Default (30s)

+

Use Case: Standard operations with reliable timeouts

+
+ +
+

⚡ RestClientBuilder (5s/10s)

+

Configuration: Programmatic

+

Connect Timeout: 5 seconds

+

Read Timeout: 10 seconds

+

Use Case: Quick availability checks

+
+ +
+

🚀 Advanced RestClientBuilder (3s/8s)

+

Configuration: Programmatic

+

Connect Timeout: 3 seconds

+

Read Timeout: 8 seconds

+

Use Case: Fast product info retrieval

+
+
+ +

📊 Timeout Configuration Comparison

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Client TypeConnect TimeoutRead TimeoutConfiguration MethodEndpoints Using ItPurpose
@RestClient Injection30s (default)30s (default)microprofile-config.propertiesPOST/PUT inventories, bulk operationsReliable product validation
RestClientBuilder (Standard)5 seconds10 secondsRestClientBuilder.connectTimeout()PATCH /reserve/{quantity}Quick availability checks
RestClientBuilder (Advanced)3 seconds8 secondsRestClientBuilder.readTimeout()GET /product-info/{productId}Fast product information
+ +

🔧 Timeout Configuration Code Examples

+

1. CDI Injection Configuration (microprofile-config.properties):

+
# Default timeouts - can be customized via properties
+io.microprofile.tutorial.store.inventory.client.ProductServiceClient/mp-rest/url=http://localhost:5050/catalog/api
+io.microprofile.tutorial.store.inventory.client.ProductServiceClient/mp-rest/scope=javax.inject.Singleton
+
+# Optional custom timeouts (if needed):
+# io.microprofile.tutorial.store.inventory.client.ProductServiceClient/mp-rest/connectTimeout=30000
+# io.microprofile.tutorial.store.inventory.client.ProductServiceClient/mp-rest/readTimeout=30000
+ +

2. RestClientBuilder with 5s/10s Timeouts (Availability Check):

+
ProductServiceClient dynamicClient = RestClientBuilder.newBuilder()
+    .baseUri(URI.create("http://localhost:5050/catalog/api"))
+    .connectTimeout(5, TimeUnit.SECONDS)    // 5 seconds to establish connection
+    .readTimeout(10, TimeUnit.SECONDS)      // 10 seconds to read response
+    .build(ProductServiceClient.class);
+ +

3. Advanced RestClientBuilder with 3s/8s Timeouts (Product Info):

+
ProductServiceClient customClient = RestClientBuilder.newBuilder()
+    .baseUri(URI.create("http://localhost:5050/catalog/api"))
+    .connectTimeout(3, TimeUnit.SECONDS)    // 3 seconds to establish connection
+    .readTimeout(8, TimeUnit.SECONDS)       // 8 seconds to read response
+    .build(ProductServiceClient.class);
+ +

📈 Timeout Strategy Benefits

+
    +
  • Connect Timeout: Prevents hanging when catalog service is unreachable
  • +
  • Read Timeout: Ensures timely response even if catalog service is slow
  • +
  • Different Strategies: Optimized timeouts for different operation types
  • +
  • Fail-Fast Behavior: Quick error detection and graceful degradation
  • +
  • Performance Optimization: Shorter timeouts for non-critical operations
  • +
+
+ +

Available Endpoints:

+ +
+

OpenAPI Documentation API Docs

+

GET /openapi - Access OpenAPI documentation

+ View API Documentation +
+ +
+

Basic Inventory Operations

+

GET /api/inventories - Get all inventory items

+

GET /api/inventories/{id} - Get inventory by ID

+

GET /api/inventories/product/{productId} - Get inventory by product ID

+

POST /api/inventories - Create new inventory @RestClient Validation

+

PUT /api/inventories/{id} - Update inventory @RestClient Validation

+

DELETE /api/inventories/{id} - Delete inventory

+

PATCH /api/inventories/product/{productId}/quantity/{quantity} - Update product quantity

+
+ +
+

🔌 MicroProfile Rest Client Features

+ +

1. Injected REST Client (@RestClient)

+

POST /api/inventories - Product validation during inventory creation

+

PUT /api/inventories/{id} - Product validation during inventory updates

+

POST /api/inventories/bulk - Bulk inventory creation with validation

+

GET /api/inventories/{id}/with-product-info - Enriched inventory with product details

+

GET /api/inventories/category/{category} - Inventories filtered by product category

+ +

2. RestClientBuilder (5s connect / 10s read timeout)

+

PATCH /api/inventories/product/{productId}/reserve/{quantity} - Reserve inventory with availability check

+ +

3. Advanced RestClientBuilder (3s connect / 8s read timeout)

+

GET /api/inventories/product-info/{productId} - Get detailed product information

+
+ +
+

🚀 Advanced Features

+

GET /api/inventories?page={page}&size={size} - Pagination support

+

GET /api/inventories?minQuantity={min}&maxQuantity={max} - Quantity filtering

+

GET /api/inventories/count?minQuantity={min}&maxQuantity={max} - Count with filters

+

POST /api/inventories/bulk - Bulk inventory operations

+
+ +

Example Requests

+ +
+

💡 Quick Start Examples

+ +

1. Basic Operations

+
# Get all inventories
+curl -X GET http://localhost:7050/inventory/api/inventories
+
+# Create inventory (with automatic product validation)
+curl -X POST http://localhost:7050/inventory/api/inventories \
+  -H "Content-Type: application/json" \
+  -d '{"productId": 1, "quantity": 100, "reservedQuantity": 0}'
+ +

2. MicroProfile Rest Client Features

+
# Reserve inventory (uses RestClientBuilder for availability check)
+curl -X PATCH http://localhost:7050/inventory/api/inventories/product/1/reserve/10
+
+# Get product info (uses Advanced RestClientBuilder)
+curl -X GET http://localhost:7050/inventory/api/inventories/product-info/1
+
+# Get enriched inventory with product details
+curl -X GET http://localhost:7050/inventory/api/inventories/1/with-product-info
+ +

3. Advanced Features

+
# Pagination and filtering
+curl -X GET "http://localhost:7050/inventory/api/inventories?page=0&size=5&minQuantity=50"
+
+# Bulk operations
+curl -X POST http://localhost:7050/inventory/api/inventories/bulk \
+  -H "Content-Type: application/json" \
+  -d '[{"productId": 1, "quantity": 100}, {"productId": 2, "quantity": 50}]'
+
+ +
+

⚙️ Configuration & Testing

+

Test Scripts Available:

+
    +
  • ./test-inventory-endpoints.sh - Comprehensive test suite
  • +
  • ./test-inventory-endpoints.sh --restclient - RestClient features only
  • +
  • ./quick-test-commands.sh - Command reference
  • +
+ +

Service Dependencies:

+
    +
  • Catalog Service: http://localhost:5050
  • +
  • Inventory Service: http://localhost:7050
  • +
+ +

MicroProfile Config:

+
io.microprofile.tutorial.store.inventory.client.ProductServiceClient/mp-rest/url=http://localhost:5050/catalog/api
+io.microprofile.tutorial.store.inventory.client.ProductServiceClient/mp-rest/scope=javax.inject.Singleton
+
+ +

🏗️ Architecture

+
+

MicroProfile Rest Client Integration Patterns

+

Pattern 1 - CDI Injection: Automatic client injection with configuration-driven setup

+

Pattern 2 - Programmatic Creation: Dynamic client building with custom timeouts and error handling

+

Pattern 3 - Advanced Configuration: Per-use-case client optimization

+ +

Technologies Used:

+
    +
  • Jakarta EE 10
  • +
  • MicroProfile 6.1 (Rest Client, OpenAPI, Config)
  • +
  • Open Liberty 24.0.0.x
  • +
  • Jackson for JSON processing
  • +
  • Lombok for reduced boilerplate
  • +
+
+ +
+

MicroProfile REST Client Tutorial - Inventory Service

+

Demonstrates comprehensive MicroProfile Rest Client integration with Jakarta EE

+

© 2025 - Updated June 7, 2025

+
+ + diff --git a/code/chapter11/inventory/src/test/java/io/microprofile/tutorial/store/inventory/integration/InventoryServiceIntegrationTest.java b/code/chapter11/inventory/src/test/java/io/microprofile/tutorial/store/inventory/integration/InventoryServiceIntegrationTest.java new file mode 100644 index 0000000..1edc39d --- /dev/null +++ b/code/chapter11/inventory/src/test/java/io/microprofile/tutorial/store/inventory/integration/InventoryServiceIntegrationTest.java @@ -0,0 +1,157 @@ +package io.microprofile.tutorial.store.inventory.integration; + +import io.microprofile.tutorial.store.inventory.service.InventoryService; +import io.microprofile.tutorial.store.inventory.entity.Inventory; +import io.microprofile.tutorial.store.inventory.dto.Product; +import io.microprofile.tutorial.store.inventory.client.ProductServiceClient; +import io.microprofile.tutorial.store.inventory.repository.InventoryRepository; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.lang.reflect.Field; +import java.util.Optional; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +/** + * Integration tests for InventoryService with ProductServiceClient. + * This test class focuses on the main integration points. + */ +@ExtendWith(MockitoExtension.class) +class InventoryServiceIntegrationTest { + + @Mock + private InventoryRepository inventoryRepository; + + @Mock + private ProductServiceClient productServiceClient; + + @InjectMocks + private InventoryService inventoryService; + + private Product mockProduct; + private Inventory mockInventory; + + @BeforeEach + void setUp() throws Exception { + // Create mock product using constructor + mockProduct = new Product(1L, "Test Product", 29.99, "Electronics", "A test product"); + + // Create mock inventory with proper productId set using reflection + mockInventory = new Inventory(); + setPrivateField(mockInventory, "id", 1L); + setPrivateField(mockInventory, "productId", 1L); + setPrivateField(mockInventory, "quantity", 10); + setPrivateField(mockInventory, "availableQuantity", 8); + setPrivateField(mockInventory, "reservedQuantity", 2); + setPrivateField(mockInventory, "location", "Warehouse A"); + } + + private void setPrivateField(Object obj, String fieldName, Object value) throws Exception { + Field field = obj.getClass().getDeclaredField(fieldName); + field.setAccessible(true); + field.set(obj, value); + } + + private Object getPrivateField(Object obj, String fieldName) throws Exception { + Field field = obj.getClass().getDeclaredField(fieldName); + field.setAccessible(true); + return field.get(obj); + } + + @Test + void testProductServiceClientIntegration_BasicCall() throws Exception { + // Arrange + lenient().when(productServiceClient.getProductById(anyLong())).thenReturn(mockProduct); + + // Act + Product result = inventoryService.getProductInfo(mockInventory); + + // Assert + assertNotNull(result); + assertEquals(1L, getPrivateField(result, "id")); + verify(productServiceClient).getProductById(1L); + } + + @Test + void testCreateInventory_CallsProductValidation() throws Exception { + // Arrange + Inventory newInventory = new Inventory(); + setPrivateField(newInventory, "productId", 1L); + setPrivateField(newInventory, "quantity", 5); + setPrivateField(newInventory, "location", "Test Location"); + + lenient().when(productServiceClient.getProductById(anyLong())).thenReturn(mockProduct); + lenient().when(inventoryRepository.findByProductId(anyLong())).thenReturn(Optional.empty()); + lenient().when(inventoryRepository.save(any(Inventory.class))).thenReturn(mockInventory); + + // Act + Inventory result = inventoryService.createInventory(newInventory); + + // Assert + assertNotNull(result); + verify(productServiceClient).getProductById(1L); + verify(inventoryRepository).save(newInventory); + } + + @Test + void testUpdateInventory_CallsProductValidation() throws Exception { + // Arrange + Inventory updatedInventory = new Inventory(); + setPrivateField(updatedInventory, "productId", 1L); + setPrivateField(updatedInventory, "quantity", 15); + + lenient().when(productServiceClient.getProductById(anyLong())).thenReturn(mockProduct); + lenient().when(inventoryRepository.findByProductId(anyLong())).thenReturn(Optional.of(mockInventory)); + lenient().when(inventoryRepository.update(anyLong(), any(Inventory.class))).thenReturn(Optional.of(mockInventory)); + + // Act + Inventory result = inventoryService.updateInventory(1L, updatedInventory); + + // Assert + assertNotNull(result); + verify(productServiceClient).getProductById(1L); + verify(inventoryRepository).update(1L, updatedInventory); + } + + @Test + void testProductServiceClient_ReturnsProductData() throws Exception { + // Arrange + lenient().when(productServiceClient.getProductById(anyLong())).thenReturn(mockProduct); + + // Act + Product result = inventoryService.getProductInfo(mockInventory); + + // Assert + assertNotNull(result); + // Test using reflection to access private fields + assertEquals("Test Product", getPrivateField(result, "name")); + assertEquals("Electronics", getPrivateField(result, "category")); + verify(productServiceClient).getProductById(1L); + } + + @Test + void testCreateInventory_WithInvalidProduct_ThrowsException() throws Exception { + // Arrange + Inventory newInventory = new Inventory(); + setPrivateField(newInventory, "productId", 999L); + setPrivateField(newInventory, "quantity", 5); + + lenient().when(productServiceClient.getProductById(999L)).thenReturn(null); + + // Act & Assert + RuntimeException exception = assertThrows(RuntimeException.class, () -> { + inventoryService.createInventory(newInventory); + }); + + assertTrue(exception.getMessage().contains("Product with ID 999 not found")); + verify(productServiceClient).getProductById(999L); + } +} +} diff --git a/code/chapter11/inventory/src/test/java/io/microprofile/tutorial/store/inventory/service/InventoryServiceTest.java b/code/chapter11/inventory/src/test/java/io/microprofile/tutorial/store/inventory/service/InventoryServiceTest.java new file mode 100644 index 0000000..a7859a7 --- /dev/null +++ b/code/chapter11/inventory/src/test/java/io/microprofile/tutorial/store/inventory/service/InventoryServiceTest.java @@ -0,0 +1,302 @@ +package io.microprofile.tutorial.store.inventory.service; + +import io.microprofile.tutorial.store.inventory.entity.Inventory; +import io.microprofile.tutorial.store.inventory.exception.InventoryNotFoundException; +import io.microprofile.tutorial.store.inventory.exception.InventoryConflictException; +import io.microprofile.tutorial.store.inventory.repository.InventoryRepository; +import io.microprofile.tutorial.store.inventory.client.ProductServiceClient; +import io.microprofile.tutorial.store.inventory.dto.Product; +import io.microprofile.tutorial.store.inventory.dto.InventoryWithProductInfo; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import jakarta.ws.rs.WebApplicationException; +import jakarta.ws.rs.core.Response; + +import java.util.Arrays; +import java.util.List; +import java.util.Optional; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +/** + * Unit tests for InventoryService with ProductServiceClient integration. + */ +@ExtendWith(MockitoExtension.class) +class InventoryServiceTest { + + @Mock + private InventoryRepository inventoryRepository; + + @Mock + private ProductServiceClient productServiceClient; + + @InjectMocks + private InventoryService inventoryService; + + private Product mockProduct; + private Inventory mockInventory; + + @BeforeEach + void setUp() { + mockProduct = new Product(1L, "Test Product", 29.99, "Electronics", "A test product"); + + mockInventory = new Inventory(); + mockInventory.setInventoryId(1L); + mockInventory.setProductId(1L); + mockInventory.setQuantity(100); + mockInventory.setReservedQuantity(10); + } + + @Test + void testCreateInventory_WithValidProduct_ShouldSucceed() { + // Arrange + Inventory newInventory = Inventory.builder() + .productId(1L) + .quantity(50) + .reservedQuantity(0) + .build(); + + when(productServiceClient.getProductById(1L)).thenReturn(mockProduct); + when(inventoryRepository.findByProductId(1L)).thenReturn(Optional.empty()); + when(inventoryRepository.save(any(Inventory.class))).thenReturn(mockInventory); + + // Act + Inventory result = inventoryService.createInventory(newInventory); + + // Assert + assertNotNull(result); + assertEquals(1L, result.getInventoryId()); + verify(productServiceClient).getProductById(1L); + verify(inventoryRepository).findByProductId(1L); + verify(inventoryRepository).save(newInventory); + } + + @Test + void testCreateInventory_WithInvalidProduct_ShouldThrowNotFoundException() { + // Arrange + Inventory newInventory = Inventory.builder() + .productId(999L) + .quantity(50) + .reservedQuantity(0) + .build(); + + when(productServiceClient.getProductById(999L)).thenReturn(null); + + // Act & Assert + InventoryNotFoundException exception = assertThrows( + InventoryNotFoundException.class, + () -> inventoryService.createInventory(newInventory) + ); + + assertTrue(exception.getMessage().contains("Product not found in catalog with ID: 999")); + verify(productServiceClient).getProductById(999L); + verify(inventoryRepository, never()).save(any()); + } + + @Test + void testCreateInventory_WithExistingInventory_ShouldThrowConflictException() { + // Arrange + Inventory newInventory = Inventory.builder() + .productId(1L) + .quantity(50) + .reservedQuantity(0) + .build(); + + when(productServiceClient.getProductById(1L)).thenReturn(mockProduct); + when(inventoryRepository.findByProductId(1L)).thenReturn(Optional.of(mockInventory)); + + // Act & Assert + InventoryConflictException exception = assertThrows( + InventoryConflictException.class, + () -> inventoryService.createInventory(newInventory) + ); + + assertTrue(exception.getMessage().contains("Inventory for product already exists")); + verify(productServiceClient).getProductById(1L); + verify(inventoryRepository).findByProductId(1L); + verify(inventoryRepository, never()).save(any()); + } + + @Test + void testUpdateInventory_WithValidProduct_ShouldSucceed() { + // Arrange + Inventory updatedInventory = Inventory.builder() + .productId(1L) + .quantity(75) + .reservedQuantity(5) + .build(); + + when(productServiceClient.getProductById(1L)).thenReturn(mockProduct); + when(inventoryRepository.findByProductId(1L)).thenReturn(Optional.of(mockInventory)); + when(inventoryRepository.update(1L, updatedInventory)).thenReturn(Optional.of(mockInventory)); + + // Act + Inventory result = inventoryService.updateInventory(1L, updatedInventory); + + // Assert + assertNotNull(result); + verify(productServiceClient).getProductById(1L); + verify(inventoryRepository).findByProductId(1L); + verify(inventoryRepository).update(1L, updatedInventory); + } + + @Test + void testUpdateInventory_WithProductConflict_ShouldThrowConflictException() { + // Arrange + Inventory existingInventory = Inventory.builder() + .inventoryId(2L) + .productId(1L) + .quantity(25) + .reservedQuantity(0) + .build(); + + Inventory updatedInventory = Inventory.builder() + .productId(1L) + .quantity(75) + .reservedQuantity(5) + .build(); + + when(productServiceClient.getProductById(1L)).thenReturn(mockProduct); + when(inventoryRepository.findByProductId(1L)).thenReturn(Optional.of(existingInventory)); + + // Act & Assert + InventoryConflictException exception = assertThrows( + InventoryConflictException.class, + () -> inventoryService.updateInventory(1L, updatedInventory) + ); + + assertTrue(exception.getMessage().contains("Another inventory record already exists")); + verify(productServiceClient).getProductById(1L); + verify(inventoryRepository).findByProductId(1L); + verify(inventoryRepository, never()).update(anyLong(), any()); + } + + @Test + void testCreateBulkInventories_WithValidProducts_ShouldSucceed() { + // Arrange + Product product2 = new Product(2L, "Product 2", 19.99, "Home", "Another product"); + + Inventory inventory1 = Inventory.builder().productId(1L).quantity(50).reservedQuantity(0).build(); + Inventory inventory2 = Inventory.builder().productId(2L).quantity(25).reservedQuantity(0).build(); + List inventories = Arrays.asList(inventory1, inventory2); + + Inventory saved1 = Inventory.builder().inventoryId(1L).productId(1L).quantity(50).reservedQuantity(0).build(); + Inventory saved2 = Inventory.builder().inventoryId(2L).productId(2L).quantity(25).reservedQuantity(0).build(); + + when(productServiceClient.getProductById(1L)).thenReturn(mockProduct); + when(productServiceClient.getProductById(2L)).thenReturn(product2); + when(inventoryRepository.findByProductId(1L)).thenReturn(Optional.empty()); + when(inventoryRepository.findByProductId(2L)).thenReturn(Optional.empty()); + when(inventoryRepository.save(inventory1)).thenReturn(saved1); + when(inventoryRepository.save(inventory2)).thenReturn(saved2); + + // Act + List result = inventoryService.createBulkInventories(inventories); + + // Assert + assertNotNull(result); + assertEquals(2, result.size()); + verify(productServiceClient).getProductById(1L); + verify(productServiceClient).getProductById(2L); + verify(inventoryRepository, times(2)).save(any(Inventory.class)); + } + + @Test + void testGetInventoryWithProductInfo_ShouldReturnEnrichedData() { + // Arrange + when(inventoryRepository.findById(1L)).thenReturn(Optional.of(mockInventory)); + when(productServiceClient.getProductById(1L)).thenReturn(mockProduct); + + // Act + InventoryWithProductInfo result = inventoryService.getInventoryWithProductInfo(1L); + + // Assert + assertNotNull(result); + assertEquals(mockInventory, result.getInventory()); + assertEquals(mockProduct, result.getProduct()); + assertEquals("Test Product", result.getProductName()); + assertEquals(29.99, result.getProductPrice()); + assertEquals("Electronics", result.getProductCategory()); + assertEquals(100, result.getQuantity()); + assertEquals(10, result.getReservedQuantity()); + assertEquals(90, result.getAvailableQuantity()); + } + + @Test + void testGetInventoriesByCategory_ShouldReturnFilteredInventories() { + // Arrange + Product product2 = new Product(2L, "Product 2", 39.99, "Electronics", "Another electronics product"); + List electronicsProducts = Arrays.asList(mockProduct, product2); + + Inventory inventory2 = Inventory.builder() + .inventoryId(2L) + .productId(2L) + .quantity(75) + .reservedQuantity(5) + .build(); + + when(productServiceClient.getProductsByCategory("Electronics")).thenReturn(electronicsProducts); + when(inventoryRepository.findByProductId(1L)).thenReturn(Optional.of(mockInventory)); + when(inventoryRepository.findByProductId(2L)).thenReturn(Optional.of(inventory2)); + + // Act + List result = inventoryService.getInventoriesByCategory("Electronics"); + + // Assert + assertNotNull(result); + assertEquals(2, result.size()); + + InventoryWithProductInfo first = result.get(0); + assertEquals("Test Product", first.getProductName()); + assertEquals("Electronics", first.getProductCategory()); + + InventoryWithProductInfo second = result.get(1); + assertEquals("Product 2", second.getProductName()); + assertEquals("Electronics", second.getProductCategory()); + + verify(productServiceClient).getProductsByCategory("Electronics"); + verify(inventoryRepository).findByProductId(1L); + verify(inventoryRepository).findByProductId(2L); + } + + @Test + void testGetProductInfo_ShouldReturnProductDetails() { + // Arrange + when(productServiceClient.getProductById(1L)).thenReturn(mockProduct); + + // Act + Product result = inventoryService.getProductInfo(mockInventory); + + // Assert + assertNotNull(result); + assertEquals("Test Product", result.getName()); + assertEquals(29.99, result.getPrice()); + assertEquals("Electronics", result.getCategory()); + verify(productServiceClient).getProductById(1L); + } + + @Test + void testValidateProductExists_WithServiceError_ShouldThrowRuntimeException() { + // Arrange + WebApplicationException serviceException = new WebApplicationException( + Response.status(500).build() + ); + when(productServiceClient.getProductById(1L)).thenThrow(serviceException); + + // Act & Assert + RuntimeException exception = assertThrows( + RuntimeException.class, + () -> inventoryService.createInventory(mockInventory) + ); + + assertTrue(exception.getMessage().contains("Failed to validate product with catalog service")); + verify(productServiceClient).getProductById(1L); + } +} diff --git a/code/chapter11/inventory/test-inventory-endpoints.sh b/code/chapter11/inventory/test-inventory-endpoints.sh new file mode 100755 index 0000000..8337af3 --- /dev/null +++ b/code/chapter11/inventory/test-inventory-endpoints.sh @@ -0,0 +1,436 @@ +#!/bin/bash + +# ============================================================================ +# Inventory Service REST API Test Script +# ============================================================================ +# This script tests all inventory endpoints including: +# - Basic CRUD operations +# - MicroProfile Rest Client integration +# - RestClientBuilder functionality +# - Product validation features +# - Reservation and advanced features +# ============================================================================ + +# Colors for output +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +BLUE='\033[0;34m' +NC='\033[0m' # No Color + +# Base URLs +INVENTORY_BASE_URL="http://localhost:7050/inventory/api" +CATALOG_BASE_URL="http://localhost:5050/catalog/api" + +# Function to print section headers +print_section() { + echo -e "\n${BLUE}============================================================================${NC}" + echo -e "${BLUE}$1${NC}" + echo -e "${BLUE}============================================================================${NC}\n" +} + +# Function to print test results +print_test() { + echo -e "${YELLOW}TEST:${NC} $1" + echo -e "${YELLOW}COMMAND:${NC} $2" +} + +# Function to check if services are running +check_services() { + print_section "🔍 CHECKING SERVICE AVAILABILITY" + + echo "Checking Catalog Service (port 5050)..." + if curl -s "$CATALOG_BASE_URL/products" > /dev/null; then + echo -e "${GREEN}✅ Catalog Service is running${NC}" + else + echo -e "${RED}❌ Catalog Service is not available${NC}" + exit 1 + fi + + echo "Checking Inventory Service (port 7050)..." + if curl -s "$INVENTORY_BASE_URL/inventories" > /dev/null; then + echo -e "${GREEN}✅ Inventory Service is running${NC}" + else + echo -e "${RED}❌ Inventory Service is not available${NC}" + exit 1 + fi +} + +# Function to show available products in catalog +show_catalog_products() { + print_section "📋 AVAILABLE PRODUCTS IN CATALOG" + + print_test "Get all products from catalog" "curl -X GET '$CATALOG_BASE_URL/products'" + curl -X GET "$CATALOG_BASE_URL/products" -H "Content-Type: application/json" | jq '.' + echo +} + +# Test basic inventory operations +test_basic_operations() { + print_section "🏪 BASIC INVENTORY OPERATIONS" + + # Get all inventories (should be empty initially) + print_test "Get all inventories (empty initially)" "curl -X GET '$INVENTORY_BASE_URL/inventories'" + curl -X GET "$INVENTORY_BASE_URL/inventories" -H "Content-Type: application/json" | jq '.' + echo -e "\n" + + # Create inventory for product 1 (iPhone) + print_test "Create inventory for product 1 (iPhone)" "curl -X POST '$INVENTORY_BASE_URL/inventories'" + curl -X POST "$INVENTORY_BASE_URL/inventories" \ + -H "Content-Type: application/json" \ + -d '{ + "productId": 1, + "quantity": 100, + "reservedQuantity": 0 + }' | jq '.' + echo -e "\n" + + # Create inventory for product 2 (MacBook) + print_test "Create inventory for product 2 (MacBook)" "curl -X POST '$INVENTORY_BASE_URL/inventories'" + curl -X POST "$INVENTORY_BASE_URL/inventories" \ + -H "Content-Type: application/json" \ + -d '{ + "productId": 2, + "quantity": 50, + "reservedQuantity": 0 + }' | jq '.' + echo -e "\n" + + # Create inventory for product 3 (iPad) + print_test "Create inventory for product 3 (iPad)" "curl -X POST '$INVENTORY_BASE_URL/inventories'" + curl -X POST "$INVENTORY_BASE_URL/inventories" \ + -H "Content-Type: application/json" \ + -d '{ + "productId": 3, + "quantity": 75, + "reservedQuantity": 0 + }' | jq '.' + echo -e "\n" + + # Get all inventories after creation + print_test "Get all inventories after creation" "curl -X GET '$INVENTORY_BASE_URL/inventories'" + curl -X GET "$INVENTORY_BASE_URL/inventories" -H "Content-Type: application/json" | jq '.' + echo -e "\n" + + # Get inventory by ID + print_test "Get inventory by ID (1)" "curl -X GET '$INVENTORY_BASE_URL/inventories/1'" + curl -X GET "$INVENTORY_BASE_URL/inventories/1" -H "Content-Type: application/json" | jq '.' + echo -e "\n" + + # Get inventory by product ID + print_test "Get inventory by product ID (2)" "curl -X GET '$INVENTORY_BASE_URL/inventories/product/2'" + curl -X GET "$INVENTORY_BASE_URL/inventories/product/2" -H "Content-Type: application/json" | jq '.' + echo -e "\n" +} + +# Test error handling +test_error_handling() { + print_section "❌ ERROR HANDLING TESTS" + + # Try to create inventory for non-existent product + print_test "Try to create inventory for non-existent product (999)" "curl -X POST '$INVENTORY_BASE_URL/inventories'" + curl -X POST "$INVENTORY_BASE_URL/inventories" \ + -H "Content-Type: application/json" \ + -d '{ + "productId": 999, + "quantity": 10, + "reservedQuantity": 0 + }' | jq '.' + echo -e "\n" + + # Try to create duplicate inventory + print_test "Try to create duplicate inventory (product 1)" "curl -X POST '$INVENTORY_BASE_URL/inventories'" + curl -X POST "$INVENTORY_BASE_URL/inventories" \ + -H "Content-Type: application/json" \ + -d '{ + "productId": 1, + "quantity": 20, + "reservedQuantity": 0 + }' | jq '.' + echo -e "\n" + + # Try to get non-existent inventory + print_test "Try to get non-existent inventory (999)" "curl -X GET '$INVENTORY_BASE_URL/inventories/999'" + curl -X GET "$INVENTORY_BASE_URL/inventories/999" -H "Content-Type: application/json" | jq '.' + echo -e "\n" + + # Try to get inventory for non-existent product + print_test "Try to get inventory for non-existent product (888)" "curl -X GET '$INVENTORY_BASE_URL/inventories/product/888'" + curl -X GET "$INVENTORY_BASE_URL/inventories/product/888" -H "Content-Type: application/json" | jq '.' + echo -e "\n" +} + +# Test update operations +test_update_operations() { + print_section "✏️ UPDATE OPERATIONS" + + # Update inventory quantity + print_test "Update inventory ID 1" "curl -X PUT '$INVENTORY_BASE_URL/inventories/1'" + curl -X PUT "$INVENTORY_BASE_URL/inventories/1" \ + -H "Content-Type: application/json" \ + -d '{ + "productId": 1, + "quantity": 120, + "reservedQuantity": 5 + }' | jq '.' + echo -e "\n" + + # Update quantity for product + print_test "Update quantity for product 2 to 60" "curl -X PATCH '$INVENTORY_BASE_URL/inventories/product/2/quantity/60'" + curl -X PATCH "$INVENTORY_BASE_URL/inventories/product/2/quantity/60" -H "Content-Type: application/json" | jq '.' + echo -e "\n" + + # Verify updates + print_test "Verify updates - Get all inventories" "curl -X GET '$INVENTORY_BASE_URL/inventories'" + curl -X GET "$INVENTORY_BASE_URL/inventories" -H "Content-Type: application/json" | jq '.' + echo -e "\n" +} + +# Test pagination and filtering +test_pagination_filtering() { + print_section "📄 PAGINATION AND FILTERING" + + # Test pagination + print_test "Get inventories with pagination (page=0, size=2)" "curl -X GET '$INVENTORY_BASE_URL/inventories?page=0&size=2'" + curl -X GET "$INVENTORY_BASE_URL/inventories?page=0&size=2" -H "Content-Type: application/json" | jq '.' + echo -e "\n" + + # Test filtering by minimum quantity + print_test "Filter inventories with minimum quantity 70" "curl -X GET '$INVENTORY_BASE_URL/inventories?minQuantity=70'" + curl -X GET "$INVENTORY_BASE_URL/inventories?minQuantity=70" -H "Content-Type: application/json" | jq '.' + echo -e "\n" + + # Test filtering by quantity range + print_test "Filter inventories with quantity range (50-100)" "curl -X GET '$INVENTORY_BASE_URL/inventories?minQuantity=50&maxQuantity=100'" + curl -X GET "$INVENTORY_BASE_URL/inventories?minQuantity=50&maxQuantity=100" -H "Content-Type: application/json" | jq '.' + echo -e "\n" + + # Get count with filters + print_test "Count inventories with minimum quantity 60" "curl -X GET '$INVENTORY_BASE_URL/inventories/count?minQuantity=60'" + curl -X GET "$INVENTORY_BASE_URL/inventories/count?minQuantity=60" -H "Content-Type: application/json" | jq '.' + echo -e "\n" +} + +# Test RestClientBuilder functionality (Product Availability) +test_restclient_availability() { + print_section "🔌 RESTCLIENTBUILDER - PRODUCT AVAILABILITY" + + # Reserve inventory (uses RestClientBuilder for availability check) + print_test "Reserve 10 units of product 1 (uses RestClientBuilder)" "curl -X PATCH '$INVENTORY_BASE_URL/inventories/product/1/reserve/10'" + curl -X PATCH "$INVENTORY_BASE_URL/inventories/product/1/reserve/10" -H "Content-Type: application/json" | jq '.' + echo -e "\n" + + # Reserve inventory for product 2 + print_test "Reserve 5 units of product 2" "curl -X PATCH '$INVENTORY_BASE_URL/inventories/product/2/reserve/5'" + curl -X PATCH "$INVENTORY_BASE_URL/inventories/product/2/reserve/5" -H "Content-Type: application/json" | jq '.' + echo -e "\n" + + # Try to reserve inventory for non-existent product + print_test "Try to reserve inventory for non-existent product (999)" "curl -X PATCH '$INVENTORY_BASE_URL/inventories/product/999/reserve/1'" + curl -X PATCH "$INVENTORY_BASE_URL/inventories/product/999/reserve/1" -H "Content-Type: application/json" | jq '.' + echo -e "\n" + + # Try to reserve more than available + print_test "Try to reserve more than available (1000 units of product 3)" "curl -X PATCH '$INVENTORY_BASE_URL/inventories/product/3/reserve/1000'" + curl -X PATCH "$INVENTORY_BASE_URL/inventories/product/3/reserve/1000" -H "Content-Type: application/json" | jq '.' + echo -e "\n" +} + +# Test Advanced RestClientBuilder functionality +test_advanced_restclient() { + print_section "🚀 ADVANCED RESTCLIENTBUILDER - CUSTOM CONFIGURATION" + + # Get product info using custom RestClientBuilder (3s connect, 8s read timeout) + print_test "Get product 1 info using custom RestClientBuilder" "curl -X GET '$INVENTORY_BASE_URL/inventories/product-info/1'" + curl -X GET "$INVENTORY_BASE_URL/inventories/product-info/1" -H "Content-Type: application/json" | jq '.' + echo -e "\n" + + # Get product info for MacBook + print_test "Get product 2 info using custom RestClientBuilder" "curl -X GET '$INVENTORY_BASE_URL/inventories/product-info/2'" + curl -X GET "$INVENTORY_BASE_URL/inventories/product-info/2" -H "Content-Type: application/json" | jq '.' + echo -e "\n" + + # Get product info for iPad + print_test "Get product 3 info using custom RestClientBuilder" "curl -X GET '$INVENTORY_BASE_URL/inventories/product-info/3'" + curl -X GET "$INVENTORY_BASE_URL/inventories/product-info/3" -H "Content-Type: application/json" | jq '.' + echo -e "\n" + + # Try to get info for non-existent product + print_test "Try to get info for non-existent product (777)" "curl -X GET '$INVENTORY_BASE_URL/inventories/product-info/777'" + curl -X GET "$INVENTORY_BASE_URL/inventories/product-info/777" -H "Content-Type: application/json" | jq '.' + echo -e "\n" +} + +# Test enriched inventory with product information +test_enriched_inventory() { + print_section "💎 ENRICHED INVENTORY WITH PRODUCT INFO" + + # Get inventory with product info by inventory ID + print_test "Get inventory 1 with product information" "curl -X GET '$INVENTORY_BASE_URL/inventories/1/with-product-info'" + curl -X GET "$INVENTORY_BASE_URL/inventories/1/with-product-info" -H "Content-Type: application/json" | jq '.' + echo -e "\n" + + # Get inventories by category (if catalog supports categories) + print_test "Get inventories by category 'electronics'" "curl -X GET '$INVENTORY_BASE_URL/inventories/category/electronics'" + curl -X GET "$INVENTORY_BASE_URL/inventories/category/electronics" -H "Content-Type: application/json" | jq '.' + echo -e "\n" +} + +# Test bulk operations +test_bulk_operations() { + print_section "📦 BULK OPERATIONS" + + # Delete existing inventories first to test bulk create + print_test "Delete inventory 1" "curl -X DELETE '$INVENTORY_BASE_URL/inventories/1'" + curl -X DELETE "$INVENTORY_BASE_URL/inventories/1" -H "Content-Type: application/json" + echo -e "\n" + + print_test "Delete inventory 2" "curl -X DELETE '$INVENTORY_BASE_URL/inventories/2'" + curl -X DELETE "$INVENTORY_BASE_URL/inventories/2" -H "Content-Type: application/json" + echo -e "\n" + + print_test "Delete inventory 3" "curl -X DELETE '$INVENTORY_BASE_URL/inventories/3'" + curl -X DELETE "$INVENTORY_BASE_URL/inventories/3" -H "Content-Type: application/json" + echo -e "\n" + + # Bulk create inventories + print_test "Bulk create inventories" "curl -X POST '$INVENTORY_BASE_URL/inventories/bulk'" + curl -X POST "$INVENTORY_BASE_URL/inventories/bulk" \ + -H "Content-Type: application/json" \ + -d '[ + { + "productId": 1, + "quantity": 200, + "reservedQuantity": 0 + }, + { + "productId": 2, + "quantity": 150, + "reservedQuantity": 0 + }, + { + "productId": 3, + "quantity": 100, + "reservedQuantity": 0 + } + ]' | jq '.' + echo -e "\n" + + # Verify bulk creation + print_test "Verify bulk creation - Get all inventories" "curl -X GET '$INVENTORY_BASE_URL/inventories'" + curl -X GET "$INVENTORY_BASE_URL/inventories" -H "Content-Type: application/json" | jq '.' + echo -e "\n" +} + +# Test delete operations +test_delete_operations() { + print_section "🗑️ DELETE OPERATIONS" + + # Delete inventory by ID + print_test "Delete inventory by ID (2)" "curl -X DELETE '$INVENTORY_BASE_URL/inventories/5'" + curl -X DELETE "$INVENTORY_BASE_URL/inventories/5" -H "Content-Type: application/json" | jq '.' + echo -e "\n" + + # Try to delete non-existent inventory + print_test "Try to delete non-existent inventory (999)" "curl -X DELETE '$INVENTORY_BASE_URL/inventories/999'" + curl -X DELETE "$INVENTORY_BASE_URL/inventories/999" -H "Content-Type: application/json" | jq '.' + echo -e "\n" + + # Final inventory state + print_test "Final inventory state" "curl -X GET '$INVENTORY_BASE_URL/inventories'" + curl -X GET "$INVENTORY_BASE_URL/inventories" -H "Content-Type: application/json" | jq '.' + echo -e "\n" +} + +# Performance test +test_performance() { + print_section "⚡ PERFORMANCE TESTS" + + echo "Testing response times for different RestClient approaches:" + echo + + echo "1. Injected REST Client (used in product validation):" + time curl -s -X POST "$INVENTORY_BASE_URL/inventories" \ + -H "Content-Type: application/json" \ + -d '{ + "productId": 1, + "quantity": 1, + "reservedQuantity": 0 + }' > /dev/null 2>&1 || true + echo + + echo "2. RestClientBuilder with 5s/10s timeout (availability check):" + time curl -s -X PATCH "$INVENTORY_BASE_URL/inventories/product/1/reserve/1" > /dev/null 2>&1 || true + echo + + echo "3. RestClientBuilder with 3s/8s timeout (product info):" + time curl -s -X GET "$INVENTORY_BASE_URL/inventories/product-info/1" > /dev/null 2>&1 || true + echo +} + +# Main execution +main() { + echo -e "${GREEN}🚀 Starting Inventory Service REST API Tests${NC}" + echo -e "${GREEN}Date: $(date)${NC}\n" + + # Check if jq is available + if ! command -v jq &> /dev/null; then + echo -e "${RED}❌ jq is required for JSON formatting. Please install jq first.${NC}" + echo "To install jq: sudo apt-get install jq (Ubuntu/Debian) or brew install jq (macOS)" + exit 1 + fi + + check_services + show_catalog_products + test_basic_operations + test_error_handling + test_update_operations + test_pagination_filtering + test_restclient_availability + test_advanced_restclient + test_enriched_inventory + test_bulk_operations + test_delete_operations + test_performance + + print_section "✅ ALL TESTS COMPLETED" + echo -e "${GREEN}🎉 Inventory Service REST API testing completed successfully!${NC}" + echo -e "${GREEN}📊 Summary of features tested:${NC}" + echo -e " • Basic CRUD operations" + echo -e " • MicroProfile Rest Client integration" + echo -e " • RestClientBuilder with custom configuration" + echo -e " • Product validation and error handling" + echo -e " • Inventory reservation functionality" + echo -e " • Pagination and filtering" + echo -e " • Bulk operations" + echo -e " • Performance comparison" + echo +} + +# Handle script arguments +case "${1:-}" in + --basic) + check_services + test_basic_operations + ;; + --restclient) + check_services + test_restclient_availability + test_advanced_restclient + ;; + --performance) + check_services + test_performance + ;; + --help) + echo "Usage: $0 [--basic|--restclient|--performance|--help]" + echo " --basic Run only basic CRUD tests" + echo " --restclient Run only RestClient tests" + echo " --performance Run only performance tests" + echo " --help Show this help message" + echo " (no args) Run all tests" + ;; + *) + main + ;; +esac diff --git a/code/chapter11/order/Dockerfile b/code/chapter11/order/Dockerfile new file mode 100644 index 0000000..6854964 --- /dev/null +++ b/code/chapter11/order/Dockerfile @@ -0,0 +1,19 @@ +FROM openliberty/open-liberty:23.0.0.3-full-java17-openj9-ubi + +# Copy Liberty configuration +COPY --chown=1001:0 src/main/liberty/config/ /config/ + +# Copy application WAR file +COPY --chown=1001:0 target/order.war /config/apps/ + +# Set environment variables +ENV PORT=9080 + +# Configure the server to run +RUN configure.sh + +# Expose ports +EXPOSE 8050 8051 + +# Start the server +CMD ["/opt/ol/wlp/bin/server", "run", "defaultServer"] diff --git a/code/chapter11/order/README.md b/code/chapter11/order/README.md new file mode 100644 index 0000000..36c554f --- /dev/null +++ b/code/chapter11/order/README.md @@ -0,0 +1,148 @@ +# Order Service + +A Jakarta EE and MicroProfile-based REST service for order management in the Liberty Rest App demo. + +## Features + +- Provides CRUD operations for order management +- Tracks orders with order_id, user_id, total_price, and status +- Manages order items with order_item_id, order_id, product_id, quantity, and price_at_order +- Uses Jakarta EE 10.0 and MicroProfile 6.1 +- Runs on Open Liberty runtime + +## Running the Application + +There are multiple ways to run the application: + +### Using Maven + +``` +cd order +mvn liberty:run +``` + +### Using the provided script + +``` +./run.sh +``` + +### Using Docker + +``` +./run-docker.sh +``` + +This will start the Open Liberty server on port 8050 (HTTP) and 8051 (HTTPS). + +## API Endpoints + +| Method | URL | Description | +|--------|:----------------------------------------|:-------------------------------------| +| GET | /api/orders | Get 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/orders | Create new order | +| PUT | /api/orders/{id} | Update order | +| DELETE | /api/orders/{id} | Delete order | +| PATCH | /api/orders/{id}/status/{status} | Update order status | +| GET | /api/orders/{orderId}/items | Get items for an order | +| GET | /api/orders/items/{orderItemId} | Get specific order item | +| POST | /api/orders/{orderId}/items | Add item to order | +| PUT | /api/orders/items/{orderItemId} | Update order item | +| DELETE | /api/orders/items/{orderItemId} | Delete order item | + +## Testing with cURL + +### Get all orders +``` +curl -X GET http://localhost:8050/order/api/orders +``` + +### Get order by ID +``` +curl -X GET http://localhost:8050/order/api/orders/1 +``` + +### Get orders by user ID +``` +curl -X GET http://localhost:8050/order/api/orders/user/1 +``` + +### Create new order +``` +curl -X POST http://localhost:8050/order/api/orders \ + -H "Content-Type: application/json" \ + -d '{ + "userId": 1, + "totalPrice": 149.98, + "status": "CREATED", + "orderItems": [ + { + "productId": 101, + "quantity": 2, + "priceAtOrder": 49.99 + }, + { + "productId": 102, + "quantity": 1, + "priceAtOrder": 50.00 + } + ] + }' +``` + +### Update order +``` +curl -X PUT http://localhost:8050/order/api/orders/1 \ + -H "Content-Type: application/json" \ + -d '{ + "userId": 1, + "totalPrice": 149.98, + "status": "PAID" + }' +``` + +### Update order status +``` +curl -X PATCH http://localhost:8050/order/api/orders/1/status/SHIPPED +``` + +### Delete order +``` +curl -X DELETE http://localhost:8050/order/api/orders/1 +``` + +### Get items for an order +``` +curl -X GET http://localhost:8050/order/api/orders/1/items +``` + +### Add item to order +``` +curl -X POST http://localhost:8050/order/api/orders/1/items \ + -H "Content-Type: application/json" \ + -d '{ + "productId": 103, + "quantity": 1, + "priceAtOrder": 29.99 + }' +``` + +### Update order item +``` +curl -X PUT http://localhost:8050/order/api/orders/items/1 \ + -H "Content-Type: application/json" \ + -d '{ + "orderId": 1, + "productId": 103, + "quantity": 2, + "priceAtOrder": 29.99 + }' +``` + +### Delete order item +``` +curl -X DELETE http://localhost:8050/order/api/orders/items/1 +``` diff --git a/code/chapter11/order/debug-jwt.sh b/code/chapter11/order/debug-jwt.sh new file mode 100755 index 0000000..4fcd855 --- /dev/null +++ b/code/chapter11/order/debug-jwt.sh @@ -0,0 +1,67 @@ +#!/bin/bash +# Script to debug JWT authentication issues + +# Check tools directory +if [ ! -d "/workspaces/liberty-rest-app/tools" ]; then + echo "Error: Tools directory not found!" + exit 1 +fi + +# Get the JWT token +TOKEN_FILE="/workspaces/liberty-rest-app/tools/token.jwt" +if [ ! -f "$TOKEN_FILE" ]; then + echo "JWT token not found at $TOKEN_FILE" + echo "Generating a new token..." + cd /workspaces/liberty-rest-app/tools + java -Dverbose -jar jwtenizr.jar +fi + +# Read the token +TOKEN=$(cat "$TOKEN_FILE") + +# Check for missing token +if [ -z "$TOKEN" ]; then + echo "Error: Empty JWT token" + exit 1 +fi + +# Print token details +echo "=== JWT Token Details ===" +echo "First 50 characters of token: ${TOKEN:0:50}..." +echo "Token length: ${#TOKEN}" +echo "" + +# Display token headers +echo "=== JWT Token Headers ===" +HEADER=$(echo $TOKEN | cut -d. -f1) +DECODED_HEADER=$(echo $HEADER | base64 -d 2>/dev/null || echo $HEADER | base64 --decode 2>/dev/null) +echo "$DECODED_HEADER" | jq . 2>/dev/null || echo "$DECODED_HEADER" +echo "" + +# Display token payload +echo "=== JWT Token Payload ===" +PAYLOAD=$(echo $TOKEN | cut -d. -f2) +DECODED=$(echo $PAYLOAD | base64 -d 2>/dev/null || echo $PAYLOAD | base64 --decode 2>/dev/null) +echo "$DECODED" | jq . 2>/dev/null || echo "$DECODED" +echo "" + +# Test the endpoint with verbose output +echo "=== Testing Endpoint with JWT Token ===" +echo "GET http://localhost:8050/order/api/orders/1" +echo "Authorization: Bearer ${TOKEN:0:50}..." +echo "" +echo "CURL Response Headers:" +curl -v -s -o /dev/null -H "Authorization: Bearer $TOKEN" "http://localhost:8050/order/api/orders/1" 2>&1 | grep -i '> ' +echo "" +echo "CURL Response:" +curl -v -H "Authorization: Bearer $TOKEN" "http://localhost:8050/order/api/orders/1" +echo "" + +# Check server logs for JWT-related messages +echo "=== Recent JWT-related Log Messages ===" +find /workspaces/liberty-rest-app/order/target/liberty/wlp/usr/servers/orderServer/logs -name "*.log" -exec grep -l "jwt\|JWT\|auth" {} \; | xargs tail -n 30 2>/dev/null +echo "" + +echo "=== Debug Complete ===" +echo "If you still have issues, check detailed logs in the Liberty server logs directory:" +echo "/workspaces/liberty-rest-app/order/target/liberty/wlp/usr/servers/orderServer/logs/" diff --git a/code/chapter11/order/enhanced-jwt-test.sh b/code/chapter11/order/enhanced-jwt-test.sh new file mode 100755 index 0000000..ab94882 --- /dev/null +++ b/code/chapter11/order/enhanced-jwt-test.sh @@ -0,0 +1,146 @@ +#!/bin/bash +# Enhanced script to test JWT authentication with the Order Service + +echo "==== JWT Authentication Test Script ====" +echo "Testing Order Service API with JWT authentication" +echo + +# Check if we're inside the tools directory +if [ ! -d "/workspaces/liberty-rest-app/tools" ]; then + echo "Error: Tools directory not found at /workspaces/liberty-rest-app/tools" + echo "Make sure you're running this script from the correct location" + exit 1 +fi + +# Check if jwtenizr.jar exists +if [ ! -f "/workspaces/liberty-rest-app/tools/jwtenizr.jar" ]; then + echo "Error: jwtenizr.jar not found in tools directory" + echo "Please ensure the JWT token generator is available" + exit 1 +fi + +# Step 1: Generate a fresh JWT token +echo "Step 1: Generating a fresh JWT token..." +cd /workspaces/liberty-rest-app/tools +java -Dverbose -jar jwtenizr.jar + +# Check if token was generated +if [ ! -f "/workspaces/liberty-rest-app/tools/token.jwt" ]; then + echo "Error: Failed to generate JWT token" + exit 1 +fi + +# Read the token +TOKEN=$(cat "/workspaces/liberty-rest-app/tools/token.jwt") +echo "JWT token generated successfully" + +# Step 2: Display token information +echo +echo "Step 2: JWT Token Information" +echo "------------------------" + +# Get the payload part (second part of the JWT) and decode it +PAYLOAD=$(echo $TOKEN | cut -d. -f2) +DECODED=$(echo $PAYLOAD | base64 -d 2>/dev/null || echo $PAYLOAD | base64 --decode 2>/dev/null) + +# Check if jq is installed +if command -v jq &> /dev/null; then + echo "Token claims:" + echo $DECODED | jq . +else + echo "Token claims (install jq for pretty printing):" + echo $DECODED +fi + +# Step 3: Test the protected endpoints +echo +echo "Step 3: Testing protected endpoints" +echo "------------------------" + +# Create a test order +echo +echo "Testing POST /api/orders (creating a test order)" +echo "Command: curl -s -X POST -H \"Content-Type: application/json\" -H \"Authorization: Bearer \$TOKEN\" -d http://localhost:8050/order/api/orders" +echo +echo "Response:" +CREATE_RESPONSE=$(curl -s -X POST \ + -H "Content-Type: application/json" \ + -H "Authorization: Bearer $TOKEN" \ + -d '{ + "customerId": "test-user", + "customerEmail": "test@example.com", + "status": "PENDING", + "totalAmount": 99.99, + "currency": "USD", + "items": [ + { + "productId": "prod-123", + "productName": "Test Product", + "quantity": 1, + "unitPrice": 99.99, + "totalPrice": 99.99 + } + ], + "shippingAddress": { + "street": "123 Test St", + "city": "Test City", + "state": "TS", + "postalCode": "12345", + "country": "Test Country" + }, + "billingAddress": { + "street": "123 Test St", + "city": "Test City", + "state": "TS", + "postalCode": "12345", + "country": "Test Country" + } + }' \ + http://localhost:8050/order/api/orders) +echo "$CREATE_RESPONSE" + +# Extract the order ID if present in the response +ORDER_ID=$(echo "$CREATE_RESPONSE" | grep -o '"id":[0-9]*' | cut -d':' -f2 | head -1) + +# Test the user endpoint (GET) +echo "Testing GET /api/orders/$ORDER_ID (requires 'user' role)" +echo "Command: curl -s -H \"Authorization: Bearer \$TOKEN\" http://localhost:8050/order/api/orders/$ORDER_ID" +echo +echo "Response:" +RESPONSE=$(curl -s -H "Authorization: Bearer $TOKEN" http://localhost:8050/order/api/orders/$ORDER_ID) +echo "$RESPONSE" + +# If the response contains "error", it's likely an error +if [[ "$RESPONSE" == *"error"* ]]; then + echo + echo "The request may have failed. Trying with verbose output..." + curl -v -H "Authorization: Bearer $TOKEN" http://localhost:8050/order/api/orders/1 +fi + +# Step 4: Admin access test +echo +echo "Step 4: Admin Access Test" +echo "------------------------" +echo "Currently using token with groups: $(echo $DECODED | grep -o '"groups":\[[^]]*\]')" +echo +echo "To test admin endpoint (DELETE /api/orders/{id}):" +echo "1. Edit /workspaces/liberty-rest-app/tools/jwt-token.json" +echo "2. Add 'admin' to the groups array: \"groups\": [\"user\", \"admin\"]" +echo "3. Regenerate the token: cd /workspaces/liberty-rest-app/tools && java -jar jwtenizr.jar" +echo "4. Run this test command:" +if [ -n "$ORDER_ID" ]; then + echo " curl -v -X DELETE -H \"Authorization: Bearer \$(cat /workspaces/liberty-rest-app/tools/token.jwt)\" http://localhost:8050/order/api/orders/$ORDER_ID" +else + echo " curl -v -X DELETE -H \"Authorization: Bearer \$(cat /workspaces/liberty-rest-app/tools/token.jwt)\" http://localhost:8050/order/api/orders/1" +fi + +# Step 5: Troubleshooting tips +echo +echo "Step 5: Troubleshooting Tips" +echo "------------------------" +echo "1. Check JWT configuration in server.xml" +echo "2. Verify public key format in /workspaces/liberty-rest-app/order/src/main/resources/META-INF/publicKey.pem" +echo "3. Run the copy-jwt-key.sh script to ensure the key is in the right location" +echo "4. Verify Liberty server logs: cat /workspaces/liberty-rest-app/order/target/liberty/wlp/usr/servers/orderServer/logs/messages.log | grep -i jwt" +echo +echo "For more details, see: /workspaces/liberty-rest-app/order/JWT-TROUBLESHOOTING.md" diff --git a/code/chapter11/order/fix-jwt-auth.sh b/code/chapter11/order/fix-jwt-auth.sh new file mode 100755 index 0000000..47889c0 --- /dev/null +++ b/code/chapter11/order/fix-jwt-auth.sh @@ -0,0 +1,68 @@ +#!/bin/bash +# Script to fix JWT authentication issues in the order service + +set -e # Exit on any error + +echo "=== Order Service JWT Authentication Fix ===" + +cd /workspaces/liberty-rest-app/order + +# 1. Stop the server if running +echo "Stopping Liberty server..." +mvn liberty:stop || true + +# 2. Clean the target directory to ensure clean configuration +echo "Cleaning target directory..." +mvn clean + +# 3. Make sure our public key is in the correct format +echo "Checking public key format..." +cat src/main/resources/META-INF/publicKey.pem +if ! grep -q "BEGIN PUBLIC KEY" src/main/resources/META-INF/publicKey.pem; then + echo "ERROR: Public key is not in the correct format. It should start with '-----BEGIN PUBLIC KEY-----'" + exit 1 +fi + +# 4. Verify microprofile-config.properties +echo "Checking microprofile-config.properties..." +cat src/main/resources/META-INF/microprofile-config.properties +if ! grep -q "mp.jwt.verify.publickey.location" src/main/resources/META-INF/microprofile-config.properties; then + echo "ERROR: mp.jwt.verify.publickey.location not found in microprofile-config.properties" + exit 1 +fi + +# 5. Verify web.xml +echo "Checking web.xml..." +cat src/main/webapp/WEB-INF/web.xml | grep -A 3 "login-config" +if ! grep -q "MP-JWT" src/main/webapp/WEB-INF/web.xml; then + echo "ERROR: MP-JWT auth-method not found in web.xml" + exit 1 +fi + +# 6. Create the configuration directory and copy resources +echo "Creating configuration directory structure..." +mkdir -p target/liberty/wlp/usr/servers/orderServer + +# 7. Copy the public key to the Liberty server configuration directory +echo "Copying public key to Liberty server configuration..." +cp src/main/resources/META-INF/publicKey.pem target/liberty/wlp/usr/servers/orderServer/ + +# 8. Build the application with the updated configuration +echo "Building and packaging the application..." +mvn package + +# 9. Start the server with the fixed configuration +echo "Starting the Liberty server..." +mvn liberty:start + +# 10. Verify the server started properly +echo "Verifying server status..." +sleep 5 # Give the server a moment to start +if grep -q "CWWKF0011I" target/liberty/wlp/usr/servers/orderServer/logs/messages.log; then + echo "✅ Server started successfully!" +else + echo "❌ Server may have encountered issues. Check logs for details." +fi + +echo "=== Fix applied successfully! ===" +echo "Now test JWT authentication with: ./debug-jwt.sh" diff --git a/code/chapter11/order/pom.xml b/code/chapter11/order/pom.xml new file mode 100644 index 0000000..ff7fdc9 --- /dev/null +++ b/code/chapter11/order/pom.xml @@ -0,0 +1,114 @@ + + + + 4.0.0 + + io.microprofile + order + 1.0-SNAPSHOT + war + + order-management + https://microprofile.io + + + UTF-8 + 17 + 10.0.0 + 6.1 + 23.0.0.3 + 1.18.24 + + + + + + jakarta.platform + jakarta.jakartaee-api + ${jakarta.jakartaee-api.version} + provided + + + + org.eclipse.microprofile + microprofile + ${microprofile.version} + pom + provided + + + + org.projectlombok + lombok + ${lombok.version} + provided + + + + + order + + + + io.openliberty.tools + liberty-maven-plugin + 3.8.2 + + orderServer + runnable + 120 + + /order + + + + + + + + + maven-clean-plugin + 3.1.0 + + + + maven-resources-plugin + 3.0.2 + + + maven-compiler-plugin + 3.8.0 + + + maven-surefire-plugin + 2.22.1 + + + maven-war-plugin + 3.3.2 + + false + + + + maven-install-plugin + 2.5.2 + + + maven-deploy-plugin + 2.8.2 + + + + maven-site-plugin + 3.7.1 + + + maven-project-info-reports-plugin + 3.0.0 + + + + + diff --git a/code/chapter11/order/restart-server.sh b/code/chapter11/order/restart-server.sh new file mode 100755 index 0000000..cf673cc --- /dev/null +++ b/code/chapter11/order/restart-server.sh @@ -0,0 +1,35 @@ +#!/bin/bash +# Script to restart the Liberty server and test the Swagger UI + +# Change to the order directory +cd /workspaces/liberty-rest-app/order + +# Build the application +echo "Building the Order Service application..." +mvn clean package + +# Stop the Liberty server +echo "Stopping Liberty server..." +mvn liberty:stop + +# Copy the public key to the Liberty server config directory +echo "Copying public key to Liberty config directory..." +mkdir -p target/liberty/wlp/usr/servers/orderServer +cp src/main/resources/META-INF/publicKey.pem target/liberty/wlp/usr/servers/orderServer/ + +# Start the Liberty server +echo "Starting Liberty server..." +mvn liberty:start + +# Wait for the server to start +echo "Waiting for server to start..." +sleep 10 + +# Print URLs for testing +echo "" +echo "Server started. You can access the following URLs:" +echo "- API Documentation: http://localhost:8050/order/openapi/ui" +echo "- Custom Swagger UI: http://localhost:8050/order/swagger.html" +echo "- Home Page: http://localhost:8050/order/index.html" +echo "" +echo "If Swagger UI has CORS issues, use the custom Swagger UI at /swagger.html" diff --git a/code/chapter11/order/run-docker.sh b/code/chapter11/order/run-docker.sh new file mode 100755 index 0000000..c3d8912 --- /dev/null +++ b/code/chapter11/order/run-docker.sh @@ -0,0 +1,10 @@ +#!/bin/bash + +# Build the application +mvn clean package + +# Build the Docker image +docker build -t order-service . + +# Run the container +docker run -d --name order-service -p 8050:8050 -p 8051:8051 order-service diff --git a/code/chapter11/order/run.sh b/code/chapter11/order/run.sh new file mode 100755 index 0000000..7b7db54 --- /dev/null +++ b/code/chapter11/order/run.sh @@ -0,0 +1,12 @@ +#!/bin/bash + +# Navigate to the order service directory +cd "$(dirname "$0")" + +# Build the project +echo "Building Order Service..." +mvn clean package + +# Run the Liberty server +echo "Starting Order Service..." +mvn liberty:run diff --git a/code/chapter11/order/src/main/java/io/microprofile/tutorial/store/order/OrderApplication.java b/code/chapter11/order/src/main/java/io/microprofile/tutorial/store/order/OrderApplication.java new file mode 100644 index 0000000..3113aac --- /dev/null +++ b/code/chapter11/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/chapter11/order/src/main/java/io/microprofile/tutorial/store/order/entity/Order.java b/code/chapter11/order/src/main/java/io/microprofile/tutorial/store/order/entity/Order.java new file mode 100644 index 0000000..c1d8be1 --- /dev/null +++ b/code/chapter11/order/src/main/java/io/microprofile/tutorial/store/order/entity/Order.java @@ -0,0 +1,45 @@ +package io.microprofile.tutorial.store.order.entity; + +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotEmpty; +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/chapter11/order/src/main/java/io/microprofile/tutorial/store/order/entity/OrderItem.java b/code/chapter11/order/src/main/java/io/microprofile/tutorial/store/order/entity/OrderItem.java new file mode 100644 index 0000000..ef84996 --- /dev/null +++ b/code/chapter11/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/chapter11/order/src/main/java/io/microprofile/tutorial/store/order/entity/OrderStatus.java b/code/chapter11/order/src/main/java/io/microprofile/tutorial/store/order/entity/OrderStatus.java new file mode 100644 index 0000000..af04ec2 --- /dev/null +++ b/code/chapter11/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/chapter11/order/src/main/java/io/microprofile/tutorial/store/order/package-info.java b/code/chapter11/order/src/main/java/io/microprofile/tutorial/store/order/package-info.java new file mode 100644 index 0000000..9c72ad8 --- /dev/null +++ b/code/chapter11/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/chapter11/order/src/main/java/io/microprofile/tutorial/store/order/repository/OrderItemRepository.java b/code/chapter11/order/src/main/java/io/microprofile/tutorial/store/order/repository/OrderItemRepository.java new file mode 100644 index 0000000..1aa11cf --- /dev/null +++ b/code/chapter11/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/chapter11/order/src/main/java/io/microprofile/tutorial/store/order/repository/OrderRepository.java b/code/chapter11/order/src/main/java/io/microprofile/tutorial/store/order/repository/OrderRepository.java new file mode 100644 index 0000000..743bd26 --- /dev/null +++ b/code/chapter11/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/chapter11/order/src/main/java/io/microprofile/tutorial/store/order/resource/OrderItemResource.java b/code/chapter11/order/src/main/java/io/microprofile/tutorial/store/order/resource/OrderItemResource.java new file mode 100644 index 0000000..e20d36f --- /dev/null +++ b/code/chapter11/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/chapter11/order/src/main/java/io/microprofile/tutorial/store/order/resource/OrderResource.java b/code/chapter11/order/src/main/java/io/microprofile/tutorial/store/order/resource/OrderResource.java new file mode 100644 index 0000000..955b044 --- /dev/null +++ b/code/chapter11/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/chapter11/order/src/main/java/io/microprofile/tutorial/store/order/service/OrderService.java b/code/chapter11/order/src/main/java/io/microprofile/tutorial/store/order/service/OrderService.java new file mode 100644 index 0000000..5d3eb30 --- /dev/null +++ b/code/chapter11/order/src/main/java/io/microprofile/tutorial/store/order/service/OrderService.java @@ -0,0 +1,360 @@ +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 java.util.Optional; + +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; + + /** + * 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 + return getOrderById(savedOrder.getOrderId()); + } + + /** + * 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); + + 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/chapter11/order/src/main/webapp/WEB-INF/web.xml b/code/chapter11/order/src/main/webapp/WEB-INF/web.xml new file mode 100644 index 0000000..6a516f1 --- /dev/null +++ b/code/chapter11/order/src/main/webapp/WEB-INF/web.xml @@ -0,0 +1,10 @@ + + + Order Management + + index.html + + diff --git a/code/chapter11/order/src/main/webapp/index.html b/code/chapter11/order/src/main/webapp/index.html new file mode 100644 index 0000000..605f8a0 --- /dev/null +++ b/code/chapter11/order/src/main/webapp/index.html @@ -0,0 +1,148 @@ + + + + + + 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
+ +
+

MicroProfile Tutorial Store - © 2025

+
+ + diff --git a/code/chapter11/order/src/main/webapp/order-status-codes.html b/code/chapter11/order/src/main/webapp/order-status-codes.html new file mode 100644 index 0000000..faed8a0 --- /dev/null +++ b/code/chapter11/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/chapter11/order/test-jwt.sh b/code/chapter11/order/test-jwt.sh new file mode 100755 index 0000000..7077a93 --- /dev/null +++ b/code/chapter11/order/test-jwt.sh @@ -0,0 +1,34 @@ +#!/bin/bash +# Script to test JWT authentication with the Order Service + +# Check tools directory +if [ ! -d "/workspaces/liberty-rest-app/tools" ]; then + echo "Error: Tools directory not found!" + exit 1 +fi + +# Get the JWT token +TOKEN_FILE="/workspaces/liberty-rest-app/tools/token.jwt" +if [ ! -f "$TOKEN_FILE" ]; then + echo "JWT token not found at $TOKEN_FILE" + echo "Generating a new token..." + cd /workspaces/liberty-rest-app/tools + java -Dverbose -jar jwtenizr.jar +fi + +# Read the token +TOKEN=$(cat "$TOKEN_FILE") + +# Test User Endpoint (GET order) +echo "Testing GET order with user role..." +echo "URL: http://localhost:8050/order/api/orders/1" +curl -v -H "Authorization: Bearer $TOKEN" "http://localhost:8050/order/api/orders/1" + +echo -e "\n\nChecking token payload:" +# Get the payload part (second part of the JWT) and decode it +PAYLOAD=$(echo $TOKEN | cut -d. -f2) +DECODED=$(echo $PAYLOAD | base64 -d 2>/dev/null || echo $PAYLOAD | base64 --decode 2>/dev/null) +echo $DECODED | jq . 2>/dev/null || echo $DECODED + +echo -e "\n\nIf you need admin access, edit the JWT roles in /workspaces/liberty-rest-app/tools/jwt-token.json" +echo "Add 'admin' to the groups array, then regenerate the token with: java -jar tools/jwtenizr.jar" diff --git a/code/chapter11/payment/Dockerfile b/code/chapter11/payment/Dockerfile new file mode 100644 index 0000000..77e6dde --- /dev/null +++ b/code/chapter11/payment/Dockerfile @@ -0,0 +1,20 @@ +FROM icr.io/appcafe/open-liberty:full-java17-openj9-ubi + +# Copy configuration files +COPY --chown=1001:0 src/main/liberty/config/ /config/ + +# Create the apps directory and copy the application +COPY --chown=1001:0 target/payment.war /config/apps/ + +# Configure the server to run in production mode +RUN configure.sh + +# Expose the default port +EXPOSE 9050 9443 + +# Set the health check +HEALTHCHECK --start-period=60s --interval=10s --timeout=5s --retries=3 \ + CMD curl -f http://localhost:9050/health || exit 1 + +# Run the server +CMD ["/opt/ol/wlp/bin/server", "run", "defaultServer"] diff --git a/code/chapter11/payment/README.adoc b/code/chapter11/payment/README.adoc new file mode 100644 index 0000000..500d807 --- /dev/null +++ b/code/chapter11/payment/README.adoc @@ -0,0 +1,266 @@ += Payment Service + +This microservice is part of the Jakarta EE 10 and MicroProfile 6.1-based e-commerce application. It handles payment processing and transaction management. + +== Features + +* Payment transaction processing +* Dynamic configuration management via MicroProfile Config +* RESTful API endpoints with JSON support +* Custom ConfigSource implementation +* OpenAPI documentation + +== Endpoints + +=== GET /payment/api/payment-config +* Returns all current payment configuration values +* Example: `GET http://localhost:9080/payment/api/payment-config` +* Response: `{"gateway.endpoint":"https://api.paymentgateway.com"}` + +=== POST /payment/api/payment-config +* Updates a payment configuration value +* Example: `POST http://localhost:9080/payment/api/payment-config` +* Request body: `{"key": "payment.gateway.endpoint", "value": "https://new-api.paymentgateway.com"}` +* Response: `{"key":"payment.gateway.endpoint","value":"https://new-api.paymentgateway.com","message":"Configuration updated successfully"}` + +=== POST /payment/api/authorize +* Processes a payment +* Example: `POST http://localhost:9080/payment/api/authorize` +* Response: `{"status":"success", "message":"Payment processed successfully."}` + +=== POST /payment/api/payment-config/process-example +* Example endpoint demonstrating payment processing with configuration +* Example: `POST http://localhost:9080/payment/api/payment-config/process-example` +* Request body: `{"cardNumber":"4111111111111111", "cardHolderName":"Test User", "expiryDate":"12/25", "securityCode":"123", "amount":100.00}` +* Response: `{"amount":100.00,"message":"Payment processed successfully","status":"success","configUsed":{"gatewayEndpoint":"https://new-api.paymentgateway.com"}}` + +== Building and Running the Service + +=== Prerequisites + +* JDK 17 or higher +* Maven 3.6.0 or higher + +=== Local Development + +[source,bash] +---- +# Build the application +mvn clean package + +# Run the application with Liberty +mvn liberty:run +---- + +The server will start on port 9080 (HTTP) and 9081 (HTTPS). + +=== Docker + +[source,bash] +---- +# Build and run with Docker +./run-docker.sh +---- + +== Project Structure + +* `src/main/java/io/microprofile/tutorial/PaymentRestApplication.java` - Jakarta Restful web service application class +* `src/main/java/io/microprofile/tutorial/store/payment/config/` - Configuration classes +* `src/main/java/io/microprofile/tutorial/store/payment/resource/` - REST resource endpoints +* `src/main/java/io/microprofile/tutorial/store/payment/service/` - Business logic services +* `src/main/java/io/microprofile/tutorial/store/payment/entity/` - Data models +* `src/main/resources/META-INF/services/` - Service provider configuration +* `src/main/liberty/config/` - Liberty server configuration + +== Custom ConfigSource + +The Payment Service implements a custom MicroProfile ConfigSource named `PaymentServiceConfigSource` that provides payment-specific configuration with high priority (ordinal: 600). + +=== Available Configuration Properties + +[cols="1,2,2", options="header"] +|=== +|Property +|Description +|Default Value + +|payment.gateway.endpoint +|Payment gateway endpoint URL +|https://api.paymentgateway.com +|=== + +=== Testing ConfigSource Endpoints + +You can test the ConfigSource endpoints using curl or any REST client: + +[source,bash] +---- +# Get current configuration +curl -s http://localhost:9080/payment/api/payment-config | json_pp + +# Update configuration property +curl -s -X POST -H "Content-Type: application/json" \ + -d '{"key":"payment.gateway.endpoint", "value":"https://new-api.paymentgateway.com"}' \ + http://localhost:9080/payment/api/payment-config | json_pp + +# Test payment processing with the configuration +curl -s -X POST -H "Content-Type: application/json" \ + -d '{"cardNumber":"4111111111111111", "cardHolderName":"Test User", "expiryDate":"12/25", "securityCode":"123", "amount":100.00}' \ + http://localhost:9080/payment/api/payment-config/process-example | json_pp + +# Test basic payment authorization +curl -s -X POST -H "Content-Type: application/json" \ + http://localhost:9080/payment/api/authorize | json_pp +---- + +=== Implementation Details + +The custom ConfigSource is implemented in the following classes: + +* `PaymentServiceConfigSource.java` - Implements the MicroProfile ConfigSource interface +* `PaymentConfig.java` - Utility class for accessing configuration properties + +Example usage in application code: + +[source,java] +---- +// Inject standard MicroProfile Config +@Inject +@ConfigProperty(name="payment.gateway.endpoint") +private String endpoint; + +// Or use the utility class +String gatewayUrl = PaymentConfig.getConfigProperty("payment.gateway.endpoint"); +---- + +The custom ConfigSource provides a higher priority (ordinal: 600) than system properties and environment variables, allowing for service-specific defaults while still enabling override via standard mechanisms. + +=== MicroProfile Config Sources + +MicroProfile Config uses a prioritized set of configuration sources. The payment service uses the following configuration sources in order of priority (highest to lowest): + +1. Custom ConfigSource (`PaymentServiceConfigSource`) - Ordinal: 600 +2. System properties - Ordinal: 400 +3. Environment variables - Ordinal: 300 +4. microprofile-config.properties file - Ordinal: 100 + +==== Updating Configuration Values + +You can update configuration properties through different methods: + +===== 1. Using the REST API (runtime) + +This uses the custom ConfigSource and persists only for the current server session: + +[source,bash] +---- +curl -X POST -H "Content-Type: application/json" \ + -d '{"key":"payment.gateway.endpoint", "value":"https://test-api.paymentgateway.com"}' \ + http://localhost:9080/payment/api/payment-config +---- + +===== 2. Using System Properties (startup) + +[source,bash] +---- +# Linux/macOS +mvn liberty:run -Dpayment.gateway.endpoint=https://sys-api.paymentgateway.com + +# Windows +mvn liberty:run "-Dpayment.gateway.endpoint=https://sys-api.paymentgateway.com" +---- + +===== 3. Using Environment Variables (startup) + +Environment variable names must follow the MicroProfile Config convention (uppercase with underscores): + +[source,bash] +---- +# Linux/macOS +export PAYMENT_GATEWAY_ENDPOINT=https://env-api.paymentgateway.com +mvn liberty:run + +# Windows PowerShell +$env:PAYMENT_GATEWAY_ENDPOINT="https://env-api.paymentgateway.com" +mvn liberty:run + +# Windows CMD +set PAYMENT_GATEWAY_ENDPOINT=https://env-api.paymentgateway.com +mvn liberty:run +---- + +===== 4. Using microprofile-config.properties File (build time) + +Edit the file at `src/main/resources/META-INF/microprofile-config.properties`: + +[source,properties] +---- +# Update the endpoint +payment.gateway.endpoint=https://config-api.paymentgateway.com +---- + +Then rebuild and restart the application: + +[source,bash] +---- +mvn clean package liberty:run +---- + +==== Testing Configuration Changes + +After changing a configuration property, you can verify it was updated by calling: + +[source,bash] +---- +curl http://localhost:9080/payment/api/payment-config +---- + +== Documentation + +=== OpenAPI + +The payment service automatically generates OpenAPI documentation using MicroProfile OpenAPI annotations. + +* OpenAPI UI: `http://localhost:9080/payment/api/openapi-ui/` +* OpenAPI JSON: `http://localhost:9080/payment/api/openapi` + +=== MicroProfile Config Specification + +For more information about MicroProfile Config, refer to the official documentation: + +* https://download.eclipse.org/microprofile/microprofile-config-3.1/microprofile-config-spec-3.1.html + +=== Related Resources + +* MicroProfile: https://microprofile.io/ +* Jakarta EE: https://jakarta.ee/ +* Open Liberty: https://openliberty.io/ + +== Troubleshooting + +=== Common Issues + +==== Port Conflicts + +If you encounter a port conflict when starting the server, you can change the ports in the `pom.xml` file: + +[source,xml] +---- +9080 +9081 +---- + +==== ConfigSource Not Loading + +If the custom ConfigSource is not loading, check the following: + +1. Verify the service provider configuration file exists at: + `src/main/resources/META-INF/services/org.eclipse.microprofile.config.spi.ConfigSource` + +2. Ensure it contains the correct fully qualified class name: + `io.microprofile.tutorial.store.payment.config.PaymentServiceConfigSource` + +==== Deployment Errors + +For CWWKZ0004E deployment errors, check the server logs at: +`target/liberty/wlp/usr/servers/mpServer/logs/messages.log` diff --git a/code/chapter11/payment/README.md b/code/chapter11/payment/README.md new file mode 100644 index 0000000..70b0621 --- /dev/null +++ b/code/chapter11/payment/README.md @@ -0,0 +1,116 @@ +# Payment Service + +This microservice is part of the Jakarta EE and MicroProfile-based e-commerce application. It handles payment processing and transaction management. + +## Features + +- Payment transaction processing +- Multiple payment methods support +- Transaction status tracking +- Order payment integration +- User payment history + +## Endpoints + +### GET /payment/api/payments +- Returns all payments in the system + +### GET /payment/api/payments/{id} +- Returns a specific payment by ID + +### GET /payment/api/payments/user/{userId} +- Returns all payments for a specific user + +### GET /payment/api/payments/order/{orderId} +- Returns all payments for a specific order + +### GET /payment/api/payments/status/{status} +- Returns all payments with a specific status + +### POST /payment/api/payments +- Creates a new payment +- Request body: Payment JSON + +### PUT /payment/api/payments/{id} +- Updates an existing payment +- Request body: Updated Payment JSON + +### PATCH /payment/api/payments/{id}/status/{status} +- Updates the status of an existing payment + +### POST /payment/api/payments/{id}/process +- Processes a pending payment + +### DELETE /payment/api/payments/{id} +- Deletes a payment + +## Payment Flow + +1. Create a payment with status `PENDING` +2. Process the payment to change status to `PROCESSING` +3. Payment will automatically be updated to either `COMPLETED` or `FAILED` +4. If needed, payments can be `REFUNDED` or `CANCELLED` + +## Running the Service + +### Local Development + +```bash +./run.sh +``` + +### Docker + +```bash +./run-docker.sh +``` + +## Integration with Other Services + +The Payment Service integrates with: + +- **Order Service**: Updates order status based on payment status +- **User Service**: Validates user information for payment processing + +## Testing + +For testing purposes, payments with amounts ending in `.00` will fail, all others will succeed. + +## Custom ConfigSource + +The Payment Service implements a custom MicroProfile ConfigSource named `PaymentServiceConfigSource` that provides payment-specific configuration with high priority (ordinal: 500). + +### Available Configuration Properties + +| Property | Description | Default Value | +|----------|-------------|---------------| +| payment.gateway.endpoint | Payment gateway endpoint URL | https://secure-payment-gateway.example.com/api/v1 | + +### ConfigSource Endpoints + +The custom ConfigSource can be accessed and modified via the following endpoints: + +#### GET /payment/api/payment-config +- Returns all current payment configuration values + +#### POST /payment/api/payment-config +- Updates a payment configuration value +- Request body: `{"key": "payment.property.name", "value": "new-value"}` + +### Example Usage + +```java +// Inject standard MicroProfile Config +@Inject +@ConfigProperty(name="payment.gateway.endpoint") +String gatewayUrl; + +// Or use the utility class +String url = PaymentConfig.getConfigProperty("payment.gateway.endpoint"); +``` + +The custom ConfigSource provides a higher priority than system properties and environment variables, allowing for service-specific defaults while still enabling override via standard mechanisms. + +## Swagger UI + +OpenAPI documentation is available at: `http://localhost:9050/payment/api/openapi-ui/` diff --git a/code/chapter11/payment/pom.xml b/code/chapter11/payment/pom.xml new file mode 100644 index 0000000..12b8fad --- /dev/null +++ b/code/chapter11/payment/pom.xml @@ -0,0 +1,85 @@ + + + + 4.0.0 + + io.microprofile.tutorial + payment + 1.0-SNAPSHOT + war + + + + UTF-8 + 17 + 17 + + UTF-8 + UTF-8 + + + 9080 + 9081 + + payment + + + + + + + + + org.projectlombok + lombok + 1.18.26 + provided + + + + + jakarta.platform + jakarta.jakartaee-api + 10.0.0 + provided + + + + + org.eclipse.microprofile + microprofile + 6.1 + pom + provided + + + + junit + junit + 4.11 + test + + + + + ${project.artifactId} + + + + io.openliberty.tools + liberty-maven-plugin + 3.11.2 + + mpServer + + + + + org.apache.maven.plugins + maven-war-plugin + 3.4.0 + + + + \ No newline at end of file diff --git a/code/chapter11/payment/run-docker.sh b/code/chapter11/payment/run-docker.sh new file mode 100755 index 0000000..e027baf --- /dev/null +++ b/code/chapter11/payment/run-docker.sh @@ -0,0 +1,23 @@ +#!/bin/bash + +# Script to build and run the Payment service in Docker + +# Stop execution on any error +set -e + +# Navigate to the payment service directory +cd "$(dirname "$0")" + +# Build the project with Maven +echo "Building with Maven..." +mvn clean package + +# Build the Docker image +echo "Building Docker image..." +docker build -t payment-service . + +# Run the Docker container +echo "Starting Docker container..." +docker run -d --name payment-service -p 9050:9050 payment-service + +echo "Payment service is running on http://localhost:9050/payment" diff --git a/code/chapter11/payment/run.sh b/code/chapter11/payment/run.sh new file mode 100755 index 0000000..75fc5f2 --- /dev/null +++ b/code/chapter11/payment/run.sh @@ -0,0 +1,19 @@ +#!/bin/bash + +# Script to build and run the Payment service + +# Stop execution on any error +set -e + +echo "Building and running Payment service..." + +# Navigate to the payment service directory +cd "$(dirname "$0")" + +# Build the project with Maven +echo "Building with Maven..." +mvn clean package + +# Run the application using Liberty Maven plugin +echo "Starting Liberty server..." +mvn liberty:run diff --git a/code/chapter11/payment/src/main/java/io/microprofile/tutorial/PaymentRestApplication.java b/code/chapter11/payment/src/main/java/io/microprofile/tutorial/PaymentRestApplication.java new file mode 100644 index 0000000..9ffd751 --- /dev/null +++ b/code/chapter11/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/chapter11/payment/src/main/java/io/microprofile/tutorial/store/payment/client/ProductClient.java b/code/chapter11/payment/src/main/java/io/microprofile/tutorial/store/payment/client/ProductClient.java new file mode 100644 index 0000000..5100206 --- /dev/null +++ b/code/chapter11/payment/src/main/java/io/microprofile/tutorial/store/payment/client/ProductClient.java @@ -0,0 +1,52 @@ +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 ProductClient { + 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/chapter11/payment/src/main/java/io/microprofile/tutorial/store/payment/client/ProductClientJson.java b/code/chapter11/payment/src/main/java/io/microprofile/tutorial/store/payment/client/ProductClientJson.java new file mode 100644 index 0000000..32333de --- /dev/null +++ b/code/chapter11/payment/src/main/java/io/microprofile/tutorial/store/payment/client/ProductClientJson.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 ProductClientJson { + 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/chapter11/payment/src/main/java/io/microprofile/tutorial/store/payment/config/PaymentConfig.java b/code/chapter11/payment/src/main/java/io/microprofile/tutorial/store/payment/config/PaymentConfig.java new file mode 100644 index 0000000..c4df4d6 --- /dev/null +++ b/code/chapter11/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/chapter11/payment/src/main/java/io/microprofile/tutorial/store/payment/config/PaymentServiceConfigSource.java b/code/chapter11/payment/src/main/java/io/microprofile/tutorial/store/payment/config/PaymentServiceConfigSource.java new file mode 100644 index 0000000..25b59a4 --- /dev/null +++ b/code/chapter11/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/chapter11/payment/src/main/java/io/microprofile/tutorial/store/payment/dto/product/Product.java b/code/chapter11/payment/src/main/java/io/microprofile/tutorial/store/payment/dto/product/Product.java new file mode 100644 index 0000000..3b58843 --- /dev/null +++ b/code/chapter11/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/chapter11/payment/src/main/java/io/microprofile/tutorial/store/payment/entity/PaymentDetails.java b/code/chapter11/payment/src/main/java/io/microprofile/tutorial/store/payment/entity/PaymentDetails.java new file mode 100644 index 0000000..4b62460 --- /dev/null +++ b/code/chapter11/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/chapter11/payment/src/main/java/io/microprofile/tutorial/store/payment/examples/ProductClientExample.java b/code/chapter11/payment/src/main/java/io/microprofile/tutorial/store/payment/examples/ProductClientExample.java new file mode 100644 index 0000000..0decaa3 --- /dev/null +++ b/code/chapter11/payment/src/main/java/io/microprofile/tutorial/store/payment/examples/ProductClientExample.java @@ -0,0 +1,71 @@ +package io.microprofile.tutorial.store.payment.examples; + +import io.microprofile.tutorial.store.payment.client.ProductClientJson; +import io.microprofile.tutorial.store.payment.dto.product.Product; + +import java.util.Arrays; +import java.util.logging.Logger; + +/** + * Example demonstrating how to use the ProductClientJson.getProductsWithJsonp method. + */ +public class ProductClientExample { + + private static final Logger LOGGER = Logger.getLogger(ProductClientExample.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 = ProductClientJson.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 = ProductClientJson.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 = ProductClientJson.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/chapter11/payment/src/main/java/io/microprofile/tutorial/store/payment/resource/PaymentConfigResource.java b/code/chapter11/payment/src/main/java/io/microprofile/tutorial/store/payment/resource/PaymentConfigResource.java new file mode 100644 index 0000000..6a4002f --- /dev/null +++ b/code/chapter11/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/chapter11/payment/src/main/java/io/microprofile/tutorial/store/payment/resource/PaymentProductResource.java b/code/chapter11/payment/src/main/java/io/microprofile/tutorial/store/payment/resource/PaymentProductResource.java new file mode 100644 index 0000000..9d1069e --- /dev/null +++ b/code/chapter11/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.ProductClientJson; +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 ProductClientJson.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 = ProductClientJson.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 = ProductClientJson.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/chapter11/payment/src/main/java/io/microprofile/tutorial/store/payment/service/PaymentService.java b/code/chapter11/payment/src/main/java/io/microprofile/tutorial/store/payment/service/PaymentService.java new file mode 100644 index 0000000..7e7c6d2 --- /dev/null +++ b/code/chapter11/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/chapter11/payment/src/main/java/io/microprofile/tutorial/store/payment/service/payment.http b/code/chapter11/payment/src/main/java/io/microprofile/tutorial/store/payment/service/payment.http new file mode 100644 index 0000000..98ae2e5 --- /dev/null +++ b/code/chapter11/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/chapter11/payment/src/main/resources/META-INF/microprofile-config.properties b/code/chapter11/payment/src/main/resources/META-INF/microprofile-config.properties new file mode 100644 index 0000000..cf83436 --- /dev/null +++ b/code/chapter11/payment/src/main/resources/META-INF/microprofile-config.properties @@ -0,0 +1,11 @@ +# microprofile-config.properties +mp.openapi.scan=true +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 \ No newline at end of file diff --git a/code/chapter11/payment/src/main/resources/META-INF/services/org.eclipse.microprofile.config.spi.ConfigSource b/code/chapter11/payment/src/main/resources/META-INF/services/org.eclipse.microprofile.config.spi.ConfigSource new file mode 100644 index 0000000..9870717 --- /dev/null +++ b/code/chapter11/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/chapter11/payment/src/main/webapp/WEB-INF/web.xml b/code/chapter11/payment/src/main/webapp/WEB-INF/web.xml new file mode 100644 index 0000000..9e4411b --- /dev/null +++ b/code/chapter11/payment/src/main/webapp/WEB-INF/web.xml @@ -0,0 +1,12 @@ + + + Payment Service + + + index.html + index.jsp + + diff --git a/code/chapter11/payment/src/main/webapp/index.html b/code/chapter11/payment/src/main/webapp/index.html new file mode 100644 index 0000000..33086f2 --- /dev/null +++ b/code/chapter11/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/chapter11/payment/src/main/webapp/index.jsp b/code/chapter11/payment/src/main/webapp/index.jsp new file mode 100644 index 0000000..d5de5cb --- /dev/null +++ b/code/chapter11/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/chapter11/payment/test-product-client.sh b/code/chapter11/payment/test-product-client.sh new file mode 100755 index 0000000..9295681 --- /dev/null +++ b/code/chapter11/payment/test-product-client.sh @@ -0,0 +1,35 @@ +#!/bin/bash + +# Script to test the ProductClientJson.getProductsWithJsonp method integration + +echo "=== Testing ProductClientJson Integration ===" + +# Base URL for the payment service +PAYMENT_SERVICE_URL="http://localhost:9080/payment/api" + +echo "" +echo "1. Testing Get All Products" +echo "curl -X GET $PAYMENT_SERVICE_URL/products" +curl -X GET "$PAYMENT_SERVICE_URL/products" | json_pp 2>/dev/null || echo "Failed to parse JSON response" + +echo "" +echo "2. Testing Get Products from Custom URL" +echo "curl -X GET '$PAYMENT_SERVICE_URL/products/from-url?url=http://localhost:5050/catalog/api/products'" +curl -X GET "$PAYMENT_SERVICE_URL/products/from-url?url=http://localhost:5050/catalog/api/products" | json_pp 2>/dev/null || echo "Failed to parse JSON response" + +echo "" +echo "3. Testing Product Validation" +echo "curl -X GET $PAYMENT_SERVICE_URL/products/1/validate" +curl -X GET "$PAYMENT_SERVICE_URL/products/1/validate" | json_pp 2>/dev/null || echo "Failed to parse JSON response" + +echo "" +echo "4. Testing Products by Price Range" +echo "curl -X GET '$PAYMENT_SERVICE_URL/products/price-range?minPrice=10&maxPrice=100'" +curl -X GET "$PAYMENT_SERVICE_URL/products/price-range?minPrice=10&maxPrice=100" | json_pp 2>/dev/null || echo "Failed to parse JSON response" + +echo "" +echo "5. Testing ProductClientExample (if compiled)" +echo "cd /workspaces/liberty-rest-app/payment && java -cp target/classes io.microprofile.tutorial.store.payment.examples.ProductClientExample" + +echo "" +echo "=== Test Complete ===" diff --git a/code/chapter11/run-all-services.sh b/code/chapter11/run-all-services.sh new file mode 100755 index 0000000..5127720 --- /dev/null +++ b/code/chapter11/run-all-services.sh @@ -0,0 +1,36 @@ +#!/bin/bash + +# Build all projects +echo "Building User Service..." +cd user && mvn clean package && cd .. + +echo "Building Inventory Service..." +cd inventory && mvn clean package && cd .. + +echo "Building Order Service..." +cd order && mvn clean package && cd .. + +echo "Building Catalog Service..." +cd catalog && mvn clean package && cd .. + +echo "Building Payment Service..." +cd payment && mvn clean package && cd .. + +echo "Building Shopping Cart Service..." +cd shoppingcart && mvn clean package && cd .. + +echo "Building Shipment Service..." +cd shipment && mvn clean package && cd .. + +# Start all services using docker-compose +echo "Starting all services with Docker Compose..." +docker-compose up -d + +echo "All services are running:" +echo "- User Service: https://scaling-pancake-77vj4pwq7fpjqx-6050.app.github.dev/user" +echo "- Inventory Service: https://scaling-pancake-77vj4pwq7fpjqx-7050.app.github.dev/inventory" +echo "- Order Service: https://scaling-pancake-77vj4pwq7fpjqx-8050.app.github.dev/order" +echo "- Catalog Service: https://scaling-pancake-77vj4pwq7fpjqx-5050.app.github.dev/catalog" +echo "- Payment Service: https://scaling-pancake-77vj4pwq7fpjqx-9050.app.github.dev/payment" +echo "- Shopping Cart Service: https://scaling-pancake-77vj4pwq7fpjqx-4050.app.github.dev/shoppingcart" +echo "- Shipment Service: https://scaling-pancake-77vj4pwq7fpjqx-8060.app.github.dev/shipment" diff --git a/code/chapter11/shipment/Dockerfile b/code/chapter11/shipment/Dockerfile new file mode 100644 index 0000000..287b43d --- /dev/null +++ b/code/chapter11/shipment/Dockerfile @@ -0,0 +1,27 @@ +FROM icr.io/appcafe/open-liberty:23.0.0.3-full-java17-openj9-ubi + +# Copy config +COPY --chown=1001:0 src/main/liberty/config/ /config/ + +# Create the app directory +COPY --chown=1001:0 target/shipment.war /config/apps/ + +# Optional: Copy utility scripts +COPY --chown=1001:0 *.sh /opt/ol/helpers/ + +# Environment variables +ENV VERBOSE=true + +# This is important - adds the management of vulnerability databases to allow Docker scanning +RUN dnf install -y shadow-utils + +# Set environment variable for MP config profile +ENV MP_CONFIG_PROFILE=docker + +EXPOSE 8060 9060 + +# Run as non-root user for security +USER 1001 + +# Start Liberty +ENTRYPOINT ["/opt/ol/wlp/bin/server", "run", "defaultServer"] diff --git a/code/chapter11/shipment/README.md b/code/chapter11/shipment/README.md new file mode 100644 index 0000000..4161994 --- /dev/null +++ b/code/chapter11/shipment/README.md @@ -0,0 +1,87 @@ +# Shipment Service + +This is the Shipment Service for the MicroProfile Tutorial e-commerce application. The service manages shipments for orders in the system. + +## Overview + +The Shipment Service is responsible for: +- Creating shipments for orders +- Tracking shipment status (PENDING, PROCESSING, SHIPPED, IN_TRANSIT, OUT_FOR_DELIVERY, DELIVERED, FAILED, RETURNED) +- Assigning tracking numbers +- Estimating delivery dates +- Communicating with the Order Service to update order status + +## Technologies + +The Shipment Service is built using: +- Jakarta EE 10 +- MicroProfile 6.1 +- Open Liberty +- Java 17 + +## Getting Started + +### Prerequisites + +- JDK 17+ +- Maven 3.8+ +- Docker (for containerized deployment) + +### Running Locally + +To build and run the service: + +```bash +./run.sh +``` + +This will build the application and start the Open Liberty server. The service will be available at: http://localhost:8060/shipment + +### Running with Docker + +To build and run the service in a Docker container: + +```bash +./run-docker.sh +``` + +This will build a Docker image for the service and run it, exposing ports 8060 and 9060. + +## API Endpoints + +| Method | URL | Description | +|--------|-------------------------------------------|--------------------------------------| +| POST | /api/shipments/orders/{orderId} | Create a new shipment | +| GET | /api/shipments/{shipmentId} | Get a shipment by ID | +| GET | /api/shipments | Get all shipments | +| GET | /api/shipments/status/{status} | Get shipments by status | +| GET | /api/shipments/orders/{orderId} | Get shipments for an order | +| GET | /api/shipments/tracking/{trackingNumber} | Get a shipment by tracking number | +| PUT | /api/shipments/{shipmentId}/status/{status} | Update shipment status | +| PUT | /api/shipments/{shipmentId}/carrier | Update shipment carrier | +| PUT | /api/shipments/{shipmentId}/tracking | Update shipment tracking number | +| PUT | /api/shipments/{shipmentId}/delivery-date | Update estimated delivery date | +| PUT | /api/shipments/{shipmentId}/notes | Update shipment notes | +| DELETE | /api/shipments/{shipmentId} | Delete a shipment | + +## MicroProfile Features + +The service utilizes several MicroProfile features: + +- **Config**: For external configuration +- **Health**: For liveness and readiness checks +- **Metrics**: For monitoring service performance +- **Fault Tolerance**: For resilient communication with the Order Service +- **OpenAPI**: For API documentation + +## Documentation + +API documentation is available at: +- OpenAPI: http://localhost:8060/shipment/openapi +- Swagger UI: http://localhost:8060/shipment/openapi/ui + +## Monitoring + +Health and metrics endpoints: +- Health: http://localhost:8060/shipment/health +- Metrics: http://localhost:8060/shipment/metrics diff --git a/code/chapter11/shipment/pom.xml b/code/chapter11/shipment/pom.xml new file mode 100644 index 0000000..9a78242 --- /dev/null +++ b/code/chapter11/shipment/pom.xml @@ -0,0 +1,114 @@ + + + + 4.0.0 + + io.microprofile + shipment + 1.0-SNAPSHOT + war + + shipment-service + https://microprofile.io + + + UTF-8 + 17 + 10.0.0 + 6.1 + 23.0.0.3 + 1.18.24 + + + + + + jakarta.platform + jakarta.jakartaee-api + ${jakarta.jakartaee-api.version} + provided + + + + org.eclipse.microprofile + microprofile + ${microprofile.version} + pom + provided + + + + org.projectlombok + lombok + ${lombok.version} + provided + + + + + shipment + + + + io.openliberty.tools + liberty-maven-plugin + 3.8.2 + + shipmentServer + runnable + 120 + + /shipment + + + + + + + + + maven-clean-plugin + 3.1.0 + + + + maven-resources-plugin + 3.0.2 + + + maven-compiler-plugin + 3.8.0 + + + maven-surefire-plugin + 2.22.1 + + + maven-war-plugin + 3.3.2 + + false + + + + maven-install-plugin + 2.5.2 + + + maven-deploy-plugin + 2.8.2 + + + + maven-site-plugin + 3.7.1 + + + maven-project-info-reports-plugin + 3.0.0 + + + + + diff --git a/code/chapter11/shipment/run-docker.sh b/code/chapter11/shipment/run-docker.sh new file mode 100755 index 0000000..69a5150 --- /dev/null +++ b/code/chapter11/shipment/run-docker.sh @@ -0,0 +1,11 @@ +#!/bin/bash + +# Build and run the Shipment Service in Docker +echo "Building and starting Shipment Service in Docker..." + +# Build the application +mvn clean package + +# Build and run the Docker image +docker build -t shipment-service . +docker run -p 8060:8060 -p 9060:9060 --name shipment-service shipment-service diff --git a/code/chapter11/shipment/run.sh b/code/chapter11/shipment/run.sh new file mode 100755 index 0000000..b6fd34a --- /dev/null +++ b/code/chapter11/shipment/run.sh @@ -0,0 +1,12 @@ +#!/bin/bash + +# Build and run the Shipment Service +echo "Building and starting Shipment Service..." + +# Stop running server if already running +if [ -f target/liberty/wlp/usr/servers/shipmentServer/workarea/.sRunning ]; then + mvn liberty:stop +fi + +# Clean, build and run +mvn clean package liberty:run diff --git a/code/chapter11/shipment/src/main/java/io/microprofile/tutorial/store/shipment/ShipmentApplication.java b/code/chapter11/shipment/src/main/java/io/microprofile/tutorial/store/shipment/ShipmentApplication.java new file mode 100644 index 0000000..9ccfbc6 --- /dev/null +++ b/code/chapter11/shipment/src/main/java/io/microprofile/tutorial/store/shipment/ShipmentApplication.java @@ -0,0 +1,35 @@ +package io.microprofile.tutorial.store.shipment; + +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 class for the shipment service. + */ +@ApplicationPath("/") +@OpenAPIDefinition( + info = @Info( + title = "Shipment Service API", + version = "1.0.0", + description = "API for managing shipments in the microprofile tutorial store", + contact = @Contact( + name = "Shipment Service Support", + email = "shipment@example.com" + ), + license = @License( + name = "Apache 2.0", + url = "https://www.apache.org/licenses/LICENSE-2.0.html" + ) + ), + tags = { + @Tag(name = "Shipment Resource", description = "Operations for managing shipments") + } +) +public class ShipmentApplication extends Application { + // Empty application class, all configuration is provided by annotations +} diff --git a/code/chapter11/shipment/src/main/java/io/microprofile/tutorial/store/shipment/client/OrderClient.java b/code/chapter11/shipment/src/main/java/io/microprofile/tutorial/store/shipment/client/OrderClient.java new file mode 100644 index 0000000..a930d3c --- /dev/null +++ b/code/chapter11/shipment/src/main/java/io/microprofile/tutorial/store/shipment/client/OrderClient.java @@ -0,0 +1,193 @@ +package io.microprofile.tutorial.store.shipment.client; + +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.ws.rs.ProcessingException; +import jakarta.ws.rs.client.Client; +import jakarta.ws.rs.client.ClientBuilder; +import jakarta.ws.rs.client.Entity; +import jakarta.ws.rs.core.MediaType; +import jakarta.ws.rs.core.Response; + +import org.eclipse.microprofile.config.inject.ConfigProperty; +import org.eclipse.microprofile.faulttolerance.CircuitBreaker; +import org.eclipse.microprofile.faulttolerance.Fallback; +import org.eclipse.microprofile.faulttolerance.Retry; +import org.eclipse.microprofile.faulttolerance.Timeout; + +import java.time.temporal.ChronoUnit; +import java.util.logging.Level; +import java.util.logging.Logger; + +/** + * Client for communicating with the Order Service. + */ +@ApplicationScoped +public class OrderClient { + + private static final Logger LOGGER = Logger.getLogger(OrderClient.class.getName()); + + @ConfigProperty(name = "order.service.url", defaultValue = "http://localhost:8050/order") + private String orderServiceUrl; + + /** + * Updates the order status after a shipment has been processed. + * + * @param orderId The ID of the order to update + * @param newStatus The new status for the order + * @return true if the update was successful, false otherwise + */ + @Retry(maxRetries = 3, delay = 1000, jitter = 200, unit = ChronoUnit.MILLIS) + @Timeout(value = 5, unit = ChronoUnit.SECONDS) + @CircuitBreaker(requestVolumeThreshold = 4, failureRatio = 0.5, delay = 10000, successThreshold = 2) + @Fallback(fallbackMethod = "updateOrderStatusFallback") + public boolean updateOrderStatus(Long orderId, String newStatus) { + LOGGER.info(String.format("Updating order %d status to %s", orderId, newStatus)); + + Client client = null; + try { + client = ClientBuilder.newClient(); + String url = String.format("%s/api/orders/%d/status/%s", orderServiceUrl, orderId, newStatus); + + Response response = client.target(url) + .request(MediaType.APPLICATION_JSON) + .put(Entity.json("{}")); + + boolean success = response.getStatus() == Response.Status.OK.getStatusCode(); + if (!success) { + LOGGER.warning(String.format("Failed to update order status. Status code: %d", response.getStatus())); + } + return success; + } catch (ProcessingException e) { + LOGGER.log(Level.SEVERE, "Error connecting to Order Service", e); + throw e; + } finally { + if (client != null) { + client.close(); + } + } + } + + /** + * Verifies that an order exists and is in a valid state for shipment. + * + * @param orderId The ID of the order to verify + * @return true if the order exists and is in a valid state, false otherwise + */ + @Retry(maxRetries = 3, delay = 1000, jitter = 200, unit = ChronoUnit.MILLIS) + @Timeout(value = 5, unit = ChronoUnit.SECONDS) + @CircuitBreaker(requestVolumeThreshold = 4, failureRatio = 0.5, delay = 10000, successThreshold = 2) + @Fallback(fallbackMethod = "verifyOrderFallback") + public boolean verifyOrder(Long orderId) { + LOGGER.info(String.format("Verifying order %d for shipment", orderId)); + + Client client = null; + try { + client = ClientBuilder.newClient(); + String url = String.format("%s/api/orders/%d", orderServiceUrl, orderId); + + Response response = client.target(url) + .request(MediaType.APPLICATION_JSON) + .get(); + + if (response.getStatus() == Response.Status.OK.getStatusCode()) { + String jsonResponse = response.readEntity(String.class); + // Simple check if the order is in a valid state for shipment + // In a real app, we'd parse the JSON properly + return jsonResponse.contains("\"status\":\"PAID\"") || + jsonResponse.contains("\"status\":\"PROCESSING\"") || + jsonResponse.contains("\"status\":\"READY_FOR_SHIPMENT\""); + } + + LOGGER.warning(String.format("Failed to verify order. Status code: %d", response.getStatus())); + return false; + } catch (ProcessingException e) { + LOGGER.log(Level.SEVERE, "Error connecting to Order Service", e); + throw e; + } finally { + if (client != null) { + client.close(); + } + } + } + + /** + * Gets the shipping address for an order. + * + * @param orderId The ID of the order + * @return The shipping address, or null if not found + */ + @Retry(maxRetries = 3, delay = 1000, jitter = 200, unit = ChronoUnit.MILLIS) + @Timeout(value = 5, unit = ChronoUnit.SECONDS) + @CircuitBreaker(requestVolumeThreshold = 4, failureRatio = 0.5, delay = 10000, successThreshold = 2) + @Fallback(fallbackMethod = "getShippingAddressFallback") + public String getShippingAddress(Long orderId) { + LOGGER.info(String.format("Getting shipping address for order %d", orderId)); + + Client client = null; + try { + client = ClientBuilder.newClient(); + String url = String.format("%s/api/orders/%d", orderServiceUrl, orderId); + + Response response = client.target(url) + .request(MediaType.APPLICATION_JSON) + .get(); + + if (response.getStatus() == Response.Status.OK.getStatusCode()) { + String jsonResponse = response.readEntity(String.class); + // Simple extract of shipping address - in real app use proper JSON parsing + if (jsonResponse.contains("\"shippingAddress\":")) { + int startIndex = jsonResponse.indexOf("\"shippingAddress\":") + "\"shippingAddress\":".length(); + startIndex = jsonResponse.indexOf("\"", startIndex) + 1; + int endIndex = jsonResponse.indexOf("\"", startIndex); + if (endIndex > startIndex) { + return jsonResponse.substring(startIndex, endIndex); + } + } + } + + LOGGER.warning(String.format("Failed to get shipping address. Status code: %d", response.getStatus())); + return null; + } catch (ProcessingException e) { + LOGGER.log(Level.SEVERE, "Error connecting to Order Service", e); + throw e; + } finally { + if (client != null) { + client.close(); + } + } + } + + /** + * Fallback method for updateOrderStatus. + * + * @param orderId The ID of the order + * @param newStatus The new status for the order + * @return false, indicating failure + */ + public boolean updateOrderStatusFallback(Long orderId, String newStatus) { + LOGGER.warning(String.format("Using fallback for order status update. Order ID: %d, Status: %s", orderId, newStatus)); + return false; + } + + /** + * Fallback method for verifyOrder. + * + * @param orderId The ID of the order + * @return false, indicating failure + */ + public boolean verifyOrderFallback(Long orderId) { + LOGGER.warning(String.format("Using fallback for order verification. Order ID: %d", orderId)); + return false; + } + + /** + * Fallback method for getShippingAddress. + * + * @param orderId The ID of the order + * @return null, indicating failure + */ + public String getShippingAddressFallback(Long orderId) { + LOGGER.warning(String.format("Using fallback for getting shipping address. Order ID: %d", orderId)); + return null; + } +} diff --git a/code/chapter11/shipment/src/main/java/io/microprofile/tutorial/store/shipment/entity/Shipment.java b/code/chapter11/shipment/src/main/java/io/microprofile/tutorial/store/shipment/entity/Shipment.java new file mode 100644 index 0000000..d9bea89 --- /dev/null +++ b/code/chapter11/shipment/src/main/java/io/microprofile/tutorial/store/shipment/entity/Shipment.java @@ -0,0 +1,45 @@ +package io.microprofile.tutorial.store.shipment.entity; + +import jakarta.validation.constraints.NotNull; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; + +/** + * Shipment class for the microprofile tutorial store application. + * This class represents a shipment of an order in the system. + */ +@Data +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class Shipment { + + private Long shipmentId; + + @NotNull(message = "Order ID cannot be null") + private Long orderId; + + private String trackingNumber; + + @NotNull(message = "Status cannot be null") + private ShipmentStatus status; + + private LocalDateTime estimatedDelivery; + + private LocalDateTime shippedAt; + + @Builder.Default + private LocalDateTime createdAt = LocalDateTime.now(); + + private LocalDateTime updatedAt; + + private String carrier; + + private String shippingAddress; + + private String notes; +} diff --git a/code/chapter11/shipment/src/main/java/io/microprofile/tutorial/store/shipment/entity/ShipmentStatus.java b/code/chapter11/shipment/src/main/java/io/microprofile/tutorial/store/shipment/entity/ShipmentStatus.java new file mode 100644 index 0000000..0e120a9 --- /dev/null +++ b/code/chapter11/shipment/src/main/java/io/microprofile/tutorial/store/shipment/entity/ShipmentStatus.java @@ -0,0 +1,16 @@ +package io.microprofile.tutorial.store.shipment.entity; + +/** + * ShipmentStatus enum for the microprofile tutorial store application. + * This enum defines the possible statuses for a shipment. + */ +public enum ShipmentStatus { + PENDING, // Shipment is pending + PROCESSING, // Shipment is being processed + SHIPPED, // Shipment has been shipped + IN_TRANSIT, // Shipment is in transit + OUT_FOR_DELIVERY,// Shipment is out for delivery + DELIVERED, // Shipment has been delivered + FAILED, // Shipment delivery failed + RETURNED // Shipment was returned +} diff --git a/code/chapter11/shipment/src/main/java/io/microprofile/tutorial/store/shipment/filter/CorsFilter.java b/code/chapter11/shipment/src/main/java/io/microprofile/tutorial/store/shipment/filter/CorsFilter.java new file mode 100644 index 0000000..ec26495 --- /dev/null +++ b/code/chapter11/shipment/src/main/java/io/microprofile/tutorial/store/shipment/filter/CorsFilter.java @@ -0,0 +1,43 @@ +package io.microprofile.tutorial.store.shipment.filter; + +import jakarta.servlet.*; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import java.io.IOException; + +/** + * Filter to enable CORS for the Shipment service. + */ +public class CorsFilter implements Filter { + + @Override + public void init(FilterConfig filterConfig) throws ServletException { + // No initialization required + } + + @Override + public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain chain) + throws IOException, ServletException { + + HttpServletRequest request = (HttpServletRequest) servletRequest; + HttpServletResponse response = (HttpServletResponse) servletResponse; + + // Allow requests from any origin + response.setHeader("Access-Control-Allow-Origin", "*"); + response.setHeader("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS"); + response.setHeader("Access-Control-Allow-Headers", "Content-Type, Authorization"); + response.setHeader("Access-Control-Max-Age", "3600"); + + // For preflight requests + if ("OPTIONS".equalsIgnoreCase(request.getMethod())) { + response.setStatus(HttpServletResponse.SC_OK); + } else { + chain.doFilter(request, response); + } + } + + @Override + public void destroy() { + // No cleanup required + } +} diff --git a/code/chapter11/shipment/src/main/java/io/microprofile/tutorial/store/shipment/health/ShipmentHealthCheck.java b/code/chapter11/shipment/src/main/java/io/microprofile/tutorial/store/shipment/health/ShipmentHealthCheck.java new file mode 100644 index 0000000..4bf8a50 --- /dev/null +++ b/code/chapter11/shipment/src/main/java/io/microprofile/tutorial/store/shipment/health/ShipmentHealthCheck.java @@ -0,0 +1,67 @@ +package io.microprofile.tutorial.store.shipment.health; + +import io.microprofile.tutorial.store.shipment.client.OrderClient; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.inject.Inject; +import org.eclipse.microprofile.health.HealthCheck; +import org.eclipse.microprofile.health.HealthCheckResponse; +import org.eclipse.microprofile.health.Liveness; +import org.eclipse.microprofile.health.Readiness; + +/** + * Health check for the shipment service. + */ +@ApplicationScoped +public class ShipmentHealthCheck { + + @Inject + private OrderClient orderClient; + + /** + * Liveness check for the shipment service. + * Verifies that the application is running and not in a failed state. + * + * @return HealthCheckResponse indicating whether the service is live + */ + @Liveness + @ApplicationScoped + public static class LivenessCheck implements HealthCheck { + @Override + public HealthCheckResponse call() { + return HealthCheckResponse.named("shipment-liveness") + .up() + .withData("memory", Runtime.getRuntime().freeMemory()) + .build(); + } + } + + /** + * Readiness check for the shipment service. + * Verifies that the service is ready to handle requests, including connectivity to dependencies. + * + * @return HealthCheckResponse indicating whether the service is ready + */ + @Readiness + @ApplicationScoped + public class ReadinessCheck implements HealthCheck { + @Override + public HealthCheckResponse call() { + boolean orderServiceReachable = false; + + try { + // Simple check to see if the Order service is reachable + // We use a dummy order ID just to test connectivity + orderClient.getShippingAddress(999999L); + orderServiceReachable = true; + } catch (Exception e) { + // If the order service is not reachable, the health check will fail + orderServiceReachable = false; + } + + return HealthCheckResponse.named("shipment-readiness") + .status(orderServiceReachable) + .withData("orderServiceReachable", orderServiceReachable) + .build(); + } + } +} diff --git a/code/chapter11/shipment/src/main/java/io/microprofile/tutorial/store/shipment/repository/ShipmentRepository.java b/code/chapter11/shipment/src/main/java/io/microprofile/tutorial/store/shipment/repository/ShipmentRepository.java new file mode 100644 index 0000000..c4013a9 --- /dev/null +++ b/code/chapter11/shipment/src/main/java/io/microprofile/tutorial/store/shipment/repository/ShipmentRepository.java @@ -0,0 +1,148 @@ +package io.microprofile.tutorial.store.shipment.repository; + +import io.microprofile.tutorial.store.shipment.entity.Shipment; +import io.microprofile.tutorial.store.shipment.entity.ShipmentStatus; + +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.concurrent.ConcurrentHashMap; +import java.util.stream.Collectors; + +import jakarta.enterprise.context.ApplicationScoped; + +/** + * Simple in-memory repository for Shipment objects. + * This class provides CRUD operations for Shipment entities. + */ +@ApplicationScoped +public class ShipmentRepository { + + private final Map shipments = new ConcurrentHashMap<>(); + private long nextId = 1; + + /** + * Saves a shipment to the repository. + * If the shipment has no ID, a new ID is assigned. + * + * @param shipment The shipment to save + * @return The saved shipment with ID assigned + */ + public Shipment save(Shipment shipment) { + if (shipment.getShipmentId() == null) { + shipment.setShipmentId(nextId++); + } + + if (shipment.getCreatedAt() == null) { + shipment.setCreatedAt(LocalDateTime.now()); + } + + shipment.setUpdatedAt(LocalDateTime.now()); + + shipments.put(shipment.getShipmentId(), shipment); + return shipment; + } + + /** + * Finds a shipment by ID. + * + * @param id The shipment ID + * @return An Optional containing the shipment if found, or empty if not found + */ + public Optional findById(Long id) { + return Optional.ofNullable(shipments.get(id)); + } + + /** + * Finds shipments by order ID. + * + * @param orderId The order ID + * @return A list of shipments for the specified order + */ + public List findByOrderId(Long orderId) { + return shipments.values().stream() + .filter(shipment -> shipment.getOrderId().equals(orderId)) + .collect(Collectors.toList()); + } + + /** + * Finds shipments by tracking number. + * + * @param trackingNumber The tracking number + * @return A list of shipments with the specified tracking number + */ + public List findByTrackingNumber(String trackingNumber) { + return shipments.values().stream() + .filter(shipment -> trackingNumber.equals(shipment.getTrackingNumber())) + .collect(Collectors.toList()); + } + + /** + * Finds shipments by status. + * + * @param status The shipment status + * @return A list of shipments with the specified status + */ + public List findByStatus(ShipmentStatus status) { + return shipments.values().stream() + .filter(shipment -> shipment.getStatus() == status) + .collect(Collectors.toList()); + } + + /** + * Finds shipments that are expected to be delivered by a certain date. + * + * @param deliveryDate The delivery date + * @return A list of shipments expected to be delivered by the specified date + */ + public List findByEstimatedDeliveryBefore(LocalDateTime deliveryDate) { + return shipments.values().stream() + .filter(shipment -> shipment.getEstimatedDelivery() != null && + shipment.getEstimatedDelivery().isBefore(deliveryDate)) + .collect(Collectors.toList()); + } + + /** + * Retrieves all shipments from the repository. + * + * @return A list of all shipments + */ + public List findAll() { + return new ArrayList<>(shipments.values()); + } + + /** + * Deletes a shipment by ID. + * + * @param id The ID of the shipment to delete + * @return true if the shipment was deleted, false if not found + */ + public boolean deleteById(Long id) { + return shipments.remove(id) != null; + } + + /** + * Updates an existing shipment. + * + * @param id The ID of the shipment to update + * @param shipment The updated shipment information + * @return An Optional containing the updated shipment, or empty if not found + */ + public Optional update(Long id, Shipment shipment) { + if (!shipments.containsKey(id)) { + return Optional.empty(); + } + + // Preserve creation date + LocalDateTime createdAt = shipments.get(id).getCreatedAt(); + shipment.setCreatedAt(createdAt); + + shipment.setShipmentId(id); + shipment.setUpdatedAt(LocalDateTime.now()); + + shipments.put(id, shipment); + return Optional.of(shipment); + } +} diff --git a/code/chapter11/shipment/src/main/java/io/microprofile/tutorial/store/shipment/resource/ShipmentResource.java b/code/chapter11/shipment/src/main/java/io/microprofile/tutorial/store/shipment/resource/ShipmentResource.java new file mode 100644 index 0000000..602be80 --- /dev/null +++ b/code/chapter11/shipment/src/main/java/io/microprofile/tutorial/store/shipment/resource/ShipmentResource.java @@ -0,0 +1,397 @@ +package io.microprofile.tutorial.store.shipment.resource; + +import io.microprofile.tutorial.store.shipment.entity.Shipment; +import io.microprofile.tutorial.store.shipment.entity.ShipmentStatus; +import io.microprofile.tutorial.store.shipment.service.ShipmentService; +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.MediaType; +import jakarta.ws.rs.core.Response; +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; + +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; +import java.util.List; +import java.util.Optional; +import java.util.logging.Logger; + +/** + * REST resource for shipment operations. + */ +@Path("/api/shipments") +@RequestScoped +@Produces(MediaType.APPLICATION_JSON) +@Consumes(MediaType.APPLICATION_JSON) +@Tag(name = "Shipment Resource", description = "Operations for managing shipments") +public class ShipmentResource { + + private static final Logger LOGGER = Logger.getLogger(ShipmentResource.class.getName()); + + @Inject + private ShipmentService shipmentService; + + /** + * Creates a new shipment for an order. + * + * @param orderId The order ID + * @return The created shipment + */ + @POST + @Path("/orders/{orderId}") + @Operation(summary = "Create a new shipment for an order") + @APIResponse(responseCode = "201", description = "Shipment created", + content = @Content(mediaType = MediaType.APPLICATION_JSON, + schema = @Schema(implementation = Shipment.class))) + @APIResponse(responseCode = "400", description = "Invalid order ID") + @APIResponse(responseCode = "404", description = "Order not found or not ready for shipment") + public Response createShipment( + @Parameter(description = "Order ID", required = true) + @PathParam("orderId") Long orderId) { + + LOGGER.info("REST request to create shipment for order: " + orderId); + + Shipment shipment = shipmentService.createShipment(orderId); + if (shipment == null) { + return Response.status(Response.Status.NOT_FOUND) + .entity("{\"error\": \"Order not found or not ready for shipment\"}") + .build(); + } + + return Response.status(Response.Status.CREATED) + .entity(shipment) + .build(); + } + + /** + * Gets a shipment by ID. + * + * @param shipmentId The shipment ID + * @return The shipment + */ + @GET + @Path("/{shipmentId}") + @Operation(summary = "Get a shipment by ID") + @APIResponse(responseCode = "200", description = "Shipment found", + content = @Content(mediaType = MediaType.APPLICATION_JSON, + schema = @Schema(implementation = Shipment.class))) + @APIResponse(responseCode = "404", description = "Shipment not found") + public Response getShipment( + @Parameter(description = "Shipment ID", required = true) + @PathParam("shipmentId") Long shipmentId) { + + LOGGER.info("REST request to get shipment: " + shipmentId); + + Optional shipment = shipmentService.getShipment(shipmentId); + if (shipment.isPresent()) { + return Response.ok(shipment.get()).build(); + } + + return Response.status(Response.Status.NOT_FOUND) + .entity("{\"error\": \"Shipment not found\"}") + .build(); + } + + /** + * Gets all shipments. + * + * @return All shipments + */ + @GET + @Operation(summary = "Get all shipments") + @APIResponse(responseCode = "200", description = "All shipments", + content = @Content(mediaType = MediaType.APPLICATION_JSON, + schema = @Schema(type = SchemaType.ARRAY, implementation = Shipment.class))) + public Response getAllShipments() { + LOGGER.info("REST request to get all shipments"); + + List shipments = shipmentService.getAllShipments(); + return Response.ok(shipments).build(); + } + + /** + * Gets shipments by status. + * + * @param status The status + * @return The shipments with the given status + */ + @GET + @Path("/status/{status}") + @Operation(summary = "Get shipments by status") + @APIResponse(responseCode = "200", description = "Shipments with the given status", + content = @Content(mediaType = MediaType.APPLICATION_JSON, + schema = @Schema(type = SchemaType.ARRAY, implementation = Shipment.class))) + public Response getShipmentsByStatus( + @Parameter(description = "Shipment status", required = true) + @PathParam("status") ShipmentStatus status) { + + LOGGER.info("REST request to get shipments with status: " + status); + + List shipments = shipmentService.getShipmentsByStatus(status); + return Response.ok(shipments).build(); + } + + /** + * Gets shipments by order ID. + * + * @param orderId The order ID + * @return The shipments for the given order + */ + @GET + @Path("/orders/{orderId}") + @Operation(summary = "Get shipments by order ID") + @APIResponse(responseCode = "200", description = "Shipments for the given order", + content = @Content(mediaType = MediaType.APPLICATION_JSON, + schema = @Schema(type = SchemaType.ARRAY, implementation = Shipment.class))) + public Response getShipmentsByOrder( + @Parameter(description = "Order ID", required = true) + @PathParam("orderId") Long orderId) { + + LOGGER.info("REST request to get shipments for order: " + orderId); + + List shipments = shipmentService.getShipmentsByOrder(orderId); + return Response.ok(shipments).build(); + } + + /** + * Gets a shipment by tracking number. + * + * @param trackingNumber The tracking number + * @return The shipment + */ + @GET + @Path("/tracking/{trackingNumber}") + @Operation(summary = "Get a shipment by tracking number") + @APIResponse(responseCode = "200", description = "Shipment found", + content = @Content(mediaType = MediaType.APPLICATION_JSON, + schema = @Schema(implementation = Shipment.class))) + @APIResponse(responseCode = "404", description = "Shipment not found") + public Response getShipmentByTrackingNumber( + @Parameter(description = "Tracking number", required = true) + @PathParam("trackingNumber") String trackingNumber) { + + LOGGER.info("REST request to get shipment with tracking number: " + trackingNumber); + + Optional shipment = shipmentService.getShipmentByTrackingNumber(trackingNumber); + if (shipment.isPresent()) { + return Response.ok(shipment.get()).build(); + } + + return Response.status(Response.Status.NOT_FOUND) + .entity("{\"error\": \"Shipment not found\"}") + .build(); + } + + /** + * Updates the status of a shipment. + * + * @param shipmentId The shipment ID + * @param status The new status + * @return The updated shipment + */ + @PUT + @Path("/{shipmentId}/status/{status}") + @Operation(summary = "Update shipment status") + @APIResponse(responseCode = "200", description = "Shipment status updated", + content = @Content(mediaType = MediaType.APPLICATION_JSON, + schema = @Schema(implementation = Shipment.class))) + @APIResponse(responseCode = "404", description = "Shipment not found") + public Response updateShipmentStatus( + @Parameter(description = "Shipment ID", required = true) + @PathParam("shipmentId") Long shipmentId, + @Parameter(description = "New status", required = true) + @PathParam("status") ShipmentStatus status) { + + LOGGER.info("REST request to update shipment " + shipmentId + " status to " + status); + + Optional shipment = shipmentService.updateShipmentStatus(shipmentId, status); + if (shipment.isPresent()) { + return Response.ok(shipment.get()).build(); + } + + return Response.status(Response.Status.NOT_FOUND) + .entity("{\"error\": \"Shipment not found\"}") + .build(); + } + + /** + * Updates the carrier for a shipment. + * + * @param shipmentId The shipment ID + * @param carrier The new carrier + * @return The updated shipment + */ + @PUT + @Path("/{shipmentId}/carrier") + @Operation(summary = "Update shipment carrier") + @APIResponse(responseCode = "200", description = "Carrier updated", + content = @Content(mediaType = MediaType.APPLICATION_JSON, + schema = @Schema(implementation = Shipment.class))) + @APIResponse(responseCode = "404", description = "Shipment not found") + public Response updateCarrier( + @Parameter(description = "Shipment ID", required = true) + @PathParam("shipmentId") Long shipmentId, + @Parameter(description = "New carrier", required = true) + @NotNull String carrier) { + + LOGGER.info("REST request to update carrier for shipment " + shipmentId + " to " + carrier); + + Optional shipment = shipmentService.updateCarrier(shipmentId, carrier); + if (shipment.isPresent()) { + return Response.ok(shipment.get()).build(); + } + + return Response.status(Response.Status.NOT_FOUND) + .entity("{\"error\": \"Shipment not found\"}") + .build(); + } + + /** + * Updates the tracking number for a shipment. + * + * @param shipmentId The shipment ID + * @param trackingNumber The new tracking number + * @return The updated shipment + */ + @PUT + @Path("/{shipmentId}/tracking") + @Operation(summary = "Update shipment tracking number") + @APIResponse(responseCode = "200", description = "Tracking number updated", + content = @Content(mediaType = MediaType.APPLICATION_JSON, + schema = @Schema(implementation = Shipment.class))) + @APIResponse(responseCode = "404", description = "Shipment not found") + public Response updateTrackingNumber( + @Parameter(description = "Shipment ID", required = true) + @PathParam("shipmentId") Long shipmentId, + @Parameter(description = "New tracking number", required = true) + @NotNull String trackingNumber) { + + LOGGER.info("REST request to update tracking number for shipment " + shipmentId + " to " + trackingNumber); + + Optional shipment = shipmentService.updateTrackingNumber(shipmentId, trackingNumber); + if (shipment.isPresent()) { + return Response.ok(shipment.get()).build(); + } + + return Response.status(Response.Status.NOT_FOUND) + .entity("{\"error\": \"Shipment not found\"}") + .build(); + } + + /** + * Updates the estimated delivery date for a shipment. + * + * @param shipmentId The shipment ID + * @param dateStr The new estimated delivery date (ISO format) + * @return The updated shipment + */ + @PUT + @Path("/{shipmentId}/delivery-date") + @Operation(summary = "Update shipment estimated delivery date") + @APIResponse(responseCode = "200", description = "Estimated delivery date updated", + content = @Content(mediaType = MediaType.APPLICATION_JSON, + schema = @Schema(implementation = Shipment.class))) + @APIResponse(responseCode = "400", description = "Invalid date format") + @APIResponse(responseCode = "404", description = "Shipment not found") + public Response updateEstimatedDelivery( + @Parameter(description = "Shipment ID", required = true) + @PathParam("shipmentId") Long shipmentId, + @Parameter(description = "New estimated delivery date (ISO format: yyyy-MM-dd'T'HH:mm:ss)", required = true) + @NotNull String dateStr) { + + LOGGER.info("REST request to update estimated delivery for shipment " + shipmentId + " to " + dateStr); + + try { + LocalDateTime date = LocalDateTime.parse(dateStr, DateTimeFormatter.ISO_LOCAL_DATE_TIME); + Optional shipment = shipmentService.updateEstimatedDelivery(shipmentId, date); + + if (shipment.isPresent()) { + return Response.ok(shipment.get()).build(); + } + + return Response.status(Response.Status.NOT_FOUND) + .entity("{\"error\": \"Shipment not found\"}") + .build(); + } catch (Exception e) { + return Response.status(Response.Status.BAD_REQUEST) + .entity("{\"error\": \"Invalid date format. Use ISO format: yyyy-MM-dd'T'HH:mm:ss\"}") + .build(); + } + } + + /** + * Updates the notes for a shipment. + * + * @param shipmentId The shipment ID + * @param notes The new notes + * @return The updated shipment + */ + @PUT + @Path("/{shipmentId}/notes") + @Operation(summary = "Update shipment notes") + @APIResponse(responseCode = "200", description = "Notes updated", + content = @Content(mediaType = MediaType.APPLICATION_JSON, + schema = @Schema(implementation = Shipment.class))) + @APIResponse(responseCode = "404", description = "Shipment not found") + public Response updateNotes( + @Parameter(description = "Shipment ID", required = true) + @PathParam("shipmentId") Long shipmentId, + @Parameter(description = "New notes", required = true) + String notes) { + + LOGGER.info("REST request to update notes for shipment " + shipmentId); + + Optional shipment = shipmentService.updateNotes(shipmentId, notes); + if (shipment.isPresent()) { + return Response.ok(shipment.get()).build(); + } + + return Response.status(Response.Status.NOT_FOUND) + .entity("{\"error\": \"Shipment not found\"}") + .build(); + } + + /** + * Deletes a shipment. + * + * @param shipmentId The shipment ID + * @return A response indicating success or failure + */ + @DELETE + @Path("/{shipmentId}") + @Operation(summary = "Delete a shipment") + @APIResponse(responseCode = "204", description = "Shipment deleted") + @APIResponse(responseCode = "404", description = "Shipment not found") + @APIResponse(responseCode = "400", description = "Shipment cannot be deleted due to its status") + public Response deleteShipment( + @Parameter(description = "Shipment ID", required = true) + @PathParam("shipmentId") Long shipmentId) { + + LOGGER.info("REST request to delete shipment: " + shipmentId); + + boolean deleted = shipmentService.deleteShipment(shipmentId); + if (deleted) { + return Response.noContent().build(); + } + + // Check if shipment exists but cannot be deleted due to its status + Optional shipment = shipmentService.getShipment(shipmentId); + if (shipment.isPresent()) { + return Response.status(Response.Status.BAD_REQUEST) + .entity("{\"error\": \"Shipment cannot be deleted due to its status\"}") + .build(); + } + + return Response.status(Response.Status.NOT_FOUND) + .entity("{\"error\": \"Shipment not found\"}") + .build(); + } +} diff --git a/code/chapter11/shipment/src/main/java/io/microprofile/tutorial/store/shipment/service/ShipmentService.java b/code/chapter11/shipment/src/main/java/io/microprofile/tutorial/store/shipment/service/ShipmentService.java new file mode 100644 index 0000000..f29aade --- /dev/null +++ b/code/chapter11/shipment/src/main/java/io/microprofile/tutorial/store/shipment/service/ShipmentService.java @@ -0,0 +1,305 @@ +package io.microprofile.tutorial.store.shipment.service; + +import io.microprofile.tutorial.store.shipment.client.OrderClient; +import io.microprofile.tutorial.store.shipment.entity.Shipment; +import io.microprofile.tutorial.store.shipment.entity.ShipmentStatus; +import io.microprofile.tutorial.store.shipment.repository.ShipmentRepository; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.inject.Inject; +import org.eclipse.microprofile.metrics.annotation.Counted; +import org.eclipse.microprofile.metrics.annotation.Timed; + +import java.time.LocalDateTime; +import java.time.temporal.ChronoUnit; +import java.util.*; +import java.util.logging.Logger; + +/** + * Shipment Service for managing shipments. + */ +@ApplicationScoped +public class ShipmentService { + + private static final Logger LOGGER = Logger.getLogger(ShipmentService.class.getName()); + private static final Random RANDOM = new Random(); + private static final String[] CARRIERS = {"FedEx", "UPS", "USPS", "DHL", "Amazon Logistics"}; + + @Inject + private ShipmentRepository shipmentRepository; + + @Inject + private OrderClient orderClient; + + /** + * Creates a new shipment for an order. + * + * @param orderId The order ID + * @return The created shipment, or null if the order is invalid + */ + @Counted(name = "shipmentCreations", description = "Number of shipments created") + @Timed(name = "createShipmentTimer", description = "Time to create a shipment") + public Shipment createShipment(Long orderId) { + LOGGER.info("Creating shipment for order: " + orderId); + + // Verify that the order exists and is ready for shipment + if (!orderClient.verifyOrder(orderId)) { + LOGGER.warning("Order " + orderId + " is not valid for shipment"); + return null; + } + + // Get shipping address from order service + String shippingAddress = orderClient.getShippingAddress(orderId); + if (shippingAddress == null) { + LOGGER.warning("Could not retrieve shipping address for order " + orderId); + return null; + } + + // Create a new shipment + Shipment shipment = Shipment.builder() + .orderId(orderId) + .status(ShipmentStatus.PENDING) + .trackingNumber(generateTrackingNumber()) + .carrier(selectRandomCarrier()) + .shippingAddress(shippingAddress) + .estimatedDelivery(LocalDateTime.now().plusDays(5)) + .createdAt(LocalDateTime.now()) + .build(); + + Shipment savedShipment = shipmentRepository.save(shipment); + + // Update order status to indicate shipment is being processed + orderClient.updateOrderStatus(orderId, "SHIPMENT_CREATED"); + + return savedShipment; + } + + /** + * Updates the status of a shipment. + * + * @param shipmentId The shipment ID + * @param status The new status + * @return The updated shipment, or empty if not found + */ + @Counted(name = "shipmentStatusUpdates", description = "Number of shipment status updates") + public Optional updateShipmentStatus(Long shipmentId, ShipmentStatus status) { + LOGGER.info("Updating shipment " + shipmentId + " status to " + status); + + Optional shipmentOpt = shipmentRepository.findById(shipmentId); + if (shipmentOpt.isPresent()) { + Shipment shipment = shipmentOpt.get(); + shipment.setStatus(status); + shipment.setUpdatedAt(LocalDateTime.now()); + + // If status is SHIPPED, set the shipped date + if (status == ShipmentStatus.SHIPPED) { + shipment.setShippedAt(LocalDateTime.now()); + orderClient.updateOrderStatus(shipment.getOrderId(), "SHIPPED"); + } + // If status is DELIVERED, update order status + else if (status == ShipmentStatus.DELIVERED) { + orderClient.updateOrderStatus(shipment.getOrderId(), "DELIVERED"); + } + // If status is FAILED, update order status + else if (status == ShipmentStatus.FAILED) { + orderClient.updateOrderStatus(shipment.getOrderId(), "DELIVERY_FAILED"); + } + + return Optional.of(shipmentRepository.save(shipment)); + } + + return Optional.empty(); + } + + /** + * Gets a shipment by ID. + * + * @param shipmentId The shipment ID + * @return The shipment, or empty if not found + */ + public Optional getShipment(Long shipmentId) { + LOGGER.info("Getting shipment: " + shipmentId); + return shipmentRepository.findById(shipmentId); + } + + /** + * Gets all shipments for an order. + * + * @param orderId The order ID + * @return The list of shipments for the order + */ + public List getShipmentsByOrder(Long orderId) { + LOGGER.info("Getting shipments for order: " + orderId); + return shipmentRepository.findByOrderId(orderId); + } + + /** + * Gets a shipment by tracking number. + * + * @param trackingNumber The tracking number + * @return The shipment, or empty if not found + */ + public Optional getShipmentByTrackingNumber(String trackingNumber) { + LOGGER.info("Getting shipment with tracking number: " + trackingNumber); + List shipments = shipmentRepository.findByTrackingNumber(trackingNumber); + return shipments.isEmpty() ? Optional.empty() : Optional.of(shipments.get(0)); + } + + /** + * Gets all shipments. + * + * @return All shipments + */ + public List getAllShipments() { + LOGGER.info("Getting all shipments"); + return shipmentRepository.findAll(); + } + + /** + * Gets shipments by status. + * + * @param status The status + * @return The list of shipments with the given status + */ + public List getShipmentsByStatus(ShipmentStatus status) { + LOGGER.info("Getting shipments with status: " + status); + return shipmentRepository.findByStatus(status); + } + + /** + * Gets shipments due for delivery by the given date. + * + * @param date The date + * @return The list of shipments due by the given date + */ + public List getShipmentsDueBy(LocalDateTime date) { + LOGGER.info("Getting shipments due by: " + date); + return shipmentRepository.findByEstimatedDeliveryBefore(date); + } + + /** + * Updates the carrier for a shipment. + * + * @param shipmentId The shipment ID + * @param carrier The new carrier + * @return The updated shipment, or empty if not found + */ + public Optional updateCarrier(Long shipmentId, String carrier) { + LOGGER.info("Updating carrier for shipment " + shipmentId + " to " + carrier); + + Optional shipmentOpt = shipmentRepository.findById(shipmentId); + if (shipmentOpt.isPresent()) { + Shipment shipment = shipmentOpt.get(); + shipment.setCarrier(carrier); + shipment.setUpdatedAt(LocalDateTime.now()); + return Optional.of(shipmentRepository.save(shipment)); + } + + return Optional.empty(); + } + + /** + * Updates the tracking number for a shipment. + * + * @param shipmentId The shipment ID + * @param trackingNumber The new tracking number + * @return The updated shipment, or empty if not found + */ + public Optional updateTrackingNumber(Long shipmentId, String trackingNumber) { + LOGGER.info("Updating tracking number for shipment " + shipmentId + " to " + trackingNumber); + + Optional shipmentOpt = shipmentRepository.findById(shipmentId); + if (shipmentOpt.isPresent()) { + Shipment shipment = shipmentOpt.get(); + shipment.setTrackingNumber(trackingNumber); + shipment.setUpdatedAt(LocalDateTime.now()); + return Optional.of(shipmentRepository.save(shipment)); + } + + return Optional.empty(); + } + + /** + * Updates the estimated delivery date for a shipment. + * + * @param shipmentId The shipment ID + * @param estimatedDelivery The new estimated delivery date + * @return The updated shipment, or empty if not found + */ + public Optional updateEstimatedDelivery(Long shipmentId, LocalDateTime estimatedDelivery) { + LOGGER.info("Updating estimated delivery for shipment " + shipmentId + " to " + estimatedDelivery); + + Optional shipmentOpt = shipmentRepository.findById(shipmentId); + if (shipmentOpt.isPresent()) { + Shipment shipment = shipmentOpt.get(); + shipment.setEstimatedDelivery(estimatedDelivery); + shipment.setUpdatedAt(LocalDateTime.now()); + return Optional.of(shipmentRepository.save(shipment)); + } + + return Optional.empty(); + } + + /** + * Updates the notes for a shipment. + * + * @param shipmentId The shipment ID + * @param notes The new notes + * @return The updated shipment, or empty if not found + */ + public Optional updateNotes(Long shipmentId, String notes) { + LOGGER.info("Updating notes for shipment " + shipmentId); + + Optional shipmentOpt = shipmentRepository.findById(shipmentId); + if (shipmentOpt.isPresent()) { + Shipment shipment = shipmentOpt.get(); + shipment.setNotes(notes); + shipment.setUpdatedAt(LocalDateTime.now()); + return Optional.of(shipmentRepository.save(shipment)); + } + + return Optional.empty(); + } + + /** + * Deletes a shipment. + * + * @param shipmentId The shipment ID + * @return true if the shipment was deleted, false if not found + */ + public boolean deleteShipment(Long shipmentId) { + LOGGER.info("Deleting shipment: " + shipmentId); + Optional shipmentOpt = shipmentRepository.findById(shipmentId); + if (shipmentOpt.isPresent()) { + // Only allow deletion if the shipment is in PENDING or PROCESSING status + ShipmentStatus status = shipmentOpt.get().getStatus(); + if (status == ShipmentStatus.PENDING || status == ShipmentStatus.PROCESSING) { + return shipmentRepository.deleteById(shipmentId); + } + LOGGER.warning("Cannot delete shipment with status: " + status); + return false; + } + return false; + } + + /** + * Generates a random tracking number. + * + * @return A random tracking number + */ + private String generateTrackingNumber() { + return String.format("%s-%04d-%04d-%04d", + CARRIERS[RANDOM.nextInt(CARRIERS.length)].substring(0, 2).toUpperCase(), + RANDOM.nextInt(10000), + RANDOM.nextInt(10000), + RANDOM.nextInt(10000)); + } + + /** + * Selects a random carrier. + * + * @return A random carrier + */ + private String selectRandomCarrier() { + return CARRIERS[RANDOM.nextInt(CARRIERS.length)]; + } +} diff --git a/code/chapter11/shipment/src/main/resources/META-INF/microprofile-config.properties b/code/chapter11/shipment/src/main/resources/META-INF/microprofile-config.properties new file mode 100644 index 0000000..5057c12 --- /dev/null +++ b/code/chapter11/shipment/src/main/resources/META-INF/microprofile-config.properties @@ -0,0 +1,32 @@ +# Shipment Service Configuration + +# Order Service URL +order.service.url=http://localhost:8050/order + +# Configure health check properties +mp.health.check.timeout=5s + +# Configure default MP Metrics properties +mp.metrics.tags=app=shipment-service + +# Configure fault tolerance policies +# Retry configuration +mp.fault.tolerance.Retry.delay=1000 +mp.fault.tolerance.Retry.maxRetries=3 +mp.fault.tolerance.Retry.jitter=200 + +# Timeout configuration +mp.fault.tolerance.Timeout.value=5000 + +# Circuit Breaker configuration +mp.fault.tolerance.CircuitBreaker.requestVolumeThreshold=5 +mp.fault.tolerance.CircuitBreaker.failureRatio=0.5 +mp.fault.tolerance.CircuitBreaker.delay=10000 +mp.fault.tolerance.CircuitBreaker.successThreshold=2 + +# Open API configuration +mp.openapi.scan.disable=false +mp.openapi.scan.packages=io.microprofile.tutorial.store.shipment + +# In Docker environment, override the Order service URL +%docker.order.service.url=http://order:8050/order diff --git a/code/chapter11/shipment/src/main/webapp/WEB-INF/web.xml b/code/chapter11/shipment/src/main/webapp/WEB-INF/web.xml new file mode 100644 index 0000000..73f6b5e --- /dev/null +++ b/code/chapter11/shipment/src/main/webapp/WEB-INF/web.xml @@ -0,0 +1,23 @@ + + + + Shipment Service + + + index.html + + + + + CorsFilter + io.microprofile.tutorial.store.shipment.filter.CorsFilter + + + CorsFilter + /* + + + diff --git a/code/chapter11/shipment/src/main/webapp/index.html b/code/chapter11/shipment/src/main/webapp/index.html new file mode 100644 index 0000000..5641acb --- /dev/null +++ b/code/chapter11/shipment/src/main/webapp/index.html @@ -0,0 +1,150 @@ + + + + + + Shipment Service - MicroProfile Tutorial + + + +

Shipment Service

+

+ This is the Shipment Service for the MicroProfile Tutorial e-commerce application. + The service manages shipments for orders in the system. +

+ +

REST API

+

+ The service exposes the following endpoints: +

+ +
+

POST /api/shipments/orders/{orderId}

+

Create a new shipment for an order.

+
+ +
+

GET /api/shipments/{shipmentId}

+

Get a shipment by ID.

+
+ +
+

GET /api/shipments

+

Get all shipments.

+
+ +
+

GET /api/shipments/status/{status}

+

Get shipments by status (e.g., PENDING, PROCESSING, SHIPPED, etc.).

+
+ +
+

GET /api/shipments/orders/{orderId}

+

Get all shipments for an order.

+
+ +
+

GET /api/shipments/tracking/{trackingNumber}

+

Get a shipment by tracking number.

+
+ +
+

PUT /api/shipments/{shipmentId}/status/{status}

+

Update the status of a shipment.

+
+ +
+

PUT /api/shipments/{shipmentId}/carrier

+

Update the carrier for a shipment.

+
+ +
+

PUT /api/shipments/{shipmentId}/tracking

+

Update the tracking number for a shipment.

+
+ +
+

PUT /api/shipments/{shipmentId}/delivery-date

+

Update the estimated delivery date for a shipment.

+
+ +
+

PUT /api/shipments/{shipmentId}/notes

+

Update the notes for a shipment.

+
+ +
+

DELETE /api/shipments/{shipmentId}

+

Delete a shipment (only allowed for shipments in PENDING or PROCESSING status).

+
+ +

OpenAPI Documentation

+

+ The service provides OpenAPI documentation at /shipment/openapi. + You can also access the Swagger UI at /shipment/openapi/ui. +

+ +

Health Checks

+

+ MicroProfile Health endpoints are available at: +

+ + +

Metrics

+

+ MicroProfile Metrics are available at /shipment/metrics. +

+ +
+

Shipment Service - MicroProfile Tutorial E-commerce Application

+
+ + diff --git a/code/chapter11/shoppingcart/Dockerfile b/code/chapter11/shoppingcart/Dockerfile new file mode 100644 index 0000000..c207b40 --- /dev/null +++ b/code/chapter11/shoppingcart/Dockerfile @@ -0,0 +1,20 @@ +FROM icr.io/appcafe/open-liberty:full-java17-openj9-ubi + +# Copy configuration files +COPY --chown=1001:0 src/main/liberty/config/ /config/ + +# Create the apps directory and copy the application +COPY --chown=1001:0 target/shoppingcart.war /config/apps/ + +# Configure the server to run in production mode +RUN configure.sh + +# Expose the default port +EXPOSE 4050 4443 + +# Set the health check +HEALTHCHECK --start-period=60s --interval=10s --timeout=5s --retries=3 \ + CMD curl -f http://localhost:4050/health || exit 1 + +# Run the server +CMD ["/opt/ol/wlp/bin/server", "run", "defaultServer"] diff --git a/code/chapter11/shoppingcart/README.md b/code/chapter11/shoppingcart/README.md new file mode 100644 index 0000000..a989bfe --- /dev/null +++ b/code/chapter11/shoppingcart/README.md @@ -0,0 +1,87 @@ +# Shopping Cart Service + +This microservice is part of the Jakarta EE and MicroProfile-based e-commerce application. It handles shopping cart management for users. + +## Features + +- Create and manage user shopping carts +- Add products to cart with quantity +- Update and remove cart items +- Check product availability via the Inventory Service +- Fetch product details from the Catalog Service + +## Endpoints + +### GET /shoppingcart/api/carts +- Returns all shopping carts in the system + +### GET /shoppingcart/api/carts/{id} +- Returns a specific shopping cart by ID + +### GET /shoppingcart/api/carts/user/{userId} +- Returns or creates a shopping cart for a specific user + +### POST /shoppingcart/api/carts/user/{userId} +- Creates a new shopping cart for a user + +### POST /shoppingcart/api/carts/{cartId}/items +- Adds an item to a shopping cart +- Request body: CartItem JSON + +### PUT /shoppingcart/api/carts/{cartId}/items/{itemId} +- Updates an item in a shopping cart +- Request body: Updated CartItem JSON + +### DELETE /shoppingcart/api/carts/{cartId}/items/{itemId} +- Removes an item from a shopping cart + +### DELETE /shoppingcart/api/carts/{cartId}/items +- Removes all items from a shopping cart + +### DELETE /shoppingcart/api/carts/{cartId} +- Deletes a shopping cart + +## Cart Item JSON Example + +```json +{ + "productId": 1, + "quantity": 2, + "productName": "Product Name", // Optional, will be fetched from Catalog if not provided + "price": 29.99, // Optional, will be fetched from Catalog if not provided + "imageUrl": "product-image.jpg" // Optional, will be fetched from Catalog if not provided +} +``` + +## Running the Service + +### Local Development + +```bash +./run.sh +``` + +### Docker + +```bash +./run-docker.sh +``` + +## Integration with Other Services + +The Shopping Cart Service integrates with: + +- **Inventory Service**: Checks product availability before adding to cart +- **Catalog Service**: Retrieves product details (name, price, image) +- **Order Service**: Indirectly, when a cart is converted to an order + +## MicroProfile Features Used + +- **Config**: For service URL configuration +- **Fault Tolerance**: Circuit breakers, timeouts, retries, and fallbacks for resilient communication +- **Health**: Liveness and readiness checks +- **OpenAPI**: API documentation + +## Swagger UI + +OpenAPI documentation is available at: `http://localhost:4050/shoppingcart/api/openapi-ui/` diff --git a/code/chapter11/shoppingcart/pom.xml b/code/chapter11/shoppingcart/pom.xml new file mode 100644 index 0000000..9451fea --- /dev/null +++ b/code/chapter11/shoppingcart/pom.xml @@ -0,0 +1,114 @@ + + + + 4.0.0 + + io.microprofile + shoppingcart + 1.0-SNAPSHOT + war + + shopping-cart-service + https://microprofile.io + + + UTF-8 + 17 + 10.0.0 + 6.1 + 23.0.0.3 + 1.18.24 + + + + + + jakarta.platform + jakarta.jakartaee-api + ${jakarta.jakartaee-api.version} + provided + + + + org.eclipse.microprofile + microprofile + ${microprofile.version} + pom + provided + + + + org.projectlombok + lombok + ${lombok.version} + provided + + + + + shoppingcart + + + + io.openliberty.tools + liberty-maven-plugin + 3.8.2 + + shoppingcartServer + runnable + 120 + + /shoppingcart + + + + + + + + + maven-clean-plugin + 3.1.0 + + + + maven-resources-plugin + 3.0.2 + + + maven-compiler-plugin + 3.8.0 + + + maven-surefire-plugin + 2.22.1 + + + maven-war-plugin + 3.3.2 + + false + + + + maven-install-plugin + 2.5.2 + + + maven-deploy-plugin + 2.8.2 + + + + maven-site-plugin + 3.7.1 + + + maven-project-info-reports-plugin + 3.0.0 + + + + + diff --git a/code/chapter11/shoppingcart/run-docker.sh b/code/chapter11/shoppingcart/run-docker.sh new file mode 100755 index 0000000..6b32df8 --- /dev/null +++ b/code/chapter11/shoppingcart/run-docker.sh @@ -0,0 +1,23 @@ +#!/bin/bash + +# Script to build and run the Shopping Cart service in Docker + +# Stop execution on any error +set -e + +# Navigate to the shopping cart service directory +cd "$(dirname "$0")" + +# Build the project with Maven +echo "Building with Maven..." +mvn clean package + +# Build the Docker image +echo "Building Docker image..." +docker build -t shoppingcart-service . + +# Run the Docker container +echo "Starting Docker container..." +docker run -d --name shoppingcart-service -p 4050:4050 shoppingcart-service + +echo "Shopping Cart service is running on http://localhost:4050/shoppingcart" diff --git a/code/chapter11/shoppingcart/run.sh b/code/chapter11/shoppingcart/run.sh new file mode 100755 index 0000000..02b3ee6 --- /dev/null +++ b/code/chapter11/shoppingcart/run.sh @@ -0,0 +1,19 @@ +#!/bin/bash + +# Script to build and run the Shopping Cart service + +# Stop execution on any error +set -e + +echo "Building and running Shopping Cart service..." + +# Navigate to the shopping cart service directory +cd "$(dirname "$0")" + +# Build the project with Maven +echo "Building with Maven..." +mvn clean package + +# Run the application using Liberty Maven plugin +echo "Starting Liberty server..." +mvn liberty:run diff --git a/code/chapter11/shoppingcart/src/main/java/io/microprofile/tutorial/store/shoppingcart/ShoppingCartApplication.java b/code/chapter11/shoppingcart/src/main/java/io/microprofile/tutorial/store/shoppingcart/ShoppingCartApplication.java new file mode 100644 index 0000000..84cfe0d --- /dev/null +++ b/code/chapter11/shoppingcart/src/main/java/io/microprofile/tutorial/store/shoppingcart/ShoppingCartApplication.java @@ -0,0 +1,12 @@ +package io.microprofile.tutorial.store.shoppingcart; + +import jakarta.ws.rs.ApplicationPath; +import jakarta.ws.rs.core.Application; + +/** + * REST application for shopping cart management. + */ +@ApplicationPath("/api") +public class ShoppingCartApplication extends Application { + // The resources will be discovered automatically +} diff --git a/code/chapter11/shoppingcart/src/main/java/io/microprofile/tutorial/store/shoppingcart/client/CatalogClient.java b/code/chapter11/shoppingcart/src/main/java/io/microprofile/tutorial/store/shoppingcart/client/CatalogClient.java new file mode 100644 index 0000000..e13684c --- /dev/null +++ b/code/chapter11/shoppingcart/src/main/java/io/microprofile/tutorial/store/shoppingcart/client/CatalogClient.java @@ -0,0 +1,184 @@ +package io.microprofile.tutorial.store.shoppingcart.client; + +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.ws.rs.ProcessingException; +import jakarta.ws.rs.client.Client; +import jakarta.ws.rs.client.ClientBuilder; +import jakarta.ws.rs.core.MediaType; +import jakarta.ws.rs.core.Response; + +import org.eclipse.microprofile.config.inject.ConfigProperty; +import org.eclipse.microprofile.faulttolerance.CircuitBreaker; +import org.eclipse.microprofile.faulttolerance.Fallback; +import org.eclipse.microprofile.faulttolerance.Retry; +import org.eclipse.microprofile.faulttolerance.Timeout; + +import java.time.temporal.ChronoUnit; +import java.util.HashMap; +import java.util.Map; +import java.util.logging.Level; +import java.util.logging.Logger; + +/** + * Client for communicating with the Catalog Service. + */ +@ApplicationScoped +public class CatalogClient { + + private static final Logger LOGGER = Logger.getLogger(CatalogClient.class.getName()); + + @ConfigProperty(name = "catalog.service.url", defaultValue = "http://localhost:5050/catalog") + private String catalogServiceUrl; + + // Cache for product details to reduce service calls + private final Map productCache = new HashMap<>(); + + /** + * Gets product information from the catalog service. + * + * @param productId The product ID + * @return ProductInfo containing product details + */ + // @Retry(maxRetries = 3, delay = 1000, jitter = 200, unit = ChronoUnit.MILLIS) + // @Timeout(value = 5, unit = ChronoUnit.SECONDS) + // @CircuitBreaker(requestVolumeThreshold = 4, failureRatio = 0.5, delay = 10000, successThreshold = 2) + // @Fallback(fallbackMethod = "getProductInfoFallback") + public ProductInfo getProductInfo(Long productId) { + // Check cache first + if (productCache.containsKey(productId)) { + return productCache.get(productId); + } + + LOGGER.info(String.format("Fetching product info for product %d", productId)); + + Client client = null; + try { + client = ClientBuilder.newClient(); + String url = String.format("%s/api/products/%d", catalogServiceUrl, productId); + + Response response = client.target(url) + .request(MediaType.APPLICATION_JSON) + .get(); + + if (response.getStatus() == Response.Status.OK.getStatusCode()) { + String jsonResponse = response.readEntity(String.class); + // Simple parsing - in a real app, use proper JSON parsing + String name = extractField(jsonResponse, "name"); + String priceStr = extractField(jsonResponse, "price"); + + double price = 0.0; + try { + price = Double.parseDouble(priceStr); + } catch (NumberFormatException e) { + LOGGER.warning("Failed to parse product price: " + priceStr); + } + + ProductInfo productInfo = new ProductInfo(productId, name, price); + + // Cache the result + productCache.put(productId, productInfo); + + return productInfo; + } + + LOGGER.warning(String.format("Failed to get product info. Status code: %d", response.getStatus())); + return new ProductInfo(productId, "Unknown Product", 0.0); + } catch (ProcessingException e) { + LOGGER.log(Level.SEVERE, "Error connecting to Catalog Service", e); + throw e; + } finally { + if (client != null) { + client.close(); + } + } + } + + /** + * Fallback method for getProductInfo. + * Returns a placeholder product info when the catalog service is unavailable. + * + * @param productId The product ID + * @return A placeholder ProductInfo object + */ + public ProductInfo getProductInfoFallback(Long productId) { + LOGGER.warning(String.format("Using fallback for product info. Product ID: %d", productId)); + + // Check if we have a cached version + if (productCache.containsKey(productId)) { + return productCache.get(productId); + } + + // Return a placeholder + return new ProductInfo( + productId, + "Product " + productId + " (Service Unavailable)", + 0.0 + ); + } + + /** + * Helper method to extract field values from JSON string. + * This is a simplified approach - in a real app, use a proper JSON parser. + * + * @param jsonString The JSON string + * @param fieldName The name of the field to extract + * @return The extracted field value + */ + private String extractField(String jsonString, String fieldName) { + String searchPattern = "\"" + fieldName + "\":"; + if (jsonString.contains(searchPattern)) { + int startIndex = jsonString.indexOf(searchPattern) + searchPattern.length(); + int endIndex; + + // Skip whitespace + while (startIndex < jsonString.length() && + (jsonString.charAt(startIndex) == ' ' || jsonString.charAt(startIndex) == '\t')) { + startIndex++; + } + + if (startIndex < jsonString.length() && jsonString.charAt(startIndex) == '"') { + // String value + startIndex++; // Skip opening quote + endIndex = jsonString.indexOf("\"", startIndex); + } else { + // Number or boolean value + endIndex = jsonString.indexOf(",", startIndex); + if (endIndex == -1) { + endIndex = jsonString.indexOf("}", startIndex); + } + } + + if (endIndex > startIndex) { + return jsonString.substring(startIndex, endIndex); + } + } + return ""; + } + + /** + * Inner class to hold product information. + */ + public static class ProductInfo { + private final Long productId; + private final String name; + private final double price; + + public ProductInfo(Long productId, String name, double price) { + this.productId = productId; + this.name = name; + this.price = price; + } + + public Long getProductId() { + return productId; + } + + public String getName() { + return name; + } + + public double getPrice() { + return price; + } + } +} diff --git a/code/chapter11/shoppingcart/src/main/java/io/microprofile/tutorial/store/shoppingcart/client/InventoryClient.java b/code/chapter11/shoppingcart/src/main/java/io/microprofile/tutorial/store/shoppingcart/client/InventoryClient.java new file mode 100644 index 0000000..b9ac4c0 --- /dev/null +++ b/code/chapter11/shoppingcart/src/main/java/io/microprofile/tutorial/store/shoppingcart/client/InventoryClient.java @@ -0,0 +1,96 @@ +package io.microprofile.tutorial.store.shoppingcart.client; + +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.ws.rs.ProcessingException; +import jakarta.ws.rs.client.Client; +import jakarta.ws.rs.client.ClientBuilder; +import jakarta.ws.rs.core.MediaType; +import jakarta.ws.rs.core.Response; + +import org.eclipse.microprofile.config.inject.ConfigProperty; +import org.eclipse.microprofile.faulttolerance.CircuitBreaker; +import org.eclipse.microprofile.faulttolerance.Fallback; +import org.eclipse.microprofile.faulttolerance.Retry; +import org.eclipse.microprofile.faulttolerance.Timeout; + +import java.time.temporal.ChronoUnit; +import java.util.logging.Level; +import java.util.logging.Logger; + +/** + * Client for communicating with the Inventory Service. + */ +@ApplicationScoped +public class InventoryClient { + + private static final Logger LOGGER = Logger.getLogger(InventoryClient.class.getName()); + + @ConfigProperty(name = "inventory.service.url", defaultValue = "http://localhost:7050/inventory") + private String inventoryServiceUrl; + + /** + * Checks if a product is available in sufficient quantity. + * + * @param productId The product ID + * @param quantity The requested quantity + * @return true if the product is available in the requested quantity, false otherwise + */ + // @Retry(maxRetries = 3, delay = 1000, jitter = 200, unit = ChronoUnit.MILLIS) + // @Timeout(value = 5, unit = ChronoUnit.SECONDS) + // @CircuitBreaker(requestVolumeThreshold = 4, failureRatio = 0.5, delay = 10000, successThreshold = 2) + // @Fallback(fallbackMethod = "checkProductAvailabilityFallback") + public boolean checkProductAvailability(Long productId, int quantity) { + LOGGER.info(String.format("Checking availability for product %d, quantity %d", productId, quantity)); + + Client client = null; + try { + client = ClientBuilder.newClient(); + String url = String.format("%s/api/inventories/product/%d", inventoryServiceUrl, productId); + + Response response = client.target(url) + .request(MediaType.APPLICATION_JSON) + .get(); + + if (response.getStatus() == Response.Status.OK.getStatusCode()) { + String jsonResponse = response.readEntity(String.class); + // Simple parsing - in a real app, use proper JSON parsing + if (jsonResponse.contains("\"quantity\":")) { + String quantityStr = jsonResponse.split("\"quantity\":")[1].split(",")[0].trim(); + int availableQuantity = Integer.parseInt(quantityStr); + return availableQuantity >= quantity; + } + } + + LOGGER.warning(String.format("Failed to check product availability. Status code: %d", response.getStatus())); + return false; + } catch (ProcessingException e) { + LOGGER.log(Level.SEVERE, "Error connecting to Inventory Service", e); + throw e; + } catch (Exception e) { + LOGGER.log(Level.SEVERE, "Error parsing inventory response", e); + return false; + } finally { + if (client != null) { + client.close(); + } + } + } + + /** + * Fallback method for checkProductAvailability. + * Always returns true to allow the cart operation to continue, + * but logs a warning. + * + * @param productId The product ID + * @param quantity The requested quantity + * @return true, allowing the operation to proceed + */ + public boolean checkProductAvailabilityFallback(Long productId, int quantity) { + LOGGER.warning(String.format( + "Using fallback for product availability check. Product ID: %d, Quantity: %d", + productId, quantity)); + // In a production system, you might want to cache product availability + // or implement a more sophisticated fallback mechanism + return true; // Allow the operation to proceed + } +} diff --git a/code/chapter11/shoppingcart/src/main/java/io/microprofile/tutorial/store/shoppingcart/entity/CartItem.java b/code/chapter11/shoppingcart/src/main/java/io/microprofile/tutorial/store/shoppingcart/entity/CartItem.java new file mode 100644 index 0000000..dc4537e --- /dev/null +++ b/code/chapter11/shoppingcart/src/main/java/io/microprofile/tutorial/store/shoppingcart/entity/CartItem.java @@ -0,0 +1,32 @@ +package io.microprofile.tutorial.store.shoppingcart.entity; + +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotNull; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +/** + * CartItem class for the microprofile tutorial store application. + * This class represents an item in a shopping cart. + */ +@Data +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class CartItem { + + private Long itemId; + + @NotNull(message = "Product ID cannot be null") + private Long productId; + + private String productName; + + @Min(value = 0, message = "Price must be greater than or equal to 0") + private double price; + + @Min(value = 1, message = "Quantity must be at least 1") + private int quantity; +} diff --git a/code/chapter11/shoppingcart/src/main/java/io/microprofile/tutorial/store/shoppingcart/entity/ShoppingCart.java b/code/chapter11/shoppingcart/src/main/java/io/microprofile/tutorial/store/shoppingcart/entity/ShoppingCart.java new file mode 100644 index 0000000..08f1c0a --- /dev/null +++ b/code/chapter11/shoppingcart/src/main/java/io/microprofile/tutorial/store/shoppingcart/entity/ShoppingCart.java @@ -0,0 +1,57 @@ +package io.microprofile.tutorial.store.shoppingcart.entity; + +import jakarta.validation.constraints.NotNull; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.List; + +/** + * ShoppingCart class for the microprofile tutorial store application. + * This class represents a user's shopping cart. + */ +@Data +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class ShoppingCart { + + private Long cartId; + + @NotNull(message = "User ID cannot be null") + private Long userId; + + @Builder.Default + private List items = new ArrayList<>(); + + @Builder.Default + private LocalDateTime createdAt = LocalDateTime.now(); + + private LocalDateTime updatedAt; + + /** + * Calculate the total number of items in the cart. + * + * @return The total number of items + */ + public int getTotalItems() { + return items.stream() + .mapToInt(CartItem::getQuantity) + .sum(); + } + + /** + * Calculate the total price of all items in the cart. + * + * @return The total price + */ + public double getTotalPrice() { + return items.stream() + .mapToDouble(item -> item.getPrice() * item.getQuantity()) + .sum(); + } +} diff --git a/code/chapter11/shoppingcart/src/main/java/io/microprofile/tutorial/store/shoppingcart/health/ShoppingCartHealthCheck.java b/code/chapter11/shoppingcart/src/main/java/io/microprofile/tutorial/store/shoppingcart/health/ShoppingCartHealthCheck.java new file mode 100644 index 0000000..91dc833 --- /dev/null +++ b/code/chapter11/shoppingcart/src/main/java/io/microprofile/tutorial/store/shoppingcart/health/ShoppingCartHealthCheck.java @@ -0,0 +1,68 @@ +// package io.microprofile.tutorial.store.shoppingcart.health; + +// import jakarta.enterprise.context.ApplicationScoped; +// import jakarta.inject.Inject; + +// import org.eclipse.microprofile.health.HealthCheck; +// import org.eclipse.microprofile.health.HealthCheckResponse; +// import org.eclipse.microprofile.health.Liveness; +// import org.eclipse.microprofile.health.Readiness; + +// import io.microprofile.tutorial.store.shoppingcart.repository.ShoppingCartRepository; + +// /** +// * Health checks for the Shopping Cart service. +// */ +// @ApplicationScoped +// public class ShoppingCartHealthCheck { + +// @Inject +// private ShoppingCartRepository cartRepository; + +// /** +// * Liveness check for the Shopping Cart service. +// * This check ensures that the application is running. +// * +// * @return A HealthCheckResponse indicating whether the service is alive +// */ +// @Liveness +// public HealthCheck shoppingCartLivenessCheck() { +// return () -> HealthCheckResponse.named("shopping-cart-service-liveness") +// .up() +// .withData("message", "Shopping Cart Service is alive") +// .build(); +// } + +// /** +// * Readiness check for the Shopping Cart service. +// * This check ensures that the application is ready to serve requests. +// * In a real application, this would check dependencies like databases. +// * +// * @return A HealthCheckResponse indicating whether the service is ready +// */ +// @Readiness +// public HealthCheck shoppingCartReadinessCheck() { +// boolean isReady = true; + +// try { +// // Simple check to ensure repository is functioning +// cartRepository.findAll(); +// } catch (Exception e) { +// isReady = false; +// } + +// return () -> { +// if (isReady) { +// return HealthCheckResponse.named("shopping-cart-service-readiness") +// .up() +// .withData("message", "Shopping Cart Service is ready") +// .build(); +// } else { +// return HealthCheckResponse.named("shopping-cart-service-readiness") +// .down() +// .withData("message", "Shopping Cart Service is not ready") +// .build(); +// } +// }; +// } +// } diff --git a/code/chapter11/shoppingcart/src/main/java/io/microprofile/tutorial/store/shoppingcart/repository/ShoppingCartRepository.java b/code/chapter11/shoppingcart/src/main/java/io/microprofile/tutorial/store/shoppingcart/repository/ShoppingCartRepository.java new file mode 100644 index 0000000..90b3c65 --- /dev/null +++ b/code/chapter11/shoppingcart/src/main/java/io/microprofile/tutorial/store/shoppingcart/repository/ShoppingCartRepository.java @@ -0,0 +1,199 @@ +package io.microprofile.tutorial.store.shoppingcart.repository; + +import io.microprofile.tutorial.store.shoppingcart.entity.CartItem; +import io.microprofile.tutorial.store.shoppingcart.entity.ShoppingCart; + +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.concurrent.ConcurrentHashMap; + +import jakarta.enterprise.context.ApplicationScoped; + +/** + * Simple in-memory repository for ShoppingCart objects. + * This class provides operations for shopping cart management. + */ +@ApplicationScoped +public class ShoppingCartRepository { + + private final Map carts = new ConcurrentHashMap<>(); + private final Map> cartItems = new ConcurrentHashMap<>(); + private long nextCartId = 1; + private long nextItemId = 1; + + /** + * Finds a shopping cart by user ID. + * + * @param userId The user ID + * @return An Optional containing the shopping cart if found, or empty if not found + */ + public Optional findByUserId(Long userId) { + return carts.values().stream() + .filter(cart -> cart.getUserId().equals(userId)) + .findFirst(); + } + + /** + * Finds a shopping cart by cart ID. + * + * @param cartId The cart ID + * @return An Optional containing the shopping cart if found, or empty if not found + */ + public Optional findById(Long cartId) { + return Optional.ofNullable(carts.get(cartId)); + } + + /** + * Creates a new shopping cart for a user. + * + * @param userId The user ID + * @return The created shopping cart + */ + public ShoppingCart createCart(Long userId) { + ShoppingCart cart = ShoppingCart.builder() + .cartId(nextCartId++) + .userId(userId) + .createdAt(LocalDateTime.now()) + .updatedAt(LocalDateTime.now()) + .build(); + + carts.put(cart.getCartId(), cart); + cartItems.put(cart.getCartId(), new HashMap<>()); + + return cart; + } + + /** + * Adds an item to a shopping cart. + * If the product already exists in the cart, the quantity is increased. + * + * @param cartId The cart ID + * @param item The item to add + * @return The updated cart item + */ + public CartItem addItem(Long cartId, CartItem item) { + Map items = cartItems.get(cartId); + if (items == null) { + throw new IllegalArgumentException("Cart not found: " + cartId); + } + + // Check if the product already exists in the cart + Optional existingItem = items.values().stream() + .filter(i -> i.getProductId().equals(item.getProductId())) + .findFirst(); + + if (existingItem.isPresent()) { + // Update existing item quantity + CartItem updatedItem = existingItem.get(); + updatedItem.setQuantity(updatedItem.getQuantity() + item.getQuantity()); + items.put(updatedItem.getItemId(), updatedItem); + updateCartItems(cartId); + return updatedItem; + } else { + // Add new item + if (item.getItemId() == null) { + item.setItemId(nextItemId++); + } + items.put(item.getItemId(), item); + updateCartItems(cartId); + return item; + } + } + + /** + * Updates an item in a shopping cart. + * + * @param cartId The cart ID + * @param itemId The item ID + * @param item The updated item + * @return The updated cart item + */ + public CartItem updateItem(Long cartId, Long itemId, CartItem item) { + Map items = cartItems.get(cartId); + if (items == null || !items.containsKey(itemId)) { + throw new IllegalArgumentException("Item not found in cart"); + } + + item.setItemId(itemId); + items.put(itemId, item); + updateCartItems(cartId); + + return item; + } + + /** + * Removes an item from a shopping cart. + * + * @param cartId The cart ID + * @param itemId The item ID + * @return true if the item was removed, false otherwise + */ + public boolean removeItem(Long cartId, Long itemId) { + Map items = cartItems.get(cartId); + if (items == null) { + return false; + } + + boolean removed = items.remove(itemId) != null; + if (removed) { + updateCartItems(cartId); + } + + return removed; + } + + /** + * Clears all items from a shopping cart. + * + * @param cartId The cart ID + * @return true if the cart was cleared, false if the cart wasn't found + */ + public boolean clearCart(Long cartId) { + Map items = cartItems.get(cartId); + if (items == null) { + return false; + } + + items.clear(); + updateCartItems(cartId); + + return true; + } + + /** + * Deletes a shopping cart. + * + * @param cartId The cart ID + * @return true if the cart was deleted, false if not found + */ + public boolean deleteCart(Long cartId) { + cartItems.remove(cartId); + return carts.remove(cartId) != null; + } + + /** + * Gets all shopping carts. + * + * @return A list of all shopping carts + */ + public List findAll() { + return new ArrayList<>(carts.values()); + } + + /** + * Updates the items list in a shopping cart and updates the timestamp. + * + * @param cartId The cart ID + */ + private void updateCartItems(Long cartId) { + ShoppingCart cart = carts.get(cartId); + if (cart != null) { + cart.setItems(new ArrayList<>(cartItems.get(cartId).values())); + cart.setUpdatedAt(LocalDateTime.now()); + } + } +} diff --git a/code/chapter11/shoppingcart/src/main/java/io/microprofile/tutorial/store/shoppingcart/resource/ShoppingCartResource.java b/code/chapter11/shoppingcart/src/main/java/io/microprofile/tutorial/store/shoppingcart/resource/ShoppingCartResource.java new file mode 100644 index 0000000..ec40e55 --- /dev/null +++ b/code/chapter11/shoppingcart/src/main/java/io/microprofile/tutorial/store/shoppingcart/resource/ShoppingCartResource.java @@ -0,0 +1,240 @@ +package io.microprofile.tutorial.store.shoppingcart.resource; + +import io.microprofile.tutorial.store.shoppingcart.entity.CartItem; +import io.microprofile.tutorial.store.shoppingcart.entity.ShoppingCart; +import io.microprofile.tutorial.store.shoppingcart.service.ShoppingCartService; + +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; + +import java.net.URI; +import java.util.List; + +/** + * REST resource for shopping cart operations. + */ +@Path("/carts") +@RequestScoped +@Produces(MediaType.APPLICATION_JSON) +@Consumes(MediaType.APPLICATION_JSON) +@Tag(name = "Shopping Cart Resource", description = "Shopping cart management operations") +public class ShoppingCartResource { + + @Inject + private ShoppingCartService cartService; + + @Context + private UriInfo uriInfo; + + @GET + @Operation(summary = "Get all shopping carts", description = "Returns a list of all shopping carts") + @APIResponse( + responseCode = "200", + description = "List of shopping carts", + content = @Content( + mediaType = MediaType.APPLICATION_JSON, + schema = @Schema(type = SchemaType.ARRAY, implementation = ShoppingCart.class) + ) + ) + public List getAllCarts() { + return cartService.getAllCarts(); + } + + @GET + @Path("/{id}") + @Operation(summary = "Get cart by ID", description = "Returns a specific shopping cart by ID") + @APIResponse( + responseCode = "200", + description = "Shopping cart", + content = @Content( + mediaType = MediaType.APPLICATION_JSON, + schema = @Schema(implementation = ShoppingCart.class) + ) + ) + @APIResponse( + responseCode = "404", + description = "Cart not found" + ) + public ShoppingCart getCartById( + @Parameter(description = "ID of the cart", required = true) + @PathParam("id") Long cartId) { + return cartService.getCartById(cartId); + } + + @GET + @Path("/user/{userId}") + @Operation(summary = "Get cart by user ID", description = "Returns a user's shopping cart") + @APIResponse( + responseCode = "200", + description = "Shopping cart", + content = @Content( + mediaType = MediaType.APPLICATION_JSON, + schema = @Schema(implementation = ShoppingCart.class) + ) + ) + @APIResponse( + responseCode = "404", + description = "Cart not found for user" + ) + public Response getCartByUserId( + @Parameter(description = "ID of the user", required = true) + @PathParam("userId") Long userId) { + try { + ShoppingCart cart = cartService.getCartByUserId(userId); + return Response.ok(cart).build(); + } catch (WebApplicationException e) { + if (e.getResponse().getStatus() == Response.Status.NOT_FOUND.getStatusCode()) { + // Create a new cart for the user + ShoppingCart newCart = cartService.getOrCreateCart(userId); + return Response.ok(newCart).build(); + } + throw e; + } + } + + @POST + @Path("/user/{userId}") + @Operation(summary = "Create cart for user", description = "Creates a new shopping cart for a user") + @APIResponse( + responseCode = "201", + description = "Cart created", + content = @Content( + mediaType = MediaType.APPLICATION_JSON, + schema = @Schema(implementation = ShoppingCart.class) + ) + ) + public Response createCartForUser( + @Parameter(description = "ID of the user", required = true) + @PathParam("userId") Long userId) { + ShoppingCart cart = cartService.getOrCreateCart(userId); + URI location = uriInfo.getAbsolutePathBuilder().path(cart.getCartId().toString()).build(); + return Response.created(location).entity(cart).build(); + } + + @POST + @Path("/{cartId}/items") + @Operation(summary = "Add item to cart", description = "Adds an item to a shopping cart") + @APIResponse( + responseCode = "200", + description = "Item added", + content = @Content( + mediaType = MediaType.APPLICATION_JSON, + schema = @Schema(implementation = CartItem.class) + ) + ) + @APIResponse( + responseCode = "400", + description = "Invalid input or insufficient inventory" + ) + @APIResponse( + responseCode = "404", + description = "Cart not found" + ) + public CartItem addItemToCart( + @Parameter(description = "ID of the cart", required = true) + @PathParam("cartId") Long cartId, + @Parameter(description = "Item to add", required = true) + @NotNull @Valid CartItem item) { + return cartService.addItemToCart(cartId, item); + } + + @PUT + @Path("/{cartId}/items/{itemId}") + @Operation(summary = "Update cart item", description = "Updates an item in a shopping cart") + @APIResponse( + responseCode = "200", + description = "Item updated", + content = @Content( + mediaType = MediaType.APPLICATION_JSON, + schema = @Schema(implementation = CartItem.class) + ) + ) + @APIResponse( + responseCode = "400", + description = "Invalid input or insufficient inventory" + ) + @APIResponse( + responseCode = "404", + description = "Cart or item not found" + ) + public CartItem updateCartItem( + @Parameter(description = "ID of the cart", required = true) + @PathParam("cartId") Long cartId, + @Parameter(description = "ID of the item", required = true) + @PathParam("itemId") Long itemId, + @Parameter(description = "Updated item", required = true) + @NotNull @Valid CartItem item) { + return cartService.updateCartItem(cartId, itemId, item); + } + + @DELETE + @Path("/{cartId}/items/{itemId}") + @Operation(summary = "Remove item from cart", description = "Removes an item from a shopping cart") + @APIResponse( + responseCode = "204", + description = "Item removed" + ) + @APIResponse( + responseCode = "404", + description = "Cart or item not found" + ) + public Response removeItemFromCart( + @Parameter(description = "ID of the cart", required = true) + @PathParam("cartId") Long cartId, + @Parameter(description = "ID of the item", required = true) + @PathParam("itemId") Long itemId) { + cartService.removeItemFromCart(cartId, itemId); + return Response.noContent().build(); + } + + @DELETE + @Path("/{cartId}/items") + @Operation(summary = "Clear cart", description = "Removes all items from a shopping cart") + @APIResponse( + responseCode = "204", + description = "Cart cleared" + ) + @APIResponse( + responseCode = "404", + description = "Cart not found" + ) + public Response clearCart( + @Parameter(description = "ID of the cart", required = true) + @PathParam("cartId") Long cartId) { + cartService.clearCart(cartId); + return Response.noContent().build(); + } + + @DELETE + @Path("/{cartId}") + @Operation(summary = "Delete cart", description = "Deletes a shopping cart") + @APIResponse( + responseCode = "204", + description = "Cart deleted" + ) + @APIResponse( + responseCode = "404", + description = "Cart not found" + ) + public Response deleteCart( + @Parameter(description = "ID of the cart", required = true) + @PathParam("cartId") Long cartId) { + cartService.deleteCart(cartId); + return Response.noContent().build(); + } +} diff --git a/code/chapter11/shoppingcart/src/main/java/io/microprofile/tutorial/store/shoppingcart/service/ShoppingCartService.java b/code/chapter11/shoppingcart/src/main/java/io/microprofile/tutorial/store/shoppingcart/service/ShoppingCartService.java new file mode 100644 index 0000000..bc39375 --- /dev/null +++ b/code/chapter11/shoppingcart/src/main/java/io/microprofile/tutorial/store/shoppingcart/service/ShoppingCartService.java @@ -0,0 +1,223 @@ +package io.microprofile.tutorial.store.shoppingcart.service; + +import io.microprofile.tutorial.store.shoppingcart.client.CatalogClient; +import io.microprofile.tutorial.store.shoppingcart.client.InventoryClient; +import io.microprofile.tutorial.store.shoppingcart.entity.CartItem; +import io.microprofile.tutorial.store.shoppingcart.entity.ShoppingCart; +import io.microprofile.tutorial.store.shoppingcart.repository.ShoppingCartRepository; + +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.inject.Inject; +import jakarta.ws.rs.WebApplicationException; +import jakarta.ws.rs.core.Response; + +import java.util.List; +import java.util.Optional; +import java.util.logging.Logger; + +/** + * Service class for Shopping Cart management operations. + */ +@ApplicationScoped +public class ShoppingCartService { + + private static final Logger LOGGER = Logger.getLogger(ShoppingCartService.class.getName()); + + @Inject + private ShoppingCartRepository cartRepository; + + @Inject + private InventoryClient inventoryClient; + + @Inject + private CatalogClient catalogClient; + + /** + * Gets a shopping cart for a user, creating one if it doesn't exist. + * + * @param userId The user ID + * @return The user's shopping cart + */ + public ShoppingCart getOrCreateCart(Long userId) { + Optional existingCart = cartRepository.findByUserId(userId); + + return existingCart.orElseGet(() -> { + LOGGER.info("Creating new cart for user: " + userId); + return cartRepository.createCart(userId); + }); + } + + /** + * Gets a shopping cart by ID. + * + * @param cartId The cart ID + * @return The shopping cart + * @throws WebApplicationException if the cart is not found + */ + public ShoppingCart getCartById(Long cartId) { + return cartRepository.findById(cartId) + .orElseThrow(() -> new WebApplicationException("Cart not found", Response.Status.NOT_FOUND)); + } + + /** + * Gets a user's shopping cart. + * + * @param userId The user ID + * @return The user's shopping cart + * @throws WebApplicationException if the cart is not found + */ + public ShoppingCart getCartByUserId(Long userId) { + return cartRepository.findByUserId(userId) + .orElseThrow(() -> new WebApplicationException("Cart not found for user", Response.Status.NOT_FOUND)); + } + + /** + * Gets all shopping carts. + * + * @return A list of all shopping carts + */ + public List getAllCarts() { + return cartRepository.findAll(); + } + + /** + * Adds an item to a shopping cart. + * + * @param cartId The cart ID + * @param item The item to add + * @return The updated cart item + * @throws WebApplicationException if the cart is not found or inventory is insufficient + */ + public CartItem addItemToCart(Long cartId, CartItem item) { + // Verify the cart exists + getCartById(cartId); + + // Check inventory availability + boolean isAvailable = inventoryClient.checkProductAvailability(item.getProductId(), item.getQuantity()); + if (!isAvailable) { + throw new WebApplicationException("Insufficient inventory for product: " + item.getProductId(), + Response.Status.BAD_REQUEST); + } + + // Enrich item with product details if needed + if (item.getProductName() == null || item.getPrice() == 0) { + CatalogClient.ProductInfo productInfo = catalogClient.getProductInfo(item.getProductId()); + item.setProductName(productInfo.getName()); + item.setPrice(productInfo.getPrice()); + } + + LOGGER.info(String.format("Adding item to cart %d: %s, quantity %d", + cartId, item.getProductName(), item.getQuantity())); + + return cartRepository.addItem(cartId, item); + } + + /** + * Updates an item in a shopping cart. + * + * @param cartId The cart ID + * @param itemId The item ID + * @param item The updated item + * @return The updated cart item + * @throws WebApplicationException if the cart or item is not found or inventory is insufficient + */ + public CartItem updateCartItem(Long cartId, Long itemId, CartItem item) { + // Verify the cart exists + ShoppingCart cart = getCartById(cartId); + + // Verify the item exists + Optional existingItem = cart.getItems().stream() + .filter(i -> i.getItemId().equals(itemId)) + .findFirst(); + + if (!existingItem.isPresent()) { + throw new WebApplicationException("Item not found in cart", Response.Status.NOT_FOUND); + } + + // Check inventory availability if quantity is increasing + CartItem currentItem = existingItem.get(); + if (item.getQuantity() > currentItem.getQuantity()) { + int additionalQuantity = item.getQuantity() - currentItem.getQuantity(); + boolean isAvailable = inventoryClient.checkProductAvailability( + currentItem.getProductId(), additionalQuantity); + + if (!isAvailable) { + throw new WebApplicationException("Insufficient inventory for product: " + currentItem.getProductId(), + Response.Status.BAD_REQUEST); + } + } + + // Preserve product information + item.setProductId(currentItem.getProductId()); + + // If no product name is provided, use the existing one + if (item.getProductName() == null) { + item.setProductName(currentItem.getProductName()); + } + + // If no price is provided, use the existing one + if (item.getPrice() == 0) { + item.setPrice(currentItem.getPrice()); + } + + LOGGER.info(String.format("Updating item %d in cart %d: new quantity %d", + itemId, cartId, item.getQuantity())); + + return cartRepository.updateItem(cartId, itemId, item); + } + + /** + * Removes an item from a shopping cart. + * + * @param cartId The cart ID + * @param itemId The item ID + * @throws WebApplicationException if the cart or item is not found + */ + public void removeItemFromCart(Long cartId, Long itemId) { + // Verify the cart exists + getCartById(cartId); + + boolean removed = cartRepository.removeItem(cartId, itemId); + if (!removed) { + throw new WebApplicationException("Item not found in cart", Response.Status.NOT_FOUND); + } + + LOGGER.info(String.format("Removed item %d from cart %d", itemId, cartId)); + } + + /** + * Clears all items from a shopping cart. + * + * @param cartId The cart ID + * @throws WebApplicationException if the cart is not found + */ + public void clearCart(Long cartId) { + // Verify the cart exists + getCartById(cartId); + + boolean cleared = cartRepository.clearCart(cartId); + if (!cleared) { + throw new WebApplicationException("Failed to clear cart", Response.Status.INTERNAL_SERVER_ERROR); + } + + LOGGER.info(String.format("Cleared cart %d", cartId)); + } + + /** + * Deletes a shopping cart. + * + * @param cartId The cart ID + * @throws WebApplicationException if the cart is not found + */ + public void deleteCart(Long cartId) { + // Verify the cart exists + getCartById(cartId); + + boolean deleted = cartRepository.deleteCart(cartId); + if (!deleted) { + throw new WebApplicationException("Failed to delete cart", Response.Status.INTERNAL_SERVER_ERROR); + } + + LOGGER.info(String.format("Deleted cart %d", cartId)); + } +} diff --git a/code/chapter11/shoppingcart/src/main/resources/META-INF/microprofile-config.properties b/code/chapter11/shoppingcart/src/main/resources/META-INF/microprofile-config.properties new file mode 100644 index 0000000..9990f3d --- /dev/null +++ b/code/chapter11/shoppingcart/src/main/resources/META-INF/microprofile-config.properties @@ -0,0 +1,16 @@ +# Shopping Cart Service Configuration +mp.openapi.scan=true + +# Service URLs +inventory.service.url=https://scaling-pancake-77vj4pwq7fpjqx-7050.app.github.dev/ +catalog.service.url=https://scaling-pancake-77vj4pwq7fpjqx-5050.app.github.dev/ +user.service.url=https://scaling-pancake-77vj4pwq7fpjqx-6050.app.github.dev/ + +# Fault Tolerance Configuration +# circuitBreaker.delay=10000 +# circuitBreaker.requestVolumeThreshold=4 +# circuitBreaker.failureRatio=0.5 +# retry.maxRetries=3 +# retry.delay=1000 +# retry.jitter=200 +# timeout.value=5000 diff --git a/code/chapter11/shoppingcart/src/main/webapp/WEB-INF/web.xml b/code/chapter11/shoppingcart/src/main/webapp/WEB-INF/web.xml new file mode 100644 index 0000000..383982d --- /dev/null +++ b/code/chapter11/shoppingcart/src/main/webapp/WEB-INF/web.xml @@ -0,0 +1,12 @@ + + + Shopping Cart Service + + + index.html + index.jsp + + diff --git a/code/chapter11/shoppingcart/src/main/webapp/index.html b/code/chapter11/shoppingcart/src/main/webapp/index.html new file mode 100644 index 0000000..d2d2519 --- /dev/null +++ b/code/chapter11/shoppingcart/src/main/webapp/index.html @@ -0,0 +1,128 @@ + + + + + + Shopping Cart Service - MicroProfile E-Commerce + + + +
+

Shopping Cart Service

+

Part of the MicroProfile E-Commerce Application

+
+ +
+
+

About this Service

+

The Shopping Cart Service manages user shopping carts in the e-commerce system.

+

It provides endpoints for creating carts, adding/removing items, and managing cart contents.

+

This service integrates with the Inventory service to check product availability and the Catalog service to get product details.

+
+ +
+

API Endpoints

+
    +
  • GET /api/carts - Get all shopping carts
  • +
  • GET /api/carts/{id} - Get cart by ID
  • +
  • GET /api/carts/user/{userId} - Get cart by user ID
  • +
  • POST /api/carts/user/{userId} - Create cart for user
  • +
  • POST /api/carts/{cartId}/items - Add item to cart
  • +
  • PUT /api/carts/{cartId}/items/{itemId} - Update cart item
  • +
  • DELETE /api/carts/{cartId}/items/{itemId} - Remove item from cart
  • +
  • DELETE /api/carts/{cartId}/items - Clear cart
  • +
  • DELETE /api/carts/{cartId} - Delete cart
  • +
+
+ + +
+ +
+

MicroProfile E-Commerce Demo Application | Shopping Cart Service

+

Powered by Open Liberty & MicroProfile

+
+ + diff --git a/code/chapter11/shoppingcart/src/main/webapp/index.jsp b/code/chapter11/shoppingcart/src/main/webapp/index.jsp new file mode 100644 index 0000000..1fcd419 --- /dev/null +++ b/code/chapter11/shoppingcart/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 Shopping Cart Service homepage...

+ + diff --git a/code/chapter11/user/README.adoc b/code/chapter11/user/README.adoc new file mode 100644 index 0000000..fdcc577 --- /dev/null +++ b/code/chapter11/user/README.adoc @@ -0,0 +1,280 @@ += User Management Service +:toc: left +:icons: font +:source-highlighter: highlightjs +:sectnums: +:imagesdir: images + +This document provides information about the User Management Service, part of the MicroProfile tutorial store application. + +== Overview + +The User Management Service is responsible for user operations including: + +* User registration and management +* User profile information +* Basic authentication + +This service demonstrates MicroProfile and Jakarta EE technologies in a microservice architecture. + +== Technology Stack + +The User Management Service uses the following technologies: + +* Jakarta EE 10 +** RESTful Web Services (JAX-RS 3.1) +** Context and Dependency Injection (CDI 4.0) +** Bean Validation 3.0 +** JSON-B 3.0 +* MicroProfile 6.1 +** OpenAPI 3.1 +* Open Liberty +* Maven + +== Project Structure + +[source] +---- +user/ +├── src/ +│ ├── main/ +│ │ ├── java/ +│ │ │ └── io/microprofile/tutorial/store/user/ +│ │ │ ├── entity/ # Domain objects +│ │ │ ├── exception/ # Custom exceptions +│ │ │ ├── repository/ # Data access layer +│ │ │ ├── resource/ # REST endpoints +│ │ │ ├── service/ # Business logic +│ │ │ └── UserApplication.java +│ │ ├── liberty/ +│ │ │ └── config/ +│ │ │ └── server.xml # Liberty server configuration +│ │ ├── resources/ +│ │ │ └── META-INF/ +│ │ │ └── microprofile-config.properties +│ │ └── webapp/ +│ │ └── index.html # Welcome page +│ └── test/ # Unit and integration tests +└── pom.xml # Maven configuration +---- + +== API Endpoints + +The service exposes the following RESTful endpoints: + +[cols="2,1,4", options="header"] +|=== +| Endpoint | Method | Description + +| `/api/users` | GET | Retrieve all users +| `/api/users/{id}` | GET | Retrieve a specific user by ID +| `/api/users` | POST | Create a new user +| `/api/users/{id}` | PUT | Update an existing user +| `/api/users/{id}` | DELETE | Delete a user +|=== + +== Running the Service + +=== Prerequisites + +* JDK 17 or later +* Maven 3.8+ +* Docker (optional, for containerized deployment) + +=== Local Development + +1. Clone the repository: ++ +[source,bash] +---- +git clone https://github.com/your-org/liberty-rest-app.git +cd liberty-rest-app/user +---- + +2. Build the project: ++ +[source,bash] +---- +mvn clean package +---- + +3. Run the service: ++ +[source,bash] +---- +mvn liberty:run +---- + +4. The service will be available at: ++ +[source] +---- +http://localhost:6050/user/api/users +---- + +=== Docker Deployment + +To build and run using Docker: + +[source,bash] +---- +# Build the Docker image +docker build -t microprofile-tutorial/user-service . + +# Run the container +docker run -p 6050:6050 microprofile-tutorial/user-service +---- + +== Configuration + +The service can be configured using Liberty server.xml and MicroProfile Config: + +=== server.xml + +The main configuration file at `src/main/liberty/config/server.xml` includes: + +* HTTP endpoint configuration (port 6050) +* Feature enablement +* Application context configuration + +=== MicroProfile Config + +Environment-specific configuration can be modified in: +`src/main/resources/META-INF/microprofile-config.properties` + +== OpenAPI Documentation + +The service provides OpenAPI documentation of all endpoints. + +Access the OpenAPI UI at: +[source] +---- +http://localhost:6050/openapi/ui +---- + +Raw OpenAPI specification: +[source] +---- +http://localhost:6050/openapi +---- + +== Exception Handling + +The service includes a comprehensive exception handling strategy: + +* Custom exceptions for domain-specific errors +* Global exception mapping to appropriate HTTP status codes +* Consistent error response format + +Error responses follow this structure: + +[source,json] +---- +{ + "errorCode": "user_not_found", + "message": "User with ID 123 not found", + "timestamp": "2023-04-15T14:30:45Z" +} +---- + +Common error scenarios: + +* 400 Bad Request - Invalid input data +* 404 Not Found - Requested user doesn't exist +* 409 Conflict - Email address already in use + +== Testing + +=== Running Tests + +Execute unit and integration tests with: + +[source,bash] +---- +mvn test +---- + +=== Testing with cURL + +*Get all users:* +[source,bash] +---- +curl -X GET http://localhost:6050/user/api/users +---- + +*Get user by ID:* +[source,bash] +---- +curl -X GET http://localhost:6050/user/api/users/1 +---- + +*Create new user:* +[source,bash] +---- +curl -X POST http://localhost:6050/user/api/users \ + -H "Content-Type: application/json" \ + -d '{ + "name": "John Doe", + "email": "john@example.com", + "passwordHash": "hashed_password", + "address": "123 Main St", + "phone": "+1234567890" + }' +---- + +*Update user:* +[source,bash] +---- +curl -X PUT http://localhost:6050/user/api/users/1 \ + -H "Content-Type: application/json" \ + -d '{ + "name": "John Updated", + "email": "john@example.com", + "passwordHash": "hashed_password", + "address": "456 New Address", + "phone": "+1234567890" + }' +---- + +*Delete user:* +[source,bash] +---- +curl -X DELETE http://localhost:6050/user/api/users/1 +---- + +== Implementation Notes + +=== In-Memory Storage + +The service currently uses thread-safe in-memory storage: + +* `ConcurrentHashMap` for storing user data +* `AtomicLong` for generating sequence IDs +* No persistence to external databases + +For production use, consider implementing a proper database persistence layer. + +=== Security Considerations + +* Passwords are stored as hashes (not encrypted or in plain text) +* Input validation helps prevent injection attacks +* No authentication mechanism is implemented (for demo purposes only) + +== Troubleshooting + +=== Common Issues + +* *Port conflicts:* Check if port 6050 is already in use +* *CORS issues:* For browser access, check CORS configuration in server.xml +* *404 errors:* Verify the application context root and API path + +=== Logs + +* Liberty server logs are in `target/liberty/wlp/usr/servers/defaultServer/logs/` +* Application logs use standard JDK logging with info level by default + +== Further Resources + +* https://jakarta.ee/specifications/restful-ws/3.1/jakarta-restful-ws-spec-3.1.html[Jakarta RESTful Web Services Specification] +* https://openliberty.io/docs/latest/documentation.html[Open Liberty Documentation] +* https://download.eclipse.org/microprofile/microprofile-6.1/microprofile-spec-6.1.html[MicroProfile 6.1 Specification] \ No newline at end of file diff --git a/code/chapter11/user/pom.xml b/code/chapter11/user/pom.xml new file mode 100644 index 0000000..f743ec4 --- /dev/null +++ b/code/chapter11/user/pom.xml @@ -0,0 +1,115 @@ + + + + 4.0.0 + + io.microprofile + user + 1.0-SNAPSHOT + war + + user-management + https://microprofile.io + + + UTF-8 + 17 + 17 + 10.0.0 + 6.1 + 23.0.0.3 + 1.18.24 + + + + + + jakarta.platform + jakarta.jakartaee-api + ${jakarta.jakartaee-api.version} + provided + + + + org.eclipse.microprofile + microprofile + ${microprofile.version} + pom + provided + + + + org.projectlombok + lombok + ${lombok.version} + provided + + + + + user + + + + io.openliberty.tools + liberty-maven-plugin + 3.8.2 + + userServer + runnable + 120 + + /user + + + + + + + + + maven-clean-plugin + 3.1.0 + + + + maven-resources-plugin + 3.0.2 + + + maven-compiler-plugin + 3.8.0 + + + maven-surefire-plugin + 2.22.1 + + + maven-war-plugin + 3.3.2 + + false + + + + maven-install-plugin + 2.5.2 + + + maven-deploy-plugin + 2.8.2 + + + + maven-site-plugin + 3.7.1 + + + maven-project-info-reports-plugin + 3.0.0 + + + + + diff --git a/code/chapter11/user/src/main/java/io/microprofile/tutorial/store/user/UserApplication.java b/code/chapter11/user/src/main/java/io/microprofile/tutorial/store/user/UserApplication.java new file mode 100644 index 0000000..347a04d --- /dev/null +++ b/code/chapter11/user/src/main/java/io/microprofile/tutorial/store/user/UserApplication.java @@ -0,0 +1,12 @@ +package io.microprofile.tutorial.store.user; + +import jakarta.ws.rs.ApplicationPath; +import jakarta.ws.rs.core.Application; + +/** + * Application class to activate REST resources. + */ +@ApplicationPath("/api") +public class UserApplication extends Application { + // The resources will be automatically discovered +} diff --git a/code/chapter11/user/src/main/java/io/microprofile/tutorial/store/user/entity/User.java b/code/chapter11/user/src/main/java/io/microprofile/tutorial/store/user/entity/User.java new file mode 100644 index 0000000..c2fe3df --- /dev/null +++ b/code/chapter11/user/src/main/java/io/microprofile/tutorial/store/user/entity/User.java @@ -0,0 +1,75 @@ +package io.microprofile.tutorial.store.user.entity; + +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.NotEmpty; +import jakarta.validation.constraints.Pattern; +import jakarta.validation.constraints.Size; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +/** + * User entity for the microprofile tutorial store application. + * Represents a user in the system with their profile information. + * Uses in-memory storage with thread-safe operations. + * + * Key features: + * - Validated user information + * - Secure password storage (hashed) + * - Contact information validation + * + * Potential improvements: + * 1. Auditing fields: + * - createdAt: Timestamp for account creation + * - modifiedAt: Last modification timestamp + * - version: For optimistic locking in concurrent updates + * + * 2. Security enhancements: + * - passwordSalt: For more secure password hashing + * - lastPasswordChange: Track password updates + * - failedLoginAttempts: For account security + * - accountLocked: Boolean for account status + * - lockTimeout: Timestamp for temporary locks + * + * 3. Additional features: + * - userRole: ENUM for role-based access (USER, ADMIN, etc.) + * - status: ENUM for account state (ACTIVE, INACTIVE, SUSPENDED) + * - emailVerified: Boolean for email verification + * - timeZone: User's preferred timezone + * - locale: User's preferred language/region + * - lastLoginAt: Track user activity + * + * 4. Compliance: + * - privacyPolicyAccepted: Track user consent + * - marketingPreferences: User communication preferences + * - dataRetentionPolicy: For GDPR compliance + */ +@Data +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class User { + + private Long userId; + + @NotEmpty(message = "Name cannot be empty") + @Size(min = 2, max = 100, message = "Name must be between 2 and 100 characters") + private String name; + + @NotEmpty(message = "Email cannot be empty") + @Email(message = "Email should be valid") + @Size(max = 255, message = "Email must not exceed 255 characters") + private String email; + + @NotEmpty(message = "Password hash cannot be empty") + private String passwordHash; + + @Size(max = 200, message = "Address must not exceed 200 characters") + private String address; + + @Pattern(regexp = "^\\+?[1-9]\\d{1,14}$", message = "Phone number must be in E.164 format") + @Size(max = 15, message = "Phone number must not exceed 15 characters") + private String phoneNumber; +} diff --git a/code/chapter11/user/src/main/java/io/microprofile/tutorial/store/user/entity/package-info.java b/code/chapter11/user/src/main/java/io/microprofile/tutorial/store/user/entity/package-info.java new file mode 100644 index 0000000..e240f3a --- /dev/null +++ b/code/chapter11/user/src/main/java/io/microprofile/tutorial/store/user/entity/package-info.java @@ -0,0 +1,6 @@ +/** + * Entity classes for the user management module. + * + * This package contains the domain objects representing user data. + */ +package io.microprofile.tutorial.store.user.entity; diff --git a/code/chapter11/user/src/main/java/io/microprofile/tutorial/store/user/package-info.java b/code/chapter11/user/src/main/java/io/microprofile/tutorial/store/user/package-info.java new file mode 100644 index 0000000..a92fafc --- /dev/null +++ b/code/chapter11/user/src/main/java/io/microprofile/tutorial/store/user/package-info.java @@ -0,0 +1,6 @@ +/** + * User management package for the microprofile tutorial store application. + * + * This package contains classes related to user management functionality. + */ +package io.microprofile.tutorial.store.user; \ No newline at end of file diff --git a/code/chapter11/user/src/main/java/io/microprofile/tutorial/store/user/repository/UserRepository.java b/code/chapter11/user/src/main/java/io/microprofile/tutorial/store/user/repository/UserRepository.java new file mode 100644 index 0000000..db979c0 --- /dev/null +++ b/code/chapter11/user/src/main/java/io/microprofile/tutorial/store/user/repository/UserRepository.java @@ -0,0 +1,135 @@ +package io.microprofile.tutorial.store.user.repository; + +import io.microprofile.tutorial.store.user.entity.User; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.atomic.AtomicLong; + +import jakarta.enterprise.context.ApplicationScoped; + +/** + * Thread-safe in-memory repository for User objects. + * This class provides CRUD operations for User entities using a ConcurrentHashMap for thread-safe storage + * and AtomicLong for safe ID generation in a concurrent environment. + * + * Key features: + * - Thread-safe operations using ConcurrentHashMap + * - Atomic ID generation + * - Immutable User objects in storage + * - Validation of user data + * - Optional return types for null-safety + * + * Note: This is a demo implementation. In production: + * - Consider using a persistent database + * - Add caching mechanisms + * - Implement proper pagination + * - Add audit logging + */ +@ApplicationScoped +public class UserRepository { + + private final Map users = new ConcurrentHashMap<>(); + private final AtomicLong nextId = new AtomicLong(1); + + /** + * Saves a user to the repository. + * If the user has no ID, a new ID is assigned. + * + * @param user The user to save + * @return The saved user with ID assigned + */ + public User save(User user) { + if (user.getUserId() == null) { + user.setUserId(nextId.getAndIncrement()); + } + User savedUser = User.builder() + .userId(user.getUserId()) + .name(user.getName()) + .email(user.getEmail()) + .passwordHash(user.getPasswordHash()) + .address(user.getAddress()) + .phoneNumber(user.getPhoneNumber()) + .build(); + users.put(savedUser.getUserId(), savedUser); + return savedUser; + } + + /** + * Finds a user by ID. + * + * @param id The user ID + * @return An Optional containing the user if found, or empty if not found + */ + public Optional findById(Long id) { + return Optional.ofNullable(users.get(id)); + } + + /** + * Finds a user by email. + * + * @param email The user's email + * @return An Optional containing the user if found, or empty if not found + */ + public Optional findByEmail(String email) { + return users.values().stream() + .filter(user -> user.getEmail().equals(email)) + .findFirst(); + } + + /** + * Retrieves all users from the repository. + * + * @return A list of all users + */ + public List findAll() { + return new ArrayList<>(users.values()); + } + + /** + * Deletes a user by ID. + * + * @param id The ID of the user to delete + * @return true if the user was deleted, false if not found + */ + public boolean deleteById(Long id) { + return users.remove(id) != null; + } + + /** + * Updates an existing user. + * + * @param id The ID of the user to update + * @param user The updated user information + * @return An Optional containing the updated user, or empty if not found + */ + /** + * Updates an existing user atomically. + * Only updates the user if it exists and the update is valid. + * + * @param id The ID of the user to update + * @param user The updated user information + * @return An Optional containing the updated user, or empty if not found + * @throws IllegalArgumentException if user is null or has invalid data + */ + public Optional update(Long id, User user) { + if (user == null) { + throw new IllegalArgumentException("User cannot be null"); + } + + return Optional.ofNullable(users.computeIfPresent(id, (key, existingUser) -> { + User updatedUser = User.builder() + .userId(id) + .name(user.getName() != null ? user.getName() : existingUser.getName()) + .email(user.getEmail() != null ? user.getEmail() : existingUser.getEmail()) + .passwordHash(user.getPasswordHash() != null ? user.getPasswordHash() : existingUser.getPasswordHash()) + .address(user.getAddress() != null ? user.getAddress() : existingUser.getAddress()) + .phoneNumber(user.getPhoneNumber() != null ? user.getPhoneNumber() : existingUser.getPhoneNumber()) + .build(); + return updatedUser; + })); + } +} diff --git a/code/chapter11/user/src/main/java/io/microprofile/tutorial/store/user/repository/package-info.java b/code/chapter11/user/src/main/java/io/microprofile/tutorial/store/user/repository/package-info.java new file mode 100644 index 0000000..0988dcb --- /dev/null +++ b/code/chapter11/user/src/main/java/io/microprofile/tutorial/store/user/repository/package-info.java @@ -0,0 +1,6 @@ +/** + * Repository classes for the user management module. + * + * This package contains classes responsible for data access and persistence. + */ +package io.microprofile.tutorial.store.user.repository; diff --git a/code/chapter11/user/src/main/java/io/microprofile/tutorial/store/user/resource/UserResource.java b/code/chapter11/user/src/main/java/io/microprofile/tutorial/store/user/resource/UserResource.java new file mode 100644 index 0000000..bdd2e21 --- /dev/null +++ b/code/chapter11/user/src/main/java/io/microprofile/tutorial/store/user/resource/UserResource.java @@ -0,0 +1,132 @@ +package io.microprofile.tutorial.store.user.resource; + +import io.microprofile.tutorial.store.user.entity.User; +import io.microprofile.tutorial.store.user.service.UserService; + +import java.net.URI; +import java.util.List; + +import jakarta.inject.Inject; +import jakarta.validation.Valid; +import jakarta.validation.constraints.NotNull; +import jakarta.ws.rs.Consumes; +import jakarta.ws.rs.DELETE; +import jakarta.ws.rs.GET; +import jakarta.ws.rs.POST; +import jakarta.ws.rs.PUT; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.PathParam; +import jakarta.ws.rs.Produces; +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.parameters.Parameter; +import org.eclipse.microprofile.openapi.annotations.responses.APIResponse; +import org.eclipse.microprofile.openapi.annotations.tags.Tag; + +/** + * REST resource for user management operations. + * Provides endpoints for creating, retrieving, updating, and deleting users. + * Implements standard RESTful practices with proper status codes and hypermedia links. + */ +@Path("/users") +@Tag(name = "User Management", description = "Operations for managing users") +@Consumes(MediaType.APPLICATION_JSON) +@Produces(MediaType.APPLICATION_JSON) +public class UserResource { + + @Inject + private UserService userService; + + @Context + private UriInfo uriInfo; + + @GET + @Operation(summary = "Get all users", description = "Returns a list of all users") + @APIResponse(responseCode = "200", description = "List of users") + @APIResponse(responseCode = "204", description = "No users found") + public Response getAllUsers() { + List users = userService.getAllUsers(); + + if (users.isEmpty()) { + return Response.noContent().build(); + } + + return Response.ok(users).build(); + } + + @GET + @Path("/{id}") + @Operation(summary = "Get user by ID", description = "Returns a user by their ID") + @APIResponse(responseCode = "200", description = "User found") + @APIResponse(responseCode = "404", description = "User not found") + public Response getUserById( + @PathParam("id") + @Parameter(description = "User ID", required = true) + Long id) { + User user = userService.getUserById(id); + // Add HATEOAS links + URI selfLink = uriInfo.getBaseUriBuilder() + .path(UserResource.class) + .path(String.valueOf(user.getUserId())) + .build(); + return Response.ok(user) + .link(selfLink, "self") + .build(); + } + + @POST + @Operation(summary = "Create new user", description = "Creates a new user") + @APIResponse(responseCode = "201", description = "User created successfully") + @APIResponse(responseCode = "400", description = "Invalid user data") + @APIResponse(responseCode = "409", description = "Email already in use") + public Response createUser( + @Valid + @NotNull(message = "Request body cannot be empty") + @Parameter(description = "User to create", required = true) + User user) { + User createdUser = userService.createUser(user); + URI location = uriInfo.getAbsolutePathBuilder() + .path(String.valueOf(createdUser.getUserId())) + .build(); + return Response.created(location) + .entity(createdUser) + .build(); + } + + @PUT + @Path("/{id}") + @Operation(summary = "Update user", description = "Updates an existing user") + @APIResponse(responseCode = "200", description = "User updated successfully") + @APIResponse(responseCode = "400", description = "Invalid user data") + @APIResponse(responseCode = "404", description = "User not found") + @APIResponse(responseCode = "409", description = "Email already in use") + public Response updateUser( + @PathParam("id") + @Parameter(description = "User ID", required = true) + Long id, + + @Valid + @NotNull(message = "Request body cannot be empty") + @Parameter(description = "Updated user information", required = true) + User user) { + User updatedUser = userService.updateUser(id, user); + return Response.ok(updatedUser).build(); + } + + @DELETE + @Path("/{id}") + @Operation(summary = "Delete user", description = "Deletes a user by ID") + @APIResponse(responseCode = "204", description = "User successfully deleted") + @APIResponse(responseCode = "404", description = "User not found") + public Response deleteUser( + @PathParam("id") + @Parameter(description = "User ID to delete", required = true) + Long id) { + userService.deleteUser(id); + return Response.noContent().build(); + } +} diff --git a/code/chapter11/user/src/main/java/io/microprofile/tutorial/store/user/resource/package-info.java b/code/chapter11/user/src/main/java/io/microprofile/tutorial/store/user/resource/package-info.java new file mode 100644 index 0000000..e69de29 diff --git a/code/chapter11/user/src/main/java/io/microprofile/tutorial/store/user/service/UserService.java b/code/chapter11/user/src/main/java/io/microprofile/tutorial/store/user/service/UserService.java new file mode 100644 index 0000000..db81d5e --- /dev/null +++ b/code/chapter11/user/src/main/java/io/microprofile/tutorial/store/user/service/UserService.java @@ -0,0 +1,130 @@ +package io.microprofile.tutorial.store.user.service; + +import io.microprofile.tutorial.store.user.entity.User; +import io.microprofile.tutorial.store.user.repository.UserRepository; + +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.List; +import java.util.Optional; + +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.inject.Inject; +import jakarta.ws.rs.WebApplicationException; +import jakarta.ws.rs.core.Response; + +/** + * Service class for User management operations. + */ +@ApplicationScoped +public class UserService { + + @Inject + private UserRepository userRepository; + + /** + * Creates a new user. + * + * @param user The user to create + * @return The created user + * @throws WebApplicationException if a user with the email already exists + */ + public User createUser(User user) { + // Check if email already exists + Optional existingUser = userRepository.findByEmail(user.getEmail()); + if (existingUser.isPresent()) { + throw new WebApplicationException("Email already in use", Response.Status.CONFLICT); + } + + // Hash the password + if (user.getPasswordHash() != null) { + user.setPasswordHash(hashPassword(user.getPasswordHash())); + } + + return userRepository.save(user); + } + + /** + * Gets a user by ID. + * + * @param id The user ID + * @return The user + * @throws WebApplicationException if the user is not found + */ + public User getUserById(Long id) { + return userRepository.findById(id) + .orElseThrow(() -> new WebApplicationException("User not found", Response.Status.NOT_FOUND)); + } + + /** + * Gets all users. + * + * @return A list of all users + */ + public List getAllUsers() { + return userRepository.findAll(); + } + + /** + * Updates a user. + * + * @param id The user ID + * @param user The updated user information + * @return The updated user + * @throws WebApplicationException if the user is not found or if updating to an email that's already in use + */ + public User updateUser(Long id, User user) { + // Check if email already exists and belongs to another user + Optional existingUserWithEmail = userRepository.findByEmail(user.getEmail()); + if (existingUserWithEmail.isPresent() && !existingUserWithEmail.get().getUserId().equals(id)) { + throw new WebApplicationException("Email already in use", Response.Status.CONFLICT); + } + + // Hash the password if it has changed + if (user.getPasswordHash() != null && + !user.getPasswordHash().matches("^[a-fA-F0-9]{64}$")) { // Simple check if it's already a SHA-256 hash + user.setPasswordHash(hashPassword(user.getPasswordHash())); + } + + return userRepository.update(id, user) + .orElseThrow(() -> new WebApplicationException("User not found", Response.Status.NOT_FOUND)); + } + + /** + * Deletes a user. + * + * @param id The user ID + * @throws WebApplicationException if the user is not found + */ + public void deleteUser(Long id) { + boolean deleted = userRepository.deleteById(id); + if (!deleted) { + throw new WebApplicationException("User not found", Response.Status.NOT_FOUND); + } + } + + /** + * Simple password hashing using SHA-256. + * Note: In a production environment, use a more secure hashing algorithm with salt + * + * @param password The password to hash + * @return The hashed password + */ + private String hashPassword(String password) { + try { + MessageDigest digest = MessageDigest.getInstance("SHA-256"); + byte[] hash = digest.digest(password.getBytes()); + + StringBuilder hexString = new StringBuilder(); + for (byte b : hash) { + String hex = Integer.toHexString(0xff & b); + if (hex.length() == 1) hexString.append('0'); + hexString.append(hex); + } + + return hexString.toString(); + } catch (NoSuchAlgorithmException e) { + throw new RuntimeException("Failed to hash password", e); + } + } +} diff --git a/code/chapter11/user/src/main/java/io/microprofile/tutorial/store/user/service/package-info.java b/code/chapter11/user/src/main/java/io/microprofile/tutorial/store/user/service/package-info.java new file mode 100644 index 0000000..e69de29 diff --git a/code/chapter11/user/src/main/webapp/index.html b/code/chapter11/user/src/main/webapp/index.html new file mode 100644 index 0000000..fdb15f4 --- /dev/null +++ b/code/chapter11/user/src/main/webapp/index.html @@ -0,0 +1,107 @@ + + + + User Management Service API + + + +

User Management Service API

+

This service provides RESTful endpoints for managing users in the MicroProfile REST application.

+ +

Available Endpoints

+ +
+
GET /api/users
+
Get all users
+
+ Response: 200 (List of users), 204 (No users found) +
+
+ +
+
GET /api/users/{id}
+
Get a specific user by ID
+
+ Response: 200 (User found), 404 (User not found) +
+
+ +
+
POST /api/users
+
Create a new user
+
+ Request Body: User JSON object
+ Response: 201 (User created), 400 (Invalid data), 409 (Email already in use) +
+
+ +
+
PUT /api/users/{id}
+
Update an existing user
+
+ Request Body: Updated User JSON object
+ Response: 200 (User updated), 400 (Invalid data), 404 (User not found), 409 (Email already in use) +
+
+ +
+
DELETE /api/users/{id}
+
Delete a user by ID
+
+ Response: 204 (User deleted), 404 (User not found) +
+
+ +

Features

+
    +
  • Full CRUD operations for user management
  • +
  • Input validation using Bean Validation
  • +
  • HATEOAS links for improved API discoverability
  • +
  • OpenAPI documentation annotations
  • +
  • Proper HTTP status codes and error handling
  • +
  • Email uniqueness validation
  • +
+ +

Example User JSON

+
+{
+    "userId": 1,
+    "email": "user@example.com",
+    "firstName": "John",
+    "lastName": "Doe"
+}
+    
+ +