Blog series: Migrate a Optimizely/Episerver plugin from CMS 11 to CMS 12 – Breaking changes

Blog series: Migrate a Optimizely/Episerver plugin from CMS 11 to CMS 12 – Breaking changes

In the first blog post of these series we talked about how to migrate a plugin for Optimizely CMS 11 which is in the .NET framework 4.8 to .NET Core 5.0 framework. In this blog post, we are going to focus in the code changes that we will need in order to make the code compile and work again due to some namespaces changes, not existing features or classes in the new framework and some shift in paradigms replaced in the new framework. So without further due, lets begin.

We will start removing some components that are not available anymore in CMS 12. The first one to go would be the dashboard plugin that was created for CMS 11. The dashboard is not available anymore in CMS 12 so, it does not make any sense to have one. We will remove the gadget controller class, gadget plugin class, gadget views and script that was displaying the gadget in the dashboard. It is important to specify that even if the CMS 12 version does not have a dashboard, if your plugin is displayed as a gadget in the editor interface, that feature is still available and you should make those changes accordingly.

In our scenario, because we are not adding the plugin as a gadget, we can also remove the module.config.transform file which was adding the code to include the scripts in the plugin.

And we also should modify the module.config file to remove the gadget plugin from it.

The same applies to the web.config.transform file., but this removal applies to all the projects which have xml transforms configurations for the web.config file because that file does not exists anymore in .NET core. We used that file to add app settings for our solution and the http handlers for robots and sitemap files.

For the app settings section, we had to ask the installer of the plugin to add those configurations in their app.settings file manually. Unfortunately, for the moment there is no way to transform json files automatically as we did with the transform xml files so we added instructions in the readme file. We did not pass the sitemap config key to the new settings because we are not using those configurations anymore.

  "Siro": {
    "Show_Localized_URLS": "true",
    "Display_Localized_URLS_Separated": "false",
    "Show_Trailing_Slash": "false",
    "SiteMap_OutPutRobots_txt": "1"
  }

For the handlers section, unfortunately we cannot use them as they were anymore. The concept of http handlers do no exists in .NET core and instead we will have to use the middleware concept. To explain some of the changes, we will see as example the Robots handler, showing the old code first.

using EPiServer;
using EPiServer.ServiceLocation;
using EPiServer.Web;

namespace Verndale.Sitemap.Robots.Generator.Handlers
{
    using System;
    using System.Collections.Generic;
    using System.Linq;

    /// <summary>
    /// The robots handler.
    /// </summary>
    public class RobotsHandler : IHttpHandler
    {
        /// <summary>
        /// The rep.
        /// </summary>
        private readonly IContentLoader rep;

        /// <summary>
        /// The site rep.
        /// </summary>
        private readonly ISiteDefinitionRepository siteRep;


        /// <summary>
        /// The sitemap config manager.
        /// </summary>
        private readonly Models.DDS.ISitemapConfigurationDataStoreManager sitemapConfigManager;

        /// <summary>
        /// Initializes a new instance of the <see cref="RobotsHandler"/> class.
        /// </summary>
        public RobotsHandler()
        {
            rep = ServiceLocator.Current.GetInstance<IContentLoader>();
            siteRep = ServiceLocator.Current.GetInstance<ISiteDefinitionRepository>();
            sitemapConfigManager = new Models.DDS.SitemapConfigurationDataStoreManager();
        }

        /// <summary>
        /// The is reusable.
        /// </summary>
        public bool IsReusable => false;

        /// <summary>
        /// The process request.
        /// </summary>
        /// <param name="context">
        /// The context.
        /// </param>
        public void ProcessRequest(HttpContext context)
        {
            var domainUri = context.Request.Url;
            var currentSite = siteRep.List().FirstOrDefault(x => verifyInHost(x.Hosts, domainUri.Authority) == true);

            var robotsTxtContent = @"User-agent: *"
                                   + Environment.NewLine +
                                   "Disallow: /episerver";

            if (currentSite != null)
            {
                var sitemapConfigData = sitemapConfigManager.GetFirstData();

                var restrictedSite = sitemapConfigData?.RestrictedSites?.FirstOrDefault(x => x.SiteName == currentSite.Name);
                var robotsFromField = string.Empty;

                // Get robots data from restricted site
                if (restrictedSite != null && !restrictedSite.Restricted)
                {
                    robotsFromField = restrictedSite.RobotsData;
                }

                // Generate robots.txt file
                if (!string.IsNullOrEmpty(robotsFromField))
                {
                    robotsTxtContent += Environment.NewLine + robotsFromField;
                }
            }

            // Set the response code, content type and appropriate robots file here
            // also think about handling caching, sending error codes etc.
            context.Response.StatusCode = 200;
            context.Response.ContentType = "text/plain";

            // Return the robots content
            context.Response.Write(robotsTxtContent);
            context.Response.End();
        }

        private bool verifyInHost(IList<HostDefinition> hosts, string siteName)
        {
            foreach (var host in hosts)
            {
                if (host.Authority.Hostname == siteName)
                {
                    return true;
                }
            }

            return false;
        }
    }
}

The changes we applied to this file are the following:

  • Remove the inheritance from IHttpHandler
  • Constructor should receive as parameter a Request Delegate to handle the request received (not used in this plugin)
  • Method process request is changed to async method Invoke
  • Context variable Request.Url changed to Request.GetDisplayUrl

In one of the helpers methods used in the class above we try to access the property host.Authority.Hostname from the class HostDefinition which is not available anymore, so we had to change it to host.Authority.ToString() to check for names instead of host names.

         private bool verifyInHost(IList<HostDefinition> hosts, string siteName)
        {
            foreach (var host in hosts)
            {
                if (host.Authority.Hostname == siteName)
                {
                    return true;
                }
            }

            return false;
        }
        public static bool VerifyInHost(IList<HostDefinition> hosts, string siteName)
        {
            foreach (var host in hosts)
            {
                if (host.Authority.ToString() == siteName)
                {
                    return true;
                }
            }

            return false;
        }

The final code for the robots handler class is the following:

´╗┐using EPiServer;
using EPiServer.ServiceLocation;
using EPiServer.Web;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Http.Extensions;
using System.Threading.Tasks;

namespace Verndale.Sitemap.Robots.Generator.Handlers
{
    using System;
    using System.Linq;
    using Verndale.Sitemap.Robots.Generator.Helpers;

    /// <summary>
    /// The robots handler.
    /// </summary>
    public class RobotsHandler
    {
        /// <summary>
        /// The site rep.
        /// </summary>
        private readonly ISiteDefinitionRepository siteRep;


        /// <summary>
        /// The sitemap config manager.
        /// </summary>
        private readonly Models.DDS.ISitemapConfigurationDataStoreManager sitemapConfigManager;

        /// <summary>
        /// Initializes a new instance of the <see cref="RobotsHandler"/> class.
        /// </summary>
        public RobotsHandler(RequestDelegate next)
        {
            // No need to save request delegate
            ServiceLocator.Current.GetInstance<IContentLoader>();
            siteRep = ServiceLocator.Current.GetInstance<ISiteDefinitionRepository>();
            sitemapConfigManager = new Models.DDS.SitemapConfigurationDataStoreManager();
        }

        /// <summary>
        /// The process request.
        /// </summary>
        /// <param name="context">
        /// The context.
        /// </param>
        public async Task Invoke(HttpContext context)
        {
            var domainUri = new Uri(context.Request.GetDisplayUrl());
            var currentSite = siteRep.List().FirstOrDefault(x => SiteHelper.VerifyInHost(x.Hosts, domainUri.Authority));

            var robotsTxtContent = @"User-agent: *"
                                   + Environment.NewLine +
                                   "Disallow: /episerver";

            if (currentSite != null)
            {
                var sitemapConfigData = sitemapConfigManager.GetFirstData();

                var restrictedSite = sitemapConfigData?.RestrictedSites?.FirstOrDefault(x => x.SiteName == currentSite.Name);
                var robotsFromField = string.Empty;

                // Get robots data from restricted site
                if (restrictedSite != null && !restrictedSite.Restricted)
                {
                    robotsFromField = restrictedSite.RobotsData;
                }

                // Generate robots.txt file
                if (!string.IsNullOrEmpty(robotsFromField))
                {
                    robotsTxtContent += Environment.NewLine + robotsFromField;
                }
            }

            // Set the response code, content type and appropriate robots file here
            // also think about handling caching, sending error codes etc.
            context.Response.StatusCode = 200;
            context.Response.ContentType = "text/plain";

            // Return the robots content
            await context.Response.WriteAsync(robotsTxtContent);
        }
    }
}

Unfortunately, that is not all we need to do in order to make these handlers work. We also need to add a middleware class so when we include it in the program.cs or startup.cs of the project, it can execute the main logic of our handlers. For the robots handler we added the following middleware class:

using Microsoft.AspNetCore.Builder;
using Verndale.Sitemap.Robots.Generator.Handlers;

namespace Verndale.Sitemap.Robots.Generator.Extensions
{
    public static class RobotsMiddlewareExtensions
    {
        public static IApplicationBuilder UseRobotsMiddleware(this IApplicationBuilder builder)
        {
            return builder.UseMiddleware<RobotsHandler>();
        }
    }
}

If you have more than one handler in your project, like we have in our scenario. You will have to create middleware classes per each handler.

using Microsoft.AspNetCore.Builder;
using Verndale.Sitemap.Robots.Generator.Handlers;

namespace Verndale.Sitemap.Robots.Generator.Extensions
{
    public static class SitemapMiddlewareExtensions
    {
        public static IApplicationBuilder UseSitemapMiddleware(this IApplicationBuilder builder)
        {
            return builder.UseMiddleware<SitemapHandler>();
        }
    }
}

And if you want to add both middleware to your solution you should add another middleware class which must include both specifying the paths where the are going to be called. In our case, robots.txt path for the robots handler and sitemap.xml for the sitemap handler.

using Microsoft.AspNetCore.Builder;

namespace Verndale.Sitemap.Robots.Generator.Extensions
{
    /// <summary>
    /// The sitemap robots middleware extension.
    /// </summary>
    public static class SitemapRobotsMiddlewareExtensions
    {
        /// <summary>
        /// Use sitemap and robots middleware
        /// </summary>
        /// <param name="app"></param>
        public static void UseSitemapRobotsMiddleware(this IApplicationBuilder app)
        {
            app.MapWhen(
                context => context.Request.Path.ToString().EndsWith("/robots.txt"),
                appBranch =>
                {
                    // ... optionally add more middleware to this branch
                    appBranch.UseRobotsMiddleware();
                });

            app.MapWhen(
                context => context.Request.Path.ToString().EndsWith("/sitemap.xml"),
                appBranch =>
                {
                    // ... optionally add more middleware to this branch
                    appBranch.UseSitemapMiddleware();
                });
        }
    }
}

To use it in your project, you must add the following line in your startup.cs or program.cs.

public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
{
     app.UseSitemapRobotsMiddleware(); // Line to add
}

After all those modifications, we will then remove any class which has relation to the structure map dependency injection engine. By default .NET 5 includes its own engine, and Optimizely CMS 12 uses the already included engine instead of structure map.

Because of this dependency injection change we also have to modify the file DependencyResolverInitialization so it uses the new Microsoft dependency injection engine. Below is the new code for the plugin. Keep in mind, that the only change was to add the new dependency Microsoft.Extensions.DependencyInjection

´╗┐using EPiServer.Framework;
using EPiServer.Framework.Initialization;
using EPiServer.ServiceLocation;
using Microsoft.Extensions.DependencyInjection;
using Verndale.Sitemap.Robots.Generator.Sitemap;
using ISitemapConfigurationDataStoreManager = Verndale.Sitemap.Robots.Generator.Models.DDS.ISitemapConfigurationDataStoreManager;
using ISitemapExtend = Verndale.Sitemap.Robots.Generator.Sitemap.ISitemapExtend;
using ISitemapGenerator = Verndale.Sitemap.Robots.Generator.Sitemap.ISitemapGenerator;
using ISitemapManager = Verndale.Sitemap.Robots.Generator.Sitemap.ISitemapManager;
using SitemapConfigurationDataStoreManager = Verndale.Sitemap.Robots.Generator.Models.DDS.SitemapConfigurationDataStoreManager;
using SitemapExtend = Verndale.Sitemap.Robots.Generator.Sitemap.SitemapExtend;
using SitemapGenerator = Verndale.Sitemap.Robots.Generator.Sitemap.SitemapGenerator;
using SitemapManager = Verndale.Sitemap.Robots.Generator.Sitemap.SitemapManager;

namespace Verndale.Sitemap.Robots.Generator.Initialization
{
    /// <summary>
    /// The dependency resolver initialization.
    /// </summary>
    [InitializableModule]
    public class DependencyResolverInitialization : IConfigurableModule
    {
        /// <summary>
        /// The configure container.
        /// </summary>
        /// <param name="context">
        /// The context.
        /// </param>
        public void ConfigureContainer(ServiceConfigurationContext context)
        {
            //Implementations for custom interfaces can be registered here.
            var services = context.Services;

            //Register custom implementations that should be used in favor of the default implementations
            services
                .AddTransient<ISitemapConfigurationDataStoreManager, SitemapConfigurationDataStoreManager>()
                .AddTransient<ISitemapGenerator, SitemapGenerator>()
                .AddTransient<ISitemapManager, SitemapManager>()
                .AddTransient<ISitemapExtend, SitemapExtend>()
                .AddTransient<ISitemapFilter, SitemapFilter>();
        }

        /// <summary>
        /// The initialize.
        /// </summary>
        /// <param name="context">
        /// The context.
        /// </param>
        public void Initialize(InitializationEngine context)
        {
            // Do nothing
        }

        /// <summary>
        /// The uninitialize.
        /// </summary>
        /// <param name="context">
        /// The context.
        /// </param>
        public void Uninitialize(InitializationEngine context)
        {
            // Do nothing
        }
    }
}

We will also remove some configuration files and other files that were not used anymore in the project and were specific to our scenario like the app.config file.

Now we will have to add a location for our plugin. In the CMS 11, you could add to your plugin controller some attributes in order to decide where the tool is going to appear. For instance, in our scenario we added some attributes to display the configuration of the plugin in the admin section, config area.

[Authorize(Roles = "CmsAdmins, Administrators, SIRO")]
[GuiPlugIn(
    Area = PlugInArea.AdminConfigMenu,
    Url = "/SitemapPlugin/Index",
    DisplayName = "Sitemap and Robots Manager")]
public class SitemapPluginController : Controller
{
    //Something here
}

This code does not work anymore for version 12 and must be removed from the controller. Instead, we will add a new menu provider which will be able to specify where is going to be located, who can access it and what controller to call when the user click in that new menu. The code is the following:

using EPiServer.Authorization;
using EPiServer.Shell.Navigation;
using System.Collections.Generic;

namespace Verndale.Sitemap.Robots.Generator
{
    [MenuProvider]
    public class SitemapMenuProvider : IMenuProvider
    {
        public IEnumerable<MenuItem> GetMenuItems()
        {
            var menuItems = new List<MenuItem>();
            menuItems.Add(new UrlMenuItem("Sitemap & Robots",
                MenuPaths.Global + "/cms/admin/configurations" + "/siro",
                "/SitemapPlugin/Index")
            {
                SortIndex = SortIndex.Last,
                AuthorizationPolicy = CmsPolicyNames.CmsAdmin
            });

            return menuItems;
        }
    }
}

Take into account that roles before were added as another attribute to the plugin controller and even though roles are now setup at the menu provider, you can still set access to the controller using the Authorize attribute.

Finally, we made some small modifications to other files which we were having some issues. Some of those modifications are:

  • ConfigurationManager.AppSettings does not exists anymore, add IConfiguration configuration to your constructor so you can access the appsettings file within your class.
        public SitemapGenerator(ISitemapManager sitemapManager,
            IConfiguration configuration)
        {
            this.sitemapManager = sitemapManager;
            this.configuration = configuration;
        }

        public void RefreshSitemapTask(SitemapJob sitemapJob, SitemapConfigurationDataStore dataBase = null)
        {
            var outPutRobots = configuration["Siro:SiteMap_OutPutRobots_txt"].Trim();
            if (outPutRobots == "1")
            {
                sitemapManager.RegisterToRobotsFields();
            }
        }
  • Use WebUtility.HtmlEncode instead of HttpUtility.HtmlEncode
 var url = WebUtility.HtmlEncode(GetItemUrl(item, culture ?? GetLanguage(item), site));
  • Get API methods in an API controller do not require anymore JsonRequestBehavior.AllowGet
 return this.Json("OK", JsonRequestBehavior.AllowGet);
 return this.Json("OK");
  • API methods require a new attribute, Route action, in order to be considered as part of the API. The same applies with the controller, which requires a new attribute, Route controller.
       public class SitemapPluginController : Controller
       {

       }        

       public JsonResult ListSearchEngines()
        {
            var model = new Models.ViewModels.SitemapConfigurationViewModel();
            return this.Json(model.SearchEngines);
        }
       [Route("[controller]")]
       public class SitemapPluginController : Controller
       {

       }

       [Route("[action]")]
       public JsonResult ListSearchEngines()
       {
            var model = new Models.ViewModels.SitemapConfigurationViewModel();
            return this.Json(model.SearchEngines);
       }
  • Optimizely CMS URL resolver does return already the main host name, so there is no need to add it to the current host name.
      public static string GetItemUrl(ContentReference contentLink, CultureInfo lang, SiteDefinition site)
     {
            // Get page url based on language
            var urlResolver = ServiceLocator.Current.GetInstance<UrlResolver>();
            var pageUrl = urlResolver.GetVirtualPath(contentLink, lang.Name);

            // Add last host name to the page url
            var uriBuilder = new UriBuilder(site.SiteUrl) { Path = pageUrl.VirtualPath };
            var url = uriBuilder.Uri.AbsoluteUri;

            return url;
     }

     public static string GetItemUrl(ContentReference contentLink, SiteDefinition site)
     {
            // Get page url based on language
            var urlResolver = ServiceLocator.Current.GetInstance<UrlResolver>();
            var pageUrl = urlResolver.GetVirtualPath(contentLink);

            // Add last host name to the page url
            var uriBuilder = new UriBuilder(site.SiteUrl) { Path = pageUrl.VirtualPath };
            var url = uriBuilder.Uri.AbsoluteUri;

            return url;
      }
     public static string GetItemUrl(ContentReference contentLink, CultureInfo lang, SiteDefinition site)
     {
            // Get page url based on language
            var urlResolver = ServiceLocator.Current.GetInstance<IUrlResolver>();
            var pageUrl = urlResolver.GetUrl(contentLink, lang.Name);
            return pageUrl;
     }

     public static string GetItemUrl(ContentReference contentLink, SiteDefinition site)
     {
            // Get page url based on language
            var urlResolver = ServiceLocator.Current.GetInstance<IUrlResolver>();
            var pageUrl = urlResolver.GetUrl(contentLink);

            // Add last host name to the page url
            var uriBuilder = new UriBuilder(site.SiteUrl) { Path = pageUrl };
            var url = uriBuilder.Uri.AbsoluteUri;

            return url;
     }
  • If your view was using the inherits tag, replace it with model
@inherits System.Web.Mvc.WebViewPage<Verndale.Sitemap.Robots.Generator.Models.ViewModels.SitemapConfigurationViewModel>
@model Verndale.Sitemap.Robots.Generator.Models.ViewModels.SitemapConfigurationViewModel
  • If your view was using Layout assigned to null, change it to string.Empty
@{
    Layout = null;
}
@{
    Layout = string.Empty;
}
  • If your view was using Html.Raw(Html.ShellInitializationScript()) remove it and to make the new menu configuration appear, add the method Html.CreatePlaformNavigationMenu() at the header level.
<head>
    <title>Sitemap and Robots Manager</title>
    @Html.CreatePlatformNavigationMenu()
    <meta http-equiv="X-UA-Compatible" content="IE=Edge" />
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width, initial-scale=1">
    <!-- Shell --> @Html.Raw(ClientResources.RenderResources("ShellCore"))
    <!-- LightTheme --> @Html.Raw(ClientResources.RenderResources("ShellCoreLightTheme"))
    <link rel="stylesheet" type="text/css" href="@("" + Paths.ProtectedRootPath + "Verndale.Sitemap.Robots.Generator/Styles/bootstrap.css")" />
    <script src="@("" + Paths.ProtectedRootPath + "Verndale.Sitemap.Robots.Generator/Scripts/jquery-3.3.1.min.js")" type="text/javascript"></script>
    <script src="@("" + Paths.ProtectedRootPath + "Verndale.Sitemap.Robots.Generator/Scripts/bootstrap.js")" type="text/javascript"></script>
    <script src="@("" + Paths.ProtectedRootPath + "Verndale.Sitemap.Robots.Generator/Scripts/sitemapConfig.js")" type="text/javascript"></script>
</head>

The final step to check if everything is compiling and running as expected is to modify the current dependencies in the project file. For this you must right click in your project and the go to the edit project file option.

This will open the project file where you can modify the package references your project uses and even more project related things. For now, we will remove anything related to structure map references and old versions of Optimizely CMS and add the new ones, for our project the final file obtained was the following:

<Project Sdk="Microsoft.NET.Sdk">
  <PropertyGroup>
    <OutputType>Library</OutputType>
  </PropertyGroup>
  <PropertyGroup Condition=" '$(Configuration)|$(Platform)' == 'Release|AnyCPU' ">
    <WarningLevel>0</WarningLevel>
  </PropertyGroup>
  <PropertyGroup>
    <TargetFramework>net5.0</TargetFramework>
    <GenerateAssemblyInfo>false</GenerateAssemblyInfo>
  </PropertyGroup>
  <ItemGroup>
    <PackageReference Include="EPiServer.CMS.Core" Version="12.3.0" />
    <PackageReference Include="EPiServer.Framework" Version="12.3.0" />
    <PackageReference Include="EPiServer.CMS.UI.Core" Version="12.3.0" />
    <PackageReference Include="System.ComponentModel.Annotations" Version="5.0.0" />
    <PackageReference Include="System.Data.DataSetExtensions" Version="4.5.0" />
    <PackageReference Include="System.Configuration.ConfigurationManager" Version="5.0.0" />
  </ItemGroup>
  <ItemGroup>
    <PackageReference Include="Microsoft.AspNetCore.Http.Extensions" Version="2.2.0" />
    <PackageReference Include="Microsoft.AspNetCore.Http.Features" Version="5.0.13" />
    <PackageReference Include="Microsoft.Extensions.FileProviders.Abstractions" Version="5.0.0" />
    <PackageReference Include="Microsoft.Net.Http.Headers" Version="2.2.8" />
    <PackageReference Include="System.Text.Encodings.Web" Version="5.0.1" />
  </ItemGroup>
  <ItemGroup>
    <FrameworkReference Include="Microsoft.AspNetCore.App" />
  </ItemGroup>
</Project>

With all these final touches you should be able to compile your code without any problems.

After such a long post if you are still here, I am grateful for your effort. This is it, now you can compile your project and you are ready for the final step, packaging. We are going to cover that in our next blog of this series. If you have any questions or suggestions please let me know in the comments. I hope it can help someone and as always keep learning !!!

Written by:

Jorge Cardenas

Developer with several years of experience who is passionate about technology and how to solve problems through it.

View All Posts

1 COMMENT

Leave a Reply