From 7483c600ff845b653bb3fedb5bb371fc375e39eb Mon Sep 17 00:00:00 2001 From: Artak Mkrtchyan Date: Thu, 17 Apr 2025 09:07:30 -0700 Subject: [PATCH 1/7] Added S.T.J-serialization based JsonPatch --- AspNetCore.slnx | 4 + .../JsonPatch.SystemTextJson/.vsconfig | 12 + .../JsonPatch.SystemTextJson.slnf | 9 + .../JsonPatch.SystemTextJson/build.cmd | 4 + .../JsonPatch.SystemTextJson/build.sh | 7 + .../src/Adapters/AdapterFactory.cs | 40 + .../src/Adapters/IAdapterFactory.cs | 19 + .../src/Adapters/IObjectAdapter.cs | 111 +++ .../src/Adapters/IObjectAdapterWithTest.cs | 31 + .../src/Adapters/ObjectAdapter.cs | 280 +++++++ .../JsonConverterForJsonPatchDocumentOfT.cs | 80 ++ .../Converters/JsonPatchDocumentConverter.cs | 61 ++ .../JsonPatchDocumentConverterFactory.cs | 27 + .../Converters/OperationConverterFactory.cs | 25 + .../src/Converters/OperationConverterOfT.cs | 69 ++ .../src/Exceptions/JsonPatchException.cs | 34 + .../GenericListOrJsonArrayUtilities.cs | 105 +++ .../src/IJsonPatchDocument.cs | 15 + .../src/Internal/ConversionResult.cs | 16 + .../src/Internal/ConversionResultProvider.cs | 90 +++ .../src/Internal/DictionaryAdapterOfTU.cs | 245 ++++++ .../src/Internal/ErrorReporter.cs | 15 + .../src/Internal/IAdapter.cs | 54 ++ .../src/Internal/JsonObjectAdapter.cs | 140 ++++ .../src/Internal/ListAdapter.cs | 299 +++++++ .../src/Internal/ObjectVisitor.cs | 78 ++ .../src/Internal/ParsedPath.cs | 86 ++ .../src/Internal/PathHelpers.cs | 32 + .../src/Internal/PocoAdapter.cs | 236 ++++++ .../src/JsonPatchDocument.cs | 221 ++++++ .../src/JsonPatchDocumentOfT.cs | 746 ++++++++++++++++++ .../src/JsonPatchError.cs | 46 ++ ...AspNetCore.JsonPatch.SystemTextJson.csproj | 22 + .../src/Operations/Operation.cs | 75 ++ .../src/Operations/OperationBase.cs | 68 ++ .../src/Operations/OperationOfT.cs | 71 ++ .../src/Operations/OperationType.cs | 15 + .../JsonPatch.SystemTextJson/src/PACKAGE.md | 58 ++ .../src/PublicAPI.Shipped.txt | 1 + .../src/PublicAPI.Unshipped.txt | 116 +++ .../src/Resources.resx | 186 +++++ .../JsonPatch.SystemTextJson/startvs.cmd | 3 + .../test/Adapters/AdapterFactoryTests.cs | 55 ++ .../test/Adapters/TestDynamicObject.cs | 9 + .../test/CustomNamingStrategyTests.cs | 46 ++ .../AnonymousObjectIntegrationTest.cs | 165 ++++ .../DictionaryIntegrationTest.cs | 318 ++++++++ .../HeterogenousCollectionTests.cs | 123 +++ .../IntegrationTests/ListIntegrationTest.cs | 365 +++++++++ .../NestedObjectIntegrationTest.cs | 347 ++++++++ .../SimpleObjectIntegrationTest.cs | 174 ++++ .../test/Internal/DictionaryAdapterTest.cs | 335 ++++++++ .../test/Internal/ListAdapterTest.cs | 498 ++++++++++++ .../test/Internal/ObjectVisitorTest.cs | 222 ++++++ .../test/Internal/ParsedPathTests.cs | 41 + .../test/Internal/PocoAdapterTest.cs | 268 +++++++ .../test/JsonPatchDocumentGetPathTest.cs | 121 +++ .../test/JsonPatchDocumentJObjectTest.cs | 174 ++++ ...nPatchDocumentJsonPropertyAttributeTest.cs | 84 ++ .../test/JsonPatchDocumentTest.cs | 200 +++++ ...Core.JsonPatch.SystemTextJson.Tests.csproj | 11 + .../test/OperationBaseTests.cs | 40 + .../test/TestErrorLogger.cs | 14 + .../test/TestObjectModels/Customer.cs | 16 + .../TestObjectModels/DynamicTestObject.cs | 86 ++ .../HeterogenousCollection.cs | 125 +++ .../test/TestObjectModels/InheritedObject.cs | 9 + .../test/TestObjectModels/NestedObject.cs | 10 + .../TestObjectModels/ObjectWithJObject.cs | 11 + .../test/TestObjectModels/SimpleObject.cs | 22 + .../SimpleObjectWithNestedObject.cs | 29 + .../test/WriteOnceDynamicTestObject.cs | 116 +++ 72 files changed, 7886 insertions(+) create mode 100644 src/Features/JsonPatch.SystemTextJson/.vsconfig create mode 100644 src/Features/JsonPatch.SystemTextJson/JsonPatch.SystemTextJson.slnf create mode 100644 src/Features/JsonPatch.SystemTextJson/build.cmd create mode 100644 src/Features/JsonPatch.SystemTextJson/build.sh create mode 100644 src/Features/JsonPatch.SystemTextJson/src/Adapters/AdapterFactory.cs create mode 100644 src/Features/JsonPatch.SystemTextJson/src/Adapters/IAdapterFactory.cs create mode 100644 src/Features/JsonPatch.SystemTextJson/src/Adapters/IObjectAdapter.cs create mode 100644 src/Features/JsonPatch.SystemTextJson/src/Adapters/IObjectAdapterWithTest.cs create mode 100644 src/Features/JsonPatch.SystemTextJson/src/Adapters/ObjectAdapter.cs create mode 100644 src/Features/JsonPatch.SystemTextJson/src/Converters/JsonConverterForJsonPatchDocumentOfT.cs create mode 100644 src/Features/JsonPatch.SystemTextJson/src/Converters/JsonPatchDocumentConverter.cs create mode 100644 src/Features/JsonPatch.SystemTextJson/src/Converters/JsonPatchDocumentConverterFactory.cs create mode 100644 src/Features/JsonPatch.SystemTextJson/src/Converters/OperationConverterFactory.cs create mode 100644 src/Features/JsonPatch.SystemTextJson/src/Converters/OperationConverterOfT.cs create mode 100644 src/Features/JsonPatch.SystemTextJson/src/Exceptions/JsonPatchException.cs create mode 100644 src/Features/JsonPatch.SystemTextJson/src/Helpers/GenericListOrJsonArrayUtilities.cs create mode 100644 src/Features/JsonPatch.SystemTextJson/src/IJsonPatchDocument.cs create mode 100644 src/Features/JsonPatch.SystemTextJson/src/Internal/ConversionResult.cs create mode 100644 src/Features/JsonPatch.SystemTextJson/src/Internal/ConversionResultProvider.cs create mode 100644 src/Features/JsonPatch.SystemTextJson/src/Internal/DictionaryAdapterOfTU.cs create mode 100644 src/Features/JsonPatch.SystemTextJson/src/Internal/ErrorReporter.cs create mode 100644 src/Features/JsonPatch.SystemTextJson/src/Internal/IAdapter.cs create mode 100644 src/Features/JsonPatch.SystemTextJson/src/Internal/JsonObjectAdapter.cs create mode 100644 src/Features/JsonPatch.SystemTextJson/src/Internal/ListAdapter.cs create mode 100644 src/Features/JsonPatch.SystemTextJson/src/Internal/ObjectVisitor.cs create mode 100644 src/Features/JsonPatch.SystemTextJson/src/Internal/ParsedPath.cs create mode 100644 src/Features/JsonPatch.SystemTextJson/src/Internal/PathHelpers.cs create mode 100644 src/Features/JsonPatch.SystemTextJson/src/Internal/PocoAdapter.cs create mode 100644 src/Features/JsonPatch.SystemTextJson/src/JsonPatchDocument.cs create mode 100644 src/Features/JsonPatch.SystemTextJson/src/JsonPatchDocumentOfT.cs create mode 100644 src/Features/JsonPatch.SystemTextJson/src/JsonPatchError.cs create mode 100644 src/Features/JsonPatch.SystemTextJson/src/Microsoft.AspNetCore.JsonPatch.SystemTextJson.csproj create mode 100644 src/Features/JsonPatch.SystemTextJson/src/Operations/Operation.cs create mode 100644 src/Features/JsonPatch.SystemTextJson/src/Operations/OperationBase.cs create mode 100644 src/Features/JsonPatch.SystemTextJson/src/Operations/OperationOfT.cs create mode 100644 src/Features/JsonPatch.SystemTextJson/src/Operations/OperationType.cs create mode 100644 src/Features/JsonPatch.SystemTextJson/src/PACKAGE.md create mode 100644 src/Features/JsonPatch.SystemTextJson/src/PublicAPI.Shipped.txt create mode 100644 src/Features/JsonPatch.SystemTextJson/src/PublicAPI.Unshipped.txt create mode 100644 src/Features/JsonPatch.SystemTextJson/src/Resources.resx create mode 100644 src/Features/JsonPatch.SystemTextJson/startvs.cmd create mode 100644 src/Features/JsonPatch.SystemTextJson/test/Adapters/AdapterFactoryTests.cs create mode 100644 src/Features/JsonPatch.SystemTextJson/test/Adapters/TestDynamicObject.cs create mode 100644 src/Features/JsonPatch.SystemTextJson/test/CustomNamingStrategyTests.cs create mode 100644 src/Features/JsonPatch.SystemTextJson/test/IntegrationTests/AnonymousObjectIntegrationTest.cs create mode 100644 src/Features/JsonPatch.SystemTextJson/test/IntegrationTests/DictionaryIntegrationTest.cs create mode 100644 src/Features/JsonPatch.SystemTextJson/test/IntegrationTests/HeterogenousCollectionTests.cs create mode 100644 src/Features/JsonPatch.SystemTextJson/test/IntegrationTests/ListIntegrationTest.cs create mode 100644 src/Features/JsonPatch.SystemTextJson/test/IntegrationTests/NestedObjectIntegrationTest.cs create mode 100644 src/Features/JsonPatch.SystemTextJson/test/IntegrationTests/SimpleObjectIntegrationTest.cs create mode 100644 src/Features/JsonPatch.SystemTextJson/test/Internal/DictionaryAdapterTest.cs create mode 100644 src/Features/JsonPatch.SystemTextJson/test/Internal/ListAdapterTest.cs create mode 100644 src/Features/JsonPatch.SystemTextJson/test/Internal/ObjectVisitorTest.cs create mode 100644 src/Features/JsonPatch.SystemTextJson/test/Internal/ParsedPathTests.cs create mode 100644 src/Features/JsonPatch.SystemTextJson/test/Internal/PocoAdapterTest.cs create mode 100644 src/Features/JsonPatch.SystemTextJson/test/JsonPatchDocumentGetPathTest.cs create mode 100644 src/Features/JsonPatch.SystemTextJson/test/JsonPatchDocumentJObjectTest.cs create mode 100644 src/Features/JsonPatch.SystemTextJson/test/JsonPatchDocumentJsonPropertyAttributeTest.cs create mode 100644 src/Features/JsonPatch.SystemTextJson/test/JsonPatchDocumentTest.cs create mode 100644 src/Features/JsonPatch.SystemTextJson/test/Microsoft.AspNetCore.JsonPatch.SystemTextJson.Tests.csproj create mode 100644 src/Features/JsonPatch.SystemTextJson/test/OperationBaseTests.cs create mode 100644 src/Features/JsonPatch.SystemTextJson/test/TestErrorLogger.cs create mode 100644 src/Features/JsonPatch.SystemTextJson/test/TestObjectModels/Customer.cs create mode 100644 src/Features/JsonPatch.SystemTextJson/test/TestObjectModels/DynamicTestObject.cs create mode 100644 src/Features/JsonPatch.SystemTextJson/test/TestObjectModels/HeterogenousCollection.cs create mode 100644 src/Features/JsonPatch.SystemTextJson/test/TestObjectModels/InheritedObject.cs create mode 100644 src/Features/JsonPatch.SystemTextJson/test/TestObjectModels/NestedObject.cs create mode 100644 src/Features/JsonPatch.SystemTextJson/test/TestObjectModels/ObjectWithJObject.cs create mode 100644 src/Features/JsonPatch.SystemTextJson/test/TestObjectModels/SimpleObject.cs create mode 100644 src/Features/JsonPatch.SystemTextJson/test/TestObjectModels/SimpleObjectWithNestedObject.cs create mode 100644 src/Features/JsonPatch.SystemTextJson/test/WriteOnceDynamicTestObject.cs 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..622a15da4b45 --- /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. +/// +public 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..49514dab532e --- /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()); + 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..6b6df21dab4e --- /dev/null +++ b/src/Features/JsonPatch.SystemTextJson/src/Converters/JsonConverterForJsonPatchDocumentOfT.cs @@ -0,0 +1,80 @@ +// 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 +{ + public override bool CanConvert(Type typeToConvert) + { + var result = base.CanConvert(typeToConvert); + return result; + } + + public override JsonPatchDocument Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + // Parse root object + try + { + using var document = JsonDocument.ParseValue(ref reader); + var operationsElement = document.RootElement; + if (operationsElement.ValueKind != JsonValueKind.Array) + { + throw new JsonException("Expected 'Operations' array property in JsonPatchDocument."); + } + + // Clone options with Operation converter + var effectiveOptions = CloneWithOperationConverter(options); + + // Deserialize the operations array + var operations = JsonSerializer.Deserialize>>(operationsElement.GetRawText(), effectiveOptions); + + return new JsonPatchDocument(operations, options); + + } + catch (Exception ex) + { + throw new JsonException(Resources.InvalidJsonPatchDocument, ex); + } + } + + public override void Write(Utf8JsonWriter writer, JsonPatchDocument value, JsonSerializerOptions options) + { + JsonSerializer.Serialize(writer, value.Operations, CloneWithOperationConverter(options)); + } + + private static JsonSerializerOptions CloneWithOperationConverter(JsonSerializerOptions baseOptions) + { + var options = baseOptions; + + var converterRegistered = IsOperationConverterRegistered(options); + if (!converterRegistered) + { + options = new JsonSerializerOptions(baseOptions); + options.Converters.Add(new OperationConverterFactory()); + } + + return options; + } + + private static bool IsOperationConverterRegistered(JsonSerializerOptions options) + { + for (var i = 0; i < options.Converters.Count; i++) + { + var converter = options.Converters[i]; + if (converter is OperationConverterFactory || converter.GetType().IsGenericType && converter.GetType().GetGenericTypeDefinition() == typeof(OperationConverter<>)) + { + return true; + } + } + + return false; + } +} 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..ec54e97e8bc3 --- /dev/null +++ b/src/Features/JsonPatch.SystemTextJson/src/Converters/JsonPatchDocumentConverter.cs @@ -0,0 +1,61 @@ +// 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 Microsoft.AspNetCore.JsonPatch.SystemTextJson.Operations; + +namespace Microsoft.AspNetCore.JsonPatch.SystemTextJson.Converters; + +internal class JsonPatchDocumentConverter : JsonConverter +{ + internal static JsonSerializerOptions DefaultSerializerOptions { get; } = JsonSerializerOptions.Default; + + public override JsonPatchDocument Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + if (typeToConvert != typeof(JsonPatchDocument)) + { + throw new ArgumentException(Resources.FormatParameterMustMatchType(nameof(typeToConvert), "JsonPatchDocument"), nameof(typeToConvert)); + } + + try + { + if (reader.TokenType == JsonTokenType.Null) + { + return null; + } + + var operations = new List(); + + JsonNode node = JsonArray.Parse(ref reader, new JsonNodeOptions { PropertyNameCaseInsensitive = options.PropertyNameCaseInsensitive }); + JsonArray operationsArray = node.AsArray(); + foreach (var item in operationsArray) + { + operations.Add(item.Deserialize(options)); + } + + // container target: the JsonPatchDocument. + var container = new JsonPatchDocument(operations, DefaultSerializerOptions); + + return container; + } + catch (Exception ex) + { + throw new JsonException(Resources.InvalidJsonPatchDocument, ex); + } + } + + public override void Write(Utf8JsonWriter writer, JsonPatchDocument value, JsonSerializerOptions options) + { + writer.WriteStartArray(); + foreach (var operation in value.Operations) + { + JsonSerializer.Serialize(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/Converters/OperationConverterFactory.cs b/src/Features/JsonPatch.SystemTextJson/src/Converters/OperationConverterFactory.cs new file mode 100644 index 000000000000..b5f7ffc0a424 --- /dev/null +++ b/src/Features/JsonPatch.SystemTextJson/src/Converters/OperationConverterFactory.cs @@ -0,0 +1,25 @@ +// 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; +using Microsoft.AspNetCore.JsonPatch.SystemTextJson.Operations; + +namespace Microsoft.AspNetCore.JsonPatch.SystemTextJson.Converters; + +internal class OperationConverterFactory : JsonConverterFactory +{ + public override bool CanConvert(Type typeToConvert) + { + return typeToConvert.IsGenericType && + typeToConvert.GetGenericTypeDefinition() == typeof(Operation<>); + } + + public override JsonConverter CreateConverter(Type type, JsonSerializerOptions options) + { + var elementType = type.GetGenericArguments()[0]; + var converterType = typeof(OperationConverter<>).MakeGenericType(elementType); + return (JsonConverter)Activator.CreateInstance(converterType)!; + } +} diff --git a/src/Features/JsonPatch.SystemTextJson/src/Converters/OperationConverterOfT.cs b/src/Features/JsonPatch.SystemTextJson/src/Converters/OperationConverterOfT.cs new file mode 100644 index 000000000000..d50cd654490d --- /dev/null +++ b/src/Features/JsonPatch.SystemTextJson/src/Converters/OperationConverterOfT.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.Text.Json; +using System.Text.Json.Serialization; +using Microsoft.AspNetCore.JsonPatch.SystemTextJson.Operations; + +namespace Microsoft.AspNetCore.JsonPatch.SystemTextJson.Converters; + +internal class OperationConverter : JsonConverter> where T : class +{ + public override bool CanConvert(Type typeToConvert) + { + var result = base.CanConvert(typeToConvert); + return result; + } + + public override Operation Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + using var doc = JsonDocument.ParseValue(ref reader); + var root = doc.RootElement; + + var op = root.GetProperty("op").GetString(); + var path = root.GetProperty("path").GetString(); + + string from = null; + if (root.TryGetProperty("from", out var fromProp)) + { + from = fromProp.GetString(); + } + + object value = null; + if (root.TryGetProperty("value", out var valueProp)) + { + // Deserialize "value" into object using System.Text.Json – you might deserialize to T here if you prefer + value = valueProp.Deserialize(options); + } + + return new Operation + { + op = op, + path = path, + from = from, + value = value + }; + } + + public override void Write(Utf8JsonWriter writer, Operation value, JsonSerializerOptions options) + { + writer.WriteStartObject(); + + writer.WriteString("op", value.op); + writer.WriteString("path", value.path); + + if (value.from != null) + { + writer.WriteString("from", value.from); + } + + if (value.value != null) + { + writer.WritePropertyName("value"); + JsonSerializer.Serialize(writer, value.value, options); + } + + writer.WriteEndObject(); + } +} 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/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..f12df6aacb3b --- /dev/null +++ b/src/Features/JsonPatch.SystemTextJson/src/Internal/ConversionResultProvider.cs @@ -0,0 +1,90 @@ +// 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.Internal; + +internal static class ConversionResultProvider +{ + public static ConversionResult ConvertTo(object value, Type typeToConvertTo) + { + return ConvertTo(value, typeToConvertTo, null); + } + + 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); + } + + // Workaround for the https://github.com/dotnet/runtime/issues/113926 + if (typeToConvertTo.Name == "JsonValuePrimitive`1") + { + typeToConvertTo = typeof(JsonNode); + } + + try + { + var serializedDocument = JsonSerializer.Serialize(value, serializerOptions); + var deserialized = JsonSerializer.Deserialize(serializedDocument, typeToConvertTo, serializerOptions); + return new ConversionResult(true, deserialized); + } + catch + { + return new ConversionResult(canBeConverted: false, convertedInstance: null); + } + } + + public static ConversionResult CopyTo(object value, Type typeToConvertTo) + { + 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(); + } + + // Workaround for the https://github.com/dotnet/runtime/issues/113926 + if (targetType.Name == "JsonValuePrimitive`1") + { + targetType = typeof(JsonNode); + } + + try + { + var deserialized = JsonSerializer.Deserialize(JsonSerializer.Serialize(value), targetType); + 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; + } +} 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..86d5893480d9 --- /dev/null +++ b/src/Features/JsonPatch.SystemTextJson/src/Internal/DictionaryAdapterOfTU.cs @@ -0,0 +1,245 @@ +// 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 System.Text.Json.Serialization.Metadata; + +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 = ExtractKeyFromSegment(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 = ExtractKeyFromSegment(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 = ExtractKeyFromSegment(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 = ExtractKeyFromSegment(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 = ExtractKeyFromSegment(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 (!JsonObject.DeepEquals(JsonSerializer.SerializeToNode(currentValue), JsonSerializer.SerializeToNode(convertedValue))) + { + 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 = ExtractKeyFromSegment(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 string ExtractKeyFromSegment(string segment) + { + return segment.ToString(); + } + + protected virtual 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; + } + } + + protected virtual bool TryConvertValue(object value, out TValue convertedValue, out string errorMessage) + { + return TryConvertValue(value, null, out convertedValue, out errorMessage); + } + + protected virtual 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..fdf154a499aa --- /dev/null +++ b/src/Features/JsonPatch.SystemTextJson/src/Internal/IAdapter.cs @@ -0,0 +1,54 @@ +// 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; + +/// +/// This API supports infrastructure and is not intended to be used +/// directly from your code. This API may change or be removed in future releases. +/// +public 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..04304a92838a --- /dev/null +++ b/src/Features/JsonPatch.SystemTextJson/src/Internal/JsonObjectAdapter.cs @@ -0,0 +1,140 @@ +// 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; + +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; + + if (!obj.ContainsKey(segment)) + { + errorMessage = Resources.FormatTargetLocationAtPathSegmentNotFound(segment); + return false; + } + + obj[segment] = value != null ? JsonSerializer.SerializeToNode(value) : 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 (!JsonObject.DeepEquals(JsonSerializer.SerializeToNode(currentValue, serializerOptions), JsonSerializer.SerializeToNode(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..89016c5e1cb4 --- /dev/null +++ b/src/Features/JsonPatch.SystemTextJson/src/Internal/ListAdapter.cs @@ -0,0 +1,299 @@ +// 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 System.Text.Json.Nodes; +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 (!JsonObject.DeepEquals(JsonSerializer.SerializeToNode(currentValue), JsonSerializer.SerializeToNode(convertedValue))) + { + 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; + } + } + + /// + /// This API supports infrastructure and is not intended to be used + /// directly from your code. This API may change or be removed in future releases. + /// + protected readonly struct PositionInfo + { + public PositionInfo(PositionType type, int index) + { + Type = type; + Index = index; + } + + public PositionType Type { get; } + public int Index { get; } + } + + /// + /// This API supports infrastructure and is not intended to be used + /// directly from your code. This API may change or be removed in future releases. + /// + protected enum PositionType + { + Index, // valid index + EndOfList, // '-' + Invalid, // Ex: not an integer + OutOfBounds + } + + /// + /// This API supports infrastructure and is not intended to be used + /// directly from your code. This API may change or be removed in future releases. + /// + 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..0c50d23bec2e --- /dev/null +++ b/src/Features/JsonPatch.SystemTextJson/src/Internal/PocoAdapter.cs @@ -0,0 +1,236 @@ +// 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; + +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 (!JsonObject.DeepEquals(JsonSerializer.SerializeToNode(currentValue), JsonSerializer.SerializeToNode(convertedValue))) + { + 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..79c793b10267 --- /dev/null +++ b/src/Features/JsonPatch.SystemTextJson/src/JsonPatchDocumentOfT.cs @@ -0,0 +1,746 @@ +// 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 jsonObjectContract = SerializerOptions.GetTypeInfo(memberExpression.Expression.Type); + if (jsonObjectContract != null) + { + var jsonName = memberExpression.Member.Name; + var propNameAttribute = memberExpression.Member.GetCustomAttribute(); + if (propNameAttribute is not null) + { + jsonName = propNameAttribute.Name; + } + + return jsonObjectContract.Properties + .First(jsonProperty => jsonProperty.Name == jsonName) + .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..655bde69d86c --- /dev/null +++ b/src/Features/JsonPatch.SystemTextJson/src/Operations/Operation.cs @@ -0,0 +1,75 @@ +// 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; + } + } + + public bool ShouldSerializevalue() + { + return (OperationType == OperationType.Add + || OperationType == OperationType.Replace + || OperationType == OperationType.Test); + } +} 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..282f2271337b --- /dev/null +++ b/src/Features/JsonPatch.SystemTextJson/src/PublicAPI.Unshipped.txt @@ -0,0 +1,116 @@ +#nullable enable +Microsoft.AspNetCore.JsonPatch.SystemTextJson.Adapters.IAdapterFactory +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.Internal.IAdapter +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.ShouldSerializevalue() -> bool +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.IAdapterFactory.Create(object target) -> Microsoft.AspNetCore.JsonPatch.SystemTextJson.Internal.IAdapter +~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.Internal.IAdapter.TryAdd(object target, string segment, System.Text.Json.JsonSerializerOptions serializerOptions, object value, out string errorMessage) -> bool +~Microsoft.AspNetCore.JsonPatch.SystemTextJson.Internal.IAdapter.TryGet(object target, string segment, System.Text.Json.JsonSerializerOptions serializerOptions, out object value, out string errorMessage) -> bool +~Microsoft.AspNetCore.JsonPatch.SystemTextJson.Internal.IAdapter.TryRemove(object target, string segment, System.Text.Json.JsonSerializerOptions serializerOptions, out string errorMessage) -> bool +~Microsoft.AspNetCore.JsonPatch.SystemTextJson.Internal.IAdapter.TryReplace(object target, string segment, System.Text.Json.JsonSerializerOptions serializerOptions, object value, out string errorMessage) -> bool +~Microsoft.AspNetCore.JsonPatch.SystemTextJson.Internal.IAdapter.TryTest(object target, string segment, System.Text.Json.JsonSerializerOptions serializerOptions, object value, out string errorMessage) -> bool +~Microsoft.AspNetCore.JsonPatch.SystemTextJson.Internal.IAdapter.TryTraverse(object target, string segment, System.Text.Json.JsonSerializerOptions serializerOptions, out object nextTarget, out string errorMessage) -> bool +~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..4486d6bee8f8 --- /dev/null +++ b/src/Features/JsonPatch.SystemTextJson/startvs.cmd @@ -0,0 +1,3 @@ +@ECHO OFF + +%~dp0..\..\..\startvs.cmd %~dp0JsonPatch.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..3b279f3a127c --- /dev/null +++ b/src/Features/JsonPatch.SystemTextJson/test/JsonPatchDocumentTest.cs @@ -0,0 +1,200 @@ +// 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.Converters; +using Microsoft.AspNetCore.JsonPatch.SystemTextJson.Exceptions; +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); + } +} 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..65d39becaa7a --- /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 JsonPatch#110 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; + } +} From 0dcb4a6fa82c039a48d74cc55e333b01ed444a60 Mon Sep 17 00:00:00 2001 From: Artak Mkrtchyan Date: Thu, 17 Apr 2025 16:03:50 -0700 Subject: [PATCH 2/7] Cleanup + a few bugfixes --- .../src/Adapters/ObjectAdapter.cs | 2 +- .../src/Helpers/JsonUtilities.cs | 17 +++++++++++ .../src/Internal/ConversionResultProvider.cs | 17 +++++------ .../src/Internal/DictionaryAdapterOfTU.cs | 30 +++++++------------ .../src/Internal/JsonObjectAdapter.cs | 3 +- .../src/Internal/ListAdapter.cs | 2 +- .../src/Internal/PocoAdapter.cs | 3 +- 7 files changed, 41 insertions(+), 33 deletions(-) create mode 100644 src/Features/JsonPatch.SystemTextJson/src/Helpers/JsonUtilities.cs diff --git a/src/Features/JsonPatch.SystemTextJson/src/Adapters/ObjectAdapter.cs b/src/Features/JsonPatch.SystemTextJson/src/Adapters/ObjectAdapter.cs index 49514dab532e..708dd61dffeb 100644 --- a/src/Features/JsonPatch.SystemTextJson/src/Adapters/ObjectAdapter.cs +++ b/src/Features/JsonPatch.SystemTextJson/src/Adapters/ObjectAdapter.cs @@ -183,7 +183,7 @@ public void Copy(Operation operation, object objectToApplyTo) if (TryGetValue(operation.from, objectToApplyTo, operation, out var propertyValue)) { // Create deep copy - var copyResult = ConversionResultProvider.CopyTo(propertyValue, propertyValue?.GetType()); + var copyResult = ConversionResultProvider.CopyTo(propertyValue, propertyValue?.GetType(), SerializerOptions); if (copyResult.CanBeConverted) { Add(operation.path, copyResult.ConvertedInstance, objectToApplyTo, operation); 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..dfecc83739b3 --- /dev/null +++ b/src/Features/JsonPatch.SystemTextJson/src/Helpers/JsonUtilities.cs @@ -0,0 +1,17 @@ +// 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; + +namespace Microsoft.AspNetCore.JsonPatch.SystemTextJson.Helpers; + +internal static class JsonUtilities +{ + public static bool DeepEquals(object a, object b, JsonSerializerOptions serializerOptions) + { + return JsonObject.DeepEquals( + JsonSerializer.SerializeToNode(a, serializerOptions), + JsonSerializer.SerializeToNode(b, serializerOptions)); + } +} diff --git a/src/Features/JsonPatch.SystemTextJson/src/Internal/ConversionResultProvider.cs b/src/Features/JsonPatch.SystemTextJson/src/Internal/ConversionResultProvider.cs index f12df6aacb3b..3e5cf444ec7c 100644 --- a/src/Features/JsonPatch.SystemTextJson/src/Internal/ConversionResultProvider.cs +++ b/src/Features/JsonPatch.SystemTextJson/src/Internal/ConversionResultProvider.cs @@ -9,11 +9,6 @@ namespace Microsoft.AspNetCore.JsonPatch.SystemTextJson.Internal; internal static class ConversionResultProvider { - public static ConversionResult ConvertTo(object value, Type typeToConvertTo) - { - return ConvertTo(value, typeToConvertTo, null); - } - internal static ConversionResult ConvertTo(object value, Type typeToConvertTo, JsonSerializerOptions serializerOptions) { if (value == null) @@ -35,8 +30,7 @@ internal static ConversionResult ConvertTo(object value, Type typeToConvertTo, J try { - var serializedDocument = JsonSerializer.Serialize(value, serializerOptions); - var deserialized = JsonSerializer.Deserialize(serializedDocument, typeToConvertTo, serializerOptions); + var deserialized = ConvertToTargetType(value, typeToConvertTo, serializerOptions); return new ConversionResult(true, deserialized); } catch @@ -45,7 +39,7 @@ internal static ConversionResult ConvertTo(object value, Type typeToConvertTo, J } } - public static ConversionResult CopyTo(object value, Type typeToConvertTo) + internal static ConversionResult CopyTo(object value, Type typeToConvertTo, JsonSerializerOptions serializerOptions) { var targetType = typeToConvertTo; if (value == null) @@ -67,7 +61,7 @@ public static ConversionResult CopyTo(object value, Type typeToConvertTo) try { - var deserialized = JsonSerializer.Deserialize(JsonSerializer.Serialize(value), targetType); + var deserialized = ConvertToTargetType(value, targetType, serializerOptions); return new ConversionResult(true, deserialized); } catch @@ -87,4 +81,9 @@ private static bool IsNullableType(Type type) // reference types are always nullable return true; } + + private static object ConvertToTargetType(object value, Type targetType, JsonSerializerOptions serializerOptions) + { + return JsonSerializer.Deserialize(JsonSerializer.Serialize(value, serializerOptions), targetType, serializerOptions); + } } diff --git a/src/Features/JsonPatch.SystemTextJson/src/Internal/DictionaryAdapterOfTU.cs b/src/Features/JsonPatch.SystemTextJson/src/Internal/DictionaryAdapterOfTU.cs index 86d5893480d9..c0540d4c4cd0 100644 --- a/src/Features/JsonPatch.SystemTextJson/src/Internal/DictionaryAdapterOfTU.cs +++ b/src/Features/JsonPatch.SystemTextJson/src/Internal/DictionaryAdapterOfTU.cs @@ -4,7 +4,7 @@ using System.Collections.Generic; 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; @@ -17,7 +17,7 @@ public virtual bool TryAdd( object value, out string errorMessage) { - var key = ExtractKeyFromSegment(segment); + var key = segment; var dictionary = (IDictionary)target; // As per JsonPatch spec, if a key already exists, adding should replace the existing value @@ -43,7 +43,7 @@ public virtual bool TryGet( out object value, out string errorMessage) { - var key = ExtractKeyFromSegment(segment); + var key = segment; var dictionary = (IDictionary)target; if (!TryConvertKey(key, out var convertedKey, out errorMessage)) @@ -70,7 +70,7 @@ public virtual bool TryRemove( JsonSerializerOptions serializerOptions, out string errorMessage) { - var key = ExtractKeyFromSegment(segment); + var key = segment; var dictionary = (IDictionary)target; if (!TryConvertKey(key, out var convertedKey, out errorMessage)) @@ -96,7 +96,7 @@ public virtual bool TryReplace( object value, out string errorMessage) { - var key = ExtractKeyFromSegment(segment); + var key = segment; var dictionary = (IDictionary)target; if (!TryConvertKey(key, out var convertedKey, out errorMessage)) @@ -129,7 +129,7 @@ public virtual bool TryTest( object value, out string errorMessage) { - var key = ExtractKeyFromSegment(segment); + var key = segment; var dictionary = (IDictionary)target; if (!TryConvertKey(key, out var convertedKey, out errorMessage)) @@ -156,7 +156,7 @@ public virtual bool TryTest( return false; } - if (!JsonObject.DeepEquals(JsonSerializer.SerializeToNode(currentValue), JsonSerializer.SerializeToNode(convertedValue))) + if (!JsonUtilities.DeepEquals(currentValue, convertedValue, serializerOptions)) { errorMessage = Resources.FormatValueNotEqualToTestValue(currentValue, value, segment); return false; @@ -175,7 +175,7 @@ public virtual bool TryTraverse( out object nextTarget, out string errorMessage) { - var key = ExtractKeyFromSegment(segment); + var key = segment; var dictionary = (IDictionary)target; if (!TryConvertKey(key, out var convertedKey, out errorMessage)) @@ -198,12 +198,7 @@ public virtual bool TryTraverse( } } - private static string ExtractKeyFromSegment(string segment) - { - return segment.ToString(); - } - - protected virtual bool TryConvertKey(string key, out TKey convertedKey, out string errorMessage) + 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); @@ -221,12 +216,7 @@ protected virtual bool TryConvertKey(string key, out TKey convertedKey, out stri } } - protected virtual bool TryConvertValue(object value, out TValue convertedValue, out string errorMessage) - { - return TryConvertValue(value, null, out convertedValue, out errorMessage); - } - - protected virtual bool TryConvertValue(object value, JsonSerializerOptions serializerOptions, out TValue convertedValue, out string errorMessage) + 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) diff --git a/src/Features/JsonPatch.SystemTextJson/src/Internal/JsonObjectAdapter.cs b/src/Features/JsonPatch.SystemTextJson/src/Internal/JsonObjectAdapter.cs index 04304a92838a..22c8f6d4bcc8 100644 --- a/src/Features/JsonPatch.SystemTextJson/src/Internal/JsonObjectAdapter.cs +++ b/src/Features/JsonPatch.SystemTextJson/src/Internal/JsonObjectAdapter.cs @@ -3,6 +3,7 @@ using System.Text.Json; using System.Text.Json.Nodes; +using Microsoft.AspNetCore.JsonPatch.SystemTextJson.Helpers; namespace Microsoft.AspNetCore.JsonPatch.SystemTextJson.Internal; @@ -105,7 +106,7 @@ public virtual bool TryTest( return false; } - if (!JsonObject.DeepEquals(JsonSerializer.SerializeToNode(currentValue, serializerOptions), JsonSerializer.SerializeToNode(value, serializerOptions))) + if (!JsonUtilities.DeepEquals(currentValue, value, serializerOptions)) { errorMessage = Resources.FormatValueNotEqualToTestValue(currentValue, value, segment); return false; diff --git a/src/Features/JsonPatch.SystemTextJson/src/Internal/ListAdapter.cs b/src/Features/JsonPatch.SystemTextJson/src/Internal/ListAdapter.cs index 89016c5e1cb4..7d309b64d874 100644 --- a/src/Features/JsonPatch.SystemTextJson/src/Internal/ListAdapter.cs +++ b/src/Features/JsonPatch.SystemTextJson/src/Internal/ListAdapter.cs @@ -134,7 +134,7 @@ public virtual bool TryTest(object target, string segment, JsonSerializerOptions var currentValue = GenericListOrJsonArrayUtilities.GetElementAt(target, positionInfo.Index); - if (!JsonObject.DeepEquals(JsonSerializer.SerializeToNode(currentValue), JsonSerializer.SerializeToNode(convertedValue))) + if (!JsonUtilities.DeepEquals(currentValue, convertedValue, serializerOptions)) { errorMessage = Resources.FormatValueAtListPositionNotEqualToTestValue(currentValue, value, positionInfo.Index); return false; diff --git a/src/Features/JsonPatch.SystemTextJson/src/Internal/PocoAdapter.cs b/src/Features/JsonPatch.SystemTextJson/src/Internal/PocoAdapter.cs index 0c50d23bec2e..ff364a7d25a7 100644 --- a/src/Features/JsonPatch.SystemTextJson/src/Internal/PocoAdapter.cs +++ b/src/Features/JsonPatch.SystemTextJson/src/Internal/PocoAdapter.cs @@ -6,6 +6,7 @@ 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; @@ -158,7 +159,7 @@ public virtual bool TryTest( } var currentValue = jsonProperty.Get(target); - if (!JsonObject.DeepEquals(JsonSerializer.SerializeToNode(currentValue), JsonSerializer.SerializeToNode(convertedValue))) + if (!JsonUtilities.DeepEquals(currentValue, convertedValue, serializerOptions)) { errorMessage = Resources.FormatValueNotEqualToTestValue(currentValue, value, segment); return false; From 00fe8616ae2136352bb07d10086c0b1f66ce2717 Mon Sep 17 00:00:00 2001 From: Artak Mkrtchyan Date: Thu, 17 Apr 2025 17:03:06 -0700 Subject: [PATCH 3/7] Address review feedback --- .../JsonPatch.SystemTextJson/src/Operations/Operation.cs | 7 ------- .../JsonPatch.SystemTextJson/src/PublicAPI.Unshipped.txt | 1 - .../test/WriteOnceDynamicTestObject.cs | 4 ++-- 3 files changed, 2 insertions(+), 10 deletions(-) diff --git a/src/Features/JsonPatch.SystemTextJson/src/Operations/Operation.cs b/src/Features/JsonPatch.SystemTextJson/src/Operations/Operation.cs index 655bde69d86c..bb2941ff2851 100644 --- a/src/Features/JsonPatch.SystemTextJson/src/Operations/Operation.cs +++ b/src/Features/JsonPatch.SystemTextJson/src/Operations/Operation.cs @@ -65,11 +65,4 @@ public void Apply(object objectToApplyTo, IObjectAdapter adapter) break; } } - - public bool ShouldSerializevalue() - { - return (OperationType == OperationType.Add - || OperationType == OperationType.Replace - || OperationType == OperationType.Test); - } } diff --git a/src/Features/JsonPatch.SystemTextJson/src/PublicAPI.Unshipped.txt b/src/Features/JsonPatch.SystemTextJson/src/PublicAPI.Unshipped.txt index 282f2271337b..6e48736e109e 100644 --- a/src/Features/JsonPatch.SystemTextJson/src/PublicAPI.Unshipped.txt +++ b/src/Features/JsonPatch.SystemTextJson/src/PublicAPI.Unshipped.txt @@ -12,7 +12,6 @@ Microsoft.AspNetCore.JsonPatch.SystemTextJson.JsonPatchDocument.JsonPatc 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.ShouldSerializevalue() -> bool Microsoft.AspNetCore.JsonPatch.SystemTextJson.Operations.Operation.Operation() -> void Microsoft.AspNetCore.JsonPatch.SystemTextJson.Operations.OperationBase Microsoft.AspNetCore.JsonPatch.SystemTextJson.Operations.OperationBase.OperationBase() -> void diff --git a/src/Features/JsonPatch.SystemTextJson/test/WriteOnceDynamicTestObject.cs b/src/Features/JsonPatch.SystemTextJson/test/WriteOnceDynamicTestObject.cs index 65d39becaa7a..52cf38a17bb7 100644 --- a/src/Features/JsonPatch.SystemTextJson/test/WriteOnceDynamicTestObject.cs +++ b/src/Features/JsonPatch.SystemTextJson/test/WriteOnceDynamicTestObject.cs @@ -13,11 +13,11 @@ namespace Microsoft.AspNetCore.JsonPatch.SystemTextJson.Internal; /// "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 +/// 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 JsonPatch#110 for further details. +/// See https://github.com/dotnet/aspnetcore/issues/3623 for further details. /// /// public class WriteOnceDynamicTestObject : DynamicObject From 840e1309b5e63568300a4439937dd71bb8e7bc49 Mon Sep 17 00:00:00 2001 From: Artak Mkrtchyan Date: Fri, 18 Apr 2025 05:27:35 -0700 Subject: [PATCH 4/7] - Adopt the converter implementation provided by the S.T.J owner team - Turn the IAdapter and IAdapterFactory types internal --- .../src/Adapters/IAdapterFactory.cs | 2 +- .../JsonConverterForJsonPatchDocumentOfT.cs | 73 ++++++++----------- .../Converters/JsonPatchDocumentConverter.cs | 45 ++++++++---- .../Converters/OperationConverterFactory.cs | 25 ------- .../src/Converters/OperationConverterOfT.cs | 69 ------------------ .../src/Internal/IAdapter.cs | 6 +- .../src/PublicAPI.Unshipped.txt | 9 --- 7 files changed, 62 insertions(+), 167 deletions(-) delete mode 100644 src/Features/JsonPatch.SystemTextJson/src/Converters/OperationConverterFactory.cs delete mode 100644 src/Features/JsonPatch.SystemTextJson/src/Converters/OperationConverterOfT.cs diff --git a/src/Features/JsonPatch.SystemTextJson/src/Adapters/IAdapterFactory.cs b/src/Features/JsonPatch.SystemTextJson/src/Adapters/IAdapterFactory.cs index 622a15da4b45..2afae2ee4e6c 100644 --- a/src/Features/JsonPatch.SystemTextJson/src/Adapters/IAdapterFactory.cs +++ b/src/Features/JsonPatch.SystemTextJson/src/Adapters/IAdapterFactory.cs @@ -8,7 +8,7 @@ namespace Microsoft.AspNetCore.JsonPatch.SystemTextJson.Adapters; /// /// Defines the operations used for loading an based on the current object and ContractResolver. /// -public interface IAdapterFactory +internal interface IAdapterFactory { /// /// Creates an for the current object diff --git a/src/Features/JsonPatch.SystemTextJson/src/Converters/JsonConverterForJsonPatchDocumentOfT.cs b/src/Features/JsonPatch.SystemTextJson/src/Converters/JsonConverterForJsonPatchDocumentOfT.cs index 6b6df21dab4e..d6871a8b455e 100644 --- a/src/Features/JsonPatch.SystemTextJson/src/Converters/JsonConverterForJsonPatchDocumentOfT.cs +++ b/src/Features/JsonPatch.SystemTextJson/src/Converters/JsonConverterForJsonPatchDocumentOfT.cs @@ -12,32 +12,37 @@ namespace Microsoft.AspNetCore.JsonPatch.SystemTextJson.Converters; internal sealed class JsonConverterForJsonPatchDocumentOfT : JsonConverter> where T : class { - public override bool CanConvert(Type typeToConvert) - { - var result = base.CanConvert(typeToConvert); - return result; - } + private static JsonConverter> GetConverter(JsonSerializerOptions options) => + (JsonConverter>)options.GetConverter(typeof(Operation)); public override JsonPatchDocument Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) { - // Parse root object - try + if (typeToConvert != typeof(JsonPatchDocument)) { - using var document = JsonDocument.ParseValue(ref reader); - var operationsElement = document.RootElement; - if (operationsElement.ValueKind != JsonValueKind.Array) - { - throw new JsonException("Expected 'Operations' array property in JsonPatchDocument."); - } + throw new ArgumentException(Resources.FormatParameterMustMatchType(nameof(typeToConvert), nameof(JsonPatchDocument)), nameof(typeToConvert)); + } - // Clone options with Operation converter - var effectiveOptions = CloneWithOperationConverter(options); + if (reader.TokenType != JsonTokenType.StartArray) + { + throw new JsonException(Resources.InvalidJsonPatchDocument); + } - // Deserialize the operations array - var operations = JsonSerializer.Deserialize>>(operationsElement.GetRawText(), effectiveOptions); + if (reader.TokenType == JsonTokenType.Null) + { + return null; + } - return new JsonPatchDocument(operations, options); + 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) { @@ -47,34 +52,18 @@ public override JsonPatchDocument Read(ref Utf8JsonReader reader, Type typeTo public override void Write(Utf8JsonWriter writer, JsonPatchDocument value, JsonSerializerOptions options) { - JsonSerializer.Serialize(writer, value.Operations, CloneWithOperationConverter(options)); - } - - private static JsonSerializerOptions CloneWithOperationConverter(JsonSerializerOptions baseOptions) - { - var options = baseOptions; - - var converterRegistered = IsOperationConverterRegistered(options); - if (!converterRegistered) + if (value == null) { - options = new JsonSerializerOptions(baseOptions); - options.Converters.Add(new OperationConverterFactory()); + writer.WriteNullValue(); + return; } - return options; - } - - private static bool IsOperationConverterRegistered(JsonSerializerOptions options) - { - for (var i = 0; i < options.Converters.Count; i++) + JsonConverter> operationConverter = GetConverter(options); + writer.WriteStartArray(); + foreach (var operation in value.Operations) { - var converter = options.Converters[i]; - if (converter is OperationConverterFactory || converter.GetType().IsGenericType && converter.GetType().GetGenericTypeDefinition() == typeof(OperationConverter<>)) - { - return true; - } + operationConverter.Write(writer, operation, options); } - - return false; + writer.WriteEndArray(); } } diff --git a/src/Features/JsonPatch.SystemTextJson/src/Converters/JsonPatchDocumentConverter.cs b/src/Features/JsonPatch.SystemTextJson/src/Converters/JsonPatchDocumentConverter.cs index ec54e97e8bc3..3f8c9ea689cf 100644 --- a/src/Features/JsonPatch.SystemTextJson/src/Converters/JsonPatchDocumentConverter.cs +++ b/src/Features/JsonPatch.SystemTextJson/src/Converters/JsonPatchDocumentConverter.cs @@ -14,33 +14,38 @@ internal class JsonPatchDocumentConverter : JsonConverter { internal static JsonSerializerOptions DefaultSerializerOptions { get; } = JsonSerializerOptions.Default; + 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), "JsonPatchDocument"), nameof(typeToConvert)); + throw new ArgumentException(Resources.FormatParameterMustMatchType(nameof(typeToConvert), nameof(JsonPatchDocument)), nameof(typeToConvert)); } - try + if (reader.TokenType != JsonTokenType.StartArray) { - if (reader.TokenType == JsonTokenType.Null) - { - return null; - } + throw new JsonException(Resources.InvalidJsonPatchDocument); + } + + if (reader.TokenType == JsonTokenType.Null) + { + return null; + } - var operations = new List(); + List ops = []; - JsonNode node = JsonArray.Parse(ref reader, new JsonNodeOptions { PropertyNameCaseInsensitive = options.PropertyNameCaseInsensitive }); - JsonArray operationsArray = node.AsArray(); - foreach (var item in operationsArray) + try + { + JsonConverter operationConverter = GetConverter(options); + while (reader.Read() && reader.TokenType is not JsonTokenType.EndArray) { - operations.Add(item.Deserialize(options)); + var op = operationConverter.Read(ref reader, typeof(Operation), options); + ops.Add(op); } - // container target: the JsonPatchDocument. - var container = new JsonPatchDocument(operations, DefaultSerializerOptions); - - return container; + return new JsonPatchDocument(ops, options); } catch (Exception ex) { @@ -50,10 +55,18 @@ public override JsonPatchDocument Read(ref Utf8JsonReader reader, Type typeToCon 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) { - JsonSerializer.Serialize(writer, operation, options); + operationConverter.Write(writer, operation, options); } writer.WriteEndArray(); diff --git a/src/Features/JsonPatch.SystemTextJson/src/Converters/OperationConverterFactory.cs b/src/Features/JsonPatch.SystemTextJson/src/Converters/OperationConverterFactory.cs deleted file mode 100644 index b5f7ffc0a424..000000000000 --- a/src/Features/JsonPatch.SystemTextJson/src/Converters/OperationConverterFactory.cs +++ /dev/null @@ -1,25 +0,0 @@ -// 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; -using Microsoft.AspNetCore.JsonPatch.SystemTextJson.Operations; - -namespace Microsoft.AspNetCore.JsonPatch.SystemTextJson.Converters; - -internal class OperationConverterFactory : JsonConverterFactory -{ - public override bool CanConvert(Type typeToConvert) - { - return typeToConvert.IsGenericType && - typeToConvert.GetGenericTypeDefinition() == typeof(Operation<>); - } - - public override JsonConverter CreateConverter(Type type, JsonSerializerOptions options) - { - var elementType = type.GetGenericArguments()[0]; - var converterType = typeof(OperationConverter<>).MakeGenericType(elementType); - return (JsonConverter)Activator.CreateInstance(converterType)!; - } -} diff --git a/src/Features/JsonPatch.SystemTextJson/src/Converters/OperationConverterOfT.cs b/src/Features/JsonPatch.SystemTextJson/src/Converters/OperationConverterOfT.cs deleted file mode 100644 index d50cd654490d..000000000000 --- a/src/Features/JsonPatch.SystemTextJson/src/Converters/OperationConverterOfT.cs +++ /dev/null @@ -1,69 +0,0 @@ -// 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; -using Microsoft.AspNetCore.JsonPatch.SystemTextJson.Operations; - -namespace Microsoft.AspNetCore.JsonPatch.SystemTextJson.Converters; - -internal class OperationConverter : JsonConverter> where T : class -{ - public override bool CanConvert(Type typeToConvert) - { - var result = base.CanConvert(typeToConvert); - return result; - } - - public override Operation Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) - { - using var doc = JsonDocument.ParseValue(ref reader); - var root = doc.RootElement; - - var op = root.GetProperty("op").GetString(); - var path = root.GetProperty("path").GetString(); - - string from = null; - if (root.TryGetProperty("from", out var fromProp)) - { - from = fromProp.GetString(); - } - - object value = null; - if (root.TryGetProperty("value", out var valueProp)) - { - // Deserialize "value" into object using System.Text.Json – you might deserialize to T here if you prefer - value = valueProp.Deserialize(options); - } - - return new Operation - { - op = op, - path = path, - from = from, - value = value - }; - } - - public override void Write(Utf8JsonWriter writer, Operation value, JsonSerializerOptions options) - { - writer.WriteStartObject(); - - writer.WriteString("op", value.op); - writer.WriteString("path", value.path); - - if (value.from != null) - { - writer.WriteString("from", value.from); - } - - if (value.value != null) - { - writer.WritePropertyName("value"); - JsonSerializer.Serialize(writer, value.value, options); - } - - writer.WriteEndObject(); - } -} diff --git a/src/Features/JsonPatch.SystemTextJson/src/Internal/IAdapter.cs b/src/Features/JsonPatch.SystemTextJson/src/Internal/IAdapter.cs index fdf154a499aa..5e1dbceb75a7 100644 --- a/src/Features/JsonPatch.SystemTextJson/src/Internal/IAdapter.cs +++ b/src/Features/JsonPatch.SystemTextJson/src/Internal/IAdapter.cs @@ -5,11 +5,7 @@ namespace Microsoft.AspNetCore.JsonPatch.SystemTextJson.Internal; -/// -/// This API supports infrastructure and is not intended to be used -/// directly from your code. This API may change or be removed in future releases. -/// -public interface IAdapter +internal interface IAdapter { bool TryTraverse( object target, diff --git a/src/Features/JsonPatch.SystemTextJson/src/PublicAPI.Unshipped.txt b/src/Features/JsonPatch.SystemTextJson/src/PublicAPI.Unshipped.txt index 6e48736e109e..e2421fc8af3c 100644 --- a/src/Features/JsonPatch.SystemTextJson/src/PublicAPI.Unshipped.txt +++ b/src/Features/JsonPatch.SystemTextJson/src/PublicAPI.Unshipped.txt @@ -1,11 +1,9 @@ #nullable enable -Microsoft.AspNetCore.JsonPatch.SystemTextJson.Adapters.IAdapterFactory 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.Internal.IAdapter Microsoft.AspNetCore.JsonPatch.SystemTextJson.JsonPatchDocument Microsoft.AspNetCore.JsonPatch.SystemTextJson.JsonPatchDocument.JsonPatchDocument() -> void Microsoft.AspNetCore.JsonPatch.SystemTextJson.JsonPatchDocument.JsonPatchDocument() -> void @@ -25,7 +23,6 @@ Microsoft.AspNetCore.JsonPatch.SystemTextJson.Operations.OperationType.Move = 3 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.IAdapterFactory.Create(object target) -> Microsoft.AspNetCore.JsonPatch.SystemTextJson.Internal.IAdapter ~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 @@ -40,12 +37,6 @@ Microsoft.AspNetCore.JsonPatch.SystemTextJson.Operations.OperationType.Test = 5 ~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.Internal.IAdapter.TryAdd(object target, string segment, System.Text.Json.JsonSerializerOptions serializerOptions, object value, out string errorMessage) -> bool -~Microsoft.AspNetCore.JsonPatch.SystemTextJson.Internal.IAdapter.TryGet(object target, string segment, System.Text.Json.JsonSerializerOptions serializerOptions, out object value, out string errorMessage) -> bool -~Microsoft.AspNetCore.JsonPatch.SystemTextJson.Internal.IAdapter.TryRemove(object target, string segment, System.Text.Json.JsonSerializerOptions serializerOptions, out string errorMessage) -> bool -~Microsoft.AspNetCore.JsonPatch.SystemTextJson.Internal.IAdapter.TryReplace(object target, string segment, System.Text.Json.JsonSerializerOptions serializerOptions, object value, out string errorMessage) -> bool -~Microsoft.AspNetCore.JsonPatch.SystemTextJson.Internal.IAdapter.TryTest(object target, string segment, System.Text.Json.JsonSerializerOptions serializerOptions, object value, out string errorMessage) -> bool -~Microsoft.AspNetCore.JsonPatch.SystemTextJson.Internal.IAdapter.TryTraverse(object target, string segment, System.Text.Json.JsonSerializerOptions serializerOptions, out object nextTarget, out string errorMessage) -> bool ~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 From 32e451f96c9f2c08624c6f402b5a046c48097e79 Mon Sep 17 00:00:00 2001 From: Artak Mkrtchyan Date: Fri, 18 Apr 2025 10:52:16 -0700 Subject: [PATCH 5/7] Optimizations based on review from the Json team --- .../src/Helpers/JsonUtilities.cs | 40 +++++++++++++++++-- .../src/Internal/ConversionResultProvider.cs | 10 ++++- .../src/Internal/ListAdapter.cs | 13 ------ .../src/JsonPatchDocumentOfT.cs | 19 ++++----- 4 files changed, 52 insertions(+), 30 deletions(-) diff --git a/src/Features/JsonPatch.SystemTextJson/src/Helpers/JsonUtilities.cs b/src/Features/JsonPatch.SystemTextJson/src/Helpers/JsonUtilities.cs index dfecc83739b3..fe5e6decafa7 100644 --- a/src/Features/JsonPatch.SystemTextJson/src/Helpers/JsonUtilities.cs +++ b/src/Features/JsonPatch.SystemTextJson/src/Helpers/JsonUtilities.cs @@ -1,6 +1,7 @@ // 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; @@ -10,8 +11,41 @@ internal static class JsonUtilities { public static bool DeepEquals(object a, object b, JsonSerializerOptions serializerOptions) { - return JsonObject.DeepEquals( - JsonSerializer.SerializeToNode(a, serializerOptions), - JsonSerializer.SerializeToNode(b, serializerOptions)); + if (a == null && b == null) + { + return true; + } + + if (a == null || b == null) + { + return false; + } + + if (a.GetType() == typeof(JsonNode) && b.GetType() == typeof(JsonNode)) + { + return JsonNode.DeepEquals((JsonNode)a, (JsonNode)b); + } + + 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/Internal/ConversionResultProvider.cs b/src/Features/JsonPatch.SystemTextJson/src/Internal/ConversionResultProvider.cs index 3e5cf444ec7c..fadb7869adc6 100644 --- a/src/Features/JsonPatch.SystemTextJson/src/Internal/ConversionResultProvider.cs +++ b/src/Features/JsonPatch.SystemTextJson/src/Internal/ConversionResultProvider.cs @@ -56,7 +56,7 @@ internal static ConversionResult CopyTo(object value, Type typeToConvertTo, Json // Workaround for the https://github.com/dotnet/runtime/issues/113926 if (targetType.Name == "JsonValuePrimitive`1") { - targetType = typeof(JsonNode); + targetType = typeof(JsonElement); } try @@ -84,6 +84,12 @@ private static bool IsNullableType(Type type) private static object ConvertToTargetType(object value, Type targetType, JsonSerializerOptions serializerOptions) { - return JsonSerializer.Deserialize(JsonSerializer.Serialize(value, serializerOptions), targetType, 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/ListAdapter.cs b/src/Features/JsonPatch.SystemTextJson/src/Internal/ListAdapter.cs index 7d309b64d874..8f481928fe9a 100644 --- a/src/Features/JsonPatch.SystemTextJson/src/Internal/ListAdapter.cs +++ b/src/Features/JsonPatch.SystemTextJson/src/Internal/ListAdapter.cs @@ -5,7 +5,6 @@ using System.Collections; using System.Collections.Generic; using System.Text.Json; -using System.Text.Json.Nodes; using Microsoft.AspNetCore.JsonPatch.SystemTextJson.Helpers; using Microsoft.Extensions.Internal; @@ -257,10 +256,6 @@ protected virtual bool TryGetPositionInfo(int collectionCount, string segment, O } } - /// - /// This API supports infrastructure and is not intended to be used - /// directly from your code. This API may change or be removed in future releases. - /// protected readonly struct PositionInfo { public PositionInfo(PositionType type, int index) @@ -273,10 +268,6 @@ public PositionInfo(PositionType type, int index) public int Index { get; } } - /// - /// This API supports infrastructure and is not intended to be used - /// directly from your code. This API may change or be removed in future releases. - /// protected enum PositionType { Index, // valid index @@ -285,10 +276,6 @@ protected enum PositionType OutOfBounds } - /// - /// This API supports infrastructure and is not intended to be used - /// directly from your code. This API may change or be removed in future releases. - /// protected enum OperationType { Add, diff --git a/src/Features/JsonPatch.SystemTextJson/src/JsonPatchDocumentOfT.cs b/src/Features/JsonPatch.SystemTextJson/src/JsonPatchDocumentOfT.cs index 79c793b10267..afb3f9ab0858 100644 --- a/src/Features/JsonPatch.SystemTextJson/src/JsonPatchDocumentOfT.cs +++ b/src/Features/JsonPatch.SystemTextJson/src/JsonPatchDocumentOfT.cs @@ -713,19 +713,14 @@ private List GetPathSegments(Expression expr) private string GetPropertyNameFromMemberExpression(MemberExpression memberExpression) { - var jsonObjectContract = SerializerOptions.GetTypeInfo(memberExpression.Expression.Type); - if (jsonObjectContract != null) + var jsonTypeInfo = SerializerOptions.GetTypeInfo(memberExpression.Expression.Type); + if (jsonTypeInfo != null) { - var jsonName = memberExpression.Member.Name; - var propNameAttribute = memberExpression.Member.GetCustomAttribute(); - if (propNameAttribute is not null) - { - jsonName = propNameAttribute.Name; - } - - return jsonObjectContract.Properties - .First(jsonProperty => jsonProperty.Name == jsonName) - .Name; + var memberInfo = memberExpression.Member; + var matchingProp = jsonTypeInfo + .Properties + .First(jsonProp => jsonProp.AttributeProvider is MemberInfo mi && mi == memberInfo); + return matchingProp.Name; } return null; From 70dcae7f66649fa5754ead5b55c695698219c058 Mon Sep 17 00:00:00 2001 From: Artak Mkrtchyan Date: Fri, 18 Apr 2025 13:01:18 -0700 Subject: [PATCH 6/7] Addressed review feedback --- .../Converters/JsonPatchDocumentConverter.cs | 3 --- .../src/Helpers/JsonUtilities.cs | 4 ++-- .../src/Internal/ConversionResultProvider.cs | 22 +++++-------------- .../src/Internal/JsonObjectAdapter.cs | 5 +++-- .../JsonPatch.SystemTextJson/startvs.cmd | 2 +- 5 files changed, 12 insertions(+), 24 deletions(-) diff --git a/src/Features/JsonPatch.SystemTextJson/src/Converters/JsonPatchDocumentConverter.cs b/src/Features/JsonPatch.SystemTextJson/src/Converters/JsonPatchDocumentConverter.cs index 3f8c9ea689cf..554fe8d12eb8 100644 --- a/src/Features/JsonPatch.SystemTextJson/src/Converters/JsonPatchDocumentConverter.cs +++ b/src/Features/JsonPatch.SystemTextJson/src/Converters/JsonPatchDocumentConverter.cs @@ -4,7 +4,6 @@ using System; using System.Collections.Generic; using System.Text.Json; -using System.Text.Json.Nodes; using System.Text.Json.Serialization; using Microsoft.AspNetCore.JsonPatch.SystemTextJson.Operations; @@ -12,8 +11,6 @@ namespace Microsoft.AspNetCore.JsonPatch.SystemTextJson.Converters; internal class JsonPatchDocumentConverter : JsonConverter { - internal static JsonSerializerOptions DefaultSerializerOptions { get; } = JsonSerializerOptions.Default; - private static JsonConverter GetConverter(JsonSerializerOptions options) => (JsonConverter)options.GetConverter(typeof(Operation)); diff --git a/src/Features/JsonPatch.SystemTextJson/src/Helpers/JsonUtilities.cs b/src/Features/JsonPatch.SystemTextJson/src/Helpers/JsonUtilities.cs index fe5e6decafa7..7c223fff2093 100644 --- a/src/Features/JsonPatch.SystemTextJson/src/Helpers/JsonUtilities.cs +++ b/src/Features/JsonPatch.SystemTextJson/src/Helpers/JsonUtilities.cs @@ -21,9 +21,9 @@ public static bool DeepEquals(object a, object b, JsonSerializerOptions serializ return false; } - if (a.GetType() == typeof(JsonNode) && b.GetType() == typeof(JsonNode)) + if (a is JsonNode nodeA && b is JsonNode nodeB) { - return JsonNode.DeepEquals((JsonNode)a, (JsonNode)b); + return JsonNode.DeepEquals(nodeA, nodeB); } using var docA = TryGetJsonElement(a, serializerOptions, out var elementA); diff --git a/src/Features/JsonPatch.SystemTextJson/src/Internal/ConversionResultProvider.cs b/src/Features/JsonPatch.SystemTextJson/src/Internal/ConversionResultProvider.cs index fadb7869adc6..1ef5c87a92e7 100644 --- a/src/Features/JsonPatch.SystemTextJson/src/Internal/ConversionResultProvider.cs +++ b/src/Features/JsonPatch.SystemTextJson/src/Internal/ConversionResultProvider.cs @@ -3,7 +3,6 @@ using System; using System.Text.Json; -using System.Text.Json.Nodes; namespace Microsoft.AspNetCore.JsonPatch.SystemTextJson.Internal; @@ -22,21 +21,7 @@ internal static ConversionResult ConvertTo(object value, Type typeToConvertTo, J return new ConversionResult(true, value); } - // Workaround for the https://github.com/dotnet/runtime/issues/113926 - if (typeToConvertTo.Name == "JsonValuePrimitive`1") - { - typeToConvertTo = typeof(JsonNode); - } - - try - { - var deserialized = ConvertToTargetType(value, typeToConvertTo, serializerOptions); - return new ConversionResult(true, deserialized); - } - catch - { - return new ConversionResult(canBeConverted: false, convertedInstance: null); - } + return GetConvertedValue(value, serializerOptions, ref typeToConvertTo); } internal static ConversionResult CopyTo(object value, Type typeToConvertTo, JsonSerializerOptions serializerOptions) @@ -53,6 +38,11 @@ internal static ConversionResult CopyTo(object value, Type typeToConvertTo, Json 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") { diff --git a/src/Features/JsonPatch.SystemTextJson/src/Internal/JsonObjectAdapter.cs b/src/Features/JsonPatch.SystemTextJson/src/Internal/JsonObjectAdapter.cs index 22c8f6d4bcc8..7db344c78545 100644 --- a/src/Features/JsonPatch.SystemTextJson/src/Internal/JsonObjectAdapter.cs +++ b/src/Features/JsonPatch.SystemTextJson/src/Internal/JsonObjectAdapter.cs @@ -73,13 +73,14 @@ public virtual bool TryReplace( { var obj = (JsonObject)target; - if (!obj.ContainsKey(segment)) + int index = obj.IndexOf(segment); + if (index == -1) { errorMessage = Resources.FormatTargetLocationAtPathSegmentNotFound(segment); return false; } - obj[segment] = value != null ? JsonSerializer.SerializeToNode(value) : GetJsonNull(); + obj[index] = value != null ? JsonSerializer.SerializeToNode(value, serializerOptions) : GetJsonNull(); errorMessage = null; return true; diff --git a/src/Features/JsonPatch.SystemTextJson/startvs.cmd b/src/Features/JsonPatch.SystemTextJson/startvs.cmd index 4486d6bee8f8..f83559bb92c7 100644 --- a/src/Features/JsonPatch.SystemTextJson/startvs.cmd +++ b/src/Features/JsonPatch.SystemTextJson/startvs.cmd @@ -1,3 +1,3 @@ @ECHO OFF -%~dp0..\..\..\startvs.cmd %~dp0JsonPatch.slnf +%~dp0..\..\..\startvs.cmd %~dp0JsonPatch.SystemTextJson.slnf From f8483ff43be67a18f8bb60bfb50ac8c3bb0b5eac Mon Sep 17 00:00:00 2001 From: Artak Mkrtchyan Date: Fri, 18 Apr 2025 15:46:31 -0700 Subject: [PATCH 7/7] Added a unit test to validate that JsonNamingPolicy is taking into consideration --- .../test/JsonPatchDocumentTest.cs | 58 +++++++++++++++++++ 1 file changed, 58 insertions(+) diff --git a/src/Features/JsonPatch.SystemTextJson/test/JsonPatchDocumentTest.cs b/src/Features/JsonPatch.SystemTextJson/test/JsonPatchDocumentTest.cs index 3b279f3a127c..9e7c6073e036 100644 --- a/src/Features/JsonPatch.SystemTextJson/test/JsonPatchDocumentTest.cs +++ b/src/Features/JsonPatch.SystemTextJson/test/JsonPatchDocumentTest.cs @@ -1,10 +1,12 @@ // 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; @@ -197,4 +199,60 @@ var deserialized // 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); + } }