feat: add rss output at route /blog/feed

This commit is contained in:
Oliver Booth 2023-08-09 21:08:57 +01:00
parent 6743918f44
commit 8a1cd689ea
Signed by: oliverbooth
GPG Key ID: 725DB725A0D9EE61
11 changed files with 338 additions and 0 deletions

View File

@ -0,0 +1,15 @@
using System.Xml.Serialization;
namespace OliverBooth.Data.Rss;
public sealed class AtomLink
{
[XmlAttribute("href")]
public string Href { get; set; } = default!;
[XmlAttribute("rel")]
public string Rel { get; set; } = "self";
[XmlAttribute("type")]
public string Type { get; set; } = "application/rss+xml";
}

View File

@ -0,0 +1,33 @@
using System.Xml.Serialization;
namespace OliverBooth.Data.Rss;
public sealed class BlogChannel
{
[XmlElement("title")]
public string Title { get; set; } = default!;
[XmlElement("link", Namespace = "http://www.w3.org/2005/Atom")]
public AtomLink AtomLink { get; set; } = default!;
[XmlElement("link")]
public string Link { get; set; } = default!;
[XmlElement("description")]
public string Description { get; set; } = default!;
[XmlElement("lastBuildDate")]
public string LastBuildDate { get; set; } = default!;
[XmlElement("updatePeriod", Namespace = "http://purl.org/rss/1.0/modules/syndication/")]
public string UpdatePeriod { get; set; } = "hourly";
[XmlElement("updateFrequency", Namespace = "http://purl.org/rss/1.0/modules/syndication/")]
public string UpdateFrequency { get; set; } = "1";
[XmlElement("generator")]
public string Generator { get; set; } = default!;
[XmlElement("item")]
public List<BlogItem> Items { get; set; } = new();
}

View File

@ -0,0 +1,27 @@
using System.Xml.Serialization;
namespace OliverBooth.Data.Rss;
public sealed class BlogItem
{
[XmlElement("title")]
public string Title { get; set; } = default!;
[XmlElement("link")]
public string Link { get; set; } = default!;
[XmlElement("comments")]
public string Comments { get; set; } = default!;
[XmlElement("creator", Namespace = "http://purl.org/dc/elements/1.1/")]
public string Creator { get; set; } = default!;
[XmlElement("pubDate")]
public string PubDate { get; set; } = default!;
[XmlElement("guid")]
public string Guid { get; set; } = default!;
[XmlElement("description")]
public string Description { get; set; } = default!;
}

View File

@ -0,0 +1,13 @@
using System.Xml.Serialization;
namespace OliverBooth.Data.Rss;
[XmlRoot("rss")]
public sealed class BlogRoot
{
[XmlAttribute("version")]
public string Version { get; set; } = default!;
[XmlElement("channel")]
public BlogChannel Channel { get; set; } = default!;
}

View File

@ -0,0 +1,20 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace OliverBooth.Data.Web.Configuration;
/// <summary>
/// Represents the configuration for the <see cref="SiteConfiguration" /> entity.
/// </summary>
internal sealed class SiteConfigurationConfiguration : IEntityTypeConfiguration<SiteConfiguration>
{
/// <inheritdoc />
public void Configure(EntityTypeBuilder<SiteConfiguration> builder)
{
builder.ToTable("SiteConfig");
builder.HasKey(x => x.Key);
builder.Property(x => x.Key).HasMaxLength(50).IsRequired();
builder.Property(x => x.Value).HasMaxLength(1000);
}
}

View File

@ -0,0 +1,80 @@
namespace OliverBooth.Data.Web;
/// <summary>
/// Represents a site configuration item.
/// </summary>
public sealed class SiteConfiguration : IEquatable<SiteConfiguration>
{
/// <summary>
/// Gets or sets the name of the configuration item.
/// </summary>
/// <value>The name of the configuration item.</value>
public string Key { get; set; } = string.Empty;
/// <summary>
/// Gets or sets the value of the configuration item.
/// </summary>
/// <value>The value of the configuration item.</value>
public string? Value { get; set; }
/// <summary>
/// Returns a value indicating whether two instances of <see cref="SiteConfiguration" /> are equal.
/// </summary>
/// <param name="left">The first instance of <see cref="SiteConfiguration" /> to compare.</param>
/// <param name="right">The second instance of <see cref="SiteConfiguration" /> to compare.</param>
/// <returns>
/// <see langword="true" /> if <paramref name="left" /> and <paramref name="right" /> are equal; otherwise,
/// <see langword="false" />.
/// </returns>
public static bool operator ==(SiteConfiguration? left, SiteConfiguration? right) => Equals(left, right);
/// <summary>
/// Returns a value indicating whether two instances of <see cref="SiteConfiguration" /> are not equal.
/// </summary>
/// <param name="left">The first instance of <see cref="SiteConfiguration" /> to compare.</param>
/// <param name="right">The second instance of <see cref="SiteConfiguration" /> to compare.</param>
/// <returns>
/// <see langword="true" /> if <paramref name="left" /> and <paramref name="right" /> are not equal; otherwise,
/// <see langword="false" />.
/// </returns>
public static bool operator !=(SiteConfiguration? left, SiteConfiguration? right) => !(left == right);
/// <summary>
/// Returns a value indicating whether this instance of <see cref="SiteConfiguration" /> is equal to another
/// instance.
/// </summary>
/// <param name="other">An instance to compare with this instance.</param>
/// <returns>
/// <see langword="true" /> if <paramref name="other" /> is equal to this instance; otherwise,
/// <see langword="false" />.
/// </returns>
public bool Equals(SiteConfiguration? other)
{
if (ReferenceEquals(null, other)) return false;
if (ReferenceEquals(this, other)) return true;
return Key == other.Key;
}
/// <summary>
/// Returns a value indicating whether this instance is equal to a specified object.
/// </summary>
/// <param name="obj">An object to compare with this instance.</param>
/// <returns>
/// <see langword="true" /> if <paramref name="obj" /> is an instance of <see cref="SiteConfiguration" /> and
/// equals the value of this instance; otherwise, <see langword="false" />.
/// </returns>
public override bool Equals(object? obj)
{
return ReferenceEquals(this, obj) || obj is SiteConfiguration other && Equals(other);
}
/// <summary>
/// Gets the hash code for this instance.
/// </summary>
/// <returns>The hash code.</returns>
public override int GetHashCode()
{
// ReSharper disable once NonReadonlyMemberInGetHashCode
return Key.GetHashCode();
}
}

View File

@ -26,6 +26,12 @@ public sealed class WebContext : DbContext
/// <value>The set of article templates.</value>
public DbSet<ArticleTemplate> ArticleTemplates { get; private set; } = null!;
/// <summary>
/// Gets the set of site configuration items.
/// </summary>
/// <value>The set of site configuration items.</value>
public DbSet<SiteConfiguration> SiteConfiguration { get; private set; } = null!;
/// <inheritdoc />
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
{
@ -37,5 +43,6 @@ public sealed class WebContext : DbContext
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.ApplyConfiguration(new ArticleTemplateConfiguration());
modelBuilder.ApplyConfiguration(new SiteConfigurationConfiguration());
}
}

View File

@ -0,0 +1,13 @@
namespace OliverBooth.Middleware;
internal static class RssEndpointExtensions
{
public static IEndpointConventionBuilder MapRssFeed(this IEndpointRouteBuilder endpoints, string pattern)
{
RequestDelegate pipeline = endpoints.CreateApplicationBuilder()
.UseMiddleware<RssMiddleware>()
.Build();
return endpoints.Map(pattern, pipeline).WithDisplayName("RSS Feed");
}
}

View File

@ -0,0 +1,93 @@
using System.Diagnostics.CodeAnalysis;
using System.Xml.Serialization;
using Microsoft.EntityFrameworkCore;
using OliverBooth.Data;
using OliverBooth.Data.Blog;
using OliverBooth.Data.Rss;
using OliverBooth.Services;
namespace OliverBooth.Middleware;
/// <summary>
/// Represents the RSS middleware.
/// </summary>
internal sealed class RssMiddleware
{
private readonly BlogService _blogService;
private readonly ConfigurationService _configurationService;
/// <summary>
/// Initializes a new instance of the <see cref="RssMiddleware" /> class.
/// </summary>
/// <param name="_">The request delegate.</param>
/// <param name="blogService">The blog service.</param>
/// <param name="configurationService">The configuration service.</param>
public RssMiddleware(RequestDelegate _,
BlogService blogService,
ConfigurationService configurationService)
{
_blogService = blogService;
_configurationService = configurationService;
}
[SuppressMessage("Performance", "CA1822:Mark members as static", Justification = "Middleware")]
public async Task Invoke(HttpContext context)
{
context.Response.ContentType = "application/rss+xml";
var baseUrl = $"https://{context.Request.Host}/blog";
var blogItems = new List<BlogItem>();
foreach (BlogPost blogPost in _blogService.AllPosts.OrderByDescending(p => p.Published))
{
var url = $"{baseUrl}/{blogPost.Published:yyyy/MM/dd}/{blogPost.Slug}";
string excerpt = _blogService.GetExcerpt(blogPost, out _);
var description = $"{excerpt}<p><a href=\"{url}\">Read more...</a></p>";
_blogService.TryGetAuthor(blogPost, out Author? author);
var item = new BlogItem
{
Title = blogPost.Title,
Link = url,
Comments = $"{url}#disqus_thread",
Creator = author?.Name ?? string.Empty,
PubDate = blogPost.Published.ToString("R"),
Guid = $"{baseUrl}?pid={blogPost.Id}",
Description = description
};
blogItems.Add(item);
}
string siteTitle = _configurationService.GetSiteConfiguration("SiteTitle") ?? string.Empty;
var rss = new BlogRoot
{
Channel = new BlogChannel
{
AtomLink = new AtomLink
{
Href = $"{baseUrl}/feed/",
},
Description = $"{baseUrl}/",
LastBuildDate = DateTimeOffset.UtcNow.ToString("R"),
Link = $"{baseUrl}/",
Title = siteTitle,
Generator = $"{baseUrl}/",
Items = blogItems
}
};
var serializer = new XmlSerializer(typeof(BlogRoot));
var xmlNamespaces = new XmlSerializerNamespaces();
xmlNamespaces.Add("content", "http://purl.org/rss/1.0/modules/content/");
xmlNamespaces.Add("wfw", "http://wellformedweb.org/CommentAPI/");
xmlNamespaces.Add("dc", "http://purl.org/dc/elements/1.1/");
xmlNamespaces.Add("atom", "http://www.w3.org/2005/Atom");
xmlNamespaces.Add("sy", "http://purl.org/rss/1.0/modules/syndication/");
xmlNamespaces.Add("slash", "http://purl.org/rss/1.0/modules/slash/");
await using var writer = new StreamWriter(context.Response.BodyWriter.AsStream());
serializer.Serialize(writer, rss, xmlNamespaces);
// await context.Response.WriteAsync(document.OuterXml);
}
}

View File

@ -2,6 +2,7 @@ using Markdig;
using NLog.Extensions.Logging;
using OliverBooth.Data;
using OliverBooth.Markdown;
using OliverBooth.Middleware;
using OliverBooth.Services;
using X10D.Hosting.DependencyInjection;
@ -11,6 +12,7 @@ builder.Configuration.AddTomlFile("data/config.toml", true, true);
builder.Logging.ClearProviders();
builder.Logging.AddNLog();
builder.Services.AddHostedSingleton<LoggingService>();
builder.Services.AddSingleton<ConfigurationService>();
builder.Services.AddSingleton<TemplateService>();
builder.Services.AddSingleton(provider => new MarkdownPipelineBuilder()
@ -53,5 +55,6 @@ app.UseAuthorization();
app.MapControllers();
app.MapControllerRoute("default", "{controller=Home}/{action=Index}/{id?}");
app.MapRazorPages();
app.MapRssFeed("/blog/feed");
app.Run();

View File

@ -0,0 +1,34 @@
using Microsoft.EntityFrameworkCore;
using OliverBooth.Data;
namespace OliverBooth.Services;
/// <summary>
/// Represents a service for retrieving configuration values.
/// </summary>
public sealed class ConfigurationService
{
private readonly IDbContextFactory<WebContext> _webContextFactory;
/// <summary>
/// Initializes a new instance of the <see cref="ConfigurationService" /> class.
/// </summary>
/// <param name="webContextFactory">
/// The <see cref="IDbContextFactory{TContext}" /> for the <see cref="WebContext" />.
/// </param>
public ConfigurationService(IDbContextFactory<WebContext> webContextFactory)
{
_webContextFactory = webContextFactory;
}
/// <summary>
/// Gets the value of a site configuration key.
/// </summary>
/// <param name="key">The key of the site configuration item.</param>
/// <returns>The value of the site configuration item.</returns>
public string? GetSiteConfiguration(string key)
{
using WebContext context = _webContextFactory.CreateDbContext();
return context.SiteConfiguration.Find(key)?.Value;
}
}