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);
}
}