Country VisitorGroup routing in URL

domain/visitorgroup/language/ (domain.com/nl/nl/)

Every once in a while a client mentions the request for their global website to have country specific content based on a country segment in the URL. Resulting in specific URLs like domain.com/nl/nl/ for Dutch content in Dutch, and domain.com/be/nl/ for Belgian content in Dutch.

Most times we talk the client into using the region parts in cultures. So use the en neutral culture for global English, the culture nl-NL for Dutch content in Dutch and the nl-BE culture for Belgian content in Dutch. However sometimes, using this approach we run into problems when a culture does not exist. Say we want the Dutch content to be available in German too, the de-NL culture does not exist.

This got me thinking about adding an URL segment to the URL for a specific country, where the country is represented by an episerver visitor group.

With the help of this blog explaining custom routing for Episerver I created my custom segment and a custom visitor group.

    public class VisitorGroupSegment : SegmentBase
    {
        public VisitorGroupSegment(string name) : base(name)
        {
        }

        public override bool RouteDataMatch(SegmentContext context)
        {
            SegmentPair nextValue = context.GetNextValue(context.RemainingPath);

            // For the first fragment, check if it is a 'visitor group' segment
            if (context.LastConsumedFragment is null && IsVisitorGroupSegment(nextValue.Next))
            {
                context.RemainingPath = nextValue.Remaining;

                // Data Token to be used by VisitorGroup Criterion
                context.RouteData.DataTokens.Add("visitorgroup", nextValue.Next);

                return true;
            }

            return false;
        }

        private static bool IsVisitorGroupSegment(string segment)
        {
            var visitorGroupRepository = ServiceLocator.Current.GetInstance<IVisitorGroupRepository>();
            foreach (var vg in visitorGroupRepository.List())
            {
                foreach (var criterion in vg.Criteria)
                {
                    if (criterion.Model is RouteVisitorGroupSettings model &&
                        model.RouteSegment.Equals(segment, StringComparison.InvariantCultureIgnoreCase))
                    {
                        return true;
                    }
                }
            }

            return false;
        }

        public override string GetVirtualPathSegment(RequestContext requestContext, RouteValueDictionary values)
        {
            if (GetContextMode(requestContext.HttpContext, requestContext.RouteData)
                != ContextMode.Default)
            {
                return null;
            }

            return GetRoutingVisitorGroupByCurrentUser(new HttpContextWrapper(HttpContext.Current));
        }

        private static ContextMode GetContextMode(HttpContextBase httpContext, RouteData routeData)
        {
            var contextModeKey = "contextmode";

            if (routeData.DataTokens.ContainsKey(contextModeKey))
            {
                return (ContextMode)routeData.DataTokens[contextModeKey];
            }

            if (httpContext?.Request == null)
            {
                return ContextMode.Default;
            }

            if (!PageEditing.PageIsInEditMode)
            {
                return ContextMode.Default;
            }

            return ContextMode.Edit;
        }


        private static string GetRoutingVisitorGroupByCurrentUser(HttpContextBase httpContext)
        {
            var visitorGroupRepository = ServiceLocator.Current.GetInstance<IVisitorGroupRepository>();
            var visitorGroupRoleRepository = ServiceLocator.Current.GetInstance<IVisitorGroupRoleRepository>();

            var user = httpContext.User;

            // Check all visitor groups and check if one is active, if one is active return the segment.
            var visitorGroups = visitorGroupRepository.List();
            foreach (var vg in visitorGroups)
            {
                foreach (var criterion in vg.Criteria)
                {
                    if (criterion.Model is RouteVisitorGroupSettings model && visitorGroupRoleRepository.TryGetRole(vg.Name, out var virtualRoleObject))
                    {
                        if (virtualRoleObject.IsMatch(user, httpContext))
                        {
                            return model.RouteSegment;
                        }
                    }
                }
            }
            return null;
        }
    }

The custom visitor group:

    public class RouteVisitorGroupSettings : CriterionModelBase
    {
        [Required]
        public string RouteSegment { get; set; }

        public override ICriterionModel Copy()
        {
            return ShallowCopy();
        }
    }

    [VisitorGroupCriterion(
        Category = "Technical",
        DisplayName = "VisitorGroup Routing",
        Description = "")]
    public class RouteVisitorGroupCriterion : CriterionBase
    {
        public override bool IsMatch(IPrincipal principal, HttpContextBase httpContext)
        {
            // Check for DataToken added by RouteSegment.
            var routeDataTokens = httpContext.Request.RequestContext.RouteData.DataTokens;

            return routeDataTokens.ContainsKey("visitorgroup")
                   && ((string)routeDataTokens["visitorgroup"]).Equals(Model.RouteSegment, StringComparison.InvariantCultureIgnoreCase);
        }
    }

Use an initializable module to make this work:

    [InitializableModule]
    [ModuleDependency(typeof(EPiServer.Web.InitializationModule))]
    public class RouteEventInitializationModule : IInitializableModule
    {
        public void Initialize(InitializationEngine context)
        {
            var segment = new VisitorGroupSegment("visitorgroup");

            var routingParameters = new MapContentRouteParameters()
            {
                SegmentMappings = new Dictionary<string, ISegment>()
            };

            routingParameters.SegmentMappings.Add("visitorgroup", segment);

            RouteTable.Routes.MapContentRoute(
                name: "visitorgroups",
                url: "{visitorgroup}/{language}/{node}/{partial}/{action}",
                defaults: new { action = "index" },
                parameters: routingParameters);
        }

        public void Uninitialize(InitializationEngine context)
        {
        }
    }

Now by adding this to a default Alloy site and creating two vistorgroups, one with segment ‘nl’ for Netherlands called 'NLD', and one with segment ‘en’ for United Kingdom called 'ENG'. I can display different content using personalization by visitor groups.

http://localhost:23529/ http://localhost:23529/en
http://localhost:23529/en/en
http://localhost:23529/nl
http://localhost:23529/nl/en

This is just a simple proof of concept, all seems to be working correctly, but I would not copy paste this into a production environment.

Mark Prins

Mark Prins

Senior Microsoft .Net / Optimizely Developer @ Arlanet (part of Conclusion)