From ac78b9b68504c450f4a94d1723802c5fcd076336 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Wed, 22 Apr 2026 22:22:09 +0000 Subject: [PATCH 1/7] Add GraphQL support: foundation and catalog resolvers Co-Authored-By: Arjun Mishra --- core/broadleaf-framework-web/pom.xml | 4 + .../graphql/GraphQLContextInterceptor.java | 133 ++++++++++++ .../web/graphql/GraphQLExceptionResolver.java | 82 +++++++ .../resolvers/CatalogQueryResolver.java | 91 ++++++++ .../resolvers/CategoryFieldResolver.java | 63 ++++++ .../resolvers/ProductFieldResolver.java | 55 +++++ .../resolvers/SearchQueryResolver.java | 76 +++++++ .../graphql/resolvers/SkuFieldResolver.java | 52 +++++ .../resources/graphql-application.properties | 20 ++ .../main/resources/graphql/schema.graphqls | 201 ++++++++++++++++++ pom.xml | 16 ++ 11 files changed, 793 insertions(+) create mode 100644 core/broadleaf-framework-web/src/main/java/org/broadleafcommerce/core/web/graphql/GraphQLContextInterceptor.java create mode 100644 core/broadleaf-framework-web/src/main/java/org/broadleafcommerce/core/web/graphql/GraphQLExceptionResolver.java create mode 100644 core/broadleaf-framework-web/src/main/java/org/broadleafcommerce/core/web/graphql/resolvers/CatalogQueryResolver.java create mode 100644 core/broadleaf-framework-web/src/main/java/org/broadleafcommerce/core/web/graphql/resolvers/CategoryFieldResolver.java create mode 100644 core/broadleaf-framework-web/src/main/java/org/broadleafcommerce/core/web/graphql/resolvers/ProductFieldResolver.java create mode 100644 core/broadleaf-framework-web/src/main/java/org/broadleafcommerce/core/web/graphql/resolvers/SearchQueryResolver.java create mode 100644 core/broadleaf-framework-web/src/main/java/org/broadleafcommerce/core/web/graphql/resolvers/SkuFieldResolver.java create mode 100644 core/broadleaf-framework-web/src/main/resources/graphql-application.properties create mode 100644 core/broadleaf-framework-web/src/main/resources/graphql/schema.graphqls 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..b0c5d00941 --- /dev/null +++ b/core/broadleaf-framework-web/src/main/java/org/broadleafcommerce/core/web/graphql/GraphQLContextInterceptor.java @@ -0,0 +1,133 @@ +/*- + * #%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.lang3.math.NumberUtils; +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 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"; + + 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); + } + 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()) { + if (NumberUtils.isCreatable(customerId)) { + Customer customer = customerService.readCustomerById(Long.valueOf(customerId)); + if (customer != null) { + CustomerState.setCustomer(customer); + setupCustomerForRuleProcessing(customer); + return; + } + } else { + LOG.warn(String.format("The customer id passed in '%s' was not a number", + StringUtil.sanitize(customerId))); + } + } + + Customer anonymousCustomer = customerService.createCustomer(); + CustomerState.setCustomer(anonymousCustomer); + setupCustomerForRuleProcessing(anonymousCustomer); + } + + 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); + } + } + + protected void setupCustomerForRuleProcessing(Customer customer) { + BroadleafRequestContext brc = BroadleafRequestContext.getBroadleafRequestContext(); + if (brc == null) { + return; + } + Map ruleMap = brc.getAdditionalProperties(); + if (ruleMap == null) { + ruleMap = new HashMap<>(); + brc.setAdditionalProperties(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..439534d097 --- /dev/null +++ b/core/broadleaf-framework-web/src/main/java/org/broadleafcommerce/core/web/graphql/GraphQLExceptionResolver.java @@ -0,0 +1,82 @@ +/*- + * #%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()); + + return GraphqlErrorBuilder.newError(env) + .errorType(errorType) + .message(ex.getMessage() != null ? ex.getMessage() : errorCode) + .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..20a661bf7a --- /dev/null +++ b/core/broadleaf-framework-web/src/main/java/org/broadleafcommerce/core/web/graphql/resolvers/CatalogQueryResolver.java @@ -0,0 +1,91 @@ +/*- + * #%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 { + + @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 int limit, + @Argument int offset) { + return catalogService.findProductsByName(name, limit, offset); + } + + @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 int limit, + @Argument int offset) { + return catalogService.findCategoriesByName(name, limit, offset); + } + + @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..17fd11f6d1 --- /dev/null +++ b/core/broadleaf-framework-web/src/main/java/org/broadleafcommerce/core/web/graphql/resolvers/SearchQueryResolver.java @@ -0,0 +1,76 @@ +/*- + * #%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.apache.commons.logging.Log; +import org.apache.commons.logging.LogFactory; +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 Log LOG = LogFactory.getLog(SearchQueryResolver.class); + + 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) { + int effectiveLimit = limit != null ? limit : DEFAULT_LIMIT; + int effectiveOffset = offset != null ? offset : DEFAULT_OFFSET; + + SearchCriteria criteria = new SearchCriteria(); + criteria.setQuery(query); + criteria.setPageSize(effectiveLimit); + criteria.setStartIndex(effectiveOffset); + int page = effectiveLimit > 0 ? (effectiveOffset / effectiveLimit) + 1 : 1; + criteria.setPage(page); + + try { + SearchResult result = searchService.findSearchResults(criteria); + if (result == null || result.getProducts() == null) { + return Collections.emptyList(); + } + return result.getProducts(); + } catch (Exception ex) { + LOG.warn("GraphQL search failed for query='" + query + "'", ex); + return Collections.emptyList(); + } + } +} 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 From ebf832b3e1ccacde6615e0906441aa997edcc7ce Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Wed, 22 Apr 2026 22:28:01 +0000 Subject: [PATCH 2/7] Address Devin Review: bind WebRequest to BRC; stop leaking internal error messages Co-Authored-By: Arjun Mishra --- .../graphql/GraphQLContextInterceptor.java | 21 +++++++++++++++++++ .../web/graphql/GraphQLExceptionResolver.java | 5 ++++- 2 files changed, 25 insertions(+), 1 deletion(-) 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 index b0c5d00941..5527085244 100644 --- 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 @@ -31,6 +31,10 @@ 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; @@ -116,6 +120,23 @@ protected void ensureBroadleafRequestContext() { 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; } protected void setupCustomerForRuleProcessing(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 index 439534d097..dbab5f0977 100644 --- 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 @@ -73,9 +73,12 @@ protected GraphQLError resolveToSingleError(Throwable ex, DataFetchingEnvironmen 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(ex.getMessage() != null ? ex.getMessage() : errorCode) + .message(message) .extensions(extensions) .build(); } From 8504ee280f7cb78802082184c854263fa9f3e100 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Wed, 22 Apr 2026 22:36:37 +0000 Subject: [PATCH 3/7] Address Devin Review: nest customer under blRuleMap; nullable Integer args Co-Authored-By: Arjun Mishra --- .../graphql/GraphQLContextInterceptor.java | 21 +++++++++++++++++-- .../resolvers/CatalogQueryResolver.java | 19 +++++++++++------ 2 files changed, 32 insertions(+), 8 deletions(-) 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 index 5527085244..3e7c70928b 100644 --- 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 @@ -52,6 +52,7 @@ 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); @@ -139,15 +140,31 @@ protected WebRequest resolveWebRequest() { return null; } + @SuppressWarnings("unchecked") protected void setupCustomerForRuleProcessing(Customer customer) { BroadleafRequestContext brc = BroadleafRequestContext.getBroadleafRequestContext(); if (brc == null) { return; } - Map ruleMap = brc.getAdditionalProperties(); + 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<>(); - brc.setAdditionalProperties(ruleMap); + 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/resolvers/CatalogQueryResolver.java b/core/broadleaf-framework-web/src/main/java/org/broadleafcommerce/core/web/graphql/resolvers/CatalogQueryResolver.java index 20a661bf7a..44fb1dfe0f 100644 --- 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 @@ -36,6 +36,9 @@ @Controller public class CatalogQueryResolver { + protected static final int DEFAULT_LIMIT = 50; + protected static final int DEFAULT_OFFSET = 0; + @Autowired @Qualifier("blCatalogService") protected CatalogService catalogService; @@ -57,9 +60,11 @@ public Product productByUri(@Argument String uri) { @QueryMapping public List products(@Argument String name, - @Argument int limit, - @Argument int offset) { - return catalogService.findProductsByName(name, limit, offset); + @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 @@ -74,9 +79,11 @@ public Category categoryByUri(@Argument String uri) { @QueryMapping public List categories(@Argument String name, - @Argument int limit, - @Argument int offset) { - return catalogService.findCategoriesByName(name, limit, offset); + @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 From 2f170315ea26c458d72f92df0a649226c0c70952 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Wed, 22 Apr 2026 22:42:39 +0000 Subject: [PATCH 4/7] Address Devin Review: ensure anonymous fallback + strict Long validation Co-Authored-By: Arjun Mishra --- .../graphql/GraphQLContextInterceptor.java | 54 +++++++++++++------ 1 file changed, 38 insertions(+), 16 deletions(-) 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 index 3e7c70928b..877d27b4aa 100644 --- 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 @@ -17,7 +17,6 @@ */ package org.broadleafcommerce.core.web.graphql; -import org.apache.commons.lang3.math.NumberUtils; import org.apache.commons.logging.Log; import org.apache.commons.logging.LogFactory; import org.broadleafcommerce.common.util.StringUtil; @@ -67,6 +66,7 @@ public Mono intercept(WebGraphQlRequest request, Chain chain } catch (Exception ex) { LOG.warn("Failed to populate customer context for GraphQL request", ex); } + ensureCustomerContext(); return chain.next(request); } @@ -78,23 +78,45 @@ protected void populateCustomerContext(WebGraphQlRequest request) { } String customerId = resolveCustomerId(request); - if (customerId != null && !customerId.trim().isEmpty()) { - if (NumberUtils.isCreatable(customerId)) { - Customer customer = customerService.readCustomerById(Long.valueOf(customerId)); - if (customer != null) { - CustomerState.setCustomer(customer); - setupCustomerForRuleProcessing(customer); - return; - } - } else { - LOG.warn(String.format("The customer id passed in '%s' was not a number", - StringUtil.sanitize(customerId))); - } + 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) { + CustomerState.setCustomer(customer); + setupCustomerForRuleProcessing(customer); } + } - Customer anonymousCustomer = customerService.createCustomer(); - CustomerState.setCustomer(anonymousCustomer); - setupCustomerForRuleProcessing(anonymousCustomer); + protected void ensureCustomerContext() { + if (CustomerState.getCustomer() != null) { + return; + } + try { + ensureBroadleafRequestContext(); + Customer anonymousCustomer = customerService.createCustomer(); + CustomerState.setCustomer(anonymousCustomer); + setupCustomerForRuleProcessing(anonymousCustomer); + } catch (Exception ex) { + LOG.warn("Failed to create anonymous customer for GraphQL request", ex); + } + } + + 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) { From fe6ac6f418fe75f7bc5d53a9f0eac1421a9556a3 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Wed, 22 Apr 2026 22:52:03 +0000 Subject: [PATCH 5/7] Address Devin Review: propagate search errors to GraphQL exception resolver Co-Authored-By: Arjun Mishra --- .../graphql/resolvers/SearchQueryResolver.java | 17 ++++------------- 1 file changed, 4 insertions(+), 13 deletions(-) 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 index 17fd11f6d1..1454be036a 100644 --- 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 @@ -17,8 +17,6 @@ */ package org.broadleafcommerce.core.web.graphql.resolvers; -import org.apache.commons.logging.Log; -import org.apache.commons.logging.LogFactory; import org.broadleafcommerce.core.catalog.domain.Product; import org.broadleafcommerce.core.search.domain.SearchCriteria; import org.broadleafcommerce.core.search.domain.SearchResult; @@ -39,8 +37,6 @@ @Controller public class SearchQueryResolver { - protected static final Log LOG = LogFactory.getLog(SearchQueryResolver.class); - protected static final int DEFAULT_LIMIT = 50; protected static final int DEFAULT_OFFSET = 0; @@ -51,7 +47,7 @@ public class SearchQueryResolver { @QueryMapping public List search(@Argument String query, @Argument Integer limit, - @Argument Integer offset) { + @Argument Integer offset) throws Exception { int effectiveLimit = limit != null ? limit : DEFAULT_LIMIT; int effectiveOffset = offset != null ? offset : DEFAULT_OFFSET; @@ -62,15 +58,10 @@ public List search(@Argument String query, int page = effectiveLimit > 0 ? (effectiveOffset / effectiveLimit) + 1 : 1; criteria.setPage(page); - try { - SearchResult result = searchService.findSearchResults(criteria); - if (result == null || result.getProducts() == null) { - return Collections.emptyList(); - } - return result.getProducts(); - } catch (Exception ex) { - LOG.warn("GraphQL search failed for query='" + query + "'", ex); + SearchResult result = searchService.findSearchResults(criteria); + if (result == null || result.getProducts() == null) { return Collections.emptyList(); } + return result.getProducts(); } } From ea2e0a925ac8a3295f1e53f951e1da0f9e2e69fc Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Wed, 22 Apr 2026 22:59:42 +0000 Subject: [PATCH 6/7] Address Devin Review: guard null WebRequest and reject non-page-aligned search offsets Co-Authored-By: Arjun Mishra --- .../graphql/GraphQLContextInterceptor.java | 34 ++++++++++++++++--- .../resolvers/SearchQueryResolver.java | 16 +++++++-- 2 files changed, 44 insertions(+), 6 deletions(-) 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 index 877d27b4aa..d089b21d15 100644 --- 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 @@ -88,8 +88,7 @@ protected void populateCustomerContext(WebGraphQlRequest request) { } Customer customer = customerService.readCustomerById(Long.valueOf(customerId)); if (customer != null) { - CustomerState.setCustomer(customer); - setupCustomerForRuleProcessing(customer); + applyCustomer(customer); } } @@ -100,13 +99,40 @@ protected void ensureCustomerContext() { try { ensureBroadleafRequestContext(); Customer anonymousCustomer = customerService.createCustomer(); - CustomerState.setCustomer(anonymousCustomer); - setupCustomerForRuleProcessing(anonymousCustomer); + applyCustomer(anonymousCustomer); } catch (Exception ex) { LOG.warn("Failed to create anonymous customer for GraphQL request", ex); } } + /** + * 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; 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 index 1454be036a..aad61de7b7 100644 --- 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 @@ -51,11 +51,23 @@ public List search(@Argument String query, 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); - criteria.setStartIndex(effectiveOffset); - int page = effectiveLimit > 0 ? (effectiveOffset / effectiveLimit) + 1 : 1; + int page = (effectiveOffset / effectiveLimit) + 1; criteria.setPage(page); SearchResult result = searchService.findSearchResults(criteria); From 8caca98c7ee37a0579c0f9bb7bd4adc12c8f3d24 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Wed, 22 Apr 2026 23:05:33 +0000 Subject: [PATCH 7/7] Address Devin Review: check additionalProperties in ensureCustomerContext to avoid overwriting resolved customer Co-Authored-By: Arjun Mishra --- .../graphql/GraphQLContextInterceptor.java | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) 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 index d089b21d15..2f01c29c89 100644 --- 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 @@ -93,7 +93,7 @@ protected void populateCustomerContext(WebGraphQlRequest request) { } protected void ensureCustomerContext() { - if (CustomerState.getCustomer() != null) { + if (hasResolvedCustomer()) { return; } try { @@ -105,6 +105,23 @@ protected void ensureCustomerContext() { } } + /** + * 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