Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
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
2 changes: 1 addition & 1 deletion .gitignore
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
.dotnet/
.dotnet/
.DS_Store
.vs/
.idea*
Expand Down
2 changes: 1 addition & 1 deletion Directory.Packages.props
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
<Project>
<Project>
<ItemGroup>
<PackageVersion Include="Autofac.Extensions.DependencyInjection" Version="10.0.0" />
<PackageVersion Include="BenchmarkDotNet" Version="0.15.1" />
Expand Down
159 changes: 159 additions & 0 deletions src/Shared/CompressedEmbeddedFileResponder.cs
Original file line number Diff line number Diff line change
@@ -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<string, ResourceIndexCache> _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<bool> 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();

Check warning on line 107 in src/Shared/CompressedEmbeddedFileResponder.cs

View check run for this annotation

Codecov / codecov/patch

src/Shared/CompressedEmbeddedFileResponder.cs#L103-L107

Added lines #L103 - L107 were not covered by tests
}
}

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;
}
}
24 changes: 24 additions & 0 deletions src/Shared/HttpContextAcceptEncodingCheckExtensions.cs
Original file line number Diff line number Diff line change
@@ -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;
}
}
39 changes: 11 additions & 28 deletions src/Swashbuckle.AspNetCore.ReDoc/ReDocMiddleware.cs
Original file line number Diff line number Diff line change
@@ -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;

Expand All @@ -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)
Expand Down Expand Up @@ -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()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,6 @@
<EmbeddedResource Include="index.css" />
<EmbeddedResource Include="index.html" />
<EmbeddedResource Include="index.js" />
<EmbeddedResource Include="node_modules/redoc/bundles/redoc.standalone.js" />
</ItemGroup>

<ItemGroup>
Expand All @@ -42,6 +41,11 @@
<AdditionalFiles Include="PublicAPI\$(_TargetFrameworkIdentifier)\PublicAPI.Unshipped.txt" />
</ItemGroup>

<ItemGroup>
<Compile Include="..\Shared\CompressedEmbeddedFileResponder.cs" Link="CompressedEmbeddedFileResponder.cs" />
<Compile Include="..\Shared\HttpContextAcceptEncodingCheckExtensions.cs" Link="HttpContextAcceptEncodingCheckExtensions.cs" />
</ItemGroup>

<Target Name="NpmInstall" BeforeTargets="DispatchToInnerBuilds" Condition=" '$(CI)' != '' OR !Exists('$(MSBuildThisFileDirectory)\node_modules') ">
<Exec Command="npm install" ContinueOnError="true">
<Output TaskParameter="ExitCode" PropertyName="ErrorCode" />
Expand Down Expand Up @@ -73,4 +77,27 @@
</ItemGroup>
</Target>

<!--embed compressed file using sdk task-->
<PropertyGroup>
<_SdkTasksTFM Condition=" '$(MSBuildRuntimeType)' == 'Core'">net9.0</_SdkTasksTFM>
<_SdkTasksTFM Condition=" '$(MSBuildRuntimeType)' != 'Core'">net472</_SdkTasksTFM>
</PropertyGroup>

<UsingTask TaskName="Microsoft.NET.Sdk.BlazorWebAssembly.GzipCompress"
AssemblyFile="$(MicrosoftNETBuildTasksDirectoryRoot)../../Microsoft.NET.Sdk.BlazorWebAssembly/tools/$(_SdkTasksTFM)/Microsoft.NET.Sdk.BlazorWebAssembly.Tasks.dll" />

<ItemGroup>
<CompressFiles Include="node_modules/redoc/bundles/redoc.standalone.js" />
</ItemGroup>

<Target Name="_CompressAssets" BeforeTargets="CheckForDuplicateItems">
<GZipCompress FilesToCompress="@(CompressFiles)" OutputDirectory="$(IntermediateOutputPath)CompressedAssets">
<Output TaskParameter="CompressedFiles" ItemName="_CompressedAssetsFile" />
</GZipCompress>

<ItemGroup>
<EmbeddedResource Include="@(_CompressedAssetsFile)" />
<EmbeddedResource Condition="'%(EmbeddedResource.OriginalItemSpec)' != ''" Link="%(EmbeddedResource.OriginalItemSpec)" />
</ItemGroup>
</Target>
</Project>
41 changes: 12 additions & 29 deletions src/Swashbuckle.AspNetCore.SwaggerUI/SwaggerUIMiddleware.cs
Original file line number Diff line number Diff line change
@@ -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;

Expand All @@ -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)
Expand Down Expand Up @@ -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()
Expand Down
Loading