From 1fd4ed1de763cd6005a2aea5863f45de3a7497f6 Mon Sep 17 00:00:00 2001 From: Nikolaj Geisle <70372949+Zeegaan@users.noreply.github.com> Date: Mon, 12 Dec 2022 14:15:54 +0100 Subject: [PATCH] V12: Named json options (#13537) * Introduce named JSON options for specific input/output JSON formatting * Handle empty objects * Remove obsolete attributes * Update src/Umbraco.Cms.Api.Management/DependencyInjection/MvcBuilderExtensions.cs Co-authored-by: Mole * Add constant for Backoffice NamedJsonOptions Co-authored-by: kjac Co-authored-by: Mole Co-authored-by: Zeegaan --- .../Configuration/ConfigureMvcJsonOptions.cs | 31 ++++++++ .../ManagementApiControllerBase.cs | 3 +- .../MvcBuilderExtensions.cs | 22 ++++++ .../Filters/JsonOptionsNameAttribute.cs | 9 +++ ...ManagementApiJsonConfigurationAttribute.cs | 33 --------- .../ManagementApiJsonOutputFormatter.cs | 19 ----- .../Json/HttpContextJsonExtensions.cs | 10 +++ .../Json/JsonObjectConverter.cs | 73 +++++++++++++++++++ .../Json/NamedSystemTextJsonInputFormatter.cs | 17 +++++ .../NamedSystemTextJsonOutputFormatter.cs | 17 +++++ .../ManagementApiComposer.cs | 16 +++- .../Constants-JsonOptionsNames.cs | 15 ++++ .../Constants-OauthClientIds.cs | 2 +- 13 files changed, 212 insertions(+), 55 deletions(-) create mode 100644 src/Umbraco.Cms.Api.Management/Configuration/ConfigureMvcJsonOptions.cs create mode 100644 src/Umbraco.Cms.Api.Management/DependencyInjection/MvcBuilderExtensions.cs create mode 100644 src/Umbraco.Cms.Api.Management/Filters/JsonOptionsNameAttribute.cs delete mode 100644 src/Umbraco.Cms.Api.Management/Filters/ManagementApiJsonConfigurationAttribute.cs delete mode 100644 src/Umbraco.Cms.Api.Management/Filters/ManagementApiJsonOutputFormatter.cs create mode 100644 src/Umbraco.Cms.Api.Management/Json/HttpContextJsonExtensions.cs create mode 100644 src/Umbraco.Cms.Api.Management/Json/JsonObjectConverter.cs create mode 100644 src/Umbraco.Cms.Api.Management/Json/NamedSystemTextJsonInputFormatter.cs create mode 100644 src/Umbraco.Cms.Api.Management/Json/NamedSystemTextJsonOutputFormatter.cs create mode 100644 src/Umbraco.New.Cms.Core/Constants-JsonOptionsNames.cs diff --git a/src/Umbraco.Cms.Api.Management/Configuration/ConfigureMvcJsonOptions.cs b/src/Umbraco.Cms.Api.Management/Configuration/ConfigureMvcJsonOptions.cs new file mode 100644 index 0000000000..2bc51fa404 --- /dev/null +++ b/src/Umbraco.Cms.Api.Management/Configuration/ConfigureMvcJsonOptions.cs @@ -0,0 +1,31 @@ +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using Umbraco.Cms.Api.Management.Json; + +namespace Umbraco.Cms.Api.Management.Configuration; + +public class ConfigureMvcJsonOptions : IConfigureOptions +{ + private readonly string _jsonOptionsName; + private readonly IOptionsMonitor _jsonOptions; + private readonly ILoggerFactory _loggerFactory; + + public ConfigureMvcJsonOptions( + string jsonOptionsName, + IOptionsMonitor jsonOptions, + ILoggerFactory loggerFactory) + { + _jsonOptionsName = jsonOptionsName; + _jsonOptions = jsonOptions; + _loggerFactory = loggerFactory; + } + + public void Configure(MvcOptions options) + { + JsonOptions jsonOptions = _jsonOptions.Get(_jsonOptionsName); + ILogger logger = _loggerFactory.CreateLogger(); + options.InputFormatters.Insert(0, new NamedSystemTextJsonInputFormatter(_jsonOptionsName, jsonOptions, logger)); + options.OutputFormatters.Insert(0, new NamedSystemTextJsonOutputFormatter(_jsonOptionsName, jsonOptions.JsonSerializerOptions)); + } +} diff --git a/src/Umbraco.Cms.Api.Management/Controllers/ManagementApiControllerBase.cs b/src/Umbraco.Cms.Api.Management/Controllers/ManagementApiControllerBase.cs index f1f462bd0f..9447deff07 100644 --- a/src/Umbraco.Cms.Api.Management/Controllers/ManagementApiControllerBase.cs +++ b/src/Umbraco.Cms.Api.Management/Controllers/ManagementApiControllerBase.cs @@ -1,9 +1,10 @@ using Microsoft.AspNetCore.Mvc; using Umbraco.Cms.Api.Management.Filters; +using Umbraco.New.Cms.Core; namespace Umbraco.Cms.Api.Management.Controllers; -[ManagementApiJsonConfiguration] +[JsonOptionsName(Constants.JsonOptionsNames.BackOffice)] public class ManagementApiControllerBase : Controller { diff --git a/src/Umbraco.Cms.Api.Management/DependencyInjection/MvcBuilderExtensions.cs b/src/Umbraco.Cms.Api.Management/DependencyInjection/MvcBuilderExtensions.cs new file mode 100644 index 0000000000..6faf2e46eb --- /dev/null +++ b/src/Umbraco.Cms.Api.Management/DependencyInjection/MvcBuilderExtensions.cs @@ -0,0 +1,22 @@ +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using Umbraco.Cms.Api.Management.Configuration; + +namespace Umbraco.Cms.Api.Management.DependencyInjection; + +public static class MvcBuilderExtensions +{ + public static IMvcBuilder AddJsonOptions(this IMvcBuilder builder, string settingsName, Action configure) + { + builder.Services.Configure(settingsName, configure); + builder.Services.AddSingleton>(provider => + { + IOptionsMonitor options = provider.GetRequiredService>(); + ILoggerFactory loggerFactory = provider.GetRequiredService(); + return new ConfigureMvcJsonOptions(settingsName, options, loggerFactory); + }); + return builder; + } +} diff --git a/src/Umbraco.Cms.Api.Management/Filters/JsonOptionsNameAttribute.cs b/src/Umbraco.Cms.Api.Management/Filters/JsonOptionsNameAttribute.cs new file mode 100644 index 0000000000..2c92a577e1 --- /dev/null +++ b/src/Umbraco.Cms.Api.Management/Filters/JsonOptionsNameAttribute.cs @@ -0,0 +1,9 @@ +namespace Umbraco.Cms.Api.Management.Filters; + +[AttributeUsage(AttributeTargets.Class)] +public class JsonOptionsNameAttribute : Attribute +{ + public JsonOptionsNameAttribute(string jsonOptionsName) => JsonOptionsName = jsonOptionsName; + + public string JsonOptionsName { get; } +} diff --git a/src/Umbraco.Cms.Api.Management/Filters/ManagementApiJsonConfigurationAttribute.cs b/src/Umbraco.Cms.Api.Management/Filters/ManagementApiJsonConfigurationAttribute.cs deleted file mode 100644 index b8c7dcfb46..0000000000 --- a/src/Umbraco.Cms.Api.Management/Filters/ManagementApiJsonConfigurationAttribute.cs +++ /dev/null @@ -1,33 +0,0 @@ -using System.Text.Json; -using Microsoft.AspNetCore.Mvc; -using Microsoft.AspNetCore.Mvc.Filters; - -namespace Umbraco.Cms.Api.Management.Filters; - -public class ManagementApiJsonConfigurationAttribute : TypeFilterAttribute -{ - public ManagementApiJsonConfigurationAttribute() : base(typeof(SystemTextJsonConfigurationFilter)) => - Order = 1; // Must be low, to be overridden by other custom formatters, but higher then all framework stuff. - - private class SystemTextJsonConfigurationFilter : IResultFilter - { - public void OnResultExecuted(ResultExecutedContext context) - { - } - - public void OnResultExecuting(ResultExecutingContext context) - { - if (context.Result is ObjectResult objectResult) - { - var serializerOptions = new JsonSerializerOptions - { - PropertyNamingPolicy = JsonNamingPolicy.CamelCase, - }; - objectResult.Formatters.Clear(); - objectResult.Formatters.Add(new ManagementApiJsonOutputFormatter(serializerOptions)); - } - } - } -} - - diff --git a/src/Umbraco.Cms.Api.Management/Filters/ManagementApiJsonOutputFormatter.cs b/src/Umbraco.Cms.Api.Management/Filters/ManagementApiJsonOutputFormatter.cs deleted file mode 100644 index f6494dc9e1..0000000000 --- a/src/Umbraco.Cms.Api.Management/Filters/ManagementApiJsonOutputFormatter.cs +++ /dev/null @@ -1,19 +0,0 @@ -using System.Text.Json; -using System.Text.Json.Serialization; -using Microsoft.AspNetCore.Mvc.Formatters; - -namespace Umbraco.Cms.Api.Management.Filters; - -public class ManagementApiJsonOutputFormatter : SystemTextJsonOutputFormatter -{ - public ManagementApiJsonOutputFormatter(JsonSerializerOptions jsonSerializerOptions) : base(RegisterJsonConverters(jsonSerializerOptions)) - { - } - - protected static JsonSerializerOptions RegisterJsonConverters(JsonSerializerOptions serializerOptions) - { - serializerOptions.Converters.Add(new JsonStringEnumConverter()); - - return serializerOptions; - } -} diff --git a/src/Umbraco.Cms.Api.Management/Json/HttpContextJsonExtensions.cs b/src/Umbraco.Cms.Api.Management/Json/HttpContextJsonExtensions.cs new file mode 100644 index 0000000000..d891e4e82e --- /dev/null +++ b/src/Umbraco.Cms.Api.Management/Json/HttpContextJsonExtensions.cs @@ -0,0 +1,10 @@ +using Microsoft.AspNetCore.Http; +using Umbraco.Cms.Api.Management.Filters; + +namespace Umbraco.Cms.Api.Management.Json; + +public static class HttpContextJsonExtensions +{ + public static string? CurrentJsonOptionsName(this HttpContext context) + => context.GetEndpoint()?.Metadata.GetMetadata()?.JsonOptionsName; +} diff --git a/src/Umbraco.Cms.Api.Management/Json/JsonObjectConverter.cs b/src/Umbraco.Cms.Api.Management/Json/JsonObjectConverter.cs new file mode 100644 index 0000000000..6f475e045a --- /dev/null +++ b/src/Umbraco.Cms.Api.Management/Json/JsonObjectConverter.cs @@ -0,0 +1,73 @@ +using System.Text.Json; +using System.Text.Json.Nodes; +using System.Text.Json.Serialization; + +namespace Umbraco.Cms.Api.Management.Json; + +public class JsonObjectConverter : JsonConverter +{ + public override object Read( + ref Utf8JsonReader reader, + Type typeToConvert, + JsonSerializerOptions options) => + ParseObject(ref reader); + + public override void Write( + Utf8JsonWriter writer, + object objectToWrite, + JsonSerializerOptions options) + { + if (objectToWrite is null) + { + return; + } + + // If an object is equals "new object()", Json.Serialize would recurse forever and cause a stack overflow + // We have no good way of checking if its an empty object + // which is why we try to check if the object has any properties, and thus will be empty. + if (objectToWrite.GetType().Name is "Object" && !objectToWrite.GetType().GetProperties().Any()) + { + writer.WriteStartObject(); + writer.WriteEndObject(); + } + else + { + JsonSerializer.Serialize(writer, objectToWrite, objectToWrite.GetType(), options); + } + } + + private object ParseObject(ref Utf8JsonReader reader) + { + if (reader.TokenType == JsonTokenType.StartArray) + { + var items = new List(); + while (reader.Read() && reader.TokenType != JsonTokenType.EndArray) + { + items.Add(ParseObject(ref reader)); + } + + return items.ToArray(); + } + + if (reader.TokenType == JsonTokenType.StartObject) + { + var jsonNode = JsonNode.Parse(ref reader); + if (jsonNode is JsonObject jsonObject) + { + return jsonObject; + } + } + + return reader.TokenType switch + { + JsonTokenType.True => true, + JsonTokenType.False => false, + JsonTokenType.Number when reader.TryGetInt32(out int i) => i, + JsonTokenType.Number when reader.TryGetInt64(out long l) => l, + JsonTokenType.Number => reader.GetDouble(), + JsonTokenType.String when reader.TryGetDateTime(out DateTime datetime) => datetime, + JsonTokenType.String => reader.GetString()!, + _ => JsonDocument.ParseValue(ref reader).RootElement.Clone() + }; + } +} diff --git a/src/Umbraco.Cms.Api.Management/Json/NamedSystemTextJsonInputFormatter.cs b/src/Umbraco.Cms.Api.Management/Json/NamedSystemTextJsonInputFormatter.cs new file mode 100644 index 0000000000..42548a1b33 --- /dev/null +++ b/src/Umbraco.Cms.Api.Management/Json/NamedSystemTextJsonInputFormatter.cs @@ -0,0 +1,17 @@ +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.Formatters; +using Microsoft.Extensions.Logging; + +namespace Umbraco.Cms.Api.Management.Json; + +public class NamedSystemTextJsonInputFormatter : SystemTextJsonInputFormatter +{ + private readonly string _jsonOptionsName; + + public NamedSystemTextJsonInputFormatter(string jsonOptionsName, JsonOptions options, ILogger logger) + : base(options, logger) => + _jsonOptionsName = jsonOptionsName; + + public override bool CanRead(InputFormatterContext context) + => context.HttpContext.CurrentJsonOptionsName() == _jsonOptionsName && base.CanRead(context); +} diff --git a/src/Umbraco.Cms.Api.Management/Json/NamedSystemTextJsonOutputFormatter.cs b/src/Umbraco.Cms.Api.Management/Json/NamedSystemTextJsonOutputFormatter.cs new file mode 100644 index 0000000000..fd5f6e158f --- /dev/null +++ b/src/Umbraco.Cms.Api.Management/Json/NamedSystemTextJsonOutputFormatter.cs @@ -0,0 +1,17 @@ +using System.Text.Json; +using Microsoft.AspNetCore.Mvc.Formatters; + +namespace Umbraco.Cms.Api.Management.Json; + +public class NamedSystemTextJsonOutputFormatter : SystemTextJsonOutputFormatter +{ + private readonly string _jsonOptionsName; + + public NamedSystemTextJsonOutputFormatter(string jsonOptionsName, JsonSerializerOptions jsonSerializerOptions) : base(jsonSerializerOptions) + { + _jsonOptionsName = jsonOptionsName; + } + + public override bool CanWriteResult(OutputFormatterCanWriteContext context) + => context.HttpContext.CurrentJsonOptionsName() == _jsonOptionsName && base.CanWriteResult(context); +} diff --git a/src/Umbraco.Cms.Api.Management/ManagementApiComposer.cs b/src/Umbraco.Cms.Api.Management/ManagementApiComposer.cs index 372416d61c..513a8c0dfe 100644 --- a/src/Umbraco.Cms.Api.Management/ManagementApiComposer.cs +++ b/src/Umbraco.Cms.Api.Management/ManagementApiComposer.cs @@ -1,3 +1,5 @@ +using System.Text.Json; +using System.Text.Json.Serialization; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Diagnostics; using Microsoft.AspNetCore.Hosting; @@ -15,6 +17,7 @@ using Umbraco.Cms.Core.Configuration.Models; using Umbraco.Cms.Core.DependencyInjection; using Umbraco.Cms.Api.Common.Configuration; using Umbraco.Cms.Api.Management.DependencyInjection; +using Umbraco.Cms.Api.Management.Json; using Umbraco.Cms.Api.Management.OpenApi; using Umbraco.Cms.Web.Common.ApplicationBuilder; using Umbraco.Extensions; @@ -158,7 +161,18 @@ public class ManagementApiComposer : IComposer options.AddApiVersionParametersWhenVersionNeutral = true; options.AssumeDefaultVersionWhenUnspecified = true; }); - services.AddControllers(); + services.AddControllers() + .AddJsonOptions(options => + { + // any generic JSON options go here + }) + .AddJsonOptions(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()); + }); builder.Services.ConfigureOptions(); // TODO: when this is moved to core, make the AddUmbracoOptions extension private again and remove core InternalsVisibleTo for Umbraco.Cms.Api.Management diff --git a/src/Umbraco.New.Cms.Core/Constants-JsonOptionsNames.cs b/src/Umbraco.New.Cms.Core/Constants-JsonOptionsNames.cs new file mode 100644 index 0000000000..e7841c1607 --- /dev/null +++ b/src/Umbraco.New.Cms.Core/Constants-JsonOptionsNames.cs @@ -0,0 +1,15 @@ +namespace Umbraco.New.Cms.Core; + +public static partial class Constants +{ + // TODO: move this class to Umbraco.Cms.Core as a partial class + public static partial class JsonOptionsNames + { + /// + /// Name used for JsonOptions + /// + public const string BackOffice = "BackOffice"; + } +} + + diff --git a/src/Umbraco.New.Cms.Core/Constants-OauthClientIds.cs b/src/Umbraco.New.Cms.Core/Constants-OauthClientIds.cs index 2fdc54e011..02f190f0ab 100644 --- a/src/Umbraco.New.Cms.Core/Constants-OauthClientIds.cs +++ b/src/Umbraco.New.Cms.Core/Constants-OauthClientIds.cs @@ -1,7 +1,7 @@ namespace Umbraco.New.Cms.Core; // TODO: move this class to Umbraco.Cms.Core as a partial class -public static class Constants +public static partial class Constants { public static partial class OauthClientIds {