Skip to content

Commit

Permalink
Support native AoT with SwaggerUI
Browse files Browse the repository at this point in the history
Add support for using SwaggerUI with native AoT.
SwaggerGen still does not support native AoT, though it happens to work for simple cases.
Resolves domaindrivendev#2550.
  • Loading branch information
martincostello committed Apr 16, 2024
1 parent 250a0c2 commit 4a8806b
Show file tree
Hide file tree
Showing 14 changed files with 312 additions and 24 deletions.
15 changes: 15 additions & 0 deletions Swashbuckle.AspNetCore.sln
Expand Up @@ -109,6 +109,8 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "workflows", "workflows", "{
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "WebApi", "test\WebSites\WebApi\WebApi.csproj", "{DE1D77F8-3916-4DEE-A57D-6DDC357F64C6}"
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "WebApi.Aot", "test\WebSites\WebApi.Aot\WebApi.Aot.csproj", "{07BB09CF-6C6F-4D00-A459-93586345C921}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
Expand Down Expand Up @@ -515,6 +517,18 @@ Global
{DE1D77F8-3916-4DEE-A57D-6DDC357F64C6}.Release|x64.Build.0 = Release|Any CPU
{DE1D77F8-3916-4DEE-A57D-6DDC357F64C6}.Release|x86.ActiveCfg = Release|Any CPU
{DE1D77F8-3916-4DEE-A57D-6DDC357F64C6}.Release|x86.Build.0 = Release|Any CPU
{07BB09CF-6C6F-4D00-A459-93586345C921}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{07BB09CF-6C6F-4D00-A459-93586345C921}.Debug|Any CPU.Build.0 = Debug|Any CPU
{07BB09CF-6C6F-4D00-A459-93586345C921}.Debug|x64.ActiveCfg = Debug|Any CPU
{07BB09CF-6C6F-4D00-A459-93586345C921}.Debug|x64.Build.0 = Debug|Any CPU
{07BB09CF-6C6F-4D00-A459-93586345C921}.Debug|x86.ActiveCfg = Debug|Any CPU
{07BB09CF-6C6F-4D00-A459-93586345C921}.Debug|x86.Build.0 = Debug|Any CPU
{07BB09CF-6C6F-4D00-A459-93586345C921}.Release|Any CPU.ActiveCfg = Release|Any CPU
{07BB09CF-6C6F-4D00-A459-93586345C921}.Release|Any CPU.Build.0 = Release|Any CPU
{07BB09CF-6C6F-4D00-A459-93586345C921}.Release|x64.ActiveCfg = Release|Any CPU
{07BB09CF-6C6F-4D00-A459-93586345C921}.Release|x64.Build.0 = Release|Any CPU
{07BB09CF-6C6F-4D00-A459-93586345C921}.Release|x86.ActiveCfg = Release|Any CPU
{07BB09CF-6C6F-4D00-A459-93586345C921}.Release|x86.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
Expand Down Expand Up @@ -556,6 +570,7 @@ Global
{3BA087DA-788C-43D6-9D8B-1EF017014A4A} = {FA1B4021-0A97-4F68-B966-148191F6AAA8}
{A0EC16BE-C520-4FCF-BB54-2D79CD255F00} = {3BA087DA-788C-43D6-9D8B-1EF017014A4A}
{DE1D77F8-3916-4DEE-A57D-6DDC357F64C6} = {DB3F57FC-1472-4F03-B551-43394DA3C5EB}
{07BB09CF-6C6F-4D00-A459-93586345C921} = {DB3F57FC-1472-4F03-B551-43394DA3C5EB}
EndGlobalSection
GlobalSection(ExtensibilityGlobals) = postSolution
SolutionGuid = {36FC6A67-247D-4149-8EDD-79FFD1A75F51}
Expand Down
Expand Up @@ -9,6 +9,9 @@
<SignAssembly>true</SignAssembly>
<TargetFrameworks>netstandard2.0;netcoreapp3.0;net5.0;net6.0;net7.0;net8.0</TargetFrameworks>
</PropertyGroup>
<PropertyGroup Condition="$([MSBuild]::IsTargetFrameworkCompatible('$(TargetFramework)', 'net8.0'))">
<EnableAotAnalyzer>true</EnableAotAnalyzer>
</PropertyGroup>

<ItemGroup Condition=" '$(TargetFramework)' == 'netstandard2.0' ">
<PackageReference Include="Microsoft.AspNetCore.Routing" />
Expand Down
@@ -0,0 +1,18 @@
#if NET6_0_OR_GREATER
using System;
using System.Diagnostics.CodeAnalysis;
using System.Text.Json;
using System.Text.Json.Serialization;

namespace Swashbuckle.AspNetCore.SwaggerUI;

internal sealed class JavascriptStringEnumConverter<[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicFields)] TEnum>() :
#if NET8_0_OR_GREATER
JsonStringEnumConverter<TEnum>(JsonNamingPolicy.CamelCase, false)
#else
JsonStringEnumConverter(JsonNamingPolicy.CamelCase, false)
#endif
where TEnum : struct, Enum
{
}
#endif
@@ -0,0 +1,67 @@
#if NET6_0_OR_GREATER
using System;
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using System.Text.Json;
using System.Text.Json.Serialization;

namespace Swashbuckle.AspNetCore.SwaggerUI;

internal sealed class JavascriptStringEnumEnumerableConverter<[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicFields)] TEnum>() :
JsonConverterFactory
where TEnum : struct, Enum
{
private readonly JavascriptStringEnumConverter<TEnum> _enumConverter = new();

public override bool CanConvert(Type typeToConvert)
=> typeToConvert.IsAssignableFrom(typeof(IEnumerable<TEnum>));

public override JsonConverter CreateConverter(Type typeToConvert, JsonSerializerOptions options)
{
if (!typeToConvert.IsAssignableFrom(typeof(IEnumerable<TEnum>)))
{
throw new NotSupportedException($"The type {typeToConvert} is not supported.");
}

var valueConverter = (JsonConverter<TEnum>)_enumConverter.CreateConverter(typeof(TEnum), options);
return new EnumEnumerableConverter(valueConverter);
}

private sealed class EnumEnumerableConverter(JsonConverter<TEnum> inner) : JsonConverter<IEnumerable<TEnum>>
{
public override IEnumerable<TEnum> Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
{
if (reader.TokenType != JsonTokenType.StartArray)
{
throw new JsonException("Expected start of a JSON array.");
}

var result = new List<TEnum>();

while (reader.Read())
{
if (reader.TokenType == JsonTokenType.EndArray)
{
return result;
}

result.Add(inner.Read(ref reader, typeof(TEnum), options));
}

throw new JsonException("JSON array is malformed.");
}

public override void Write(Utf8JsonWriter writer, IEnumerable<TEnum> value, JsonSerializerOptions options)
{
writer.WriteStartArray();

foreach (var item in value)
{
inner.Write(writer, item, options);
}

writer.WriteEndArray();
}
}
}
#endif
@@ -0,0 +1,13 @@
#if NET6_0_OR_GREATER
using System.Text.Json.Serialization;

namespace Swashbuckle.AspNetCore.SwaggerUI;

[JsonSerializable(typeof(ConfigObject))]
[JsonSerializable(typeof(InterceptorFunctions))]
[JsonSerializable(typeof(OAuthConfigObject))]
[JsonSourceGenerationOptions(
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase)]
internal sealed partial class SwaggerUIOptionsJsonContext : JsonSerializerContext;
#endif
83 changes: 61 additions & 22 deletions src/Swashbuckle.AspNetCore.SwaggerUI/SwaggerUIMiddleware.cs
@@ -1,29 +1,28 @@
using System;
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using System.IO;
using System.Linq;
using System.Reflection;
using System.Threading.Tasks;
using System.Text.RegularExpressions;
using System.Text;
using System.Collections.Generic;
using System.IO;
using System.Text.Json;
using System.Text.Json.Serialization;
using System.Text.RegularExpressions;
using System.Threading.Tasks;
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 Microsoft.Extensions.FileProviders;
using Microsoft.AspNetCore.StaticFiles;
using Microsoft.AspNetCore.Http.Extensions;
using System.Linq;

#if NETSTANDARD2_0
using IWebHostEnvironment = Microsoft.AspNetCore.Hosting.IHostingEnvironment;
#endif

namespace Swashbuckle.AspNetCore.SwaggerUI
{
public class SwaggerUIMiddleware
public partial class SwaggerUIMiddleware
{
private const string EmbeddedFileNamespace = "Swashbuckle.AspNetCore.SwaggerUI.node_modules.swagger_ui_dist";

Expand All @@ -41,23 +40,36 @@ public class SwaggerUIMiddleware

_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)
{
var httpMethod = httpContext.Request.Method;
var path = httpContext.Request.Path.Value;

var isGet = HttpMethods.IsGet(httpMethod);

// If the RoutePrefix is requested (with or without trailing slash), redirect to index URL
if (httpMethod == "GET" && Regex.IsMatch(path, $"^/?{Regex.Escape(_options.RoutePrefix)}/?$", RegexOptions.IgnoreCase))
if (isGet && Regex.IsMatch(path, $"^/?{Regex.Escape(_options.RoutePrefix)}/?$", RegexOptions.IgnoreCase))
{
// Use relative redirect to support proxy environments
var relativeIndexUrl = string.IsNullOrEmpty(path) || path.EndsWith("/")
Expand All @@ -68,7 +80,7 @@ public async Task Invoke(HttpContext httpContext)
return;
}

if (httpMethod == "GET" && Regex.IsMatch(path, $"^/{Regex.Escape(_options.RoutePrefix)}/?index.html$", RegexOptions.IgnoreCase))
if (isGet && Regex.IsMatch(path, $"^/{Regex.Escape(_options.RoutePrefix)}/?index.html$", RegexOptions.IgnoreCase))
{
await RespondWithIndexHtml(httpContext.Response);
return;
Expand Down Expand Up @@ -118,15 +130,42 @@ private async Task RespondWithIndexHtml(HttpResponse response)
}
}

private IDictionary<string, string> 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<string, string> GetIndexArguments()
{
string configObject = null;
string oauthConfigObject = null;
string interceptors = null;

#if NET6_0_OR_GREATER
if (_jsonSerializerOptions is null)
{
configObject = JsonSerializer.Serialize(_options.ConfigObject, SwaggerUIOptionsJsonContext.Default.ConfigObject);
oauthConfigObject = JsonSerializer.Serialize(_options.OAuthConfigObject, SwaggerUIOptionsJsonContext.Default.OAuthConfigObject);
interceptors = JsonSerializer.Serialize(_options.Interceptors, SwaggerUIOptionsJsonContext.Default.InterceptorFunctions);
}
#endif

configObject ??= JsonSerializer.Serialize(_options.ConfigObject, _jsonSerializerOptions);
oauthConfigObject ??= JsonSerializer.Serialize(_options.OAuthConfigObject, _jsonSerializerOptions);
interceptors ??= JsonSerializer.Serialize(_options.Interceptors, _jsonSerializerOptions);

return new Dictionary<string, string>()
{
{ "%(DocumentTitle)", _options.DocumentTitle },
{ "%(HeadContent)", _options.HeadContent },
{ "%(ConfigObject)", JsonSerializer.Serialize(_options.ConfigObject, _jsonSerializerOptions) },
{ "%(OAuthConfigObject)", JsonSerializer.Serialize(_options.OAuthConfigObject, _jsonSerializerOptions) },
{ "%(Interceptors)", JsonSerializer.Serialize(_options.Interceptors) },
{ "%(ConfigObject)", configObject },
{ "%(OAuthConfigObject)", oauthConfigObject },
{ "%(Interceptors)", interceptors },
};
}
}
Expand Down
24 changes: 22 additions & 2 deletions src/Swashbuckle.AspNetCore.SwaggerUI/SwaggerUIOptions.cs
Expand Up @@ -4,6 +4,7 @@
using System.Collections.Generic;
using System.Linq;
using System.Text.Json.Serialization;
using System.Text.Json;

namespace Swashbuckle.AspNetCore.SwaggerUI
{
Expand Down Expand Up @@ -44,6 +45,11 @@ public class SwaggerUIOptions
/// Gets the interceptor functions that define client-side request/response interceptors
/// </summary>
public InterceptorFunctions Interceptors { get; set; } = new InterceptorFunctions();

/// <summary>
/// Gets or sets the optional JSON serialization options to use to serialize options to the HTML document.
/// </summary>
public JsonSerializerOptions JsonSerializerOptions { get; set; }
}

public class ConfigObject
Expand Down Expand Up @@ -81,6 +87,9 @@ public class ConfigObject
/// Controls how the model is shown when the API is first rendered.
/// (The user can always switch the rendering for a given model by clicking the 'Model' and 'Example Value' links)
/// </summary>
#if NET6_0_OR_GREATER
[JsonConverter(typeof(JavascriptStringEnumConverter<ModelRendering>))]
#endif
public ModelRendering DefaultModelRendering { get; set; } = ModelRendering.Example;

/// <summary>
Expand All @@ -92,6 +101,9 @@ public class ConfigObject
/// Controls the default expansion setting for the operations and tags.
/// It can be 'list' (expands only the tags), 'full' (expands the tags and operations) or 'none' (expands nothing)
/// </summary>
#if NET6_0_OR_GREATER
[JsonConverter(typeof(JavascriptStringEnumConverter<DocExpansion>))]
#endif
public DocExpansion DocExpansion { get; set; } = DocExpansion.List;

/// <summary>
Expand Down Expand Up @@ -126,7 +138,15 @@ public class ConfigObject
/// List of HTTP methods that have the Try it out feature enabled.
/// An empty array disables Try it out for all operations. This does not filter the operations from the display
/// </summary>
public IEnumerable<SubmitMethod> SupportedSubmitMethods { get; set; } = Enum.GetValues(typeof(SubmitMethod)).Cast<SubmitMethod>();
#if NET6_0_OR_GREATER
[JsonConverter(typeof(JavascriptStringEnumEnumerableConverter<SubmitMethod>))]
#endif
public IEnumerable<SubmitMethod> SupportedSubmitMethods { get; set; } =
#if NET5_0_OR_GREATER
Enum.GetValues<SubmitMethod>();
#else
Enum.GetValues(typeof(SubmitMethod)).Cast<SubmitMethod>();
#endif

/// <summary>
/// Controls whether the "Try it out" section should be enabled by default.
Expand Down Expand Up @@ -252,4 +272,4 @@ public class InterceptorFunctions
/// </summary>
public string ResponseInterceptorFunction { get; set; }
}
}
}
Expand Up @@ -9,6 +9,10 @@
<SignAssembly>true</SignAssembly>
<TargetFrameworks>netstandard2.0;netcoreapp3.0;net5.0;net6.0;net7.0;net8.0</TargetFrameworks>
</PropertyGroup>
<PropertyGroup Condition="$([MSBuild]::IsTargetFrameworkCompatible('$(TargetFramework)', 'net8.0'))">
<EnableAotAnalyzer>true</EnableAotAnalyzer>
<IsAotCompatible>true</IsAotCompatible>
</PropertyGroup>

<ItemGroup>
<EmbeddedResource Include="node_modules/swagger-ui-dist/**/*" Exclude="**/*/index.html;**/*/*.map;**/*/*.json;**/*/*.md" />
Expand Down

0 comments on commit 4a8806b

Please sign in to comment.