Prototype Content Delivery .NET Typed Clients for the Contentstack SaaS Headless CMS

This blog post describes a prototype for a .NET typed client that accesses HTTPS content delivery services that return JSON from the Contentstack SaaS headless content management system. This post also contains some important notes about appsettings.json and exceptions in Blazor, which I understand uses JavaScript and some JavaScript components to run C# as WebAssembly in the browser.

I developed this typed client after learning about certain limitations of Blazor, but the information in this post may apply to calling any vendors HTTPS APIs and especially avoiding stale DNS caches and socket exhaustion, whether working with Blazor or not. After working with .NET SDKs from multiple headless CMS vendors, I seem to prefer the knowledge, awareness, and control that result from calling HTTP APIs directly, although this could be cumbersome for complex operations such as defining queries at runtime, for which I would instead use a search index anyway (or possibly GraphQL).

Starting from the beginning, while I was writing that blog post about Blazor that used HttpClient:

I read in ASP.NET Core in Action by Andrew Lock about the potential for socket exhaustion while calling HTTPS APIs from .NET code, specifically because the runtime cannot access sockets for two minutes after their release. This may not be important for client applications that do not place many API calls, but at least for some server applications, this results in the need for HttpClientFactory that re-uses connection resources as described in one of Andrew’s blog posts:

In my first attempt with Blazor, I knew that I was hacking a hard-coded solution rather than following best practice, but I have also been reading about typed clients that use HttpClientFactory:

I would not describe my result as best practice, especially regarding configuration, dependency injection, and asynchronous programming. It may serve as a functional pointer to myself or someone else for possible eventual completion.

IContentDeliveryClient.cs defines an interface that abstracts accessing a content repository.

using System.Threading.Tasks;

using Newtonsoft.Json.Linq;

public interface IContentDeliveryClient
{
    public abstract Task<JObject> AsObjectAsync(
        EntryIdentifier entryId);

    public abstract Task<TType> AsObjectAsync<TType>(
        EntryIdentifier entryId);

    public abstract Task<string> AsStringAsync(
        EntryIdentifier entryId);
}

ContentstackDeliveryClientOptions.cs helps to pass values from appsettings.json to ContentstackDeliveryClient.

using Microsoft.Extensions.Configuration;

public class ContentstackDeliveryClientOptions
{
    public static ContentstackDeliveryClientOptions GetDefault(
        IConfigurationRoot config)
    {
        return GetNamed(config, "ContentstackOptions");
    }

    public static ContentstackDeliveryClientOptions GetNamed(
      IConfigurationRoot config, string name)
    {
        return GetFromConfig(config.GetSection(name));
    }

    public static ContentstackDeliveryClientOptions GetFromConfig(
        IConfigurationSection config)
    {
        return new ContentstackDeliveryClientOptions()
        {
            AccessToken = config["AccessToken"],
            ApiKey = config["ApiKey"],
            BaseAddress = config["BaseAddress"],
            Environment = config["Environment"]
        };
    }

    public ContentstackDeliveryClientOptions()
    {
    }

    public string ApiKey { get; set; }

    public string AccessToken { get; set; }

    public string Environment { get; set; }

    public string BaseAddress { get; set; }
}

ContentstackDeliveryClient.cs implements IContentDeliveryClient to access Contentstack.

using System;
using System.Linq;
using System.Net.Http;
using System.Threading.Tasks;

using Newtonsoft.Json.Linq;

public class ContentstackDeliveryClient : IContentDeliveryClient
{
    private HttpClient _httpClient;

    public ContentstackDeliveryClient(HttpClient httpClient,
        ContentstackDeliveryClientOptions options)
    {
        _httpClient = httpClient;
        ApiKey = options.ApiKey;
        AccessToken = options.AccessToken;
        BaseAddress = options.BaseAddress;
        Environment = options.Environment;
    }

    public string ApiKey
    {
        set
        {
            _httpClient.DefaultRequestHeaders.Add("api_key", value);
        }
    }

    public string AccessToken
    {
        set
        {
            _httpClient.DefaultRequestHeaders.Add("access_token", value);
        }
    }

    public string Environment
    {
        set
        {
            // Contentstack ignores this header, so this is just for storage
            // for a query string parameter value calculated in AsStringAsync().
            _httpClient.DefaultRequestHeaders.Add("environment", value);
        }

        private get
        {
            return _httpClient.DefaultRequestHeaders.GetValues("environment").First();
        }
    }

    public string BaseAddress
    {
        set
        {
            _httpClient.BaseAddress = new Uri(value);
        }
    }

    public Task<string> AsStringAsync(EntryIdentifier entryId)
    {
        // https://www.contentstack.com/docs/developers/apis/content-delivery-api/#single-entry
        return _httpClient.GetStringAsync(
            $"/v3/content_types/{entryId.ContentType}/entries/{entryId.EntryUid}?environment={Environment}");
    }

    public async Task<JObject> AsObjectAsync(EntryIdentifier entryId)
    {
        return JObject.Parse(await AsStringAsync(entryId));
    }

    public async Task<TType> AsObjectAsync<TType>(EntryIdentifier entryId)
    {
        //TODO: configure serialization - jsonconverters for modular blocks
        return JObject.Parse(await AsStringAsync(entryId)).SelectToken(
            "$.entry").ToObject<TType>();
    }
}

EntryIdentifier.cs represents the elements of an entry identifier in a repository, including its content type and unique ID, but could include its language, version, repository identifier, and any additional data for retrieving the entry.

public struct EntryIdentifier
{
    public EntryIdentifier(string contentType, string entryUid)
    {
        ContentType = contentType;
        EntryUid = entryUid;
    }

    public string ContentType { get; set; }

    public string EntryUid { get; set; }
}

ProductModel.cs represents an entry based on a content type that contains fields to define products.

public class ProductModel
{
    public string Title { get; set; }
    public string Description { get; set; }
    public string Url { get; set; }
}

ProductRepository.cs abstracts an IContentDeliveryClient as a repository that returns instances of ProductModel.

using System.Threading.Tasks;

public class ProductRepository
{
    IContentDeliveryClient _client;

    public ProductRepository(IContentDeliveryClient client)
    {
        _client = client;
    }

    public Task<ProductModel> GetProduct(string productId)
    {
        return _client.AsObjectAsync<ProductModel>(
            new EntryIdentifier("product", productId));
    }
}

Program.cs configures dependency injection.

using System.Threading.Tasks;

using Microsoft.AspNetCore.Components.WebAssembly.Hosting;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;

using Newtonsoft.Json;

public class Program
{
    public static async Task Main(string[] args)
    {
        var builder = WebAssemblyHostBuilder.CreateDefault(args);
        builder.RootComponents.Add<App>("app");

        JsonConvert.DefaultSettings = () => new JsonSerializerSettings()
        {
            //TODO: configure serializer settings including JsonConverters for modular blocks
        };

        ContentstackDeliveryClientOptions options = new ContentstackDeliveryClientOptions();
        builder.Configuration.GetSection("ContentstackOptions").Bind(options);
        builder.Services.AddSingleton<ContentstackDeliveryClientOptions>(options);
        builder.Services.AddHttpClient<IContentDeliveryClient, ContentstackDeliveryClient>();
        builder.Services.AddSingleton<ProductRepository, ProductRepository>();

        await builder.Build().RunAsync();
    }
}

Some developers may prefer to use System.Text.Json rather than Newtonsoft.Json. I do not know the differences, but I am familiar with NewtonSoft, specifically for JsonConverters, so I used NewtonSoft. Whether using System.Text.Json or Newtonsonft, it can be important to configure serialization, such as to register JsonConverters for Contentstack modular blocks. In the past, I used an attribute to identify JsonConverters:

I want to try JsonConvert.DefaultSettings, which I hope to automate by attributing the JsonConverters accordingly.

Program registers a ContentstackDeliveryClientOptions object configured from the default ContentstackOptions section of appsettings.json, which looks like this:

{
  "ContentstackOptions": {
    "ApiKey": "//TODO: value",
    "AccessToken": "//TODO: value",
    "Environment": "//TODO: value",
    "BaseAddress": "https://cdn.contentstack.io"
  }
}

Along with an HttpClient, the dependency injection framework will pass that ContentstackDeliveryClientOptions object using this configuration to the constructor of ContentstackDeliveryClient.

Program then registers ContentstackDeliveryClient to serve IContentDeliveryClient and a ProductRepository. In this configuration, the dependency injection framework will pass an instance of ContentstackDeliveryClient with the default configuration to the ProductRepository constructor. You can use named typed clients and otherwise configure and work with multiple repositories using other configuration sections from appsettings.json and elsewhere.

FetchData.razor demonstrates use of the typed client.

@page "/fetchdata"
@inject IContentDeliveryClient Content // Generic Contenstack repository
@inject ProductRepository Products  // Repository with only products

<pre>
@message
</pre>

@code 
{
    private string message = String.Empty;

    protected override async Task OnInitializedAsync()
    {
        await Content.AsObjectAsync(
            new EntryIdentifier("aa", "blt8c3febf4493333cf")).ContinueWith((jObject) =>
        {
            message += jObject.Result.ToString() + Environment.NewLine;
        });

        await Products.GetProduct("blt5690d9068e4652f4").ContinueWith((product) =>
        {
            message += "Title: " + product.Result.Title;
        });
    }
}

Some things I found worth noting about Blazor apps:

  • appsetting.json goes in the wwwroot subdirectory. This makes sense because it is available to client applications, but remember this if your settings appear empty because it differs from ASP.NET project models that store appsettings.json outside of the document root. It also differs from models that prevent clients from accessing appsettings.json and web.config even when they are within the document root.
  • Apparently by default Blazor renders exception details to the browser console.

Here is example output after fixing some defects and/or configuration:

Example Blazor output

One other little detail I found is that Contentstack requires specification of environment by query string parameter, unlike with api_key and access_token for which Contentstack also supports specification as HTTP headers.

6 thoughts on “Prototype Content Delivery .NET Typed Clients for the Contentstack 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: