diff --git a/.gitignore b/.gitignore
index 5b53584003..0941846d76 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,4 +1,4 @@
-.dotnet/
+.dotnet/
.DS_Store
.vs/
.idea*
diff --git a/Directory.Packages.props b/Directory.Packages.props
index b4d0770ee0..4f1d72c04d 100644
--- a/Directory.Packages.props
+++ b/Directory.Packages.props
@@ -1,4 +1,4 @@
-
+
diff --git a/src/Shared/CompressedEmbeddedFileResponder.cs b/src/Shared/CompressedEmbeddedFileResponder.cs
new file mode 100644
index 0000000000..152db8a181
--- /dev/null
+++ b/src/Shared/CompressedEmbeddedFileResponder.cs
@@ -0,0 +1,159 @@
+#nullable enable
+
+using System.Collections.Frozen;
+using System.IO.Compression;
+using System.Reflection;
+using System.Security.Cryptography;
+using Microsoft.AspNetCore.Http;
+using Microsoft.AspNetCore.StaticFiles;
+using Microsoft.Extensions.Primitives;
+using Microsoft.Net.Http.Headers;
+
+namespace Swashbuckle.AspNetCore;
+
+internal class CompressedEmbeddedFileResponder
+{
+ private readonly Assembly _assembly;
+
+ private readonly StringValues _cacheControlHeaderValue;
+
+ private readonly FileExtensionContentTypeProvider _contentTypeProvider = new();
+
+ private readonly string _pathPrefix;
+
+ private readonly FrozenDictionary _resourceMap;
+
+ public CompressedEmbeddedFileResponder(Assembly assembly, string resourceNamePrefix, string pathPrefix, TimeSpan? cacheLifetime)
+ {
+ _assembly = assembly ?? throw new ArgumentNullException(nameof(assembly));
+ _pathPrefix = pathPrefix.TrimEnd('/');
+ _cacheControlHeaderValue = GetCacheControlHeaderValue(cacheLifetime);
+
+ var resourceMap = assembly.GetManifestResourceNames()
+ .Where(name => name.StartsWith(resourceNamePrefix, StringComparison.Ordinal))
+ .ToDictionary(name => name.Substring(resourceNamePrefix.Length), name => new ResourceIndexCache(name), StringComparer.Ordinal);
+
+ _resourceMap = resourceMap.ToFrozenDictionary();
+ }
+
+ public async Task TryRespondWithFileAsync(HttpContext httpContext)
+ {
+ var path = httpContext.Request.Path.Value?.ToString() ?? string.Empty;
+ if (!path.StartsWith(_pathPrefix, StringComparison.OrdinalIgnoreCase))
+ {
+ return false;
+ }
+
+ path = path.Substring(_pathPrefix.Length).Replace('/', '.');
+
+ if (!_resourceMap.TryGetValue(path, out var resourceIndexCache))
+ {
+ return false;
+ }
+
+ var contentType = GetContentType(resourceIndexCache);
+ var (etag, Length) = GetDecompressContentETag(resourceIndexCache);
+
+ var responseHeaders = httpContext.Response.Headers;
+ var ifNoneMatch = httpContext.Request.Headers.IfNoneMatch.ToString();
+ if (ifNoneMatch == etag)
+ {
+ httpContext.Response.StatusCode = StatusCodes.Status304NotModified;
+ return true;
+ }
+
+ var responseWithGZip = httpContext.IsGZipAccepted();
+ if (responseWithGZip)
+ {
+ responseHeaders.ContentEncoding = "gzip";
+ }
+
+ responseHeaders.ContentType = contentType;
+ responseHeaders.ETag = etag;
+ responseHeaders.CacheControl = _cacheControlHeaderValue;
+
+ using var stream = OpenResourceStream(resourceIndexCache);
+ if (responseWithGZip)
+ {
+ responseHeaders.ContentLength = stream.Length;
+ await stream.CopyToAsync(httpContext.Response.Body, httpContext.RequestAborted);
+ }
+ else
+ {
+ responseHeaders.ContentLength = Length;
+ using var gzipStream = new GZipStream(stream, CompressionMode.Decompress);
+ await gzipStream.CopyToAsync(httpContext.Response.Body, httpContext.RequestAborted);
+ }
+
+ return true;
+ }
+
+ private static string GetCacheControlHeaderValue(TimeSpan? cacheLifetime)
+ {
+ if (cacheLifetime is { } maxAge)
+ {
+ return new CacheControlHeaderValue()
+ {
+ MaxAge = maxAge,
+ Private = true,
+ }.ToString();
+ }
+ else
+ {
+ return new CacheControlHeaderValue()
+ {
+ NoCache = true,
+ NoStore = true,
+ }.ToString();
+ }
+ }
+
+ private string GetContentType(ResourceIndexCache resourceIndexCache)
+ {
+ return resourceIndexCache.ContentType
+ ?? (_contentTypeProvider.TryGetContentType(resourceIndexCache.ResourceName, out var contentTypeValue)
+ ? contentTypeValue
+ : "application/octet-stream");
+ }
+
+ private (string ETag, long DecompressContentLength) GetDecompressContentETag(ResourceIndexCache resourceIndexCache)
+ {
+ if (resourceIndexCache.ETag != null
+ && resourceIndexCache.DecompressContentLength != null)
+ {
+ return (resourceIndexCache.ETag, resourceIndexCache.DecompressContentLength.Value);
+ }
+
+ using var stream = OpenResourceStream(resourceIndexCache);
+
+ using var memoryStream = new MemoryStream((int)stream.Length * 2);
+ using var gzipStream = new GZipStream(stream, CompressionMode.Decompress);
+ gzipStream.CopyTo(memoryStream);
+ memoryStream.Seek(0, SeekOrigin.Begin);
+
+ resourceIndexCache.DecompressContentLength = memoryStream.Length;
+
+ var hashData = SHA1.HashData(memoryStream);
+
+ resourceIndexCache.ETag = $"\"{Convert.ToBase64String(hashData)}\"";
+
+ return (resourceIndexCache.ETag, resourceIndexCache.DecompressContentLength.Value);
+ }
+
+ private Stream OpenResourceStream(ResourceIndexCache resourceIndexCache)
+ {
+ // Actually, since the name comes from GetManifestResourceNames(), the content can definitely be obtained
+ return _assembly.GetManifestResourceStream(resourceIndexCache.ResourceName)!;
+ }
+
+ private sealed class ResourceIndexCache(string resourceName)
+ {
+ public string? ContentType { get; set; }
+
+ public long? DecompressContentLength { get; set; }
+
+ public string? ETag { get; set; }
+
+ public string ResourceName { get; } = resourceName;
+ }
+}
diff --git a/src/Shared/HttpContextAcceptEncodingCheckExtensions.cs b/src/Shared/HttpContextAcceptEncodingCheckExtensions.cs
new file mode 100644
index 0000000000..1e5979aa4a
--- /dev/null
+++ b/src/Shared/HttpContextAcceptEncodingCheckExtensions.cs
@@ -0,0 +1,24 @@
+#nullable enable
+
+using Microsoft.AspNetCore.Http;
+
+namespace Swashbuckle.AspNetCore;
+
+internal static class HttpContextAcceptEncodingCheckExtensions
+{
+ public static bool IsGZipAccepted(this HttpContext httpContext)
+ {
+ var acceptEncoding = httpContext.Request.Headers.AcceptEncoding;
+
+ for (var index = 0; index < acceptEncoding.Count; index++)
+ {
+ var stringValue = acceptEncoding[index].AsSpan();
+ if (stringValue.Contains("gzip", StringComparison.OrdinalIgnoreCase))
+ {
+ return true;
+ }
+ }
+
+ return false;
+ }
+}
diff --git a/src/Swashbuckle.AspNetCore.ReDoc/ReDocMiddleware.cs b/src/Swashbuckle.AspNetCore.ReDoc/ReDocMiddleware.cs
index a632815d71..1aeb8a5c3c 100644
--- a/src/Swashbuckle.AspNetCore.ReDoc/ReDocMiddleware.cs
+++ b/src/Swashbuckle.AspNetCore.ReDoc/ReDocMiddleware.cs
@@ -1,16 +1,10 @@
using System.Diagnostics.CodeAnalysis;
using System.Reflection;
+using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using System.Text.RegularExpressions;
-using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
-using Microsoft.AspNetCore.StaticFiles;
-using Microsoft.Extensions.FileProviders;
-using Microsoft.AspNetCore.Hosting;
-using Microsoft.Extensions.Logging;
-using Microsoft.Extensions.Options;
-using System.Security.Cryptography;
namespace Swashbuckle.AspNetCore.ReDoc;
@@ -20,24 +14,26 @@ internal sealed class ReDocMiddleware
private static readonly string ReDocVersion = GetReDocVersion();
+ private readonly RequestDelegate _next;
private readonly ReDocOptions _options;
- private readonly StaticFileMiddleware _staticFileMiddleware;
private readonly JsonSerializerOptions _jsonSerializerOptions;
+ private readonly CompressedEmbeddedFileResponder _compressedEmbeddedFileResponder;
+
public ReDocMiddleware(
RequestDelegate next,
- IWebHostEnvironment hostingEnv,
- ILoggerFactory loggerFactory,
ReDocOptions options)
{
+ _next = next ?? throw new ArgumentNullException(nameof(next));
_options = options ?? new ReDocOptions();
- _staticFileMiddleware = CreateStaticFileMiddleware(next, hostingEnv, loggerFactory, options);
-
if (options.JsonSerializerOptions != null)
{
_jsonSerializerOptions = options.JsonSerializerOptions;
}
+
+ var pathPrefix = options.RoutePrefix.StartsWith('/') ? options.RoutePrefix : $"/{options.RoutePrefix}";
+ _compressedEmbeddedFileResponder = new(typeof(ReDocMiddleware).Assembly, EmbeddedFileNamespace, pathPrefix, _options.CacheLifetime);
}
public async Task Invoke(HttpContext httpContext)
@@ -70,23 +66,10 @@ public async Task Invoke(HttpContext httpContext)
}
}
- await _staticFileMiddleware.Invoke(httpContext);
- }
-
- private static StaticFileMiddleware CreateStaticFileMiddleware(
- RequestDelegate next,
- IWebHostEnvironment hostingEnv,
- ILoggerFactory loggerFactory,
- ReDocOptions options)
- {
- var staticFileOptions = new StaticFileOptions
+ if (!await _compressedEmbeddedFileResponder.TryRespondWithFileAsync(httpContext))
{
- RequestPath = string.IsNullOrEmpty(options.RoutePrefix) ? string.Empty : $"/{options.RoutePrefix}",
- FileProvider = new EmbeddedFileProvider(typeof(ReDocMiddleware).Assembly, EmbeddedFileNamespace),
- OnPrepareResponse = (context) => SetCacheHeaders(context.Context.Response, options),
- };
-
- return new StaticFileMiddleware(next, hostingEnv, Options.Create(staticFileOptions), loggerFactory);
+ await _next(httpContext);
+ }
}
private static string GetReDocVersion()
diff --git a/src/Swashbuckle.AspNetCore.ReDoc/Swashbuckle.AspNetCore.ReDoc.csproj b/src/Swashbuckle.AspNetCore.ReDoc/Swashbuckle.AspNetCore.ReDoc.csproj
index 345c45ab8f..026f5889e5 100644
--- a/src/Swashbuckle.AspNetCore.ReDoc/Swashbuckle.AspNetCore.ReDoc.csproj
+++ b/src/Swashbuckle.AspNetCore.ReDoc/Swashbuckle.AspNetCore.ReDoc.csproj
@@ -22,7 +22,6 @@
-
@@ -42,6 +41,11 @@
+
+
+
+
+
@@ -73,4 +77,27 @@
+
+
+ <_SdkTasksTFM Condition=" '$(MSBuildRuntimeType)' == 'Core'">net9.0
+ <_SdkTasksTFM Condition=" '$(MSBuildRuntimeType)' != 'Core'">net472
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/Swashbuckle.AspNetCore.SwaggerUI/SwaggerUIMiddleware.cs b/src/Swashbuckle.AspNetCore.SwaggerUI/SwaggerUIMiddleware.cs
index 1ebb7edcd5..805118149c 100644
--- a/src/Swashbuckle.AspNetCore.SwaggerUI/SwaggerUIMiddleware.cs
+++ b/src/Swashbuckle.AspNetCore.SwaggerUI/SwaggerUIMiddleware.cs
@@ -1,16 +1,10 @@
using System.Diagnostics.CodeAnalysis;
+using System.Reflection;
+using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using System.Text.RegularExpressions;
-using Microsoft.AspNetCore.Builder;
-using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Http;
-using Microsoft.AspNetCore.StaticFiles;
-using Microsoft.Extensions.FileProviders;
-using Microsoft.Extensions.Logging;
-using Microsoft.Extensions.Options;
-using System.Reflection;
-using System.Security.Cryptography;
namespace Swashbuckle.AspNetCore.SwaggerUI;
@@ -20,24 +14,26 @@ internal sealed partial class SwaggerUIMiddleware
private static readonly string SwaggerUIVersion = GetSwaggerUIVersion();
+ private readonly RequestDelegate _next;
private readonly SwaggerUIOptions _options;
- private readonly StaticFileMiddleware _staticFileMiddleware;
private readonly JsonSerializerOptions _jsonSerializerOptions;
+ private readonly CompressedEmbeddedFileResponder _compressedEmbeddedFileResponder;
+
public SwaggerUIMiddleware(
RequestDelegate next,
- IWebHostEnvironment hostingEnv,
- ILoggerFactory loggerFactory,
SwaggerUIOptions options)
{
+ _next = next ?? throw new ArgumentNullException(nameof(next));
_options = options ?? new SwaggerUIOptions();
- _staticFileMiddleware = CreateStaticFileMiddleware(next, hostingEnv, loggerFactory, options);
-
if (options.JsonSerializerOptions != null)
{
_jsonSerializerOptions = options.JsonSerializerOptions;
}
+
+ var pathPrefix = options.RoutePrefix.StartsWith('/') ? options.RoutePrefix : $"/{options.RoutePrefix}";
+ _compressedEmbeddedFileResponder = new(typeof(SwaggerUIMiddleware).Assembly, EmbeddedFileNamespace, pathPrefix, _options.CacheLifetime);
}
public async Task Invoke(HttpContext httpContext)
@@ -77,23 +73,10 @@ public async Task Invoke(HttpContext httpContext)
}
}
- await _staticFileMiddleware.Invoke(httpContext);
- }
-
- private static StaticFileMiddleware CreateStaticFileMiddleware(
- RequestDelegate next,
- IWebHostEnvironment hostingEnv,
- ILoggerFactory loggerFactory,
- SwaggerUIOptions options)
- {
- var staticFileOptions = new StaticFileOptions
+ if (!await _compressedEmbeddedFileResponder.TryRespondWithFileAsync(httpContext))
{
- RequestPath = string.IsNullOrEmpty(options.RoutePrefix) ? string.Empty : $"/{options.RoutePrefix}",
- FileProvider = new EmbeddedFileProvider(typeof(SwaggerUIMiddleware).Assembly, EmbeddedFileNamespace),
- OnPrepareResponse = (context) => SetCacheHeaders(context.Context.Response, options),
- };
-
- return new StaticFileMiddleware(next, hostingEnv, Options.Create(staticFileOptions), loggerFactory);
+ await _next(httpContext);
+ }
}
private static string GetSwaggerUIVersion()
diff --git a/src/Swashbuckle.AspNetCore.SwaggerUI/Swashbuckle.AspNetCore.SwaggerUI.csproj b/src/Swashbuckle.AspNetCore.SwaggerUI/Swashbuckle.AspNetCore.SwaggerUI.csproj
index 0e48416301..f5e8f3045c 100644
--- a/src/Swashbuckle.AspNetCore.SwaggerUI/Swashbuckle.AspNetCore.SwaggerUI.csproj
+++ b/src/Swashbuckle.AspNetCore.SwaggerUI/Swashbuckle.AspNetCore.SwaggerUI.csproj
@@ -14,7 +14,6 @@
-
@@ -35,6 +34,10 @@
+
+
+
+
@@ -67,4 +70,27 @@
+
+
+ <_SdkTasksTFM Condition=" '$(MSBuildRuntimeType)' == 'Core'">net9.0
+ <_SdkTasksTFM Condition=" '$(MSBuildRuntimeType)' != 'Core'">net472
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/test/Swashbuckle.AspNetCore.IntegrationTests/ReDocIntegrationTests.cs b/test/Swashbuckle.AspNetCore.IntegrationTests/ReDocIntegrationTests.cs
index e5a5b2cb13..9ec838910a 100644
--- a/test/Swashbuckle.AspNetCore.IntegrationTests/ReDocIntegrationTests.cs
+++ b/test/Swashbuckle.AspNetCore.IntegrationTests/ReDocIntegrationTests.cs
@@ -1,4 +1,6 @@
-using System.Net;
+using System.IO.Compression;
+using System.Net;
+using System.Security.Cryptography;
using Microsoft.AspNetCore.Builder;
using Swashbuckle.AspNetCore.ReDoc;
using ReDocApp = ReDoc;
@@ -32,13 +34,13 @@ public async Task IndexUrl_ReturnsEmbeddedVersionOfTheRedocUI()
AssertResource(htmlResponse);
AssertResource(cssResponse);
- AssertResource(jsResponse);
+ AssertResource(jsResponse, weakETag: false);
- static void AssertResource(HttpResponseMessage response)
+ static void AssertResource(HttpResponseMessage response, bool weakETag = true)
{
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
Assert.NotNull(response.Headers.ETag);
- Assert.True(response.Headers.ETag.IsWeak);
+ Assert.Equal(weakETag, response.Headers.ETag.IsWeak);
Assert.NotEmpty(response.Headers.ETag.Tag);
Assert.NotNull(response.Headers.CacheControl);
Assert.True(response.Headers.CacheControl.Private);
@@ -162,4 +164,41 @@ public void ReDocOptions_Extensions()
Assert.True(options.ConfigObject.SortPropsAlphabetically);
Assert.True(options.ConfigObject.UntrustedSpec);
}
+
+ [Fact]
+ public async Task ReDocMiddleware_Returns_ExpectedAssetContents()
+ {
+ var site = new TestSite(typeof(ReDocApp.Startup), outputHelper);
+ using var client = site.BuildClient();
+
+ using var htmlResponse = await client.GetAsync("/Api-Docs/redoc.standalone.js", TestContext.Current.CancellationToken);
+ Assert.Equal(HttpStatusCode.OK, htmlResponse.StatusCode);
+
+ using var stream = await htmlResponse.Content.ReadAsStreamAsync(TestContext.Current.CancellationToken);
+ using var rawFileStream = typeof(ReDocIntegrationTests).Assembly.GetManifestResourceStream("Swashbuckle.AspNetCore.IntegrationTests.Embedded.ReDoc.redoc.standalone.js");
+
+ Assert.NotNull(rawFileStream);
+ Assert.Equal(SHA1.HashData(rawFileStream), SHA1.HashData(stream));
+ }
+
+ [Fact]
+ public async Task ReDocMiddleware_Returns_ExpectedAssetContents_GZipDirectly()
+ {
+ var site = new TestSite(typeof(ReDocApp.Startup), outputHelper);
+ using var client = site.BuildClient();
+
+ using var requestMessage = new HttpRequestMessage(HttpMethod.Get, "/Api-Docs/redoc.standalone.js");
+ requestMessage.Headers.AcceptEncoding.Add(new("gzip"));
+
+ using var htmlResponse = await client.SendAsync(requestMessage, TestContext.Current.CancellationToken);
+ Assert.Equal(HttpStatusCode.OK, htmlResponse.StatusCode);
+ Assert.Equal("gzip", htmlResponse.Content.Headers.ContentEncoding.Single());
+
+ using var stream = await htmlResponse.Content.ReadAsStreamAsync(TestContext.Current.CancellationToken);
+ using var gzipStream = new GZipStream(stream, CompressionMode.Decompress);
+ using var rawFileStream = typeof(ReDocIntegrationTests).Assembly.GetManifestResourceStream("Swashbuckle.AspNetCore.IntegrationTests.Embedded.ReDoc.redoc.standalone.js");
+
+ Assert.NotNull(rawFileStream);
+ Assert.Equal(SHA1.HashData(rawFileStream), SHA1.HashData(gzipStream));
+ }
}
diff --git a/test/Swashbuckle.AspNetCore.IntegrationTests/SwaggerUIIntegrationTests.cs b/test/Swashbuckle.AspNetCore.IntegrationTests/SwaggerUIIntegrationTests.cs
index b5c5443d3f..a849941e5a 100644
--- a/test/Swashbuckle.AspNetCore.IntegrationTests/SwaggerUIIntegrationTests.cs
+++ b/test/Swashbuckle.AspNetCore.IntegrationTests/SwaggerUIIntegrationTests.cs
@@ -1,4 +1,6 @@
-using System.Net;
+using System.IO.Compression;
+using System.Net;
+using System.Security.Cryptography;
namespace Swashbuckle.AspNetCore.IntegrationTests;
@@ -40,19 +42,19 @@ public async Task IndexUrl_ReturnsEmbeddedVersionOfTheSwaggerUI(
AssertResource(htmlResponse);
using var jsResponse = await client.GetAsync(swaggerUijsPath, TestContext.Current.CancellationToken);
- AssertResource(jsResponse);
+ AssertResource(jsResponse, weakETag: false);
using var indexCss = await client.GetAsync(indexCssPath, TestContext.Current.CancellationToken);
- AssertResource(indexCss);
+ AssertResource(indexCss, weakETag: false);
using var cssResponse = await client.GetAsync(swaggerUiCssPath, TestContext.Current.CancellationToken);
- AssertResource(cssResponse);
+ AssertResource(cssResponse, weakETag: false);
- static void AssertResource(HttpResponseMessage response)
+ static void AssertResource(HttpResponseMessage response, bool weakETag = true)
{
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
Assert.NotNull(response.Headers.ETag);
- Assert.True(response.Headers.ETag.IsWeak);
+ Assert.Equal(weakETag, response.Headers.ETag.IsWeak);
Assert.NotEmpty(response.Headers.ETag.Tag);
Assert.NotNull(response.Headers.CacheControl);
Assert.True(response.Headers.CacheControl.Private);
@@ -190,4 +192,91 @@ public async Task IndexUrl_Returns_ExpectedAssetPaths(
Assert.Contains($"