From 9e610d179db16aee99fab566bcd3bc8231ddfb30 Mon Sep 17 00:00:00 2001 From: Jean-Philippe Levesque Date: Fri, 14 Oct 2022 16:11:32 -0400 Subject: [PATCH 1/2] feat: Add support for simple application caching. --- README.md | 90 +++++++- .../CacheHandlerTests.cs | 213 ++++++++++++++++++ .../CachingRefitIntegrationTests.cs | 198 ++++++++++++++++ .../MallardMessageHandlers.Tests.csproj | 1 + .../MemoryCacheServiceTests.cs | 82 +++++++ src/MallardMessageHandlers/IsExternalInit.cs | 8 + .../MallardMessageHandlers.csproj | 1 + .../SimpleCaching/ISimpleCacheKeyProvider.cs | 20 ++ .../SimpleCaching/ISimpleCacheService.cs | 37 +++ .../SimpleCaching/MemorySimpleCacheService.cs | 75 ++++++ .../SimpleCaching/SimpleCacheHandler.cs | 147 ++++++++++++ .../SimpleCaching/SimpleCacheKeyProvider.cs | 69 ++++++ 12 files changed, 933 insertions(+), 8 deletions(-) create mode 100644 src/MallardMessageHandlers.Tests/CacheHandlerTests.cs create mode 100644 src/MallardMessageHandlers.Tests/CachingRefitIntegrationTests.cs create mode 100644 src/MallardMessageHandlers.Tests/MemoryCacheServiceTests.cs create mode 100644 src/MallardMessageHandlers/IsExternalInit.cs create mode 100644 src/MallardMessageHandlers/SimpleCaching/ISimpleCacheKeyProvider.cs create mode 100644 src/MallardMessageHandlers/SimpleCaching/ISimpleCacheService.cs create mode 100644 src/MallardMessageHandlers/SimpleCaching/MemorySimpleCacheService.cs create mode 100644 src/MallardMessageHandlers/SimpleCaching/SimpleCacheHandler.cs create mode 100644 src/MallardMessageHandlers/SimpleCaching/SimpleCacheKeyProvider.cs diff --git a/README.md b/README.md index 3b44caf..e4480d1 100644 --- a/README.md +++ b/README.md @@ -37,6 +37,7 @@ This repository contains multiple implementations of `DelegatingHandlers` for di Here is a list of the `DelegatingHandlers` provided. - [NetworkExceptionHandler](#NetworkExceptionHandler) : Throws an exception if the HttpRequest fails and there is no network. +- [SimpleCacheHandler](#SimpleCacheHandler) : Implement simple application caching using instructions from custom HTTP headers. - [ExceptionHubHandler](#ExceptionHubHandler) : Reports all exceptions that occur on the pipeline. - [ExceptionInterpreterHandler](#ExceptionInterpreterHandler) : Interprets error responses and converts them to exceptions. - [AuthenticationTokenHandler](#AuthenticationTokenHandler) : Adds the authentication token to the authorization header. @@ -90,6 +91,85 @@ private void ConfigureNetworkExceptionHandler(IServiceCollection services) } ``` +### SimpleCacheHandler + +The `SimpleCacheHandler` is a `DelegatingHandler` that executes custom caching instructions. + +When you use Refit for your endpoints declaration, you can neatly specify caching instructions with attributes. + +- You can specify time-to-live at different levels. + - On a per-call level (using attributes) + - Globally (using default headers) +- You can support force-refresh scenarios (i.e. don't read the cache, but update it). +This can be useful for things like pull-to-refresh. +- You can disable the cache on a per-call level. +This only makes sense when you define default caching globally. + +```csharp +// (...) +public static class CacheInstructions +{ + public const string DefaultCacheValue = "600"; // 10 minutes + public const string ForceRefresh = SimpleCacheHandler.CacheForceRefreshHeaderName; + public const string Cache5Minutes = SimpleCacheHandler.CacheTimeToLiveHeaderName + ":300"; + public const string NoCache = SimpleCacheHandler.CacheDisableHeaderName + ":true"; +} + +// (...) +using static YourNamespace.CacheInstructions; + +public interface ISampleEndpoint +{ + [Get("/sample")] + Task GetSomeInfo([Header(ForceRefresh)] bool forceRefresh = false); + + [Get("/sample")] + [Headers(Cache5Minutes)] // You can customize the TTL on a per-call basis. + Task GetSomeStuff([Header(ForceRefresh)] bool forceRefresh = false); + + [Get("/sample")] + [Headers(NoCache)] // When you have a default TTL, you can bypass it on a per-call basis. + Task GetSomeStatus(); +} +``` + +Because header attributes works with `string` constants, we recommend you create a static class to declare your caching instructions. +> 💡 You can even leverage `using static` to have super concise instructions by not repeating the static class name. + +Here's how you can configure a default time-to-live for all calls. +```csharp +serviceCollection + .AddRefitClient() + .ConfigureHttpClient((client) => + { + // You can configure a default time-to-live for all calls. + client.DefaultRequestHeaders.Add(SimpleCacheHandler.CacheTimeToLiveHeaderName, "600"); + }); +``` + +The `SimpleCacheHandler` has a few dependencies. +- `ISimpleCacheService` which implements the actual caching of data. + - The interface is pretty simple, so you can easily create implementations. + - You can also use our `MemorySimpleCacheService` implementation. +- `ISimpleCacheKeyProvider` which generates the cache keys from the `HttpMessageRequest` objects. + - You can use the `SimpleCacheKeyProvider` to create your keys using a custom `Func`. + - You can also use one of our built-in implementations: + - `SimpleCacheKeyProvider.FromUriOnly` + - `SimpleCacheKeyProvider.FromUriAndAuthorizationHash` + +```csharp +private void ConfigureCacheHandler(IServiceCollection services) +{ + // The ISimpleCacheService and ISimpleCacheKeyProvider are shared for all HttpRequests so we add them as singleton. + services + .AddSingleton(); + .AddSingleton(CacheKeyProvider.FromUriOnly); + + // The SimpleCacheHandler must be recreated for all HttpRequests so we add it as transient. + services.AddTransient(); +} +``` + ### ExceptionHubHandler The `ExceptionHubHandler` is a `DelegatingHandler` that will report all exceptions thrown during the execution of the `HttpRequest` to an `IExceptionHub`. @@ -232,10 +312,9 @@ private void ConfigureAuthenticationTokenHandler(IServiceCollection services) } ``` -## Changelog +## Breaking Changes -Please consult the [CHANGELOG](CHANGELOG.md) for more information about version -history. +Please consult the [BREAKING_CHANGES.md](BREAKING_CHANGES.md) for more information about breaking changes and version history. ## License @@ -248,8 +327,3 @@ Please read [CONTRIBUTING.md](CONTRIBUTING.md) for details on the process for contributing to this project. Be mindful of our [Code of Conduct](CODE_OF_CONDUCT.md). - -## Contributors - - - diff --git a/src/MallardMessageHandlers.Tests/CacheHandlerTests.cs b/src/MallardMessageHandlers.Tests/CacheHandlerTests.cs new file mode 100644 index 0000000..8511d97 --- /dev/null +++ b/src/MallardMessageHandlers.Tests/CacheHandlerTests.cs @@ -0,0 +1,213 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Net.Http; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using FluentAssertions; +using MallardMessageHandlers.SimpleCaching; +using Microsoft.Extensions.DependencyInjection; +using Moq; +using Xunit; + +namespace MallardMessageHandlers.Tests +{ + public class CacheHandlerTests + { + private const string DefaultRequestUri = "http://wwww.test.com"; + private const string Cache5Minutes = SimpleCacheHandler.CacheTimeToLiveHeaderName + ": 300"; + private const string Cache10Minutes = SimpleCacheHandler.CacheTimeToLiveHeaderName + ": 600"; + private const string ForceRefresh = SimpleCacheHandler.CacheForceRefreshHeaderName + ": true"; + private const string DisableCache = SimpleCacheHandler.CacheDisableHeaderName + ": true"; + + [Fact] + public async Task It_doesnt_invoke_cache_service_when_ttl_header_isnt_present() + { + // Arrange + var cacheServiceMock = new Mock(); + var httpClient = GetClient(cacheServiceMock); + + // Act + await httpClient.GetAsync(DefaultRequestUri); + + // Assert + var x = default(byte[]); + cacheServiceMock.Verify(s => s.TryGet(It.IsAny(), It.IsAny(), out x), Times.Never); + cacheServiceMock.Verify(s => s.Add(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); + } + + [Fact] + public async Task It_doesnt_invoke_cache_service_when_cache_disable_header_is_present() + { + // Arrange + var cacheServiceMock = new Mock(); + var httpClient = GetClient(cacheServiceMock, Cache5Minutes, DisableCache); + + // Act + await httpClient.GetAsync(DefaultRequestUri); + + // Assert + var x = default(byte[]); + cacheServiceMock.Verify(s => s.TryGet(It.IsAny(), It.IsAny(), out x), Times.Never); + cacheServiceMock.Verify(s => s.Add(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); + } + + [Fact] + public async Task Cache_service_is_invoked_when_ttl_header_is_present() + { + // Arrange + var cacheServiceMock = new Mock(); + var httpClient = GetClient(cacheServiceMock, Cache5Minutes); + + // Act + await httpClient.GetAsync(DefaultRequestUri); + + // Assert + var x = default(byte[]); + cacheServiceMock.Verify(s => s.TryGet(It.IsAny(), It.IsAny(), out x), Times.Once); + cacheServiceMock.Verify(s => s.Add(It.IsAny(), It.IsAny(), It.Is(ts => ts.TotalMinutes == 5), It.IsAny()), Times.Once); + } + + [Fact] + public async Task Cache_service_is_invoked_most_up_to_date_TTL_value() + { + // Arrange + var cacheServiceMock = new Mock(); + // 5 Minutes is the default, 10 minutes is the override. 10 minutes should be used. + var httpClient = GetClientWithExtraHeader(cacheServiceMock, insertedHeader: Cache10Minutes, Cache5Minutes); + + // Act + await httpClient.GetAsync(DefaultRequestUri); + + // Assert + cacheServiceMock.Verify(s => s.Add(It.IsAny(), It.IsAny(), It.Is(ts => ts.TotalMinutes == 10), It.IsAny()), Times.Once); + } + + [Fact] + public async Task Inner_handler_isnt_called_when_cache_is_active() + { + // Arrange + var cacheServiceMock = new Mock(); + var payload = new byte[] { 1, 2, 3 }; + cacheServiceMock.Setup(s => s.TryGet(It.IsAny(), It.IsAny(), out payload)).ReturnsAsync(true); + var innerHandlerWasInvoked = new TaskCompletionSource(); + + var httpClient = GetClient(cacheServiceMock, innerHandlerWasInvoked, Cache5Minutes); + + // Act + var result = await httpClient.GetAsync(DefaultRequestUri); + + // Assert + innerHandlerWasInvoked.Task.Status.Should().NotBe(TaskStatus.RanToCompletion); + (await result.Content.ReadAsByteArrayAsync()).Should().BeEquivalentTo(payload); + } + + [Fact] + public async Task InnerHandler_is_called_and_Cache_is_updated_when_ForceRefresh_and_CacheTTL_headers_are_present() + { + // Arrange + var cacheServiceMock = new Mock(); + var payload = new byte[] { 1, 2, 3 }; + cacheServiceMock.Setup(s => s.TryGet(It.IsAny(), It.IsAny(), out payload)).ReturnsAsync(true); + var innerHandlerWasInvoked = new TaskCompletionSource(); + + var httpClient = GetClient(cacheServiceMock, innerHandlerWasInvoked, Cache5Minutes, ForceRefresh); + + // Act + var result = await httpClient.GetAsync(DefaultRequestUri); + + // Assert + innerHandlerWasInvoked.Task.Status.Should().Be(TaskStatus.RanToCompletion); + (await result.Content.ReadAsStringAsync()).Should().Be("Hello"); + cacheServiceMock.Verify(s => s.Add( + It.IsAny(), + It.Is(bytes => bytes.SequenceEqual(UTF8Encoding.UTF8.GetBytes("Hello"))), + It.IsAny(), + It.IsAny()), + Times.Once); + } + + private static HttpClient GetClient(Mock cacheServiceMock, params string[] headers) + => GetClient(cacheServiceMock, new TaskCompletionSource(), headers); + + private static HttpClient GetClient(Mock cacheServiceMock, TaskCompletionSource innerHandlerWasInvoked, params string[] headers) + { + void BuildServices(IServiceCollection s) => s + .AddSingleton(cacheServiceMock.Object) + .AddSingleton(SimpleCacheKeyProvider.FromUriOnly) + .AddTransient(_ => new TestHandler((r, ct) => + { + innerHandlerWasInvoked.TrySetResult(true); + return Task.FromResult(new HttpResponseMessage() + { + Content = new StringContent("Hello") + }); + })) + .AddTransient(); + + void BuildHttpClient(IHttpClientBuilder h) => h + .AddHttpMessageHandler() + .AddHttpMessageHandler(); + + var httpClient = HttpClientTestsHelper.GetTestHttpClient(BuildServices, BuildHttpClient); + foreach (var header in headers) + { + var parts = header.Split(':'); + var name = parts[0]; + var value = parts[1]; + httpClient.DefaultRequestHeaders.Add(name, value); + } + return httpClient; + } + + private static HttpClient GetClientWithExtraHeader(Mock cacheServiceMock, string insertedHeader, params string[] headers) + { + void BuildServices(IServiceCollection s) => s + .AddSingleton(cacheServiceMock.Object) + .AddSingleton(SimpleCacheKeyProvider.FromUriOnly) + .AddTransient(_ => new TestHandler((r, ct) => + { + return Task.FromResult(new HttpResponseMessage() + { + Content = new StringContent("Hello") + }); + })) + .AddTransient(); + + void BuildHttpClient(IHttpClientBuilder h) => h + .AddHttpMessageHandler(() => new HeaderInserterHandler(insertedHeader)) + .AddHttpMessageHandler() + .AddHttpMessageHandler(); + + var httpClient = HttpClientTestsHelper.GetTestHttpClient(BuildServices, BuildHttpClient); + foreach (var header in headers) + { + var parts = header.Split(':'); + var name = parts[0]; + var value = parts[1]; + httpClient.DefaultRequestHeaders.Add(name, value); + } + return httpClient; + } + + private class HeaderInserterHandler : DelegatingHandler + { + private readonly string _header; + + public HeaderInserterHandler(string header) + { + _header = header; + } + + protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + var parts = _header.Split(':'); + var name = parts[0]; + var value = parts[1]; + request.Headers.Add(name, value); + return base.SendAsync(request, cancellationToken); + } + } + } +} diff --git a/src/MallardMessageHandlers.Tests/CachingRefitIntegrationTests.cs b/src/MallardMessageHandlers.Tests/CachingRefitIntegrationTests.cs new file mode 100644 index 0000000..71df98b --- /dev/null +++ b/src/MallardMessageHandlers.Tests/CachingRefitIntegrationTests.cs @@ -0,0 +1,198 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Net.Http; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using FluentAssertions; +using MallardMessageHandlers.SimpleCaching; +using Microsoft.Extensions.DependencyInjection; +using Moq; +using Refit; +using Xunit; +using static MallardMessageHandlers.Tests.CacheInstructions; + +namespace MallardMessageHandlers.Tests +{ + public static class CacheInstructions + { + public const string DefaultCacheValue = "600"; // 10 minutes + public const string ForceRefresh = SimpleCacheHandler.CacheForceRefreshHeaderName; + public const string Cache5Minutes = SimpleCacheHandler.CacheTimeToLiveHeaderName + ":300"; + public const string NoCache = SimpleCacheHandler.CacheDisableHeaderName + ":true"; + } + + public interface ISampleEndpoint + { + // Default cache is 10 minutes on all methods. + + [Get("/sample")] + Task GetSampleDefault(CancellationToken ct, [Header(ForceRefresh)] bool forceRefresh = false); + + [Get("/sample")] + [Headers(Cache5Minutes)] // You can customize the TTL on a per-call basis. + Task GetSampleCustomTTL(CancellationToken ct, [Header(ForceRefresh)] bool forceRefresh = false); + + [Get("/sample")] + [Headers(NoCache)] // When you have a default TTL, you can bypass it on a per-call basis. + Task GetSampleNoCache(CancellationToken ct); + } + + public class CachingRefitIntegrationTests + { + public static IEnumerable GetDataFor_GetSampleCache_x1() + { + yield return new object[] { (Func>)(endpoint => endpoint.GetSampleDefault(CancellationToken.None)), 10 }; + yield return new object[] { (Func>)(endpoint => endpoint.GetSampleCustomTTL(CancellationToken.None)), 5 }; + } + + [Theory] + [MemberData(nameof(GetDataFor_GetSampleCache_x1))] + public async Task GetSampleCached_x1(Func> action, int ttl) + { + // Arrange + var cacheServiceMock = new Mock(() => new MemorySimpleCacheService()); + var endpoint = Setup(cacheServiceMock.Object); + + // Act + var result = await action(endpoint); + + // Assert + res­­ult.Should().Be("1"); + var x = default(byte[]); + cacheServiceMock.Verify(s => s.TryGet(It.IsAny(), It.IsAny(), out x), Times.Once); + cacheServiceMock.Verify(s => s.Add(It.IsAny(), It.IsAny(), It.Is(ts => ts.TotalMinutes == ttl), It.IsAny()), Times.Once); + } + + public static IEnumerable GetDataFor_GetSampleCache_x2() + { + yield return new object[] { (Func>)(endpoint => endpoint.GetSampleDefault(CancellationToken.None)) }; + yield return new object[] { (Func>)(endpoint => endpoint.GetSampleCustomTTL(CancellationToken.None))}; + } + + [Theory] + [MemberData(nameof(GetDataFor_GetSampleCache_x2))] + public async Task GetSampleCached_x2(Func> action) + { + // Arrange + var endpoint = Setup(); + + // Act + var result1 = await action(endpoint); + var result2 = await action(endpoint); + + // Assert + res­­ult1.Should().Be("1"); + res­­ult2.Should().Be("1"); + } + + public static IEnumerable GetDataFor_GetSampleCached_with_force_refresh_x1() + { + yield return new object[] { (Func>)(endpoint => endpoint.GetSampleDefault(CancellationToken.None, forceRefresh: true)), 10 }; + yield return new object[] { (Func>)(endpoint => endpoint.GetSampleCustomTTL(CancellationToken.None, forceRefresh: true)), 5 }; + } + + [Theory] + [MemberData(nameof(GetDataFor_GetSampleCached_with_force_refresh_x1))] + public async Task GetSampleCached_with_force_refresh_x1(Func> action, int ttl) + { + // Arrange + var cacheServiceMock = new Mock(() => new MemorySimpleCacheService()); + var endpoint = Setup(cacheServiceMock.Object); + + // Act + var result1 = await action(endpoint); + + // Assert + var x = default(byte[]); + cacheServiceMock.Verify(s => s.TryGet(It.IsAny(), It.IsAny(), out x), Times.Never); + cacheServiceMock.Verify(s => s.Add(It.IsAny(), It.IsAny(), It.Is(ts => ts.TotalMinutes == ttl), It.IsAny()), Times.Once); + } + + public static IEnumerable GetDataFor_GetSampleCached_with_force_refresh_x2() + { + yield return new object[] { (Func>)(endpoint => endpoint.GetSampleDefault(CancellationToken.None, forceRefresh: true)) }; + yield return new object[] { (Func>)(endpoint => endpoint.GetSampleCustomTTL(CancellationToken.None, forceRefresh: true)) }; + } + + [Theory] + [MemberData(nameof(GetDataFor_GetSampleCached_with_force_refresh_x2))] + public async Task GetSampleCached_with_force_refresh_x2(Func> action) + { + // Arrange + var endpoint = Setup(); + + // Act + var result1 = await action(endpoint); + var result2 = await action(endpoint); + + // Assert + res­­ult1.Should().Be("1"); + res­­ult2.Should().Be("2"); + } + + [Fact] + public async Task GetSampleNoCache_x1() + { + // Arrange + var cacheServiceMock = new Mock(() => new MemorySimpleCacheService()); + var endpoint = Setup(cacheServiceMock.Object); + + // Act + var result = await endpoint.GetSampleNoCache(CancellationToken.None); + + // Assert + res­­ult.Should().Be("1"); + var x = default(byte[]); + cacheServiceMock.Verify(s => s.TryGet(It.IsAny(), It.IsAny(), out x), Times.Never); + cacheServiceMock.Verify(s => s.Add(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); + } + + [Fact] + public async Task GetSampleNoCache_x2() + { + // Arrange + var endpoint = Setup(); + + // Act + var result1 = await endpoint.GetSampleNoCache(CancellationToken.None); + var result2 = await endpoint.GetSampleNoCache(CancellationToken.None); + + // Assert + res­­ult1.Should().Be("1"); + res­­ult2.Should().Be("2"); + } + + private ISampleEndpoint Setup(ISimpleCacheService cacheService = null) + { + int callCount = 0; + var serviceCollection = new ServiceCollection() + .AddSingleton(cacheService ?? new MemorySimpleCacheService()) + .AddSingleton(SimpleCacheKeyProvider.FromUriAndAuthorizationHash) + .AddTransient(_ => new TestHandler((request, ct) => + { + var response = new HttpResponseMessage() + { + Content = new StringContent($"{++callCount}") + }; + return Task.FromResult(response); + })) + .AddTransient(); + + serviceCollection + .AddRefitClient() + .ConfigureHttpClient((client) => + { + client.BaseAddress = new Uri("http://localhost"); + client.DefaultRequestHeaders.Add("Authorization", "Bearer 123"); + client.DefaultRequestHeaders.Add(SimpleCacheHandler.CacheTimeToLiveHeaderName, "600"); + }) + .ConfigurePrimaryHttpMessageHandler() + .AddHttpMessageHandler(); + + var serviceProvider = serviceCollection.BuildServiceProvider(); + return serviceProvider.GetRequiredService(); + } + } +} diff --git a/src/MallardMessageHandlers.Tests/MallardMessageHandlers.Tests.csproj b/src/MallardMessageHandlers.Tests/MallardMessageHandlers.Tests.csproj index d66cbbc..ade02ed 100644 --- a/src/MallardMessageHandlers.Tests/MallardMessageHandlers.Tests.csproj +++ b/src/MallardMessageHandlers.Tests/MallardMessageHandlers.Tests.csproj @@ -11,6 +11,7 @@ + diff --git a/src/MallardMessageHandlers.Tests/MemoryCacheServiceTests.cs b/src/MallardMessageHandlers.Tests/MemoryCacheServiceTests.cs new file mode 100644 index 0000000..d5dbbbb --- /dev/null +++ b/src/MallardMessageHandlers.Tests/MemoryCacheServiceTests.cs @@ -0,0 +1,82 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using FluentAssertions; +using MallardMessageHandlers.SimpleCaching; +using Moq; +using Xunit; + +namespace MallardMessageHandlers.Tests +{ + public class MemoryCacheServiceTests + { + private readonly CancellationToken _ctNone = CancellationToken.None; + + [Fact] + public async Task TryGet_returns_non_expired_payloads() + { + // Arrange + var cache = new MemorySimpleCacheService(); + var payload = new byte[0]; + + // Act + await cache.Add("key", payload, TimeSpan.FromMinutes(1000), _ctNone); + + // Assert + (await cache.TryGet("key", _ctNone, out var content)).Should().BeTrue(); + content.Should().BeSameAs(payload); + } + + [Fact] + public async Task TryGet_doesnt_return_expired_payloads() + { + // Arrange + var cache = new MemorySimpleCacheService(); + var payload = new byte[0]; + + // Act + await cache.Add("key", payload, TimeSpan.FromMinutes(0), _ctNone); + + // Assert + (await cache.TryGet("key", _ctNone, out var content)).Should().BeFalse(); + content.Should().BeNull(); + } + + [Fact] + public async Task Clear_removes_previous_items() + { + // Arrange + var cache = new MemorySimpleCacheService(); + var payload = new byte[0]; + + // Act + await cache.Add("key", payload, TimeSpan.FromMinutes(1000), _ctNone); + await cache.Clear(_ctNone); + + // Assert + (await cache.TryGet("key", _ctNone, out var content)).Should().BeFalse(); + content.Should().BeNull(); + } + + [Fact] + public async Task Add_preserves_the_latest_value() + { + // Arrange + var cache = new MemorySimpleCacheService(); + var payload = new byte[0]; + var payload2 = new byte[1]; + + // Act + await cache.Add("key", payload, TimeSpan.FromMinutes(1000), _ctNone); + await cache.Add("key", payload2, TimeSpan.FromMinutes(1000), _ctNone); + + // Assert + (await cache.TryGet("key", _ctNone, out var content)).Should().BeTrue(); + content.Should().BeSameAs(payload2); + } + + } +} diff --git a/src/MallardMessageHandlers/IsExternalInit.cs b/src/MallardMessageHandlers/IsExternalInit.cs new file mode 100644 index 0000000..e70b829 --- /dev/null +++ b/src/MallardMessageHandlers/IsExternalInit.cs @@ -0,0 +1,8 @@ +namespace System.Runtime.CompilerServices; + +/// +/// This allows the usage of record even though this is a .NET Standard 2.0 project. +/// +internal static class IsExternalInit +{ +} diff --git a/src/MallardMessageHandlers/MallardMessageHandlers.csproj b/src/MallardMessageHandlers/MallardMessageHandlers.csproj index 6a7740b..205de06 100644 --- a/src/MallardMessageHandlers/MallardMessageHandlers.csproj +++ b/src/MallardMessageHandlers/MallardMessageHandlers.csproj @@ -8,6 +8,7 @@ MallardMessageHandlers MallardMessageHandlers true + 10 delegatinghandler;oauth;network-status;network;networkexception;authenticationtoken;exceptioninterpreter;exceptionhub Apache-2.0 https://github.com/nventive/MallardMessageHandlers diff --git a/src/MallardMessageHandlers/SimpleCaching/ISimpleCacheKeyProvider.cs b/src/MallardMessageHandlers/SimpleCaching/ISimpleCacheKeyProvider.cs new file mode 100644 index 0000000..8622e9c --- /dev/null +++ b/src/MallardMessageHandlers/SimpleCaching/ISimpleCacheKeyProvider.cs @@ -0,0 +1,20 @@ +using System; +using System.Collections.Generic; +using System.Net.Http; +using System.Text; + +namespace MallardMessageHandlers.SimpleCaching; + +/// +/// Provides a way to get a cache key from a . +/// This is used by . +/// +public interface ISimpleCacheKeyProvider +{ + /// + /// Gets the cache key for the . + /// + /// The request. + /// A cache key. + string GetKey(HttpRequestMessage request); +} diff --git a/src/MallardMessageHandlers/SimpleCaching/ISimpleCacheService.cs b/src/MallardMessageHandlers/SimpleCaching/ISimpleCacheService.cs new file mode 100644 index 0000000..fba3d9b --- /dev/null +++ b/src/MallardMessageHandlers/SimpleCaching/ISimpleCacheService.cs @@ -0,0 +1,37 @@ +using System; +using System.Collections.Generic; +using System.Text; +using System.Threading; +using System.Threading.Tasks; + +namespace MallardMessageHandlers.SimpleCaching; + +/// +/// Represents a simple cache service allowing to cache data using a string key and a time-to-live. +/// +public interface ISimpleCacheService +{ + /// + /// Adds a payload to the cache with the specified key and expiration. + /// + /// The cache key. + /// The data to cache. + /// The duration for which the cache entry should stay retrievable. + /// The cancellation token. + Task Add(string key, byte[] payload, TimeSpan timeToLive, CancellationToken ct); + + /// + /// Gets the payload associated with the specified key, if available. + /// + /// The cache key. + /// The cancellation token. + /// The cache content for that key. + /// The cached payload or null when the key isn't found or when the payload is expired. + Task TryGet(string key, CancellationToken ct, out byte[] payload); + + /// + /// Clears all cache data. + /// + /// The cancellation token. + Task Clear(CancellationToken ct); +} diff --git a/src/MallardMessageHandlers/SimpleCaching/MemorySimpleCacheService.cs b/src/MallardMessageHandlers/SimpleCaching/MemorySimpleCacheService.cs new file mode 100644 index 0000000..75d8b7f --- /dev/null +++ b/src/MallardMessageHandlers/SimpleCaching/MemorySimpleCacheService.cs @@ -0,0 +1,75 @@ +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Data.SqlTypes; +using System.Text; +using System.Threading; +using System.Threading.Tasks; + +namespace MallardMessageHandlers.SimpleCaching; + +/// +/// This is a simple implementation of that stores payloads in a +/// and uses for time reference. +/// +[Preserve(AllMembers = true)] +public sealed class MemorySimpleCacheService : ISimpleCacheService +{ + private static readonly Task _trueResult = Task.FromResult(true); + private static readonly Task _falseResult = Task.FromResult(false); + private readonly ConcurrentDictionary _data = new ConcurrentDictionary(); + + /// + public Task Add(string key, byte[] payload, TimeSpan timeToLive, CancellationToken ct) + { + if (ct.IsCancellationRequested) + { + return Task.CompletedTask; + } + + var entry = new CacheEntry(payload, DateTimeOffset.Now + timeToLive); + _data[key] = entry; + + return Task.CompletedTask; + } + + /// + public Task Clear(CancellationToken ct) + { + if (ct.IsCancellationRequested) + { + return Task.CompletedTask; + } + + _data.Clear(); + return Task.CompletedTask; + } + + /// + public Task TryGet(string key, CancellationToken ct, out byte[] payload) + { + if (ct.IsCancellationRequested) + { + payload = null; + return _falseResult; + } + + if (_data.TryGetValue(key, out var entry)) + { + if (entry.Expiration > DateTimeOffset.Now) + { + payload = entry.Payload; + return _trueResult; + } + else + { + _data.TryRemove(key, out _); + } + } + + payload = null; + return _falseResult; + } + + private record CacheEntry(byte[] Payload, DateTimeOffset Expiration) { } +} diff --git a/src/MallardMessageHandlers/SimpleCaching/SimpleCacheHandler.cs b/src/MallardMessageHandlers/SimpleCaching/SimpleCacheHandler.cs new file mode 100644 index 0000000..1f10409 --- /dev/null +++ b/src/MallardMessageHandlers/SimpleCaching/SimpleCacheHandler.cs @@ -0,0 +1,147 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Net.Http; +using System.Text; +using System.Threading; +using System.Threading.Tasks; + +namespace MallardMessageHandlers.SimpleCaching; + +[Preserve(AllMembers = true)] +public sealed class SimpleCacheHandler : DelegatingHandler +{ + /// + /// The HTTP header name for the Time-To-Live caching instruction. + /// The header value needs to be an integer representing the total seconds of the time-to-live. + /// + /// // Example: + /// "X-Mallard-SimpleCache-TTL:600" // Sets the time-to-live to 10 minutes (600 seconds). + /// + /// + public const string CacheTimeToLiveHeaderName = "X-Mallard-SimpleCache-TTL"; + + /// + /// The HTTP header name for the Force-Refresh caching instruction. + /// The header value needs to be a boolean representing whether the force-refresh instruction is to be applied. + /// + public const string CacheForceRefreshHeaderName = "X-Mallard-SimpleCache-ForceRefresh"; + + /// + /// The HTTP header name for the Disable caching instruction. + /// The header value needs to be a boolean representing whether the disable instruction is to be applied. + /// This instruction takes precedence over all others. + /// + public const string CacheDisableHeaderName = "X-Mallard-SimpleCache-Disable"; + + private readonly ISimpleCacheService _cacheService; + private readonly ISimpleCacheKeyProvider _cacheKeyProvider; + + /// + /// Initializes a new instance of . + /// + /// The cache service to use to store payloads. + /// The to use to generate the cache keys. + public SimpleCacheHandler(ISimpleCacheService cacheService, ISimpleCacheKeyProvider cacheKeyProvider) + { + _cacheService = cacheService; + _cacheKeyProvider = cacheKeyProvider; + } + + /// + protected override async Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + if (request.Method != HttpMethod.Get) + { + return await base.SendAsync(request, cancellationToken); + } + + // The Cache-Disable header overrides all other cache instructions and forces normal execution. + if (ExtractIsCacheDisabled(request)) + { + return await base.SendAsync(request, cancellationToken); + } + + var cacheKey = _cacheKeyProvider.GetKey(request); + var isForceRefresh = ExtractIsForceRefresh(request); + var isCacheable = ExtractIsCacheEnabled(request, out var timeToLive); + var cachedPayload = default(byte[]); + + if (isForceRefresh || !isCacheable || isCacheable && !await _cacheService.TryGet(cacheKey, cancellationToken, out cachedPayload)) + { + var response = await base.SendAsync(request, cancellationToken); + + if (isCacheable && response.IsSuccessStatusCode && !cancellationToken.IsCancellationRequested) + { + await _cacheService.Add(cacheKey, await response.Content.ReadAsByteArrayAsync(), timeToLive, cancellationToken); + } + + return response; + } + else + { + var cacheResponse = new HttpResponseMessage(); + cacheResponse.Content = new ByteArrayContent(cachedPayload); + return cacheResponse; + } + } + + /// + /// Gets whether the request is a force refresh request and removes the force refresh header. + /// + /// The request. + /// Whether requires a refresh. + private bool ExtractIsForceRefresh(HttpRequestMessage request) + { + var headerName = CacheForceRefreshHeaderName; + if (request.Headers.TryGetValues(headerName, out var forceRefreshValues)) + { + request.Headers.Remove(headerName); + var rawForceRefresh = forceRefreshValues.LastOrDefault(); + return bool.Parse(rawForceRefresh); + } + + return false; + } + + /// + /// Gets whether the request has caching disabled. + /// + /// The request. + /// Whether doesn't have caching. + private bool ExtractIsCacheDisabled(HttpRequestMessage request) + { + var headerName = CacheDisableHeaderName; + if (request.Headers.TryGetValues(headerName, out var disableCacheValues)) + { + request.Headers.Remove(headerName); + request.Headers.Remove(CacheForceRefreshHeaderName); + request.Headers.Remove(CacheTimeToLiveHeaderName); + var rawDisableCache = disableCacheValues.LastOrDefault(); + return bool.Parse(rawDisableCache); + } + + return false; + } + + /// + /// Gets whether the request is a force refresh request and removes the force refresh header. + /// + /// The request. + /// The time to live of result of this request, when the cache is enable. + /// Whether requires a refresh. + private bool ExtractIsCacheEnabled(HttpRequestMessage request, out TimeSpan timeToLive) + { + var headerName = CacheTimeToLiveHeaderName; + if (request.Headers.TryGetValues(headerName, out var timeToLiveValues)) + { + request.Headers.Remove(headerName); + // We use LastOrDefault to use the lastest value. (The can be a default value and an override value). + var rawTimeToLive = timeToLiveValues.LastOrDefault(); + timeToLive = TimeSpan.FromSeconds(int.Parse(rawTimeToLive)); + return true; + } + + return false; + } +} diff --git a/src/MallardMessageHandlers/SimpleCaching/SimpleCacheKeyProvider.cs b/src/MallardMessageHandlers/SimpleCaching/SimpleCacheKeyProvider.cs new file mode 100644 index 0000000..a826053 --- /dev/null +++ b/src/MallardMessageHandlers/SimpleCaching/SimpleCacheKeyProvider.cs @@ -0,0 +1,69 @@ +using System; +using System.Collections.Generic; +using System.Net.Http; +using System.Text; + +namespace MallardMessageHandlers.SimpleCaching; + +/// +/// This class provides a default implementation of and common static instances. +/// +public sealed class SimpleCacheKeyProvider : ISimpleCacheKeyProvider +{ + /// + /// Gets a that generates a cache key using only the Uri of the . + /// + public static ISimpleCacheKeyProvider FromUriOnly { get; } = new SimpleCacheKeyProvider(GetKeyFromUriOnly); + + /// + /// Gets a that generates a cache key using the Uri of the and a hash (SHA256) of the Authorization header value. + /// + public static ISimpleCacheKeyProvider FromUriAndAuthorizationHash { get; } = new UriAndAuthorizationHashCacheKeyProvider(); + + private static string GetKeyFromUriOnly(HttpRequestMessage httpRequestMessage) + => httpRequestMessage.RequestUri.ToString(); + + private readonly Func _function; + + /// + /// Initializes a new instances of . + /// + /// The function that gets the cache key. + public SimpleCacheKeyProvider(Func function) + { + _function = function; + } + + /// + public string GetKey(HttpRequestMessage request) => _function(request); +} + +/// +/// This implementation of generates a cache key using the Uri of the and a hash of the Authorization header value. +/// +public sealed class UriAndAuthorizationHashCacheKeyProvider : ISimpleCacheKeyProvider, IDisposable +{ + private readonly System.Security.Cryptography.SHA256Managed _sha = new(); + + /// + public string GetKey(HttpRequestMessage httpRequestMessage) + { + var sb = new StringBuilder(); + sb.Append(httpRequestMessage.RequestUri); + var authorizationValue = httpRequestMessage.Headers.Authorization.Parameter; + if (!string.IsNullOrEmpty(authorizationValue)) + { + var textData = Encoding.UTF8.GetBytes(authorizationValue); + var hashBytes = _sha.ComputeHash(textData); + var hash = BitConverter.ToString(hashBytes).Replace("-", string.Empty); + sb.Append(hash); + } + return sb.ToString(); + } + + /// + public void Dispose() + { + _sha.Dispose(); + } +} From 26bdb76c3f00711edc9e29abd6691d4660b0f449 Mon Sep 17 00:00:00 2001 From: Jean-Philippe Levesque Date: Thu, 20 Oct 2022 10:08:44 -0400 Subject: [PATCH 2/2] feat: Add MallardMessageHandlers.Refit package to support cleaner caching attributes in Refit endpoints. --- MallardMessageHandlers.sln | 14 ++++- README.md | 27 +++----- .../MallardMessageHandlers.Refit.csproj | 43 +++++++++++++ .../SimpleCaching/RefitAttributes.cs | 62 +++++++++++++++++++ .../CachingRefitIntegrationTests.cs | 17 ++--- .../MallardMessageHandlers.Tests.csproj | 25 +++++--- 6 files changed, 144 insertions(+), 44 deletions(-) create mode 100644 src/MallardMessageHandlers.Refit/MallardMessageHandlers.Refit.csproj create mode 100644 src/MallardMessageHandlers.Refit/SimpleCaching/RefitAttributes.cs diff --git a/MallardMessageHandlers.sln b/MallardMessageHandlers.sln index ef9a8e9..f0c40fa 100644 --- a/MallardMessageHandlers.sln +++ b/MallardMessageHandlers.sln @@ -1,12 +1,14 @@  Microsoft Visual Studio Solution File, Format Version 12.00 -# Visual Studio Version 16 -VisualStudioVersion = 16.0.29905.134 +# Visual Studio Version 17 +VisualStudioVersion = 17.3.32901.215 MinimumVisualStudioVersion = 10.0.40219.1 Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "MallardMessageHandlers", "src\MallardMessageHandlers\MallardMessageHandlers.csproj", "{8EEF328D-CBC8-45DB-9526-904CC58D2F57}" EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "MallardMessageHandlers.Tests", "src\MallardMessageHandlers.Tests\MallardMessageHandlers.Tests.csproj", "{47E50E30-646E-441B-B2EF-77A33CA4D4B1}" EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "MallardMessageHandlers.Refit", "src\MallardMessageHandlers.Refit\MallardMessageHandlers.Refit.csproj", "{3032964A-167E-4DF5-B279-B88E504A282F}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -31,6 +33,14 @@ Global {47E50E30-646E-441B-B2EF-77A33CA4D4B1}.Release|Any CPU.Build.0 = Release|Any CPU {47E50E30-646E-441B-B2EF-77A33CA4D4B1}.Release|NuGet.ActiveCfg = Release|Any CPU {47E50E30-646E-441B-B2EF-77A33CA4D4B1}.Release|NuGet.Build.0 = Release|Any CPU + {3032964A-167E-4DF5-B279-B88E504A282F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {3032964A-167E-4DF5-B279-B88E504A282F}.Debug|Any CPU.Build.0 = Debug|Any CPU + {3032964A-167E-4DF5-B279-B88E504A282F}.Debug|NuGet.ActiveCfg = Debug|Any CPU + {3032964A-167E-4DF5-B279-B88E504A282F}.Debug|NuGet.Build.0 = Debug|Any CPU + {3032964A-167E-4DF5-B279-B88E504A282F}.Release|Any CPU.ActiveCfg = Release|Any CPU + {3032964A-167E-4DF5-B279-B88E504A282F}.Release|Any CPU.Build.0 = Release|Any CPU + {3032964A-167E-4DF5-B279-B88E504A282F}.Release|NuGet.ActiveCfg = Release|Any CPU + {3032964A-167E-4DF5-B279-B88E504A282F}.Release|NuGet.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE diff --git a/README.md b/README.md index e4480d1..b4f895d 100644 --- a/README.md +++ b/README.md @@ -96,6 +96,7 @@ private void ConfigureNetworkExceptionHandler(IServiceCollection services) The `SimpleCacheHandler` is a `DelegatingHandler` that executes custom caching instructions. When you use Refit for your endpoints declaration, you can neatly specify caching instructions with attributes. +Just install the the `MallardMessageHandler.Refit` package to get those Refit-compatible attributes. - You can specify time-to-live at different levels. - On a per-call level (using attributes) @@ -106,36 +107,23 @@ This can be useful for things like pull-to-refresh. This only makes sense when you define default caching globally. ```csharp -// (...) -public static class CacheInstructions -{ - public const string DefaultCacheValue = "600"; // 10 minutes - public const string ForceRefresh = SimpleCacheHandler.CacheForceRefreshHeaderName; - public const string Cache5Minutes = SimpleCacheHandler.CacheTimeToLiveHeaderName + ":300"; - public const string NoCache = SimpleCacheHandler.CacheDisableHeaderName + ":true"; -} - -// (...) -using static YourNamespace.CacheInstructions; +using MallardMessageHandlers.SimpleCaching; public interface ISampleEndpoint { [Get("/sample")] - Task GetSomeInfo([Header(ForceRefresh)] bool forceRefresh = false); + Task GetSampleDefault(CancellationToken ct, [ForceRefresh] bool forceRefresh = false); [Get("/sample")] - [Headers(Cache5Minutes)] // You can customize the TTL on a per-call basis. - Task GetSomeStuff([Header(ForceRefresh)] bool forceRefresh = false); + [TimeToLive(totalMinutes: 5)] // You can customize the TTL on a per-call basis. + Task GetSampleCustomTTL(CancellationToken ct, [ForceRefresh] bool forceRefresh = false); [Get("/sample")] - [Headers(NoCache)] // When you have a default TTL, you can bypass it on a per-call basis. - Task GetSomeStatus(); + [NoCache] // When you have a default TTL, you can bypass it on a per-call basis. + Task GetSampleNoCache(CancellationToken ct); } ``` -Because header attributes works with `string` constants, we recommend you create a static class to declare your caching instructions. -> 💡 You can even leverage `using static` to have super concise instructions by not repeating the static class name. - Here's how you can configure a default time-to-live for all calls. ```csharp serviceCollection @@ -143,6 +131,7 @@ serviceCollection .ConfigureHttpClient((client) => { // You can configure a default time-to-live for all calls. + // "600" represents 10 minutes (600 seconds). client.DefaultRequestHeaders.Add(SimpleCacheHandler.CacheTimeToLiveHeaderName, "600"); }); ``` diff --git a/src/MallardMessageHandlers.Refit/MallardMessageHandlers.Refit.csproj b/src/MallardMessageHandlers.Refit/MallardMessageHandlers.Refit.csproj new file mode 100644 index 0000000..4305d2d --- /dev/null +++ b/src/MallardMessageHandlers.Refit/MallardMessageHandlers.Refit.csproj @@ -0,0 +1,43 @@ + + + netstandard2.0 + MallardMessageHandlers + nventive + nventive + MallardMessageHandlers.Refit + MallardMessageHandlers.Refit + MallardMessageHandlers.Refit + true + 10 + delegatinghandler;oauth;network-status;network;networkexception;authenticationtoken;exceptioninterpreter;exceptionhub;refit + Apache-2.0 + https://github.com/nventive/MallardMessageHandlers + README.md + + + true + true + true + snupkg + + + + + True + \ + + + + + + + + + + + + + + + + diff --git a/src/MallardMessageHandlers.Refit/SimpleCaching/RefitAttributes.cs b/src/MallardMessageHandlers.Refit/SimpleCaching/RefitAttributes.cs new file mode 100644 index 0000000..c67650e --- /dev/null +++ b/src/MallardMessageHandlers.Refit/SimpleCaching/RefitAttributes.cs @@ -0,0 +1,62 @@ +using System; +using System.Collections.Generic; +using System.Text; +using Refit; + +namespace MallardMessageHandlers.SimpleCaching +{ + /// + /// Sets the cache time-to-live for the current call. + /// + [AttributeUsage(AttributeTargets.Interface | AttributeTargets.Method)] + public class TimeToLiveAttribute : HeadersAttribute + { + /// + /// Initializes a new instance of the class. + /// + /// The time-to-live in seconds. + public TimeToLiveAttribute(int totalSeconds) + : base(SimpleCacheHandler.CacheTimeToLiveHeaderName + ":" + totalSeconds) + { + } + + /// + /// Initializes a new instance of the class. + /// + /// The time-to-live in minutes. + public TimeToLiveAttribute(double totalMinutes) + : base(SimpleCacheHandler.CacheTimeToLiveHeaderName + ":" + (int)(totalMinutes * 60)) + { + } + } + + /// + /// Bypasses the other simple caching instructions. + /// + [AttributeUsage(AttributeTargets.Interface | AttributeTargets.Method)] + public class NoCacheAttribute : HeadersAttribute + { + /// + /// Initializes a new instance of the class. + /// + public NoCacheAttribute() + : base(SimpleCacheHandler.CacheDisableHeaderName + ":true") + { + } + } + + /// + /// Associates the force-refresh simple caching instruction with a boolean parameter. + /// + [AttributeUsage(AttributeTargets.Parameter)] + public class ForceRefresh : HeaderAttribute + { + /// + /// Initializes a new instance of the class. + /// + public ForceRefresh() + : base(SimpleCacheHandler.CacheForceRefreshHeaderName) + { + } + } +} diff --git a/src/MallardMessageHandlers.Tests/CachingRefitIntegrationTests.cs b/src/MallardMessageHandlers.Tests/CachingRefitIntegrationTests.cs index 71df98b..73a4531 100644 --- a/src/MallardMessageHandlers.Tests/CachingRefitIntegrationTests.cs +++ b/src/MallardMessageHandlers.Tests/CachingRefitIntegrationTests.cs @@ -11,31 +11,22 @@ using Moq; using Refit; using Xunit; -using static MallardMessageHandlers.Tests.CacheInstructions; namespace MallardMessageHandlers.Tests { - public static class CacheInstructions - { - public const string DefaultCacheValue = "600"; // 10 minutes - public const string ForceRefresh = SimpleCacheHandler.CacheForceRefreshHeaderName; - public const string Cache5Minutes = SimpleCacheHandler.CacheTimeToLiveHeaderName + ":300"; - public const string NoCache = SimpleCacheHandler.CacheDisableHeaderName + ":true"; - } - public interface ISampleEndpoint { // Default cache is 10 minutes on all methods. [Get("/sample")] - Task GetSampleDefault(CancellationToken ct, [Header(ForceRefresh)] bool forceRefresh = false); + Task GetSampleDefault(CancellationToken ct, [ForceRefresh] bool forceRefresh = false); [Get("/sample")] - [Headers(Cache5Minutes)] // You can customize the TTL on a per-call basis. - Task GetSampleCustomTTL(CancellationToken ct, [Header(ForceRefresh)] bool forceRefresh = false); + [TimeToLive(totalMinutes: 5)] // You can customize the TTL on a per-call basis. + Task GetSampleCustomTTL(CancellationToken ct, [ForceRefresh] bool forceRefresh = false); [Get("/sample")] - [Headers(NoCache)] // When you have a default TTL, you can bypass it on a per-call basis. + [NoCache] // When you have a default TTL, you can bypass it on a per-call basis. Task GetSampleNoCache(CancellationToken ct); } diff --git a/src/MallardMessageHandlers.Tests/MallardMessageHandlers.Tests.csproj b/src/MallardMessageHandlers.Tests/MallardMessageHandlers.Tests.csproj index ade02ed..07c025d 100644 --- a/src/MallardMessageHandlers.Tests/MallardMessageHandlers.Tests.csproj +++ b/src/MallardMessageHandlers.Tests/MallardMessageHandlers.Tests.csproj @@ -1,25 +1,30 @@  - net461 + net472 false MallardMessageHandlers.Tests MallardMessageHandlers.Tests - - - - - - - - - + + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + +