diff --git a/src/Dfe.ContentSupport.Web/Controllers/CacheController.cs b/src/Dfe.ContentSupport.Web/Controllers/CacheController.cs index 1b4a598..fd06ad6 100644 --- a/src/Dfe.ContentSupport.Web/Controllers/CacheController.cs +++ b/src/Dfe.ContentSupport.Web/Controllers/CacheController.cs @@ -1,4 +1,5 @@ -using Dfe.ContentSupport.Web.Models.Mapped; +using Dfe.ContentSupport.Web.Extensions; +using Dfe.ContentSupport.Web.Models.Mapped; using Dfe.ContentSupport.Web.Services; using Microsoft.AspNetCore.Mvc; @@ -6,7 +7,9 @@ namespace Dfe.ContentSupport.Web.Controllers; [Route("api/[controller]")] [ApiController] -public class CacheController(ICacheService> cache) : ControllerBase +public class CacheController( + [FromKeyedServices(WebApplicationBuilderExtensions.ContentAndSupportServiceKey)] + ICacheService> cache) : ControllerBase { [HttpGet] [Route("clear")] diff --git a/src/Dfe.ContentSupport.Web/Controllers/ContentController.cs b/src/Dfe.ContentSupport.Web/Controllers/ContentController.cs index 169511c..4c97efd 100644 --- a/src/Dfe.ContentSupport.Web/Controllers/ContentController.cs +++ b/src/Dfe.ContentSupport.Web/Controllers/ContentController.cs @@ -1,4 +1,5 @@ using System.Diagnostics; +using Dfe.ContentSupport.Web.Extensions; using Dfe.ContentSupport.Web.Services; using Dfe.ContentSupport.Web.ViewModels; using Microsoft.AspNetCore.Authorization; @@ -8,23 +9,32 @@ namespace Dfe.ContentSupport.Web.Controllers; [Route("/content")] [AllowAnonymous] -public class ContentController(IContentService contentService, ILayoutService layoutService, ILogger logger) +public class ContentController( + [FromKeyedServices(WebApplicationBuilderExtensions.ContentAndSupportServiceKey)] + IContentService contentService, + [FromKeyedServices(WebApplicationBuilderExtensions.ContentAndSupportServiceKey)] + ILayoutService layoutService, + ILogger logger) : Controller { public const string ErrorActionName = "error"; [HttpGet("{slug}/{page?}")] - public async Task Index(string slug, string page = "", bool isPreview = false, [FromQuery] List? tags = null) + public async Task Index(string slug, string page = "", bool isPreview = false, + [FromQuery] List? tags = null) { if (!ModelState.IsValid) { - logger.LogError("Invalid model state received for {Controller} {Action} with slug {Slug}", nameof(ContentController), nameof(Index), slug); + logger.LogError( + "Invalid model state received for {Controller} {Action} with slug {Slug}", + nameof(ContentController), nameof(Index), slug); return RedirectToAction(ErrorActionName); } if (string.IsNullOrEmpty(slug)) { - logger.LogError("No slug received for C&S {Controller} {Action}", nameof(ContentController), nameof(Index)); + logger.LogError("No slug received for C&S {Controller} {Action}", + nameof(ContentController), nameof(Index)); return RedirectToAction(ErrorActionName); } @@ -33,7 +43,8 @@ public async Task Index(string slug, string page = "", bool isPre var resp = await contentService.GetContent(slug, isPreview); if (resp is null) { - logger.LogError("Failed to load content for C&S page {Slug}; no content received.", slug); + logger.LogError("Failed to load content for C&S page {Slug}; no content received.", + slug); return RedirectToAction(ErrorActionName); } @@ -48,12 +59,11 @@ public async Task Index(string slug, string page = "", bool isPre return RedirectToAction(ErrorActionName); } } - [ResponseCache(Duration = 0, Location = ResponseCacheLocation.None, NoStore = true)] public IActionResult Error() { return View(new ErrorViewModel - { RequestId = Activity.Current?.Id ?? HttpContext.TraceIdentifier }); + { RequestId = Activity.Current?.Id ?? HttpContext.TraceIdentifier }); } } \ No newline at end of file diff --git a/src/Dfe.ContentSupport.Web/Controllers/SitemapController.cs b/src/Dfe.ContentSupport.Web/Controllers/SitemapController.cs index fb8338d..87f0175 100644 --- a/src/Dfe.ContentSupport.Web/Controllers/SitemapController.cs +++ b/src/Dfe.ContentSupport.Web/Controllers/SitemapController.cs @@ -1,4 +1,4 @@ -using Dfe.ContentSupport.Web.Models.Mapped; +using Dfe.ContentSupport.Web.Extensions; using Dfe.ContentSupport.Web.Services; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; @@ -7,9 +7,10 @@ namespace Dfe.ContentSupport.Web.Controllers; [Route("/sitemap")] [AllowAnonymous] -public class SitemapController(IContentService contentfulService) : Controller +public class SitemapController( + [FromKeyedServices(WebApplicationBuilderExtensions.ContentAndSupportServiceKey)] + IContentService contentfulService) : Controller { - [HttpGet] [Route("/sitemap.xml")] public async Task Sitemap() diff --git a/src/Dfe.ContentSupport.Web/Extensions/CsHttpClientPolicyExtensions.cs b/src/Dfe.ContentSupport.Web/Extensions/CsHttpClientPolicyExtensions.cs new file mode 100644 index 0000000..a107f2f --- /dev/null +++ b/src/Dfe.ContentSupport.Web/Extensions/CsHttpClientPolicyExtensions.cs @@ -0,0 +1,22 @@ +using System.Net; +using Polly; +using Polly.Extensions.Http; + +namespace Dfe.ContentSupport.Web.Extensions; + +public static class CsHttpClientPolicyExtensions +{ + public static void AddRetryPolicy(IHttpClientBuilder builder) + { + builder + .SetHandlerLifetime(TimeSpan.FromMinutes(5)) + .AddPolicyHandler(GetRetryPolicy()); + } + + public static IAsyncPolicy GetRetryPolicy() + { + return HttpPolicyExtensions.HandleTransientHttpError() + .OrResult(msg => msg.StatusCode == HttpStatusCode.NotFound) + .WaitAndRetryAsync(6, retryAttempt => TimeSpan.FromSeconds(Math.Pow(2, retryAttempt))); + } +} \ No newline at end of file diff --git a/src/Dfe.ContentSupport.Web/Extensions/WebApplicationBuilderExtensions.cs b/src/Dfe.ContentSupport.Web/Extensions/WebApplicationBuilderExtensions.cs index 085bd9e..d42fd86 100644 --- a/src/Dfe.ContentSupport.Web/Extensions/WebApplicationBuilderExtensions.cs +++ b/src/Dfe.ContentSupport.Web/Extensions/WebApplicationBuilderExtensions.cs @@ -1,4 +1,4 @@ -using Contentful.Core; +using Contentful.Core; using Contentful.Core.Configuration; using Dfe.ContentSupport.Web.Configuration; using Dfe.ContentSupport.Web.Models.Mapped; @@ -9,20 +9,25 @@ namespace Dfe.ContentSupport.Web.Extensions; public static class WebApplicationBuilderExtensions { + public const string ContentAndSupportServiceKey = "content-and-support"; + public static void InitCsDependencyInjection(this WebApplicationBuilder app) { app.Services.Configure(app.Configuration.GetSection("tracking")) .AddSingleton(sp => sp.GetRequiredService>().Value); - app.Services.Configure(app.Configuration.GetSection("cs:supportedAssetTypes")) + app.Services + .Configure(app.Configuration.GetSection("cs:supportedAssetTypes")) .AddSingleton(sp => sp.GetRequiredService>().Value); app.Services.SetupContentfulClient(app); - app.Services.AddTransient>, CsPagesCacheService>(); - app.Services.AddTransient(); - app.Services.AddTransient(); - app.Services.AddTransient(); + app.Services.AddKeyedTransient>, CsPagesCacheService>( + ContentAndSupportServiceKey); + app.Services.AddKeyedTransient(ContentAndSupportServiceKey); + app.Services + .AddKeyedTransient(ContentAndSupportServiceKey); + app.Services.AddKeyedTransient(ContentAndSupportServiceKey); app.Services.Configure(options => { @@ -30,28 +35,32 @@ public static void InitCsDependencyInjection(this WebApplicationBuilder app) options.MinimumSameSitePolicy = SameSiteMode.Strict; options.ConsentCookieValue = "false"; }); - - } - public static void SetupContentfulClient(this IServiceCollection services, WebApplicationBuilder app) + public static void SetupContentfulClient(this IServiceCollection services, + WebApplicationBuilder app) { app.Services.Configure(app.Configuration.GetSection("cs:contentful")) - .AddSingleton(sp => sp.GetRequiredService>().Value); + .AddKeyedSingleton(ContentAndSupportServiceKey, (IServiceProvider sp) => + sp.GetRequiredService>().Value); + + services.AddKeyedScoped(ContentAndSupportServiceKey, + (sp, _) => + { + var contentfulOptions = + sp.GetRequiredKeyedService>( + ContentAndSupportServiceKey)(sp); + var httpClient = sp.GetRequiredService(); + return new ContentfulClient(httpClient, contentfulOptions); + }); - services.AddScoped(); - if (app.Environment.EnvironmentName.Equals("e2e")) - { - services.AddScoped(); - } + services.AddKeyedScoped( + ContentAndSupportServiceKey); else - { - services.AddScoped(); - } - - + services.AddKeyedScoped( + ContentAndSupportServiceKey); - HttpClientPolicyExtensions.AddRetryPolicy(services.AddHttpClient()); + CsHttpClientPolicyExtensions.AddRetryPolicy(services.AddHttpClient()); } } \ No newline at end of file diff --git a/src/Dfe.ContentSupport.Web/Services/ContentService.cs b/src/Dfe.ContentSupport.Web/Services/ContentService.cs index d049c38..3b37449 100644 --- a/src/Dfe.ContentSupport.Web/Services/ContentService.cs +++ b/src/Dfe.ContentSupport.Web/Services/ContentService.cs @@ -1,12 +1,16 @@ using System.Xml.Linq; +using Dfe.ContentSupport.Web.Extensions; using Dfe.ContentSupport.Web.Models.Mapped; using Dfe.ContentSupport.Web.ViewModels; namespace Dfe.ContentSupport.Web.Services; public class ContentService( + [FromKeyedServices(WebApplicationBuilderExtensions.ContentAndSupportServiceKey)] IContentfulService contentfulService, + [FromKeyedServices(WebApplicationBuilderExtensions.ContentAndSupportServiceKey)] ICacheService> cache, + [FromKeyedServices(WebApplicationBuilderExtensions.ContentAndSupportServiceKey)] IModelMapper modelMapper) : IContentService { @@ -51,20 +55,14 @@ public async Task> GetContentSupportPages( if (!isPreview) { var fromCache = cache.GetFromCache(key); - if (fromCache is not null) - { - return fromCache; - } + if (fromCache is not null) return fromCache; } var result = await contentfulService.GetContentSupportPages(field, value); var pages = modelMapper.MapToCsPages(result); - if (!isPreview) - { - cache.AddToCache(key, pages); - } + if (!isPreview) cache.AddToCache(key, pages); return pages; } diff --git a/src/Dfe.ContentSupport.Web/Services/ContentfulService.cs b/src/Dfe.ContentSupport.Web/Services/ContentfulService.cs index c446905..c7409b6 100644 --- a/src/Dfe.ContentSupport.Web/Services/ContentfulService.cs +++ b/src/Dfe.ContentSupport.Web/Services/ContentfulService.cs @@ -1,17 +1,17 @@ using Contentful.Core; using Contentful.Core.Search; +using Dfe.ContentSupport.Web.Extensions; using Dfe.ContentSupport.Web.ViewModels; namespace Dfe.ContentSupport.Web.Services; -public class ContentfulService(IContentfulClient client) +public class ContentfulService( + [FromKeyedServices(WebApplicationBuilderExtensions.ContentAndSupportServiceKey)] + IContentfulClient client) : IContentfulService { private const int DefaultRequestDepth = 10; - private readonly IContentfulClient _client = - client ?? throw new ArgumentNullException(nameof(client)); - public async Task> GetContentSupportPages(string field, string value, CancellationToken cancellationToken = default) { @@ -19,7 +19,7 @@ public async Task> GetContentSupportPages(string .FieldEquals($"fields.{field}", value) .Include(DefaultRequestDepth); - var entries = await _client.GetEntries(builder, cancellationToken); + var entries = await client.GetEntries(builder, cancellationToken); return entries ?? Enumerable.Empty(); } diff --git a/src/Dfe.ContentSupport.Web/Services/CsPagesCacheService.cs b/src/Dfe.ContentSupport.Web/Services/CsPagesCacheService.cs index 0826f40..fe548b2 100644 --- a/src/Dfe.ContentSupport.Web/Services/CsPagesCacheService.cs +++ b/src/Dfe.ContentSupport.Web/Services/CsPagesCacheService.cs @@ -25,7 +25,7 @@ public void AddToCache(string key, List item) } public void ClearCache() - { + { (cache as MemoryCache)?.Clear(); } } \ No newline at end of file diff --git a/src/Dfe.ContentSupport.Web/Services/ICacheService.cs b/src/Dfe.ContentSupport.Web/Services/ICacheService.cs index 2752a6d..96e409b 100644 --- a/src/Dfe.ContentSupport.Web/Services/ICacheService.cs +++ b/src/Dfe.ContentSupport.Web/Services/ICacheService.cs @@ -2,7 +2,7 @@ public interface ICacheService { - void AddToCache(string key,T item); + void AddToCache(string key, T item); T? GetFromCache(string key); void ClearCache(); } \ No newline at end of file diff --git a/src/Dfe.ContentSupport.Web/Services/ModelMapper.cs b/src/Dfe.ContentSupport.Web/Services/ModelMapper.cs index 3c7043f..712aff2 100644 --- a/src/Dfe.ContentSupport.Web/Services/ModelMapper.cs +++ b/src/Dfe.ContentSupport.Web/Services/ModelMapper.cs @@ -1,4 +1,4 @@ -using Contentful.Core.Models; +using Contentful.Core.Models; using Dfe.ContentSupport.Web.Common; using Dfe.ContentSupport.Web.Configuration; using Dfe.ContentSupport.Web.Models; @@ -7,6 +7,7 @@ using Dfe.ContentSupport.Web.Models.Mapped.Standard; using Dfe.ContentSupport.Web.Models.Mapped.Types; using Dfe.ContentSupport.Web.ViewModels; +using Hyperlink = Dfe.ContentSupport.Web.Models.Mapped.Standard.Hyperlink; namespace Dfe.ContentSupport.Web.Services; @@ -17,41 +18,9 @@ public List MapToCsPages(IEnumerable incoming) return incoming.Select(MapToCsPage).ToList(); } - public CsPage MapToCsPage(ContentSupportPage incoming) - { - CsPage result = new CsPage - { - Heading = incoming.Heading, - Slug = incoming.Slug, - IsSitemap = incoming.IsSitemap, - HasCitation = incoming.HasCitation, - HasBackToTop = incoming.HasBackToTop, - HasFeedbackBanner = incoming.HasFeedbackBanner, - HasPrint = incoming.HasPrint, - Content = MapEntriesToContent(incoming.Content), - ShowVerticalNavigation = incoming.ShowVerticalNavigation, - CreatedAt = incoming.SystemProperties.CreatedAt, - UpdatedAt = incoming.SystemProperties.UpdatedAt, - Tags = FlattenMetadata(incoming.Metadata) - }; - return result; - } - - private static List FlattenMetadata(ContentfulMetadata item) - { - if (item is null) return []; - - return item.Tags.Select(o => o.Sys.Id).ToList(); - } - - private List MapEntriesToContent(List entries) - { - return entries.Select(ConvertEntryToContentItem).ToList(); - } - public CsContentItem ConvertEntryToContentItem(Entry entry) { - CsContentItem item = entry.RichText is not null + var item = entry.RichText is not null ? MapRichTextContent(entry.RichText, entry)! : new CsContentItem { @@ -61,24 +30,6 @@ public CsContentItem ConvertEntryToContentItem(Entry entry) return item; } - public RichTextContentItem? MapRichTextContent(ContentItemBase? richText, Entry entry) - { - if (richText is null) return null; - RichTextContentItem item = - new RichTextContentItem - { - InternalName = entry.InternalName, - Slug = entry.Slug, - Title = entry.Title, - Subtitle = entry.Subtitle, - UseParentHero = entry.UseParentHero, - NodeType = ConvertToRichTextNodeType(richText.NodeType), - Content = MapRichTextNodes(richText.Content), - Tags = FlattenMetadata(entry.Metadata) - }; - return item; - } - public List MapRichTextNodes(List nodes) { return nodes.Select(node => MapContent(node) ?? new RichTextContentItem @@ -103,7 +54,7 @@ public List MapRichTextNodes(List nodes) break; case RichTextNodeType.Hyperlink: var uri = contentItem.Data.Uri.ToString(); - item = new Models.Mapped.Standard.Hyperlink + item = new Hyperlink { Uri = uri, IsVimeo = uri.Contains("vimeo.com") @@ -174,6 +125,84 @@ public List MapRichTextNodes(List nodes) }; } + public RichTextNodeType ConvertToRichTextNodeType(string str) + { + return str switch + { + RichTextTags.Document => RichTextNodeType.Document, + RichTextTags.Paragraph => RichTextNodeType.Paragraph, + RichTextTags.Heading2 => RichTextNodeType.Heading2, + RichTextTags.Heading3 => RichTextNodeType.Heading3, + RichTextTags.Heading4 => RichTextNodeType.Heading4, + RichTextTags.Heading5 => RichTextNodeType.Heading5, + RichTextTags.Heading6 => RichTextNodeType.Heading6, + RichTextTags.UnorderedList => RichTextNodeType.UnorderedList, + RichTextTags.OrderedList => RichTextNodeType.OrderedList, + RichTextTags.ListItem => RichTextNodeType.ListItem, + RichTextTags.Hyperlink => RichTextNodeType.Hyperlink, + RichTextTags.Table => RichTextNodeType.Table, + RichTextTags.TableRow => RichTextNodeType.TableRow, + RichTextTags.TableHeaderCell => RichTextNodeType.TableHeaderCell, + RichTextTags.TableCell => RichTextNodeType.TableCell, + RichTextTags.Hr => RichTextNodeType.Hr, + RichTextTags.EmbeddedAsset => RichTextNodeType.EmbeddedAsset, + RichTextTags.Text => RichTextNodeType.Text, + RichTextTags.EmbeddedEntry or RichTextTags.EmbeddedEntryInline => RichTextNodeType + .EmbeddedEntry, + _ => RichTextNodeType.Unknown + }; + } + + public CsPage MapToCsPage(ContentSupportPage incoming) + { + var result = new CsPage + { + Heading = incoming.Heading, + Slug = incoming.Slug, + IsSitemap = incoming.IsSitemap, + HasCitation = incoming.HasCitation, + HasBackToTop = incoming.HasBackToTop, + HasFeedbackBanner = incoming.HasFeedbackBanner, + HasPrint = incoming.HasPrint, + Content = MapEntriesToContent(incoming.Content), + ShowVerticalNavigation = incoming.ShowVerticalNavigation, + CreatedAt = incoming.SystemProperties.CreatedAt, + UpdatedAt = incoming.SystemProperties.UpdatedAt, + Tags = FlattenMetadata(incoming.Metadata) + }; + return result; + } + + private static List FlattenMetadata(ContentfulMetadata item) + { + if (item is null) return []; + + return item.Tags.Select(o => o.Sys.Id).ToList(); + } + + private List MapEntriesToContent(List entries) + { + return entries.Select(ConvertEntryToContentItem).ToList(); + } + + public RichTextContentItem? MapRichTextContent(ContentItemBase? richText, Entry entry) + { + if (richText is null) return null; + var item = + new RichTextContentItem + { + InternalName = entry.InternalName, + Slug = entry.Slug, + Title = entry.Title, + Subtitle = entry.Subtitle, + UseParentHero = entry.UseParentHero, + NodeType = ConvertToRichTextNodeType(richText.NodeType), + Content = MapRichTextNodes(richText.Content), + Tags = FlattenMetadata(entry.Metadata) + }; + return item; + } + private CustomAccordion GenerateCustomAccordion(Target target) { return new CustomAccordion @@ -223,34 +252,6 @@ private CustomGridContainer GenerateCustomGridContainer(Target target) }; } - public RichTextNodeType ConvertToRichTextNodeType(string str) - { - return str switch - { - RichTextTags.Document => RichTextNodeType.Document, - RichTextTags.Paragraph => RichTextNodeType.Paragraph, - RichTextTags.Heading2 => RichTextNodeType.Heading2, - RichTextTags.Heading3 => RichTextNodeType.Heading3, - RichTextTags.Heading4 => RichTextNodeType.Heading4, - RichTextTags.Heading5 => RichTextNodeType.Heading5, - RichTextTags.Heading6 => RichTextNodeType.Heading6, - RichTextTags.UnorderedList => RichTextNodeType.UnorderedList, - RichTextTags.OrderedList => RichTextNodeType.OrderedList, - RichTextTags.ListItem => RichTextNodeType.ListItem, - RichTextTags.Hyperlink => RichTextNodeType.Hyperlink, - RichTextTags.Table => RichTextNodeType.Table, - RichTextTags.TableRow => RichTextNodeType.TableRow, - RichTextTags.TableHeaderCell => RichTextNodeType.TableHeaderCell, - RichTextTags.TableCell => RichTextNodeType.TableCell, - RichTextTags.Hr => RichTextNodeType.Hr, - RichTextTags.EmbeddedAsset => RichTextNodeType.EmbeddedAsset, - RichTextTags.Text => RichTextNodeType.Text, - RichTextTags.EmbeddedEntry or RichTextTags.EmbeddedEntryInline => RichTextNodeType - .EmbeddedEntry, - _ => RichTextNodeType.Unknown - }; - } - public AssetContentType ConvertToAssetContentType(string str) { diff --git a/src/Dfe.ContentSupport.Web/Services/StubContentfulService.cs b/src/Dfe.ContentSupport.Web/Services/StubContentfulService.cs index 30578ac..eae1fbc 100644 --- a/src/Dfe.ContentSupport.Web/Services/StubContentfulService.cs +++ b/src/Dfe.ContentSupport.Web/Services/StubContentfulService.cs @@ -1,18 +1,23 @@ -using Contentful.Core; +using Contentful.Core; using Contentful.Core.Configuration; using Contentful.Core.Models; +using Dfe.ContentSupport.Web.Extensions; using Dfe.ContentSupport.Web.ViewModels; using Newtonsoft.Json; +using File = System.IO.File; namespace Dfe.ContentSupport.Web.Services; -public class StubContentfulService(HttpClient httpClient, ContentfulOptions options) +public class StubContentfulService( + HttpClient httpClient, + [FromKeyedServices(WebApplicationBuilderExtensions.ContentAndSupportServiceKey)] + ContentfulOptions options) : ContentfulClient(httpClient, options), IContentfulService { public async Task> GetContentSupportPages(string field, string value, CancellationToken cancellationToken = default) { - var json = await System.IO.File.ReadAllTextAsync("StubData/ContentfulCollection.json", + var json = await File.ReadAllTextAsync("StubData/ContentfulCollection.json", cancellationToken); var resp = JsonConvert.DeserializeObject(json); return resp == null diff --git a/tests/Dfe.ContentSupport.Web.Tests/Extensions/WebApplicationBuilderExtensionsTests.cs b/tests/Dfe.ContentSupport.Web.Tests/Extensions/WebApplicationBuilderExtensionsTests.cs index c7b1363..b84dab6 100644 --- a/tests/Dfe.ContentSupport.Web.Tests/Extensions/WebApplicationBuilderExtensionsTests.cs +++ b/tests/Dfe.ContentSupport.Web.Tests/Extensions/WebApplicationBuilderExtensionsTests.cs @@ -1,4 +1,4 @@ -using Dfe.ContentSupport.Web.Extensions; +using Dfe.ContentSupport.Web.Extensions; using Dfe.ContentSupport.Web.Models.Mapped; using Microsoft.AspNetCore.Builder; @@ -32,7 +32,7 @@ public void Builder_Default_Uses_DefaultClient() var service = builder.Services.First(o => o.ServiceType == typeof(IContentfulService)); - service.ImplementationType?.Name.Should().BeEquivalentTo(nameof(ContentfulService)); + service.KeyedImplementationType?.Name.Should().BeEquivalentTo(nameof(ContentfulService)); } [Fact] @@ -46,6 +46,7 @@ public void Builder_E2e_Uses_MockClient() builder.InitCsDependencyInjection(); var service = builder.Services.First(o => o.ServiceType == typeof(IContentfulService)); - service.ImplementationType?.Name.Should().BeEquivalentTo(nameof(StubContentfulService)); + service.KeyedImplementationType?.Name.Should() + .BeEquivalentTo(nameof(StubContentfulService)); } } \ No newline at end of file