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($"