From e92b103644a573a73e3f720b134b9baac86cf145 Mon Sep 17 00:00:00 2001 From: Mohsen Rajabi Date: Thu, 19 Oct 2023 14:10:13 +0330 Subject: [PATCH] Cache by header value: a new Header property in (File)CacheOptions configuration of a route (#1172) @EngRajabi, Mohsen Rajabi (7): add header to file cache option fix private set fix fix build fail fix: fix review comment. add unit test for change @raman-m, Raman Maksimchuk (1): Update caching.rst @raman-m (7): Fix errors Fix errors Fix styling warnings Refactor tests Add Delimiter Refactor generator Add unit tests --- docs/features/caching.rst | 50 ++++---- src/Ocelot/Cache/CacheKeyGenerator.cs | 39 +++++-- src/Ocelot/Cache/ICacheKeyGenerator.cs | 5 +- .../Cache/Middleware/OutputCacheMiddleware.cs | 2 +- src/Ocelot/Configuration/CacheOptions.cs | 9 +- .../Configuration/Creator/RoutesCreator.cs | 2 +- .../Configuration/File/FileCacheOptions.cs | 1 + .../Request/Middleware/DownstreamRequest.cs | 15 ++- .../Middleware/RequestIdMiddleware.cs | 4 +- .../Cache/CacheKeyGeneratorTests.cs | 108 ++++++++++++++++-- .../Cache/OutputCacheMiddlewareTests.cs | 2 +- .../OutputCacheMiddlewareRealCacheTests.cs | 4 +- 12 files changed, 187 insertions(+), 54 deletions(-) diff --git a/docs/features/caching.rst b/docs/features/caching.rst index 0a1cac37b..51bc7b8aa 100644 --- a/docs/features/caching.rst +++ b/docs/features/caching.rst @@ -3,46 +3,56 @@ Caching Ocelot supports some very rudimentary caching at the moment provider by the `CacheManager `_ project. This is an amazing project that is solving a lot of caching problems. I would recommend using this package to cache with Ocelot. -The following example shows how to add CacheManager to Ocelot so that you can do output caching. +The following example shows how to add **CacheManager** to Ocelot so that you can do output caching. -First of all add the following NuGet package. +First of all add the following `NuGet package `_: - ``Install-Package Ocelot.Cache.CacheManager`` +.. code-block:: powershell + + Install-Package Ocelot.Cache.CacheManager This will give you access to the Ocelot cache manager extension methods. -The second thing you need to do something like the following to your ConfigureServices.. +The second thing you need to do something like the following to your ``ConfigureServices`` method: .. code-block:: csharp using Ocelot.Cache.CacheManager; - s.AddOcelot() - .AddCacheManager(x => - { - x.WithDictionaryHandle(); - }) + ConfigureServices(services => + { + services.AddOcelot() + .AddCacheManager(x => x.WithDictionaryHandle()); + }); -Finally in order to use caching on a route in your Route configuration add this setting. +Finally, in order to use caching on a route in your Route configuration add this setting: .. code-block:: json - "FileCacheOptions": { "TtlSeconds": 15, "Region": "somename" } + "FileCacheOptions": { "TtlSeconds": 15, "Region": "europe-central", "Header": "Authorization" } + +In this example **TtlSeconds** is set to 15 which means the cache will expire after 15 seconds. +The **Region** represents a region of caching. -In this example ttl seconds is set to 15 which means the cache will expire after 15 seconds. +Additionally, if a header name is defined in the **Header** property, that header value is looked up by the key (header name) in the ``HttpRequest`` headers, +and if the header is found, its value will be included in caching key. This causes the cache to become invalid due to the header value changing. -If you look at the example `here `_ you can see how the cache manager is setup and then passed into the Ocelot AddCacheManager configuration method. You can use any settings supported by the CacheManager package and just pass them in. +If you look at the example `here `_ you can see how the cache manager is setup and then passed into the Ocelot ``AddCacheManager`` configuration method. +You can use any settings supported by the **CacheManager** package and just pass them in. -Anyway Ocelot currently supports caching on the URL of the downstream service and setting a TTL in seconds to expire the cache. You can also clear the cache for a region by calling Ocelot's administration API. +Anyway, Ocelot currently supports caching on the URL of the downstream service and setting a TTL in seconds to expire the cache. You can also clear the cache for a region by calling Ocelot's administration API. -Your own caching -^^^^^^^^^^^^^^^^ +Your Own Caching +---------------- -If you want to add your own caching method implement the following interfaces and register them in DI e.g. ``services.AddSingleton, MyCache>()`` +If you want to add your own caching method, implement the following interfaces and register them in DI e.g. -``IOcelotCache`` this is for output caching. +.. code-block:: csharp -``IOcelotCache`` this is for caching the file configuration if you are calling something remote to get your config such as Consul. + services.AddSingleton, MyCache>(); -Please dig into the Ocelot source code to find more. I would really appreciate it if anyone wants to implement Redis, memcache etc.. +* ``IOcelotCache`` this is for output caching. +* ``IOcelotCache`` this is for caching the file configuration if you are calling something remote to get your config such as Consul. +Please dig into the Ocelot source code to find more. +We would really appreciate it if anyone wants to implement `Redis `_, `Memcached `_ etc. diff --git a/src/Ocelot/Cache/CacheKeyGenerator.cs b/src/Ocelot/Cache/CacheKeyGenerator.cs index e6ae88213..0b25212aa 100644 --- a/src/Ocelot/Cache/CacheKeyGenerator.cs +++ b/src/Ocelot/Cache/CacheKeyGenerator.cs @@ -1,20 +1,43 @@ -using Ocelot.Request.Middleware; +using Ocelot.Configuration; +using Ocelot.Request.Middleware; namespace Ocelot.Cache { public class CacheKeyGenerator : ICacheKeyGenerator { - public string GenerateRequestCacheKey(DownstreamRequest downstreamRequest) + private const char Delimiter = '-'; + + public async ValueTask GenerateRequestCacheKey(DownstreamRequest downstreamRequest, DownstreamRoute downstreamRoute) { - var downStreamUrlKeyBuilder = new StringBuilder($"{downstreamRequest.Method}-{downstreamRequest.OriginalString}"); - if (downstreamRequest.Content != null) + var builder = new StringBuilder() + .Append(downstreamRequest.Method) + .Append(Delimiter) + .Append(downstreamRequest.OriginalString); + + var cacheOptionsHeader = downstreamRoute?.CacheOptions?.Header; + if (!string.IsNullOrEmpty(cacheOptionsHeader)) + { + var header = downstreamRequest.Headers + .FirstOrDefault(r => r.Key.Equals(cacheOptionsHeader, StringComparison.OrdinalIgnoreCase)) + .Value?.FirstOrDefault(); + + if (!string.IsNullOrEmpty(header)) + { + builder.Append(Delimiter) + .Append(header); + } + } + + if (!downstreamRequest.HasContent) { - var requestContentString = Task.Run(async () => await downstreamRequest.Content.ReadAsStringAsync()).Result; - downStreamUrlKeyBuilder.Append(requestContentString); + return MD5Helper.GenerateMd5(builder.ToString()); } - var hashedContent = MD5Helper.GenerateMd5(downStreamUrlKeyBuilder.ToString()); - return hashedContent; + var requestContentString = await downstreamRequest.ReadContentAsync(); + builder.Append(Delimiter) + .Append(requestContentString); + + return MD5Helper.GenerateMd5(builder.ToString()); } } } diff --git a/src/Ocelot/Cache/ICacheKeyGenerator.cs b/src/Ocelot/Cache/ICacheKeyGenerator.cs index 32a1f989e..d2ccb0ef5 100644 --- a/src/Ocelot/Cache/ICacheKeyGenerator.cs +++ b/src/Ocelot/Cache/ICacheKeyGenerator.cs @@ -1,9 +1,10 @@ -using Ocelot.Request.Middleware; +using Ocelot.Configuration; +using Ocelot.Request.Middleware; namespace Ocelot.Cache { public interface ICacheKeyGenerator { - string GenerateRequestCacheKey(DownstreamRequest downstreamRequest); + ValueTask GenerateRequestCacheKey(DownstreamRequest downstreamRequest, DownstreamRoute downstreamRoute); } } diff --git a/src/Ocelot/Cache/Middleware/OutputCacheMiddleware.cs b/src/Ocelot/Cache/Middleware/OutputCacheMiddleware.cs index 0117c4536..134708881 100644 --- a/src/Ocelot/Cache/Middleware/OutputCacheMiddleware.cs +++ b/src/Ocelot/Cache/Middleware/OutputCacheMiddleware.cs @@ -33,7 +33,7 @@ public async Task Invoke(HttpContext httpContext) var downstreamRequest = httpContext.Items.DownstreamRequest(); var downstreamUrlKey = $"{downstreamRequest.Method}-{downstreamRequest.OriginalString}"; - var downStreamRequestCacheKey = _cacheGenerator.GenerateRequestCacheKey(downstreamRequest); + var downStreamRequestCacheKey = await _cacheGenerator.GenerateRequestCacheKey(downstreamRequest, downstreamRoute); Logger.LogDebug($"Started checking cache for the '{downstreamUrlKey}' key."); diff --git a/src/Ocelot/Configuration/CacheOptions.cs b/src/Ocelot/Configuration/CacheOptions.cs index d509b38e9..5b8d2987b 100644 --- a/src/Ocelot/Configuration/CacheOptions.cs +++ b/src/Ocelot/Configuration/CacheOptions.cs @@ -2,14 +2,17 @@ { public class CacheOptions { - public CacheOptions(int ttlSeconds, string region) + public CacheOptions(int ttlSeconds, string region, string header) { TtlSeconds = ttlSeconds; - Region = region; + Region = region; + Header = header; } public int TtlSeconds { get; } - public string Region { get; } + public string Region { get; } + + public string Header { get; } } } diff --git a/src/Ocelot/Configuration/Creator/RoutesCreator.cs b/src/Ocelot/Configuration/Creator/RoutesCreator.cs index 100c65116..8c1f1de63 100644 --- a/src/Ocelot/Configuration/Creator/RoutesCreator.cs +++ b/src/Ocelot/Configuration/Creator/RoutesCreator.cs @@ -122,7 +122,7 @@ private DownstreamRoute SetUpDownstreamRoute(FileRoute fileRoute, FileGlobalConf .WithClaimsToDownstreamPath(claimsToDownstreamPath) .WithRequestIdKey(requestIdKey) .WithIsCached(fileRouteOptions.IsCached) - .WithCacheOptions(new CacheOptions(fileRoute.FileCacheOptions.TtlSeconds, region)) + .WithCacheOptions(new CacheOptions(fileRoute.FileCacheOptions.TtlSeconds, region, fileRoute.FileCacheOptions.Header)) .WithDownstreamScheme(fileRoute.DownstreamScheme) .WithLoadBalancerOptions(lbOptions) .WithDownstreamAddresses(downstreamAddresses) diff --git a/src/Ocelot/Configuration/File/FileCacheOptions.cs b/src/Ocelot/Configuration/File/FileCacheOptions.cs index b5168b3c0..e1438bbab 100644 --- a/src/Ocelot/Configuration/File/FileCacheOptions.cs +++ b/src/Ocelot/Configuration/File/FileCacheOptions.cs @@ -4,5 +4,6 @@ public class FileCacheOptions { public int TtlSeconds { get; set; } public string Region { get; set; } + public string Header { get; set; } } } diff --git a/src/Ocelot/Request/Middleware/DownstreamRequest.cs b/src/Ocelot/Request/Middleware/DownstreamRequest.cs index 18b8641e1..03e4134fc 100644 --- a/src/Ocelot/Request/Middleware/DownstreamRequest.cs +++ b/src/Ocelot/Request/Middleware/DownstreamRequest.cs @@ -6,6 +6,8 @@ public class DownstreamRequest { private readonly HttpRequestMessage _request; + public DownstreamRequest() { } + public DownstreamRequest(HttpRequestMessage request) { _request = request; @@ -17,14 +19,13 @@ public DownstreamRequest(HttpRequestMessage request) Headers = _request.Headers; AbsolutePath = _request.RequestUri.AbsolutePath; Query = _request.RequestUri.Query; - Content = _request.Content; } - public HttpRequestHeaders Headers { get; } + public virtual HttpHeaders Headers { get; } - public string Method { get; } + public virtual string Method { get; } - public string OriginalString { get; } + public virtual string OriginalString { get; } public string Scheme { get; set; } @@ -36,7 +37,11 @@ public DownstreamRequest(HttpRequestMessage request) public string Query { get; set; } - public HttpContent Content { get; set; } + public virtual bool HasContent { get => _request?.Content != null; } + + public virtual Task ReadContentAsync() => HasContent + ? _request.Content.ReadAsStringAsync() + : Task.FromResult(string.Empty); public HttpRequestMessage ToHttpRequestMessage() { diff --git a/src/Ocelot/RequestId/Middleware/RequestIdMiddleware.cs b/src/Ocelot/RequestId/Middleware/RequestIdMiddleware.cs index f2fc77ca1..795fd89f9 100644 --- a/src/Ocelot/RequestId/Middleware/RequestIdMiddleware.cs +++ b/src/Ocelot/RequestId/Middleware/RequestIdMiddleware.cs @@ -58,14 +58,14 @@ private void SetOcelotRequestId(HttpContext httpContext) } } - private static bool ShouldAddRequestId(RequestId requestId, HttpRequestHeaders headers) + private static bool ShouldAddRequestId(RequestId requestId, HttpHeaders headers) { return !string.IsNullOrEmpty(requestId?.RequestIdKey) && !string.IsNullOrEmpty(requestId.RequestIdValue) && !RequestIdInHeaders(requestId, headers); } - private static bool RequestIdInHeaders(RequestId requestId, HttpRequestHeaders headers) + private static bool RequestIdInHeaders(RequestId requestId, HttpHeaders headers) { return headers.TryGetValues(requestId.RequestIdKey, out var value); } diff --git a/test/Ocelot.UnitTests/Cache/CacheKeyGeneratorTests.cs b/test/Ocelot.UnitTests/Cache/CacheKeyGeneratorTests.cs index f71e684af..c0572cfb2 100644 --- a/test/Ocelot.UnitTests/Cache/CacheKeyGeneratorTests.cs +++ b/test/Ocelot.UnitTests/Cache/CacheKeyGeneratorTests.cs @@ -1,32 +1,122 @@ using Ocelot.Cache; +using Ocelot.Configuration; +using Ocelot.Configuration.Builder; using Ocelot.Request.Middleware; +using System.Net.Http.Headers; namespace Ocelot.UnitTests.Cache { public class CacheKeyGeneratorTests { private readonly ICacheKeyGenerator _cacheKeyGenerator; - private readonly DownstreamRequest _downstreamRequest; + private readonly Mock _downstreamRequest; + + private const string verb = "GET"; + private const string url = "https://some.url/blah?abcd=123"; + private const string header = nameof(CacheKeyGeneratorTests); + private const string headerName = "auth"; public CacheKeyGeneratorTests() { _cacheKeyGenerator = new CacheKeyGenerator(); - _cacheKeyGenerator = new CacheKeyGenerator(); - _downstreamRequest = new DownstreamRequest(new HttpRequestMessage(HttpMethod.Get, "https://some.url/blah?abcd=123")); + + _downstreamRequest = new Mock(); + _downstreamRequest.SetupGet(x => x.Method).Returns(verb); + _downstreamRequest.SetupGet(x => x.OriginalString).Returns(url); + + var headers = new HttpHeadersStub + { + { headerName, header }, + }; + _downstreamRequest.SetupGet(x => x.Headers).Returns(headers); + } + + [Fact] + public void should_generate_cache_key_with_request_content() + { + const string content = nameof(should_generate_cache_key_with_request_content); + + _downstreamRequest.SetupGet(x => x.HasContent).Returns(true); + _downstreamRequest.Setup(x => x.ReadContentAsync()).ReturnsAsync(content); + + var cachekey = MD5Helper.GenerateMd5($"{verb}-{url}-{content}"); + + this.Given(x => x.GivenDownstreamRoute(null)) + .When(x => x.WhenGenerateRequestCacheKey()) + .Then(x => x.ThenGeneratedCacheKeyIs(cachekey)) + .BDDfy(); + } + + [Fact] + public void should_generate_cache_key_without_request_content() + { + _downstreamRequest.SetupGet(x => x.HasContent).Returns(false); + + CacheOptions options = null; + var cachekey = MD5Helper.GenerateMd5($"{verb}-{url}"); + + this.Given(x => x.GivenDownstreamRoute(options)) + .When(x => x.WhenGenerateRequestCacheKey()) + .Then(x => x.ThenGeneratedCacheKeyIs(cachekey)) + .BDDfy(); + } + + [Fact] + public void should_generate_cache_key_with_cache_options_header() + { + _downstreamRequest.SetupGet(x => x.HasContent).Returns(false); + + CacheOptions options = new CacheOptions(100, "region", headerName); + var cachekey = MD5Helper.GenerateMd5($"{verb}-{url}-{header}"); + + this.Given(x => x.GivenDownstreamRoute(options)) + .When(x => x.WhenGenerateRequestCacheKey()) + .Then(x => x.ThenGeneratedCacheKeyIs(cachekey)) + .BDDfy(); } [Fact] - public void should_generate_cache_key_from_context() + public void should_generate_cache_key_happy_path() { - this.Given(x => x.GivenCacheKeyFromContext(_downstreamRequest)) + const string content = nameof(should_generate_cache_key_happy_path); + + _downstreamRequest.SetupGet(x => x.HasContent).Returns(true); + _downstreamRequest.Setup(x => x.ReadContentAsync()).ReturnsAsync(content); + + CacheOptions options = new CacheOptions(100, "region", headerName); + var cachekey = MD5Helper.GenerateMd5($"{verb}-{url}-{header}-{content}"); + + this.Given(x => x.GivenDownstreamRoute(options)) + .When(x => x.WhenGenerateRequestCacheKey()) + .Then(x => x.ThenGeneratedCacheKeyIs(cachekey)) .BDDfy(); } - private void GivenCacheKeyFromContext(DownstreamRequest downstreamRequest) + private DownstreamRoute _downstreamRoute; + + private void GivenDownstreamRoute(CacheOptions options) + { + _downstreamRoute = new DownstreamRouteBuilder() + .WithKey("key1") + .WithCacheOptions(options) + .Build(); + } + + private string _generatedCacheKey; + + private async Task WhenGenerateRequestCacheKey() + { + _generatedCacheKey = await _cacheKeyGenerator.GenerateRequestCacheKey(_downstreamRequest.Object, _downstreamRoute); + } + + private void ThenGeneratedCacheKeyIs(string expected) { - var generatedCacheKey = _cacheKeyGenerator.GenerateRequestCacheKey(downstreamRequest); - var cachekey = MD5Helper.GenerateMd5("GET-https://some.url/blah?abcd=123"); - generatedCacheKey.ShouldBe(cachekey); + _generatedCacheKey.ShouldBe(expected); } } + + internal class HttpHeadersStub : HttpHeaders + { + public HttpHeadersStub() : base() { } + } } diff --git a/test/Ocelot.UnitTests/Cache/OutputCacheMiddlewareTests.cs b/test/Ocelot.UnitTests/Cache/OutputCacheMiddlewareTests.cs index b494cbe7f..6ad948232 100644 --- a/test/Ocelot.UnitTests/Cache/OutputCacheMiddlewareTests.cs +++ b/test/Ocelot.UnitTests/Cache/OutputCacheMiddlewareTests.cs @@ -106,7 +106,7 @@ private void GivenTheDownstreamRouteIs() var route = new RouteBuilder() .WithDownstreamRoute(new DownstreamRouteBuilder() .WithIsCached(true) - .WithCacheOptions(new CacheOptions(100, "kanken")) + .WithCacheOptions(new CacheOptions(100, "kanken", null)) .WithUpstreamHttpMethod(new List { "Get" }) .Build()) .WithUpstreamHttpMethod(new List { "Get" }) diff --git a/test/Ocelot.UnitTests/CacheManager/OutputCacheMiddlewareRealCacheTests.cs b/test/Ocelot.UnitTests/CacheManager/OutputCacheMiddlewareRealCacheTests.cs index bb02d98b0..9a588002e 100644 --- a/test/Ocelot.UnitTests/CacheManager/OutputCacheMiddlewareRealCacheTests.cs +++ b/test/Ocelot.UnitTests/CacheManager/OutputCacheMiddlewareRealCacheTests.cs @@ -25,7 +25,7 @@ public OutputCacheMiddlewareRealCacheTests() { _httpContext = new DefaultHttpContext(); _loggerFactory = new Mock(); - _logger = new Mock(); + _logger = new Mock(); _loggerFactory.Setup(x => x.CreateLogger()).Returns(_logger.Object); var cacheManagerOutputCache = CacheFactory.Build("OcelotOutputCache", x => { @@ -77,7 +77,7 @@ private void GivenTheDownstreamRouteIs() { var route = new DownstreamRouteBuilder() .WithIsCached(true) - .WithCacheOptions(new CacheOptions(100, "kanken")) + .WithCacheOptions(new CacheOptions(100, "kanken", null)) .WithUpstreamHttpMethod(new List { "Get" }) .Build();