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
144 changes: 89 additions & 55 deletions src/Shared/EmbeddedResourceProvider.cs
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ internal sealed class EmbeddedResourceProvider(
{
private const string GZipEncodingValue = "gzip";
private static readonly StringValues GZipEncodingHeader = new(GZipEncodingValue);
private static readonly StringValues VaryAcceptEncodingHeader = new(HeaderNames.AcceptEncoding);

private readonly Assembly _assembly = assembly;
private readonly StringValues _cacheControl = GetCacheControlHeader(cacheLifetime);
Expand All @@ -32,56 +33,72 @@ internal sealed class EmbeddedResourceProvider(

public async Task<bool> TryRespondWithFileAsync(HttpContext httpContext)
{
var path = httpContext.Request.Path.Value ?? string.Empty;
if (!path.StartsWith(_pathPrefix, StringComparison.OrdinalIgnoreCase))
if (!TryGetResourcePath(httpContext.Request.Path.Value, out var resourcePath) ||
!_resourceCache.TryGetValue(resourcePath, out var cacheEntry))
{
return false;
}

path = path[_pathPrefix.Length..].Replace('/', '.');
var contentType = GetContentType(cacheEntry);
var content = GetContent(cacheEntry);

if (!_resourceCache.TryGetValue(path, out var cacheEntry))
{
return false;
}
// The representation is selected before the conditional request is evaluated: the entity tag
// has to identify the representation that would actually be served, otherwise a client or a
// cache holding one representation is told that a different one has not been modified.
var serveCompressed = content.SupportsCompression && IsGZipAccepted(httpContext.Request);

var contentType = GetContentType(cacheEntry);
var (etag, supportsCompression) = GetContentProperties(cacheEntry);
var etag = serveCompressed ? content.CompressedETag : content.DecompressedETag;
var body = serveCompressed ? content.Compressed : content.Decompressed;

var response = httpContext.Response;
var ifNoneMatch = httpContext.Request.Headers.IfNoneMatch;
var responseHeaders = response.Headers;

// The response is negotiated on Accept-Encoding, so a cache must not share it between
// clients that sent different values for that header.
responseHeaders.Vary = VaryAcceptEncodingHeader;
responseHeaders.ETag = etag;

if (ifNoneMatch == etag)
if (httpContext.Request.Headers.IfNoneMatch == etag)
{
response.StatusCode = StatusCodes.Status304NotModified;
return true;
}

var serveCompressed = supportsCompression && IsGZipAccepted(httpContext.Request);
var responseHeaders = response.Headers;

if (serveCompressed)
{
responseHeaders.ContentEncoding = GZipEncodingHeader;
}

responseHeaders.CacheControl = _cacheControl;
responseHeaders.ContentLength = serveCompressed ? cacheEntry.CompressedLength : cacheEntry.DecompressedLength;
responseHeaders.ContentLength = body.Length;
responseHeaders.ContentType = contentType;
responseHeaders.ETag = etag;

ReadOnlyMemory<byte> body;
await response.BodyWriter.WriteAsync(body, httpContext.RequestAborted);

if (serveCompressed)
return true;
}

private bool TryGetResourcePath(string? requestPath, out string resourcePath)
{
resourcePath = string.Empty;

var path = requestPath ?? string.Empty;

if (!path.StartsWith(_pathPrefix, StringComparison.OrdinalIgnoreCase))
{
body = cacheEntry.Compressed;
return false;
}
else

var relativePath = path[_pathPrefix.Length..];

// The prefix has to be followed by a segment boundary, otherwise a path such as
// "/swaggerXfoo.css" is treated as though it were below the configured route prefix.
if (relativePath.Length is 0 || relativePath[0] is not '/')
{
body = cacheEntry.Decompressed;
return false;
}

await response.BodyWriter.WriteAsync(body, httpContext.RequestAborted);
resourcePath = relativePath.Replace('/', '.');

return true;
}
Expand Down Expand Up @@ -137,66 +154,83 @@ private string GetContentType(ResourceEntry entry)
? contentType
: "application/octet-stream");

private (string ETag, bool Compressed) GetContentProperties(ResourceEntry entry)
private ResourceContent GetContent(ResourceEntry entry)
{
if (entry.ETag == null)
// Published as a single reference so that a concurrent reader either sees nothing at all or
// sees a fully initialized value, rather than a partially populated set of fields.
if (Volatile.Read(ref entry.Content) is { } existing)
{
using var compressed = GetResource(entry);
using var decompressed = new MemoryStream((int)compressed.Length * 2);
using var gzip = new GZipStream(compressed, CompressionMode.Decompress);
return existing;
}

using var compressed = GetResource(entry);
using var decompressed = new MemoryStream((int)compressed.Length * 2);

using (var gzip = new GZipStream(compressed, CompressionMode.Decompress, leaveOpen: true))
{
gzip.CopyTo(decompressed);
}

entry.Decompressed = decompressed.ToArray();
var decompressedBytes = decompressed.ToArray();

// Some embedded resources may already be compressed or compress worse than the original
entry.SupportsCompression = compressed.Length < decompressed.Length;
// Some embedded resources may already be compressed or compress worse than the original
var supportsCompression = compressed.Length < decompressedBytes.Length;

byte[] content;
byte[]? compressedBytes = null;
string? compressedETag = null;

if (entry.SupportsCompression)
{
compressed.Seek(0, SeekOrigin.Begin);
if (supportsCompression)
{
compressed.Seek(0, SeekOrigin.Begin);

using var memoryStream = new MemoryStream((int)compressed.Length);
compressed.CopyTo(memoryStream);
using var memoryStream = new MemoryStream((int)compressed.Length);
compressed.CopyTo(memoryStream);

content = entry.Compressed = memoryStream.ToArray();
}
else
{
content = entry.Decompressed;
}
compressedBytes = memoryStream.ToArray();
compressedETag = ComputeETag(compressedBytes);
}

var hash = SHA1.HashData(content);
var content = new ResourceContent(
decompressedBytes,
compressedBytes,
ComputeETag(decompressedBytes),
compressedETag,
supportsCompression);

entry.CompressedLength = compressed.Length;
entry.DecompressedLength = decompressed.Length;
entry.ETag = $"\"{Convert.ToBase64String(hash)}\"";
}
Volatile.Write(ref entry.Content, content);

return (entry.ETag, entry.SupportsCompression);
return content;

static string ComputeETag(byte[] content) => $"\"{Convert.ToBase64String(SHA1.HashData(content))}\"";
}

private Stream GetResource(ResourceEntry entry)
=> _assembly.GetManifestResourceStream(entry.ResourceName)!;

private sealed class ResourceEntry(string resourceName)
{
public long? CompressedLength { get; set; }
public ResourceContent? Content;

public string? ContentType { get; set; }

public long? DecompressedLength { get; set; }
public string ResourceName { get; } = resourceName;
}

public string? ETag { get; set; }
private sealed class ResourceContent(
byte[] decompressed,
byte[]? compressed,
string decompressedETag,
string? compressedETag,
bool supportsCompression)
{
public byte[] Decompressed { get; } = decompressed;

public string ResourceName { get; } = resourceName;
public byte[] Compressed { get; } = compressed ?? decompressed;

public bool SupportsCompression { get; set; }
public string DecompressedETag { get; } = decompressedETag;

public byte[]? Compressed { get; set; }
public string CompressedETag { get; } = compressedETag ?? decompressedETag;

public byte[]? Decompressed { get; set; }
public bool SupportsCompression { get; } = supportsCompression;
}
}
68 changes: 39 additions & 29 deletions src/Swashbuckle.AspNetCore.ReDoc/ReDocMiddleware.cs
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@

namespace Swashbuckle.AspNetCore.ReDoc;

internal sealed class ReDocMiddleware
internal sealed partial class ReDocMiddleware
{
private static readonly HashSet<string> AllowedHttpMethods = new(StringComparer.OrdinalIgnoreCase) { HttpMethods.Get, HttpMethods.Head };
private static readonly string ReDocVersion = GetReDocVersion();
Expand Down Expand Up @@ -55,19 +55,21 @@ public async Task Invoke(HttpContext httpContext)
return;
}

var match = Regex.Match(path, $"^/{Regex.Escape(_options.RoutePrefix)}/?(index.(html|css|js))$", RegexOptions.IgnoreCase);
var match = Regex.Match(path, $@"^/{Regex.Escape(_options.RoutePrefix)}/?(index\.(html|css|js))$", RegexOptions.IgnoreCase);

if (match.Success)
{
await RespondWithFile(httpContext, match.Groups[1].Value);
return;
}
}

if (!await _resourceProvider.TryRespondWithFileAsync(httpContext))
{
await _next(httpContext);
if (await _resourceProvider.TryRespondWithFileAsync(httpContext))
{
return;
}
}

await _next(httpContext);
}

private static string GetReDocVersion()
Expand Down Expand Up @@ -109,6 +111,9 @@ private static void RespondWithRedirect(HttpResponse response, string location)
response.Headers.Location = location;
}

[GeneratedRegex(@"%\([A-Za-z]+\)")]
private static partial Regex IndexArgumentPattern();

private async Task RespondWithFile(HttpContext context, string fileName)
{
var cancellationToken = context.RequestAborted;
Expand All @@ -118,22 +123,23 @@ private async Task RespondWithFile(HttpContext context, string fileName)

Stream stream;

switch (fileName)
// The route is matched case-insensitively, so the file must be selected the same way,
// otherwise a request for "INDEX.JS" is answered with the HTML document instead. The
// canonical name is used to look the resource up, as manifest names are case-sensitive.
if (string.Equals(fileName, "index.css", StringComparison.OrdinalIgnoreCase))
{
case "index.css":
response.ContentType = "text/css";
stream = ResourceHelper.GetEmbeddedResource(fileName);
break;

case "index.js":
response.ContentType = "application/javascript;charset=utf-8";
stream = ResourceHelper.GetEmbeddedResource(fileName);
break;

default:
response.ContentType = "text/html;charset=utf-8";
stream = _options.IndexStream();
break;
response.ContentType = "text/css";
stream = ResourceHelper.GetEmbeddedResource("index.css");
}
else if (string.Equals(fileName, "index.js", StringComparison.OrdinalIgnoreCase))
{
response.ContentType = "application/javascript;charset=utf-8";
stream = ResourceHelper.GetEmbeddedResource("index.js");
}
else
{
response.ContentType = "text/html;charset=utf-8";
stream = _options.IndexStream();
}

using (stream)
Expand All @@ -146,14 +152,15 @@ private async Task RespondWithFile(HttpContext context, string fileName)
template = await reader.ReadToEndAsync(cancellationToken);
}

var content = new StringBuilder(template);
var arguments = GetIndexArguments();

foreach (var entry in GetIndexArguments())
{
content.Replace(entry.Key, entry.Value);
}
// Single pass over the original template: replacement values are never re-scanned for
// further placeholder matches, so a value that happens to look like another placeholder
// token cannot be substituted a second time.
var text = IndexArgumentPattern().Replace(
template,
(match) => arguments.TryGetValue(match.Value, out var value) ? value : match.Value);

var text = content.ToString();
var etag = GetETag(text);

var ifNoneMatch = context.Request.Headers.IfNoneMatch;
Expand Down Expand Up @@ -197,19 +204,22 @@ static string GetETag(string text)
private Dictionary<string, string> GetIndexArguments()
{
string configObject = null;
string specUrl = null;

if (_jsonSerializerOptions is null)
{
configObject = JsonSerializer.Serialize(_options.ConfigObject, ReDocOptionsJsonContext.Default.ConfigObject);
specUrl = JsonSerializer.Serialize(_options.SpecUrl ?? string.Empty, ReDocOptionsJsonContext.Default.String);
}

configObject ??= JsonSerializer.Serialize(_options.ConfigObject, _jsonSerializerOptions);
specUrl ??= JsonSerializer.Serialize(_options.SpecUrl ?? string.Empty, _jsonSerializerOptions);

return new Dictionary<string, string>()
{
{ "%(DocumentTitle)", _options.DocumentTitle },
{ "%(DocumentTitle)", System.Net.WebUtility.HtmlEncode(_options.DocumentTitle) },
{ "%(HeadContent)", _options.HeadContent },
{ "%(SpecUrl)", _options.SpecUrl },
{ "%(SpecUrl)", specUrl },
{ "%(ConfigObject)", configObject },
};
}
Expand Down
2 changes: 1 addition & 1 deletion src/Swashbuckle.AspNetCore.ReDoc/index.js
Original file line number Diff line number Diff line change
@@ -1 +1 @@
Redoc.init('%(SpecUrl)', JSON.parse('%(ConfigObject)'), document.getElementById('redoc-container'));
Redoc.init(%(SpecUrl), %(ConfigObject), document.getElementById('redoc-container'));
Loading
Loading