diff --git a/src/Swashbuckle.AspNetCore.ReDoc/ReDocMiddleware.cs b/src/Swashbuckle.AspNetCore.ReDoc/ReDocMiddleware.cs index 8c037a4028..6df5702c62 100644 --- a/src/Swashbuckle.AspNetCore.ReDoc/ReDocMiddleware.cs +++ b/src/Swashbuckle.AspNetCore.ReDoc/ReDocMiddleware.cs @@ -58,7 +58,7 @@ public async Task Invoke(HttpContext httpContext) if (match.Success) { - await RespondWithFile(httpContext.Response, match.Groups[1].Value, httpContext.RequestAborted); + await RespondWithFile(httpContext, match.Groups[1].Value); return; } } @@ -99,7 +99,7 @@ private static void SetHeaders(HttpResponse response, ReDocOptions options, stri }; } - headers.ETag = new($"\"{etag}\"", isWeak: true); + headers.ETag = new(etag); } private static void RespondWithRedirect(HttpResponse response, string location) @@ -108,11 +108,11 @@ private static void RespondWithRedirect(HttpResponse response, string location) response.Headers.Location = location; } - private async Task RespondWithFile( - HttpResponse response, - string fileName, - CancellationToken cancellationToken) + private async Task RespondWithFile(HttpContext context, string fileName) { + var cancellationToken = context.RequestAborted; + var response = context.Response; + response.StatusCode = StatusCodes.Status200OK; Stream stream; @@ -153,19 +153,27 @@ private async Task RespondWithFile( } var text = content.ToString(); - var etag = HashText(text); + var etag = GetETag(text); + + var ifNoneMatch = context.Request.Headers.IfNoneMatch; + + if (ifNoneMatch == etag) + { + response.StatusCode = StatusCodes.Status304NotModified; + return; + } SetHeaders(response, _options, etag); await response.WriteAsync(text, Encoding.UTF8, cancellationToken); } - static string HashText(string text) + static string GetETag(string text) { var buffer = Encoding.UTF8.GetBytes(text); var hash = SHA1.HashData(buffer); - return Convert.ToBase64String(hash); + return $"\"{Convert.ToBase64String(hash)}\""; } } diff --git a/src/Swashbuckle.AspNetCore.ReDoc/ReDocOptions.cs b/src/Swashbuckle.AspNetCore.ReDoc/ReDocOptions.cs index 31cffc1aa7..ae77cbb71a 100644 --- a/src/Swashbuckle.AspNetCore.ReDoc/ReDocOptions.cs +++ b/src/Swashbuckle.AspNetCore.ReDoc/ReDocOptions.cs @@ -43,7 +43,7 @@ public class ReDocOptions /// Gets or sets the cache lifetime to use for the ReDoc files, if any. /// /// - /// The default value is 7 days. + /// The default value is 0 days (ETags are used to check if resources have been updated). /// - public TimeSpan? CacheLifetime { get; set; } = TimeSpan.FromDays(7); + public TimeSpan? CacheLifetime { get; set; } = TimeSpan.Zero; } diff --git a/src/Swashbuckle.AspNetCore.SwaggerUI/SwaggerUIMiddleware.cs b/src/Swashbuckle.AspNetCore.SwaggerUI/SwaggerUIMiddleware.cs index c38a548a83..42315d0aaa 100644 --- a/src/Swashbuckle.AspNetCore.SwaggerUI/SwaggerUIMiddleware.cs +++ b/src/Swashbuckle.AspNetCore.SwaggerUI/SwaggerUIMiddleware.cs @@ -58,14 +58,14 @@ public async Task Invoke(HttpContext httpContext) if (match.Success) { - await RespondWithFile(httpContext.Response, match.Groups[1].Value, httpContext.RequestAborted); + await RespondWithFile(httpContext, match.Groups[1].Value); return; } var pattern = $"^/?{Regex.Escape(_options.RoutePrefix)}/{_options.SwaggerDocumentUrlsPath}/?$"; if (Regex.IsMatch(path, pattern, RegexOptions.IgnoreCase)) { - await RespondWithDocumentUrls(httpContext.Response); + await RespondWithDocumentUrls(httpContext); return; } } @@ -106,7 +106,7 @@ private static void SetHeaders(HttpResponse response, SwaggerUIOptions options, }; } - headers.ETag = new($"\"{etag}\"", isWeak: true); + headers.ETag = new(etag); } private static void RespondWithRedirect(HttpResponse response, string location) @@ -115,23 +115,22 @@ private static void RespondWithRedirect(HttpResponse response, string location) response.Headers.Location = location; } - private async Task RespondWithFile( - HttpResponse response, - string fileName, - CancellationToken cancellationToken) + private async Task RespondWithFile(HttpContext context, string fileName) { - response.StatusCode = StatusCodes.Status200OK; + var cancellationToken = context.RequestAborted; + var response = context.Response; + string contentType; Stream stream; if (fileName == "index.js") { - response.ContentType = "application/javascript;charset=utf-8"; + contentType = "application/javascript;charset=utf-8"; stream = ResourceHelper.GetEmbeddedResource(fileName); } else { - response.ContentType = "text/html;charset=utf-8"; + contentType = "text/html;charset=utf-8"; stream = _options.IndexStream(); } @@ -153,11 +152,23 @@ private async Task RespondWithFile( } var text = content.ToString(); - var etag = HashText(text); + var etag = GetETag(text); - SetHeaders(response, _options, etag); + var ifNoneMatch = context.Request.Headers.IfNoneMatch; - await response.WriteAsync(text, Encoding.UTF8, cancellationToken); + if (ifNoneMatch == etag) + { + response.StatusCode = StatusCodes.Status304NotModified; + } + else + { + response.ContentType = contentType; + response.StatusCode = StatusCodes.Status200OK; + + SetHeaders(response, _options, etag); + + await response.WriteAsync(text, Encoding.UTF8, cancellationToken); + } } } @@ -169,11 +180,10 @@ private async Task RespondWithFile( "AOT", "IL3050:RequiresDynamicCode", Justification = "Method is only called if the user provides their own custom JsonSerializerOptions.")] - private async Task RespondWithDocumentUrls(HttpResponse response) + private async Task RespondWithDocumentUrls(HttpContext context) { - response.StatusCode = 200; + var response = context.Response; - response.ContentType = "application/javascript;charset=utf-8"; string json = "[]"; if (_jsonSerializerOptions is null) @@ -182,20 +192,32 @@ private async Task RespondWithDocumentUrls(HttpResponse response) json = JsonSerializer.Serialize(l, SwaggerUIOptionsJsonContext.Default.ListUrlDescriptor); } - json ??= JsonSerializer.Serialize(_options.ConfigObject, _jsonSerializerOptions); + json ??= JsonSerializer.Serialize(_options.ConfigObject.Urls, _jsonSerializerOptions); - var etag = HashText(json); - SetHeaders(response, _options, etag); + var etag = GetETag(json); + var ifNoneMatch = context.Request.Headers.IfNoneMatch; - await response.WriteAsync(json, Encoding.UTF8); + if (ifNoneMatch == etag) + { + response.StatusCode = StatusCodes.Status304NotModified; + } + else + { + response.StatusCode = StatusCodes.Status200OK; + response.ContentType = "application/javascript;charset=utf-8"; + + SetHeaders(response, _options, etag); + + await response.WriteAsync(json, Encoding.UTF8, context.RequestAborted); + } } - private static string HashText(string text) + private static string GetETag(string text) { var buffer = Encoding.UTF8.GetBytes(text); var hash = SHA1.HashData(buffer); - return Convert.ToBase64String(hash); + return $"\"{Convert.ToBase64String(hash)}\""; } [UnconditionalSuppressMessage( diff --git a/src/Swashbuckle.AspNetCore.SwaggerUI/SwaggerUIOptions.cs b/src/Swashbuckle.AspNetCore.SwaggerUI/SwaggerUIOptions.cs index cd086163f2..5508636738 100644 --- a/src/Swashbuckle.AspNetCore.SwaggerUI/SwaggerUIOptions.cs +++ b/src/Swashbuckle.AspNetCore.SwaggerUI/SwaggerUIOptions.cs @@ -75,7 +75,7 @@ public class SwaggerUIOptions /// Gets or sets the cache lifetime to use for the SwaggerUI files, if any. /// /// - /// The default value is 7 days. + /// The default value is 0 days (ETags are used to check if resources have been updated). /// - public TimeSpan? CacheLifetime { get; set; } = TimeSpan.FromDays(7); + public TimeSpan? CacheLifetime { get; set; } = TimeSpan.Zero; } diff --git a/test/Swashbuckle.AspNetCore.IntegrationTests/ReDocIntegrationTests.cs b/test/Swashbuckle.AspNetCore.IntegrationTests/ReDocIntegrationTests.cs index 7546fc79aa..186665fdb2 100644 --- a/test/Swashbuckle.AspNetCore.IntegrationTests/ReDocIntegrationTests.cs +++ b/test/Swashbuckle.AspNetCore.IntegrationTests/ReDocIntegrationTests.cs @@ -39,17 +39,17 @@ public async Task IndexUrl_ReturnsEmbeddedVersionOfTheRedocUI() AssertResource(htmlResponse); AssertResource(cssResponse); - AssertResource(jsResponse, weakETag: false); + AssertResource(jsResponse); - static void AssertResource(HttpResponseMessage response, bool weakETag = true) + static void AssertResource(HttpResponseMessage response) { Assert.Equal(HttpStatusCode.OK, response.StatusCode); Assert.NotNull(response.Headers.ETag); - Assert.Equal(weakETag, response.Headers.ETag.IsWeak); + Assert.False(response.Headers.ETag.IsWeak); Assert.NotEmpty(response.Headers.ETag.Tag); Assert.NotNull(response.Headers.CacheControl); Assert.True(response.Headers.CacheControl.Private); - Assert.Equal(TimeSpan.FromDays(7), response.Headers.CacheControl.MaxAge); + Assert.Equal(TimeSpan.Zero, response.Headers.CacheControl.MaxAge); } } @@ -61,7 +61,9 @@ public async Task RedocMiddleware_ReturnsInitializerScript() var site = new TestSite(typeof(ReDocApp.Startup), outputHelper); using var client = site.BuildClient(); - using var response = await client.GetAsync("/api-docs/index.js", cancellationToken); + var requestUri = "/api-docs/index.js"; + + using var response = await client.GetAsync(requestUri, cancellationToken); var content = await response.Content.ReadAsStringAsync(cancellationToken); Assert.Equal(HttpStatusCode.OK, response.StatusCode); @@ -70,6 +72,16 @@ public async Task RedocMiddleware_ReturnsInitializerScript() Assert.DoesNotContain("%(HeadContent)", content); Assert.DoesNotContain("%(SpecUrl)", content); Assert.DoesNotContain("%(ConfigObject)", content); + + using var request = new HttpRequestMessage(HttpMethod.Get, requestUri); + request.Headers.IfNoneMatch.Add(response.Headers.ETag); + + using var cached = await client.SendAsync(request, cancellationToken); + + Assert.Equal(HttpStatusCode.NotModified, cached.StatusCode); + + using var stream = await cached.Content.ReadAsStreamAsync(cancellationToken); + Assert.Equal(0, stream.Length); } [Fact] @@ -229,7 +241,7 @@ public async Task ReDocMiddleware_Returns_ExpectedAssetContents_Decompressed(str Assert.NotNull(response.Headers.CacheControl); Assert.True(response.Headers.CacheControl.Private); - Assert.Equal(TimeSpan.FromDays(7), response.Headers.CacheControl.MaxAge); + Assert.Equal(TimeSpan.Zero, response.Headers.CacheControl.MaxAge); Assert.Equal(response.Content.Headers.ContentLength, actual.Length); } @@ -293,7 +305,7 @@ public async Task ReDocMiddleware_Returns_ExpectedAssetContents_GZip_Compressed( Assert.NotNull(response.Headers.CacheControl); Assert.True(response.Headers.CacheControl.Private); - Assert.Equal(TimeSpan.FromDays(7), response.Headers.CacheControl.MaxAge); + Assert.Equal(TimeSpan.Zero, response.Headers.CacheControl.MaxAge); Assert.Equal(response.Content.Headers.ContentLength, actual.Length); } diff --git a/test/Swashbuckle.AspNetCore.IntegrationTests/SwaggerUIIntegrationTests.cs b/test/Swashbuckle.AspNetCore.IntegrationTests/SwaggerUIIntegrationTests.cs index 943d0b2b23..10448e0c35 100644 --- a/test/Swashbuckle.AspNetCore.IntegrationTests/SwaggerUIIntegrationTests.cs +++ b/test/Swashbuckle.AspNetCore.IntegrationTests/SwaggerUIIntegrationTests.cs @@ -72,23 +72,23 @@ public async Task IndexUrl_ReturnsEmbeddedVersionOfTheSwaggerUI( AssertResource(htmlResponse); using var jsResponse = await client.GetAsync(swaggerUijsPath, cancellationToken); - AssertResource(jsResponse, weakETag: false); + AssertResource(jsResponse); using var indexCss = await client.GetAsync(indexCssPath, cancellationToken); - AssertResource(indexCss, weakETag: false); + AssertResource(indexCss); using var cssResponse = await client.GetAsync(swaggerUiCssPath, cancellationToken); - AssertResource(cssResponse, weakETag: false); + AssertResource(cssResponse); - static void AssertResource(HttpResponseMessage response, bool weakETag = true) + static void AssertResource(HttpResponseMessage response) { Assert.Equal(HttpStatusCode.OK, response.StatusCode); Assert.NotNull(response.Headers.ETag); - Assert.Equal(weakETag, response.Headers.ETag.IsWeak); + Assert.False(response.Headers.ETag.IsWeak); Assert.NotEmpty(response.Headers.ETag.Tag); Assert.NotNull(response.Headers.CacheControl); Assert.True(response.Headers.CacheControl.Private); - Assert.Equal(TimeSpan.FromDays(7), response.Headers.CacheControl.MaxAge); + Assert.Equal(TimeSpan.Zero, response.Headers.CacheControl.MaxAge); } } @@ -104,11 +104,11 @@ public async Task SwaggerUIMiddleware_ReturnsInitializerScript( var site = new TestSite(startupType, outputHelper); using var client = site.BuildClient(); - using var jsResponse = await client.GetAsync(indexJsPath, cancellationToken); + using var response = await client.GetAsync(indexJsPath, cancellationToken); - Assert.Equal(HttpStatusCode.OK, jsResponse.StatusCode); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); - var jsContent = await jsResponse.Content.ReadAsStringAsync(cancellationToken); + var jsContent = await response.Content.ReadAsStringAsync(cancellationToken); Assert.Contains("SwaggerUIBundle", jsContent); Assert.DoesNotContain("%(DocumentTitle)", jsContent); @@ -119,6 +119,16 @@ public async Task SwaggerUIMiddleware_ReturnsInitializerScript( Assert.DoesNotContain("%(ConfigObject)", jsContent); Assert.DoesNotContain("%(OAuthConfigObject)", jsContent); Assert.DoesNotContain("%(Interceptors)", jsContent); + + using var request = new HttpRequestMessage(HttpMethod.Get, indexJsPath); + request.Headers.IfNoneMatch.Add(response.Headers.ETag); + + using var cached = await client.SendAsync(request, cancellationToken); + + Assert.Equal(HttpStatusCode.NotModified, cached.StatusCode); + + using var stream = await cached.Content.ReadAsStreamAsync(cancellationToken); + Assert.Equal(0, stream.Length); } [Fact] @@ -282,7 +292,7 @@ public async Task SwaggerUIMiddleware_Returns_ExpectedAssetContents_Decompressed Assert.NotNull(response.Headers.CacheControl); Assert.True(response.Headers.CacheControl.Private); - Assert.Equal(TimeSpan.FromDays(7), response.Headers.CacheControl.MaxAge); + Assert.Equal(TimeSpan.Zero, response.Headers.CacheControl.MaxAge); Assert.Equal(response.Content.Headers.ContentLength, actual.Length); } @@ -341,7 +351,7 @@ public async Task SwaggerUIMiddleware_Returns_ExpectedAssetContents_GZip_Compres Assert.NotNull(response.Headers.CacheControl); Assert.True(response.Headers.CacheControl.Private); - Assert.Equal(TimeSpan.FromDays(7), response.Headers.CacheControl.MaxAge); + Assert.Equal(TimeSpan.Zero, response.Headers.CacheControl.MaxAge); Assert.Equal(response.Content.Headers.ContentLength, actual.Length); } @@ -370,7 +380,7 @@ public async Task SwaggerUIMiddleware_Returns_ExpectedAssetContents_NotModified( // Arrange using var request = new HttpRequestMessage(HttpMethod.Get, fileName); - request.Headers.IfNoneMatch.Add(new(uncached.Headers.ETag.Tag)); + request.Headers.IfNoneMatch.Add(uncached.Headers.ETag); // Act using var cached = await client.SendAsync(request, cancellationToken); @@ -391,8 +401,10 @@ public async Task DocumentUrlsEndpoint_ReturnsJsonWithCacheHeaders() var site = new TestSite(typeof(MultipleVersions.Startup), outputHelper); using var client = site.BuildClient(); + var requestUri = "/swagger/documentUrls"; + // Act - using var response = await client.GetAsync("/swagger/documentUrls", cancellationToken); + using var response = await client.GetAsync(requestUri, cancellationToken); // Assert Assert.Equal(HttpStatusCode.OK, response.StatusCode); @@ -405,11 +417,21 @@ public async Task DocumentUrlsEndpoint_ReturnsJsonWithCacheHeaders() // Verify cache headers are set Assert.NotNull(response.Headers.ETag); - Assert.True(response.Headers.ETag.IsWeak); + Assert.False(response.Headers.ETag.IsWeak); Assert.NotEmpty(response.Headers.ETag.Tag); Assert.NotNull(response.Headers.CacheControl); Assert.True(response.Headers.CacheControl.Private); - Assert.Equal(TimeSpan.FromDays(7), response.Headers.CacheControl.MaxAge); + Assert.Equal(TimeSpan.Zero, response.Headers.CacheControl.MaxAge); + + using var request = new HttpRequestMessage(HttpMethod.Get, requestUri); + request.Headers.IfNoneMatch.Add(response.Headers.ETag); + + using var cached = await client.SendAsync(request, cancellationToken); + + Assert.Equal(HttpStatusCode.NotModified, cached.StatusCode); + + using var stream = await cached.Content.ReadAsStreamAsync(cancellationToken); + Assert.Equal(0, stream.Length); } }