diff --git a/.github/workflows/cleanup.yml b/.github/workflows/cleanup.yml index 34f2ba4..6808a63 100644 --- a/.github/workflows/cleanup.yml +++ b/.github/workflows/cleanup.yml @@ -16,6 +16,7 @@ jobs: - { name: 'Tingle.AspNetCore.Authentication' } - { name: 'Tingle.AspNetCore.Authorization' } - { name: 'Tingle.AspNetCore.DataProtection.MongoDB' } + - { name: 'Tingle.AspNetCore.JsonPatch' } - { name: 'Tingle.AspNetCore.JsonPatch.NewtonsoftJson' } - { name: 'Tingle.AspNetCore.Swagger' } - { name: 'Tingle.AspNetCore.Tokens' } diff --git a/README.md b/README.md index 52a77e8..ccc9038 100644 --- a/README.md +++ b/README.md @@ -15,6 +15,7 @@ This repository contains projects/libraries for adding useful functionality to . |[`Tingle.AspNetCore.Authentication`](https://www.nuget.org/packages/Tingle.AspNetCore.Authentication/)|Convenience authentication functionality such as pass through and pre-shared key authentication mechanisms. See [docs](./src/Tingle.AspNetCore.Authentication/README.md) and [sample](./samples/AuthenticationSample)| |[`Tingle.AspNetCore.Authorization`](https://www.nuget.org/packages/Tingle.AspNetCore.Authorization/)|Additional authorization functionality such as handlers and requirements. See [docs](./src/Tingle.AspNetCore.Authorization/README.md) and [sample](./samples/AuthorizationSample)| |[`Tingle.AspNetCore.DataProtection.MongoDB`](https://www.nuget.org/packages/Tingle.AspNetCore.DataProtection.MongoDB/)|Data Protection store in [MongoDB](https://mongodb.com) for ASP.NET Core. See [docs](./src/Tingle.AspNetCore.DataProtection.MongoDB/README.md) and [sample](./samples/DataProtectionMongoDBSample).| +|[`Tingle.AspNetCore.JsonPatch`](https://www.nuget.org/packages/Tingle.AspNetCore.JsonPatch/)|JSON Patch support for AspNetCore using System.Text.Json. See [docs](./src/Tingle.AspNetCore.JsonPatch/README.md).| |[`Tingle.AspNetCore.JsonPatch.NewtonsoftJson`](https://www.nuget.org/packages/Tingle.AspNetCore.JsonPatch.NewtonsoftJson/)|Helpers for validation when working with JsonPatch in ASP.NET Core. See [docs](./src/Tingle.AspNetCore.JsonPatch.NewtonsoftJson/README.md) and [blog](https://maxwellweru.com/blog/2020-11-17-immutable-properties-with-json-patch-in-aspnet-core).| |[`Tingle.AspNetCore.Swagger`](https://www.nuget.org/packages/Tingle.AspNetCore.Swagger/)|Usability extensions for Swagger middleware including smaller ReDoc support. See [docs](./src/Tingle.AspNetCore.Swagger/README.md).| |[`Tingle.AspNetCore.Tokens`](https://www.nuget.org/packages/Tingle.AspNetCore.Tokens/)|Support for generation of continuation tokens in ASP.NET Core with optional expiry. Useful for pagination, user invite tokens, expiring operation tokens, etc. This is availed through the `ContinuationToken` and `TimedContinuationToken` types. See [docs](./src/Tingle.AspNetCore.Tokens/README.md) and [sample](./samples/TokensSample).| diff --git a/Tingle.Extensions.sln b/Tingle.Extensions.sln index 6edb961..37cd146 100644 --- a/Tingle.Extensions.sln +++ b/Tingle.Extensions.sln @@ -16,6 +16,8 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Tingle.AspNetCore.Authoriza EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Tingle.AspNetCore.DataProtection.MongoDB", "src\Tingle.AspNetCore.DataProtection.MongoDB\Tingle.AspNetCore.DataProtection.MongoDB.csproj", "{6F93ED1D-6475-46F2-A7DB-B2A5F9DB5A83}" EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Tingle.AspNetCore.JsonPatch", "src\Tingle.AspNetCore.JsonPatch\Tingle.AspNetCore.JsonPatch.csproj", "{A8FCE1AF-3844-4D79-A6E2-A8B2BC9C6B82}" +EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Tingle.AspNetCore.JsonPatch.NewtonsoftJson", "src\Tingle.AspNetCore.JsonPatch.NewtonsoftJson\Tingle.AspNetCore.JsonPatch.NewtonsoftJson.csproj", "{82B17C91-B96A-4290-A623-6867912A4C8E}" EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Tingle.AspNetCore.Swagger", "src\Tingle.AspNetCore.Swagger\Tingle.AspNetCore.Swagger.csproj", "{C8093B92-5322-4B24-B71B-497340E5C5AA}" @@ -63,6 +65,8 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Tingle.AspNetCore.DataProte EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Tingle.AspNetCore.JsonPatch.NewtonsoftJson.Tests", "tests\Tingle.AspNetCore.JsonPatch.NewtonsoftJson.Tests\Tingle.AspNetCore.JsonPatch.NewtonsoftJson.Tests.csproj", "{55B3650C-36C6-4C05-B6A2-B4CBC3DC3E4C}" EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Tingle.AspNetCore.JsonPatch.Tests", "tests\Tingle.AspNetCore.JsonPatch.Tests\Tingle.AspNetCore.JsonPatch.Tests.csproj", "{01998E3A-C61A-44CD-B2DD-B04A5CFAA592}" +EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Tingle.AspNetCore.Swagger.Tests", "tests\Tingle.AspNetCore.Swagger.Tests\Tingle.AspNetCore.Swagger.Tests.csproj", "{AAA0315A-779D-4F1E-89CA-EA78A5B6E3ED}" EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Tingle.AspNetCore.Tokens.Tests", "tests\Tingle.AspNetCore.Tokens.Tests\Tingle.AspNetCore.Tokens.Tests.csproj", "{FB2F8961-9F8F-4B35-ACAC-CCBEA2A89684}" @@ -142,6 +146,10 @@ Global {6F93ED1D-6475-46F2-A7DB-B2A5F9DB5A83}.Debug|Any CPU.Build.0 = Debug|Any CPU {6F93ED1D-6475-46F2-A7DB-B2A5F9DB5A83}.Release|Any CPU.ActiveCfg = Release|Any CPU {6F93ED1D-6475-46F2-A7DB-B2A5F9DB5A83}.Release|Any CPU.Build.0 = Release|Any CPU + {A8FCE1AF-3844-4D79-A6E2-A8B2BC9C6B82}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {A8FCE1AF-3844-4D79-A6E2-A8B2BC9C6B82}.Debug|Any CPU.Build.0 = Debug|Any CPU + {A8FCE1AF-3844-4D79-A6E2-A8B2BC9C6B82}.Release|Any CPU.ActiveCfg = Release|Any CPU + {A8FCE1AF-3844-4D79-A6E2-A8B2BC9C6B82}.Release|Any CPU.Build.0 = Release|Any CPU {82B17C91-B96A-4290-A623-6867912A4C8E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {82B17C91-B96A-4290-A623-6867912A4C8E}.Debug|Any CPU.Build.0 = Debug|Any CPU {82B17C91-B96A-4290-A623-6867912A4C8E}.Release|Any CPU.ActiveCfg = Release|Any CPU @@ -226,6 +234,10 @@ Global {55B3650C-36C6-4C05-B6A2-B4CBC3DC3E4C}.Debug|Any CPU.Build.0 = Debug|Any CPU {55B3650C-36C6-4C05-B6A2-B4CBC3DC3E4C}.Release|Any CPU.ActiveCfg = Release|Any CPU {55B3650C-36C6-4C05-B6A2-B4CBC3DC3E4C}.Release|Any CPU.Build.0 = Release|Any CPU + {01998E3A-C61A-44CD-B2DD-B04A5CFAA592}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {01998E3A-C61A-44CD-B2DD-B04A5CFAA592}.Debug|Any CPU.Build.0 = Debug|Any CPU + {01998E3A-C61A-44CD-B2DD-B04A5CFAA592}.Release|Any CPU.ActiveCfg = Release|Any CPU + {01998E3A-C61A-44CD-B2DD-B04A5CFAA592}.Release|Any CPU.Build.0 = Release|Any CPU {AAA0315A-779D-4F1E-89CA-EA78A5B6E3ED}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {AAA0315A-779D-4F1E-89CA-EA78A5B6E3ED}.Debug|Any CPU.Build.0 = Debug|Any CPU {AAA0315A-779D-4F1E-89CA-EA78A5B6E3ED}.Release|Any CPU.ActiveCfg = Release|Any CPU @@ -335,6 +347,7 @@ Global {98F3A2B7-5774-4E38-8FA0-FA13B6134454} = {9546186D-D4E1-4EDB-956E-1F81C7F4DB72} {22690754-DCFB-4CD2-968D-239C1952B52C} = {9546186D-D4E1-4EDB-956E-1F81C7F4DB72} {6F93ED1D-6475-46F2-A7DB-B2A5F9DB5A83} = {9546186D-D4E1-4EDB-956E-1F81C7F4DB72} + {A8FCE1AF-3844-4D79-A6E2-A8B2BC9C6B82} = {9546186D-D4E1-4EDB-956E-1F81C7F4DB72} {82B17C91-B96A-4290-A623-6867912A4C8E} = {9546186D-D4E1-4EDB-956E-1F81C7F4DB72} {C8093B92-5322-4B24-B71B-497340E5C5AA} = {9546186D-D4E1-4EDB-956E-1F81C7F4DB72} {B545B88C-4BE0-43FB-AE87-47706D479C6B} = {9546186D-D4E1-4EDB-956E-1F81C7F4DB72} @@ -356,6 +369,7 @@ Global {E67CB6B9-6F42-4E63-9603-810B5B9FBF57} = {815F0941-3B70-4705-A583-AF627559595C} {E28F4E8D-148B-4583-A27D-E1DA2CC08167} = {815F0941-3B70-4705-A583-AF627559595C} {55B3650C-36C6-4C05-B6A2-B4CBC3DC3E4C} = {815F0941-3B70-4705-A583-AF627559595C} + {01998E3A-C61A-44CD-B2DD-B04A5CFAA592} = {815F0941-3B70-4705-A583-AF627559595C} {AAA0315A-779D-4F1E-89CA-EA78A5B6E3ED} = {815F0941-3B70-4705-A583-AF627559595C} {FB2F8961-9F8F-4B35-ACAC-CCBEA2A89684} = {815F0941-3B70-4705-A583-AF627559595C} {0EA063C6-9A97-4DE8-9344-5D2BDD301134} = {815F0941-3B70-4705-A583-AF627559595C} diff --git a/src/Tingle.AspNetCore.JsonPatch/Adapters/AdapterFactory.cs b/src/Tingle.AspNetCore.JsonPatch/Adapters/AdapterFactory.cs new file mode 100644 index 0000000..df80fc5 --- /dev/null +++ b/src/Tingle.AspNetCore.JsonPatch/Adapters/AdapterFactory.cs @@ -0,0 +1,47 @@ +using System.Collections; +using System.Dynamic; +using System.Text.Json; +using System.Text.Json.Nodes; +using Tingle.AspNetCore.JsonPatch.Internal; + +namespace Tingle.AspNetCore.JsonPatch.Adapters; + +/// +/// The default AdapterFactory to be used for resolving . +/// +public class AdapterFactory : IAdapterFactory +{ + internal static AdapterFactory Default { get; } = new(); + + /// + public virtual IAdapter Create(object target, JsonSerializerOptions serializerOptions) + { + ArgumentNullException.ThrowIfNull(target); + + ArgumentNullException.ThrowIfNull(serializerOptions); + + if (target is JsonObject) + { + return new JsonObjectAdapter(); + } + if (target is IList) + { + return new ListAdapter(); + } + else if (target is IDictionary || target is IDictionary) // ExpandoObject implements IDictionary + { + var intf = target.GetType().GetInterfaces().FirstOrDefault(i => i.IsGenericType && i.GetGenericTypeDefinition() == typeof(IDictionary<,>)); + if (intf != null) + { + var type = typeof(DictionaryAdapter<,>).MakeGenericType(intf.GetGenericArguments()); + return (IAdapter)Activator.CreateInstance(type)!; + } + } + else if (target is DynamicObject) + { + return new DynamicObjectAdapter(); + } + + return new PocoAdapter(); + } +} diff --git a/src/Tingle.AspNetCore.JsonPatch/Adapters/IAdapterFactory.cs b/src/Tingle.AspNetCore.JsonPatch/Adapters/IAdapterFactory.cs new file mode 100644 index 0000000..760abf0 --- /dev/null +++ b/src/Tingle.AspNetCore.JsonPatch/Adapters/IAdapterFactory.cs @@ -0,0 +1,18 @@ +using System.Text.Json; +using Tingle.AspNetCore.JsonPatch.Internal; + +namespace Tingle.AspNetCore.JsonPatch.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 current + /// The needed + IAdapter Create(object target, JsonSerializerOptions serializerOptions); +} diff --git a/src/Tingle.AspNetCore.JsonPatch/Adapters/IObjectAdapter.cs b/src/Tingle.AspNetCore.JsonPatch/Adapters/IObjectAdapter.cs new file mode 100644 index 0000000..66f3b93 --- /dev/null +++ b/src/Tingle.AspNetCore.JsonPatch/Adapters/IObjectAdapter.cs @@ -0,0 +1,108 @@ +using Tingle.AspNetCore.JsonPatch.Operations; + +namespace Tingle.AspNetCore.JsonPatch.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 https://tools.ietf.org/html/rfc6902#page-4 + /// + /// 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 https://tools.ietf.org/html/rfc6902#page-7 + /// + /// 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 https://tools.ietf.org/html/rfc6902#page-6 + /// + /// 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 https://tools.ietf.org/html/rfc6902#page-6 + /// + /// 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 https://tools.ietf.org/html/rfc6902#page-6 + /// + /// The replace operation. + /// Object to apply the operation to. + void Replace(Operation operation, object objectToApplyTo); +} diff --git a/src/Tingle.AspNetCore.JsonPatch/Adapters/IObjectAdapterWithTest.cs b/src/Tingle.AspNetCore.JsonPatch/Adapters/IObjectAdapterWithTest.cs new file mode 100644 index 0000000..403849c --- /dev/null +++ b/src/Tingle.AspNetCore.JsonPatch/Adapters/IObjectAdapterWithTest.cs @@ -0,0 +1,28 @@ +using Tingle.AspNetCore.JsonPatch.Operations; + +namespace Tingle.AspNetCore.JsonPatch.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 https://tools.ietf.org/html/rfc6902#page-7 + /// + /// The test operation. + /// Object to apply the operation to. + void Test(Operation operation, object objectToApplyTo); +} diff --git a/src/Tingle.AspNetCore.JsonPatch/Adapters/ObjectAdapter.cs b/src/Tingle.AspNetCore.JsonPatch/Adapters/ObjectAdapter.cs new file mode 100644 index 0000000..01b2054 --- /dev/null +++ b/src/Tingle.AspNetCore.JsonPatch/Adapters/ObjectAdapter.cs @@ -0,0 +1,278 @@ +using System.Text.Json; +using Tingle.AspNetCore.JsonPatch.Internal; +using Tingle.AspNetCore.JsonPatch.Operations; +using Tingle.AspNetCore.JsonPatch.Properties; + +namespace Tingle.AspNetCore.JsonPatch.Adapters; + +/// +/// The . +/// The for logging . +/// The to use when creating adaptors. +public class ObjectAdapter(JsonSerializerOptions serializerOptions, Action? logErrorAction, IAdapterFactory adapterFactory, bool create) : IObjectAdapterWithTest +{ + /// + /// Initializes a new instance of . + /// + /// The . + /// The for logging . + public ObjectAdapter( + JsonSerializerOptions serializerOptions, + Action? logErrorAction, + bool create) : + this(serializerOptions, logErrorAction, Adapters.AdapterFactory.Default, create) + { + } + + /// + /// Gets or sets the . + /// + public JsonSerializerOptions SerializerOptions { get; } = serializerOptions ?? throw new ArgumentNullException(nameof(serializerOptions)); + + /// + /// Gets or sets the + /// + public IAdapterFactory AdapterFactory { get; } = adapterFactory ?? throw new ArgumentNullException(nameof(adapterFactory)); + + /// + /// Action for logging . + /// + public Action? LogErrorAction { get; } = logErrorAction; + + public void Add(Operation operation, object objectToApplyTo) + { + ArgumentNullException.ThrowIfNull(operation); + + ArgumentNullException.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) + { + ArgumentNullException.ThrowIfNull(path); + + ArgumentNullException.ThrowIfNull(objectToApplyTo); + + ArgumentNullException.ThrowIfNull(operation); + + var parsedPath = new ParsedPath(path); + var visitor = new ObjectVisitor(parsedPath, SerializerOptions, AdapterFactory, create); + + var target = objectToApplyTo; + 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) + { + ArgumentNullException.ThrowIfNull(operation); + + ArgumentNullException.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) + { + ArgumentNullException.ThrowIfNull(operation); + + ArgumentNullException.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, create); + + 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) + { + ArgumentNullException.ThrowIfNull(operation); + + ArgumentNullException.ThrowIfNull(objectToApplyTo); + + var parsedPath = new ParsedPath(operation.path); + var visitor = new ObjectVisitor(parsedPath, SerializerOptions, AdapterFactory, create); + + 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) + { + ArgumentNullException.ThrowIfNull(operation); + + ArgumentNullException.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) + { + ArgumentNullException.ThrowIfNull(operation); + + ArgumentNullException.ThrowIfNull(objectToApplyTo); + + var parsedPath = new ParsedPath(operation.path); + var visitor = new ObjectVisitor(parsedPath, SerializerOptions, AdapterFactory, create); + + 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) + { + ArgumentNullException.ThrowIfNull(fromLocation); + + ArgumentNullException.ThrowIfNull(objectToGetValueFrom); + + ArgumentNullException.ThrowIfNull(operation); + + propertyValue = null; + + var parsedPath = new ParsedPath(fromLocation); + var visitor = new ObjectVisitor(parsedPath, SerializerOptions, AdapterFactory, create); + + 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/Tingle.AspNetCore.JsonPatch/Converters/JsonPatchDocumentConverter.cs b/src/Tingle.AspNetCore.JsonPatch/Converters/JsonPatchDocumentConverter.cs new file mode 100644 index 0000000..ae05b8c --- /dev/null +++ b/src/Tingle.AspNetCore.JsonPatch/Converters/JsonPatchDocumentConverter.cs @@ -0,0 +1,49 @@ +using System.Text.Json; +using System.Text.Json.Serialization; +using Tingle.AspNetCore.JsonPatch.Operations; + +namespace Tingle.AspNetCore.JsonPatch.Converters; + +public class JsonPatchDocumentConverter : JsonConverter +{ + /// + public override bool CanConvert(Type typeToConvert) => typeToConvert == typeof(JsonPatchDocument); + + /// + public override JsonPatchDocument? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + if (reader.TokenType == JsonTokenType.Null) return default; + + var operations = JsonSerializer.Deserialize>(ref reader, options); + return new JsonPatchDocument(operations ?? [], options); + } + + /// + public override void Write(Utf8JsonWriter writer, JsonPatchDocument value, JsonSerializerOptions options) + { + // we write an array of the operations + JsonSerializer.Serialize(writer, value.Operations, options); + } +} + +public class JsonPatchDocumentConverter : JsonConverter> where TModel : class +{ + /// + public override bool CanConvert(Type typeToConvert) => typeToConvert == typeof(JsonPatchDocument); + + /// + public override JsonPatchDocument? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + if (reader.TokenType == JsonTokenType.Null) return default; + + var operations = JsonSerializer.Deserialize>>(ref reader, options); + return new JsonPatchDocument(operations ?? [], options); + } + + /// + public override void Write(Utf8JsonWriter writer, JsonPatchDocument value, JsonSerializerOptions options) + { + // we write an array of the operations + JsonSerializer.Serialize(writer, value.Operations, options); + } +} diff --git a/src/Tingle.AspNetCore.JsonPatch/Converters/JsonPatchMergeDocumentConverter.cs b/src/Tingle.AspNetCore.JsonPatch/Converters/JsonPatchMergeDocumentConverter.cs new file mode 100644 index 0000000..901ee84 --- /dev/null +++ b/src/Tingle.AspNetCore.JsonPatch/Converters/JsonPatchMergeDocumentConverter.cs @@ -0,0 +1,161 @@ +using System.Text.Json; +using System.Text.Json.Nodes; +using System.Text.Json.Serialization; + +namespace Tingle.AspNetCore.JsonPatch.Converters; + +public class JsonPatchMergeDocumentConverter : JsonConverter +{ + /// + public override bool CanConvert(Type typeToConvert) => typeToConvert == typeof(JsonPatchMergeDocument); + + /// + public override JsonPatchMergeDocument? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + if (reader.TokenType == JsonTokenType.Null) return default; + if (reader.TokenType is not JsonTokenType.StartObject) + { + throw new InvalidOperationException("Only objects are supported"); + } + + var no = new JsonNodeOptions { PropertyNameCaseInsensitive = options.PropertyNameCaseInsensitive, }; + var node = JsonNode.Parse(ref reader, no)!.AsObject(); + var operations = new List(); + JsonPatchMergeDocumentConverterHelper.PopulateOperations(operations, node); + + return new JsonPatchMergeDocument(operations, options); + } + + /// + public override void Write(Utf8JsonWriter writer, JsonPatchMergeDocument value, JsonSerializerOptions options) + { + // convert the operations to a JSON object + var operations = value.Operations ?? []; + var node = new JsonObject(); + + foreach (var operation in operations) + { + var type = operation.OperationType; + if (type is Operations.OperationType.Add or Operations.OperationType.Replace) + { + var segments = operation.path.Trim('/').Split('/'); + JsonPatchMergeDocumentConverterHelper.PopulateJsonObject(node, segments, operation.value, options); + } + } + + // write the object + node.WriteTo(writer, options); + } +} + +public class JsonPatchMergeDocumentConverter : JsonConverter> where TModel : class +{ + /// + public override bool CanConvert(Type typeToConvert) => typeToConvert == typeof(JsonPatchMergeDocument); + + /// + public override JsonPatchMergeDocument? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + if (reader.TokenType == JsonTokenType.Null) return default; + if (reader.TokenType is not JsonTokenType.StartObject) + { + throw new InvalidOperationException("Only objects are supported"); + } + + var no = new JsonNodeOptions { PropertyNameCaseInsensitive = options.PropertyNameCaseInsensitive, }; + var node = JsonNode.Parse(ref reader, no)!.AsObject(); + var operations = new List>(); + JsonPatchMergeDocumentConverterHelper.PopulateOperations(operations, node); + + return new JsonPatchMergeDocument(operations, options); + } + + /// + public override void Write(Utf8JsonWriter writer, JsonPatchMergeDocument value, JsonSerializerOptions options) + { + // convert the operations to a JSON object + var operations = value.Operations ?? []; + var node = new JsonObject(); + + foreach (var operation in operations) + { + var type = operation.OperationType; + var segments = operation.path.Trim('/').Split('/'); + var opvalue = type is Operations.OperationType.Remove ? null : operation.value; + JsonPatchMergeDocumentConverterHelper.PopulateJsonObject(node, segments, opvalue, options); + } + + // write the object + node.WriteTo(writer, options); + } +} + +internal static class JsonPatchMergeDocumentConverterHelper +{ + internal static void PopulateJsonObject(JsonObject node, IReadOnlyList segments, object? value, JsonSerializerOptions options) + { + var currentNode = node; + for (var i = 0; i < segments.Count; i++) + { + var segment = segments[i]; + if (i + 1 == segments.Count) + { + currentNode[segment] = JsonSerializer.SerializeToNode(value, options); + } + else + { + // TODO: consider arrays here!!! + var obj = new JsonObject(); + if (currentNode.TryGetPropertyValue(segment, out var existing)) obj = existing!.AsObject(); + else currentNode[segment] = obj; + + currentNode = obj; + } + } + } + + internal static void PopulateOperations(List operations, JsonNode? node, string key = "") where TOperation : Operations.Operation, new() + { + if (node is null && key == "") return; + + if (node is JsonObject jo) + { + foreach (var pair in jo) + { + var value = pair.Value; + PopulateOperations(operations, value, key + "/" + pair.Key); + } + } + else if (node is JsonArray ja) + { + var index = 0; + foreach (var element in ja) + { + PopulateOperations(operations, element, key + "/" + index); + index++; + } + } + else if (node is JsonValue jv) + { + // convert to JsonElement to avoid type conversions for JsonValueKind.Number + var value = JsonSerializer.SerializeToElement(jv); + if (value.ValueKind == JsonValueKind.Null) + { + operations.Add(new TOperation { op = "remove", path = key }); + } + else + { + //operations.Add(new TOperation { op = "replace", path = key, value = value }); + operations.Add(new TOperation { op = "add", path = key, value = value }); + } + } + else if (node is null) + { + operations.Add(new TOperation { op = "remove", path = key }); + } + else + { + throw new InvalidOperationException($"'{node?.GetType()}' types are not allowed here!"); + } + } +} diff --git a/src/Tingle.AspNetCore.JsonPatch/Converters/TypedJsonPatchDocumentConverter.cs b/src/Tingle.AspNetCore.JsonPatch/Converters/TypedJsonPatchDocumentConverter.cs new file mode 100644 index 0000000..efd79fb --- /dev/null +++ b/src/Tingle.AspNetCore.JsonPatch/Converters/TypedJsonPatchDocumentConverter.cs @@ -0,0 +1,19 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace Tingle.AspNetCore.JsonPatch.Converters; + +internal class TypedJsonPatchDocumentConverter : JsonConverterFactory +{ + public override bool CanConvert(Type typeToConvert) + { + return typeToConvert.IsGenericType && typeToConvert.GetGenericTypeDefinition() == typeof(JsonPatchDocument<>); + } + + public override JsonConverter? CreateConverter(Type typeToConvert, JsonSerializerOptions options) + { + var modelType = typeToConvert.GetGenericArguments()[0]; + var conveterType = typeof(JsonPatchDocumentConverter<>).MakeGenericType(modelType); + return (JsonConverter?)Activator.CreateInstance(conveterType); + } +} diff --git a/src/Tingle.AspNetCore.JsonPatch/Converters/TypedJsonPatchMergeDocumentConverter.cs b/src/Tingle.AspNetCore.JsonPatch/Converters/TypedJsonPatchMergeDocumentConverter.cs new file mode 100644 index 0000000..b02edce --- /dev/null +++ b/src/Tingle.AspNetCore.JsonPatch/Converters/TypedJsonPatchMergeDocumentConverter.cs @@ -0,0 +1,19 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace Tingle.AspNetCore.JsonPatch.Converters; + +internal class TypedJsonPatchMergeDocumentConverter : JsonConverterFactory +{ + public override bool CanConvert(Type typeToConvert) + { + return typeToConvert.IsGenericType && typeToConvert.GetGenericTypeDefinition() == typeof(JsonPatchMergeDocument<>); + } + + public override JsonConverter? CreateConverter(Type typeToConvert, JsonSerializerOptions options) + { + var modelType = typeToConvert.GetGenericArguments()[0]; + var conveterType = typeof(JsonPatchMergeDocumentConverter<>).MakeGenericType(modelType); + return (JsonConverter?)Activator.CreateInstance(conveterType); + } +} diff --git a/src/Tingle.AspNetCore.JsonPatch/Exceptions/JsonPatchException.cs b/src/Tingle.AspNetCore.JsonPatch/Exceptions/JsonPatchException.cs new file mode 100644 index 0000000..c685198 --- /dev/null +++ b/src/Tingle.AspNetCore.JsonPatch/Exceptions/JsonPatchException.cs @@ -0,0 +1,23 @@ +using Tingle.AspNetCore.JsonPatch.Operations; + +namespace Tingle.AspNetCore.JsonPatch.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/Tingle.AspNetCore.JsonPatch/Helpers/GetValueResult.cs b/src/Tingle.AspNetCore.JsonPatch/Helpers/GetValueResult.cs new file mode 100644 index 0000000..4208682 --- /dev/null +++ b/src/Tingle.AspNetCore.JsonPatch/Helpers/GetValueResult.cs @@ -0,0 +1,21 @@ +namespace Tingle.AspNetCore.JsonPatch.Helpers; + +/// +/// Return value for the helper method used by Copy/Move. Needed to ensure we can make a different +/// decision in the calling method when the value is null because it cannot be fetched (HasError = true) +/// versus when it actually is null (much like why RemovedPropertyTypeResult is used for returning +/// type in the Remove operation). +/// +public class GetValueResult(object propertyValue, bool hasError) +{ + + /// + /// The value of the property we're trying to get + /// + public object PropertyValue { get; private set; } = propertyValue; + + /// + /// HasError: true when an error occurred, the operation didn't complete successfully + /// + public bool HasError { get; private set; } = hasError; +} diff --git a/src/Tingle.AspNetCore.JsonPatch/Helpers/JsonPatchProperty.cs b/src/Tingle.AspNetCore.JsonPatch/Helpers/JsonPatchProperty.cs new file mode 100644 index 0000000..88c74d6 --- /dev/null +++ b/src/Tingle.AspNetCore.JsonPatch/Helpers/JsonPatchProperty.cs @@ -0,0 +1,19 @@ +using System.Text.Json; + +namespace Tingle.AspNetCore.JsonPatch; + +/// +/// Metadata for JsonProperty. +/// +public class JsonPatchProperty(JsonProperty property, object parent) +{ + /// + /// Gets or sets JsonProperty. + /// + public JsonProperty Property { get; set; } = property; + + /// + /// Gets or sets Parent. + /// + public object Parent { get; set; } = parent ?? throw new ArgumentNullException(nameof(parent)); +} diff --git a/src/Tingle.AspNetCore.JsonPatch/IJsonPatchDocument.cs b/src/Tingle.AspNetCore.JsonPatch/IJsonPatchDocument.cs new file mode 100644 index 0000000..1715879 --- /dev/null +++ b/src/Tingle.AspNetCore.JsonPatch/IJsonPatchDocument.cs @@ -0,0 +1,11 @@ +using System.Text.Json; +using Tingle.AspNetCore.JsonPatch.Operations; + +namespace Tingle.AspNetCore.JsonPatch; + +public interface IJsonPatchDocument +{ + JsonSerializerOptions SerializerOptions { get; set; } + + IList GetOperations(); +} diff --git a/src/Tingle.AspNetCore.JsonPatch/IJsonPatchMergeDocument.cs b/src/Tingle.AspNetCore.JsonPatch/IJsonPatchMergeDocument.cs new file mode 100644 index 0000000..549f8f5 --- /dev/null +++ b/src/Tingle.AspNetCore.JsonPatch/IJsonPatchMergeDocument.cs @@ -0,0 +1,11 @@ +using System.Text.Json; +using Tingle.AspNetCore.JsonPatch.Operations; + +namespace Tingle.AspNetCore.JsonPatch; + +public interface IJsonPatchMergeDocument +{ + JsonSerializerOptions SerializerOptions { get; set; } + + IList GetOperations(); +} diff --git a/src/Tingle.AspNetCore.JsonPatch/IMvcBuilderExtensions.cs b/src/Tingle.AspNetCore.JsonPatch/IMvcBuilderExtensions.cs new file mode 100644 index 0000000..b19e5f8 --- /dev/null +++ b/src/Tingle.AspNetCore.JsonPatch/IMvcBuilderExtensions.cs @@ -0,0 +1,63 @@ +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.ApiExplorer; +using Microsoft.Extensions.DependencyInjection.Extensions; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using Tingle.AspNetCore.JsonPatch; + +namespace Microsoft.Extensions.DependencyInjection; + +/// Extension methods for . +public static class IMvcBuilderExtensions +{ + /// + /// Adds JSON Patch support via System.Text.Json library. + /// + /// The . + /// The . + public static IMvcBuilder AddJsonPatch(this IMvcBuilder builder) + { + var services = builder.Services; + + services.TryAddEnumerable( + ServiceDescriptor.Transient()); + + services.TryAddEnumerable( + ServiceDescriptor.Transient()); + + services.TryAddEnumerable( + ServiceDescriptor.Transient, JsonPatchMvcOptionsSetup>()); + + return builder; + } + + private class JsonPatchMvcOptionsSetup : IConfigureOptions + { + private readonly ILoggerFactory loggerFactory; + private readonly JsonOptions jsonOptions; + + public JsonPatchMvcOptionsSetup(ILoggerFactory loggerFactory, IOptions jsonOptions) + { + this.loggerFactory = loggerFactory ?? throw new ArgumentNullException(nameof(loggerFactory)); + this.jsonOptions = jsonOptions?.Value ?? throw new ArgumentNullException(nameof(jsonOptions)); ; + } + + public void Configure(MvcOptions options) + { + // Register patch input formatters before SystemTextJsonInputFormatter, otherwise + // SystemTextJsonInputFormatter would consume "application/json-patch+json" requests first + + options.InputFormatters.Insert( + 0, + new SystemTextJsonPatchInputFormatter( + jsonOptions, + loggerFactory.CreateLogger())); + + options.InputFormatters.Insert( + 0, + new SystemTextJsonPatchMergeInputFormatter( + jsonOptions, + loggerFactory.CreateLogger())); + } + } +} diff --git a/src/Tingle.AspNetCore.JsonPatch/Internal/ClosedGenericMatcher.cs b/src/Tingle.AspNetCore.JsonPatch/Internal/ClosedGenericMatcher.cs new file mode 100644 index 0000000..11bdddf --- /dev/null +++ b/src/Tingle.AspNetCore.JsonPatch/Internal/ClosedGenericMatcher.cs @@ -0,0 +1,92 @@ +namespace Tingle.AspNetCore.JsonPatch.Internal; + +/// +/// Helper related to generic interface definitions and implementing classes. +/// +internal static class ClosedGenericMatcher +{ + /// + /// Determine whether is or implements a closed generic + /// created from . + /// + /// The of interest. + /// The open generic to match. Usually an interface. + /// + /// The closed generic created from that + /// is or implements. null if the two s have no such + /// relationship. + /// + /// + /// This method will return if is + /// typeof(KeyValuePair{,}), and is + /// typeof(KeyValuePair{string, object}). + /// + public static Type? ExtractGenericInterface(Type queryType, Type interfaceType) + { + ArgumentNullException.ThrowIfNull(queryType); + + ArgumentNullException.ThrowIfNull(interfaceType); + + if (IsGenericInstantiation(queryType, interfaceType)) + { + // queryType matches (i.e. is a closed generic type created from) the open generic type. + return queryType; + } + + // Otherwise check all interfaces the type implements for a match. + // - If multiple different generic instantiations exists, we want the most derived one. + // - If that doesn't break the tie, then we sort alphabetically so that it's deterministic. + // + // We do this by looking at interfaces on the type, and recursing to the base type + // if we don't find any matches. + return GetGenericInstantiation(queryType, interfaceType); + } + + private static bool IsGenericInstantiation(Type candidate, Type interfaceType) + { + return + candidate.IsGenericType && + candidate.GetGenericTypeDefinition() == interfaceType; + } + + private static Type? GetGenericInstantiation(Type queryType, Type interfaceType) + { + Type? bestMatch = null; + var interfaces = queryType.GetInterfaces(); + foreach (var @interface in interfaces) + { + if (IsGenericInstantiation(@interface, interfaceType)) + { + if (bestMatch == null) + { + bestMatch = @interface; + } + else if (StringComparer.Ordinal.Compare(@interface.FullName, bestMatch.FullName) < 0) + { + bestMatch = @interface; + } + else + { + // There are two matches at this level of the class hierarchy, but @interface is after + // bestMatch in the sort order. + } + } + } + + if (bestMatch != null) + { + return bestMatch; + } + + // BaseType will be null for object and interfaces, which means we've reached 'bottom'. + var baseType = queryType?.BaseType; + if (baseType == null) + { + return null; + } + else + { + return GetGenericInstantiation(baseType, interfaceType); + } + } +} diff --git a/src/Tingle.AspNetCore.JsonPatch/Internal/ConversionResult.cs b/src/Tingle.AspNetCore.JsonPatch/Internal/ConversionResult.cs new file mode 100644 index 0000000..119cc00 --- /dev/null +++ b/src/Tingle.AspNetCore.JsonPatch/Internal/ConversionResult.cs @@ -0,0 +1,14 @@ +using System.Diagnostics.CodeAnalysis; + +namespace Tingle.AspNetCore.JsonPatch.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 class ConversionResult(bool canBeConverted, object? convertedInstance) +{ + [MemberNotNullWhen(true, nameof(ConvertedInstance))] + public bool CanBeConverted { get; } = canBeConverted; + public object? ConvertedInstance { get; } = convertedInstance; +} diff --git a/src/Tingle.AspNetCore.JsonPatch/Internal/ConversionResultProvider.cs b/src/Tingle.AspNetCore.JsonPatch/Internal/ConversionResultProvider.cs new file mode 100644 index 0000000..c723bd1 --- /dev/null +++ b/src/Tingle.AspNetCore.JsonPatch/Internal/ConversionResultProvider.cs @@ -0,0 +1,88 @@ +using System.ComponentModel; +using System.Text.Json; +using System.Text.Json.Nodes; + +namespace Tingle.AspNetCore.JsonPatch.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 static class ConversionResultProvider +{ + internal static ConversionResult ConvertTo(object? value, Type typeToConvertTo, JsonSerializerOptions serializerOptions) + { + if (value == null) return new ConversionResult(IsNullableType(typeToConvertTo), null); + + // If already in the type, not conversion required + if (typeToConvertTo.IsAssignableFrom(value.GetType())) + { + // No need to convert + return new ConversionResult(true, value); + } + + // If type conversion can work, it is faster + var converter = TypeDescriptor.GetConverter(typeToConvertTo); + if (converter.CanConvertFrom(value.GetType())) + { + try + { + var converted = converter.ConvertFrom(value); + return new ConversionResult(true, converted); + } + catch { } // fall back to next conversion mechanism + } + + // Lastly, try out + try + { + object? deserialized; + if (typeToConvertTo == typeof(JsonNode)) deserialized = JsonSerializer.SerializeToNode(value, serializerOptions); + else if (typeToConvertTo == typeof(JsonDocument)) deserialized = JsonSerializer.SerializeToDocument(value, serializerOptions); + else if (typeToConvertTo == typeof(JsonElement)) deserialized = JsonSerializer.SerializeToElement(value, serializerOptions); + else deserialized = JsonSerializer.Deserialize(JsonSerializer.Serialize(value, serializerOptions), 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); + } + else if (typeToConvertTo.IsAssignableFrom(value.GetType())) + { + // Keep original type + targetType = value.GetType(); + } + try + { + var deserialized = JsonSerializer.Deserialize(json: JsonSerializer.Serialize(value: value), returnType: 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<>); + } + else + { + // reference types are always nullable + return true; + } + } +} diff --git a/src/Tingle.AspNetCore.JsonPatch/Internal/DictionaryAdapterOfTU.cs b/src/Tingle.AspNetCore.JsonPatch/Internal/DictionaryAdapterOfTU.cs new file mode 100644 index 0000000..9066d79 --- /dev/null +++ b/src/Tingle.AspNetCore.JsonPatch/Internal/DictionaryAdapterOfTU.cs @@ -0,0 +1,272 @@ +using System.Diagnostics.CodeAnalysis; +using System.Text.Json; +using Tingle.AspNetCore.JsonPatch.Properties; + +namespace Tingle.AspNetCore.JsonPatch.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 class DictionaryAdapter : IAdapter +{ + public virtual bool TryAdd( + object target, + string segment, + JsonSerializerOptions serializerOptions, + object? value, + out string? errorMessage) + { + var key = MakeKeyFromSegment(target, segment, serializerOptions); + var dictionary = (IDictionary)target; + + // As per JsonPatch spec, if a key already exists, adding should replace the existing value + if (!TryConvertKey(key, serializerOptions, 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 TryCreate( + object target, + string segment, + JsonSerializerOptions serializerOptions, + out object? nextTarget, + out string? errorMessage) + { + var key = MakeKeyFromSegment(target, segment, serializerOptions); + var dictionary = (IDictionary)target; + + if (!TryConvertKey(key, serializerOptions, out var convertedKey, out errorMessage)) + { + nextTarget = null; + return false; + } + + if (dictionary.ContainsKey(convertedKey)) + { + nextTarget = null; + return true; + } + + try + { + nextTarget = dictionary[convertedKey] = Activator.CreateInstance(); + } + catch (Exception) + { + nextTarget = null; + return false; + } + + errorMessage = null; + return true; + } + + public virtual bool TryGet( + object target, + string segment, + JsonSerializerOptions serializerOptions, + out object? value, + out string? errorMessage) + { + var key = MakeKeyFromSegment(target, segment, serializerOptions); + var dictionary = (IDictionary)target; + + if (!TryConvertKey(key, serializerOptions, 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 = MakeKeyFromSegment(target, segment, serializerOptions); + var dictionary = (IDictionary)target; + + if (!TryConvertKey(key, serializerOptions, 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 = MakeKeyFromSegment(target, segment, serializerOptions); + var dictionary = (IDictionary)target; + + if (!TryConvertKey(key, serializerOptions, 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 = MakeKeyFromSegment(target, segment, serializerOptions); + var dictionary = (IDictionary)target; + + if (!TryConvertKey(key, serializerOptions, out var convertedKey, out errorMessage)) + { + return false; + } + + // As per JsonPatch spec, the target location must exist for test to be successful + if (!dictionary.ContainsKey(convertedKey)) + { + errorMessage = Resources.FormatTargetLocationAtPathSegmentNotFound(segment); + return false; + } + + if (!TryConvertValue(value, serializerOptions, out var convertedValue, out errorMessage)) + { + return false; + } + + var currentValue = dictionary[convertedKey]; + + // The target segment does not have an assigned value to compare the test value with + if (currentValue == null) + { + errorMessage = Resources.FormatValueForTargetSegmentCannotBeNullOrEmpty(segment); + return false; + } + + var comparer = new JsonElementComparer(); + if (!comparer.Equals(JsonDocument.Parse(JsonSerializer.Serialize(currentValue, serializerOptions)).RootElement, JsonDocument.Parse(JsonSerializer.Serialize(convertedValue, serializerOptions)).RootElement)) + { + 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 key = MakeKeyFromSegment(target, segment, serializerOptions); + var dictionary = (IDictionary)target; + + if (!TryConvertKey(key, serializerOptions, out var convertedKey, out errorMessage)) + { + nextTarget = null; + return false; + } + + if (dictionary.TryGetValue(convertedKey, out var valueAsT)) + { + nextTarget = valueAsT; + errorMessage = null; + return true; + } + + nextTarget = null; + errorMessage = null; + return false; + } + + protected virtual bool TryConvertKey(string key, JsonSerializerOptions serializerOptions, [NotNullWhen(true)] out TKey? convertedKey, out string? errorMessage) + { + var conversionResult = ConversionResultProvider.ConvertTo(key, typeof(TKey), serializerOptions); + if (conversionResult.CanBeConverted) + { + errorMessage = null; + convertedKey = (TKey)conversionResult.ConvertedInstance; + return true; + } + + errorMessage = Resources.FormatInvalidPathSegment(key); + convertedKey = default; + return false; + } + + 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; + } + + errorMessage = Resources.FormatInvalidValueForProperty(value); + convertedValue = default; + return false; + } + + private static string MakeKeyFromSegment(object target, string segment, JsonSerializerOptions serializerOptions) + { + return target is System.Dynamic.ExpandoObject + ? serializerOptions.PropertyNamingPolicy?.ConvertName(segment) ?? segment + : serializerOptions.DictionaryKeyPolicy?.ConvertName(segment) ?? segment; + } +} diff --git a/src/Tingle.AspNetCore.JsonPatch/Internal/DynamicGetMemberBinder.cs b/src/Tingle.AspNetCore.JsonPatch/Internal/DynamicGetMemberBinder.cs new file mode 100644 index 0000000..6e4dd97 --- /dev/null +++ b/src/Tingle.AspNetCore.JsonPatch/Internal/DynamicGetMemberBinder.cs @@ -0,0 +1,11 @@ +using System.Dynamic; + +namespace Tingle.AspNetCore.JsonPatch.Internal; + +internal class DynamicGetMemberBinder(string name, bool ignoreCase) : GetMemberBinder(name, ignoreCase) +{ + public override DynamicMetaObject FallbackGetMember(DynamicMetaObject target, DynamicMetaObject? errorSuggestion) + { + throw new InvalidOperationException(typeof(DynamicGetMemberBinder).FullName + ".FallbackGetMember"); + } +} diff --git a/src/Tingle.AspNetCore.JsonPatch/Internal/DynamicObjectAdapter.cs b/src/Tingle.AspNetCore.JsonPatch/Internal/DynamicObjectAdapter.cs new file mode 100644 index 0000000..f7472fb --- /dev/null +++ b/src/Tingle.AspNetCore.JsonPatch/Internal/DynamicObjectAdapter.cs @@ -0,0 +1,255 @@ +using Microsoft.CSharp.RuntimeBinder; +using System.Diagnostics.CodeAnalysis; +using System.Dynamic; +using System.Runtime.CompilerServices; +using System.Text.Json; +using Tingle.AspNetCore.JsonPatch.Properties; + +namespace Tingle.AspNetCore.JsonPatch.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 class DynamicObjectAdapter : IAdapter +{ + public virtual bool TryAdd( + object target, + string segment, + JsonSerializerOptions serializerOptions, + object? value, + out string? errorMessage) + { + if (!TrySetDynamicObjectProperty(target, serializerOptions, segment, value, out errorMessage)) + { + return false; + } + + errorMessage = null; + return true; + } + + public virtual bool TryCreate( + object target, + string segment, + JsonSerializerOptions serializerOptions, + out object? nextTarget, + out string? errorMessage) + { + if (TryGetDynamicObjectProperty(target, serializerOptions, segment, out nextTarget, out errorMessage)) + { + return true; + } + + nextTarget = new ExpandoObject(); + if (!TrySetDynamicObjectProperty(target, serializerOptions, segment, nextTarget, out errorMessage)) + { + return false; + } + + errorMessage = null; + return true; + } + + public virtual bool TryGet( + object target, + string segment, + JsonSerializerOptions serializerOptions, + out object? value, + out string? errorMessage) + { + if (!TryGetDynamicObjectProperty(target, serializerOptions, segment, out value, out errorMessage)) + { + value = null; + return false; + } + + errorMessage = null; + return true; + } + + public virtual bool TryRemove( + object target, + string segment, + JsonSerializerOptions serializerOptions, + out string? errorMessage) + { + if (!TryGetDynamicObjectProperty(target, serializerOptions, segment, out var property, out errorMessage)) + { + 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 (property.GetType().IsValueType + && Nullable.GetUnderlyingType(property.GetType()) == null) + { + value = Activator.CreateInstance(property.GetType()); + } + + if (!TrySetDynamicObjectProperty(target, serializerOptions, segment, value, out errorMessage)) + { + return false; + } + + errorMessage = null; + return true; + } + + public virtual bool TryReplace( + object target, + string segment, + JsonSerializerOptions serializerOptions, + object? value, + out string? errorMessage) + { + if (!TryGetDynamicObjectProperty(target, serializerOptions, segment, out var property, out errorMessage)) + { + return false; + } + + if (!TryConvertValue(value, property.GetType(), serializerOptions, out var convertedValue)) + { + errorMessage = Resources.FormatInvalidValueForProperty(value); + return false; + } + + if (!TryRemove(target, segment, serializerOptions, out errorMessage)) + { + return false; + } + + if (!TrySetDynamicObjectProperty(target, serializerOptions, segment, convertedValue, out errorMessage)) + { + return false; + } + + errorMessage = null; + return true; + } + + public virtual bool TryTest( + object target, + string segment, + JsonSerializerOptions serializerOptions, + object? value, + out string? errorMessage) + { + if (!TryGetDynamicObjectProperty(target, serializerOptions, segment, out var property, out errorMessage)) + { + return false; + } + + if (!TryConvertValue(value, property.GetType(), serializerOptions, out var convertedValue)) + { + errorMessage = Resources.FormatInvalidValueForProperty(value); + return false; + } + + var comparer = new JsonElementComparer(); + if (!comparer.Equals(JsonDocument.Parse(JsonSerializer.Serialize(property, serializerOptions)).RootElement, JsonDocument.Parse(JsonSerializer.Serialize(convertedValue, serializerOptions)).RootElement)) + { + errorMessage = Resources.FormatValueNotEqualToTestValue(property, value, segment); + return false; + } + + errorMessage = null; + return true; + } + + public virtual bool TryTraverse( + object target, + string segment, + JsonSerializerOptions serializerOptions, + out object? nextTarget, + out string? errorMessage) + { + if (!TryGetDynamicObjectProperty(target, serializerOptions, segment, out var property, out errorMessage)) + { + nextTarget = null; + return false; + } + + nextTarget = property; + errorMessage = null; + return true; + } + + protected virtual bool TryGetDynamicObjectProperty( + object target, + JsonSerializerOptions serializerOptions, + string segment, + [NotNullWhen(true)] out object? value, + out string? errorMessage) + { + var propertyName = serializerOptions.PropertyNamingPolicy?.ConvertName(segment) ?? segment; + + var binder = Binder.GetMember( + CSharpBinderFlags.None, + propertyName, + target.GetType(), + [CSharpArgumentInfo.Create(CSharpArgumentInfoFlags.None, null)]); + + var callsite = CallSite>.Create(binder); + + try + { + value = callsite.Target(callsite, target); + errorMessage = null; + return true; + } + catch (RuntimeBinderException) + { + value = null; + errorMessage = Resources.FormatTargetLocationAtPathSegmentNotFound(segment); + return false; + } + } + + protected virtual bool TrySetDynamicObjectProperty( + object target, + JsonSerializerOptions serializerOptions, + string segment, + object? value, + out string? errorMessage) + { + var propertyName = serializerOptions.PropertyNamingPolicy?.ConvertName(segment) ?? segment; + + var binder = Binder.SetMember( + CSharpBinderFlags.None, + propertyName, + target.GetType(), + [ + CSharpArgumentInfo.Create(CSharpArgumentInfoFlags.None, null), + CSharpArgumentInfo.Create(CSharpArgumentInfoFlags.None, null) + ]); + + var callsite = CallSite>.Create(binder); + + try + { + callsite.Target(callsite, target, value); + errorMessage = null; + return true; + } + catch (RuntimeBinderException) + { + errorMessage = Resources.FormatTargetLocationAtPathSegmentNotFound(segment); + 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; + } +} diff --git a/src/Tingle.AspNetCore.JsonPatch/Internal/DynamicSetMemberBinder.cs b/src/Tingle.AspNetCore.JsonPatch/Internal/DynamicSetMemberBinder.cs new file mode 100644 index 0000000..30c0af3 --- /dev/null +++ b/src/Tingle.AspNetCore.JsonPatch/Internal/DynamicSetMemberBinder.cs @@ -0,0 +1,11 @@ +using System.Dynamic; + +namespace Tingle.AspNetCore.JsonPatch.Internal; + +internal class DynamicSetMemberBinder(string name, bool ignoreCase) : SetMemberBinder(name, ignoreCase) +{ + public override DynamicMetaObject FallbackSetMember(DynamicMetaObject target, DynamicMetaObject value, DynamicMetaObject? errorSuggestion) + { + throw new InvalidOperationException(typeof(DynamicSetMemberBinder).FullName + ".FallbackGetMember"); + } +} diff --git a/src/Tingle.AspNetCore.JsonPatch/Internal/ErrorReporter.cs b/src/Tingle.AspNetCore.JsonPatch/Internal/ErrorReporter.cs new file mode 100644 index 0000000..0a582f6 --- /dev/null +++ b/src/Tingle.AspNetCore.JsonPatch/Internal/ErrorReporter.cs @@ -0,0 +1,11 @@ +using Tingle.AspNetCore.JsonPatch.Exceptions; + +namespace Tingle.AspNetCore.JsonPatch.Internal; + +internal static class ErrorReporter +{ + public static readonly Action Default = (error) => + { + throw new JsonPatchException(error); + }; +} diff --git a/src/Tingle.AspNetCore.JsonPatch/Internal/IAdapter.cs b/src/Tingle.AspNetCore.JsonPatch/Internal/IAdapter.cs new file mode 100644 index 0000000..95f9d29 --- /dev/null +++ b/src/Tingle.AspNetCore.JsonPatch/Internal/IAdapter.cs @@ -0,0 +1,58 @@ +using System.Text.Json; + +namespace Tingle.AspNetCore.JsonPatch.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 TryCreate( + 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/Tingle.AspNetCore.JsonPatch/Internal/JsonElementComparer.cs b/src/Tingle.AspNetCore.JsonPatch/Internal/JsonElementComparer.cs new file mode 100644 index 0000000..b8d18cf --- /dev/null +++ b/src/Tingle.AspNetCore.JsonPatch/Internal/JsonElementComparer.cs @@ -0,0 +1,120 @@ +using System.Text.Json; + +namespace Tingle.AspNetCore.JsonPatch.Internal; + +// See https://stackoverflow.com/questions/60580743/what-is-equivalent-in-jtoken-deepequals-in-system-text-json +public class JsonElementComparer(int maxHashDepth) : IEqualityComparer +{ + public JsonElementComparer() : this(-1) { } + + int MaxHashDepth { get; } = maxHashDepth; + + #region IEqualityComparer Members + + public bool Equals(JsonElement x, JsonElement y) + { + if (x.ValueKind != y.ValueKind) + return false; + switch (x.ValueKind) + { + case JsonValueKind.Null: + case JsonValueKind.True: + case JsonValueKind.False: + case JsonValueKind.Undefined: + return true; + + // Compare the raw values of numbers, and the text of strings. + // Note this means that 0.0 will differ from 0.00 -- which may be correct as deserializing either to `decimal` will result in subtly different results. + // Newtonsoft's JValue.Compare(JTokenType valueType, object? objA, object? objB) has logic for detecting "equivalent" values, + // you may want to examine it to see if anything there is required here. + // https://github.com/JamesNK/Newtonsoft.Json/blob/master/Src/Newtonsoft.Json/Linq/JValue.cs#L246 + case JsonValueKind.Number: + return x.GetRawText() == y.GetRawText(); + + case JsonValueKind.String: + return x.GetString() == y.GetString(); // Do not use GetRawText() here, it does not automatically resolve JSON escape sequences to their corresponding characters. + + case JsonValueKind.Array: + return x.EnumerateArray().SequenceEqual(y.EnumerateArray(), this); + + case JsonValueKind.Object: + { + // Surprisingly, JsonDocument fully supports duplicate property names. + // I.e. it's perfectly happy to parse {"Value":"a", "Value" : "b"} and will store both + // key/value pairs inside the document! + // A close reading of https://www.rfc-editor.org/rfc/rfc8259#section-4 seems to indicate that + // such objects are allowed but not recommended, and when they arise, interpretation of + // identically-named properties is order-dependent. + // So stably sorting by name then comparing values seems the way to go. + var xPropertiesUnsorted = x.EnumerateObject().ToList(); + var yPropertiesUnsorted = y.EnumerateObject().ToList(); + if (xPropertiesUnsorted.Count != yPropertiesUnsorted.Count) + return false; + var xProperties = xPropertiesUnsorted.OrderBy(p => p.Name, StringComparer.Ordinal); + var yProperties = yPropertiesUnsorted.OrderBy(p => p.Name, StringComparer.Ordinal); + foreach (var (px, py) in xProperties.Zip(yProperties)) + { + if (px.Name != py.Name) + return false; + if (!Equals(px.Value, py.Value)) + return false; + } + return true; + } + + default: + throw new JsonException(string.Format("Unknown JsonValueKind {0}", x.ValueKind)); + } + } + + public int GetHashCode(JsonElement obj) + { + var hash = new HashCode(); // New in .Net core: https://docs.microsoft.com/en-us/dotnet/api/system.hashcode + ComputeHashCode(obj, ref hash, 0); + return hash.ToHashCode(); + } + + void ComputeHashCode(JsonElement obj, ref HashCode hash, int depth) + { + hash.Add(obj.ValueKind); + + switch (obj.ValueKind) + { + case JsonValueKind.Null: + case JsonValueKind.True: + case JsonValueKind.False: + case JsonValueKind.Undefined: + break; + + case JsonValueKind.Number: + hash.Add(obj.GetRawText()); + break; + + case JsonValueKind.String: + hash.Add(obj.GetString()); + break; + + case JsonValueKind.Array: + if (depth != MaxHashDepth) + foreach (var item in obj.EnumerateArray()) + ComputeHashCode(item, ref hash, depth + 1); + else + hash.Add(obj.GetArrayLength()); + break; + + case JsonValueKind.Object: + foreach (var property in obj.EnumerateObject().OrderBy(p => p.Name, StringComparer.Ordinal)) + { + hash.Add(property.Name); + if (depth != MaxHashDepth) + ComputeHashCode(property.Value, ref hash, depth + 1); + } + break; + + default: + throw new JsonException(string.Format("Unknown JsonValueKind {0}", obj.ValueKind)); + } + } + + #endregion +} diff --git a/src/Tingle.AspNetCore.JsonPatch/Internal/JsonObjectAdapter.cs b/src/Tingle.AspNetCore.JsonPatch/Internal/JsonObjectAdapter.cs new file mode 100644 index 0000000..aa801d1 --- /dev/null +++ b/src/Tingle.AspNetCore.JsonPatch/Internal/JsonObjectAdapter.cs @@ -0,0 +1,157 @@ +using System.Text.Json; +using System.Text.Json.Nodes; +using Tingle.AspNetCore.JsonPatch.Properties; + +namespace Tingle.AspNetCore.JsonPatch.Internal; + +public class JsonObjectAdapter : IAdapter +{ + public virtual bool TryAdd( + object target, + string segment, + JsonSerializerOptions serializerOptions, + object? value, + out string? errorMessage) + { + var obj = (JsonObject)target; + + obj[segment] = value != null ? JsonSerializer.SerializeToNode(value) : null; + + errorMessage = null; + return true; + } + + public virtual bool TryCreate( + object target, + string segment, + JsonSerializerOptions serializerOptions, + out object? nextTarget, + out string? errorMessage) + { + var obj = (JsonObject)target; + + if (obj.TryGetPropertyValue(segment, out var valueAsNode)) + { + nextTarget = valueAsNode; + errorMessage = null; + return true; + } + + nextTarget = obj[segment] = new JsonObject(); + 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 valueAsNode)) + { + value = null; + errorMessage = Resources.FormatTargetLocationAtPathSegmentNotFound(segment); + return false; + } + + value = valueAsNode; + 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 ? JsonValue.Create(value) : JsonValue.Create(null); + + 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; + } + + var comparer = new JsonElementComparer(); + if (!comparer.Equals(JsonDocument.Parse(JsonSerializer.Serialize(currentValue, serializerOptions)).RootElement, JsonDocument.Parse(JsonSerializer.Serialize(value, serializerOptions)).RootElement)) + { + 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 nextTargetNode)) + { + nextTarget = null; + errorMessage = null; + return false; + } + + nextTarget = nextTargetNode; + errorMessage = null; + return true; + } +} diff --git a/src/Tingle.AspNetCore.JsonPatch/Internal/ListAdapter.cs b/src/Tingle.AspNetCore.JsonPatch/Internal/ListAdapter.cs new file mode 100644 index 0000000..ce0a5e4 --- /dev/null +++ b/src/Tingle.AspNetCore.JsonPatch/Internal/ListAdapter.cs @@ -0,0 +1,361 @@ +using System.Collections; +using System.Diagnostics.CodeAnalysis; +using System.Text.Json; +using Tingle.AspNetCore.JsonPatch.Properties; + +namespace Tingle.AspNetCore.JsonPatch.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 class ListAdapter : IAdapter +{ + public virtual bool TryAdd( + object target, + string segment, + JsonSerializerOptions serializerOptions, + object? value, + out string? errorMessage) + { + var list = (IList)target; + + if (!TryGetListTypeArgument(list, out var typeArgument, out errorMessage)) + { + return false; + } + + if (!TryGetPositionInfo(list, 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) + { + list.Add(convertedValue); + } + else + { + list.Insert(positionInfo.Index, convertedValue); + } + + errorMessage = null; + return true; + } + + public virtual bool TryGet( + object target, + string segment, + JsonSerializerOptions serializerOptions, + out object? value, + out string? errorMessage) + { + var list = (IList)target; + if (!TryGetListTypeArgument(list, out _, out errorMessage)) + { + value = null; + return false; + } + + if (!TryGetPositionInfo(list, segment, OperationType.Get, out var positionInfo, out errorMessage)) + { + value = null; + return false; + } + + if (positionInfo.Type == PositionType.EndOfList) + { + value = list[list.Count - 1]; + } + else + { + value = list[positionInfo.Index]; + } + + errorMessage = null; + return true; + } + + public virtual bool TryCreate( + object target, + string segment, + JsonSerializerOptions serializerOptions, + out object? nextTarget, + out string? errorMessage) + { + throw new NotImplementedException(); + } + + public virtual bool TryRemove( + object target, + string segment, + JsonSerializerOptions serializerOptions, + out string? errorMessage) + { + var list = (IList)target; + if (!TryGetListTypeArgument(list, out _, out errorMessage)) + { + return false; + } + + if (!TryGetPositionInfo(list, segment, OperationType.Remove, out var positionInfo, out errorMessage)) + { + return false; + } + + if (positionInfo.Type == PositionType.EndOfList) + { + list.RemoveAt(list.Count - 1); + } + else + { + list.RemoveAt(positionInfo.Index); + } + + errorMessage = null; + return true; + } + + public virtual bool TryReplace( + object target, + string segment, + JsonSerializerOptions serializerOptions, + object? value, + out string? errorMessage) + { + var list = (IList)target; + + if (!TryGetListTypeArgument(list, out var typeArgument, out errorMessage)) + { + return false; + } + + if (!TryGetPositionInfo(list, segment, OperationType.Replace, 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) + { + list[list.Count - 1] = convertedValue; + } + else + { + list[positionInfo.Index] = convertedValue; + } + + errorMessage = null; + return true; + } + + public virtual bool TryTest( + object target, + string segment, + JsonSerializerOptions serializerOptions, + object? value, + out string? errorMessage) + { + var list = (IList)target; + + if (!TryGetListTypeArgument(list, out var typeArgument, out errorMessage)) + { + return false; + } + + if (!TryGetPositionInfo(list, 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 = list[positionInfo.Index]; + var comparer = new JsonElementComparer(); + if (!comparer.Equals(JsonDocument.Parse(JsonSerializer.Serialize(currentValue, serializerOptions)).RootElement, JsonDocument.Parse(JsonSerializer.Serialize(convertedValue, serializerOptions)).RootElement)) + { + 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) + { + if (target is not IList list) + { + value = null; + errorMessage = null; + return false; + } + + var index = -1; + if (!int.TryParse(segment, out 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, + 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; + } + + protected virtual bool TryGetListTypeArgument(IList list, [NotNullWhen(true)] out Type? listTypeArgument, out string? errorMessage) + { + // Arrays are not supported as they have fixed size and operations like Add, Insert do not make sense + var listType = list.GetType(); + if (listType.IsArray) + { + errorMessage = Resources.FormatPatchNotSupportedForArrays(listType.FullName); + listTypeArgument = null; + return false; + } + else + { + var genericList = ClosedGenericMatcher.ExtractGenericInterface(listType, typeof(IList<>)); + if (genericList == null) + { + errorMessage = Resources.FormatPatchNotSupportedForNonGenericLists(listType.FullName); + listTypeArgument = null; + return false; + } + else + { + listTypeArgument = genericList.GenericTypeArguments[0]; + errorMessage = null; + return true; + } + } + } + + protected virtual bool TryGetPositionInfo( + IList list, + string segment, + OperationType operationType, + out PositionInfo positionInfo, + out string? errorMessage) + { + if (segment == "-") + { + positionInfo = new PositionInfo(PositionType.EndOfList, -1); + errorMessage = null; + return true; + } + + var position = -1; + if (int.TryParse(segment, out position)) + { + if (position >= 0 && position < list.Count) + { + 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. + else if (position == list.Count && operationType == OperationType.Add) + { + positionInfo = new PositionInfo(PositionType.EndOfList, -1); + errorMessage = null; + return true; + } + else + { + 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(ListAdapter.PositionType type, int index) + { + public PositionType Type { get; } = type; + public int Index { get; } = index; + } + + /// + /// 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/Tingle.AspNetCore.JsonPatch/Internal/ObjectVisitor.cs b/src/Tingle.AspNetCore.JsonPatch/Internal/ObjectVisitor.cs new file mode 100644 index 0000000..a8f4e63 --- /dev/null +++ b/src/Tingle.AspNetCore.JsonPatch/Internal/ObjectVisitor.cs @@ -0,0 +1,71 @@ +using System.Diagnostics.CodeAnalysis; +using System.Text.Json; +using Tingle.AspNetCore.JsonPatch.Adapters; + +namespace Tingle.AspNetCore.JsonPatch.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. +/// +/// The path of the JsonPatch operation +/// The . +/// The to use when creating adaptors. +public class ObjectVisitor(ParsedPath path, JsonSerializerOptions serializerOptions, IAdapterFactory adapterFactory, bool create) +{ + private readonly IAdapterFactory adapterFactory = adapterFactory ?? throw new ArgumentNullException(nameof(adapterFactory)); + private readonly JsonSerializerOptions serializerOptions = serializerOptions ?? throw new ArgumentNullException(nameof(serializerOptions)); + + /// + /// Initializes a new instance of . + /// + /// The path of the JsonPatch operation + /// The . + public ObjectVisitor(ParsedPath path, JsonSerializerOptions serializerOptions, bool create) + : this(path, serializerOptions, AdapterFactory.Default, create) + { + } + + public bool TryVisit(ref object target, [NotNullWhen(true)] 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)) + { + if (!create || !adapter.TryCreate(target, path.Segments[i], serializerOptions, out 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, serializerOptions); + } +} diff --git a/src/Tingle.AspNetCore.JsonPatch/Internal/ParsedPath.cs b/src/Tingle.AspNetCore.JsonPatch/Internal/ParsedPath.cs new file mode 100644 index 0000000..a6c5cdf --- /dev/null +++ b/src/Tingle.AspNetCore.JsonPatch/Internal/ParsedPath.cs @@ -0,0 +1,86 @@ +using System.Text; +using Tingle.AspNetCore.JsonPatch.Exceptions; +using Tingle.AspNetCore.JsonPatch.Properties; + +namespace Tingle.AspNetCore.JsonPatch.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 readonly struct ParsedPath +{ + private readonly string[] _segments; + + public ParsedPath(string path) + { + ArgumentNullException.ThrowIfNull(path); + + _segments = ParsePath(path); + } + + public string? LastSegment + { + get + { + if (_segments == null || _segments.Length == 0) + { + return null; + } + + return _segments[^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/Tingle.AspNetCore.JsonPatch/Internal/PathHelpers.cs b/src/Tingle.AspNetCore.JsonPatch/Internal/PathHelpers.cs new file mode 100644 index 0000000..ac24b21 --- /dev/null +++ b/src/Tingle.AspNetCore.JsonPatch/Internal/PathHelpers.cs @@ -0,0 +1,28 @@ +using Tingle.AspNetCore.JsonPatch.Exceptions; +using Tingle.AspNetCore.JsonPatch.Properties; + +namespace Tingle.AspNetCore.JsonPatch.Helpers; + +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('/')) + { + return "/" + path; + } + else + { + return path; + } + } +} diff --git a/src/Tingle.AspNetCore.JsonPatch/Internal/PocoAdapter.cs b/src/Tingle.AspNetCore.JsonPatch/Internal/PocoAdapter.cs new file mode 100644 index 0000000..f8e6e2b --- /dev/null +++ b/src/Tingle.AspNetCore.JsonPatch/Internal/PocoAdapter.cs @@ -0,0 +1,259 @@ +using System.Diagnostics.CodeAnalysis; +using System.Text.Json; +using System.Text.Json.Nodes; +using Tingle.AspNetCore.JsonPatch.Properties; + +namespace Tingle.AspNetCore.JsonPatch.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 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.Writeable) + { + errorMessage = Resources.FormatCannotUpdateProperty(segment); + return false; + } + + if (!TryConvertValue(value, jsonProperty.PropertyType, serializerOptions, out var convertedValue)) + { + errorMessage = Resources.FormatInvalidValueForProperty(value); + return false; + } + + jsonProperty.SetValue(target, convertedValue); + + errorMessage = null; + return true; + } + + public virtual bool TryCreate( + object target, + string segment, + JsonSerializerOptions serializerOptions, + out object? nextTarget, + out string? errorMessage) + { + if (!TryGetJsonProperty(target, serializerOptions, segment, out var jsonProperty)) + { + nextTarget = null; + errorMessage = Resources.FormatTargetLocationAtPathSegmentNotFound(segment); + return false; + } + + if (!jsonProperty.Writeable) + { + nextTarget = null; + errorMessage = Resources.FormatCannotUpdateProperty(segment); + return false; + } + + nextTarget = jsonProperty.GetValue(target); + if (nextTarget is null) + { + nextTarget = Activator.CreateInstance(jsonProperty.PropertyType); + jsonProperty.SetValue(target, nextTarget); + } + + 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.Readable) + { + errorMessage = Resources.FormatCannotReadProperty(segment); + value = null; + return false; + } + + value = jsonProperty.GetValue(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.Writeable) + { + errorMessage = Resources.FormatCannotUpdateProperty(segment); + return false; + } + + jsonProperty.RemoveValue(target); + + 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.Writeable) + { + errorMessage = Resources.FormatCannotUpdateProperty(segment); + return false; + } + + if (!TryConvertValue(value, jsonProperty.PropertyType, serializerOptions, out var convertedValue)) + { + errorMessage = Resources.FormatInvalidValueForProperty(value); + return false; + } + + jsonProperty.SetValue(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.Readable) + { + errorMessage = Resources.FormatCannotReadProperty(segment); + return false; + } + + if (!TryConvertValue(value, jsonProperty.PropertyType, serializerOptions, out var convertedValue)) + { + errorMessage = Resources.FormatInvalidValueForProperty(value); + return false; + } + + var comparer = new JsonElementComparer(); + var currentValue = jsonProperty.GetValue(target); + if (!comparer.Equals(JsonDocument.Parse(JsonSerializer.Serialize(currentValue, serializerOptions)).RootElement, JsonDocument.Parse(JsonSerializer.Serialize(convertedValue, serializerOptions)).RootElement)) + { + errorMessage = Resources.FormatValueNotEqualToTestValue(JsonSerializer.SerializeToNode(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.GetValue(target); + errorMessage = null; + return true; + } + + value = null; + errorMessage = Resources.FormatTargetLocationAtPathSegmentNotFound(segment); + return false; + } + + protected virtual bool TryGetJsonProperty( + object target, + JsonSerializerOptions serializerOptions, + string segment, + [NotNullWhen(true)] out IPropertyProxy? jsonProperty) + { + return (jsonProperty = FindPropertyByName(segment, target, serializerOptions)) != null; + } + + private static IPropertyProxy? FindPropertyByName(string name, object target, JsonSerializerOptions serializerOptions) + { + var propertyName = serializerOptions.PropertyNamingPolicy?.ConvertName(name) ?? name; + + if (target is JsonArray jsonArray) + { + return new JsonArrayProxy(jsonArray, propertyName); + } + + if (target is JsonNode jsonElement) + { + return new JsonNodeProxy(jsonElement, propertyName); + } + + return PropertyProxyCache.GetPropertyProxy(target.GetType(), propertyName, serializerOptions); + } + + protected virtual bool TryConvertValue(object? value, Type propertyType, JsonSerializerOptions serializerOptions, [NotNullWhen(true)] out object? convertedValue) + { + var conversionResult = ConversionResultProvider.ConvertTo(value, propertyType, serializerOptions); + if (!conversionResult.CanBeConverted) + { + convertedValue = null; + return false; + } + + convertedValue = conversionResult.ConvertedInstance; + return true; + } +} diff --git a/src/Tingle.AspNetCore.JsonPatch/Internal/PropertyProxyCache.cs b/src/Tingle.AspNetCore.JsonPatch/Internal/PropertyProxyCache.cs new file mode 100644 index 0000000..bd03af8 --- /dev/null +++ b/src/Tingle.AspNetCore.JsonPatch/Internal/PropertyProxyCache.cs @@ -0,0 +1,123 @@ +using System.Collections.Concurrent; +using System.Globalization; +using System.Reflection; +using System.Text.Json; +using System.Text.Json.Nodes; +using System.Text.Json.Serialization; + +namespace Tingle.AspNetCore.JsonPatch.Internal; + +// inspired by https://github.com/Havunen/SystemTextJsonPatch/tree/main/SystemTextJsonPatch/Internal/Proxies + +internal static class PropertyProxyCache +{ + private static readonly ConcurrentDictionary CachedTypeProperties = new(); + private static readonly ConcurrentDictionary<(Type, string), PropertyProxy?> CachedPropertyProxies = new(); + + internal static PropertyProxy? GetPropertyProxy(Type type, string name, JsonSerializerOptions options) + { + var key = (type, name); + if (CachedPropertyProxies.TryGetValue(key, out var proxy)) return proxy; + + if (!CachedTypeProperties.TryGetValue(type, out var properties)) + { + properties = type.GetProperties(BindingFlags.Public | BindingFlags.Instance); + CachedTypeProperties[type] = properties; + } + + proxy = FindPropertyInfo(properties, name, options); + CachedPropertyProxies[key] = proxy; + + return proxy; + } + + private static PropertyProxy? FindPropertyInfo(PropertyInfo[] properties, string name, JsonSerializerOptions options) + { + // First check through all properties if property name matches JsonPropertyNameAttribute + foreach (var propertyInfo in properties) + { + var attr = propertyInfo.GetCustomAttribute(); + if (attr != null && string.Equals(attr.Name, name, StringComparison.OrdinalIgnoreCase)) + { + return new PropertyProxy(propertyInfo); + } + } + + // If it didn't find match by JsonPropertyName then use serialized name + foreach (var pi in properties) + { + if (options.PropertyNamingPolicy is not null && string.Equals(options.PropertyNamingPolicy.ConvertName(pi.Name), name)) + { + return new PropertyProxy(pi); + } + } + + // If it didn't find match by JsonPropertyName or serialized name then use property name + foreach (var pi in properties) + { + if (string.Equals(pi.Name, name, StringComparison.OrdinalIgnoreCase)) + { + return new PropertyProxy(pi); + } + } + + return null; + } +} + +public interface IPropertyProxy +{ + object? GetValue(object target); + void SetValue(object target, object? convertedValue); + void RemoveValue(object target); + bool Readable { get; } + bool Writeable { get; } + Type PropertyType { get; } +} + +internal sealed class PropertyProxy(PropertyInfo info) : IPropertyProxy +{ + public object? GetValue(object target) => info.GetValue(target); + public void SetValue(object target, object? convertedValue) => info.SetValue(target, convertedValue); + public void RemoveValue(object target) => info.SetValue(target, null); + + public bool Readable => info.CanRead; + public bool Writeable => info.CanWrite; + public Type PropertyType => info.PropertyType; +} + +internal sealed class JsonNodeProxy(JsonNode node, string name) : IPropertyProxy +{ + public object? GetValue(object target) => node[name]; + public void SetValue(object target, object? convertedValue) => node[name] = convertedValue != null ? JsonSerializer.SerializeToNode(convertedValue) : null; + public void RemoveValue(object target) => node.AsObject().Remove(name); + + public bool Readable => true; + public bool Writeable => true; + public Type PropertyType => typeof(JsonNode); +} + +internal sealed class JsonArrayProxy(JsonArray array, string name) : IPropertyProxy +{ + public object? GetValue(object target) => array[name == "-" ? array.Count - 1 : PropIndex]; + public void SetValue(object target, object? convertedValue) + { + var value = convertedValue == null ? null : JsonSerializer.SerializeToNode(convertedValue); + + if (name == "-") array.Add(value); + else + { + var idx = PropIndex; + + array.RemoveAt(idx); + array.Insert(idx, value); + } + } + public void RemoveValue(object target) => array.RemoveAt(index: name == "-" ? array.Count - 1 : PropIndex); + + public bool Readable => true; + public bool Writeable => true; + public Type PropertyType => typeof(JsonNode); + + private int PropIndex => int.Parse(name, NumberStyles.Number, CultureInfo.InvariantCulture); +} diff --git a/src/Tingle.AspNetCore.JsonPatch/JsonPatchDocument.cs b/src/Tingle.AspNetCore.JsonPatch/JsonPatchDocument.cs new file mode 100644 index 0000000..1c45ce2 --- /dev/null +++ b/src/Tingle.AspNetCore.JsonPatch/JsonPatchDocument.cs @@ -0,0 +1,209 @@ +using System.Text.Json; +using System.Text.Json.Serialization; +using Tingle.AspNetCore.JsonPatch.Adapters; +using Tingle.AspNetCore.JsonPatch.Converters; +using Tingle.AspNetCore.JsonPatch.Exceptions; +using Tingle.AspNetCore.JsonPatch.Helpers; +using Tingle.AspNetCore.JsonPatch.Internal; +using Tingle.AspNetCore.JsonPatch.Operations; + +namespace Tingle.AspNetCore.JsonPatch; + +[JsonConverter(typeof(JsonPatchDocumentConverter))] +public class JsonPatchDocument(List operations, JsonSerializerOptions serializerOptions) : IJsonPatchDocument +{ + public List Operations { get; private set; } = operations ?? throw new ArgumentNullException(nameof(operations)); + + [JsonIgnore] + public JsonSerializerOptions SerializerOptions { get; set; } = serializerOptions ?? throw new ArgumentNullException(nameof(serializerOptions)); + + public JsonPatchDocument() : this([]) { } + + public JsonPatchDocument(List operations) : this(operations, new()) { } + + public JsonPatchDocument(JsonSerializerOptions serializerOptions) : this([], serializerOptions) { } + + /// + /// Add operation. Will result in, for example, + /// { "op": "add", "path": "/a/b/c", "value": [ "foo", "bar" ] } + /// + /// target location + /// value + /// + public JsonPatchDocument Add(string path, object value) + { + ArgumentNullException.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 + /// + public JsonPatchDocument Remove(string path) + { + ArgumentNullException.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 + /// + public JsonPatchDocument Replace(string path, object value) + { + ArgumentNullException.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 + /// + public JsonPatchDocument Test(string path, object value) + { + ArgumentNullException.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 + /// + public JsonPatchDocument Move(string from, string path) + { + ArgumentNullException.ThrowIfNull(from); + + ArgumentNullException.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 + /// + public JsonPatchDocument Copy(string from, string path) + { + ArgumentNullException.ThrowIfNull(from); + + ArgumentNullException.ThrowIfNull(path); + + Operations.Add(new Operation("copy", PathHelpers.ValidateAndNormalizePath(path), PathHelpers.ValidateAndNormalizePath(from))); + return this; + } + + /// + /// Apply this JsonPatchDocument + /// + /// Object to apply the JsonPatchDocument to + public void ApplyTo(object objectToApplyTo) + { + ArgumentNullException.ThrowIfNull(objectToApplyTo); + + ApplyTo(objectToApplyTo, new ObjectAdapter(SerializerOptions, null, AdapterFactory.Default, create: false)); + } + + /// + /// 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, create: false), 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) + { + ArgumentNullException.ThrowIfNull(objectToApplyTo); + + ArgumentNullException.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) + { + ArgumentNullException.ThrowIfNull(objectToApplyTo); + + ArgumentNullException.ThrowIfNull(adapter); + + // apply each operation in order + foreach (var op in Operations) + { + op.Apply(objectToApplyTo, adapter); + } + } + + IList IJsonPatchDocument.GetOperations() + { + var allOps = new List(); + + 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; + } +} diff --git a/src/Tingle.AspNetCore.JsonPatch/JsonPatchDocumentExtensions.cs b/src/Tingle.AspNetCore.JsonPatch/JsonPatchDocumentExtensions.cs new file mode 100644 index 0000000..0deccdc --- /dev/null +++ b/src/Tingle.AspNetCore.JsonPatch/JsonPatchDocumentExtensions.cs @@ -0,0 +1,185 @@ +using Microsoft.AspNetCore.Mvc.ModelBinding; +using System.Reflection; + +namespace Tingle.AspNetCore.JsonPatch; + +/// +/// Extensions for +/// +public static class JsonPatchDocumentExtensions +{ + /// + /// Applies JSON patch operations on object and logs errors in . + /// + /// + /// The . + /// The entity on which is applied. + /// The to add errors. + /// The properties that are not allowed to changed + public static void ApplyToSafely(this JsonPatchDocument patchDoc, + T objectToApplyTo, + ModelStateDictionary modelState, + IEnumerable immutableProperties) + where T : class + { + ArgumentNullException.ThrowIfNull(patchDoc); + ArgumentNullException.ThrowIfNull(objectToApplyTo); + ArgumentNullException.ThrowIfNull(modelState); + ArgumentNullException.ThrowIfNull(immutableProperties); + + // if we get here, there are no changes to the immutable properties + // we can thus proceed to apply the other properties + patchDoc.ApplyToSafely(objectToApplyTo: objectToApplyTo, + modelState: modelState, + immutableProperties: immutableProperties, + prefix: string.Empty); + } + + /// + /// Applies JSON patch operations on object and logs errors in . + /// + /// + /// The . + /// The entity on which is applied. + /// The to add errors. + /// The prefix to use when looking up values in . + /// The properties that are not allowed to changed + public static void ApplyToSafely(this JsonPatchDocument patchDoc, + T objectToApplyTo, + ModelStateDictionary modelState, + string prefix, + IEnumerable immutableProperties) + where T : class + { + ArgumentNullException.ThrowIfNull(patchDoc); + ArgumentNullException.ThrowIfNull(objectToApplyTo); + ArgumentNullException.ThrowIfNull(modelState); + ArgumentNullException.ThrowIfNull(immutableProperties); + + // check each operation + foreach (var op in patchDoc.Operations) + { + // only consider when the operation path is present + if (!string.IsNullOrWhiteSpace(op.path)) + { + var path = op.path.Trim('/').ToLowerInvariant(); + if (immutableProperties.Contains(path, StringComparer.OrdinalIgnoreCase)) + { + var affectedObjectName = objectToApplyTo.GetType().Name; + var key = string.IsNullOrEmpty(prefix) ? affectedObjectName : prefix + "." + affectedObjectName; + modelState.TryAddModelError(key, $"The property at path '{op.path}' is immutable."); + return; + } + } + } + + // if we get here, there are no changes to the immutable properties + // we can thus proceed to apply the other properties + patchDoc.ApplyTo(objectToApplyTo: objectToApplyTo, modelState: modelState, prefix: prefix); + } + + /// + /// Applies JSON patch operations on object and logs errors in . + /// + /// + /// The . + /// The entity on which is applied. + /// The to add errors. + public static void ApplyToSafely(this JsonPatchDocument patchDoc, + T objectToApplyTo, + ModelStateDictionary modelState) + where T : class + { + ArgumentNullException.ThrowIfNull(patchDoc); + ArgumentNullException.ThrowIfNull(objectToApplyTo); + ArgumentNullException.ThrowIfNull(modelState); + + // if we get here, there are no changes to the immutable properties + // we can thus proceed to apply the other properties + patchDoc.ApplyToSafely(objectToApplyTo: objectToApplyTo, modelState: modelState, prefix: string.Empty); + } + + /// + /// Applies JSON patch operations on object and logs errors in . + /// + /// + /// The . + /// The entity on which is applied. + /// The to add errors. + /// The prefix to use when looking up values in . + public static void ApplyToSafely(this JsonPatchDocument patchDoc, + T objectToApplyTo, + ModelStateDictionary modelState, + string prefix) + where T : class + { + ArgumentNullException.ThrowIfNull(patchDoc); + ArgumentNullException.ThrowIfNull(objectToApplyTo); + ArgumentNullException.ThrowIfNull(modelState); + + var attrs = BindingFlags.Public | BindingFlags.FlattenHierarchy | BindingFlags.Instance; + var properties = typeof(T).GetProperties(attrs).Select(p => + { + var attr = p.GetCustomAttribute(); + return attr?.Name ?? patchDoc.SerializerOptions.PropertyNamingPolicy?.ConvertName(p.Name) ?? p.Name; + }).ToList(); + + // check each operation + foreach (var op in patchDoc.Operations) + { + // only consider when the operation path is present + if (!string.IsNullOrWhiteSpace(op.path)) + { + var segments = op.path.TrimStart('/').Split('/'); + var target = segments.First(); + if (!properties.Contains(target, StringComparer.OrdinalIgnoreCase)) + { + var key = string.IsNullOrEmpty(prefix) ? target : prefix + "." + target; + modelState.TryAddModelError(key, $"The property at path '{op.path}' is immutable or does not exist."); + return; + } + } + } + + // if we get here, there are no changes to the immutable properties + // we can thus proceed to apply the other properties + patchDoc.ApplyTo(objectToApplyTo: objectToApplyTo, modelState: modelState, prefix: prefix); + } + + /// + /// Applies JSON patch operations on object and logs errors in . + /// + /// The . + /// The entity on which is applied. + /// The to add errors. + public static void ApplyTo(this JsonPatchDocument patchDoc, T objectToApplyTo, ModelStateDictionary modelState) where T : class + { + ArgumentNullException.ThrowIfNull(patchDoc); + ArgumentNullException.ThrowIfNull(objectToApplyTo); + ArgumentNullException.ThrowIfNull(modelState); + + patchDoc.ApplyTo(objectToApplyTo, modelState, prefix: string.Empty); + } + + /// + /// Applies JSON patch operations on object and logs errors in . + /// + /// The . + /// The entity on which is applied. + /// The to add errors. + /// The prefix to use when looking up values in . + public static void ApplyTo(this JsonPatchDocument patchDoc, T objectToApplyTo, ModelStateDictionary modelState, string prefix) where T : class + { + ArgumentNullException.ThrowIfNull(patchDoc); + ArgumentNullException.ThrowIfNull(objectToApplyTo); + ArgumentNullException.ThrowIfNull(modelState); + + patchDoc.ApplyTo(objectToApplyTo, jsonPatchError => + { + var affectedObjectName = jsonPatchError.AffectedObject.GetType().Name; + var key = string.IsNullOrEmpty(prefix) ? affectedObjectName : prefix + "." + affectedObjectName; + + modelState.TryAddModelError(key, jsonPatchError.ErrorMessage); + }); + } +} \ No newline at end of file diff --git a/src/Tingle.AspNetCore.JsonPatch/JsonPatchDocumentOfT.cs b/src/Tingle.AspNetCore.JsonPatch/JsonPatchDocumentOfT.cs new file mode 100644 index 0000000..5d4054b --- /dev/null +++ b/src/Tingle.AspNetCore.JsonPatch/JsonPatchDocumentOfT.cs @@ -0,0 +1,816 @@ +using System.Globalization; +using System.Linq.Expressions; +using System.Text.Json; +using System.Text.Json.Serialization; +using Tingle.AspNetCore.JsonPatch.Adapters; +using Tingle.AspNetCore.JsonPatch.Converters; +using Tingle.AspNetCore.JsonPatch.Exceptions; +using Tingle.AspNetCore.JsonPatch.Internal; +using Tingle.AspNetCore.JsonPatch.Operations; +using Tingle.AspNetCore.JsonPatch.Properties; + +namespace Tingle.AspNetCore.JsonPatch; + +[JsonConverter(typeof(TypedJsonPatchDocumentConverter))] +public class JsonPatchDocument(List> operations, JsonSerializerOptions serializerOptions) : IJsonPatchDocument where TModel : class +{ + public List> Operations { get; private set; } = operations ?? throw new ArgumentNullException(nameof(operations)); + + [JsonIgnore] + public JsonSerializerOptions SerializerOptions { get; set; } = serializerOptions ?? throw new ArgumentNullException(nameof(serializerOptions)); + + public JsonPatchDocument() : this([]) { } + + public JsonPatchDocument(List> operations) : this(operations, new()) { } + + public JsonPatchDocument(JsonSerializerOptions serializerOptions) : this([], 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) + { + ArgumentNullException.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) + { + ArgumentNullException.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) + { + ArgumentNullException.ThrowIfNull(path); + + Operations.Add(new Operation( + "add", + GetPath(path, "-"), + from: null, + value: value)); + + return this; + } + /// + /// Add operation. Will result in, for example, + /// { "op": "add", "path": "/a/b/c", "value": [ "foo", "bar" ] } + /// + /// key type + /// value type + /// target location + /// key + /// value + /// + public JsonPatchDocument Add(Expression?>> path, + TKey key, + TValue value) + where TKey : IConvertible + { + ArgumentNullException.ThrowIfNull(path); + + Operations.Add(new Operation( + op: "add", + path: GetPath(path, Convert.ToString(key)), + from: null, + value: value)); + + return this; + } + + /// + /// Remove value at target location. Will result in, for example, + /// { "op": "remove", "path": "/a/b/c" } + /// + /// target location + /// + public JsonPatchDocument Remove(Expression> path) + { + ArgumentNullException.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 + /// + public JsonPatchDocument Remove(Expression?>> path, int position) + { + ArgumentNullException.ThrowIfNull(path); + + Operations.Add(new Operation( + "remove", + GetPath(path, position.ToString()), + from: null)); + + return this; + } + + /// + /// Remove value from end of list + /// + /// value type + /// target location + /// + public JsonPatchDocument Remove(Expression?>> path) + { + ArgumentNullException.ThrowIfNull(path); + + Operations.Add(new Operation( + "remove", + GetPath(path, "-"), + from: null)); + + return this; + } + + /// + /// Remove value at target location. Will result in, for example, + /// { "op": "remove", "path": "/a/b/c" } + /// + /// key type + /// value type + /// target location + /// key + /// + public JsonPatchDocument Remove(Expression?>> path, TKey key) + where TKey : IConvertible + { + ArgumentNullException.ThrowIfNull(path); + + Operations.Add(new Operation( + op: "remove", + path: GetPath(path, Convert.ToString(key)), + from: null)); + + return this; + } + + /// + /// Replace value. Will result in, for example, + /// { "op": "replace", "path": "/a/b/c", "value": 42 } + /// + /// target location + /// value + /// + public JsonPatchDocument Replace(Expression> path, TProp value) + { + ArgumentNullException.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 + /// + public JsonPatchDocument Replace(Expression?>> path, TProp value, int position) + { + ArgumentNullException.ThrowIfNull(path); + + Operations.Add(new Operation( + "replace", + GetPath(path, position.ToString()), + from: null, + value: value)); + + return this; + } + + /// + /// Replace value at end of a list + /// + /// value type + /// target location + /// value + /// + public JsonPatchDocument Replace(Expression?>> path, TProp value) + { + ArgumentNullException.ThrowIfNull(path); + + Operations.Add(new Operation( + "replace", + GetPath(path, "-"), + from: null, + value: value)); + + return this; + } + + /// + /// Replace value. Will result in, for example, + /// { "op": "replace", "path": "/a/b/c", "value": 42 } + /// + /// key type + /// value type + /// target location + /// key + /// value + /// + public JsonPatchDocument Replace(Expression?>> path, + TKey key, + TValue value) + where TKey : IConvertible + { + ArgumentNullException.ThrowIfNull(path); + + Operations.Add(new Operation( + op: "replace", + path: GetPath(path, Convert.ToString(key)), + from: null, + value: value)); + + return this; + } + + /// + /// Test value. Will result in, for example, + /// { "op": "test", "path": "/a/b/c", "value": 42 } + /// + /// target location + /// value + /// + public JsonPatchDocument Test(Expression> path, TProp value) + { + ArgumentNullException.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 + /// + public JsonPatchDocument Test(Expression?>> path, TProp value, int position) + { + ArgumentNullException.ThrowIfNull(path); + + Operations.Add(new Operation( + "test", + GetPath(path, position.ToString()), + from: null, + value: value)); + + return this; + } + + /// + /// Test value at end of a list + /// + /// value type + /// target location + /// value + /// + public JsonPatchDocument Test(Expression?>> path, TProp value) + { + ArgumentNullException.ThrowIfNull(path); + + Operations.Add(new Operation( + "test", + GetPath(path, "-"), + from: null, + value: value)); + + return this; + } + + /// + /// Test value. Will result in, for example, + /// { "op": "test", "path": "/a/b/c", "value": 42 } + /// + /// key type + /// value type + /// target location + /// key + /// value + /// + public JsonPatchDocument Test(Expression?>> path, + TKey key, + TValue value) + where TKey : IConvertible + { + ArgumentNullException.ThrowIfNull(path); + + Operations.Add(new Operation( + op: "test", + path: GetPath(path, Convert.ToString(key)), + 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 + /// + public JsonPatchDocument Move(Expression> from, Expression> path) + { + ArgumentNullException.ThrowIfNull(from); + + ArgumentNullException.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 + /// + public JsonPatchDocument Move(Expression?>> from, + int positionFrom, + Expression> path) + { + ArgumentNullException.ThrowIfNull(from); + + ArgumentNullException.ThrowIfNull(path); + + Operations.Add(new Operation( + "move", + GetPath(path, null), + GetPath(from, positionFrom.ToString()))); + + return this; + } + + /// + /// Move from a property to a location in a list + /// + /// + /// source location + /// target location + /// position + /// + public JsonPatchDocument Move(Expression> from, + Expression?>> path, + int positionTo) + { + ArgumentNullException.ThrowIfNull(from); + + ArgumentNullException.ThrowIfNull(path); + + Operations.Add(new Operation( + "move", + GetPath(path, positionTo.ToString()), + 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) + /// + public JsonPatchDocument Move(Expression?>> from, + int positionFrom, + Expression?>> path, + int positionTo) + { + ArgumentNullException.ThrowIfNull(from); + + ArgumentNullException.ThrowIfNull(path); + + Operations.Add(new Operation( + "move", + GetPath(path, positionTo.ToString()), + GetPath(from, positionFrom.ToString()))); + + return this; + } + + /// + /// Move from a position in a list to the end of another list + /// + /// + /// source location + /// position + /// target location + /// + public JsonPatchDocument Move(Expression?>> from, + int positionFrom, + Expression?>> path) + { + ArgumentNullException.ThrowIfNull(from); + + ArgumentNullException.ThrowIfNull(path); + + Operations.Add(new Operation( + "move", + GetPath(path, "-"), + GetPath(from, positionFrom.ToString()))); + + return this; + } + + /// + /// Move to the end of a list + /// + /// + /// source location + /// target location + /// + public JsonPatchDocument Move(Expression> from, Expression?>> path) + { + ArgumentNullException.ThrowIfNull(from); + + ArgumentNullException.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 esult in, for example: + /// { "op": "copy", "from": "/a/b/c", "path": "/a/b/e" } + /// + /// source location + /// target location + /// + public JsonPatchDocument Copy(Expression> from, Expression> path) + { + ArgumentNullException.ThrowIfNull(from); + + ArgumentNullException.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 + /// + public JsonPatchDocument Copy(Expression?>> from, + int positionFrom, + Expression> path) + { + ArgumentNullException.ThrowIfNull(from); + + ArgumentNullException.ThrowIfNull(path); + + Operations.Add(new Operation( + "copy", + GetPath(path, null), + GetPath(from, positionFrom.ToString()))); + + return this; + } + + /// + /// Copy from a property to a location in a list + /// + /// + /// source location + /// target location + /// position + /// + public JsonPatchDocument Copy(Expression> from, + Expression?>> path, + int positionTo) + { + ArgumentNullException.ThrowIfNull(from); + + ArgumentNullException.ThrowIfNull(path); + + Operations.Add(new Operation( + "copy", + GetPath(path, positionTo.ToString()), + 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) + /// + public JsonPatchDocument Copy(Expression?>> from, + int positionFrom, + Expression?>> path, + int positionTo) + { + ArgumentNullException.ThrowIfNull(from); + + ArgumentNullException.ThrowIfNull(path); + + Operations.Add(new Operation( + "copy", + GetPath(path, positionTo.ToString()), + GetPath(from, positionFrom.ToString()))); + + return this; + } + + /// + /// Copy from a position in a list to the end of another list + /// + /// + /// source location + /// position + /// target location + /// + public JsonPatchDocument Copy(Expression?>> from, + int positionFrom, + Expression?>> path) + { + ArgumentNullException.ThrowIfNull(from); + + ArgumentNullException.ThrowIfNull(path); + + Operations.Add(new Operation( + "copy", + GetPath(path, "-"), + GetPath(from, positionFrom.ToString()))); + + return this; + } + + /// + /// Copy to the end of a list + /// + /// + /// source location + /// target location + /// + public JsonPatchDocument Copy(Expression> from, Expression?>> path) + { + ArgumentNullException.ThrowIfNull(from); + + ArgumentNullException.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) + { + ArgumentNullException.ThrowIfNull(objectToApplyTo); + + ApplyTo(objectToApplyTo, new ObjectAdapter(SerializerOptions, null, AdapterFactory.Default, create: false)); + } + + /// + /// 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, create: false), 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) + { + ArgumentNullException.ThrowIfNull(objectToApplyTo); + + ArgumentNullException.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) + { + ArgumentNullException.ThrowIfNull(objectToApplyTo); + + ArgumentNullException.ThrowIfNull(adapter); + + // apply each operation in order + foreach (var op in Operations) + { + op.Apply(objectToApplyTo, adapter); + } + } + + IList IJsonPatchDocument.GetOperations() + { + var allOps = new List(); + + 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 += "/" + (SerializerOptions.DictionaryKeyPolicy?.ConvertName(position) ?? position); + if (segments.Count == 0) + { + return path; + } + } + + return "/" + path; + } + + private List GetPathSegments(Expression? expr) + { + if (expr is null) return []; + 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 = (MemberExpression)expr; + listOfSegments.AddRange(GetPathSegments(memberExpression.Expression)); + // Get property name, respecting JsonPropertyName attribute + listOfSegments.Add(GetPropertyNameFromMemberExpression(SerializerOptions, 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 static string GetPropertyNameFromMemberExpression(JsonSerializerOptions serializerOptions, MemberExpression memberExpression) + { + var propertyInfo = memberExpression.Expression!.Type.GetProperty(memberExpression.Member.Name); + var targetAttr = propertyInfo?.GetCustomAttributes(typeof(JsonPropertyNameAttribute), false) + .OfType() + .FirstOrDefault(); + + return targetAttr?.Name + ?? serializerOptions.PropertyNamingPolicy?.ConvertName(memberExpression.Member.Name) + ?? memberExpression.Member.Name; + } + + // 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/Tingle.AspNetCore.JsonPatch/JsonPatchError.cs b/src/Tingle.AspNetCore.JsonPatch/JsonPatchError.cs new file mode 100644 index 0000000..75dcf6d --- /dev/null +++ b/src/Tingle.AspNetCore.JsonPatch/JsonPatchError.cs @@ -0,0 +1,27 @@ +using Tingle.AspNetCore.JsonPatch.Operations; + +namespace Tingle.AspNetCore.JsonPatch; + +/// +/// Captures error message and the related entity and the operation that caused it. +/// +/// The object that is affected by the error. +/// The that caused the error. +/// The error message. +public class JsonPatchError(object affectedObject, Operation operation, string errorMessage) +{ + /// + /// Gets the object that is affected by the error. + /// + public object AffectedObject { get; } = affectedObject; + + /// + /// Gets the that caused the error. + /// + public Operation Operation { get; } = operation; + + /// + /// Gets the error message. + /// + public string ErrorMessage { get; } = errorMessage; +} diff --git a/src/Tingle.AspNetCore.JsonPatch/JsonPatchMergeDocument.cs b/src/Tingle.AspNetCore.JsonPatch/JsonPatchMergeDocument.cs new file mode 100644 index 0000000..9010861 --- /dev/null +++ b/src/Tingle.AspNetCore.JsonPatch/JsonPatchMergeDocument.cs @@ -0,0 +1,64 @@ +using System.Text.Json; +using System.Text.Json.Serialization; +using Tingle.AspNetCore.JsonPatch.Adapters; +using Tingle.AspNetCore.JsonPatch.Converters; +using Tingle.AspNetCore.JsonPatch.Operations; + +namespace Tingle.AspNetCore.JsonPatch; + +[JsonConverter(typeof(JsonPatchMergeDocumentConverter))] +public class JsonPatchMergeDocument(JsonPatchDocument inner) : IJsonPatchMergeDocument +{ + private readonly JsonPatchDocument inner = inner ?? throw new ArgumentNullException(nameof(inner)); + + [JsonIgnore] + public JsonSerializerOptions SerializerOptions { get { return inner.SerializerOptions; } set { inner.SerializerOptions = value; } } + + public JsonPatchMergeDocument() : this([]) { } + + public JsonPatchMergeDocument(List operations) : this(operations, new()) { } + + public JsonPatchMergeDocument(JsonSerializerOptions serializerOptions) : this([], serializerOptions) { } + + public JsonPatchMergeDocument(List operations, JsonSerializerOptions serializerOptions) + : this(new JsonPatchDocument(operations, serializerOptions)) { } + + internal List Operations => inner.Operations; + IList IJsonPatchMergeDocument.GetOperations() => ((IJsonPatchDocument)inner).GetOperations(); + + /// + /// Apply this JsonPatchMergeDocument + /// + /// Object to apply the JsonPatchMergeDocument to + public void ApplyTo(object objectToApplyTo) + { + ArgumentNullException.ThrowIfNull(objectToApplyTo); + + ApplyTo(objectToApplyTo, new ObjectAdapter(SerializerOptions, null, AdapterFactory.Default, create: true)); + } + + /// + /// Apply this JsonPatchMergeDocument + /// + /// Object to apply the JsonPatchMergeDocument to + /// Action to log errors + public void ApplyTo(object objectToApplyTo, Action logErrorAction) + { + ApplyTo(objectToApplyTo, new ObjectAdapter(SerializerOptions, logErrorAction, AdapterFactory.Default, create: true), logErrorAction); + } + + /// + /// Apply this JsonPatchMergeDocument + /// + /// Object to apply the JsonPatchMergeDocument to + /// IObjectAdapter instance to use when applying + /// Action to log errors + public void ApplyTo(object objectToApplyTo, IObjectAdapter adapter, Action logErrorAction) => inner.ApplyTo(objectToApplyTo, adapter, logErrorAction); + + /// + /// Apply this JsonPatchMergeDocument + /// + /// Object to apply the JsonPatchMergeDocument to + /// IObjectAdapter instance to use when applying + public void ApplyTo(object objectToApplyTo, IObjectAdapter adapter) => inner.ApplyTo(objectToApplyTo, adapter); +} diff --git a/src/Tingle.AspNetCore.JsonPatch/JsonPatchMergeDocumentExtensions.cs b/src/Tingle.AspNetCore.JsonPatch/JsonPatchMergeDocumentExtensions.cs new file mode 100644 index 0000000..d3f98e4 --- /dev/null +++ b/src/Tingle.AspNetCore.JsonPatch/JsonPatchMergeDocumentExtensions.cs @@ -0,0 +1,185 @@ +using Microsoft.AspNetCore.Mvc.ModelBinding; +using System.Reflection; + +namespace Tingle.AspNetCore.JsonPatch; + +/// +/// Extensions for +/// +public static class JsonPatchMergeDocumentExtensions +{ + /// + /// Applies JSON patch operations on object and logs errors in . + /// + /// + /// The . + /// The entity on which is applied. + /// The to add errors. + /// The properties that are not allowed to changed + public static void ApplyToSafely(this JsonPatchMergeDocument patchDoc, + T objectToApplyTo, + ModelStateDictionary modelState, + IEnumerable immutableProperties) + where T : class + { + ArgumentNullException.ThrowIfNull(patchDoc); + ArgumentNullException.ThrowIfNull(objectToApplyTo); + ArgumentNullException.ThrowIfNull(modelState); + ArgumentNullException.ThrowIfNull(immutableProperties); + + // if we get here, there are no changes to the immutable properties + // we can thus proceed to apply the other properties + patchDoc.ApplyToSafely(objectToApplyTo: objectToApplyTo, + modelState: modelState, + immutableProperties: immutableProperties, + prefix: string.Empty); + } + + /// + /// Applies JSON patch operations on object and logs errors in . + /// + /// + /// The . + /// The entity on which is applied. + /// The to add errors. + /// The prefix to use when looking up values in . + /// The properties that are not allowed to changed + public static void ApplyToSafely(this JsonPatchMergeDocument patchDoc, + T objectToApplyTo, + ModelStateDictionary modelState, + string prefix, + IEnumerable immutableProperties) + where T : class + { + ArgumentNullException.ThrowIfNull(patchDoc); + ArgumentNullException.ThrowIfNull(objectToApplyTo); + ArgumentNullException.ThrowIfNull(modelState); + ArgumentNullException.ThrowIfNull(immutableProperties); + + // check each operation + foreach (var op in patchDoc.Operations) + { + // only consider when the operation path is present + if (!string.IsNullOrWhiteSpace(op.path)) + { + var path = op.path.Trim('/').ToLowerInvariant(); + if (immutableProperties.Contains(path, StringComparer.OrdinalIgnoreCase)) + { + var affectedObjectName = objectToApplyTo.GetType().Name; + var key = string.IsNullOrEmpty(prefix) ? affectedObjectName : prefix + "." + affectedObjectName; + modelState.TryAddModelError(key, $"The property at path '{op.path}' is immutable."); + return; + } + } + } + + // if we get here, there are no changes to the immutable properties + // we can thus proceed to apply the other properties + patchDoc.ApplyTo(objectToApplyTo: objectToApplyTo, modelState: modelState, prefix: prefix); + } + + /// + /// Applies JSON patch operations on object and logs errors in . + /// + /// + /// The . + /// The entity on which is applied. + /// The to add errors. + public static void ApplyToSafely(this JsonPatchMergeDocument patchDoc, + T objectToApplyTo, + ModelStateDictionary modelState) + where T : class + { + ArgumentNullException.ThrowIfNull(patchDoc); + ArgumentNullException.ThrowIfNull(objectToApplyTo); + ArgumentNullException.ThrowIfNull(modelState); + + // if we get here, there are no changes to the immutable properties + // we can thus proceed to apply the other properties + patchDoc.ApplyToSafely(objectToApplyTo: objectToApplyTo, modelState: modelState, prefix: string.Empty); + } + + /// + /// Applies JSON patch operations on object and logs errors in . + /// + /// + /// The . + /// The entity on which is applied. + /// The to add errors. + /// The prefix to use when looking up values in . + public static void ApplyToSafely(this JsonPatchMergeDocument patchDoc, + T objectToApplyTo, + ModelStateDictionary modelState, + string prefix) + where T : class + { + ArgumentNullException.ThrowIfNull(patchDoc); + ArgumentNullException.ThrowIfNull(objectToApplyTo); + ArgumentNullException.ThrowIfNull(modelState); + + var attrs = BindingFlags.Public | BindingFlags.FlattenHierarchy | BindingFlags.Instance; + var properties = typeof(T).GetProperties(attrs).Select(p => + { + var attr = p.GetCustomAttribute(); + return attr?.Name ?? patchDoc.SerializerOptions.PropertyNamingPolicy?.ConvertName(p.Name) ?? p.Name; + }).ToList(); + + // check each operation + foreach (var op in patchDoc.Operations) + { + // only consider when the operation path is present + if (!string.IsNullOrWhiteSpace(op.path)) + { + var segments = op.path.TrimStart('/').Split('/'); + var target = segments.First(); + if (!properties.Contains(target, StringComparer.OrdinalIgnoreCase)) + { + var key = string.IsNullOrEmpty(prefix) ? target : prefix + "." + target; + modelState.TryAddModelError(key, $"The property at path '{op.path}' is immutable or does not exist."); + return; + } + } + } + + // if we get here, there are no changes to the immutable properties + // we can thus proceed to apply the other properties + patchDoc.ApplyTo(objectToApplyTo: objectToApplyTo, modelState: modelState, prefix: prefix); + } + + /// + /// Applies JSON patch operations on object and logs errors in . + /// + /// The . + /// The entity on which is applied. + /// The to add errors. + public static void ApplyTo(this JsonPatchMergeDocument patchDoc, T objectToApplyTo, ModelStateDictionary modelState) where T : class + { + ArgumentNullException.ThrowIfNull(patchDoc); + ArgumentNullException.ThrowIfNull(objectToApplyTo); + ArgumentNullException.ThrowIfNull(modelState); + + patchDoc.ApplyTo(objectToApplyTo, modelState, prefix: string.Empty); + } + + /// + /// Applies JSON patch operations on object and logs errors in . + /// + /// The . + /// The entity on which is applied. + /// The to add errors. + /// The prefix to use when looking up values in . + public static void ApplyTo(this JsonPatchMergeDocument patchDoc, T objectToApplyTo, ModelStateDictionary modelState, string prefix) where T : class + { + ArgumentNullException.ThrowIfNull(patchDoc); + ArgumentNullException.ThrowIfNull(objectToApplyTo); + ArgumentNullException.ThrowIfNull(modelState); + + patchDoc.ApplyTo(objectToApplyTo, jsonPatchError => + { + var affectedObjectName = jsonPatchError.AffectedObject.GetType().Name; + var key = string.IsNullOrEmpty(prefix) ? affectedObjectName : prefix + "." + affectedObjectName; + + modelState.TryAddModelError(key, jsonPatchError.ErrorMessage); + }); + } +} \ No newline at end of file diff --git a/src/Tingle.AspNetCore.JsonPatch/JsonPatchMergeDocumentOfT.cs b/src/Tingle.AspNetCore.JsonPatch/JsonPatchMergeDocumentOfT.cs new file mode 100644 index 0000000..c582106 --- /dev/null +++ b/src/Tingle.AspNetCore.JsonPatch/JsonPatchMergeDocumentOfT.cs @@ -0,0 +1,64 @@ +using System.Text.Json; +using System.Text.Json.Serialization; +using Tingle.AspNetCore.JsonPatch.Adapters; +using Tingle.AspNetCore.JsonPatch.Converters; +using Tingle.AspNetCore.JsonPatch.Operations; + +namespace Tingle.AspNetCore.JsonPatch; + +[JsonConverter(typeof(TypedJsonPatchMergeDocumentConverter))] +public class JsonPatchMergeDocument(JsonPatchDocument inner) : IJsonPatchMergeDocument where TModel : class +{ + private readonly JsonPatchDocument inner = inner ?? throw new ArgumentNullException(nameof(inner)); + + [JsonIgnore] + public JsonSerializerOptions SerializerOptions { get { return inner.SerializerOptions; } set { inner.SerializerOptions = value; } } + + public JsonPatchMergeDocument() : this([]) { } + + public JsonPatchMergeDocument(List> operations) : this(operations, new()) { } + + public JsonPatchMergeDocument(JsonSerializerOptions serializerOptions) : this([], serializerOptions) { } + + public JsonPatchMergeDocument(List> operations, JsonSerializerOptions serializerOptions) + : this(new JsonPatchDocument(operations, serializerOptions)) { } + + internal List> Operations => inner.Operations; + IList IJsonPatchMergeDocument.GetOperations() => ((IJsonPatchDocument)inner).GetOperations(); + + /// + /// Apply this JsonPatchMergeDocument + /// + /// Object to apply the JsonPatchMergeDocument to + public void ApplyTo(TModel objectToApplyTo) + { + ArgumentNullException.ThrowIfNull(objectToApplyTo); + + ApplyTo(objectToApplyTo, new ObjectAdapter(SerializerOptions, null, AdapterFactory.Default, create: true)); + } + + /// + /// Apply this JsonPatchMergeDocument + /// + /// Object to apply the JsonPatchMergeDocument to + /// Action to log errors + public void ApplyTo(TModel objectToApplyTo, Action logErrorAction) + { + ApplyTo(objectToApplyTo, new ObjectAdapter(SerializerOptions, logErrorAction, AdapterFactory.Default, create: true), logErrorAction); + } + + /// + /// Apply this JsonPatchMergeDocument + /// + /// Object to apply the JsonPatchMergeDocument to + /// IObjectAdapter instance to use when applying + /// Action to log errors + public void ApplyTo(TModel objectToApplyTo, IObjectAdapter adapter, Action logErrorAction) => inner.ApplyTo(objectToApplyTo, adapter, logErrorAction); + + /// + /// Apply this JsonPatchMergeDocument + /// + /// Object to apply the JsonPatchMergeDocument to + /// IObjectAdapter instance to use when applying + public void ApplyTo(TModel objectToApplyTo, IObjectAdapter adapter) => inner.ApplyTo(objectToApplyTo, adapter); +} diff --git a/src/Tingle.AspNetCore.JsonPatch/JsonPatchMergeDocumentProvider.cs b/src/Tingle.AspNetCore.JsonPatch/JsonPatchMergeDocumentProvider.cs new file mode 100644 index 0000000..e66a998 --- /dev/null +++ b/src/Tingle.AspNetCore.JsonPatch/JsonPatchMergeDocumentProvider.cs @@ -0,0 +1,44 @@ +using Microsoft.AspNetCore.Mvc.ApiExplorer; +using Microsoft.AspNetCore.Mvc.ModelBinding; + +namespace Tingle.AspNetCore.JsonPatch; + +/// +/// Implements a provider of to change parameters of +/// type to the model type. +/// +/// The . +internal sealed class JsonPatchMergeDocumentProvider(IModelMetadataProvider modelMetadataProvider) : IApiDescriptionProvider +{ + /// + /// + /// The order -999 ensures that this provider is executed right after the Microsoft.AspNetCore.Mvc.ApiExplorer.DefaultApiDescriptionProvider. + /// + public int Order => -999; + + /// + public void OnProvidersExecuting(ApiDescriptionProviderContext context) + { + ArgumentNullException.ThrowIfNull(context); + + foreach (var result in context.Results) + { + foreach (var parameterDescription in result.ParameterDescriptions) + { + var parameterType = parameterDescription.Type; + if (parameterType.IsGenericType && parameterType.GetGenericTypeDefinition() == typeof(JsonPatchMergeDocument<>)) + { + var modelType = parameterType.GetGenericArguments()[0]; + + parameterDescription.Type = modelType; + parameterDescription.ModelMetadata = modelMetadataProvider.GetMetadataForType(modelType); + } + } + } + } + + /// + public void OnProvidersExecuted(ApiDescriptionProviderContext context) + { + } +} diff --git a/src/Tingle.AspNetCore.JsonPatch/JsonPatchOperationsArrayProvider.cs b/src/Tingle.AspNetCore.JsonPatch/JsonPatchOperationsArrayProvider.cs new file mode 100644 index 0000000..6d275ba --- /dev/null +++ b/src/Tingle.AspNetCore.JsonPatch/JsonPatchOperationsArrayProvider.cs @@ -0,0 +1,43 @@ +using Microsoft.AspNetCore.Mvc.ApiExplorer; +using Microsoft.AspNetCore.Mvc.ModelBinding; +using System.Reflection; +using Tingle.AspNetCore.JsonPatch.Operations; + +namespace Tingle.AspNetCore.JsonPatch; + +/// +/// Implements a provider of to change parameters of +/// type to an array of . +/// +/// The . +internal sealed class JsonPatchOperationsArrayProvider(IModelMetadataProvider modelMetadataProvider) : IApiDescriptionProvider +{ + /// + /// + /// The order -999 ensures that this provider is executed right after the Microsoft.AspNetCore.Mvc.ApiExplorer.DefaultApiDescriptionProvider. + /// + public int Order => -999; + + /// + public void OnProvidersExecuting(ApiDescriptionProviderContext context) + { + ArgumentNullException.ThrowIfNull(context); + + foreach (var result in context.Results) + { + foreach (var parameterDescription in result.ParameterDescriptions) + { + if (typeof(IJsonPatchDocument).GetTypeInfo().IsAssignableFrom(parameterDescription.Type)) + { + parameterDescription.Type = typeof(Operation[]); + parameterDescription.ModelMetadata = modelMetadataProvider.GetMetadataForType(typeof(Operation[])); + } + } + } + } + + /// + public void OnProvidersExecuted(ApiDescriptionProviderContext context) + { + } +} diff --git a/src/Tingle.AspNetCore.JsonPatch/MediaTypeHeaderValues.cs b/src/Tingle.AspNetCore.JsonPatch/MediaTypeHeaderValues.cs new file mode 100644 index 0000000..4b1165f --- /dev/null +++ b/src/Tingle.AspNetCore.JsonPatch/MediaTypeHeaderValues.cs @@ -0,0 +1,12 @@ +using Microsoft.Net.Http.Headers; + +namespace Tingle.AspNetCore.JsonPatch; + +internal static class MediaTypeHeaderValues +{ + public static readonly MediaTypeHeaderValue ApplicationJsonPatch + = MediaTypeHeaderValue.Parse("application/json-patch+json").CopyAsReadOnly(); + + public static readonly MediaTypeHeaderValue ApplicationJsonPatchMerge + = MediaTypeHeaderValue.Parse("application/merge-patch+json").CopyAsReadOnly(); +} diff --git a/src/Tingle.AspNetCore.JsonPatch/Operations/Operation.cs b/src/Tingle.AspNetCore.JsonPatch/Operations/Operation.cs new file mode 100644 index 0000000..c13d468 --- /dev/null +++ b/src/Tingle.AspNetCore.JsonPatch/Operations/Operation.cs @@ -0,0 +1,92 @@ +using System.Text.Json.Serialization; +using Tingle.AspNetCore.JsonPatch.Adapters; +using Tingle.AspNetCore.JsonPatch.Properties; + +namespace Tingle.AspNetCore.JsonPatch.Operations; + +#pragma warning disable IDE1006 // Naming Styles + +public class Operation +{ + private string _op = default!; + private OperationType _operationType; + + [JsonIgnore] + public OperationType OperationType => _operationType; + + [JsonPropertyName("path")] + public string path { get; set; } = default!; + + [JsonPropertyName("op")] + public string op + { + get => _op; + set + { + if (!Enum.TryParse(value, ignoreCase: true, result: out OperationType result)) + { + result = OperationType.Invalid; + } + _operationType = result; + _op = value; + } + } + + [JsonPropertyName("from")] + public string? from { get; set; } + + [JsonPropertyName("value")] + public object? value { get; set; } + + public Operation() { } + + public Operation(string op, string path, string? from) + { + this.op = op ?? throw new ArgumentNullException(nameof(op)); + this.path = path ?? throw new ArgumentNullException(nameof(path)); + this.from = from; + } + + public Operation(string op, string path, string? from, object? value) : this(op, path, from) + { + this.value = value; + } + + public void Apply(object objectToApplyTo, IObjectAdapter adapter) + { + ArgumentNullException.ThrowIfNull(objectToApplyTo); + + ArgumentNullException.ThrowIfNull(adapter); + + switch (OperationType) + { + case OperationType.Add: + adapter.Add(this, objectToApplyTo); + break; + case OperationType.Remove: + adapter.Remove(this, objectToApplyTo); + break; + case OperationType.Replace: + adapter.Replace(this, objectToApplyTo); + break; + case OperationType.Move: + adapter.Move(this, objectToApplyTo); + break; + case OperationType.Copy: + adapter.Copy(this, objectToApplyTo); + break; + case OperationType.Test: + if (adapter is IObjectAdapterWithTest adapterWithTest) + { + adapterWithTest.Test(this, objectToApplyTo); + break; + } + else + { + throw new NotSupportedException(Resources.TestOperationNotSupported); + } + default: + break; + } + } +} diff --git a/src/Tingle.AspNetCore.JsonPatch/Operations/OperationOfT.cs b/src/Tingle.AspNetCore.JsonPatch/Operations/OperationOfT.cs new file mode 100644 index 0000000..2614490 --- /dev/null +++ b/src/Tingle.AspNetCore.JsonPatch/Operations/OperationOfT.cs @@ -0,0 +1,55 @@ +using Tingle.AspNetCore.JsonPatch.Adapters; +using Tingle.AspNetCore.JsonPatch.Exceptions; +using Tingle.AspNetCore.JsonPatch.Properties; + +namespace Tingle.AspNetCore.JsonPatch.Operations; + +public class Operation : Operation where TModel : class +{ + public Operation() { } + + public Operation(string op, string path, string? from) : base(op, path, from) { } + + public Operation(string op, string path, string? from, object? value) : base(op, path, from, value) { } + + public void Apply(TModel objectToApplyTo, IObjectAdapter adapter) + { + ArgumentNullException.ThrowIfNull(objectToApplyTo); + + ArgumentNullException.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/Tingle.AspNetCore.JsonPatch/Operations/OperationType.cs b/src/Tingle.AspNetCore.JsonPatch/Operations/OperationType.cs new file mode 100644 index 0000000..c1cc905 --- /dev/null +++ b/src/Tingle.AspNetCore.JsonPatch/Operations/OperationType.cs @@ -0,0 +1,12 @@ +namespace Tingle.AspNetCore.JsonPatch.Operations; + +public enum OperationType +{ + Add, + Remove, + Replace, + Move, + Copy, + Test, + Invalid +} diff --git a/src/Tingle.AspNetCore.JsonPatch/Properties/Resources.Designer.cs b/src/Tingle.AspNetCore.JsonPatch/Properties/Resources.Designer.cs new file mode 100644 index 0000000..274178e --- /dev/null +++ b/src/Tingle.AspNetCore.JsonPatch/Properties/Resources.Designer.cs @@ -0,0 +1,261 @@ +//------------------------------------------------------------------------------ +// +// This code was generated by a tool. +// Runtime Version:4.0.30319.42000 +// +// Changes to this file may cause incorrect behavior and will be lost if +// the code is regenerated. +// +//------------------------------------------------------------------------------ + +namespace Tingle.AspNetCore.JsonPatch.Properties { + using System; + + + /// + /// A strongly-typed resource class, for looking up localized strings, etc. + /// + // This class was auto-generated by the StronglyTypedResourceBuilder + // class via a tool like ResGen or Visual Studio. + // To add or remove a member, edit your .ResX file then rerun ResGen + // with the /str option, or rebuild your VS project. + [global::System.CodeDom.Compiler.GeneratedCodeAttribute("System.Resources.Tools.StronglyTypedResourceBuilder", "17.0.0.0")] + [global::System.Diagnostics.DebuggerNonUserCodeAttribute()] + [global::System.Runtime.CompilerServices.CompilerGeneratedAttribute()] + internal partial class Resources { + + private static global::System.Resources.ResourceManager resourceMan; + + private static global::System.Globalization.CultureInfo resourceCulture; + + [global::System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1811:AvoidUncalledPrivateCode")] + internal Resources() { + } + + /// + /// Returns the cached ResourceManager instance used by this class. + /// + [global::System.ComponentModel.EditorBrowsableAttribute(global::System.ComponentModel.EditorBrowsableState.Advanced)] + internal static global::System.Resources.ResourceManager ResourceManager { + get { + if (object.ReferenceEquals(resourceMan, null)) { + global::System.Resources.ResourceManager temp = new global::System.Resources.ResourceManager("Tingle.AspNetCore.JsonPatch.Properties.Resources", typeof(Resources).Assembly); + resourceMan = temp; + } + return resourceMan; + } + } + + /// + /// Overrides the current thread's CurrentUICulture property for all + /// resource lookups using this strongly typed resource class. + /// + [global::System.ComponentModel.EditorBrowsableAttribute(global::System.ComponentModel.EditorBrowsableState.Advanced)] + internal static global::System.Globalization.CultureInfo Culture { + get { + return resourceCulture; + } + set { + resourceCulture = value; + } + } + + /// + /// Looks up a localized string similar to The property at '{0}' could not be copied.. + /// + internal static string CannotCopyProperty { + get { + return ResourceManager.GetString("CannotCopyProperty", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to The type of the property at path '{0}' could not be determined.. + /// + internal static string CannotDeterminePropertyType { + get { + return ResourceManager.GetString("CannotDeterminePropertyType", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to The '{0}' operation at path '{1}' could not be performed.. + /// + internal static string CannotPerformOperation { + get { + return ResourceManager.GetString("CannotPerformOperation", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to The property at '{0}' could not be read.. + /// + internal static string CannotReadProperty { + get { + return ResourceManager.GetString("CannotReadProperty", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to The property at path '{0}' could not be updated.. + /// + internal static string CannotUpdateProperty { + get { + return ResourceManager.GetString("CannotUpdateProperty", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to The expression '{0}' is not supported. Supported expressions include member access and indexer expressions.. + /// + internal static string ExpressionTypeNotSupported { + get { + return ResourceManager.GetString("ExpressionTypeNotSupported", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to The index value provided by path segment '{0}' is out of bounds of the array size.. + /// + internal static string IndexOutOfBounds { + get { + return ResourceManager.GetString("IndexOutOfBounds", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to The path segment '{0}' is invalid for an array index.. + /// + internal static string InvalidIndexValue { + get { + return ResourceManager.GetString("InvalidIndexValue", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to The type '{0}' was malformed and could not be parsed.. + /// + internal static string InvalidJsonPatchDocument { + get { + return ResourceManager.GetString("InvalidJsonPatchDocument", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Invalid JsonPatch operation '{0}'.. + /// + internal static string InvalidJsonPatchOperation { + get { + return ResourceManager.GetString("InvalidJsonPatchOperation", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to The provided path segment '{0}' cannot be converted to the target type.. + /// + internal static string InvalidPathSegment { + get { + return ResourceManager.GetString("InvalidPathSegment", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to The provided string '{0}' is an invalid path.. + /// + internal static string InvalidValueForPath { + get { + return ResourceManager.GetString("InvalidValueForPath", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to The value '{0}' is invalid for target location.. + /// + internal static string InvalidValueForProperty { + get { + return ResourceManager.GetString("InvalidValueForProperty", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to '{0}' must be of type '{1}'.. + /// + internal static string ParameterMustMatchType { + get { + return ResourceManager.GetString("ParameterMustMatchType", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to The type '{0}' which is an array is not supported for json patch operations as it has a fixed size.. + /// + internal static string PatchNotSupportedForArrays { + get { + return ResourceManager.GetString("PatchNotSupportedForArrays", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to The type '{0}' which is a non generic list is not supported for json patch operations. Only generic list types are supported.. + /// + internal static string PatchNotSupportedForNonGenericLists { + get { + return ResourceManager.GetString("PatchNotSupportedForNonGenericLists", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to The target location specified by path segment '{0}' was not found.. + /// + internal static string TargetLocationAtPathSegmentNotFound { + get { + return ResourceManager.GetString("TargetLocationAtPathSegmentNotFound", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to For operation '{0}', the target location specified by path '{1}' was not found.. + /// + internal static string TargetLocationNotFound { + get { + return ResourceManager.GetString("TargetLocationNotFound", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to The test operation is not supported.. + /// + internal static string TestOperationNotSupported { + get { + return ResourceManager.GetString("TestOperationNotSupported", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to The current value '{0}' at position '{2}' is not equal to the test value '{1}'.. + /// + internal static string ValueAtListPositionNotEqualToTestValue { + get { + return ResourceManager.GetString("ValueAtListPositionNotEqualToTestValue", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to The value at '{0}' cannot be null or empty to perform the test operation.. + /// + internal static string ValueForTargetSegmentCannotBeNullOrEmpty { + get { + return ResourceManager.GetString("ValueForTargetSegmentCannotBeNullOrEmpty", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to The current value '{0}' at path '{2}' is not equal to the test value '{1}'.. + /// + internal static string ValueNotEqualToTestValue { + get { + return ResourceManager.GetString("ValueNotEqualToTestValue", resourceCulture); + } + } + } +} diff --git a/src/Tingle.AspNetCore.JsonPatch/Properties/Resources.resx b/src/Tingle.AspNetCore.JsonPatch/Properties/Resources.resx new file mode 100644 index 0000000..87cc399 --- /dev/null +++ b/src/Tingle.AspNetCore.JsonPatch/Properties/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/Tingle.AspNetCore.JsonPatch/Properties/ResourcesExtensions.cs b/src/Tingle.AspNetCore.JsonPatch/Properties/ResourcesExtensions.cs new file mode 100644 index 0000000..e1b94be --- /dev/null +++ b/src/Tingle.AspNetCore.JsonPatch/Properties/ResourcesExtensions.cs @@ -0,0 +1,62 @@ +using System.Linq.Expressions; + +#nullable disable + +namespace Tingle.AspNetCore.JsonPatch.Properties; + +internal partial class Resources +{ + public static string FormatTargetLocationAtPathSegmentNotFound(string segment) + => string.Format(TargetLocationAtPathSegmentNotFound, segment); + + public static string FormatValueForTargetSegmentCannotBeNullOrEmpty(string segment) + => string.Format(ValueForTargetSegmentCannotBeNullOrEmpty, segment); + + public static string FormatValueNotEqualToTestValue(object currentValue, object value, string segment) + => string.Format(ValueNotEqualToTestValue, currentValue, value, segment); + + public static string FormatCannotCopyProperty(string propertyName) + => string.Format(CannotCopyProperty, propertyName); + + public static string FormatCannotPerformOperation(string operation, string path) + => string.Format(CannotPerformOperation, operation, path); + + public static string FormatCannotReadProperty(object propertyName) + => string.Format(CannotReadProperty, propertyName); + + public static string FormatCannotUpdateProperty(object propertyName) + => string.Format(CannotUpdateProperty, propertyName); + + public static string FormatTargetLocationNotFound(string operation, string path) + => string.Format(TargetLocationNotFound, operation, path); + + public static string FormatInvalidPathSegment(string path) + => string.Format(InvalidPathSegment, path); + + public static string FormatInvalidValueForPath(string path) + => string.Format(InvalidValueForPath, path); + + public static string FormatInvalidIndexValue(string segment) + => string.Format(InvalidIndexValue, segment); + + public static string FormatIndexOutOfBounds(string segment) + => string.Format(IndexOutOfBounds, segment); + + public static string FormatValueAtListPositionNotEqualToTestValue(object currentValue, object value, int position) + => string.Format(ValueAtListPositionNotEqualToTestValue, currentValue, value, position); + + public static string FormatInvalidValueForProperty(object value) + => string.Format(InvalidValueForProperty, value); + + public static string FormatPatchNotSupportedForArrays(string name) + => string.Format(PatchNotSupportedForArrays, name); + + public static string FormatPatchNotSupportedForNonGenericLists(string name) + => string.Format(PatchNotSupportedForNonGenericLists, name); + + public static string FormatExpressionTypeNotSupported(Expression expr) + => string.Format(ExpressionTypeNotSupported, expr); + + public static string FormatInvalidJsonPatchOperation(string path) + => string.Format(InvalidJsonPatchOperation, path); +} diff --git a/src/Tingle.AspNetCore.JsonPatch/README.md b/src/Tingle.AspNetCore.JsonPatch/README.md new file mode 100644 index 0000000..093df13 --- /dev/null +++ b/src/Tingle.AspNetCore.JsonPatch/README.md @@ -0,0 +1,131 @@ +# Tingle.AspNetCore.JsonPatch + +The primary goal of this library is to provide functionalities to perform [JsonPatch](https://tools.ietf.org/html/rfc6902) operations on documents using `System.Text.Json` library. We'll show how to do this in some examples below. + +Let us first define a class representing a customer with orders. + +```cs +class Customer +{ + public string Name { get; set; } + public List Orders { get; set; } + public string AlternateName { get; set; } +} + +class Order +{ + public string Item { get; set; } + public int Quantity { get; set;} +} +``` + +An instantiated `Customer` object would then look like this: + +```cs +{ + "name": "John", + "alternateName": null, + "orders": + [ + { + "item": "Toy car", + "quantity": 1 + }, + { + "item": "C# In A Nutshell Book", + "quantity": 1 + } + ] +} +``` + +A JSON Patch document has an array of operations. Each operation identifies a particular type of change. Examples of such changes include adding an array element or replacing a property value. + +Let us create `JsonPatchDocument` instance to demonstrate the various patching functionalities we provide. + +```cs +var patchDoc = new JsonPatchDocument(); +// Define operations here... +``` + +By default, the case transform type is `LowerCase`. Other options available are `UpperCase`, `CamelCase` and `OriginalCase`. These can be set via the constructor of the `JsonPatchDocument`. For our example purposes we'll go with the default casing. Now let us see the supported patch operations. + +## Add Operation + +Let us set the `Name` of the customer and add an object to the end of the `orders` array. + +```cs +var order = new Order +{ + Item = "Car tracker", + Quantity = 10 +}; + +var patchDoc = new JsonPatchDocument(); +patchDoc.Add(x => x.Name, "Ben") + .Add(y => y.Orders, order); +``` + +## Remove Operation + +Let us set the `Name` to null and delete `orders[0]` + +```cs +var patchDoc = new JsonPatchDocument(); +patchDoc.Remove(x => x.Name, null) + .Remove(y => y.Orders, 0); +``` + +## Replace Operation + +This is the same as a `Remove` operation followed by an `Add`. Let us show how to do this below: + +```cs +var order = new Order +{ + Item = "Air Fryer", + Quantity = 1 +}; + +var patchDoc = new JsonPatchDocument(); +patchDoc.Replace(x => x.Name, null) + .Replace(y => y.Orders, order, 0); +``` + +The `Replace` operation can also be used to replace items in a dictionary by the given key. + +## Move Operation + +Let us Move `orders[1]` to before `orders[0]` and set `AlternateName` from the `Name` value. + +```cs +var patchDoc = new JsonPatchDocument(); +patchDoc.Move(x => x.Orders, 0, y => y.Orders, 1) // swap the orders + .Move(x => x.Name, y => y.AlternateName); // set AlternateName to Name while leaving Name as null +``` + +## Copy Operation + +This operation is fundamentally the same as `Move` without the final `Remove` step. + +Let us in the example below copy the value of `Name` to the `AlternateName` and insert a copy of `orders[1]` before `orders[0]`. + +```cs +var patchDoc = new JsonPatchDocument(); +patchDoc.Copy(x => x.Orders, 1, y => y.Orders, 0) + .Copy(x => x.Name, y => y.AlternateName); +``` + +## Test Operation + +This operation is commonly used to prevent an update when there's a concurrency conflict. + +The following sample patch document has no effect if the initial value of `Name` is "John", because the test fails: + +```cs +var patchDoc = new JsonPatchDocument(); +patchDoc.Test(x => x.Name, "Andrew") + .Add(x => x.Name, "Ben"); +``` + +The instantiated patch document can then be serialized or deserialized using the `JsonSerializer` in the `System.Text.Json` library. diff --git a/src/Tingle.AspNetCore.JsonPatch/SystemTextJsonPatchInputFormatter.cs b/src/Tingle.AspNetCore.JsonPatch/SystemTextJsonPatchInputFormatter.cs new file mode 100644 index 0000000..2a1f824 --- /dev/null +++ b/src/Tingle.AspNetCore.JsonPatch/SystemTextJsonPatchInputFormatter.cs @@ -0,0 +1,66 @@ +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.Formatters; +using Microsoft.Extensions.Logging; + +namespace Tingle.AspNetCore.JsonPatch; + +internal class SystemTextJsonPatchInputFormatter : SystemTextJsonInputFormatter, IInputFormatterExceptionPolicy +{ + /// + /// Initializes a new instance of . + /// + /// The . + /// The . + public SystemTextJsonPatchInputFormatter(JsonOptions options, ILogger logger) : base(options, logger) + { + // Clear all values and only include json-patch+json value. + SupportedMediaTypes.Clear(); + + SupportedMediaTypes.Add(MediaTypeHeaderValues.ApplicationJsonPatch); + } + + /// + public virtual InputFormatterExceptionPolicy ExceptionPolicy + { + get + { + if (GetType() == typeof(SystemTextJsonPatchInputFormatter)) + { + return InputFormatterExceptionPolicy.MalformedInputExceptions; + } + return InputFormatterExceptionPolicy.AllExceptions; + } + } + + /// + public override async Task ReadRequestBodyAsync(InputFormatterContext context) + { + ArgumentNullException.ThrowIfNull(context); + + var result = await base.ReadRequestBodyAsync(context).ConfigureAwait(false); + if (!result.HasError) + { + if (result.Model is IJsonPatchDocument jsonPatchDocument && SerializerOptions is not null) + { + jsonPatchDocument.SerializerOptions = SerializerOptions; + } + } + + return result; + } + + /// + public override bool CanRead(InputFormatterContext context) + { + ArgumentNullException.ThrowIfNull(context); + + var modelType = context.ModelType; + if (!typeof(IJsonPatchDocument).IsAssignableFrom(modelType) || + !modelType.IsGenericType) + { + return false; + } + + return base.CanRead(context); + } +} diff --git a/src/Tingle.AspNetCore.JsonPatch/SystemTextJsonPatchMergeInputFormatter.cs b/src/Tingle.AspNetCore.JsonPatch/SystemTextJsonPatchMergeInputFormatter.cs new file mode 100644 index 0000000..e0a07cf --- /dev/null +++ b/src/Tingle.AspNetCore.JsonPatch/SystemTextJsonPatchMergeInputFormatter.cs @@ -0,0 +1,66 @@ +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.Formatters; +using Microsoft.Extensions.Logging; + +namespace Tingle.AspNetCore.JsonPatch; + +internal class SystemTextJsonPatchMergeInputFormatter : SystemTextJsonInputFormatter, IInputFormatterExceptionPolicy +{ + /// + /// Initializes a new instance of . + /// + /// The . + /// The . + public SystemTextJsonPatchMergeInputFormatter(JsonOptions options, ILogger logger) : base(options, logger) + { + // Clear all values and only include merge-patch+json value. + SupportedMediaTypes.Clear(); + + SupportedMediaTypes.Add(MediaTypeHeaderValues.ApplicationJsonPatchMerge); + } + + /// + public virtual InputFormatterExceptionPolicy ExceptionPolicy + { + get + { + if (GetType() == typeof(SystemTextJsonPatchMergeInputFormatter)) + { + return InputFormatterExceptionPolicy.MalformedInputExceptions; + } + return InputFormatterExceptionPolicy.AllExceptions; + } + } + + /// + public override async Task ReadRequestBodyAsync(InputFormatterContext context) + { + ArgumentNullException.ThrowIfNull(context); + + var result = await base.ReadRequestBodyAsync(context).ConfigureAwait(false); + if (!result.HasError) + { + if (result.Model is IJsonPatchMergeDocument jsonPatchMergeDocument && SerializerOptions is not null) + { + jsonPatchMergeDocument.SerializerOptions = SerializerOptions; + } + } + + return result; + } + + /// + public override bool CanRead(InputFormatterContext context) + { + ArgumentNullException.ThrowIfNull(context); + + var modelType = context.ModelType; + if (!typeof(IJsonPatchMergeDocument).IsAssignableFrom(modelType) || + !modelType.IsGenericType) + { + return false; + } + + return base.CanRead(context); + } +} diff --git a/src/Tingle.AspNetCore.JsonPatch/Tingle.AspNetCore.JsonPatch.csproj b/src/Tingle.AspNetCore.JsonPatch/Tingle.AspNetCore.JsonPatch.csproj new file mode 100644 index 0000000..8d75530 --- /dev/null +++ b/src/Tingle.AspNetCore.JsonPatch/Tingle.AspNetCore.JsonPatch.csproj @@ -0,0 +1,17 @@ + + + + JSON Patch support for AspNetCore using System.Text.Json + false + false + + + + + + + + + + + diff --git a/tests/Tingle.AspNetCore.JsonPatch.Tests/Adapters/AdapterFactoryTests.cs b/tests/Tingle.AspNetCore.JsonPatch.Tests/Adapters/AdapterFactoryTests.cs new file mode 100644 index 0000000..3762e23 --- /dev/null +++ b/tests/Tingle.AspNetCore.JsonPatch.Tests/Adapters/AdapterFactoryTests.cs @@ -0,0 +1,65 @@ +using System.Text.Json; +using Tingle.AspNetCore.JsonPatch.Adapters; +using Tingle.AspNetCore.JsonPatch.Internal; + +namespace Tingle.AspNetCore.JsonPatch.Test.Adapters; + +public class AdapterFactoryTests +{ + [Fact] + public void GetListAdapterForListTargets() + { + // Arrange + AdapterFactory factory = new(); + + //Act: + IAdapter adapter = factory.Create(new List(), new JsonSerializerOptions()); + + // Assert + Assert.Equal(typeof(ListAdapter), adapter.GetType()); + } + + //[Fact] + //public void GetDictionaryAdapterForDictionaryObjects() + //{ + // // Arrange + // AdapterFactory factory = new AdapterFactory(); + + // //Act: + // IAdapter adapter = factory.Create(new Dictionary(), new JsonSerializerOptions()); + + // // Assert + // Assert.Equal(typeof(DictionaryAdapter), adapter.GetType()); + //} + + private class PocoModel { } + + + [Fact] + public void GetPocoAdapterForGenericObjects() + { + // Arrange + AdapterFactory factory = new(); + + //Act: + IAdapter adapter = factory.Create(new PocoModel(), new JsonSerializerOptions()); + + // Assert + Assert.Equal(typeof(PocoAdapter), adapter.GetType()); + } + + + + //[Fact] + //public void GetDynamicAdapterForGenericObjects() + //{ + // // Arrange + // AdapterFactory factory = new AdapterFactory(); + + // //Act: + // IAdapter adapter = factory.Create(new TestDynamicObject(), new JsonSerializerOptions()); + + // // Assert + // Assert.Equal(typeof(DynamicObjectAdapter), adapter.GetType()); + //} +} diff --git a/tests/Tingle.AspNetCore.JsonPatch.Tests/Adapters/TestDynamicObject.cs b/tests/Tingle.AspNetCore.JsonPatch.Tests/Adapters/TestDynamicObject.cs new file mode 100644 index 0000000..bc3d8b8 --- /dev/null +++ b/tests/Tingle.AspNetCore.JsonPatch.Tests/Adapters/TestDynamicObject.cs @@ -0,0 +1,5 @@ +using System.Dynamic; + +namespace Tingle.AspNetCore.JsonPatch.Test.Adapters; + +public class TestDynamicObject : DynamicObject { } diff --git a/tests/Tingle.AspNetCore.JsonPatch.Tests/CustomNamingStrategyTests.cs b/tests/Tingle.AspNetCore.JsonPatch.Tests/CustomNamingStrategyTests.cs new file mode 100644 index 0000000..2a8754c --- /dev/null +++ b/tests/Tingle.AspNetCore.JsonPatch.Tests/CustomNamingStrategyTests.cs @@ -0,0 +1,187 @@ +using System.Dynamic; +using System.Text.Json; + +namespace Tingle.AspNetCore.JsonPatch; + +public class CustomNamingPolicyTests +{ + [Fact] + public void OperationsRespectPropertyNamingPolicy() + { + // Arrange + var serializerOptions = new JsonSerializerOptions + { +#if NET8_0_OR_GREATER + PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower, +#else + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, +#endif + }; + + var patchDocument = new JsonPatchDocument(serializerOptions); + patchDocument.Replace(x => x.StringProperty, "Test"); + + // Act + var operation = Assert.Single(patchDocument.Operations); +#if NET8_0_OR_GREATER + Assert.Equal("/string_property", operation.path); +#else + Assert.Equal("/stringProperty", operation.path); +#endif + } + + [Fact] + public void OperationsRespectDictionaryKeyPolicy() + { + // Arrange + var serializerOptions = new JsonSerializerOptions + { +#if NET8_0_OR_GREATER + PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower, +#else + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, +#endif + }; + + var patchDocument = new JsonPatchDocument(serializerOptions); + patchDocument.Replace(x => x.CustomData, "NamedKey", "Test"); + + // Act + var operation = Assert.Single(patchDocument.Operations); +#if NET8_0_OR_GREATER + Assert.Equal("/custom_data/NamedKey", operation.path); +#else + Assert.Equal("/customData/NamedKey", operation.path); +#endif + } + + [Fact] + public void AddProperty_ToDynamicTestObject_WithCustomNamingStrategy() + { + // Arrange + var serializerOptions = new JsonSerializerOptions + { + PropertyNamingPolicy = new TestNamingPolicy() + }; + + dynamic targetObject = new DynamicTestObject(); + targetObject.Test = 1; + + var patchDocument = new JsonPatchDocument(); + patchDocument.Add("NewInt", 1); + patchDocument.SerializerOptions = serializerOptions; + + // Act + patchDocument.ApplyTo(targetObject); + + // Assert + Assert.Equal(1, targetObject.customNewInt); + Assert.Equal(1, targetObject.Test); + } + + [Fact] + public void CopyPropertyValue_ToDynamicTestObject_WithCustomNamingStrategy() + { + // Arrange + var serializerOptions = new JsonSerializerOptions + { + PropertyNamingPolicy = new TestNamingPolicy() + }; + + dynamic targetObject = new DynamicTestObject(); + targetObject.customStringProperty = "A"; + targetObject.customAnotherStringProperty = "B"; + + var patchDocument = new JsonPatchDocument(); + patchDocument.Copy("StringProperty", "AnotherStringProperty"); + patchDocument.SerializerOptions = serializerOptions; + + // Act + patchDocument.ApplyTo(targetObject); + + // Assert + Assert.Equal("A", targetObject.customAnotherStringProperty); + } + + [Fact] + public void MovePropertyValue_ForExpandoObject_WithCustomNamingStrategy() + { + // Arrange + var serializerOptions = new JsonSerializerOptions + { + PropertyNamingPolicy = new TestNamingPolicy() + }; + + dynamic targetObject = new ExpandoObject(); + targetObject.customStringProperty = "A"; + targetObject.customAnotherStringProperty = "B"; + + var patchDocument = new JsonPatchDocument(); + patchDocument.Move("StringProperty", "AnotherStringProperty"); + patchDocument.SerializerOptions = serializerOptions; + + // Act + patchDocument.ApplyTo(targetObject); + var cont = (IDictionary)targetObject; + cont.TryGetValue("customStringProperty", out var valueFromDictionary); + + // Assert + Assert.Equal("A", targetObject.customAnotherStringProperty); + Assert.Null(valueFromDictionary); + } + + [Fact] + public void RemoveProperty_FromDictionaryObject_WithCustomNamingStrategy() + { + // Arrange + var serializerOptions = new JsonSerializerOptions + { + DictionaryKeyPolicy = new TestNamingPolicy(), + }; + + var targetObject = new Dictionary() + { + { "customTest", 1}, + }; + + var patchDocument = new JsonPatchDocument(); + patchDocument.Remove("Test"); + patchDocument.SerializerOptions = serializerOptions; + + // Act + patchDocument.ApplyTo(targetObject); + var cont = targetObject as IDictionary; + cont.TryGetValue("customTest", out var valueFromDictionary); + + // Assert + Assert.Equal(0, valueFromDictionary); + } + + [Fact] + public void ReplacePropertyValue_ForExpandoObject_WithCustomNamingStrategy() + { + // Arrange + var serializerOptions = new JsonSerializerOptions + { + PropertyNamingPolicy = new TestNamingPolicy() + }; + + dynamic targetObject = new ExpandoObject(); + targetObject.customTest = 1; + + var patchDocument = new JsonPatchDocument(); + patchDocument.Replace("Test", 2); + patchDocument.SerializerOptions = serializerOptions; + + // Act + patchDocument.ApplyTo(targetObject); + + // Assert + Assert.Equal(2, targetObject.customTest); + } + + private class TestNamingPolicy : JsonNamingPolicy + { + public override string ConvertName(string name) => "custom" + name; + } +} \ No newline at end of file diff --git a/tests/Tingle.AspNetCore.JsonPatch.Tests/IntegrationTests/AnonymousObjectIntegrationTest.cs b/tests/Tingle.AspNetCore.JsonPatch.Tests/IntegrationTests/AnonymousObjectIntegrationTest.cs new file mode 100644 index 0000000..ca3d444 --- /dev/null +++ b/tests/Tingle.AspNetCore.JsonPatch.Tests/IntegrationTests/AnonymousObjectIntegrationTest.cs @@ -0,0 +1,185 @@ +using Tingle.AspNetCore.JsonPatch.Exceptions; + +namespace Tingle.AspNetCore.JsonPatch.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 AddNewProperty_ToNestedAnonymousObject_ShouldFail() + { + // Arrange + dynamic targetObject = new + { + Test = 1, + nested = new { } + }; + + var patchDocument = new JsonPatchDocument(); + patchDocument.Add("Nested/NewInt", 1); + + // Act + var exception = Assert.Throws(() => + { + patchDocument.ApplyTo(targetObject); + }); + + // Assert + Assert.Equal("The target location specified by path segment 'NewInt' 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/tests/Tingle.AspNetCore.JsonPatch.Tests/IntegrationTests/DictionaryIntegrationTest.cs b/tests/Tingle.AspNetCore.JsonPatch.Tests/IntegrationTests/DictionaryIntegrationTest.cs new file mode 100644 index 0000000..925e338 --- /dev/null +++ b/tests/Tingle.AspNetCore.JsonPatch.Tests/IntegrationTests/DictionaryIntegrationTest.cs @@ -0,0 +1,313 @@ +using Tingle.AspNetCore.JsonPatch.Exceptions; + +namespace Tingle.AspNetCore.JsonPatch.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/tests/Tingle.AspNetCore.JsonPatch.Tests/IntegrationTests/DynamicObjectIntegrationTest.cs b/tests/Tingle.AspNetCore.JsonPatch.Tests/IntegrationTests/DynamicObjectIntegrationTest.cs new file mode 100644 index 0000000..b28a931 --- /dev/null +++ b/tests/Tingle.AspNetCore.JsonPatch.Tests/IntegrationTests/DynamicObjectIntegrationTest.cs @@ -0,0 +1,228 @@ +using Tingle.AspNetCore.JsonPatch.Exceptions; + +namespace Tingle.AspNetCore.JsonPatch.IntegrationTests; + +public class DynamicObjectIntegrationTest +{ + [Fact] + public void AddResults_ShouldReplaceExistingPropertyValue_InNestedDynamicObject() + { + // Arrange + dynamic dynamicTestObject = new DynamicTestObject(); + dynamicTestObject.Nested = new NestedObject(); + dynamicTestObject.Nested.DynamicProperty = new DynamicTestObject(); + dynamicTestObject.Nested.DynamicProperty.InBetweenFirst = new DynamicTestObject(); + dynamicTestObject.Nested.DynamicProperty.InBetweenFirst.InBetweenSecond = new DynamicTestObject(); + dynamicTestObject.Nested.DynamicProperty.InBetweenFirst.InBetweenSecond.StringProperty = "A"; + + var patchDocument = new JsonPatchDocument(); + patchDocument.Add("/Nested/DynamicProperty/InBetweenFirst/InBetweenSecond/StringProperty", "B"); + + // Act + patchDocument.ApplyTo(dynamicTestObject); + + // Assert + Assert.Equal("B", dynamicTestObject.Nested.DynamicProperty.InBetweenFirst.InBetweenSecond.StringProperty); + } + + [Fact] + public void ShouldNotBeAbleToAdd_ToNonExistingProperty_ThatIsNotTheRoot() + { + //Adding to a Nonexistent Target + // + // An example target JSON document: + // { "foo": "bar" } + // A JSON Patch document: + // [ + // { "op": "add", "path": "/baz/bat", "value": "qux" } + // ] + // This JSON Patch document, applied to the target JSON document above, + // would result in an error (therefore, it would not be applied), + // because the "add" operation's target location that references neither + // the root of the document, nor a member of an existing object, nor a + // member of an existing array. + + // Arrange + var nestedObject = new NestedObject() + { + DynamicProperty = new DynamicTestObject() + }; + + var patchDocument = new JsonPatchDocument(); + patchDocument.Add("DynamicProperty/OtherProperty/IntProperty", 1); + + // Act + var exception = Assert.Throws(() => + { + patchDocument.ApplyTo(nestedObject); + }); + + // Assert + Assert.Equal("The target location specified by path segment 'OtherProperty' was not found.", exception.Message); + } + + [Fact] + public void CopyProperties_InNestedDynamicObject() + { + // Arrange + dynamic dynamicTestObject = new DynamicTestObject(); + dynamicTestObject.NestedDynamicObject = new DynamicTestObject(); + dynamicTestObject.NestedDynamicObject.StringProperty = "A"; + dynamicTestObject.NestedDynamicObject.AnotherStringProperty = "B"; + + var patchDocument = new JsonPatchDocument(); + patchDocument.Copy("NestedDynamicObject/StringProperty", "NestedDynamicObject/AnotherStringProperty"); + + // Act + patchDocument.ApplyTo(dynamicTestObject); + + // Assert + Assert.Equal("A", dynamicTestObject.NestedDynamicObject.AnotherStringProperty); + } + + [Fact] + public void MoveToNonExistingProperty_InDynamicObject_ShouldAddNewProperty() + { + // Arrange + dynamic dynamicTestObject = new DynamicTestObject(); + dynamicTestObject.StringProperty = "A"; + + var patchDocument = new JsonPatchDocument(); + patchDocument.Move("StringProperty", "AnotherStringProperty"); + + // Act + patchDocument.ApplyTo(dynamicTestObject); + dynamicTestObject.TryGetValue("StringProperty", out object valueFromDictionary); + + // Assert + Assert.Equal("A", dynamicTestObject.AnotherStringProperty); + Assert.Null(valueFromDictionary); + } + + [Fact] + public void MovePropertyValue_FromDynamicObject_ToTypedObject() + { + // Arrange + dynamic dynamicTestObject = new DynamicTestObject(); + dynamicTestObject.StringProperty = "A"; + dynamicTestObject.SimpleObject = new SimpleObject() { AnotherStringProperty = "B" }; + + var patchDocument = new JsonPatchDocument(); + patchDocument.Move("StringProperty", "SimpleObject/AnotherStringProperty"); + + // Act + patchDocument.ApplyTo(dynamicTestObject); + dynamicTestObject.TryGetValue("StringProperty", out object valueFromDictionary); + + // Assert + Assert.Equal("A", dynamicTestObject.SimpleObject.AnotherStringProperty); + Assert.Null(valueFromDictionary); + } + + [Fact] + public void RemoveNestedProperty_FromDynamicObject() + { + // Arrange + dynamic dynamicTestObject = new DynamicTestObject(); + dynamicTestObject.Test = new DynamicTestObject(); + dynamicTestObject.Test.AnotherTest = "A"; + + var patchDocument = new JsonPatchDocument(); + patchDocument.Remove("Test"); + + // Act + patchDocument.ApplyTo(dynamicTestObject); + dynamicTestObject.TryGetValue("Test", out object valueFromDictionary); + + // Assert + Assert.Null(valueFromDictionary); + } + + [Fact] + public void RemoveFromNestedObject_InDynamicObject_MixedCase_ThrowsPathNotFoundException() + { + // Arrange + dynamic dynamicTestObject = new DynamicTestObject(); + dynamicTestObject.SimpleObject = new SimpleObject() + { + StringProperty = "A" + }; + + var patchDocument = new JsonPatchDocument(); + patchDocument.Remove("Simpleobject/stringProperty"); + + // Act + var exception = Assert.Throws(() => + { + patchDocument.ApplyTo(dynamicTestObject); + }); + + // Assert + Assert.Equal("The target location specified by path segment 'Simpleobject' was not found.", exception.Message); + } + + [Fact] + public void ReplaceNestedTypedObject_InDynamicObject() + { + // Arrange + dynamic dynamicTestObject = new DynamicTestObject(); + dynamicTestObject.SimpleObject = new SimpleObject() + { + IntegerValue = 5, + IntegerList = [1, 2, 3] + }; + + var newObject = new SimpleObject() + { + DoubleValue = 1 + }; + + var patchDocument = new JsonPatchDocument(); + patchDocument.Replace("SimpleObject", newObject); + + // Act + patchDocument.ApplyTo(dynamicTestObject); + + // Assert + Assert.Equal(1, dynamicTestObject.SimpleObject.DoubleValue); + Assert.Equal(0, dynamicTestObject.SimpleObject.IntegerValue); + Assert.Null(dynamicTestObject.SimpleObject.IntegerList); + } + + [Fact] + public void TestStringPropertyValue_IsSuccessful() + { + // Arrange + dynamic dynamicTestObject = new DynamicTestObject(); + dynamicTestObject.Property = "A"; + + var patchDocument = new JsonPatchDocument(); + patchDocument.Test("Property", "A"); + + // Act & Assert + patchDocument.ApplyTo(dynamicTestObject); + } + + [Fact] + public void TestIntegerPropertyValue_ThrowsJsonPatchException_IfTestFails() + { + // Arrange + dynamic dynamicTestObject = new DynamicTestObject(); + dynamicTestObject.Nested = new SimpleObject() + { + IntegerList = [1, 2, 3] + }; + + var patchDocument = new JsonPatchDocument(); + patchDocument.Test("Nested/IntegerList/0", 2); + + // Act + var exception = Assert.Throws(() => + { + patchDocument.ApplyTo(dynamicTestObject); + }); + + // Assert + Assert.Equal("The current value '1' at position '0' is not equal to the test value '2'.", exception.Message); + } +} diff --git a/tests/Tingle.AspNetCore.JsonPatch.Tests/IntegrationTests/ExpandoObjectIntegrationTest.cs b/tests/Tingle.AspNetCore.JsonPatch.Tests/IntegrationTests/ExpandoObjectIntegrationTest.cs new file mode 100644 index 0000000..3ddc589 --- /dev/null +++ b/tests/Tingle.AspNetCore.JsonPatch.Tests/IntegrationTests/ExpandoObjectIntegrationTest.cs @@ -0,0 +1,374 @@ +using System.Dynamic; +using Tingle.AspNetCore.JsonPatch.Exceptions; + +namespace Tingle.AspNetCore.JsonPatch.IntegrationTests; + +public class ExpandoObjectIntegrationTest +{ + [Fact] + public void AddNewIntProperty() + { + // Arrange + dynamic targetObject = new ExpandoObject(); + targetObject.Test = 1; + + var patchDocument = new JsonPatchDocument(); + patchDocument.Add("NewInt", 1); + + // Act + patchDocument.ApplyTo(targetObject); + + // Assert + Assert.Equal(1, targetObject.NewInt); + Assert.Equal(1, targetObject.Test); + } + + [Fact] + public void AddNewProperty_ToTypedObject_InExpandoObject() + { + // Arrange + dynamic dynamicProperty = new ExpandoObject(); + dynamicProperty.StringProperty = "A"; + + var targetObject = new NestedObject() + { + DynamicProperty = dynamicProperty + }; + + var patchDocument = new JsonPatchDocument(); + patchDocument.Add("DynamicProperty/StringProperty", "B"); + + // Act + patchDocument.ApplyTo(targetObject); + + // Assert + Assert.Equal("B", targetObject.DynamicProperty.StringProperty); + } + + [Fact] + public void AddReplaces_ExistingProperty() + { + // Arrange + dynamic targetObject = new ExpandoObject(); + targetObject.StringProperty = "A"; + + var patchDocument = new JsonPatchDocument(); + patchDocument.Add("StringProperty", "B"); + + // Act + patchDocument.ApplyTo(targetObject); + + // Assert + Assert.Equal("B", targetObject.StringProperty); + } + + [Fact] + public void AddReplaces_ExistingProperty_InNestedExpandoObject() + { + // Arrange + dynamic targetObject = new ExpandoObject(); + targetObject.InBetweenFirst = new ExpandoObject(); + targetObject.InBetweenFirst.InBetweenSecond = new ExpandoObject(); + targetObject.InBetweenFirst.InBetweenSecond.StringProperty = "A"; + + var patchDocument = new JsonPatchDocument(); + patchDocument.Add("/InBetweenFirst/InBetweenSecond/StringProperty", "B"); + + // Act + patchDocument.ApplyTo(targetObject); + + // Assert + Assert.Equal("B", targetObject.InBetweenFirst.InBetweenSecond.StringProperty); + } + + [Fact] + public void ShouldNotReplaceProperty_WithDifferentCase() + { + // Arrange + dynamic targetObject = new ExpandoObject(); + targetObject.StringProperty = "A"; + + var patchDocument = new JsonPatchDocument(); + patchDocument.Add("stringproperty", "B"); + + // Act + patchDocument.ApplyTo(targetObject); + + // Assert + Assert.Equal("A", targetObject.StringProperty); + Assert.Equal("B", targetObject.stringproperty); + } + + [Fact] + public void TestIntegerProperty_IsSuccessful() + { + // Arrange + dynamic targetObject = new ExpandoObject(); + targetObject.Test = 1; + + var patchDocument = new JsonPatchDocument(); + patchDocument.Test("Test", 1); + + // Act & Assert + patchDocument.ApplyTo(targetObject); + } + + [Fact] + public void TestEmptyProperty_IsSuccessful() + { + // Arrange + dynamic targetObject = new ExpandoObject(); + targetObject.Test = ""; + + var patchDocument = new JsonPatchDocument(); + patchDocument.Test("Test", ""); + + // Act & Assert + patchDocument.ApplyTo(targetObject); + } + + [Fact] + public void TestValueAgainstEmptyProperty_ThrowsJsonPatchException_IsSuccessful() + { + // Arrange + dynamic targetObject = new ExpandoObject(); + targetObject.Test = ""; + + var patchDocument = new JsonPatchDocument(); + patchDocument.Test("Test", "TestValue"); + + // Act + var exception = Assert.Throws(() => + { + patchDocument.ApplyTo(targetObject); + }); + + // Assert + Assert.Equal("The current value '' at path 'Test' is not equal to the test value 'TestValue'.", + exception.Message); + } + + [Fact] + public void TestStringProperty_ThrowsJsonPatchException_IfTestFails() + { + // Arrange + dynamic targetObject = new ExpandoObject(); + targetObject.Test = "Value"; + + var patchDocument = new JsonPatchDocument(); + patchDocument.Test("Test", "TestValue"); + + // Act + var exception = Assert.Throws(() => + { + patchDocument.ApplyTo(targetObject); + }); + + // Assert + Assert.Equal("The current value 'Value' at path 'Test' is not equal to the test value 'TestValue'.", + exception.Message); + } + + [Fact] + public void CopyStringProperty_ToAnotherStringProperty() + { + // Arrange + dynamic targetObject = new ExpandoObject(); + + targetObject.StringProperty = "A"; + targetObject.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 + dynamic targetObject = new ExpandoObject(); + + targetObject.StringProperty = null; + targetObject.AnotherStringProperty = "B"; + + var patchDocument = new JsonPatchDocument(); + patchDocument.Copy("StringProperty", "AnotherStringProperty"); + + // Act + patchDocument.ApplyTo(targetObject); + + // Assert + Assert.Null(targetObject.AnotherStringProperty); + } + + [Fact] + public void MoveIntegerValue_ToAnotherIntegerProperty() + { + // Arrange + dynamic targetObject = new ExpandoObject(); + targetObject.IntegerValue = 100; + targetObject.AnotherIntegerValue = 200; + + var patchDocument = new JsonPatchDocument(); + patchDocument.Move("IntegerValue", "AnotherIntegerValue"); + + // Act + patchDocument.ApplyTo(targetObject); + + Assert.Equal(100, targetObject.AnotherIntegerValue); + + var cont = (IDictionary)targetObject; + cont.TryGetValue("IntegerValue", out object? valueFromDictionary); + + // Assert + Assert.Null(valueFromDictionary); + } + + [Fact] + public void Move_ToNonExistingProperty() + { + // Arrange + dynamic targetObject = new ExpandoObject(); + targetObject.StringProperty = "A"; + + var patchDocument = new JsonPatchDocument(); + patchDocument.Move("StringProperty", "AnotherStringProperty"); + + // Act + patchDocument.ApplyTo(targetObject); + + Assert.Equal("A", targetObject.AnotherStringProperty); + + var cont = (IDictionary)targetObject; + cont.TryGetValue("StringProperty", out var valueFromDictionary); + + // Assert + Assert.Null(valueFromDictionary); + } + + [Fact] + public void RemoveProperty_ShouldFail_IfItDoesntExist() + { + // Arrange + dynamic targetObject = new ExpandoObject(); + targetObject.Test = 1; + + var patchDocument = new JsonPatchDocument(); + patchDocument.Remove("NonExisting"); + + // Act + var exception = Assert.Throws(() => + { + patchDocument.ApplyTo(targetObject); + }); + + // Assert + Assert.Equal("The target location specified by path segment 'NonExisting' was not found.", exception.Message); + } + + [Fact] + public void RemoveStringProperty() + { + // Arrange + dynamic targetObject = new ExpandoObject(); + targetObject.Test = 1; + + var patchDocument = new JsonPatchDocument(); + patchDocument.Remove("Test"); + + // Act + patchDocument.ApplyTo(targetObject); + + var cont = (IDictionary)targetObject; + cont.TryGetValue("Test", out object? valueFromDictionary); + + // Assert + Assert.Null(valueFromDictionary); + } + + [Fact] + public void RemoveProperty_MixedCase_ThrowsPathNotFoundException() + { + // Arrange + dynamic targetObject = new ExpandoObject(); + targetObject.Test = 1; + + var patchDocument = new JsonPatchDocument(); + patchDocument.Remove("test"); + + // Act + var exception = Assert.Throws(() => + { + patchDocument.ApplyTo(targetObject); + }); + + // Assert + Assert.Equal("The target location specified by path segment 'test' was not found.", exception.Message); + } + + [Fact] + public void RemoveNestedProperty() + { + // Arrange + dynamic targetObject = new ExpandoObject(); + targetObject.Test = new ExpandoObject(); + targetObject.Test.AnotherTest = "A"; + + var patchDocument = new JsonPatchDocument(); + patchDocument.Remove("Test"); + + // Act + patchDocument.ApplyTo(targetObject); + + var cont = (IDictionary)targetObject; + cont.TryGetValue("Test", out object? valueFromDictionary); + + // Assert + Assert.Null(valueFromDictionary); + } + + [Fact] + public void RemoveNestedProperty_MixedCase_ThrowsPathNotFoundException() + { + // Arrange + dynamic targetObject = new ExpandoObject(); + targetObject.Test = new ExpandoObject(); + targetObject.Test.AnotherTest = "A"; + + var patchDocument = new JsonPatchDocument(); + patchDocument.Remove("test"); + + // Act + var exception = Assert.Throws(() => + { + patchDocument.ApplyTo(targetObject); + }); + + // Assert + Assert.Equal("The target location specified by path segment 'test' was not found.", exception.Message); + } + + [Fact] + public void ReplaceGuid() + { + // Arrange + dynamic targetObject = new ExpandoObject(); + targetObject.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); + } +} diff --git a/tests/Tingle.AspNetCore.JsonPatch.Tests/IntegrationTests/HeterogenousCollectionTests.cs b/tests/Tingle.AspNetCore.JsonPatch.Tests/IntegrationTests/HeterogenousCollectionTests.cs new file mode 100644 index 0000000..b709637 --- /dev/null +++ b/tests/Tingle.AspNetCore.JsonPatch.Tests/IntegrationTests/HeterogenousCollectionTests.cs @@ -0,0 +1,76 @@ +using System.Text.Json; +using System.Text.Json.Nodes; +using System.Text.Json.Serialization; + +namespace Tingle.AspNetCore.JsonPatch.IntegrationTests; + +public class HeterogenousCollectionTests +{ + [Fact] + public void AddItemToList() + { + // Arrange + var targetObject = new Canvas() + { + Items = [] + }; + + var circleJsonNode = JsonNode.Parse(@"{ + ""Type"": ""Circle"", + ""ShapeProperty"": ""Shape property"", + ""CircleProperty"": ""Circle property"" + }")!; + + var patchDocument = new JsonPatchDocument + { + SerializerOptions = new JsonSerializerOptions() + { + Converters = { new ShapeJsonConverter() } + } + }; + + patchDocument.Add("/Items/-", circleJsonNode); + + // 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 ShapeJsonConverter : JsonConverter +{ + private const string TypeProperty = "Type"; + + private static Shape CreateShape(JsonNode jsonNode) + { + var typeProperty = jsonNode[TypeProperty]!; + + return typeProperty.GetValue() switch + { + "Circle" => new Circle(), + "Rectangle" => new Rectangle(), + _ => throw new NotSupportedException(), + }; + } + + public override Shape Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + var jsonNode = JsonNode.Parse(ref reader); + + var target = CreateShape(jsonNode!); + + target = (Shape)JsonSerializer.Deserialize(jsonNode, target.GetType())!; + + return target; + } + + public override void Write(Utf8JsonWriter writer, Shape value, JsonSerializerOptions options) + { + throw new NotImplementedException(); + } +} diff --git a/tests/Tingle.AspNetCore.JsonPatch.Tests/IntegrationTests/ListIntegrationTest.cs b/tests/Tingle.AspNetCore.JsonPatch.Tests/IntegrationTests/ListIntegrationTest.cs new file mode 100644 index 0000000..65027fa --- /dev/null +++ b/tests/Tingle.AspNetCore.JsonPatch.Tests/IntegrationTests/ListIntegrationTest.cs @@ -0,0 +1,349 @@ +using System.Collections.ObjectModel; +using Tingle.AspNetCore.JsonPatch.Exceptions; + +namespace Tingle.AspNetCore.JsonPatch.IntegrationTests; + +public class ListIntegrationTest +{ + [Fact] + public void TestInList_IsSuccessful() + { + // Arrange + var targetObject = new SimpleObjectWithNestedObject() + { + SimpleObject = new SimpleObject() + { + IntegerList = [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 = [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 = [1, 2, 3] + } + }; + + var patchDocument = new JsonPatchDocument(); + patchDocument.Add(o => (List?)o.SimpleObject.IntegerIList, 4, 0); + + // Act + patchDocument.ApplyTo(targetObject); + + // Assert + Assert.Equal([4, 1, 2, 3], targetObject.SimpleObject.IntegerIList); + } + + [Fact] + public void AddToComplextTypeList_SpecifyIndex() + { + // Arrange + var targetObject = new SimpleObjectWithNestedObject() + { + SimpleObjectList = [ + 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 = [1, 2, 3] + } + }; + + var patchDocument = new JsonPatchDocument(); + patchDocument.Add(o => o.SimpleObject.IntegerList, 4); + + // Act + patchDocument.ApplyTo(targetObject); + + // Assert + Assert.Equal([1, 2, 3, 4], targetObject.SimpleObject.IntegerList); + } + + [Fact] + public void RemoveFromList() + { + // Arrange + var targetObject = new SimpleObject() + { + IntegerList = [1, 2, 3] + }; + + var patchDocument = new JsonPatchDocument(); + patchDocument.Remove("IntegerList/2"); + + // Act + patchDocument.ApplyTo(targetObject); + + // Assert + Assert.Equal([1, 2], targetObject.IntegerList); + } + + [Theory] + [InlineData("3")] + [InlineData("-1")] + public void RemoveFromList_InvalidPosition(string position) + { + // Arrange + var targetObject = new SimpleObject() + { + IntegerList = [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 = [1, 2, 3] + } + }; + + var patchDocument = new JsonPatchDocument(); + patchDocument.Remove(o => o.SimpleObject.IntegerList); + + // Act + patchDocument.ApplyTo(targetObject); + + // Assert + Assert.Equal([1, 2], targetObject.SimpleObject.IntegerList); + } + + [Fact] + public void ReplaceFullList_WithCollection() + { + // Arrange + var targetObject = new SimpleObject() + { + IntegerList = [1, 2, 3] + }; + + var patchDocument = new JsonPatchDocument(); + patchDocument.Replace("IntegerList", new Collection() { 4, 5, 6 }); + + // Act + patchDocument.ApplyTo(targetObject); + + // Assert + Assert.Equal([4, 5, 6], targetObject.IntegerList); + } + + [Fact] + public void Replace_AtEndOfList() + { + // Arrange + var targetObject = new SimpleObjectWithNestedObject() + { + SimpleObject = new SimpleObject() + { + IntegerList = [1, 2, 3] + } + }; + + var patchDocument = new JsonPatchDocument(); + patchDocument.Replace(o => o.SimpleObject.IntegerList, 5); + + // Act + patchDocument.ApplyTo(targetObject); + + // Assert + Assert.Equal([1, 2, 5], targetObject.SimpleObject.IntegerList); + } + + [Fact] + public void Replace_InList_InvalidPosition() + { + // Arrange + var targetObject = new SimpleObjectWithNestedObject() + { + SimpleObject = new SimpleObject() + { + IntegerList = [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 = [1, 2, 3] + }; + + var patchDocument = new JsonPatchDocument(); + patchDocument.Copy("IntegerList/0", "IntegerList/-"); + + // Act + patchDocument.ApplyTo(targetObject); + + // Assert + Assert.Equal([1, 2, 3, 1], targetObject.IntegerList); + } + + [Fact] + public void CopyFromListToNonList() + { + // Arrange + var targetObject = new SimpleObject() + { + IntegerList = [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 = [1, 2, 3] + }; + + var patchDocument = new JsonPatchDocument(); + patchDocument.Move("IntegerValue", "IntegerList/-"); + + // Act + patchDocument.ApplyTo(targetObject); + + // Assert + Assert.Equal(0, targetObject.IntegerValue); + Assert.Equal([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 = [simpleObject1, simpleObject2, simpleObject3,] + }; + + var patchDocument = new JsonPatchDocument(); + patchDocument.Move(o => o.SimpleObjectList, 0, o => o.SimpleObjectList, 1); + + // Act + patchDocument.ApplyTo(targetObject); + + // Assert + Assert.Equal([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 = [1, 2, 3] + } + }; + + var patchDocument = new JsonPatchDocument(); + patchDocument.Move(o => o.SimpleObject.IntegerList, 0, o => o.IntegerValue); + + // Act + patchDocument.ApplyTo(targetObject); + + // Assert + Assert.Equal([2, 3], targetObject.SimpleObject.IntegerList); + Assert.Equal(1, targetObject.IntegerValue); + } +} diff --git a/tests/Tingle.AspNetCore.JsonPatch.Tests/IntegrationTests/NestedObjectIntegrationTest.cs b/tests/Tingle.AspNetCore.JsonPatch.Tests/IntegrationTests/NestedObjectIntegrationTest.cs new file mode 100644 index 0000000..e75efb7 --- /dev/null +++ b/tests/Tingle.AspNetCore.JsonPatch.Tests/IntegrationTests/NestedObjectIntegrationTest.cs @@ -0,0 +1,351 @@ +using System.Dynamic; +using System.Text.Json; + +namespace Tingle.AspNetCore.JsonPatch.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 AddNewProperty_ToExpandoOject_InTypedObject() + { + var targetObject = new NestedObject() + { + DynamicProperty = new ExpandoObject() + }; + + var patchDocument = new JsonPatchDocument(); + patchDocument.Add("DynamicProperty/NewInt", 1); + + patchDocument.ApplyTo(targetObject); + + Assert.Equal(1, targetObject.DynamicProperty.NewInt); + } + + [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 = default!; + + public string StringProperty + { + get => stringProperty; + set + { + ArgumentNullException.ThrowIfNull(value); + stringProperty = value; + } + } + } + + private class SimpleObjectWithNestedObjectWithNullCheck + { + public SimpleObjectWithNullCheck SimpleObjectWithNullCheck { get; set; } + + public SimpleObjectWithNestedObjectWithNullCheck() + { + SimpleObjectWithNullCheck = new SimpleObjectWithNullCheck(); + } + } +} \ No newline at end of file diff --git a/tests/Tingle.AspNetCore.JsonPatch.Tests/IntegrationTests/SimpleObjectIntegrationTest.cs b/tests/Tingle.AspNetCore.JsonPatch.Tests/IntegrationTests/SimpleObjectIntegrationTest.cs new file mode 100644 index 0000000..72e9ea9 --- /dev/null +++ b/tests/Tingle.AspNetCore.JsonPatch.Tests/IntegrationTests/SimpleObjectIntegrationTest.cs @@ -0,0 +1,168 @@ +using System.Dynamic; +using Tingle.AspNetCore.JsonPatch.Exceptions; + +namespace Tingle.AspNetCore.JsonPatch.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 { 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/tests/Tingle.AspNetCore.JsonPatch.Tests/Internal/DictionaryAdapterTest.cs b/tests/Tingle.AspNetCore.JsonPatch.Tests/Internal/DictionaryAdapterTest.cs new file mode 100644 index 0000000..50c919f --- /dev/null +++ b/tests/Tingle.AspNetCore.JsonPatch.Tests/Internal/DictionaryAdapterTest.cs @@ -0,0 +1,297 @@ +using System.Globalization; +using System.Text.Json; + +namespace Tingle.AspNetCore.JsonPatch.Internal; + +public class DictionaryAdapterTest +{ + [Fact] + public void Add_KeyWhichAlreadyExists_ReplacesExistingValue() + { + // Arrange + var key = "Status"; + var dictionary = new Dictionary(StringComparer.Ordinal) { [key] = 404, }; + var dictionaryAdapter = new DictionaryAdapter(); + var options = new JsonSerializerOptions(); + + // Act + var addStatus = dictionaryAdapter.TryAdd(dictionary, key, options, 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 { [intKey] = "Mike", }; + var dictionaryAdapter = new DictionaryAdapter(); + var options = new JsonSerializerOptions(); + + // Act + var addStatus = dictionaryAdapter.TryAdd(dictionary, intKey.ToString(CultureInfo.InvariantCulture), options, "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 options = new JsonSerializerOptions(); + var key = 1; + var dictionary = new Dictionary(); + + // Act + var addStatus = dictionaryAdapter.TryAdd(dictionary, key.ToString(CultureInfo.InvariantCulture), options, "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(), options, out var outValue, out message); + + // Assert + Assert.False(getStatus); + Assert.Equal($"The provided path segment '{guidKey}' cannot be converted to the target type.", message); + Assert.Null(outValue); + } + + [Fact] + public void Get_UsingCaseSensitiveKey_FailureScenario() + { + // Arrange + var dictionaryAdapter = new DictionaryAdapter(); + var options = new JsonSerializerOptions(); + var nameKey = "Name"; + var dictionary = new Dictionary(StringComparer.Ordinal); + + // Act + var addStatus = dictionaryAdapter.TryAdd(dictionary, nameKey, options, "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(), options, 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 options = new JsonSerializerOptions(); + var nameKey = "Name"; + var dictionary = new Dictionary(StringComparer.Ordinal); + + // Act + var addStatus = dictionaryAdapter.TryAdd(dictionary, nameKey, options, "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, options, 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) { { nameKey, "Mike" }, }; + var dictionaryAdapter = new DictionaryAdapter(); + var options = new JsonSerializerOptions(); + + // Act + var replaceStatus = dictionaryAdapter.TryReplace(dictionary, nameKey, options, "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 { { guidKey, "Mike" }, }; + var dictionaryAdapter = new DictionaryAdapter(); + var options = new JsonSerializerOptions(); + + // Act + var replaceStatus = dictionaryAdapter.TryReplace(dictionary, guidKey.ToString(), options, "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 { { guidKey, 5 }, }; + var dictionaryAdapter = new DictionaryAdapter(); + var options = new JsonSerializerOptions(); + + // Act + var replaceStatus = dictionaryAdapter.TryReplace(dictionary, guidKey.ToString(), options, "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 options = new JsonSerializerOptions(); + + // Act + var replaceStatus = dictionaryAdapter.TryReplace(dictionary, nameKey, options, "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 options = new JsonSerializerOptions(); + + // Act + var removeStatus = dictionaryAdapter.TryRemove(dictionary, nameKey, options, 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 Remove_RemovesFromDictionary() + { + // Arrange + var nameKey = "Name"; + var dictionary = new Dictionary(StringComparer.Ordinal) { [nameKey] = "James", }; + var dictionaryAdapter = new DictionaryAdapter(); + var options = new JsonSerializerOptions(); + + // Act + var removeStatus = dictionaryAdapter.TryRemove(dictionary, nameKey, options, 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 { [uriKey] = "James", }; + var dictionaryAdapter = new DictionaryAdapter(); + var options = new JsonSerializerOptions(); + + // Act + var removeStatus = dictionaryAdapter.TryRemove(dictionary, uriKey.ToString(), options, 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 options = new JsonSerializerOptions(); + + // Act + var testStatus = dictionaryAdapter.TryTest(dictionary, key, options, 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 { [key] = "James", }; + var dictionaryAdapter = new DictionaryAdapter(); + var options = new JsonSerializerOptions(); + var expectedErrorMessage = "The current value 'James' at path 'Name' is not equal to the test value 'John'."; + + // Act + var testStatus = dictionaryAdapter.TryTest(dictionary, key, options, "John", out var errorMessage); + + //Assert + Assert.False(testStatus); + Assert.Equal(expectedErrorMessage, errorMessage); + } +} diff --git a/tests/Tingle.AspNetCore.JsonPatch.Tests/Internal/DynamicObjectAdapterTest.cs b/tests/Tingle.AspNetCore.JsonPatch.Tests/Internal/DynamicObjectAdapterTest.cs new file mode 100644 index 0000000..7a445c8 --- /dev/null +++ b/tests/Tingle.AspNetCore.JsonPatch.Tests/Internal/DynamicObjectAdapterTest.cs @@ -0,0 +1,268 @@ +using System.Text.Json; + +namespace Tingle.AspNetCore.JsonPatch.Internal; + +public class DynamicObjectAdapterTest +{ + [Fact] + public void TryAdd_AddsNewProperty() + { + // Arrange + var adapter = new DynamicObjectAdapter(); + dynamic target = new DynamicTestObject(); + var segment = "NewProperty"; + var options = new JsonSerializerOptions(); + + // Act + var status = adapter.TryAdd(target, segment, options, "new", out string errorMessage); + + // Assert + Assert.True(status); + Assert.Null(errorMessage); + Assert.Equal("new", target.NewProperty); + } + + [Fact] + public void TryAdd_ReplacesExistingPropertyValue() + { + // Arrange + var adapter = new DynamicObjectAdapter(); + dynamic target = new DynamicTestObject(); + target.List = new List() { 1, 2, 3 }; + var value = new List() { "stringValue1", "stringValue2" }; + var segment = "List"; + var options = new JsonSerializerOptions(); + + // Act + var status = adapter.TryAdd(target, segment, options, value, out string errorMessage); + + // Assert + Assert.True(status); + Assert.Null(errorMessage); + Assert.Equal(value, target.List); + } + + [Fact] + public void TryGet_GetsPropertyValue_ForExistingProperty() + { + // Arrange + var adapter = new DynamicObjectAdapter(); + dynamic target = new DynamicTestObject(); + var segment = "NewProperty"; + var options = new JsonSerializerOptions(); + + // Act 1 + var addStatus = adapter.TryAdd(target, segment, options, "new", out string errorMessage); + + // Assert 1 + Assert.True(addStatus); + Assert.Null(errorMessage); + Assert.Equal("new", target.NewProperty); + + // Act 2 + var getStatus = adapter.TryGet(target, segment, options, out object getValue, out string getErrorMessage); + + // Assert 2 + Assert.True(getStatus); + Assert.Null(getErrorMessage); + Assert.Equal(getValue, target.NewProperty); + } + + [Fact] + public void TryGet_ThrowsPathNotFoundException_ForNonExistingProperty() + { + // Arrange + var adapter = new DynamicObjectAdapter(); + dynamic target = new DynamicTestObject(); + var segment = "NewProperty"; + var options = new JsonSerializerOptions(); + + // Act + var getStatus = adapter.TryGet(target, segment, options, out object getValue, out string getErrorMessage); + + // Assert + Assert.False(getStatus); + Assert.Null(getValue); + Assert.Equal($"The target location specified by path segment '{segment}' was not found.", getErrorMessage); + } + + [Fact] + public void TryTraverse_FindsNextTarget() + { + // Arrange + var adapter = new DynamicObjectAdapter(); + dynamic target = new DynamicTestObject(); + target.NestedObject = new DynamicTestObject(); + target.NestedObject.NewProperty = "A"; + var segment = "NestedObject"; + var options = new JsonSerializerOptions(); + + // Act + var status = adapter.TryTraverse(target, segment, options, out object nextTarget, out string errorMessage); + + // Assert + Assert.True(status); + Assert.Null(errorMessage); + Assert.Equal(target.NestedObject, nextTarget); + } + + [Fact] + public void TryTraverse_ThrowsPathNotFoundException_ForNonExistingProperty() + { + // Arrange + var adapter = new DynamicObjectAdapter(); + dynamic target = new DynamicTestObject(); + target.NestedObject = new DynamicTestObject(); + var segment = "NewProperty"; + var options = new JsonSerializerOptions(); + + // Act + var status = adapter.TryTraverse(target.NestedObject, segment, options, out object _, out string errorMessage); + + // Assert + Assert.False(status); + Assert.Equal($"The target location specified by path segment '{segment}' was not found.", errorMessage); + } + + [Fact] + public void TryReplace_RemovesExistingValue_BeforeAddingNewValue() + { + // Arrange + var adapter = new DynamicObjectAdapter(); + dynamic target = new WriteOnceDynamicTestObject(); + target.NewProperty = new object(); + var segment = "NewProperty"; + var options = new JsonSerializerOptions(); + + // Act + var status = adapter.TryReplace(target, segment, options, "new", out string errorMessage); + + // Assert + Assert.True(status); + Assert.Null(errorMessage); + Assert.Equal("new", target.NewProperty); + } + + [Fact] + public void TryReplace_ThrowsPathNotFoundException_ForNonExistingProperty() + { + // Arrange + var adapter = new DynamicObjectAdapter(); + dynamic target = new DynamicTestObject(); + var segment = "NewProperty"; + var options = new JsonSerializerOptions(); + + // Act + var status = adapter.TryReplace(target, segment, options, "test", out string errorMessage); + + // Assert + Assert.False(status); + Assert.Equal($"The target location specified by path segment '{segment}' was not found.", errorMessage); + } + + [Fact] + public void TryReplace_ThrowsPropertyInvalidException_IfNewValueIsNotTheSameTypeAsInitialValue() + { + // Arrange + var adapter = new DynamicObjectAdapter(); + dynamic target = new DynamicTestObject(); + target.NewProperty = 1; + var segment = "NewProperty"; + var options = new JsonSerializerOptions(); + + // Act + var status = adapter.TryReplace(target, segment, options, "test", out string errorMessage); + + // Assert + Assert.False(status); + Assert.Equal($"The value 'test' is invalid for target location.", errorMessage); + } + + [Theory] + [InlineData(1, 0)] + [InlineData("new", null)] + public void TryRemove_SetsPropertyToDefaultOrNull(object value, object? expectedValue) + { + // Arrange + var adapter = new DynamicObjectAdapter(); + dynamic target = new DynamicTestObject(); + var segment = "NewProperty"; + var options = new JsonSerializerOptions(); + + // Act 1 + var addStatus = adapter.TryAdd(target, segment, options, value, out string errorMessage); + + // Assert 1 + Assert.True(addStatus); + Assert.Null(errorMessage); + Assert.Equal(value, target.NewProperty); + + // Act 2 + var removeStatus = adapter.TryRemove(target, segment, options, out string removeErrorMessage); + + // Assert 2 + Assert.True(removeStatus); + Assert.Null(removeErrorMessage); + Assert.Equal(expectedValue, target.NewProperty); + } + + [Fact] + public void TryRemove_ThrowsPathNotFoundException_ForNonExistingProperty() + { + // Arrange + var adapter = new DynamicObjectAdapter(); + dynamic target = new DynamicTestObject(); + var segment = "NewProperty"; + var options = new JsonSerializerOptions(); + + // Act + var removeStatus = adapter.TryRemove(target, segment, options, out string removeErrorMessage); + + // Assert + Assert.False(removeStatus); + Assert.Equal($"The target location specified by path segment '{segment}' was not found.", removeErrorMessage); + } + + [Fact] + public void TryTest_DoesNotThrowException_IfTestSuccessful() + { + var adapter = new DynamicObjectAdapter(); + dynamic target = new DynamicTestObject(); + var value = new List() + { + "Joana", + 2, + new Customer("Joana", 25) + }; + target.NewProperty = value; + var segment = "NewProperty"; + var options = new JsonSerializerOptions(); + + // Act + var testStatus = adapter.TryTest(target, segment, options, value, out string errorMessage); + + // Assert + Assert.Equal(value, target.NewProperty); + Assert.True(testStatus); + Assert.True(string.IsNullOrEmpty(errorMessage), "Expected no error message"); + } + + [Fact] + public void TryTest_ThrowsJsonPatchException_IfTestFails() + { + // Arrange + var adapter = new DynamicObjectAdapter(); + dynamic target = new DynamicTestObject(); + target.NewProperty = "Joana"; + var segment = "NewProperty"; + var options = new JsonSerializerOptions(); + var expectedErrorMessage = $"The current value 'Joana' at path '{segment}' is not equal to the test value 'John'."; + + // Act + var testStatus = adapter.TryTest(target, segment, options, "John", out string errorMessage); + + // Assert + Assert.False(testStatus); + Assert.Equal(expectedErrorMessage, errorMessage); + } +} diff --git a/tests/Tingle.AspNetCore.JsonPatch.Tests/Internal/ListAdapterTest.cs b/tests/Tingle.AspNetCore.JsonPatch.Tests/Internal/ListAdapterTest.cs new file mode 100644 index 0000000..09df43f --- /dev/null +++ b/tests/Tingle.AspNetCore.JsonPatch.Tests/Internal/ListAdapterTest.cs @@ -0,0 +1,489 @@ +using System.Collections; +using System.Globalization; +using System.Text.Json; + +namespace Tingle.AspNetCore.JsonPatch.Internal; + +public class ListAdapterTest +{ + [Fact] + public void Patch_OnArrayObject_Fails() + { + // Arrange + var options = new JsonSerializerOptions(); + var targetObject = new[] { 20, 30 }; + var listAdapter = new ListAdapter(); + + // Act + var addStatus = listAdapter.TryAdd(targetObject, "0", options, "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 options = new JsonSerializerOptions(); + var targetObject = new ArrayList { 20, 30, }; + var listAdapter = new ListAdapter(); + + // Act + var addStatus = listAdapter.TryAdd(targetObject, "-", options, "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 options = new JsonSerializerOptions(); + var targetObject = new List() { "James", "Mike" }; + var listAdapter = new ListAdapter(); + var position = targetObject.Count.ToString(CultureInfo.InvariantCulture); + + // Act + var addStatus = listAdapter.TryAdd(targetObject, position, options, "Rob", out var message); + + // Assert + Assert.Null(message); + Assert.True(addStatus); + Assert.Equal(3, targetObject.Count); + Assert.Equal(["James", "Mike", "Rob"], targetObject); + } + + [Theory] + [InlineData("-1")] + [InlineData("-2")] + [InlineData("3")] + public void Add_WithOutOfBoundsIndex_Fails(string position) + { + // Arrange + var options = new JsonSerializerOptions(); + var targetObject = new List() { "James", "Mike" }; + var listAdapter = new ListAdapter(); + + // Act + var addStatus = listAdapter.TryAdd(targetObject, position, options, "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 options = new JsonSerializerOptions(); + var targetObject = new List() { "James", "Mike" }; + var listAdapter = new ListAdapter(); + + // Act + var addStatus = listAdapter.TryAdd(targetObject, position, options, "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 options = new JsonSerializerOptions(); + var listAdapter = new ListAdapter(); + + // Act + var addStatus = listAdapter.TryAdd(targetObject, "-", options, 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 options = new JsonSerializerOptions(); + var listAdapter = new ListAdapter(); + var targetObject = new List() { "James", "Mike" }; + + // Act + var addStatus = listAdapter.TryAdd(targetObject, "-", options, 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(["James", "Mike", null], targetObject); + } + + [Fact] + public void Add_CompatibleTypeWorks() + { + // Arrange + var sDto = new SimpleObject(); + var iDto = new InheritedObject(); + var options = new JsonSerializerOptions(); + var targetObject = new List() { sDto }; + var listAdapter = new ListAdapter(); + + // Act + var addStatus = listAdapter.TryAdd(targetObject, "-", options, iDto, out var message); + + // Assert + Assert.True(addStatus); + Assert.True(string.IsNullOrEmpty(message), "Expected no error message"); + Assert.Equal(2, targetObject.Count); + Assert.Equal([sDto, iDto], targetObject); + } + + [Fact] + public void Add_NonCompatibleType_Fails() + { + // Arrange + var options = new JsonSerializerOptions(); + var targetObject = new List() { 10, 20 }; + var listAdapter = new ListAdapter(); + + // Act + var addStatus = listAdapter.TryAdd(targetObject, "-", options, "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 options = new JsonSerializerOptions(); + var listAdapter = new ListAdapter(); + + // Act + var addStatus = listAdapter.TryAdd(targetObject, position, options, 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 options = new JsonSerializerOptions(); + var listAdapter = new ListAdapter(); + + // Act + var addStatus = listAdapter.TryAdd(targetObject, position, options, 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 options = new JsonSerializerOptions(); + var targetObject = new List(input); + var listAdapter = new ListAdapter(); + + // Act + var getStatus = listAdapter.TryGet(targetObject, position, options, out _, 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 options = new JsonSerializerOptions(); + var targetObject = new List(input); + var listAdapter = new ListAdapter(); + + // Act + var getStatus = listAdapter.TryGet(targetObject, position, options, out var value, out _); + + // 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 options = new JsonSerializerOptions(); + var targetObject = new List(input); + var listAdapter = new ListAdapter(); + + // Act + var removeStatus = listAdapter.TryRemove(targetObject, position, options, 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 options = new JsonSerializerOptions(); + var targetObject = new List(input); + var listAdapter = new ListAdapter(); + + // Act + var removeStatus = listAdapter.TryRemove(targetObject, position, options, out _); + + // Assert + Assert.True(removeStatus); + Assert.Equal(new List(expected), targetObject); + } + + [Fact] + public void Replace_NonCompatibleType_Fails() + { + // Arrange + var options = new JsonSerializerOptions(); + var targetObject = new List() { 10, 20 }; + var listAdapter = new ListAdapter(); + + // Act + var replaceStatus = listAdapter.TryReplace(targetObject, "-", options, "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 options = new JsonSerializerOptions(); + var targetObject = new List() { 10, 20 }; + var listAdapter = new ListAdapter(); + + // Act + var replaceStatus = listAdapter.TryReplace(targetObject, "-", options, 30, out var message); + + // Assert + Assert.True(replaceStatus); + Assert.True(string.IsNullOrEmpty(message), "Expected no error message"); + Assert.Equal([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 options = new JsonSerializerOptions(); + var targetObject = new List() { 10, 20 }; + var listAdapter = new ListAdapter(); + + // Act + var replaceStatus = listAdapter.TryReplace(targetObject, position, options, 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 options = new JsonSerializerOptions(); + var targetObject = new List() { 10, 20 }; + var listAdapter = new ListAdapter(); + + // Act + var testStatus = listAdapter.TryTest(targetObject, "0", options, 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 options = new JsonSerializerOptions(); + 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", options, 10, out var errorMessage); + + //Assert + Assert.False(testStatus); + Assert.Equal(expectedErrorMessage, errorMessage); + } + + [Fact] + public void Test_ThrowsJsonPatchException_IfListPositionOutOfBounds() + { + // Arrange + var options = new JsonSerializerOptions(); + 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", options, "10", out var errorMessage); + + //Assert + Assert.False(testStatus); + Assert.Equal(expectedErrorMessage, errorMessage); + } +} diff --git a/tests/Tingle.AspNetCore.JsonPatch.Tests/Internal/ObjectVisitorTest.cs b/tests/Tingle.AspNetCore.JsonPatch.Tests/Internal/ObjectVisitorTest.cs new file mode 100644 index 0000000..0b67bc6 --- /dev/null +++ b/tests/Tingle.AspNetCore.JsonPatch.Tests/Internal/ObjectVisitorTest.cs @@ -0,0 +1,233 @@ +using System.Dynamic; +using System.Text.Json; + +namespace Tingle.AspNetCore.JsonPatch.Internal; + +public class ObjectVisitorTest +{ + private class Class1 + { + public string? Name { get; set; } + public IList? States { get; set; } = new List(); + public IDictionary CountriesAndRegions { get; set; } = new Dictionary(); + public dynamic Items { get; set; } = new ExpandoObject(); + } + + private class Class1Nested + { + public List Customers { get; set; } = []; + } + + [Theory] + [ClassData(typeof(ReturnsListAdapterData))] + public void Visit_ValidPathToArray_ReturnsListAdapter(object targetObject, string path, object expectedTargetObject) + { + // Arrange + var visitor = new ObjectVisitor(new ParsedPath(path), new JsonSerializerOptions(), create: false); + + // 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); + } + + class ReturnsListAdapterData : TheoryData + { + public ReturnsListAdapterData() + { + var model = new Class1(); + Add(model, "/States/-", model.States); + Add(model.States, "/-", model.States); + + var nestedModel = new Class1Nested(); + nestedModel.Customers.Add(new Class1()); + Add(nestedModel, "/Customers/0/States/-", nestedModel.Customers[0].States); + Add(nestedModel, "/Customers/0/States/0", nestedModel.Customers[0].States); + Add(nestedModel.Customers, "/0/States/-", nestedModel.Customers[0].States); + Add(nestedModel.Customers[0], "/States/-", nestedModel.Customers[0].States); + } + } + + [Theory] + [ClassData(typeof(ReturnsDictionaryAdapterData))] + public void Visit_ValidPathToDictionary_ReturnsDictionaryAdapter(object targetObject, string path, object expectedTargetObject) + { + // Arrange + var visitor = new ObjectVisitor(new ParsedPath(path), new JsonSerializerOptions(), create: false); + + // Act + var visitStatus = visitor.TryVisit(ref targetObject, out _, 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()); + } + + class ReturnsDictionaryAdapterData : TheoryData + { + public ReturnsDictionaryAdapterData() + { + var model = new Class1(); + Add(model, "/CountriesAndRegions/USA", model.CountriesAndRegions); + Add(model.CountriesAndRegions, "/USA", model.CountriesAndRegions); + + var nestedModel = new Class1Nested(); + nestedModel.Customers.Add(new Class1()); + Add(nestedModel, "/Customers/0/CountriesAndRegions/USA", nestedModel.Customers[0].CountriesAndRegions); + Add(nestedModel.Customers, "/0/CountriesAndRegions/USA", nestedModel.Customers[0].CountriesAndRegions); + Add(nestedModel.Customers[0], "/CountriesAndRegions/USA", nestedModel.Customers[0].CountriesAndRegions); + } + } + + [Theory] + [ClassData(typeof(ReturnsExpandoAdapterData))] + public void Visit_ValidPathToExpandoObject_ReturnsExpandoAdapter(object targetObject, string path, object expectedTargetObject) + { + // Arrange + var visitor = new ObjectVisitor(new ParsedPath(path), new JsonSerializerOptions(), create: false); + + // Act + var visitStatus = visitor.TryVisit(ref targetObject, out _, out var message); + + // Assert + Assert.True(visitStatus); + Assert.True(string.IsNullOrEmpty(message), "Expected no error message"); + Assert.Same(expectedTargetObject, targetObject); + //Assert.Same(typeof(DictionaryAdapter), adapter.GetType()); + } + + class ReturnsExpandoAdapterData : TheoryData + { + public ReturnsExpandoAdapterData() + { + var nestedModel = new Class1Nested(); + nestedModel.Customers.Add(new Class1()); + Add(nestedModel, "/Customers/0/Items/Name", nestedModel.Customers[0].Items); + Add(nestedModel.Customers, "/0/Items/Name", nestedModel.Customers[0].Items); + Add(nestedModel.Customers[0], "/Items/Name", nestedModel.Customers[0].Items); + } + } + + [Theory] + [ClassData(typeof(ReturnsPocoAdapterData))] + public void Visit_ValidPath_ReturnsExpandoAdapter(object targetObject, string path, object expectedTargetObject) + { + // Arrange + var visitor = new ObjectVisitor(new ParsedPath(path), new JsonSerializerOptions(), create: false); + + // 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); + } + + class ReturnsPocoAdapterData : TheoryData + { + public ReturnsPocoAdapterData() + { + var model = new Class1(); + Add(model, "/Name", model); + + var nestedModel = new Class1Nested(); + nestedModel.Customers.Add(new Class1()); + Add(nestedModel, "/Customers/0/Name", nestedModel.Customers[0]); + Add(nestedModel.Customers, "/0/Name", nestedModel.Customers[0]); + Add(nestedModel.Customers[0], "/Name", nestedModel.Customers[0]); + } + } + + [Theory] + [InlineData("0")] + [InlineData("-1")] + public void Visit_InvalidIndexToArray_Fails(string position) + { + // Arrange + var visitor = new ObjectVisitor(new ParsedPath($"/Customers/{position}/States/-"), new JsonSerializerOptions(), create: false); + var automobileDepartment = new Class1Nested(); + object targetObject = automobileDepartment; + + // Act + var visitStatus = visitor.TryVisit(ref targetObject, out _, 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/-"), new JsonSerializerOptions(), create: false); + var automobileDepartment = new Class1Nested(); + object targetObject = automobileDepartment; + + // Act + var visitStatus = visitor.TryVisit(ref targetObject, out _, 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"), new JsonSerializerOptions(), create: false); + 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"), new JsonSerializerOptions(), create: false); + + // 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"), new JsonSerializerOptions(), create: false); + + // 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/tests/Tingle.AspNetCore.JsonPatch.Tests/Internal/ParsedPathTests.cs b/tests/Tingle.AspNetCore.JsonPatch.Tests/Internal/ParsedPathTests.cs new file mode 100644 index 0000000..da3f841 --- /dev/null +++ b/tests/Tingle.AspNetCore.JsonPatch.Tests/Internal/ParsedPathTests.cs @@ -0,0 +1,37 @@ +using Tingle.AspNetCore.JsonPatch.Exceptions; + +namespace Tingle.AspNetCore.JsonPatch.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/tests/Tingle.AspNetCore.JsonPatch.Tests/Internal/PocoAdapterTest.cs b/tests/Tingle.AspNetCore.JsonPatch.Tests/Internal/PocoAdapterTest.cs new file mode 100644 index 0000000..abee7de --- /dev/null +++ b/tests/Tingle.AspNetCore.JsonPatch.Tests/Internal/PocoAdapterTest.cs @@ -0,0 +1,275 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace Tingle.AspNetCore.JsonPatch.Internal; + +public class PocoAdapterTest +{ + [Fact] + public void TryAdd_ReplacesExistingProperty() + { + // Arrange + var adapter = new PocoAdapter(); + var options = new JsonSerializerOptions(); + var model = new Customer + { + Name = "Joana" + }; + + // Act + var addStatus = adapter.TryAdd(model, "Name", options, "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 options = new JsonSerializerOptions(); + 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", options, "Smith", out var errorMessage); + + // Assert + Assert.False(addStatus); + Assert.Equal(expectedErrorMessage, errorMessage); + } + + [Fact] + public void TryGet_ExistingProperty() + { + // Arrange + var adapter = new PocoAdapter(); + var options = new JsonSerializerOptions(); + var model = new Customer + { + Name = "Joana" + }; + + // Act + var getStatus = adapter.TryGet(model, "Name", options, 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 options = new JsonSerializerOptions(); + 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", options, 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 options = new JsonSerializerOptions(); + var model = new Customer + { + Name = "Joana" + }; + + // Act + var removeStatus = adapter.TryRemove(model, "Name", options, 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 options = new JsonSerializerOptions(); + 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", options, out var errorMessage); + + // Assert + Assert.False(removeStatus); + Assert.Equal(expectedErrorMessage, errorMessage); + } + + [Fact] + public void TryReplace_OverwritesExistingValue() + { + // Arrange + var adapter = new PocoAdapter(); + var options = new JsonSerializerOptions(); + var model = new Customer + { + Name = "Joana" + }; + + // Act + var replaceStatus = adapter.TryReplace(model, "Name", options, "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 options = new JsonSerializerOptions(); + var model = new Customer + { + Age = 25 + }; + + var expectedErrorMessage = "The value 'TwentySix' is invalid for target location."; + + // Act + var replaceStatus = adapter.TryReplace(model, "Age", options, "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 options = new JsonSerializerOptions(); + 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", options, "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 options = new JsonSerializerOptions { Converters = { new RectangleJsonConverter(), }, }; + var model = new Square() + { + Rectangle = new Rectangle() + { + RectangleProperty = "Square" + } + }; + + // Act + var replaceStatus = adapter.TryReplace(model, "Rectangle", options, "Oval", out _); + + // Assert + Assert.Equal("Oval", model.Rectangle.RectangleProperty); + Assert.True(replaceStatus); + } + + [Fact] + public void TryTest_DoesNotThrowException_IfTestSuccessful() + { + var adapter = new PocoAdapter(); + var options = new JsonSerializerOptions(); + var model = new Customer + { + Name = "Joana" + }; + + // Act + var testStatus = adapter.TryTest(model, "Name", options, "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 options = new JsonSerializerOptions(); + 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", options, "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; } + } + + public class RectangleJsonConverter : JsonConverter + { + public override Rectangle Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + return new Rectangle + { + RectangleProperty = reader.GetString(), + }; + } + + public override void Write(Utf8JsonWriter writer, Rectangle value, JsonSerializerOptions options) + { + throw new NotImplementedException(); + } + } +} diff --git a/tests/Tingle.AspNetCore.JsonPatch.Tests/JsonPatchDocumentExtensionsTest.cs b/tests/Tingle.AspNetCore.JsonPatch.Tests/JsonPatchDocumentExtensionsTest.cs new file mode 100644 index 0000000..0e1a1ca --- /dev/null +++ b/tests/Tingle.AspNetCore.JsonPatch.Tests/JsonPatchDocumentExtensionsTest.cs @@ -0,0 +1,92 @@ +using Microsoft.AspNetCore.Mvc.ModelBinding; +using Tingle.AspNetCore.JsonPatch.Operations; + +namespace Tingle.AspNetCore.JsonPatch; + +public class JsonPatchDocumentExtensionsTest +{ + [Fact] + public void ApplyTo_JsonPatchDocument_ModelState() + { + // Arrange + var operation = new Operation("add", "CustomerId", from: null, value: "TestName"); + var patchDoc = new JsonPatchDocument(); + patchDoc.Operations.Add(operation); + + var modelState = new ModelStateDictionary(); + + // Act + patchDoc.ApplyTo(new Customer(), modelState); + + // Assert + var error = Assert.Single(modelState["Customer"]!.Errors); + Assert.Equal("The target location specified by path segment 'CustomerId' was not found.", error.ErrorMessage); + } + + [Fact] + public void ApplyTo_JsonPatchDocument_PrefixModelState() + { + // Arrange + var operation = new Operation("add", "CustomerId", from: null, value: "TestName"); + var patchDoc = new JsonPatchDocument(); + patchDoc.Operations.Add(operation); + + var modelState = new ModelStateDictionary(); + + // Act + patchDoc.ApplyTo(new Customer(), modelState, "jsonpatch"); + + // Assert + var error = Assert.Single(modelState["jsonpatch.Customer"]!.Errors); + Assert.Equal("The target location specified by path segment 'CustomerId' was not found.", error.ErrorMessage); + } + + [Fact] + public void ApplyTo_ValidPatchOperation_NoErrorsAdded() + { + // Arrange + var patch = new JsonPatchDocument(); + patch.Operations.Add(new Operation("replace", "/CustomerName", null, "James")); + var model = new Customer(); + var modelState = new ModelStateDictionary(); + + // Act + patch.ApplyTo(model, modelState); + + // Assert + Assert.Equal(0, modelState.ErrorCount); + Assert.Equal("James", model.CustomerName); + } + + [Theory] + [InlineData("test", "/CustomerName", null, "James", "The current value '' at path 'CustomerName' is not equal to the test value 'James'.")] + [InlineData("invalid", "/CustomerName", null, "James", "Invalid JsonPatch operation 'invalid'.")] + [InlineData("", "/CustomerName", null, "James", "Invalid JsonPatch operation ''.")] + public void ApplyTo_InvalidPatchOperations_AddsModelStateError( + string op, + string path, + string? from, + string value, + string error) + { + // Arrange + var patch = new JsonPatchDocument(); + patch.Operations.Add(new Operation(op, path, from, value)); + var model = new Customer(); + var modelState = new ModelStateDictionary(); + + // Act + patch.ApplyTo(model, modelState); + + // Assert + Assert.Equal(1, modelState.ErrorCount); + Assert.Equal(nameof(Customer), modelState.First().Key); + Assert.Single(modelState.First().Value!.Errors); + Assert.Equal(error, modelState.First().Value!.Errors.First().ErrorMessage); + } + + private class Customer + { + public string? CustomerName { get; set; } + } +} diff --git a/tests/Tingle.AspNetCore.JsonPatch.Tests/JsonPatchDocumentGetPathTest.cs b/tests/Tingle.AspNetCore.JsonPatch.Tests/JsonPatchDocumentGetPathTest.cs new file mode 100644 index 0000000..25e372a --- /dev/null +++ b/tests/Tingle.AspNetCore.JsonPatch.Tests/JsonPatchDocumentGetPathTest.cs @@ -0,0 +1,114 @@ +namespace Tingle.AspNetCore.JsonPatch; + +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/tests/Tingle.AspNetCore.JsonPatch.Tests/JsonPatchDocumentJsonObjectTest.cs b/tests/Tingle.AspNetCore.JsonPatch.Tests/JsonPatchDocumentJsonObjectTest.cs new file mode 100644 index 0000000..b08dba2 --- /dev/null +++ b/tests/Tingle.AspNetCore.JsonPatch.Tests/JsonPatchDocumentJsonObjectTest.cs @@ -0,0 +1,318 @@ +using System.Text.Json; +using System.Text.Json.Nodes; +using SystemTextJsonPatch; +using Tingle.AspNetCore.JsonPatch.Exceptions; +using Tingle.AspNetCore.JsonPatch.Operations; + +namespace Tingle.AspNetCore.JsonPatch; + +public class JsonPatchDocumentJsonObjectTest +{ + [Fact] + public void ApplyTo_Array_Add() + { + // Arrange + var model = new ObjectWithJsonNode { CustomData = 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@bar.com", model.CustomData["Emails"]![0]!.GetValue()); + Assert.Equal("foo@baz.com", model.CustomData["Emails"]![1]!.GetValue()); + } + + [Fact] + public void ApplyTo_Model_Test1() + { + // Arrange + var model = new ObjectWithJsonNode { CustomData = 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 ObjectWithJsonNode { CustomData = JsonSerializer.SerializeToNode(new { Email = "foo@bar.com", 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 ObjectWithJsonNode { CustomData = JsonSerializer.SerializeToNode(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 ObjectWithJsonNode { CustomData = JsonSerializer.SerializeToNode(new { FirstName = "Foo", LastName = "Bar" })!, }; + var patch = new JsonPatchDocument(); + + patch.Operations.Add(new Operation("remove", "/CustomData/LastName", null)); + + // Act + patch.ApplyTo(model); + + // Assert + Assert.Null(model.CustomData["LastName"]); + } + + [Fact] + public void ApplyTo_Model_Move() + { + // Arrange + var model = new ObjectWithJsonNode { CustomData = JsonSerializer.SerializeToNode(new { FirstName = "Bar" })!, }; + var patch = new JsonPatchDocument(); + + patch.Operations.Add(new Operation("move", "/CustomData/LastName", "/CustomData/FirstName")); + + // Act + patch.ApplyTo(model); + + // Assert + Assert.Null(model.CustomData["FirstName"]); + Assert.Equal("Bar", model.CustomData["LastName"]!.GetValue()); + } + + [Fact] + public void ApplyTo_Model_Add() + { + // Arrange + var model = new ObjectWithJsonNode(); + 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 ObjectWithJsonNode(); + var patch = new JsonPatchDocument(); + + patch.Operations.Add(new Operation("add", "/CustomData/Name", null, null)); + + // Act + patch.ApplyTo(model); + + // Assert + Assert.Null(model.CustomData["Name"]); + } + + [Fact] + public void ApplyTo_Model_Replace() + { + // Arrange + var model = new ObjectWithJsonNode { CustomData = JsonSerializer.SerializeToNode(new { Email = "foo@bar.com", 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 ObjectWithJsonNode { CustomData = JsonSerializer.SerializeToNode(new { Email = "foo@bar.com", 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"]); + } + + + [Fact] + public void ReplaceJsonNodeWithNewJson() + { + // Arrange + var model = new ObjectWithJsonNode { CustomData = JsonSerializer.SerializeToNode(new { Testing = "JsonNodes" })!, }; + var patch = new JsonPatchDocument(); + + patch.Operations.Add(new Operation("replace", "/CustomData", null, "{\"foo\": \"bar\"}")); + + // Act + patch.ApplyTo(model); + + // Assert + Assert.Equal("{\"foo\": \"bar\"}", model.CustomData.ToString()); + } + + [Fact] + public void ReplaceJsonElementWithNewJson() + { + // Arrange + var model = new ObjectWithJsonElement { CustomData = JsonSerializer.SerializeToElement(new { Testing = "JsonNodes" }) }; + var patch = new JsonPatchDocument(); + + patch.Operations.Add(new Operation("replace", "/CustomData", null, "{\"foo\": \"bar\"}")); + + // Act + patch.ApplyTo(model); + + // Assert + Assert.Equal("{\"foo\": \"bar\"}", model.CustomData.ToString()); + } + + [Fact] + public void ReplaceJsonDocumentWithNewJson() + { + // Arrange + var model = new ObjectWithJsonDocument { CustomData = JsonSerializer.SerializeToDocument(new { Testing = "JsonNodes" }) }; + var patch = new JsonPatchDocument(); + + patch.Operations.Add(new Operation("replace", "/CustomData", null, "{\"foo\": \"bar\"}")); + + // Act + patch.ApplyTo(model); + + // Assert + Assert.Equal("{\"foo\": \"bar\"}", model.CustomData.RootElement.ToString()); + } + + [Fact] + public void ReplaceArrayCell() + { + var node = JsonSerializer.Deserialize("[ 123 ]")!; + var patch = JsonSerializer.Deserialize(@"[ { ""op"": ""replace"", ""path"": ""/0"", ""value"": 456 } ]")!; + + patch.ApplyTo(node); + + Assert.Equal(456, node.ElementAt(0)!.GetValue()); + Assert.Single(node); + } + + [Fact] + public void ReplaceJsonObjInArray0Idx() + { + var node = JsonSerializer.Deserialize("[ {\"a\": \"12\"}, {\"a\": \"13\"} ]")!; + var patch = JsonSerializer.Deserialize(@"[ { ""op"": ""replace"", ""path"": ""/0/a"", ""value"": ""456"" } ]")!; + + patch.ApplyTo(node); + + var resultJson = node.ToJsonString(); + + Assert.Equal("[{\"a\":\"456\"},{\"a\":\"13\"}]", resultJson); + } + + [Fact] + public void ReplaceJsonObjInArray1Idx() + { + var node = JsonSerializer.Deserialize("[ {\"a\": \"12\"}, {\"a\": \"12\"}, {\"a\": \"13\"} ]")!; + var patch = JsonSerializer.Deserialize(@"[ { ""op"": ""replace"", ""path"": ""/1/a"", ""value"": ""456"" } ]")!; + + patch.ApplyTo(node); + + var resultJson = node.ToJsonString(); + + Assert.Equal("[{\"a\":\"12\"},{\"a\":\"456\"},{\"a\":\"13\"}]", resultJson); + } + + [Fact] + public void ReplaceJsonObjInArrayLastIdx() + { + var node = JsonSerializer.Deserialize("[ {\"a\": \"12\"}, {\"a\": \"12\"}, {\"a\": \"13\"} ]")!; + var patch = JsonSerializer.Deserialize(@"[ { ""op"": ""replace"", ""path"": ""/-/a"", ""value"": ""456"" } ]")!; + + patch.ApplyTo(node); + + var resultJson = node.ToJsonString(); + + Assert.Equal("[{\"a\":\"12\"},{\"a\":\"12\"},{\"a\":\"456\"}]", resultJson); + } + + [Fact] + public void ReplaceJsonObjInArrayMultipleTimes() + { + var node = JsonSerializer.Deserialize("[ {\"a\": \"12\"}, {\"a\": \"12\"}, {\"a\": \"13\"} ]")!; + var patch = JsonSerializer.Deserialize(@"[ { ""op"": ""replace"", ""path"": ""/1/a"", ""value"": ""456"" } ]")!; + + patch.ApplyTo(node); + + patch = JsonSerializer.Deserialize(@"[ { ""op"": ""replace"", ""path"": ""/1/a"", ""value"": ""33"" } ]")!; + + patch.ApplyTo(node); + + var resultJson = node.ToJsonString(); + + Assert.Equal("[{\"a\":\"12\"},{\"a\":\"33\"},{\"a\":\"13\"}]", resultJson); + } + + [Fact] + public void ReplaceJsonProp() + { + var node = JsonSerializer.Deserialize("{\"a\": \"12\"}")!; + var patch = JsonSerializer.Deserialize(@"[ { ""op"": ""replace"", ""path"": ""/a"", ""value"": 456 } ]")!; + + patch.ApplyTo(node); + + node.TryGetPropertyValue("a", out var prop); + + Assert.Equal("456", prop!.ToString()); + } + + [Fact] + public void ApplyToArrayAddAndRemove() + { + // Arrange + var model = new ObjectWithJsonNode { CustomData = JsonSerializer.SerializeToNode(new { Emails = new[] { "foo@bar.com" } })!, }; + var patch = new JsonPatchDocument(); + + patch.Operations.Add(new Operation("add", "/CustomData/Emails/-", null, "foo@baz.com")); + patch.Operations.Add(new Operation("remove", "/CustomData/Emails/-", null)); + + // Act + patch.ApplyTo(model); + + // Assert + Assert.Equal("foo@bar.com", model.CustomData["Emails"]![0]!.GetValue()); + } +} diff --git a/tests/Tingle.AspNetCore.JsonPatch.Tests/JsonPatchDocumentJsonPropertyAttributeTest.cs b/tests/Tingle.AspNetCore.JsonPatch.Tests/JsonPatchDocumentJsonPropertyAttributeTest.cs new file mode 100644 index 0000000..13a1c05 --- /dev/null +++ b/tests/Tingle.AspNetCore.JsonPatch.Tests/JsonPatchDocumentJsonPropertyAttributeTest.cs @@ -0,0 +1,79 @@ +using System.Text.Json.Serialization; + +namespace Tingle.AspNetCore.JsonPatch; + +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/tests/Tingle.AspNetCore.JsonPatch.Tests/JsonPatchDocumentTest.cs b/tests/Tingle.AspNetCore.JsonPatch.Tests/JsonPatchDocumentTest.cs new file mode 100644 index 0000000..197fc20 --- /dev/null +++ b/tests/Tingle.AspNetCore.JsonPatch.Tests/JsonPatchDocumentTest.cs @@ -0,0 +1,157 @@ +using System.Text.Json; +using Tingle.AspNetCore.JsonPatch.Exceptions; + +namespace Tingle.AspNetCore.JsonPatch; + +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); + } + + [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.StartsWith("The JSON value could not be converted to ", 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 deserialized + = JsonSerializer.Deserialize>(serialized); + }); + + // Assert + Assert.StartsWith("The JSON value could not be converted to ", exception.Message); + } +} diff --git a/tests/Tingle.AspNetCore.JsonPatch.Tests/JsonPatchMergeDocumentConverterHelperTests.cs b/tests/Tingle.AspNetCore.JsonPatch.Tests/JsonPatchMergeDocumentConverterHelperTests.cs new file mode 100644 index 0000000..cf2826f --- /dev/null +++ b/tests/Tingle.AspNetCore.JsonPatch.Tests/JsonPatchMergeDocumentConverterHelperTests.cs @@ -0,0 +1,179 @@ +using System.Text.Json; +using System.Text.Json.Nodes; +using Tingle.AspNetCore.JsonPatch.Converters; + +namespace Tingle.AspNetCore.JsonPatch; + +public class JsonPatchMergeDocumentConverterHelperTests +{ + [Fact] + public void PopulateOperations_Works() + { + var node = new JsonObject + { + ["translations"] = new JsonObject + { + ["swa"] = new JsonObject + { + ["body"] = "rudi shule", + ["provider"] = "google", + }, + }, + ["metadata"] = new JsonObject + { + ["primary"] = "hapa tu", + ["secondary"] = "pale tu", + }, + ["tags"] = new JsonArray { "prod", "ken", }, + ["description"] = "immigration", + ["name"] = null, + }; + + var operations = new List>(); + JsonPatchMergeDocumentConverterHelper.PopulateOperations(operations, node); + + Assert.Equal([ + "add", + "add", + + "add", + "add", + + "add", + "add", + + "add", + "remove", + ], operations.Select(o => o.op)); + Assert.Equal([ + "/translations/swa/body", + "/translations/swa/provider", + + "/metadata/primary", + "/metadata/secondary", + + "/tags/0", + "/tags/1", + + "/description", + "/name", + ], operations.Select(o => o.path)); + Assert.Equal([ + "rudi shule", + "google", + + "hapa tu", + "pale tu", + + "prod", + "ken", + + "immigration", + null, + ], operations.Select(o => ((JsonElement?)o.value)?.ToString())); + } + + [Fact] + public void PopulateJsonObject_Works() + { + var operations = new List> + { + new() { op = "add", path = "/translations/swa/body", value = "rudi shule" }, + new() { op = "add", path = "/translations/swa/provider", value = "google" }, + new() { op = "add", path = "/metadata/primary", value = "hapa tu" }, + new() { op = "add", path = "/metadata/secondary", value = "pale tu" }, + //new() { op = "add", path = "/tags/0", value = "prod" }, + //new() { op = "add", path = "/tags/1", value = "ken" }, + new() { op = "add", path = "/description", value = "immigration" }, + new() { op = "remove", path = "/name" }, + }; + + var serializerOptions = new JsonSerializerOptions(JsonSerializerDefaults.Web); + + var node = new JsonObject(); + foreach (var operation in operations) + { + var type = operation.OperationType; + var segments = operation.path.Trim('/').Split('/'); ; + var opvalue = type is Operations.OperationType.Remove ? null : operation.value; + JsonPatchMergeDocumentConverterHelper.PopulateJsonObject(node, segments, opvalue, serializerOptions); + } + + var expected = new JsonObject + { + ["translations"] = new JsonObject + { + ["swa"] = new JsonObject + { + ["body"] = "rudi shule", + ["provider"] = "google", + }, + }, + ["metadata"] = new JsonObject + { + ["primary"] = "hapa tu", + ["secondary"] = "pale tu", + }, + //["tags"] = new JsonArray { "prod", "ken", }, + ["description"] = "immigration", + ["name"] = null, + }; + + Assert.Equal(expected.ToJsonString(serializerOptions), node.ToJsonString(serializerOptions)); + } + + [Fact] + public void Apply_Works() + { + var node = new JsonObject + { + ["translations"] = new JsonObject + { + ["swa"] = new JsonObject + { + ["body"] = "rudi shule", + ["provider"] = "google", + }, + }, + ["metadata"] = new JsonObject + { + ["primary"] = "hapa tu", + ["secondary"] = "pale tu", + }, + ["tags"] = new JsonArray { "prod", "ken", }, + ["description"] = "immigration", + ["name"] = null, + }; + + var operations = new List>(); + JsonPatchMergeDocumentConverterHelper.PopulateOperations(operations, node); + + var video = new Video { Metadata = new() { ["primary"] = "cake", } }; + var serializerOptions = new JsonSerializerOptions(JsonSerializerDefaults.Web); + var doc = new JsonPatchMergeDocument