// Copyright (c) Microsoft Open Technologies, Inc. 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; using System.Reflection; using Microsoft.AspNet.JsonPatch.Exceptions; using Microsoft.AspNet.JsonPatch.Helpers; using Microsoft.AspNet.JsonPatch.Operations; using Newtonsoft.Json; using Newtonsoft.Json.Serialization; namespace Microsoft.AspNet.JsonPatch.Adapters { public class ObjectAdapter : IObjectAdapter where T : class { public IContractResolver ContractResolver { get; set; } public ObjectAdapter(IContractResolver contractResolver) { ContractResolver = contractResolver; } /// /// The "add" operation performs one of the following functions, /// depending upon what the target location references: /// /// o If the target location specifies an array index, a new value is /// inserted into the array at the specified index. /// /// o If the target location specifies an object member that does not /// already exist, a new member is added to the object. /// /// o If the target location specifies an object member that does exist, /// that member's value is replaced. /// /// The operation object MUST contain a "value" member whose content /// specifies the value to be added. /// /// For example: /// /// { "op": "add", "path": "/a/b/c", "value": [ "foo", "bar" ] } /// /// When the operation is applied, the target location MUST reference one /// of: /// /// o The root of the target document - whereupon the specified value /// becomes the entire content of the target document. /// /// o A member to add to an existing object - whereupon the supplied /// value is added to that object at the indicated location. If the /// member already exists, it is replaced by the specified value. /// /// o An element to add to an existing array - whereupon the supplied /// value is added to the array at the indicated location. Any /// elements at or above the specified index are shifted one position /// to the right. The specified index MUST NOT be greater than the /// number of elements in the array. If the "-" character is used to /// index the end of the array (see [RFC6901]), this has the effect of /// appending the value to the array. /// /// Because this operation is designed to add to existing objects and /// arrays, its target location will often not exist. Although the /// pointer's error handling algorithm will thus be invoked, this /// specification defines the error handling behavior for "add" pointers /// to ignore that error and add the value as specified. /// /// However, the object itself or an array containing it does need to /// exist, and it remains an error for that not to be the case. For /// example, an "add" with a target location of "/a/b" starting with this /// document: /// /// { "a": { "foo": 1 } } /// /// is not an error, because "a" exists, and "b" will be added to its /// value. It is an error in this document: /// /// { "q": { "bar": 2 } } /// /// because "a" does not exist. /// /// The add operation /// Object to apply the operation to public void Add(Operation operation, T objectToApplyTo) { Add(operation.path, operation.value, objectToApplyTo, operation); } /// /// Add is used by various operations (eg: add, copy, ...), yet through different operations; /// This method allows code reuse yet reporting the correct operation on error /// private void Add(string path, object value, T objectToApplyTo, Operation operationToReport) { // add, in this implementation, does not just "add" properties - that's // technically impossible; It can however be used to add items to arrays, // or to replace values. // first up: if the path ends in a numeric value, we're inserting in a list and // that value represents the position; if the path ends in "-", we're appending // to the list. var appendList = false; var positionAsInteger = -1; var actualPathToProperty = path; if (path.EndsWith("/-")) { appendList = true; actualPathToProperty = path.Substring(0, path.Length - 2); } else { positionAsInteger = PropertyHelpers.GetNumericEnd(path); if (positionAsInteger > -1) { actualPathToProperty = path.Substring(0, path.IndexOf('/' + positionAsInteger.ToString())); } } var patchProperty = PropertyHelpers .FindPropertyAndParent(objectToApplyTo, actualPathToProperty, ContractResolver); // does property at path exist? CheckIfPropertyExists(patchProperty, objectToApplyTo, operationToReport, path); // it exists. If it' an array, add to that array. If it's not, we replace. // is the path an array (but not a string (= char[]))? In this case, // the path must end with "/position" or "/-", which we already determined before. if (appendList || positionAsInteger > -1) { // what if it's an array but there's no position?? if (IsNonStringArray(patchProperty)) { // now, get the generic type of the enumerable var genericTypeOfArray = PropertyHelpers.GetEnumerableType( patchProperty.Property.PropertyType); var conversionResult = PropertyHelpers.ConvertToActualType(genericTypeOfArray, value); CheckIfPropertyCanBeSet(conversionResult, objectToApplyTo, operationToReport, path); // get value (it can be cast, we just checked that) var array = (IList)patchProperty.Property.ValueProvider.GetValue(patchProperty.Parent); if (appendList) { array.Add(conversionResult.ConvertedInstance); } else { // specified index must not be greater than the amount of items in the array if (positionAsInteger <= array.Count) { array.Insert(positionAsInteger, conversionResult.ConvertedInstance); } else { throw new JsonPatchException(operationToReport, string.Format("Patch failed: provided path is invalid for array property type at " + "location path: {0}: position larger than array size", path, 422), objectToApplyTo); } } } else { throw new JsonPatchException(operationToReport, string.Format("Patch failed: provided path is invalid for array property type at location " + "path: {0}: expected array", path), objectToApplyTo, 422); } } else { var conversionResultTuple = PropertyHelpers.ConvertToActualType( patchProperty.Property.PropertyType, value); // Is conversion successful CheckIfPropertyCanBeSet(conversionResultTuple, objectToApplyTo, operationToReport, path); patchProperty.Property.ValueProvider.SetValue( patchProperty.Parent, conversionResultTuple.ConvertedInstance); } } /// /// The "move" operation removes the value at a specified location and /// adds it to the target location. /// /// The operation object MUST contain a "from" member, which is a string /// containing a JSON Pointer value that references the location in the /// target document to move the value from. /// /// The "from" location MUST exist for the operation to be successful. /// /// For example: /// /// { "op": "move", "from": "/a/b/c", "path": "/a/b/d" } /// /// This operation is functionally identical to a "remove" operation on /// the "from" location, followed immediately by an "add" operation at /// the target location with the value that was just removed. /// /// The "from" location MUST NOT be a proper prefix of the "path" /// location; i.e., a location cannot be moved into one of its children. /// /// The move operation /// Object to apply the operation to public void Move(Operation operation, T objectToApplyTo) { // get value at from location object valueAtFromLocation = null; var positionAsInteger = -1; var actualFromProperty = operation.from; positionAsInteger = PropertyHelpers.GetNumericEnd(operation.from); if (positionAsInteger > -1) { actualFromProperty = operation.from.Substring(0, operation.from.IndexOf('/' + positionAsInteger.ToString())); } var patchProperty = PropertyHelpers .FindPropertyAndParent(objectToApplyTo, actualFromProperty, ContractResolver); // does property at from exist? CheckIfPropertyExists(patchProperty, objectToApplyTo, operation, operation.from); // is the path an array (but not a string (= char[]))? In this case, // the path must end with "/position" or "/-", which we already determined before. if (positionAsInteger > -1) { if (IsNonStringArray(patchProperty)) { // now, get the generic type of the enumerable var genericTypeOfArray = PropertyHelpers.GetEnumerableType(patchProperty.Property.PropertyType); // get value (it can be cast, we just checked that) var array = (IList)patchProperty.Property.ValueProvider.GetValue(patchProperty.Parent); if (array.Count <= positionAsInteger) { throw new JsonPatchException(operation, string.Format("Patch failed: provided from path is invalid for array property type at " + "location from: {0}: invalid position", operation.from), objectToApplyTo, 422); } valueAtFromLocation = array[positionAsInteger]; } else { throw new JsonPatchException(operation, string.Format("Patch failed: provided from path is invalid for array property type at " + "location from: {0}: expected array", operation.from), objectToApplyTo, 422); } } else { // no list, just get the value // set the new value valueAtFromLocation = patchProperty.Property.ValueProvider.GetValue(patchProperty.Parent); } // remove that value Remove(operation.from, objectToApplyTo, operation); // add that value to the path location Add(operation.path, valueAtFromLocation, objectToApplyTo, operation); } /// /// The "remove" operation removes the value at the target location. /// /// The target location MUST exist for the operation to be successful. /// /// For example: /// /// { "op": "remove", "path": "/a/b/c" } /// /// If removing an element from an array, any elements above the /// specified index are shifted one position to the left. /// /// The remove operation /// Object to apply the operation to public void Remove(Operation operation, T objectToApplyTo) { Remove(operation.path, objectToApplyTo, operation); } /// /// Remove is used by various operations (eg: remove, move, ...), yet through different operations; /// This method allows code reuse yet reporting the correct operation on error /// private void Remove(string path, T objectToApplyTo, Operation operationToReport) { var removeFromList = false; var positionAsInteger = -1; var actualPathToProperty = path; if (path.EndsWith("/-")) { removeFromList = true; actualPathToProperty = path.Substring(0, path.Length - 2); } else { positionAsInteger = PropertyHelpers.GetNumericEnd(path); if (positionAsInteger > -1) { actualPathToProperty = path.Substring(0, path.IndexOf('/' + positionAsInteger.ToString())); } } var patchProperty = PropertyHelpers .FindPropertyAndParent(objectToApplyTo, actualPathToProperty, ContractResolver); // does the target location exist? CheckIfPropertyExists(patchProperty, objectToApplyTo, operationToReport, path); // get the property, and remove it - in this case, for DTO's, that means setting // it to null or its default value; in case of an array, remove at provided index // or at the end. if (removeFromList || positionAsInteger > -1) { // what if it's an array but there's no position?? if (IsNonStringArray(patchProperty)) { // now, get the generic type of the enumerable var genericTypeOfArray = PropertyHelpers.GetEnumerableType(patchProperty.Property.PropertyType); // get value (it can be cast, we just checked that) var array = (IList)patchProperty.Property.ValueProvider.GetValue(patchProperty.Parent); if (removeFromList) { array.RemoveAt(array.Count - 1); } else { if (positionAsInteger < array.Count) { array.RemoveAt(positionAsInteger); } else { throw new JsonPatchException(operationToReport, string.Format("Patch failed: provided path is invalid for array property type at " + "location path: {0}: position larger than array size", path), objectToApplyTo, 422); } } } else { throw new JsonPatchException(operationToReport, string.Format("Patch failed: provided path is invalid for array property type at " + "location path: {0}: expected array", path), objectToApplyTo, 422); } } else { // setting the value to "null" will use the default value in case of value types, and // null in case of reference types object value = null; if (patchProperty.Property.PropertyType.GetTypeInfo().IsValueType && Nullable.GetUnderlyingType(patchProperty.Property.PropertyType) == null) { value = Activator.CreateInstance(patchProperty.Property.PropertyType); } patchProperty.Property.ValueProvider.SetValue(patchProperty.Parent, value); } } /// /// The "test" operation tests that a value at the target location is /// equal to a specified value. /// /// The operation object MUST contain a "value" member that conveys the /// value to be compared to the target location's value. /// /// The target location MUST be equal to the "value" value for the /// operation to be considered successful. /// /// Here, "equal" means that the value at the target location and the /// value conveyed by "value" are of the same JSON type, and that they /// are considered equal by the following rules for that type: /// /// o strings: are considered equal if they contain the same number of /// Unicode characters and their code points are byte-by-byte equal. /// /// o numbers: are considered equal if their values are numerically /// equal. /// /// o arrays: are considered equal if they contain the same number of /// values, and if each value can be considered equal to the value at /// the corresponding position in the other array, using this list of /// type-specific rules. /// /// o objects: are considered equal if they contain the same number of /// members, and if each member can be considered equal to a member in /// the other object, by comparing their keys (as strings) and their /// values (using this list of type-specific rules). /// /// o literals (false, true, and null): are considered equal if they are /// the same. /// /// Note that the comparison that is done is a logical comparison; e.g., /// whitespace between the member values of an array is not significant. /// /// Also, note that ordering of the serialization of object members is /// not significant. /// /// Note that we divert from the rules here - we use .NET's comparison, /// not the one above. In a future version, a "strict" setting might /// be added (configurable), that takes into account above rules. /// /// For example: /// /// { "op": "test", "path": "/a/b/c", "value": "foo" } /// /// The test operation /// Object to apply the operation to public void Test(Operation operation, T objectToApplyTo) { // get value at path location object valueAtPathLocation = null; var positionInPathAsInteger = -1; var actualPathProperty = operation.path; positionInPathAsInteger = PropertyHelpers.GetNumericEnd(operation.path); if (positionInPathAsInteger > -1) { actualPathProperty = operation.path.Substring(0, operation.path.IndexOf('/' + positionInPathAsInteger.ToString())); } var patchProperty = PropertyHelpers .FindPropertyAndParent(objectToApplyTo, actualPathProperty, ContractResolver); // does property at path exist? CheckIfPropertyExists(patchProperty, objectToApplyTo, operation, operation.path); // get the property path Type typeOfFinalPropertyAtPathLocation; // is the path an array (but not a string (= char[]))? In this case, // the path must end with "/position" or "/-", which we already determined before. if (positionInPathAsInteger > -1) { if (IsNonStringArray(patchProperty)) { // now, get the generic type of the enumerable typeOfFinalPropertyAtPathLocation = PropertyHelpers .GetEnumerableType(patchProperty.Property.PropertyType); // get value (it can be cast, we just checked that) var array = (IList)patchProperty.Property.ValueProvider.GetValue(patchProperty.Parent); if (array.Count <= positionInPathAsInteger) { throw new JsonPatchException(operation, string.Format("Patch failed: provided from path is invalid for array property type at " + "location path: {0}: invalid position", operation.path), objectToApplyTo, 422); } valueAtPathLocation = array[positionInPathAsInteger]; } else { throw new JsonPatchException(operation, string.Format("Patch failed: provided from path is invalid for array property type at " + "location path: {0}: expected array", operation.path), objectToApplyTo, 422); } } else { // no list, just get the value valueAtPathLocation = patchProperty.Property.ValueProvider.GetValue(patchProperty.Parent); typeOfFinalPropertyAtPathLocation = patchProperty.Property.PropertyType; } var conversionResultTuple = PropertyHelpers.ConvertToActualType( typeOfFinalPropertyAtPathLocation, operation.value); // Is conversion successful CheckIfPropertyCanBeSet(conversionResultTuple, objectToApplyTo, operation, operation.path); //Compare } /// /// The "replace" operation replaces the value at the target location /// with a new value. The operation object MUST contain a "value" member /// whose content specifies the replacement value. /// /// The target location MUST exist for the operation to be successful. /// /// For example: /// /// { "op": "replace", "path": "/a/b/c", "value": 42 } /// /// This operation is functionally identical to a "remove" operation for /// a value, followed immediately by an "add" operation at the same /// location with the replacement value. /// /// Note: even though it's the same functionally, we do not call remove + add /// for performance reasons (multiple checks of same requirements). /// /// The replace operation /// Object to apply the operation to public void Replace(Operation operation, T objectToApplyTo) { Remove(operation.path, objectToApplyTo, operation); Add(operation.path, operation.value, objectToApplyTo, operation); } /// /// The "copy" operation copies the value at a specified location to the /// target location. /// /// The operation object MUST contain a "from" member, which is a string /// containing a JSON Pointer value that references the location in the /// target document to copy the value from. /// /// The "from" location MUST exist for the operation to be successful. /// /// For example: /// /// { "op": "copy", "from": "/a/b/c", "path": "/a/b/e" } /// /// This operation is functionally identical to an "add" operation at the /// target location using the value specified in the "from" member. /// /// Note: even though it's the same functionally, we do not call add with /// the value specified in from for performance reasons (multiple checks of same requirements). /// /// The copy operation /// Object to apply the operation to public void Copy(Operation operation, T objectToApplyTo) { // get value at from location object valueAtFromLocation = null; var positionAsInteger = -1; var actualFromProperty = operation.from; positionAsInteger = PropertyHelpers.GetNumericEnd(operation.from); if (positionAsInteger > -1) { actualFromProperty = operation.from.Substring(0, operation.from.IndexOf('/' + positionAsInteger.ToString())); } var patchProperty = PropertyHelpers .FindPropertyAndParent(objectToApplyTo, actualFromProperty, ContractResolver); // does property at from exist? CheckIfPropertyExists(patchProperty, objectToApplyTo, operation, operation.from); // get the property path // is the path an array (but not a string (= char[]))? In this case, // the path must end with "/position" or "/-", which we already determined before. if (positionAsInteger > -1) { if (IsNonStringArray(patchProperty)) { // now, get the generic type of the enumerable var genericTypeOfArray = PropertyHelpers.GetEnumerableType(patchProperty.Property.PropertyType); // get value (it can be cast, we just checked that) var array = (IList)patchProperty.Property.ValueProvider.GetValue(patchProperty.Parent); if (array.Count <= positionAsInteger) { throw new JsonPatchException(operation, string.Format("Patch failed: provided from path is invalid for array property type at " + "location from: {0}: invalid position", operation.from), objectToApplyTo, 422); } valueAtFromLocation = array[positionAsInteger]; } else { throw new JsonPatchException(operation, string.Format("Patch failed: provided from path is invalid for array property type at " + "location from: {0}: expected array", operation.from), objectToApplyTo, 422); } } else { // no list, just get the value // set the new value valueAtFromLocation = patchProperty.Property.ValueProvider.GetValue(patchProperty.Parent); } // add operation to target location with that value. Add(operation.path, valueAtFromLocation, objectToApplyTo, operation); } private void CheckIfPropertyExists( JsonPatchProperty patchProperty, T objectToApplyTo, Operation operation, string propertyPath) { if (patchProperty == null) { throw new JsonPatchException( operation, string.Format("Patch failed: property at location {0} does not exist", propertyPath), objectToApplyTo, 422); } if (patchProperty.Property.Ignored) { throw new JsonPatchException( operation, string.Format("Patch failed: cannot update property at location {0}", propertyPath), objectToApplyTo, 422); } } private bool IsNonStringArray(JsonPatchProperty patchProperty) { return !(patchProperty.Property.PropertyType == typeof(string)) && typeof(IList).GetTypeInfo().IsAssignableFrom( patchProperty.Property.PropertyType.GetTypeInfo()); } private void CheckIfPropertyCanBeSet( ConversionResult result, T objectToApplyTo, Operation operation, string path) { var errorMessage = "Patch failed: provided value is invalid for property type at location path: "; if (!result.CanBeConverted) { throw new JsonPatchException(operation, string.Format(errorMessage + "{0}", path), objectToApplyTo, 422); } } } }