diff --git a/src/Ocelot/Cache/OutputCacheMiddleware.cs b/src/Ocelot/Cache/OutputCacheMiddleware.cs index 4eb02ae08..ca130abe2 100644 --- a/src/Ocelot/Cache/OutputCacheMiddleware.cs +++ b/src/Ocelot/Cache/OutputCacheMiddleware.cs @@ -60,8 +60,16 @@ public async Task Invoke(HttpContext httpContext) cached = await CreateCachedResponse(downstreamResponse); var ttl = TimeSpan.FromSeconds(options.TtlSeconds); - _outputCache.Add(downStreamRequestCacheKey, cached, options.Region, ttl); - Logger.LogDebug(() => $"Finished response added to cache for the '{downstreamUrlKey}' key."); + if (options.StatusCodes == null || options.StatusCodes.Length == 0 || options.StatusCodes.Contains(cached.StatusCode)) + { + _outputCache.Add(downStreamRequestCacheKey, cached, options.Region, ttl); + Logger.LogDebug(() => $"Finished response added to cache for the '{downstreamUrlKey}' key."); + } + else + { + Logger.LogDebug(() => $"Finished response filtered out from being added to cache due to response status for the '{downstreamUrlKey}' key."); + } + } private static void SetHttpResponseMessageThisRequest(HttpContext context, DownstreamResponse response) diff --git a/src/Ocelot/Configuration/CacheOptions.cs b/src/Ocelot/Configuration/CacheOptions.cs index a3b77d0a6..adf43216b 100644 --- a/src/Ocelot/Configuration/CacheOptions.cs +++ b/src/Ocelot/Configuration/CacheOptions.cs @@ -15,9 +15,14 @@ public class CacheOptions internal CacheOptions() { } public CacheOptions(FileCacheOptions from, string defaultRegion) - : this(from.TtlSeconds, from.Region.IfEmpty(defaultRegion), from.Header, from.EnableContentHashing) + : this(from.TtlSeconds, from.Region.IfEmpty(defaultRegion), from.Header, from.EnableContentHashing, from.StatusCodes) { } + public CacheOptions(int ttlSeconds, string region, string header, bool? enableContentHashing) + : this(ttlSeconds, region, header, enableContentHashing, null) + { } + + /// /// Initializes a new instance of the class. /// @@ -31,13 +36,15 @@ public CacheOptions(FileCacheOptions from, string defaultRegion) /// 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) + /// The switcher for content hashing. If not speciefied, false value is used by default. + /// Status codes to filter. + public CacheOptions(int? ttlSeconds, string region, string header, bool? enableContentHashing, int[] statusCodes) { TtlSeconds = ttlSeconds ?? NoSeconds; Region = region; Header = header.IfEmpty(Oc_Cache_Control); EnableContentHashing = enableContentHashing ?? false; + StatusCodes = ConvertToHttpStatusCodes(statusCodes); } /// Time-to-live seconds. @@ -53,4 +60,24 @@ public CacheOptions(int? ttlSeconds, string region, string header, bool? enableC public bool EnableContentHashing { get; } public bool UseCache => TtlSeconds > NoSeconds; + + public HttpStatusCode[] StatusCodes { get; } + + private static HttpStatusCode[] ConvertToHttpStatusCodes(int[] statusCodes) + { + if (statusCodes is null || statusCodes.Length == 0) + { + return []; + } + + return statusCodes.Select(code => + { + if (!Enum.IsDefined(typeof(HttpStatusCode), code)) + { + throw new ArgumentException($"Invalid HTTP status code: {code}", nameof(statusCodes)); + } + + return (HttpStatusCode)code; + }).ToArray(); + } } diff --git a/src/Ocelot/Configuration/Creator/CacheOptionsCreator.cs b/src/Ocelot/Configuration/Creator/CacheOptionsCreator.cs index 0d11eb058..4c12cc1e7 100644 --- a/src/Ocelot/Configuration/Creator/CacheOptionsCreator.cs +++ b/src/Ocelot/Configuration/Creator/CacheOptionsCreator.cs @@ -6,7 +6,7 @@ namespace Ocelot.Configuration.Creator; public class CacheOptionsCreator : ICacheOptionsCreator { public CacheOptions Create(FileCacheOptions options) - => new(options?.TtlSeconds, options?.Region, options?.Header, options?.EnableContentHashing); + => new(options?.TtlSeconds, options?.Region, options?.Header, options?.EnableContentHashing, options?.StatusCodes); public CacheOptions Create(FileRoute route, FileGlobalConfiguration globalConfiguration, string loadBalancingKey) { @@ -58,6 +58,7 @@ protected virtual CacheOptions Merge(FileCacheOptions options, FileCacheOptions var header = options.Header.IfEmpty(globalOptions.Header).IfEmpty(CacheOptions.Oc_Cache_Control); var ttlSeconds = options.TtlSeconds ?? globalOptions.TtlSeconds; var enableHashing = options.EnableContentHashing ?? globalOptions.EnableContentHashing; - return new CacheOptions(ttlSeconds, region, header, enableHashing); + var statusCodes = options.StatusCodes ?? globalOptions.StatusCodes; + return new CacheOptions(ttlSeconds, region, header, enableHashing, statusCodes); } } diff --git a/src/Ocelot/Configuration/File/FileCacheOptions.cs b/src/Ocelot/Configuration/File/FileCacheOptions.cs index 81a01d943..7a2fb5b55 100644 --- a/src/Ocelot/Configuration/File/FileCacheOptions.cs +++ b/src/Ocelot/Configuration/File/FileCacheOptions.cs @@ -23,4 +23,7 @@ public FileCacheOptions(FileCacheOptions from) /// If then use global configuration with by default. /// if content hashing is enabled; otherwise, . public bool? EnableContentHashing { get; set; } + + //public HttpStatusCode[] StatusCodes { get; set; } + public int[] StatusCodes { get; set; } } diff --git a/test/Ocelot.AcceptanceTests/Caching/CachingTests.cs b/test/Ocelot.AcceptanceTests/Caching/CachingTests.cs index b774c90cb..f6a06e3cf 100644 --- a/test/Ocelot.AcceptanceTests/Caching/CachingTests.cs +++ b/test/Ocelot.AcceptanceTests/Caching/CachingTests.cs @@ -215,6 +215,70 @@ public void Should_return_same_cached_response_when_request_body_changes_and_Ena .BDDfy(); } + [Theory] + [InlineData(null, HttpStatusCode.OK)] + [InlineData(null, HttpStatusCode.Forbidden)] + [InlineData(null, HttpStatusCode.InternalServerError)] + [InlineData(null, HttpStatusCode.Unauthorized)] + [InlineData(new int[] { (int)HttpStatusCode.OK, (int)HttpStatusCode.Forbidden }, HttpStatusCode.OK)] + [InlineData(new int[] { StatusCodes.Status200OK, StatusCodes.Status403Forbidden }, HttpStatusCode.Forbidden)] + [Trait("Feat", "741")] + public void Should_cache_when_whitelisted(int[] statusCodes, HttpStatusCode responseCode) + { + // Arrange + var port = PortFinder.GetRandomPort(); + var options = new FileCacheOptions + { + TtlSeconds = 100, + StatusCodes = statusCodes, + }; + var (testBody1String, testBody2String) = TestBodiesFactory(); + var configuration = GivenFileConfiguration(port, options); + + this.Given(x => x.GivenThereIsAServiceRunningOn(port, responseCode, HelloLauraContent, null, null)) + .And(x => GivenThereIsAConfiguration(configuration)) + .And(x => GivenOcelotIsRunning()) + .When(x => WhenIGetUrlOnTheApiGateway("/")) + .Then(x => ThenTheStatusCodeShouldBe(responseCode)) + .And(x => ThenTheResponseBodyShouldBe(HelloLauraContent)) + .Given(x => x.GivenTheServiceNowReturns(port, responseCode, HelloTomContent, null, null)) + .When(x => WhenIGetUrlOnTheApiGateway("/")) + .Then(x => ThenTheStatusCodeShouldBe(responseCode)) + .And(x => ThenTheResponseBodyShouldBe(HelloLauraContent)) + .And(x => ThenTheContentLengthIs(HelloLauraContent.Length)) + .BDDfy(); + } + + [Theory] + [InlineData(new int[] { StatusCodes.Status200OK, StatusCodes.Status403Forbidden }, HttpStatusCode.InternalServerError)] + [InlineData(new int[] { StatusCodes.Status200OK, StatusCodes.Status403Forbidden }, HttpStatusCode.BadRequest)] + [Trait("Feat", "741")] + public void Should_not_cache_when_not_whitelisted(int[] statusCodes, HttpStatusCode responseCode) + { + // Arrange + var port = PortFinder.GetRandomPort(); + var options = new FileCacheOptions + { + TtlSeconds = 100, + StatusCodes = statusCodes, + }; + var (testBody1String, testBody2String) = TestBodiesFactory(); + var configuration = GivenFileConfiguration(port, options); + + this.Given(x => x.GivenThereIsAServiceRunningOn(port, responseCode, HelloLauraContent, null, null)) + .And(x => GivenThereIsAConfiguration(configuration)) + .And(x => GivenOcelotIsRunning()) + .When(x => WhenIGetUrlOnTheApiGateway("/")) + .Then(x => ThenTheStatusCodeShouldBe(responseCode)) + .And(x => ThenTheResponseBodyShouldBe(HelloLauraContent)) + .Given(x => x.GivenTheServiceNowReturns(port, responseCode, HelloTomContent, null, null)) + .When(x => WhenIGetUrlOnTheApiGateway("/")) + .Then(x => ThenTheStatusCodeShouldBe(responseCode)) + .And(x => ThenTheResponseBodyShouldBe(HelloTomContent)) + .And(x => ThenTheContentLengthIs(HelloTomContent.Length)) + .BDDfy(); + } + [Fact] [Trait("Issue", "1172")] public void Should_clean_cached_response_by_cache_header_via_new_caching_key() diff --git a/test/Ocelot.UnitTests/Cache/OutputCacheMiddlewareTests.cs b/test/Ocelot.UnitTests/Cache/OutputCacheMiddlewareTests.cs index 97826bc0e..3ccb1d00d 100644 --- a/test/Ocelot.UnitTests/Cache/OutputCacheMiddlewareTests.cs +++ b/test/Ocelot.UnitTests/Cache/OutputCacheMiddlewareTests.cs @@ -150,6 +150,44 @@ private void ThenTheMessageIs(string expected) Assert.Equal(expected, msg); } + [Theory] + [InlineData(null, HttpStatusCode.OK)] + [InlineData(null, HttpStatusCode.Forbidden)] + [InlineData(null, HttpStatusCode.InternalServerError)] + [InlineData(null, HttpStatusCode.Unauthorized)] + [InlineData(new int[] { StatusCodes.Status200OK, StatusCodes.Status403Forbidden }, HttpStatusCode.OK)] + [InlineData(new int[] { StatusCodes.Status200OK, StatusCodes.Status403Forbidden }, HttpStatusCode.Forbidden)] + public async Task Should_cache_when_whitelisted(int[] statusCodes, HttpStatusCode responseCode) + { + // Arrange + var response = new HttpResponseMessage(responseCode); + GivenResponseIsNotCached(response); + GivenTheDownstreamRouteIs(new CacheOptions(100, "kanken", null, false, statusCodes)); + + // Act + await WhenICallTheMiddlewareAsync(); + + // Assert + ThenTheCacheAddIsCalled(Times.Once); + } + + [Theory] + [InlineData(new int[] { StatusCodes.Status200OK, StatusCodes.Status403Forbidden }, HttpStatusCode.InternalServerError)] + [InlineData(new int[] { StatusCodes.Status200OK, StatusCodes.Status403Forbidden }, HttpStatusCode.BadRequest)] + public async Task Should_not_cache_when_not_whitelisted(int[] statusCodes, HttpStatusCode responseCode) + { + // Arrange + var response = new HttpResponseMessage(responseCode); + GivenResponseIsNotCached(response); + GivenTheDownstreamRouteIs(new CacheOptions(100, "kanken", null, false, statusCodes)); + + // Act + await WhenICallTheMiddlewareAsync(); + + // Assert + ThenTheCacheAddIsCalled(Times.Never); + } + private async Task WhenICallTheMiddlewareAsync() { _middleware = new OutputCacheMiddleware(_next, _loggerFactory.Object, _cache.Object, _cacheGenerator.Object);