Creating a Blog With Blazor Pt 1

I decided to start work on this blog about a month from the time of writing this. I want a space to publish all the projects I'm doing this summer. I decided to use .NET 5 and Blazor to familiarize myself with the technology.

I created some initial goals for the project:

  • I wanted a dark / pink color theme while resisting gradients
  • The post display will parse markdown into html
  • There will be a homepage will be a chronological feed.

I set these goals to consider what I actually wanted and minimize scope creep. It was futile; there is always scope creep.

Data Layer

The next step was to design the system that would dictate how the data underlying the blog would be stored and retrieved. For this I decided to use PostgreSQL and the filesystem. The filesystem would store the markdown content of each post, and the database would store everything else. Theoretically, I could store the markdown in the database, but fitting all those characters into a column just never sits right with me, so each post will get its own file.

use pg_dump to create some nice graphs of db schema

An overview of the database schema

Posts will be retrieved from the /srv directory which will be mounted as a docker volume for easy management. Each post will be in the directory structure as

/srv/FracturedCode/<Topic.SeoKeyword>/<Post.SeoName>

The url structure to access the post will look like the end of the directory structure.

/<Topic.SeoKeyword>/<Post.SeoName>

Then, I used pgadmin to create the database. I already had a postgres container with a port exposed internally. I used the SSH tunnel option in pgadmin in order to access the port.

https://i.imgur.com/HaoyBPx.png

The configuration of the Post table

Code Implementation of Data Layer

Next it was time to use this data layer in C#. By this point I had already created a "Blazor WebAssembly" solution in Visual Studio. I added a "BusinessLogic" project to the solution.

https://i.imgur.com/phYkR3C.png

I installed EF Core with the Npgsql.EntityFrameworkCore.PostgreSQL package and created the typical "DbContext" class and modeled the tables as "DTOs" or "entities".

using Microsoft.EntityFrameworkCore;
using System;

namespace BusinessLogic.Database
{
    public class FracturedCodeContext : DbContext
    {
        public DbSet<Post> Posts { get; set; }
        public DbSet<Topic> Topics { get; set; }
    }
}
using System;
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;

namespace BusinessLogic.Database
{
    public class Post
    {
        [Key]
        public Guid PostGuid { get; set; }
        public Guid TopicGuid { get; set; }
        public bool IsPublished { get; set; }
        public DateTime PublishedDate { get; set; }
        [MaxLength(100)]
        public string Name { get; set; }
        [MaxLength(100)]
        public string SeoName { get; set; }
        [MaxLength(200)]
        public string Preview { get; set; }

        [ForeignKey(nameof(TopicGuid))]
        public Topic Topic { get; set; }
    }
}

Then I made a service to retrieve the post contents from the filesystem. This is pretty simple but the scope of the service might increase as I add more features to the site.

public class PostContentService : IPostContentService
{
    private PostContentConfiguration _options { get; init; }
    public PostContentService(IOptions<PostContentConfiguration> options)
    {
        _options = options.Value;
    }
    public string GetPostContent(string topicSeoKeyword, string postSeoName) =>
        File.ReadAllText(Path.Combine(_options.PostDirectory, topicSeoKeyword, postSeoName));
}

I created an interface for the service and a configuration model that I added to appsettings.json in order to configure the directory the service looks for posts in. This also has the added benefit of being able to use different directories for the development environment by using Development.appsettings.json.

At this point it was time to move on to the website backend.

Blazor Webassembly

At this point I had already started the WASM project, but it's not difficult to get started. Go to create a new project in Visual Studio and choose "Blazor App". After you click "Create" it will ask which model you want to use: Blazor Server or Blazor Webassembly. For the purposes of this project I was interested in Webassembly.

What is Blazor Webassembly?

You can see what Microsoft says about it here, but I will summarize. You might call webassembly a "version" of the Assembly language meant to run on the web, in browsers. The cool thing about webassembly is we could run any low-level program we want. This means the .NET Runtime can be compiled to webassembly and run in the browser. So we can feed compiled C# IL into the runtime for our web app. Blazor implements this idea. The Blazor Webassembly model is to download the entire website to the client, backed by Razor and all our view models. The Blazor project doesn't typically send HTML. Instead, an API is queried whenever we want data and razor is used to render the models client side. And because the entire .NET Runtime exists on the client, we can run pretty much any C# code we want.

Blazor Website Backend

This section starts in the "FracturedCode.Server" project. First I needed to be able to access the data layer that I had created earlier. I did this by adding IPostContentService and FracturedCodeContext as services to Startup.cs.

public void ConfigureServices(IServiceCollection services)
{

    services.AddControllersWithViews();
    services.AddRazorPages();

    services.AddDbContext<FracturedCodeContext>(options => options.UseNpgsql(Configuration.GetConnectionString("DefaultConnection")));

    services.Configure<PostContentConfiguration>(Configuration.GetSection(nameof(PostContentConfiguration)));
    services.AddScoped<IPostContentService, PostContentService>();
}

You can see the services require some configuration, which is taken from appsettings.json. Here's an example appsettings for the development environment:

{
  "PostContentConfiguration": {
    "PostDirectory": "E:\\repos\\FracturedCode\\TestContent"
  },
  "ConnectionStrings": {
    "DefaultConnection": "Host=localhost;Database=FracturedCode;Username=postgres;Password=nope"
  }
}

Now by dependency injection these services are added to all of the controllers:

public class HomeController : Controller
{
    public IPostContentService _postContentService { get; init; }
    public FracturedCodeContext _dbContext { get; init; }
    public HomeController(IPostContentService postContentService, FracturedCodeContext dbContext)
    {
        _postContentService = postContentService;
        _dbContext = dbContext;
    }

}

I only had two pages planned out at this point: the homepage and the post page for viewing post contents. I created two view models to represent the contents of these pages that will be sent to the client: PostModel and TopicModel. These get placed in the ".Shared" project. It is called shared because it is consumed by both the Server and the Client projects. Data is sent between the server and the client in json format and parsed into the models in the Shared project.

public class TopicModel
{
    public string Name { get; set; }
    public string Keyword { get; set; }
}
public class PostModel
{
    public DateTime PublishedDate { get; init; }
    public string Name { get; init; }
    public string SeoName { get; init; }
    public string Content { get; init; } = "loading";
    public string Preview { get; init; }

    public TopicModel Topic { get; init; }
}

You'll notice that these models don't have all the data presented by our database objects, some parts have different names, and indeed have some of their own exclusive features. This is because it is generally considered best practice for the view models to remove unnecessary data that users don't need. Some fields, like PostModel.Preview, aren't even driven by the database.

And finally I wrote the API endpoints that drive our content delivery. GetAllPostPreviews feeds the post previews that will be clickable links on the homepage, and GetPost simply returns a PostModel of the requested post with the important Content field populated containing markdown.

You can see I awaited the post content Task created earlier after I had awaited the database call. This makes it so that I could be querying the database and accessing the filesystem at the same time, thus theoretically reducing response time.

public class HomeController : Controller
{
    private IPostContentService _postContentService { get; init; }
    private FracturedCodeContext _dbContext { get; init; }
    public HomeController(IPostContentService postContentService, FracturedCodeContext dbContext)
    {
        _postContentService = postContentService;
        _dbContext = dbContext;
    }

    [HttpGet]
    [Route("api/{keyword}/{post}")]
    [ProducesDefaultResponseType(typeof(OkObjectResult))]
    [ProducesErrorResponseType(typeof(NotFoundResult))]
    public async Task<IActionResult> GetPost(string keyword, string post)
    {
        var dbPostTask = _dbContext.Post.SingleOrDefaultAsync(p =>
            p.SeoName == post
            && p.IsPublished
            && p.PublishedDate <= DateTime.Now
        );

        string content = await _postContentService.GetPostContentAsync(keyword, post);
        if (content is null)
            return NotFound();

        Post dbPost = await dbPostTask.ConfigureAwait(false);
        
        if (dbPost is not null)
        {
            return Ok(new PostModel
            {
                PublishedDate = dbPost.PublishedDate,
                Name = dbPost.Name,
                SeoName = dbPost.SeoName,
                Content = content
            });
        }
        else
        {
            return NotFound();
        }
    }

    [HttpGet]
    [Route("api/PostPreviews")]
    [ProducesDefaultResponseType(typeof(OkObjectResult))]
    public async Task<IActionResult> GetAllPostsPreviews()
    {
        List<Post> postEntities = await _dbContext.Post
            .Where(p => p.IsPublished && p.PublishedDate <= DateTime.Now && !p.Topic.IsDeleted)
            .Include(p => p.Topic)
            .ToListAsync()
            .ConfigureAwait(false);

        List<TopicModel> topics = postEntities.Select(p => p.Topic).Distinct()
            .Select(t => new TopicModel
            {
                Name = t.Name,
                Keyword = t.SeoKeyword
            })
            .ToList();

        List<PostModel> postModels = postEntities.ConvertAll(p => new PostModel
        {
            Name = p.Name,
            SeoName = p.SeoName,
            PublishedDate = p.PublishedDate,
            Topic = topics.Single(t => t.Name == p.Topic.Name),
            Preview = p.Preview
        });

        return Ok(postModels);
    }
}

Tune in next week for the website frontend, where I had some tough challenges creating CSS animations, creating assets and creating some style guides.