diff --git a/code/chapter12/catalog/README.adoc b/code/chapter12/catalog/README.adoc new file mode 100644 index 00000000..d1794fd5 --- /dev/null +++ b/code/chapter12/catalog/README.adoc @@ -0,0 +1,783 @@ += MicroProfile GraphQL Catalog Service +:toc: macro +:toclevels: 3 +:icons: font +:source-highlighter: highlight.js +:experimental: + +toc::[] + +== Overview + +This MicroProfile GraphQL Catalog Service provides a production-ready GraphQL API for product catalog management as per MicroProfile GraphQL 2.0 specification. + +Key features demonstrated: + +* GraphQL queries and mutations for data retrieval and modification +* Field resolvers using `@Source` for on-demand relationship resolution +* Computed fields for derived data (tax calculations, price categories, stock status) +* Custom error handling with GraphQL-compliant responses +* MicroProfile Config integration for externalized configuration + +== MicroProfile Features Implemented + +=== MicroProfile GraphQL 2.0 + +The application exposes a GraphQL API at `/graphql` with the following capabilities: + +==== Queries + +* `products` - Retrieve all products +* `product(id)` - Get a specific product by ID +* `searchProducts(searchTerm, category)` - Search products +* `productCount` - Get total number of products +* `averagePrice` - Get average product price +* `categories` - Get all categories + +==== Mutations + +* `createProduct(input)` - Create a new product +* `updateProduct(id, input)` - Update an existing product +* `deleteProduct(id)` - Delete a product + +==== Field Resolvers + +* `reviews` - Get reviews for a product +* `topReviews(limit)` - Get top reviews with a limit +* `averageRating` - Computed average rating +* `priceCategory` - Computed price category +* `priceWithTax` - Computed price including tax +* `availabilityStatus` - Computed stock status + +=== MicroProfile Config + +Configuration is externalized using MicroProfile Config: + +[source,properties] +---- +product.max.results=100 +product.currency=USD +mp.graphql.defaultErrorMessage=An error occurred processing your request +---- + +== Prerequisites + +* Java 21 or later +* Maven 3.13 or later + +== Building the Application + +To build the application: + +[source,bash] +---- +mvn clean package +---- + +== Running the Application + +To run the application using Liberty Maven plugin: + +[source,bash] +---- +mvn liberty:dev +---- + +=== Run with Liberty server.xml + +This project includes Liberty configuration at `src/main/liberty/config/server.xml`. +The Liberty Maven plugin uses this file automatically. + +Start the server in development mode: + +[source,bash] +---- +mvn liberty:dev +---- + +Or start in normal mode: + +[source,bash] +---- +mvn liberty:start +---- + +The application will start on: + +* HTTP: `http://localhost:5060` +* Context root: `/graphql-catalog` + +== Accessing GraphQL + +=== GraphQL Endpoint + +The GraphQL endpoint is available at: + +---- +http://localhost:5060/graphql-catalog/graphql +---- + +=== GraphQL UI (GraphiQL) + +To enable the built-in GraphQL UI on Open Liberty, add the following variable to `src/main/liberty/config/server.xml`: + +[source,xml] +---- + +---- + +Once enabled, the UI is accessible at: + +---- +http://localhost:5060/graphql-catalog/graphql-ui +---- + +== Example GraphQL Queries + +This section provides example GraphQL queries and mutations that you can run against the GraphQL Catalog API at `http://localhost:5060/graphql-catalog/graphql`. + +=== Get All Products + +Retrieves the full list of products in the catalog. Use this query to display a product listing or to pre-populate a UI with basic product data. + +[source,graphql] +---- +query { + products { + id + name + price + category + stockQuantity + } +} +---- + +=== Get Product by ID + +Fetches complete details for a single product by its unique identifier. Useful for product detail pages where you need a specific product's full data. + +[source,graphql] +---- +query { + product(id: 1) { + id + name + description + price + category + stockQuantity + } +} +---- + +=== Get Product with Computed Fields + +Retrieves a product with server-side computed fields. `priceWithTax` applies an 8% tax rate, `priceCategory` classifies the product as Budget, Mid-range, or Premium, and `availabilityStatus` reflects current stock levels (In Stock, Low Stock, or Out of Stock). + +[source,graphql] +---- +query { + product(id: 1) { + id + name + price + priceWithTax + priceCategory + availabilityStatus + } +} +---- + +=== Get Product with Reviews + +Fetches a product together with all its customer reviews and a computed average rating. The `reviews` field is resolved by a `@Source` field resolver that loads reviews on demand for the specified product. + +[source,graphql] +---- +query { + product(id: 1) { + id + name + price + reviews { + id + reviewerName + rating + comment + createdAt + } + averageRating + } +} +---- + +=== Get Multiple Products with Reviews + +Retrieves all products along with their associated reviews in a single request. Each product's `reviews` field is resolved individually by the `@Source` field resolver. + +[source,graphql] +---- +query { + products { + id + name + price + reviews { + reviewerName + rating + comment + } + } +} +---- + +=== Get Product with Top Reviews + +Fetches a product's highest-rated reviews up to a configurable limit. The `topReviews` field resolver accepts a `limit` argument (default: 5) and returns reviews sorted by rating descending — ideal for surfacing the best customer feedback. + +[source,graphql] +---- +query { + product(id: 1) { + id + name + topReviews(limit: 3) { + reviewerName + rating + comment + } + } +} +---- + +=== Search Products + +Searches the catalog for products whose name or description matches a keyword, with an optional category filter. Both arguments are optional — omit `category` to search across all categories. + +[source,graphql] +---- +query { + searchProducts(searchTerm: "laptop", category: "Electronics") { + id + name + price + stockQuantity + } +} +---- + +=== Search by Term Only + +Searches for products by keyword without restricting results to a specific category. + +[source,graphql] +---- +query { + searchProducts(searchTerm: "mouse") { + id + name + price + } +} +---- + +=== Get Catalog Statistics + +Returns aggregate statistics about the catalog: the total number of products, the mean price across all products, and a deduplicated list of all product categories. + +[source,graphql] +---- +query { + productCount + averagePrice + categories +} +---- + +=== Complex Query with All Features + +A single request that combines all query capabilities — basic fields, computed fields, field resolver data (reviews, average rating, top reviews), and catalog-level statistics. Demonstrates GraphQL's strength in replacing multiple REST calls with one efficient network round-trip. + +[source,graphql] +---- +query { + products { + id + name + description + price + priceWithTax + priceCategory + availabilityStatus + category + stockQuantity + reviews { + reviewerName + rating + comment + } + averageRating + topReviews(limit: 2) { + rating + comment + } + } + productCount + averagePrice + categories +} +---- + +== Mutations + +=== Create Product + +Creates a new product in the catalog. The server auto-generates the `id`. The response can include computed fields so you can confirm the persisted state without an extra query. + +[source,graphql] +---- +mutation { + createProduct(input: { + name: "Wireless Charger" + description: "Fast wireless charging pad" + price: 39.99 + category: "Electronics" + stockQuantity: 100 + }) { + id + name + price + priceWithTax + priceCategory + } +} +---- + +=== Update Product + +Updates an existing product identified by its `id`. All `input` fields are applied to the stored product. The updated product is returned so you can verify the change immediately without a separate query. + +[source,graphql] +---- +mutation { + updateProduct( + id: 1 + input: { + name: "Gaming Laptop Pro" + description: "Professional gaming laptop with RTX 4080" + price: 1499.99 + category: "Electronics" + stockQuantity: 25 + } + ) { + id + name + price + stockQuantity + } +} +---- + +=== Delete Product + +Removes the specified product from the catalog by ID. Returns `true` if the product was found and deleted successfully. + +[source,graphql] +---- +mutation { + deleteProduct(id: 6) +} +---- + +=== Create and Query in Same Request + +GraphQL allows sending a mutation and a query together. The mutation runs first, then the query returns current catalog state. This pattern confirms the effect of the mutation in a single round-trip. + +[source,graphql] +---- +mutation { + createProduct(input: { + name: "USB Cable" + description: "USB-C to USB-C cable" + price: 12.99 + category: "Accessories" + stockQuantity: 500 + }) { + id + name + price + priceCategory + availabilityStatus + } +} + +query { + productCount +} +---- + +== GraphQL Variables + +Variables decouple query logic from runtime values. This is the recommended pattern in client applications because it avoids string interpolation, improves type safety, and makes query documents reusable. + +=== Query with Variables + +Declares the product ID as a typed variable (`$productId: ID!`) in the operation signature. The variable value is supplied separately in the variables map, keeping the query document static. + +[source,graphql] +---- +query GetProduct($productId: ID!) { + product(id: $productId) { + id + name + price + reviews { + rating + comment + } + } +} +---- + +Variables: +[source,json] +---- +{ + "productId": "1" +} +---- + +=== Mutation with Variables + +Passes the entire product input as a typed variable (`$input: ProductInput!`). The mutation document stays constant while the payload changes at runtime, which also enables server-side validation of the input shape. + +[source,graphql] +---- +mutation CreateProduct($input: ProductInput!) { + createProduct(input: $input) { + id + name + price + } +} +---- + +Variables: +[source,json] +---- +{ + "input": { + "name": "Smart Watch", + "description": "Fitness tracking smart watch", + "price": 199.99, + "category": "Wearables", + "stockQuantity": 75 + } +} +---- + +== Aliases + +Aliases let you execute the same field multiple times with different arguments within a single request. Each result appears under its chosen alias key in the response, making it easy to fetch several data sets in one network call. + +[source,graphql] +---- +query { + budget: searchProducts(searchTerm: "mouse") { + id + name + price + } + premium: searchProducts(searchTerm: "laptop") { + id + name + price + } +} +---- + +== Fragments + +Fragments define reusable, named field selections that can be composed across multiple queries or mutations. They reduce repetition and keep large query documents maintainable. Fragments can spread other fragments using the `...` operator. + +[source,graphql] +---- +fragment ProductBasic on Product { + id + name + price + category +} + +fragment ProductDetailed on Product { + ...ProductBasic + description + stockQuantity + priceWithTax + availabilityStatus +} + +query { + products { + ...ProductBasic + } + product(id: 1) { + ...ProductDetailed + reviews { + rating + comment + } + } +} +---- + +== Error Handling + +The service uses `ProductNotFoundException` (extending `RuntimeException`) to signal missing products. MicroProfile GraphQL catches unchecked exceptions and returns a structured error response. The response message is controlled by the `mp.graphql.defaultErrorMessage` configuration property. + +=== Query Non-Existent Product + +Requesting a product that does not exist triggers the custom exception. The `product` field resolves to `null` and the `errors` array describes the failure. + +[source,graphql] +---- +query { + product(id: 9999) { + id + name + } +} +---- + +Expected response: +[source,json] +---- +{ + "data": { + "product": null + }, + "errors": [ + { + "message": "An error occurred processing your request", + "locations": [{"line": 2, "column": 3}], + "path": ["product"] + } + ] +} +---- + +=== Update Non-Existent Product + +Attempting to update a product that does not exist follows the same error pattern — the mutation returns `null` and the `errors` array explains the failure. + +[source,graphql] +---- +mutation { + updateProduct( + id: 9999 + input: { + name: "Test" + price: 1.0 + } + ) { + id + } +} +---- + +== Using with curl + +All GraphQL requests are HTTP POST calls with a JSON body containing a `query` field. The examples below target the local development server. + +=== Simple Query + +Sends a compact inline query to retrieve all product IDs, names, and prices. + +[source,bash] +---- +curl -X POST http://localhost:5060/graphql-catalog/graphql \ + -H "Content-Type: application/json" \ + -d '{"query": "{ products { id name price } }"}' +---- + +=== Query with Variables + +Sends a parameterized query. The `variables` object maps variable names to their runtime values and is processed server-side alongside the query document. + +[source,bash] +---- +curl -X POST http://localhost:5060/graphql-catalog/graphql \ + -H "Content-Type: application/json" \ + -d '{ + "query": "query GetProduct($id: ID!) { product(id: $id) { id name price } }", + "variables": {"id": "1"} + }' +---- + +=== Mutation + +Sends a `createProduct` mutation. Double quotes inside the query string are escaped with a backslash when using a single-quoted shell string. + +[source,bash] +---- +curl -X POST http://localhost:5060/graphql-catalog/graphql \ + -H "Content-Type: application/json" \ + -d '{ + "query": "mutation { createProduct(input: { name: \"Test\", price: 9.99, category: \"Test\" }) { id name } }" + }' +---- + +== Schema Introspection + +GraphQL supports built-in introspection queries that expose schema structure at runtime. Tools like GraphiQL use introspection to power autocompletion and inline documentation. + +=== Get All Schema Types + +Returns all types in the schema — built-in scalars, custom types (`Product`, `Review`), and input types (`ProductInput`). Use this to understand the complete type inventory of the API. + +[source,graphql] +---- +query { + __schema { + types { + name + kind + } + } +} +---- + +=== Get Type Information for Product + +Returns the field definitions for the `Product` type: each field's name and its return type. Useful for exploring the exact shape of a type without reading source code. + +[source,graphql] +---- +query { + __type(name: "Product") { + name + fields { + name + type { + name + kind + } + } + } +} +---- + +=== Get Available Query Operations + +Lists all available query operations with their names, descriptions, and accepted arguments. This is the foundation for dynamic documentation generators and query builders. + +[source,graphql] +---- +query { + __schema { + queryType { + fields { + name + description + args { + name + type { + name + } + } + } + } + } +} +---- + +== Project Structure + +---- +catalog/ +├── pom.xml +├── README.adoc +└── src/ + └── main/ + ├── java/ + │ └── io/microprofile/tutorial/graphql/product/ + │ ├── Product.java # Product entity + │ ├── ProductInput.java # Input type for mutations + │ ├── ProductReview.java # Review entity + │ ├── ProductService.java # Business logic + │ ├── ReviewService.java # Review operations + │ ├── ProductGraphQLApi.java # GraphQL API + │ └── ProductNotFoundException.java # Custom exception + ├── liberty/ + │ └── config/ + │ └── server.xml # Liberty configuration + └── resources/ + └── META-INF/ + └── microprofile-config.properties # Configuration +---- + +== Key Implementation Details + +=== @GraphQLApi Annotation + +The `ProductGraphQLApi` class is marked with `@GraphQLApi` to expose it as a GraphQL endpoint: + +[source,java] +---- +@GraphQLApi +@ApplicationScoped +@Description("Product management GraphQL API") +public class ProductGraphQLApi { + // Queries and mutations +} +---- + +=== Field Resolvers with @Source + +Field resolvers add additional fields to types: + +[source,java] +---- +@Description("Reviews for this product") +public List reviews(@Source Product product) { + return reviewService.findByProductId(product.getId()); +} +---- + +=== Computed Fields + +Computed fields are defined directly in the entity: + +[source,java] +---- +public Double getPriceWithTax() { + return price != null ? price * 1.08 : null; +} +---- + +=== Custom Error Handling + +Custom exceptions extend `RuntimeException`. MicroProfile GraphQL catches unchecked exceptions and returns a structured error response with the message defined by `mp.graphql.defaultErrorMessage`: + +[source,java] +---- +public class ProductNotFoundException extends RuntimeException { + public ProductNotFoundException(Long productId) { + super("Product not found: " + productId); + } +} +---- + +== Development Tips + +* Use GraphiQL to interactively explore the schema and test queries +* The schema is automatically generated from your Java classes +* Use `@Description` annotations to document your API +* Use `@DefaultValue` for optional parameters +* Use `@Source` to add computed or related fields to GraphQL types without modifying the entity class +* Use `@Name` to customize argument names in the schema + +== Stopping the Application + +To stop the development server, press `Ctrl+C` or type `q` and press Enter in the Liberty dev mode console. \ No newline at end of file diff --git a/code/chapter12/catalog/pom.xml b/code/chapter12/catalog/pom.xml new file mode 100644 index 00000000..4fea464c --- /dev/null +++ b/code/chapter12/catalog/pom.xml @@ -0,0 +1,95 @@ + + + + 4.0.0 + + io.microprofile.tutorial + graphql-catalog + 1.0-SNAPSHOT + war + + + + 21 + 21 + + UTF-8 + UTF-8 + + + 5060 + 5061 + + graphql-catalog + + + + + + + org.projectlombok + lombok + 1.18.36 + provided + + + + + jakarta.platform + jakarta.jakartaee-api + 10.0.0 + provided + + + + + org.eclipse.microprofile + microprofile + 7.1 + pom + provided + + + + + org.eclipse.microprofile.graphql + microprofile-graphql-api + 2.0 + provided + + + + + ${project.artifactId} + + + + io.openliberty.tools + liberty-maven-plugin + 3.11.1 + + graphqlCatalogServer + + + + + + org.apache.maven.plugins + maven-war-plugin + 3.4.0 + + false + pom.xml + + + + + + org.apache.maven.plugins + maven-compiler-plugin + 3.13.0 + + + + \ No newline at end of file diff --git a/code/chapter12/catalog/src/main/java/io/microprofile/tutorial/graphql/product/api/ConfigurableApi.java b/code/chapter12/catalog/src/main/java/io/microprofile/tutorial/graphql/product/api/ConfigurableApi.java new file mode 100644 index 00000000..a5c6ec0e --- /dev/null +++ b/code/chapter12/catalog/src/main/java/io/microprofile/tutorial/graphql/product/api/ConfigurableApi.java @@ -0,0 +1,27 @@ +package io.microprofile.tutorial.graphql.product.api; + +import org.eclipse.microprofile.config.Config; +import org.eclipse.microprofile.config.ConfigProvider; +import org.eclipse.microprofile.graphql.GraphQLApi; +import org.eclipse.microprofile.graphql.Query; +import org.eclipse.microprofile.graphql.Name; +import org.eclipse.microprofile.graphql.Description; + +import jakarta.enterprise.context.ApplicationScoped; + +/** + * GraphQL API demonstrating dynamic configuration + */ +@GraphQLApi +@ApplicationScoped +@Description("Dynamic configuration API") +public class ConfigurableApi { + + @Query + @Description("Retrieves a configuration value by key at runtime") + public String getConfigValue(@Name("key") String key) { + Config config = ConfigProvider.getConfig(); + return config.getOptionalValue(key, String.class) + .orElse("Not configured"); + } +} diff --git a/code/chapter12/catalog/src/main/java/io/microprofile/tutorial/graphql/product/api/ProductGraphQLApi.java b/code/chapter12/catalog/src/main/java/io/microprofile/tutorial/graphql/product/api/ProductGraphQLApi.java new file mode 100644 index 00000000..a2b3219c --- /dev/null +++ b/code/chapter12/catalog/src/main/java/io/microprofile/tutorial/graphql/product/api/ProductGraphQLApi.java @@ -0,0 +1,306 @@ +package io.microprofile.tutorial.graphql.product.api; + +import org.eclipse.microprofile.config.inject.ConfigProperty; +import org.eclipse.microprofile.graphql.GraphQLApi; +import org.eclipse.microprofile.graphql.Query; +import org.eclipse.microprofile.graphql.Mutation; +import org.eclipse.microprofile.graphql.Name; +import org.eclipse.microprofile.graphql.Description; +import org.eclipse.microprofile.graphql.DefaultValue; +import org.eclipse.microprofile.graphql.Source; +import org.eclipse.microprofile.graphql.DateFormat; +import io.microprofile.tutorial.graphql.product.dto.ProductInput; +import io.microprofile.tutorial.graphql.product.entity.Product; +import io.microprofile.tutorial.graphql.product.entity.ProductReview; +import io.microprofile.tutorial.graphql.product.entity.Identifiable; +import io.microprofile.tutorial.graphql.product.exception.ProductNotFoundException; +import io.microprofile.tutorial.graphql.product.exception.InsufficientStockException; +import io.microprofile.tutorial.graphql.product.service.ProductService; +import io.microprofile.tutorial.graphql.product.service.ReviewService; +import io.microprofile.tutorial.graphql.product.service.PricingService; +import io.microprofile.tutorial.graphql.product.service.InventoryService; +import io.microprofile.tutorial.graphql.product.service.OrderService; +import io.microprofile.tutorial.graphql.product.entity.Order; + +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.inject.Inject; + +import java.time.LocalDate; +import java.util.List; + +/** + * GraphQL API for product operations + */ +@GraphQLApi +@ApplicationScoped +@Description("Product management GraphQL API") +public class ProductGraphQLApi { + + @Inject + @ConfigProperty(name = "product.max.results", defaultValue = "100") + private Integer maxResults; + + @Inject + @ConfigProperty(name = "product.currency", defaultValue = "USD") + private String currency; + + @Inject + ProductService productService; + + @Inject + ReviewService reviewService; + + @Inject + PricingService pricingService; + + @Inject + InventoryService inventoryService; + + @Inject + OrderService orderService; + + // ===== Queries ===== + + @Query("products") + @Description("Retrieves all products from the catalog") + public List getAllProducts() { + // Use maxResults to limit query results + return productService.getProducts(maxResults); + } + + @Query("product") + @Description("Retrieves a single product by its unique identifier") + public Product getProduct( + @Name("id") + @Description("The unique identifier of the product") + Long id) { + Product product = productService.findById(id); + if (product == null) { + throw new ProductNotFoundException(id); + } + return product; + } + + @Query("searchProducts") + @Description("Search for products by name or category") + public List searchProducts( + @Name("searchTerm") + @Description("Search term to match against product name or description") + String searchTerm, + @Name("category") + @Description("Filter by category") + String category) { + return productService.search(searchTerm, category); + } + + @Query("productCount") + @Description("Returns the total number of products in the catalog") + public int getProductCount() { + return productService.getProductCount(); + } + + @Query("averagePrice") + @Description("Returns the average price of all products") + public Double getAveragePrice() { + return productService.getAveragePrice(); + } + + @Query("testRuntimeException") + @Description("Demonstrates runtime exception handling - throws RuntimeException for testing") + public String testRuntimeException() { + throw new RuntimeException("This is a test runtime exception that will be caught and converted to a GraphQL error"); + } + + @Query("categories") + @Description("Returns all available product categories") + public List getCategories() { + return productService.getAllCategories(); + } + + @Query("productReleaseDate") + @Description("Returns the release date of a product in formatted string") + @DateFormat(value = "dd MMM yyyy") + public LocalDate getProductReleaseDate(@Name("id") Long id) { + Product product = productService.findById(id); + return product != null ? product.getReleaseDate() : null; + } + + @Query("recentItems") + @Description("Returns recently added products and reviews as a unified list. " + + "Demonstrates polymorphic queries using the Identifiable interface.") + public List getRecentItems( + @Name("limit") + @DefaultValue("10") + @Description("Maximum number of items to return") + int limit) { + // Get recent products and reviews, then combine and sort by ID (newest first) + List items = new java.util.ArrayList<>(); + + // Add recent products + List recentProducts = productService.getProducts(limit / 2); + items.addAll(recentProducts); + + // Add recent reviews + List recentReviews = reviewService.getRecentReviews(limit / 2); + items.addAll(recentReviews); + + // Sort by ID descending (assuming higher IDs are newer) + items.sort((a, b) -> Long.compare(b.getId(), a.getId())); + + // Return top 'limit' items + return items.stream().limit(limit).collect(java.util.stream.Collectors.toList()); + } + + @Query("findById") + @Description("Find any entity (Product or Review) by its ID. " + + "Demonstrates the benefits of the Identifiable interface for unified lookups.") + public Identifiable findById( + @Name("id") + @Description("The unique identifier to search for") + Long id) { + // Try to find as Product first + Product product = productService.findById(id); + if (product != null) { + return product; + } + + // Try to find as Review + ProductReview review = reviewService.findById(id); + if (review != null) { + return review; + } + + return null; // Not found + } + + // ===== Mutations ===== + + @Mutation("createProduct") + @Description("Creates a new product in the catalog") + public Product createProduct( + @Name("input") + @Description("Product input data") + ProductInput input) { + return productService.createProduct(input); + } + + @Mutation("updateProduct") + @Description("Updates an existing product") + public Product updateProduct( + @Name("id") + @Description("Product ID to update") + Long id, + @Name("input") + @Description("Updated product data") + ProductInput input) { + return productService.updateProduct(id, input); + } + + @Mutation("deleteProduct") + @Description("Deletes a product from the catalog") + public boolean deleteProduct( + @Name("id") + @Description("Product ID to delete") + Long id) { + return productService.deleteProduct(id); + } + + @Mutation("orderProduct") + @Description("Place an order for a product - demonstrates custom GraphQL exception handling") + public Order orderProduct( + @Name("productId") + @Description("Product ID to order") + Long productId, + @Name("quantity") + @Description("Quantity to order") + int quantity) throws InsufficientStockException { + int available = inventoryService.getStockLevel(productId); + if (available < quantity) { + throw new InsufficientStockException(productId, quantity, available); + } + return orderService.createOrder(productId, quantity); + } + + // ===== Field Resolvers ===== + + /** + * Field resolver to add reviews to a product + * Non-batched version - causes N+1 queries + */ + @Description("Reviews for this product") + public List reviews(@Source Product product) { + return reviewService.findByProductId(product.getId()); + } + + /** + * Field resolver with parameters + */ + @Description("Top reviews for this product") + public List topReviews( + @Source Product product, + @Name("limit") + @DefaultValue("5") + @Description("Maximum number of reviews to return") + int limit) { + return reviewService.findTopReviewsByProductId(product.getId(), limit); + } + + /** + * Computed field - average rating + */ + @Description("Average rating for this product") + public Double averageRating(@Source Product product) { + return reviewService.getAverageRating(product.getId()); + } + + /** + * Computed field - price category + */ + @Description("Price category based on product price") + public String priceCategory(@Source Product product) { + Double price = product.getPrice(); + if (price < 50) return "BUDGET"; + if (price < 200) return "STANDARD"; + if (price < 500) return "PREMIUM"; + return "LUXURY"; + } + + /** + * Computed field using external service - calculate discounted price + */ + @Description("Calculate discounted price using a discount code") + public Double discountedPrice( + @Source Product product, + @Name("discountCode") + @Description("Discount code to apply (e.g., SAVE10, SAVE20, SAVE30, HALF)") + String discountCode) { + return pricingService.calculateDiscountedPrice(product.getPrice(), discountCode); + } + + /** + * Field resolver - adds 'stockLevel' field to Product type + * Demonstrates @Source annotation for adding fields from external service + */ + @Description("Current stock level from inventory system") + public int stockLevel(@Source Product product) { + return inventoryService.getStockLevel(product.getId()); + } + + /** + * Field resolver that may fail for some products + * Demonstrates partial results when some fields throw exceptions + */ + @Description("Special promotional price (may not be available for all products)") + public Double specialPrice(@Source Product product) throws org.eclipse.microprofile.graphql.GraphQLException { + try { + // This may throw exception for certain products + pricingService.calculateSpecialPrice(product.getId()); + // If successful, return 10% discount + return product.getPrice() * 0.9; + } catch (Exception e) { + throw new org.eclipse.microprofile.graphql.GraphQLException( + "Failed to calculate special price", + org.eclipse.microprofile.graphql.GraphQLException.ExceptionType.DataFetchingException); + } + } +} diff --git a/code/chapter12/catalog/src/main/java/io/microprofile/tutorial/graphql/product/dto/ProductInput.java b/code/chapter12/catalog/src/main/java/io/microprofile/tutorial/graphql/product/dto/ProductInput.java new file mode 100644 index 00000000..d6089aad --- /dev/null +++ b/code/chapter12/catalog/src/main/java/io/microprofile/tutorial/graphql/product/dto/ProductInput.java @@ -0,0 +1,37 @@ +package io.microprofile.tutorial.graphql.product.dto; + +import org.eclipse.microprofile.graphql.Input; +import org.eclipse.microprofile.graphql.Description; +import org.eclipse.microprofile.graphql.NonNull; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +/** + * Input type for creating or updating products + */ +@Input("ProductInput") +@Description("Input for creating or updating a product") +@Data +@NoArgsConstructor +@AllArgsConstructor +public class ProductInput { + + @NonNull + @Description("Product name") + private String name; + + @Description("Product description") + private String description; + + @NonNull + @Description("Product price in USD") + private Double price; + + @Description("Product category") + private String category; + + @Description("Initial stock quantity") + private Integer stockQuantity; +} \ No newline at end of file diff --git a/code/chapter12/catalog/src/main/java/io/microprofile/tutorial/graphql/product/entity/Identifiable.java b/code/chapter12/catalog/src/main/java/io/microprofile/tutorial/graphql/product/entity/Identifiable.java new file mode 100644 index 00000000..47f2b2f6 --- /dev/null +++ b/code/chapter12/catalog/src/main/java/io/microprofile/tutorial/graphql/product/entity/Identifiable.java @@ -0,0 +1,16 @@ +package io.microprofile.tutorial.graphql.product.entity; + +import org.eclipse.microprofile.graphql.Interface; +import org.eclipse.microprofile.graphql.Description; + +/** + * GraphQL interface for entities with unique identifiers. + * Enables polymorphic queries across different entity types. + */ +@Interface +@Description("Common interface for entities with unique identifiers") +public interface Identifiable { + + @Description("Unique identifier for the entity") + Long getId(); +} diff --git a/code/chapter12/catalog/src/main/java/io/microprofile/tutorial/graphql/product/entity/Order.java b/code/chapter12/catalog/src/main/java/io/microprofile/tutorial/graphql/product/entity/Order.java new file mode 100644 index 00000000..6ea4a425 --- /dev/null +++ b/code/chapter12/catalog/src/main/java/io/microprofile/tutorial/graphql/product/entity/Order.java @@ -0,0 +1,36 @@ +package io.microprofile.tutorial.graphql.product.entity; + +import org.eclipse.microprofile.graphql.Type; +import org.eclipse.microprofile.graphql.Description; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; + +/** + * Order entity representing a product order + */ +@Type("Order") +@Description("An order for products") +@Data +@NoArgsConstructor +@AllArgsConstructor +public class Order { + + @Description("Unique order identifier") + private Long id; + + @Description("Product ID being ordered") + private Long productId; + + @Description("Quantity ordered") + private Integer quantity; + + @Description("Order status") + private String status; + + @Description("Order creation date") + private LocalDateTime createdAt; +} diff --git a/code/chapter12/catalog/src/main/java/io/microprofile/tutorial/graphql/product/entity/Product.java b/code/chapter12/catalog/src/main/java/io/microprofile/tutorial/graphql/product/entity/Product.java new file mode 100644 index 00000000..f7dc6271 --- /dev/null +++ b/code/chapter12/catalog/src/main/java/io/microprofile/tutorial/graphql/product/entity/Product.java @@ -0,0 +1,103 @@ +package io.microprofile.tutorial.graphql.product.entity; + +import org.eclipse.microprofile.graphql.Type; +import org.eclipse.microprofile.graphql.Description; +import org.eclipse.microprofile.graphql.NonNull; +import org.eclipse.microprofile.graphql.Enum; +import org.eclipse.microprofile.graphql.NumberFormat; +import org.eclipse.microprofile.graphql.DateFormat; +import org.eclipse.microprofile.graphql.Ignore; + +import java.time.LocalDate; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +/** + * Product entity representing a product in the catalog + */ +@Type("Product") +@Description("A product in the catalog") +@Data +@NoArgsConstructor +@AllArgsConstructor +public class Product implements Identifiable { + + @Description("Unique product identifier") + private Long id; + + @NonNull + @Description("Product name") + private String name; + + @Description("Product description") + private String description; + + @NonNull + @Description("Product price in USD") + @NumberFormat(value = "$ #,##0.00", locale = "en-US") + private Double price; + + @Description("Product category") + private String category; + + @Description("Stock quantity available") + private Integer stockQuantity; + + @Description("Product release date") + @DateFormat(value = "dd MMM yyyy") + private LocalDate releaseDate; + + @Description("Current stock status") + private StockStatus stockStatus; + + @Ignore + @Description("Internal code for inventory management - excluded from GraphQL schema") + private String internalCode; + + @Description("Audit log for product changes - excluded from output type only") + private String auditLog; + + @Description("Tax rate for price calculations") + private Double taxRate; + + /** + * Stock status enumeration + */ + @Enum("StockStatus") + public enum StockStatus { + IN_STOCK, + LOW_STOCK, + OUT_OF_STOCK + } + + // Computed field - price with tax + public Double getPriceWithTax() { + if (price == null) return null; + double rate = (taxRate != null) ? taxRate : 0.08; // Default 8% tax + return price * (1 + rate); + } + + // Computed field with logic - display name in uppercase + public String getDisplayName() { + return name != null ? name.toUpperCase() : "UNKNOWN"; + } + + // Computed field - availability status based on stock quantity + public StockStatus getAvailabilityStatus() { + if (stockQuantity == null || stockQuantity == 0) { + return StockStatus.OUT_OF_STOCK; + } else if (stockQuantity < 10) { + return StockStatus.LOW_STOCK; + } else { + return StockStatus.IN_STOCK; + } + } + + // Audit log getter with @Ignore to exclude from output type only + @Ignore + public String getAuditLog() { + return auditLog; + } +} \ No newline at end of file diff --git a/code/chapter12/catalog/src/main/java/io/microprofile/tutorial/graphql/product/entity/ProductReview.java b/code/chapter12/catalog/src/main/java/io/microprofile/tutorial/graphql/product/entity/ProductReview.java new file mode 100644 index 00000000..a098f6af --- /dev/null +++ b/code/chapter12/catalog/src/main/java/io/microprofile/tutorial/graphql/product/entity/ProductReview.java @@ -0,0 +1,39 @@ +package io.microprofile.tutorial.graphql.product.entity; + +import org.eclipse.microprofile.graphql.Type; +import org.eclipse.microprofile.graphql.Description; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; + +/** + * Review entity for product reviews + */ +@Type("Review") +@Description("A customer review for a product") +@Data +@NoArgsConstructor +@AllArgsConstructor +public class ProductReview implements Identifiable { + + @Description("Unique review identifier") + private Long id; + + @Description("Product ID this review belongs to") + private Long productId; + + @Description("Reviewer name") + private String reviewerName; + + @Description("Rating from 1 to 5") + private Integer rating; + + @Description("Review comment") + private String comment; + + @Description("Review creation date") + private LocalDateTime createdAt; +} \ No newline at end of file diff --git a/code/chapter12/catalog/src/main/java/io/microprofile/tutorial/graphql/product/entity/Review.java b/code/chapter12/catalog/src/main/java/io/microprofile/tutorial/graphql/product/entity/Review.java new file mode 100644 index 00000000..c28c1499 --- /dev/null +++ b/code/chapter12/catalog/src/main/java/io/microprofile/tutorial/graphql/product/entity/Review.java @@ -0,0 +1,39 @@ +package io.microprofile.tutorial.graphql.product.entity; + +import org.eclipse.microprofile.graphql.Type; +import org.eclipse.microprofile.graphql.Description; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; + +/** + * Review entity for product reviews + */ +@Type("Review") +@Description("A customer review for a product") +@Data +@NoArgsConstructor +@AllArgsConstructor +public class Review implements Identifiable { + + @Description("Unique review identifier") + private Long id; + + @Description("Product ID this review belongs to") + private Long productId; + + @Description("Reviewer name") + private String reviewerName; + + @Description("Rating from 1 to 5") + private Integer rating; + + @Description("Review comment") + private String comment; + + @Description("Review creation date") + private LocalDateTime createdAt; +} \ No newline at end of file diff --git a/code/chapter12/catalog/src/main/java/io/microprofile/tutorial/graphql/product/exception/InsufficientStockException.java b/code/chapter12/catalog/src/main/java/io/microprofile/tutorial/graphql/product/exception/InsufficientStockException.java new file mode 100644 index 00000000..8e5fdca7 --- /dev/null +++ b/code/chapter12/catalog/src/main/java/io/microprofile/tutorial/graphql/product/exception/InsufficientStockException.java @@ -0,0 +1,35 @@ +package io.microprofile.tutorial.graphql.product.exception; + +import org.eclipse.microprofile.graphql.GraphQLException; + +/** + * Custom GraphQL exception for insufficient stock scenarios + * Demonstrates using GraphQLException with descriptive error messages + */ +public class InsufficientStockException extends GraphQLException { + + private final Long productId; + private final int requestedQuantity; + private final int availableQuantity; + + public InsufficientStockException(Long productId, int requested, int available) { + super(String.format("Insufficient stock for product %d: requested %d, available %d", + productId, requested, available), + GraphQLException.ExceptionType.DataFetchingException); + this.productId = productId; + this.requestedQuantity = requested; + this.availableQuantity = available; + } + + public Long getProductId() { + return productId; + } + + public int getRequestedQuantity() { + return requestedQuantity; + } + + public int getAvailableQuantity() { + return availableQuantity; + } +} diff --git a/code/chapter12/catalog/src/main/java/io/microprofile/tutorial/graphql/product/exception/ProductNotFoundException.java b/code/chapter12/catalog/src/main/java/io/microprofile/tutorial/graphql/product/exception/ProductNotFoundException.java new file mode 100644 index 00000000..518e4805 --- /dev/null +++ b/code/chapter12/catalog/src/main/java/io/microprofile/tutorial/graphql/product/exception/ProductNotFoundException.java @@ -0,0 +1,11 @@ +package io.microprofile.tutorial.graphql.product.exception; + +/** + * Custom exception for when a product is not found + */ +public class ProductNotFoundException extends RuntimeException { + + public ProductNotFoundException(Long productId) { + super("Product not found: " + productId); + } +} \ No newline at end of file diff --git a/code/chapter12/catalog/src/main/java/io/microprofile/tutorial/graphql/product/repository/ProductRepository.java b/code/chapter12/catalog/src/main/java/io/microprofile/tutorial/graphql/product/repository/ProductRepository.java new file mode 100644 index 00000000..c2e9ac9e --- /dev/null +++ b/code/chapter12/catalog/src/main/java/io/microprofile/tutorial/graphql/product/repository/ProductRepository.java @@ -0,0 +1,85 @@ +package io.microprofile.tutorial.graphql.product.repository; + +import jakarta.enterprise.context.ApplicationScoped; +import io.microprofile.tutorial.graphql.product.dto.ProductInput; +import io.microprofile.tutorial.graphql.product.entity.Product; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.atomic.AtomicLong; +import java.util.stream.Collectors; + +/** + * Repository layer for product persistence operations. + */ +@ApplicationScoped +public class ProductRepository { + + private final Map products = new ConcurrentHashMap<>(); + private final AtomicLong idCounter = new AtomicLong(1); + + public ProductRepository() { + initializeSampleData(); + } + + public List findAll() { + return new ArrayList<>(products.values()); + } + + public Product findById(Long id) { + return products.get(id); + } + + public List findByIds(List ids) { + return ids.stream() + .map(products::get) + .filter(Objects::nonNull) + .collect(Collectors.toList()); + } + + public Product save(Product product) { + products.put(product.getId(), product); + return product; + } + + public boolean deleteById(Long id) { + return products.remove(id) != null; + } + + public int count() { + return products.size(); + } + + public Long nextId() { + return idCounter.getAndIncrement(); + } + + private void initializeSampleData() { + seed(new ProductInput("Laptop", "High-performance laptop", 999.99, "Electronics", 50)); + seed(new ProductInput("Mouse", "Wireless mouse", 29.99, "Electronics", 150)); + seed(new ProductInput("Keyboard", "Mechanical keyboard", 89.99, "Electronics", 75)); + seed(new ProductInput("Monitor", "27-inch 4K monitor", 399.99, "Electronics", 30)); + seed(new ProductInput("Headphones", "Noise-canceling headphones", 199.99, "Electronics", 100)); + } + + private void seed(ProductInput input) { + Long id = nextId(); + Product product = new Product( + id, + input.getName(), + input.getDescription(), + input.getPrice(), + input.getCategory(), + input.getStockQuantity(), + java.time.LocalDate.now(), // releaseDate - set to current date + null, // stockStatus will be computed by getAvailabilityStatus() + "SKU-" + id, // internalCode - excluded from GraphQL schema + "Product created on " + java.time.LocalDate.now(), // auditLog + 0.08 // taxRate - default 8% tax + ); + save(product); + } +} diff --git a/code/chapter12/catalog/src/main/java/io/microprofile/tutorial/graphql/product/repository/ReviewRepository.java b/code/chapter12/catalog/src/main/java/io/microprofile/tutorial/graphql/product/repository/ReviewRepository.java new file mode 100644 index 00000000..91d04c01 --- /dev/null +++ b/code/chapter12/catalog/src/main/java/io/microprofile/tutorial/graphql/product/repository/ReviewRepository.java @@ -0,0 +1,52 @@ +package io.microprofile.tutorial.graphql.product.repository; + +import jakarta.enterprise.context.ApplicationScoped; +import io.microprofile.tutorial.graphql.product.entity.ProductReview; + +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.atomic.AtomicLong; + +/** + * Repository layer for review persistence operations. + */ +@ApplicationScoped +public class ReviewRepository { + + private final Map reviews = new ConcurrentHashMap<>(); + private final AtomicLong idCounter = new AtomicLong(1); + + public ReviewRepository() { + initializeSampleData(); + } + + public List findAll() { + return new ArrayList<>(reviews.values()); + } + + public ProductReview save(ProductReview review) { + reviews.put(review.getId(), review); + return review; + } + + public Long nextId() { + return idCounter.getAndIncrement(); + } + + private void initializeSampleData() { + seed(1L, "John Doe", 5, "Excellent laptop, very fast!"); + seed(1L, "Jane Smith", 4, "Good performance, a bit pricey."); + seed(2L, "Bob Johnson", 5, "Perfect mouse, comfortable grip."); + seed(3L, "Alice Brown", 4, "Great keyboard, keys are responsive."); + seed(4L, "Charlie Davis", 5, "Amazing display quality!"); + } + + private void seed(Long productId, String reviewerName, Integer rating, String comment) { + Long id = nextId(); + ProductReview review = new ProductReview(id, productId, reviewerName, rating, comment, LocalDateTime.now()); + save(review); + } +} diff --git a/code/chapter12/catalog/src/main/java/io/microprofile/tutorial/graphql/product/service/InventoryService.java b/code/chapter12/catalog/src/main/java/io/microprofile/tutorial/graphql/product/service/InventoryService.java new file mode 100644 index 00000000..f090ce85 --- /dev/null +++ b/code/chapter12/catalog/src/main/java/io/microprofile/tutorial/graphql/product/service/InventoryService.java @@ -0,0 +1,42 @@ +package io.microprofile.tutorial.graphql.product.service; + +import jakarta.enterprise.context.ApplicationScoped; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; + +/** + * Service for managing product inventory levels + */ +@ApplicationScoped +public class InventoryService { + + private final Map stockLevels = new ConcurrentHashMap<>(); + + /** + * Get current stock level for a product + * + * @param productId The product ID + * @return Current stock level + */ + public int getStockLevel(Long productId) { + // Return random stock levels for demonstration + // In a real application, this would query a database + return stockLevels.computeIfAbsent(productId, id -> { + // Simulate different stock levels + if (id % 3 == 0) return 5; // Low stock + if (id % 3 == 1) return 0; // Out of stock + return 50; // Normal stock + }); + } + + /** + * Update stock level after an order + * + * @param productId The product ID + * @param quantity Quantity to deduct + */ + public void deductStock(Long productId, int quantity) { + int currentStock = getStockLevel(productId); + stockLevels.put(productId, currentStock - quantity); + } +} diff --git a/code/chapter12/catalog/src/main/java/io/microprofile/tutorial/graphql/product/service/OrderService.java b/code/chapter12/catalog/src/main/java/io/microprofile/tutorial/graphql/product/service/OrderService.java new file mode 100644 index 00000000..16fa3703 --- /dev/null +++ b/code/chapter12/catalog/src/main/java/io/microprofile/tutorial/graphql/product/service/OrderService.java @@ -0,0 +1,57 @@ +package io.microprofile.tutorial.graphql.product.service; + +import io.microprofile.tutorial.graphql.product.entity.Order; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.inject.Inject; + +import java.time.LocalDateTime; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.atomic.AtomicLong; + +/** + * Service for managing orders + */ +@ApplicationScoped +public class OrderService { + + private final Map orders = new ConcurrentHashMap<>(); + private final AtomicLong idCounter = new AtomicLong(1); + + @Inject + InventoryService inventoryService; + + /** + * Create a new order + * + * @param productId The product ID to order + * @param quantity The quantity to order + * @return The created order + */ + public Order createOrder(Long productId, int quantity) { + Long orderId = idCounter.getAndIncrement(); + Order order = new Order( + orderId, + productId, + quantity, + "PENDING", + LocalDateTime.now() + ); + orders.put(orderId, order); + + // Deduct stock + inventoryService.deductStock(productId, quantity); + + return order; + } + + /** + * Get order by ID + * + * @param id The order ID + * @return The order or null if not found + */ + public Order findById(Long id) { + return orders.get(id); + } +} diff --git a/code/chapter12/catalog/src/main/java/io/microprofile/tutorial/graphql/product/service/PricingService.java b/code/chapter12/catalog/src/main/java/io/microprofile/tutorial/graphql/product/service/PricingService.java new file mode 100644 index 00000000..90b37007 --- /dev/null +++ b/code/chapter12/catalog/src/main/java/io/microprofile/tutorial/graphql/product/service/PricingService.java @@ -0,0 +1,52 @@ +package io.microprofile.tutorial.graphql.product.service; + +import jakarta.enterprise.context.ApplicationScoped; + +/** + * Service for pricing calculations including discounts + */ +@ApplicationScoped +public class PricingService { + + /** + * Calculate discounted price based on discount code + * + * @param originalPrice The original price + * @param discountCode The discount code to apply + * @return Discounted price + */ + public Double calculateDiscountedPrice(Double originalPrice, String discountCode) { + if (originalPrice == null || discountCode == null) { + return originalPrice; + } + + // Simple discount logic based on code + double discountRate = switch (discountCode.toUpperCase()) { + case "SAVE10" -> 0.10; // 10% off + case "SAVE20" -> 0.20; // 20% off + case "SAVE30" -> 0.30; // 30% off + case "HALF" -> 0.50; // 50% off + default -> 0.0; // No discount + }; + + return originalPrice * (1 - discountRate); + } + + /** + * Calculate special promotional price for a product + * Throws exception for certain products to demonstrate partial results + * + * @param productId The product ID + * @return Special price + * @throws RuntimeException for product IDs divisible by 3 (for demonstration) + */ + public Double calculateSpecialPrice(Long productId) { + // Simulate failure for certain products to demonstrate partial results + if (productId % 3 == 0) { + throw new RuntimeException("Special pricing service temporarily unavailable for product " + productId); + } + + // Return a 10% discount for other products + return null; // This will be calculated per product + } +} diff --git a/code/chapter12/catalog/src/main/java/io/microprofile/tutorial/graphql/product/service/ProductService.java b/code/chapter12/catalog/src/main/java/io/microprofile/tutorial/graphql/product/service/ProductService.java new file mode 100644 index 00000000..7c0a549b --- /dev/null +++ b/code/chapter12/catalog/src/main/java/io/microprofile/tutorial/graphql/product/service/ProductService.java @@ -0,0 +1,122 @@ +package io.microprofile.tutorial.graphql.product.service; + +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.inject.Inject; +import org.eclipse.microprofile.config.inject.ConfigProperty; +import io.microprofile.tutorial.graphql.product.dto.ProductInput; +import io.microprofile.tutorial.graphql.product.entity.Product; +import io.microprofile.tutorial.graphql.product.exception.ProductNotFoundException; +import io.microprofile.tutorial.graphql.product.repository.ProductRepository; + +import java.util.List; +import java.util.Objects; +import java.util.stream.Collectors; + +/** + * Service layer for product operations + */ +@ApplicationScoped +public class ProductService { + + @Inject + ProductRepository productRepository; + + @Inject + @ConfigProperty(name = "product.max.results", defaultValue = "100") + Integer maxResults; + + public List findAll() { + long limit = maxResults != null && maxResults > 0 ? maxResults : 100; + return productRepository.findAll().stream() + .limit(limit) + .collect(Collectors.toList()); + } + + public List getProducts(Integer limit) { + long maxLimit = limit != null && limit > 0 ? limit : 100; + return productRepository.findAll().stream() + .limit(maxLimit) + .collect(Collectors.toList()); + } + + public Product findById(Long id) { + return productRepository.findById(id); + } + + public List findByIds(List ids) { + return productRepository.findByIds(ids).stream() + .filter(Objects::nonNull) + .collect(Collectors.toList()); + } + + public List search(String searchTerm, String category) { + return productRepository.findAll().stream() + .filter(p -> { + boolean matchesSearch = searchTerm == null || + p.getName().toLowerCase().contains(searchTerm.toLowerCase()) || + (p.getDescription() != null && p.getDescription().toLowerCase().contains(searchTerm.toLowerCase())); + boolean matchesCategory = category == null || category.equals(p.getCategory()); + return matchesSearch && matchesCategory; + }) + .collect(Collectors.toList()); + } + + public Product createProduct(ProductInput input) { + Long id = productRepository.nextId(); + Product product = new Product( + id, + input.getName(), + input.getDescription(), + input.getPrice(), + input.getCategory(), + input.getStockQuantity(), + java.time.LocalDate.now(), // releaseDate - set to current date + null, // stockStatus will be computed by getAvailabilityStatus() + "SKU-" + id, // internalCode - excluded from GraphQL schema + "Product created on " + java.time.LocalDate.now(), // auditLog + 0.08 // taxRate - default 8% tax + ); + productRepository.save(product); + return product; + } + + public Product updateProduct(Long id, ProductInput input) { + Product existing = productRepository.findById(id); + if (existing == null) { + throw new ProductNotFoundException(id); + } + + existing.setName(input.getName()); + existing.setDescription(input.getDescription()); + existing.setPrice(input.getPrice()); + existing.setCategory(input.getCategory()); + existing.setStockQuantity(input.getStockQuantity()); + + productRepository.save(existing); + return existing; + } + + public boolean deleteProduct(Long id) { + return productRepository.deleteById(id); + } + + public int getProductCount() { + return productRepository.count(); + } + + public Double getAveragePrice() { + return productRepository.findAll().stream() + .mapToDouble(Product::getPrice) + .average() + .orElse(0.0); + } + + public List getAllCategories() { + return productRepository.findAll().stream() + .map(Product::getCategory) + .filter(Objects::nonNull) + .distinct() + .sorted() + .collect(Collectors.toList()); + } +} \ No newline at end of file diff --git a/code/chapter12/catalog/src/main/java/io/microprofile/tutorial/graphql/product/service/ReviewService.java b/code/chapter12/catalog/src/main/java/io/microprofile/tutorial/graphql/product/service/ReviewService.java new file mode 100644 index 00000000..364313a1 --- /dev/null +++ b/code/chapter12/catalog/src/main/java/io/microprofile/tutorial/graphql/product/service/ReviewService.java @@ -0,0 +1,65 @@ +package io.microprofile.tutorial.graphql.product.service; + +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.inject.Inject; +import io.microprofile.tutorial.graphql.product.entity.ProductReview; +import io.microprofile.tutorial.graphql.product.repository.ReviewRepository; + +import java.util.Comparator; +import java.util.HashSet; +import java.util.List; +import java.util.Set; +import java.util.stream.Collectors; + +/** + * Service layer for review operations + */ +@ApplicationScoped +public class ReviewService { + + @Inject + ReviewRepository reviewRepository; + + public List findByProductId(Long productId) { + return reviewRepository.findAll().stream() + .filter(r -> r.getProductId().equals(productId)) + .collect(Collectors.toList()); + } + + public List findByProductIds(List productIds) { + Set productIdSet = new HashSet<>(productIds); + return reviewRepository.findAll().stream() + .filter(r -> productIdSet.contains(r.getProductId())) + .collect(Collectors.toList()); + } + + public List findTopReviewsByProductId(Long productId, int limit) { + return reviewRepository.findAll().stream() + .filter(r -> r.getProductId().equals(productId)) + .sorted(Comparator.comparing(ProductReview::getRating).reversed()) + .limit(limit) + .collect(Collectors.toList()); + } + + public Double getAverageRating(Long productId) { + return reviewRepository.findAll().stream() + .filter(r -> r.getProductId().equals(productId)) + .mapToInt(ProductReview::getRating) + .average() + .orElse(0.0); + } + + public List getRecentReviews(int limit) { + return reviewRepository.findAll().stream() + .sorted(Comparator.comparing(ProductReview::getId).reversed()) + .limit(limit) + .collect(Collectors.toList()); + } + + public ProductReview findById(Long id) { + return reviewRepository.findAll().stream() + .filter(r -> r.getId().equals(id)) + .findFirst() + .orElse(null); + } +} \ No newline at end of file diff --git a/code/chapter12/catalog/src/main/resources/META-INF/microprofile-config.properties b/code/chapter12/catalog/src/main/resources/META-INF/microprofile-config.properties new file mode 100644 index 00000000..e40f24a6 --- /dev/null +++ b/code/chapter12/catalog/src/main/resources/META-INF/microprofile-config.properties @@ -0,0 +1,10 @@ +# MicroProfile Configuration for GraphQL Catalog Service + +# GraphQL Configuration +mp.graphql.defaultErrorMessage=An error occurred processing your request +mp.graphql.hideErrorMessage=java.lang.NullPointerException +mp.graphql.showErrorMessage=io.microprofile.tutorial.graphql.product.exception.ProductNotFoundException + +# Product Service Configuration +product.max.results=100 +product.currency=USD \ No newline at end of file