Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 17 additions & 9 deletions src/Swashbuckle.AspNetCore.ReDoc/ReDocMiddleware.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
}
Expand Down Expand Up @@ -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)
Expand All @@ -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;
Expand Down Expand Up @@ -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)}\"";
}
}

Expand Down
4 changes: 2 additions & 2 deletions src/Swashbuckle.AspNetCore.ReDoc/ReDocOptions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ public class ReDocOptions
/// Gets or sets the cache lifetime to use for the ReDoc files, if any.
/// </summary>
/// <remarks>
/// The default value is 7 days.
/// The default value is 0 days (ETags are used to check if resources have been updated).
/// </remarks>
public TimeSpan? CacheLifetime { get; set; } = TimeSpan.FromDays(7);
public TimeSpan? CacheLifetime { get; set; } = TimeSpan.Zero;
}
66 changes: 44 additions & 22 deletions src/Swashbuckle.AspNetCore.SwaggerUI/SwaggerUIMiddleware.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
}
Expand Down Expand Up @@ -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)
Expand All @@ -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();
}

Expand All @@ -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);
}
}
}

Expand All @@ -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)
Expand All @@ -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);

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Doesn't this still suffer the issue? The json variable has a non-null default value, so if you pass json options it won't null coalesce

Or am I missing something?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.


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(
Expand Down
4 changes: 2 additions & 2 deletions src/Swashbuckle.AspNetCore.SwaggerUI/SwaggerUIOptions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,7 @@ public class SwaggerUIOptions
/// Gets or sets the cache lifetime to use for the SwaggerUI files, if any.
/// </summary>
/// <remarks>
/// The default value is 7 days.
/// The default value is 0 days (ETags are used to check if resources have been updated).
/// </remarks>
public TimeSpan? CacheLifetime { get; set; } = TimeSpan.FromDays(7);
public TimeSpan? CacheLifetime { get; set; } = TimeSpan.Zero;
}
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
}

Expand All @@ -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);
Expand All @@ -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]
Expand Down Expand Up @@ -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);
}
Expand Down Expand Up @@ -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);
}
Expand Down
Loading
Loading