diff --git a/core/broadleaf-framework-web/pom.xml b/core/broadleaf-framework-web/pom.xml index c732740dce..0c7dcfa040 100644 --- a/core/broadleaf-framework-web/pom.xml +++ b/core/broadleaf-framework-web/pom.xml @@ -94,5 +94,13 @@ org.springframework.security spring-security-oauth2-client + + org.springframework.boot + spring-boot-starter-graphql + + + com.graphql-java + graphql-java-extended-scalars + diff --git a/core/broadleaf-framework-web/src/main/java/org/broadleafcommerce/core/web/graphql/GraphQLConfig.java b/core/broadleaf-framework-web/src/main/java/org/broadleafcommerce/core/web/graphql/GraphQLConfig.java new file mode 100644 index 0000000000..7069b09085 --- /dev/null +++ b/core/broadleaf-framework-web/src/main/java/org/broadleafcommerce/core/web/graphql/GraphQLConfig.java @@ -0,0 +1,38 @@ +/*- + * #%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.scalars.ExtendedScalars; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.graphql.execution.RuntimeWiringConfigurer; + +/** + * Registers Broadleaf-specific GraphQL runtime wiring, including the + * {@code BigDecimal} scalar used for monetary amounts. The scalar preserves + * the full precision of {@link java.math.BigDecimal}; serializing via + * {@code GraphQLFloat} would silently degrade values to IEEE 754 doubles. + */ +@Configuration +public class GraphQLConfig { + + @Bean + public RuntimeWiringConfigurer runtimeWiringConfigurer() { + return wiringBuilder -> wiringBuilder.scalar(ExtendedScalars.GraphQLBigDecimal); + } +} 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..dc97093ed5 --- /dev/null +++ b/core/broadleaf-framework-web/src/main/java/org/broadleafcommerce/core/web/graphql/GraphQLContextInterceptor.java @@ -0,0 +1,185 @@ +/*- + * #%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.core.order.domain.Order; +import org.broadleafcommerce.core.order.service.OrderService; +import org.broadleafcommerce.core.web.order.CartState; +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.RequestAttributes; +import org.springframework.web.context.request.RequestContextHolder; +import org.springframework.web.context.request.ServletRequestAttributes; +import org.springframework.web.context.request.ServletWebRequest; + +import java.util.HashMap; +import java.util.Map; + +import jakarta.servlet.http.HttpServletRequest; +import reactor.core.publisher.Mono; + +/** + * A {@link WebGraphQlInterceptor} that establishes the Broadleaf customer and cart + * context for GraphQL requests. The customer identity is resolved following the same + * precedence used by {@code RestApiCustomerStateFilter}: request attribute first, then + * request parameter, then request header. If no customer can be resolved, an anonymous + * customer is created. Once the customer is established, the active cart is populated + * into {@link CartState} for downstream resolvers. + */ +@Component +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); + + protected final CustomerService customerService; + protected final OrderService orderService; + + @Autowired + public GraphQLContextInterceptor( + @Qualifier("blCustomerService") CustomerService customerService, + @Qualifier("blOrderService") OrderService orderService + ) { + this.customerService = customerService; + this.orderService = orderService; + } + + @Override + public Mono intercept(WebGraphQlRequest request, Chain chain) { + HttpServletRequest httpRequest = extractHttpRequest(request); + if (httpRequest != null) { + populateCustomerAndCart(httpRequest); + } + return chain.next(request); + } + + protected HttpServletRequest extractHttpRequest(WebGraphQlRequest request) { + RequestAttributes requestAttributes = RequestContextHolder.getRequestAttributes(); + if (requestAttributes instanceof ServletRequestAttributes servletRequestAttributes) { + return servletRequestAttributes.getRequest(); + } + return null; + } + + protected void populateCustomerAndCart(HttpServletRequest request) { + Customer customer = resolveCustomer(request); + if (customer == null) { + customer = customerService.createCustomer(); + CustomerState.setCustomer(customer); + setupCustomerForRuleProcessing(customer, request); + } + populateCart(customer); + } + + protected Customer resolveCustomer(HttpServletRequest request) { + String customerId = readCustomerId(request); + + if (customerId != null && customerId.trim().length() > 0) { + Long parsedId = parseCustomerId(customerId); + if (parsedId != null) { + Customer customer = customerService.readCustomerById(parsedId); + if (customer != null) { + ensureWebRequest(request); + CustomerState.setCustomer(customer); + setupCustomerForRuleProcessing(customer, request); + return customer; + } + } else { + LOG.warn(String.format("The customer id passed in '%s' was not a valid long", StringUtil.sanitize(customerId))); + } + } else if (LOG.isDebugEnabled()) { + LOG.debug("No customer ID was found for the GraphQL request. In order to look up a customer for the request" + + " send a request parameter or request header for the '" + CUSTOMER_ID_ATTRIBUTE + "' attribute"); + } + + Customer existingCustomer = CustomerState.getCustomer(); + if (existingCustomer != null) { + return existingCustomer; + } + + ensureWebRequest(request); + return null; + } + + protected Long parseCustomerId(String customerId) { + if (!NumberUtils.isDigits(customerId)) { + return null; + } + try { + return Long.valueOf(customerId); + } catch (NumberFormatException ex) { + return null; + } + } + + protected String readCustomerId(HttpServletRequest request) { + String customerId = null; + if (request.getAttribute(CUSTOMER_ID_ATTRIBUTE) != null) { + customerId = String.valueOf(request.getAttribute(CUSTOMER_ID_ATTRIBUTE)); + } + if (customerId == null) { + customerId = request.getParameter(CUSTOMER_ID_ATTRIBUTE); + } + if (customerId == null) { + customerId = request.getHeader(CUSTOMER_ID_ATTRIBUTE); + } + return customerId; + } + + protected void ensureWebRequest(HttpServletRequest request) { + BroadleafRequestContext ctx = BroadleafRequestContext.getBroadleafRequestContext(); + if (ctx != null && ctx.getWebRequest() == null) { + ctx.setWebRequest(new ServletWebRequest(request)); + } + } + + protected void populateCart(Customer customer) { + if (customer == null) { + return; + } + Order cart = orderService.findCartForCustomer(customer); + if (cart != null) { + CartState.setCart(cart); + } + } + + protected void setupCustomerForRuleProcessing(Customer customer, HttpServletRequest request) { + @SuppressWarnings("unchecked") + Map ruleMap = (Map) request.getAttribute(BLC_RULE_MAP_PARAM); + if (ruleMap == null) { + ruleMap = new HashMap<>(); + } + ruleMap.put("customer", customer); + request.setAttribute(BLC_RULE_MAP_PARAM, ruleMap); + } + +} 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..d313216f55 --- /dev/null +++ b/core/broadleaf-framework-web/src/main/java/org/broadleafcommerce/core/web/graphql/GraphQLExceptionResolver.java @@ -0,0 +1,92 @@ +/*- + * #%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.ErrorClassification; +import graphql.ErrorType; +import graphql.GraphQLError; +import graphql.GraphqlErrorBuilder; +import graphql.schema.DataFetchingEnvironment; +import org.broadleafcommerce.core.checkout.service.exception.CheckoutException; +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.stereotype.Component; + +import java.util.Map; + +/** + * Maps Broadleaf domain exceptions to structured GraphQL errors with stable + * {@code code} extensions so clients can branch on a known error taxonomy. + */ +@Component +public class GraphQLExceptionResolver extends DataFetcherExceptionResolverAdapter { + + public static final String CODE_ADD_TO_CART = "ADD_TO_CART_ERROR"; + public static final String CODE_PRICING = "PRICING_ERROR"; + public static final String CODE_OFFER_MAX_USE_EXCEEDED = "OFFER_MAX_USE_EXCEEDED"; + public static final String CODE_ILLEGAL_CART_OPERATION = "ILLEGAL_CART_OPERATION"; + public static final String CODE_CHECKOUT = "CHECKOUT_ERROR"; + public static final String CODE_REMOVE_FROM_CART = "REMOVE_FROM_CART_ERROR"; + public static final String CODE_UPDATE_CART = "UPDATE_CART_ERROR"; + + @Override + protected GraphQLError resolveToSingleError(Throwable ex, DataFetchingEnvironment env) { + if (ex instanceof AddToCartException) { + return buildError(ex, env, ErrorType.ValidationError, CODE_ADD_TO_CART); + } + if (ex instanceof RemoveFromCartException) { + return buildError(ex, env, ErrorType.ValidationError, CODE_REMOVE_FROM_CART); + } + if (ex instanceof UpdateCartException) { + return buildError(ex, env, ErrorType.ValidationError, CODE_UPDATE_CART); + } + if (ex instanceof IllegalCartOperationException) { + return buildError(ex, env, ErrorType.ValidationError, CODE_ILLEGAL_CART_OPERATION); + } + if (ex instanceof OfferMaxUseExceededException) { + return buildError(ex, env, ErrorType.ValidationError, CODE_OFFER_MAX_USE_EXCEEDED); + } + if (ex instanceof CheckoutException) { + return buildError(ex, env, ErrorType.ValidationError, CODE_CHECKOUT); + } + if (ex instanceof PricingException) { + return buildError(ex, env, ErrorType.DataFetchingException, CODE_PRICING); + } + return null; + } + + protected GraphQLError buildError( + Throwable ex, + DataFetchingEnvironment env, + ErrorClassification classification, + String code + ) { + String message = ex.getMessage() != null ? ex.getMessage() : ex.getClass().getSimpleName(); + return GraphqlErrorBuilder.newError(env) + .message(message) + .errorType(classification) + .extensions(Map.of("code", code)) + .build(); + } + +} diff --git a/core/broadleaf-framework-web/src/main/resources/config/bc/web/common.properties b/core/broadleaf-framework-web/src/main/resources/config/bc/web/common.properties index f98baafc07..4770c1d49c 100644 --- a/core/broadleaf-framework-web/src/main/resources/config/bc/web/common.properties +++ b/core/broadleaf-framework-web/src/main/resources/config/bc/web/common.properties @@ -40,3 +40,10 @@ googleAnalytics4.tagIdForProperty= # This approach can be more efficient for large catalogs and more easily support dynamic URL building allowProductResolutionUsingIdParam=false allowCategoryResolutionUsingIdParam=false + +# GraphQL endpoint configuration. Schema files are loaded from classpath:graphql/ +# and GraphiQL is enabled by default; override in a consuming application's +# common.properties to disable for production environments. +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..a7b0e6b4e1 --- /dev/null +++ b/core/broadleaf-framework-web/src/main/resources/graphql/schema.graphqls @@ -0,0 +1,258 @@ +type Query { + # Catalog + product(id: ID!): Product + productByExternalId(externalId: String!): Product + productByUri(uri: String!): Product + products(name: String, limit: Int = 25, offset: Int = 0): [Product!]! + category(id: ID!): Category + categoryByUri(uri: String!): Category + categories(name: String, limit: Int = 25, offset: Int = 0): [Category!]! + sku(id: ID!): Sku + skuByUpc(upc: String!): Sku + search(query: String!, limit: Int = 25, offset: Int = 0): [Product!]! + + # Cart & Orders + cart: Order + order(id: ID!): Order + orderByNumber(orderNumber: String!): Order + orderHistory(status: OrderStatus): [Order!]! + + # Account + customer: Customer + customerAddresses: [CustomerAddress!]! +} + +type Mutation { + # Cart + addToCart(input: AddToCartInput!): Order! + updateCartItemQuantity(orderItemId: ID!, quantity: Int!): Order! + removeFromCart(orderItemId: ID!): Order! + applyPromoCode(code: String!): PromoCodeResult! + removePromoCode(offerCodeId: ID!): Order! + + # Checkout + saveShippingInfo(input: ShippingInfoInput!): Order! + saveBillingInfo(input: BillingInfoInput!): Order! + saveOrderEmail(email: String!): Order! + processCheckout(orderId: ID!): CheckoutResult! + + # Account + updateAccount(input: UpdateAccountInput!): Customer! + changePassword(input: ChangePasswordInput!): Boolean! + addCustomerAddress(input: CustomerAddressInput!): CustomerAddress! + updateCustomerAddress(input: CustomerAddressInput!): CustomerAddress! + removeCustomerAddress(id: ID!): Boolean! +} + +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 + description: String + longDescription: String + url: String + activeSubCategories(limit: Int = 25, offset: Int = 0): [Category!]! + products(limit: Int = 25, offset: Int = 0): [Product!]! +} + +type Sku { + id: ID! + name: String + description: String + salePrice: Money + retailPrice: Money + upc: String + quantityAvailable: Int + available: Boolean +} + +type Money { + amount: BigDecimal + currency: String +} + +type Order { + id: ID! + orderNumber: String + status: OrderStatus + subTotal: Money + total: Money + totalTax: Money + totalShipping: Money + orderItems: [OrderItem!]! + fulfillmentGroups: [FulfillmentGroup!]! + payments: [OrderPayment!]! + emailAddress: String + customer: Customer + addedOfferCodes: [OfferCode!]! + itemCount: Int +} + +type OrderItem { + id: ID! + name: String + quantity: Int + price: Money + salePrice: Money + retailPrice: Money + totalPrice: Money + product: Product + sku: Sku +} + +type FulfillmentGroup { + id: ID! + address: Address + fulfillmentOption: FulfillmentOption + fulfillmentPrice: Money + items: [FulfillmentGroupItem!]! +} + +type FulfillmentGroupItem { + id: ID! + orderItem: OrderItem + quantity: Int +} + +type FulfillmentOption { + id: ID! + name: String + description: String + price: Money +} + +type OrderPayment { + id: ID! + type: String + amount: Money + billingAddress: Address +} + +type OfferCode { + id: ID! + offerCode: String +} + +type Customer { + id: ID! + firstName: String + lastName: String + emailAddress: String + username: String +} + +type CustomerAddress { + id: ID! + addressName: String + address: Address +} + +type Address { + id: ID! + addressLine1: String + addressLine2: String + city: String + stateProvinceRegion: String + postalCode: String + country: String + phonePrimary: String +} + +type ProductOption { + id: ID! + label: String + type: String + required: Boolean + values: [ProductOptionValue!]! +} + +type ProductOptionValue { + id: ID! + attributeValue: String + displayOrder: Int + priceAdjustment: Money +} + +type PromoCodeResult { + order: Order! + promoAdded: Boolean! + exception: String +} + +type CheckoutResult { + order: Order! + success: Boolean! + errorMessage: String +} + +enum OrderStatus { + IN_PROCESS + SUBMITTED + CANCELLED +} + +scalar BigDecimal + +input AddToCartInput { + productId: ID! + skuId: ID + quantity: Int! + itemAttributes: [ItemAttributeInput!] +} + +input ItemAttributeInput { + name: String! + value: String! +} + +input ShippingInfoInput { + address: AddressInput! + fulfillmentOptionId: ID! + personalMessage: String + deliveryMessage: String +} + +input BillingInfoInput { + address: AddressInput! + paymentType: String +} + +input AddressInput { + addressLine1: String! + addressLine2: String + city: String! + stateProvinceRegion: String! + postalCode: String! + country: String! + phonePrimary: String +} + +input UpdateAccountInput { + firstName: String + lastName: String + emailAddress: String +} + +input ChangePasswordInput { + currentPassword: String! + newPassword: String! + newPasswordConfirm: String! +} + +input CustomerAddressInput { + id: ID + addressName: String! + address: AddressInput! +} diff --git a/pom.xml b/pom.xml index 6836d3b269..dd28511854 100644 --- a/pom.xml +++ b/pom.xml @@ -47,6 +47,8 @@ 6.5.9 3.3.6 3.5.13 + 1.3.4 + 22.0 2.21.2 1.18.44 1.5.32 @@ -1202,6 +1204,28 @@ spring-boot-autoconfigure ${spring.boot.version} + + org.springframework.boot + spring-boot-starter-graphql + ${spring.boot.version} + + + com.graphql-java + graphql-java-extended-scalars + ${graphql-java-extended-scalars.version} + + + com.graphql-java + graphql-java + + + + + + org.reactivestreams + reactive-streams + 1.0.4 + org.springframework.security spring-security-oauth2-client