diff --git a/docs/features/caching.rst b/docs/features/caching.rst index f2bf302ed..84803060f 100644 --- a/docs/features/caching.rst +++ b/docs/features/caching.rst @@ -39,7 +39,8 @@ Finally, in order to use caching on a route in your Route configuration add this "FileCacheOptions": { "TtlSeconds": 15, "Region": "europe-central", - "Header": "Authorization" + "Header": "OC-Caching-Control", + "EnableContentHashing": false // my route has GET verb only, assigning 'true' for requests with body: POST, PUT etc. } In this example **TtlSeconds** is set to 15 which means the cache will expire after 15 seconds. @@ -48,10 +49,38 @@ The **Region** represents a region of caching. 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. +``EnableContentHashing`` option +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +In version `23.0`_, the new property **EnableContentHashing** has been introduced. Previously, the request body was utilized to compute the cache key. +However, due to potential performance issues arising from request body hashing, it has been disabled by default. +Clearly, this constitutes a breaking change and presents challenges for users who require cache key calculations that consider the request body (e.g., for the POST method). +To address this issue, it is recommended to enable the option either at the route level or globally in the :ref:`cch-global-configuration` section: + +.. code-block:: json + + "CacheOptions": { + // ... + "EnableContentHashing": true + } + +.. _cch-global-configuration: + +Global Configuration +-------------------- + +The positive update is that copying Route-level properties for each route is no longer necessary, as version `23.3`_ allows for setting their values in the ``GlobalConfiguration``. +This convenience extends to **Header** and **Region** as well. +However, an alternative is still being sought for **TtlSeconds**, which must be explicitly set for each route to enable caching. + +Notes +----- + 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 ---------------- @@ -68,3 +97,6 @@ If you want to add your own caching method, implement the following interfaces a Please dig into the Ocelot source code to find more. We would really appreciate it if anyone wants to implement `Redis `_, `Memcached `_ etc. Please, open a new `Show and tell `_ thread in `Discussions `_ space of the repository. + +.. _23.0: https://github.com/ThreeMammals/Ocelot/releases/tag/23.0.0 +.. _23.3: https://github.com/ThreeMammals/Ocelot/releases/tag/23.3.0 diff --git a/src/Ocelot/Cache/AspMemoryCache.cs b/src/Ocelot/Cache/DefaultMemoryCache.cs similarity index 94% rename from src/Ocelot/Cache/AspMemoryCache.cs rename to src/Ocelot/Cache/DefaultMemoryCache.cs index 2067b3813..f53b6ceaf 100644 --- a/src/Ocelot/Cache/AspMemoryCache.cs +++ b/src/Ocelot/Cache/DefaultMemoryCache.cs @@ -2,12 +2,12 @@ namespace Ocelot.Cache { - public class AspMemoryCache : IOcelotCache + public class DefaultMemoryCache : IOcelotCache { private readonly IMemoryCache _memoryCache; private readonly Dictionary> _regions; - public AspMemoryCache(IMemoryCache memoryCache) + public DefaultMemoryCache(IMemoryCache memoryCache) { _memoryCache = memoryCache; _regions = new Dictionary>(); diff --git a/src/Ocelot/Cache/IRegionCreator.cs b/src/Ocelot/Cache/IRegionCreator.cs deleted file mode 100644 index da1b042da..000000000 --- a/src/Ocelot/Cache/IRegionCreator.cs +++ /dev/null @@ -1,9 +0,0 @@ -using Ocelot.Configuration.File; - -namespace Ocelot.Cache -{ - public interface IRegionCreator - { - string Create(FileRoute route); - } -} diff --git a/src/Ocelot/Cache/RegionCreator.cs b/src/Ocelot/Cache/RegionCreator.cs deleted file mode 100644 index c2dd0ccac..000000000 --- a/src/Ocelot/Cache/RegionCreator.cs +++ /dev/null @@ -1,21 +0,0 @@ -using Ocelot.Configuration.File; - -namespace Ocelot.Cache -{ - public class RegionCreator : IRegionCreator - { - public string Create(FileRoute route) - { - if (!string.IsNullOrEmpty(route?.FileCacheOptions?.Region)) - { - return route?.FileCacheOptions?.Region; - } - - var methods = string.Join(string.Empty, route.UpstreamHttpMethod.Select(m => m)); - - var region = $"{methods}{route.UpstreamPathTemplate.Replace("/", string.Empty)}"; - - return region; - } - } -} diff --git a/src/Ocelot/Configuration/Builder/DownstreamRouteBuilder.cs b/src/Ocelot/Configuration/Builder/DownstreamRouteBuilder.cs index 9dc93008e..281e93033 100644 --- a/src/Ocelot/Configuration/Builder/DownstreamRouteBuilder.cs +++ b/src/Ocelot/Configuration/Builder/DownstreamRouteBuilder.cs @@ -19,7 +19,7 @@ public class DownstreamRouteBuilder private List _claimToDownstreamPath; private string _requestIdHeaderKey; private bool _isCached; - private CacheOptions _fileCacheOptions; + private CacheOptions _cacheOptions; private string _downstreamScheme; private LoadBalancerOptions _loadBalancerOptions; private QoSOptions _qosOptions; @@ -87,7 +87,7 @@ public DownstreamRouteBuilder WithUpstreamPathTemplate(UpstreamPathTemplate inpu public DownstreamRouteBuilder WithUpstreamHttpMethod(List input) { - _upstreamHttpMethod = (input.Count == 0) ? new List() : input.Select(x => new HttpMethod(x.Trim())).ToList(); + _upstreamHttpMethod = input.Count == 0 ? new List() : input.Select(x => new HttpMethod(x.Trim())).ToList(); return this; } @@ -147,7 +147,7 @@ public DownstreamRouteBuilder WithIsCached(bool input) public DownstreamRouteBuilder WithCacheOptions(CacheOptions input) { - _fileCacheOptions = input; + _cacheOptions = input; return this; } @@ -276,7 +276,7 @@ public DownstreamRoute Build() _downstreamScheme, _requestIdHeaderKey, _isCached, - _fileCacheOptions, + _cacheOptions, _loadBalancerOptions, _rateLimitOptions, _routeClaimRequirement, diff --git a/src/Ocelot/Configuration/CacheOptions.cs b/src/Ocelot/Configuration/CacheOptions.cs index 352b501d8..dc3c19116 100644 --- a/src/Ocelot/Configuration/CacheOptions.cs +++ b/src/Ocelot/Configuration/CacheOptions.cs @@ -1,39 +1,42 @@ using Ocelot.Request.Middleware; -namespace Ocelot.Configuration -{ - public class CacheOptions - { - internal CacheOptions() { } +namespace Ocelot.Configuration; - public CacheOptions(int ttlSeconds, string region, string header) - { - TtlSeconds = ttlSeconds; - Region = region; - Header = header; - } - - public CacheOptions(int ttlSeconds, string region, string header, bool enableContentHashing) - { - TtlSeconds = ttlSeconds; - Region = region; - Header = header; - EnableContentHashing = enableContentHashing; - } +public class CacheOptions +{ + internal CacheOptions() { } - public int TtlSeconds { get; } - public string Region { get; } - public string Header { get; } - - /// - /// Enables MD5 hash calculation of the of the object. - /// - /// - /// Default value is . No hashing by default. - /// - /// - /// if hashing is enabled, otherwise it is . - /// - public bool EnableContentHashing { get; } + /// + /// Initializes a new instance of the class. + /// + /// + /// Internal defaults: + /// + /// The default value for is , but it is set to null for route-level configuration to allow global configuration usage. + /// The default value for is 0. + /// + /// + /// Time-to-live seconds. If not speciefied, zero value is used by default. + /// The region of caching. + /// The header name to control cached value. + /// The switcher for content hashing. If not speciefied, false value is used by default. + public CacheOptions(int? ttlSeconds, string region, string header, bool? enableContentHashing) + { + TtlSeconds = ttlSeconds ?? 0; + Region = region; + Header = header; + EnableContentHashing = enableContentHashing ?? false; } -} + + /// Time-to-live seconds. + /// Default value is 0. No caching by default. + /// An value of seconds. + public int TtlSeconds { get; } + public string Region { get; } + public string Header { get; } + + /// Enables MD5 hash calculation of the of the object. + /// Default value is . No hashing by default. + /// if hashing is enabled, otherwise it is . + public bool EnableContentHashing { get; } +} diff --git a/src/Ocelot/Configuration/Creator/CacheOptionsCreator.cs b/src/Ocelot/Configuration/Creator/CacheOptionsCreator.cs new file mode 100644 index 000000000..6d1c8f2b4 --- /dev/null +++ b/src/Ocelot/Configuration/Creator/CacheOptionsCreator.cs @@ -0,0 +1,27 @@ +using Ocelot.Configuration.File; + +namespace Ocelot.Configuration.Creator; + +public class CacheOptionsCreator : ICacheOptionsCreator +{ + public CacheOptions Create(FileCacheOptions options, FileGlobalConfiguration global, string upstreamPathTemplate, IList upstreamHttpMethods) + { + var region = GetRegion(options.Region ?? global?.CacheOptions.Region, upstreamPathTemplate, upstreamHttpMethods); + var header = options.Header ?? global?.CacheOptions.Header; + var ttlSeconds = options.TtlSeconds ?? global?.CacheOptions.TtlSeconds; + var enableContentHashing = options.EnableContentHashing ?? global?.CacheOptions.EnableContentHashing; + + return new CacheOptions(ttlSeconds, region, header, enableContentHashing); + } + + protected virtual string GetRegion(string region, string upstreamPathTemplate, IList upstreamHttpMethod) + { + if (!string.IsNullOrEmpty(region)) + { + return region; + } + + var methods = string.Join(string.Empty, upstreamHttpMethod); + return $"{methods}{upstreamPathTemplate.Replace("/", string.Empty)}"; + } +} diff --git a/src/Ocelot/Configuration/Creator/ICacheOptionsCreator.cs b/src/Ocelot/Configuration/Creator/ICacheOptionsCreator.cs new file mode 100644 index 000000000..a76a1b20e --- /dev/null +++ b/src/Ocelot/Configuration/Creator/ICacheOptionsCreator.cs @@ -0,0 +1,19 @@ +using Ocelot.Configuration.File; + +namespace Ocelot.Configuration.Creator; + +/// +/// This interface is used to create cache options. +/// +public interface ICacheOptionsCreator +{ + /// + /// Creates cache options based on the file cache options, upstream path template and upstream HTTP methods. + /// Upstream path template and upstream HTTP methods are used to get the region name. + /// The file cache options. + /// The global configuration. + /// The upstream path template as string. + /// The upstream http methods as a list of strings. + /// The generated cache options. + CacheOptions Create(FileCacheOptions options, FileGlobalConfiguration global, string upstreamPathTemplate, IList upstreamHttpMethods); +} diff --git a/src/Ocelot/Configuration/Creator/RouteOptionsCreator.cs b/src/Ocelot/Configuration/Creator/RouteOptionsCreator.cs index 8e0911e56..8b6f0e3ea 100644 --- a/src/Ocelot/Configuration/Creator/RouteOptionsCreator.cs +++ b/src/Ocelot/Configuration/Creator/RouteOptionsCreator.cs @@ -17,6 +17,8 @@ public RouteOptions Create(FileRoute fileRoute) && (!string.IsNullOrEmpty(authOpts.AuthenticationProviderKey) || authOpts.AuthenticationProviderKeys?.Any(k => !string.IsNullOrWhiteSpace(k)) == true); var isAuthorized = fileRoute.RouteClaimsRequirement?.Any() == true; + + // TODO: This sounds more like a hack, it might be better to refactor this at some point. var isCached = fileRoute.FileCacheOptions.TtlSeconds > 0; var enableRateLimiting = fileRoute.RateLimitOptions?.EnableRateLimiting == true; var useServiceDiscovery = !string.IsNullOrEmpty(fileRoute.ServiceName); diff --git a/src/Ocelot/Configuration/Creator/RoutesCreator.cs b/src/Ocelot/Configuration/Creator/RoutesCreator.cs index 8c1f1de63..19bce7b17 100644 --- a/src/Ocelot/Configuration/Creator/RoutesCreator.cs +++ b/src/Ocelot/Configuration/Creator/RoutesCreator.cs @@ -1,5 +1,4 @@ -using Ocelot.Cache; -using Ocelot.Configuration.Builder; +using Ocelot.Configuration.Builder; using Ocelot.Configuration.File; namespace Ocelot.Configuration.Creator @@ -14,7 +13,7 @@ public class RoutesCreator : IRoutesCreator private readonly IQoSOptionsCreator _qosOptionsCreator; private readonly IRouteOptionsCreator _fileRouteOptionsCreator; private readonly IRateLimitOptionsCreator _rateLimitOptionsCreator; - private readonly IRegionCreator _regionCreator; + private readonly ICacheOptionsCreator _cacheOptionsCreator; private readonly IHttpHandlerOptionsCreator _httpHandlerOptionsCreator; private readonly IHeaderFindAndReplaceCreator _headerFAndRCreator; private readonly IDownstreamAddressesCreator _downstreamAddressesCreator; @@ -30,21 +29,20 @@ public RoutesCreator( IQoSOptionsCreator qosOptionsCreator, IRouteOptionsCreator fileRouteOptionsCreator, IRateLimitOptionsCreator rateLimitOptionsCreator, - IRegionCreator regionCreator, + ICacheOptionsCreator cacheOptionsCreator, IHttpHandlerOptionsCreator httpHandlerOptionsCreator, IHeaderFindAndReplaceCreator headerFAndRCreator, IDownstreamAddressesCreator downstreamAddressesCreator, ILoadBalancerOptionsCreator loadBalancerOptionsCreator, IRouteKeyCreator routeKeyCreator, ISecurityOptionsCreator securityOptionsCreator, - IVersionCreator versionCreator - ) + IVersionCreator versionCreator) { _routeKeyCreator = routeKeyCreator; _loadBalancerOptionsCreator = loadBalancerOptionsCreator; _downstreamAddressesCreator = downstreamAddressesCreator; _headerFAndRCreator = headerFAndRCreator; - _regionCreator = regionCreator; + _cacheOptionsCreator = cacheOptionsCreator; _rateLimitOptionsCreator = rateLimitOptionsCreator; _requestIdKeyCreator = requestIdKeyCreator; _upstreamTemplatePatternCreator = upstreamTemplatePatternCreator; @@ -93,8 +91,6 @@ private DownstreamRoute SetUpDownstreamRoute(FileRoute fileRoute, FileGlobalConf var rateLimitOption = _rateLimitOptionsCreator.Create(fileRoute.RateLimitOptions, globalConfiguration); - var region = _regionCreator.Create(fileRoute); - var httpHandlerOptions = _httpHandlerOptionsCreator.Create(fileRoute.HttpHandlerOptions); var hAndRs = _headerFAndRCreator.Create(fileRoute); @@ -107,6 +103,8 @@ private DownstreamRoute SetUpDownstreamRoute(FileRoute fileRoute, FileGlobalConf var downstreamHttpVersion = _versionCreator.Create(fileRoute.DownstreamHttpVersion); + var cacheOptions = _cacheOptionsCreator.Create(fileRoute.FileCacheOptions, globalConfiguration, fileRoute.UpstreamPathTemplate, fileRoute.UpstreamHttpMethod); + var route = new DownstreamRouteBuilder() .WithKey(fileRoute.Key) .WithDownstreamPathTemplate(fileRoute.DownstreamPathTemplate) @@ -122,7 +120,7 @@ private DownstreamRoute SetUpDownstreamRoute(FileRoute fileRoute, FileGlobalConf .WithClaimsToDownstreamPath(claimsToDownstreamPath) .WithRequestIdKey(requestIdKey) .WithIsCached(fileRouteOptions.IsCached) - .WithCacheOptions(new CacheOptions(fileRoute.FileCacheOptions.TtlSeconds, region, fileRoute.FileCacheOptions.Header)) + .WithCacheOptions(cacheOptions) .WithDownstreamScheme(fileRoute.DownstreamScheme) .WithLoadBalancerOptions(lbOptions) .WithDownstreamAddresses(downstreamAddresses) diff --git a/src/Ocelot/Configuration/File/FileCacheOptions.cs b/src/Ocelot/Configuration/File/FileCacheOptions.cs index a1b1deed5..42b793390 100644 --- a/src/Ocelot/Configuration/File/FileCacheOptions.cs +++ b/src/Ocelot/Configuration/File/FileCacheOptions.cs @@ -1,21 +1,26 @@ -namespace Ocelot.Configuration.File -{ - public class FileCacheOptions - { - public FileCacheOptions() - { - Region = string.Empty; - TtlSeconds = 0; - } +namespace Ocelot.Configuration.File; - public FileCacheOptions(FileCacheOptions from) - { - Region = from.Region; - TtlSeconds = from.TtlSeconds; - } +public class FileCacheOptions +{ + public FileCacheOptions() { } - public int TtlSeconds { get; set; } - public string Region { get; set; } - public string Header { get; set; } - } + public FileCacheOptions(FileCacheOptions from) + { + Region = from.Region; + TtlSeconds = from.TtlSeconds; + Header = from.Header; + EnableContentHashing = from.EnableContentHashing; + } + + /// Using where T is to have as default value and allowing global configuration usage. + /// If then use global configuration with 0 by default. + /// The time to live seconds, with 0 by default. + public int? TtlSeconds { get; set; } + public string Region { get; set; } + public string Header { get; set; } + + /// Using where T is to have as default value and allowing global configuration usage. + /// If then use global configuration with by default. + /// if content hashing is enabled; otherwise, . + public bool? EnableContentHashing { get; set; } } diff --git a/src/Ocelot/Configuration/File/FileGlobalConfiguration.cs b/src/Ocelot/Configuration/File/FileGlobalConfiguration.cs index 6692ba046..04497cca1 100644 --- a/src/Ocelot/Configuration/File/FileGlobalConfiguration.cs +++ b/src/Ocelot/Configuration/File/FileGlobalConfiguration.cs @@ -9,6 +9,7 @@ public FileGlobalConfiguration() LoadBalancerOptions = new FileLoadBalancerOptions(); QoSOptions = new FileQoSOptions(); HttpHandlerOptions = new FileHttpHandlerOptions(); + CacheOptions = new FileCacheOptions(); } public string RequestIdKey { get; set; } @@ -28,5 +29,7 @@ public FileGlobalConfiguration() public FileHttpHandlerOptions HttpHandlerOptions { get; set; } public string DownstreamHttpVersion { get; set; } + + public FileCacheOptions CacheOptions { get; set; } } } diff --git a/src/Ocelot/Configuration/File/FileRoute.cs b/src/Ocelot/Configuration/File/FileRoute.cs index 5823113ad..ea076e022 100644 --- a/src/Ocelot/Configuration/File/FileRoute.cs +++ b/src/Ocelot/Configuration/File/FileRoute.cs @@ -41,8 +41,8 @@ public FileRoute(FileRoute from) public string DownstreamHttpMethod { get; set; } public string DownstreamHttpVersion { get; set; } public string DownstreamPathTemplate { get; set; } - public string DownstreamScheme { get; set; } - public FileCacheOptions FileCacheOptions { get; set; } + public string DownstreamScheme { get; set; } + public FileCacheOptions FileCacheOptions { get; set; } public FileHttpHandlerOptions HttpHandlerOptions { get; set; } public string Key { get; set; } public FileLoadBalancerOptions LoadBalancerOptions { get; set; } diff --git a/src/Ocelot/DependencyInjection/Features.cs b/src/Ocelot/DependencyInjection/Features.cs index 51f836ea9..8910145af 100644 --- a/src/Ocelot/DependencyInjection/Features.cs +++ b/src/Ocelot/DependencyInjection/Features.cs @@ -1,4 +1,7 @@ using Microsoft.Extensions.DependencyInjection; +using Ocelot.Cache; +using Ocelot.Configuration.Creator; +using Ocelot.Configuration.File; using Ocelot.RateLimiting; namespace Ocelot.DependencyInjection; @@ -8,9 +11,26 @@ public static class Features /// /// Ocelot feature: Rate Limiting. /// + /// + /// Read The Docs: Rate Limiting. + /// /// The services collection to add the feature to. /// The same object. public static IServiceCollection AddRateLimiting(this IServiceCollection services) => services .AddSingleton() .AddSingleton(); + + /// + /// Ocelot feature: Request Caching. + /// + /// + /// Read The Docs: Caching. + /// + /// The services collection to add the feature to. + /// The same object. + public static IServiceCollection AddOcelotCache(this IServiceCollection services) => services + .AddSingleton, DefaultMemoryCache>() + .AddSingleton, DefaultMemoryCache>() + .AddSingleton() + .AddSingleton(); } diff --git a/src/Ocelot/DependencyInjection/OcelotBuilder.cs b/src/Ocelot/DependencyInjection/OcelotBuilder.cs index a501014cc..9dc65a3de 100644 --- a/src/Ocelot/DependencyInjection/OcelotBuilder.cs +++ b/src/Ocelot/DependencyInjection/OcelotBuilder.cs @@ -54,8 +54,6 @@ public OcelotBuilder(IServiceCollection services, IConfiguration configurationRo Services = services; Services.Configure(configurationRoot); - Services.TryAddSingleton, AspMemoryCache>(); - Services.TryAddSingleton, AspMemoryCache>(); Services.TryAddSingleton(); Services.TryAddSingleton(); Services.TryAddSingleton(); @@ -81,7 +79,6 @@ public OcelotBuilder(IServiceCollection services, IConfiguration configurationRo Services.TryAddSingleton(); Services.TryAddSingleton(); Services.TryAddSingleton(); - Services.TryAddSingleton(); Services.TryAddSingleton(); Services.TryAddSingleton(); Services.TryAddSingleton(); @@ -114,9 +111,11 @@ public OcelotBuilder(IServiceCollection services, IConfiguration configurationRo Services.TryAddSingleton(); Services.TryAddSingleton(); Services.TryAddSingleton(); - Services.TryAddSingleton(); + Services.TryAddSingleton(); Services.TryAddSingleton, OcelotConfigurationMonitor>(); + + Services.AddOcelotCache(); Services.AddOcelotMessageInvokerPool(); // See this for why we register this as singleton: diff --git a/test/Ocelot.AcceptanceTests/Caching/CachingTests.cs b/test/Ocelot.AcceptanceTests/Caching/CachingTests.cs index c61ce3864..c029ad56d 100644 --- a/test/Ocelot.AcceptanceTests/Caching/CachingTests.cs +++ b/test/Ocelot.AcceptanceTests/Caching/CachingTests.cs @@ -1,5 +1,7 @@ using Microsoft.AspNetCore.Http; using Ocelot.Configuration.File; +using System.Text; +using JsonSerializer = System.Text.Json.JsonSerializer; namespace Ocelot.AcceptanceTests.Caching { @@ -10,6 +12,7 @@ public sealed class CachingTests : IDisposable private const string HelloTomContent = "Hello from Tom"; private const string HelloLauraContent = "Hello from Laura"; + private int _counter = 0; public CachingTests() { @@ -113,6 +116,75 @@ public void Should_not_return_cached_response_as_ttl_expires() .BDDfy(); } + [Theory] + [InlineData(true)] + [InlineData(false)] + [Trait("Feat", "2058")] + [Trait("Bug", "2059")] + public void Should_return_different_cached_response_when_request_body_changes_and_EnableContentHashing_is_true(bool asGlobalConfig) + { + var port = PortFinder.GetRandomPort(); + var options = new FileCacheOptions + { + TtlSeconds = 100, + EnableContentHashing = true, + }; + var (testBody1String, testBody2String) = TestBodiesFactory(); + var configuration = GivenFileConfiguration(port, options, asGlobalConfig); + + this.Given(x => x.GivenThereIsAnEchoServiceRunningOn($"http://localhost:{port}")) + .And(x => _steps.GivenThereIsAConfiguration(configuration)) + .And(x => _steps.GivenOcelotIsRunning()) + .When(x => _steps.WhenIPostUrlOnTheApiGateway("/", new StringContent(testBody1String, Encoding.UTF8, "application/json"))) + .Then(x => _steps.ThenTheStatusCodeShouldBe(HttpStatusCode.OK)) + .And(x => _steps.ThenTheResponseBodyShouldBe(testBody1String)) + .When(x => _steps.WhenIPostUrlOnTheApiGateway("/", new StringContent(testBody2String, Encoding.UTF8, "application/json"))) + .Then(x => _steps.ThenTheStatusCodeShouldBe(HttpStatusCode.OK)) + .And(x => _steps.ThenTheResponseBodyShouldBe(testBody2String)) + .When(x => _steps.WhenIPostUrlOnTheApiGateway("/", new StringContent(testBody1String, Encoding.UTF8, "application/json"))) + .Then(x => _steps.ThenTheStatusCodeShouldBe(HttpStatusCode.OK)) + .And(x => _steps.ThenTheResponseBodyShouldBe(testBody1String)) + .When(x => _steps.WhenIPostUrlOnTheApiGateway("/", new StringContent(testBody2String, Encoding.UTF8, "application/json"))) + .Then(x => _steps.ThenTheStatusCodeShouldBe(HttpStatusCode.OK)) + .And(x => _steps.ThenTheResponseBodyShouldBe(testBody2String)) + .And(x => ThenTheCounterValueShouldBe(2)) + .BDDfy(); + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + [Trait("Feat", "2058")] + [Trait("Bug", "2059")] + public void Should_return_same_cached_response_when_request_body_changes_and_EnableContentHashing_is_false(bool asGlobalConfig) + { + var port = PortFinder.GetRandomPort(); + var options = new FileCacheOptions + { + TtlSeconds = 100, + }; + var (testBody1String, testBody2String) = TestBodiesFactory(); + var configuration = GivenFileConfiguration(port, options, asGlobalConfig); + + this.Given(x => x.GivenThereIsAnEchoServiceRunningOn($"http://localhost:{port}")) + .And(x => _steps.GivenThereIsAConfiguration(configuration)) + .And(x => _steps.GivenOcelotIsRunning()) + .When(x => _steps.WhenIPostUrlOnTheApiGateway("/", new StringContent(testBody1String, Encoding.UTF8, "application/json"))) + .Then(x => _steps.ThenTheStatusCodeShouldBe(HttpStatusCode.OK)) + .And(x => _steps.ThenTheResponseBodyShouldBe(testBody1String)) + .When(x => _steps.WhenIPostUrlOnTheApiGateway("/", new StringContent(testBody2String, Encoding.UTF8, "application/json"))) + .Then(x => _steps.ThenTheStatusCodeShouldBe(HttpStatusCode.OK)) + .And(x => _steps.ThenTheResponseBodyShouldBe(testBody1String)) + .When(x => _steps.WhenIPostUrlOnTheApiGateway("/", new StringContent(testBody1String, Encoding.UTF8, "application/json"))) + .Then(x => _steps.ThenTheStatusCodeShouldBe(HttpStatusCode.OK)) + .And(x => _steps.ThenTheResponseBodyShouldBe(testBody1String)) + .When(x => _steps.WhenIPostUrlOnTheApiGateway("/", new StringContent(testBody2String, Encoding.UTF8, "application/json"))) + .Then(x => _steps.ThenTheStatusCodeShouldBe(HttpStatusCode.OK)) + .And(x => _steps.ThenTheResponseBodyShouldBe(testBody1String)) + .And(x => ThenTheCounterValueShouldBe(1)) + .BDDfy(); + } + [Fact] [Trait("Issue", "1172")] public void Should_clean_cached_response_by_cache_header_via_new_caching_key() @@ -152,7 +224,7 @@ public void Should_clean_cached_response_by_cache_header_via_new_caching_key() .BDDfy(); } - private static FileConfiguration GivenFileConfiguration(int port, FileCacheOptions cacheOptions) => new() + private static FileConfiguration GivenFileConfiguration(int port, FileCacheOptions cacheOptions, bool asGlobalConfig = false) => new() { Routes = new() { @@ -163,12 +235,14 @@ public void Should_clean_cached_response_by_cache_header_via_new_caching_key() { new FileHostAndPort("localhost", port), }, + DownstreamHttpMethod = "Post", DownstreamScheme = Uri.UriSchemeHttp, UpstreamPathTemplate = "/", - UpstreamHttpMethod = new() { HttpMethods.Get }, - FileCacheOptions = cacheOptions, + UpstreamHttpMethod = new() { HttpMethods.Get, HttpMethods.Post }, + FileCacheOptions = asGlobalConfig ? new FileCacheOptions { TtlSeconds = cacheOptions.TtlSeconds } : cacheOptions, }, }, + GlobalConfiguration = asGlobalConfig ? new FileGlobalConfiguration { CacheOptions = cacheOptions } : null, }; private static void GivenTheCacheExpires() @@ -196,10 +270,61 @@ private void GivenThereIsAServiceRunningOn(string url, HttpStatusCode statusCode }); } + private void GivenThereIsAnEchoServiceRunningOn(string url) + { + _serviceHandler.GivenThereIsAServiceRunningOn(url, async context => + { + using var streamReader = new StreamReader(context.Request.Body); + var requestBody = await streamReader.ReadToEndAsync(); + + _counter++; + context.Response.StatusCode = (int)HttpStatusCode.OK; + await context.Response.WriteAsync(requestBody); + }); + } + + private void ThenTheCounterValueShouldBe(int expected) + { + Assert.Equal(expected, _counter); + } + + private (string TestBody1String, string TestBody2String) TestBodiesFactory() + { + var testBody1 = new TestBody + { + Age = 30, + Email = "test.test@email.com", + FirstName = "Jean", + LastName = "Test", + }; + + var testBody1String = JsonSerializer.Serialize(testBody1); + + var testBody2 = new TestBody + { + Age = 31, + Email = "test.test@email.com", + FirstName = "Jean", + LastName = "Test", + }; + + var testBody2String = JsonSerializer.Serialize(testBody2); + + return (testBody1String, testBody2String); + } + public void Dispose() { _serviceHandler?.Dispose(); _steps.Dispose(); } } + + public class TestBody + { + public string FirstName { get; set; } + public string LastName { get; set; } + public string Email { get; set; } + public int Age { get; set; } + } } diff --git a/test/Ocelot.IntegrationTests/AdministrationTests.cs b/test/Ocelot.IntegrationTests/AdministrationTests.cs index 4fc91e0a8..87ff03fd1 100644 --- a/test/Ocelot.IntegrationTests/AdministrationTests.cs +++ b/test/Ocelot.IntegrationTests/AdministrationTests.cs @@ -882,7 +882,7 @@ private void ThenTheStatusCodeShouldBe(HttpStatusCode expectedHttpStatusCode) private void ThenTheResultHaveMultiLineIndentedJson() { const string indent = " "; - const int total = 46, skip = 1; + const int total = 52, skip = 1; var lines = _response.Content.ReadAsStringAsync().Result.Split(Environment.NewLine); lines.Length.ShouldBe(total); lines.First().ShouldNotStartWith(indent); diff --git a/test/Ocelot.UnitTests/Cache/RegionCreatorTests.cs b/test/Ocelot.UnitTests/Cache/CacheOptionsCreatorTests.cs similarity index 73% rename from test/Ocelot.UnitTests/Cache/RegionCreatorTests.cs rename to test/Ocelot.UnitTests/Cache/CacheOptionsCreatorTests.cs index 89d552009..f3584e714 100644 --- a/test/Ocelot.UnitTests/Cache/RegionCreatorTests.cs +++ b/test/Ocelot.UnitTests/Cache/CacheOptionsCreatorTests.cs @@ -1,59 +1,60 @@ -using Ocelot.Cache; +using Ocelot.Configuration; +using Ocelot.Configuration.Creator; using Ocelot.Configuration.File; - -namespace Ocelot.UnitTests.Cache -{ - public class RegionCreatorTests : UnitTest - { - private string _result; - private FileRoute _route; - - [Fact] - public void should_create_region() - { - var route = new FileRoute - { - UpstreamHttpMethod = new List { "Get" }, - UpstreamPathTemplate = "/testdummy", - }; - - this.Given(_ => GivenTheRoute(route)) - .When(_ => WhenICreateTheRegion()) - .Then(_ => ThenTheRegionIs("Gettestdummy")) - .BDDfy(); - } - - [Fact] - public void should_use_region() - { - var route = new FileRoute - { - FileCacheOptions = new FileCacheOptions - { - Region = "region", - }, - }; - - this.Given(_ => GivenTheRoute(route)) - .When(_ => WhenICreateTheRegion()) - .Then(_ => ThenTheRegionIs("region")) - .BDDfy(); - } - - private void GivenTheRoute(FileRoute route) - { - _route = route; - } - - private void WhenICreateTheRegion() - { - var regionCreator = new RegionCreator(); - _result = regionCreator.Create(_route); - } - - private void ThenTheRegionIs(string expected) - { - _result.ShouldBe(expected); - } - } -} + +namespace Ocelot.UnitTests.Cache +{ + public class CacheOptionsCreatorTests : UnitTest + { + private CacheOptions _cacheOptions; + private FileRoute _route; + + [Fact] + public void should_create_region() + { + var route = new FileRoute + { + UpstreamHttpMethod = new List { "Get" }, + UpstreamPathTemplate = "/testdummy", + }; + + this.Given(_ => GivenTheRoute(route)) + .When(_ => WhenICreateTheRegion()) + .Then(_ => ThenTheRegionIs("Gettestdummy")) + .BDDfy(); + } + + [Fact] + public void should_use_region() + { + var route = new FileRoute + { + FileCacheOptions = new FileCacheOptions + { + Region = "region", + }, + }; + + this.Given(_ => GivenTheRoute(route)) + .When(_ => WhenICreateTheRegion()) + .Then(_ => ThenTheRegionIs("region")) + .BDDfy(); + } + + private void GivenTheRoute(FileRoute route) + { + _route = route; + } + + private void WhenICreateTheRegion() + { + var cacheOptionsCreator = new CacheOptionsCreator(); + _cacheOptions = cacheOptionsCreator.Create(_route.FileCacheOptions, new FileGlobalConfiguration(), _route.UpstreamPathTemplate, _route.UpstreamHttpMethod); + } + + private void ThenTheRegionIs(string expected) + { + _cacheOptions.Region.ShouldBe(expected); + } + } +} diff --git a/test/Ocelot.UnitTests/Cache/DefaultCacheKeyGeneratorTests.cs b/test/Ocelot.UnitTests/Cache/DefaultCacheKeyGeneratorTests.cs index be9de81e8..50c7de6c9 100644 --- a/test/Ocelot.UnitTests/Cache/DefaultCacheKeyGeneratorTests.cs +++ b/test/Ocelot.UnitTests/Cache/DefaultCacheKeyGeneratorTests.cs @@ -59,7 +59,7 @@ public void should_generate_cache_key_without_request_content() [Fact] public void should_generate_cache_key_with_cache_options_header() { - CacheOptions options = new CacheOptions(100, "region", headerName); + CacheOptions options = new CacheOptions(100, "region", headerName, false); var cachekey = MD5Helper.GenerateMd5($"{verb}-{url}-{header}"); this.Given(x => x.GivenDownstreamRoute(options)) diff --git a/test/Ocelot.UnitTests/Cache/AspMemoryCacheTests.cs b/test/Ocelot.UnitTests/Cache/DefaultMemoryCacheTests.cs similarity index 90% rename from test/Ocelot.UnitTests/Cache/AspMemoryCacheTests.cs rename to test/Ocelot.UnitTests/Cache/DefaultMemoryCacheTests.cs index 69cda7c8d..e5d14dbe4 100644 --- a/test/Ocelot.UnitTests/Cache/AspMemoryCacheTests.cs +++ b/test/Ocelot.UnitTests/Cache/DefaultMemoryCacheTests.cs @@ -3,13 +3,13 @@ namespace Ocelot.UnitTests.Cache { - public class AspMemoryCacheTests : UnitTest + public class DefaultMemoryCacheTests : UnitTest { - private readonly AspMemoryCache _cache; + private readonly DefaultMemoryCache _cache; - public AspMemoryCacheTests() + public DefaultMemoryCacheTests() { - _cache = new AspMemoryCache(new MemoryCache(new MemoryCacheOptions())); + _cache = new DefaultMemoryCache(new MemoryCache(new MemoryCacheOptions())); } [Fact] diff --git a/test/Ocelot.UnitTests/Cache/OutputCacheMiddlewareTests.cs b/test/Ocelot.UnitTests/Cache/OutputCacheMiddlewareTests.cs index aaaf263f0..ab00e7675 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", null)) + .WithCacheOptions(new CacheOptions(100, "kanken", null, false)) .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 fc002eaab..76fe0977d 100644 --- a/test/Ocelot.UnitTests/CacheManager/OutputCacheMiddlewareRealCacheTests.cs +++ b/test/Ocelot.UnitTests/CacheManager/OutputCacheMiddlewareRealCacheTests.cs @@ -77,7 +77,7 @@ private void GivenTheDownstreamRouteIs() { var route = new DownstreamRouteBuilder() .WithIsCached(true) - .WithCacheOptions(new CacheOptions(100, "kanken", null)) + .WithCacheOptions(new CacheOptions(100, "kanken", null, false)) .WithUpstreamHttpMethod(new List { "Get" }) .Build(); diff --git a/test/Ocelot.UnitTests/Configuration/CacheOptionsCreatorTests.cs b/test/Ocelot.UnitTests/Configuration/CacheOptionsCreatorTests.cs new file mode 100644 index 000000000..12ed54cdf --- /dev/null +++ b/test/Ocelot.UnitTests/Configuration/CacheOptionsCreatorTests.cs @@ -0,0 +1,100 @@ +using Ocelot.Configuration.Creator; +using Ocelot.Configuration.File; + +namespace Ocelot.UnitTests.Configuration; + +[Trait("Feat", "2058")] +[Trait("Bug", "2059")] +public class CacheOptionsCreatorTests +{ + [Fact] + public void ShouldCreateCacheOptions() + { + var options = FileCacheOptionsFactory(); + var cacheOptionsCreator = new CacheOptionsCreator(); + var result = cacheOptionsCreator.Create(options, null, null, null); + + result.TtlSeconds.ShouldBe(options.TtlSeconds.Value); + result.Region.ShouldBe(options.Region); + result.Header.ShouldBe(options.Header); + result.EnableContentHashing.ShouldBe(options.EnableContentHashing.Value); + } + + [Fact] + public void ShouldCreateCacheOptionsUsingGlobalConfiguration() + { + var global = GlobalConfigurationFactory(); + + var cacheOptionsCreator = new CacheOptionsCreator(); + var result = cacheOptionsCreator.Create(new FileCacheOptions(), global, null, null); + + result.TtlSeconds.ShouldBe(global.CacheOptions.TtlSeconds.Value); + result.Region.ShouldBe(global.CacheOptions.Region); + result.Header.ShouldBe(global.CacheOptions.Header); + result.EnableContentHashing.ShouldBe(global.CacheOptions.EnableContentHashing.Value); + } + + [Fact] + public void RouteCacheOptionsShouldOverrideGlobalConfiguration() + { + var global = GlobalConfigurationFactory(); + var options = FileCacheOptionsFactory(); + + var cacheOptionsCreator = new CacheOptionsCreator(); + var result = cacheOptionsCreator.Create(options, global, null, null); + + result.TtlSeconds.ShouldBe(options.TtlSeconds.Value); + result.Region.ShouldBe(options.Region); + result.Header.ShouldBe(options.Header); + result.EnableContentHashing.ShouldBe(options.EnableContentHashing.Value); + } + + [Fact] + public void ShouldCreateCacheOptionsWithDefaults() + { + var cacheOptionsCreator = new CacheOptionsCreator(); + var result = cacheOptionsCreator.Create(new FileCacheOptions(), null, "/", new List { "GET" }); + + result.TtlSeconds.ShouldBe(0); + result.Region.ShouldBe("GET"); + result.Header.ShouldBe(null); + result.EnableContentHashing.ShouldBe(false); + } + + [Fact] + public void ShouldComputeRegionIfNotProvided() + { + var global = GlobalConfigurationFactory(); + var options = FileCacheOptionsFactory(); + + global.CacheOptions.Region = null; + options.Region = null; + + var cacheOptionsCreator = new CacheOptionsCreator(); + var result = cacheOptionsCreator.Create(options, global, "/api/values", new List { "GET", "POST" }); + + result.TtlSeconds.ShouldBe(options.TtlSeconds.Value); + result.Region.ShouldBe("GETPOSTapivalues"); + result.Header.ShouldBe(options.Header); + result.EnableContentHashing.ShouldBe(options.EnableContentHashing.Value); + } + + private static FileGlobalConfiguration GlobalConfigurationFactory() => new() + { + CacheOptions = new FileCacheOptions + { + TtlSeconds = 20, + Region = "globalRegion", + Header = "globalHeader", + EnableContentHashing = false, + }, + }; + + private static FileCacheOptions FileCacheOptionsFactory() => new() + { + TtlSeconds = 10, + Region = "region", + Header = "header", + EnableContentHashing = true, + }; +} diff --git a/test/Ocelot.UnitTests/Configuration/RoutesCreatorTests.cs b/test/Ocelot.UnitTests/Configuration/RoutesCreatorTests.cs index 382eb3a44..11b5fc3e0 100644 --- a/test/Ocelot.UnitTests/Configuration/RoutesCreatorTests.cs +++ b/test/Ocelot.UnitTests/Configuration/RoutesCreatorTests.cs @@ -1,5 +1,4 @@ -using Ocelot.Cache; -using Ocelot.Configuration; +using Ocelot.Configuration; using Ocelot.Configuration.Builder; using Ocelot.Configuration.Creator; using Ocelot.Configuration.File; @@ -17,7 +16,7 @@ public class RoutesCreatorTests : UnitTest private readonly Mock _qosoCreator; private readonly Mock _rroCreator; private readonly Mock _rloCreator; - private readonly Mock _rCreator; + private readonly Mock _coCreator; private readonly Mock _hhoCreator; private readonly Mock _hfarCreator; private readonly Mock _daCreator; @@ -34,7 +33,7 @@ public class RoutesCreatorTests : UnitTest private List _ctt; private QoSOptions _qoso; private RateLimitOptions _rlo; - private string _region; + private CacheOptions _cacheOptions; private HttpHandlerOptions _hho; private HeaderTransformations _ht; private List _dhp; @@ -51,7 +50,7 @@ public RoutesCreatorTests() _qosoCreator = new Mock(); _rroCreator = new Mock(); _rloCreator = new Mock(); - _rCreator = new Mock(); + _coCreator = new Mock(); _hhoCreator = new Mock(); _hfarCreator = new Mock(); _daCreator = new Mock(); @@ -68,7 +67,7 @@ public RoutesCreatorTests() _qosoCreator.Object, _rroCreator.Object, _rloCreator.Object, - _rCreator.Object, + _coCreator.Object, _hhoCreator.Object, _hfarCreator.Object, _daCreator.Object, @@ -161,7 +160,8 @@ private void GivenTheDependenciesAreSetUpCorrectly() _ctt = new List(); _qoso = new QoSOptionsBuilder().Build(); _rlo = new RateLimitOptionsBuilder().Build(); - _region = "vesty"; + + _cacheOptions = new CacheOptions(0, "vesty", null, false); _hho = new HttpHandlerOptionsBuilder().Build(); _ht = new HeaderTransformations(new List(), new List(), new List(), new List()); _dhp = new List(); @@ -175,7 +175,7 @@ private void GivenTheDependenciesAreSetUpCorrectly() _cthCreator.Setup(x => x.Create(It.IsAny>())).Returns(_ctt); _qosoCreator.Setup(x => x.Create(It.IsAny(), It.IsAny(), It.IsAny>())).Returns(_qoso); _rloCreator.Setup(x => x.Create(It.IsAny(), It.IsAny())).Returns(_rlo); - _rCreator.Setup(x => x.Create(It.IsAny())).Returns(_region); + _coCreator.Setup(x => x.Create(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny>())).Returns(_cacheOptions); _hhoCreator.Setup(x => x.Create(It.IsAny())).Returns(_hho); _hfarCreator.Setup(x => x.Create(It.IsAny())).Returns(_ht); _daCreator.Setup(x => x.Create(It.IsAny())).Returns(_dhp); @@ -222,8 +222,8 @@ private void ThenTheRouteIsSet(FileRoute expected, int routeIndex) _result[routeIndex].DownstreamRoute[0].ClaimsToClaims.ShouldBe(_ctt); _result[routeIndex].DownstreamRoute[0].QosOptions.ShouldBe(_qoso); _result[routeIndex].DownstreamRoute[0].RateLimitOptions.ShouldBe(_rlo); - _result[routeIndex].DownstreamRoute[0].CacheOptions.Region.ShouldBe(_region); - _result[routeIndex].DownstreamRoute[0].CacheOptions.TtlSeconds.ShouldBe(expected.FileCacheOptions.TtlSeconds); + _result[routeIndex].DownstreamRoute[0].CacheOptions.Region.ShouldBe(_cacheOptions.Region); + _result[routeIndex].DownstreamRoute[0].CacheOptions.TtlSeconds.ShouldBe(0); _result[routeIndex].DownstreamRoute[0].HttpHandlerOptions.ShouldBe(_hho); _result[routeIndex].DownstreamRoute[0].UpstreamHeadersFindAndReplace.ShouldBe(_ht.Upstream); _result[routeIndex].DownstreamRoute[0].DownstreamHeadersFindAndReplace.ShouldBe(_ht.Downstream); @@ -264,7 +264,7 @@ private void ThenTheDepsAreCalledFor(FileRoute fileRoute, FileGlobalConfiguratio _cthCreator.Verify(x => x.Create(fileRoute.AddQueriesToRequest), Times.Once); _qosoCreator.Verify(x => x.Create(fileRoute.QoSOptions, fileRoute.UpstreamPathTemplate, fileRoute.UpstreamHttpMethod)); _rloCreator.Verify(x => x.Create(fileRoute.RateLimitOptions, globalConfig), Times.Once); - _rCreator.Verify(x => x.Create(fileRoute), Times.Once); + _coCreator.Verify(x => x.Create(fileRoute.FileCacheOptions, globalConfig, fileRoute.UpstreamPathTemplate, fileRoute.UpstreamHttpMethod), Times.Once); _hhoCreator.Verify(x => x.Create(fileRoute.HttpHandlerOptions), Times.Once); _hfarCreator.Verify(x => x.Create(fileRoute), Times.Once); _daCreator.Verify(x => x.Create(fileRoute), Times.Once);