diff --git a/AspNetCore.slnx b/AspNetCore.slnx index 42988a869787..b4b5b19b9662 100644 --- a/AspNetCore.slnx +++ b/AspNetCore.slnx @@ -211,6 +211,10 @@ + + + + diff --git a/src/Features/JsonPatch.SystemTextJson/.vsconfig b/src/Features/JsonPatch.SystemTextJson/.vsconfig new file mode 100644 index 000000000000..77009588dff2 --- /dev/null +++ b/src/Features/JsonPatch.SystemTextJson/.vsconfig @@ -0,0 +1,12 @@ +{ + "version": "1.0", + "components": [ + "Microsoft.Net.Component.4.6.2.TargetingPack", + "Microsoft.Net.Component.4.7.2.SDK", + "Microsoft.Net.Component.4.7.2.TargetingPack", + "Microsoft.VisualStudio.Workload.ManagedDesktop", + "Microsoft.VisualStudio.Workload.NetCoreTools", + "Microsoft.VisualStudio.Workload.NetWeb", + "Microsoft.VisualStudio.Workload.VisualStudioExtension" + ] +} diff --git a/src/Features/JsonPatch.SystemTextJson/JsonPatch.SystemTextJson.slnf b/src/Features/JsonPatch.SystemTextJson/JsonPatch.SystemTextJson.slnf new file mode 100644 index 000000000000..19edc5866421 --- /dev/null +++ b/src/Features/JsonPatch.SystemTextJson/JsonPatch.SystemTextJson.slnf @@ -0,0 +1,9 @@ +{ + "solution": { + "path": "..\\..\\..\\AspNetCore.slnx", + "projects" : [ + "src\\Features\\JsonPatch.SystemTextJson\\src\\Microsoft.AspNetCore.JsonPatch.SystemTextJson.csproj", + "src\\Features\\JsonPatch.SystemTextJson\\test\\Microsoft.AspNetCore.JsonPatch.SystemTextJson.Tests.csproj" + ] + } +} diff --git a/src/Features/JsonPatch.SystemTextJson/build.cmd b/src/Features/JsonPatch.SystemTextJson/build.cmd new file mode 100644 index 000000000000..956c031417d3 --- /dev/null +++ b/src/Features/JsonPatch.SystemTextJson/build.cmd @@ -0,0 +1,4 @@ + +@ECHO OFF +SET RepoRoot=%~dp0..\..\.. +%RepoRoot%\eng\build.cmd -projects %~dp0**\*.*proj %* diff --git a/src/Features/JsonPatch.SystemTextJson/build.sh b/src/Features/JsonPatch.SystemTextJson/build.sh new file mode 100644 index 000000000000..4eb40c27e392 --- /dev/null +++ b/src/Features/JsonPatch.SystemTextJson/build.sh @@ -0,0 +1,7 @@ +#!/usr/bin/env bash + +set -euo pipefail + +DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )" +repo_root="$DIR/../.." +"$repo_root/eng/build.sh" --projects "$DIR/**/*.*proj" "$@" diff --git a/src/Features/JsonPatch.SystemTextJson/src/Adapters/AdapterFactory.cs b/src/Features/JsonPatch.SystemTextJson/src/Adapters/AdapterFactory.cs new file mode 100644 index 000000000000..aff6f3a6bf5a --- /dev/null +++ b/src/Features/JsonPatch.SystemTextJson/src/Adapters/AdapterFactory.cs @@ -0,0 +1,40 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Collections; +using System.Collections.Generic; +using System.Text.Json.Nodes; +using Microsoft.AspNetCore.JsonPatch.SystemTextJson.Internal; +using Microsoft.AspNetCore.Shared; + +namespace Microsoft.AspNetCore.JsonPatch.SystemTextJson.Adapters; + +/// +/// The default AdapterFactory to be used for resolving . +/// +internal class AdapterFactory : IAdapterFactory +{ + internal static AdapterFactory Default { get; } = new(); + + /// + public virtual IAdapter Create(object target) + { + ArgumentNullThrowHelper.ThrowIfNull(target); + + var typeToConvert = target.GetType(); + if (typeToConvert.IsGenericType && typeToConvert.GetGenericTypeDefinition() == typeof(Dictionary<,>)) + { + return (IAdapter)Activator.CreateInstance(typeof(DictionaryAdapter<,>).MakeGenericType(typeToConvert.GenericTypeArguments[0], typeToConvert.GenericTypeArguments[1])); + } + + return target switch + { + JsonObject => new JsonObjectAdapter(), + JsonArray => new ListAdapter(), + IList => new ListAdapter(), + _ => new PocoAdapter() + }; + } +} + diff --git a/src/Features/JsonPatch.SystemTextJson/src/Adapters/IAdapterFactory.cs b/src/Features/JsonPatch.SystemTextJson/src/Adapters/IAdapterFactory.cs new file mode 100644 index 000000000000..2afae2ee4e6c --- /dev/null +++ b/src/Features/JsonPatch.SystemTextJson/src/Adapters/IAdapterFactory.cs @@ -0,0 +1,19 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Microsoft.AspNetCore.JsonPatch.SystemTextJson.Internal; + +namespace Microsoft.AspNetCore.JsonPatch.SystemTextJson.Adapters; + +/// +/// Defines the operations used for loading an based on the current object and ContractResolver. +/// +internal interface IAdapterFactory +{ + /// + /// Creates an for the current object + /// + /// The target object + /// The needed + IAdapter Create(object target); +} diff --git a/src/Features/JsonPatch.SystemTextJson/src/Adapters/IObjectAdapter.cs b/src/Features/JsonPatch.SystemTextJson/src/Adapters/IObjectAdapter.cs new file mode 100644 index 000000000000..4a20c424484d --- /dev/null +++ b/src/Features/JsonPatch.SystemTextJson/src/Adapters/IObjectAdapter.cs @@ -0,0 +1,111 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Microsoft.AspNetCore.JsonPatch.SystemTextJson.Operations; + +namespace Microsoft.AspNetCore.JsonPatch.SystemTextJson.Adapters; + +/// +/// Defines the operations that can be performed on a JSON patch document. +/// +public interface IObjectAdapter +{ + /// + /// Using the "add" operation a new value is inserted into the root of the target + /// document, into the target array at the specified valid index, or to a target object at + /// the specified location. + /// + /// When adding to arrays, the specified index MUST NOT be greater than the number of elements in the array. + /// To append the value to the array, the index of "-" character is used (see [RFC6901]). + /// + /// When adding to an object, if an object member does not already exist, a new member is added to the object at the + /// specified location or if an object member 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" ] } + /// + /// See RFC 6902 + /// + /// The add operation. + /// Object to apply the operation to. + void Add(Operation operation, object objectToApplyTo); + + /// + /// Using the "copy" operation, a value is copied from a specified location to the + /// target location. + /// + /// The operation object MUST contain a "from" member, which 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" } + /// + /// See RFC 6902 + /// + /// The copy operation. + /// Object to apply the operation to. + void Copy(Operation operation, object objectToApplyTo); + + /// + /// Using the "move" operation the value at a specified location is removed and + /// added to the target location. + /// + /// The operation object MUST contain a "from" member, which 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" } + /// + /// A location cannot be moved into one of its children. + /// + /// See RFC 6902 + /// + /// The move operation. + /// Object to apply the operation to. + void Move(Operation operation, object objectToApplyTo); + + /// + /// Using the "remove" operation the value at the target location is removed. + /// + /// 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. + /// + /// See RFC 6902 + /// + /// The remove operation. + /// Object to apply the operation to. + void Remove(Operation operation, object objectToApplyTo); + + /// + /// Using the "replace" operation the value at the target location is replaced + /// with a new value. The operation object MUST contain a "value" member + /// which 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 } + /// + /// See RFC 6902 + /// + /// The replace operation. + /// Object to apply the operation to. + void Replace(Operation operation, object objectToApplyTo); +} diff --git a/src/Features/JsonPatch.SystemTextJson/src/Adapters/IObjectAdapterWithTest.cs b/src/Features/JsonPatch.SystemTextJson/src/Adapters/IObjectAdapterWithTest.cs new file mode 100644 index 000000000000..9dd4612a727f --- /dev/null +++ b/src/Features/JsonPatch.SystemTextJson/src/Adapters/IObjectAdapterWithTest.cs @@ -0,0 +1,31 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Microsoft.AspNetCore.JsonPatch.SystemTextJson.Operations; + +namespace Microsoft.AspNetCore.JsonPatch.SystemTextJson.Adapters; + +/// +/// Defines the operations that can be performed on a JSON patch document, including "test". +/// +public interface IObjectAdapterWithTest : IObjectAdapter +{ + /// + /// Using the "test" operation a value at the target location is compared for + /// equality to a specified value. + /// + /// The operation object MUST contain a "value" member that specifies + /// 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. + /// + /// For example: + /// { "op": "test", "path": "/a/b/c", "value": "foo" } + /// + /// See RFC 6902 + /// + /// The test operation. + /// Object to apply the operation to. + void Test(Operation operation, object objectToApplyTo); +} diff --git a/src/Features/JsonPatch.SystemTextJson/src/Adapters/ObjectAdapter.cs b/src/Features/JsonPatch.SystemTextJson/src/Adapters/ObjectAdapter.cs new file mode 100644 index 000000000000..708dd61dffeb --- /dev/null +++ b/src/Features/JsonPatch.SystemTextJson/src/Adapters/ObjectAdapter.cs @@ -0,0 +1,280 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Text.Json; +using System.Text.Json.Serialization.Metadata; +using Microsoft.AspNetCore.JsonPatch.SystemTextJson.Internal; +using Microsoft.AspNetCore.JsonPatch.SystemTextJson.Operations; +using Microsoft.AspNetCore.Shared; + +namespace Microsoft.AspNetCore.JsonPatch.SystemTextJson.Adapters; + +/// +internal class ObjectAdapter : IObjectAdapterWithTest +{ + /// + /// Initializes a new instance of . + /// + /// The . + /// The for logging . + public ObjectAdapter( + JsonSerializerOptions serializerOptions, + Action logErrorAction) : + this(serializerOptions, logErrorAction, Adapters.AdapterFactory.Default) + { + } + + /// + /// Initializes a new instance of . + /// + /// The . + /// The for logging . + /// The to use when creating adaptors. + public ObjectAdapter( + JsonSerializerOptions serializerOptions, + Action logErrorAction, + IAdapterFactory adapterFactory) + { + SerializerOptions = serializerOptions ?? throw new ArgumentNullException(nameof(serializerOptions)); + LogErrorAction = logErrorAction; + AdapterFactory = adapterFactory ?? throw new ArgumentNullException(nameof(adapterFactory)); + } + + /// + /// Gets or sets the . + /// + public JsonSerializerOptions SerializerOptions { get; } + + /// + /// Gets or sets the + /// + public IAdapterFactory AdapterFactory { get; } + + /// + /// Action for logging . + /// + public Action LogErrorAction { get; } + + public void Add(Operation operation, object objectToApplyTo) + { + ArgumentNullThrowHelper.ThrowIfNull(operation); + ArgumentNullThrowHelper.ThrowIfNull(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, + object objectToApplyTo, + Operation operation) + { + ArgumentNullThrowHelper.ThrowIfNull(path); + ArgumentNullThrowHelper.ThrowIfNull(objectToApplyTo); + ArgumentNullThrowHelper.ThrowIfNull(operation); + + var parsedPath = new ParsedPath(path); + var visitor = new ObjectVisitor(parsedPath, SerializerOptions, AdapterFactory); + + var target = objectToApplyTo; + // Find the target object and the appropriate adapter + if (!visitor.TryVisit(ref target, out var adapter, out var errorMessage)) + { + var error = CreatePathNotFoundError(objectToApplyTo, path, operation, errorMessage); + ErrorReporter(error); + return; + } + + if (!adapter.TryAdd(target, parsedPath.LastSegment, SerializerOptions, value, out errorMessage)) + { + var error = CreateOperationFailedError(objectToApplyTo, path, operation, errorMessage); + ErrorReporter(error); + return; + } + } + + public void Move(Operation operation, object objectToApplyTo) + { + ArgumentNullThrowHelper.ThrowIfNull(operation); + ArgumentNullThrowHelper.ThrowIfNull(objectToApplyTo); + + // Get value at 'from' location and add that value to the 'path' location + if (TryGetValue(operation.from, objectToApplyTo, operation, out var propertyValue)) + { + // remove that value + Remove(operation.from, objectToApplyTo, operation); + + // add that value to the path location + Add(operation.path, propertyValue, objectToApplyTo, operation); + } + } + + public void Remove(Operation operation, object objectToApplyTo) + { + ArgumentNullThrowHelper.ThrowIfNull(operation); + ArgumentNullThrowHelper.ThrowIfNull(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. The return value + /// contains the type of the item that has been removed (and a bool possibly signifying an error) + /// This can be used by other methods, like replace, to ensure that we can pass in the correctly + /// typed value to whatever method follows. + /// + private void Remove(string path, object objectToApplyTo, Operation operationToReport) + { + var parsedPath = new ParsedPath(path); + var visitor = new ObjectVisitor(parsedPath, SerializerOptions, AdapterFactory); + + var target = objectToApplyTo; + if (!visitor.TryVisit(ref target, out var adapter, out var errorMessage)) + { + var error = CreatePathNotFoundError(objectToApplyTo, path, operationToReport, errorMessage); + ErrorReporter(error); + return; + } + + if (!adapter.TryRemove(target, parsedPath.LastSegment, SerializerOptions, out errorMessage)) + { + var error = CreateOperationFailedError(objectToApplyTo, path, operationToReport, errorMessage); + ErrorReporter(error); + return; + } + } + + public void Replace(Operation operation, object objectToApplyTo) + { + ArgumentNullThrowHelper.ThrowIfNull(operation); + ArgumentNullThrowHelper.ThrowIfNull(objectToApplyTo); + + var parsedPath = new ParsedPath(operation.path); + var visitor = new ObjectVisitor(parsedPath, SerializerOptions, AdapterFactory); + + var target = objectToApplyTo; + if (!visitor.TryVisit(ref target, out var adapter, out var errorMessage)) + { + var error = CreatePathNotFoundError(objectToApplyTo, operation.path, operation, errorMessage); + ErrorReporter(error); + return; + } + + if (!adapter.TryReplace(target, parsedPath.LastSegment, SerializerOptions, operation.value, out errorMessage)) + { + var error = CreateOperationFailedError(objectToApplyTo, operation.path, operation, errorMessage); + ErrorReporter(error); + return; + } + } + + public void Copy(Operation operation, object objectToApplyTo) + { + ArgumentNullThrowHelper.ThrowIfNull(operation); + ArgumentNullThrowHelper.ThrowIfNull(objectToApplyTo); + + // Get value at 'from' location and add that value to the 'path' location + if (TryGetValue(operation.from, objectToApplyTo, operation, out var propertyValue)) + { + // Create deep copy + var copyResult = ConversionResultProvider.CopyTo(propertyValue, propertyValue?.GetType(), SerializerOptions); + if (copyResult.CanBeConverted) + { + Add(operation.path, copyResult.ConvertedInstance, objectToApplyTo, operation); + } + else + { + var error = CreateOperationFailedError(objectToApplyTo, operation.path, operation, Resources.FormatCannotCopyProperty(operation.from)); + ErrorReporter(error); + return; + } + } + } + + public void Test(Operation operation, object objectToApplyTo) + { + ArgumentNullThrowHelper.ThrowIfNull(operation); + ArgumentNullThrowHelper.ThrowIfNull(objectToApplyTo); + + var parsedPath = new ParsedPath(operation.path); + var visitor = new ObjectVisitor(parsedPath, SerializerOptions, AdapterFactory); + + var target = objectToApplyTo; + if (!visitor.TryVisit(ref target, out var adapter, out var errorMessage)) + { + var error = CreatePathNotFoundError(objectToApplyTo, operation.path, operation, errorMessage); + ErrorReporter(error); + return; + } + + if (!adapter.TryTest(target, parsedPath.LastSegment, SerializerOptions, operation.value, out errorMessage)) + { + var error = CreateOperationFailedError(objectToApplyTo, operation.path, operation, errorMessage); + ErrorReporter(error); + return; + } + } + + private bool TryGetValue( + string fromLocation, + object objectToGetValueFrom, + Operation operation, + out object propertyValue) + { + ArgumentNullThrowHelper.ThrowIfNull(fromLocation); + ArgumentNullThrowHelper.ThrowIfNull(objectToGetValueFrom); + ArgumentNullThrowHelper.ThrowIfNull(operation); + + propertyValue = null; + + var parsedPath = new ParsedPath(fromLocation); + var visitor = new ObjectVisitor(parsedPath, SerializerOptions, AdapterFactory); + + var target = objectToGetValueFrom; + if (!visitor.TryVisit(ref target, out var adapter, out var errorMessage)) + { + var error = CreatePathNotFoundError(objectToGetValueFrom, fromLocation, operation, errorMessage); + ErrorReporter(error); + return false; + } + + if (!adapter.TryGet(target, parsedPath.LastSegment, SerializerOptions, out propertyValue, out errorMessage)) + { + var error = CreateOperationFailedError(objectToGetValueFrom, fromLocation, operation, errorMessage); + ErrorReporter(error); + return false; + } + + return true; + } + + private Action ErrorReporter + { + get + { + return LogErrorAction ?? Internal.ErrorReporter.Default; + } + } + + private static JsonPatchError CreateOperationFailedError(object target, string path, Operation operation, string errorMessage) + { + return new JsonPatchError( + target, + operation, + errorMessage ?? Resources.FormatCannotPerformOperation(operation.op, path)); + } + + private static JsonPatchError CreatePathNotFoundError(object target, string path, Operation operation, string errorMessage) + { + return new JsonPatchError( + target, + operation, + errorMessage ?? Resources.FormatTargetLocationNotFound(operation.op, path)); + } +} diff --git a/src/Features/JsonPatch.SystemTextJson/src/Converters/JsonConverterForJsonPatchDocumentOfT.cs b/src/Features/JsonPatch.SystemTextJson/src/Converters/JsonConverterForJsonPatchDocumentOfT.cs new file mode 100644 index 000000000000..d6871a8b455e --- /dev/null +++ b/src/Features/JsonPatch.SystemTextJson/src/Converters/JsonConverterForJsonPatchDocumentOfT.cs @@ -0,0 +1,69 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Collections.Generic; +using System.Text.Json; +using System.Text.Json.Serialization; +using Microsoft.AspNetCore.JsonPatch.SystemTextJson.Operations; + +namespace Microsoft.AspNetCore.JsonPatch.SystemTextJson.Converters; + +internal sealed class JsonConverterForJsonPatchDocumentOfT : JsonConverter> + where T : class +{ + private static JsonConverter> GetConverter(JsonSerializerOptions options) => + (JsonConverter>)options.GetConverter(typeof(Operation)); + + public override JsonPatchDocument Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + if (typeToConvert != typeof(JsonPatchDocument)) + { + throw new ArgumentException(Resources.FormatParameterMustMatchType(nameof(typeToConvert), nameof(JsonPatchDocument)), nameof(typeToConvert)); + } + + if (reader.TokenType != JsonTokenType.StartArray) + { + throw new JsonException(Resources.InvalidJsonPatchDocument); + } + + if (reader.TokenType == JsonTokenType.Null) + { + return null; + } + + List> ops = []; + try + { + JsonConverter> operationConverter = GetConverter(options); + while (reader.Read() && reader.TokenType is not JsonTokenType.EndArray) + { + var op = operationConverter.Read(ref reader, typeof(Operation), options); + ops.Add(op); + } + + return new JsonPatchDocument(ops, options); + } + catch (Exception ex) + { + throw new JsonException(Resources.InvalidJsonPatchDocument, ex); + } + } + + public override void Write(Utf8JsonWriter writer, JsonPatchDocument value, JsonSerializerOptions options) + { + if (value == null) + { + writer.WriteNullValue(); + return; + } + + JsonConverter> operationConverter = GetConverter(options); + writer.WriteStartArray(); + foreach (var operation in value.Operations) + { + operationConverter.Write(writer, operation, options); + } + writer.WriteEndArray(); + } +} diff --git a/src/Features/JsonPatch.SystemTextJson/src/Converters/JsonPatchDocumentConverter.cs b/src/Features/JsonPatch.SystemTextJson/src/Converters/JsonPatchDocumentConverter.cs new file mode 100644 index 000000000000..554fe8d12eb8 --- /dev/null +++ b/src/Features/JsonPatch.SystemTextJson/src/Converters/JsonPatchDocumentConverter.cs @@ -0,0 +1,71 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Collections.Generic; +using System.Text.Json; +using System.Text.Json.Serialization; +using Microsoft.AspNetCore.JsonPatch.SystemTextJson.Operations; + +namespace Microsoft.AspNetCore.JsonPatch.SystemTextJson.Converters; + +internal class JsonPatchDocumentConverter : JsonConverter +{ + private static JsonConverter GetConverter(JsonSerializerOptions options) => + (JsonConverter)options.GetConverter(typeof(Operation)); + + public override JsonPatchDocument Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + if (typeToConvert != typeof(JsonPatchDocument)) + { + throw new ArgumentException(Resources.FormatParameterMustMatchType(nameof(typeToConvert), nameof(JsonPatchDocument)), nameof(typeToConvert)); + } + + if (reader.TokenType != JsonTokenType.StartArray) + { + throw new JsonException(Resources.InvalidJsonPatchDocument); + } + + if (reader.TokenType == JsonTokenType.Null) + { + return null; + } + + List ops = []; + + try + { + JsonConverter operationConverter = GetConverter(options); + while (reader.Read() && reader.TokenType is not JsonTokenType.EndArray) + { + var op = operationConverter.Read(ref reader, typeof(Operation), options); + ops.Add(op); + } + + return new JsonPatchDocument(ops, options); + } + catch (Exception ex) + { + throw new JsonException(Resources.InvalidJsonPatchDocument, ex); + } + } + + public override void Write(Utf8JsonWriter writer, JsonPatchDocument value, JsonSerializerOptions options) + { + if (value == null) + { + writer.WriteNullValue(); + return; + } + + JsonConverter operationConverter = GetConverter(options); + + writer.WriteStartArray(); + foreach (var operation in value.Operations) + { + operationConverter.Write(writer, operation, options); + } + + writer.WriteEndArray(); + } +} diff --git a/src/Features/JsonPatch.SystemTextJson/src/Converters/JsonPatchDocumentConverterFactory.cs b/src/Features/JsonPatch.SystemTextJson/src/Converters/JsonPatchDocumentConverterFactory.cs new file mode 100644 index 000000000000..d862cb97df80 --- /dev/null +++ b/src/Features/JsonPatch.SystemTextJson/src/Converters/JsonPatchDocumentConverterFactory.cs @@ -0,0 +1,27 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace Microsoft.AspNetCore.JsonPatch.SystemTextJson.Converters; + +internal class JsonPatchDocumentConverterFactory : JsonConverterFactory +{ + public override bool CanConvert(Type typeToConvert) + { + return typeToConvert == typeof(JsonPatchDocument) || + (typeToConvert.IsGenericType && typeToConvert.GetGenericTypeDefinition() == typeof(JsonPatchDocument<>)); + } + + public override JsonConverter CreateConverter(Type typeToConvert, JsonSerializerOptions options) + { + if (typeToConvert == typeof(JsonPatchDocument)) + { + return new JsonPatchDocumentConverter(); + } + + return (JsonConverter)Activator.CreateInstance(typeof(JsonConverterForJsonPatchDocumentOfT<>).MakeGenericType(typeToConvert.GenericTypeArguments[0])); + } +} diff --git a/src/Features/JsonPatch.SystemTextJson/src/Exceptions/JsonPatchException.cs b/src/Features/JsonPatch.SystemTextJson/src/Exceptions/JsonPatchException.cs new file mode 100644 index 000000000000..a772e4393773 --- /dev/null +++ b/src/Features/JsonPatch.SystemTextJson/src/Exceptions/JsonPatchException.cs @@ -0,0 +1,34 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using Microsoft.AspNetCore.JsonPatch.SystemTextJson.Operations; + +namespace Microsoft.AspNetCore.JsonPatch.SystemTextJson.Exceptions; + +public class JsonPatchException : Exception +{ + public Operation FailedOperation { get; private set; } + public object AffectedObject { get; private set; } + + public JsonPatchException() + { + } + + public JsonPatchException(JsonPatchError jsonPatchError, Exception innerException) + : base(jsonPatchError.ErrorMessage, innerException) + { + FailedOperation = jsonPatchError.Operation; + AffectedObject = jsonPatchError.AffectedObject; + } + + public JsonPatchException(JsonPatchError jsonPatchError) + : this(jsonPatchError, null) + { + } + + public JsonPatchException(string message, Exception innerException) + : base(message, innerException) + { + } +} diff --git a/src/Features/JsonPatch.SystemTextJson/src/Helpers/GenericListOrJsonArrayUtilities.cs b/src/Features/JsonPatch.SystemTextJson/src/Helpers/GenericListOrJsonArrayUtilities.cs new file mode 100644 index 000000000000..a82ee38ab951 --- /dev/null +++ b/src/Features/JsonPatch.SystemTextJson/src/Helpers/GenericListOrJsonArrayUtilities.cs @@ -0,0 +1,105 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Collections; +using System.Text.Json.Nodes; + +namespace Microsoft.AspNetCore.JsonPatch.SystemTextJson.Helpers; + +internal static class GenericListOrJsonArrayUtilities +{ + internal static object GetElementAt(object list, int index) + { + if (list is IList nonGenericList) + { + return nonGenericList[index]; + } + + if (list is JsonArray array) + { + return array[index]; + } + + throw new InvalidOperationException($"Unsupported list type: {list.GetType()}"); + } + + internal static void SetValueAt(object list, int index, object value) + { + if (list is IList nonGenericList) + { + nonGenericList[index] = value; + } + else if (list is JsonArray array) + { + array[index] = (JsonNode)value; + } + else + { + throw new InvalidOperationException($"Unsupported list type: {list.GetType()}"); + } + } + + internal static int GetCount(object list) + { + if (list is ICollection nonGenericList) + { + return nonGenericList.Count; + } + + if (list is JsonArray jsonArray) + { + return jsonArray.Count; + } + + throw new InvalidOperationException($"Unsupported list type: {list.GetType()}"); + } + + internal static void RemoveElementAt(object list, int index) + { + if (list is IList nonGenericList) + { + nonGenericList.RemoveAt(index); + } + else if (list is JsonArray array) + { + array.RemoveAt(index); + } + else + { + throw new InvalidOperationException($"Unsupported list type: {list.GetType()}"); + } + } + + internal static void InsertElementAt(object list, int index, object value) + { + if (list is IList nonGenericList) + { + nonGenericList.Insert(index, value); + } + else if (list is JsonArray array) + { + array.Insert(index, (JsonNode)value); + } + else + { + throw new InvalidOperationException($"Unsupported list type: {list.GetType()}"); + } + } + + internal static void AddElement(object list, object value) + { + if (list is IList nonGenericList) + { + nonGenericList.Add(value); + } + else if (list is JsonArray array) + { + array.Add(value); + } + else + { + throw new InvalidOperationException($"Unsupported list type: {list.GetType()}"); + } + } +} diff --git a/src/Features/JsonPatch.SystemTextJson/src/Helpers/JsonUtilities.cs b/src/Features/JsonPatch.SystemTextJson/src/Helpers/JsonUtilities.cs new file mode 100644 index 000000000000..7c223fff2093 --- /dev/null +++ b/src/Features/JsonPatch.SystemTextJson/src/Helpers/JsonUtilities.cs @@ -0,0 +1,51 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Text.Json; +using System.Text.Json.Nodes; + +namespace Microsoft.AspNetCore.JsonPatch.SystemTextJson.Helpers; + +internal static class JsonUtilities +{ + public static bool DeepEquals(object a, object b, JsonSerializerOptions serializerOptions) + { + if (a == null && b == null) + { + return true; + } + + if (a == null || b == null) + { + return false; + } + + if (a is JsonNode nodeA && b is JsonNode nodeB) + { + return JsonNode.DeepEquals(nodeA, nodeB); + } + + using var docA = TryGetJsonElement(a, serializerOptions, out var elementA); + using var docB = TryGetJsonElement(b, serializerOptions, out var elementB); + + return JsonElement.DeepEquals(elementA, elementB); + } + + private static IDisposable TryGetJsonElement(object item, JsonSerializerOptions serializerOptions, out JsonElement element) + { + IDisposable result = null; + if (item is JsonElement jsonElement) + { + element = jsonElement; + } + else + { + var docA = JsonSerializer.SerializeToDocument(item, serializerOptions); + element = docA.RootElement; + result = docA; + } + + return result; + } +} diff --git a/src/Features/JsonPatch.SystemTextJson/src/IJsonPatchDocument.cs b/src/Features/JsonPatch.SystemTextJson/src/IJsonPatchDocument.cs new file mode 100644 index 000000000000..01aafc5431a7 --- /dev/null +++ b/src/Features/JsonPatch.SystemTextJson/src/IJsonPatchDocument.cs @@ -0,0 +1,15 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Collections.Generic; +using System.Text.Json; +using Microsoft.AspNetCore.JsonPatch.SystemTextJson.Operations; + +namespace Microsoft.AspNetCore.JsonPatch.SystemTextJson; + +public interface IJsonPatchDocument +{ + JsonSerializerOptions SerializerOptions { get; set; } + + IList GetOperations(); +} diff --git a/src/Features/JsonPatch.SystemTextJson/src/Internal/ConversionResult.cs b/src/Features/JsonPatch.SystemTextJson/src/Internal/ConversionResult.cs new file mode 100644 index 000000000000..57d88953acea --- /dev/null +++ b/src/Features/JsonPatch.SystemTextJson/src/Internal/ConversionResult.cs @@ -0,0 +1,16 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace Microsoft.AspNetCore.JsonPatch.SystemTextJson.Internal; + +internal class ConversionResult +{ + public ConversionResult(bool canBeConverted, object convertedInstance) + { + CanBeConverted = canBeConverted; + ConvertedInstance = convertedInstance; + } + + public bool CanBeConverted { get; } + public object ConvertedInstance { get; } +} diff --git a/src/Features/JsonPatch.SystemTextJson/src/Internal/ConversionResultProvider.cs b/src/Features/JsonPatch.SystemTextJson/src/Internal/ConversionResultProvider.cs new file mode 100644 index 000000000000..1ef5c87a92e7 --- /dev/null +++ b/src/Features/JsonPatch.SystemTextJson/src/Internal/ConversionResultProvider.cs @@ -0,0 +1,85 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Text.Json; + +namespace Microsoft.AspNetCore.JsonPatch.SystemTextJson.Internal; + +internal static class ConversionResultProvider +{ + internal static ConversionResult ConvertTo(object value, Type typeToConvertTo, JsonSerializerOptions serializerOptions) + { + if (value == null) + { + return new ConversionResult(IsNullableType(typeToConvertTo), null); + } + + if (typeToConvertTo.IsAssignableFrom(value.GetType())) + { + // No need to convert + return new ConversionResult(true, value); + } + + return GetConvertedValue(value, serializerOptions, ref typeToConvertTo); + } + + internal static ConversionResult CopyTo(object value, Type typeToConvertTo, JsonSerializerOptions serializerOptions) + { + var targetType = typeToConvertTo; + if (value == null) + { + return new ConversionResult(canBeConverted: true, convertedInstance: null); + } + + if (typeToConvertTo != value.GetType() && typeToConvertTo.IsAssignableFrom(value.GetType())) + { + // Keep original type + targetType = value.GetType(); + } + + return GetConvertedValue(value, serializerOptions, ref targetType); + } + + private static ConversionResult GetConvertedValue(object value, JsonSerializerOptions serializerOptions, ref Type targetType) + { + // Workaround for the https://github.com/dotnet/runtime/issues/113926 + if (targetType.Name == "JsonValuePrimitive`1") + { + targetType = typeof(JsonElement); + } + + try + { + var deserialized = ConvertToTargetType(value, targetType, serializerOptions); + return new ConversionResult(true, deserialized); + } + catch + { + return new ConversionResult(canBeConverted: false, convertedInstance: null); + } + } + + private static bool IsNullableType(Type type) + { + if (type.IsValueType) + { + // value types are only nullable if they are Nullable + return type.IsGenericType && type.GetGenericTypeDefinition() == typeof(Nullable<>); + } + + // reference types are always nullable + return true; + } + + private static object ConvertToTargetType(object value, Type targetType, JsonSerializerOptions serializerOptions) + { + if (value is JsonElement jsonElement) + { + return JsonSerializer.Deserialize(jsonElement, targetType, serializerOptions); + } + + using JsonDocument doc = JsonSerializer.SerializeToDocument(value, serializerOptions); + return JsonSerializer.Deserialize(doc.RootElement, targetType, serializerOptions); + } +} diff --git a/src/Features/JsonPatch.SystemTextJson/src/Internal/DictionaryAdapterOfTU.cs b/src/Features/JsonPatch.SystemTextJson/src/Internal/DictionaryAdapterOfTU.cs new file mode 100644 index 000000000000..c0540d4c4cd0 --- /dev/null +++ b/src/Features/JsonPatch.SystemTextJson/src/Internal/DictionaryAdapterOfTU.cs @@ -0,0 +1,235 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Collections.Generic; +using System.Text.Json; +using System.Text.Json.Nodes; +using Microsoft.AspNetCore.JsonPatch.SystemTextJson.Helpers; + +namespace Microsoft.AspNetCore.JsonPatch.SystemTextJson.Internal; + +internal class DictionaryAdapter : IAdapter +{ + public virtual bool TryAdd( + object target, + string segment, + JsonSerializerOptions serializerOptions, + object value, + out string errorMessage) + { + var key = segment; + var dictionary = (IDictionary)target; + + // As per JsonPatch spec, if a key already exists, adding should replace the existing value + if (!TryConvertKey(key, out var convertedKey, out errorMessage)) + { + return false; + } + + if (!TryConvertValue(value, serializerOptions, out var convertedValue, out errorMessage)) + { + return false; + } + + dictionary[convertedKey] = convertedValue; + errorMessage = null; + return true; + } + + public virtual bool TryGet( + object target, + string segment, + JsonSerializerOptions serializerOptions, + out object value, + out string errorMessage) + { + var key = segment; + var dictionary = (IDictionary)target; + + if (!TryConvertKey(key, out var convertedKey, out errorMessage)) + { + value = null; + return false; + } + + if (!dictionary.TryGetValue(convertedKey, out var valueAsT)) + { + value = null; + errorMessage = Resources.FormatTargetLocationAtPathSegmentNotFound(segment); + return false; + } + + value = valueAsT; + errorMessage = null; + return true; + } + + public virtual bool TryRemove( + object target, + string segment, + JsonSerializerOptions serializerOptions, + out string errorMessage) + { + var key = segment; + var dictionary = (IDictionary)target; + + if (!TryConvertKey(key, out var convertedKey, out errorMessage)) + { + return false; + } + + // As per JsonPatch spec, the target location must exist for remove to be successful + if (!dictionary.Remove(convertedKey)) + { + errorMessage = Resources.FormatTargetLocationAtPathSegmentNotFound(segment); + return false; + } + + errorMessage = null; + return true; + } + + public virtual bool TryReplace( + object target, + string segment, + JsonSerializerOptions serializerOptions, + object value, + out string errorMessage) + { + var key = segment; + var dictionary = (IDictionary)target; + + if (!TryConvertKey(key, out var convertedKey, out errorMessage)) + { + return false; + } + + // As per JsonPatch spec, the target location must exist for remove to be successful + if (!dictionary.ContainsKey(convertedKey)) + { + errorMessage = Resources.FormatTargetLocationAtPathSegmentNotFound(segment); + return false; + } + + if (!TryConvertValue(value, serializerOptions, out var convertedValue, out errorMessage)) + { + return false; + } + + dictionary[convertedKey] = convertedValue; + + errorMessage = null; + return true; + } + + public virtual bool TryTest( + object target, + string segment, + JsonSerializerOptions serializerOptions, + object value, + out string errorMessage) + { + var key = segment; + var dictionary = (IDictionary)target; + + if (!TryConvertKey(key, out var convertedKey, out errorMessage)) + { + return false; + } + + // As per JsonPatch spec, the target location must exist for test to be successful + if (!dictionary.TryGetValue(convertedKey, out var currentValue)) + { + errorMessage = Resources.FormatTargetLocationAtPathSegmentNotFound(segment); + return false; + } + + if (!TryConvertValue(value, serializerOptions, out var convertedValue, out errorMessage)) + { + return false; + } + + // The target segment does not have an assigned value to compare the test value with + if (currentValue == null) + { + errorMessage = Resources.FormatValueForTargetSegmentCannotBeNullOrEmpty(segment); + return false; + } + + if (!JsonUtilities.DeepEquals(currentValue, convertedValue, serializerOptions)) + { + errorMessage = Resources.FormatValueNotEqualToTestValue(currentValue, value, segment); + return false; + } + else + { + errorMessage = null; + return true; + } + } + + public virtual bool TryTraverse( + object target, + string segment, + JsonSerializerOptions serializerOptions, + out object nextTarget, + out string errorMessage) + { + var key = segment; + var dictionary = (IDictionary)target; + + if (!TryConvertKey(key, out var convertedKey, out errorMessage)) + { + nextTarget = null; + return false; + } + + if (dictionary.TryGetValue(convertedKey, out var valueAsT)) + { + nextTarget = valueAsT; + errorMessage = null; + return true; + } + else + { + nextTarget = null; + errorMessage = null; + return false; + } + } + + private static bool TryConvertKey(string key, out TKey convertedKey, out string errorMessage) + { + var options = new JsonSerializerOptions() { NumberHandling = System.Text.Json.Serialization.JsonNumberHandling.AllowReadingFromString }; + var conversionResult = ConversionResultProvider.ConvertTo(key, typeof(TKey), options); + if (conversionResult.CanBeConverted) + { + errorMessage = null; + convertedKey = (TKey)conversionResult.ConvertedInstance; + return true; + } + else + { + errorMessage = Resources.FormatInvalidPathSegment(key); + convertedKey = default(TKey); + return false; + } + } + + private static bool TryConvertValue(object value, JsonSerializerOptions serializerOptions, out TValue convertedValue, out string errorMessage) + { + var conversionResult = ConversionResultProvider.ConvertTo(value, typeof(TValue), serializerOptions); + if (conversionResult.CanBeConverted) + { + errorMessage = null; + convertedValue = (TValue)conversionResult.ConvertedInstance; + return true; + } + else + { + errorMessage = Resources.FormatInvalidValueForProperty(value); + convertedValue = default(TValue); + return false; + } + } +} diff --git a/src/Features/JsonPatch.SystemTextJson/src/Internal/ErrorReporter.cs b/src/Features/JsonPatch.SystemTextJson/src/Internal/ErrorReporter.cs new file mode 100644 index 000000000000..2b32156b2d90 --- /dev/null +++ b/src/Features/JsonPatch.SystemTextJson/src/Internal/ErrorReporter.cs @@ -0,0 +1,15 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using Microsoft.AspNetCore.JsonPatch.SystemTextJson.Exceptions; + +namespace Microsoft.AspNetCore.JsonPatch.SystemTextJson.Internal; + +internal static class ErrorReporter +{ + public static readonly Action Default = (error) => + { + throw new JsonPatchException(error); + }; +} diff --git a/src/Features/JsonPatch.SystemTextJson/src/Internal/IAdapter.cs b/src/Features/JsonPatch.SystemTextJson/src/Internal/IAdapter.cs new file mode 100644 index 000000000000..5e1dbceb75a7 --- /dev/null +++ b/src/Features/JsonPatch.SystemTextJson/src/Internal/IAdapter.cs @@ -0,0 +1,50 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Text.Json; + +namespace Microsoft.AspNetCore.JsonPatch.SystemTextJson.Internal; + +internal interface IAdapter +{ + bool TryTraverse( + object target, + string segment, + JsonSerializerOptions serializerOptions, + out object nextTarget, + out string errorMessage); + + bool TryAdd( + object target, + string segment, + JsonSerializerOptions serializerOptions, + object value, + out string errorMessage); + + bool TryRemove( + object target, + string segment, + JsonSerializerOptions serializerOptions, + out string errorMessage); + + bool TryGet( + object target, + string segment, + JsonSerializerOptions serializerOptions, + out object value, + out string errorMessage); + + bool TryReplace( + object target, + string segment, + JsonSerializerOptions serializerOptions, + object value, + out string errorMessage); + + bool TryTest( + object target, + string segment, + JsonSerializerOptions serializerOptions, + object value, + out string errorMessage); +} diff --git a/src/Features/JsonPatch.SystemTextJson/src/Internal/JsonObjectAdapter.cs b/src/Features/JsonPatch.SystemTextJson/src/Internal/JsonObjectAdapter.cs new file mode 100644 index 000000000000..7db344c78545 --- /dev/null +++ b/src/Features/JsonPatch.SystemTextJson/src/Internal/JsonObjectAdapter.cs @@ -0,0 +1,142 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Text.Json; +using System.Text.Json.Nodes; +using Microsoft.AspNetCore.JsonPatch.SystemTextJson.Helpers; + +namespace Microsoft.AspNetCore.JsonPatch.SystemTextJson.Internal; + +internal class JsonObjectAdapter : IAdapter +{ + public virtual bool TryAdd( + object target, + string segment, + JsonSerializerOptions serializerOptions, + object value, + out string errorMessage) + { + // Set the property specified by the `segment` argument to the given `value` of the `target` object. + var obj = (JsonObject)target; + + obj[segment] = value != null ? JsonSerializer.SerializeToNode(value, serializerOptions) : GetJsonNull(); + + errorMessage = null; + return true; + } + + public virtual bool TryGet( + object target, + string segment, + JsonSerializerOptions serializerOptions, + out object value, + out string errorMessage) + { + var obj = (JsonObject)target; + + if (!obj.TryGetPropertyValue(segment, out var valueAsToken)) + { + value = null; + errorMessage = Resources.FormatTargetLocationAtPathSegmentNotFound(segment); + return false; + } + + value = valueAsToken; + errorMessage = null; + return true; + } + + public virtual bool TryRemove( + object target, + string segment, + JsonSerializerOptions serializerOptions, + out string errorMessage) + { + var obj = (JsonObject)target; + + if (!obj.Remove(segment)) + { + errorMessage = Resources.FormatTargetLocationAtPathSegmentNotFound(segment); + return false; + } + + errorMessage = null; + return true; + } + + public virtual bool TryReplace( + object target, + string segment, + JsonSerializerOptions serializerOptions, + object value, + out string errorMessage) + { + var obj = (JsonObject)target; + + int index = obj.IndexOf(segment); + if (index == -1) + { + errorMessage = Resources.FormatTargetLocationAtPathSegmentNotFound(segment); + return false; + } + + obj[index] = value != null ? JsonSerializer.SerializeToNode(value, serializerOptions) : GetJsonNull(); + + errorMessage = null; + return true; + } + + public virtual bool TryTest( + object target, + string segment, + JsonSerializerOptions serializerOptions, + object value, + out string errorMessage) + { + var obj = (JsonObject)target; + + if (!obj.TryGetPropertyValue(segment, out var currentValue)) + { + errorMessage = Resources.FormatTargetLocationAtPathSegmentNotFound(segment); + return false; + } + + if (currentValue == null || string.IsNullOrEmpty(currentValue.ToString())) + { + errorMessage = Resources.FormatValueForTargetSegmentCannotBeNullOrEmpty(segment); + return false; + } + + if (!JsonUtilities.DeepEquals(currentValue, value, serializerOptions)) + { + errorMessage = Resources.FormatValueNotEqualToTestValue(currentValue, value, segment); + return false; + } + + errorMessage = null; + return true; + } + + public virtual bool TryTraverse( + object target, + string segment, + JsonSerializerOptions serializerOptions, + out object nextTarget, + out string errorMessage) + { + var obj = (JsonObject)target; + + if (!obj.TryGetPropertyValue(segment, out var nextTargetToken)) + { + nextTarget = null; + errorMessage = null; + return false; + } + + nextTarget = nextTargetToken; + errorMessage = null; + return true; + } + + private static JsonValue GetJsonNull() => JsonValue.Create(null); +} diff --git a/src/Features/JsonPatch.SystemTextJson/src/Internal/ListAdapter.cs b/src/Features/JsonPatch.SystemTextJson/src/Internal/ListAdapter.cs new file mode 100644 index 000000000000..8f481928fe9a --- /dev/null +++ b/src/Features/JsonPatch.SystemTextJson/src/Internal/ListAdapter.cs @@ -0,0 +1,286 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Collections; +using System.Collections.Generic; +using System.Text.Json; +using Microsoft.AspNetCore.JsonPatch.SystemTextJson.Helpers; +using Microsoft.Extensions.Internal; + +namespace Microsoft.AspNetCore.JsonPatch.SystemTextJson.Internal; + +internal class ListAdapter : IAdapter +{ + public virtual bool TryAdd(object target, string segment, JsonSerializerOptions serializerOptions, object value, out string errorMessage) + { + if (!TryGetListTypeArgument(target, out var typeArgument, out errorMessage)) + { + return false; + } + + var targetCollectionCount = GenericListOrJsonArrayUtilities.GetCount(target); + if (!TryGetPositionInfo(targetCollectionCount, segment, OperationType.Add, out var positionInfo, out errorMessage)) + { + return false; + } + + if (!TryConvertValue(value, typeArgument, segment, serializerOptions, out var convertedValue, out errorMessage)) + { + return false; + } + + if (positionInfo.Type == PositionType.EndOfList) + { + GenericListOrJsonArrayUtilities.AddElement(target, convertedValue); + } + else + { + GenericListOrJsonArrayUtilities.InsertElementAt(target, positionInfo.Index, convertedValue); + } + + errorMessage = null; + return true; + } + + public virtual bool TryGet(object target, string segment, JsonSerializerOptions serializerOptions, out object value, out string errorMessage) + { + if (!TryGetListTypeArgument(target, out _, out errorMessage)) + { + value = null; + return false; + } + + var targetCollectionCount = GenericListOrJsonArrayUtilities.GetCount(target); + + if (!TryGetPositionInfo(targetCollectionCount, segment, OperationType.Get, out var positionInfo, out errorMessage)) + { + value = null; + return false; + } + + var valueIndex = positionInfo.Type == PositionType.EndOfList ? targetCollectionCount - 1 : positionInfo.Index; + value = GenericListOrJsonArrayUtilities.GetElementAt(target, valueIndex); + + errorMessage = null; + return true; + } + + public virtual bool TryRemove(object target, string segment, JsonSerializerOptions serializerOptions, out string errorMessage) + { + if (!TryGetListTypeArgument(target, out _, out errorMessage)) + { + return false; + } + + var count = GenericListOrJsonArrayUtilities.GetCount(target); + if (!TryGetPositionInfo(count, segment, OperationType.Remove, out var positionInfo, out errorMessage)) + { + return false; + } + + var indexToRemove = positionInfo.Type == PositionType.EndOfList ? count - 1 : positionInfo.Index; + GenericListOrJsonArrayUtilities.RemoveElementAt(target, indexToRemove); + + errorMessage = null; + return true; + } + + public virtual bool TryReplace(object target, string segment, JsonSerializerOptions serializerOptions, object value, out string errorMessage) + { + if (!TryGetListTypeArgument(target, out var typeArgument, out errorMessage)) + { + return false; + } + + var count = GenericListOrJsonArrayUtilities.GetCount(target); + if (!TryGetPositionInfo(count, segment, OperationType.Replace, out var positionInfo, out errorMessage)) + { + return false; + } + + if (!TryConvertValue(value, typeArgument, segment, serializerOptions, out var convertedValue, out errorMessage)) + { + return false; + } + + var indexToAddTo = positionInfo.Type == PositionType.EndOfList ? count - 1 : positionInfo.Index; + GenericListOrJsonArrayUtilities.SetValueAt(target, indexToAddTo, convertedValue); + + errorMessage = null; + return true; + } + + public virtual bool TryTest(object target, string segment, JsonSerializerOptions serializerOptions, object value, out string errorMessage) + { + + if (!TryGetListTypeArgument(target, out var typeArgument, out errorMessage)) + { + return false; + } + + var count = GenericListOrJsonArrayUtilities.GetCount(target); + + if (!TryGetPositionInfo(count, segment, OperationType.Replace, out var positionInfo, out errorMessage)) + { + return false; + } + + if (!TryConvertValue(value, typeArgument, segment, serializerOptions, out var convertedValue, out errorMessage)) + { + return false; + } + + var currentValue = GenericListOrJsonArrayUtilities.GetElementAt(target, positionInfo.Index); + + if (!JsonUtilities.DeepEquals(currentValue, convertedValue, serializerOptions)) + { + errorMessage = Resources.FormatValueAtListPositionNotEqualToTestValue(currentValue, value, positionInfo.Index); + return false; + } + else + { + errorMessage = null; + return true; + } + } + + public virtual bool TryTraverse(object target, string segment, JsonSerializerOptions serializerOptions, out object value, out string errorMessage) + { + var list = target as IList; + if (list == null) + { + value = null; + errorMessage = null; + return false; + } + + if (!int.TryParse(segment, out var index)) + { + value = null; + errorMessage = Resources.FormatInvalidIndexValue(segment); + return false; + } + + if (index < 0 || index >= list.Count) + { + value = null; + errorMessage = Resources.FormatIndexOutOfBounds(segment); + return false; + } + + value = list[index]; + errorMessage = null; + return true; + } + + protected virtual bool TryConvertValue(object originalValue, Type listTypeArgument, string segment, out object convertedValue, out string errorMessage) + { + return TryConvertValue(originalValue, listTypeArgument, segment, null, out convertedValue, out errorMessage); + } + + protected virtual bool TryConvertValue(object originalValue, Type listTypeArgument, string segment, JsonSerializerOptions serializerOptions, out object convertedValue, out string errorMessage) + { + var conversionResult = ConversionResultProvider.ConvertTo(originalValue, listTypeArgument, serializerOptions); + if (!conversionResult.CanBeConverted) + { + convertedValue = null; + errorMessage = Resources.FormatInvalidValueForProperty(originalValue); + return false; + } + + convertedValue = conversionResult.ConvertedInstance; + errorMessage = null; + return true; + } + + private static bool TryGetListTypeArgument(object list, out Type listTypeArgument, out string errorMessage) + { + var listType = list.GetType(); + if (listType.IsArray) + { + errorMessage = $"The type '{listType.FullName}' which is an array is not supported for json patch operations as it has a fixed size."; + listTypeArgument = null; + return false; + } + + var genericList = ClosedGenericMatcher.ExtractGenericInterface(listType, typeof(IList<>)); + if (genericList == null) + { + errorMessage = $"The type '{listType.FullName}' which is a non generic list is not supported for json patch operations. Only generic list types are supported."; + listTypeArgument = null; + return false; + } + + listTypeArgument = genericList.GenericTypeArguments[0]; + errorMessage = null; + return true; + } + + protected virtual bool TryGetPositionInfo(int collectionCount, string segment, OperationType operationType, out PositionInfo positionInfo, out string errorMessage) + { + if (segment == "-") + { + positionInfo = new PositionInfo(PositionType.EndOfList, -1); + errorMessage = null; + return true; + } + + if (int.TryParse(segment, out var position)) + { + if (position >= 0 && position < collectionCount) + { + positionInfo = new PositionInfo(PositionType.Index, position); + errorMessage = null; + return true; + } + + // As per JSON Patch spec, for Add operation the index value representing the number of elements is valid, + // where as for other operations like Remove, Replace, Move and Copy the target index MUST exist. + if (position == collectionCount && operationType == OperationType.Add) + { + positionInfo = new PositionInfo(PositionType.EndOfList, -1); + errorMessage = null; + return true; + } + + positionInfo = new PositionInfo(PositionType.OutOfBounds, position); + errorMessage = Resources.FormatIndexOutOfBounds(segment); + return false; + } + else + { + positionInfo = new PositionInfo(PositionType.Invalid, -1); + errorMessage = Resources.FormatInvalidIndexValue(segment); + return false; + } + } + + protected readonly struct PositionInfo + { + public PositionInfo(PositionType type, int index) + { + Type = type; + Index = index; + } + + public PositionType Type { get; } + public int Index { get; } + } + + protected enum PositionType + { + Index, // valid index + EndOfList, // '-' + Invalid, // Ex: not an integer + OutOfBounds + } + + protected enum OperationType + { + Add, + Remove, + Get, + Replace + } +} diff --git a/src/Features/JsonPatch.SystemTextJson/src/Internal/ObjectVisitor.cs b/src/Features/JsonPatch.SystemTextJson/src/Internal/ObjectVisitor.cs new file mode 100644 index 000000000000..f9151adecde9 --- /dev/null +++ b/src/Features/JsonPatch.SystemTextJson/src/Internal/ObjectVisitor.cs @@ -0,0 +1,78 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Text.Json; +using Microsoft.AspNetCore.JsonPatch.SystemTextJson.Adapters; + +namespace Microsoft.AspNetCore.JsonPatch.SystemTextJson.Internal; + +internal class ObjectVisitor +{ + private readonly IAdapterFactory _adapterFactory; + private readonly JsonSerializerOptions _serializerOptions; + private readonly ParsedPath _path; + + /// + /// Initializes a new instance of . + /// + /// The path of the JsonPatch operation + /// The . + public ObjectVisitor(ParsedPath path, JsonSerializerOptions serializerOptions) + : this(path, serializerOptions, AdapterFactory.Default) + { + } + + /// + /// Initializes a new instance of . + /// + /// The path of the JsonPatch operation + /// The . + /// The to use when creating adaptors. + public ObjectVisitor(ParsedPath path, JsonSerializerOptions serializerOptions, IAdapterFactory adapterFactory) + { + _path = path; + _serializerOptions = serializerOptions ?? throw new ArgumentNullException(nameof(serializerOptions)); + _adapterFactory = adapterFactory ?? throw new ArgumentNullException(nameof(adapterFactory)); + } + + public bool TryVisit(ref object target, out IAdapter adapter, out string errorMessage) + { + if (target == null) + { + adapter = null; + errorMessage = null; + return false; + } + + adapter = SelectAdapter(target); + + // Traverse until the penultimate segment to get the target object and adapter + for (var i = 0; i < _path.Segments.Count - 1; i++) + { + if (!adapter.TryTraverse(target, _path.Segments[i], _serializerOptions, out var next, out errorMessage)) + { + adapter = null; + return false; + } + + // If we hit a null on an interior segment then we need to stop traversing. + if (next == null) + { + adapter = null; + return false; + } + + target = next; + adapter = SelectAdapter(target); + } + + errorMessage = null; + return true; + } + + private IAdapter SelectAdapter(object targetObject) + { + return _adapterFactory.Create(targetObject); + } +} diff --git a/src/Features/JsonPatch.SystemTextJson/src/Internal/ParsedPath.cs b/src/Features/JsonPatch.SystemTextJson/src/Internal/ParsedPath.cs new file mode 100644 index 000000000000..a5e13cae9663 --- /dev/null +++ b/src/Features/JsonPatch.SystemTextJson/src/Internal/ParsedPath.cs @@ -0,0 +1,86 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Collections.Generic; +using System.Text; +using Microsoft.AspNetCore.JsonPatch.SystemTextJson.Exceptions; +using Microsoft.AspNetCore.Shared; + +namespace Microsoft.AspNetCore.JsonPatch.SystemTextJson.Internal; + +internal readonly struct ParsedPath +{ + private readonly string[] _segments; + + public ParsedPath(string path) + { + ArgumentNullThrowHelper.ThrowIfNull(path); + + _segments = ParsePath(path); + } + + public string LastSegment + { + get + { + if (_segments == null || _segments.Length == 0) + { + return null; + } + + return _segments[_segments.Length - 1]; + } + } + + public IReadOnlyList Segments => _segments; + + private static string[] ParsePath(string path) + { + var strings = new List(); + var sb = new StringBuilder(path.Length); + + for (var i = 0; i < path.Length; i++) + { + if (path[i] == '/') + { + if (sb.Length > 0) + { + strings.Add(sb.ToString()); + sb.Length = 0; + } + } + else if (path[i] == '~') + { + ++i; + if (i >= path.Length) + { + throw new JsonPatchException(Resources.FormatInvalidValueForPath(path), null); + } + + if (path[i] == '0') + { + sb.Append('~'); + } + else if (path[i] == '1') + { + sb.Append('/'); + } + else + { + throw new JsonPatchException(Resources.FormatInvalidValueForPath(path), null); + } + } + else + { + sb.Append(path[i]); + } + } + + if (sb.Length > 0) + { + strings.Add(sb.ToString()); + } + + return strings.ToArray(); + } +} diff --git a/src/Features/JsonPatch.SystemTextJson/src/Internal/PathHelpers.cs b/src/Features/JsonPatch.SystemTextJson/src/Internal/PathHelpers.cs new file mode 100644 index 000000000000..2dd355a71179 --- /dev/null +++ b/src/Features/JsonPatch.SystemTextJson/src/Internal/PathHelpers.cs @@ -0,0 +1,32 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using Microsoft.AspNetCore.JsonPatch.SystemTextJson.Exceptions; +using Microsoft.AspNetCore.JsonPatch.SystemTextJson; + +namespace Microsoft.AspNetCore.JsonPatch.SystemTextJson.Internal; + +internal static class PathHelpers +{ + internal static string ValidateAndNormalizePath(string path) + { + // check for most common path errors on create. This is not + // absolutely necessary, but it allows us to already catch mistakes + // on creation of the patch document rather than on execute. + + if (path.Contains("//")) + { + throw new JsonPatchException(Resources.FormatInvalidValueForPath(path), null); + } + + if (!path.StartsWith("/", StringComparison.Ordinal)) + { + return "/" + path; + } + else + { + return path; + } + } +} diff --git a/src/Features/JsonPatch.SystemTextJson/src/Internal/PocoAdapter.cs b/src/Features/JsonPatch.SystemTextJson/src/Internal/PocoAdapter.cs new file mode 100644 index 000000000000..ff364a7d25a7 --- /dev/null +++ b/src/Features/JsonPatch.SystemTextJson/src/Internal/PocoAdapter.cs @@ -0,0 +1,237 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Linq; +using System.Text.Json; +using System.Text.Json.Nodes; +using System.Text.Json.Serialization.Metadata; +using Microsoft.AspNetCore.JsonPatch.SystemTextJson.Helpers; + +namespace Microsoft.AspNetCore.JsonPatch.SystemTextJson.Internal; + +internal class PocoAdapter : IAdapter +{ + public virtual bool TryAdd( + object target, + string segment, + JsonSerializerOptions serializerOptions, + object value, + out string errorMessage) + { + if (!TryGetJsonProperty(target, serializerOptions, segment, out var jsonProperty)) + { + errorMessage = Resources.FormatTargetLocationAtPathSegmentNotFound(segment); + return false; + } + + if (jsonProperty.Set == null) + { + errorMessage = Resources.FormatCannotUpdateProperty(segment); + return false; + } + + if (!TryConvertValue(value, jsonProperty.PropertyType, serializerOptions, out var convertedValue)) + { + errorMessage = Resources.FormatInvalidValueForProperty(value); + return false; + } + + jsonProperty.Set(target, convertedValue); + + errorMessage = null; + return true; + } + + public virtual bool TryGet( + object target, + string segment, + JsonSerializerOptions serializerOptions, + out object value, + out string errorMessage) + { + if (!TryGetJsonProperty(target, serializerOptions, segment, out var jsonProperty)) + { + errorMessage = Resources.FormatTargetLocationAtPathSegmentNotFound(segment); + value = null; + return false; + } + + if (jsonProperty.Get == null) + { + errorMessage = Resources.FormatCannotReadProperty(segment); + value = null; + return false; + } + + value = jsonProperty.Get(target); + errorMessage = null; + return true; + } + + public virtual bool TryRemove( + object target, + string segment, + JsonSerializerOptions serializerOptions, + out string errorMessage) + { + if (!TryGetJsonProperty(target, serializerOptions, segment, out var jsonProperty)) + { + errorMessage = Resources.FormatTargetLocationAtPathSegmentNotFound(segment); + return false; + } + + if (jsonProperty.Set == null) + { + errorMessage = Resources.FormatCannotUpdateProperty(segment); + return false; + } + + // 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 (jsonProperty.PropertyType.IsValueType + && Nullable.GetUnderlyingType(jsonProperty.PropertyType) == null) + { + value = Activator.CreateInstance(jsonProperty.PropertyType); + } + + jsonProperty.Set(target, value); + + errorMessage = null; + return true; + } + + public virtual bool TryReplace( + object target, + string segment, + JsonSerializerOptions serializerOptions, + object value, + out string errorMessage) + { + if (!TryGetJsonProperty(target, serializerOptions, segment, out var jsonProperty)) + { + errorMessage = Resources.FormatTargetLocationAtPathSegmentNotFound(segment); + return false; + } + + if (jsonProperty.Set == null) + { + errorMessage = Resources.FormatCannotUpdateProperty(segment); + return false; + } + + if (!TryConvertValue(value, jsonProperty.PropertyType, serializerOptions, out var convertedValue)) + { + errorMessage = Resources.FormatInvalidValueForProperty(value); + return false; + } + + jsonProperty.Set(target, convertedValue); + + errorMessage = null; + return true; + } + + public virtual bool TryTest( + object target, + string segment, + JsonSerializerOptions serializerOptions, + object value, + out string errorMessage) + { + if (!TryGetJsonProperty(target, serializerOptions, segment, out var jsonProperty)) + { + errorMessage = Resources.FormatTargetLocationAtPathSegmentNotFound(segment); + return false; + } + + if (jsonProperty.Get == null) + { + errorMessage = Resources.FormatCannotReadProperty(segment); + return false; + } + + if (!TryConvertValue(value, jsonProperty.PropertyType, serializerOptions, out var convertedValue)) + { + errorMessage = Resources.FormatInvalidValueForProperty(value); + return false; + } + + var currentValue = jsonProperty.Get(target); + if (!JsonUtilities.DeepEquals(currentValue, convertedValue, serializerOptions)) + { + errorMessage = Resources.FormatValueNotEqualToTestValue(currentValue, value, segment); + return false; + } + + errorMessage = null; + return true; + } + + public virtual bool TryTraverse( + object target, + string segment, + JsonSerializerOptions serializerOptions, + out object value, + out string errorMessage) + { + if (target == null) + { + value = null; + errorMessage = null; + return false; + } + + if (TryGetJsonProperty(target, serializerOptions, segment, out var jsonProperty)) + { + value = jsonProperty.Get(target); + errorMessage = null; + return true; + } + + value = null; + errorMessage = Resources.FormatTargetLocationAtPathSegmentNotFound(segment); + return false; + } + + protected virtual bool TryGetJsonProperty( + object target, + JsonSerializerOptions serializerOptions, + string segment, + out JsonPropertyInfo jsonProperty) + { + var typeInfo = serializerOptions.GetTypeInfo(target.GetType()); + if (typeInfo is not null) + { + var pocoProperty = typeInfo + .Properties + .FirstOrDefault(p => string.Equals(p.Name, segment, ExtractStringComparison(serializerOptions))); + + if (pocoProperty != null) + { + jsonProperty = pocoProperty; + return true; + } + } + + jsonProperty = null; + return false; + } + + protected virtual bool TryConvertValue(object value, Type propertyType, JsonSerializerOptions serializerOptions, out object convertedValue) + { + var conversionResult = ConversionResultProvider.ConvertTo(value, propertyType, serializerOptions); + if (!conversionResult.CanBeConverted) + { + convertedValue = null; + return false; + } + + convertedValue = conversionResult.ConvertedInstance; + return true; + } + + private static StringComparison ExtractStringComparison(JsonSerializerOptions serializerOptions) + => serializerOptions.PropertyNameCaseInsensitive ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal; +} diff --git a/src/Features/JsonPatch.SystemTextJson/src/JsonPatchDocument.cs b/src/Features/JsonPatch.SystemTextJson/src/JsonPatchDocument.cs new file mode 100644 index 000000000000..c57febaff4e9 --- /dev/null +++ b/src/Features/JsonPatch.SystemTextJson/src/JsonPatchDocument.cs @@ -0,0 +1,221 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Collections.Generic; +using System.Text.Json; +using System.Text.Json.Serialization; +using Microsoft.AspNetCore.JsonPatch.SystemTextJson.Adapters; +using Microsoft.AspNetCore.JsonPatch.SystemTextJson.Converters; +using Microsoft.AspNetCore.JsonPatch.SystemTextJson.Exceptions; +using Microsoft.AspNetCore.JsonPatch.SystemTextJson.Internal; +using Microsoft.AspNetCore.JsonPatch.SystemTextJson.Operations; +using Microsoft.AspNetCore.Shared; + +namespace Microsoft.AspNetCore.JsonPatch.SystemTextJson; + +// Implementation details: the purpose of this type of patch document is to allow creation of such +// documents for cases where there's no class/DTO to work on. Typical use case: backend not built in +// .NET or architecture doesn't contain a shared DTO layer. +[JsonConverter(typeof(JsonPatchDocumentConverter))] +public class JsonPatchDocument : IJsonPatchDocument +{ + public List Operations { get; private set; } + + [JsonIgnore] + public JsonSerializerOptions SerializerOptions { get; set; } + + public JsonPatchDocument() + { + Operations = new List(); + SerializerOptions = JsonSerializerOptions.Default; + } + + public JsonPatchDocument(List operations, JsonSerializerOptions serializerOptions) + { + ArgumentNullThrowHelper.ThrowIfNull(operations); + ArgumentNullThrowHelper.ThrowIfNull(serializerOptions); + + Operations = operations; + SerializerOptions = serializerOptions; + } + + /// + /// Add operation. Will result in, for example, + /// { "op": "add", "path": "/a/b/c", "value": [ "foo", "bar" ] } + /// + /// target location + /// value + /// The for chaining. + public JsonPatchDocument Add(string path, object value) + { + ArgumentNullThrowHelper.ThrowIfNull(path); + + Operations.Add(new Operation("add", PathHelpers.ValidateAndNormalizePath(path), null, value)); + return this; + } + + /// + /// Remove value at target location. Will result in, for example, + /// { "op": "remove", "path": "/a/b/c" } + /// + /// target location + /// The for chaining. + public JsonPatchDocument Remove(string path) + { + ArgumentNullThrowHelper.ThrowIfNull(path); + + Operations.Add(new Operation("remove", PathHelpers.ValidateAndNormalizePath(path), null, null)); + return this; + } + + /// + /// Replace value. Will result in, for example, + /// { "op": "replace", "path": "/a/b/c", "value": 42 } + /// + /// target location + /// value + /// The for chaining. + public JsonPatchDocument Replace(string path, object value) + { + ArgumentNullThrowHelper.ThrowIfNull(path); + + Operations.Add(new Operation("replace", PathHelpers.ValidateAndNormalizePath(path), null, value)); + return this; + } + + /// + /// Test value. Will result in, for example, + /// { "op": "test", "path": "/a/b/c", "value": 42 } + /// + /// target location + /// value + /// The for chaining. + public JsonPatchDocument Test(string path, object value) + { + ArgumentNullThrowHelper.ThrowIfNull(path); + + Operations.Add(new Operation("test", PathHelpers.ValidateAndNormalizePath(path), null, value)); + return this; + } + + /// + /// Removes value at specified location and add it to the target location. Will result in, for example: + /// { "op": "move", "from": "/a/b/c", "path": "/a/b/d" } + /// + /// source location + /// target location + /// The for chaining. + public JsonPatchDocument Move(string from, string path) + { + ArgumentNullThrowHelper.ThrowIfNull(from); + ArgumentNullThrowHelper.ThrowIfNull(path); + + Operations.Add(new Operation("move", PathHelpers.ValidateAndNormalizePath(path), PathHelpers.ValidateAndNormalizePath(from))); + return this; + } + + /// + /// Copy the value at specified location to the target location. Will result in, for example: + /// { "op": "copy", "from": "/a/b/c", "path": "/a/b/e" } + /// + /// source location + /// target location + /// The for chaining. + public JsonPatchDocument Copy(string from, string path) + { + ArgumentNullThrowHelper.ThrowIfNull(from); + ArgumentNullThrowHelper.ThrowIfNull(path); + + Operations.Add(new Operation("copy", PathHelpers.ValidateAndNormalizePath(path), PathHelpers.ValidateAndNormalizePath(from))); + return this; + } + + /// + /// Apply this JsonPatchDocument to a specified object. + /// + /// Object to apply the JsonPatchDocument to + public void ApplyTo(object objectToApplyTo) + { + ArgumentNullThrowHelper.ThrowIfNull(objectToApplyTo); + + ApplyTo(objectToApplyTo, new ObjectAdapter(SerializerOptions, null, AdapterFactory.Default)); + } + + /// + /// Apply this JsonPatchDocument + /// + /// Object to apply the JsonPatchDocument to + /// Action to log errors + public void ApplyTo(object objectToApplyTo, Action logErrorAction) + { + ApplyTo(objectToApplyTo, new ObjectAdapter(SerializerOptions, logErrorAction, AdapterFactory.Default), logErrorAction); + } + + /// + /// Apply this JsonPatchDocument + /// + /// Object to apply the JsonPatchDocument to + /// IObjectAdapter instance to use when applying + /// Action to log errors + public void ApplyTo(object objectToApplyTo, IObjectAdapter adapter, Action logErrorAction) + { + ArgumentNullThrowHelper.ThrowIfNull(objectToApplyTo); + ArgumentNullThrowHelper.ThrowIfNull(adapter); + + foreach (var op in Operations) + { + try + { + op.Apply(objectToApplyTo, adapter); + } + catch (JsonPatchException jsonPatchException) + { + var errorReporter = logErrorAction ?? ErrorReporter.Default; + errorReporter(new JsonPatchError(objectToApplyTo, op, jsonPatchException.Message)); + + // As per JSON Patch spec if an operation results in error, further operations should not be executed. + break; + } + } + } + + /// + /// Apply this JsonPatchDocument + /// + /// Object to apply the JsonPatchDocument to + /// IObjectAdapter instance to use when applying + public void ApplyTo(object objectToApplyTo, IObjectAdapter adapter) + { + ArgumentNullThrowHelper.ThrowIfNull(objectToApplyTo); + ArgumentNullThrowHelper.ThrowIfNull(adapter); + + // apply each operation in order + foreach (var op in Operations) + { + op.Apply(objectToApplyTo, adapter); + } + } + + IList IJsonPatchDocument.GetOperations() + { + var allOps = new List(Operations?.Count ?? 0); + + if (Operations != null) + { + foreach (var op in Operations) + { + var untypedOp = new Operation(); + + untypedOp.op = op.op; + untypedOp.value = op.value; + untypedOp.path = op.path; + untypedOp.from = op.from; + + allOps.Add(untypedOp); + } + } + + return allOps; + } +} diff --git a/src/Features/JsonPatch.SystemTextJson/src/JsonPatchDocumentOfT.cs b/src/Features/JsonPatch.SystemTextJson/src/JsonPatchDocumentOfT.cs new file mode 100644 index 000000000000..afb3f9ab0858 --- /dev/null +++ b/src/Features/JsonPatch.SystemTextJson/src/JsonPatchDocumentOfT.cs @@ -0,0 +1,741 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Linq; +using System.Linq.Expressions; +using System.Reflection; +using System.Text.Json; +using System.Text.Json.Serialization; +using Microsoft.AspNetCore.JsonPatch.SystemTextJson.Adapters; +using Microsoft.AspNetCore.JsonPatch.SystemTextJson.Converters; +using Microsoft.AspNetCore.JsonPatch.SystemTextJson.Exceptions; +using Microsoft.AspNetCore.JsonPatch.SystemTextJson.Internal; +using Microsoft.AspNetCore.JsonPatch.SystemTextJson.Operations; +using Microsoft.AspNetCore.Shared; + +namespace Microsoft.AspNetCore.JsonPatch.SystemTextJson; + +// Implementation details: the purpose of this type of patch document is to ensure we can do type-checking +// when producing a JsonPatchDocument. However, we cannot send this "typed" over the wire, as that would require +// including type data in the JsonPatchDocument serialized as JSON (to allow for correct deserialization) - that's +// not according to RFC 6902, and would thus break cross-platform compatibility. +[JsonConverter(typeof(JsonPatchDocumentConverterFactory))] +public class JsonPatchDocument : IJsonPatchDocument where TModel : class +{ + public List> Operations { get; private set; } + + [JsonIgnore] + public JsonSerializerOptions SerializerOptions { get; set; } + + public JsonPatchDocument() + { + Operations = new List>(); + SerializerOptions = JsonSerializerOptions.Default; + } + + // Create from list of operations + public JsonPatchDocument(List> operations, JsonSerializerOptions serializerOptions) + { + Operations = operations ?? throw new ArgumentNullException(nameof(operations)); + SerializerOptions = serializerOptions ?? throw new ArgumentNullException(nameof(serializerOptions)); + } + + /// + /// Add operation. Will result in, for example, + /// { "op": "add", "path": "/a/b/c", "value": [ "foo", "bar" ] } + /// + /// value type + /// target location + /// value + /// The for chaining. + public JsonPatchDocument Add(Expression> path, TProp value) + { + ArgumentNullThrowHelper.ThrowIfNull(path); + + Operations.Add(new Operation( + "add", + GetPath(path, null), + from: null, + value: value)); + + return this; + } + + /// + /// Add value to list at given position + /// + /// value type + /// target location + /// value + /// position + /// The for chaining. + public JsonPatchDocument Add( + Expression>> path, + TProp value, + int position) + { + ArgumentNullThrowHelper.ThrowIfNull(path); + + Operations.Add(new Operation( + "add", + GetPath(path, position.ToString(CultureInfo.InvariantCulture)), + from: null, + value: value)); + + return this; + } + + /// + /// Add value to the end of the list + /// + /// value type + /// target location + /// value + /// The for chaining. + public JsonPatchDocument Add(Expression>> path, TProp value) + { + ArgumentNullThrowHelper.ThrowIfNull(path); + + Operations.Add(new Operation( + "add", + GetPath(path, "-"), + from: null, + value: value)); + + return this; + } + + /// + /// Remove value at target location. Will result in, for example, + /// { "op": "remove", "path": "/a/b/c" } + /// + /// target location + /// The for chaining. + public JsonPatchDocument Remove(Expression> path) + { + ArgumentNullThrowHelper.ThrowIfNull(path); + + Operations.Add(new Operation("remove", GetPath(path, null), from: null)); + + return this; + } + + /// + /// Remove value from list at given position + /// + /// value type + /// target location + /// position + /// The for chaining. + public JsonPatchDocument Remove(Expression>> path, int position) + { + ArgumentNullThrowHelper.ThrowIfNull(path); + + Operations.Add(new Operation( + "remove", + GetPath(path, position.ToString(CultureInfo.InvariantCulture)), + from: null)); + + return this; + } + + /// + /// Remove value from end of list + /// + /// value type + /// target location + /// The for chaining. + public JsonPatchDocument Remove(Expression>> path) + { + ArgumentNullThrowHelper.ThrowIfNull(path); + + Operations.Add(new Operation( + "remove", + GetPath(path, "-"), + from: null)); + + return this; + } + + /// + /// Replace value. Will result in, for example, + /// { "op": "replace", "path": "/a/b/c", "value": 42 } + /// + /// target location + /// value + /// The for chaining. + public JsonPatchDocument Replace(Expression> path, TProp value) + { + ArgumentNullThrowHelper.ThrowIfNull(path); + + Operations.Add(new Operation( + "replace", + GetPath(path, null), + from: null, + value: value)); + + return this; + } + + /// + /// Replace value in a list at given position + /// + /// value type + /// target location + /// value + /// position + /// The for chaining. + public JsonPatchDocument Replace(Expression>> path, + TProp value, int position) + { + ArgumentNullThrowHelper.ThrowIfNull(path); + + Operations.Add(new Operation( + "replace", + GetPath(path, position.ToString(CultureInfo.InvariantCulture)), + from: null, + value: value)); + + return this; + } + + /// + /// Replace value at end of a list + /// + /// value type + /// target location + /// value + /// The for chaining. + public JsonPatchDocument Replace(Expression>> path, TProp value) + { + ArgumentNullThrowHelper.ThrowIfNull(path); + + Operations.Add(new Operation( + "replace", + GetPath(path, "-"), + from: null, + value: value)); + + return this; + } + + /// + /// Test value. Will result in, for example, + /// { "op": "test", "path": "/a/b/c", "value": 42 } + /// + /// target location + /// value + /// The for chaining. + public JsonPatchDocument Test(Expression> path, TProp value) + { + ArgumentNullThrowHelper.ThrowIfNull(path); + + Operations.Add(new Operation( + "test", + GetPath(path, null), + from: null, + value: value)); + + return this; + } + + /// + /// Test value in a list at given position + /// + /// value type + /// target location + /// value + /// position + /// The for chaining. + public JsonPatchDocument Test(Expression>> path, + TProp value, int position) + { + ArgumentNullThrowHelper.ThrowIfNull(path); + + Operations.Add(new Operation( + "test", + GetPath(path, position.ToString(CultureInfo.InvariantCulture)), + from: null, + value: value)); + + return this; + } + + /// + /// Test value at end of a list + /// + /// value type + /// target location + /// value + /// The for chaining. + public JsonPatchDocument Test(Expression>> path, TProp value) + { + ArgumentNullThrowHelper.ThrowIfNull(path); + + Operations.Add(new Operation( + "test", + GetPath(path, "-"), + from: null, + value: value)); + + return this; + } + + /// + /// Removes value at specified location and add it to the target location. Will result in, for example: + /// { "op": "move", "from": "/a/b/c", "path": "/a/b/d" } + /// + /// source location + /// target location + /// The for chaining. + public JsonPatchDocument Move( + Expression> from, + Expression> path) + { + ArgumentNullThrowHelper.ThrowIfNull(from); + ArgumentNullThrowHelper.ThrowIfNull(path); + + Operations.Add(new Operation( + "move", + GetPath(path, null), + GetPath(from, null))); + + return this; + } + + /// + /// Move from a position in a list to a new location + /// + /// + /// source location + /// position + /// target location + /// The for chaining. + public JsonPatchDocument Move( + Expression>> from, + int positionFrom, + Expression> path) + { + ArgumentNullThrowHelper.ThrowIfNull(from); + ArgumentNullThrowHelper.ThrowIfNull(path); + + Operations.Add(new Operation( + "move", + GetPath(path, null), + GetPath(from, positionFrom.ToString(CultureInfo.InvariantCulture)))); + + return this; + } + + /// + /// Move from a property to a location in a list + /// + /// + /// source location + /// target location + /// position + /// The for chaining. + public JsonPatchDocument Move( + Expression> from, + Expression>> path, + int positionTo) + { + ArgumentNullThrowHelper.ThrowIfNull(from); + ArgumentNullThrowHelper.ThrowIfNull(path); + + Operations.Add(new Operation( + "move", + GetPath(path, positionTo.ToString(CultureInfo.InvariantCulture)), + GetPath(from, null))); + + return this; + } + + /// + /// Move from a position in a list to another location in a list + /// + /// + /// source location + /// position (source) + /// target location + /// position (target) + /// The for chaining. + public JsonPatchDocument Move( + Expression>> from, + int positionFrom, + Expression>> path, + int positionTo) + { + ArgumentNullThrowHelper.ThrowIfNull(from); + ArgumentNullThrowHelper.ThrowIfNull(path); + + Operations.Add(new Operation( + "move", + GetPath(path, positionTo.ToString(CultureInfo.InvariantCulture)), + GetPath(from, positionFrom.ToString(CultureInfo.InvariantCulture)))); + + return this; + } + + /// + /// Move from a position in a list to the end of another list + /// + /// + /// source location + /// position + /// target location + /// The for chaining. + public JsonPatchDocument Move( + Expression>> from, + int positionFrom, + Expression>> path) + { + ArgumentNullThrowHelper.ThrowIfNull(from); + ArgumentNullThrowHelper.ThrowIfNull(path); + + Operations.Add(new Operation( + "move", + GetPath(path, "-"), + GetPath(from, positionFrom.ToString(CultureInfo.InvariantCulture)))); + + return this; + } + + /// + /// Move to the end of a list + /// + /// + /// source location + /// target location + /// The for chaining. + public JsonPatchDocument Move( + Expression> from, + Expression>> path) + { + ArgumentNullThrowHelper.ThrowIfNull(from); + ArgumentNullThrowHelper.ThrowIfNull(path); + + Operations.Add(new Operation( + "move", + GetPath(path, "-"), + GetPath(from, null))); + + return this; + } + + /// + /// Copy the value at specified location to the target location. Will result in, for example: + /// { "op": "copy", "from": "/a/b/c", "path": "/a/b/e" } + /// + /// source location + /// target location + /// The for chaining. + public JsonPatchDocument Copy( + Expression> from, + Expression> path) + { + ArgumentNullThrowHelper.ThrowIfNull(from); + ArgumentNullThrowHelper.ThrowIfNull(path); + + Operations.Add(new Operation( + "copy", + GetPath(path, null), + GetPath(from, null))); + + return this; + } + + /// + /// Copy from a position in a list to a new location + /// + /// + /// source location + /// position + /// target location + /// The for chaining. + public JsonPatchDocument Copy( + Expression>> from, + int positionFrom, + Expression> path) + { + ArgumentNullThrowHelper.ThrowIfNull(from); + ArgumentNullThrowHelper.ThrowIfNull(path); + + Operations.Add(new Operation( + "copy", + GetPath(path, null), + GetPath(from, positionFrom.ToString(CultureInfo.InvariantCulture)))); + + return this; + } + + /// + /// Copy from a property to a location in a list + /// + /// + /// source location + /// target location + /// position + /// The for chaining. + public JsonPatchDocument Copy( + Expression> from, + Expression>> path, + int positionTo) + { + ArgumentNullThrowHelper.ThrowIfNull(from); + ArgumentNullThrowHelper.ThrowIfNull(path); + + Operations.Add(new Operation( + "copy", + GetPath(path, positionTo.ToString(CultureInfo.InvariantCulture)), + GetPath(from, null))); + + return this; + } + + /// + /// Copy from a position in a list to a new location in a list + /// + /// + /// source location + /// position (source) + /// target location + /// position (target) + /// The for chaining. + public JsonPatchDocument Copy( + Expression>> from, + int positionFrom, + Expression>> path, + int positionTo) + { + ArgumentNullThrowHelper.ThrowIfNull(from); + ArgumentNullThrowHelper.ThrowIfNull(path); + + Operations.Add(new Operation( + "copy", + GetPath(path, positionTo.ToString(CultureInfo.InvariantCulture)), + GetPath(from, positionFrom.ToString(CultureInfo.InvariantCulture)))); + + return this; + } + + /// + /// Copy from a position in a list to the end of another list + /// + /// + /// source location + /// position + /// target location + /// The for chaining. + public JsonPatchDocument Copy( + Expression>> from, + int positionFrom, + Expression>> path) + { + ArgumentNullThrowHelper.ThrowIfNull(from); + ArgumentNullThrowHelper.ThrowIfNull(path); + + Operations.Add(new Operation( + "copy", + GetPath(path, "-"), + GetPath(from, positionFrom.ToString(CultureInfo.InvariantCulture)))); + + return this; + } + + /// + /// Copy to the end of a list + /// + /// + /// source location + /// target location + /// The for chaining. + public JsonPatchDocument Copy( + Expression> from, + Expression>> path) + { + ArgumentNullThrowHelper.ThrowIfNull(from); + ArgumentNullThrowHelper.ThrowIfNull(path); + + Operations.Add(new Operation( + "copy", + GetPath(path, "-"), + GetPath(from, null))); + + return this; + } + + /// + /// Apply this JsonPatchDocument + /// + /// Object to apply the JsonPatchDocument to + public void ApplyTo(TModel objectToApplyTo) + { + ArgumentNullThrowHelper.ThrowIfNull(objectToApplyTo); + + ApplyTo(objectToApplyTo, new ObjectAdapter(SerializerOptions, null, AdapterFactory.Default)); + } + + /// + /// Apply this JsonPatchDocument + /// + /// Object to apply the JsonPatchDocument to + /// Action to log errors + public void ApplyTo(TModel objectToApplyTo, Action logErrorAction) + { + ApplyTo(objectToApplyTo, new ObjectAdapter(SerializerOptions, logErrorAction, AdapterFactory.Default), logErrorAction); + } + + /// + /// Apply this JsonPatchDocument + /// + /// Object to apply the JsonPatchDocument to + /// IObjectAdapter instance to use when applying + /// Action to log errors + public void ApplyTo(TModel objectToApplyTo, IObjectAdapter adapter, Action logErrorAction) + { + ArgumentNullThrowHelper.ThrowIfNull(objectToApplyTo); + ArgumentNullThrowHelper.ThrowIfNull(adapter); + + foreach (var op in Operations) + { + try + { + op.Apply(objectToApplyTo, adapter); + } + catch (JsonPatchException jsonPatchException) + { + var errorReporter = logErrorAction ?? ErrorReporter.Default; + errorReporter(new JsonPatchError(objectToApplyTo, op, jsonPatchException.Message)); + + // As per JSON Patch spec if an operation results in error, further operations should not be executed. + break; + } + } + } + + /// + /// Apply this JsonPatchDocument + /// + /// Object to apply the JsonPatchDocument to + /// IObjectAdapter instance to use when applying + public void ApplyTo(TModel objectToApplyTo, IObjectAdapter adapter) + { + ArgumentNullThrowHelper.ThrowIfNull(objectToApplyTo); + ArgumentNullThrowHelper.ThrowIfNull(adapter); + + // apply each operation in order + foreach (var op in Operations) + { + op.Apply(objectToApplyTo, adapter); + } + } + + IList IJsonPatchDocument.GetOperations() + { + var allOps = new List(Operations?.Count ?? 0); + + if (Operations != null) + { + foreach (var op in Operations) + { + var untypedOp = new Operation + { + op = op.op, + value = op.value, + path = op.path, + from = op.from + }; + + allOps.Add(untypedOp); + } + } + + return allOps; + } + + // Internal for testing + internal string GetPath(Expression> expr, string position) + { + var segments = GetPathSegments(expr.Body); + var path = String.Join("/", segments); + if (position != null) + { + path += "/" + position; + if (segments.Count == 0) + { + return path; + } + } + + return "/" + path; + } + + private List GetPathSegments(Expression expr) + { + var listOfSegments = new List(); + switch (expr.NodeType) + { + case ExpressionType.ArrayIndex: + var binaryExpression = (BinaryExpression)expr; + listOfSegments.AddRange(GetPathSegments(binaryExpression.Left)); + listOfSegments.Add(binaryExpression.Right.ToString()); + return listOfSegments; + + case ExpressionType.Call: + var methodCallExpression = (MethodCallExpression)expr; + listOfSegments.AddRange(GetPathSegments(methodCallExpression.Object)); + listOfSegments.Add(EvaluateExpression(methodCallExpression.Arguments[0])); + return listOfSegments; + + case ExpressionType.Convert: + listOfSegments.AddRange(GetPathSegments(((UnaryExpression)expr).Operand)); + return listOfSegments; + + case ExpressionType.MemberAccess: + var memberExpression = expr as MemberExpression; + listOfSegments.AddRange(GetPathSegments(memberExpression.Expression)); + // Get property name, respecting JsonProperty attribute + listOfSegments.Add(GetPropertyNameFromMemberExpression(memberExpression)); + return listOfSegments; + + case ExpressionType.Parameter: + // Fits "x => x" (the whole document which is "" as JSON pointer) + return listOfSegments; + + default: + throw new InvalidOperationException(Resources.FormatExpressionTypeNotSupported(expr)); + } + } + + private string GetPropertyNameFromMemberExpression(MemberExpression memberExpression) + { + var jsonTypeInfo = SerializerOptions.GetTypeInfo(memberExpression.Expression.Type); + if (jsonTypeInfo != null) + { + var memberInfo = memberExpression.Member; + var matchingProp = jsonTypeInfo + .Properties + .First(jsonProp => jsonProp.AttributeProvider is MemberInfo mi && mi == memberInfo); + return matchingProp.Name; + } + + return null; + } + + // Evaluates the value of the key or index which may be an int or a string, + // or some other expression type. + // The expression is converted to a delegate and the result of executing the delegate is returned as a string. + private static string EvaluateExpression(Expression expression) + { + var converted = Expression.Convert(expression, typeof(object)); + var fakeParameter = Expression.Parameter(typeof(object), null); + var lambda = Expression.Lambda>(converted, fakeParameter); + var func = lambda.Compile(); + + return Convert.ToString(func(null), CultureInfo.InvariantCulture); + } +} diff --git a/src/Features/JsonPatch.SystemTextJson/src/JsonPatchError.cs b/src/Features/JsonPatch.SystemTextJson/src/JsonPatchError.cs new file mode 100644 index 000000000000..e18d8d909aa5 --- /dev/null +++ b/src/Features/JsonPatch.SystemTextJson/src/JsonPatchError.cs @@ -0,0 +1,46 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Microsoft.AspNetCore.JsonPatch.SystemTextJson.Operations; +using Microsoft.AspNetCore.Shared; + +namespace Microsoft.AspNetCore.JsonPatch.SystemTextJson; + +/// +/// Captures error message and the related entity and the operation that caused it. +/// +public class JsonPatchError +{ + /// + /// Initializes a new instance of . + /// + /// The object that is affected by the error. + /// The that caused the error. + /// The error message. + public JsonPatchError( + object affectedObject, + Operation operation, + string errorMessage) + { + ArgumentNullThrowHelper.ThrowIfNull(errorMessage); + + AffectedObject = affectedObject; + Operation = operation; + ErrorMessage = errorMessage; + } + + /// + /// Gets the object that is affected by the error. + /// + public object AffectedObject { get; } + + /// + /// Gets the that caused the error. + /// + public Operation Operation { get; } + + /// + /// Gets the error message. + /// + public string ErrorMessage { get; } +} diff --git a/src/Features/JsonPatch.SystemTextJson/src/Microsoft.AspNetCore.JsonPatch.SystemTextJson.csproj b/src/Features/JsonPatch.SystemTextJson/src/Microsoft.AspNetCore.JsonPatch.SystemTextJson.csproj new file mode 100644 index 000000000000..8174fe7c0c92 --- /dev/null +++ b/src/Features/JsonPatch.SystemTextJson/src/Microsoft.AspNetCore.JsonPatch.SystemTextJson.csproj @@ -0,0 +1,22 @@ + + + + ASP.NET Core support for JSON PATCH, based on System.Text.Json serialization. + $(DefaultNetCoreTargetFramework) + $(NoWarn);CS1591 + $(DefineConstants);INTERNAL_NULLABLE_ATTRIBUTES + true + aspnetcore;json;jsonpatch + disable + + + + + + + + + + + + diff --git a/src/Features/JsonPatch.SystemTextJson/src/Operations/Operation.cs b/src/Features/JsonPatch.SystemTextJson/src/Operations/Operation.cs new file mode 100644 index 000000000000..bb2941ff2851 --- /dev/null +++ b/src/Features/JsonPatch.SystemTextJson/src/Operations/Operation.cs @@ -0,0 +1,68 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Text.Json.Serialization; +using Microsoft.AspNetCore.JsonPatch.SystemTextJson.Adapters; +using Microsoft.AspNetCore.JsonPatch.SystemTextJson; +using Microsoft.AspNetCore.Shared; + +namespace Microsoft.AspNetCore.JsonPatch.SystemTextJson.Operations; + +public class Operation : OperationBase +{ + [JsonPropertyName(nameof(value))] + public object value { get; set; } + + public Operation() + { + } + + public Operation(string op, string path, string from, object value) + : base(op, path, from) + { + this.value = value; + } + + public Operation(string op, string path, string from) + : base(op, path, from) + { + } + + public void Apply(object objectToApplyTo, IObjectAdapter adapter) + { + ArgumentNullThrowHelper.ThrowIfNull(objectToApplyTo); + ArgumentNullThrowHelper.ThrowIfNull(adapter); + + switch (OperationType) + { + case OperationType.Add: + adapter.Add(this, objectToApplyTo); + break; + case OperationType.Remove: + adapter.Remove(this, objectToApplyTo); + break; + case OperationType.Replace: + adapter.Replace(this, objectToApplyTo); + break; + case OperationType.Move: + adapter.Move(this, objectToApplyTo); + break; + case OperationType.Copy: + adapter.Copy(this, objectToApplyTo); + break; + case OperationType.Test: + if (adapter is IObjectAdapterWithTest adapterWithTest) + { + adapterWithTest.Test(this, objectToApplyTo); + break; + } + else + { + throw new NotSupportedException(Resources.TestOperationNotSupported); + } + default: + break; + } + } +} diff --git a/src/Features/JsonPatch.SystemTextJson/src/Operations/OperationBase.cs b/src/Features/JsonPatch.SystemTextJson/src/Operations/OperationBase.cs new file mode 100644 index 000000000000..adfa72767aa0 --- /dev/null +++ b/src/Features/JsonPatch.SystemTextJson/src/Operations/OperationBase.cs @@ -0,0 +1,68 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Text.Json.Serialization; +using Microsoft.AspNetCore.Shared; + +namespace Microsoft.AspNetCore.JsonPatch.SystemTextJson.Operations; + +public class OperationBase +{ + private string _op; + private OperationType _operationType; + + [JsonIgnore] + public OperationType OperationType + { + get + { + return _operationType; + } + } + + [JsonPropertyName(nameof(path))] + public string path { get; set; } + + [JsonPropertyName(nameof(op))] + public string op + { + get + { + return _op; + } + set + { + OperationType result; + if (!Enum.TryParse(value, ignoreCase: true, result: out result)) + { + result = OperationType.Invalid; + } + _operationType = result; + _op = value; + } + } + + [JsonPropertyName(nameof(from))] + public string from { get; set; } + + public OperationBase() + { + } + + public OperationBase(string op, string path, string from) + { + ArgumentNullThrowHelper.ThrowIfNull(op); + ArgumentNullThrowHelper.ThrowIfNull(path); + + this.op = op; + this.path = path; + this.from = from; + } + + public bool ShouldSerializeFrom() + { + return (OperationType == OperationType.Move + || OperationType == OperationType.Copy); + } +} diff --git a/src/Features/JsonPatch.SystemTextJson/src/Operations/OperationOfT.cs b/src/Features/JsonPatch.SystemTextJson/src/Operations/OperationOfT.cs new file mode 100644 index 000000000000..ac00db7652f7 --- /dev/null +++ b/src/Features/JsonPatch.SystemTextJson/src/Operations/OperationOfT.cs @@ -0,0 +1,71 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Microsoft.AspNetCore.JsonPatch.SystemTextJson.Adapters; +using Microsoft.AspNetCore.JsonPatch.SystemTextJson.Exceptions; +using Microsoft.AspNetCore.Shared; + +namespace Microsoft.AspNetCore.JsonPatch.SystemTextJson.Operations; + +public class Operation : Operation where TModel : class +{ + public Operation() + { + } + + public Operation(string op, string path, string from, object value) + : base(op, path, from) + { + ArgumentNullThrowHelper.ThrowIfNull(op); + ArgumentNullThrowHelper.ThrowIfNull(path); + + this.value = value; + } + + public Operation(string op, string path, string from) + : base(op, path, from) + { + ArgumentNullThrowHelper.ThrowIfNull(op); + ArgumentNullThrowHelper.ThrowIfNull(path); + } + + public void Apply(TModel objectToApplyTo, IObjectAdapter adapter) + { + ArgumentNullThrowHelper.ThrowIfNull(objectToApplyTo); + ArgumentNullThrowHelper.ThrowIfNull(adapter); + + switch (OperationType) + { + case OperationType.Add: + adapter.Add(this, objectToApplyTo); + break; + case OperationType.Remove: + adapter.Remove(this, objectToApplyTo); + break; + case OperationType.Replace: + adapter.Replace(this, objectToApplyTo); + break; + case OperationType.Move: + adapter.Move(this, objectToApplyTo); + break; + case OperationType.Copy: + adapter.Copy(this, objectToApplyTo); + break; + case OperationType.Test: + if (adapter is IObjectAdapterWithTest adapterWithTest) + { + adapterWithTest.Test(this, objectToApplyTo); + break; + } + else + { + throw new JsonPatchException(new JsonPatchError(objectToApplyTo, this, Resources.TestOperationNotSupported)); + } + case OperationType.Invalid: + throw new JsonPatchException( + Resources.FormatInvalidJsonPatchOperation(op), innerException: null); + default: + break; + } + } +} diff --git a/src/Features/JsonPatch.SystemTextJson/src/Operations/OperationType.cs b/src/Features/JsonPatch.SystemTextJson/src/Operations/OperationType.cs new file mode 100644 index 000000000000..52af41c723de --- /dev/null +++ b/src/Features/JsonPatch.SystemTextJson/src/Operations/OperationType.cs @@ -0,0 +1,15 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace Microsoft.AspNetCore.JsonPatch.SystemTextJson.Operations; + +public enum OperationType +{ + Add, + Remove, + Replace, + Move, + Copy, + Test, + Invalid +} diff --git a/src/Features/JsonPatch.SystemTextJson/src/PACKAGE.md b/src/Features/JsonPatch.SystemTextJson/src/PACKAGE.md new file mode 100644 index 000000000000..ebc6e9ba7284 --- /dev/null +++ b/src/Features/JsonPatch.SystemTextJson/src/PACKAGE.md @@ -0,0 +1,58 @@ +## About + +`Microsoft.AspNetCore.JsonPatch.SystemTextJson` provides ASP.NET Core support for JSON PATCH requests. + +## How to Use + +To use `Microsoft.AspNetCore.JsonPatch.SystemTextJson`, follow these steps: + +### Installation + +```shell +dotnet add package Microsoft.AspNetCore.JsonPatch.SystemTextJson +``` + + +### Usage + +To define an action method for a JSON Patch in an API controller: +1. Annotate it with the `HttpPatch` attribute +2. Accept a `JsonPatchDocument` +3. Call `ApplyTo` on the patch document to apply changes + +For example: + +```csharp +[HttpPatch] +public IActionResult JsonPatchWithModelState( + [FromBody] JsonPatchDocument patchDoc) +{ + if (patchDoc is not null) + { + var customer = CreateCustomer(); + + patchDoc.ApplyTo(customer, ModelState); + + if (!ModelState.IsValid) + { + return BadRequest(ModelState); + } + + return new ObjectResult(customer); + } + else + { + return BadRequest(ModelState); + } +} +``` + +In a real app, the code would retrieve the data from a store such as a database and update the database after applying the patch. + +## Additional Documentation + +For additional documentation and examples, refer to the [official documentation](https://learn.microsoft.com/aspnet/core/web-api/jsonpatch) on JSON Patch in ASP.NET Core. + +## Feedback & Contributing + +`Microsoft.AspNetCore.JsonPatch.SystemTextJson` is released as open-source under the [MIT license](https://licenses.nuget.org/MIT). Bug reports and contributions are welcome at [the GitHub repository](https://github.com/dotnet/aspnetcore). diff --git a/src/Features/JsonPatch.SystemTextJson/src/PublicAPI.Shipped.txt b/src/Features/JsonPatch.SystemTextJson/src/PublicAPI.Shipped.txt new file mode 100644 index 000000000000..7dc5c58110bf --- /dev/null +++ b/src/Features/JsonPatch.SystemTextJson/src/PublicAPI.Shipped.txt @@ -0,0 +1 @@ +#nullable enable diff --git a/src/Features/JsonPatch.SystemTextJson/src/PublicAPI.Unshipped.txt b/src/Features/JsonPatch.SystemTextJson/src/PublicAPI.Unshipped.txt new file mode 100644 index 000000000000..e2421fc8af3c --- /dev/null +++ b/src/Features/JsonPatch.SystemTextJson/src/PublicAPI.Unshipped.txt @@ -0,0 +1,106 @@ +#nullable enable +Microsoft.AspNetCore.JsonPatch.SystemTextJson.Adapters.IObjectAdapter +Microsoft.AspNetCore.JsonPatch.SystemTextJson.Adapters.IObjectAdapterWithTest +Microsoft.AspNetCore.JsonPatch.SystemTextJson.Exceptions.JsonPatchException +Microsoft.AspNetCore.JsonPatch.SystemTextJson.Exceptions.JsonPatchException.JsonPatchException() -> void +Microsoft.AspNetCore.JsonPatch.SystemTextJson.IJsonPatchDocument +Microsoft.AspNetCore.JsonPatch.SystemTextJson.JsonPatchDocument +Microsoft.AspNetCore.JsonPatch.SystemTextJson.JsonPatchDocument.JsonPatchDocument() -> void +Microsoft.AspNetCore.JsonPatch.SystemTextJson.JsonPatchDocument.JsonPatchDocument() -> void +Microsoft.AspNetCore.JsonPatch.SystemTextJson.JsonPatchError +Microsoft.AspNetCore.JsonPatch.SystemTextJson.Operations.Operation +Microsoft.AspNetCore.JsonPatch.SystemTextJson.Operations.Operation.Operation() -> void +Microsoft.AspNetCore.JsonPatch.SystemTextJson.Operations.Operation.Operation() -> void +Microsoft.AspNetCore.JsonPatch.SystemTextJson.Operations.OperationBase +Microsoft.AspNetCore.JsonPatch.SystemTextJson.Operations.OperationBase.OperationBase() -> void +Microsoft.AspNetCore.JsonPatch.SystemTextJson.Operations.OperationBase.OperationType.get -> Microsoft.AspNetCore.JsonPatch.SystemTextJson.Operations.OperationType +Microsoft.AspNetCore.JsonPatch.SystemTextJson.Operations.OperationBase.ShouldSerializeFrom() -> bool +Microsoft.AspNetCore.JsonPatch.SystemTextJson.Operations.OperationType +Microsoft.AspNetCore.JsonPatch.SystemTextJson.Operations.OperationType.Add = 0 -> Microsoft.AspNetCore.JsonPatch.SystemTextJson.Operations.OperationType +Microsoft.AspNetCore.JsonPatch.SystemTextJson.Operations.OperationType.Copy = 4 -> Microsoft.AspNetCore.JsonPatch.SystemTextJson.Operations.OperationType +Microsoft.AspNetCore.JsonPatch.SystemTextJson.Operations.OperationType.Invalid = 6 -> Microsoft.AspNetCore.JsonPatch.SystemTextJson.Operations.OperationType +Microsoft.AspNetCore.JsonPatch.SystemTextJson.Operations.OperationType.Move = 3 -> Microsoft.AspNetCore.JsonPatch.SystemTextJson.Operations.OperationType +Microsoft.AspNetCore.JsonPatch.SystemTextJson.Operations.OperationType.Remove = 1 -> Microsoft.AspNetCore.JsonPatch.SystemTextJson.Operations.OperationType +Microsoft.AspNetCore.JsonPatch.SystemTextJson.Operations.OperationType.Replace = 2 -> Microsoft.AspNetCore.JsonPatch.SystemTextJson.Operations.OperationType +Microsoft.AspNetCore.JsonPatch.SystemTextJson.Operations.OperationType.Test = 5 -> Microsoft.AspNetCore.JsonPatch.SystemTextJson.Operations.OperationType +~Microsoft.AspNetCore.JsonPatch.SystemTextJson.Adapters.IObjectAdapter.Add(Microsoft.AspNetCore.JsonPatch.SystemTextJson.Operations.Operation operation, object objectToApplyTo) -> void +~Microsoft.AspNetCore.JsonPatch.SystemTextJson.Adapters.IObjectAdapter.Copy(Microsoft.AspNetCore.JsonPatch.SystemTextJson.Operations.Operation operation, object objectToApplyTo) -> void +~Microsoft.AspNetCore.JsonPatch.SystemTextJson.Adapters.IObjectAdapter.Move(Microsoft.AspNetCore.JsonPatch.SystemTextJson.Operations.Operation operation, object objectToApplyTo) -> void +~Microsoft.AspNetCore.JsonPatch.SystemTextJson.Adapters.IObjectAdapter.Remove(Microsoft.AspNetCore.JsonPatch.SystemTextJson.Operations.Operation operation, object objectToApplyTo) -> void +~Microsoft.AspNetCore.JsonPatch.SystemTextJson.Adapters.IObjectAdapter.Replace(Microsoft.AspNetCore.JsonPatch.SystemTextJson.Operations.Operation operation, object objectToApplyTo) -> void +~Microsoft.AspNetCore.JsonPatch.SystemTextJson.Adapters.IObjectAdapterWithTest.Test(Microsoft.AspNetCore.JsonPatch.SystemTextJson.Operations.Operation operation, object objectToApplyTo) -> void +~Microsoft.AspNetCore.JsonPatch.SystemTextJson.Exceptions.JsonPatchException.AffectedObject.get -> object +~Microsoft.AspNetCore.JsonPatch.SystemTextJson.Exceptions.JsonPatchException.FailedOperation.get -> Microsoft.AspNetCore.JsonPatch.SystemTextJson.Operations.Operation +~Microsoft.AspNetCore.JsonPatch.SystemTextJson.Exceptions.JsonPatchException.JsonPatchException(Microsoft.AspNetCore.JsonPatch.SystemTextJson.JsonPatchError jsonPatchError) -> void +~Microsoft.AspNetCore.JsonPatch.SystemTextJson.Exceptions.JsonPatchException.JsonPatchException(Microsoft.AspNetCore.JsonPatch.SystemTextJson.JsonPatchError jsonPatchError, System.Exception innerException) -> void +~Microsoft.AspNetCore.JsonPatch.SystemTextJson.Exceptions.JsonPatchException.JsonPatchException(string message, System.Exception innerException) -> void +~Microsoft.AspNetCore.JsonPatch.SystemTextJson.IJsonPatchDocument.GetOperations() -> System.Collections.Generic.IList +~Microsoft.AspNetCore.JsonPatch.SystemTextJson.IJsonPatchDocument.SerializerOptions.get -> System.Text.Json.JsonSerializerOptions +~Microsoft.AspNetCore.JsonPatch.SystemTextJson.IJsonPatchDocument.SerializerOptions.set -> void +~Microsoft.AspNetCore.JsonPatch.SystemTextJson.JsonPatchDocument.Add(string path, object value) -> Microsoft.AspNetCore.JsonPatch.SystemTextJson.JsonPatchDocument +~Microsoft.AspNetCore.JsonPatch.SystemTextJson.JsonPatchDocument.ApplyTo(object objectToApplyTo) -> void +~Microsoft.AspNetCore.JsonPatch.SystemTextJson.JsonPatchDocument.ApplyTo(object objectToApplyTo, Microsoft.AspNetCore.JsonPatch.SystemTextJson.Adapters.IObjectAdapter adapter) -> void +~Microsoft.AspNetCore.JsonPatch.SystemTextJson.JsonPatchDocument.ApplyTo(object objectToApplyTo, Microsoft.AspNetCore.JsonPatch.SystemTextJson.Adapters.IObjectAdapter adapter, System.Action logErrorAction) -> void +~Microsoft.AspNetCore.JsonPatch.SystemTextJson.JsonPatchDocument.ApplyTo(object objectToApplyTo, System.Action logErrorAction) -> void +~Microsoft.AspNetCore.JsonPatch.SystemTextJson.JsonPatchDocument.Copy(string from, string path) -> Microsoft.AspNetCore.JsonPatch.SystemTextJson.JsonPatchDocument +~Microsoft.AspNetCore.JsonPatch.SystemTextJson.JsonPatchDocument.JsonPatchDocument(System.Collections.Generic.List operations, System.Text.Json.JsonSerializerOptions serializerOptions) -> void +~Microsoft.AspNetCore.JsonPatch.SystemTextJson.JsonPatchDocument.Move(string from, string path) -> Microsoft.AspNetCore.JsonPatch.SystemTextJson.JsonPatchDocument +~Microsoft.AspNetCore.JsonPatch.SystemTextJson.JsonPatchDocument.Operations.get -> System.Collections.Generic.List +~Microsoft.AspNetCore.JsonPatch.SystemTextJson.JsonPatchDocument.Remove(string path) -> Microsoft.AspNetCore.JsonPatch.SystemTextJson.JsonPatchDocument +~Microsoft.AspNetCore.JsonPatch.SystemTextJson.JsonPatchDocument.Replace(string path, object value) -> Microsoft.AspNetCore.JsonPatch.SystemTextJson.JsonPatchDocument +~Microsoft.AspNetCore.JsonPatch.SystemTextJson.JsonPatchDocument.SerializerOptions.get -> System.Text.Json.JsonSerializerOptions +~Microsoft.AspNetCore.JsonPatch.SystemTextJson.JsonPatchDocument.SerializerOptions.set -> void +~Microsoft.AspNetCore.JsonPatch.SystemTextJson.JsonPatchDocument.Test(string path, object value) -> Microsoft.AspNetCore.JsonPatch.SystemTextJson.JsonPatchDocument +~Microsoft.AspNetCore.JsonPatch.SystemTextJson.JsonPatchDocument +~Microsoft.AspNetCore.JsonPatch.SystemTextJson.JsonPatchDocument.Add(System.Linq.Expressions.Expression>> path, TProp value) -> Microsoft.AspNetCore.JsonPatch.SystemTextJson.JsonPatchDocument +~Microsoft.AspNetCore.JsonPatch.SystemTextJson.JsonPatchDocument.Add(System.Linq.Expressions.Expression>> path, TProp value, int position) -> Microsoft.AspNetCore.JsonPatch.SystemTextJson.JsonPatchDocument +~Microsoft.AspNetCore.JsonPatch.SystemTextJson.JsonPatchDocument.Add(System.Linq.Expressions.Expression> path, TProp value) -> Microsoft.AspNetCore.JsonPatch.SystemTextJson.JsonPatchDocument +~Microsoft.AspNetCore.JsonPatch.SystemTextJson.JsonPatchDocument.ApplyTo(TModel objectToApplyTo) -> void +~Microsoft.AspNetCore.JsonPatch.SystemTextJson.JsonPatchDocument.ApplyTo(TModel objectToApplyTo, Microsoft.AspNetCore.JsonPatch.SystemTextJson.Adapters.IObjectAdapter adapter) -> void +~Microsoft.AspNetCore.JsonPatch.SystemTextJson.JsonPatchDocument.ApplyTo(TModel objectToApplyTo, Microsoft.AspNetCore.JsonPatch.SystemTextJson.Adapters.IObjectAdapter adapter, System.Action logErrorAction) -> void +~Microsoft.AspNetCore.JsonPatch.SystemTextJson.JsonPatchDocument.ApplyTo(TModel objectToApplyTo, System.Action logErrorAction) -> void +~Microsoft.AspNetCore.JsonPatch.SystemTextJson.JsonPatchDocument.Copy(System.Linq.Expressions.Expression>> from, int positionFrom, System.Linq.Expressions.Expression>> path) -> Microsoft.AspNetCore.JsonPatch.SystemTextJson.JsonPatchDocument +~Microsoft.AspNetCore.JsonPatch.SystemTextJson.JsonPatchDocument.Copy(System.Linq.Expressions.Expression>> from, int positionFrom, System.Linq.Expressions.Expression>> path, int positionTo) -> Microsoft.AspNetCore.JsonPatch.SystemTextJson.JsonPatchDocument +~Microsoft.AspNetCore.JsonPatch.SystemTextJson.JsonPatchDocument.Copy(System.Linq.Expressions.Expression>> from, int positionFrom, System.Linq.Expressions.Expression> path) -> Microsoft.AspNetCore.JsonPatch.SystemTextJson.JsonPatchDocument +~Microsoft.AspNetCore.JsonPatch.SystemTextJson.JsonPatchDocument.Copy(System.Linq.Expressions.Expression> from, System.Linq.Expressions.Expression>> path) -> Microsoft.AspNetCore.JsonPatch.SystemTextJson.JsonPatchDocument +~Microsoft.AspNetCore.JsonPatch.SystemTextJson.JsonPatchDocument.Copy(System.Linq.Expressions.Expression> from, System.Linq.Expressions.Expression>> path, int positionTo) -> Microsoft.AspNetCore.JsonPatch.SystemTextJson.JsonPatchDocument +~Microsoft.AspNetCore.JsonPatch.SystemTextJson.JsonPatchDocument.Copy(System.Linq.Expressions.Expression> from, System.Linq.Expressions.Expression> path) -> Microsoft.AspNetCore.JsonPatch.SystemTextJson.JsonPatchDocument +~Microsoft.AspNetCore.JsonPatch.SystemTextJson.JsonPatchDocument.JsonPatchDocument(System.Collections.Generic.List> operations, System.Text.Json.JsonSerializerOptions serializerOptions) -> void +~Microsoft.AspNetCore.JsonPatch.SystemTextJson.JsonPatchDocument.Move(System.Linq.Expressions.Expression>> from, int positionFrom, System.Linq.Expressions.Expression>> path) -> Microsoft.AspNetCore.JsonPatch.SystemTextJson.JsonPatchDocument +~Microsoft.AspNetCore.JsonPatch.SystemTextJson.JsonPatchDocument.Move(System.Linq.Expressions.Expression>> from, int positionFrom, System.Linq.Expressions.Expression>> path, int positionTo) -> Microsoft.AspNetCore.JsonPatch.SystemTextJson.JsonPatchDocument +~Microsoft.AspNetCore.JsonPatch.SystemTextJson.JsonPatchDocument.Move(System.Linq.Expressions.Expression>> from, int positionFrom, System.Linq.Expressions.Expression> path) -> Microsoft.AspNetCore.JsonPatch.SystemTextJson.JsonPatchDocument +~Microsoft.AspNetCore.JsonPatch.SystemTextJson.JsonPatchDocument.Move(System.Linq.Expressions.Expression> from, System.Linq.Expressions.Expression>> path) -> Microsoft.AspNetCore.JsonPatch.SystemTextJson.JsonPatchDocument +~Microsoft.AspNetCore.JsonPatch.SystemTextJson.JsonPatchDocument.Move(System.Linq.Expressions.Expression> from, System.Linq.Expressions.Expression>> path, int positionTo) -> Microsoft.AspNetCore.JsonPatch.SystemTextJson.JsonPatchDocument +~Microsoft.AspNetCore.JsonPatch.SystemTextJson.JsonPatchDocument.Move(System.Linq.Expressions.Expression> from, System.Linq.Expressions.Expression> path) -> Microsoft.AspNetCore.JsonPatch.SystemTextJson.JsonPatchDocument +~Microsoft.AspNetCore.JsonPatch.SystemTextJson.JsonPatchDocument.Operations.get -> System.Collections.Generic.List> +~Microsoft.AspNetCore.JsonPatch.SystemTextJson.JsonPatchDocument.Remove(System.Linq.Expressions.Expression>> path) -> Microsoft.AspNetCore.JsonPatch.SystemTextJson.JsonPatchDocument +~Microsoft.AspNetCore.JsonPatch.SystemTextJson.JsonPatchDocument.Remove(System.Linq.Expressions.Expression>> path, int position) -> Microsoft.AspNetCore.JsonPatch.SystemTextJson.JsonPatchDocument +~Microsoft.AspNetCore.JsonPatch.SystemTextJson.JsonPatchDocument.Remove(System.Linq.Expressions.Expression> path) -> Microsoft.AspNetCore.JsonPatch.SystemTextJson.JsonPatchDocument +~Microsoft.AspNetCore.JsonPatch.SystemTextJson.JsonPatchDocument.Replace(System.Linq.Expressions.Expression>> path, TProp value) -> Microsoft.AspNetCore.JsonPatch.SystemTextJson.JsonPatchDocument +~Microsoft.AspNetCore.JsonPatch.SystemTextJson.JsonPatchDocument.Replace(System.Linq.Expressions.Expression>> path, TProp value, int position) -> Microsoft.AspNetCore.JsonPatch.SystemTextJson.JsonPatchDocument +~Microsoft.AspNetCore.JsonPatch.SystemTextJson.JsonPatchDocument.Replace(System.Linq.Expressions.Expression> path, TProp value) -> Microsoft.AspNetCore.JsonPatch.SystemTextJson.JsonPatchDocument +~Microsoft.AspNetCore.JsonPatch.SystemTextJson.JsonPatchDocument.SerializerOptions.get -> System.Text.Json.JsonSerializerOptions +~Microsoft.AspNetCore.JsonPatch.SystemTextJson.JsonPatchDocument.SerializerOptions.set -> void +~Microsoft.AspNetCore.JsonPatch.SystemTextJson.JsonPatchDocument.Test(System.Linq.Expressions.Expression>> path, TProp value) -> Microsoft.AspNetCore.JsonPatch.SystemTextJson.JsonPatchDocument +~Microsoft.AspNetCore.JsonPatch.SystemTextJson.JsonPatchDocument.Test(System.Linq.Expressions.Expression>> path, TProp value, int position) -> Microsoft.AspNetCore.JsonPatch.SystemTextJson.JsonPatchDocument +~Microsoft.AspNetCore.JsonPatch.SystemTextJson.JsonPatchDocument.Test(System.Linq.Expressions.Expression> path, TProp value) -> Microsoft.AspNetCore.JsonPatch.SystemTextJson.JsonPatchDocument +~Microsoft.AspNetCore.JsonPatch.SystemTextJson.JsonPatchError.AffectedObject.get -> object +~Microsoft.AspNetCore.JsonPatch.SystemTextJson.JsonPatchError.ErrorMessage.get -> string +~Microsoft.AspNetCore.JsonPatch.SystemTextJson.JsonPatchError.JsonPatchError(object affectedObject, Microsoft.AspNetCore.JsonPatch.SystemTextJson.Operations.Operation operation, string errorMessage) -> void +~Microsoft.AspNetCore.JsonPatch.SystemTextJson.JsonPatchError.Operation.get -> Microsoft.AspNetCore.JsonPatch.SystemTextJson.Operations.Operation +~Microsoft.AspNetCore.JsonPatch.SystemTextJson.Operations.Operation.Apply(object objectToApplyTo, Microsoft.AspNetCore.JsonPatch.SystemTextJson.Adapters.IObjectAdapter adapter) -> void +~Microsoft.AspNetCore.JsonPatch.SystemTextJson.Operations.Operation.Operation(string op, string path, string from) -> void +~Microsoft.AspNetCore.JsonPatch.SystemTextJson.Operations.Operation.Operation(string op, string path, string from, object value) -> void +~Microsoft.AspNetCore.JsonPatch.SystemTextJson.Operations.Operation.value.get -> object +~Microsoft.AspNetCore.JsonPatch.SystemTextJson.Operations.Operation.value.set -> void +~Microsoft.AspNetCore.JsonPatch.SystemTextJson.Operations.Operation +~Microsoft.AspNetCore.JsonPatch.SystemTextJson.Operations.Operation.Apply(TModel objectToApplyTo, Microsoft.AspNetCore.JsonPatch.SystemTextJson.Adapters.IObjectAdapter adapter) -> void +~Microsoft.AspNetCore.JsonPatch.SystemTextJson.Operations.Operation.Operation(string op, string path, string from) -> void +~Microsoft.AspNetCore.JsonPatch.SystemTextJson.Operations.Operation.Operation(string op, string path, string from, object value) -> void +~Microsoft.AspNetCore.JsonPatch.SystemTextJson.Operations.OperationBase.from.get -> string +~Microsoft.AspNetCore.JsonPatch.SystemTextJson.Operations.OperationBase.from.set -> void +~Microsoft.AspNetCore.JsonPatch.SystemTextJson.Operations.OperationBase.op.get -> string +~Microsoft.AspNetCore.JsonPatch.SystemTextJson.Operations.OperationBase.op.set -> void +~Microsoft.AspNetCore.JsonPatch.SystemTextJson.Operations.OperationBase.OperationBase(string op, string path, string from) -> void +~Microsoft.AspNetCore.JsonPatch.SystemTextJson.Operations.OperationBase.path.get -> string +~Microsoft.AspNetCore.JsonPatch.SystemTextJson.Operations.OperationBase.path.set -> void diff --git a/src/Features/JsonPatch.SystemTextJson/src/Resources.resx b/src/Features/JsonPatch.SystemTextJson/src/Resources.resx new file mode 100644 index 000000000000..87cc399c6274 --- /dev/null +++ b/src/Features/JsonPatch.SystemTextJson/src/Resources.resx @@ -0,0 +1,186 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + The property at '{0}' could not be copied. + + + The type of the property at path '{0}' could not be determined. + + + The '{0}' operation at path '{1}' could not be performed. + + + The property at '{0}' could not be read. + + + The property at path '{0}' could not be updated. + + + The expression '{0}' is not supported. Supported expressions include member access and indexer expressions. + + + The index value provided by path segment '{0}' is out of bounds of the array size. + + + The path segment '{0}' is invalid for an array index. + + + The JSON patch document was malformed and could not be parsed. + + + Invalid JsonPatch operation '{0}'. + + + The provided path segment '{0}' cannot be converted to the target type. + + + The provided string '{0}' is an invalid path. + + + The value '{0}' is invalid for target location. + + + '{0}' must be of type '{1}'. + + + The type '{0}' which is an array is not supported for json patch operations as it has a fixed size. + + + The type '{0}' which is a non generic list is not supported for json patch operations. Only generic list types are supported. + + + The target location specified by path segment '{0}' was not found. + + + For operation '{0}', the target location specified by path '{1}' was not found. + + + The test operation is not supported. + + + The current value '{0}' at position '{2}' is not equal to the test value '{1}'. + + + The value at '{0}' cannot be null or empty to perform the test operation. + + + The current value '{0}' at path '{2}' is not equal to the test value '{1}'. + + \ No newline at end of file diff --git a/src/Features/JsonPatch.SystemTextJson/startvs.cmd b/src/Features/JsonPatch.SystemTextJson/startvs.cmd new file mode 100644 index 000000000000..f83559bb92c7 --- /dev/null +++ b/src/Features/JsonPatch.SystemTextJson/startvs.cmd @@ -0,0 +1,3 @@ +@ECHO OFF + +%~dp0..\..\..\startvs.cmd %~dp0JsonPatch.SystemTextJson.slnf diff --git a/src/Features/JsonPatch.SystemTextJson/test/Adapters/AdapterFactoryTests.cs b/src/Features/JsonPatch.SystemTextJson/test/Adapters/AdapterFactoryTests.cs new file mode 100644 index 000000000000..32096e7257bc --- /dev/null +++ b/src/Features/JsonPatch.SystemTextJson/test/Adapters/AdapterFactoryTests.cs @@ -0,0 +1,55 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Collections.Generic; +using System.Text.Json.Serialization.Metadata; +using Microsoft.AspNetCore.JsonPatch.SystemTextJson.Adapters; +using Microsoft.AspNetCore.JsonPatch.SystemTextJson.Internal; +using Xunit; + +namespace Microsoft.AspNetCore.JsonPatch.SystemTextJson.Test.Adapters; + +public class AdapterFactoryTests +{ + [Fact] + public void GetListAdapterForListTargets() + { + // Arrange + AdapterFactory factory = new AdapterFactory(); + + //Act: + IAdapter adapter = factory.Create(new List()); + + // Assert + Assert.Equal(typeof(ListAdapter), adapter.GetType()); + } + + [Fact] + public void GetDictionaryAdapterForDictionaryObjects() + { + // Arrange + AdapterFactory factory = new AdapterFactory(); + + //Act: + IAdapter adapter = factory.Create(new Dictionary()); + + // Assert + Assert.Equal(typeof(DictionaryAdapter), adapter.GetType()); + } + + private class PocoModel + { } + + [Fact] + public void GetPocoAdapterForGenericObjects() + { + // Arrange + AdapterFactory factory = new AdapterFactory(); + + //Act: + IAdapter adapter = factory.Create(new PocoModel()); + + // Assert + Assert.Equal(typeof(PocoAdapter), adapter.GetType()); + } +} diff --git a/src/Features/JsonPatch.SystemTextJson/test/Adapters/TestDynamicObject.cs b/src/Features/JsonPatch.SystemTextJson/test/Adapters/TestDynamicObject.cs new file mode 100644 index 000000000000..dc6342236021 --- /dev/null +++ b/src/Features/JsonPatch.SystemTextJson/test/Adapters/TestDynamicObject.cs @@ -0,0 +1,9 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Dynamic; + +namespace Microsoft.AspNetCore.JsonPatch.SystemTextJson.Test.Adapters; + +public class TestDynamicObject : DynamicObject +{ } diff --git a/src/Features/JsonPatch.SystemTextJson/test/CustomNamingStrategyTests.cs b/src/Features/JsonPatch.SystemTextJson/test/CustomNamingStrategyTests.cs new file mode 100644 index 000000000000..9f40bfad961f --- /dev/null +++ b/src/Features/JsonPatch.SystemTextJson/test/CustomNamingStrategyTests.cs @@ -0,0 +1,46 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Collections.Generic; +using System.Text.Json; +using System.Text.Json.Serialization.Metadata; +using Xunit; + +namespace Microsoft.AspNetCore.JsonPatch.SystemTextJson; + +public class CustomNamingStrategyTests +{ + [Fact] + public void RemoveProperty_FromDictionaryObject_WithCustomNamingStrategy() + { + // Arrange + var serializerOptions = new JsonSerializerOptions(); + serializerOptions.PropertyNamingPolicy = new TestNamingPolicy(); + serializerOptions.TypeInfoResolver = new DefaultJsonTypeInfoResolver(); + + var targetObject = new Dictionary() + { + { "customTest", 1}, + }; + + var patchDocument = new JsonPatchDocument(); + patchDocument.Remove("customTest"); + patchDocument.SerializerOptions = serializerOptions; + + // Act + patchDocument.ApplyTo(targetObject); + var cont = targetObject as IDictionary; + cont.TryGetValue("customTest", out var valueFromDictionary); + + // Assert + Assert.Equal(0, valueFromDictionary); + } + + private class TestNamingPolicy : JsonNamingPolicy + { + public override string ConvertName(string name) + { + return "custom" + name; + } + } +} diff --git a/src/Features/JsonPatch.SystemTextJson/test/IntegrationTests/AnonymousObjectIntegrationTest.cs b/src/Features/JsonPatch.SystemTextJson/test/IntegrationTests/AnonymousObjectIntegrationTest.cs new file mode 100644 index 000000000000..64bf3de4a5f0 --- /dev/null +++ b/src/Features/JsonPatch.SystemTextJson/test/IntegrationTests/AnonymousObjectIntegrationTest.cs @@ -0,0 +1,165 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Microsoft.AspNetCore.JsonPatch.SystemTextJson.Exceptions; +using Xunit; + +namespace Microsoft.AspNetCore.JsonPatch.SystemTextJson.IntegrationTests; + +public class AnonymousObjectIntegrationTest +{ + [Fact] + public void AddNewProperty_ShouldFail() + { + // Arrange + var targetObject = new { }; + + var patchDocument = new JsonPatchDocument(); + patchDocument.Add("NewProperty", 4); + + // Act + var exception = Assert.Throws(() => + { + patchDocument.ApplyTo(targetObject); + }); + + // Assert + Assert.Equal("The target location specified by path segment 'NewProperty' was not found.", + exception.Message); + } + + [Fact] + public void AddDoesNotReplace() + { + // Arrange + var targetObject = new + { + StringProperty = "A" + }; + + var patchDocument = new JsonPatchDocument(); + patchDocument.Add("StringProperty", "B"); + + // Act + var exception = Assert.Throws(() => + { + patchDocument.ApplyTo(targetObject); + }); + + // Assert + Assert.Equal("The property at path 'StringProperty' could not be updated.", + exception.Message); + } + + [Fact] + public void RemoveProperty_ShouldFail() + { + // Arrange + dynamic targetObject = new + { + Test = 1 + }; + + var patchDocument = new JsonPatchDocument(); + patchDocument.Remove("Test"); + + // Act + var exception = Assert.Throws(() => + { + patchDocument.ApplyTo(targetObject); + }); + + // Assert + Assert.Equal("The property at path 'Test' could not be updated.", + exception.Message); + } + + [Fact] + public void ReplaceProperty_ShouldFail() + { + // Arrange + var targetObject = new + { + StringProperty = "A", + AnotherStringProperty = "B" + }; + + var patchDocument = new JsonPatchDocument(); + patchDocument.Replace("StringProperty", "AnotherStringProperty"); + + // Act + var exception = Assert.Throws(() => + { + patchDocument.ApplyTo(targetObject); + }); + + // Assert + Assert.Equal("The property at path 'StringProperty' could not be updated.", + exception.Message); + } + + [Fact] + public void MoveProperty_ShouldFail() + { + // Arrange + var targetObject = new + { + StringProperty = "A", + AnotherStringProperty = "B" + }; + + var patchDocument = new JsonPatchDocument(); + patchDocument.Move("StringProperty", "AnotherStringProperty"); + + // Act + var exception = Assert.Throws(() => + { + patchDocument.ApplyTo(targetObject); + }); + + // Assert + Assert.Equal("The property at path 'StringProperty' could not be updated.", + exception.Message); + } + + [Fact] + public void TestStringProperty_IsSuccessful() + { + // Arrange + var targetObject = new + { + StringProperty = "A", + AnotherStringProperty = "B" + }; + + var patchDocument = new JsonPatchDocument(); + patchDocument.Test("StringProperty", "A"); + + // Act & Assert + patchDocument.ApplyTo(targetObject); + } + + [Fact] + public void TestStringProperty_Fails() + { + // Arrange + var targetObject = new + { + StringProperty = "A", + AnotherStringProperty = "B" + }; + + var patchDocument = new JsonPatchDocument(); + patchDocument.Test("StringProperty", "B"); + + // Act + var exception = Assert.Throws(() => + { + patchDocument.ApplyTo(targetObject); + }); + + // Assert + Assert.Equal("The current value 'A' at path 'StringProperty' is not equal to the test value 'B'.", + exception.Message); + } +} diff --git a/src/Features/JsonPatch.SystemTextJson/test/IntegrationTests/DictionaryIntegrationTest.cs b/src/Features/JsonPatch.SystemTextJson/test/IntegrationTests/DictionaryIntegrationTest.cs new file mode 100644 index 000000000000..227748e5ac15 --- /dev/null +++ b/src/Features/JsonPatch.SystemTextJson/test/IntegrationTests/DictionaryIntegrationTest.cs @@ -0,0 +1,318 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Collections.Generic; +using Microsoft.AspNetCore.JsonPatch.SystemTextJson.Exceptions; +using Xunit; + +namespace Microsoft.AspNetCore.JsonPatch.SystemTextJson.IntegrationTests; + +public class DictionaryTest +{ + [Fact] + public void TestIntegerValue_IsSuccessful() + { + // Arrange + var model = new IntDictionary(); + model.DictionaryOfStringToInteger["one"] = 1; + model.DictionaryOfStringToInteger["two"] = 2; + var patchDocument = new JsonPatchDocument(); + patchDocument.Test("/DictionaryOfStringToInteger/two", 2); + + // Act & Assert + patchDocument.ApplyTo(model); + } + + [Fact] + public void AddIntegerValue_Succeeds() + { + // Arrange + var model = new IntDictionary(); + model.DictionaryOfStringToInteger["one"] = 1; + model.DictionaryOfStringToInteger["two"] = 2; + var patchDocument = new JsonPatchDocument(); + patchDocument.Add("/DictionaryOfStringToInteger/three", 3); + + // Act + patchDocument.ApplyTo(model); + + // Assert + Assert.Equal(3, model.DictionaryOfStringToInteger.Count); + Assert.Equal(1, model.DictionaryOfStringToInteger["one"]); + Assert.Equal(2, model.DictionaryOfStringToInteger["two"]); + Assert.Equal(3, model.DictionaryOfStringToInteger["three"]); + } + + [Fact] + public void RemoveIntegerValue_Succeeds() + { + // Arrange + var model = new IntDictionary(); + model.DictionaryOfStringToInteger["one"] = 1; + model.DictionaryOfStringToInteger["two"] = 2; + var patchDocument = new JsonPatchDocument(); + patchDocument.Remove("/DictionaryOfStringToInteger/two"); + + // Act + patchDocument.ApplyTo(model); + + // Assert + Assert.Single(model.DictionaryOfStringToInteger); + Assert.Equal(1, model.DictionaryOfStringToInteger["one"]); + } + + [Fact] + public void MoveIntegerValue_Succeeds() + { + // Arrange + var model = new IntDictionary(); + model.DictionaryOfStringToInteger["one"] = 1; + model.DictionaryOfStringToInteger["two"] = 2; + var patchDocument = new JsonPatchDocument(); + patchDocument.Move("/DictionaryOfStringToInteger/one", "/DictionaryOfStringToInteger/two"); + + // Act + patchDocument.ApplyTo(model); + + // Assert + Assert.Single(model.DictionaryOfStringToInteger); + Assert.Equal(1, model.DictionaryOfStringToInteger["two"]); + } + + [Fact] + public void ReplaceIntegerValue_Succeeds() + { + // Arrange + var model = new IntDictionary(); + model.DictionaryOfStringToInteger["one"] = 1; + model.DictionaryOfStringToInteger["two"] = 2; + var patchDocument = new JsonPatchDocument(); + patchDocument.Replace("/DictionaryOfStringToInteger/two", 20); + + // Act + patchDocument.ApplyTo(model); + + // Assert + Assert.Equal(2, model.DictionaryOfStringToInteger.Count); + Assert.Equal(1, model.DictionaryOfStringToInteger["one"]); + Assert.Equal(20, model.DictionaryOfStringToInteger["two"]); + } + + [Fact] + public void CopyIntegerValue_Succeeds() + { + // Arrange + var model = new IntDictionary(); + model.DictionaryOfStringToInteger["one"] = 1; + model.DictionaryOfStringToInteger["two"] = 2; + var patchDocument = new JsonPatchDocument(); + patchDocument.Copy("/DictionaryOfStringToInteger/one", "/DictionaryOfStringToInteger/two"); + + // Act + patchDocument.ApplyTo(model); + + // Assert + Assert.Equal(2, model.DictionaryOfStringToInteger.Count); + Assert.Equal(1, model.DictionaryOfStringToInteger["one"]); + Assert.Equal(1, model.DictionaryOfStringToInteger["two"]); + } + + private class Customer + { + public string Name { get; set; } + public Address Address { get; set; } + } + + private class Address + { + public string City { get; set; } + } + + private class IntDictionary + { + public IDictionary DictionaryOfStringToInteger { get; } = new Dictionary(); + } + + private class CustomerDictionary + { + public IDictionary DictionaryOfStringToCustomer { get; } = new Dictionary(); + } + + [Fact] + public void TestPocoObject_Succeeds() + { + // Arrange + var key1 = 100; + var value1 = new Customer() { Name = "James" }; + var model = new CustomerDictionary(); + model.DictionaryOfStringToCustomer[key1] = value1; + var patchDocument = new JsonPatchDocument(); + patchDocument.Test($"/DictionaryOfStringToCustomer/{key1}/Name", "James"); + + // Act & Assert + patchDocument.ApplyTo(model); + } + + [Fact] + public void TestPocoObject_FailsWhenTestValueIsNotEqualToObjectValue() + { + // Arrange + var key1 = 100; + var value1 = new Customer() { Name = "James" }; + var model = new CustomerDictionary(); + model.DictionaryOfStringToCustomer[key1] = value1; + var patchDocument = new JsonPatchDocument(); + patchDocument.Test($"/DictionaryOfStringToCustomer/{key1}/Name", "Mike"); + + // Act + var exception = Assert.Throws(() => + { + patchDocument.ApplyTo(model); + }); + + // Assert + Assert.Equal("The current value 'James' at path 'Name' is not equal to the test value 'Mike'.", exception.Message); + } + + [Fact] + public void AddReplacesPocoObject_Succeeds() + { + // Arrange + var key1 = 100; + var value1 = new Customer() { Name = "Jamesss" }; + var key2 = 200; + var value2 = new Customer() { Name = "Mike" }; + var model = new CustomerDictionary(); + model.DictionaryOfStringToCustomer[key1] = value1; + model.DictionaryOfStringToCustomer[key2] = value2; + var patchDocument = new JsonPatchDocument(); + patchDocument.Add($"/DictionaryOfStringToCustomer/{key1}/Name", "James"); + + // Act + patchDocument.ApplyTo(model); + + // Assert + Assert.Equal(2, model.DictionaryOfStringToCustomer.Count); + var actualValue1 = model.DictionaryOfStringToCustomer[key1]; + Assert.NotNull(actualValue1); + Assert.Equal("James", actualValue1.Name); + } + + [Fact] + public void RemovePocoObject_Succeeds() + { + // Arrange + var key1 = 100; + var value1 = new Customer() { Name = "Jamesss" }; + var key2 = 200; + var value2 = new Customer() { Name = "Mike" }; + var model = new CustomerDictionary(); + model.DictionaryOfStringToCustomer[key1] = value1; + model.DictionaryOfStringToCustomer[key2] = value2; + var patchDocument = new JsonPatchDocument(); + patchDocument.Remove($"/DictionaryOfStringToCustomer/{key1}/Name"); + + // Act + patchDocument.ApplyTo(model); + + // Assert + var actualValue1 = model.DictionaryOfStringToCustomer[key1]; + Assert.Null(actualValue1.Name); + } + + [Fact] + public void MovePocoObject_Succeeds() + { + // Arrange + var key1 = 100; + var value1 = new Customer() { Name = "James" }; + var key2 = 200; + var value2 = new Customer() { Name = "Mike" }; + var model = new CustomerDictionary(); + model.DictionaryOfStringToCustomer[key1] = value1; + model.DictionaryOfStringToCustomer[key2] = value2; + var patchDocument = new JsonPatchDocument(); + patchDocument.Move($"/DictionaryOfStringToCustomer/{key1}/Name", $"/DictionaryOfStringToCustomer/{key2}/Name"); + + // Act + patchDocument.ApplyTo(model); + + // Assert + var actualValue2 = model.DictionaryOfStringToCustomer[key2]; + Assert.NotNull(actualValue2); + Assert.Equal("James", actualValue2.Name); + } + + [Fact] + public void CopyPocoObject_Succeeds() + { + // Arrange + var key1 = 100; + var value1 = new Customer() { Name = "James" }; + var key2 = 200; + var value2 = new Customer() { Name = "Mike" }; + var model = new CustomerDictionary(); + model.DictionaryOfStringToCustomer[key1] = value1; + model.DictionaryOfStringToCustomer[key2] = value2; + var patchDocument = new JsonPatchDocument(); + patchDocument.Copy($"/DictionaryOfStringToCustomer/{key1}/Name", $"/DictionaryOfStringToCustomer/{key2}/Name"); + + // Act + patchDocument.ApplyTo(model); + + // Assert + Assert.Equal(2, model.DictionaryOfStringToCustomer.Count); + var actualValue2 = model.DictionaryOfStringToCustomer[key2]; + Assert.NotNull(actualValue2); + Assert.Equal("James", actualValue2.Name); + } + + [Fact] + public void ReplacePocoObject_Succeeds() + { + // Arrange + var key1 = 100; + var value1 = new Customer() { Name = "Jamesss" }; + var key2 = 200; + var value2 = new Customer() { Name = "Mike" }; + var model = new CustomerDictionary(); + model.DictionaryOfStringToCustomer[key1] = value1; + model.DictionaryOfStringToCustomer[key2] = value2; + var patchDocument = new JsonPatchDocument(); + patchDocument.Replace($"/DictionaryOfStringToCustomer/{key1}/Name", "James"); + + // Act + patchDocument.ApplyTo(model); + + // Assert + Assert.Equal(2, model.DictionaryOfStringToCustomer.Count); + var actualValue1 = model.DictionaryOfStringToCustomer[key1]; + Assert.NotNull(actualValue1); + Assert.Equal("James", actualValue1.Name); + } + + [Fact] + public void ReplacePocoObject_WithEscaping_Succeeds() + { + // Arrange + var key1 = "Foo/Name"; + var value1 = 100; + var key2 = "Foo"; + var value2 = 200; + var model = new IntDictionary(); + model.DictionaryOfStringToInteger[key1] = value1; + model.DictionaryOfStringToInteger[key2] = value2; + var patchDocument = new JsonPatchDocument(); + patchDocument.Replace($"/DictionaryOfStringToInteger/Foo~1Name", 300); + + // Act + patchDocument.ApplyTo(model); + + // Assert + Assert.Equal(2, model.DictionaryOfStringToInteger.Count); + var actualValue1 = model.DictionaryOfStringToInteger[key1]; + var actualValue2 = model.DictionaryOfStringToInteger[key2]; + Assert.Equal(300, actualValue1); + Assert.Equal(200, actualValue2); + } +} diff --git a/src/Features/JsonPatch.SystemTextJson/test/IntegrationTests/HeterogenousCollectionTests.cs b/src/Features/JsonPatch.SystemTextJson/test/IntegrationTests/HeterogenousCollectionTests.cs new file mode 100644 index 000000000000..bbc59d86db63 --- /dev/null +++ b/src/Features/JsonPatch.SystemTextJson/test/IntegrationTests/HeterogenousCollectionTests.cs @@ -0,0 +1,123 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Collections.Generic; +using System.Text.Json; +using System.Text.Json.Nodes; +using System.Text.Json.Serialization; +using System.Text.Json.Serialization.Metadata; +using Xunit; + +namespace Microsoft.AspNetCore.JsonPatch.SystemTextJson.IntegrationTests; + +public class HeterogenousCollectionTests +{ + [Fact] + public void AddItemToList() + { + // Arrange + var targetObject = new Canvas() + { + Items = new List() + }; + + var circleJObject = JsonObject.Parse(@"{ + ""Type"": ""Circle"", + ""ShapeProperty"": ""Shape property"", + ""CircleProperty"": ""Circle property"" + }"); + + var serializerOptions = new JsonSerializerOptions(); + serializerOptions.TypeInfoResolver = new CanvasContractResolver(); + + var patchDocument = new JsonPatchDocument + { + SerializerOptions = serializerOptions + }; + + patchDocument.Add("/Items/-", circleJObject); + + // Act + patchDocument.ApplyTo(targetObject); + + // Assert + var circle = targetObject.Items[0] as Circle; + Assert.NotNull(circle); + Assert.Equal("Shape property", circle.ShapeProperty); + Assert.Equal("Circle property", circle.CircleProperty); + } +} + +public class CanvasContractResolver : DefaultJsonTypeInfoResolver +{ + public override JsonTypeInfo GetTypeInfo(Type type, JsonSerializerOptions options) + { + // Get the default metadata for the type. + var jsonTypeInfo = base.GetTypeInfo(type, options); + + // Check if the type is Shape or derives from it. + if (jsonTypeInfo.Type == typeof(Shape)) + { + // Configure polymorphism options if they haven't been set yet. + if (jsonTypeInfo.PolymorphismOptions == null) + { + jsonTypeInfo.PolymorphismOptions = new JsonPolymorphismOptions + { + TypeDiscriminatorPropertyName = "Type", + IgnoreUnrecognizedTypeDiscriminators = true, + UnknownDerivedTypeHandling = JsonUnknownDerivedTypeHandling.FailSerialization, + DerivedTypes = { + new JsonDerivedType(typeof(Circle), "Circle"), + new JsonDerivedType(typeof(Rectangle), "Rectangle") + } + }; + } + } + + return jsonTypeInfo; + } +} + +public class ShapeJsonConverter : JsonConverter +{ + public override Shape Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + if (reader.TokenType != JsonTokenType.StartObject) + { + throw new JsonException(); + } + + using var doc = JsonDocument.ParseValue(ref reader); + var root = doc.RootElement; + + if (root.TryGetProperty("CircleProperty", out _)) + { + return JsonSerializer.Deserialize(root.GetRawText(), options); + } + else if (root.TryGetProperty("RectangleProperty", out _)) + { + return JsonSerializer.Deserialize(root.GetRawText(), options); + } + else + { + throw new JsonException("Unknown shape type"); + } + } + + public override void Write(Utf8JsonWriter writer, Shape value, JsonSerializerOptions options) + { + if (value is Circle circle) + { + JsonSerializer.Serialize(writer, circle, options); + } + else if (value is Rectangle rectangle) + { + JsonSerializer.Serialize(writer, rectangle, options); + } + else + { + throw new JsonException("Unknown shape type"); + } + } +} diff --git a/src/Features/JsonPatch.SystemTextJson/test/IntegrationTests/ListIntegrationTest.cs b/src/Features/JsonPatch.SystemTextJson/test/IntegrationTests/ListIntegrationTest.cs new file mode 100644 index 000000000000..b68dff481b84 --- /dev/null +++ b/src/Features/JsonPatch.SystemTextJson/test/IntegrationTests/ListIntegrationTest.cs @@ -0,0 +1,365 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Collections.Generic; +using System.Collections.ObjectModel; +using Microsoft.AspNetCore.JsonPatch.SystemTextJson.Exceptions; +using Xunit; + +namespace Microsoft.AspNetCore.JsonPatch.SystemTextJson.IntegrationTests; + +public class ListIntegrationTest +{ + [Fact] + public void TestInList_IsSuccessful() + { + // Arrange + var targetObject = new SimpleObjectWithNestedObject() + { + SimpleObject = new SimpleObject() + { + IntegerList = new List() { 1, 2, 3 } + } + }; + + var patchDocument = new JsonPatchDocument(); + patchDocument.Test(o => o.SimpleObject.IntegerList, 3, 2); + + // Act & Assert + patchDocument.ApplyTo(targetObject); + } + + [Fact] + public void TestInList_InvalidPosition() + { + // Arrange + var targetObject = new SimpleObjectWithNestedObject() + { + SimpleObject = new SimpleObject() + { + IntegerList = new List() { 1, 2, 3 } + } + }; + + var patchDocument = new JsonPatchDocument(); + patchDocument.Test(o => o.SimpleObject.IntegerList, 4, -1); + + // Act & Assert + var exception = Assert.Throws(() => { patchDocument.ApplyTo(targetObject); }); + Assert.Equal("The index value provided by path segment '-1' is out of bounds of the array size.", + exception.Message); + } + + [Fact] + public void AddToIntegerIList() + { + // Arrange + var targetObject = new SimpleObjectWithNestedObject() + { + SimpleObject = new SimpleObject() + { + IntegerIList = new List() { 1, 2, 3 } + } + }; + + var patchDocument = new JsonPatchDocument(); + patchDocument.Add(o => (List)o.SimpleObject.IntegerIList, 4, 0); + + // Act + patchDocument.ApplyTo(targetObject); + + // Assert + Assert.Equal(new List() { 4, 1, 2, 3 }, targetObject.SimpleObject.IntegerIList); + } + + [Fact] + public void AddToComplextTypeList_SpecifyIndex() + { + // Arrange + var targetObject = new SimpleObjectWithNestedObject() + { + SimpleObjectList = new List() + { + new SimpleObject + { + StringProperty = "String1" + }, + new SimpleObject + { + StringProperty = "String2" + } + } + }; + + var patchDocument = new JsonPatchDocument(); + patchDocument.Add(o => o.SimpleObjectList[0].StringProperty, "ChangedString1"); + + // Act + patchDocument.ApplyTo(targetObject); + + // Assert + Assert.Equal("ChangedString1", targetObject.SimpleObjectList[0].StringProperty); + } + + [Fact] + public void AddToListAppend() + { + // Arrange + var targetObject = new SimpleObjectWithNestedObject() + { + SimpleObject = new SimpleObject() + { + IntegerList = new List() { 1, 2, 3 } + } + }; + + var patchDocument = new JsonPatchDocument(); + patchDocument.Add(o => o.SimpleObject.IntegerList, 4); + + // Act + patchDocument.ApplyTo(targetObject); + + // Assert + Assert.Equal(new List() { 1, 2, 3, 4 }, targetObject.SimpleObject.IntegerList); + } + + [Fact] + public void RemoveFromList() + { + // Arrange + var targetObject = new SimpleObject() + { + IntegerList = new List() { 1, 2, 3 } + }; + + var patchDocument = new JsonPatchDocument(); + patchDocument.Remove("IntegerList/2"); + + // Act + patchDocument.ApplyTo(targetObject); + + // Assert + Assert.Equal(new List() { 1, 2 }, targetObject.IntegerList); + } + + [Theory] + [InlineData("3")] + [InlineData("-1")] + public void RemoveFromList_InvalidPosition(string position) + { + // Arrange + var targetObject = new SimpleObject() + { + IntegerList = new List() { 1, 2, 3 } + }; + + var patchDocument = new JsonPatchDocument(); + patchDocument.Remove("IntegerList/" + position); + + // Act + var exception = Assert.Throws(() => + { + patchDocument.ApplyTo(targetObject); + }); + + // Assert + Assert.Equal($"The index value provided by path segment '{position}' is out of bounds of the array size.", exception.Message); + } + + [Fact] + public void Remove_FromEndOfList() + { + // Arrange + var targetObject = new SimpleObjectWithNestedObject() + { + SimpleObject = new SimpleObject() + { + IntegerList = new List() { 1, 2, 3 } + } + }; + + var patchDocument = new JsonPatchDocument(); + patchDocument.Remove(o => o.SimpleObject.IntegerList); + + // Act + patchDocument.ApplyTo(targetObject); + + // Assert + Assert.Equal(new List() { 1, 2 }, targetObject.SimpleObject.IntegerList); + } + + [Fact] + public void ReplaceFullList_WithCollection() + { + // Arrange + var targetObject = new SimpleObject() + { + IntegerList = new List() { 1, 2, 3 } + }; + + var patchDocument = new JsonPatchDocument(); + patchDocument.Replace("IntegerList", new Collection() { 4, 5, 6 }); + + // Act + patchDocument.ApplyTo(targetObject); + + // Assert + Assert.Equal(new List() { 4, 5, 6 }, targetObject.IntegerList); + } + + [Fact] + public void Replace_AtEndOfList() + { + // Arrange + var targetObject = new SimpleObjectWithNestedObject() + { + SimpleObject = new SimpleObject() + { + IntegerList = new List() { 1, 2, 3 } + } + }; + + var patchDocument = new JsonPatchDocument(); + patchDocument.Replace(o => o.SimpleObject.IntegerList, 5); + + // Act + patchDocument.ApplyTo(targetObject); + + // Assert + Assert.Equal(new List() { 1, 2, 5 }, targetObject.SimpleObject.IntegerList); + } + + [Fact] + public void Replace_InList_InvalidPosition() + { + // Arrange + var targetObject = new SimpleObjectWithNestedObject() + { + SimpleObject = new SimpleObject() + { + IntegerList = new List() { 1, 2, 3 } + } + }; + + var patchDocument = new JsonPatchDocument(); + patchDocument.Replace(o => o.SimpleObject.IntegerList, 5, -1); + + // Act + var exception = Assert.Throws(() => { patchDocument.ApplyTo(targetObject); }); + + // Assert + Assert.Equal("The index value provided by path segment '-1' is out of bounds of the array size.", exception.Message); + } + + [Fact] + public void CopyFromListToEndOfList() + { + // Arrange + var targetObject = new SimpleObject() + { + IntegerList = new List() { 1, 2, 3 } + }; + + var patchDocument = new JsonPatchDocument(); + patchDocument.Copy("IntegerList/0", "IntegerList/-"); + + // Act + patchDocument.ApplyTo(targetObject); + + // Assert + Assert.Equal(new List() { 1, 2, 3, 1 }, targetObject.IntegerList); + } + + [Fact] + public void CopyFromListToNonList() + { + // Arrange + var targetObject = new SimpleObject() + { + IntegerList = new List() { 1, 2, 3 } + }; + + var patchDocument = new JsonPatchDocument(); + patchDocument.Copy("IntegerList/0", "IntegerValue"); + + // Act + patchDocument.ApplyTo(targetObject); + + // Assert + Assert.Equal(1, targetObject.IntegerValue); + } + + [Fact] + public void MoveToEndOfList() + { + // Arrange + var targetObject = new SimpleObject() + { + IntegerValue = 5, + IntegerList = new List() { 1, 2, 3 } + }; + + var patchDocument = new JsonPatchDocument(); + patchDocument.Move("IntegerValue", "IntegerList/-"); + + // Act + patchDocument.ApplyTo(targetObject); + + // Assert + Assert.Equal(0, targetObject.IntegerValue); + Assert.Equal(new List() { 1, 2, 3, 5 }, targetObject.IntegerList); + } + + [Fact] + public void Move_KeepsObjectReferenceInList() + { + // Arrange + var simpleObject1 = new SimpleObject() { IntegerValue = 1 }; + var simpleObject2 = new SimpleObject() { IntegerValue = 2 }; + var simpleObject3 = new SimpleObject() { IntegerValue = 3 }; + var targetObject = new SimpleObjectWithNestedObject() + { + SimpleObjectList = new List() { + simpleObject1, + simpleObject2, + simpleObject3 + } + }; + + var patchDocument = new JsonPatchDocument(); + patchDocument.Move(o => o.SimpleObjectList, 0, o => o.SimpleObjectList, 1); + + // Act + patchDocument.ApplyTo(targetObject); + + // Assert + Assert.Equal(new List() { simpleObject2, simpleObject1, simpleObject3 }, targetObject.SimpleObjectList); + Assert.Equal(2, targetObject.SimpleObjectList[0].IntegerValue); + Assert.Equal(1, targetObject.SimpleObjectList[1].IntegerValue); + Assert.Same(simpleObject2, targetObject.SimpleObjectList[0]); + Assert.Same(simpleObject1, targetObject.SimpleObjectList[1]); + } + + [Fact] + public void MoveFromList_ToNonList_BetweenHierarchy() + { + // Arrange + var targetObject = new SimpleObjectWithNestedObject() + { + SimpleObject = new SimpleObject() + { + IntegerList = new List() { 1, 2, 3 } + } + }; + + var patchDocument = new JsonPatchDocument(); + patchDocument.Move(o => o.SimpleObject.IntegerList, 0, o => o.IntegerValue); + + // Act + patchDocument.ApplyTo(targetObject); + + // Assert + Assert.Equal(new List() { 2, 3 }, targetObject.SimpleObject.IntegerList); + Assert.Equal(1, targetObject.IntegerValue); + } +} diff --git a/src/Features/JsonPatch.SystemTextJson/test/IntegrationTests/NestedObjectIntegrationTest.cs b/src/Features/JsonPatch.SystemTextJson/test/IntegrationTests/NestedObjectIntegrationTest.cs new file mode 100644 index 000000000000..6a8dc03a4590 --- /dev/null +++ b/src/Features/JsonPatch.SystemTextJson/test/IntegrationTests/NestedObjectIntegrationTest.cs @@ -0,0 +1,347 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Dynamic; +using System.Text.Json; +using System.Text.Json.Serialization; +using Microsoft.AspNetCore.Shared; +using Xunit; + +namespace Microsoft.AspNetCore.JsonPatch.SystemTextJson.IntegrationTests; + +public class NestedObjectIntegrationTest +{ + [Fact] + public void Replace_DTOWithNullCheck() + { + // Arrange + var targetObject = new SimpleObjectWithNestedObjectWithNullCheck() + { + SimpleObjectWithNullCheck = new SimpleObjectWithNullCheck() + { + StringProperty = "A" + } + }; + + var patchDocument = new JsonPatchDocument(); + patchDocument.Replace(o => o.SimpleObjectWithNullCheck.StringProperty, "B"); + + // Act + patchDocument.ApplyTo(targetObject); + + // Assert + Assert.Equal("B", targetObject.SimpleObjectWithNullCheck.StringProperty); + } + + [Fact] + public void ReplaceNestedObject_WithSerialization() + { + // Arrange + var targetObject = new SimpleObjectWithNestedObject() + { + IntegerValue = 1 + }; + + var newNested = new NestedObject() { StringProperty = "B" }; + var patchDocument = new JsonPatchDocument(); + patchDocument.Replace(o => o.NestedObject, newNested); + + var serialized = JsonSerializer.Serialize(patchDocument); + var deserialized = JsonSerializer.Deserialize>(serialized); + + // Act + deserialized.ApplyTo(targetObject); + + // Assert + Assert.Equal("B", targetObject.NestedObject.StringProperty); + } + + [Fact] + public void TestStringProperty_InNestedObject() + { + // Arrange + var targetObject = new SimpleObjectWithNestedObject() + { + NestedObject = new NestedObject() { StringProperty = "A" } + }; + + var patchDocument = new JsonPatchDocument(); + patchDocument.Test(o => o.StringProperty, "A"); + + // Act + patchDocument.ApplyTo(targetObject.NestedObject); + + // Assert + Assert.Equal("A", targetObject.NestedObject.StringProperty); + } + + [Fact] + public void TestNestedObject() + { + // Arrange + var targetObject = new SimpleObjectWithNestedObject() + { + NestedObject = new NestedObject() { StringProperty = "B" } + }; + + var testNested = new NestedObject() { StringProperty = "B" }; + var patchDocument = new JsonPatchDocument(); + patchDocument.Test(o => o.NestedObject, testNested); + + // Act + patchDocument.ApplyTo(targetObject); + + // Assert + Assert.Equal("B", targetObject.NestedObject.StringProperty); + } + + [Fact] + public void AddReplaces_ExistingStringProperty() + { + // Arrange + var targetObject = new SimpleObjectWithNestedObject() + { + SimpleObject = new SimpleObject() + { + StringProperty = "A" + } + }; + + var patchDocument = new JsonPatchDocument(); + patchDocument.Add(o => o.SimpleObject.StringProperty, "B"); + + // Act + patchDocument.ApplyTo(targetObject); + + // Assert + Assert.Equal("B", targetObject.SimpleObject.StringProperty); + } + + [Fact] + public void RemoveStringProperty() + { + // Arrange + var targetObject = new SimpleObjectWithNestedObject() + { + SimpleObject = new SimpleObject() + { + StringProperty = "A" + } + }; + + var patchDocument = new JsonPatchDocument(); + patchDocument.Remove(o => o.SimpleObject.StringProperty); + + // Act + patchDocument.ApplyTo(targetObject); + + // Assert + Assert.Null(targetObject.SimpleObject.StringProperty); + } + + [Fact] + public void CopyStringProperty_ToAnotherStringProperty() + { + // Arrange + var targetObject = new SimpleObjectWithNestedObject() + { + SimpleObject = new SimpleObject() + { + StringProperty = "A", + AnotherStringProperty = "B" + } + }; + + var patchDocument = new JsonPatchDocument(); + patchDocument.Copy(o => o.SimpleObject.StringProperty, o => o.SimpleObject.AnotherStringProperty); + + // Act + patchDocument.ApplyTo(targetObject); + + // Assert + Assert.Equal("A", targetObject.SimpleObject.AnotherStringProperty); + } + + [Fact] + public void CopyNullStringProperty_ToAnotherStringProperty() + { + // Arrange + var targetObject = new SimpleObjectWithNestedObject() + { + SimpleObject = new SimpleObject() + { + StringProperty = null, + AnotherStringProperty = "B" + } + }; + + var patchDocument = new JsonPatchDocument(); + patchDocument.Copy(o => o.SimpleObject.StringProperty, o => o.SimpleObject.AnotherStringProperty); + + // Act + patchDocument.ApplyTo(targetObject); + + // Assert + Assert.Null(targetObject.SimpleObject.AnotherStringProperty); + } + + [Fact] + public void Copy_DeepClonesObject() + { + // Arrange + var targetObject = new SimpleObjectWithNestedObject() + { + SimpleObject = new SimpleObject() + { + StringProperty = "A", + AnotherStringProperty = "B" + }, + InheritedObject = new InheritedObject() + { + StringProperty = "C", + AnotherStringProperty = "D" + } + }; + + var patchDocument = new JsonPatchDocument(); + patchDocument.Copy(o => o.InheritedObject, o => o.SimpleObject); + + // Act + patchDocument.ApplyTo(targetObject); + + // Assert + Assert.Equal("C", targetObject.SimpleObject.StringProperty); + Assert.Equal("D", targetObject.SimpleObject.AnotherStringProperty); + Assert.Equal("C", targetObject.InheritedObject.StringProperty); + Assert.Equal("D", targetObject.InheritedObject.AnotherStringProperty); + Assert.NotSame(targetObject.SimpleObject.StringProperty, targetObject.InheritedObject.StringProperty); + } + + [Fact] + public void Copy_KeepsObjectType() + { + // Arrange + var targetObject = new SimpleObjectWithNestedObject() + { + SimpleObject = new SimpleObject(), + InheritedObject = new InheritedObject() + }; + + var patchDocument = new JsonPatchDocument(); + patchDocument.Copy(o => o.InheritedObject, o => o.SimpleObject); + + // Act + patchDocument.ApplyTo(targetObject); + + // Assert + Assert.Equal(typeof(InheritedObject), targetObject.SimpleObject.GetType()); + } + + [Fact] + public void Copy_BreaksObjectReference() + { + // Arrange + var targetObject = new SimpleObjectWithNestedObject() + { + SimpleObject = new SimpleObject(), + InheritedObject = new InheritedObject() + }; + + var patchDocument = new JsonPatchDocument(); + patchDocument.Copy(o => o.InheritedObject, o => o.SimpleObject); + + // Act + patchDocument.ApplyTo(targetObject); + + // Assert + Assert.NotSame(targetObject.SimpleObject, targetObject.InheritedObject); + } + + [Fact] + public void MoveIntegerValue_ToAnotherIntegerProperty() + { + // Arrange + var targetObject = new SimpleObjectWithNestedObject() + { + SimpleObject = new SimpleObject() + { + IntegerValue = 2, + AnotherIntegerValue = 3 + } + }; + + var patchDocument = new JsonPatchDocument(); + patchDocument.Move(o => o.SimpleObject.IntegerValue, o => o.SimpleObject.AnotherIntegerValue); + + // Act + patchDocument.ApplyTo(targetObject); + + // Assert + Assert.Equal(2, targetObject.SimpleObject.AnotherIntegerValue); + Assert.Equal(0, targetObject.SimpleObject.IntegerValue); + } + + [Fact] + public void Move_KeepsObjectReference() + { + // Arrange + var sDto = new SimpleObject() + { + StringProperty = "A", + AnotherStringProperty = "B" + }; + var iDto = new InheritedObject() + { + StringProperty = "C", + AnotherStringProperty = "D" + }; + var targetObject = new SimpleObjectWithNestedObject() + { + SimpleObject = sDto, + InheritedObject = iDto + }; + + var patchDocument = new JsonPatchDocument(); + patchDocument.Move(o => o.InheritedObject, o => o.SimpleObject); + + // Act + patchDocument.ApplyTo(targetObject); + + // Assert + Assert.Equal("C", targetObject.SimpleObject.StringProperty); + Assert.Equal("D", targetObject.SimpleObject.AnotherStringProperty); + Assert.Same(iDto, targetObject.SimpleObject); + Assert.Null(targetObject.InheritedObject); + } + + private class SimpleObjectWithNullCheck + { + private string stringProperty; + + public string StringProperty + { + get + { + return stringProperty; + } + + set + { + ArgumentNullThrowHelper.ThrowIfNull(value); + + stringProperty = value; + } + } + } + + private class SimpleObjectWithNestedObjectWithNullCheck + { + public SimpleObjectWithNullCheck SimpleObjectWithNullCheck { get; set; } + + public SimpleObjectWithNestedObjectWithNullCheck() + { + SimpleObjectWithNullCheck = new SimpleObjectWithNullCheck(); + } + } +} diff --git a/src/Features/JsonPatch.SystemTextJson/test/IntegrationTests/SimpleObjectIntegrationTest.cs b/src/Features/JsonPatch.SystemTextJson/test/IntegrationTests/SimpleObjectIntegrationTest.cs new file mode 100644 index 000000000000..1e44ba161746 --- /dev/null +++ b/src/Features/JsonPatch.SystemTextJson/test/IntegrationTests/SimpleObjectIntegrationTest.cs @@ -0,0 +1,174 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Dynamic; +using Microsoft.AspNetCore.JsonPatch.SystemTextJson.Exceptions; +using Xunit; + +namespace Microsoft.AspNetCore.JsonPatch.SystemTextJson.IntegrationTests; + +public class SimpleObjectIntegrationTest +{ + [Fact] + public void TestDoubleValueProperty() + { + // Arrange + var targetObject = new SimpleObject() + { + DoubleValue = 9.8 + }; + + var patchDocument = new JsonPatchDocument(); + patchDocument.Test("DoubleValue", 9.8); + + // Act & Assert + patchDocument.ApplyTo(targetObject); + } + + [Fact] + public void CopyStringProperty_ToAnotherStringProperty() + { + // Arrange + var targetObject = new SimpleObject() + { + StringProperty = "A", + AnotherStringProperty = "B" + }; + + var patchDocument = new JsonPatchDocument(); + patchDocument.Copy("StringProperty", "AnotherStringProperty"); + + // Act + patchDocument.ApplyTo(targetObject); + + // Assert + Assert.Equal("A", targetObject.AnotherStringProperty); + } + + [Fact] + public void CopyNullStringProperty_ToAnotherStringProperty() + { + // Arrange + var targetObject = new SimpleObject() + { + StringProperty = null, + AnotherStringProperty = "B" + }; + + var patchDocument = new JsonPatchDocument(); + patchDocument.Copy("StringProperty", "AnotherStringProperty"); + + // Act + patchDocument.ApplyTo(targetObject); + + // Assert + Assert.Null(targetObject.AnotherStringProperty); + } + + [Fact] + public void MoveIntegerProperty_ToAnotherIntegerProperty() + { + // Arrange + var targetObject = new SimpleObject() + { + IntegerValue = 2, + AnotherIntegerValue = 3 + }; + + var patchDocument = new JsonPatchDocument(); + patchDocument.Move("IntegerValue", "AnotherIntegerValue"); + + // Act + patchDocument.ApplyTo(targetObject); + + // Assert + Assert.Equal(2, targetObject.AnotherIntegerValue); + Assert.Equal(0, targetObject.IntegerValue); + } + + [Fact] + public void RemoveDecimalPropertyValue() + { + // Arrange + var targetObject = new SimpleObject() + { + DecimalValue = 9.8M + }; + + var patchDocument = new JsonPatchDocument(); + patchDocument.Remove("DecimalValue"); + + // Act + patchDocument.ApplyTo(targetObject); + + // Assert + Assert.Equal(0, targetObject.DecimalValue); + } + + [Fact] + public void ReplaceGuid() + { + // Arrange + var targetObject = new SimpleObject() + { + GuidValue = Guid.NewGuid() + }; + + var newGuid = Guid.NewGuid(); + var patchDocument = new JsonPatchDocument(); + patchDocument.Replace("GuidValue", newGuid); + + // Act + patchDocument.ApplyTo(targetObject); + + // Assert + Assert.Equal(newGuid, targetObject.GuidValue); + } + + [Fact] + public void AddReplacesGuid() + { + // Arrange + var targetObject = new SimpleObject() + { + GuidValue = Guid.NewGuid() + }; + + var newGuid = Guid.NewGuid(); + var patchDocument = new JsonPatchDocument(); + patchDocument.Add("GuidValue", newGuid); + + // Act + patchDocument.ApplyTo(targetObject); + + // Assert + Assert.Equal(newGuid, targetObject.GuidValue); + } + + // https://github.com/dotnet/aspnetcore/issues/3634 + [Fact] + public void Regression_AspNetCore3634() + { + // Assert + var document = new JsonPatchDocument(); + document.Move("/Object", "/Object/goodbye"); + + dynamic @object = new ExpandoObject(); + @object.hello = "world"; + + var target = new Regression_AspNetCore3634_Object(); + target.Object = @object; + + // Act + var ex = Assert.Throws(() => document.ApplyTo(target)); + + // Assert + Assert.Equal("For operation 'move', the target location specified by path '/Object/goodbye' was not found.", ex.Message); + } + + private class Regression_AspNetCore3634_Object + { + public dynamic Object { get; set; } + } +} diff --git a/src/Features/JsonPatch.SystemTextJson/test/Internal/DictionaryAdapterTest.cs b/src/Features/JsonPatch.SystemTextJson/test/Internal/DictionaryAdapterTest.cs new file mode 100644 index 000000000000..907b13bde3d0 --- /dev/null +++ b/src/Features/JsonPatch.SystemTextJson/test/Internal/DictionaryAdapterTest.cs @@ -0,0 +1,335 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Text.Json; +using Xunit; + +namespace Microsoft.AspNetCore.JsonPatch.SystemTextJson.Internal; + +public class DictionaryAdapterTest +{ + [Fact] + public void Add_KeyWhichAlreadyExists_ReplacesExistingValue() + { + // Arrange + var key = "Status"; + var dictionary = new Dictionary(StringComparer.Ordinal); + dictionary[key] = 404; + var dictionaryAdapter = new DictionaryAdapter(); + var serializerOptions = JsonSerializerOptions.Default; + + // Act + var addStatus = dictionaryAdapter.TryAdd(dictionary, key, serializerOptions, 200, out var message); + + // Assert + Assert.True(addStatus); + Assert.True(string.IsNullOrEmpty(message), "Expected no error message"); + Assert.Single(dictionary); + Assert.Equal(200, dictionary[key]); + } + + [Fact] + public void Add_IntKeyWhichAlreadyExists_ReplacesExistingValue() + { + // Arrange + var intKey = 1; + var dictionary = new Dictionary(); + dictionary[intKey] = "Mike"; + var dictionaryAdapter = new DictionaryAdapter(); + var serializerOptions = JsonSerializerOptions.Default; + + // Act + var addStatus = dictionaryAdapter.TryAdd(dictionary, intKey.ToString(CultureInfo.InvariantCulture), serializerOptions, "James", out var message); + + // Assert + Assert.True(addStatus); + Assert.True(string.IsNullOrEmpty(message), "Expected no error message"); + Assert.Single(dictionary); + Assert.Equal("James", dictionary[intKey]); + } + + [Fact] + public void GetInvalidKey_ThrowsInvalidPathSegmentException() + { + // Arrange + var dictionaryAdapter = new DictionaryAdapter(); + var serializerOptions = JsonSerializerOptions.Default; + var key = 1; + var dictionary = new Dictionary(); + + // Act + var addStatus = dictionaryAdapter.TryAdd(dictionary, key.ToString(CultureInfo.InvariantCulture), serializerOptions, "James", out var message); + + // Assert + Assert.True(addStatus); + Assert.True(string.IsNullOrEmpty(message), "Expected no error message"); + Assert.Single(dictionary); + Assert.Equal("James", dictionary[key]); + + // Act + var guidKey = new Guid(); + var getStatus = dictionaryAdapter.TryGet(dictionary, guidKey.ToString(), serializerOptions, out var outValue, out message); + + // Assert + Assert.False(getStatus); + Assert.Equal($"The provided path segment '{guidKey.ToString()}' cannot be converted to the target type.", message); + Assert.Null(outValue); + } + + [Fact] + public void Get_UsingCaseSensitiveKey_FailureScenario() + { + // Arrange + var dictionaryAdapter = new DictionaryAdapter(); + var serializerOptions = JsonSerializerOptions.Default; + var nameKey = "Name"; + var dictionary = new Dictionary(StringComparer.Ordinal); + + // Act + var addStatus = dictionaryAdapter.TryAdd(dictionary, nameKey, serializerOptions, "James", out var message); + + // Assert + Assert.True(addStatus); + Assert.True(string.IsNullOrEmpty(message), "Expected no error message"); + Assert.Single(dictionary); + Assert.Equal("James", dictionary[nameKey]); + + // Act + var getStatus = dictionaryAdapter.TryGet(dictionary, nameKey.ToUpperInvariant(), serializerOptions, out var outValue, out message); + + // Assert + Assert.False(getStatus); + Assert.Equal("The target location specified by path segment 'NAME' was not found.", message); + Assert.Null(outValue); + } + + [Fact] + public void Get_UsingCaseSensitiveKey_SuccessScenario() + { + // Arrange + var dictionaryAdapter = new DictionaryAdapter(); + var serializerOptions = JsonSerializerOptions.Default; + var nameKey = "Name"; + var dictionary = new Dictionary(StringComparer.Ordinal); + + // Act + var addStatus = dictionaryAdapter.TryAdd(dictionary, nameKey, serializerOptions, "James", out var message); + + // Assert + Assert.True(addStatus); + Assert.True(string.IsNullOrEmpty(message), "Expected no error message"); + Assert.Single(dictionary); + Assert.Equal("James", dictionary[nameKey]); + + // Act + addStatus = dictionaryAdapter.TryGet(dictionary, nameKey, serializerOptions, out var outValue, out message); + + // Assert + Assert.True(addStatus); + Assert.True(string.IsNullOrEmpty(message), "Expected no error message"); + Assert.Equal("James", outValue?.ToString()); + } + + [Fact] + public void ReplacingExistingItem() + { + // Arrange + var nameKey = "Name"; + var dictionary = new Dictionary(StringComparer.Ordinal); + dictionary.Add(nameKey, "Mike"); + var dictionaryAdapter = new DictionaryAdapter(); + var serializerOptions = JsonSerializerOptions.Default; + + // Act + var replaceStatus = dictionaryAdapter.TryReplace(dictionary, nameKey, serializerOptions, "James", out var message); + + // Assert + Assert.True(replaceStatus); + Assert.True(string.IsNullOrEmpty(message), "Expected no error message"); + Assert.Single(dictionary); + Assert.Equal("James", dictionary[nameKey]); + } + + [Fact] + public void ReplacingExistingItem_WithGuidKey() + { + // Arrange + var guidKey = new Guid(); + var dictionary = new Dictionary(); + dictionary.Add(guidKey, "Mike"); + var dictionaryAdapter = new DictionaryAdapter(); + var serializerOptions = JsonSerializerOptions.Default; + + // Act + var replaceStatus = dictionaryAdapter.TryReplace(dictionary, guidKey.ToString(), serializerOptions, "James", out var message); + + // Assert + Assert.True(replaceStatus); + Assert.True(string.IsNullOrEmpty(message), "Expected no error message"); + Assert.Single(dictionary); + Assert.Equal("James", dictionary[guidKey]); + } + + [Fact] + public void ReplacingWithInvalidValue_ThrowsInvalidValueForPropertyException() + { + // Arrange + var guidKey = new Guid(); + var dictionary = new Dictionary(); + dictionary.Add(guidKey, 5); + var dictionaryAdapter = new DictionaryAdapter(); + var serializerOptions = JsonSerializerOptions.Default; + + // Act + var replaceStatus = dictionaryAdapter.TryReplace(dictionary, guidKey.ToString(), serializerOptions, "test", out var message); + + // Assert + Assert.False(replaceStatus); + Assert.Equal("The value 'test' is invalid for target location.", message); + Assert.Equal(5, dictionary[guidKey]); + } + + [Fact] + public void Replace_NonExistingKey_Fails() + { + // Arrange + var nameKey = "Name"; + var dictionary = new Dictionary(StringComparer.Ordinal); + var dictionaryAdapter = new DictionaryAdapter(); + var serializerOptions = JsonSerializerOptions.Default; + + // Act + var replaceStatus = dictionaryAdapter.TryReplace(dictionary, nameKey, serializerOptions, "Mike", out var message); + + // Assert + Assert.False(replaceStatus); + Assert.Equal("The target location specified by path segment 'Name' was not found.", message); + Assert.Empty(dictionary); + } + + [Fact] + public void Remove_NonExistingKey_Fails() + { + // Arrange + var nameKey = "Name"; + var dictionary = new Dictionary(StringComparer.Ordinal); + var dictionaryAdapter = new DictionaryAdapter(); + var serializerOptions = JsonSerializerOptions.Default; + + // Act + var removeStatus = dictionaryAdapter.TryRemove(dictionary, nameKey, serializerOptions, out var message); + + // Assert + Assert.False(removeStatus); + Assert.Equal("The target location specified by path segment 'Name' was not found.", message); + Assert.Empty(dictionary); + } + + [Fact] + public void Replace_UsesCustomConverter() + { + // Arrange + var nameKey = "Name"; + var dictionary = new Dictionary(StringComparer.Ordinal); + dictionary.Add(nameKey, new Rectangle() + { + RectangleProperty = "Mike" + }); + var dictionaryAdapter = new DictionaryAdapter(); + var serializerOptions = new JsonSerializerOptions(JsonSerializerDefaults.Web) { PropertyNameCaseInsensitive = true }; + serializerOptions.Converters.Add(new RectangleJsonConverter()); + + // Act + var replaceStatus = dictionaryAdapter.TryReplace(dictionary, nameKey, serializerOptions, "James", out var message); + + // Assert + Assert.True(replaceStatus); + Assert.True(string.IsNullOrEmpty(message), "Expected no error message"); + Assert.Single(dictionary); + Assert.Equal("James", dictionary[nameKey].RectangleProperty); + } + + [Fact] + public void Remove_RemovesFromDictionary() + { + // Arrange + var nameKey = "Name"; + var dictionary = new Dictionary(StringComparer.Ordinal); + dictionary[nameKey] = "James"; + var dictionaryAdapter = new DictionaryAdapter(); + var serializerOptions = JsonSerializerOptions.Default; + + // Act + var removeStatus = dictionaryAdapter.TryRemove(dictionary, nameKey, serializerOptions, out var message); + + //Assert + Assert.True(removeStatus); + Assert.True(string.IsNullOrEmpty(message), "Expected no error message"); + Assert.Empty(dictionary); + } + + [Fact] + public void Remove_RemovesFromDictionary_WithUriKey() + { + // Arrange + var uriKey = new Uri("http://www.test.com/name"); + var dictionary = new Dictionary(); + dictionary[uriKey] = "James"; + var dictionaryAdapter = new DictionaryAdapter(); + var serializerOptions = JsonSerializerOptions.Default; + + // Act + var removeStatus = dictionaryAdapter.TryRemove(dictionary, uriKey.ToString(), serializerOptions, out var message); + + //Assert + Assert.True(removeStatus); + Assert.True(string.IsNullOrEmpty(message), "Expected no error message"); + Assert.Empty(dictionary); + } + + [Fact] + public void Test_DoesNotThrowException_IfTestIsSuccessful() + { + // Arrange + var key = "Name"; + var dictionary = new Dictionary>(); + var value = new List() + { + "James", + 2, + new Customer("James", 25) + }; + dictionary[key] = value; + var dictionaryAdapter = new DictionaryAdapter>(); + var serializerOptions = JsonSerializerOptions.Default; + + // Act + var testStatus = dictionaryAdapter.TryTest(dictionary, key, serializerOptions, value, out var message); + + //Assert + Assert.True(testStatus); + Assert.True(string.IsNullOrEmpty(message), "Expected no error message"); + } + + [Fact] + public void Test_ThrowsJsonPatchException_IfTestFails() + { + // Arrange + var key = "Name"; + var dictionary = new Dictionary(); + dictionary[key] = "James"; + var dictionaryAdapter = new DictionaryAdapter(); + var serializerOptions = JsonSerializerOptions.Default; + var expectedErrorMessage = "The current value 'James' at path 'Name' is not equal to the test value 'John'."; + + // Act + var testStatus = dictionaryAdapter.TryTest(dictionary, key, serializerOptions, "John", out var errorMessage); + + //Assert + Assert.False(testStatus); + Assert.Equal(expectedErrorMessage, errorMessage); + } +} diff --git a/src/Features/JsonPatch.SystemTextJson/test/Internal/ListAdapterTest.cs b/src/Features/JsonPatch.SystemTextJson/test/Internal/ListAdapterTest.cs new file mode 100644 index 000000000000..deb1b1a404a4 --- /dev/null +++ b/src/Features/JsonPatch.SystemTextJson/test/Internal/ListAdapterTest.cs @@ -0,0 +1,498 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Collections; +using System.Collections.Generic; +using System.Globalization; +using System.Text.Json; +using Xunit; + +namespace Microsoft.AspNetCore.JsonPatch.SystemTextJson.Internal; + +public class ListAdapterTest +{ + [Fact] + public void Patch_OnArrayObject_Fails() + { + // Arrange + var serializerOptions = JsonSerializerOptions.Default; + var targetObject = new[] { 20, 30 }; + var listAdapter = new ListAdapter(); + + // Act + var addStatus = listAdapter.TryAdd(targetObject, "0", serializerOptions, "40", out var message); + + // Assert + Assert.False(addStatus); + Assert.Equal($"The type '{targetObject.GetType().FullName}' which is an array is not supported for json patch operations as it has a fixed size.", message); + } + + [Fact] + public void Patch_OnNonGenericListObject_Fails() + { + // Arrange + var serializerOptions = JsonSerializerOptions.Default; + var targetObject = new ArrayList(); + targetObject.Add(20); + targetObject.Add(30); + var listAdapter = new ListAdapter(); + + // Act + var addStatus = listAdapter.TryAdd(targetObject, "-", serializerOptions, "40", out var message); + + // Assert + Assert.False(addStatus); + Assert.Equal($"The type '{targetObject.GetType().FullName}' which is a non generic list is not supported for json patch operations. Only generic list types are supported.", message); + } + + [Fact] + public void Add_WithIndexSameAsNumberOfElements_Works() + { + // Arrange + var serializerOptions = JsonSerializerOptions.Default; + var targetObject = new List() { "James", "Mike" }; + var listAdapter = new ListAdapter(); + var position = targetObject.Count.ToString(CultureInfo.InvariantCulture); + + // Act + var addStatus = listAdapter.TryAdd(targetObject, position, serializerOptions, "Rob", out var message); + + // Assert + Assert.Null(message); + Assert.True(addStatus); + Assert.Equal(3, targetObject.Count); + Assert.Equal(new List() { "James", "Mike", "Rob" }, targetObject); + } + + [Theory] + [InlineData("-1")] + [InlineData("-2")] + [InlineData("3")] + public void Add_WithOutOfBoundsIndex_Fails(string position) + { + // Arrange + var serializerOptions = JsonSerializerOptions.Default; + var targetObject = new List() { "James", "Mike" }; + var listAdapter = new ListAdapter(); + + // Act + var addStatus = listAdapter.TryAdd(targetObject, position, serializerOptions, "40", out var message); + + // Assert + Assert.False(addStatus); + Assert.Equal($"The index value provided by path segment '{position}' is out of bounds of the array size.", message); + } + + [Theory] + [InlineData("_")] + [InlineData("blah")] + public void Patch_WithInvalidPositionFormat_Fails(string position) + { + // Arrange + var serializerOptions = JsonSerializerOptions.Default; + var targetObject = new List() { "James", "Mike" }; + var listAdapter = new ListAdapter(); + + // Act + var addStatus = listAdapter.TryAdd(targetObject, position, serializerOptions, "40", out var message); + + // Assert + Assert.False(addStatus); + Assert.Equal($"The path segment '{position}' is invalid for an array index.", message); + } + + public static TheoryData, List> AppendAtEndOfListData + { + get + { + return new TheoryData, List>() + { + { + new List() { }, + new List() { 20 } + }, + { + new List() { 5, 10 }, + new List() { 5, 10, 20 } + } + }; + } + } + + [Theory] + [MemberData(nameof(AppendAtEndOfListData))] + public void Add_Appends_AtTheEnd(List targetObject, List expected) + { + // Arrange + var serializerOptions = JsonSerializerOptions.Default; + var listAdapter = new ListAdapter(); + + // Act + var addStatus = listAdapter.TryAdd(targetObject, "-", serializerOptions, 20, out var message); + + // Assert + Assert.True(addStatus); + Assert.True(string.IsNullOrEmpty(message), "Expected no error message"); + Assert.Equal(expected.Count, targetObject.Count); + Assert.Equal(expected, targetObject); + } + + [Fact] + public void Add_NullObject_ToReferenceTypeListWorks() + { + // Arrange + var serializerOptions = JsonSerializerOptions.Default; + var listAdapter = new ListAdapter(); + var targetObject = new List() { "James", "Mike" }; + + // Act + var addStatus = listAdapter.TryAdd(targetObject, "-", serializerOptions, value: null, errorMessage: out var message); + + // Assert + Assert.True(addStatus); + Assert.True(string.IsNullOrEmpty(message), "Expected no error message"); + Assert.Equal(3, targetObject.Count); + Assert.Equal(new List() { "James", "Mike", null }, targetObject); + } + + [Fact] + public void Add_CompatibleTypeWorks() + { + // Arrange + var sDto = new SimpleObject(); + var iDto = new InheritedObject(); + var serializerOptions = JsonSerializerOptions.Default; + var targetObject = new List() { sDto }; + var listAdapter = new ListAdapter(); + + // Act + var addStatus = listAdapter.TryAdd(targetObject, "-", serializerOptions, iDto, out var message); + + // Assert + Assert.True(addStatus); + Assert.True(string.IsNullOrEmpty(message), "Expected no error message"); + Assert.Equal(2, targetObject.Count); + Assert.Equal(new List() { sDto, iDto }, targetObject); + } + + [Fact] + public void Add_NonCompatibleType_Fails() + { + // Arrange + var serializerOptions = JsonSerializerOptions.Default; + var targetObject = new List() { 10, 20 }; + var listAdapter = new ListAdapter(); + + // Act + var addStatus = listAdapter.TryAdd(targetObject, "-", serializerOptions, "James", out var message); + + // Assert + Assert.False(addStatus); + Assert.Equal("The value 'James' is invalid for target location.", message); + } + + public static TheoryData AddingDifferentComplexTypeWorksData + { + get + { + return new TheoryData() + { + { + new List() { }, + "a", + "-", + new List() { "a" } + }, + { + new List() { "a", "b" }, + "c", + "-", + new List() { "a", "b", "c" } + }, + { + new List() { "a", "b" }, + "c", + "0", + new List() { "c", "a", "b" } + }, + { + new List() { "a", "b" }, + "c", + "1", + new List() { "a", "c", "b" } + } + }; + } + } + + [Theory] + [MemberData(nameof(AddingDifferentComplexTypeWorksData))] + public void Add_DifferentComplexTypeWorks(IList targetObject, object value, string position, IList expected) + { + // Arrange + var serializerOptions = JsonSerializerOptions.Default; + var listAdapter = new ListAdapter(); + + // Act + var addStatus = listAdapter.TryAdd(targetObject, position, serializerOptions, value, out var message); + + // Assert + Assert.True(addStatus); + Assert.True(string.IsNullOrEmpty(message), "Expected no error message"); + Assert.Equal(expected.Count, targetObject.Count); + Assert.Equal(expected, targetObject); + } + + public static TheoryData AddingKeepsObjectReferenceData + { + get + { + var sDto1 = new SimpleObject(); + var sDto2 = new SimpleObject(); + var sDto3 = new SimpleObject(); + return new TheoryData() + { + { + new List() { }, + sDto1, + "-", + new List() { sDto1 } + }, + { + new List() { sDto1, sDto2 }, + sDto3, + "-", + new List() { sDto1, sDto2, sDto3 } + }, + { + new List() { sDto1, sDto2 }, + sDto3, + "0", + new List() { sDto3, sDto1, sDto2 } + }, + { + new List() { sDto1, sDto2 }, + sDto3, + "1", + new List() { sDto1, sDto3, sDto2 } + } + }; + } + } + + [Theory] + [MemberData(nameof(AddingKeepsObjectReferenceData))] + public void Add_KeepsObjectReference(IList targetObject, object value, string position, IList expected) + { + // Arrange + var serializerOptions = JsonSerializerOptions.Default; + var listAdapter = new ListAdapter(); + + // Act + var addStatus = listAdapter.TryAdd(targetObject, position, serializerOptions, value, out var message); + + // Assert + Assert.True(addStatus); + Assert.True(string.IsNullOrEmpty(message), "Expected no error message"); + Assert.Equal(expected.Count, targetObject.Count); + Assert.Equal(expected, targetObject); + } + + [Theory] + [InlineData(new int[] { }, "0")] + [InlineData(new[] { 10, 20 }, "-1")] + [InlineData(new[] { 10, 20 }, "2")] + public void Get_IndexOutOfBounds(int[] input, string position) + { + // Arrange + var serializerOptions = JsonSerializerOptions.Default; + var targetObject = new List(input); + var listAdapter = new ListAdapter(); + + // Act + var getStatus = listAdapter.TryGet(targetObject, position, serializerOptions, out var value, out var message); + + // Assert + Assert.False(getStatus); + Assert.Equal($"The index value provided by path segment '{position}' is out of bounds of the array size.", message); + } + + [Theory] + [InlineData(new[] { 10, 20 }, "0", 10)] + [InlineData(new[] { 10, 20 }, "1", 20)] + [InlineData(new[] { 10 }, "0", 10)] + public void Get(int[] input, string position, object expected) + { + // Arrange + var serializerOptions = JsonSerializerOptions.Default; + var targetObject = new List(input); + var listAdapter = new ListAdapter(); + + // Act + var getStatus = listAdapter.TryGet(targetObject, position, serializerOptions, out var value, out var message); + + // Assert + Assert.True(getStatus); + Assert.Equal(expected, value); + Assert.Equal(new List(input), targetObject); + } + + [Theory] + [InlineData(new int[] { }, "0")] + [InlineData(new[] { 10, 20 }, "-1")] + [InlineData(new[] { 10, 20 }, "2")] + public void Remove_IndexOutOfBounds(int[] input, string position) + { + // Arrange + var serializerOptions = JsonSerializerOptions.Default; + var targetObject = new List(input); + var listAdapter = new ListAdapter(); + + // Act + var removeStatus = listAdapter.TryRemove(targetObject, position, serializerOptions, out var message); + + // Assert + Assert.False(removeStatus); + Assert.Equal($"The index value provided by path segment '{position}' is out of bounds of the array size.", message); + } + + [Theory] + [InlineData(new[] { 10, 20 }, "0", new[] { 20 })] + [InlineData(new[] { 10, 20 }, "1", new[] { 10 })] + [InlineData(new[] { 10 }, "0", new int[] { })] + public void Remove(int[] input, string position, int[] expected) + { + // Arrange + var serializerOptions = JsonSerializerOptions.Default; + var targetObject = new List(input); + var listAdapter = new ListAdapter(); + + // Act + var removeStatus = listAdapter.TryRemove(targetObject, position, serializerOptions, out var message); + + // Assert + Assert.True(removeStatus); + Assert.Equal(new List(expected), targetObject); + } + + [Fact] + public void Replace_NonCompatibleType_Fails() + { + // Arrange + var serializerOptions = JsonSerializerOptions.Default; + var targetObject = new List() { 10, 20 }; + var listAdapter = new ListAdapter(); + + // Act + var replaceStatus = listAdapter.TryReplace(targetObject, "-", serializerOptions, "James", out var message); + + // Assert + Assert.False(replaceStatus); + Assert.Equal("The value 'James' is invalid for target location.", message); + } + + [Fact] + public void Replace_ReplacesValue_AtTheEnd() + { + // Arrange + var serializerOptions = JsonSerializerOptions.Default; + var targetObject = new List() { 10, 20 }; + var listAdapter = new ListAdapter(); + + // Act + var replaceStatus = listAdapter.TryReplace(targetObject, "-", serializerOptions, 30, out var message); + + // Assert + Assert.True(replaceStatus); + Assert.True(string.IsNullOrEmpty(message), "Expected no error message"); + Assert.Equal(new List() { 10, 30 }, targetObject); + } + + public static TheoryData> ReplacesValuesAtPositionData + { + get + { + return new TheoryData>() + { + { + "0", + new List() { 30, 20 } + }, + { + "1", + new List() { 10, 30 } + } + }; + } + } + + [Theory] + [MemberData(nameof(ReplacesValuesAtPositionData))] + public void Replace_ReplacesValue_AtGivenPosition(string position, List expected) + { + // Arrange + var serializerOptions = JsonSerializerOptions.Default; + var targetObject = new List() { 10, 20 }; + var listAdapter = new ListAdapter(); + + // Act + var replaceStatus = listAdapter.TryReplace(targetObject, position, serializerOptions, 30, out var message); + + // Assert + Assert.True(replaceStatus); + Assert.True(string.IsNullOrEmpty(message), "Expected no error message"); + Assert.Equal(expected, targetObject); + } + + [Fact] + public void Test_DoesNotThrowException_IfTestIsSuccessful() + { + // Arrange + var serializerOptions = new JsonSerializerOptions(); + serializerOptions.NumberHandling = System.Text.Json.Serialization.JsonNumberHandling.AllowReadingFromString; + + var targetObject = new List() { 10, 20 }; + var listAdapter = new ListAdapter(); + + // Act + var testStatus = listAdapter.TryTest(targetObject, "0", serializerOptions, "10", out var message); + + //Assert + Assert.True(testStatus); + Assert.True(string.IsNullOrEmpty(message), "Expected no error message"); + } + + [Fact] + public void Test_ThrowsJsonPatchException_IfTestFails() + { + // Arrange + var serializerOptions = JsonSerializerOptions.Default; + var targetObject = new List() { 10, 20 }; + var listAdapter = new ListAdapter(); + var expectedErrorMessage = "The current value '20' at position '1' is not equal to the test value '10'."; + + // Act + var testStatus = listAdapter.TryTest(targetObject, "1", serializerOptions, 10, out var errorMessage); + + //Assert + Assert.False(testStatus); + Assert.Equal(expectedErrorMessage, errorMessage); + } + + [Fact] + public void Test_ThrowsJsonPatchException_IfListPositionOutOfBounds() + { + // Arrange + var serializerOptions = JsonSerializerOptions.Default; + var targetObject = new List() { 10, 20 }; + var listAdapter = new ListAdapter(); + var expectedErrorMessage = "The index value provided by path segment '2' is out of bounds of the array size."; + + // Act + var testStatus = listAdapter.TryTest(targetObject, "2", serializerOptions, "10", out var errorMessage); + + //Assert + Assert.False(testStatus); + Assert.Equal(expectedErrorMessage, errorMessage); + } +} diff --git a/src/Features/JsonPatch.SystemTextJson/test/Internal/ObjectVisitorTest.cs b/src/Features/JsonPatch.SystemTextJson/test/Internal/ObjectVisitorTest.cs new file mode 100644 index 000000000000..fca3d196a228 --- /dev/null +++ b/src/Features/JsonPatch.SystemTextJson/test/Internal/ObjectVisitorTest.cs @@ -0,0 +1,222 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Collections.Generic; +using System.Dynamic; +using System.Text.Json; +using Xunit; + +namespace Microsoft.AspNetCore.JsonPatch.SystemTextJson.Internal; + +public class ObjectVisitorTest +{ + private class Class1 + { + public string Name { get; set; } + public IList States { get; set; } = new List(); + public IDictionary CountriesAndRegions = new Dictionary(); + public dynamic Items { get; set; } = new ExpandoObject(); + } + + private class Class1Nested + { + public List Customers { get; set; } = new List(); + } + + public static IEnumerable ReturnsListAdapterData + { + get + { + var model = new Class1(); + yield return new object[] { model, "/States/-", model.States }; + yield return new object[] { model.States, "/-", model.States }; + + var nestedModel = new Class1Nested(); + nestedModel.Customers.Add(new Class1()); + yield return new object[] { nestedModel, "/Customers/0/States/-", nestedModel.Customers[0].States }; + yield return new object[] { nestedModel, "/Customers/0/States/0", nestedModel.Customers[0].States }; + yield return new object[] { nestedModel.Customers, "/0/States/-", nestedModel.Customers[0].States }; + yield return new object[] { nestedModel.Customers[0], "/States/-", nestedModel.Customers[0].States }; + } + } + + [Theory] + [MemberData(nameof(ReturnsListAdapterData))] + public void Visit_ValidPathToArray_ReturnsListAdapter(object targetObject, string path, object expectedTargetObject) + { + // Arrange + var visitor = new ObjectVisitor(new ParsedPath(path), JsonSerializerOptions.Default); + + // Act + var visitStatus = visitor.TryVisit(ref targetObject, out var adapter, out var message); + + // Assert + Assert.True(visitStatus); + Assert.True(string.IsNullOrEmpty(message), "Expected no error message"); + Assert.Same(expectedTargetObject, targetObject); + Assert.IsType(adapter); + } + + public static IEnumerable ReturnsDictionaryAdapterData + { + get + { + var model = new Class1(); + yield return new object[] { model, "/CountriesAndRegions/USA", model.CountriesAndRegions }; + yield return new object[] { model.CountriesAndRegions, "/USA", model.CountriesAndRegions }; + + var nestedModel = new Class1Nested(); + nestedModel.Customers.Add(new Class1()); + yield return new object[] { nestedModel, "/Customers/0/CountriesAndRegions/USA", nestedModel.Customers[0].CountriesAndRegions }; + yield return new object[] { nestedModel.Customers, "/0/CountriesAndRegions/USA", nestedModel.Customers[0].CountriesAndRegions }; + yield return new object[] { nestedModel.Customers[0], "/CountriesAndRegions/USA", nestedModel.Customers[0].CountriesAndRegions }; + } + } + + [Theory] + [MemberData(nameof(ReturnsDictionaryAdapterData))] + public void Visit_ValidPathToDictionary_ReturnsDictionaryAdapter(object targetObject, string path, object expectedTargetObject) + { + // Arrange + var options = new JsonSerializerOptions(JsonSerializerOptions.Web) { IncludeFields = true }; + var visitor = new ObjectVisitor(new ParsedPath(path), options); + + // Act + var visitStatus = visitor.TryVisit(ref targetObject, out var adapter, out var message); + + // Assert + Assert.True(visitStatus); + Assert.True(string.IsNullOrEmpty(message), "Expected no error message"); + Assert.Same(expectedTargetObject, targetObject); + Assert.Equal(typeof(DictionaryAdapter), adapter.GetType()); + } + + public static IEnumerable ReturnsExpandoAdapterData + { + get + { + var nestedModel = new Class1Nested(); + nestedModel.Customers.Add(new Class1()); + yield return new object[] { nestedModel, "/Customers/0/Items/Name", nestedModel.Customers[0].Items }; + yield return new object[] { nestedModel.Customers, "/0/Items/Name", nestedModel.Customers[0].Items }; + yield return new object[] { nestedModel.Customers[0], "/Items/Name", nestedModel.Customers[0].Items }; + } + } + + public static IEnumerable ReturnsPocoAdapterData + { + get + { + var model = new Class1(); + yield return new object[] { model, "/Name", model }; + + var nestedModel = new Class1Nested(); + nestedModel.Customers.Add(new Class1()); + yield return new object[] { nestedModel, "/Customers/0/Name", nestedModel.Customers[0] }; + yield return new object[] { nestedModel.Customers, "/0/Name", nestedModel.Customers[0] }; + yield return new object[] { nestedModel.Customers[0], "/Name", nestedModel.Customers[0] }; + } + } + + [Theory] + [MemberData(nameof(ReturnsPocoAdapterData))] + public void Visit_ValidPath_ReturnsExpandoAdapter(object targetObject, string path, object expectedTargetObject) + { + // Arrange + var visitor = new ObjectVisitor(new ParsedPath(path), JsonSerializerOptions.Default); + + // Act + var visitStatus = visitor.TryVisit(ref targetObject, out var adapter, out var message); + + // Assert + Assert.True(visitStatus); + Assert.True(string.IsNullOrEmpty(message), "Expected no error message"); + Assert.Same(expectedTargetObject, targetObject); + Assert.IsType(adapter); + } + + [Theory] + [InlineData("0")] + [InlineData("-1")] + public void Visit_InvalidIndexToArray_Fails(string position) + { + // Arrange + var visitor = new ObjectVisitor(new ParsedPath($"/Customers/{position}/States/-"), JsonSerializerOptions.Default); + var automobileDepartment = new Class1Nested(); + object targetObject = automobileDepartment; + + // Act + var visitStatus = visitor.TryVisit(ref targetObject, out var adapter, out var message); + + // Assert + Assert.False(visitStatus); + Assert.Equal($"The index value provided by path segment '{position}' is out of bounds of the array size.", message); + } + + [Theory] + [InlineData("-")] + [InlineData("foo")] + public void Visit_InvalidIndexFormatToArray_Fails(string position) + { + // Arrange + var visitor = new ObjectVisitor(new ParsedPath($"/Customers/{position}/States/-"), JsonSerializerOptions.Default); + var automobileDepartment = new Class1Nested(); + object targetObject = automobileDepartment; + + // Act + var visitStatus = visitor.TryVisit(ref targetObject, out var adapter, out var message); + + // Assert + Assert.False(visitStatus); + Assert.Equal($"The path segment '{position}' is invalid for an array index.", message); + } + + [Fact] + public void Visit_DoesNotValidate_FinalPathSegment() + { + // Arrange + var visitor = new ObjectVisitor(new ParsedPath($"/NonExisting"), JsonSerializerOptions.Default); + var model = new Class1(); + object targetObject = model; + + // Act + var visitStatus = visitor.TryVisit(ref targetObject, out var adapter, out var message); + + // Assert + Assert.True(visitStatus); + Assert.True(string.IsNullOrEmpty(message), "Expected no error message"); + Assert.IsType(adapter); + } + + [Fact] + public void Visit_NullInteriorTarget_ReturnsFalse() + { + // Arrange + var visitor = new ObjectVisitor(new ParsedPath("/States/0"), JsonSerializerOptions.Default); + + // Act + object target = new Class1() { States = null, }; + var visitStatus = visitor.TryVisit(ref target, out var adapter, out var message); + + // Assert + Assert.False(visitStatus); + Assert.Null(adapter); + Assert.Null(message); + } + + [Fact] + public void Visit_NullTarget_ReturnsNullAdapter() + { + // Arrange + var visitor = new ObjectVisitor(new ParsedPath("test"), JsonSerializerOptions.Default); + + // Act + object target = null; + var visitStatus = visitor.TryVisit(ref target, out var adapter, out var message); + + // Assert + Assert.False(visitStatus); + Assert.Null(adapter); + Assert.Null(message); + } +} diff --git a/src/Features/JsonPatch.SystemTextJson/test/Internal/ParsedPathTests.cs b/src/Features/JsonPatch.SystemTextJson/test/Internal/ParsedPathTests.cs new file mode 100644 index 000000000000..547d69ed704c --- /dev/null +++ b/src/Features/JsonPatch.SystemTextJson/test/Internal/ParsedPathTests.cs @@ -0,0 +1,41 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Microsoft.AspNetCore.JsonPatch.SystemTextJson.Exceptions; +using Xunit; + +namespace Microsoft.AspNetCore.JsonPatch.SystemTextJson.Internal; + +public class ParsedPathTests +{ + [Theory] + [InlineData("foo/bar~0baz", new string[] { "foo", "bar~baz" })] + [InlineData("foo/bar~00baz", new string[] { "foo", "bar~0baz" })] + [InlineData("foo/bar~01baz", new string[] { "foo", "bar~1baz" })] + [InlineData("foo/bar~10baz", new string[] { "foo", "bar/0baz" })] + [InlineData("foo/bar~1baz", new string[] { "foo", "bar/baz" })] + [InlineData("foo/bar~0/~0/~1~1/~0~0/baz", new string[] { "foo", "bar~", "~", "//", "~~", "baz" })] + [InlineData("~0~1foo", new string[] { "~/foo" })] + public void ParsingValidPathShouldSucceed(string path, string[] expected) + { + // Arrange & Act + var parsedPath = new ParsedPath(path); + + // Assert + Assert.Equal(expected, parsedPath.Segments); + } + + [Theory] + [InlineData("foo/bar~")] + [InlineData("~")] + [InlineData("~2")] + [InlineData("foo~3bar")] + public void PathWithInvalidEscapeSequenceShouldFail(string path) + { + // Arrange, Act & Assert + Assert.Throws(() => + { + var parsedPath = new ParsedPath(path); + }); + } +} diff --git a/src/Features/JsonPatch.SystemTextJson/test/Internal/PocoAdapterTest.cs b/src/Features/JsonPatch.SystemTextJson/test/Internal/PocoAdapterTest.cs new file mode 100644 index 000000000000..34195b4e75f9 --- /dev/null +++ b/src/Features/JsonPatch.SystemTextJson/test/Internal/PocoAdapterTest.cs @@ -0,0 +1,268 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Text.Json; +using System.Text.Json.Serialization.Metadata; +using Xunit; + +namespace Microsoft.AspNetCore.JsonPatch.SystemTextJson.Internal; + +public class PocoAdapterTest +{ + [Fact] + public void TryAdd_ReplacesExistingProperty() + { + // Arrange + var adapter = new PocoAdapter(); + var serializerOptions = JsonSerializerOptions.Default; + var model = new Customer + { + Name = "Joana" + }; + + // Act + var addStatus = adapter.TryAdd(model, "Name", serializerOptions, "John", out var errorMessage); + + // Assert + Assert.Equal("John", model.Name); + Assert.True(addStatus); + Assert.True(string.IsNullOrEmpty(errorMessage), "Expected no error message"); + } + + [Fact] + public void TryAdd_ThrowsJsonPatchException_IfPropertyDoesNotExist() + { + // Arrange + var adapter = new PocoAdapter(); + var serializerOptions = JsonSerializerOptions.Default; + var model = new Customer + { + Name = "Joana" + }; + var expectedErrorMessage = "The target location specified by path segment 'LastName' was not found."; + + // Act + var addStatus = adapter.TryAdd(model, "LastName", serializerOptions, "Smith", out var errorMessage); + + // Assert + Assert.False(addStatus); + Assert.Equal(expectedErrorMessage, errorMessage); + } + + [Fact] + public void TryGet_ExistingProperty() + { + // Arrange + var adapter = new PocoAdapter(); + var serializerOptions = JsonSerializerOptions.Default; + var model = new Customer + { + Name = "Joana" + }; + + // Act + var getStatus = adapter.TryGet(model, "Name", serializerOptions, out var value, out var errorMessage); + + // Assert + Assert.Equal("Joana", value); + Assert.True(getStatus); + Assert.True(string.IsNullOrEmpty(errorMessage), "Expected no error message"); + } + + [Fact] + public void TryGet_ThrowsJsonPatchException_IfPropertyDoesNotExist() + { + // Arrange + var adapter = new PocoAdapter(); + var serializerOptions = JsonSerializerOptions.Default; + var model = new Customer + { + Name = "Joana" + }; + var expectedErrorMessage = "The target location specified by path segment 'LastName' was not found."; + + // Act + var getStatus = adapter.TryGet(model, "LastName", serializerOptions, out var value, out var errorMessage); + + // Assert + Assert.Null(value); + Assert.False(getStatus); + Assert.Equal(expectedErrorMessage, errorMessage); + } + + [Fact] + public void TryRemove_SetsPropertyToNull() + { + // Arrange + var adapter = new PocoAdapter(); + var serializerOptions = JsonSerializerOptions.Default; + var model = new Customer + { + Name = "Joana" + }; + + // Act + var removeStatus = adapter.TryRemove(model, "Name", serializerOptions, out var errorMessage); + + // Assert + Assert.Null(model.Name); + Assert.True(removeStatus); + Assert.True(string.IsNullOrEmpty(errorMessage), "Expected no error message"); + } + + [Fact] + public void TryRemove_ThrowsJsonPatchException_IfPropertyDoesNotExist() + { + // Arrange + var adapter = new PocoAdapter(); + var serializerOptions = JsonSerializerOptions.Default; + var model = new Customer + { + Name = "Joana" + }; + var expectedErrorMessage = "The target location specified by path segment 'LastName' was not found."; + + // Act + var removeStatus = adapter.TryRemove(model, "LastName", serializerOptions, out var errorMessage); + + // Assert + Assert.False(removeStatus); + Assert.Equal(expectedErrorMessage, errorMessage); + } + + [Fact] + public void TryReplace_OverwritesExistingValue() + { + // Arrange + var adapter = new PocoAdapter(); + var serializerOptions = JsonSerializerOptions.Default; + var model = new Customer + { + Name = "Joana" + }; + + // Act + var replaceStatus = adapter.TryReplace(model, "Name", serializerOptions, "John", out var errorMessage); + + // Assert + Assert.Equal("John", model.Name); + Assert.True(replaceStatus); + Assert.True(string.IsNullOrEmpty(errorMessage), "Expected no error message"); + } + + [Fact] + public void TryReplace_ThrowsJsonPatchException_IfNewValueIsInvalidType() + { + // Arrange + var adapter = new PocoAdapter(); + var serializerOptions = JsonSerializerOptions.Default; + var model = new Customer + { + Age = 25 + }; + + var expectedErrorMessage = "The value 'TwentySix' is invalid for target location."; + + // Act + var replaceStatus = adapter.TryReplace(model, "Age", serializerOptions, "TwentySix", out var errorMessage); + + // Assert + Assert.Equal(25, model.Age); + Assert.False(replaceStatus); + Assert.Equal(expectedErrorMessage, errorMessage); + } + + [Fact] + public void TryReplace_ThrowsJsonPatchException_IfPropertyDoesNotExist() + { + // Arrange + var adapter = new PocoAdapter(); + var serializerOptions = JsonSerializerOptions.Default; + var model = new Customer + { + Name = "Joana" + }; + var expectedErrorMessage = "The target location specified by path segment 'LastName' was not found."; + + // Act + var replaceStatus = adapter.TryReplace(model, "LastName", serializerOptions, "Smith", out var errorMessage); + + // Assert + Assert.Equal("Joana", model.Name); + Assert.False(replaceStatus); + Assert.Equal(expectedErrorMessage, errorMessage); + } + + [Fact] + public void TryReplace_UsesCustomConverter() + { + // Arrange + var adapter = new PocoAdapter(); + var contractResolver = new RectangleContractResolver(); + var serializerOptions = new JsonSerializerOptions(); + serializerOptions.Converters.Add(new RectangleJsonConverter()); + serializerOptions.TypeInfoResolver = new DefaultJsonTypeInfoResolver(); + + var model = new Square() + { + Rectangle = new Rectangle() + { + RectangleProperty = "Square" + } + }; + + // Act + var replaceStatus = adapter.TryReplace(model, "Rectangle", serializerOptions, "Oval", out var errorMessage); + + // Assert + Assert.True(replaceStatus); + Assert.Equal("Oval", model.Rectangle.RectangleProperty); + Assert.Null(errorMessage); + } + + [Fact] + public void TryTest_DoesNotThrowException_IfTestSuccessful() + { + var adapter = new PocoAdapter(); + var serializerOptions = JsonSerializerOptions.Default; + var model = new Customer + { + Name = "Joana" + }; + + // Act + var testStatus = adapter.TryTest(model, "Name", serializerOptions, "Joana", out var errorMessage); + + // Assert + Assert.Equal("Joana", model.Name); + Assert.True(testStatus); + Assert.True(string.IsNullOrEmpty(errorMessage), "Expected no error message"); + } + + [Fact] + public void TryTest_ThrowsJsonPatchException_IfTestFails() + { + // Arrange + var adapter = new PocoAdapter(); + var serializerOptions = JsonSerializerOptions.Default; + var model = new Customer + { + Name = "Joana" + }; + var expectedErrorMessage = "The current value 'Joana' at path 'Name' is not equal to the test value 'John'."; + + // Act + var testStatus = adapter.TryTest(model, "Name", serializerOptions, "John", out var errorMessage); + + // Assert + Assert.False(testStatus); + Assert.Equal(expectedErrorMessage, errorMessage); + } + + private class Customer + { + public string Name { get; set; } + + public int Age { get; set; } + } +} diff --git a/src/Features/JsonPatch.SystemTextJson/test/JsonPatchDocumentGetPathTest.cs b/src/Features/JsonPatch.SystemTextJson/test/JsonPatchDocumentGetPathTest.cs new file mode 100644 index 000000000000..167ad59a7e3a --- /dev/null +++ b/src/Features/JsonPatch.SystemTextJson/test/JsonPatchDocumentGetPathTest.cs @@ -0,0 +1,121 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Collections.Generic; +using Xunit; + +namespace Microsoft.AspNetCore.JsonPatch.SystemTextJson; + +public class JsonPatchDocumentGetPathTest +{ + [Fact] + public void ExpressionType_MemberAccess() + { + // Arrange + var patchDocument = new JsonPatchDocument(); + + // Act + var path = patchDocument.GetPath(p => p.SimpleObject.IntegerList, "-"); + + // Assert + Assert.Equal("/SimpleObject/IntegerList/-", path); + } + + [Fact] + public void ExpressionType_ArrayIndex() + { + // Arrange + var patchDocument = new JsonPatchDocument(); + + // Act + var path = patchDocument.GetPath(p => p[3], null); + + // Assert + Assert.Equal("/3", path); + } + + [Fact] + public void ExpressionType_Call() + { + // Arrange + var patchDocument = new JsonPatchDocument>(); + + // Act + var path = patchDocument.GetPath(p => p["key"], "3"); + + // Assert + Assert.Equal("/key/3", path); + } + + [Fact] + public void ExpressionType_Parameter_NullPosition() + { + // Arrange + var patchDocument = new JsonPatchDocument(); + + // Act + var path = patchDocument.GetPath(p => p, null); + + // Assert + Assert.Equal("/", path); + } + + [Fact] + public void ExpressionType_Parameter_WithPosition() + { + // Arrange + var patchDocument = new JsonPatchDocument(); + + // Act + var path = patchDocument.GetPath(p => p, "-"); + + // Assert + Assert.Equal("/-", path); + } + + [Fact] + public void ExpressionType_Convert() + { + // Arrange + var patchDocument = new JsonPatchDocument(); + + // Act + var path = patchDocument.GetPath(p => (BaseClass)p.DerivedObject, null); + + // Assert + Assert.Equal("/DerivedObject", path); + } + + [Fact] + public void ExpressionType_NotSupported() + { + // Arrange + var patchDocument = new JsonPatchDocument(); + + // Act + var exception = Assert.Throws(() => + { + patchDocument.GetPath(p => p.IntegerValue >= 4, null); + }); + + // Assert + Assert.Equal("The expression '(p.IntegerValue >= 4)' is not supported. Supported expressions include member access and indexer expressions.", exception.Message); + } +} + +internal class DerivedClass : BaseClass +{ + public DerivedClass() + { + } +} + +internal class NestedObjectWithDerivedClass +{ + public DerivedClass DerivedObject { get; set; } +} + +internal class BaseClass +{ +} diff --git a/src/Features/JsonPatch.SystemTextJson/test/JsonPatchDocumentJObjectTest.cs b/src/Features/JsonPatch.SystemTextJson/test/JsonPatchDocumentJObjectTest.cs new file mode 100644 index 000000000000..2aa62546b5da --- /dev/null +++ b/src/Features/JsonPatch.SystemTextJson/test/JsonPatchDocumentJObjectTest.cs @@ -0,0 +1,174 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Text.Json; +using System.Text.Json.Nodes; +using Microsoft.AspNetCore.JsonPatch.SystemTextJson.Exceptions; +using Microsoft.AspNetCore.JsonPatch.SystemTextJson.Operations; +using Xunit; + +namespace Microsoft.AspNetCore.JsonPatch.SystemTextJson; + +public class JsonPatchDocumentJObjectTest +{ + [Fact] + public void ApplyTo_Array_Add() + { + // Arrange + var model = new ObjectWithJObject { CustomData = (JsonObject)JsonSerializer.SerializeToNode(new { Emails = new[] { "foo@bar.com" } }) }; + var patch = new JsonPatchDocument(); + + patch.Operations.Add(new Operation("add", "/CustomData/Emails/-", null, "foo@baz.com")); + + // Act + patch.ApplyTo(model); + + // Assert + Assert.Equal("foo@baz.com", model.CustomData["Emails"][1].GetValue()); + } + + [Fact] + public void ApplyTo_Model_Test1() + { + // Arrange + var model = new ObjectWithJObject { CustomData = (JsonObject)JsonSerializer.SerializeToNode(new { Email = "foo@bar.com", Name = "Bar" }) }; + var patch = new JsonPatchDocument(); + + patch.Operations.Add(new Operation("test", "/CustomData/Email", null, "foo@baz.com")); + patch.Operations.Add(new Operation("add", "/CustomData/Name", null, "Bar Baz")); + + // Act & Assert + Assert.Throws(() => patch.ApplyTo(model)); + } + + [Fact] + public void ApplyTo_Model_Test2() + { + // Arrange + var model = new ObjectWithJObject { CustomData = new JsonObject([new("Email", "foo@bar.com"), new("Name", "Bar")]) }; + var patch = new JsonPatchDocument(); + + patch.Operations.Add(new Operation("test", "/CustomData/Email", null, "foo@bar.com")); + patch.Operations.Add(new Operation("add", "/CustomData/Name", null, "Bar Baz")); + + // Act + patch.ApplyTo(model); + + // Assert + Assert.Equal("Bar Baz", model.CustomData["Name"].GetValue()); + } + + [Fact] + public void ApplyTo_Model_Copy() + { + // Arrange + var model = new ObjectWithJObject { CustomData = new JsonObject([new("Email", "foo@bar.com")]) }; + + var patch = new JsonPatchDocument(); + patch.Operations.Add(new Operation("copy", "/CustomData/UserName", "/CustomData/Email")); + + // Act + patch.ApplyTo(model); + + // Assert + Assert.Equal("foo@bar.com", model.CustomData["UserName"].GetValue()); + } + + [Fact] + public void ApplyTo_Model_Remove() + { + // Arrange + var model = new ObjectWithJObject { CustomData = new JsonObject([new("FirstName", "Bar"), new("LastName", "Bar")]) }; + var patch = new JsonPatchDocument(); + + patch.Operations.Add(new Operation("remove", "/CustomData/LastName", null)); + + // Act + patch.ApplyTo(model); + + // Assert + Assert.False(model.CustomData.ContainsKey("LastName")); + } + + [Fact] + public void ApplyTo_Model_Move() + { + // Arrange + var model = new ObjectWithJObject { CustomData = new JsonObject([new("FirstName", "Bar")]) }; + var patch = new JsonPatchDocument(); + + patch.Operations.Add(new Operation("move", "/CustomData/LastName", "/CustomData/FirstName")); + + // Act + patch.ApplyTo(model); + + // Assert + Assert.False(model.CustomData.ContainsKey("FirstName")); + Assert.Equal("Bar", model.CustomData["LastName"].GetValue()); + } + + [Fact] + public void ApplyTo_Model_Add() + { + // Arrange + var model = new ObjectWithJObject(); + var patch = new JsonPatchDocument(); + + patch.Operations.Add(new Operation("add", "/CustomData/Name", null, "Foo")); + + // Act + patch.ApplyTo(model); + + // Assert + Assert.Equal("Foo", model.CustomData["Name"].GetValue()); + } + + [Fact] + public void ApplyTo_Model_Add_Null() + { + // Arrange + var model = new ObjectWithJObject(); + var patch = new JsonPatchDocument(); + + patch.Operations.Add(new Operation("add", "/CustomData/Name", null, null)); + + // Act + patch.ApplyTo(model); + + // Assert + Assert.Contains("Name", model.CustomData); + Assert.Null(model.CustomData["Name"]); + } + + [Fact] + public void ApplyTo_Model_Replace() + { + // Arrange + var model = new ObjectWithJObject { CustomData = new JsonObject([new("Email", "foo@bar.com"), new("Name", "Bar")]) }; + var patch = new JsonPatchDocument(); + + patch.Operations.Add(new Operation("replace", "/CustomData/Email", null, "foo@baz.com")); + + // Act + patch.ApplyTo(model); + + // Assert + Assert.Equal("foo@baz.com", model.CustomData["Email"].GetValue()); + } + + [Fact] + public void ApplyTo_Model_Replace_Null() + { + // Arrange + var model = new ObjectWithJObject { CustomData = new JsonObject([new("Email", "foo@bar.com"), new("Name", "Bar")]) }; + var patch = new JsonPatchDocument(); + + patch.Operations.Add(new Operation("replace", "/CustomData/Email", null, null)); + + // Act + patch.ApplyTo(model); + + // Assert + Assert.Null(model.CustomData["Email"]); + } +} diff --git a/src/Features/JsonPatch.SystemTextJson/test/JsonPatchDocumentJsonPropertyAttributeTest.cs b/src/Features/JsonPatch.SystemTextJson/test/JsonPatchDocumentJsonPropertyAttributeTest.cs new file mode 100644 index 000000000000..9175ffe1e97a --- /dev/null +++ b/src/Features/JsonPatch.SystemTextJson/test/JsonPatchDocumentJsonPropertyAttributeTest.cs @@ -0,0 +1,84 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Linq; +using System.Text.Json.Serialization; +using Xunit; + +namespace Microsoft.AspNetCore.JsonPatch.SystemTextJson; + +public class JsonPatchDocumentJsonPropertyAttributeTest +{ + [Fact] + public void Add_RespectsJsonPropertyAttribute() + { + // Arrange + var patchDocument = new JsonPatchDocument(); + + // Act + patchDocument.Add(p => p.Name, "John"); + + // Assert + var pathToCheck = patchDocument.Operations.First().path; + Assert.Equal("/AnotherName", pathToCheck); + } + + [Fact] + public void Add_RespectsJsonPropertyAttribute_WithDotWhitespaceAndBackslashInName() + { + // Arrange + var obj = new JsonPropertyObjectWithStrangeNames(); + var patchDocument = new JsonPatchDocument(); + + // Act + patchDocument.Add("/First Name.", "John"); + patchDocument.Add("Last\\Name", "Doe"); + patchDocument.ApplyTo(obj); + + // Assert + Assert.Equal("John", obj.FirstName); + Assert.Equal("Doe", obj.LastName); + } + + [Fact] + public void Move_FallsbackToPropertyName_WhenJsonPropertyAttributeName_IsEmpty() + { + // Arrange + var patchDocument = new JsonPatchDocument(); + + // Act + patchDocument.Move(m => m.StringProperty, m => m.StringProperty2); + + // Assert + var fromPath = patchDocument.Operations.First().from; + Assert.Equal("/StringProperty", fromPath); + var toPath = patchDocument.Operations.First().path; + Assert.Equal("/StringProperty2", toPath); + } + + private class JsonPropertyObject + { + [JsonPropertyName("AnotherName")] + public string Name { get; set; } + } + + private class JsonPropertyObjectWithStrangeNames + { + [JsonPropertyName("First Name.")] + public string FirstName { get; set; } + + [JsonPropertyName("Last\\Name")] + public string LastName { get; set; } + } + + private class JsonPropertyWithNoPropertyName + { + public string StringProperty { get; set; } + + public string[] ArrayProperty { get; set; } + + public string StringProperty2 { get; set; } + + public string SSN { get; set; } + } +} diff --git a/src/Features/JsonPatch.SystemTextJson/test/JsonPatchDocumentTest.cs b/src/Features/JsonPatch.SystemTextJson/test/JsonPatchDocumentTest.cs new file mode 100644 index 000000000000..9e7c6073e036 --- /dev/null +++ b/src/Features/JsonPatch.SystemTextJson/test/JsonPatchDocumentTest.cs @@ -0,0 +1,258 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Collections.Generic; +using System.Text.Json; +using Microsoft.AspNetCore.JsonPatch.SystemTextJson.Converters; +using Microsoft.AspNetCore.JsonPatch.SystemTextJson.Exceptions; +using Microsoft.AspNetCore.JsonPatch.SystemTextJson.Operations; +using Xunit; + +namespace Microsoft.AspNetCore.JsonPatch.SystemTextJson; + +public class JsonPatchDocumentTest +{ + [Fact] + public void InvalidPathAtBeginningShouldThrowException() + { + // Arrange + var patchDocument = new JsonPatchDocument(); + + // Act + var exception = Assert.Throws(() => + { + patchDocument.Add("//NewInt", 1); + }); + + // Assert + Assert.Equal( + "The provided string '//NewInt' is an invalid path.", + exception.Message); + } + + [Fact] + public void InvalidPathAtEndShouldThrowException() + { + // Arrange + var patchDocument = new JsonPatchDocument(); + + // Act + var exception = Assert.Throws(() => + { + patchDocument.Add("NewInt//", 1); + }); + + // Assert + Assert.Equal( + "The provided string 'NewInt//' is an invalid path.", + exception.Message); + } + + [Fact] + public void NonGenericPatchDocToGenericMustSerialize() + { + // Arrange + var targetObject = new SimpleObject() + { + StringProperty = "A", + AnotherStringProperty = "B" + }; + + var patchDocument = new JsonPatchDocument(); + patchDocument.Copy("StringProperty", "AnotherStringProperty"); + + var serialized = JsonSerializer.Serialize(patchDocument); + var deserialized = JsonSerializer.Deserialize>(serialized); + + // Act + deserialized.ApplyTo(targetObject); + + // Assert + Assert.Equal("A", targetObject.AnotherStringProperty); + } + + public class Employee + { + public int EmployeeId { get; set; } + public string Name { get; set; } + } + + public class SalariedEmployee : Employee + { + public decimal AnnualSalary { get; set; } + } + + public class Organization + { + public List Employees { get; } = new(); + } + + [Fact] + public void ListWithGenericTypeWorkForSpecificChildren() + { + //Arrange + var org = new Organization(); + // Populate Employees with two employees + org.Employees.Add(new SalariedEmployee { EmployeeId = 2, Name = "Jane", AnnualSalary = 50000 }); + org.Employees.Add(new Employee { EmployeeId = 1, Name = "John" }); + + var doc = new JsonPatchDocument(); + doc.Operations.Add(new Operations.Operation("add", "/Employees/0/AnnualSalary", "", 100)); + + // Act + doc.ApplyTo(org); + + // Assert + Assert.Equal(100, (org.Employees[0] as SalariedEmployee).AnnualSalary); + } + + [Fact] + public void GenericPatchDocToNonGenericMustSerialize() + { + // Arrange + var targetObject = new SimpleObject() + { + StringProperty = "A", + AnotherStringProperty = "B" + }; + + var patchDocTyped = new JsonPatchDocument(); + patchDocTyped.Copy(o => o.StringProperty, o => o.AnotherStringProperty); + + var patchDocUntyped = new JsonPatchDocument(); + patchDocUntyped.Copy("StringProperty", "AnotherStringProperty"); + + var serializedTyped = JsonSerializer.Serialize(patchDocTyped); + var serializedUntyped = JsonSerializer.Serialize(patchDocUntyped); + var deserialized = JsonSerializer.Deserialize(serializedTyped); + + // Act + deserialized.ApplyTo(targetObject); + + // Assert + Assert.Equal("A", targetObject.AnotherStringProperty); + } + + [Fact] + public void Deserialization_Successful_ForValidJsonPatchDocument() + { + // Arrange + var doc = new SimpleObject() + { + StringProperty = "A", + DecimalValue = 10, + DoubleValue = 10, + FloatValue = 10, + IntegerValue = 10 + }; + + var patchDocument = new JsonPatchDocument(); + patchDocument.Replace(o => o.StringProperty, "B"); + patchDocument.Replace(o => o.DecimalValue, 12); + patchDocument.Replace(o => o.DoubleValue, 12); + patchDocument.Replace(o => o.FloatValue, 12); + patchDocument.Replace(o => o.IntegerValue, 12); + + // default: no envelope + var serialized = JsonSerializer.Serialize(patchDocument); + + // Act + var deserialized = JsonSerializer.Deserialize>(serialized); + + // Assert + Assert.IsType>(deserialized); + } + + [Fact] + public void Deserialization_Fails_ForInvalidJsonPatchDocument() + { + // Arrange + var serialized = "{\"Operations\": [{ \"op\": \"replace\", \"path\": \"/title\", \"value\": \"New Title\"}]}"; + + // Act + var exception = Assert.Throws(() => + { + var deserialized + = JsonSerializer.Deserialize(serialized); + }); + + // Assert + Assert.Equal("The JSON patch document was malformed and could not be parsed.", exception.Message); + } + + [Fact] + public void Deserialization_Fails_ForInvalidTypedJsonPatchDocument() + { + // Arrange + var serialized = "{\"Operations\": [{ \"op\": \"replace\", \"path\": \"/title\", \"value\": \"New Title\"}]}"; + + // Act + var exception = Assert.Throws(() => + { + var options = new JsonSerializerOptions(JsonSerializerDefaults.Web); + options.Converters.Add(new JsonConverterForJsonPatchDocumentOfT()); + var deserialized + = JsonSerializer.Deserialize>(serialized, options); + }); + + // Assert + Assert.Equal("The JSON patch document was malformed and could not be parsed.", exception.Message); + } + + [Fact] + public void Deserialization_RespectsNamingPolicy() + { + // Arrange + var childToAdd = new SimpleObject + { + GuidValue = Guid.NewGuid(), + StringProperty = "some test data" + }; + + var options = new JsonSerializerOptions(JsonSerializerDefaults.Web); + options.PropertyNamingPolicy = JsonNamingPolicy.CamelCase; + options.DefaultIgnoreCondition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingDefault; + + var json = GeneratePatchDocumentJson(childToAdd, options); + + var getTestObject = () => new SimpleObject() { SimpleObjectList = new() }; + + //Act + var docSuccess = DeserializePatchDocumentWithNamingPolicy(json, JsonNamingPolicy.CamelCase); + var docFail = DeserializePatchDocumentWithNamingPolicy(json, JsonNamingPolicy.KebabCaseLower); + + // Assert + + // The following call should succeed + docSuccess.ApplyTo(getTestObject()); + + // The following call should fail + Assert.Throws(() => + { + docFail.ApplyTo(getTestObject()); + }); + } + + private static JsonPatchDocument DeserializePatchDocumentWithNamingPolicy(string json, JsonNamingPolicy policy) + { + var compatibleSerializerOption = new JsonSerializerOptions(JsonSerializerDefaults.Web); + compatibleSerializerOption.PropertyNamingPolicy = policy; + var docSuccess = JsonSerializer.Deserialize>(json, compatibleSerializerOption); + return docSuccess; + } + + private string GeneratePatchDocumentJson(SimpleObject toAdd, JsonSerializerOptions jsonSerializerOptions) + { + var document = new JsonPatchDocument(); + var operation = new Operation + { + op = "add", + path = "/simpleObjectList/-", + value = toAdd + }; + document.Operations.Add(operation); + + return JsonSerializer.Serialize>(document, jsonSerializerOptions); + } +} diff --git a/src/Features/JsonPatch.SystemTextJson/test/Microsoft.AspNetCore.JsonPatch.SystemTextJson.Tests.csproj b/src/Features/JsonPatch.SystemTextJson/test/Microsoft.AspNetCore.JsonPatch.SystemTextJson.Tests.csproj new file mode 100644 index 000000000000..037ec1c36b54 --- /dev/null +++ b/src/Features/JsonPatch.SystemTextJson/test/Microsoft.AspNetCore.JsonPatch.SystemTextJson.Tests.csproj @@ -0,0 +1,11 @@ + + + + $(DefaultNetCoreTargetFramework) + + + + + + + diff --git a/src/Features/JsonPatch.SystemTextJson/test/OperationBaseTests.cs b/src/Features/JsonPatch.SystemTextJson/test/OperationBaseTests.cs new file mode 100644 index 000000000000..aabb27c1cb57 --- /dev/null +++ b/src/Features/JsonPatch.SystemTextJson/test/OperationBaseTests.cs @@ -0,0 +1,40 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Xunit; + +namespace Microsoft.AspNetCore.JsonPatch.SystemTextJson.Operations; + +public class OperationBaseTests +{ + [Theory] + [InlineData("ADd", OperationType.Add)] + [InlineData("Copy", OperationType.Copy)] + [InlineData("mOVE", OperationType.Move)] + [InlineData("REMOVE", OperationType.Remove)] + [InlineData("replace", OperationType.Replace)] + [InlineData("TeSt", OperationType.Test)] + public void SetValidOperationType(string op, OperationType operationType) + { + // Arrange + var operationBase = new OperationBase(); + operationBase.op = op; + + // Act & Assert + Assert.Equal(operationType, operationBase.OperationType); + } + + [Theory] + [InlineData("invalid", OperationType.Invalid)] + [InlineData("coppy", OperationType.Invalid)] + [InlineData("notvalid", OperationType.Invalid)] + public void InvalidOperationType_SetsOperationTypeInvalid(string op, OperationType operationType) + { + // Arrange + var operationBase = new OperationBase(); + operationBase.op = op; + + // Act & Assert + Assert.Equal(operationType, operationBase.OperationType); + } +} diff --git a/src/Features/JsonPatch.SystemTextJson/test/TestErrorLogger.cs b/src/Features/JsonPatch.SystemTextJson/test/TestErrorLogger.cs new file mode 100644 index 000000000000..a99e4582a97c --- /dev/null +++ b/src/Features/JsonPatch.SystemTextJson/test/TestErrorLogger.cs @@ -0,0 +1,14 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace Microsoft.AspNetCore.JsonPatch.SystemTextJson; + +public class TestErrorLogger where T : class +{ + public string ErrorMessage { get; set; } + + public void LogErrorMessage(JsonPatchError patchError) + { + ErrorMessage = patchError.ErrorMessage; + } +} diff --git a/src/Features/JsonPatch.SystemTextJson/test/TestObjectModels/Customer.cs b/src/Features/JsonPatch.SystemTextJson/test/TestObjectModels/Customer.cs new file mode 100644 index 000000000000..a5c60fe88248 --- /dev/null +++ b/src/Features/JsonPatch.SystemTextJson/test/TestObjectModels/Customer.cs @@ -0,0 +1,16 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace Microsoft.AspNetCore.JsonPatch.SystemTextJson.Internal; + +internal class Customer +{ + private string _name; + private int _age; + + public Customer(string name, int age) + { + _name = name; + _age = age; + } +} diff --git a/src/Features/JsonPatch.SystemTextJson/test/TestObjectModels/DynamicTestObject.cs b/src/Features/JsonPatch.SystemTextJson/test/TestObjectModels/DynamicTestObject.cs new file mode 100644 index 000000000000..e0382a6ed15e --- /dev/null +++ b/src/Features/JsonPatch.SystemTextJson/test/TestObjectModels/DynamicTestObject.cs @@ -0,0 +1,86 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Collections.Generic; +using System.Dynamic; + +namespace Microsoft.AspNetCore.JsonPatch.SystemTextJson; + +public class DynamicTestObject : DynamicObject +{ + private Dictionary _dictionary = new Dictionary(); + + public object this[string key] { get => ((IDictionary)_dictionary)[key]; set => ((IDictionary)_dictionary)[key] = value; } + + public ICollection Keys => ((IDictionary)_dictionary).Keys; + + public ICollection Values => ((IDictionary)_dictionary).Values; + + public int Count => ((IDictionary)_dictionary).Count; + + public bool IsReadOnly => ((IDictionary)_dictionary).IsReadOnly; + + public void Add(string key, object value) + { + ((IDictionary)_dictionary).Add(key, value); + } + + public void Add(KeyValuePair item) + { + ((IDictionary)_dictionary).Add(item); + } + + public void Clear() + { + ((IDictionary)_dictionary).Clear(); + } + + public bool Contains(KeyValuePair item) + { + return ((IDictionary)_dictionary).Contains(item); + } + + public bool ContainsKey(string key) + { + return ((IDictionary)_dictionary).ContainsKey(key); + } + + public void CopyTo(KeyValuePair[] array, int arrayIndex) + { + ((IDictionary)_dictionary).CopyTo(array, arrayIndex); + } + + public IEnumerator> GetEnumerator() + { + return ((IDictionary)_dictionary).GetEnumerator(); + } + + public bool Remove(string key) + { + return ((IDictionary)_dictionary).Remove(key); + } + + public bool Remove(KeyValuePair item) + { + return ((IDictionary)_dictionary).Remove(item); + } + + public bool TryGetValue(string key, out object value) + { + return ((IDictionary)_dictionary).TryGetValue(key, out value); + } + + public override bool TryGetMember(GetMemberBinder binder, out object result) + { + var name = binder.Name; + + return TryGetValue(name, out result); + } + + public override bool TrySetMember(SetMemberBinder binder, object value) + { + _dictionary[binder.Name] = value; + + return true; + } +} diff --git a/src/Features/JsonPatch.SystemTextJson/test/TestObjectModels/HeterogenousCollection.cs b/src/Features/JsonPatch.SystemTextJson/test/TestObjectModels/HeterogenousCollection.cs new file mode 100644 index 000000000000..3a70519ff04c --- /dev/null +++ b/src/Features/JsonPatch.SystemTextJson/test/TestObjectModels/HeterogenousCollection.cs @@ -0,0 +1,125 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Collections.Generic; +using System.Text.Json; +using System.Text.Json.Serialization; +using System.Text.Json.Serialization.Metadata; + +namespace Microsoft.AspNetCore.JsonPatch.SystemTextJson; + +public abstract class Shape +{ + public string ShapeProperty { get; set; } +} + +public class Circle : Shape +{ + public string CircleProperty { get; set; } +} + +public class Rectangle : Shape +{ + public string RectangleProperty { get; set; } +} + +public class Square : Shape +{ + public Rectangle Rectangle { get; set; } +} + +public class Canvas +{ + public IList Items { get; set; } +} + +public class RectangleContractResolver : DefaultJsonTypeInfoResolver +{ + public override JsonTypeInfo GetTypeInfo(Type type, JsonSerializerOptions options) + { + if (type == typeof(Rectangle)) + { + JsonTypeInfo jsonTypeInfo = (JsonTypeInfo)base.GetTypeInfo(type, options); + jsonTypeInfo.CreateObject = () => new Rectangle(); + + var stringComparison = options.PropertyNameCaseInsensitive ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal; + foreach (var property in jsonTypeInfo.Properties) + { + if (nameof(Rectangle.ShapeProperty).Equals(property.Name, stringComparison)) + { + property.Get = (obj) => ((Rectangle)obj).ShapeProperty; + property.Set = (obj, value) => ((Rectangle)obj).ShapeProperty = (string)value; + } + else if (nameof(Rectangle.RectangleProperty).Equals(property.Name, stringComparison)) + { + property.Get = (obj) => ((Rectangle)obj).RectangleProperty; + property.Set = (obj, value) => ((Rectangle)obj).RectangleProperty = (string)value; + } + } + + return jsonTypeInfo; + } + + return base.GetTypeInfo(type, options); + } +} + +public class RectangleJsonConverter : JsonConverter +{ + public override Rectangle Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + if (reader.TokenType == JsonTokenType.String) + { + return new Rectangle { RectangleProperty = reader.GetString() }; + } + + if (reader.TokenType != JsonTokenType.StartObject) + { + throw new JsonException(); + } + + var rectangle = new Rectangle(); + + while (reader.Read()) + { + if (reader.TokenType == JsonTokenType.EndObject) + { + return rectangle; + } + + if (reader.TokenType != JsonTokenType.PropertyName) + { + throw new JsonException(); + } + + string propertyName = reader.GetString(); + + reader.Read(); + + switch (propertyName) + { + case nameof(Rectangle.ShapeProperty): + rectangle.ShapeProperty = reader.GetString(); + break; + case nameof(Rectangle.RectangleProperty): + rectangle.RectangleProperty = reader.GetString(); + break; + default: + throw new JsonException(); + } + } + + return rectangle; + } + + public override void Write(Utf8JsonWriter writer, Rectangle value, JsonSerializerOptions options) + { + writer.WriteStartObject(); + + writer.WriteString(nameof(Rectangle.ShapeProperty), value.ShapeProperty); + writer.WriteString(nameof(Rectangle.RectangleProperty), value.RectangleProperty); + + writer.WriteEndObject(); + } +} diff --git a/src/Features/JsonPatch.SystemTextJson/test/TestObjectModels/InheritedObject.cs b/src/Features/JsonPatch.SystemTextJson/test/TestObjectModels/InheritedObject.cs new file mode 100644 index 000000000000..2516d7138445 --- /dev/null +++ b/src/Features/JsonPatch.SystemTextJson/test/TestObjectModels/InheritedObject.cs @@ -0,0 +1,9 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace Microsoft.AspNetCore.JsonPatch.SystemTextJson; + +public class InheritedObject : SimpleObject +{ + public string AdditionalStringProperty { get; set; } +} diff --git a/src/Features/JsonPatch.SystemTextJson/test/TestObjectModels/NestedObject.cs b/src/Features/JsonPatch.SystemTextJson/test/TestObjectModels/NestedObject.cs new file mode 100644 index 000000000000..e2bd24203369 --- /dev/null +++ b/src/Features/JsonPatch.SystemTextJson/test/TestObjectModels/NestedObject.cs @@ -0,0 +1,10 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace Microsoft.AspNetCore.JsonPatch.SystemTextJson; + +public class NestedObject +{ + public string StringProperty { get; set; } + public dynamic DynamicProperty { get; set; } +} diff --git a/src/Features/JsonPatch.SystemTextJson/test/TestObjectModels/ObjectWithJObject.cs b/src/Features/JsonPatch.SystemTextJson/test/TestObjectModels/ObjectWithJObject.cs new file mode 100644 index 000000000000..48c290903a53 --- /dev/null +++ b/src/Features/JsonPatch.SystemTextJson/test/TestObjectModels/ObjectWithJObject.cs @@ -0,0 +1,11 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Text.Json.Nodes; + +namespace Microsoft.AspNetCore.JsonPatch.SystemTextJson; + +public class ObjectWithJObject +{ + public JsonObject CustomData { get; set; } = new JsonObject(); +} diff --git a/src/Features/JsonPatch.SystemTextJson/test/TestObjectModels/SimpleObject.cs b/src/Features/JsonPatch.SystemTextJson/test/TestObjectModels/SimpleObject.cs new file mode 100644 index 000000000000..020ebe8699b7 --- /dev/null +++ b/src/Features/JsonPatch.SystemTextJson/test/TestObjectModels/SimpleObject.cs @@ -0,0 +1,22 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Collections.Generic; + +namespace Microsoft.AspNetCore.JsonPatch.SystemTextJson; + +public class SimpleObject +{ + public List SimpleObjectList { get; set; } + public List IntegerList { get; set; } + public IList IntegerIList { get; set; } + public int IntegerValue { get; set; } + public int AnotherIntegerValue { get; set; } + public string StringProperty { get; set; } + public string AnotherStringProperty { get; set; } + public decimal DecimalValue { get; set; } + public double DoubleValue { get; set; } + public float FloatValue { get; set; } + public Guid GuidValue { get; set; } +} diff --git a/src/Features/JsonPatch.SystemTextJson/test/TestObjectModels/SimpleObjectWithNestedObject.cs b/src/Features/JsonPatch.SystemTextJson/test/TestObjectModels/SimpleObjectWithNestedObject.cs new file mode 100644 index 000000000000..59123e7bcedc --- /dev/null +++ b/src/Features/JsonPatch.SystemTextJson/test/TestObjectModels/SimpleObjectWithNestedObject.cs @@ -0,0 +1,29 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Collections.Generic; + +namespace Microsoft.AspNetCore.JsonPatch.SystemTextJson; + +public class SimpleObjectWithNestedObject +{ + public int IntegerValue { get; set; } + + public NestedObject NestedObject { get; set; } + + public SimpleObject SimpleObject { get; set; } + + public InheritedObject InheritedObject { get; set; } + + public List SimpleObjectList { get; set; } + + public IList SimpleObjectIList { get; set; } + + public SimpleObjectWithNestedObject() + { + NestedObject = new NestedObject(); + SimpleObject = new SimpleObject(); + InheritedObject = new InheritedObject(); + SimpleObjectList = new List(); + } +} diff --git a/src/Features/JsonPatch.SystemTextJson/test/WriteOnceDynamicTestObject.cs b/src/Features/JsonPatch.SystemTextJson/test/WriteOnceDynamicTestObject.cs new file mode 100644 index 000000000000..52cf38a17bb7 --- /dev/null +++ b/src/Features/JsonPatch.SystemTextJson/test/WriteOnceDynamicTestObject.cs @@ -0,0 +1,116 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Collections.Generic; +using System.Dynamic; + +namespace Microsoft.AspNetCore.JsonPatch.SystemTextJson.Internal; + +/// +/// +/// This class is used specifically to test that JSON patch "replace" operations are functionally equivalent to +/// "add" and "remove" operations applied sequentially using the same path. +/// +/// +/// This is done by asserting that no value exists for a particular key before setting its value. To replace the +/// value for a key, the key must first be removed, and then re-added with the new value. +/// +/// +/// See https://github.com/dotnet/aspnetcore/issues/3623 for further details. +/// +/// +public class WriteOnceDynamicTestObject : DynamicObject +{ + private Dictionary _dictionary = new Dictionary(); + + public object this[string key] { get => ((IDictionary)_dictionary)[key]; set => SetValueForKey(key, value); } + + public ICollection Keys => ((IDictionary)_dictionary).Keys; + + public ICollection Values => ((IDictionary)_dictionary).Values; + + public int Count => ((IDictionary)_dictionary).Count; + + public bool IsReadOnly => ((IDictionary)_dictionary).IsReadOnly; + + public void Add(string key, object value) + { + SetValueForKey(key, value); + } + + public void Add(KeyValuePair item) + { + SetValueForKey(item.Key, item.Value); + } + + public void Clear() + { + ((IDictionary)_dictionary).Clear(); + } + + public bool Contains(KeyValuePair item) + { + return ((IDictionary)_dictionary).Contains(item); + } + + public bool ContainsKey(string key) + { + return ((IDictionary)_dictionary).ContainsKey(key); + } + + public void CopyTo(KeyValuePair[] array, int arrayIndex) + { + ((IDictionary)_dictionary).CopyTo(array, arrayIndex); + } + + public IEnumerator> GetEnumerator() + { + return ((IDictionary)_dictionary).GetEnumerator(); + } + + public bool Remove(string key) + { + return ((IDictionary)_dictionary).Remove(key); + } + + public bool Remove(KeyValuePair item) + { + return ((IDictionary)_dictionary).Remove(item); + } + + public bool TryGetValue(string key, out object value) + { + return ((IDictionary)_dictionary).TryGetValue(key, out value); + } + + public override bool TryGetMember(GetMemberBinder binder, out object result) + { + var name = binder.Name; + + return TryGetValue(name, out result); + } + + public override bool TrySetMember(SetMemberBinder binder, object value) + { + SetValueForKey(binder.Name, value); + + return true; + } + + private void SetValueForKey(string key, object value) + { + if (value == null) + { + _dictionary.Remove(key); + return; + } + + if (_dictionary.ContainsKey(key)) + { + throw new ArgumentException($"Value for {key} already exists"); + } + + _dictionary[key] = value; + } +}