From 523019857dd68b4cb830abdcc6ccc95887924c82 Mon Sep 17 00:00:00 2001 From: martincostello Date: Mon, 30 Sep 2024 14:07:28 +0100 Subject: [PATCH] Add native AoT support for ReDoc - Add support for using the ReDoc middleware in native AoT applications. - Add missing test coverage for ReDoc extensions. - Fix some code analysis suggestions. - Fix native AoT test app exception on start-up (see #2951). --- .../PublicAPI/PublicAPI.Unshipped.txt | 2 + .../ReDocMiddleware.cs | 60 ++++++++++++---- .../ReDocOptions.cs | 9 +++ .../ReDocOptionsJsonContext.cs | 45 ++++++++++++ .../Swashbuckle.AspNetCore.ReDoc.csproj | 4 ++ .../SwaggerUIMiddleware.cs | 8 ++- .../ReDocIntegrationTests.cs | 71 ++++++++++++++++++- test/WebSites/WebApi.Aot/Program.cs | 6 ++ test/WebSites/WebApi.Aot/WebApi.Aot.csproj | 1 + 9 files changed, 191 insertions(+), 15 deletions(-) create mode 100644 src/Swashbuckle.AspNetCore.ReDoc/ReDocOptionsJsonContext.cs diff --git a/src/Swashbuckle.AspNetCore.ReDoc/PublicAPI/PublicAPI.Unshipped.txt b/src/Swashbuckle.AspNetCore.ReDoc/PublicAPI/PublicAPI.Unshipped.txt index e69de29bb2..153757a7c1 100644 --- a/src/Swashbuckle.AspNetCore.ReDoc/PublicAPI/PublicAPI.Unshipped.txt +++ b/src/Swashbuckle.AspNetCore.ReDoc/PublicAPI/PublicAPI.Unshipped.txt @@ -0,0 +1,2 @@ +Swashbuckle.AspNetCore.ReDoc.ReDocOptions.JsonSerializerOptions.get -> System.Text.Json.JsonSerializerOptions +Swashbuckle.AspNetCore.ReDoc.ReDocOptions.JsonSerializerOptions.set -> void diff --git a/src/Swashbuckle.AspNetCore.ReDoc/ReDocMiddleware.cs b/src/Swashbuckle.AspNetCore.ReDoc/ReDocMiddleware.cs index 5b7a2c5963..bd4ddf044e 100644 --- a/src/Swashbuckle.AspNetCore.ReDoc/ReDocMiddleware.cs +++ b/src/Swashbuckle.AspNetCore.ReDoc/ReDocMiddleware.cs @@ -1,4 +1,5 @@ using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; using System.IO; using System.Linq; using System.Reflection; @@ -39,15 +40,25 @@ public ReDocMiddleware( _staticFileMiddleware = CreateStaticFileMiddleware(next, hostingEnv, loggerFactory, options); - _jsonSerializerOptions = new JsonSerializerOptions(); - -#if NET6_0_OR_GREATER - _jsonSerializerOptions.DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull; + if (options.JsonSerializerOptions != null) + { + _jsonSerializerOptions = options.JsonSerializerOptions; + } +#if !NET6_0_OR_GREATER + else + { + _jsonSerializerOptions = new JsonSerializerOptions() + { +#if NET5_0_OR_GREATER + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, #else - _jsonSerializerOptions.IgnoreNullValues = true; + IgnoreNullValues = true, +#endif + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + Converters = { new JsonStringEnumConverter(JsonNamingPolicy.CamelCase, false) } + }; + } #endif - _jsonSerializerOptions.PropertyNamingPolicy = JsonNamingPolicy.CamelCase; - _jsonSerializerOptions.Converters.Add(new JsonStringEnumConverter(JsonNamingPolicy.CamelCase, false)); } public async Task Invoke(HttpContext httpContext) @@ -82,7 +93,7 @@ public async Task Invoke(HttpContext httpContext) await _staticFileMiddleware.Invoke(httpContext); } - private StaticFileMiddleware CreateStaticFileMiddleware( + private static StaticFileMiddleware CreateStaticFileMiddleware( RequestDelegate next, IWebHostEnvironment hostingEnv, ILoggerFactory loggerFactory, @@ -97,10 +108,14 @@ private StaticFileMiddleware CreateStaticFileMiddleware( return new StaticFileMiddleware(next, hostingEnv, Options.Create(staticFileOptions), loggerFactory); } - private void RespondWithRedirect(HttpResponse response, string location) + private static void RespondWithRedirect(HttpResponse response, string location) { - response.StatusCode = 301; + response.StatusCode = StatusCodes.Status301MovedPermanently; +#if NET6_0_OR_GREATER + response.Headers.Location = location; +#else response.Headers["Location"] = location; +#endif } private async Task RespondWithFile(HttpResponse response, string fileName) @@ -138,14 +153,35 @@ private async Task RespondWithFile(HttpResponse response, string fileName) } } - private IDictionary GetIndexArguments() +#if NET5_0_OR_GREATER + [UnconditionalSuppressMessage( + "AOT", + "IL2026:RequiresUnreferencedCode", + Justification = "Method is only called if the user provides their own custom JsonSerializerOptions.")] + [UnconditionalSuppressMessage( + "AOT", + "IL3050:RequiresDynamicCode", + Justification = "Method is only called if the user provides their own custom JsonSerializerOptions.")] +#endif + private Dictionary GetIndexArguments() { + string configObject = null; + +#if NET6_0_OR_GREATER + if (_jsonSerializerOptions is null) + { + configObject = JsonSerializer.Serialize(_options.ConfigObject, ReDocOptionsJsonContext.Default.ConfigObject); + } +#endif + + configObject ??= JsonSerializer.Serialize(_options.ConfigObject, _jsonSerializerOptions); + return new Dictionary() { { "%(DocumentTitle)", _options.DocumentTitle }, { "%(HeadContent)", _options.HeadContent }, { "%(SpecUrl)", _options.SpecUrl }, - { "%(ConfigObject)", JsonSerializer.Serialize(_options.ConfigObject, _jsonSerializerOptions) } + { "%(ConfigObject)", configObject }, }; } } diff --git a/src/Swashbuckle.AspNetCore.ReDoc/ReDocOptions.cs b/src/Swashbuckle.AspNetCore.ReDoc/ReDocOptions.cs index 1e7b60128c..568c93d853 100644 --- a/src/Swashbuckle.AspNetCore.ReDoc/ReDocOptions.cs +++ b/src/Swashbuckle.AspNetCore.ReDoc/ReDocOptions.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Generic; using System.IO; +using System.Text.Json; using System.Text.Json.Serialization; namespace Swashbuckle.AspNetCore.ReDoc @@ -32,7 +33,15 @@ public class ReDocOptions /// public string SpecUrl { get; set; } = null; + /// + /// Gets or sets the to use. + /// public ConfigObject ConfigObject { get; set; } = new ConfigObject(); + + /// + /// Gets or sets the optional to use. + /// + public JsonSerializerOptions JsonSerializerOptions { get; set; } } public class ConfigObject diff --git a/src/Swashbuckle.AspNetCore.ReDoc/ReDocOptionsJsonContext.cs b/src/Swashbuckle.AspNetCore.ReDoc/ReDocOptionsJsonContext.cs new file mode 100644 index 0000000000..241f7da19f --- /dev/null +++ b/src/Swashbuckle.AspNetCore.ReDoc/ReDocOptionsJsonContext.cs @@ -0,0 +1,45 @@ +#if NET6_0_OR_GREATER +using System; +using System.Text.Json; +using System.Text.Json.Nodes; +using System.Text.Json.Serialization; + +namespace Swashbuckle.AspNetCore.ReDoc; + +[JsonSerializable(typeof(ConfigObject))] +// These primitive types are declared for common types that may be used with ConfigObject.AdditionalItems. See https://github.com/domaindrivendev/Swashbuckle.AspNetCore/issues/2884. +[JsonSerializable(typeof(bool))] +[JsonSerializable(typeof(byte))] +[JsonSerializable(typeof(sbyte))] +[JsonSerializable(typeof(short))] +[JsonSerializable(typeof(ushort))] +[JsonSerializable(typeof(int))] +[JsonSerializable(typeof(int?))] +[JsonSerializable(typeof(uint))] +[JsonSerializable(typeof(long))] +[JsonSerializable(typeof(ulong))] +[JsonSerializable(typeof(float))] +[JsonSerializable(typeof(double))] +[JsonSerializable(typeof(decimal))] +[JsonSerializable(typeof(char))] +[JsonSerializable(typeof(string))] +[JsonSerializable(typeof(DateTime))] +[JsonSerializable(typeof(DateTimeOffset))] +[JsonSerializable(typeof(TimeSpan))] +[JsonSerializable(typeof(JsonArray))] +[JsonSerializable(typeof(JsonObject))] +[JsonSerializable(typeof(JsonDocument))] +#if NET7_0_OR_GREATER +[JsonSerializable(typeof(DateOnly))] +[JsonSerializable(typeof(TimeOnly))] +#endif +#if NET8_0_OR_GREATER +[JsonSerializable(typeof(Half))] +[JsonSerializable(typeof(Int128))] +[JsonSerializable(typeof(UInt128))] +#endif +[JsonSourceGenerationOptions( + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, + PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase)] +internal sealed partial class ReDocOptionsJsonContext : JsonSerializerContext; +#endif diff --git a/src/Swashbuckle.AspNetCore.ReDoc/Swashbuckle.AspNetCore.ReDoc.csproj b/src/Swashbuckle.AspNetCore.ReDoc/Swashbuckle.AspNetCore.ReDoc.csproj index e9357695b5..a5157a26b3 100644 --- a/src/Swashbuckle.AspNetCore.ReDoc/Swashbuckle.AspNetCore.ReDoc.csproj +++ b/src/Swashbuckle.AspNetCore.ReDoc/Swashbuckle.AspNetCore.ReDoc.csproj @@ -10,6 +10,10 @@ true netstandard2.0;netcoreapp3.0;net5.0;net6.0;net7.0;net8.0 + + true + true + diff --git a/src/Swashbuckle.AspNetCore.SwaggerUI/SwaggerUIMiddleware.cs b/src/Swashbuckle.AspNetCore.SwaggerUI/SwaggerUIMiddleware.cs index d89129d8c1..96b95be0a0 100644 --- a/src/Swashbuckle.AspNetCore.SwaggerUI/SwaggerUIMiddleware.cs +++ b/src/Swashbuckle.AspNetCore.SwaggerUI/SwaggerUIMiddleware.cs @@ -22,7 +22,7 @@ namespace Swashbuckle.AspNetCore.SwaggerUI { - public partial class SwaggerUIMiddleware + public class SwaggerUIMiddleware { private const string EmbeddedFileNamespace = "Swashbuckle.AspNetCore.SwaggerUI.node_modules.swagger_ui_dist"; @@ -110,8 +110,12 @@ private static StaticFileMiddleware CreateStaticFileMiddleware( private static void RespondWithRedirect(HttpResponse response, string location) { - response.StatusCode = 301; + response.StatusCode = StatusCodes.Status301MovedPermanently; +#if NET6_0_OR_GREATER + response.Headers.Location = location; +#else response.Headers["Location"] = location; +#endif } private async Task RespondWithFile(HttpResponse response, string fileName) diff --git a/test/Swashbuckle.AspNetCore.IntegrationTests/ReDocIntegrationTests.cs b/test/Swashbuckle.AspNetCore.IntegrationTests/ReDocIntegrationTests.cs index 69c635a7d5..9e4d0bb0e6 100644 --- a/test/Swashbuckle.AspNetCore.IntegrationTests/ReDocIntegrationTests.cs +++ b/test/Swashbuckle.AspNetCore.IntegrationTests/ReDocIntegrationTests.cs @@ -1,5 +1,8 @@ -using System.Net; +using System; +using System.Net; using System.Threading.Tasks; +using Microsoft.AspNetCore.Builder; +using Swashbuckle.AspNetCore.ReDoc; using Xunit; using ReDocApp = ReDoc; @@ -80,5 +83,71 @@ public async Task RedocMiddleware_CanBeConfiguredMultipleTimes(string htmlUrl, s Assert.Equal(HttpStatusCode.OK, jsResponse.StatusCode); Assert.Contains(swaggerPath, content); } + + [Fact] + public void ReDocOptions_Extensions() + { + // Arrange + var options = new ReDocOptions(); + + // Act and Assert + Assert.NotNull(options.IndexStream); + Assert.Null(options.JsonSerializerOptions); + Assert.Null(options.SpecUrl); + Assert.Equal("API Docs", options.DocumentTitle); + Assert.Equal(string.Empty, options.HeadContent); + Assert.Equal("api-docs", options.RoutePrefix); + + Assert.NotNull(options.ConfigObject); + Assert.NotNull(options.ConfigObject.AdditionalItems); + Assert.Empty(options.ConfigObject.AdditionalItems); + Assert.Null(options.ConfigObject.ScrollYOffset); + Assert.Equal("all", options.ConfigObject.ExpandResponses); + Assert.False(options.ConfigObject.DisableSearch); + Assert.False(options.ConfigObject.HideDownloadButton); + Assert.False(options.ConfigObject.HideHostname); + Assert.False(options.ConfigObject.HideLoading); + Assert.False(options.ConfigObject.NativeScrollbars); + Assert.False(options.ConfigObject.NoAutoAuth); + Assert.False(options.ConfigObject.OnlyRequiredInSamples); + Assert.False(options.ConfigObject.PathInMiddlePanel); + Assert.False(options.ConfigObject.RequiredPropsFirst); + Assert.False(options.ConfigObject.SortPropsAlphabetically); + Assert.False(options.ConfigObject.UntrustedSpec); + + // Act + options.DisableSearch(); + options.EnableUntrustedSpec(); + options.ExpandResponses("response"); + options.HideDownloadButton(); + options.HideHostname(); + options.HideLoading(); + options.InjectStylesheet("custom.css", "screen and (max-width: 700px)"); + options.NativeScrollbars(); + options.NoAutoAuth(); + options.OnlyRequiredInSamples(); + options.PathInMiddlePanel(); + options.RequiredPropsFirst(); + options.ScrollYOffset(42); + options.SortPropsAlphabetically(); + options.SpecUrl("spec.json"); + + // Assert + Assert.Equal("" + Environment.NewLine, options.HeadContent); + Assert.Equal("spec.json", options.SpecUrl); + Assert.Equal("response", options.ConfigObject.ExpandResponses); + Assert.Equal(42, options.ConfigObject.ScrollYOffset); + Assert.True(options.ConfigObject.DisableSearch); + Assert.True(options.ConfigObject.HideDownloadButton); + Assert.True(options.ConfigObject.HideHostname); + Assert.True(options.ConfigObject.HideLoading); + Assert.True(options.ConfigObject.NativeScrollbars); + Assert.True(options.ConfigObject.NoAutoAuth); + Assert.True(options.ConfigObject.OnlyRequiredInSamples); + Assert.True(options.ConfigObject.PathInMiddlePanel); + Assert.True(options.ConfigObject.RequiredPropsFirst); + Assert.True(options.ConfigObject.SortPropsAlphabetically); + Assert.True(options.ConfigObject.UntrustedSpec); + } } } diff --git a/test/WebSites/WebApi.Aot/Program.cs b/test/WebSites/WebApi.Aot/Program.cs index 57c18d71bb..c1e543567e 100644 --- a/test/WebSites/WebApi.Aot/Program.cs +++ b/test/WebSites/WebApi.Aot/Program.cs @@ -1,8 +1,12 @@ using System.Text.Json.Serialization; +using Microsoft.AspNetCore.Routing.Constraints; using Microsoft.OpenApi.Models; var builder = WebApplication.CreateSlimBuilder(args); +builder.Services.Configure( + options => options.SetParameterPolicy("regex")); + builder.Services.ConfigureHttpJsonOptions(options => { options.SerializerOptions.TypeInfoResolverChain.Insert(0, AppJsonSerializerContext.Default); @@ -41,6 +45,8 @@ app.UseSwagger(); app.UseSwaggerUI(); +app.UseReDoc(); + app.MapSwagger(); app.Run(); diff --git a/test/WebSites/WebApi.Aot/WebApi.Aot.csproj b/test/WebSites/WebApi.Aot/WebApi.Aot.csproj index f264d1d349..70cb09a09d 100644 --- a/test/WebSites/WebApi.Aot/WebApi.Aot.csproj +++ b/test/WebSites/WebApi.Aot/WebApi.Aot.csproj @@ -10,6 +10,7 @@ +