From 8a1cd689eafef0b0848ef23c3448778b28b5bc8e Mon Sep 17 00:00:00 2001 From: Oliver Booth Date: Wed, 9 Aug 2023 21:08:57 +0100 Subject: [PATCH] feat: add rss output at route /blog/feed --- OliverBooth/Data/Rss/AtomLink.cs | 15 +++ OliverBooth/Data/Rss/BlogChannel.cs | 33 +++++++ OliverBooth/Data/Rss/BlogItem.cs | 27 ++++++ OliverBooth/Data/Rss/BlogRoot.cs | 13 +++ .../SiteConfigurationConfiguration.cs | 20 ++++ OliverBooth/Data/Web/SiteConfiguration.cs | 80 ++++++++++++++++ OliverBooth/Data/WebContext.cs | 7 ++ .../Middleware/RssEndpointExtensions.cs | 13 +++ OliverBooth/Middleware/RssMiddleware.cs | 93 +++++++++++++++++++ OliverBooth/Program.cs | 3 + OliverBooth/Services/ConfigurationService.cs | 34 +++++++ 11 files changed, 338 insertions(+) create mode 100644 OliverBooth/Data/Rss/AtomLink.cs create mode 100644 OliverBooth/Data/Rss/BlogChannel.cs create mode 100644 OliverBooth/Data/Rss/BlogItem.cs create mode 100644 OliverBooth/Data/Rss/BlogRoot.cs create mode 100644 OliverBooth/Data/Web/Configuration/SiteConfigurationConfiguration.cs create mode 100644 OliverBooth/Data/Web/SiteConfiguration.cs create mode 100644 OliverBooth/Middleware/RssEndpointExtensions.cs create mode 100644 OliverBooth/Middleware/RssMiddleware.cs create mode 100644 OliverBooth/Services/ConfigurationService.cs diff --git a/OliverBooth/Data/Rss/AtomLink.cs b/OliverBooth/Data/Rss/AtomLink.cs new file mode 100644 index 0000000..79a2e5d --- /dev/null +++ b/OliverBooth/Data/Rss/AtomLink.cs @@ -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"; +} diff --git a/OliverBooth/Data/Rss/BlogChannel.cs b/OliverBooth/Data/Rss/BlogChannel.cs new file mode 100644 index 0000000..d2c3bf7 --- /dev/null +++ b/OliverBooth/Data/Rss/BlogChannel.cs @@ -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 Items { get; set; } = new(); +} \ No newline at end of file diff --git a/OliverBooth/Data/Rss/BlogItem.cs b/OliverBooth/Data/Rss/BlogItem.cs new file mode 100644 index 0000000..be2fc70 --- /dev/null +++ b/OliverBooth/Data/Rss/BlogItem.cs @@ -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!; +} diff --git a/OliverBooth/Data/Rss/BlogRoot.cs b/OliverBooth/Data/Rss/BlogRoot.cs new file mode 100644 index 0000000..fe23822 --- /dev/null +++ b/OliverBooth/Data/Rss/BlogRoot.cs @@ -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!; +} diff --git a/OliverBooth/Data/Web/Configuration/SiteConfigurationConfiguration.cs b/OliverBooth/Data/Web/Configuration/SiteConfigurationConfiguration.cs new file mode 100644 index 0000000..f327901 --- /dev/null +++ b/OliverBooth/Data/Web/Configuration/SiteConfigurationConfiguration.cs @@ -0,0 +1,20 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace OliverBooth.Data.Web.Configuration; + +/// +/// Represents the configuration for the entity. +/// +internal sealed class SiteConfigurationConfiguration : IEntityTypeConfiguration +{ + /// + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("SiteConfig"); + builder.HasKey(x => x.Key); + + builder.Property(x => x.Key).HasMaxLength(50).IsRequired(); + builder.Property(x => x.Value).HasMaxLength(1000); + } +} diff --git a/OliverBooth/Data/Web/SiteConfiguration.cs b/OliverBooth/Data/Web/SiteConfiguration.cs new file mode 100644 index 0000000..0bbee47 --- /dev/null +++ b/OliverBooth/Data/Web/SiteConfiguration.cs @@ -0,0 +1,80 @@ +namespace OliverBooth.Data.Web; + +/// +/// Represents a site configuration item. +/// +public sealed class SiteConfiguration : IEquatable +{ + /// + /// Gets or sets the name of the configuration item. + /// + /// The name of the configuration item. + public string Key { get; set; } = string.Empty; + + /// + /// Gets or sets the value of the configuration item. + /// + /// The value of the configuration item. + public string? Value { get; set; } + + /// + /// Returns a value indicating whether two instances of are equal. + /// + /// The first instance of to compare. + /// The second instance of to compare. + /// + /// if and are equal; otherwise, + /// . + /// + public static bool operator ==(SiteConfiguration? left, SiteConfiguration? right) => Equals(left, right); + + /// + /// Returns a value indicating whether two instances of are not equal. + /// + /// The first instance of to compare. + /// The second instance of to compare. + /// + /// if and are not equal; otherwise, + /// . + /// + public static bool operator !=(SiteConfiguration? left, SiteConfiguration? right) => !(left == right); + + /// + /// Returns a value indicating whether this instance of is equal to another + /// instance. + /// + /// An instance to compare with this instance. + /// + /// if is equal to this instance; otherwise, + /// . + /// + public bool Equals(SiteConfiguration? other) + { + if (ReferenceEquals(null, other)) return false; + if (ReferenceEquals(this, other)) return true; + return Key == other.Key; + } + + /// + /// Returns a value indicating whether this instance is equal to a specified object. + /// + /// An object to compare with this instance. + /// + /// if is an instance of and + /// equals the value of this instance; otherwise, . + /// + public override bool Equals(object? obj) + { + return ReferenceEquals(this, obj) || obj is SiteConfiguration other && Equals(other); + } + + /// + /// Gets the hash code for this instance. + /// + /// The hash code. + public override int GetHashCode() + { + // ReSharper disable once NonReadonlyMemberInGetHashCode + return Key.GetHashCode(); + } +} diff --git a/OliverBooth/Data/WebContext.cs b/OliverBooth/Data/WebContext.cs index 5688f56..5791d92 100644 --- a/OliverBooth/Data/WebContext.cs +++ b/OliverBooth/Data/WebContext.cs @@ -26,6 +26,12 @@ public sealed class WebContext : DbContext /// The set of article templates. public DbSet ArticleTemplates { get; private set; } = null!; + /// + /// Gets the set of site configuration items. + /// + /// The set of site configuration items. + public DbSet SiteConfiguration { get; private set; } = null!; + /// 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()); } } diff --git a/OliverBooth/Middleware/RssEndpointExtensions.cs b/OliverBooth/Middleware/RssEndpointExtensions.cs new file mode 100644 index 0000000..34544e4 --- /dev/null +++ b/OliverBooth/Middleware/RssEndpointExtensions.cs @@ -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() + .Build(); + + return endpoints.Map(pattern, pipeline).WithDisplayName("RSS Feed"); + } +} diff --git a/OliverBooth/Middleware/RssMiddleware.cs b/OliverBooth/Middleware/RssMiddleware.cs new file mode 100644 index 0000000..39f65c1 --- /dev/null +++ b/OliverBooth/Middleware/RssMiddleware.cs @@ -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; + +/// +/// Represents the RSS middleware. +/// +internal sealed class RssMiddleware +{ + private readonly BlogService _blogService; + private readonly ConfigurationService _configurationService; + + /// + /// Initializes a new instance of the class. + /// + /// The request delegate. + /// The blog service. + /// The configuration service. + 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(); + + 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}

Read more...

"; + + _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); + } +} diff --git a/OliverBooth/Program.cs b/OliverBooth/Program.cs index 026984b..5746e7e 100644 --- a/OliverBooth/Program.cs +++ b/OliverBooth/Program.cs @@ -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(); +builder.Services.AddSingleton(); builder.Services.AddSingleton(); 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(); diff --git a/OliverBooth/Services/ConfigurationService.cs b/OliverBooth/Services/ConfigurationService.cs new file mode 100644 index 0000000..249bcc8 --- /dev/null +++ b/OliverBooth/Services/ConfigurationService.cs @@ -0,0 +1,34 @@ +using Microsoft.EntityFrameworkCore; +using OliverBooth.Data; + +namespace OliverBooth.Services; + +/// +/// Represents a service for retrieving configuration values. +/// +public sealed class ConfigurationService +{ + private readonly IDbContextFactory _webContextFactory; + + /// + /// Initializes a new instance of the class. + /// + /// + /// The for the . + /// + public ConfigurationService(IDbContextFactory webContextFactory) + { + _webContextFactory = webContextFactory; + } + + /// + /// Gets the value of a site configuration key. + /// + /// The key of the site configuration item. + /// The value of the site configuration item. + public string? GetSiteConfiguration(string key) + { + using WebContext context = _webContextFactory.CreateDbContext(); + return context.SiteConfiguration.Find(key)?.Value; + } +}