diff --git a/samples/StandaloneApp/wwwroot/sample-data/weather.json b/samples/StandaloneApp/wwwroot/sample-data/weather.json index 2b8e8e3dd..2f9914fc5 100644 --- a/samples/StandaloneApp/wwwroot/sample-data/weather.json +++ b/samples/StandaloneApp/wwwroot/sample-data/weather.json @@ -1,32 +1,32 @@ [ { - "DateFormatted": "06/05/2018", - "TemperatureC": 1, - "Summary": "Freezing", - "TemperatureF": 33 + "dateFormatted": "06/05/2018", + "temperatureC": 1, + "summary": "Freezing", + "temperatureF": 33 }, { - "DateFormatted": "07/05/2018", - "TemperatureC": 14, - "Summary": "Bracing", - "TemperatureF": 57 + "dateFormatted": "07/05/2018", + "temperatureC": 14, + "summary": "Bracing", + "temperatureF": 57 }, { - "DateFormatted": "08/05/2018", - "TemperatureC": -13, - "Summary": "Freezing", - "TemperatureF": 9 + "dateFormatted": "08/05/2018", + "temperatureC": -13, + "summary": "Freezing", + "temperatureF": 9 }, { - "DateFormatted": "09/05/2018", - "TemperatureC": -16, - "Summary": "Balmy", - "TemperatureF": 4 + "dateFormatted": "09/05/2018", + "temperatureC": -16, + "summary": "Balmy", + "temperatureF": 4 }, { - "DateFormatted": "10/05/2018", - "TemperatureC": -2, - "Summary": "Chilly", - "TemperatureF": 29 + "dateFormatted": "10/05/2018", + "temperatureC": -2, + "summary": "Chilly", + "temperatureF": 29 } ] diff --git a/src/Microsoft.AspNetCore.Blazor.Templates/content/BlazorHosted-CSharp/BlazorHosted-CSharp.Server/Startup.cs b/src/Microsoft.AspNetCore.Blazor.Templates/content/BlazorHosted-CSharp/BlazorHosted-CSharp.Server/Startup.cs index 959a06825..5415ef2a2 100644 --- a/src/Microsoft.AspNetCore.Blazor.Templates/content/BlazorHosted-CSharp/BlazorHosted-CSharp.Server/Startup.cs +++ b/src/Microsoft.AspNetCore.Blazor.Templates/content/BlazorHosted-CSharp/BlazorHosted-CSharp.Server/Startup.cs @@ -18,10 +18,7 @@ public class Startup // For more information on how to configure your application, visit https://go.microsoft.com/fwlink/?LinkID=398940 public void ConfigureServices(IServiceCollection services) { - services.AddMvc().AddJsonOptions(options => - { - options.SerializerSettings.ContractResolver = new DefaultContractResolver(); - }); + services.AddMvc(); services.AddResponseCompression(options => { diff --git a/src/Microsoft.AspNetCore.Blazor.Templates/content/BlazorStandalone-CSharp/wwwroot/sample-data/weather.json b/src/Microsoft.AspNetCore.Blazor.Templates/content/BlazorStandalone-CSharp/wwwroot/sample-data/weather.json index 2681555cf..ab28bc13a 100644 --- a/src/Microsoft.AspNetCore.Blazor.Templates/content/BlazorStandalone-CSharp/wwwroot/sample-data/weather.json +++ b/src/Microsoft.AspNetCore.Blazor.Templates/content/BlazorStandalone-CSharp/wwwroot/sample-data/weather.json @@ -1,32 +1,32 @@ [ { - "Date": "2018-05-06", - "TemperatureC": 1, - "Summary": "Freezing", - "TemperatureF": 33 + "date": "2018-05-06", + "temperatureC": 1, + "summary": "Freezing", + "temperatureF": 33 }, { - "Date": "2018-05-07", - "TemperatureC": 14, - "Summary": "Bracing", - "TemperatureF": 57 + "date": "2018-05-07", + "temperatureC": 14, + "summary": "Bracing", + "temperatureF": 57 }, { - "Date": "2018-05-08", - "TemperatureC": -13, - "Summary": "Freezing", - "TemperatureF": 9 + "date": "2018-05-08", + "temperatureC": -13, + "summary": "Freezing", + "temperatureF": 9 }, { - "Date": "2018-05-09", - "TemperatureC": -16, - "Summary": "Balmy", - "TemperatureF": 4 + "date": "2018-05-09", + "temperatureC": -16, + "summary": "Balmy", + "temperatureF": 4 }, { - "Date": "2018-05-10", - "TemperatureC": -2, - "Summary": "Chilly", - "TemperatureF": 29 + "date": "2018-05-10", + "temperatureC": -2, + "summary": "Chilly", + "temperatureF": 29 } ] diff --git a/src/Microsoft.AspNetCore.Blazor/Json/CamelCase.cs b/src/Microsoft.AspNetCore.Blazor/Json/CamelCase.cs new file mode 100644 index 000000000..f0d122e02 --- /dev/null +++ b/src/Microsoft.AspNetCore.Blazor/Json/CamelCase.cs @@ -0,0 +1,59 @@ +// 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; + +namespace Microsoft.AspNetCore.Blazor.Json +{ + internal static class CamelCase + { + public static string MemberNameToCamelCase(string value) + { + if (string.IsNullOrEmpty(value)) + { + throw new ArgumentException( + $"The value '{value ?? "null"}' is not a valid member name.", + nameof(value)); + } + + // If we don't need to modify the value, bail out without creating a char array + if (!char.IsUpper(value[0])) + { + return value; + } + + // We have to modify at least one character + var chars = value.ToCharArray(); + + var length = chars.Length; + if (length < 2 || !char.IsUpper(chars[1])) + { + // Only the first character needs to be modified + // Note that this branch is functionally necessary, because the 'else' branch below + // never looks at char[1]. It's always looking at the n+2 character. + chars[0] = char.ToLowerInvariant(chars[0]); + } + else + { + // If chars[0] and chars[1] are both upper, then we'll lowercase the first char plus + // any consecutive uppercase ones, stopping if we find any char that is followed by a + // non-uppercase one + var i = 0; + while (i < length) + { + chars[i] = char.ToLowerInvariant(chars[i]); + + i++; + + // If the next-plus-one char isn't also uppercase, then we're now on the last uppercase, so stop + if (i < length - 1 && !char.IsUpper(chars[i + 1])) + { + break; + } + } + } + + return new string(chars); + } + } +} diff --git a/src/Microsoft.AspNetCore.Blazor/Json/SimpleJson/SimpleJson.cs b/src/Microsoft.AspNetCore.Blazor/Json/SimpleJson/SimpleJson.cs index feb8ca5ac..f359bebc3 100644 --- a/src/Microsoft.AspNetCore.Blazor/Json/SimpleJson/SimpleJson.cs +++ b/src/Microsoft.AspNetCore.Blazor/Json/SimpleJson/SimpleJson.cs @@ -1268,7 +1268,7 @@ public PocoJsonSerializerStrategy() protected virtual string MapClrMemberNameToJsonFieldName(string clrPropertyName) { - return clrPropertyName; + return CamelCase.MemberNameToCamelCase(clrPropertyName); } internal virtual ReflectionUtils.ConstructorDelegate ConstructorDelegateFactory(Type key) @@ -1302,7 +1302,20 @@ internal virtual ReflectionUtils.ConstructorDelegate ConstructorDelegateFactory( internal virtual IDictionary> SetterValueFactory(Type type) { - IDictionary> result = new Dictionary>(); + // BLAZOR-SPECIFIC MODIFICATION FROM STOCK SIMPLEJSON: + // + // For incoming keys we match case-insensitively. But if two .NET properties differ only by case, + // it's ambiguous which should be used: the one that matches the incoming JSON exactly, or the + // one that uses 'correct' PascalCase corresponding to the incoming camelCase? What if neither + // meets these descriptions? + // + // To resolve this: + // - If multiple public properties differ only by case, we throw + // - If multiple public fields differ only by case, we throw + // - If there's a public property and a public field that differ only by case, we prefer the property + // This unambiguously selects one member, and that's what we'll use. + + IDictionary> result = new Dictionary>(StringComparer.OrdinalIgnoreCase); foreach (PropertyInfo propertyInfo in ReflectionUtils.GetProperties(type)) { if (propertyInfo.CanWrite) @@ -1310,15 +1323,30 @@ internal virtual ReflectionUtils.ConstructorDelegate ConstructorDelegateFactory( MethodInfo setMethod = ReflectionUtils.GetSetterMethodInfo(propertyInfo); if (setMethod.IsStatic || !setMethod.IsPublic) continue; - result[MapClrMemberNameToJsonFieldName(propertyInfo.Name)] = new KeyValuePair(propertyInfo.PropertyType, ReflectionUtils.GetSetMethod(propertyInfo)); + if (result.ContainsKey(propertyInfo.Name)) + { + throw new InvalidOperationException($"The type '{type.FullName}' contains multiple public properties with names case-insensitively matching '{propertyInfo.Name.ToLowerInvariant()}'. Such types cannot be used for JSON deserialization."); + } + result[propertyInfo.Name] = new KeyValuePair(propertyInfo.PropertyType, ReflectionUtils.GetSetMethod(propertyInfo)); } } + + IDictionary> fieldResult = new Dictionary>(StringComparer.OrdinalIgnoreCase); foreach (FieldInfo fieldInfo in ReflectionUtils.GetFields(type)) { if (fieldInfo.IsInitOnly || fieldInfo.IsStatic || !fieldInfo.IsPublic) continue; - result[MapClrMemberNameToJsonFieldName(fieldInfo.Name)] = new KeyValuePair(fieldInfo.FieldType, ReflectionUtils.GetSetMethod(fieldInfo)); + if (fieldResult.ContainsKey(fieldInfo.Name)) + { + throw new InvalidOperationException($"The type '{type.FullName}' contains multiple public fields with names case-insensitively matching '{fieldInfo.Name.ToLowerInvariant()}'. Such types cannot be used for JSON deserialization."); + } + fieldResult[fieldInfo.Name] = new KeyValuePair(fieldInfo.FieldType, ReflectionUtils.GetSetMethod(fieldInfo)); + if (!result.ContainsKey(fieldInfo.Name)) + { + result[fieldInfo.Name] = fieldResult[fieldInfo.Name]; + } } + return result; } @@ -1435,13 +1463,13 @@ public virtual object DeserializeObject(object value, Type type) ?? throw new InvalidOperationException($"Cannot deserialize JSON into type '{type.FullName}' because it does not have a public parameterless constructor."); obj = constructorDelegate(); - foreach (KeyValuePair> setter in SetCache[type]) + var setterCache = SetCache[type]; + foreach (var jsonKeyValuePair in jsonObject) { - object jsonValue; - if (jsonObject.TryGetValue(setter.Key, out jsonValue)) + if (setterCache.TryGetValue(jsonKeyValuePair.Key, out var setter)) { - jsonValue = DeserializeObject(jsonValue, setter.Value.Key); - setter.Value.Value(obj, jsonValue); + var jsonValue = DeserializeObject(jsonKeyValuePair.Value, setter.Key); + setter.Value(obj, jsonValue); } } } diff --git a/test/Microsoft.AspNetCore.Blazor.Test/JsonUtilTest.cs b/test/Microsoft.AspNetCore.Blazor.Test/JsonUtilTest.cs index abf25db9e..bdaaa4613 100644 --- a/test/Microsoft.AspNetCore.Blazor.Test/JsonUtilTest.cs +++ b/test/Microsoft.AspNetCore.Blazor.Test/JsonUtilTest.cs @@ -53,12 +53,13 @@ public void CanSerializeClassToJson() Hobby = Hobbies.Swordfighting, Nicknames = new List { "Comte de la Fère", "Armand" }, BirthInstant = new DateTimeOffset(1825, 8, 6, 18, 45, 21, TimeSpan.FromHours(-6)), - Age = new TimeSpan(7665, 1, 30, 0) + Age = new TimeSpan(7665, 1, 30, 0), + Allergies = new Dictionary { { "Ducks", true }, { "Geese", false } }, }; // Act/Assert Assert.Equal( - "{\"Id\":1844,\"Name\":\"Athos\",\"Pets\":[\"Aramis\",\"Porthos\",\"D'Artagnan\"],\"Hobby\":2,\"Nicknames\":[\"Comte de la Fère\",\"Armand\"],\"BirthInstant\":\"1825-08-06T18:45:21.0000000-06:00\",\"Age\":\"7665.01:30:00\"}", + "{\"id\":1844,\"name\":\"Athos\",\"pets\":[\"Aramis\",\"Porthos\",\"D'Artagnan\"],\"hobby\":2,\"nicknames\":[\"Comte de la Fère\",\"Armand\"],\"birthInstant\":\"1825-08-06T18:45:21.0000000-06:00\",\"age\":\"7665.01:30:00\",\"allergies\":{\"Ducks\":true,\"Geese\":false}}", JsonUtil.Serialize(person)); } @@ -66,7 +67,7 @@ public void CanSerializeClassToJson() public void CanDeserializeClassFromJson() { // Arrange - var json = "{\"Id\":1844,\"Name\":\"Athos\",\"Pets\":[\"Aramis\",\"Porthos\",\"D'Artagnan\"],\"Hobby\":2,\"Nicknames\":[\"Comte de la Fère\",\"Armand\"],\"BirthInstant\":\"1825-08-06T18:45:21.0000000-06:00\",\"Age\":\"7665.01:30:00\"}"; + var json = "{\"id\":1844,\"name\":\"Athos\",\"pets\":[\"Aramis\",\"Porthos\",\"D'Artagnan\"],\"hobby\":2,\"nicknames\":[\"Comte de la Fère\",\"Armand\"],\"birthInstant\":\"1825-08-06T18:45:21.0000000-06:00\",\"age\":\"7665.01:30:00\",\"allergies\":{\"Ducks\":true,\"Geese\":false}}"; // Act var person = JsonUtil.Deserialize(json); @@ -79,6 +80,35 @@ public void CanDeserializeClassFromJson() Assert.Equal(new[] { "Comte de la Fère", "Armand" }, person.Nicknames); Assert.Equal(new DateTimeOffset(1825, 8, 6, 18, 45, 21, TimeSpan.FromHours(-6)), person.BirthInstant); Assert.Equal(new TimeSpan(7665, 1, 30, 0), person.Age); + Assert.Equal(new Dictionary { { "Ducks", true }, { "Geese", false } }, person.Allergies); + } + + [Fact] + public void CanDeserializeWithCaseInsensitiveKeys() + { + // Arrange + var json = "{\"ID\":1844,\"NamE\":\"Athos\"}"; + + // Act + var person = JsonUtil.Deserialize(json); + + // Assert + Assert.Equal(1844, person.Id); + Assert.Equal("Athos", person.Name); + } + + [Fact] + public void DeserializationPrefersPropertiesOverFields() + { + // Arrange + var json = "{\"member1\":\"Hello\"}"; + + // Act + var person = JsonUtil.Deserialize(json); + + // Assert + Assert.Equal("Hello", person.Member1); + Assert.Null(person.member1); } [Fact] @@ -96,14 +126,14 @@ public void CanSerializeStructToJson() var result = JsonUtil.Serialize(commandResult); // Assert - Assert.Equal("{\"StringProperty\":\"Test\",\"BoolProperty\":true,\"NullableIntProperty\":1}", result); + Assert.Equal("{\"stringProperty\":\"Test\",\"boolProperty\":true,\"nullableIntProperty\":1}", result); } [Fact] public void CanDeserializeStructFromJson() { // Arrange - var json = "{\"StringProperty\":\"Test\",\"BoolProperty\":true,\"NullableIntProperty\":1}"; + var json = "{\"stringProperty\":\"Test\",\"boolProperty\":true,\"nullableIntProperty\":1}"; //Act var simpleError = JsonUtil.Deserialize(json); @@ -114,6 +144,34 @@ public void CanDeserializeStructFromJson() Assert.Equal(1, simpleError.NullableIntProperty); } + [Fact] + public void RejectsTypesWithAmbiguouslyNamedProperties() + { + var ex = Assert.Throws(() => + { + JsonUtil.Deserialize("{}"); + }); + + Assert.Equal($"The type '{typeof(ClashingProperties).FullName}' contains multiple public properties " + + $"with names case-insensitively matching '{nameof(ClashingProperties.PROP1).ToLowerInvariant()}'. " + + $"Such types cannot be used for JSON deserialization.", + ex.Message); + } + + [Fact] + public void RejectsTypesWithAmbiguouslyNamedFields() + { + var ex = Assert.Throws(() => + { + JsonUtil.Deserialize("{}"); + }); + + Assert.Equal($"The type '{typeof(ClashingFields).FullName}' contains multiple public fields " + + $"with names case-insensitively matching '{nameof(ClashingFields.Field1).ToLowerInvariant()}'. " + + $"Such types cannot be used for JSON deserialization.", + ex.Message); + } + [Fact] public void NonEmptyConstructorThrowsUsefulException() { @@ -143,6 +201,50 @@ public void SupportsInternalCustomSerializer() Assert.Equal("{\"key1\":\"value1\",\"key2\":123}", json); } + // Test cases based on https://github.com/JamesNK/Newtonsoft.Json/blob/122afba9908832bd5ac207164ee6c303bfd65cf1/Src/Newtonsoft.Json.Tests/Utilities/StringUtilsTests.cs#L41 + // The only difference is that our logic doesn't have to handle space-separated words, + // because we're only use this for camelcasing .NET member names + // + // Not all of the following cases are really valid .NET member names, but we have no reason + // to implement more logic to detect invalid member names besides the basics (null or empty). + [Theory] + [InlineData("URLValue", "urlValue")] + [InlineData("URL", "url")] + [InlineData("ID", "id")] + [InlineData("I", "i")] + [InlineData("Person", "person")] + [InlineData("xPhone", "xPhone")] + [InlineData("XPhone", "xPhone")] + [InlineData("X_Phone", "x_Phone")] + [InlineData("X__Phone", "x__Phone")] + [InlineData("IsCIA", "isCIA")] + [InlineData("VmQ", "vmQ")] + [InlineData("Xml2Json", "xml2Json")] + [InlineData("SnAkEcAsE", "snAkEcAsE")] + [InlineData("SnA__kEcAsE", "snA__kEcAsE")] + [InlineData("already_snake_case_", "already_snake_case_")] + [InlineData("IsJSONProperty", "isJSONProperty")] + [InlineData("SHOUTING_CASE", "shoutinG_CASE")] + [InlineData("9999-12-31T23:59:59.9999999Z", "9999-12-31T23:59:59.9999999Z")] + [InlineData("Hi!! This is text. Time to test.", "hi!! This is text. Time to test.")] + [InlineData("BUILDING", "building")] + [InlineData("BUILDINGProperty", "buildingProperty")] + public void MemberNameToCamelCase_Valid(string input, string expectedOutput) + { + Assert.Equal(expectedOutput, CamelCase.MemberNameToCamelCase(input)); + } + + [Theory] + [InlineData("")] + [InlineData(null)] + public void MemberNameToCamelCase_Invalid(string input) + { + var ex = Assert.Throws(() => + CamelCase.MemberNameToCamelCase(input)); + Assert.Equal("value", ex.ParamName); + Assert.StartsWith($"The value '{input ?? "null"}' is not a valid member name.", ex.Message); + } + class NonEmptyConstructorPoco { public NonEmptyConstructorPoco(int parameter) {} @@ -166,6 +268,7 @@ class Person public IList Nicknames { get; set; } public DateTimeOffset BirthInstant { get; set; } public TimeSpan Age { get; set; } + public IDictionary Allergies { get; set; } } enum Hobbies { Reading = 1, Swordfighting = 2 } @@ -181,5 +284,25 @@ public object ToJsonPrimitive() }; } } + +#pragma warning disable 0649 + class ClashingProperties + { + public string Prop1 { get; set; } + public int PROP1 { get; set; } + } + + class ClashingFields + { + public string Field1; + public int field1; + } + + class PrefersPropertiesOverFields + { + public string member1; + public string Member1 { get; set; } + } +#pragma warning restore 0649 } }