From da53160af40d47ae5f2a35ebec2b8e4d9cdd5f99 Mon Sep 17 00:00:00 2001 From: ThomasWhittington <46750921+ThomasWhittington@users.noreply.github.com> Date: Wed, 3 Jul 2024 13:05:16 +0100 Subject: [PATCH] Added basic caching layer (#106) Co-authored-by: Tom Whittington --- .../WebApplicationBuilderExtensions.cs | 4 +- .../Services/ContentService.cs | 17 ++- .../Services/CsPagesCacheService.cs | 26 ++++ .../Services/ICacheService.cs | 7 ++ .../Views/Shared/_BetaHeader.cshtml | 2 +- src/Dfe.ContentSupport.Web/appsettings.json | 1 + .../WebApplicationBuilderExtensionsTests.cs | 4 +- .../Services/ContentServiceTests.cs | 111 +++++++++++++++++- 8 files changed, 165 insertions(+), 7 deletions(-) create mode 100644 src/Dfe.ContentSupport.Web/Services/CsPagesCacheService.cs create mode 100644 src/Dfe.ContentSupport.Web/Services/ICacheService.cs diff --git a/src/Dfe.ContentSupport.Web/Extensions/WebApplicationBuilderExtensions.cs b/src/Dfe.ContentSupport.Web/Extensions/WebApplicationBuilderExtensions.cs index 3c8e58e..ec77f42 100644 --- a/src/Dfe.ContentSupport.Web/Extensions/WebApplicationBuilderExtensions.cs +++ b/src/Dfe.ContentSupport.Web/Extensions/WebApplicationBuilderExtensions.cs @@ -1,5 +1,6 @@ using Dfe.ContentSupport.Web.Configuration; using Dfe.ContentSupport.Web.Http; +using Dfe.ContentSupport.Web.Models.Mapped; using Dfe.ContentSupport.Web.Services; namespace Dfe.ContentSupport.Web.Extensions; @@ -12,7 +13,8 @@ public static void InitDependencyInjection(this WebApplicationBuilder app) app.Configuration.GetSection("Contentful").Bind(contentfulOptions); app.Services.AddSingleton(contentfulOptions); - + app.Services + .AddTransient>, CsPagesCacheService>(); app.Services.AddTransient(); app.Services.AddTransient(); diff --git a/src/Dfe.ContentSupport.Web/Services/ContentService.cs b/src/Dfe.ContentSupport.Web/Services/ContentService.cs index dfa3a37..ccbc1a0 100644 --- a/src/Dfe.ContentSupport.Web/Services/ContentService.cs +++ b/src/Dfe.ContentSupport.Web/Services/ContentService.cs @@ -5,7 +5,8 @@ namespace Dfe.ContentSupport.Web.Services; -public class ContentService(IContentfulService contentfulService) : IContentService +public class ContentService(IContentfulService contentfulService, ICacheService> cache) + : IContentService { public async Task GetContent(string slug, bool isPreview = false) { @@ -41,12 +42,22 @@ public async Task> GetCsPages() return pages.ToList(); } - private async Task> GetContentSupportPages( + public async Task> GetContentSupportPages( string field, string value, bool isPreview) { + var key = $"{field}_{value}"; + var fromCache = cache.GetFromCache(key); + if (fromCache is not null) + { + return fromCache; + } + var builder = QueryBuilder.New.ContentTypeIs(nameof(ContentSupportPage)) .FieldEquals($"fields.{field}", value); var result = await contentfulService.ContentfulClient(isPreview).Query(builder); - return result.Select(page => new CsPage(page)).ToList(); + var pages = result.Select(page => new CsPage(page)).ToList(); + + cache.AddToCache(key, pages); + return pages; } } \ No newline at end of file diff --git a/src/Dfe.ContentSupport.Web/Services/CsPagesCacheService.cs b/src/Dfe.ContentSupport.Web/Services/CsPagesCacheService.cs new file mode 100644 index 0000000..4313318 --- /dev/null +++ b/src/Dfe.ContentSupport.Web/Services/CsPagesCacheService.cs @@ -0,0 +1,26 @@ +using System.Diagnostics.CodeAnalysis; +using Dfe.ContentSupport.Web.Models.Mapped; +using Microsoft.Extensions.Caching.Memory; + +namespace Dfe.ContentSupport.Web.Services; + +[ExcludeFromCodeCoverage] +public class CsPagesCacheService(IMemoryCache cache, IConfiguration configuration) + : ICacheService> +{ + private readonly int _cacheTimeOutMs = configuration.GetSection("CacheTimeOutMs").Get(); + + public void AddToCache(string key, List item) + { + MemoryCacheEntryOptions options = new() + { + AbsoluteExpirationRelativeToNow = TimeSpan.FromMilliseconds(_cacheTimeOutMs) + }; + cache.Set(key, item, options); + } + + public List? GetFromCache(string key) + { + return cache.Get>(key); + } +} \ No newline at end of file diff --git a/src/Dfe.ContentSupport.Web/Services/ICacheService.cs b/src/Dfe.ContentSupport.Web/Services/ICacheService.cs new file mode 100644 index 0000000..9ddb8dc --- /dev/null +++ b/src/Dfe.ContentSupport.Web/Services/ICacheService.cs @@ -0,0 +1,7 @@ +namespace Dfe.ContentSupport.Web.Services; + +public interface ICacheService +{ + void AddToCache(string key,T item); + T? GetFromCache(string key); +} \ No newline at end of file diff --git a/src/Dfe.ContentSupport.Web/Views/Shared/_BetaHeader.cshtml b/src/Dfe.ContentSupport.Web/Views/Shared/_BetaHeader.cshtml index 53d7ea6..a0c8d92 100644 --- a/src/Dfe.ContentSupport.Web/Views/Shared/_BetaHeader.cshtml +++ b/src/Dfe.ContentSupport.Web/Views/Shared/_BetaHeader.cshtml @@ -3,7 +3,7 @@ @* Beta *@ This is a new service - your - + feedback will help us to improve it. diff --git a/src/Dfe.ContentSupport.Web/appsettings.json b/src/Dfe.ContentSupport.Web/appsettings.json index 96fe079..2b120c4 100644 --- a/src/Dfe.ContentSupport.Web/appsettings.json +++ b/src/Dfe.ContentSupport.Web/appsettings.json @@ -1,4 +1,5 @@ { + "CacheTimeOutMs": 30000, "Logging": { "LogLevel": { "Default": "Information", diff --git a/tests/Dfe.ContentSupport.Web.Tests/Extensions/WebApplicationBuilderExtensionsTests.cs b/tests/Dfe.ContentSupport.Web.Tests/Extensions/WebApplicationBuilderExtensionsTests.cs index c178168..3946775 100644 --- a/tests/Dfe.ContentSupport.Web.Tests/Extensions/WebApplicationBuilderExtensionsTests.cs +++ b/tests/Dfe.ContentSupport.Web.Tests/Extensions/WebApplicationBuilderExtensionsTests.cs @@ -1,5 +1,6 @@ using Dfe.ContentSupport.Web.Extensions; using Dfe.ContentSupport.Web.Http; +using Dfe.ContentSupport.Web.Models.Mapped; using Microsoft.AspNetCore.Builder; namespace Dfe.ContentSupport.Web.Tests.Extensions; @@ -16,7 +17,8 @@ public void Builder_Contains_Correct_Services() { typeof(IContentService), typeof(IContentfulService), - typeof(IHttpContentfulClient) + typeof(IHttpContentfulClient), + typeof(ICacheService>) }; foreach (var type in types) builder.Services.Where(o => o.ServiceType == type).Should().ContainSingle(); diff --git a/tests/Dfe.ContentSupport.Web.Tests/Services/ContentServiceTests.cs b/tests/Dfe.ContentSupport.Web.Tests/Services/ContentServiceTests.cs index f3e81d0..0dffc83 100644 --- a/tests/Dfe.ContentSupport.Web.Tests/Services/ContentServiceTests.cs +++ b/tests/Dfe.ContentSupport.Web.Tests/Services/ContentServiceTests.cs @@ -10,6 +10,7 @@ namespace Dfe.ContentSupport.Web.Tests.Services; public class ContentServiceTests { private readonly Mock _httpContentClientMock = new(); + private readonly Mock>> _cacheMock = new(); private readonly ContentfulCollection _response = new() @@ -24,7 +25,7 @@ public class ContentServiceTests private ContentService GetService() { - return new ContentService(GetClient()); + return new ContentService(GetClient(), _cacheMock.Object); } private IContentfulService GetClient() @@ -104,4 +105,112 @@ public async void GetCsPages_Calls_Client_Once() Times.Once ); } + + [Fact] + public async void GetCsPages_Calls_Cache_Correct_Key() + { + const string expectedKey = "IsSitemap_true"; + SetupResponse(); + var sut = GetService(); + await sut.GetCsPages(); + + _cacheMock.Verify(o => o.GetFromCache(expectedKey)); + } + + + [Fact] + public async void GetContent_Calls_Cache_Correct_Key() + { + const string slug = "dummy-slug"; + const string expectedKey = $"Slug_{slug}"; + SetupResponse(); + var sut = GetService(); + await sut.GetContent(slug, It.IsAny()); + + _cacheMock.Verify(o => o.GetFromCache(expectedKey)); + } + + [Fact] + public async void GetCsPage_Calls_Cache_Correct_Key() + { + const string slug = "dummy-slug"; + const string expectedKey = $"Slug_{slug}"; + SetupResponse(); + var sut = GetService(); + await sut.GetContent(slug, It.IsAny()); + + _cacheMock.Verify(o => o.GetFromCache(expectedKey)); + } + + [Fact] + public async void GetContentSupportPages_Calls_Cache_Correct_Key() + { + const string field = "field"; + const string value = "value"; + SetupResponse(); + var isPreview = It.IsAny(); + const string expectedKey = $"{field}_{value}"; + var sut = GetService(); + await sut.GetContentSupportPages(field, value, isPreview); + + _cacheMock.Verify(o => o.GetFromCache(expectedKey)); + } + + [Fact] + public async void GetContentSupportPages_GotCache_Returns_Cache() + { + var cacheValue = new List { It.IsAny() }; + + const string field = "field"; + const string value = "value"; + const string expectedKey = $"{field}_{value}"; + var isPreview = It.IsAny(); + _cacheMock.Setup(o => o.GetFromCache(expectedKey)).Returns(cacheValue); + + var sut = GetService(); + var result = await sut.GetContentSupportPages(field, value, isPreview); + + result.Should().BeEquivalentTo(cacheValue); + } + + [Fact] + public async void GetContentSupportPages_NotGotCache_Calls_Client() + { + List? cacheValue = null; + + const string field = "field"; + const string value = "value"; + const string expectedKey = $"{field}_{value}"; + SetupResponse(); + var isPreview = It.IsAny(); + _cacheMock.Setup(o => o.GetFromCache(expectedKey)).Returns(cacheValue); + + var sut = GetService(); + await sut.GetContentSupportPages(field, value, isPreview); + + _httpContentClientMock.Verify(o => + o.Query( + It.IsAny>(), + It.IsAny()), + Times.Once + ); + } + + [Fact] + public async void GetContentSupportPages_NotGotCache_AddsToCache() + { + List? cacheValue = null; + + const string field = "field"; + const string value = "value"; + const string expectedKey = $"{field}_{value}"; + SetupResponse(); + var isPreview = It.IsAny(); + _cacheMock.Setup(o => o.GetFromCache(expectedKey)).Returns(cacheValue); + + var sut = GetService(); + await sut.GetContentSupportPages(field, value, isPreview); + + _cacheMock.Verify(o => o.AddToCache(expectedKey, It.IsAny>()), Times.Once); + } } \ No newline at end of file