Broadleaf Microservices
  • v1.0.0-latest-prod

Adding a New External Offer Provider

When offers (also known as adjustments or discounts) are being applied to an order and its items, the Offer Service handles such operations via the Offer Engine Service. In line with this, Broadleaf is now providing first-class support for allowing third-party APIs to add and return external offers onto orders when they are being processed through the Offer Engine.

As a supplement to the tutorial for adding a new External Provider to a Composite service, this tutorial aims to teach you how to leverage the existing ExternalOfferProvider interface in the Offer Service, which aims to allow users to extend it to create a new Provider that will modify and return a given order to add external offers via a third-party API.

Throughout this tutorial, we will walk through implementing the existing ExternalOfferProvider, how to set up the new Provider’s properties, registering the new relevant Spring components, and how to test the new Provider.

Implementing the Existing ExternalOfferProvider

The existing ExternalOfferProvider interface defines two methods for modifying an order with external offers/adjustments:

  • provideItemOffers - Accepts an order and can return the order with additional external item-level offers applied onto the order items themselves

  • provideOrderOffers - Accepts an order and can return the order with additional external order-level offers applied onto the order itself

Details
package com.broadleafcommerce.promotion.offer.service.provider.external;

import org.springframework.lang.Nullable;

import com.broadleafcommerce.data.tracking.core.context.ContextInfo;
import com.broadleafcommerce.promotion.offer.client.web.context.discounts.ItemResponseDetail;
import com.broadleafcommerce.promotion.offer.web.context.EnhancedOrder;
import com.broadleafcommerce.promotion.offer.web.context.EnhancedOrderLineItem;
import com.broadleafcommerce.promotion.offer.web.context.info.OrderOfferAdjustment;

/**
 * Provider for interfacing with operations around offer related entities. Typically utilizes the
 * WebClient to make requests to an external REST API.
 *
 * @since Offer Service 3.1.0
 */
public interface ExternalOfferProvider {

    /**
     * Retrieves item-level offer data and modifies the {@link EnhancedOrder#orderLineItems order's
     * items} by adding external item offer data (in the form of {@link ItemResponseDetail}) to
     * {@link EnhancedOrderLineItem#externalOfferDetails}.
     *
     * @param enhancedOrder the {@link EnhancedOrder order} to populate with external offer data
     * @param contextInfo context information around the sandbox and multitenant state
     * @return the {@link EnhancedOrder order} populated with external item-level offer data
     */
    EnhancedOrder provideItemOffers(EnhancedOrder enhancedOrder, @Nullable ContextInfo contextInfo);

    /**
     * Retrieves order-level offer data and modifies the {@link EnhancedOrder order} by adding
     * external order offer data (in the form of {@link OrderOfferAdjustment}) to
     * {@link EnhancedOrder#externalOrdersAdjustments}.
     *
     * @param enhancedOrder the {@link EnhancedOrder order} to populate with external offer data
     * @param contextInfo context information around the sandbox and multitenant state
     * @return the {@link EnhancedOrder order} populated with external order-level offer data
     */
    EnhancedOrder provideOrderOffers(EnhancedOrder enhancedOrder,
            @Nullable ContextInfo contextInfo);
}

In order to implement the interface onto a functional provider class, the methods from the interface must be implemented, and the WebClient as well as the ObjectMapper have to be injected into the Provider class to properly execute the API request for your external API. This will be discussed in detail further down the tutorial under the Register Your Spring Components section.

Below is an example of what the implementation of the ExternalOfferProvider interface can look like.

package com.broadleafcommerce.promotion.offer.service.provider.external.offer;

import static com.broadleafcommerce.data.tracking.core.context.ContextInfoHandlerMethodArgumentResolver.CONTEXT_REQUEST_HEADER;
import static com.broadleafcommerce.data.tracking.core.context.ContextInfoHandlerMethodArgumentResolver.IGNORE_TRANSLATION_HEADER;
import static org.springframework.security.oauth2.client.web.reactive.function.client.ServletOAuth2AuthorizedClientExchangeFilterFunction.clientRegistrationId;

import org.apache.commons.lang3.StringUtils;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.http.HttpStatusCode;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.lang.Nullable;
import org.springframework.web.reactive.function.client.WebClient;
import org.springframework.web.reactive.function.client.WebClientResponseException;

import com.broadleafcommerce.common.error.ApiError;
import com.broadleafcommerce.data.tracking.core.context.ContextInfo;
import com.broadleafcommerce.data.tracking.core.exception.EntityMissingException;
import com.broadleafcommerce.data.tracking.core.preview.context.DefaultPreviewDateWebRequestResolver;
import com.broadleafcommerce.data.tracking.core.preview.context.DefaultPreviewTokenWebRequestResolver;
import com.broadleafcommerce.promotion.offer.service.exception.ProviderApiException;
import com.broadleafcommerce.promotion.offer.service.provider.external.ExternalOfferProvider;
import com.broadleafcommerce.promotion.offer.web.context.EnhancedOrder;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;

import java.util.Collections;
import java.util.Optional;
import java.util.function.Supplier;

import lombok.AccessLevel;
import lombok.Getter;
import lombok.RequiredArgsConstructor;
import reactor.core.publisher.Mono;

/**
 * Test implementation of the {@link ExternalOfferProvider}.
 */
@RequiredArgsConstructor
public class TestExternalOfferProvider implements ExternalOfferProvider {

    /**
     * The {@link ApiError#getType()} that indicates entity is not found.
     */
    public final static String ENTITY_NOT_FOUND = "ENTITY_NOT_FOUND";

    @Getter(AccessLevel.PROTECTED)
    private final WebClient webClient;

    @Getter(AccessLevel.PROTECTED)
    private final ObjectMapper objectMapper;

    private final ExternalOfferProviderProperties properties;

    @Override
    public EnhancedOrder provideItemOffers(EnhancedOrder enhancedOrder,
            ContextInfo contextInfo) {
        return executeRequest(() -> getWebClient()
                .post()
                .uri(properties.getUrl() + properties.getItemOffersUri())
                .header(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE)
                .headers(httpHeaders -> httpHeaders.putAll(getHeaders(contextInfo)))
                .attributes(clientRegistrationId(getServiceClient()))
                .bodyValue(enhancedOrder)
                .retrieve()
                .onStatus(HttpStatusCode::isError,
                        response -> response.createException().flatMap(
                                exception -> Mono
                                        .just(new ProviderApiException(exception))))
                .bodyToMono(EnhancedOrder.class)
                .blockOptional()
                .orElseThrow(() -> new EntityMissingException(String.format(
                        "the External Offer Service did not return an " +
                                "EnhancedOrder after attempt to apply item-level offers to order: %s",
                        enhancedOrder.getOrderNumber()))));
    }

    @Override
    public EnhancedOrder provideOrderOffers(EnhancedOrder enhancedOrder,
            ContextInfo contextInfo) {
        return executeRequest(() -> getWebClient()
                .post()
                .uri(properties.getUrl() + properties.getOrderOffersUri())
                .header(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE)
                .headers(httpHeaders -> httpHeaders.putAll(getHeaders(contextInfo)))
                .attributes(clientRegistrationId(getServiceClient()))
                .bodyValue(enhancedOrder)
                .retrieve()
                .onStatus(HttpStatusCode::isError,
                        response -> response.createException().flatMap(
                                exception -> Mono
                                        .just(new ProviderApiException(exception))))
                .bodyToMono(EnhancedOrder.class)
                .blockOptional()
                .orElseThrow(() -> new EntityMissingException(String.format(
                        "the External Offer Service did not return an " +
                                "EnhancedOrder after attempt to apply item-level offers to order: %s",
                        enhancedOrder.getOrderNumber()))));
    }

    /**
     * Builds the headers to be passed along with the request to the provider.
     *
     * @param contextInfo {@link ContextInfo} from the original request containing tenant and
     *        sandbox info
     *
     * @return The headers to be passed along with the request to the provider.
     */
    protected HttpHeaders getHeaders(@Nullable final ContextInfo contextInfo) {
        final HttpHeaders headers = new HttpHeaders();

        if (contextInfo == null) {
            return headers;
        }

        if (contextInfo.getLocale() != null) {
            headers.setAcceptLanguageAsLocales(Collections.singletonList(contextInfo.getLocale()));
        }
        headers.add(IGNORE_TRANSLATION_HEADER, String.valueOf(contextInfo.isIgnoreTranslation()));

        if (contextInfo.getPreviewToken() != null) {
            headers.add(DefaultPreviewTokenWebRequestResolver.DEFAULT_HEADER_NAME,
                    contextInfo.getPreviewToken().getToken());
        }
        if (contextInfo.getPreviewDate() != null) {
            headers.add(DefaultPreviewDateWebRequestResolver.DEFAULT_HEADER_NAME,
                    contextInfo.getPreviewDate().toString());
        }

        try {
            headers.add(CONTEXT_REQUEST_HEADER,
                    objectMapper.writeValueAsString(contextInfo.getContextRequest()));
        } catch (JsonProcessingException e) {
            throw new IllegalStateException("Unable to convert to JSON", e);
        }

        return headers;
    }

    /**
     * Executes a request with default Web Client error handling.
     *
     * @param request the request to execute
     * @param <T> the return type of the request operation
     * @return the value generated by the supplier
     */
    protected <T> T executeRequest(Supplier<T> request) {
        try {
            return request.get();
        } catch (WebClientResponseException.NotFound nfe) {
            throw buildNotFoundException(nfe);
        } catch (WebClientResponseException e) {
            throw new ProviderApiException(e);
        }
    }

    /**
     * Builds a not found exception that correlates to the given
     * {@link WebClientResponseException.NotFound} exception.
     *
     * If the exception is of type {@link #ENTITY_NOT_FOUND}, an {@link EntityMissingException} is
     * thrown. Otherwise it's wrapped in {@link ProviderApiException}.
     *
     * @param nfe the {@link WebClientResponseException.NotFound} to build the not found exception
     *        from
     * @return a not found exception that correlates to the given
     *         {@link WebClientResponseException.NotFound} exception
     */
    protected RuntimeException buildNotFoundException(WebClientResponseException.NotFound nfe) {
        try {
            ResponseEntity<ApiError> response = objectMapper
                    .readValue(nfe.getResponseBodyAsString(), ApiError.class)
                    .toResponseEntity();

            if (isEntityNotFound(response)) {
                return new EntityMissingException();
            } else {
                return new ProviderApiException(nfe);
            }
        } catch (JsonProcessingException ignored) {
            return new ProviderApiException(nfe);
        }
    }

    /**
     * Determines if the given {@link ResponseEntity} indicates entity not found.
     *
     * This is useful to distinguish a {@link HttpStatus#NOT_FOUND} response indicating entity
     * cannot be found from a response indicating the endpoint/url cannot be found.
     *
     * @param apiError the {@link ResponseEntity} to check against
     * @return true if the given {@link ResponseEntity} indicates entity not found
     */
    protected boolean isEntityNotFound(ResponseEntity<ApiError> apiError) {
        return Optional.ofNullable(apiError.getBody())
                .map(errorBody -> StringUtils.equals(ENTITY_NOT_FOUND, errorBody.getType()))
                .orElse(false);
    }

    protected String getServiceClient() {
        return properties.getServiceClient();
    }

}

The ExternalOfferProviderProperties attribute in this example serves as a class that holds the properties that identify the URI paths and other relevant Provider properties. It is completely optional, but it allows you to configure the Provider properties via the offer-defaults.yml file (or whatever your defined Spring Boot properties file for the service is) rather than hard-coding it into the Provider class itself.

package com.broadleafcommerce.promotion.offer.service.provider.external.offer;

import lombok.Getter;
import lombok.Setter;

@Getter
@Setter
@ConfigurationProperties("broadleaf.offer.externalofferprovider")
public class ExternalOfferProviderProperties {
    /**
     * The base url for an external offer service.
     */
    private String url;

    /**
     * The URI path for adding the external item-level offers to an order.
     */
    private String itemOffersUri;

    /**
     * The URI path for adding the external order-level offers to an order.
     */
    private String orderOffersUri;
    /**
     * The service client to use when calling external offer services. Default is "offerclient".
     */
    private String serviceClient = "offerclient";
}

Then, the property values are expected to be defined in the offer-defaults.yml file of the Offer Service resources.

broadleaf:
  offer:
    externalofferprovider:
      url: https://<URL of the external API>
      item-offers-uri: /item-offers
      order-offers-uri: /order-offers

Register Your Spring Components

Finally, we need to create a configuration class to register these components and overrides with Spring. Registering the newly created provider here will allow it to be injected into DefaultOfferEngineService.

package com.broadleafdemo.offer;

import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Primary;
import org.springframework.http.MediaType;
import org.springframework.http.codec.json.Jackson2JsonDecoder;
import org.springframework.http.codec.json.Jackson2JsonEncoder;
import org.springframework.security.oauth2.client.web.reactive.function.client.ServletOAuth2AuthorizedClientExchangeFilterFunction;
import org.springframework.web.reactive.function.client.ExchangeStrategies;
import org.springframework.web.reactive.function.client.WebClient;
import org.springframework.web.util.DefaultUriBuilderFactory;

import com.broadleafdemo.offer.service.provider.external.TestExternalOfferProvider;
import com.broadleafcommerce.promotion.offer.service.provider.external.ExternalOfferProvider;
import com.fasterxml.jackson.databind.ObjectMapper;

import java.util.function.Supplier;

@Configuration
@EnableConfigurationProperties(ExternalOfferProviderProperties.class)
public class DemoOfferServiceAutoConfiguration {

    @Bean
    @Primary
    public ExternalOfferProvider productEnrichmentProvider(
            @Qualifier("externalOfferServiceWebClient") WebClient externalOfferServiceWebClient,
            ObjectMapper mapper) {
        return new TestExternalOfferProvider(externalOfferServiceWebClient, mapper);
    }

    @Bean
    public WebClient externalOfferServiceWebClient(
            @Qualifier("oAuth2FilterFunctionSupplier") Supplier<ServletOAuth2AuthorizedClientExchangeFilterFunction> oauth2FilterSupplier,
            ObjectMapper objectMapper) {
        // TODO: build your own WebClient adequate for your External API
        ExchangeStrategies strategies = ExchangeStrategies
                .builder()
                .codecs(clientDefaultCodecsConfigurer -> {
                    clientDefaultCodecsConfigurer.defaultCodecs().jackson2JsonEncoder(
                            new Jackson2JsonEncoder(objectMapper, MediaType.APPLICATION_JSON));
                    clientDefaultCodecsConfigurer.defaultCodecs().jackson2JsonDecoder(
                            new Jackson2JsonDecoder(objectMapper, MediaType.APPLICATION_JSON));
                    clientDefaultCodecsConfigurer.defaultCodecs().maxInMemorySize(10 * 1024 * 1024);
                }).build();

        WebClient.Builder webClientBuilder = WebClient.builder();

        DefaultUriBuilderFactory uriBuilderFactory = new DefaultUriBuilderFactory();
        uriBuilderFactory.setEncodingMode(DefaultUriBuilderFactory.EncodingMode.NONE);

        return webClientBuilder
                .uriBuilderFactory(uriBuilderFactory)
                .exchangeStrategies(strategies)
                .apply(oauth2FilterSupplier.get().oauth2Configuration())
                .build();
    }
}

Flex Package spring.factories Note

If you are going to include the Offer Service .jar file inside a Flex Package composition, you will want to also make sure to define a spring.factories class allowing your Flex Package application to auto-register your auto-configuration class.

In order to do so, create a src/main/resources/META-INF/spring.factories file with the following contents:

org.springframework.boot.autoconfigure.EnableAutoConfiguration=\
  com.broadleafdemo.offer.DemoOfferServiceAutoConfiguration

Testing

Once you’ve implemented and deployed the above, you can test the new Provider two ways:

  • once you are certain that the third-party API is set up and connected to the Offer Service with external offers, via the Storefront, add items into the cart and observe if the returned cart has the expected item-level and/or order-level offers applied to it.

  • issue something similar to the following request to the Offer Engine endpoint to test and validate that the external offers are added to the order response payload. The request body of the cURL request can be modeled after the example request body provided in the /offer-engine/apply OpenAPI documentation.

curl -X 'GET' \
  'https://localhost:8446/api/offer/offer-engine/apply' \
  -H 'accept: application/json' \
  -H 'X-Context-Request: {   "applicationId": "2",   "tenantId": "5DF1363059675161A85F576D" }' \
  -d '{`EnhancedOrder` request body}'

Calling this will return a response similar the one found in the /offer-engine/apply OpenAPI documentation, where the offers applied to items and orders will reflect those returned by the external API, and the savings and subtotals reflect:

{
  "orderNumber": "string",
  "adjustedSubtotal": 32.45,
  "orderItemSavings": 2.84,
  "adjustedFulfillmentTotal": 7.13,
  "fulfillmentSavings": 1.47,
  "adjustedTotal": 39.58,
  "orderSavings": 0,
  "totalSavings": 4.31,
  "totalFutureCredits": 0,
  "orderItemResponses": [ <--- should reflect the item-level offers that were applied
    {
      "itemId": "string",
      "itemResponseDetails": [
        {
          "quantity": 0,
          "itemAdjustments": [
            {
              "offerRef": {
                "id": "string",
                "name": "string",
                "description": "string",
                "totalitarian": true,
                "combinable": true,
                "futureCredit": true,
                "cartLabel": "string",
                "prorationType": "string",
                "attributes": {
                  "additionalProp1": {}
                }
              },
              "offerCodeRef": "string",
              "adjustmentAmount": 0,
              "isFutureCredit": true,
              "codeUsed": "string",
              "qualifierDetails": [
                {
                  "offerId": "string",
                  "itemId": "string",
                  "quantityPerUsage": 0,
                  "offerUses": 0
                }
              ],
              "quantityPerUsage": 0,
              "offerUses": 0,
              "campaignTrackingId": "string",
              "appliedToSalePrice": true
            }
          ],
          "proratedItemAdjustments": [
            {
              "lineItem": {
                "lineNumber": "string",
                "type": "string"
              },
              "offer": {
                "id": "string",
                "name": "string",
                "description": "string",
                "totalitarian": true,
                "combinable": true,
                "futureCredit": true,
                "cartLabel": "string",
                "prorationType": "string",
                "attributes": {
                  "additionalProp1": {}
                }
              },
              "amount": 0,
              "quantity": 1,
              "codeUsed": "10OFF"
            }
          ],
          "appliedToSalePrice": true,
          "savings": 0,
          "futureCreditSavings": 0,
          "adjustedTotal": 0,
          "beginPeriod": 0,
          "endPeriod": 0
        }
      ],
      "proratedAdjustments": [
        {
          "lineItem": {
            "lineNumber": "string",
            "type": "string"
          },
          "offer": {
            "id": "string",
            "name": "string",
            "description": "string",
            "totalitarian": true,
            "combinable": true,
            "futureCredit": true,
            "cartLabel": "string",
            "prorationType": "string",
            "attributes": {
              "additionalProp1": {}
            }
          },
          "amount": 0,
          "quantity": 1,
          "codeUsed": "10OFF"
        }
      ],
      "basePricePerItem": 32.45,
      "quantity": 0,
      "savings": 2.84,
      "adjustedTotal": 29.61,
      "futureCreditSavings": 0
    }
  ],
  "fulfillmentGroupResponses": [
    ...
  ],
  "adjustments": [ <--- should reflect the order-level offers that were applied
    {
      "offerRef": {
        "id": "string",
        "name": "string",
        "description": "string",
        "totalitarian": true,
        "combinable": true,
        "futureCredit": true,
        "cartLabel": "string",
        "prorationType": "string",
        "attributes": {
          "additionalProp1": {}
        }
      },
      "offerCodeRef": "string",
      "adjustmentAmount": 0,
      "isFutureCredit": true,
      "codeUsed": "string",
      "qualifierDetails": [
        {
          "offerId": "string",
          "itemId": "string",
          "quantityPerUsage": 0,
          "offerUses": 0
        }
      ],
      "quantityPerUsage": 0,
      "offerUses": 0,
      "campaignTrackingId": "string"
    }
  ],
  "freeGiftItems": [
    {
      "offerRef": {
        "id": "string",
        "name": "string",
        "description": "string",
        "totalitarian": true,
        "combinable": true,
        "futureCredit": true,
        "cartLabel": "string",
        "prorationType": "string",
        "attributes": {
          "additionalProp1": {}
        }
      },
      "productId": "string",
      "quantity": 0,
      "adjustment": {
        "offerRef": {
          "id": "string",
          "name": "string",
          "description": "string",
          "totalitarian": true,
          "combinable": true,
          "futureCredit": true,
          "cartLabel": "string",
          "prorationType": "string",
          "attributes": {
            "additionalProp1": {}
          }
        },
        "offerCodeRef": "string",
        "adjustmentAmount": 0,
        "isFutureCredit": true,
        "codeUsed": "string",
        "qualifierDetails": [
          {
            "offerId": "string",
            "itemId": "string",
            "quantityPerUsage": 0,
            "offerUses": 0
          }
        ],
        "quantityPerUsage": 0,
        "offerUses": 0,
        "campaignTrackingId": "string"
      }
    }
  ],
  "vouchers": [
    {
      "offerId": "string",
      "voucherCampaign": "string"
    }
  ],
  "codeResponseMap": { <--- messages for offers that could not be applied
    "additionalProp1": {
      "code": "string",
      "potentialSavings": 0,
      "notAppliedReasonMessage": "string",
      "notAppliedReasonResponseCode": "string"
    }
  }
}