Remko Jantzen
Jun 29, 2021
  2921
(1 votes)

Backend: Enabling .Net MVC parity in a SPA - Into Foundation Spa React series, Part 2

Continuing the "Into Foundation Spa React Series", the first stop we make is the enabling of feature parity with MVC CMS and all editor productivity tooling that comes with that. This post focuses on the backend, so we won't be looking at any frontend capabilities. Some of the steps here are also part of the documentation, however, I've elected to provide a holistic overview of all changes in one place.

NOTE: This solution was created before the Content Management API was released, which is a great add-on to the headless capabilities of the CMS. Yet, in the scope of this endeavor: we will not be using direct updates from the frontend into the CMS, but rely on the built-in capability of the CMS to manage and store content. So we will be extending the Content Delivery API where needed and not use the Content Management API.

First and foremost, let's start by listing out the capabilities from the CMS we want to enable:

  • On-page editing of any version
  • Version comparisons
  • Projects
  • Visitor group preview
  • Advanced Reviews
  • Common drafts preview mode
  • Hosting business logic

Next to these CMS capabilities, each content type can have its own logic in the form of a controller. Whether or not this is a "good thing", considering a headless approach, most of the developers working with the CMS will be accustomed to putting some logic into the controller in order to prepare the data for the view. For example, the execution of queries to Search & Navigation. Nevertheless, within a headless world, it is undesirable to move this logic (e.g. Constructing a search query from content properties), from the backend to the frontend, as that will require that logic to be managed on every head.

So we'll be adding the capability to execute any public controller method to the Content Delivery API in order to retain this development pattern and make migrations from a traditional MVC implementation to headless a lot easier. At the same time, we'll be doing this in a way that feels "natural" within the REST structure of the Content Delivery API.

Context mode

The API to get the context mode of the current request is the EPiServer.Web.IContextModeResolver and, specifically within the ContentDeliveryAPI, by EPiServer.ContentApi.Core.IContentModeResolver. However the EPiServer.Web implementation doesn't work for ContentAPI requests and the ContentAPI.Core implementation doesn't set the values expected by other CMS extensions.

Hence, I've added ContentApiContextModeResolver, which implements both interfaces and replaces both standard implementations in the service container. It has been constructed in a way that regardless of entry (regular, content delivery API), the context mode is set and exposed the way it is expected throughout the application. This is the first step to supporting any add-on through the ContentDelivery API.

    [ServiceConfiguration(Lifecycle = ServiceInstanceScope.Hybrid)]
    [ServiceConfiguration(typeof(EPiServer.ContentApi.Core.IContextModeResolver), Lifecycle = ServiceInstanceScope.Hybrid)]
    [ServiceConfiguration(typeof(IContextModeResolver), Lifecycle = ServiceInstanceScope.Hybrid)]
    [ServiceConfiguration(typeof(ContextModeResolver), Lifecycle = ServiceInstanceScope.Hybrid)]
    public class ContentApiContextModeResolver : ContextModeResolver, EPiServer.ContentApi.Core.IContextModeResolver, IContextModeResolver
    {
        protected readonly ServiceAccessor<HttpContextBase> _httpContextAccessor;
        protected readonly ServiceAccessor<IModuleResourceResolver> _moduleResourceResolverAccessor;
        protected readonly Regex _editRouteRegex = new Regex(",{2}\\d+");

        public ContextMode DefaultContextMode { get; set; } = ContextMode.Default;

        public ContentApiContextModeResolver(
            ServiceAccessor<HttpContextBase> httpContextAccessor,
            ServiceAccessor<IModuleResourceResolver> moduleResourceResolverAccessor
        ) {
            _httpContextAccessor = httpContextAccessor;
            _moduleResourceResolverAccessor = moduleResourceResolverAccessor;
        }

        ContextMode IContextModeResolver.CurrentMode => CurrentMode();

        public override ContextMode CurrentMode()
        {
            var httpContext = _httpContextAccessor();
            if (httpContext == null || httpContext.Request == null)
                return DefaultContextMode;
            if (httpContext.PreviousHandler != null && httpContext.CurrentHandler != httpContext.PreviousHandler)
                return DefaultContextMode;
            if (httpContext.Request.RequestContext != null && httpContext.Request.RequestContext.HasApiContextMode())
                return httpContext.Request.RequestContext.GetApiContextMode(DefaultContextMode);
            var contextMode = Resolve(httpContext.Request.RawUrl, DefaultContextMode);
            if (httpContext.Request.RequestContext != null)
                httpContext.Request.RequestContext.SetApiContextMode(contextMode);
            return contextMode;
        }

        public ContextMode Resolve(string contentUrl, ContextMode defaultContextMode)
        {
            var urlBuilder = new UrlBuilder(contentUrl);
            if (IsCmsUrl(urlBuilder) || IsApiUrl(urlBuilder)) {
                if (IsEditingActive(urlBuilder))
                    return ContextMode.Edit;
                if (IsPreviewingActive(urlBuilder))
                    return ContextMode.Preview;
            }
            return defaultContextMode;
        }

        protected virtual bool IsCmsUrl(UrlBuilder contentUrl)
        {
            var moduleResourceResolver = _moduleResourceResolverAccessor();
            return moduleResourceResolver == null || contentUrl.Path.StartsWith(moduleResourceResolver.ResolvePath("CMS", (string)null), StringComparison.OrdinalIgnoreCase);
        }

        protected virtual bool IsApiUrl(UrlBuilder contentUrl)
        {
            return contentUrl.Path.StartsWith("/api/episerver/", StringComparison.OrdinalIgnoreCase);
        }

        protected virtual bool IsEditingActive(UrlBuilder urlBuilder)
        {
            if (urlBuilder.QueryCollection[PageEditing.EpiEditMode] != null)
            {
                return urlBuilder.QueryCollection[PageEditing.EpiEditMode].Equals("true", StringComparison.OrdinalIgnoreCase);
            }
            return false;
        }

        protected virtual bool IsPreviewingActive(UrlBuilder urlBuilder)
        {
            return !string.IsNullOrEmpty(urlBuilder.Path) && _editRouteRegex.IsMatch(urlBuilder.Path);
        }
    }

The next step is to ensure that the context mode is correctly propagated through the URLs returned by the Content Delivery API, so all subsequent requests will have the same context mode. As the content delivery API always returns the "default" URL, we need to provide a custom implementation of the UrlResolverService. This is the CurrentContextUrlResolverService.

    public class CurrentContextUrlResolverService : UrlResolverService
    {
        private readonly ContentApiConfiguration _contentApiConfiguration;
        protected readonly IContextModeResolver _contextModeResolver;

        public CurrentContextUrlResolverService(
            UrlResolver urlResolver,
            ContentApiConfiguration contentApiConfiguration,
            IContextModeResolver contextModeResolver
        ) : base(urlResolver, contentApiConfiguration)
        {
            _contentApiConfiguration = contentApiConfiguration;
            _contextModeResolver = contextModeResolver;
        }

        public override string ResolveUrl(string internalLink)
        {
            var contentApiOptions = _contentApiConfiguration.Default();
            return _urlResolver.GetUrl(new UrlBuilder(internalLink), new VirtualPathArguments
            {
                ContextMode = _contextModeResolver.CurrentMode,
                ForceCanonical = true,
                ForceAbsolute = contentApiOptions.ForceAbsolute,
                ValidateTemplate = contentApiOptions.ValidateTemplateForContentUrl
            });
        }
        public override string ResolveUrl(ContentReference contentLink, string language)
        {
            var contentApiOptions = _contentApiConfiguration.Default();
            return _urlResolver.GetUrl(contentLink, language, new VirtualPathArguments
            {
                ContextMode = _contextModeResolver.CurrentMode,
                ForceCanonical = true,
                ForceAbsolute = contentApiOptions.ForceAbsolute,
                ValidateTemplate = contentApiOptions.ValidateTemplateForContentUrl
            });
        }
    }

When we combine the above with the ServiceConfigurationContext extension methods .ConfigureForExternalTemplates() and .ConfigureForContentDeliveryClient(), we can now fetch any version of the content as long as the current user is authorized to see that content and the included URLs enable navigation within edit mode. Also, all add-ons, event-handlers, etc.. that are triggered by the content loading of the content delivery API will properly understand if the current load is for an edit mode or not.

Using this we can now check: "On page editing of any version", "Version comparisons", and "Advanced Reviews".

Projects

Content Cloud offers projects to easily group multiple changes into one "package" that can be published, it's a great productivity tool, so why not allow projects to be used within a SPA? Great, now does it work with the above? Well, no, to load project content it is referred to by identifier and a project id, within edit mode, not the version id. So we need to extend the Content Delivery API to understand the epiprojects= query string parameter (that's the query string parameter used by the on-page editing) and expose that content.

This is achieved by replacing the default content loader with a slightly extended one that adds support for the project loader. This is the ProjectAwareContentLoaderService, which has two main tasks:

  1. Add the project ids to the content loader, once it is being created
  2. Allow content items that are part of the current project to be exposed by the ContentDeliveryAPI
    public class ProjectAwareContentLoaderService : ContentLoaderService
    {
        protected readonly IProjectResolver projectResolver;
        protected readonly EPiServer.Web.IContextModeResolver contextModeResolver;

        public ProjectAwareContentLoaderService(
            IContentLoader contentLoader,
            EPiServer.Web.IPermanentLinkMapper permanentLinkMapper,
            IUrlResolver urlResolver,
            EPiServer.ContentApi.Core.IContextModeResolver contextModeResolver,
            IContentProviderManager providerManager,
            IProjectResolver projectResolver,
            EPiServer.Web.IContextModeResolver coreContextModeResolver
        ) : base(contentLoader, permanentLinkMapper, urlResolver, contextModeResolver, providerManager) 
        {
            this.projectResolver = projectResolver;
            this.contextModeResolver = coreContextModeResolver;
        }

        protected override LanguageSelector CreateLoaderOptions(string language, bool shouldUseMasterIfFallbackNotExist = false)
        {
            var options = base.CreateLoaderOptions(language, shouldUseMasterIfFallbackNotExist);
            IEnumerable<int> currentProjects = projectResolver.GetCurrentProjects();
            if (currentProjects.Count() > 0)
                options.Setup<ProjectLoaderOption>(x => x.ProjectIds = currentProjects);
            return options;
        }

        protected override bool ShouldContentBeExposed(IContent content)
        {
            if (contextModeResolver.CurrentMode.EditOrPreview())
                return true;

            IEnumerable<int> currentProjects = projectResolver.GetCurrentProjects();
            if (currentProjects.Count() > 0)
            {
                var projectItems = ServiceLocator.Current.GetInstance<ProjectRepository>().GetItems(new ContentReference[] { content.ContentLink });
                if (projectItems.Any(pi => currentProjects.Contains(pi.ProjectID)))
                    return true;
            }

            return base.ShouldContentBeExposed(content);
        }
    }

The project-resolver from the Core CMS already validates access to the projects. The main caveat with the current implementation is that it won't validate access to the content item for the current item. This risks exposing some content to editors for which they do not have read access when it's in a project.

So now we check: "Projects"

Visitor group preview

The content delivery API is fully personalizable using visitor groups, so being able to preview as a specific visitor group to get an understanding of the experience that will be generated, is key to leveraging this capability. So here we follow the suggestion from Episerver World, by adding a specific model mapper for IContent and edit mode. In this implementation, we digress from the suggestion on Episerver World, as we already have a good solution to determine the current context mode using the standard APIs of the CMS. This is the VisitorGroupContentModelMapper, which checks if we're impersonating a visitor group and then runs the appropriate extension method to set up the impersonation.

    [ServiceConfiguration(typeof(IContentModelMapper))]
    class VisitorGroupContentModelMapper : ContentModelMapperBase
    {
        private readonly ServiceAccessor<HttpContextBase> _httpContextAccessor;
        private readonly ServiceAccessor<EPiServer.Web.IContextModeResolver> _contextModeAccessor;
        public VisitorGroupContentModelMapper(IContentTypeRepository contentTypeRepository, 
                                        ReflectionService reflectionService, 
                                        IContentModelReferenceConverter contentModelService, 
                                        IContentVersionRepository contentVersionRepository, 
                                        ContentLoaderService contentLoaderService, 
                                        UrlResolverService urlResolverService, 
                                        ContentApiConfiguration apiConfig, 
                                        IPropertyConverterResolver propertyConverterResolver,
                                        ServiceAccessor<HttpContextBase> httpContextAccessor,
                                        ServiceAccessor<EPiServer.Web.IContextModeResolver> contextModeAccessor)
                                        : base(contentTypeRepository, 
                                               reflectionService, 
                                               contentModelService, 
                                               contentVersionRepository, 
                                               contentLoaderService, 
                                               urlResolverService, 
                                               apiConfig, 
                                               propertyConverterResolver)
        {
            _httpContextAccessor = httpContextAccessor;
            _contextModeAccessor = contextModeAccessor;
        }

        public override int Order
        {
            get
            {
                return 200;
            }
        }

        public override ContentApiModel TransformContent(IContent content, bool excludePersonalizedContent, string expand)
        {
            var httpContext = _httpContextAccessor();
            var visitorGroupId = httpContext?.Request.QueryString[VisitorGroupHelpers.VisitorGroupKeyByID];
            if (!string.IsNullOrEmpty(visitorGroupId))
            {
                httpContext.SetupVisitorGroupImpersonation(content, AccessLevel.Read);
            }
            return base.TransformContent(content, excludePersonalizedContent, expand);
        }

        public override bool CanHandle<T>(T content)
        {
            return _contextModeAccessor().CurrentMode.EditOrPreview() && content is IContent;
        }
    }

So now we can check: "Visitor group preview"

Common drafts preview mode

The Advanced CMS add-on provides quite a nice capability, the "common drafts preview mode", this allows an editor to see the page with the "unpublished" version of all blocks on that page. This gives a preview of what will happen when the page and all blocks will be published in one go (again a feature of Advanced CMS).

In order to support this, I've replaced the standard model for a content area to load the common draft version when in edit mode and the common draft preview is active. This requires two steps:

  1. Create a new instance of the PropertyContentArea, with the updated logic (ContentAreaPropertyModel)
  2. Create a new converter, that takes priority over the default one (ContentAreaPropertyModelConverter) and make it use the new PropertyModel.
    class ContentAreaPropertyModel : CollectionPropertyModelBase<ContentAreaItemModel, PropertyContentArea>
    {
        protected readonly IContextModeResolver _contextModeResolver;

        public ContentAreaPropertyModel(
            PropertyContentArea propertyContentArea,
            bool excludePersonalizedContent
        ) : this(
            propertyContentArea, 
            excludePersonalizedContent, 
            ServiceLocator.Current.GetInstance<ContentLoaderService>(), 
            ServiceLocator.Current.GetInstance<IContentModelMapper>(), 
            ServiceLocator.Current.GetInstance<IContentAccessEvaluator>(), 
            ServiceLocator.Current.GetInstance<ISecurityPrincipal>(),
            ServiceLocator.Current.GetInstance<IContextModeResolver>()
        ) { }

        public ContentAreaPropertyModel(
            PropertyContentArea propertyContentArea,
            bool excludePersonalizedContent,
            ContentLoaderService contentLoaderService,
            IContentModelMapper contentModelMapper,
            IContentAccessEvaluator accessEvaluator,
            ISecurityPrincipal principalAccessor,
            IContextModeResolver contextModeResolver
        ) : base(
            propertyContentArea,
            excludePersonalizedContent,
            contentLoaderService,
            contentModelMapper,
            accessEvaluator,
            principalAccessor
        ) {
            _contextModeResolver = contextModeResolver;
        }

        public ContentAreaPropertyModel(
            PropertyContentArea propertyContentArea,
            ConverterContext converterContext
        ) : base(propertyContentArea, converterContext)
        {
            _contextModeResolver = ServiceLocator.Current.GetInstance<IContextModeResolver>();
        }

        public ContentAreaPropertyModel(
            PropertyContentArea propertyContentArea,
            ConverterContext converterContext,
            ContentLoaderService contentLoaderService,
            ContentConvertingService contentConvertingService,
            IContentAccessEvaluator accessEvaluator,
            ISecurityPrincipal principalAccessor,
            IContextModeResolver contextModeResolver
        ) : base(
            propertyContentArea,
            converterContext,
            contentLoaderService,
            contentConvertingService,
            accessEvaluator,
            principalAccessor
        ) {
            _contextModeResolver = contextModeResolver;
        }

        protected virtual IEnumerable<ContentAreaItem> FilteredItems(
            ContentArea contentArea,
            bool excludePersonalizedContent)
        {
            IPrincipal principal = excludePersonalizedContent ? _principalAccessor.GetAnonymousPrincipal() : _principalAccessor.GetCurrentPrincipal();
            return contentArea.Fragments.GetFilteredFragments(principal).OfType<ContentFragment>().Select(f => new ContentAreaItem(f));
        }

        protected virtual ContentAreaItemModel CreateItemModel(ContentAreaItem item)
        {
            ContentVersion contentVersion = null;
            if (_contextModeResolver.CurrentMode.EditOrPreview() && ContentDraftView.IsInContentDraftViewMode)
            {
                contentVersion = GetLatestVersion(item, true);
            }
            return new ContentAreaItemModel()
            {
                ContentLink = new ContentModelReference()
                {
                    GuidValue = new Guid?(item.ContentGuid),
                    Id = new int?(contentVersion != null ? contentVersion.ContentLink.ID : item.ContentLink.ID),
                    WorkId = new int?(contentVersion != null ? contentVersion.ContentLink.WorkID : item.ContentLink.WorkID),
                    ProviderName = contentVersion != null ? contentVersion.ContentLink.ProviderName : item.ContentLink.ProviderName
                },
                DisplayOption = item.RenderSettings.ContainsKey(ContentFragment.ContentDisplayOptionAttributeName) ? item.RenderSettings[ContentFragment.ContentDisplayOptionAttributeName].ToString() : ""
            };
        }

        protected virtual ContentVersion GetLatestVersion(ContentAreaItem item, bool nullIfPublished = true)
        {
            LanguageResolver languageResolver = ServiceLocator.Current.GetInstance<LanguageResolver>();
            var contentVersion = ServiceLocator.Current.GetInstance<IContentVersionRepository>().LoadCommonDraft(item.ContentLink, languageResolver.GetPreferredCulture().Name); //Language issues ahead?
            return nullIfPublished && contentVersion.Status == VersionStatus.Published ? null : contentVersion;
        }

        protected override IEnumerable<ContentAreaItemModel> GetValue() => !(_propertyLongString.Value is ContentArea contentArea) ? null : FilteredItems(contentArea, _excludePersonalizedContent).Select(x => CreateItemModel(x));
    }
    [ServiceConfiguration(typeof(IPropertyModelConverter), Lifecycle = ServiceInstanceScope.Singleton)]
    class ContentAreaPropertyModelConverter : DefaultPropertyModelConverter, IPropertyModelConverter
    {
        public override int SortOrder => 1000;

        public ContentAreaPropertyModelConverter() : base () { }

        public ContentAreaPropertyModelConverter(ReflectionService reflectionService) : base (reflectionService) { }

        protected override IEnumerable<TypeModel> InitializeModelTypes() => new TypeModel[] { new TypeModel {
            ModelType = typeof(ContentAreaPropertyModel),
            ModelTypeString = typeof(ContentAreaPropertyModel).FullName,
            PropertyType = typeof(PropertyContentArea)
        } };
    }

So now we can now place the second last check: "Common drafts preview mode"

Hosting business logic

In a "headless" setup, the assumption is "multi-head". So what about business logic (for example: building a search query from parameters entered by an editor), or any other bespoke logic. In this scenario, the fact that Content Cloud (even on DXP) can be built is key to the solution, as it allows the addition of bespoke APIs to a solution. However what about APIs that require the context of a piece of content? Traditionally this logic would have been placed in the .Net controllers. Well within the project, there's a specific API controller, which enables a developer to use IContent controllers to implement logic tied to content.

This is enabled by the ControllerActionApiController, which adds the capability to execute a controller method, from the ContentDeliveryAPI (/api/episerver/v3/action/{id}/{method}). For convenience, it also adds a specific route to allow access to page methods: {page Route}/{method}, but this requires the appropriate headers to be set to have the request being handled by the Content Delivery API.

Showing the logic of this controller will take quite a bit of space, hence, in this case, I'll suffice by pointing you directly to the implementation on GitHub: src\Foundation.ContentDelivery\Controller\ControllerActionApiController.cs.

Jun 29, 2021

Comments

Please login to comment.
Latest blogs
Copy Optimizely SaaS CMS Settings to ENV Format Via Bookmarklet

Do you work with multiple Optimizely SaaS CMS instances? Use a bookmarklet to automatically copy them to your clipboard, ready to paste into your e...

Daniel Isaacs | Dec 22, 2024 | Syndicated blog

Increase timeout for long running SQL queries using SQL addon

Learn how to increase the timeout for long running SQL queries using the SQL addon.

Tomas Hensrud Gulla | Dec 20, 2024 | Syndicated blog

Overriding the help text for the Name property in Optimizely CMS

I recently received a question about how to override the Help text for the built-in Name property in Optimizely CMS, so I decided to document my...

Tomas Hensrud Gulla | Dec 20, 2024 | Syndicated blog

Resize Images on the Fly with Optimizely DXP's New CDN Feature

With the latest release, you can now resize images on demand using the Content Delivery Network (CDN). This means no more storing multiple versions...

Satata Satez | Dec 19, 2024