diff --git a/src/Microsoft.AspNetCore.JsonPatch/Internal/ParsedPath.cs b/src/Microsoft.AspNetCore.JsonPatch/Internal/ParsedPath.cs index fe786b86c1..8d0e69aa4d 100644 --- a/src/Microsoft.AspNetCore.JsonPatch/Internal/ParsedPath.cs +++ b/src/Microsoft.AspNetCore.JsonPatch/Internal/ParsedPath.cs @@ -1,8 +1,10 @@ // Copyright (c) .NET Foundation. All rights reserved. // Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. +using Microsoft.AspNetCore.JsonPatch.Exceptions; using System; using System.Collections.Generic; +using System.Text; namespace Microsoft.AspNetCore.JsonPatch.Internal { @@ -19,7 +21,7 @@ namespace Microsoft.AspNetCore.JsonPatch.Internal throw new ArgumentNullException(nameof(path)); } - _segments = path.Split(new char[] { '/' }, StringSplitOptions.RemoveEmptyEntries); + _segments = ParsePath(path); } public string LastSegment @@ -36,5 +38,55 @@ namespace Microsoft.AspNetCore.JsonPatch.Internal } public IReadOnlyList Segments => _segments ?? Empty; + + 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/test/Microsoft.AspNetCore.JsonPatch.Test/JsonPatchDocumentJsonPropertyAttributeTest.cs b/test/Microsoft.AspNetCore.JsonPatch.Test/JsonPatchDocumentJsonPropertyAttributeTest.cs index ef7d53c21a..0ec0c18afd 100644 --- a/test/Microsoft.AspNetCore.JsonPatch.Test/JsonPatchDocumentJsonPropertyAttributeTest.cs +++ b/test/Microsoft.AspNetCore.JsonPatch.Test/JsonPatchDocumentJsonPropertyAttributeTest.cs @@ -144,5 +144,28 @@ namespace Microsoft.AspNetCore.JsonPatch var pathToCheck = deserialized.Operations.First().path; Assert.Equal(pathToCheck, "/anothername"); } + + [Fact] + public void Add_OnApplyFromJson_EscapingHandledOnComplexJsonPropertyNameOnJsonDocument() + { + var doc = new JsonPropertyComplexNameDTO() + { + FooSlashBars = "InitialName", + FooSlashTilde = new SimpleDTO + { + StringProperty = "Initial Value" + } + }; + + // serialization should serialize to "AnotherName" + var serialized = "[{\"value\":\"Kevin\",\"path\":\"/foo~1bar~0\",\"op\":\"add\"},{\"value\":\"Final Value\",\"path\":\"/foo~1~0/StringProperty\",\"op\":\"replace\"}]"; + var deserialized = + JsonConvert.DeserializeObject>(serialized); + + deserialized.ApplyTo(doc); + + Assert.Equal("Kevin", doc.FooSlashBars); + Assert.Equal("Final Value", doc.FooSlashTilde.StringProperty); + } } } diff --git a/test/Microsoft.AspNetCore.JsonPatch.Test/JsonPropertyComplexNameDTO.cs b/test/Microsoft.AspNetCore.JsonPatch.Test/JsonPropertyComplexNameDTO.cs new file mode 100644 index 0000000000..7ff66481ad --- /dev/null +++ b/test/Microsoft.AspNetCore.JsonPatch.Test/JsonPropertyComplexNameDTO.cs @@ -0,0 +1,16 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using Newtonsoft.Json; + +namespace Microsoft.AspNetCore.JsonPatch +{ + public class JsonPropertyComplexNameDTO + { + [JsonProperty("foo/bar~")] + public string FooSlashBars { get; set; } + + [JsonProperty("foo/~")] + public SimpleDTO FooSlashTilde { get; set; } + } +} diff --git a/test/Microsoft.AspNetCore.JsonPatch.Test/ObjectAdapterTests.cs b/test/Microsoft.AspNetCore.JsonPatch.Test/ObjectAdapterTests.cs index 88693fdbd5..5898311044 100644 --- a/test/Microsoft.AspNetCore.JsonPatch.Test/ObjectAdapterTests.cs +++ b/test/Microsoft.AspNetCore.JsonPatch.Test/ObjectAdapterTests.cs @@ -1821,6 +1821,33 @@ namespace Microsoft.AspNetCore.JsonPatch.Adapters Assert.Equal("James", actualValue1.Name); } + [Fact] + public void Replace_WhenDictionary_ValueAPocoType_WithEscaping_Succeeds() + { + // Arrange + var key1 = "Foo/Name"; + var value1 = new Customer() { Name = "Jamesss" }; + var key2 = "Foo"; + var value2 = new Customer() { Name = "Mike" }; + var model = new Class8(); + model.DictionaryOfStringToCustomer[key1] = value1; + model.DictionaryOfStringToCustomer[key2] = value2; + var patchDocument = new JsonPatchDocument(); + patchDocument.Replace($"/DictionaryOfStringToCustomer/Foo~1Name/Name", "James"); + + // Act + patchDocument.ApplyTo(model); + + // Assert + Assert.Equal(2, model.DictionaryOfStringToCustomer.Count); + var actualValue1 = model.DictionaryOfStringToCustomer[key1]; + var actualValue2 = model.DictionaryOfStringToCustomer[key2]; + Assert.NotNull(actualValue1); + Assert.Equal("James", actualValue1.Name); + Assert.Equal("Mike", actualValue2.Name); + + } + [Fact] public void Replace_DeepNested_DictionaryValue_Succeeds() { diff --git a/test/Microsoft.AspNetCore.JsonPatch.Test/ParsedPathTests.cs b/test/Microsoft.AspNetCore.JsonPatch.Test/ParsedPathTests.cs new file mode 100644 index 0000000000..6b7c2e69cb --- /dev/null +++ b/test/Microsoft.AspNetCore.JsonPatch.Test/ParsedPathTests.cs @@ -0,0 +1,39 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using Microsoft.AspNetCore.JsonPatch.Exceptions; +using Microsoft.AspNetCore.JsonPatch.Internal; +using Xunit; + +namespace Microsoft.AspNetCore.JsonPatch.Test +{ + 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) + { + var parsedPath = new ParsedPath(path); + Assert.Equal(expected, parsedPath.Segments); + } + + [Theory] + [InlineData("foo/bar~")] + [InlineData("~")] + [InlineData("~2")] + [InlineData("foo~3bar")] + public void PathWithInvalidEscapeSequenceShouldFail(string path) + { + Assert.Throws(() => + { + var parsedPath = new ParsedPath(path); + }); + } + } +}