Custom Episerver Commerce Product/Variant router

Mattias Olsson 22.12.2017 14:26:46

The router works like this:

  • When the virtual path for a variant is created, it tries to find the first parent product:
    1. If a product is found, the router will start by fetching the virtual path for the product.
    2. If the product only has 1 variant, the variant will get the same virtual path as the product.
    3. If the product has multiple variants, the current variant route segment is added to the end of the product's virtual path.
  • When a product is routed, the next URL segment is matched against the product's variants, and if a match is found it will route to that variant instead. If no next URL segment exists it will route to the first variant (if present).

The code

First of all I have a couple of extension methods to make the code a bit neater. The first is for fetching the first parent product content for a variant and the second is for fetching all variants for a product.

public static T GetFirstParentProductContent<T>(
    this EntryContentBase variant,
    IContentLoader contentLoader = null,
    IRelationRepository relationRepository = null) where T: ProductContent
{
    contentLoader = contentLoader ?? ServiceLocator.Current.GetInstance<IContentLoader>();
    relationRepository = relationRepository ?? 
        ServiceLocator.Current.GetInstance<IRelationRepository>();
    var parentProductLink = variant.GetParentProducts(relationRepository).FirstOrDefault();
    T product;

    if (contentLoader.TryGet(parentProductLink, out product))
    {
        return product;
    }

    return null;
}

public static IEnumerable<TVariant> GetVariantsContent<TVariant>(
    this ProductContent product,
    IContentLoader contentLoader = null,
    IRelationRepository relationRepository = null) where TVariant : VariationContent
{
    var locator = ServiceLocator.Current;
    contentLoader = contentLoader ?? locator.GetInstance<IContentLoader>();
    relationRepository = relationRepository ?? locator.GetInstance<IRelationRepository>();
    var variantLinks = product.GetVariants(relationRepository);
    return contentLoader.GetItems(variantLinks, product.Language).OfType<TVariant>();
}

Partial router code

public class CustomHierarchicalCatalogPartialRouter : HierarchicalCatalogPartialRouter
{
    private readonly IContentLoader _contentLoader;
    private readonly IRelationRepository _relationRepository;

    public CustomHierarchicalCatalogPartialRouter(
        Func<ContentReference> routeStartingPoint,
        CatalogContentBase commerceRoot,
        bool enableOutgoingSeoUri,
        IContentLoader contentLoader,
        IRelationRepository relationRepository) : base(
            routeStartingPoint, 
            commerceRoot, 
            enableOutgoingSeoUri)
    {
        _contentLoader = contentLoader;
        _relationRepository = relationRepository;
    }

    public override PartialRouteData GetPartialVirtualPath(
        CatalogContentBase content,
        string language,
        RouteValueDictionary routeValues,
        RequestContext requestContext)
    {
        var routeData = base.GetPartialVirtualPath(content, language, routeValues, requestContext);

        if (routeData == null)
        {
            return null;
        }

        var variant = content as VariationContent;

        if (variant != null)
        {
            return GetVariantPartialVirtualPath(
                variant,
                routeData,
                language,
                routeValues,
                requestContext);
        }

        return routeData;
    }

    protected virtual PartialRouteData GetVariantPartialVirtualPath(
        VariationContent variant,
        PartialRouteData routeData,
        string language,
        RouteValueDictionary routeValues,
        RequestContext requestContext
    )
    {
        var product = variant.GetFirstParentProductContent<ProductContent>(
            this._contentLoader,
            this._relationRepository);

        if (product != null)
        {
            var productRouteData = base.GetPartialVirtualPath(
                product,
                language,
                routeValues,
                requestContext);

            if (productRouteData != null)
            { 
                // If product has more than 1 variant, append variant route segment to URL.
                if (product.GetVariants(this._relationRepository).Count() > 1)
                {
                    routeData.PartialVirtualPath = 
                        $"{productRouteData.PartialVirtualPath}/{variant.RouteSegment}/";
                }
                else
                {
                    routeData.PartialVirtualPath = productRouteData.PartialVirtualPath;
                }
            }
        }

        return routeData;
    }

    public override object RoutePartial(PageData content, SegmentContext segmentContext)
    {
        var routed = base.RoutePartial(content, segmentContext);
        var product = routed as ProductContent;

        if (product == null)
        {
            return routed;
        }

        SegmentPair segment = segmentContext.GetNextValue(segmentContext.RemainingPath);
        IEnumerable<VariationContent> variants = product.GetVariantsContent<VariationContent>(
            this._contentLoader,
            this._relationRepository);

        var variant = !string.IsNullOrWhiteSpace(segment.Next)
            ? variants?.FirstOrDefault(v => v.RouteSegment.Equals(segment.Next))
            : variants?.FirstOrDefault();

        // If a variant is found for the product, route to that variant instead.
        if (variant != null)
        {
            segmentContext.RemainingPath = segment.Remaining;
            segmentContext.SetCustomRouteData(ProductRoutingConstants.CurrentProductKey, routed);
            segmentContext.RoutedContentLink = variant.ContentLink;
            segmentContext.RoutedObject = variant;

            return variant;
        }

        return routed;
    }
}

If you don't want to route to the first variant for a product, you can easily modify the RoutePartial method so it looks like this:

public override object RoutePartial(PageData content, SegmentContext segmentContext)
{
    var routed = base.RoutePartial(content, segmentContext);
    var product = routed as ProductContent;

    if (product == null)
    {
        return routed;
    }

    SegmentPair segment = segmentContext.GetNextValue(segmentContext.RemainingPath);

    if (!string.IsNullOrWhiteSpace(segment.Next))
    {
        IEnumerable<VariationContent> variants = product.GetVariantsContent<VariationContent>(
            this._contentLoader, 
            this._relationRepository);

        var variant = variants?.FirstOrDefault(v => v.RouteSegment.Equals(segment.Next));

        // If a variant is found for the product, route to that variant instead.
        if (variant != null)
        {
            segmentContext.RemainingPath = segment.Remaining;
            segmentContext.SetCustomRouteData(ProductRoutingConstants.CurrentProductKey, routed);
            segmentContext.RoutedContentLink = variant.ContentLink;
            segmentContext.RoutedObject = variant;

            return variant;
        }
    }

    return routed;
}

Replace default hierarchical router

To get my custom router working I have to make sure that the default hierarchical router is not registered, by not calling CatalogRouteHelper.MapDefaultHierarchialRouter(RouteTable.Routes, false). Instead, I register my custom router in an initialization module:

[InitializableModule]
[ModuleDependency(typeof(EPiServer.Commerce.Initialization.InitializationModule))]
public class RoutingInitialization : IInitializableModule
{
    private static bool _initialized;

    public void Initialize(InitializationEngine context)
    {
        if (_initialized)
        {
            return;
        }

        var locator = context.Locate.Advanced;
        // This will pick the first catalog, and strip it from all urls (in and out)
        var contentLoader = locator.GetInstance<IContentLoader>();
        var referenceConverter = locator.GetInstance<ReferenceConverter>();
        var commerceRootLink = referenceConverter.GetRootLink();

        var catalogs = contentLoader
            .GetChildren<CatalogContent>(commerceRootLink);
        var commerceRoot = contentLoader.Get<CatalogContentBase>(commerceRootLink);

        var partialRouter = new CustomHierarchicalCatalogPartialRouter(
            () => ContentReference.IsNullOrEmpty(SiteDefinition.Current.StartPage)
                ? SiteDefinition.Current.RootPage
                : SiteDefinition.Current.StartPage,
            commerceRoot,
            false,
            contentLoader,
            locator.GetInstance<IRelationRepository>());

            RouteTable.Routes.RegisterPartialRouter(partialRouter);
        }

        _initialized = true;
    }

    public void Uninitialize(InitializationEngine context)
    {
    }
}