diff --git a/src/Microsoft.AspNetCore.Mvc.Formatters.Json/MvcJsonOptionsExtensions.cs b/src/Microsoft.AspNetCore.Mvc.Formatters.Json/MvcJsonOptionsExtensions.cs new file mode 100644 index 0000000000..876325d281 --- /dev/null +++ b/src/Microsoft.AspNetCore.Mvc.Formatters.Json/MvcJsonOptionsExtensions.cs @@ -0,0 +1,88 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.Formatters.Json; +using Newtonsoft.Json; +using Newtonsoft.Json.Serialization; + +namespace Microsoft.Extensions.DependencyInjection +{ + public static class MvcJsonOptionsExtensions + { + /// + /// Configures the casing behavior of JSON serialization to use camel case for property names, + /// and optionally for dynamic types and dictionary keys. + /// + /// + /// This method modifies . + /// + /// + /// If true will camel case dictionary keys and properties of dynamic objects. + /// with camel case settings. + public static MvcJsonOptions UseCamelCasing(this MvcJsonOptions options, bool processDictionaryKeys) + { + if (options == null) + { + throw new ArgumentNullException(nameof(options)); + } + + if (options.SerializerSettings.ContractResolver is DefaultContractResolver resolver) + { + resolver.NamingStrategy = new CamelCaseNamingStrategy + { + ProcessDictionaryKeys = processDictionaryKeys + }; + } + else + { + if (options.SerializerSettings.ContractResolver == null) + { + throw new InvalidOperationException(Resources.FormatContractResolverCannotBeNull(nameof(JsonSerializerSettings.ContractResolver))); + } + + var contractResolverName = options.SerializerSettings.ContractResolver.GetType().Name; + throw new InvalidOperationException( + Resources.FormatInvalidContractResolverForJsonCasingConfiguration(contractResolverName, nameof(DefaultContractResolver))); + } + + return options; + } + + /// + /// Configures the casing behavior of JSON serialization to use the member's casing for property names, + /// properties of dynamic types, and dictionary keys. + /// + /// + /// This method modifies . + /// + /// + /// with member casing settings. + public static MvcJsonOptions UseMemberCasing(this MvcJsonOptions options) + { + if (options == null) + { + throw new ArgumentNullException(nameof(options)); + } + + if (options.SerializerSettings.ContractResolver is DefaultContractResolver resolver) + { + resolver.NamingStrategy = new DefaultNamingStrategy(); + } + else + { + if (options.SerializerSettings.ContractResolver == null) + { + throw new InvalidOperationException(Resources.FormatContractResolverCannotBeNull(nameof(JsonSerializerSettings.ContractResolver))); + } + + var contractResolverName = options.SerializerSettings.ContractResolver.GetType().Name; + throw new InvalidOperationException( + Resources.FormatInvalidContractResolverForJsonCasingConfiguration(contractResolverName, nameof(DefaultContractResolver))); + } + + return options; + } + } +} \ No newline at end of file diff --git a/src/Microsoft.AspNetCore.Mvc.Formatters.Json/Properties/AssemblyInfo.cs b/src/Microsoft.AspNetCore.Mvc.Formatters.Json/Properties/AssemblyInfo.cs new file mode 100644 index 0000000000..7982959233 --- /dev/null +++ b/src/Microsoft.AspNetCore.Mvc.Formatters.Json/Properties/AssemblyInfo.cs @@ -0,0 +1,6 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System.Runtime.CompilerServices; + +[assembly: InternalsVisibleTo("Microsoft.AspNetCore.Mvc.Formatters.Json.Test, PublicKey=0024000004800000940000000602000000240000525341310004000001000100f33a29044fa9d740c9b3213a93e57c84b472c84e0b8a0e1ae48e67a9f8f6de9d5f7f3d52ac23e48ac51801f1dc950abe901da34d2a9e3baadb141a17c77ef3c565dd5ee5054b91cf63bb3c6ab83f72ab3aafe93d0fc3c2348b764fafb0b1c0733de51459aeab46580384bf9d74c4e28164b7cde247f891ba07891c9d872ad2bb")] diff --git a/src/Microsoft.AspNetCore.Mvc.Formatters.Json/Properties/Resources.Designer.cs b/src/Microsoft.AspNetCore.Mvc.Formatters.Json/Properties/Resources.Designer.cs new file mode 100644 index 0000000000..b552db2345 --- /dev/null +++ b/src/Microsoft.AspNetCore.Mvc.Formatters.Json/Properties/Resources.Designer.cs @@ -0,0 +1,58 @@ +// +namespace Microsoft.AspNetCore.Mvc.Formatters.Json +{ + using System.Globalization; + using System.Reflection; + using System.Resources; + + internal static class Resources + { + private static readonly ResourceManager _resourceManager + = new ResourceManager("Microsoft.AspNetCore.Mvc.Formatters.Json.Resources", typeof(Resources).GetTypeInfo().Assembly); + + /// + /// {0} cannot be null. + /// + internal static string ContractResolverCannotBeNull + { + get => GetString("ContractResolverCannotBeNull"); + } + + /// + /// {0} cannot be null. + /// + internal static string FormatContractResolverCannotBeNull(object p0) + => string.Format(CultureInfo.CurrentCulture, GetString("ContractResolverCannotBeNull"), p0); + + /// + /// Cannot configure JSON casing behavior on '{0}' contract resolver. The supported contract resolver is {1}. + /// + internal static string InvalidContractResolverForJsonCasingConfiguration + { + get => GetString("InvalidContractResolverForJsonCasingConfiguration"); + } + + /// + /// Cannot configure JSON casing behavior on '{0}' contract resolver. The supported contract resolver is {1}. + /// + internal static string FormatInvalidContractResolverForJsonCasingConfiguration(object p0, object p1) + => string.Format(CultureInfo.CurrentCulture, GetString("InvalidContractResolverForJsonCasingConfiguration"), p0, p1); + + private static string GetString(string name, params string[] formatterNames) + { + var value = _resourceManager.GetString(name); + + System.Diagnostics.Debug.Assert(value != null); + + if (formatterNames != null) + { + for (var i = 0; i < formatterNames.Length; i++) + { + value = value.Replace("{" + formatterNames[i] + "}", "{" + i + "}"); + } + } + + return value; + } + } +} diff --git a/src/Microsoft.AspNetCore.Mvc.Formatters.Json/Resources.resx b/src/Microsoft.AspNetCore.Mvc.Formatters.Json/Resources.resx new file mode 100644 index 0000000000..729545d687 --- /dev/null +++ b/src/Microsoft.AspNetCore.Mvc.Formatters.Json/Resources.resx @@ -0,0 +1,126 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + {0} cannot be null. + + + Cannot configure JSON casing behavior on '{0}' contract resolver. The supported contract resolver is {1}. + + \ No newline at end of file diff --git a/test/Microsoft.AspNetCore.Mvc.Formatters.Json.Test/MvcJsonOptionsExtensionsTests.cs b/test/Microsoft.AspNetCore.Mvc.Formatters.Json.Test/MvcJsonOptionsExtensionsTests.cs new file mode 100644 index 0000000000..b3370c312a --- /dev/null +++ b/test/Microsoft.AspNetCore.Mvc.Formatters.Json.Test/MvcJsonOptionsExtensionsTests.cs @@ -0,0 +1,264 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Collections.Generic; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.Formatters.Json; +using Newtonsoft.Json; +using Newtonsoft.Json.Serialization; +using Xunit; + +namespace Microsoft.Extensions.DependencyInjection +{ + public class MvcJsonOptionsExtensionsTests + { + [Fact] + public void UseCamelCasing_WillSet_CamelCasingStrategy_NameStrategy() + { + // Arrange + var options = new MvcJsonOptions(); + options.SerializerSettings.ContractResolver = new DefaultContractResolver() + { + NamingStrategy = new DefaultNamingStrategy() + }; + var expected = typeof(CamelCaseNamingStrategy); + + // Act + options.UseCamelCasing(processDictionaryKeys: true); + var resolver = options.SerializerSettings.ContractResolver as DefaultContractResolver; + var actual = resolver.NamingStrategy; + + // Assert + Assert.IsType(expected, actual); + } + + [Fact] + public void UseCamelCasing_WillNot_OverrideSpecifiedNames() + { + // Arrange + var options = new MvcJsonOptions().UseCamelCasing(processDictionaryKeys: true); + var annotatedFoo = new AnnotatedFoo() + { + HelloWorld = "Hello" + }; + var expected = "{\"HELLO-WORLD\":\"Hello\"}"; + + // Act + var actual = SerializeToJson(options, value: annotatedFoo); + + // Assert + Assert.Equal(expected, actual); + } + + [Fact] + public void UseCamelCasing_WillChange_PropertyNames() + { + // Arrange + var options = new MvcJsonOptions().UseCamelCasing(processDictionaryKeys: true); + var foo = new { TestName = "TestFoo", TestValue = 10 }; + var expected = "{\"testName\":\"TestFoo\",\"testValue\":10}"; + + // Act + var actual = SerializeToJson(options, value: foo); + + // Assert + Assert.Equal(expected, actual); + } + + [Fact] + public void UseCamelCasing_WillChangeFirstPartBeforeSeparator_InPropertyName() + { + // Arrange + var options = new MvcJsonOptions().UseCamelCasing(processDictionaryKeys: true); + var foo = new { TestFoo_TestValue = "Test" }; + var expected = "{\"testFoo_TestValue\":\"Test\"}"; + + // Act + var actual = SerializeToJson(options, value: foo); + + // Assert + Assert.Equal(expected, actual); + } + + [Fact] + public void UseCamelCasing_ProcessDictionaryKeys_WillChange_DictionaryKeys_IfTrue() + { + // Arrange + var options = new MvcJsonOptions().UseCamelCasing(processDictionaryKeys: true); + var dictionary = new Dictionary + { + ["HelloWorld"] = 1, + ["HELLOWORLD"] = 2 + }; + var expected = "{\"helloWorld\":1,\"helloworld\":2}"; + + // Act + var actual = SerializeToJson(options, value: dictionary); + + // Assert + Assert.Equal(expected, actual); + } + + [Fact] + public void UseCamelCasing_ProcessDictionaryKeys_WillChangeFirstPartBeforeSeparator_InDictionaryKey_IfTrue() + { + // Arrange + var options = new MvcJsonOptions().UseCamelCasing(processDictionaryKeys: true); + var dictionary = new Dictionary() + { + ["HelloWorld_HelloWorld"] = 1 + }; + + var expected = "{\"helloWorld_HelloWorld\":1}"; + + // Act + var actual = SerializeToJson(options, value: dictionary); + + // Assert + Assert.Equal(expected, actual); + } + + [Fact] + public void UseCamelCasing_ProcessDictionaryKeys_WillNotChangeDictionaryKeys_IfFalse() + { + // Arrange + var options = new MvcJsonOptions().UseCamelCasing(processDictionaryKeys: false); + var dictionary = new Dictionary + { + ["HelloWorld"] = 1, + ["HELLO-WORLD"] = 2 + }; + var expected = "{\"HelloWorld\":1,\"HELLO-WORLD\":2}"; + + // Act + var actual = SerializeToJson(options, value: dictionary); + + // Assert + Assert.Equal(expected, actual); + } + + [Fact] + public void UseMemberCasing_WillNotChange_OverrideSpecifiedNames() + { + // Arrange + var options = new MvcJsonOptions().UseMemberCasing(); + var annotatedFoo = new AnnotatedFoo() + { + HelloWorld = "Hello" + }; + var expected = "{\"HELLO-WORLD\":\"Hello\"}"; + + // Act + var actual = SerializeToJson(options, value: annotatedFoo); + + // Assert + Assert.Equal(expected, actual); + } + + [Fact] + public void UseMemberCasing_WillSet_DefaultNamingStrategy_AsNamingStrategy() + { + // Arrange + var options = new MvcJsonOptions(); + options.SerializerSettings.ContractResolver = new DefaultContractResolver + { + NamingStrategy = new CamelCaseNamingStrategy() + }; + var expected = typeof(DefaultNamingStrategy); + + // Act + options.UseMemberCasing(); + var resolver = options.SerializerSettings.ContractResolver as DefaultContractResolver; + var actual = resolver.NamingStrategy; + + // Assert + Assert.IsType(expected, actual); + } + + [Fact] + public void UseMemberCasing_WillNotChange_PropertyNames() + { + // Arrange + var options = new MvcJsonOptions().UseMemberCasing(); + var foo = new { fooName = "Test", FooValue = "Value"}; + var expected = "{\"fooName\":\"Test\",\"FooValue\":\"Value\"}"; + + // Act + var actual = SerializeToJson(options, value: foo); + + // Assert + Assert.Equal(expected, actual); + } + + [Fact] + public void UseMemberCasing_WillNotChange_DictionaryKeys() + { + // Arrange + var options = new MvcJsonOptions().UseMemberCasing(); + var dictionary = new Dictionary() + { + ["HelloWorld"] = 1, + ["helloWorld"] = 2, + ["HELLO-WORLD"] = 3 + }; + var expected = "{\"HelloWorld\":1,\"helloWorld\":2,\"HELLO-WORLD\":3}"; + + // Act + var actual = SerializeToJson(options, value: dictionary); + + // Assert + Assert.Equal(expected, actual); + } + + [Fact] + public void UseCamelCasing_WillThrow_IfContractResolver_IsNot_DefaultContractResolver() + { + // Arrange + var options = new MvcJsonOptions(); + options.SerializerSettings.ContractResolver = new FooContractResolver(); + var expectedMessage = Resources.FormatInvalidContractResolverForJsonCasingConfiguration(nameof(FooContractResolver), nameof(DefaultContractResolver)); + + // Act & Assert + var exception = Assert.Throws( + () => options.UseCamelCasing(processDictionaryKeys: false)); + Assert.Equal(expectedMessage, actual: exception.Message); + } + + [Fact] + public void UseMemberCasing_WillThrow_IfContractResolver_IsNot_DefaultContractResolver() + { + // Arrange + var options = new MvcJsonOptions(); + options.SerializerSettings.ContractResolver = new FooContractResolver(); + var expectedMessage = Resources.FormatInvalidContractResolverForJsonCasingConfiguration(nameof(FooContractResolver), nameof(DefaultContractResolver)); + + // Act & Assert + var exception = Assert.Throws( + () => options.UseMemberCasing()); + Assert.Equal(expectedMessage, actual: exception.Message); + } + + private static string SerializeToJson(MvcJsonOptions options, object value) + { + return JsonConvert.SerializeObject( + value: value, + formatting: Formatting.None, + settings: options.SerializerSettings); + } + + private class AnnotatedFoo + { + [JsonProperty("HELLO-WORLD")] + public string HelloWorld { get; set; } + } + + private class FooContractResolver : IContractResolver + { + public JsonContract ResolveContract(Type type) + { + throw new NotImplementedException(); + } + } + } +}