From 8f555ed4a5dbe2afe42bc0ea09c3ef7404d8121e Mon Sep 17 00:00:00 2001 From: Alex Davies Date: Tue, 16 Oct 2018 23:08:00 +0100 Subject: [PATCH] Add functionality to determine source points from a model path --- .../JsonApiSerializer.csproj | 6 +- .../JsonConverters/DocumentRootConverter.cs | 11 +- src/JsonApiSerializer/Util/ExpressionUtil.cs | 298 ++++++++++++++++++ src/JsonApiSerializer/Util/SourcePointer.cs | 209 ++++++++++++ src/JsonApiSerializer/Util/TypeInfoShim.cs | 16 +- .../Util/SourcePointerTests.cs | 85 +++++ 6 files changed, 616 insertions(+), 9 deletions(-) create mode 100644 src/JsonApiSerializer/Util/ExpressionUtil.cs create mode 100644 src/JsonApiSerializer/Util/SourcePointer.cs create mode 100644 tests/JsonApiSerializer.Test/Util/SourcePointerTests.cs diff --git a/src/JsonApiSerializer/JsonApiSerializer.csproj b/src/JsonApiSerializer/JsonApiSerializer.csproj index 3c856dd..a7523a2 100644 --- a/src/JsonApiSerializer/JsonApiSerializer.csproj +++ b/src/JsonApiSerializer/JsonApiSerializer.csproj @@ -2,15 +2,15 @@ netstandard1.0;netstandard1.5;net45 True - 1.4.0 + 1.5.0 Alex Davies Codecutout JsonApiSerializer supports configurationless serializing and deserializing objects into the json:api format (http://jsonapi.org). https://github.com/codecutout/JsonApiSerializer/blob/master/LICENSE https://github.com/codecutout/JsonApiSerializer jsonapiserializer jsonapi json:api json.net serialization deserialization jsonapi.net - 1.4.0.0 - 1.4.0.0 + 1.5.0.0 + 1.5.0.0 diff --git a/src/JsonApiSerializer/JsonConverters/DocumentRootConverter.cs b/src/JsonApiSerializer/JsonConverters/DocumentRootConverter.cs index cabbd82..746cbf4 100644 --- a/src/JsonApiSerializer/JsonConverters/DocumentRootConverter.cs +++ b/src/JsonApiSerializer/JsonConverters/DocumentRootConverter.cs @@ -16,9 +16,14 @@ internal class DocumentRootConverter : JsonConverter { public static bool CanConvertStatic(Type objectType) { - return TypeInfoShim.GetInterfaces(objectType.GetTypeInfo()) - .Select(x => x.GetTypeInfo()) - .Any(x => x.IsGenericType && x.GetGenericTypeDefinition() == typeof(IDocumentRoot<>)); + var typeInfo = objectType.GetTypeInfo(); + + var interfaces = TypeInfoShim.GetInterfaces(typeInfo) + .Select(x => x.GetTypeInfo()); + if (typeInfo.IsInterface) + interfaces = new[] { typeInfo }.Concat(interfaces); + + return interfaces.Any(x => x.IsGenericType && x.GetGenericTypeDefinition() == typeof(IDocumentRoot<>)); } public override bool CanConvert(Type objectType) diff --git a/src/JsonApiSerializer/Util/ExpressionUtil.cs b/src/JsonApiSerializer/Util/ExpressionUtil.cs new file mode 100644 index 0000000..5f924b2 --- /dev/null +++ b/src/JsonApiSerializer/Util/ExpressionUtil.cs @@ -0,0 +1,298 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Linq.Expressions; +using System.Reflection; +using System.Text; +using System.Text.RegularExpressions; + +namespace JsonApiSerializer.Util +{ + internal class ExpressionUtil + { + /// + /// Provides utility for evaluating expressions. + /// + private class ExpressionEvaluator : ExpressionVisitor + { + /// + /// Determines whether an expression can be evaluated locally + /// + /// The expression to evaluate locally. + /// + /// true if this instance can be evaluated locallay; otherwise, false. + /// + public static bool TryEvaluate(Expression exp, out T result) + { + bool shouldBox = false; + + //check the the expression will evaluate to hte type we want to get out + if (exp.Type != typeof(T) && typeof(T).GetTypeInfo().IsAssignableFrom(exp.Type.GetTypeInfo())) + { + if (typeof(T) == typeof(object)) + { + //we can box our non-object to coerce the result as an object + shouldBox = true; + } + else + { + //our types are just not compatible so stop here + result = default(T); + return false; + } + } + + //if its a constant we can avoid compiling a lambda and get the value directly + if (exp is ConstantExpression constExp) + { + result = (T)constExp.Value; + return true; + } + + //check to see if we can evaluate + if (!ExpressionEvaluator.CanEvaluate(exp)) + { + result = default(T); + return false; + } + + try + { + if (shouldBox) + exp = Expression.Convert(exp, typeof(object)); + + var lambdaExp = Expression.Lambda>(exp); + var lambda = lambdaExp.Compile(); + result = lambda(); + return true; + } + catch (Exception) + { + //we shouldnt get here, but if we do its because something is wrong + //with the expression, which indictes we cannot evaluate it locally + result = default(T); + return false; + } + } + + + /// + /// Determines whether an expression can be evaluated locally + /// + /// The expression to evaluate locally. + /// + /// true if this instance can be evaluated locallay; otherwise, false. + /// + public static bool CanEvaluate(Expression exp) + { + var evaluator = new ExpressionEvaluator(); + evaluator.Visit(exp); + return evaluator.CanEvaluateLocally; + } + + public bool CanEvaluateLocally { get; private set; } = true; + + private ExpressionEvaluator() { } + + public override Expression Visit(Expression node) + { + //if its using a parameter it means we can not evaulate the locally + CanEvaluateLocally &= node.NodeType != ExpressionType.Parameter; + if (!CanEvaluateLocally) + return node; + return base.Visit(node); + } + } + + /// + /// Provides utility for reading a path of property access + /// + private class PropertyPathReader : ExpressionVisitor + { + public static bool TryReadPropertyPath(Expression exp, out IEnumerable path) + { + var ppr = new PropertyPathReader(); + try + { + ppr.Visit(exp); + path = ppr.Path; + return ppr.Success; + } + catch + { + path = default(IEnumerable); + return false; + } + } + + public List Path { get; private set; } = new List(); + public bool Success { get; private set; } = true; + + private PropertyPathReader() { } + + public override Expression Visit(Expression node) + { + switch (node) + { + case MemberExpression me: + Visit(me.Expression); + Path.Add(node); + return node; + case BinaryExpression be when node.NodeType == ExpressionType.ArrayIndex: + Visit(be.Left); + Path.Add(node); + return node; + case MethodCallExpression mce: + Visit(mce.Object); + Path.Add(node); + return node; + case IndexExpression ie: + Visit(ie.Object); + Path.Add(node); + return node; + case ParameterExpression pe: + Path.Add(node); + return node; + default: + Success = false; + return node; + } + } + } + + /// + /// Determines if an expression represents an index access + /// + /// The expression to check + /// The indexer used in the in the index access + /// true if expression was an index access; otherwise, false. + public static bool IsIndexAccess(Expression exp, out Expression indexExpression) + { + //Expression can have several ways to indicate an index access, so have to check them all + switch (exp) + { + case BinaryExpression be when be.NodeType == ExpressionType.ArrayIndex: + indexExpression = be.Right; + return true; + case MethodCallExpression mce when IsDefaultMemberGet(mce) && mce.Arguments.Count == 1: + indexExpression = mce.Arguments[0]; + return true; + case IndexExpression ie when ie.Arguments.Count == 1: + indexExpression = ie.Arguments[0]; + return true; + default: + indexExpression = default(Expression); + return false; + } + } + + /// + /// Detemrines if an expression in a default member get + /// + /// + /// + public static bool IsDefaultMemberGet(MethodCallExpression methodCallExp) + { + var defaultMembers = TypeInfoShim.GetDefaultMembers(methodCallExp.Object.Type.GetTypeInfo()); + //check method call has the same name as the get default member + //have to check on names as some cases the Methods are the same yet not equal + return defaultMembers.OfType().Any(pi => pi.GetMethod.Name == methodCallExp.Method.Name); + } + + /// + /// Attempts to evaluate the expression locally to produce a result. + /// + /// the result type. + /// The expression to evaluate locally. + /// The output of the expression + /// true if expression was evaluated; otherwise, false. + public static bool TryEvaluate(Expression exp, out T result) + { + return ExpressionEvaluator.TryEvaluate(exp, out result); + } + + /// + /// Attemps to read a property path defined in an expression in the order it appears + /// typically a property path is represented outside-in e.g. (((x).Author).Name) is Name->Author->x + /// while this method will read the path inside-out e.g. (((x).Author).Name) as x->Author->Name + /// + /// The expression to read the property path + /// Each property access in order of parameter to final property + /// true if path could be determined; otherwise, false. + public static bool TryReadPropertyPath(Expression exp, out IEnumerable propertyPath) + { + return PropertyPathReader.TryReadPropertyPath(exp, out propertyPath); + } + + /// + /// Tries to parse a path in the form 'x.Authors[3].FirstName' into a lambda expression selecting the same property + /// + /// The type of the intiial parameter in the path + /// A stirng path to the property to be selected + /// The lambda expression selecting the property + /// If true an exception is thrown rather than a return value + /// true if path could be parsed; otherwise, false. + public static bool TryParsePath(Type modelRoot, string path, out LambdaExpression expression, bool throwOnError) + { + expression = null; + if (modelRoot == null) + return throwOnError ? throw new ArgumentNullException(nameof(modelRoot)) : false; + if (string.IsNullOrWhiteSpace(path)) + return throwOnError ? throw new ArgumentException(nameof(path)) : false; + + var pathParts = path.Split('.'); + var parameterRegex = new Regex(@"(?:^(?\w+))"); + var pathPartRegex = new Regex(string.Join("|", new[] + { + @"(?:\.(?\w+))", + @"(?:\[(?\d+)\])", + @"(?:\['(?\w+)'\])", + @"(?:\[""(?\w+)""\])", + })); + + var parameterMatch = parameterRegex.Match(path); + if (!parameterMatch.Success) + return throwOnError ? throw new Exception($"{nameof(path)} '{path}' must begin with a parameter e.g. 'x.Author.Name'") : false; + + var paramExp = Expression.Parameter(modelRoot, parameterMatch.Groups["parameter"].Value); + Expression exp = paramExp; + + foreach (var pathPartMatch in pathPartRegex.Matches(path).Cast()) + { + var property = pathPartMatch.Groups["property"]; + if (property.Success) + { + var propInfo = TypeInfoShim.GetProperty(exp.Type.GetTypeInfo(), property.Value); + exp = Expression.MakeMemberAccess(exp, propInfo); + continue; + } + + var intIndex = pathPartMatch.Groups["intIndex"]; + if (intIndex.Success) + { + var indexProperty = TypeInfoShim.GetDefaultMembers(exp.Type.GetTypeInfo()) + .OfType() + .FirstOrDefault(x => x.SetMethod.GetParameters().Length == 2); + if (indexProperty == null) + return throwOnError ? throw new Exception($"Unable to find default index member for type '{exp.Type.GetTypeInfo()}'") : false; + exp = Expression.MakeIndex(exp, indexProperty, new[] { Expression.Constant(int.Parse(intIndex.Value)) }); + continue; + } + + var stringIndex = pathPartMatch.Groups["stringIndex"]; + if (stringIndex.Success) + { + var indexProperty = TypeInfoShim.GetDefaultMembers(exp.Type.GetTypeInfo()) + .OfType() + .FirstOrDefault(x => x.SetMethod.GetParameters().Length == 2); + if (indexProperty == null) + return throwOnError ? throw new Exception($"Unable to find default index member for type '{exp.Type.GetTypeInfo()}'") : false; + exp = Expression.MakeIndex(exp, indexProperty, new[] { Expression.Constant(stringIndex.Value) }); + continue; + } + } + expression = Expression.Lambda(exp, paramExp); + return true; + } + } +} diff --git a/src/JsonApiSerializer/Util/SourcePointer.cs b/src/JsonApiSerializer/Util/SourcePointer.cs new file mode 100644 index 0000000..47138cc --- /dev/null +++ b/src/JsonApiSerializer/Util/SourcePointer.cs @@ -0,0 +1,209 @@ +using JsonApiSerializer.ContractResolvers; +using JsonApiSerializer.JsonApi; +using JsonApiSerializer.JsonApi.WellKnown; +using JsonApiSerializer.JsonConverters; +using Newtonsoft.Json.Serialization; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Linq.Expressions; +using System.Reflection; +using System.Text; +using System.Text.RegularExpressions; + +namespace JsonApiSerializer.Util +{ + public class SourcePointer + { + /// + /// Utility class to write and encode source pointers + /// + private class SourcePointerBuilder + { + private readonly StringBuilder _sb = new StringBuilder(); + + public SourcePointerBuilder Append(string value) + { + var encoded = value + .Replace("~", "~0") + .Replace("/", "~1") + .Replace(@"\", @"\\") + .Replace("\"", "\\"); + _sb.Append("/").Append(encoded); + return this; + } + + public SourcePointerBuilder Append(MemberInfo memberInfo, JsonApiSerializerSettings settings) + { + var contractResolver = settings.ContractResolver as JsonApiContractResolver; + + //Try and determine what hte json name for the member is, that is the one we want to output + var declaringContract = contractResolver.ResolveContract(memberInfo.DeclaringType) as JsonObjectContract; + var jsonProp = declaringContract?.Properties?.FirstOrDefault(x => x.UnderlyingName == memberInfo.Name); + + this.Append(jsonProp?.PropertyName ?? memberInfo.Name); + return this; + } + + public override string ToString() + { + return _sb.ToString(); + } + } + + /// + /// Determines the equivalent JsonApi source pointer for a given model property + /// + /// The type of the paramter in the model path + /// The path to the model property. The first parameter must be the parameter e.g. x.Authors[2].FirstName + /// The JsonApiSerializerSettings used to during deserialization + /// JsonApi source pointer + public static string FromModel(string modelPath, JsonApiSerializerSettings settings) + { + return FromModel(typeof(TRoot), modelPath, settings); + } + + /// + /// Determines the equivalent JsonApi source pointer for a given model property + /// + /// The type of the paramter in the model path + /// The path to the model property. The first parameter must be the parameter e.g. x.Authors[2].FirstName + /// The JsonApiSerializerSettings used to during deserialization + /// JsonApi source pointer + public static string FromModel(Type modelRoot, string modelPath, JsonApiSerializerSettings settings) + { + ExpressionUtil.TryParsePath(modelRoot, modelPath, out var modelPathExpression, throwOnError: true); + SourcePointer.TryFromModel(modelPathExpression, settings, out string sourcePointer, throwOnError: true); + return sourcePointer; + } + + /// + /// Determines the equivalent JsonApi source pointer for a given model property + /// + /// The type of the paramter in the model path + /// Expression selecting the property + /// The JsonApiSerializerSettings used to during deserialization + /// JsonApi source pointer + public static string FromModel(Expression> modelExpressionPath, JsonApiSerializerSettings settings) + { + var success = SourcePointer.TryFromModel((LambdaExpression)modelExpressionPath, settings, out string sourcePointer, throwOnError: true); + return sourcePointer; + } + + + + /// + /// Tries to determine the equivalent JsonApi source pointer for a given model property + /// + /// The type of the paramter in the model path + /// Expression selecting property + /// The JsonApiSerializerSettings used to during deserialization + /// JsonApi source pointer + /// true if source pointer could be determined; otherwise, false + public static bool TryFromModel(Type modelRoot, string modelPath, JsonApiSerializerSettings settings, out string sourcePointer) + { + sourcePointer = default(string); + return ExpressionUtil.TryParsePath(modelRoot, modelPath, out var modelPathExpression, throwOnError: false) + && SourcePointer.TryFromModel(modelPathExpression, settings, out sourcePointer, throwOnError: false); + } + + /// + /// Tries to determine the equivalent JsonApi source pointer for a given model property + /// + /// The type of the paramter in the model path + /// The path to the model property. The first parameter must be the parameter e.g. x.Authors[2].FirstName + /// The JsonApiSerializerSettings used to during deserialization + /// JsonApi source pointer + /// true if source pointer could be determined; otherwise, false + public static bool TryFromModel(string modelPath, JsonApiSerializerSettings settings, out string sourcePointer) + { + return TryFromModel(typeof(TRoot), modelPath, settings, out sourcePointer); + } + + /// + /// Tries to determine the equivalent JsonApi source pointer for a given model property + /// + /// + /// Expression selecting the property + /// The JsonApiSerializerSettings used to during deserialization + /// JsonApi source pointer + /// true if source pointer could be determined; otherwise, false + public static bool TryFromModel(Expression> modelExpressionPath, JsonApiSerializerSettings settings, out string sourcePointer) + { + return SourcePointer.TryFromModel((LambdaExpression)modelExpressionPath, settings, out sourcePointer, throwOnError: false); + } + + private static bool TryFromModel(LambdaExpression modelExpressionPath, JsonApiSerializerSettings settings, out string jsonApiPath, bool throwOnError) + { + jsonApiPath = null; + if (modelExpressionPath.Parameters.Count != 1) + return throwOnError ? throw new NotSupportedException("Only single parameter member selection expression can be resolved") : false; + + if (!ExpressionUtil.TryReadPropertyPath(modelExpressionPath.Body, out var expressionList)) + return throwOnError ? throw new Exception($"Unable to process expresion '{modelExpressionPath}' as a property path") : false; + + var pointer = new SourcePointerBuilder(); + var contractResolver = (JsonApiContractResolver)settings.ContractResolver; + + //if we didnt start at a document root, add the 'data' to the path + var isDocRoot = DocumentRootConverter.CanConvertStatic(modelExpressionPath.Parameters[0].Type); + if (!isDocRoot) + pointer.Append("data"); + + foreach (var exp in expressionList) + { + if(exp is ParameterExpression) + { + //initial parameters are not expression in the jsonApiPath + continue; + } + else if(ExpressionUtil.IsIndexAccess(exp, out var indexExpression)) + { + if (!ExpressionUtil.TryEvaluate(indexExpression, out var index)) + return throwOnError ? throw new Exception($"Unable to process index expression '{indexExpression}'") : false; + + pointer.Append(index.ToString()); + } + else if (exp is MemberExpression memberExpression) + { + var propertyType = memberExpression.Type; + var containingType = memberExpression.Expression.Type; + + + if (contractResolver.ResourceObjectConverter.CanConvert(containingType)) + { + + if (contractResolver.ResourceObjectListConverter.CanConvert(propertyType) + || contractResolver.ResourceObjectConverter.CanConvert(propertyType)) + { + pointer.Append("relationships"); + pointer.Append(memberExpression.Member, settings); + pointer.Append("data"); + } + else if (contractResolver.ResourceRelationshipConverter.CanConvert(propertyType)) + { + pointer.Append("relationships"); + pointer.Append(memberExpression.Member, settings); + } + else + { + pointer.Append("attributes"); + pointer.Append(memberExpression.Member, settings); + } + } + else + { + pointer.Append(memberExpression.Member, settings); + } + } + else + { + return throwOnError ? throw new Exception($"Unknown expression '{exp}'") : false; + } + } + + jsonApiPath = pointer.ToString(); + return true; + } + } +} diff --git a/src/JsonApiSerializer/Util/TypeInfoShim.cs b/src/JsonApiSerializer/Util/TypeInfoShim.cs index b5dee05..d794a47 100644 --- a/src/JsonApiSerializer/Util/TypeInfoShim.cs +++ b/src/JsonApiSerializer/Util/TypeInfoShim.cs @@ -5,7 +5,7 @@ namespace JsonApiSerializer.Util { - public static class TypeInfoShim + public static class TypeInfoShim { public static IEnumerable GetInterfaces(TypeInfo info) { @@ -55,6 +55,16 @@ public static bool IsInstanceOf(TypeInfo info, object obj) #endif } - + public static MemberInfo[] GetDefaultMembers(TypeInfo info) + { +#if NETSTANDARD1_0 || NETSTANDARD1_1 || NETSTANDARD1_2 || NETSTANDARD1_3 || NETSTANDARD1_4 + var attribute = info.GetCustomAttribute(true); + var defaultMemberName = attribute?.MemberName ?? "Item"; + var itemProperty = GetPropertyFromInhertianceChain(info, defaultMemberName); + return new MemberInfo[]{itemProperty}; +#else + return info.GetDefaultMembers(); +#endif + } } -} +} \ No newline at end of file diff --git a/tests/JsonApiSerializer.Test/Util/SourcePointerTests.cs b/tests/JsonApiSerializer.Test/Util/SourcePointerTests.cs new file mode 100644 index 0000000..447e2e9 --- /dev/null +++ b/tests/JsonApiSerializer.Test/Util/SourcePointerTests.cs @@ -0,0 +1,85 @@ +using JsonApiSerializer.JsonApi; +using JsonApiSerializer.JsonApi.WellKnown; +using JsonApiSerializer.Test.Models.Articles; +using JsonApiSerializer.Util; +using System; +using System.Collections.Generic; +using System.Text; +using Xunit; + +namespace JsonApiSerializer.Test.Util +{ + public class DocumentWithLists + { + public string Id { get; set; } + + public Person[] Array { get; set; } + + public List List { get; set; } + } + + public class SourcePointerTests + { + [Fact] + public void When_property_expression_then_source_pointer_with_relationship_and_attributes() + { + var pointer = SourcePointer.FromModel
(x => x.Author.FirstName, new JsonApiSerializerSettings()); + Assert.Equal("/data/relationships/author/data/attributes/first-name", pointer); + } + + [Fact] + public void When_document_property_expression_then_source_pointer_with_relationship_and_attributes() + { + var pointer = SourcePointer.FromModel>(x => x.Data.Author.FirstName, new JsonApiSerializerSettings()); + Assert.Equal("/data/relationships/author/data/attributes/first-name", pointer); + } + + [Fact] + public void When_expression_requires_evaluation_then_source_pointer_evaluated() + { + var one = 1; + var pointer = SourcePointer.FromModel(x => x.Array[21 + one].FirstName, new JsonApiSerializerSettings()); + Assert.Equal("/data/relationships/array/data/22/attributes/first-name", pointer); + } + + [Fact] + public void When_expression_array_index_then_source_pointer_with_list_access() + { + var pointer = SourcePointer.FromModel(x => x.Array[22].FirstName, new JsonApiSerializerSettings()); + Assert.Equal("/data/relationships/array/data/22/attributes/first-name", pointer); + } + + [Fact] + public void When_expression_list_index_then_source_pointer_with_list_access() + { + var pointer = SourcePointer.FromModel(x => x.List[22].FirstName, new JsonApiSerializerSettings()); + Assert.Equal("/data/relationships/list/data/22/attributes/first-name", pointer); + } + + [Fact] + public void When_expression_dictionary_index_then_source_pointer_with_property_access() + { + var pointer = SourcePointer.FromModel>(x => x.Meta["lastUpdated"], new JsonApiSerializerSettings()); + Assert.Equal("/meta/lastUpdated", pointer); + } + + [Theory] + [InlineData(typeof(Article), "x.Title", "/data/attributes/title")] + [InlineData(typeof(Article), "x.Author.FirstName", "/data/relationships/author/data/attributes/first-name")] + [InlineData(typeof(IDocumentRoot
), "x.Data.Author.FirstName", "/data/relationships/author/data/attributes/first-name")] + + [InlineData(typeof(DocumentRoot
), "x.JsonApi.Version", "/jsonapi/version")] + [InlineData(typeof(DocumentRoot
), "x.Meta['lastUpdated']", "/meta/lastUpdated")] + [InlineData(typeof(DocumentRoot
), "x.Meta[\"lastUpdated\"]", "/meta/lastUpdated")] + + [InlineData(typeof(Article), "x.Comments[22].Body", "/data/relationships/comments/data/22/attributes/body")] + + [InlineData(typeof(ArticleWithRelationship), "x.Author.Data.FirstName", "/data/relationships/author/data/attributes/first-name")] + [InlineData(typeof(ArticleWithRelationship), "x.Author.Meta", "/data/relationships/author/meta")] + public void When_model_path_should_map_to_json_path(Type modelType, string modelPath, string expectedJsonPath) + { + var jsonPath = SourcePointer.FromModel(modelType, modelPath, new JsonApiSerializerSettings()); + Assert.Equal(expectedJsonPath, jsonPath); + } + } +}