camelCase all the JSONs (#746)

* Add camelCase utility

* Use camelCase when JSON-serializing (but not for dictionary keys)

* Make JSON deserialization treat member names case-insensitively (but retain case on dictionary keys)

* Use camelCase in JSON in the samples and templates

* Reverse the order of the params for the camelcase test because it's weird otherwise

* CR feedback
This commit is contained in:
Steve Sanderson 2018-05-04 16:14:38 +01:00 committed by GitHub
parent f9ab9cc6e5
commit 20e43adac5
6 changed files with 265 additions and 58 deletions

View File

@ -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
}
]

View File

@ -18,10 +18,7 @@ namespace BlazorHosted_CSharp.Server
// 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 =>
{

View File

@ -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
}
]

View File

@ -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);
}
}
}

View File

@ -1268,7 +1268,7 @@ namespace SimpleJson
protected virtual string MapClrMemberNameToJsonFieldName(string clrPropertyName)
{
return clrPropertyName;
return CamelCase.MemberNameToCamelCase(clrPropertyName);
}
internal virtual ReflectionUtils.ConstructorDelegate ConstructorDelegateFactory(Type key)
@ -1302,7 +1302,20 @@ namespace SimpleJson
internal virtual IDictionary<string, KeyValuePair<Type, ReflectionUtils.SetDelegate>> SetterValueFactory(Type type)
{
IDictionary<string, KeyValuePair<Type, ReflectionUtils.SetDelegate>> result = new Dictionary<string, KeyValuePair<Type, ReflectionUtils.SetDelegate>>();
// 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<string, KeyValuePair<Type, ReflectionUtils.SetDelegate>> result = new Dictionary<string, KeyValuePair<Type, ReflectionUtils.SetDelegate>>(StringComparer.OrdinalIgnoreCase);
foreach (PropertyInfo propertyInfo in ReflectionUtils.GetProperties(type))
{
if (propertyInfo.CanWrite)
@ -1310,15 +1323,30 @@ namespace SimpleJson
MethodInfo setMethod = ReflectionUtils.GetSetterMethodInfo(propertyInfo);
if (setMethod.IsStatic || !setMethod.IsPublic)
continue;
result[MapClrMemberNameToJsonFieldName(propertyInfo.Name)] = new KeyValuePair<Type, ReflectionUtils.SetDelegate>(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<Type, ReflectionUtils.SetDelegate>(propertyInfo.PropertyType, ReflectionUtils.GetSetMethod(propertyInfo));
}
}
IDictionary<string, KeyValuePair<Type, ReflectionUtils.SetDelegate>> fieldResult = new Dictionary<string, KeyValuePair<Type, ReflectionUtils.SetDelegate>>(StringComparer.OrdinalIgnoreCase);
foreach (FieldInfo fieldInfo in ReflectionUtils.GetFields(type))
{
if (fieldInfo.IsInitOnly || fieldInfo.IsStatic || !fieldInfo.IsPublic)
continue;
result[MapClrMemberNameToJsonFieldName(fieldInfo.Name)] = new KeyValuePair<Type, ReflectionUtils.SetDelegate>(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<Type, ReflectionUtils.SetDelegate>(fieldInfo.FieldType, ReflectionUtils.GetSetMethod(fieldInfo));
if (!result.ContainsKey(fieldInfo.Name))
{
result[fieldInfo.Name] = fieldResult[fieldInfo.Name];
}
}
return result;
}
@ -1435,13 +1463,13 @@ namespace SimpleJson
?? throw new InvalidOperationException($"Cannot deserialize JSON into type '{type.FullName}' because it does not have a public parameterless constructor.");
obj = constructorDelegate();
foreach (KeyValuePair<string, KeyValuePair<Type, ReflectionUtils.SetDelegate>> 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);
}
}
}

View File

@ -53,12 +53,13 @@ namespace Microsoft.AspNetCore.Blazor.Test
Hobby = Hobbies.Swordfighting,
Nicknames = new List<string> { "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<string, object> { { "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 @@ namespace Microsoft.AspNetCore.Blazor.Test
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<Person>(json);
@ -79,6 +80,35 @@ namespace Microsoft.AspNetCore.Blazor.Test
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<string, object> { { "Ducks", true }, { "Geese", false } }, person.Allergies);
}
[Fact]
public void CanDeserializeWithCaseInsensitiveKeys()
{
// Arrange
var json = "{\"ID\":1844,\"NamE\":\"Athos\"}";
// Act
var person = JsonUtil.Deserialize<Person>(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<PrefersPropertiesOverFields>(json);
// Assert
Assert.Equal("Hello", person.Member1);
Assert.Null(person.member1);
}
[Fact]
@ -96,14 +126,14 @@ namespace Microsoft.AspNetCore.Blazor.Test
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<SimpleStruct>(json);
@ -114,6 +144,34 @@ namespace Microsoft.AspNetCore.Blazor.Test
Assert.Equal(1, simpleError.NullableIntProperty);
}
[Fact]
public void RejectsTypesWithAmbiguouslyNamedProperties()
{
var ex = Assert.Throws<InvalidOperationException>(() =>
{
JsonUtil.Deserialize<ClashingProperties>("{}");
});
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<InvalidOperationException>(() =>
{
JsonUtil.Deserialize<ClashingFields>("{}");
});
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 @@ namespace Microsoft.AspNetCore.Blazor.Test
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<ArgumentException>(() =>
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 @@ namespace Microsoft.AspNetCore.Blazor.Test
public IList<string> Nicknames { get; set; }
public DateTimeOffset BirthInstant { get; set; }
public TimeSpan Age { get; set; }
public IDictionary<string, object> Allergies { get; set; }
}
enum Hobbies { Reading = 1, Swordfighting = 2 }
@ -181,5 +284,25 @@ namespace Microsoft.AspNetCore.Blazor.Test
};
}
}
#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
}
}