Skip to content

Commit

Permalink
feature: upgrade rss behaviour (#347)
Browse files Browse the repository at this point in the history
* feat: add blogPostsPerPage configuration to RssFeedController

* feat: define tests for new functionality in RssFeedController
new test expects that rss returns full blog post content when requested

* feat: implement new behaviour on RssFeedController
if requested, rss will return full content for the blog posts, as well as limit the number of returned posts to the BlogPostsPerPage configuration

* fix: add ModelState check for GetRssFeed

* feat: update tests to include new test when the user requests n posts

* feat: implement the request of n number of posts in the rss feed
when also requesting the content

* fix: correct S1125: remove unnecessary boolean literal

* feat: add dropdown to RSS button to display both feeds

* fix: add a bit of separation between text and icon in dropdown

* feat: rename RSS feeds to final names

* feat: update tests to include new CDATA content for Markup

* feat: implement Markup in SyndicationContent

* feat: delete icon from dropdown itmes

* feat: throw if blog post short description and content are both null

* refactor: move assignment of numberOfBlogPosts deeper
to GetBlogPostsItemsWithContent where it is actually used

* feat: add test to cover the usage of BlogPostsPerPage
when the numberOfBlogPosts is not specified in the call (RSS)
  • Loading branch information
elementh authored Sep 6, 2024
1 parent 5facfc2 commit a4785b5
Show file tree
Hide file tree
Showing 3 changed files with 281 additions and 14 deletions.
55 changes: 44 additions & 11 deletions src/LinkDotNet.Blog.Web/Controller/RssFeedController.cs
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
using LinkDotNet.Blog.Web.Features;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Options;
using InvalidOperationException = System.InvalidOperationException;

namespace LinkDotNet.Blog.Web.Controller;

Expand All @@ -22,6 +23,7 @@ public sealed class RssFeedController : ControllerBase
private static readonly XmlWriterSettings Settings = CreateXmlWriterSettings();
private readonly string description;
private readonly string blogName;
private readonly int blogPostsPerPage;
private readonly IRepository<BlogPost> blogPostRepository;

public RssFeedController(
Expand All @@ -34,18 +36,26 @@ public RssFeedController(

description = introductionConfiguration.Value.Description;
blogName = applicationConfiguration.Value.BlogName;
blogPostsPerPage = applicationConfiguration.Value.BlogPostsPerPage;
this.blogPostRepository = blogPostRepository;
}

[ResponseCache(Duration = 1200)]
[HttpGet]
public async Task<IActionResult> GetRssFeed()
public async Task<IActionResult> GetRssFeed([FromQuery] bool withContent = false, [FromQuery] int? numberOfBlogPosts = null)
{
if (!ModelState.IsValid)
{
return BadRequest(ModelState);
}

var url = $"{Request.Scheme}://{Request.Host}{Request.PathBase}";
var introductionDescription = MarkdownConverter.ToPlainString(description);
var feed = new SyndicationFeed(blogName, introductionDescription, new Uri(url))
{
Items = await GetBlogPostItems(url),
Items = withContent
? await GetBlogPostsItemsWithContent(url, numberOfBlogPosts)
: await GetBlogPostItems(url),
};

using var stream = new MemoryStream();
Expand All @@ -66,28 +76,28 @@ private static XmlWriterSettings CreateXmlWriterSettings()
{
var settings = new XmlWriterSettings
{
Encoding = Encoding.UTF8,
NewLineHandling = NewLineHandling.Entitize,
Indent = true,
Async = true,
Encoding = Encoding.UTF8, NewLineHandling = NewLineHandling.Entitize, Indent = true, Async = true,
};
return settings;
}

private static SyndicationItem CreateSyndicationItemFromBlogPost(string url, BlogPostRssInfo blogPost)
{
var blogPostUrl = url + $"/blogPost/{blogPost.Id}";
var shortDescription = MarkdownConverter.ToPlainString(blogPost.ShortDescription);

var content = MarkdownConverter.ToMarkupString(blogPost.ShortDescription ?? blogPost.Content ??
throw new InvalidOperationException("Blog post must have either short description or content."));

var item = new SyndicationItem(
blogPost.Title,
shortDescription,
default(SyndicationContent),
new Uri(blogPostUrl),
blogPost.Id,
blogPost.UpdatedDate)
{
PublishDate = blogPost.UpdatedDate,
LastUpdatedTime = blogPost.UpdatedDate,
ElementExtensions = { new XElement("image", blogPost.PreviewImageUrl) },
ElementExtensions = { CreateCDataElement(content.Value), new XElement("image", blogPost.PreviewImageUrl), },
};

AddCategories(item.Categories, blogPost);
Expand All @@ -105,16 +115,39 @@ private static void AddCategories(Collection<SyndicationCategory> categories, Bl
private async Task<IEnumerable<SyndicationItem>> GetBlogPostItems(string url)
{
var blogPosts = await blogPostRepository.GetAllByProjectionAsync(
s => new BlogPostRssInfo(s.Id, s.Title, s.ShortDescription, s.UpdatedDate, s.PreviewImageUrl, s.Tags),
s => new BlogPostRssInfo(s.Id, s.Title, s.ShortDescription, null, s.UpdatedDate, s.PreviewImageUrl, s.Tags),
f => f.IsPublished,
orderBy: post => post.UpdatedDate);
return blogPosts.Select(bp => CreateSyndicationItemFromBlogPost(url, bp));
}

private async Task<IEnumerable<SyndicationItem>> GetBlogPostsItemsWithContent(string url, int? numberOfBlogPosts)
{
numberOfBlogPosts ??= blogPostsPerPage;

var blogPosts = await blogPostRepository.GetAllByProjectionAsync(
s => new BlogPostRssInfo(s.Id, s.Title, null, s.Content, s.UpdatedDate, s.PreviewImageUrl, s.Tags),
f => f.IsPublished,
orderBy: post => post.UpdatedDate,
pageSize: numberOfBlogPosts.Value);
return blogPosts.Select(bp => CreateSyndicationItemFromBlogPost(url, bp));
}

private static XmlElement CreateCDataElement(string htmlContent)
{
var doc = new XmlDocument();
var cdataSection = doc.CreateCDataSection(htmlContent);
var element = doc.CreateElement("description");
element.AppendChild(cdataSection);
return element;
}


private sealed record BlogPostRssInfo(
string Id,
string Title,
string ShortDescription,
string? ShortDescription,
string? Content,
DateTime UpdatedDate,
string PreviewImageUrl,
IEnumerable<string> Tags);
Expand Down
11 changes: 10 additions & 1 deletion src/LinkDotNet.Blog.Web/Features/Home/Components/NavMenu.razor
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,16 @@
<a class="nav-link" href="AboutMe"><i class="profile"></i> About
me</a></li>
}
<li><a class="nav-link" href="/feed.rss"><i class="rss2"></i> RSS</a></li>

<li class="nav-item dropdown">
<a class="nav-link dropdown-toggle" href="#" id="rssDropdown" role="button" data-bs-toggle="dropdown" aria-expanded="false" aria-label="RSS Selector">
<i class="rss2"></i> RSS
</a>
<ul class="dropdown-menu" aria-labelledby="rssDropdown">
<li><a class="dropdown-item" href="/feed.rss" aria-label="RSS with All Posts">All Posts (Summary)</a></li>
<li><a class="dropdown-item" href="/feed.rss?withContent=true" aria-label="RSS with Full Content">Most Recent Posts (Full Content)</a></li>
</ul>
</li>

<AccessControl CurrentUri="@currentUri"></AccessControl>
<li><ThemeToggler Class="nav-link"></ThemeToggler></li>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@ public async Task ShouldCreateRssFeed()
var content = Encoding.UTF8.GetString(xml.FileContents);
content.ShouldMatch(
"""
.*
<rss version="2.0">
<channel>
<title>Test</title>
Expand All @@ -73,8 +74,9 @@ public async Task ShouldCreateRssFeed()
<guid isPermaLink="false">2</guid>
<link>http://localhost//blogPost/2</link>
<title>2</title>
<description>Short 2</description>
<pubDate>Wed, 01 Jun 2022 00:00:00.*</pubDate>
<description><!\[CDATA\[<p><strong>Short 2</strong></p>
]]></description>
<image>preview2</image>
</item>
<item>
Expand All @@ -83,12 +85,235 @@ public async Task ShouldCreateRssFeed()
<category>C#</category>
<category>.NET</category>
<title>1</title>
<description>Short 1</description>
<pubDate>Sun, 01 May 2022 00:00:00.*</pubDate>
<description><!\[CDATA\[<p>Short 1</p>
]]></description>
<image>preview1</image>
</item>
</channel>
</rss>
""");
}

[Fact]
public async Task ShouldReturnFullContentIfRequested()
{
var repository = new Repository<BlogPost>();
var request = Substitute.For<HttpRequest>();
request.Scheme.Returns("http");
request.Host.Returns(new HostString("localhost"));
request.PathBase.Returns(PathString.FromUriComponent("/"));
var httpContext = Substitute.For<HttpContext>();
httpContext.Request.Returns(request);
var controllerContext = new ControllerContext
{
HttpContext = httpContext,
};
var config = Options.Create(new ApplicationConfigurationBuilder()
.WithBlogName("Test")
.Build());

var introduction = new IntroductionBuilder()
.WithDescription("Description")
.Build();
var introductionConfig = Options.Create(introduction);
var blogPost1 = new BlogPostBuilder()
.WithTitle("1")
.WithContent("Content1")
.WithPreviewImageUrl("preview1")
.WithUpdatedDate(new DateTime(2022, 5, 1))
.WithTags("C#", ".NET")
.Build();
blogPost1.Id = "1";
var blogPost2 = new BlogPostBuilder()
.WithTitle("2")
.WithContent("**Content 2**")
.WithPreviewImageUrl("preview2")
.WithUpdatedDate(new DateTime(2022, 6, 1))
.Build();
blogPost2.Id = "2";
await repository.StoreAsync(blogPost1);
await repository.StoreAsync(blogPost2);
var cut = new RssFeedController(introductionConfig, config, repository)
{
ControllerContext = controllerContext,
};

var xml = await cut.GetRssFeed(withContent: true) as FileContentResult;

xml.ShouldNotBeNull();
var content = Encoding.UTF8.GetString(xml.FileContents);
content.ShouldMatch(
"""
.*
<rss version="2.0">
<channel>
<title>Test</title>
<link>http://localhost/</link>
<description>Description</description>
<item>
<guid isPermaLink="false">2</guid>
<link>http://localhost//blogPost/2</link>
<title>2</title>
<pubDate>Wed, 01 Jun 2022 00:00:00.*</pubDate>
<description><!\[CDATA\[<p><strong>Content 2</strong></p>
]]></description>
<image>preview2</image>
</item>
<item>
<guid isPermaLink="false">1</guid>
<link>http://localhost//blogPost/1</link>
<category>C#</category>
<category>.NET</category>
<title>1</title>
<pubDate>Sun, 01 May 2022 00:00:00.*</pubDate>
<description><!\[CDATA\[<p>Content1</p>
]]></description>
<image>preview1</image>
</item>
</channel>
</rss>
""");
}

[Fact]
public async Task ShouldReturnNPostsIfRequested()
{
var repository = new Repository<BlogPost>();
var request = Substitute.For<HttpRequest>();
request.Scheme.Returns("http");
request.Host.Returns(new HostString("localhost"));
request.PathBase.Returns(PathString.FromUriComponent("/"));
var httpContext = Substitute.For<HttpContext>();
httpContext.Request.Returns(request);
var controllerContext = new ControllerContext
{
HttpContext = httpContext,
};
var config = Options.Create(new ApplicationConfigurationBuilder()
.WithBlogName("Test")
.Build());

var introduction = new IntroductionBuilder()
.WithDescription("Description")
.Build();
var introductionConfig = Options.Create(introduction);
var blogPost1 = new BlogPostBuilder()
.WithTitle("1")
.WithShortDescription("Short 1")
.WithPreviewImageUrl("preview1")
.WithUpdatedDate(new DateTime(2022, 5, 1))
.WithTags("C#", ".NET")
.Build();
blogPost1.Id = "1";
var blogPost2 = new BlogPostBuilder()
.WithTitle("2")
.WithContent("**Content 2**")
.WithPreviewImageUrl("preview2")
.WithUpdatedDate(new DateTime(2022, 6, 1))
.Build();
blogPost2.Id = "2";
await repository.StoreAsync(blogPost1);
await repository.StoreAsync(blogPost2);
var cut = new RssFeedController(introductionConfig, config, repository)
{
ControllerContext = controllerContext,
};

var xml = await cut.GetRssFeed(withContent: true, numberOfBlogPosts: 1) as FileContentResult;

xml.ShouldNotBeNull();
var content = Encoding.UTF8.GetString(xml.FileContents);
content.ShouldMatch(
"""
.*
<rss version="2.0">
<channel>
<title>Test</title>
<link>http://localhost/</link>
<description>Description</description>
<item>
<guid isPermaLink="false">2</guid>
<link>http://localhost//blogPost/2</link>
<title>2</title>
<pubDate>Wed, 01 Jun 2022 00:00:00.*</pubDate>
<description><!\[CDATA\[<p><strong>Content 2</strong></p>
]]></description>
<image>preview2</image>
</item>
</channel>
</rss>
""");
}

[Fact]
public async Task ShouldRespectBlogPostsPerPage()
{
var repository = new Repository<BlogPost>();
var request = Substitute.For<HttpRequest>();
request.Scheme.Returns("http");
request.Host.Returns(new HostString("localhost"));
request.PathBase.Returns(PathString.FromUriComponent("/"));
var httpContext = Substitute.For<HttpContext>();
httpContext.Request.Returns(request);
var controllerContext = new ControllerContext
{
HttpContext = httpContext,
};
var config = Options.Create(new ApplicationConfigurationBuilder()
.WithBlogName("Test")
.WithBlogPostsPerPage(1)
.Build());

var introduction = new IntroductionBuilder()
.WithDescription("Description")
.Build();
var introductionConfig = Options.Create(introduction);
var blogPost1 = new BlogPostBuilder()
.WithTitle("1")
.WithShortDescription("Short 1")
.WithPreviewImageUrl("preview1")
.WithUpdatedDate(new DateTime(2022, 5, 1))
.WithTags("C#", ".NET")
.Build();
blogPost1.Id = "1";
var blogPost2 = new BlogPostBuilder()
.WithTitle("2")
.WithContent("**Content 2**")
.WithPreviewImageUrl("preview2")
.WithUpdatedDate(new DateTime(2022, 6, 1))
.Build();
blogPost2.Id = "2";
await repository.StoreAsync(blogPost1);
await repository.StoreAsync(blogPost2);
var cut = new RssFeedController(introductionConfig, config, repository)
{
ControllerContext = controllerContext,
};

var xml = await cut.GetRssFeed(withContent: true) as FileContentResult;

xml.ShouldNotBeNull();
var content = Encoding.UTF8.GetString(xml.FileContents);
content.ShouldMatch(
"""
.*
<rss version="2.0">
<channel>
<title>Test</title>
<link>http://localhost/</link>
<description>Description</description>
<item>
<guid isPermaLink="false">2</guid>
<link>http://localhost//blogPost/2</link>
<title>2</title>
<pubDate>Wed, 01 Jun 2022 00:00:00.*</pubDate>
<description><!\[CDATA\[<p><strong>Content 2</strong></p>
]]></description>
<image>preview2</image>
</item>
</channel>
</rss>
""");
}
}

0 comments on commit a4785b5

Please sign in to comment.