diff --git a/core/broadleaf-framework-web/pom.xml b/core/broadleaf-framework-web/pom.xml index c732740dce..4a8752142e 100644 --- a/core/broadleaf-framework-web/pom.xml +++ b/core/broadleaf-framework-web/pom.xml @@ -94,5 +94,9 @@ org.springframework.security spring-security-oauth2-client + + org.springframework.boot + spring-boot-starter-graphql + diff --git a/core/broadleaf-framework-web/src/main/java/org/broadleafcommerce/core/web/graphql/GraphQLContextInterceptor.java b/core/broadleaf-framework-web/src/main/java/org/broadleafcommerce/core/web/graphql/GraphQLContextInterceptor.java new file mode 100644 index 0000000000..2f01c29c89 --- /dev/null +++ b/core/broadleaf-framework-web/src/main/java/org/broadleafcommerce/core/web/graphql/GraphQLContextInterceptor.java @@ -0,0 +1,236 @@ +/*- + * #%L + * BroadleafCommerce Framework Web + * %% + * Copyright (C) 2009 - 2026 Broadleaf Commerce + * %% + * Licensed under the Broadleaf Fair Use License Agreement, Version 1.0 + * (the "Fair Use License" located at http://license.broadleafcommerce.org/fair_use_license-1.0.txt) + * unless the restrictions on use therein are violated and require payment to Broadleaf in which case + * the Broadleaf End User License Agreement (EULA), Version 1.1 + * (the "Commercial License" located at http://license.broadleafcommerce.org/commercial_license-1.1.txt) + * shall apply. + * + * Alternatively, the Commercial License may be replaced with a mutually agreed upon license (the "Custom License") + * between you and Broadleaf Commerce. You may not use this file except in compliance with the applicable license. + * #L% + */ +package org.broadleafcommerce.core.web.graphql; + +import org.apache.commons.logging.Log; +import org.apache.commons.logging.LogFactory; +import org.broadleafcommerce.common.util.StringUtil; +import org.broadleafcommerce.common.web.BroadleafRequestContext; +import org.broadleafcommerce.profile.core.domain.Customer; +import org.broadleafcommerce.profile.core.service.CustomerService; +import org.broadleafcommerce.profile.web.core.CustomerState; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.graphql.server.WebGraphQlInterceptor; +import org.springframework.graphql.server.WebGraphQlRequest; +import org.springframework.graphql.server.WebGraphQlResponse; +import org.springframework.stereotype.Component; +import org.springframework.web.context.request.RequestContextHolder; +import org.springframework.web.context.request.ServletRequestAttributes; +import org.springframework.web.context.request.ServletWebRequest; +import org.springframework.web.context.request.WebRequest; +import reactor.core.publisher.Mono; + +import java.util.HashMap; +import java.util.Map; + +/** + * A {@link WebGraphQlInterceptor} that populates Broadleaf customer context for incoming + * GraphQL requests. It mirrors the identity resolution pattern used by + * {@link org.broadleafcommerce.profile.web.core.security.RestApiCustomerStateFilter}: + * look for a {@code customerId} header or query parameter, load the customer when numeric, + * and populate {@link CustomerState}. When no customer can be resolved, an anonymous + * customer is created so downstream resolvers always see a valid customer context. + */ +@Component("blGraphQLContextInterceptor") +public class GraphQLContextInterceptor implements WebGraphQlInterceptor { + + public static final String CUSTOMER_ID_ATTRIBUTE = "customerId"; + public static final String BLC_RULE_MAP_PARAM = "blRuleMap"; + + protected static final Log LOG = LogFactory.getLog(GraphQLContextInterceptor.class); + + @Autowired + @Qualifier("blCustomerService") + protected CustomerService customerService; + + @Override + public Mono intercept(WebGraphQlRequest request, Chain chain) { + try { + populateCustomerContext(request); + } catch (Exception ex) { + LOG.warn("Failed to populate customer context for GraphQL request", ex); + } + ensureCustomerContext(); + return chain.next(request); + } + + protected void populateCustomerContext(WebGraphQlRequest request) { + ensureBroadleafRequestContext(); + + if (CustomerState.getCustomer() != null) { + return; + } + + String customerId = resolveCustomerId(request); + if (customerId == null || customerId.trim().isEmpty()) { + return; + } + if (!isValidLong(customerId)) { + LOG.warn(String.format("The customer id passed in '%s' was not a number", + StringUtil.sanitize(customerId))); + return; + } + Customer customer = customerService.readCustomerById(Long.valueOf(customerId)); + if (customer != null) { + applyCustomer(customer); + } + } + + protected void ensureCustomerContext() { + if (hasResolvedCustomer()) { + return; + } + try { + ensureBroadleafRequestContext(); + Customer anonymousCustomer = customerService.createCustomer(); + applyCustomer(anonymousCustomer); + } catch (Exception ex) { + LOG.warn("Failed to create anonymous customer for GraphQL request", ex); + } + } + + /** + * Returns {@code true} when a customer has already been attached to the current request, + * either via {@link CustomerState} (servlet transport) or via {@link BroadleafRequestContext} + * {@code additionalProperties} (non-servlet transports such as WebSocket GraphQL). + */ + protected boolean hasResolvedCustomer() { + if (CustomerState.getCustomer() != null) { + return true; + } + BroadleafRequestContext brc = BroadleafRequestContext.getBroadleafRequestContext(); + if (brc == null) { + return false; + } + Map additional = brc.getAdditionalProperties(); + return additional != null && additional.get("customer") != null; + } + + /** + * Binds the customer to the current request context. {@link CustomerState#setCustomer(Customer)} + * requires a {@link WebRequest} on the {@link BroadleafRequestContext}; for transports where no + * servlet request is bound (e.g., WebSocket GraphQL), we fall back to storing the customer directly + * in {@code additionalProperties} so downstream resolvers can still retrieve it via + * {@link BroadleafRequestContext}. + */ + protected void applyCustomer(Customer customer) { + BroadleafRequestContext brc = BroadleafRequestContext.getBroadleafRequestContext(); + if (brc != null && brc.getWebRequest() != null) { + CustomerState.setCustomer(customer); + } else { + if (brc == null) { + brc = new BroadleafRequestContext(); + BroadleafRequestContext.setBroadleafRequestContext(brc); + } + Map additional = brc.getAdditionalProperties(); + if (additional == null) { + additional = new HashMap<>(); + brc.setAdditionalProperties(additional); + } + additional.put("customer", customer); + LOG.debug("No WebRequest bound to BroadleafRequestContext; " + + "customer stored in additionalProperties for GraphQL request"); + } + setupCustomerForRuleProcessing(customer); + } + + protected boolean isValidLong(String value) { + if (value == null) { + return false; + } + try { + Long.parseLong(value); + return true; + } catch (NumberFormatException ex) { + return false; + } + } + + protected String resolveCustomerId(WebGraphQlRequest request) { + String customerId = request.getHeaders().getFirst(CUSTOMER_ID_ATTRIBUTE); + if (customerId != null) { + return customerId; + } + String query = request.getUri() != null ? request.getUri().getQuery() : null; + if (query == null) { + return null; + } + for (String pair : query.split("&")) { + int idx = pair.indexOf('='); + if (idx > 0 && CUSTOMER_ID_ATTRIBUTE.equals(pair.substring(0, idx))) { + return pair.substring(idx + 1); + } + } + return null; + } + + protected void ensureBroadleafRequestContext() { + BroadleafRequestContext brc = BroadleafRequestContext.getBroadleafRequestContext(); + if (brc == null) { + brc = new BroadleafRequestContext(); + BroadleafRequestContext.setBroadleafRequestContext(brc); + } + if (brc.getWebRequest() == null) { + WebRequest webRequest = resolveWebRequest(); + if (webRequest != null) { + brc.setWebRequest(webRequest); + } + } + } + + protected WebRequest resolveWebRequest() { + try { + if (RequestContextHolder.getRequestAttributes() instanceof ServletRequestAttributes attrs) { + return new ServletWebRequest(attrs.getRequest(), attrs.getResponse()); + } + } catch (IllegalStateException ignored) { + // No servlet request bound to this thread; fall through + } + return null; + } + + @SuppressWarnings("unchecked") + protected void setupCustomerForRuleProcessing(Customer customer) { + BroadleafRequestContext brc = BroadleafRequestContext.getBroadleafRequestContext(); + if (brc == null) { + return; + } + if (brc.getRequest() != null) { + Map ruleMap = (Map) brc.getRequest() + .getAttribute(BLC_RULE_MAP_PARAM); + if (ruleMap == null) { + ruleMap = new HashMap<>(); + } + ruleMap.put("customer", customer); + brc.getRequest().setAttribute(BLC_RULE_MAP_PARAM, ruleMap); + return; + } + Map additional = brc.getAdditionalProperties(); + if (additional == null) { + additional = new HashMap<>(); + brc.setAdditionalProperties(additional); + } + Map ruleMap = (Map) additional.get(BLC_RULE_MAP_PARAM); + if (ruleMap == null) { + ruleMap = new HashMap<>(); + additional.put(BLC_RULE_MAP_PARAM, ruleMap); + } + ruleMap.put("customer", customer); + } +} diff --git a/core/broadleaf-framework-web/src/main/java/org/broadleafcommerce/core/web/graphql/GraphQLExceptionResolver.java b/core/broadleaf-framework-web/src/main/java/org/broadleafcommerce/core/web/graphql/GraphQLExceptionResolver.java new file mode 100644 index 0000000000..dbab5f0977 --- /dev/null +++ b/core/broadleaf-framework-web/src/main/java/org/broadleafcommerce/core/web/graphql/GraphQLExceptionResolver.java @@ -0,0 +1,85 @@ +/*- + * #%L + * BroadleafCommerce Framework Web + * %% + * Copyright (C) 2009 - 2026 Broadleaf Commerce + * %% + * Licensed under the Broadleaf Fair Use License Agreement, Version 1.0 + * (the "Fair Use License" located at http://license.broadleafcommerce.org/fair_use_license-1.0.txt) + * unless the restrictions on use therein are violated and require payment to Broadleaf in which case + * the Broadleaf End User License Agreement (EULA), Version 1.1 + * (the "Commercial License" located at http://license.broadleafcommerce.org/commercial_license-1.1.txt) + * shall apply. + * + * Alternatively, the Commercial License may be replaced with a mutually agreed upon license (the "Custom License") + * between you and Broadleaf Commerce. You may not use this file except in compliance with the applicable license. + * #L% + */ +package org.broadleafcommerce.core.web.graphql; + +import graphql.GraphQLError; +import graphql.GraphqlErrorBuilder; +import graphql.schema.DataFetchingEnvironment; +import org.broadleafcommerce.core.offer.service.exception.OfferMaxUseExceededException; +import org.broadleafcommerce.core.order.service.exception.AddToCartException; +import org.broadleafcommerce.core.order.service.exception.IllegalCartOperationException; +import org.broadleafcommerce.core.order.service.exception.RemoveFromCartException; +import org.broadleafcommerce.core.order.service.exception.UpdateCartException; +import org.broadleafcommerce.core.pricing.service.exception.PricingException; +import org.springframework.graphql.execution.DataFetcherExceptionResolverAdapter; +import org.springframework.graphql.execution.ErrorType; +import org.springframework.stereotype.Component; + +import java.util.HashMap; +import java.util.Map; + +/** + * Maps Broadleaf domain exceptions to GraphQL errors with stable error codes in the + * {@code extensions} map. Unrecognized exceptions are mapped to {@code INTERNAL_ERROR}. + */ +@Component("blGraphQLExceptionResolver") +public class GraphQLExceptionResolver extends DataFetcherExceptionResolverAdapter { + + public static final String ERROR_CODE_KEY = "errorCode"; + public static final String CLASSIFICATION_KEY = "classification"; + + @Override + protected GraphQLError resolveToSingleError(Throwable ex, DataFetchingEnvironment env) { + String errorCode; + ErrorType errorType = ErrorType.INTERNAL_ERROR; + + if (ex instanceof AddToCartException) { + errorCode = "ADD_TO_CART_ERROR"; + errorType = ErrorType.BAD_REQUEST; + } else if (ex instanceof PricingException) { + errorCode = "PRICING_ERROR"; + } else if (ex instanceof OfferMaxUseExceededException) { + errorCode = "OFFER_MAX_USE_EXCEEDED"; + errorType = ErrorType.BAD_REQUEST; + } else if (ex instanceof IllegalCartOperationException) { + errorCode = "ILLEGAL_CART_OPERATION"; + errorType = ErrorType.BAD_REQUEST; + } else if (ex instanceof RemoveFromCartException) { + errorCode = "REMOVE_FROM_CART_ERROR"; + errorType = ErrorType.BAD_REQUEST; + } else if (ex instanceof UpdateCartException) { + errorCode = "UPDATE_CART_ERROR"; + errorType = ErrorType.BAD_REQUEST; + } else { + errorCode = "INTERNAL_ERROR"; + } + + Map extensions = new HashMap<>(); + extensions.put(ERROR_CODE_KEY, errorCode); + extensions.put(CLASSIFICATION_KEY, errorType.name()); + + boolean safeToExposeMessage = errorType != ErrorType.INTERNAL_ERROR && ex.getMessage() != null; + String message = safeToExposeMessage ? ex.getMessage() : errorCode; + + return GraphqlErrorBuilder.newError(env) + .errorType(errorType) + .message(message) + .extensions(extensions) + .build(); + } +} diff --git a/core/broadleaf-framework-web/src/main/java/org/broadleafcommerce/core/web/graphql/resolvers/CatalogQueryResolver.java b/core/broadleaf-framework-web/src/main/java/org/broadleafcommerce/core/web/graphql/resolvers/CatalogQueryResolver.java new file mode 100644 index 0000000000..44fb1dfe0f --- /dev/null +++ b/core/broadleaf-framework-web/src/main/java/org/broadleafcommerce/core/web/graphql/resolvers/CatalogQueryResolver.java @@ -0,0 +1,98 @@ +/*- + * #%L + * BroadleafCommerce Framework Web + * %% + * Copyright (C) 2009 - 2026 Broadleaf Commerce + * %% + * Licensed under the Broadleaf Fair Use License Agreement, Version 1.0 + * (the "Fair Use License" located at http://license.broadleafcommerce.org/fair_use_license-1.0.txt) + * unless the restrictions on use therein are violated and require payment to Broadleaf in which case + * the Broadleaf End User License Agreement (EULA), Version 1.1 + * (the "Commercial License" located at http://license.broadleafcommerce.org/commercial_license-1.1.txt) + * shall apply. + * + * Alternatively, the Commercial License may be replaced with a mutually agreed upon license (the "Custom License") + * between you and Broadleaf Commerce. You may not use this file except in compliance with the applicable license. + * #L% + */ +package org.broadleafcommerce.core.web.graphql.resolvers; + +import org.broadleafcommerce.core.catalog.domain.Category; +import org.broadleafcommerce.core.catalog.domain.Product; +import org.broadleafcommerce.core.catalog.domain.Sku; +import org.broadleafcommerce.core.catalog.service.CatalogService; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.graphql.data.method.annotation.Argument; +import org.springframework.graphql.data.method.annotation.QueryMapping; +import org.springframework.stereotype.Controller; + +import java.util.List; + +/** + * GraphQL query resolvers for catalog lookups. Delegates directly to {@link CatalogService} + * without modifying existing REST controller behavior. + */ +@Controller +public class CatalogQueryResolver { + + protected static final int DEFAULT_LIMIT = 50; + protected static final int DEFAULT_OFFSET = 0; + + @Autowired + @Qualifier("blCatalogService") + protected CatalogService catalogService; + + @QueryMapping + public Product product(@Argument Long id) { + return catalogService.findProductById(id); + } + + @QueryMapping + public Product productByExternalId(@Argument String externalId) { + return catalogService.findProductByExternalId(externalId); + } + + @QueryMapping + public Product productByUri(@Argument String uri) { + return catalogService.findProductByURI(uri); + } + + @QueryMapping + public List products(@Argument String name, + @Argument Integer limit, + @Argument Integer offset) { + int effectiveLimit = limit != null ? limit : DEFAULT_LIMIT; + int effectiveOffset = offset != null ? offset : DEFAULT_OFFSET; + return catalogService.findProductsByName(name, effectiveLimit, effectiveOffset); + } + + @QueryMapping + public Category category(@Argument Long id) { + return catalogService.findCategoryById(id); + } + + @QueryMapping + public Category categoryByUri(@Argument String uri) { + return catalogService.findCategoryByURI(uri); + } + + @QueryMapping + public List categories(@Argument String name, + @Argument Integer limit, + @Argument Integer offset) { + int effectiveLimit = limit != null ? limit : DEFAULT_LIMIT; + int effectiveOffset = offset != null ? offset : DEFAULT_OFFSET; + return catalogService.findCategoriesByName(name, effectiveLimit, effectiveOffset); + } + + @QueryMapping + public Sku sku(@Argument Long id) { + return catalogService.findSkuById(id); + } + + @QueryMapping + public Sku skuByUpc(@Argument String upc) { + return catalogService.findSkuByUpc(upc); + } +} diff --git a/core/broadleaf-framework-web/src/main/java/org/broadleafcommerce/core/web/graphql/resolvers/CategoryFieldResolver.java b/core/broadleaf-framework-web/src/main/java/org/broadleafcommerce/core/web/graphql/resolvers/CategoryFieldResolver.java new file mode 100644 index 0000000000..e6b409c6c4 --- /dev/null +++ b/core/broadleaf-framework-web/src/main/java/org/broadleafcommerce/core/web/graphql/resolvers/CategoryFieldResolver.java @@ -0,0 +1,63 @@ +/*- + * #%L + * BroadleafCommerce Framework Web + * %% + * Copyright (C) 2009 - 2026 Broadleaf Commerce + * %% + * Licensed under the Broadleaf Fair Use License Agreement, Version 1.0 + * (the "Fair Use License" located at http://license.broadleafcommerce.org/fair_use_license-1.0.txt) + * unless the restrictions on use therein are violated and require payment to Broadleaf in which case + * the Broadleaf End User License Agreement (EULA), Version 1.1 + * (the "Commercial License" located at http://license.broadleafcommerce.org/commercial_license-1.1.txt) + * shall apply. + * + * Alternatively, the Commercial License may be replaced with a mutually agreed upon license (the "Custom License") + * between you and Broadleaf Commerce. You may not use this file except in compliance with the applicable license. + * #L% + */ +package org.broadleafcommerce.core.web.graphql.resolvers; + +import org.broadleafcommerce.core.catalog.domain.Category; +import org.broadleafcommerce.core.catalog.domain.Product; +import org.broadleafcommerce.core.catalog.service.CatalogService; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.graphql.data.method.annotation.Argument; +import org.springframework.graphql.data.method.annotation.SchemaMapping; +import org.springframework.stereotype.Controller; + +import java.util.List; + +/** + * Field resolvers for the GraphQL {@code Category} type. Each method receives the parent + * {@link Category} as the first argument and lazily loads child collections via + * {@link CatalogService} so they are only fetched when requested. + */ +@Controller +public class CategoryFieldResolver { + + protected static final int DEFAULT_LIMIT = 50; + protected static final int DEFAULT_OFFSET = 0; + + @Autowired + @Qualifier("blCatalogService") + protected CatalogService catalogService; + + @SchemaMapping(typeName = "Category", field = "activeSubCategories") + public List activeSubCategories(Category category, + @Argument Integer limit, + @Argument Integer offset) { + int effectiveLimit = limit != null ? limit : DEFAULT_LIMIT; + int effectiveOffset = offset != null ? offset : DEFAULT_OFFSET; + return catalogService.findActiveSubCategoriesByCategory(category, effectiveLimit, effectiveOffset); + } + + @SchemaMapping(typeName = "Category", field = "products") + public List products(Category category, + @Argument Integer limit, + @Argument Integer offset) { + int effectiveLimit = limit != null ? limit : DEFAULT_LIMIT; + int effectiveOffset = offset != null ? offset : DEFAULT_OFFSET; + return catalogService.findActiveProductsByCategory(category, effectiveLimit, effectiveOffset); + } +} diff --git a/core/broadleaf-framework-web/src/main/java/org/broadleafcommerce/core/web/graphql/resolvers/ProductFieldResolver.java b/core/broadleaf-framework-web/src/main/java/org/broadleafcommerce/core/web/graphql/resolvers/ProductFieldResolver.java new file mode 100644 index 0000000000..eba12aa3b2 --- /dev/null +++ b/core/broadleaf-framework-web/src/main/java/org/broadleafcommerce/core/web/graphql/resolvers/ProductFieldResolver.java @@ -0,0 +1,55 @@ +/*- + * #%L + * BroadleafCommerce Framework Web + * %% + * Copyright (C) 2009 - 2026 Broadleaf Commerce + * %% + * Licensed under the Broadleaf Fair Use License Agreement, Version 1.0 + * (the "Fair Use License" located at http://license.broadleafcommerce.org/fair_use_license-1.0.txt) + * unless the restrictions on use therein are violated and require payment to Broadleaf in which case + * the Broadleaf End User License Agreement (EULA), Version 1.1 + * (the "Commercial License" located at http://license.broadleafcommerce.org/commercial_license-1.1.txt) + * shall apply. + * + * Alternatively, the Commercial License may be replaced with a mutually agreed upon license (the "Custom License") + * between you and Broadleaf Commerce. You may not use this file except in compliance with the applicable license. + * #L% + */ +package org.broadleafcommerce.core.web.graphql.resolvers; + +import org.broadleafcommerce.core.catalog.domain.Category; +import org.broadleafcommerce.core.catalog.domain.Product; +import org.broadleafcommerce.core.catalog.domain.ProductOption; +import org.broadleafcommerce.core.catalog.domain.Sku; +import org.springframework.graphql.data.method.annotation.SchemaMapping; +import org.springframework.stereotype.Controller; + +import java.util.List; + +/** + * Field resolvers for the GraphQL {@code Product} type. Lazy-loads nested associations + * so the underlying JPA proxies are only initialized when the caller selects the field. + */ +@Controller +public class ProductFieldResolver { + + @SchemaMapping(typeName = "Product", field = "defaultCategory") + public Category defaultCategory(Product product) { + return product.getDefaultCategory(); + } + + @SchemaMapping(typeName = "Product", field = "defaultSku") + public Sku defaultSku(Product product) { + return product.getDefaultSku(); + } + + @SchemaMapping(typeName = "Product", field = "additionalSkus") + public List additionalSkus(Product product) { + return product.getAdditionalSkus(); + } + + @SchemaMapping(typeName = "Product", field = "productOptions") + public List productOptions(Product product) { + return product.getProductOptions(); + } +} diff --git a/core/broadleaf-framework-web/src/main/java/org/broadleafcommerce/core/web/graphql/resolvers/SearchQueryResolver.java b/core/broadleaf-framework-web/src/main/java/org/broadleafcommerce/core/web/graphql/resolvers/SearchQueryResolver.java new file mode 100644 index 0000000000..aad61de7b7 --- /dev/null +++ b/core/broadleaf-framework-web/src/main/java/org/broadleafcommerce/core/web/graphql/resolvers/SearchQueryResolver.java @@ -0,0 +1,79 @@ +/*- + * #%L + * BroadleafCommerce Framework Web + * %% + * Copyright (C) 2009 - 2026 Broadleaf Commerce + * %% + * Licensed under the Broadleaf Fair Use License Agreement, Version 1.0 + * (the "Fair Use License" located at http://license.broadleafcommerce.org/fair_use_license-1.0.txt) + * unless the restrictions on use therein are violated and require payment to Broadleaf in which case + * the Broadleaf End User License Agreement (EULA), Version 1.1 + * (the "Commercial License" located at http://license.broadleafcommerce.org/commercial_license-1.1.txt) + * shall apply. + * + * Alternatively, the Commercial License may be replaced with a mutually agreed upon license (the "Custom License") + * between you and Broadleaf Commerce. You may not use this file except in compliance with the applicable license. + * #L% + */ +package org.broadleafcommerce.core.web.graphql.resolvers; + +import org.broadleafcommerce.core.catalog.domain.Product; +import org.broadleafcommerce.core.search.domain.SearchCriteria; +import org.broadleafcommerce.core.search.domain.SearchResult; +import org.broadleafcommerce.core.search.service.SearchService; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.graphql.data.method.annotation.Argument; +import org.springframework.graphql.data.method.annotation.QueryMapping; +import org.springframework.stereotype.Controller; + +import java.util.Collections; +import java.util.List; + +/** + * GraphQL resolver for product search. Constructs a {@link SearchCriteria} from the + * query/limit/offset arguments and delegates to {@link SearchService#findSearchResults}. + */ +@Controller +public class SearchQueryResolver { + + protected static final int DEFAULT_LIMIT = 50; + protected static final int DEFAULT_OFFSET = 0; + + @Autowired + @Qualifier("blSearchService") + protected SearchService searchService; + + @QueryMapping + public List search(@Argument String query, + @Argument Integer limit, + @Argument Integer offset) throws Exception { + int effectiveLimit = limit != null ? limit : DEFAULT_LIMIT; + int effectiveOffset = offset != null ? offset : DEFAULT_OFFSET; + + if (effectiveLimit <= 0) { + throw new IllegalArgumentException("limit must be greater than zero"); + } + if (effectiveOffset < 0) { + throw new IllegalArgumentException("offset must be greater than or equal to zero"); + } + // The Broadleaf search backend paginates via page/pageSize and ignores startIndex for + // non-page-aligned offsets, so reject values that would silently round down. + if (effectiveOffset % effectiveLimit != 0) { + throw new IllegalArgumentException( + "offset must be a multiple of limit (" + effectiveLimit + ")"); + } + + SearchCriteria criteria = new SearchCriteria(); + criteria.setQuery(query); + criteria.setPageSize(effectiveLimit); + int page = (effectiveOffset / effectiveLimit) + 1; + criteria.setPage(page); + + SearchResult result = searchService.findSearchResults(criteria); + if (result == null || result.getProducts() == null) { + return Collections.emptyList(); + } + return result.getProducts(); + } +} diff --git a/core/broadleaf-framework-web/src/main/java/org/broadleafcommerce/core/web/graphql/resolvers/SkuFieldResolver.java b/core/broadleaf-framework-web/src/main/java/org/broadleafcommerce/core/web/graphql/resolvers/SkuFieldResolver.java new file mode 100644 index 0000000000..7327341df8 --- /dev/null +++ b/core/broadleaf-framework-web/src/main/java/org/broadleafcommerce/core/web/graphql/resolvers/SkuFieldResolver.java @@ -0,0 +1,52 @@ +/*- + * #%L + * BroadleafCommerce Framework Web + * %% + * Copyright (C) 2009 - 2026 Broadleaf Commerce + * %% + * Licensed under the Broadleaf Fair Use License Agreement, Version 1.0 + * (the "Fair Use License" located at http://license.broadleafcommerce.org/fair_use_license-1.0.txt) + * unless the restrictions on use therein are violated and require payment to Broadleaf in which case + * the Broadleaf End User License Agreement (EULA), Version 1.1 + * (the "Commercial License" located at http://license.broadleafcommerce.org/commercial_license-1.1.txt) + * shall apply. + * + * Alternatively, the Commercial License may be replaced with a mutually agreed upon license (the "Custom License") + * between you and Broadleaf Commerce. You may not use this file except in compliance with the applicable license. + * #L% + */ +package org.broadleafcommerce.core.web.graphql.resolvers; + +import org.broadleafcommerce.common.money.Money; +import org.broadleafcommerce.core.catalog.domain.Sku; +import org.springframework.graphql.data.method.annotation.SchemaMapping; +import org.springframework.stereotype.Controller; + +/** + * Field resolvers for the GraphQL {@code Sku} type. Maps Broadleaf {@link Money} values to + * the shared {@code Money} GraphQL type so callers can select {@code amount} and + * {@code currency} independently. + */ +@Controller +public class SkuFieldResolver { + + @SchemaMapping(typeName = "Sku", field = "salePrice") + public Money salePrice(Sku sku) { + return sku.getSalePrice(); + } + + @SchemaMapping(typeName = "Sku", field = "retailPrice") + public Money retailPrice(Sku sku) { + return sku.getRetailPrice(); + } + + @SchemaMapping(typeName = "Sku", field = "cost") + public Money cost(Sku sku) { + return sku.getCost(); + } + + @SchemaMapping(typeName = "Money", field = "currency") + public String currency(Money money) { + return money.getCurrency() != null ? money.getCurrency().getCurrencyCode() : null; + } +} diff --git a/core/broadleaf-framework-web/src/main/resources/graphql-application.properties b/core/broadleaf-framework-web/src/main/resources/graphql-application.properties new file mode 100644 index 0000000000..bdbfa221b0 --- /dev/null +++ b/core/broadleaf-framework-web/src/main/resources/graphql-application.properties @@ -0,0 +1,20 @@ +### +# #%L +# BroadleafCommerce Framework Web +# %% +# Copyright (C) 2009 - 2026 Broadleaf Commerce +# %% +# Licensed under the Broadleaf Fair Use License Agreement, Version 1.0 +# (the "Fair Use License" located at http://license.broadleafcommerce.org/fair_use_license-1.0.txt) +# unless the restrictions on use therein are violated and require payment to Broadleaf in which case +# the Broadleaf End User License Agreement (EULA), Version 1.1 +# (the "Commercial License" located at http://license.broadleafcommerce.org/commercial_license-1.1.txt) +# shall apply. +# +# Alternatively, the Commercial License may be replaced with a mutually agreed upon license (the "Custom License") +# between you and Broadleaf Commerce. You may not use this file except in compliance with the applicable license. +# #L% +### +spring.graphql.graphiql.enabled=true +spring.graphql.path=/graphql +spring.graphql.schema.locations=classpath:graphql/ diff --git a/core/broadleaf-framework-web/src/main/resources/graphql/schema.graphqls b/core/broadleaf-framework-web/src/main/resources/graphql/schema.graphqls new file mode 100644 index 0000000000..4d8a3ebb41 --- /dev/null +++ b/core/broadleaf-framework-web/src/main/resources/graphql/schema.graphqls @@ -0,0 +1,201 @@ +type Query { + # Catalog queries + product(id: ID!): Product + productByExternalId(externalId: String!): Product + productByUri(uri: String!): Product + products(name: String!, limit: Int = 50, offset: Int = 0): [Product!]! + category(id: ID!): Category + categoryByUri(uri: String!): Category + categories(name: String!, limit: Int = 50, offset: Int = 0): [Category!]! + sku(id: ID!): Sku + skuByUpc(upc: String!): Sku + search(query: String!, limit: Int = 50, offset: Int = 0): [Product!]! +} + +type Mutation { + _placeholder: Boolean +} + +type Money { + amount: BigDecimal! + currency: String +} + +scalar BigDecimal + +type Product { + id: ID! + name: String + description: String + longDescription: String + url: String + manufacturer: String + defaultCategory: Category + defaultSku: Sku + additionalSkus: [Sku!] + productOptions: [ProductOption!] +} + +type Category { + id: ID! + name: String + url: String + description: String + longDescription: String + activeStartDate: String + activeEndDate: String + activeSubCategories(limit: Int = 50, offset: Int = 0): [Category!] + products(limit: Int = 50, offset: Int = 0): [Product!] +} + +type Sku { + id: ID! + name: String + description: String + longDescription: String + salePrice: Money + retailPrice: Money + cost: Money + upc: String + quantityAvailable: Int + available: Boolean + activeStartDate: String + activeEndDate: String +} + +type ProductOption { + id: ID! + label: String + type: String + required: Boolean + allowedValues: [ProductOptionValue!] +} + +type ProductOptionValue { + id: ID! + attributeValue: String + displayOrder: Int + priceAdjustment: Money +} + +type Order { + id: ID! + orderNumber: String + status: OrderStatus + subTotal: Money + total: Money + totalTax: Money + totalShipping: Money + emailAddress: String + orderItems: [OrderItem!] + fulfillmentGroups: [FulfillmentGroup!] + payments: [OrderPayment!] +} + +enum OrderStatus { + IN_PROCESS + NAMED + SUBMITTED + CANCELLED + QUOTE + CSR +} + +type OrderItem { + id: ID! + name: String + quantity: Int + price: Money + salePrice: Money + retailPrice: Money + totalPrice: Money + product: Product + sku: Sku + orderItemAttributes: [OrderItemAttribute!] +} + +type OrderItemAttribute { + id: ID! + name: String + value: String +} + +type FulfillmentGroup { + id: ID! + address: Address + shippingPrice: Money + merchandiseTotal: Money + total: Money + type: String + fulfillmentGroupItems: [FulfillmentGroupItem!] +} + +type FulfillmentGroupItem { + id: ID! + quantity: Int + totalItemAmount: Money + orderItem: OrderItem +} + +type Address { + id: ID! + firstName: String + lastName: String + addressLine1: String + addressLine2: String + city: String + stateProvinceRegion: String + postalCode: String + country: String + phonePrimary: String +} + +type OrderPayment { + id: ID! + amount: Money + type: String + gatewayType: String + referenceNumber: String +} + +type Customer { + id: ID! + firstName: String + lastName: String + emailAddress: String + username: String +} + +input AddToCartInput { + productId: ID! + skuId: ID + quantity: Int! + itemAttributes: [ItemAttributeInput!] +} + +input ItemAttributeInput { + name: String! + value: String! +} + +input ShippingInfoInput { + address: AddressInput! + fulfillmentOption: String +} + +input AddressInput { + firstName: String + lastName: String + addressLine1: String! + addressLine2: String + city: String! + stateProvinceRegion: String! + postalCode: String! + country: String! + phonePrimary: String +} + +input BillingInfoInput { + address: AddressInput! + paymentType: String! +} diff --git a/pom.xml b/pom.xml index 6836d3b269..c36ca6fa97 100644 --- a/pom.xml +++ b/pom.xml @@ -1202,6 +1202,22 @@ spring-boot-autoconfigure ${spring.boot.version} + + org.springframework.boot + spring-boot-starter-graphql + ${spring.boot.version} + + + org.reactivestreams + reactive-streams + + + + + org.reactivestreams + reactive-streams + 1.0.4 + org.springframework.security spring-security-oauth2-client