Skip to content

Commit 80f1550

Browse files
Add cache headers for ReDoc and SwaggerUI (#3341)
- Embed the versions of redoc and swagger-ui-dist into their respective assemblies as `[AssemblyMetdata]` attributes. - Remove redundant `GetTypeInfo()` calls. - Add cache readers to ReDoc and SwaggerUI static files. - Bump version to 8.1.0.
1 parent 37a50bc commit 80f1550

16 files changed

Lines changed: 288 additions & 73 deletions

File tree

Directory.Build.props

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,7 @@
3939
<SymbolPackageFormat>snupkg</SymbolPackageFormat>
4040
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
4141
<UseArtifactsOutput>true</UseArtifactsOutput>
42-
<VersionPrefix>8.0.1</VersionPrefix>
42+
<VersionPrefix>8.1.0</VersionPrefix>
4343
<WarnOnPackingNonPackableProject>false</WarnOnPackingNonPackableProject>
4444
</PropertyGroup>
4545
<PropertyGroup Condition=" '$(GITHUB_ACTIONS)' != '' AND '$(DEPENDABOT_JOB_ID)' == '' ">

src/Swashbuckle.AspNetCore.Cli/Program.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -247,7 +247,7 @@ private static string PrepareCommandLine(string[] args, IDictionary<string, stri
247247
"exec --depsfile {0} --runtimeconfig {1} {2} _{3} {4}", // note the underscore prepended to the command name
248248
EscapePath(depsFile),
249249
EscapePath(runtimeConfig),
250-
EscapePath(typeof(Program).GetTypeInfo().Assembly.Location),
250+
EscapePath(typeof(Program).Assembly.Location),
251251
commandName,
252252
string.Join(" ", subProcessArguments.Select(EscapePath))
253253
);
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
Swashbuckle.AspNetCore.ReDoc.ReDocOptions.CacheLifetime.get -> System.TimeSpan?
2+
Swashbuckle.AspNetCore.ReDoc.ReDocOptions.CacheLifetime.set -> void

src/Swashbuckle.AspNetCore.ReDoc/ReDocMiddleware.cs

Lines changed: 59 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
using Microsoft.Extensions.FileProviders;
99
using Microsoft.Extensions.Logging;
1010
using Microsoft.Extensions.Options;
11+
using System.Security.Cryptography;
1112

1213
#if NET
1314
using System.Diagnostics.CodeAnalysis;
@@ -23,6 +24,8 @@ internal sealed class ReDocMiddleware
2324
{
2425
private const string EmbeddedFileNamespace = "Swashbuckle.AspNetCore.ReDoc.node_modules.redoc.bundles";
2526

27+
private static readonly string ReDocVersion = GetReDocVersion();
28+
2629
private readonly ReDocOptions _options;
2730
private readonly StaticFileMiddleware _staticFileMiddleware;
2831
private readonly JsonSerializerOptions _jsonSerializerOptions;
@@ -100,12 +103,47 @@ private static StaticFileMiddleware CreateStaticFileMiddleware(
100103
var staticFileOptions = new StaticFileOptions
101104
{
102105
RequestPath = string.IsNullOrEmpty(options.RoutePrefix) ? string.Empty : $"/{options.RoutePrefix}",
103-
FileProvider = new EmbeddedFileProvider(typeof(ReDocMiddleware).GetTypeInfo().Assembly, EmbeddedFileNamespace),
106+
FileProvider = new EmbeddedFileProvider(typeof(ReDocMiddleware).Assembly, EmbeddedFileNamespace),
107+
OnPrepareResponse = (context) => SetCacheHeaders(context.Context.Response, options),
104108
};
105109

106110
return new StaticFileMiddleware(next, hostingEnv, Options.Create(staticFileOptions), loggerFactory);
107111
}
108112

113+
private static string GetReDocVersion()
114+
{
115+
return typeof(ReDocMiddleware).Assembly
116+
.GetCustomAttributes<AssemblyMetadataAttribute>()
117+
.Where((p) => p.Key is "ReDocVersion")
118+
.Select((p) => p.Value)
119+
.DefaultIfEmpty(string.Empty)
120+
.FirstOrDefault();
121+
}
122+
123+
private static void SetCacheHeaders(HttpResponse response, ReDocOptions options, string etag = null)
124+
{
125+
var headers = response.GetTypedHeaders();
126+
127+
if (options.CacheLifetime is { } maxAge)
128+
{
129+
headers.CacheControl = new()
130+
{
131+
MaxAge = maxAge,
132+
Private = true,
133+
};
134+
}
135+
else
136+
{
137+
headers.CacheControl = new()
138+
{
139+
NoCache = true,
140+
NoStore = true,
141+
};
142+
}
143+
144+
headers.ETag = new($"\"{etag ?? ReDocVersion}\"", isWeak: true);
145+
}
146+
109147
private static void RespondWithRedirect(HttpResponse response, string location)
110148
{
111149
response.StatusCode = StatusCodes.Status301MovedPermanently;
@@ -147,10 +185,29 @@ private async Task RespondWithFile(HttpResponse response, string fileName)
147185
content.Replace(entry.Key, entry.Value);
148186
}
149187

150-
await response.WriteAsync(content.ToString(), Encoding.UTF8);
188+
var text = content.ToString();
189+
var etag = HashText(text);
190+
191+
SetCacheHeaders(response, _options, etag);
192+
193+
await response.WriteAsync(text, Encoding.UTF8);
151194
}
152195
}
153196

197+
private static string HashText(string text)
198+
{
199+
var buffer = Encoding.UTF8.GetBytes(text);
200+
201+
#if NET
202+
var hash = SHA1.HashData(buffer);
203+
#else
204+
using var sha = SHA1.Create();
205+
var hash = sha.ComputeHash(buffer);
206+
#endif
207+
208+
return Convert.ToBase64String(hash);
209+
}
210+
154211
#if NET
155212
[UnconditionalSuppressMessage(
156213
"AOT",

src/Swashbuckle.AspNetCore.ReDoc/ReDocOptions.cs

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,4 +38,12 @@ public class ReDocOptions
3838
/// Gets or sets the optional <see cref="System.Text.Json.JsonSerializerOptions"/> to use.
3939
/// </summary>
4040
public JsonSerializerOptions JsonSerializerOptions { get; set; }
41+
42+
/// <summary>
43+
/// Gets or sets the cache lifetime to use for the ReDoc files, if any.
44+
/// </summary>
45+
/// <remarks>
46+
/// The default value is 7 days.
47+
/// </remarks>
48+
public TimeSpan? CacheLifetime { get; set; } = TimeSpan.FromDays(7);
4149
}
Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,10 @@
1-
using System.Reflection;
2-
3-
namespace Swashbuckle.AspNetCore.ReDoc;
1+
namespace Swashbuckle.AspNetCore.ReDoc;
42

53
internal static class ResourceHelper
64
{
75
public static Stream GetEmbeddedResource(string fileName)
86
{
9-
return typeof(ResourceHelper).GetTypeInfo().Assembly
7+
return typeof(ResourceHelper).Assembly
108
.GetManifestResourceStream($"Swashbuckle.AspNetCore.ReDoc.{fileName}");
119
}
1210
}

src/Swashbuckle.AspNetCore.ReDoc/Swashbuckle.AspNetCore.ReDoc.csproj

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,4 +58,28 @@
5858
<Error Condition="'$(ErrorCode)' != '0'" Text="Node.js/npm is required to build this project." />
5959
</Target>
6060

61+
<!-- Embed the ReDoc version into the [AssemblyMetadata] attributes -->
62+
<UsingTask TaskName="GetReDocVersion" TaskFactory="RoslynCodeTaskFactory" AssemblyFile="$(MSBuildToolsPath)\Microsoft.Build.Tasks.Core.dll">
63+
<ParameterGroup>
64+
<PackageJsonPath ParameterType="System.String" Required="true" />
65+
<ReDocVersion ParameterType="System.String" Output="true" />
66+
</ParameterGroup>
67+
<Task>
68+
<Code Type="Fragment" Language="cs"><![CDATA[
69+
var packageJson = System.IO.File.ReadAllText(PackageJsonPath);
70+
var pattern = @"""redoc"":\s*""([^""]+)""";
71+
var match = System.Text.RegularExpressions.Regex.Match(packageJson, pattern);
72+
ReDocVersion = match.Groups[1].Value;
73+
]]></Code>
74+
</Task>
75+
</UsingTask>
76+
<Target Name="AddCustomAssemblyMetadata" BeforeTargets="GetAssemblyAttributes">
77+
<GetReDocVersion PackageJsonPath="$(MSBuildThisFileDirectory)\package.json">
78+
<Output TaskParameter="ReDocVersion" PropertyName="ReDocVersion" />
79+
</GetReDocVersion>
80+
<ItemGroup>
81+
<AssemblyMetadata Include="ReDocVersion" Value="$(ReDocVersion)" />
82+
</ItemGroup>
83+
</Target>
84+
6185
</Project>
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
Swashbuckle.AspNetCore.SwaggerUI.SwaggerUIOptions.CacheLifetime.get -> System.TimeSpan?
2+
Swashbuckle.AspNetCore.SwaggerUI.SwaggerUIOptions.CacheLifetime.set -> void
Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,10 @@
1-
using System.Reflection;
2-
3-
namespace Swashbuckle.AspNetCore.SwaggerUI;
1+
namespace Swashbuckle.AspNetCore.SwaggerUI;
42

53
internal static class ResourceHelper
64
{
75
public static Stream GetEmbeddedResource(string fileName)
86
{
9-
return typeof(ResourceHelper).GetTypeInfo().Assembly
7+
return typeof(ResourceHelper).Assembly
108
.GetManifestResourceStream($"Swashbuckle.AspNetCore.SwaggerUI.{fileName}");
119
}
1210
}

src/Swashbuckle.AspNetCore.SwaggerUI/SwaggerUIMiddleware.cs

Lines changed: 66 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,20 @@
1-
using System.Diagnostics.CodeAnalysis;
2-
using System.Reflection;
3-
using System.Text;
1+
using System.Text;
42
using System.Text.Json;
5-
using System.Text.Json.Serialization;
63
using System.Text.RegularExpressions;
74
using Microsoft.AspNetCore.Builder;
8-
using Microsoft.AspNetCore.Hosting;
95
using Microsoft.AspNetCore.Http;
106
using Microsoft.AspNetCore.StaticFiles;
117
using Microsoft.Extensions.FileProviders;
128
using Microsoft.Extensions.Logging;
139
using Microsoft.Extensions.Options;
10+
using System.Reflection;
11+
using System.Security.Cryptography;
1412

15-
#if !NET
13+
#if NET
14+
using System.Diagnostics.CodeAnalysis;
15+
using Microsoft.AspNetCore.Hosting;
16+
#else
17+
using System.Text.Json.Serialization;
1618
using IWebHostEnvironment = Microsoft.AspNetCore.Hosting.IHostingEnvironment;
1719
#endif
1820

@@ -22,6 +24,8 @@ internal sealed partial class SwaggerUIMiddleware
2224
{
2325
private const string EmbeddedFileNamespace = "Swashbuckle.AspNetCore.SwaggerUI.node_modules.swagger_ui_dist";
2426

27+
private static readonly string SwaggerUIVersion = GetSwaggerUIVersion();
28+
2529
private readonly SwaggerUIOptions _options;
2630
private readonly StaticFileMiddleware _staticFileMiddleware;
2731
private readonly JsonSerializerOptions _jsonSerializerOptions;
@@ -106,12 +110,47 @@ private static StaticFileMiddleware CreateStaticFileMiddleware(
106110
var staticFileOptions = new StaticFileOptions
107111
{
108112
RequestPath = string.IsNullOrEmpty(options.RoutePrefix) ? string.Empty : $"/{options.RoutePrefix}",
109-
FileProvider = new EmbeddedFileProvider(typeof(SwaggerUIMiddleware).GetTypeInfo().Assembly, EmbeddedFileNamespace),
113+
FileProvider = new EmbeddedFileProvider(typeof(SwaggerUIMiddleware).Assembly, EmbeddedFileNamespace),
114+
OnPrepareResponse = (context) => SetCacheHeaders(context.Context.Response, options),
110115
};
111116

112117
return new StaticFileMiddleware(next, hostingEnv, Options.Create(staticFileOptions), loggerFactory);
113118
}
114119

120+
private static string GetSwaggerUIVersion()
121+
{
122+
return typeof(SwaggerUIMiddleware).Assembly
123+
.GetCustomAttributes<AssemblyMetadataAttribute>()
124+
.Where((p) => p.Key is "SwaggerUIVersion")
125+
.Select((p) => p.Value)
126+
.DefaultIfEmpty(string.Empty)
127+
.FirstOrDefault();
128+
}
129+
130+
private static void SetCacheHeaders(HttpResponse response, SwaggerUIOptions options, string etag = null)
131+
{
132+
var headers = response.GetTypedHeaders();
133+
134+
if (options.CacheLifetime is { } maxAge)
135+
{
136+
headers.CacheControl = new()
137+
{
138+
MaxAge = maxAge,
139+
Private = true,
140+
};
141+
}
142+
else
143+
{
144+
headers.CacheControl = new()
145+
{
146+
NoCache = true,
147+
NoStore = true,
148+
};
149+
}
150+
151+
headers.ETag = new($"\"{etag ?? SwaggerUIVersion}\"", isWeak: true);
152+
}
153+
115154
private static void RespondWithRedirect(HttpResponse response, string location)
116155
{
117156
response.StatusCode = StatusCodes.Status301MovedPermanently;
@@ -150,10 +189,29 @@ private async Task RespondWithFile(HttpResponse response, string fileName)
150189
content.Replace(entry.Key, entry.Value);
151190
}
152191

153-
await response.WriteAsync(content.ToString(), Encoding.UTF8);
192+
var text = content.ToString();
193+
var etag = HashText(text);
194+
195+
SetCacheHeaders(response, _options, etag);
196+
197+
await response.WriteAsync(text, Encoding.UTF8);
154198
}
155199
}
156200

201+
private static string HashText(string text)
202+
{
203+
var buffer = Encoding.UTF8.GetBytes(text);
204+
205+
#if NET
206+
var hash = SHA1.HashData(buffer);
207+
#else
208+
using var sha = SHA1.Create();
209+
var hash = sha.ComputeHash(buffer);
210+
#endif
211+
212+
return Convert.ToBase64String(hash);
213+
}
214+
157215
#if NET
158216
[UnconditionalSuppressMessage(
159217
"AOT",

0 commit comments

Comments
 (0)