From 3d30bcd31261cc6099f78bf1aa8665ca9db37a04 Mon Sep 17 00:00:00 2001 From: Tarun Telang Date: Fri, 24 Apr 2026 18:40:51 +0000 Subject: [PATCH 1/2] Add Code for Chatper 12 about MP GraphQL 2.0 --- code/chapter12/.gitignore | 28 ++ code/chapter12/catalog/README.adoc | 437 ++++++++++++++++++ code/chapter12/catalog/pom.xml | 95 ++++ .../tutorial/graphql/product/Product.java | 56 +++ .../graphql/product/ProductGraphQLApi.java | 173 +++++++ .../graphql/product/ProductInput.java | 37 ++ .../product/ProductNotFoundException.java | 15 + .../graphql/product/ProductService.java | 117 +++++ .../tutorial/graphql/product/Review.java | 39 ++ .../graphql/product/ReviewService.java | 67 +++ .../META-INF/microprofile-config.properties | 8 + code/chapter12/catalog/test-graphql-api.sh | 0 12 files changed, 1072 insertions(+) create mode 100644 code/chapter12/.gitignore create mode 100644 code/chapter12/catalog/README.adoc create mode 100644 code/chapter12/catalog/pom.xml create mode 100644 code/chapter12/catalog/src/main/java/io/microprofile/tutorial/graphql/product/Product.java create mode 100644 code/chapter12/catalog/src/main/java/io/microprofile/tutorial/graphql/product/ProductGraphQLApi.java create mode 100644 code/chapter12/catalog/src/main/java/io/microprofile/tutorial/graphql/product/ProductInput.java create mode 100644 code/chapter12/catalog/src/main/java/io/microprofile/tutorial/graphql/product/ProductNotFoundException.java create mode 100644 code/chapter12/catalog/src/main/java/io/microprofile/tutorial/graphql/product/ProductService.java create mode 100644 code/chapter12/catalog/src/main/java/io/microprofile/tutorial/graphql/product/Review.java create mode 100644 code/chapter12/catalog/src/main/java/io/microprofile/tutorial/graphql/product/ReviewService.java create mode 100644 code/chapter12/catalog/src/main/resources/META-INF/microprofile-config.properties create mode 100644 code/chapter12/catalog/test-graphql-api.sh diff --git a/code/chapter12/.gitignore b/code/chapter12/.gitignore new file mode 100644 index 00000000..f3cc90ed --- /dev/null +++ b/code/chapter12/.gitignore @@ -0,0 +1,28 @@ +# 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 + +# Liberty +.libertyls/ + +# IDE +.idea/ +*.iml +.vscode/ +.settings/ +.project +.classpath +*.swp +*.swo +*~ + +# OS +.DS_Store +Thumbs.db diff --git a/code/chapter12/catalog/README.adoc b/code/chapter12/catalog/README.adoc new file mode 100644 index 00000000..c3de9dea --- /dev/null +++ b/code/chapter12/catalog/README.adoc @@ -0,0 +1,437 @@ += MicroProfile GraphQL Catalog Service +:toc: macro +:toclevels: 3 +:icons: font +:source-highlighter: highlight.js +:experimental: + +toc::[] + +== Overview + +The MicroProfile GraphQL Catalog Service demonstrates the key features of MicroProfile GraphQL 2.0. This service provides a GraphQL API for product catalog management with reviews, computed fields, and batch loading to prevent N+1 queries. + +This project showcases: + +* GraphQL queries and mutations +* Field resolvers with `@Source` +* Batch loading to optimize performance +* Custom error handling +* Integration with MicroProfile Config +* Computed fields and derived data + +== 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 (with batch loading) +* `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.8 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 +---- + +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) + +Most MicroProfile GraphQL implementations provide a GraphQL UI for interactive exploration: + +---- +http://localhost:5060/graphql-catalog/graphql-ui +---- + +Or: + +---- +http://localhost:5060/graphql-catalog/graphiql +---- + +== Example GraphQL Queries + +=== Query All Products + +[source,graphql] +---- +query { + products { + id + name + price + category + stockQuantity + } +} +---- + +=== Query Product with Reviews + +[source,graphql] +---- +query { + product(id: 1) { + id + name + description + price + priceWithTax + priceCategory + availabilityStatus + reviews { + id + reviewerName + rating + comment + createdAt + } + averageRating + } +} +---- + +=== Search Products + +[source,graphql] +---- +query { + searchProducts(searchTerm: "laptop", category: "Electronics") { + id + name + price + stockQuantity + } +} +---- + +=== Query with Top Reviews + +[source,graphql] +---- +query { + products { + id + name + price + topReviews(limit: 3) { + reviewerName + rating + comment + } + } +} +---- + +=== Create Product Mutation + +[source,graphql] +---- +mutation { + createProduct(input: { + name: "Tablet" + description: "10-inch tablet with stylus" + price: 299.99 + category: "Electronics" + stockQuantity: 25 + }) { + id + name + price + priceWithTax + } +} +---- + +=== Update Product Mutation + +[source,graphql] +---- +mutation { + updateProduct( + id: 1 + input: { + name: "Gaming Laptop" + description: "High-performance gaming laptop with RTX graphics" + price: 1299.99 + category: "Electronics" + stockQuantity: 30 + } + ) { + id + name + price + stockQuantity + } +} +---- + +=== Delete Product Mutation + +[source,graphql] +---- +mutation { + deleteProduct(id: 5) +} +---- + +=== Query Statistics + +[source,graphql] +---- +query { + productCount + averagePrice + categories +} +---- + +=== Complex Query with Multiple Fields + +[source,graphql] +---- +query { + products { + id + name + price + priceWithTax + priceCategory + availabilityStatus + category + reviews { + reviewerName + rating + } + averageRating + } + productCount + averagePrice +} +---- + +== Batch Loading Example + +The service demonstrates how to prevent N+1 queries using batch loading. When you query multiple products with their reviews: + +[source,graphql] +---- +query { + products { + id + name + reviews { + rating + comment + } + } +} +---- + +Instead of making one query per product (N+1 queries), the batched resolver fetches all reviews in a single query. + +== Error Handling + +The service includes custom error handling. Try querying a non-existent product: + +[source,graphql] +---- +query { + product(id: 999) { + id + name + } +} +---- + +Response: + +[source,json] +---- +{ + "data": { + "product": null + }, + "errors": [ + { + "message": "Product not found", + "extensions": { + "productId": 999, + "errorCode": "PRODUCT_NOT_FOUND", + "classification": "DataFetchingException" + } + } + ] +} +---- + +== 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 + │ ├── Review.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()); +} +---- + +=== Batch Loading + +Batch loading prevents N+1 queries by accepting a list: + +[source,java] +---- +public CompletionStage> reviews(@Source List products) { + List productIds = products.stream() + .map(Product::getId) + .collect(Collectors.toList()); + return CompletableFuture.supplyAsync(() -> + reviewService.findByProductIds(productIds)); +} +---- + +=== 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 `GraphQLException`: + +[source,java] +---- +public class ProductNotFoundException extends GraphQLException { + public ProductNotFoundException(Long productId) { + super("Product not found", ExceptionType.DataFetchingException); + getExtensions().put("productId", productId); + getExtensions().put("errorCode", "PRODUCT_NOT_FOUND"); + } +} +---- + +== 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 +* Leverage batch loading for related data to improve performance +* Use `@DefaultValue` for optional parameters +* Return `CompletionStage` from field resolvers for async/batch operations + +== Stopping the Application + +To stop the development server, press `Ctrl+C` or type `q` and press Enter in the Liberty dev mode console. diff --git a/code/chapter12/catalog/pom.xml b/code/chapter12/catalog/pom.xml new file mode 100644 index 00000000..caf26557 --- /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 + + + + diff --git a/code/chapter12/catalog/src/main/java/io/microprofile/tutorial/graphql/product/Product.java b/code/chapter12/catalog/src/main/java/io/microprofile/tutorial/graphql/product/Product.java new file mode 100644 index 00000000..81e17c60 --- /dev/null +++ b/code/chapter12/catalog/src/main/java/io/microprofile/tutorial/graphql/product/Product.java @@ -0,0 +1,56 @@ +package io.microprofile.tutorial.graphql.product; + +import org.eclipse.microprofile.graphql.Type; +import org.eclipse.microprofile.graphql.Description; +import org.eclipse.microprofile.graphql.NonNull; + +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 { + + @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") + private Double price; + + @Description("Product category") + private String category; + + @Description("Stock quantity available") + private Integer stockQuantity; + + // Computed field - price with tax + public Double getPriceWithTax() { + return price != null ? price * 1.08 : null; // 8% tax + } + + // Computed field - availability status + public String getAvailabilityStatus() { + if (stockQuantity == null || stockQuantity == 0) { + return "OUT_OF_STOCK"; + } else if (stockQuantity < 10) { + return "LOW_STOCK"; + } else { + return "IN_STOCK"; + } + } +} diff --git a/code/chapter12/catalog/src/main/java/io/microprofile/tutorial/graphql/product/ProductGraphQLApi.java b/code/chapter12/catalog/src/main/java/io/microprofile/tutorial/graphql/product/ProductGraphQLApi.java new file mode 100644 index 00000000..0d8bda31 --- /dev/null +++ b/code/chapter12/catalog/src/main/java/io/microprofile/tutorial/graphql/product/ProductGraphQLApi.java @@ -0,0 +1,173 @@ +package io.microprofile.tutorial.graphql.product; + +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 jakarta.enterprise.context.ApplicationScoped; +import jakarta.inject.Inject; + +import java.util.List; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionStage; +import java.util.stream.Collectors; + +/** + * GraphQL API for product operations + */ +@GraphQLApi +@ApplicationScoped +@Description("Product management GraphQL API") +public class ProductGraphQLApi { + + @Inject + ProductService productService; + + @Inject + ReviewService reviewService; + + // ===== Queries ===== + + @Query("products") + @Description("Retrieves all products from the catalog") + public List getAllProducts() { + return productService.findAll(); + } + + @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("categories") + @Description("Returns all available product categories") + public List getCategories() { + return productService.getAllCategories(); + } + + // ===== 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); + } + + // ===== 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()); + } + + /** + * Batched field resolver for reviews + * Solves N+1 query problem by fetching all reviews in one query + */ + @Description("Reviews for products (batched)") + public CompletionStage> reviews(@Source List products) { + List productIds = products.stream() + .map(Product::getId) + .collect(Collectors.toList()); + + return CompletableFuture.supplyAsync(() -> + reviewService.findByProductIds(productIds)); + } + + /** + * 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"; + } +} diff --git a/code/chapter12/catalog/src/main/java/io/microprofile/tutorial/graphql/product/ProductInput.java b/code/chapter12/catalog/src/main/java/io/microprofile/tutorial/graphql/product/ProductInput.java new file mode 100644 index 00000000..0a08516f --- /dev/null +++ b/code/chapter12/catalog/src/main/java/io/microprofile/tutorial/graphql/product/ProductInput.java @@ -0,0 +1,37 @@ +package io.microprofile.tutorial.graphql.product; + +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; +} diff --git a/code/chapter12/catalog/src/main/java/io/microprofile/tutorial/graphql/product/ProductNotFoundException.java b/code/chapter12/catalog/src/main/java/io/microprofile/tutorial/graphql/product/ProductNotFoundException.java new file mode 100644 index 00000000..6dd3a406 --- /dev/null +++ b/code/chapter12/catalog/src/main/java/io/microprofile/tutorial/graphql/product/ProductNotFoundException.java @@ -0,0 +1,15 @@ +package io.microprofile.tutorial.graphql.product; + +import org.eclipse.microprofile.graphql.GraphQLException; + +/** + * Custom exception for when a product is not found + */ +public class ProductNotFoundException extends GraphQLException { + + public ProductNotFoundException(Long productId) { + super("Product not found", GraphQLException.ExceptionType.DataFetchingException); + getExtensions().put("productId", productId); + getExtensions().put("errorCode", "PRODUCT_NOT_FOUND"); + } +} diff --git a/code/chapter12/catalog/src/main/java/io/microprofile/tutorial/graphql/product/ProductService.java b/code/chapter12/catalog/src/main/java/io/microprofile/tutorial/graphql/product/ProductService.java new file mode 100644 index 00000000..d03b343a --- /dev/null +++ b/code/chapter12/catalog/src/main/java/io/microprofile/tutorial/graphql/product/ProductService.java @@ -0,0 +1,117 @@ +package io.microprofile.tutorial.graphql.product; + +import jakarta.enterprise.context.ApplicationScoped; +import org.eclipse.microprofile.config.inject.ConfigProperty; + +import java.util.*; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.atomic.AtomicLong; +import java.util.stream.Collectors; + +/** + * Service layer for product operations + */ +@ApplicationScoped +public class ProductService { + + private final Map products = new ConcurrentHashMap<>(); + private final AtomicLong idCounter = new AtomicLong(1); + + @ConfigProperty(name = "product.max.results", defaultValue = "100") + Integer maxResults; + + public ProductService() { + // Initialize with sample data + initializeSampleData(); + } + + private void initializeSampleData() { + createProduct(new ProductInput("Laptop", "High-performance laptop", 999.99, "Electronics", 50)); + createProduct(new ProductInput("Mouse", "Wireless mouse", 29.99, "Electronics", 150)); + createProduct(new ProductInput("Keyboard", "Mechanical keyboard", 89.99, "Electronics", 75)); + createProduct(new ProductInput("Monitor", "27-inch 4K monitor", 399.99, "Electronics", 30)); + createProduct(new ProductInput("Headphones", "Noise-canceling headphones", 199.99, "Electronics", 100)); + } + + public List findAll() { + return new ArrayList<>(products.values()).stream() + .limit(maxResults) + .collect(Collectors.toList()); + } + + 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 List search(String searchTerm, String category) { + return products.values().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 = idCounter.getAndIncrement(); + Product product = new Product( + id, + input.getName(), + input.getDescription(), + input.getPrice(), + input.getCategory(), + input.getStockQuantity() + ); + products.put(id, product); + return product; + } + + public Product updateProduct(Long id, ProductInput input) { + Product existing = products.get(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()); + + return existing; + } + + public boolean deleteProduct(Long id) { + return products.remove(id) != null; + } + + public int getProductCount() { + return products.size(); + } + + public Double getAveragePrice() { + return products.values().stream() + .mapToDouble(Product::getPrice) + .average() + .orElse(0.0); + } + + public List getAllCategories() { + return products.values().stream() + .map(Product::getCategory) + .filter(Objects::nonNull) + .distinct() + .sorted() + .collect(Collectors.toList()); + } +} diff --git a/code/chapter12/catalog/src/main/java/io/microprofile/tutorial/graphql/product/Review.java b/code/chapter12/catalog/src/main/java/io/microprofile/tutorial/graphql/product/Review.java new file mode 100644 index 00000000..c90d686c --- /dev/null +++ b/code/chapter12/catalog/src/main/java/io/microprofile/tutorial/graphql/product/Review.java @@ -0,0 +1,39 @@ +package io.microprofile.tutorial.graphql.product; + +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 { + + @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; +} diff --git a/code/chapter12/catalog/src/main/java/io/microprofile/tutorial/graphql/product/ReviewService.java b/code/chapter12/catalog/src/main/java/io/microprofile/tutorial/graphql/product/ReviewService.java new file mode 100644 index 00000000..4f4f811a --- /dev/null +++ b/code/chapter12/catalog/src/main/java/io/microprofile/tutorial/graphql/product/ReviewService.java @@ -0,0 +1,67 @@ +package io.microprofile.tutorial.graphql.product; + +import jakarta.enterprise.context.ApplicationScoped; + +import java.time.LocalDateTime; +import java.util.*; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.atomic.AtomicLong; +import java.util.stream.Collectors; + +/** + * Service layer for review operations + */ +@ApplicationScoped +public class ReviewService { + + private final Map reviews = new ConcurrentHashMap<>(); + private final AtomicLong idCounter = new AtomicLong(1); + + public ReviewService() { + // Initialize with sample reviews + initializeSampleData(); + } + + private void initializeSampleData() { + createReview(1L, "John Doe", 5, "Excellent laptop, very fast!"); + createReview(1L, "Jane Smith", 4, "Good performance, a bit pricey."); + createReview(2L, "Bob Johnson", 5, "Perfect mouse, comfortable grip."); + createReview(3L, "Alice Brown", 4, "Great keyboard, keys are responsive."); + createReview(4L, "Charlie Davis", 5, "Amazing display quality!"); + } + + private void createReview(Long productId, String reviewerName, Integer rating, String comment) { + Long id = idCounter.getAndIncrement(); + Review review = new Review(id, productId, reviewerName, rating, comment, LocalDateTime.now()); + reviews.put(id, review); + } + + public List findByProductId(Long productId) { + return reviews.values().stream() + .filter(r -> r.getProductId().equals(productId)) + .collect(Collectors.toList()); + } + + public List findByProductIds(List productIds) { + Set productIdSet = new HashSet<>(productIds); + return reviews.values().stream() + .filter(r -> productIdSet.contains(r.getProductId())) + .collect(Collectors.toList()); + } + + public List findTopReviewsByProductId(Long productId, int limit) { + return reviews.values().stream() + .filter(r -> r.getProductId().equals(productId)) + .sorted(Comparator.comparing(Review::getRating).reversed()) + .limit(limit) + .collect(Collectors.toList()); + } + + public Double getAverageRating(Long productId) { + return reviews.values().stream() + .filter(r -> r.getProductId().equals(productId)) + .mapToInt(Review::getRating) + .average() + .orElse(0.0); + } +} 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..e9c8c0f0 --- /dev/null +++ b/code/chapter12/catalog/src/main/resources/META-INF/microprofile-config.properties @@ -0,0 +1,8 @@ +# MicroProfile Configuration for GraphQL Catalog Service + +# GraphQL Configuration +mp.graphql.defaultErrorMessage=An error occurred processing your request + +# Product Service Configuration +product.max.results=100 +product.currency=USD diff --git a/code/chapter12/catalog/test-graphql-api.sh b/code/chapter12/catalog/test-graphql-api.sh new file mode 100644 index 00000000..e69de29b From 064f4e5e1feaaa67630b107469c2f781699821db Mon Sep 17 00:00:00 2001 From: Tarun Telang Date: Fri, 24 Apr 2026 18:47:30 +0000 Subject: [PATCH 2/2] Adding code for chapter 12 about MP GraphQL 2.0 --- code/chapter12/.gitignore | 28 - code/chapter12/catalog/README.adoc | 514 +++++++++++++++--- code/chapter12/catalog/pom.xml | 2 +- .../tutorial/graphql/product/Product.java | 56 -- .../graphql/product/ProductGraphQLApi.java | 173 ------ .../product/ProductNotFoundException.java | 15 - .../graphql/product/ReviewService.java | 67 --- .../graphql/product/api/ConfigurableApi.java | 27 + .../product/api/ProductGraphQLApi.java | 306 +++++++++++ .../product/{ => dto}/ProductInput.java | 4 +- .../graphql/product/entity/Identifiable.java | 16 + .../graphql/product/entity/Order.java | 36 ++ .../graphql/product/entity/Product.java | 103 ++++ .../graphql/product/entity/ProductReview.java | 39 ++ .../graphql/product/{ => entity}/Review.java | 6 +- .../exception/InsufficientStockException.java | 35 ++ .../exception/ProductNotFoundException.java | 11 + .../product/repository/ProductRepository.java | 85 +++ .../product/repository/ReviewRepository.java | 52 ++ .../product/service/InventoryService.java | 42 ++ .../graphql/product/service/OrderService.java | 57 ++ .../product/service/PricingService.java | 52 ++ .../product/{ => service}/ProductService.java | 77 +-- .../product/service/ReviewService.java | 65 +++ .../META-INF/microprofile-config.properties | 4 +- code/chapter12/catalog/test-graphql-api.sh | 0 26 files changed, 1406 insertions(+), 466 deletions(-) delete mode 100644 code/chapter12/.gitignore delete mode 100644 code/chapter12/catalog/src/main/java/io/microprofile/tutorial/graphql/product/Product.java delete mode 100644 code/chapter12/catalog/src/main/java/io/microprofile/tutorial/graphql/product/ProductGraphQLApi.java delete mode 100644 code/chapter12/catalog/src/main/java/io/microprofile/tutorial/graphql/product/ProductNotFoundException.java delete mode 100644 code/chapter12/catalog/src/main/java/io/microprofile/tutorial/graphql/product/ReviewService.java create mode 100644 code/chapter12/catalog/src/main/java/io/microprofile/tutorial/graphql/product/api/ConfigurableApi.java create mode 100644 code/chapter12/catalog/src/main/java/io/microprofile/tutorial/graphql/product/api/ProductGraphQLApi.java rename code/chapter12/catalog/src/main/java/io/microprofile/tutorial/graphql/product/{ => dto}/ProductInput.java (93%) create mode 100644 code/chapter12/catalog/src/main/java/io/microprofile/tutorial/graphql/product/entity/Identifiable.java create mode 100644 code/chapter12/catalog/src/main/java/io/microprofile/tutorial/graphql/product/entity/Order.java create mode 100644 code/chapter12/catalog/src/main/java/io/microprofile/tutorial/graphql/product/entity/Product.java create mode 100644 code/chapter12/catalog/src/main/java/io/microprofile/tutorial/graphql/product/entity/ProductReview.java rename code/chapter12/catalog/src/main/java/io/microprofile/tutorial/graphql/product/{ => entity}/Review.java (88%) create mode 100644 code/chapter12/catalog/src/main/java/io/microprofile/tutorial/graphql/product/exception/InsufficientStockException.java create mode 100644 code/chapter12/catalog/src/main/java/io/microprofile/tutorial/graphql/product/exception/ProductNotFoundException.java create mode 100644 code/chapter12/catalog/src/main/java/io/microprofile/tutorial/graphql/product/repository/ProductRepository.java create mode 100644 code/chapter12/catalog/src/main/java/io/microprofile/tutorial/graphql/product/repository/ReviewRepository.java create mode 100644 code/chapter12/catalog/src/main/java/io/microprofile/tutorial/graphql/product/service/InventoryService.java create mode 100644 code/chapter12/catalog/src/main/java/io/microprofile/tutorial/graphql/product/service/OrderService.java create mode 100644 code/chapter12/catalog/src/main/java/io/microprofile/tutorial/graphql/product/service/PricingService.java rename code/chapter12/catalog/src/main/java/io/microprofile/tutorial/graphql/product/{ => service}/ProductService.java (57%) create mode 100644 code/chapter12/catalog/src/main/java/io/microprofile/tutorial/graphql/product/service/ReviewService.java delete mode 100644 code/chapter12/catalog/test-graphql-api.sh diff --git a/code/chapter12/.gitignore b/code/chapter12/.gitignore deleted file mode 100644 index f3cc90ed..00000000 --- a/code/chapter12/.gitignore +++ /dev/null @@ -1,28 +0,0 @@ -# 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 - -# Liberty -.libertyls/ - -# IDE -.idea/ -*.iml -.vscode/ -.settings/ -.project -.classpath -*.swp -*.swo -*~ - -# OS -.DS_Store -Thumbs.db diff --git a/code/chapter12/catalog/README.adoc b/code/chapter12/catalog/README.adoc index c3de9dea..d1794fd5 100644 --- a/code/chapter12/catalog/README.adoc +++ b/code/chapter12/catalog/README.adoc @@ -9,16 +9,15 @@ toc::[] == Overview -The MicroProfile GraphQL Catalog Service demonstrates the key features of MicroProfile GraphQL 2.0. This service provides a GraphQL API for product catalog management with reviews, computed fields, and batch loading to prevent N+1 queries. +This MicroProfile GraphQL Catalog Service provides a production-ready GraphQL API for product catalog management as per MicroProfile GraphQL 2.0 specification. -This project showcases: +Key features demonstrated: -* GraphQL queries and mutations -* Field resolvers with `@Source` -* Batch loading to optimize performance -* Custom error handling -* Integration with MicroProfile Config -* Computed fields and derived data +* 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 @@ -43,7 +42,7 @@ The application exposes a GraphQL API at `/graphql` with the following capabilit ==== Field Resolvers -* `reviews` - Get reviews for a product (with batch loading) +* `reviews` - Get reviews for a product * `topReviews(limit)` - Get top reviews with a limit * `averageRating` - Computed average rating * `priceCategory` - Computed price category @@ -64,7 +63,7 @@ mp.graphql.defaultErrorMessage=An error occurred processing your request == Prerequisites * Java 21 or later -* Maven 3.8 or later +* Maven 3.13 or later == Building the Application @@ -84,6 +83,25 @@ To run the application using Liberty Maven plugin: 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` @@ -101,21 +119,26 @@ http://localhost:5060/graphql-catalog/graphql === GraphQL UI (GraphiQL) -Most MicroProfile GraphQL implementations provide a GraphQL UI for interactive exploration: +To enable the built-in GraphQL UI on Open Liberty, add the following variable to `src/main/liberty/config/server.xml`: +[source,xml] ---- -http://localhost:5060/graphql-catalog/graphql-ui + ---- -Or: +Once enabled, the UI is accessible at: ---- -http://localhost:5060/graphql-catalog/graphiql +http://localhost:5060/graphql-catalog/graphql-ui ---- == Example GraphQL Queries -=== Query All Products +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] ---- @@ -130,7 +153,9 @@ query { } ---- -=== Query Product with Reviews +=== 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] ---- @@ -140,9 +165,41 @@ query { 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 @@ -155,8 +212,49 @@ query { } ---- +=== 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 { @@ -169,7 +267,37 @@ query { } ---- -=== Query with Top Reviews +=== 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] ---- @@ -177,37 +305,58 @@ query { products { id name + description price - topReviews(limit: 3) { + priceWithTax + priceCategory + availabilityStatus + category + stockQuantity + reviews { reviewerName rating comment } + averageRating + topReviews(limit: 2) { + rating + comment + } } + productCount + averagePrice + categories } ---- -=== Create Product Mutation +== 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: "Tablet" - description: "10-inch tablet with stylus" - price: 299.99 + name: "Wireless Charger" + description: "Fast wireless charging pad" + price: 39.99 category: "Electronics" - stockQuantity: 25 + stockQuantity: 100 }) { id name price priceWithTax + priceCategory } } ---- -=== Update Product Mutation +=== 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] ---- @@ -215,11 +364,11 @@ mutation { updateProduct( id: 1 input: { - name: "Gaming Laptop" - description: "High-performance gaming laptop with RTX graphics" - price: 1299.99 + name: "Gaming Laptop Pro" + description: "Professional gaming laptop with RTX 4080" + price: 1499.99 category: "Electronics" - stockQuantity: 30 + stockQuantity: 25 } ) { id @@ -230,60 +379,151 @@ mutation { } ---- -=== Delete Product Mutation +=== 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: 5) + deleteProduct(id: 6) } ---- -=== Query Statistics +=== 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 - averagePrice - categories } ---- -=== Complex Query with Multiple Fields +== 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 { - products { +query GetProduct($productId: ID!) { + product(id: $productId) { id name price - priceWithTax - priceCategory - availabilityStatus - category reviews { - reviewerName rating + comment } - averageRating } - productCount - averagePrice } ---- -== Batch Loading Example +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 -The service demonstrates how to prevent N+1 queries using batch loading. When you query multiple products with their reviews: +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 { - products { + 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 @@ -292,24 +532,25 @@ query { } ---- -Instead of making one query per product (N+1 queries), the batched resolver fetches all reviews in a single query. - == Error Handling -The service includes custom error handling. Try querying a non-existent product: +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: 999) { + product(id: 9999) { id name } } ---- -Response: - +Expected response: [source,json] ---- { @@ -318,17 +559,139 @@ Response: }, "errors": [ { - "message": "Product not found", - "extensions": { - "productId": 999, - "errorCode": "PRODUCT_NOT_FOUND", - "classification": "DataFetchingException" - } + "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 ---- @@ -341,7 +704,7 @@ catalog/ │ └── io/microprofile/tutorial/graphql/product/ │ ├── Product.java # Product entity │ ├── ProductInput.java # Input type for mutations - │ ├── Review.java # Review entity + │ ├── ProductReview.java # Review entity │ ├── ProductService.java # Business logic │ ├── ReviewService.java # Review operations │ ├── ProductGraphQLApi.java # GraphQL API @@ -377,26 +740,11 @@ Field resolvers add additional fields to types: [source,java] ---- @Description("Reviews for this product") -public List reviews(@Source Product product) { +public List reviews(@Source Product product) { return reviewService.findByProductId(product.getId()); } ---- -=== Batch Loading - -Batch loading prevents N+1 queries by accepting a list: - -[source,java] ----- -public CompletionStage> reviews(@Source List products) { - List productIds = products.stream() - .map(Product::getId) - .collect(Collectors.toList()); - return CompletableFuture.supplyAsync(() -> - reviewService.findByProductIds(productIds)); -} ----- - === Computed Fields Computed fields are defined directly in the entity: @@ -410,15 +758,13 @@ public Double getPriceWithTax() { === Custom Error Handling -Custom exceptions extend `GraphQLException`: +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 GraphQLException { +public class ProductNotFoundException extends RuntimeException { public ProductNotFoundException(Long productId) { - super("Product not found", ExceptionType.DataFetchingException); - getExtensions().put("productId", productId); - getExtensions().put("errorCode", "PRODUCT_NOT_FOUND"); + super("Product not found: " + productId); } } ---- @@ -428,10 +774,10 @@ public class ProductNotFoundException extends GraphQLException { * 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 -* Leverage batch loading for related data to improve performance * Use `@DefaultValue` for optional parameters -* Return `CompletionStage` from field resolvers for async/batch operations +* 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. +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 index caf26557..4fea464c 100644 --- a/code/chapter12/catalog/pom.xml +++ b/code/chapter12/catalog/pom.xml @@ -92,4 +92,4 @@ - + \ No newline at end of file diff --git a/code/chapter12/catalog/src/main/java/io/microprofile/tutorial/graphql/product/Product.java b/code/chapter12/catalog/src/main/java/io/microprofile/tutorial/graphql/product/Product.java deleted file mode 100644 index 81e17c60..00000000 --- a/code/chapter12/catalog/src/main/java/io/microprofile/tutorial/graphql/product/Product.java +++ /dev/null @@ -1,56 +0,0 @@ -package io.microprofile.tutorial.graphql.product; - -import org.eclipse.microprofile.graphql.Type; -import org.eclipse.microprofile.graphql.Description; -import org.eclipse.microprofile.graphql.NonNull; - -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 { - - @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") - private Double price; - - @Description("Product category") - private String category; - - @Description("Stock quantity available") - private Integer stockQuantity; - - // Computed field - price with tax - public Double getPriceWithTax() { - return price != null ? price * 1.08 : null; // 8% tax - } - - // Computed field - availability status - public String getAvailabilityStatus() { - if (stockQuantity == null || stockQuantity == 0) { - return "OUT_OF_STOCK"; - } else if (stockQuantity < 10) { - return "LOW_STOCK"; - } else { - return "IN_STOCK"; - } - } -} diff --git a/code/chapter12/catalog/src/main/java/io/microprofile/tutorial/graphql/product/ProductGraphQLApi.java b/code/chapter12/catalog/src/main/java/io/microprofile/tutorial/graphql/product/ProductGraphQLApi.java deleted file mode 100644 index 0d8bda31..00000000 --- a/code/chapter12/catalog/src/main/java/io/microprofile/tutorial/graphql/product/ProductGraphQLApi.java +++ /dev/null @@ -1,173 +0,0 @@ -package io.microprofile.tutorial.graphql.product; - -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 jakarta.enterprise.context.ApplicationScoped; -import jakarta.inject.Inject; - -import java.util.List; -import java.util.concurrent.CompletableFuture; -import java.util.concurrent.CompletionStage; -import java.util.stream.Collectors; - -/** - * GraphQL API for product operations - */ -@GraphQLApi -@ApplicationScoped -@Description("Product management GraphQL API") -public class ProductGraphQLApi { - - @Inject - ProductService productService; - - @Inject - ReviewService reviewService; - - // ===== Queries ===== - - @Query("products") - @Description("Retrieves all products from the catalog") - public List getAllProducts() { - return productService.findAll(); - } - - @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("categories") - @Description("Returns all available product categories") - public List getCategories() { - return productService.getAllCategories(); - } - - // ===== 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); - } - - // ===== 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()); - } - - /** - * Batched field resolver for reviews - * Solves N+1 query problem by fetching all reviews in one query - */ - @Description("Reviews for products (batched)") - public CompletionStage> reviews(@Source List products) { - List productIds = products.stream() - .map(Product::getId) - .collect(Collectors.toList()); - - return CompletableFuture.supplyAsync(() -> - reviewService.findByProductIds(productIds)); - } - - /** - * 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"; - } -} diff --git a/code/chapter12/catalog/src/main/java/io/microprofile/tutorial/graphql/product/ProductNotFoundException.java b/code/chapter12/catalog/src/main/java/io/microprofile/tutorial/graphql/product/ProductNotFoundException.java deleted file mode 100644 index 6dd3a406..00000000 --- a/code/chapter12/catalog/src/main/java/io/microprofile/tutorial/graphql/product/ProductNotFoundException.java +++ /dev/null @@ -1,15 +0,0 @@ -package io.microprofile.tutorial.graphql.product; - -import org.eclipse.microprofile.graphql.GraphQLException; - -/** - * Custom exception for when a product is not found - */ -public class ProductNotFoundException extends GraphQLException { - - public ProductNotFoundException(Long productId) { - super("Product not found", GraphQLException.ExceptionType.DataFetchingException); - getExtensions().put("productId", productId); - getExtensions().put("errorCode", "PRODUCT_NOT_FOUND"); - } -} diff --git a/code/chapter12/catalog/src/main/java/io/microprofile/tutorial/graphql/product/ReviewService.java b/code/chapter12/catalog/src/main/java/io/microprofile/tutorial/graphql/product/ReviewService.java deleted file mode 100644 index 4f4f811a..00000000 --- a/code/chapter12/catalog/src/main/java/io/microprofile/tutorial/graphql/product/ReviewService.java +++ /dev/null @@ -1,67 +0,0 @@ -package io.microprofile.tutorial.graphql.product; - -import jakarta.enterprise.context.ApplicationScoped; - -import java.time.LocalDateTime; -import java.util.*; -import java.util.concurrent.ConcurrentHashMap; -import java.util.concurrent.atomic.AtomicLong; -import java.util.stream.Collectors; - -/** - * Service layer for review operations - */ -@ApplicationScoped -public class ReviewService { - - private final Map reviews = new ConcurrentHashMap<>(); - private final AtomicLong idCounter = new AtomicLong(1); - - public ReviewService() { - // Initialize with sample reviews - initializeSampleData(); - } - - private void initializeSampleData() { - createReview(1L, "John Doe", 5, "Excellent laptop, very fast!"); - createReview(1L, "Jane Smith", 4, "Good performance, a bit pricey."); - createReview(2L, "Bob Johnson", 5, "Perfect mouse, comfortable grip."); - createReview(3L, "Alice Brown", 4, "Great keyboard, keys are responsive."); - createReview(4L, "Charlie Davis", 5, "Amazing display quality!"); - } - - private void createReview(Long productId, String reviewerName, Integer rating, String comment) { - Long id = idCounter.getAndIncrement(); - Review review = new Review(id, productId, reviewerName, rating, comment, LocalDateTime.now()); - reviews.put(id, review); - } - - public List findByProductId(Long productId) { - return reviews.values().stream() - .filter(r -> r.getProductId().equals(productId)) - .collect(Collectors.toList()); - } - - public List findByProductIds(List productIds) { - Set productIdSet = new HashSet<>(productIds); - return reviews.values().stream() - .filter(r -> productIdSet.contains(r.getProductId())) - .collect(Collectors.toList()); - } - - public List findTopReviewsByProductId(Long productId, int limit) { - return reviews.values().stream() - .filter(r -> r.getProductId().equals(productId)) - .sorted(Comparator.comparing(Review::getRating).reversed()) - .limit(limit) - .collect(Collectors.toList()); - } - - public Double getAverageRating(Long productId) { - return reviews.values().stream() - .filter(r -> r.getProductId().equals(productId)) - .mapToInt(Review::getRating) - .average() - .orElse(0.0); - } -} 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/ProductInput.java b/code/chapter12/catalog/src/main/java/io/microprofile/tutorial/graphql/product/dto/ProductInput.java similarity index 93% rename from code/chapter12/catalog/src/main/java/io/microprofile/tutorial/graphql/product/ProductInput.java rename to code/chapter12/catalog/src/main/java/io/microprofile/tutorial/graphql/product/dto/ProductInput.java index 0a08516f..d6089aad 100644 --- a/code/chapter12/catalog/src/main/java/io/microprofile/tutorial/graphql/product/ProductInput.java +++ b/code/chapter12/catalog/src/main/java/io/microprofile/tutorial/graphql/product/dto/ProductInput.java @@ -1,4 +1,4 @@ -package io.microprofile.tutorial.graphql.product; +package io.microprofile.tutorial.graphql.product.dto; import org.eclipse.microprofile.graphql.Input; import org.eclipse.microprofile.graphql.Description; @@ -34,4 +34,4 @@ public class ProductInput { @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/Review.java b/code/chapter12/catalog/src/main/java/io/microprofile/tutorial/graphql/product/entity/Review.java similarity index 88% rename from code/chapter12/catalog/src/main/java/io/microprofile/tutorial/graphql/product/Review.java rename to code/chapter12/catalog/src/main/java/io/microprofile/tutorial/graphql/product/entity/Review.java index c90d686c..c28c1499 100644 --- a/code/chapter12/catalog/src/main/java/io/microprofile/tutorial/graphql/product/Review.java +++ b/code/chapter12/catalog/src/main/java/io/microprofile/tutorial/graphql/product/entity/Review.java @@ -1,4 +1,4 @@ -package io.microprofile.tutorial.graphql.product; +package io.microprofile.tutorial.graphql.product.entity; import org.eclipse.microprofile.graphql.Type; import org.eclipse.microprofile.graphql.Description; @@ -17,7 +17,7 @@ @Data @NoArgsConstructor @AllArgsConstructor -public class Review { +public class Review implements Identifiable { @Description("Unique review identifier") private Long id; @@ -36,4 +36,4 @@ public class Review { @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/ProductService.java b/code/chapter12/catalog/src/main/java/io/microprofile/tutorial/graphql/product/service/ProductService.java similarity index 57% rename from code/chapter12/catalog/src/main/java/io/microprofile/tutorial/graphql/product/ProductService.java rename to code/chapter12/catalog/src/main/java/io/microprofile/tutorial/graphql/product/service/ProductService.java index d03b343a..7c0a549b 100644 --- a/code/chapter12/catalog/src/main/java/io/microprofile/tutorial/graphql/product/ProductService.java +++ b/code/chapter12/catalog/src/main/java/io/microprofile/tutorial/graphql/product/service/ProductService.java @@ -1,11 +1,15 @@ -package io.microprofile.tutorial.graphql.product; +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.*; -import java.util.concurrent.ConcurrentHashMap; -import java.util.concurrent.atomic.AtomicLong; +import java.util.List; +import java.util.Objects; import java.util.stream.Collectors; /** @@ -13,45 +17,40 @@ */ @ApplicationScoped public class ProductService { - - private final Map products = new ConcurrentHashMap<>(); - private final AtomicLong idCounter = new AtomicLong(1); - + + @Inject + ProductRepository productRepository; + + @Inject @ConfigProperty(name = "product.max.results", defaultValue = "100") Integer maxResults; - public ProductService() { - // Initialize with sample data - initializeSampleData(); - } - - private void initializeSampleData() { - createProduct(new ProductInput("Laptop", "High-performance laptop", 999.99, "Electronics", 50)); - createProduct(new ProductInput("Mouse", "Wireless mouse", 29.99, "Electronics", 150)); - createProduct(new ProductInput("Keyboard", "Mechanical keyboard", 89.99, "Electronics", 75)); - createProduct(new ProductInput("Monitor", "27-inch 4K monitor", 399.99, "Electronics", 30)); - createProduct(new ProductInput("Headphones", "Noise-canceling headphones", 199.99, "Electronics", 100)); + public List findAll() { + long limit = maxResults != null && maxResults > 0 ? maxResults : 100; + return productRepository.findAll().stream() + .limit(limit) + .collect(Collectors.toList()); } - public List findAll() { - return new ArrayList<>(products.values()).stream() - .limit(maxResults) + 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 products.get(id); + return productRepository.findById(id); } public List findByIds(List ids) { - return ids.stream() - .map(products::get) + return productRepository.findByIds(ids).stream() .filter(Objects::nonNull) .collect(Collectors.toList()); } public List search(String searchTerm, String category) { - return products.values().stream() + return productRepository.findAll().stream() .filter(p -> { boolean matchesSearch = searchTerm == null || p.getName().toLowerCase().contains(searchTerm.toLowerCase()) || @@ -63,21 +62,26 @@ public List search(String searchTerm, String category) { } public Product createProduct(ProductInput input) { - Long id = idCounter.getAndIncrement(); + Long id = productRepository.nextId(); Product product = new Product( id, input.getName(), input.getDescription(), input.getPrice(), input.getCategory(), - input.getStockQuantity() + 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 ); - products.put(id, product); + productRepository.save(product); return product; } public Product updateProduct(Long id, ProductInput input) { - Product existing = products.get(id); + Product existing = productRepository.findById(id); if (existing == null) { throw new ProductNotFoundException(id); } @@ -87,31 +91,32 @@ public Product updateProduct(Long id, ProductInput input) { existing.setPrice(input.getPrice()); existing.setCategory(input.getCategory()); existing.setStockQuantity(input.getStockQuantity()); - + + productRepository.save(existing); return existing; } public boolean deleteProduct(Long id) { - return products.remove(id) != null; + return productRepository.deleteById(id); } public int getProductCount() { - return products.size(); + return productRepository.count(); } public Double getAveragePrice() { - return products.values().stream() + return productRepository.findAll().stream() .mapToDouble(Product::getPrice) .average() .orElse(0.0); } public List getAllCategories() { - return products.values().stream() + 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 index e9c8c0f0..e40f24a6 100644 --- a/code/chapter12/catalog/src/main/resources/META-INF/microprofile-config.properties +++ b/code/chapter12/catalog/src/main/resources/META-INF/microprofile-config.properties @@ -2,7 +2,9 @@ # 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 +product.currency=USD \ No newline at end of file diff --git a/code/chapter12/catalog/test-graphql-api.sh b/code/chapter12/catalog/test-graphql-api.sh deleted file mode 100644 index e69de29b..00000000