.NET 6: Normalize JSON Before Deserialization with the Contentstack SaaS Headless CMS

This blog post demonstrates how to use .NET 6 to modify JSON from the Contentstack SaaS headless CMS before deserialization of that data to entry models, which are .NET types arranged to match the structure of the JSON.

Almost any use of a headless CMS will involve vendor-specific data structures that are best avoided.

You can use the technique described in this blog post to implement entry models based on the structure that you prefer rather than the structure defined by the vendor, which better insulates downstream code from the CMS.

In my case, I have some entries that contain a Contenstack group field named PageData, which contains several nested fields.

I want to flatten a group – to move the nested fields from within the group up into the entry itself, and then remove the group, specifically pagedata:

{
  "_version": 14,
  "locale": "en-us",
  "uid": "blt2e7b4f3feece86f4",
  "pagedata": {
    "pagetitle": "HeadlessArchitect.net Home Page",
    "navtitle": "HeadlessArchitect",
    "layout": "default",
...

Would disappear, leaving its keys in the parent object:

{
  "_version": 14,
  "locale": "en-us",
  "uid": "blt2e7b4f3feece86f4",
  "pagetitle": "HeadlessArchitect.net Home Page",
  "navtitle": "HeadlessArchitect",
  "layout": "default",
...

I started by adding an interface for classes that can adjust entries.

namespace Deliverystack.Interfaces
{
    public interface IAdjustEntryJson
    {
        public void AdjustEntry(JsonObject entry);
    }
}

Anywhere that I want to run all of a solution’s implementations, I prefer to use attributes rather than dependency injection, so I implemented an attribute based on an existing base class for attributes.

namespace Deliverystack.Core.Attributes
{
    using System;
    using System.Diagnostics;
    using System.Reflection;

    [AttributeUsage(AttributeTargets.Class)]
    public class AutoLoadEntryAdjuster: AttributeBase
    {
        public bool Enabled { get; }

        public AutoLoadEntryAdjuster(bool enabled = true)
        {
            Enabled = enabled;
        }

        public static bool IsEnabledForType(System.Type t)
        {
            foreach (var attr in
                t.GetCustomAttributes(typeof(AutoLoadEntryAdjuster)))
            {
                AutoLoadEntryAdjuster ctdAttr = attr as AutoLoadEntryAdjuster;
 
                if (!ctdAttr.Enabled)
                {
                    return ctdAttr.Enabled;
                }
            }

            return true;
        }
    }
}

I implemented the normalizer in the individual solution (HeadlessArchitect) rather than the framework (Deliverystack). This belongs in Visual Studio project separate from the website (although technically I’m still using JetBrains Rider until Visual Studio Community supports the .NET 6 SDK, sheesh Microsoft).

using System.Collections.Generic;
using System.Linq;
using System.Text.Json.Nodes;

using Deliverystack.Core.Attributes;
using Deliverystack.Interfaces;

namespace HeadlessArchitect.Website
{

    [AutoLoadEntryAdjuster]
    public class HeadlessArchitectJsonNormalizer: IAdjustEntryJson
    {
        public void AdjustEntry(JsonObject entry)
        {
            JsonNode? pageData = entry?["pagedata"];

            if (pageData == null)
            {
                return;
            }

            List<string> keys = pageData.AsObject().Select(
                child => child.Key).ToList();

            JsonObject container = pageData.AsObject();

            foreach (string key in keys)
            {
                JsonNode? move = pageData[key];
                container.Remove(key);
                entry?.Add(key, move);
            }

            entry.Remove("pagedata");
        }
    }
}

I use a custom typed client rather than the Contentstack .NET SDK.

If you use the SDK rather than a custom typed client, then I recommend adding an abstraction layer around it, where all other code accesses that abstraction layer rather than the SDK or CMS directly. Whether you use the SDK or a custom typed client, add the JSON modification logic around the data retrieval logic.

The typed client provides three relevant methods:

  • AsStringAsync(): Get the raw JSON representation of an entry as a string.
  • AsElementAsync(): Get an entry as a read-only System.Text.Json.JsonElement.
  • AsA<TType>(): Deserialize an entry to the specified type.

AsA() calls AsElementAsync(), which calls AsAsStringAsync(). To continue to allow accessing the raw JSON, AsStringAsync() remains unchanged; AsElementAsync() includes the JSON normalization logic, and AsA() deserializes after that normalization.

The existing implementations used classes in the System.Text.Json, which does not allow updates to JSON in memory. I did not change the signature of the AsElementAsync() method but changed its implementation to use classes in the System.Text.Json.Nodes namespace that support modification of the JSON.

public async Task<JsonElement> AsElementAsync(IEntryIdentifier entryId)
{
/*
    JsonDocument json = JsonDocument.Parse(await AsStringAsync(entryId));

    if (!json.RootElement.TryGetProperty("entry", out JsonElement entry))
    {
        throw new ArgumentException("Entry not found: " + entryId.GetContentType() + " : " + entryId.GetEntryId());
    }

    return entry;
*/

    JsonObject root = JsonNode.Parse(
        await AsStringAsync(entryId))["entry"].AsObject();

    foreach (Type type in AutoLoadEntryNormalizer.GetTypesWithAttribute(typeof(
        AutoLoadEntryNormalizer)))
    {
        if (AutoLoadEntryNormalizer.IsEnabledForType(type))
        {
            INormalizeEntryJson normalizer =
                Activator.CreateInstance(type) as INormalizeEntryJson;
            Trace.Assert(normalizer != null, "normalizer");
            normalizer.NormalizeEntry(root);
        }
    }

    return JsonDocument.Parse(root.ToString()).RootElement;
}

I found that, to use reflection like this with .NET 6, I still need to force my application to load all available assemblies.

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 )

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: