ASP.NET Razor Pages Legacy/Vanity URL Redirect Prototype for SaaS Headless CMS

This blog post explains a prototype for a solution that ASP.NET Core razor pages with the Contentstack SaaS headless content management system to manage redirects from legacy and alternate to current URLs with the Contentstack SaaS headless content management system. This post is about the architectural approach rather than the code shown.

When a URL changes, you may want to configure a redirect from the obsolete URL to the current URL. In other cases, you may want to implement vanity URLs, such as providing an alternate, short URL for print. You may want some URLs on your site to redirect to external sites.

We can use CMS entries to store information about redirects. Each entry could have one or more fields to let the CMS user specify alternate URLs for the entry. Alternatively, we could create an entry to manage the redirects, specifically mapping the legacy and alternate URL paths to entries and then retrieving the current URL paths for those entries. The user experiences are different; CMS users may prefer to manage redirects all in one place or while editing entries. Factors such as the number of redirects and frequency of their creation could also influence user preferences.

If users prefer to manage redirects in one place, if the number of redirects required for a solution is relatively small, then we can use a single entry to model all of them. Otherwise, considering factors such as CMS usability, data volume, and performance, we should still model multiple redirects in individual entries rather than implementing separate entries for each redirect. Each entry could contain data for multiple redirects. If we do not manage all the redirects in a single entry, and the number of redirects is significant, then processing all of them requires paging through results.

In any case, it is probably best to abstract the implementation as a service, specifically an ASP.NET Web API, that can map an incoming URL path to the current URL path for an entry. Then we can use ASP.NET Core middleware that runs if the request is 404 to determine pass the requested URL path to the Web API and redirect to the resultant URL, if any.

We need a content type to store information about redirects, a service to map old/vanity/etc. URLs to new URLs, and logic to apply that solution.

The content type is easy – just a repeating group with three fields, one for the legacy/vanity URL, one to select an entry, and one to enter a URL that could be on an external site. I thought about using modular blocks so that the user could either select an entry or enter an external URL, but I think the usability is better if there are separate fields for each, and I can include a note that says that an entry overrides an explicit URL.

The entry model for this content type is relatively straightforward. First, we need a class that represents the content type and UID of entries selected in a reference field.

using System.Text.Json.Serialization;

public class ReferenceField
{
    public string Uid { get; set; }

    [JsonPropertyName("_content_type_uid")]
    public string ContentType { get; set; }
}

Then we need a class that stores information about a redirect configured in the CMS, including the old/vanity URL and the current entry or URL. Note that the Reference field is implemented as a list because the data structure represents it as a list of references even if it can only have one.

using System.Collections.Generic;

public class RedirectData
{
    public string RedirectFrom { get; set; }
    public List<ReferenceField> RedirectToEntry { get; set; }
    public string RedirectToUrl { get; set; }
}

The entry model a single property of this type named Redirects that will contain the list of redirects in the entry.

using System.Collections.Generic;

public class RedirectEntryModel 
{
    public List<RedirectData> Redirects { get; set; }
}

I implemented a couple of generic interfaces with methods that any class can use.

using System;
using System.Linq;

public interface ICanBeQueryStringParams
{
    public string GetQueryString()
    {
        return String.Join("&", this.GetType().GetProperties().Where(
            prop => prop.GetValue(this, null) != null).Select(
                prop => $"{Uri.EscapeDataString(prop.Name)}={Uri.EscapeDataString(prop.GetValue(this).ToString())}"));
    }
}
...
using System.Text.RegularExpressions;

public interface ICanNormalizeUrlPaths
{
    public string NormalizeUrlPath(string urlPath)
    {
        if (urlPath == null)
        {
            return "/";
        }

        string path = Regex.Replace("/" + urlPath.TrimStart('/').Replace('\\', '/').TrimEnd('/').ToLower(),
            "//+",
            "/");
        return path;
    }
}

We need a model to pass the URL to/from the Web API.

using Deliverystack.Interfaces;

public class RedirectModel : ICanBeQueryStringParams
{
    public RedirectModel()
    {
    }

    public RedirectModel(string urlPath)
    {
        Url = urlPath;
    }

    public string Url { get; set; }
}

The Web API retrieves any entries based on the redirect content type and creates a cache of their data, basically mapping legacy/vanity URLs to entries or external URLs. Since this is just passing in a URL and passing back a URL, I implemented the same model for the request and the response – a single field for the URL. Otherwise, the implementation is basically the same as the path API I implemented to map URLs to entries. Here is the controller.

using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Logging;

using Deliverystack.DeliveryApi.Models;

[Route("[controller]")]
public class RedirectApiController : ControllerBase
{
    private readonly ILogger<PathApiController> _logger;
    RedirectApiCache _redirectApiCache;

    public RedirectApiController(
        ILogger<PathApiController> logger,
        RedirectApiCache redirectApiCache)
    {
        _logger = logger;
        _redirectApiCache = redirectApiCache;
    }

    [HttpGet]
    public ActionResult Get(RedirectModel values)
    {
        if (_redirectApiCache.ContainsPath(values.Url))
        {
            return Ok(new RedirectModel(_redirectApiCache.Get(values.Url).Url));
        }

        return Ok(string.Empty);
        //TODO: more detailed response - ErrorDetails?
//            return NotFound(values.Url + " not in " + _redirectApiCache);
    }
}

We want to be able to specify the base URL of the service in appsettings.Developer.json or elsewhere.

  "RedirectApi": {
    "BaseAddress": "http://localhost:5000"
  },

We can use a class to model the configuration.

using Microsoft.Extensions.Configuration;

public class RedirectApiConfig
{
    public RedirectApiConfig(IConfigurationSection config)
    {
        config.Bind(this);
    }

    public string BaseAddress { get; set; }
}

We want to cache the redirect information on the server hosting the Web API. This is a bit convoluted by supporting storage of the URL in different places.

using System;
using System.Collections.Generic;
using System.Threading;

using Deliverystack.Interfaces;
using Deliverystack.Models;

public class RedirectApiCache : ICanNormalizeUrlPaths
{
    public class RedirectRecordModel
    {
        public class PageDataBlock
        {
            public string Url { get; set; }
        }

        public string Url { get; set; }
        public PageDataBlock PageData { get; set; }

        public string GetUrl()
        {
            if (PageData != null && !String.IsNullOrEmpty(PageData.Url))
            {
                return PageData.Url;
            }

            return Url;
        }
    }

    Dictionary<string, string> _redirects;
    IDeliveryClient _deliveryClient = null;

    private Dictionary<string, string> Redirects
    {
        get
        {
            // wait for background threads to finish loading cache
            while (_redirects == null)
            {
                Thread.Sleep(0);
            }

            return _redirects;
        }
    }

    public RedirectApiCache(IDeliveryClient deliveryClient)
    {
        _deliveryClient = deliveryClient;
        new Thread((t) => { Reset(); }).Start();
    }

    public void Reset()
    {
        Dictionary<string, string> redirects
            = new Dictionary<string, string>();

        foreach(RedirectEntryModel redirectEntry in 
            _deliveryClient.Entries<RedirectEntryModel>("redirects"))
        {
            foreach (RedirectData redirect in redirectEntry.Redirects)
            {
                string urlPath =
                    (this as ICanNormalizeUrlPaths).NormalizeUrlPath(redirect.RedirectFrom);

                if (redirect.RedirectToEntry != null 
                    && redirect.RedirectToEntry.Count > 0)
                {
                    RedirectRecordModel pageEntryModel = _deliveryClient.AsA<RedirectRecordModel>(
                        new EntryIdentifier(redirect.RedirectToEntry[0].ContentType,
                        redirect.RedirectToEntry[0].Uid));

                    if (pageEntryModel != null)
                    {
                        redirects[urlPath] = pageEntryModel.GetUrl();
                        continue;
                    }
                }

                redirects[urlPath] = redirect.RedirectToUrl;
            }
        }

        _redirects = redirects;
    }

    public bool ContainsPath(string urlPath)
    {
        urlPath = (this as ICanNormalizeUrlPaths).NormalizeUrlPath(urlPath);
        bool result = Redirects.ContainsKey(urlPath);
        return result;
    }

    public RedirectModel Get(string urlPath)
    {
        urlPath = (this as ICanNormalizeUrlPaths).NormalizeUrlPath(urlPath);

        if (Redirects.ContainsKey(urlPath))
        {
            return new RedirectModel(Redirects[urlPath]);
        }

        return null;
    }
}

We need to think about clearing this and other caches after publication.

We want to make it easy for .NET clients to access the service.

namespace Deliverystack.DeliveryApi.Models
{
    using Deliverystack.Interfaces;
    using System;
    using System.IO;
    using System.Net;
    using System.Net.Http;
    using System.Text.Json;

    public class RedirectApiClient
    {
        private HttpClient _client;

        public RedirectApiClient(HttpClient client, RedirectApiConfig config)
        {
            _client = client;
            _client.BaseAddress = new Uri(config.BaseAddress);
        }

        public RedirectModel Get(string path)
        {
            Console.WriteLine("Get(path): " + path);
            return Get(new RedirectModel(path));
        }

        public RedirectModel Get(RedirectModel values)
        {
            try
            {
                string text = _client.GetAsync("/redirectapi?" + (values as         ICanBeQueryStringParams).GetQueryString()).Result.Content.ReadAsStringAsync().Result;

                // TODO: should probably use 404 or something
                if (String.IsNullOrEmpty(text))
                {
                    return null;
                }

                return JsonSerializer.Deserialize<RedirectModel>(text, 
                    new JsonSerializerOptions() { PropertyNameCaseInsensitive = true });
            }
            catch (Exception ex)
            {
                Exception originalException = ex;

                while (ex != null)
                {
                    Console.WriteLine(ex.GetType() + " : " + ex.Message);
                    Console.WriteLine(ex.StackTrace);
                    WebException wex = ex as WebException;

                    if (wex != null)
                    {
                        if (wex.Response != null)
                        {
                            using (var stream = wex.Response.GetResponseStream())
                            {
                                using (var reader = new StreamReader(stream))
                                {
                                    Console.WriteLine(wex.GetType() + " : Response Body:");
                                    Console.WriteLine(reader.ReadToEnd());
                                }
                            }
                        }
                    }

                    //TODO:HttpRequestException hrx = ex as HttpRequestException;
                    ex = ex.InnerException;
                }

                Console.WriteLine(originalException.StackTrace);
                throw;
            }
        }
     }
}

We need to configure and inject in ConfigureServices() of Startup.cs. This is for the client.

services.AddSingleton<RedirectApiConfig>(
    new RedirectApiConfig(Configuration.GetSection("RedirectApi")));
services.AddTransient<RedirectApiClient, RedirectApiClient>();

This is for the server.

services.AddSingleton<RedirectApiCache>(serviceProvider =>
{
    return new RedirectApiCache(
        serviceProvider.GetRequiredService<IDeliveryClient>());
});

I started out using ASP.NET Core middleware to implement the redirect, but found that it is easier to just use the razor page model that I already use for requests. The solution maps all requests that do not trigger other routes to this razor page that looks up the entry in the CMS based on the requested URL. If there is no such entry, it checks the redirect service. If there is no redirect, then it returns NotFound. We can add logic to the OnGet() method in the PageModel class to apply any matching redirect if no entry matches the requested URL.

using System;

using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.RazorPages;

using Deliverystack.DeliveryApi.Models;
using Deliverystack.Models;
using Deliverystack.Interfaces;

public abstract class PageModelBase : PageModel
{
    private readonly IDeliveryClient _deliveryClient;
    PathApiClient _pathApiClient;
    RedirectApiClient _redirectApiClient;

    // represents entry specified in URL
    public IWebPageEntryModel EntryModel { get; internal set; }

    // DI infrastructure passes CMS connection to constructor
    public PageModelBase(IDeliveryClient deliveryClient, PathApiClient pathApiClient, RedirectApiClient redirectClient)
    {
        _deliveryClient = deliveryClient;
        _pathApiClient = pathApiClient;
        _redirectApiClient = redirectClient;
    }

    [FromQuery(Name = "json")]
    public bool Json { get; set; }

    public ActionResult OnGet(string path)
    {
        if (String.IsNullOrEmpty(path))
        {
            path = "/";
        }

        PathApiEntryModel entry = _pathApiClient.GetEntry(new PathApiBindingModel() { Path = path });

        if (entry != null)
        {
            Object entryModel = _deliveryClient.AsDefaultEntryModel(
                new EntryIdentifier(entry.ContentType, entry.Uid));

            if (entryModel != null)
            {
                EntryModel = entryModel as IWebPageEntryModel;

                if (EntryModel != null)
                {
                    if (Json)
                    {
                        return Content(EntryModel.GetJson().ToString());
                    }

                    return Page();
                }
            }
        }

        RedirectModel redirect = _redirectApiClient.Get(path);

        if (redirect != null)
        {
            return Redirect(redirect.Url);
        }
        else
        {
            return NotFound();
        }
    }
}

One thought on “ASP.NET Razor Pages Legacy/Vanity URL Redirect Prototype for SaaS Headless CMS

Leave a Reply

Fill in your details below or click an icon to log in:

WordPress.com Logo

You are commenting using your WordPress.com account. Log Out /  Change )

Google photo

You are commenting using your Google account. Log Out /  Change )

Twitter picture

You are commenting using your Twitter account. Log Out /  Change )

Facebook photo

You are commenting using your Facebook account. Log Out /  Change )

Connecting to %s

%d bloggers like this: