ASP.NET Core Web API Prototype, Part III: Prototype Implementation

This series of blog post explains an approach to implementing ASP.NET Core Web API servers and .NET clients and provides an example implementation of a service that maps URL paths to information about corresponding entries in the Contentstack SaaS headless CMS. This entry describes the prototype implementation.

Some developers may prefer to implement separate Visual Studio projects or even solutions for the server and client components of Web API solutions and one or more additional projects for the models that they share. I find it more convenient to maintain all the related code in a single solution. It is not much code, and it is relatively simple. I implemented this in a Web API project that I referenced from a client project in a separate solution, so that I can run both client and server from separate instances of Visual Studio. In the server project, I changed the ports used for HTTP and HTTPS services.

I used the following classes to implement a Web API that returns the content type, ID, URL, and title of zero or more related entries in the Contentstack SaaS headless CMS. The API allows callers to specify the URL path of an entry and whether to retrieve information about its ancestors, siblings, and descendants.

The PathApiEntryModel class represents data about an entry including its content type, ID, URL, and title.

public struct PathApiEntryModel 
{
    public string ContentType { get; set; }
    public string Uid { get; set; }
    public string Url { get; set; }
    public string Title { get; set; }
}

The PathApiCache class retrieves data about all entries with URLs from the CMS into a dictionary sorted by URL path using the ContentstackDeliveryClient that I had developed previously refactored for this implementation. PathApiCache caches data about all of the entries in the CMS that have URLs. I will post this code with the final entry in this series, but basically you pass it a URL path as a string and it passes back a PathApiEntryModel containing information about the entry that has that URL path.

public PathApiEntryModel GetEntry(string path)
{
    return GetCurrentGeneration(path, excludeSelf: false, includeSiblings: false)[0];
}

The PathApiBindingModel class exposes simple properties that the ASP.NET infrastructure sets from request parameters when before calling the action method.

  • Path: The path of the entry to retrieve, such as /path/to/entry.
  • Ancestors: The number of levels of ancestors to include. For example, given the path /path/to/entry, if ancestors is 1, retrieve both /path/to and /path/to/entry.
  • Descendants: The number of levels of ancestor entries to include. For example, given the path /path, if descendants is 2, retrieve /path, /path/to, and /path/to/entry.
  • ExcludeSelf: Exclude the entry at path from the results (for us with Siblings to select only sibling entries)
  • Siblings: Whether to include sibling entries of the entry with the URL path.
  • PageIndex: Page index for paging through large result sets.
  • PageSize: The number of records in a page.
public class PathApiBindingModel
{
    public string Path { get; set; } = null;
    public int Ancestors { get; set; } = 0;
    public int Descendants { get; set; } = 0;
    public bool ExcludeSelf { get; set; } = false;
    public bool Siblings { get; set; } = false;
    public int PageIndex { get; set; } = 0;

    private int _pageSize = 100;

    public int PageSize
    {
        get
        {
            return _pageSize;
        }

        set
        {
            if (value > 100)
            {
                throw new ArgumentException("PageSize exceeded");
            }

            _pageSize = value;
        }
    }
}

The PathApiConfig class exposes client configuration properties from appsettings.config and elsewhere, such as the base address of the web service.

namespace Deliverystack.DeliveryApi.Models
{
    using Microsoft.Extensions.Configuration;

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

        public string BaseAddress {  get; set; }
    }
}

The PathApiResultModel class represents results of the API call that the infrastructure will serialize as JSON.

  • Ancestors: The number of ancestor entries included in the results.
  • CurrentGeneration: The number of current generation (self and siblings) entries included in the results.
  • Descendants: The number of descendant entries included in the results.
  • Total: The total number of entries included in the results.
  • Entries: Individual entries (returned in pages of 100 at most).
using System.Collections.Generic;

using Deliverystack.DeliveryApi.Models;

public class PathApiResultModel
{
    public int Ancestors { get; set; }
    public int CurrentGeneration { get; set; }
    public int Descendants { get; set;  }
    public int Total
    {
        get
        {
            return Ancestors + CurrentGeneration + Descendants;
        }
    }

    public IEnumerable<PathApiEntryModel> Entries { get; set; }
}

The PathApiClient class abstracts the HTTPS API for clients.

namespace Deliverystack.DeliveryApi.Models
{
    using System;
    using System.Net.Http;

    using Newtonsoft.Json.Linq;

    public class PathApiClient
    {
        private HttpClient _client;

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

        public PathApiEntryModel Get(string path)
        {
            return JObject.Parse(_client.GetStringAsync(
                "/pathapi?path=" + path).Result).SelectToken(
                    "entries[0]").ToObject<PathApiEntryModel>();
        }
    }
}

Finally we have the controller that implements the Web API service.

using System;
using System.Collections.Generic;
using System.Linq;

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

using Deliverystack.Core.Models;
using Deliverystack.DeliveryApi.Models;

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

    public PathApiController(
        ILogger<PathApiController> logger, 
        PathApiCache pathCache)
    {
        _logger = logger;
        _pathCache = pathCache;
    }

    [HttpGet]
    public ActionResult Get(PathApiBindingModel values)
    {
        if (String.IsNullOrWhiteSpace(values.Path))
        {
            return BadRequest(new ProblemDetails()
            {
                Status = 400,
                Title = "Path required",
                // Type = "https://datatracker.ietf.org/doc/html/rfc7231#section-6.5.1"
            });
        }

        if (!_pathCache.ContainsPath(values.Path))
        {
            return NotFound(new ProblemDetails
            {
                Title = "Entry identified by path not found",
            });
        }

        List<PathApiEntryModel> list = new();
        PathApiResultModel result = new();

        if (values.Ancestors > 0)
        {
            PathApiEntryModel[] ancestors = _pathCache.GetAncestors(values.Path);

            if (values.Ancestors > ancestors.Length)
            {
                values.Ancestors = ancestors.Length;
            }

            for (int i = ancestors.Length - values.Ancestors; i < ancestors.Length; i++)
            {
                list.Add(ancestors[i]);
                result.Ancestors++;
            }
        }

        foreach(PathApiEntryModel entry in _pathCache.GetCurrentGeneration(
            values.Path, values.ExcludeSelf, values.Siblings))
        {
            list.Add(entry);
            result.CurrentGeneration++;
        }

        if (values.Descendants > 0)
        {
            foreach (PathApiEntryModel child 
                in _pathCache.GetDescendants(values.Path, values.Descendants))
            {
                result.Descendants++;
                list.Add(child);
            }
        }

        result.Entries = list.Skip(values.PageIndex * values.PageSize).Take(values.PageSize);
        return Ok(result);
    }
}

To configure the server, in a Web API project, add a singleton for PathApiConfig after configuring IDeliveryClient to Startip.cs. I did not want to use a singleton or build and discard a service provider to retrieve the IDeliveryClient that I had just registered, which would have used the forbidden service locator model, so I deferred its construction until its use (after the infrastructure builds the service provider), which uses service locator anyway. Again, this depends on the ContentstackDeliveryClient that I had implemented and described previously.

services.AddSingleton<ContentstackDeliveryClientOptions>(
    new ContentstackDeliveryClientOptions(Configuration.GetSection("ContentstackOptions")));
services.AddHttpClient<IDeliveryClient, ContentstackDeliveryClient>();
services.AddSingleton<PathApiCache>(
    sp => { return new PathApiCache(sp.GetRequiredService<IDeliveryClient>()); });

To configure the client, add a PathApi section to appsettings.json to specify the location of the Web API service.

"PathApi": {
  "BaseAddress":  "https://localhost:5551"
},

Then, in ConfigureServices() of Startup.cs, register PathApiClient as a transient.

services.AddSingleton<ContentstackDeliveryClientOptions>(options);
services.AddHttpClient<IDeliveryClient, ContentstackDeliveryClient>();
services.AddSingleton<PathApiConfig>(new PathApiConfig(Configuration.GetSection("PathApi")));
services.AddTransient<PathApiClient, PathApiClient>();

I am using this with a refactored version of the Razor Pages solution that I had described previously, which uses a single razor page view template that applies different layouts and partial views, and for which I updated the @page directive to process all URLs. I updated index.cshtml to apply to all URL requests.

@page "{**path}"
@model IndexModel 
... 

I updated index.cshtml.cs to use the new logic to set the entry model and to support a json query string parameter that renders the raw JSON rather than the razor page.

public class IndexModel : PageModelBase
{
  public IndexModel(IDeliveryClient client, PathApiClient pathClient) : base(client, pathClient)
  {
  }
}
...
using System;

using Microsoft.AspNetCore.Mvc.RazorPages;

using Deliverystack.Core.Models;
using Deliverystack.DeliveryApi.Models;
using Deliverystack.DeliveryClient;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.ViewFeatures;

public abstract class PageModelBase : PageModel
{
    private readonly IDeliveryClient _deliveryClient;
    private readonly PathApiClient _pathClient;

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

    // DI infrastructure passes CMS connection to constructor
    public PageModelBase(IDeliveryClient deliveryClient, PathApiClient pathClient)
    {
        _deliveryClient = deliveryClient;
        _pathClient = pathClient;
    }

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

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

        PathApiEntryModel entry = _pathClient.Get(path);
        EntryModel = _deliveryClient.AsDefaultEntryModel(
            new EntryIdentifier(entry.ContentType, entry.Uid)) as PageEntryModelBase;

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

        return Page();
    }
}

I refactored the ASP.NET Razor Pages solution into multiple projects. I will post a link to all of the code in the next and final post in this series.

One thought on “ASP.NET Core Web API Prototype, Part III: Prototype Implementation

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: