Skip to content

Commit 494cbd1

Browse files
committed
Add RSS feed endpoint with config and tests
Introduced a new RSS feed feature at /feed, including FeedOptions for configuration, FeedEndpointExtensions for endpoint mapping and cache key logic, and RssFeedDocument for generating valid RSS 2.0 XML. Registered feed options and endpoint in Program.cs, updated appsettings.json, and added unit tests for feed logic and output.
1 parent d0b94de commit 494cbd1

7 files changed

Lines changed: 292 additions & 0 deletions

File tree

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
namespace BlazorBlog.Tests.Feeds
2+
{
3+
using System;
4+
using BlazorBlog.Feeds;
5+
using Xunit;
6+
7+
public class FeedEndpointExtensionsTests
8+
{
9+
[Fact]
10+
public void BuildCacheKey_ChangesWhenSignalVersionChanges()
11+
{
12+
var baseUri = new Uri("https://example.com/");
13+
14+
var first = FeedEndpointExtensions.BuildCacheKey(baseUri, itemCount: 20, signalVersion: 1);
15+
var second = FeedEndpointExtensions.BuildCacheKey(baseUri, itemCount: 20, signalVersion: 2);
16+
17+
Assert.NotEqual(first, second);
18+
}
19+
20+
[Fact]
21+
public void Normalize_ClampsConfigurableLimits()
22+
{
23+
var options = FeedEndpointExtensions.Normalize(new FeedOptions
24+
{
25+
Title = " ",
26+
Description = "",
27+
ItemCount = 500,
28+
CacheMinutes = 0
29+
});
30+
31+
Assert.Equal("Blazor Blog", options.Title);
32+
Assert.Equal("Latest published posts from Blazor Blog.", options.Description);
33+
Assert.Equal(100, options.ItemCount);
34+
Assert.Equal(1, options.CacheMinutes);
35+
}
36+
}
37+
}
Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
namespace BlazorBlog.Tests.Feeds
2+
{
3+
using System;
4+
using System.Linq;
5+
using System.Xml.Linq;
6+
using BlazorBlog.Application.Models;
7+
using BlazorBlog.Feeds;
8+
using Xunit;
9+
10+
public class RssFeedDocumentTests
11+
{
12+
[Fact]
13+
public void Create_ProducesValidRssWithAbsoluteUrlsAndUtcDates()
14+
{
15+
var posts = new[]
16+
{
17+
new BlogPostVm
18+
{
19+
Title = "Hello RSS",
20+
Slug = "hello-rss",
21+
Introduction = "Intro",
22+
Content = "<p>Safe content</p>",
23+
PublishedAt = new DateTime(2026, 5, 24, 10, 30, 0, DateTimeKind.Utc)
24+
}
25+
};
26+
27+
var xml = RssFeedDocument.Create(
28+
posts,
29+
new Uri("https://example.com/blog/"),
30+
new Uri("https://example.com/blog/feed"),
31+
new FeedOptions { Title = "Test Blog", Description = "Latest posts" },
32+
summary => summary,
33+
content => content);
34+
35+
var document = XDocument.Parse(xml);
36+
XNamespace contentNamespace = "http://purl.org/rss/1.0/modules/content/";
37+
XNamespace atomNamespace = "http://www.w3.org/2005/Atom";
38+
39+
Assert.Equal("rss", document.Root?.Name.LocalName);
40+
Assert.Equal("2.0", document.Root?.Attribute("version")?.Value);
41+
42+
var channel = document.Root?.Element("channel");
43+
Assert.NotNull(channel);
44+
Assert.Equal("Test Blog", channel!.Element("title")?.Value);
45+
Assert.Equal("https://example.com/blog/", channel.Element("link")?.Value);
46+
47+
var selfLink = channel.Elements(atomNamespace + "link").Single();
48+
Assert.Equal("https://example.com/blog/feed", selfLink.Attribute("href")?.Value);
49+
50+
var item = channel.Elements("item").Single();
51+
Assert.Equal("https://example.com/blog/posts/hello-rss", item.Element("link")?.Value);
52+
Assert.Equal("Sun, 24 May 2026 10:30:00 GMT", item.Element("pubDate")?.Value);
53+
Assert.Equal("<p>Safe content</p>", item.Element(contentNamespace + "encoded")?.Value);
54+
}
55+
56+
[Fact]
57+
public void Create_SanitizesSummaryAndContentThroughCallbacks()
58+
{
59+
var posts = new[]
60+
{
61+
new BlogPostVm
62+
{
63+
Title = "Unsafe",
64+
Slug = "unsafe",
65+
Introduction = "<script>alert(1)</script>Summary",
66+
Content = "<script>alert(2)</script>Content",
67+
PublishedAt = DateTime.UtcNow
68+
}
69+
};
70+
71+
var xml = RssFeedDocument.Create(
72+
posts,
73+
new Uri("https://example.com/"),
74+
new Uri("https://example.com/feed"),
75+
new FeedOptions(),
76+
summary => summary.Replace("<script>alert(1)</script>", string.Empty, StringComparison.Ordinal),
77+
content => content.Replace("<script>alert(2)</script>", string.Empty, StringComparison.Ordinal));
78+
79+
var document = XDocument.Parse(xml);
80+
XNamespace contentNamespace = "http://purl.org/rss/1.0/modules/content/";
81+
var item = document.Root!.Element("channel")!.Element("item")!;
82+
83+
Assert.Equal("Summary", item.Element("description")?.Value);
84+
Assert.Equal("Content", item.Element(contentNamespace + "encoded")?.Value);
85+
}
86+
}
87+
}
Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
namespace BlazorBlog.Feeds
2+
{
3+
using BlazorBlog.Application.Contracts;
4+
using BlazorBlog.Infrastructure.Utilities;
5+
using BlazorBlog.Utilities;
6+
using Ganss.Xss;
7+
using Microsoft.Extensions.Caching.Memory;
8+
using Microsoft.Extensions.Options;
9+
10+
public static class FeedEndpointExtensions
11+
{
12+
public static IEndpointRouteBuilder MapFeedEndpoint(this IEndpointRouteBuilder endpoints)
13+
{
14+
endpoints.MapGet(
15+
"/feed",
16+
async (
17+
HttpContext context,
18+
IBlogPostService blogPostService,
19+
IHtmlSanitizer htmlSanitizer,
20+
IMemoryCache cache,
21+
IBlogCacheSignal cacheSignal,
22+
IOptions<FeedOptions> options,
23+
CancellationToken cancellationToken) =>
24+
{
25+
var feedOptions = Normalize(options.Value);
26+
var baseUri = GetBaseUri(context.Request);
27+
var feedUri = new Uri(baseUri, "feed");
28+
var cacheKey = BuildCacheKey(baseUri, feedOptions.ItemCount, cacheSignal.Version);
29+
30+
var xml = await cache.GetOrCreateAsync(
31+
cacheKey,
32+
async entry =>
33+
{
34+
entry.AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(feedOptions.CacheMinutes);
35+
36+
var posts = await blogPostService.GetRecentBlogPostsAsync(
37+
feedOptions.ItemCount,
38+
cancellationToken: cancellationToken);
39+
40+
return RssFeedDocument.Create(
41+
posts,
42+
baseUri,
43+
feedUri,
44+
feedOptions,
45+
summary => htmlSanitizer.Sanitize(summary),
46+
content => BlogContentRenderer.RenderSafeHtml(content, htmlSanitizer));
47+
});
48+
49+
return Results.Content(xml ?? string.Empty, "application/rss+xml; charset=utf-8");
50+
})
51+
.WithName("Feed")
52+
.WithTags("SEO");
53+
54+
return endpoints;
55+
}
56+
57+
public static string BuildCacheKey(Uri baseUri, int itemCount, long signalVersion)
58+
=> $"feed:rss:{baseUri.AbsoluteUri}:{itemCount}:{signalVersion}";
59+
60+
public static FeedOptions Normalize(FeedOptions options)
61+
=> new()
62+
{
63+
Title = string.IsNullOrWhiteSpace(options.Title) ? "Blazor Blog" : options.Title.Trim(),
64+
Description = string.IsNullOrWhiteSpace(options.Description)
65+
? "Latest published posts from Blazor Blog."
66+
: options.Description.Trim(),
67+
ItemCount = Math.Clamp(options.ItemCount, 1, 100),
68+
CacheMinutes = Math.Clamp(options.CacheMinutes, 1, 1440)
69+
};
70+
71+
private static Uri GetBaseUri(HttpRequest request)
72+
{
73+
var pathBase = request.PathBase.HasValue ? request.PathBase.Value : string.Empty;
74+
return new Uri($"{request.Scheme}://{request.Host}{pathBase}/");
75+
}
76+
}
77+
}

BlazorBlog/Feeds/FeedOptions.cs

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
namespace BlazorBlog.Feeds
2+
{
3+
public sealed class FeedOptions
4+
{
5+
public const string SectionName = "Feed";
6+
7+
public string Title { get; set; } = "Blazor Blog";
8+
9+
public string Description { get; set; } = "Latest published posts from Blazor Blog.";
10+
11+
public int ItemCount { get; set; } = 20;
12+
13+
public int CacheMinutes { get; set; } = 10;
14+
}
15+
}
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
namespace BlazorBlog.Feeds
2+
{
3+
using System.Globalization;
4+
using System.Xml.Linq;
5+
using BlazorBlog.Application.Models;
6+
7+
public static class RssFeedDocument
8+
{
9+
private static readonly XNamespace ContentNamespace = "http://purl.org/rss/1.0/modules/content/";
10+
11+
public static string Create(
12+
IEnumerable<BlogPostVm> posts,
13+
Uri baseUri,
14+
Uri feedUri,
15+
FeedOptions options,
16+
Func<string, string> sanitizeSummary,
17+
Func<string, string> renderSafeContent)
18+
{
19+
var channel = new XElement(
20+
"channel",
21+
new XElement("title", options.Title),
22+
new XElement("link", baseUri.AbsoluteUri),
23+
new XElement("description", options.Description),
24+
new XElement("language", "en"),
25+
new XElement("lastBuildDate", FormatRssDate(DateTime.UtcNow)));
26+
27+
foreach (var post in posts)
28+
{
29+
var postUri = new Uri(baseUri, $"posts/{Uri.EscapeDataString(post.Slug)}");
30+
var publishedAt = (post.PublishedAt ?? DateTime.UtcNow).ToUniversalTime();
31+
var safeSummary = sanitizeSummary(post.Introduction ?? string.Empty);
32+
var safeContent = renderSafeContent(post.Content ?? string.Empty);
33+
34+
channel.Add(
35+
new XElement(
36+
"item",
37+
new XElement("title", post.Title),
38+
new XElement("link", postUri.AbsoluteUri),
39+
new XElement("guid", new XAttribute("isPermaLink", "true"), postUri.AbsoluteUri),
40+
new XElement("pubDate", FormatRssDate(publishedAt)),
41+
new XElement("description", new XCData(safeSummary)),
42+
new XElement(ContentNamespace + "encoded", new XCData(safeContent))));
43+
}
44+
45+
var document = new XDocument(
46+
new XDeclaration("1.0", "utf-8", null),
47+
new XElement(
48+
"rss",
49+
new XAttribute("version", "2.0"),
50+
new XAttribute(XNamespace.Xmlns + "content", ContentNamespace),
51+
new XAttribute(XNamespace.Xmlns + "atom", "http://www.w3.org/2005/Atom"),
52+
channel));
53+
54+
channel.AddFirst(
55+
new XElement(
56+
XName.Get("link", "http://www.w3.org/2005/Atom"),
57+
new XAttribute("href", feedUri.AbsoluteUri),
58+
new XAttribute("rel", "self"),
59+
new XAttribute("type", "application/rss+xml")));
60+
61+
return document.ToString(SaveOptions.DisableFormatting);
62+
}
63+
64+
private static string FormatRssDate(DateTime value)
65+
=> value.ToUniversalTime().ToString("R", CultureInfo.InvariantCulture);
66+
}
67+
}

BlazorBlog/Program.cs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ namespace BlazorBlog
33
using System.Net;
44
using System.Text.Json;
55

6+
using BlazorBlog.Feeds;
67
using Components.Account;
78
using BlazorBlog.Health;
89
using BlazorBlog.Infrastructure;
@@ -68,6 +69,7 @@ public static async Task Main(string[] args)
6869
builder.Services.AddHealthChecks()
6970
.AddCheck<DatabaseHealthCheck>("database", tags: ["ready"])
7071
.AddCheck<DiskSpaceHealthCheck>("disk", tags: ["ready"]);
72+
builder.Services.Configure<FeedOptions>(builder.Configuration.GetSection(FeedOptions.SectionName));
7173

7274
ValidateStartupConfiguration(builder);
7375

@@ -123,6 +125,7 @@ public static async Task Main(string[] args)
123125
.AddInteractiveServerRenderMode();
124126

125127
app.MapAdditionalIdentityEndpoints();
128+
app.MapFeedEndpoint();
126129

127130
app.MapGet("/health", (ILogger<Program> logger) =>
128131
{

BlazorBlog/appsettings.json

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,12 @@
3131
}
3232
]
3333
},
34+
"Feed": {
35+
"Title": "Blazor Blog",
36+
"Description": "Latest published posts from Blazor Blog.",
37+
"ItemCount": 20,
38+
"CacheMinutes": 10
39+
},
3440
"AdminUser": {
3541
"Name": "Admin",
3642
"Email": "admin@bblog.com",

0 commit comments

Comments
 (0)