diff --git a/src/Swashbuckle.AspNetCore.ReDoc/ReDocMiddleware.cs b/src/Swashbuckle.AspNetCore.ReDoc/ReDocMiddleware.cs index 22e471a2a1..d56e94dfeb 100644 --- a/src/Swashbuckle.AspNetCore.ReDoc/ReDocMiddleware.cs +++ b/src/Swashbuckle.AspNetCore.ReDoc/ReDocMiddleware.cs @@ -1,8 +1,10 @@ using System.Diagnostics.CodeAnalysis; using System.Reflection; +using System.Runtime.CompilerServices; using System.Security.Cryptography; using System.Text; using System.Text.Json; +using System.Text.Json.Serialization.Metadata; using System.Text.RegularExpressions; using Microsoft.AspNetCore.Http; @@ -12,6 +14,7 @@ internal sealed partial class ReDocMiddleware { private static readonly HashSet AllowedHttpMethods = new(StringComparer.OrdinalIgnoreCase) { HttpMethods.Get, HttpMethods.Head }; private static readonly string ReDocVersion = GetReDocVersion(); + private static readonly JsonSerializerOptions DefaultJsonSerializerOptions = CreateDefaultJsonSerializerOptions(); private readonly RequestDelegate _next; private readonly ReDocOptions _options; @@ -23,10 +26,7 @@ public ReDocMiddleware(RequestDelegate next, ReDocOptions options) _next = next; _options = options ?? new ReDocOptions(); - if (options.JsonSerializerOptions != null) - { - _jsonSerializerOptions = options.JsonSerializerOptions; - } + _jsonSerializerOptions = _options.JsonSerializerOptions ?? DefaultJsonSerializerOptions; var pathPrefix = options.RoutePrefix.StartsWith('/') ? options.RoutePrefix : $"/{options.RoutePrefix}"; _resourceProvider = new( @@ -72,6 +72,32 @@ public async Task Invoke(HttpContext httpContext) await _next(httpContext); } + [UnconditionalSuppressMessage( + "AOT", + "IL2026:RequiresUnreferencedCode", + Justification = "The reflection-based resolver is only used when dynamic code is supported.")] + [UnconditionalSuppressMessage( + "AOT", + "IL3050:RequiresDynamicCode", + Justification = "The reflection-based resolver is only used when dynamic code is supported.")] + private static JsonSerializerOptions CreateDefaultJsonSerializerOptions() + { + if (!RuntimeFeature.IsDynamicCodeSupported) + { + // Only source-generated metadata can be used with native AoT + return null; + } + + // Chain a reflection-based resolver after the source-generated one so that custom + // values in ConfigObject.AdditionalItems (e.g. anonymous types) can be serialized. + return new JsonSerializerOptions(ReDocOptionsJsonContext.Default.Options) + { + TypeInfoResolver = JsonTypeInfoResolver.Combine( + ReDocOptionsJsonContext.Default, + new DefaultJsonTypeInfoResolver()), + }; + } + private static string GetReDocVersion() => typeof(ReDocMiddleware).Assembly .GetCustomAttributes() @@ -196,11 +222,11 @@ static string GetETag(string text) [UnconditionalSuppressMessage( "AOT", "IL2026:RequiresUnreferencedCode", - Justification = "Method is only called if the user provides their own custom JsonSerializerOptions.")] + Justification = "Reflection-based serialization is only used if the user provides their own custom JsonSerializerOptions or when dynamic code is supported.")] [UnconditionalSuppressMessage( "AOT", "IL3050:RequiresDynamicCode", - Justification = "Method is only called if the user provides their own custom JsonSerializerOptions.")] + Justification = "Reflection-based serialization is only used if the user provides their own custom JsonSerializerOptions or when dynamic code is supported.")] private Dictionary GetIndexArguments() { string configObject = null; diff --git a/src/Swashbuckle.AspNetCore.SwaggerUI/SwaggerUIMiddleware.cs b/src/Swashbuckle.AspNetCore.SwaggerUI/SwaggerUIMiddleware.cs index 7a734ad5ae..942d7c7610 100644 --- a/src/Swashbuckle.AspNetCore.SwaggerUI/SwaggerUIMiddleware.cs +++ b/src/Swashbuckle.AspNetCore.SwaggerUI/SwaggerUIMiddleware.cs @@ -1,8 +1,10 @@ using System.Diagnostics.CodeAnalysis; using System.Reflection; +using System.Runtime.CompilerServices; using System.Security.Cryptography; using System.Text; using System.Text.Json; +using System.Text.Json.Serialization.Metadata; using System.Text.RegularExpressions; using Microsoft.AspNetCore.Http; @@ -12,6 +14,7 @@ internal sealed partial class SwaggerUIMiddleware { private static readonly HashSet AllowedHttpMethods = new(StringComparer.OrdinalIgnoreCase) { HttpMethods.Get, HttpMethods.Head }; private static readonly string SwaggerUIVersion = GetSwaggerUIVersion(); + private static readonly JsonSerializerOptions DefaultJsonSerializerOptions = CreateDefaultJsonSerializerOptions(); private readonly RequestDelegate _next; private readonly SwaggerUIOptions _options; @@ -23,10 +26,7 @@ public SwaggerUIMiddleware(RequestDelegate next, SwaggerUIOptions options) _next = next; _options = options ?? new(); - if (options.JsonSerializerOptions != null) - { - _jsonSerializerOptions = options.JsonSerializerOptions; - } + _jsonSerializerOptions = _options.JsonSerializerOptions ?? DefaultJsonSerializerOptions; var pathPrefix = options.RoutePrefix.StartsWith('/') ? options.RoutePrefix : $"/{options.RoutePrefix}"; _resourceProvider = new( @@ -82,6 +82,32 @@ public async Task Invoke(HttpContext httpContext) await _next(httpContext); } + [UnconditionalSuppressMessage( + "AOT", + "IL2026:RequiresUnreferencedCode", + Justification = "The reflection-based resolver is only used when dynamic code is supported.")] + [UnconditionalSuppressMessage( + "AOT", + "IL3050:RequiresDynamicCode", + Justification = "The reflection-based resolver is only used when dynamic code is supported.")] + private static JsonSerializerOptions CreateDefaultJsonSerializerOptions() + { + if (!RuntimeFeature.IsDynamicCodeSupported) + { + // Only source-generated metadata can be used with native AoT + return null; + } + + // Chain a reflection-based resolver after the source-generated one so that custom + // values in ConfigObject.AdditionalItems (e.g. anonymous types) can be serialized. + return new JsonSerializerOptions(SwaggerUIOptionsJsonContext.Default.Options) + { + TypeInfoResolver = JsonTypeInfoResolver.Combine( + SwaggerUIOptionsJsonContext.Default, + new DefaultJsonTypeInfoResolver()), + }; + } + private static string GetSwaggerUIVersion() => typeof(SwaggerUIMiddleware).Assembly .GetCustomAttributes() @@ -196,11 +222,11 @@ private async Task RespondWithFile(HttpContext context, string fileName) [UnconditionalSuppressMessage( "AOT", "IL2026:RequiresUnreferencedCode", - Justification = "Method is only called if the user provides their own custom JsonSerializerOptions.")] + Justification = "Reflection-based serialization is only used if the user provides their own custom JsonSerializerOptions or when dynamic code is supported.")] [UnconditionalSuppressMessage( "AOT", "IL3050:RequiresDynamicCode", - Justification = "Method is only called if the user provides their own custom JsonSerializerOptions.")] + Justification = "Reflection-based serialization is only used if the user provides their own custom JsonSerializerOptions or when dynamic code is supported.")] private async Task RespondWithDocumentUrls(HttpContext context) { var response = context.Response; @@ -240,11 +266,11 @@ private static string GetETag(string text) [UnconditionalSuppressMessage( "AOT", "IL2026:RequiresUnreferencedCode", - Justification = "Method is only called if the user provides their own custom JsonSerializerOptions.")] + Justification = "Reflection-based serialization is only used if the user provides their own custom JsonSerializerOptions or when dynamic code is supported.")] [UnconditionalSuppressMessage( "AOT", "IL3050:RequiresDynamicCode", - Justification = "Method is only called if the user provides their own custom JsonSerializerOptions.")] + Justification = "Reflection-based serialization is only used if the user provides their own custom JsonSerializerOptions or when dynamic code is supported.")] private Dictionary GetIndexArguments() { string configObject = null; diff --git a/test/Swashbuckle.AspNetCore.IntegrationTests/ReDocIntegrationTests.cs b/test/Swashbuckle.AspNetCore.IntegrationTests/ReDocIntegrationTests.cs index 05e8e89074..a5ec4645da 100644 --- a/test/Swashbuckle.AspNetCore.IntegrationTests/ReDocIntegrationTests.cs +++ b/test/Swashbuckle.AspNetCore.IntegrationTests/ReDocIntegrationTests.cs @@ -433,4 +433,31 @@ public async Task ReDocMiddleware_ConfigObject_Is_Not_Spliced_Into_A_String_Lite Assert.Equal("x');alert(1);//", config.RootElement.GetProperty("theme").GetString()); } + + [Fact] + public async Task ReDocMiddleware_Serializes_Custom_Object_In_AdditionalItems() + { + // Arrange + var cancellationToken = TestContext.Current.CancellationToken; + + using var server = TestSite.CreateServer((app) => app.UseReDoc((options) => + { + options.ConfigObject.AdditionalItems["theme"] = new { colors = new { primary = "#086eaa" } }; + })); + + using var client = server.CreateClient(); + + // Act + using var response = await client.GetAsync("/api-docs/index.js", cancellationToken); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + + var body = await response.Content.ReadAsStringAsync(cancellationToken); + outputHelper.WriteLine(body); + + using var config = JsonDocument.Parse(body[body.IndexOf('{')..(body.LastIndexOf('}') + 1)]); + + Assert.Equal("#086eaa", config.RootElement.GetProperty("theme").GetProperty("colors").GetProperty("primary").GetString()); + } } diff --git a/test/Swashbuckle.AspNetCore.IntegrationTests/SwaggerUIIntegrationTests.cs b/test/Swashbuckle.AspNetCore.IntegrationTests/SwaggerUIIntegrationTests.cs index d9269fff27..31847d228a 100644 --- a/test/Swashbuckle.AspNetCore.IntegrationTests/SwaggerUIIntegrationTests.cs +++ b/test/Swashbuckle.AspNetCore.IntegrationTests/SwaggerUIIntegrationTests.cs @@ -807,4 +807,37 @@ public async Task SwaggerUIMiddleware_ConfigObject_Is_Not_Spliced_Into_A_String_ Assert.Equal("x');alert(1);//", config.RootElement.GetProperty("urls")[0].GetProperty("name").GetString()); } + + [Fact] + public async Task SwaggerUIMiddleware_Serializes_Custom_Object_In_AdditionalItems() + { + // Arrange + var cancellationToken = TestContext.Current.CancellationToken; + + using var server = TestSite.CreateServer((app) => app.UseSwaggerUI((options) => + { + options.ConfigObject.AdditionalItems["theme"] = new { colors = new { primary = "#086eaa" } }; + })); + + using var client = server.CreateClient(); + + // Act + using var response = await client.GetAsync("/swagger/index.js", cancellationToken); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + + var body = await response.Content.ReadAsStringAsync(cancellationToken); + + const string Prefix = "var configObject ="; + + var line = body.Split('\n').First((p) => p.Contains(Prefix)); + outputHelper.WriteLine(line); + + var json = line[(line.IndexOf(Prefix, StringComparison.Ordinal) + Prefix.Length)..].Trim().TrimEnd(';'); + + using var config = JsonDocument.Parse(json); + + Assert.Equal("#086eaa", config.RootElement.GetProperty("theme").GetProperty("colors").GetProperty("primary").GetString()); + } }