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:
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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}";
|
||||
}
|
||||
|
||||
@@ -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
@@ -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}";
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
using System.Text.Json.Serialization.Metadata;
|
||||
|
||||
namespace Umbraco.Cms.Infrastructure.Serialization;
|
||||
|
||||
public interface IUmbracoJsonTypeInfoResolver : IJsonTypeInfoResolver
|
||||
{
|
||||
IEnumerable<Type> FindSubTypes(Type type);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user