Skip to content

Commit 3d260bb

Browse files
Fix browser caching behaviour (#3772)
- Fix ETags not being used due to non-zero `max-age`. - Fix incorrect ETag types for hashed files. - Consistently respond with HTTP 304 for unmodified static files. - Fix document URL not responding with correct response if custom JSON serializer options are used. Resolves #3709 and #3768.
1 parent 83baaf0 commit 3d260bb

File tree

6 files changed

+121
-57
lines changed

6 files changed

+121
-57
lines changed

src/Swashbuckle.AspNetCore.ReDoc/ReDocMiddleware.cs

Lines changed: 17 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,7 @@ public async Task Invoke(HttpContext httpContext)
5858

5959
if (match.Success)
6060
{
61-
await RespondWithFile(httpContext.Response, match.Groups[1].Value, httpContext.RequestAborted);
61+
await RespondWithFile(httpContext, match.Groups[1].Value);
6262
return;
6363
}
6464
}
@@ -99,7 +99,7 @@ private static void SetHeaders(HttpResponse response, ReDocOptions options, stri
9999
};
100100
}
101101

102-
headers.ETag = new($"\"{etag}\"", isWeak: true);
102+
headers.ETag = new(etag);
103103
}
104104

105105
private static void RespondWithRedirect(HttpResponse response, string location)
@@ -108,11 +108,11 @@ private static void RespondWithRedirect(HttpResponse response, string location)
108108
response.Headers.Location = location;
109109
}
110110

111-
private async Task RespondWithFile(
112-
HttpResponse response,
113-
string fileName,
114-
CancellationToken cancellationToken)
111+
private async Task RespondWithFile(HttpContext context, string fileName)
115112
{
113+
var cancellationToken = context.RequestAborted;
114+
var response = context.Response;
115+
116116
response.StatusCode = StatusCodes.Status200OK;
117117

118118
Stream stream;
@@ -153,19 +153,27 @@ private async Task RespondWithFile(
153153
}
154154

155155
var text = content.ToString();
156-
var etag = HashText(text);
156+
var etag = GetETag(text);
157+
158+
var ifNoneMatch = context.Request.Headers.IfNoneMatch;
159+
160+
if (ifNoneMatch == etag)
161+
{
162+
response.StatusCode = StatusCodes.Status304NotModified;
163+
return;
164+
}
157165

158166
SetHeaders(response, _options, etag);
159167

160168
await response.WriteAsync(text, Encoding.UTF8, cancellationToken);
161169
}
162170

163-
static string HashText(string text)
171+
static string GetETag(string text)
164172
{
165173
var buffer = Encoding.UTF8.GetBytes(text);
166174
var hash = SHA1.HashData(buffer);
167175

168-
return Convert.ToBase64String(hash);
176+
return $"\"{Convert.ToBase64String(hash)}\"";
169177
}
170178
}
171179

src/Swashbuckle.AspNetCore.ReDoc/ReDocOptions.cs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,7 @@ public class ReDocOptions
4343
/// Gets or sets the cache lifetime to use for the ReDoc files, if any.
4444
/// </summary>
4545
/// <remarks>
46-
/// The default value is 7 days.
46+
/// The default value is 0 days (ETags are used to check if resources have been updated).
4747
/// </remarks>
48-
public TimeSpan? CacheLifetime { get; set; } = TimeSpan.FromDays(7);
48+
public TimeSpan? CacheLifetime { get; set; } = TimeSpan.Zero;
4949
}

src/Swashbuckle.AspNetCore.SwaggerUI/SwaggerUIMiddleware.cs

Lines changed: 44 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -58,14 +58,14 @@ public async Task Invoke(HttpContext httpContext)
5858

5959
if (match.Success)
6060
{
61-
await RespondWithFile(httpContext.Response, match.Groups[1].Value, httpContext.RequestAborted);
61+
await RespondWithFile(httpContext, match.Groups[1].Value);
6262
return;
6363
}
6464

6565
var pattern = $"^/?{Regex.Escape(_options.RoutePrefix)}/{_options.SwaggerDocumentUrlsPath}/?$";
6666
if (Regex.IsMatch(path, pattern, RegexOptions.IgnoreCase))
6767
{
68-
await RespondWithDocumentUrls(httpContext.Response);
68+
await RespondWithDocumentUrls(httpContext);
6969
return;
7070
}
7171
}
@@ -106,7 +106,7 @@ private static void SetHeaders(HttpResponse response, SwaggerUIOptions options,
106106
};
107107
}
108108

109-
headers.ETag = new($"\"{etag}\"", isWeak: true);
109+
headers.ETag = new(etag);
110110
}
111111

112112
private static void RespondWithRedirect(HttpResponse response, string location)
@@ -115,23 +115,22 @@ private static void RespondWithRedirect(HttpResponse response, string location)
115115
response.Headers.Location = location;
116116
}
117117

118-
private async Task RespondWithFile(
119-
HttpResponse response,
120-
string fileName,
121-
CancellationToken cancellationToken)
118+
private async Task RespondWithFile(HttpContext context, string fileName)
122119
{
123-
response.StatusCode = StatusCodes.Status200OK;
120+
var cancellationToken = context.RequestAborted;
121+
var response = context.Response;
124122

123+
string contentType;
125124
Stream stream;
126125

127126
if (fileName == "index.js")
128127
{
129-
response.ContentType = "application/javascript;charset=utf-8";
128+
contentType = "application/javascript;charset=utf-8";
130129
stream = ResourceHelper.GetEmbeddedResource(fileName);
131130
}
132131
else
133132
{
134-
response.ContentType = "text/html;charset=utf-8";
133+
contentType = "text/html;charset=utf-8";
135134
stream = _options.IndexStream();
136135
}
137136

@@ -153,11 +152,23 @@ private async Task RespondWithFile(
153152
}
154153

155154
var text = content.ToString();
156-
var etag = HashText(text);
155+
var etag = GetETag(text);
157156

158-
SetHeaders(response, _options, etag);
157+
var ifNoneMatch = context.Request.Headers.IfNoneMatch;
159158

160-
await response.WriteAsync(text, Encoding.UTF8, cancellationToken);
159+
if (ifNoneMatch == etag)
160+
{
161+
response.StatusCode = StatusCodes.Status304NotModified;
162+
}
163+
else
164+
{
165+
response.ContentType = contentType;
166+
response.StatusCode = StatusCodes.Status200OK;
167+
168+
SetHeaders(response, _options, etag);
169+
170+
await response.WriteAsync(text, Encoding.UTF8, cancellationToken);
171+
}
161172
}
162173
}
163174

@@ -169,11 +180,10 @@ private async Task RespondWithFile(
169180
"AOT",
170181
"IL3050:RequiresDynamicCode",
171182
Justification = "Method is only called if the user provides their own custom JsonSerializerOptions.")]
172-
private async Task RespondWithDocumentUrls(HttpResponse response)
183+
private async Task RespondWithDocumentUrls(HttpContext context)
173184
{
174-
response.StatusCode = 200;
185+
var response = context.Response;
175186

176-
response.ContentType = "application/javascript;charset=utf-8";
177187
string json = "[]";
178188

179189
if (_jsonSerializerOptions is null)
@@ -182,20 +192,32 @@ private async Task RespondWithDocumentUrls(HttpResponse response)
182192
json = JsonSerializer.Serialize(l, SwaggerUIOptionsJsonContext.Default.ListUrlDescriptor);
183193
}
184194

185-
json ??= JsonSerializer.Serialize(_options.ConfigObject, _jsonSerializerOptions);
195+
json ??= JsonSerializer.Serialize(_options.ConfigObject.Urls, _jsonSerializerOptions);
186196

187-
var etag = HashText(json);
188-
SetHeaders(response, _options, etag);
197+
var etag = GetETag(json);
198+
var ifNoneMatch = context.Request.Headers.IfNoneMatch;
189199

190-
await response.WriteAsync(json, Encoding.UTF8);
200+
if (ifNoneMatch == etag)
201+
{
202+
response.StatusCode = StatusCodes.Status304NotModified;
203+
}
204+
else
205+
{
206+
response.StatusCode = StatusCodes.Status200OK;
207+
response.ContentType = "application/javascript;charset=utf-8";
208+
209+
SetHeaders(response, _options, etag);
210+
211+
await response.WriteAsync(json, Encoding.UTF8, context.RequestAborted);
212+
}
191213
}
192214

193-
private static string HashText(string text)
215+
private static string GetETag(string text)
194216
{
195217
var buffer = Encoding.UTF8.GetBytes(text);
196218
var hash = SHA1.HashData(buffer);
197219

198-
return Convert.ToBase64String(hash);
220+
return $"\"{Convert.ToBase64String(hash)}\"";
199221
}
200222

201223
[UnconditionalSuppressMessage(

src/Swashbuckle.AspNetCore.SwaggerUI/SwaggerUIOptions.cs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -75,7 +75,7 @@ public class SwaggerUIOptions
7575
/// Gets or sets the cache lifetime to use for the SwaggerUI files, if any.
7676
/// </summary>
7777
/// <remarks>
78-
/// The default value is 7 days.
78+
/// The default value is 0 days (ETags are used to check if resources have been updated).
7979
/// </remarks>
80-
public TimeSpan? CacheLifetime { get; set; } = TimeSpan.FromDays(7);
80+
public TimeSpan? CacheLifetime { get; set; } = TimeSpan.Zero;
8181
}

test/Swashbuckle.AspNetCore.IntegrationTests/ReDocIntegrationTests.cs

Lines changed: 19 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -39,17 +39,17 @@ public async Task IndexUrl_ReturnsEmbeddedVersionOfTheRedocUI()
3939

4040
AssertResource(htmlResponse);
4141
AssertResource(cssResponse);
42-
AssertResource(jsResponse, weakETag: false);
42+
AssertResource(jsResponse);
4343

44-
static void AssertResource(HttpResponseMessage response, bool weakETag = true)
44+
static void AssertResource(HttpResponseMessage response)
4545
{
4646
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
4747
Assert.NotNull(response.Headers.ETag);
48-
Assert.Equal(weakETag, response.Headers.ETag.IsWeak);
48+
Assert.False(response.Headers.ETag.IsWeak);
4949
Assert.NotEmpty(response.Headers.ETag.Tag);
5050
Assert.NotNull(response.Headers.CacheControl);
5151
Assert.True(response.Headers.CacheControl.Private);
52-
Assert.Equal(TimeSpan.FromDays(7), response.Headers.CacheControl.MaxAge);
52+
Assert.Equal(TimeSpan.Zero, response.Headers.CacheControl.MaxAge);
5353
}
5454
}
5555

@@ -61,7 +61,9 @@ public async Task RedocMiddleware_ReturnsInitializerScript()
6161
var site = new TestSite(typeof(ReDocApp.Startup), outputHelper);
6262
using var client = site.BuildClient();
6363

64-
using var response = await client.GetAsync("/api-docs/index.js", cancellationToken);
64+
var requestUri = "/api-docs/index.js";
65+
66+
using var response = await client.GetAsync(requestUri, cancellationToken);
6567
var content = await response.Content.ReadAsStringAsync(cancellationToken);
6668

6769
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
@@ -70,6 +72,16 @@ public async Task RedocMiddleware_ReturnsInitializerScript()
7072
Assert.DoesNotContain("%(HeadContent)", content);
7173
Assert.DoesNotContain("%(SpecUrl)", content);
7274
Assert.DoesNotContain("%(ConfigObject)", content);
75+
76+
using var request = new HttpRequestMessage(HttpMethod.Get, requestUri);
77+
request.Headers.IfNoneMatch.Add(response.Headers.ETag);
78+
79+
using var cached = await client.SendAsync(request, cancellationToken);
80+
81+
Assert.Equal(HttpStatusCode.NotModified, cached.StatusCode);
82+
83+
using var stream = await cached.Content.ReadAsStreamAsync(cancellationToken);
84+
Assert.Equal(0, stream.Length);
7385
}
7486

7587
[Fact]
@@ -229,7 +241,7 @@ public async Task ReDocMiddleware_Returns_ExpectedAssetContents_Decompressed(str
229241

230242
Assert.NotNull(response.Headers.CacheControl);
231243
Assert.True(response.Headers.CacheControl.Private);
232-
Assert.Equal(TimeSpan.FromDays(7), response.Headers.CacheControl.MaxAge);
244+
Assert.Equal(TimeSpan.Zero, response.Headers.CacheControl.MaxAge);
233245

234246
Assert.Equal(response.Content.Headers.ContentLength, actual.Length);
235247
}
@@ -293,7 +305,7 @@ public async Task ReDocMiddleware_Returns_ExpectedAssetContents_GZip_Compressed(
293305

294306
Assert.NotNull(response.Headers.CacheControl);
295307
Assert.True(response.Headers.CacheControl.Private);
296-
Assert.Equal(TimeSpan.FromDays(7), response.Headers.CacheControl.MaxAge);
308+
Assert.Equal(TimeSpan.Zero, response.Headers.CacheControl.MaxAge);
297309

298310
Assert.Equal(response.Content.Headers.ContentLength, actual.Length);
299311
}

0 commit comments

Comments
 (0)