A common problem in larger scale projects is API versioning, or changes to the REST API which modify the models. For example, renaming of fields, adding of new fields, changing nullability, etc., all impact the models and require code changes.
Such changes may lead to unexpected behaviour, even if in many scenarios a simple JSON deserialization Exception is thrown. However, this is not always the case, for example, when new fields are added, serialization will function, but the structs or classes can “miss out” on this new data.
This got me thinking; perhaps there is a way to get notified about such changes to an API? I am a big fan of integration testing, so I built this model-validation system based on xUnit Asserts.
The following code is compatible with .NET Standard 2.0 and all newer versions of .NET.
using Microsoft.Extensions.Logging;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
using Xunit;
namespace ACA.Testing
{
public static class ModelTools
{
public static T ValidateModel(string json, ILogger log, bool throwOnUnusedProps = true)
{
log?.LogInformation("Validate model " + typeof(T));
var objJson = JsonConvert.DeserializeObject(json);
var objModel = JsonConvert.DeserializeObject(json);
Assert.NotNull(objJson);
Assert.NotNull(objModel);
StartCompare(objJson, objModel, log, throwOnUnusedProps);
Assert.NotEqual(default, objModel);
return objModel;
}
public static IList ValidateListOfModels(string json, ILogger log, bool throwOnUnusedProps = true)
{
Assert.NotNull(json);
Assert.NotEmpty(json);
log?.LogInformation("Validate list of " + typeof(T));
var objJson = JsonConvert.DeserializeObject(json);
var objModel = JsonConvert.DeserializeObject<List>(json);
Assert.NotNull(objJson);
Assert.NotNull(objModel);
if (!objModel.Any())
log?.LogWarning("Empty list of " + typeof(T).FullName);
StartCompare(objJson, objModel, log, throwOnUnusedProps);
return objModel;
}
private static void StartCompare(object unstructuredItem, object structuredItem, ILogger log, bool throwOnUnusedProps)
{
if (unstructuredItem is JObject jObj)
{
// key-value object or dictionary
try
{
ValidateObject(jObj, structuredItem, log, throwOnUnusedProps);
}
catch (UnusedPropertyException) when (!throwOnUnusedProps)
{
}
catch (AggregateException ax) when (ax.InnerExceptions.All(x => x is UnusedPropertyException) && !throwOnUnusedProps)
{
}
}
else if (unstructuredItem is JArray jArr)
{
// zero-based index array
ValidateArray(jArr, structuredItem, log, throwOnUnusedProps);
}
else if (unstructuredItem is JValue jVal)
{
// simple type
ValidatePropertyValue(log, jVal, structuredItem.GetType(), structuredItem, throwOnUnusedProps);
}
else
{
throw new NotImplementedException();
}
}
private static void ValidateObject(JObject jObj, object structuredItem, ILogger log, bool throwOnUnusedProps)
{
if (structuredItem is JObject)
{
throw new NotImplementedException("Object at path " + jObj.Path + " must support object value: " + jObj.ToString());
}
if (IsMetaObject(jObj))
{
log?.LogWarning("Skipping meta object at path " + jObj.Path);
return;
}
Assert.NotNull(structuredItem);
var type = structuredItem.GetType();
if (IsDictionary(type))
{
ValidateDictionaries(jObj, (IDictionary)structuredItem, log, throwOnUnusedProps);
return;
}
var props = type.GetProperties(BindingFlags.Public | BindingFlags.Instance | BindingFlags.SetProperty);
props = props.Where(x => x.SetMethod != null).ToArray();
var usedProps = new Dictionary<PropertyInfo, bool>();
foreach (var prop in props)
usedProps.Add(prop, false);
foreach (var kv in jObj)
{
if (kv.Key.StartsWith("__"))
{
log?.LogDebug("Skipping meta-property " + kv.Key);
continue;
}
if (kv.Value != null)
{
switch (kv.Value.Type)
{
case JTokenType.Object:
case JTokenType.Array:
log?.LogInformation("JSON object has: " + kv.Key + " ===> {" + kv.Value.Type + "}");
break;
default:
log?.LogInformation("JSON object has: " + kv.Key + " ===> " + kv.Value);
break;
}
}
else
log?.LogInformation("JSON object has null value for key: " + kv.Key);
var prop = props.SingleOrDefault(x => x.Name.Equals(kv.Key, StringComparison.OrdinalIgnoreCase));
if (prop is null)
{
var query = props.Where(x => x.CustomAttributes.Any()
&& x.GetCustomAttributes(typeof(JsonPropertyAttribute), false) is IEnumerable attributes
&& attributes.Count(y => y.PropertyName.Equals(kv.Key, StringComparison.OrdinalIgnoreCase)) == 1).ToArray();
Assert.True(query.Length == 1, userMessage: "No property found on type " + type + ", at path " + kv.Value.Path + ", mapping key [" + kv.Key + "] with value [" + kv.Value + "], parent: {" + kv.Value.Parent + "}");
prop = Assert.Single(query);
}
Assert.NotNull(prop);
usedProps[prop] = true;
var modelValue = prop.GetValue(structuredItem);
log?.LogInformation(" Model has: " + prop.Name + " ===> " + modelValue);
ValidatePropertyValue(log, kv.Value, prop.PropertyType, modelValue, throwOnUnusedProps, prop);
}
CheckForUnusedProps(log, type, usedProps);
}
private static bool IsMetaObject(JToken token)
{
if (token is JObject valObj)
{
bool ok = true;
foreach (var innerKv in valObj)
if (!innerKv.Key.StartsWith("__") && innerKv.Key.Length > 2)
ok = false;
return ok;
}
return false;
}
private static void CheckForUnusedProps(ILogger log, Type type, Dictionary<PropertyInfo, bool> usedProps)
{
var unused = usedProps.Where(x => !x.Value && x.Key.CanWrite);
if (unused.Any())
{
var aggregated = new List();
foreach (var prop in unused.Select(x => x.Key))
{
string message;
if (prop.PropertyType.IsValueType &&
Nullable.GetUnderlyingType(prop.PropertyType) is null)
{
throw new NotImplementedAsNullableException(type, prop);
}
if (prop.GetCustomAttribute() != null)
{
log?.LogTrace("Property " + prop + " has the " + nameof(JsonAllowNoneAttribute));
continue;
}
message = "Model " + type + " has unused property [" + prop.Name + "] of type [" + prop.PropertyType + "]";
log?.LogWarning(message);
aggregated.Add(new UnusedPropertyException(message));
}
if (aggregated.Count == 1)
throw aggregated.Single();
else if (aggregated.Count > 1)
throw new AggregateException(aggregated);
}
}
private static string GetPathOrClassProperty(JToken token, PropertyInfo prop = null)
{
if (prop is null)
return token.Path;
return prop.ReflectedType + "->" + prop.Name + " {" + prop.PropertyType.Name + "}";
}
private static void ValidatePropertyValue(ILogger log, JToken token, Type modelValueType, object modelValue, bool throwOnUnusedProps, PropertyInfo prop = null)
{
if (token is null || token.Type == JTokenType.Null)
{
Assert.Null(modelValue);
return;
}
if (String.IsNullOrWhiteSpace(token.ToString()))
{
log?.LogDebug("Skipping blank value on " + GetPathOrClassProperty(token, prop));
return;
}
if (prop?.PropertyType == typeof(object))
{
throw new MissingPropertyException(token, prop);
}
try
{
Assert.NotNull(modelValue);
}
catch (Exception x)
{
log?.LogCritical("Model value for " + token.Path + " is null!");
throw x;
}
var underlyingType = Nullable.GetUnderlyingType(modelValueType);
switch (token.Type)
{
case JTokenType.Array:
ValidateArray((JArray)token, modelValue, log, throwOnUnusedProps);
return;
case JTokenType.Object:
if (IsDictionary(modelValueType))
{
ValidateDictionaries((JObject)token, (IDictionary)modelValue, log, throwOnUnusedProps);
}
else // regular object
{
if (IsMetaObject(token) && prop != null && prop.PropertyType.IsValueType && modelValue != null && underlyingType is null)
{
throw new NotImplementedAsNullableException(prop.DeclaringType, prop);
}
StartCompare(token, modelValue, log, throwOnUnusedProps);
}
return;
}
if (modelValueType == typeof(string) && prop is null)
{
if (double.TryParse(token.ToString(), out _))
throw new MissingPropertyException(token, prop);
if (bool.TryParse(token.ToString(), out _))
throw new MissingPropertyException(token, prop);
if (Guid.TryParse(token.ToString(), out _))
throw new MissingPropertyException(token, prop);
}
switch (token.Type)
{
case JTokenType.Date:
if (modelValueType != typeof(DateTime) && modelValueType != typeof(DateTimeOffset) &&
underlyingType != typeof(DateTime) && underlyingType != typeof(DateTimeOffset))
{
throw new MissingPropertyException(token, prop);
}
return;
case JTokenType.Boolean:
if (modelValueType != typeof(bool) && underlyingType != typeof(bool))
{
throw new MissingPropertyException(token, prop);
}
return;
case JTokenType.Integer:
case JTokenType.String:
case JTokenType.Float:
// ignore
break;
default:
throw new NotImplementedException("TO DO - " + token.Type);
}
if (double.TryParse(token.ToString(), out _))
{
return;
}
if (bool.TryParse(token.ToString(), out var tokenBool))
{
Assert.Equal(tokenBool, Convert.ToBoolean(modelValue));
return;
}
if (DateTime.TryParse(token.ToString(), out var tokenDate))
{
Assert.Equal(tokenDate, Convert.ToDateTime(modelValue));
return;
}
if (Guid.TryParse(token.ToString(), out var tokenGuid))
{
if (modelValueType != typeof(Guid) && underlyingType != typeof(Guid))
log?.LogWarning(token.Path + " is a Guid, but the prop is " + prop + " (" + modelValueType + ")");
Assert.Equal(tokenGuid, new Guid(modelValue.ToString()));
return;
}
if (Uri.TryCreate(token.ToString(), UriKind.Absolute, out var _))
{
return; // An absolute url may have a trailing slash
}
bool ignoreCase = false;
if (modelValueType.IsEnum)
ignoreCase = true;
Assert.Equal(token.ToString(), modelValue.ToString(), ignoreCase: ignoreCase);
}
private static void ValidateArray(JArray jArr, object structuredItem, ILogger log, bool throwOnUnusedProps)
{
Assert.IsAssignableFrom(typeof(IEnumerable), structuredItem);
var modelEnumerable = (IEnumerable)structuredItem;
var modelArray = new object[0];
int i = 0;
foreach (var item in modelEnumerable)
{
Array.Resize(ref modelArray, i + 1);
modelArray[i++] = item;
}
if (jArr.Count == 0)
{
Assert.Empty(modelEnumerable);
return;
}
for (i = 0; i < jArr.Count; i++)
{
var jsonItem = jArr[i];
var modelitem = modelArray.ElementAt(i);
StartCompare(jsonItem, modelitem, log, throwOnUnusedProps);
}
}
private static void ValidateDictionaries(JObject jsonDictionary, IDictionary modelDictionary, ILogger log, bool throwOnUnusedProps)
{
Assert.NotNull(jsonDictionary);
Assert.NotNull(modelDictionary);
log?.LogInformation("Check for equality on dictionary: " + modelDictionary.GetType());
object[] modelKeys = new object[modelDictionary.Keys.Count];
modelDictionary.Keys.CopyTo(modelKeys, 0);
foreach (var kv in jsonDictionary)
{
object modelKey = modelKeys.SingleOrDefault(x => x.ToString() == kv.Key)
?? throw new KeyNotFoundException("Key " + kv.Key + " not found in model dictionary!");
var jsonItem = jsonDictionary[kv.Key];
var modelItem = modelDictionary[modelKey];
Assert.NotNull(jsonItem);
Assert.NotNull(modelItem);
StartCompare(jsonItem, modelItem, log, throwOnUnusedProps);
}
}
private static bool IsDictionary(Type type)
{
var genericDictionary = typeof(IDictionary<,>);
if (typeof(IDictionary).IsAssignableFrom(type) || genericDictionary.IsAssignableFrom(type))
{
return true;
}
var interfaces = type.GetInterfaces();
return interfaces.Any(x => x.IsGenericType &&
x.GetGenericTypeDefinition() == genericDictionary);
}
}
}
You can use this code to process a JSON string against the desired model type, and you will encounter Exceptions if the model class or struct differs from the JSON model. Combine this functionality with Integration Tests, and you have an easy way to verify your models.
Happy coding!