Menu

Hello World

I decided to start a work related blog to share knowledge, solutions and experiences about different kind of technologies. I mostly work with Azure and MS technology in general so content of this blog will be concentrated to cloud and MS tech. I will use this site as a place to test and pilot new technologies:)

First I thought that I'll build this blog top of the WordPress. I have lightly experience about WordPress from a few projects and I liked it. WordPress is a good blog platform but I wanted something else.

Headless Content Management system is a quite trendy term now so I decided to investigate this more. After googling a while I found ButterCMS which works almost every tech stack and it's free for personal use. Sounds promising.

What is a Headless CMS?

Headless Content Management means that content management is decoupled from the application itself. Content of the site is fetched through API interfaces and produced/stored elsewhere. This approach makes possible to the change solution platform quite easily.

A headless content management system consists primarily of an API as well as the backend technology required to store and deliver content. Source: Keycdn

A few words about tech behind the hood of this blog

This blog is a PaaS application which is hosted in Azure. The application is a normal ASP.NET Core 2 web app created by MVC manner way. ButterCMS provides a Headless CMS solution for this blog. This Azure Web application just has a logic to fetch content data from the ButterCMS API interfaces.

Solution architecture and dependencies

The following picture describes how the architecture of this solution is created.

External dependencies

  • Blog post and page content, images
    • ButterCMS
  • Blog comments
    • Disqus
  • Analytics
    • Google Tag Manager
    • Matomo (I will later blog how to setup Matomo to the Azure.)

How to start with ButterCMS?

Next I share some code snippets how ButterCMS is used in this site. Main focus is to show how ButterCMS API is used in the application. You can find good .NET code samples from here. Let's start. First create a ButterCMS Account in ButterCMS homepage

Create KeyVault in Azure Portal

KeyVault is used to store ButterCMS ApiKey and later other secret settings values of the site.

  • Create KeyVault to your Azure subscription
  • Open your Azure AD
    • Select App registrations
    • Register a new application
      • Name: Blog
      • Application type: Web app / API
      • Sign-on URL: ex. https://localhost:12345
    • Copy application Id of the application ex. to the notepad
    • Open application settings
      • Select Keys
      • In password section put something to description field (ex. Blog). 
      • Click Save and then copy generated value to the ex. notepad
  • Go back to KeyVault
    • Select Access policies
    • Add new
      • Select principal: Blog
      • Key permissions: Key Management Operations - Get
      • Secret permissions: List and Get
  • Open Secrets section in KeyVault to input ButterCMS ApiKey
    • Generate/Import
      • Upload options: Manual
      • Name: ButterCMSApiKey
      • Value: [ApiKey]
      • Enabled: Yes

Create Visual Studio Solution/Project

  • ASP.NET Core Web Application
  • Choose Web Application (Model-View-Controller)

Install ButterCMS and KeyVault Nuget packages to your Visual Studio-project

Install-Package ButterCMS
Install-Package Microsoft.Azure.KeyVault.Core

Configure KeyVault to the application

Add AddAzureKeyVault Middleware. KeyVault DNS name, ClientId and secret will be fetched from the appsettings. 

public static IWebHost BuildWebHost(string[] args) =>
        WebHost.CreateDefaultBuilder(args)
            .ConfigureAppConfiguration((context, config) =>
            {                
                config.SetBasePath(Directory.GetCurrentDirectory())
                    .AddJsonFile("appsettings.json", optional: false)
                    .AddEnvironmentVariables();

                var builtConfig = config.Build();

                config.AddAzureKeyVault(
                    $"https://{builtConfig["AppSettings:Vault"]}.vault.azure.net/",
                    builtConfig["AppSettings:ClientId"],
                    builtConfig["AppSettings:ClientSecret"]);                
            })
            .UseStartup<Startup>()
            .Build();
}

Add ClientId, ClientSecret and Vault to the appsettings.json

Copy KeyVault application ClientId and ClientSecret values from notepad. KeyVault DNS name can be verified from the Azure Portal.

{
  "AppSettings": {
    "ContentPageName": "content_page",
    "NavigationCacheDurationInMinutes": 60,
    "PageCacheDurationInMinutes": 60,
    "PostCacheDurationInMinutes": 60,
    "PostsCacheDurationInMinutes": 30,
    "AuthorSlug": "author",
    "Vault": "keyvault",
    "ClientId": "",
    "ClientSecret": "",
    "CacheEnabled": false
  }
}

Rename HomeController to ButterCmsController and inject IConfiguration, IOptions<AppSettings>, IStringLocalizer and IMemoryCache

Through IConfiguration you can fetch secret values (ex. ButterCMS Api key) from the KeyVault. MemoryCache is used to cache content data to prevent extra queries to the ButterCMS API. IStringLocalizer makes possible to get localized content from RESX-files.

public class ButterCmsController : Controller
{
    private ButterCMSClient _cmsClient;
    private readonly AppSettings _appSettings;
    private readonly IMemoryCache _cache;
    private readonly IConfiguration _configuration;
    private readonly IStringLocalizer<SharedResources> _localizer;

    public ButterCmsController(IOptions<AppSettings> settings, IMemoryCache cache, IConfiguration configuration, IStringLocalizer<SharedResources> localizer)
    {
        _appSettings = settings.Value;
        _cache = cache;
        _configuration = configuration;
        _localizer = localizer;

        if (!string.IsNullOrEmpty(_configuration[CommonConstants.ButterCms.ButterCmsApiKey]))
        {
            _cmsClient = new ButterCMSClient(_configuration[CommonConstants.ButterCms.ButterCmsApiKey]);
        }
    }
}

Remember to change route config to point ButterCms Controller in Startup

public void Configure(IApplicationBuilder app, IHostingEnvironment env)
{
    app.UseMvc(routes =>
    {
        routes.MapRoute(
            name: "default",
            template: "{controller=ButterCms}/{action=Index}/{id?}");
    });
}

Fetch all post items

ListPosts method parameters are documented here.

public IActionResult Index()
{            
    ViewBag.BlogTitle = _localizer["HeaderBlogTitle"];
    ViewBag.AboutTheSiteDescription = _localizer["AboutTheSiteContent"];

    var cacheKey = CommonConstants.CacheKeys.AllPosts;
    IEnumerable<Post> response = null;
    var cachedData = _cache.Get<IEnumerable<Post>>(cacheKey);

    if (cachedData != null && _appSettings.CacheEnabled)
    {
        response = cachedData;
    }
    else
    {
        if (_cmsClient == null) return View();

        var dataResponse = _cmsClient.ListPosts(int.MinValue, int.MaxValue, true, null, null, null);

        if (dataResponse == null) return View();
        if (dataResponse.Data == null) return View();
        
        response = dataResponse.Data;
        _cache.Set<IEnumerable<Post>>(cacheKey, response, TimeSpan.FromMinutes(_appSettings.PostsCacheDurationInMinutes));
        
    }

    return View(response.OrderByDescending(x => x.Published).ToList());
}

Modify view which shows all posts (ex. Index.cshtml)

@using MyBlog.Web.Constants;
@model List<ButterCMS.Models.Post>;

@foreach (var post in Model)
{
    <article class="brick entry format-standard animate-this">

        @if (!string.IsNullOrEmpty(@post.FeaturedImage))
        {
            <div class="entry-thumb">
                <a id="index-post-imagelink-@Uri.EscapeDataString(post.Slug)" href="@string.Concat(CommonConstants.Routes.Blog, Uri.EscapeDataString(post.Slug))" class="thumb-link">
                    <img src="@post.FeaturedImage" alt="building">
                </a>
            </div>
        }

        <div class="entry-text">
            <div class="entry-header">
                <div class="entry-meta">
                    <span class="cat-links">
                        @foreach (var category in post.Categories)
                        {
                            <a id="post-category-@Uri.EscapeDataString(category.Slug)" href="@string.Concat(CommonConstants.Routes.Category, Uri.EscapeDataString(category.Slug))">@category.Name</a>
                        }
                    </span>
                </div>

                <h1 class="entry-title"><a id="index-post-link-@Uri.EscapeDataString(post.Slug)" href="@string.Concat(CommonConstants.Routes.Blog, Uri.EscapeDataString(post.Slug))">@post.Title</a></h1>

            </div>
            <div class="entry-excerpt">
                @if (post.Published.HasValue)
                {
                    @post.Published.Value.ToString("dd.MM.yyyy HH:mm")
                }
                <br />
                @post.Summary
            </div>
        </div>

    </article> <!-- end article -->
}

Show single blog post content

Add new action to the ButterCmsController. This action fetches post data from API with a slug-parameter.

[Route("blog/{slug}")]
public async Task<ActionResult> ShowPost(string slug)
{
    ViewBag.BlogTitle = _localizer["HeaderBlogTitle"];

    if (string.IsNullOrEmpty(slug)) return View(CommonConstants.Views.Post);

    var cacheKey = string.Concat(CommonConstants.CacheKeys.ShowPost,"_", slug);
    PostResponse response = null;
    var cachedData = _cache.Get<PostResponse>(cacheKey);
    if(cachedData != null && _appSettings.CacheEnabled)
    {
        response = cachedData;
    }
    else
    {
        if (_cmsClient == null)
            return View(CommonConstants.Views.Post);
        var postResponse = await _cmsClient.RetrievePostAsync(slug);
        if(postResponse != null)
        {
            response = postResponse;
            _cache.Set(cacheKey, response, TimeSpan.FromMinutes(_appSettings.PostCacheDurationInMinutes));                    
        }
    }
    return View(CommonConstants.Views.Post, response);
}

Add view called Post.cshtml to the View folder

@using MyBlog.Web.Constants;
@model ButterCMS.Models.PostResponse;

@section Metadata
{
    <title>@Model.Data.SeoTitle @ViewBag.BlogTitle</title>
    <meta name="description" content="@Model.Data.MetaDescription">
    <meta name="author" content="@Model.Data.Author.FirstName @Model.Data.Author.LastName">
}

<!-- content
   ================================================== -->
<section id="content-wrap" class="blog-single">
    <div class="row">
        <div class="col-twelve">

            <article class="format-standard">

                @*@if (!string.IsNullOrEmpty(Model.Data.FeaturedImage))
                {
                    <div class="content-media">
                        <div class="post-thumb">
                            <img src="@Model.Data.FeaturedImage">
                        </div>
                    </div>
                }*@

                <div class="primary-content">

                    <h1 class="page-title">@Model.Data.Title</h1>

                    <ul class="entry-meta">

                        @if (Model.Data.Published.HasValue)
                        {
                            <li class="date">@Model.Data.Published.Value.ToString("dd.MM.yyyy HH:mm")</li>
                        }

                        <li class="cat">
                            @foreach (var category in Model.Data.Categories)
                            {
                                <a id="post-category-@Uri.EscapeDataString(category.Slug)" href="@string.Concat(CommonConstants.Routes.Category, Uri.EscapeDataString(category.Slug))">@category.Name</a>
                            }
                        </li>
                    </ul>

                    <p>@Html.Raw(Model.Data.Body)</p>

                </div>

                <!-- end entry-primary -->
                <div class="pagenav group">
                    @{
                        if (Model.Meta.PreviousPost != null)
                        {
                            <div class="prev-nav">
                                <a id="post-previous-@Uri.EscapeDataString(Model.Meta.PreviousPost.Slug)" href="@string.Concat(CommonConstants.Routes.Blog, Uri.EscapeDataString(Model.Meta.PreviousPost.Slug))" rel="prev">
                                    <span>Previous</span>
                                    @Model.Meta.PreviousPost.Title
                                </a>
                            </div>
                        }
                    }
                    @if (Model.Meta.NextPost != null)
                    {
                        <div class="next-nav">
                            <a id="post-next-@Uri.EscapeDataString(Model.Meta.NextPost.Slug)" href="@string.Concat(CommonConstants.Routes.Blog, Uri.EscapeDataString(Model.Meta.NextPost.Slug))" rel="next">
                                <span>Next</span>
                                @Model.Meta.NextPost.Title
                            </a>
                        </div>
                    }
                </div>

            </article>


        </div> <!-- end col-twelve -->
    </div> <!-- end row -->

    <div class="comments-wrap">
        <div id="comments" class="row">
            <div class="col-full">
                <!--Disqus-->
                <div id="disqus_thread"></div>
                <script>
                    (function () {
                        var d = document, s = d.createElement('script');
                        s.src = 'https://disqus.com/embed.js';
                        s.setAttribute('data-timestamp', +new Date());
                        (d.head || d.body).appendChild(s);
                    })();
                </script>
                <noscript>Please enable JavaScript to view the <a href="https://disqus.com/?ref_noscript">comments powered by Disqus.</a></noscript>
            </div>
        </div>
    </div>

</section> <!-- end content -->

Blog search

Index view is used to show content of posts.

[Route("search/{s?}")]
public IActionResult Search(string s)
{
    if(string.IsNullOrEmpty(s)) return View();

    ViewBag.BlogTitle = _localizer["HeaderBlogTitle"];
    ViewBag.AboutTheSiteDescription = _localizer["AboutTheSiteContent"];

    var cacheKey = string.Concat(CommonConstants.CacheKeys.Search, "_", s);
    IEnumerable<Post> response = null;
    var cachedData = _cache.Get<IEnumerable<Post>>(cacheKey);

    if (cachedData != null && _appSettings.CacheEnabled)
    {
        response = cachedData;
    }
    else
    {
        if (_cmsClient == null)
            return View();
        var dataResponse = _cmsClient.SearchPosts(s, int.MinValue, int.MaxValue);

        if (dataResponse == null) return View(CommonConstants.Views.Index);
        if (dataResponse.Data == null) return View(CommonConstants.Views.Index);
        
        response = dataResponse.Data;
        _cache.Set(cacheKey, response, TimeSpan.FromMinutes(_appSettings.PostsCacheDurationInMinutes));
        
    }

    return View(CommonConstants.Views.Index, response.OrderByDescending(x => x.Published).ToList());
}

Show posts by category

Index view is used to show content of posts.

[Route("blog/category/{slug}")]
public async Task<ActionResult> ShowPostsByCategory(string slug)
{
    ViewBag.BlogTitle = _localizer["HeaderBlogTitle"];
    ViewBag.AboutTheSiteDescription = _localizer["AboutTheSiteContent"];

    if (string.IsNullOrEmpty(slug)) return View(CommonConstants.Views.Index);

    var cacheKey = string.Concat(CommonConstants.CacheKeys.ShowPostsByCategory, "_", slug);
    IEnumerable<Post> response = null;
    var cachedData = _cache.Get<IEnumerable<Post>>(cacheKey);

    if (cachedData != null && _appSettings.CacheEnabled)
    {
        response = cachedData;
    }
    else
    {
        if (_cmsClient == null)
            return View();

        var dataResponse = await _cmsClient.ListPostsAsync(int.MinValue, int.MaxValue, true, null, slug, null);

        if (dataResponse == null) return View(CommonConstants.Views.Index);
        if (dataResponse.Data == null) return View(CommonConstants.Views.Index);
        
        response = dataResponse.Data;
        _cache.Set(cacheKey, response, TimeSpan.FromMinutes(_appSettings.PostsCacheDurationInMinutes));
        
    }
    return View(CommonConstants.Views.Index, response.OrderByDescending(x => x.Published).ToList());
}

I have also implemented top navigation element as a MVC ViewComponent. Basically component fetches pages from ButterCMS by using ListPagesAsync method.

Blogs usually have also normal content pages (ex. about, contact etc.). Page contents are also fetched from the ButterCMS API. I will later blog how content page model is working in ButterCMS.

ButterCMS Admin interface

WYSIWYG editor

Editor is totally sufficient for a normal use. You can easily change styles and add images, links and tables.

WYSIWYG-editor supports code formatting with the most common languages/markups.

Post metadata settings

From the metadata section of the post you can change publishing date, categories, tags etc.

Post SEO settings

SEO settings allows you to change from URL of the post, title and meta description.

Summary

I implemented ButterCMS API queries to the default Visual Studio MVC project template and after hour basic functionalities of the blog are ready for use. API is very well documented and everything has worked very smoothly. I definitely recommend ButterCMS for your headless content management system.

Thank you for reading. Later I will blog more about ButterCMS. This was a very short walk-through of the features.

Links