Serialization and deserialziation with polymorphism (#13762)

* Support polymophism when sending or receiving interfaces

* Handle attempts to deserialize interfaces nicer

* updated OpenApi.json

* Update src/Umbraco.Cms.Api.Common/Json/NamedSystemTextJsonInputFormatter.cs

Co-authored-by: Mole <nikolajlauridsen@protonmail.ch>

* Update src/Umbraco.Cms.Api.Management/ManagementApiComposer.cs

Co-authored-by: Mole <nikolajlauridsen@protonmail.ch>

* Update src/Umbraco.Cms.Api.Management/OpenApi/ConfigureUmbracoSwaggerGenOptions.cs

Co-authored-by: Mole <nikolajlauridsen@protonmail.ch>

* Update src/Umbraco.Infrastructure/Serialization/UmbracoJsonTypeInfoResolver.cs

Co-authored-by: Mole <nikolajlauridsen@protonmail.ch>

* Update src/Umbraco.Infrastructure/Serialization/UmbracoJsonTypeInfoResolver.cs

Co-authored-by: Mole <nikolajlauridsen@protonmail.ch>

* Update src/Umbraco.Infrastructure/Serialization/UmbracoJsonTypeInfoResolver.cs

Co-authored-by: Mole <nikolajlauridsen@protonmail.ch>

* Update src/Umbraco.Infrastructure/Serialization/UmbracoJsonTypeInfoResolver.cs

Co-authored-by: Mole <nikolajlauridsen@protonmail.ch>

* Update src/Umbraco.Infrastructure/Serialization/UmbracoJsonTypeInfoResolver.cs

Co-authored-by: Mole <nikolajlauridsen@protonmail.ch>

* Update src/Umbraco.Infrastructure/Serialization/UmbracoJsonTypeInfoResolver.cs

Co-authored-by: Mole <nikolajlauridsen@protonmail.ch>

* Update src/Umbraco.Infrastructure/Serialization/UmbracoJsonTypeInfoResolver.cs

Co-authored-by: Mole <nikolajlauridsen@protonmail.ch>

* Update src/Umbraco.Infrastructure/Serialization/UmbracoJsonTypeInfoResolver.cs

Co-authored-by: Mole <nikolajlauridsen@protonmail.ch>

* Invert to avoid nesting

* Updated OpenApi.json

* Update schema

---------

Co-authored-by: Mole <nikolajlauridsen@protonmail.ch>
This commit is contained in:
Bjarke Berg
2023-02-08 18:29:30 +01:00
committed by GitHub
parent 20534b7a97
commit 9b7bf81ad1
8 changed files with 1706 additions and 704 deletions

View File

@@ -14,4 +14,18 @@ public class NamedSystemTextJsonInputFormatter : SystemTextJsonInputFormatter
public override bool CanRead(InputFormatterContext context)
=> context.HttpContext.CurrentJsonOptionsName() == _jsonOptionsName && base.CanRead(context);
public override async Task<InputFormatterResult> ReadAsync(InputFormatterContext context)
{
try
{
return await base.ReadAsync(context);
}
catch (NotSupportedException exception)
{
// This happens when trying to deserialize to an interface, without sending the $type as part of the request
context.ModelState.TryAddModelException(string.Empty, new InputFormatterException(exception.Message, exception));
return await InputFormatterResult.FailureAsync();
}
}
}

View File

@@ -1,9 +1,11 @@
using Microsoft.AspNetCore.Mvc.ApiExplorer;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.OpenApi.Models;
using Umbraco.Cms.Api.Management.Controllers.Dictionary;
using Umbraco.Cms.Api.Management.Controllers.Security;
using Umbraco.Cms.Api.Management.OpenApi;
using Umbraco.Cms.Core.DependencyInjection;
using Umbraco.Cms.Infrastructure.Serialization;
using Umbraco.Extensions;
namespace Umbraco.Cms.Api.Management.DependencyInjection;
@@ -12,106 +14,11 @@ internal static class ManagementApiBuilderExtensions
{
internal static IUmbracoBuilder AddSwaggerGen(this IUmbracoBuilder builder)
{
builder.Services.AddSwaggerGen(swaggerGenOptions =>
{
swaggerGenOptions.SwaggerDoc(
ManagementApiConfiguration.DefaultApiDocumentName,
new OpenApiInfo
{
Title = ManagementApiConfiguration.ApiTitle,
Version = ManagementApiConfiguration.DefaultApiVersion.ToString(),
Description =
"This shows all APIs available in this version of Umbraco - including all the legacy apis that are available for backward compatibility"
});
builder.Services.AddSwaggerGen();
builder.Services.ConfigureOptions<ConfigureUmbracoSwaggerGenOptions>();
builder.Services.AddSingleton<IUmbracoJsonTypeInfoResolver, UmbracoJsonTypeInfoResolver>();
swaggerGenOptions.AddSecurityDefinition(
"OAuth",
new OpenApiSecurityScheme
{
In = ParameterLocation.Header,
Name = "Umbraco",
Type = SecuritySchemeType.OAuth2,
Description = "Umbraco Authentication",
Flows = new OpenApiOAuthFlows
{
AuthorizationCode = new OpenApiOAuthFlow
{
AuthorizationUrl =
new Uri(Paths.BackOfficeApiAuthorizationEndpoint, UriKind.Relative),
TokenUrl = new Uri(Paths.BackOfficeApiTokenEndpoint, UriKind.Relative)
}
}
});
swaggerGenOptions.AddSecurityRequirement(new OpenApiSecurityRequirement
{
// this weird looking construct works because OpenApiSecurityRequirement
// is a specialization of Dictionary<,>
{
new OpenApiSecurityScheme
{
Reference = new OpenApiReference { Id = "OAuth", Type = ReferenceType.SecurityScheme }
},
new List<string>()
}
});
swaggerGenOptions.CustomOperationIds(CustomOperationId);
swaggerGenOptions.DocInclusionPredicate((_, api) => !string.IsNullOrWhiteSpace(api.GroupName));
swaggerGenOptions.TagActionsBy(api => new[] { api.GroupName });
swaggerGenOptions.OrderActionsBy(ActionOrderBy);
swaggerGenOptions.DocumentFilter<MimeTypeDocumentFilter>();
swaggerGenOptions.SchemaFilter<EnumSchemaFilter>();
swaggerGenOptions.CustomSchemaIds(SchemaIdGenerator.Generate);
swaggerGenOptions.SupportNonNullableReferenceTypes();
});
return builder;
}
private static string CustomOperationId(ApiDescription api)
{
var httpMethod = api.HttpMethod?.ToLower().ToFirstUpper() ?? "Get";
// if the route info "Name" is supplied we'll use this explicitly as the operation ID
// - usage example: [HttpGet("my-api/route}", Name = "MyCustomRoute")]
if (string.IsNullOrWhiteSpace(api.ActionDescriptor.AttributeRouteInfo?.Name) == false)
{
var explicitOperationId = api.ActionDescriptor.AttributeRouteInfo!.Name;
return explicitOperationId.InvariantStartsWith(httpMethod)
? explicitOperationId
: $"{httpMethod}{explicitOperationId}";
}
var relativePath = api.RelativePath;
if (string.IsNullOrWhiteSpace(relativePath))
{
throw new Exception(
$"There is no relative path for controller action {api.ActionDescriptor.RouteValues["controller"]}");
}
// Remove the prefixed base path with version, e.g. /umbraco/management/api/v1/tracked-reference/{id} => tracked-reference/{id}
var unprefixedRelativePath = OperationIdRegexes
.VersionPrefixRegex()
.Replace(relativePath, string.Empty);
// Remove template placeholders, e.g. tracked-reference/{id} => tracked-reference/Id
var formattedOperationId = OperationIdRegexes
.TemplatePlaceholdersRegex()
.Replace(unprefixedRelativePath, m => $"By{m.Groups[1].Value.ToFirstUpper()}");
// Remove dashes (-) and slashes (/) and convert the following letter to uppercase with
// the word "By" in front, e.g. tracked-reference/Id => TrackedReferenceById
formattedOperationId = OperationIdRegexes
.ToCamelCaseRegex()
.Replace(formattedOperationId, m => m.Groups[1].Value.ToUpper());
// Return the operation ID with the formatted http method verb in front, e.g. GetTrackedReferenceById
return $"{httpMethod}{formattedOperationId.ToFirstUpper()}";
}
// see https://github.com/domaindrivendev/Swashbuckle.AspNetCore#change-operation-sort-order-eg-for-ui-sorting
private static string ActionOrderBy(ApiDescription apiDesc)
=> $"{apiDesc.GroupName}_{apiDesc.ActionDescriptor.AttributeRouteInfo?.Template ?? apiDesc.ActionDescriptor.RouteValues["controller"]}_{apiDesc.ActionDescriptor.RouteValues["action"]}_{apiDesc.HttpMethod}";
}

View File

@@ -1,5 +1,6 @@
using System.Text.Json;
using System.Text.Json.Serialization;
using System.Text.Json.Serialization.Metadata;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Options;
using Umbraco.Cms.Core.Composing;
@@ -7,6 +8,7 @@ using Umbraco.Cms.Core.DependencyInjection;
using Umbraco.Cms.Api.Common.Configuration;
using Umbraco.Cms.Api.Common.DependencyInjection;
using Umbraco.Cms.Api.Management.DependencyInjection;
using Umbraco.Cms.Api.Management.Serialization;
using Umbraco.Cms.Infrastructure.Serialization;
using Umbraco.Cms.Web.Common.ApplicationBuilder;
using Umbraco.New.Cms.Core.Models.Configuration;
@@ -56,13 +58,9 @@ public class ManagementApiComposer : IComposer
{
// any generic JSON options go here
})
.AddJsonOptions(New.Cms.Core.Constants.JsonOptionsNames.BackOffice, options =>
{
// all back-office specific JSON options go here
options.JsonSerializerOptions.PropertyNamingPolicy = JsonNamingPolicy.CamelCase;
options.JsonSerializerOptions.Converters.Add(new JsonStringEnumConverter());
options.JsonSerializerOptions.Converters.Add(new JsonObjectConverter());
});
.AddJsonOptions(New.Cms.Core.Constants.JsonOptionsNames.BackOffice, _ => { });
services.ConfigureOptions<ConfigureUmbracoBackofficeJsonOptions>( );
// FIXME: when this is moved to core, make the AddUmbracoOptions extension private again and remove core InternalsVisibleTo for Umbraco.Cms.Api.Management
builder.AddUmbracoOptions<NewBackOfficeSettings>();

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,137 @@
using Microsoft.AspNetCore.Mvc.ApiExplorer;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Options;
using Microsoft.OpenApi.Models;
using Swashbuckle.AspNetCore.SwaggerGen;
using Umbraco.Cms.Api.Management.Controllers.Security;
using Umbraco.Cms.Api.Management.DependencyInjection;
using Umbraco.Cms.Infrastructure.Serialization;
using Umbraco.Extensions;
namespace Umbraco.Cms.Api.Management.OpenApi;
internal sealed class ConfigureUmbracoSwaggerGenOptions : IConfigureOptions<SwaggerGenOptions>
{
private readonly IUmbracoJsonTypeInfoResolver _umbracoJsonTypeInfoResolver;
public ConfigureUmbracoSwaggerGenOptions(IUmbracoJsonTypeInfoResolver umbracoJsonTypeInfoResolver)
{
_umbracoJsonTypeInfoResolver = umbracoJsonTypeInfoResolver;
}
public void Configure(SwaggerGenOptions swaggerGenOptions)
{
swaggerGenOptions.SwaggerDoc(
ManagementApiConfiguration.DefaultApiDocumentName,
new OpenApiInfo
{
Title = ManagementApiConfiguration.ApiTitle,
Version = ManagementApiConfiguration.DefaultApiVersion.ToString(),
Description =
"This shows all APIs available in this version of Umbraco - including all the legacy apis that are available for backward compatibility"
});
swaggerGenOptions.AddSecurityDefinition(
"OAuth",
new OpenApiSecurityScheme
{
In = ParameterLocation.Header,
Name = "Umbraco",
Type = SecuritySchemeType.OAuth2,
Description = "Umbraco Authentication",
Flows = new OpenApiOAuthFlows
{
AuthorizationCode = new OpenApiOAuthFlow
{
AuthorizationUrl =
new Uri(Paths.BackOfficeApiAuthorizationEndpoint, UriKind.Relative),
TokenUrl = new Uri(Paths.BackOfficeApiTokenEndpoint, UriKind.Relative)
}
}
});
swaggerGenOptions.AddSecurityRequirement(new OpenApiSecurityRequirement
{
// this weird looking construct works because OpenApiSecurityRequirement
// is a specialization of Dictionary<,>
{
new OpenApiSecurityScheme
{
Reference = new OpenApiReference { Id = "OAuth", Type = ReferenceType.SecurityScheme }
},
new List<string>()
}
});
swaggerGenOptions.CustomOperationIds(CustomOperationId);
swaggerGenOptions.DocInclusionPredicate((_, api) => !string.IsNullOrWhiteSpace(api.GroupName));
swaggerGenOptions.TagActionsBy(api => new[] { api.GroupName });
swaggerGenOptions.OrderActionsBy(ActionOrderBy);
swaggerGenOptions.DocumentFilter<MimeTypeDocumentFilter>();
swaggerGenOptions.SchemaFilter<EnumSchemaFilter>();
swaggerGenOptions.CustomSchemaIds(SchemaIdGenerator.Generate);
swaggerGenOptions.SupportNonNullableReferenceTypes();
swaggerGenOptions.UseOneOfForPolymorphism();
swaggerGenOptions.UseAllOfForInheritance();
swaggerGenOptions.SelectSubTypesUsing(_umbracoJsonTypeInfoResolver.FindSubTypes);
swaggerGenOptions.SelectDiscriminatorNameUsing(type =>
{
if (type.GetInterfaces().Any())
{
return "$type";
}
return null;
});
swaggerGenOptions.SelectDiscriminatorValueUsing(x => x.Name);
}
private static string CustomOperationId(ApiDescription api)
{
var httpMethod = api.HttpMethod?.ToLower().ToFirstUpper() ?? "Get";
// if the route info "Name" is supplied we'll use this explicitly as the operation ID
// - usage example: [HttpGet("my-api/route}", Name = "MyCustomRoute")]
if (string.IsNullOrWhiteSpace(api.ActionDescriptor.AttributeRouteInfo?.Name) == false)
{
var explicitOperationId = api.ActionDescriptor.AttributeRouteInfo!.Name;
return explicitOperationId.InvariantStartsWith(httpMethod)
? explicitOperationId
: $"{httpMethod}{explicitOperationId}";
}
var relativePath = api.RelativePath;
if (string.IsNullOrWhiteSpace(relativePath))
{
throw new Exception(
$"There is no relative path for controller action {api.ActionDescriptor.RouteValues["controller"]}");
}
// Remove the prefixed base path with version, e.g. /umbraco/management/api/v1/tracked-reference/{id} => tracked-reference/{id}
var unprefixedRelativePath = OperationIdRegexes
.VersionPrefixRegex()
.Replace(relativePath, string.Empty);
// Remove template placeholders, e.g. tracked-reference/{id} => tracked-reference/Id
var formattedOperationId = OperationIdRegexes
.TemplatePlaceholdersRegex()
.Replace(unprefixedRelativePath, m => $"By{m.Groups[1].Value.ToFirstUpper()}");
// Remove dashes (-) and slashes (/) and convert the following letter to uppercase with
// the word "By" in front, e.g. tracked-reference/Id => TrackedReferenceById
formattedOperationId = OperationIdRegexes
.ToCamelCaseRegex()
.Replace(formattedOperationId, m => m.Groups[1].Value.ToUpper());
// Return the operation ID with the formatted http method verb in front, e.g. GetTrackedReferenceById
return $"{httpMethod}{formattedOperationId.ToFirstUpper()}";
}
// see https://github.com/domaindrivendev/Swashbuckle.AspNetCore#change-operation-sort-order-eg-for-ui-sorting
private static string ActionOrderBy(ApiDescription apiDesc)
=>
$"{apiDesc.GroupName}_{apiDesc.ActionDescriptor.AttributeRouteInfo?.Template ?? apiDesc.ActionDescriptor.RouteValues["controller"]}_{apiDesc.ActionDescriptor.RouteValues["action"]}_{apiDesc.HttpMethod}";
}

View File

@@ -0,0 +1,37 @@
using System.Text.Json;
using System.Text.Json.Serialization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Options;
using Umbraco.Cms.Infrastructure.Serialization;
namespace Umbraco.Cms.Api.Management.Serialization;
public class ConfigureUmbracoBackofficeJsonOptions : IConfigureNamedOptions<JsonOptions>
{
private readonly IUmbracoJsonTypeInfoResolver _umbracoJsonTypeInfoResolver;
public ConfigureUmbracoBackofficeJsonOptions(IUmbracoJsonTypeInfoResolver umbracoJsonTypeInfoResolver)
{
_umbracoJsonTypeInfoResolver = umbracoJsonTypeInfoResolver;
}
public void Configure(string? name, JsonOptions options)
{
if (name != New.Cms.Core.Constants.JsonOptionsNames.BackOffice)
{
return;
}
Configure(options);
}
public void Configure(JsonOptions options)
{
// all back-office specific JSON options go here
options.JsonSerializerOptions.PropertyNamingPolicy = JsonNamingPolicy.CamelCase;
options.JsonSerializerOptions.Converters.Add(new JsonStringEnumConverter());
options.JsonSerializerOptions.Converters.Add(new JsonObjectConverter());
options.JsonSerializerOptions.TypeInfoResolver = _umbracoJsonTypeInfoResolver;
}
}

View File

@@ -0,0 +1,8 @@
using System.Text.Json.Serialization.Metadata;
namespace Umbraco.Cms.Infrastructure.Serialization;
public interface IUmbracoJsonTypeInfoResolver : IJsonTypeInfoResolver
{
IEnumerable<Type> FindSubTypes(Type type);
}

View File

@@ -0,0 +1,61 @@
using System.Collections.Concurrent;
using System.Text.Json;
using System.Text.Json.Serialization.Metadata;
using Umbraco.Cms.Core.Composing;
namespace Umbraco.Cms.Infrastructure.Serialization;
public sealed class UmbracoJsonTypeInfoResolver : DefaultJsonTypeInfoResolver, IUmbracoJsonTypeInfoResolver
{
private readonly ITypeFinder _typeFinder;
private readonly ConcurrentDictionary<Type, ISet<Type>> _subTypesCache = new ConcurrentDictionary<Type, ISet<Type>>();
public UmbracoJsonTypeInfoResolver(ITypeFinder typeFinder)
{
_typeFinder = typeFinder;
}
public IEnumerable<Type> FindSubTypes(Type type)
{
if (_subTypesCache.TryGetValue(type, out ISet<Type>? cachedResult))
{
return cachedResult;
}
var result = _typeFinder.FindClassesOfType(type).ToHashSet();
_subTypesCache.TryAdd(type, result);
return result;
}
public override JsonTypeInfo GetTypeInfo(Type type, JsonSerializerOptions options)
{
JsonTypeInfo result = base.GetTypeInfo(type, options);
if (!type.IsInterface)
{
return result;
}
Type[] subTypes = FindSubTypes(type).ToArray();
if (!subTypes.Any())
{
return result;
}
JsonPolymorphismOptions jsonPolymorphismOptions = result.PolymorphismOptions ?? new JsonPolymorphismOptions();
IEnumerable<Type> knownSubTypes = jsonPolymorphismOptions.DerivedTypes.Select(x => x.DerivedType);
IEnumerable<Type> subTypesToAdd = subTypes.Except(knownSubTypes);
foreach (Type subType in subTypesToAdd)
{
jsonPolymorphismOptions.DerivedTypes.Add(new JsonDerivedType(
subType,
subType.Name ?? string.Empty));
}
result.PolymorphismOptions = jsonPolymorphismOptions;
return result;
}
}