File: Dcp\JsonPatch.cs
Web Access
Project: src\src\Aspire.Hosting\Aspire.Hosting.csproj (Aspire.Hosting)
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
 
using System.Globalization;
using System.Text;
using System.Text.Json;
using System.Text.Json.Nodes;
 
namespace Aspire.Hosting.Dcp;
 
/// <summary>
/// Creates and applies the subset of RFC 6902 JSON Patch operations used by DCP resources.
/// Paths use the JSON Pointer encoding defined by <see href="https://www.rfc-editor.org/rfc/rfc6901">RFC 6901</see>.
/// </summary>
internal static class JsonPatch
{
    internal static JsonArray Create(JsonNode? current, JsonNode? changed)
    {
        var operations = new JsonArray();
        AddOperations(current, changed, string.Empty, operations);
 
        return operations;
    }
 
    internal static JsonNode? Apply(JsonNode? current, JsonArray patch)
    {
        var result = current?.DeepClone();
 
        foreach (var operationNode in patch)
        {
            if (operationNode is not JsonObject operation ||
                operation["op"] is not JsonValue operationValue ||
                operation["path"] is not JsonValue pathValue ||
                !operationValue.TryGetValue<string>(out var operationName) ||
                !pathValue.TryGetValue<string>(out var path))
            {
                throw new JsonException("A JSON Patch operation must contain string 'op' and 'path' properties.");
            }
 
            var segments = ParsePath(path);
            var hasValue = operation.TryGetPropertyValue("value", out var value);
            result = ApplyOperation(result, operationName, segments, hasValue, value);
        }
 
        return result;
    }
 
    private static void AddOperations(JsonNode? current, JsonNode? changed, string path, JsonArray operations)
    {
        if (JsonNode.DeepEquals(current, changed))
        {
            return;
        }
 
        if (current is JsonObject currentObject && changed is JsonObject changedObject)
        {
            foreach (var property in currentObject)
            {
                if (!changedObject.ContainsKey(property.Key))
                {
                    operations.Add(CreateOperation("remove", AppendPath(path, property.Key)));
                }
            }
 
            foreach (var property in changedObject)
            {
                var propertyPath = AppendPath(path, property.Key);
                if (currentObject.TryGetPropertyValue(property.Key, out var currentValue))
                {
                    AddOperations(currentValue, property.Value, propertyPath, operations);
                }
                else
                {
                    operations.Add(CreateOperation("add", propertyPath, property.Value));
                }
            }
 
            return;
        }
 
        if (current is JsonArray currentArray && changed is JsonArray changedArray)
        {
            var commonLength = Math.Min(currentArray.Count, changedArray.Count);
            for (var index = 0; index < commonLength; index++)
            {
                AddOperations(currentArray[index], changedArray[index], AppendPath(path, index), operations);
            }
 
            for (var index = currentArray.Count - 1; index >= changedArray.Count; index--)
            {
                operations.Add(CreateOperation("remove", AppendPath(path, index)));
            }
 
            for (var index = currentArray.Count; index < changedArray.Count; index++)
            {
                operations.Add(CreateOperation("add", AppendPath(path, index), changedArray[index]));
            }
 
            return;
        }
 
        operations.Add(CreateOperation("replace", path, changed));
    }
 
    private static JsonObject CreateOperation(string operation, string path, JsonNode? value = null)
    {
        var result = new JsonObject
        {
            ["op"] = operation,
            ["path"] = path,
        };
 
        if (operation is not "remove")
        {
            result["value"] = value?.DeepClone();
        }
 
        return result;
    }
 
    private static string AppendPath(string path, string segment)
    {
        return $"{path}/{segment.Replace("~", "~0", StringComparison.Ordinal).Replace("/", "~1", StringComparison.Ordinal)}";
    }
 
    private static string AppendPath(string path, int index)
    {
        return $"{path}/{index.ToString(CultureInfo.InvariantCulture)}";
    }
 
    private static string[] ParsePath(string path)
    {
        if (path.Length == 0)
        {
            return [];
        }
 
        if (path[0] != '/')
        {
            throw new JsonException($"JSON Pointer path '{path}' must be empty or start with '/'.");
        }
 
        return path[1..].Split('/').Select(UnescapePathSegment).ToArray();
    }
 
    private static string UnescapePathSegment(string segment)
    {
        if (!segment.Contains('~', StringComparison.Ordinal))
        {
            return segment;
        }
 
        var result = new StringBuilder(segment.Length);
        for (var index = 0; index < segment.Length; index++)
        {
            var character = segment[index];
            if (character != '~')
            {
                result.Append(character);
                continue;
            }
 
            if (++index >= segment.Length)
            {
                throw new JsonException($"JSON Pointer segment '{segment}' ends with an incomplete escape.");
            }
 
            result.Append(segment[index] switch
            {
                '0' => '~',
                '1' => '/',
                _ => throw new JsonException($"JSON Pointer segment '{segment}' contains an invalid escape."),
            });
        }
 
        return result.ToString();
    }
 
    private static JsonNode? ApplyOperation(JsonNode? current, string operation, string[] segments, bool hasValue, JsonNode? value)
    {
        if (operation is not ("add" or "remove" or "replace"))
        {
            throw new JsonException($"JSON Patch operation '{operation}' is not supported.");
        }
 
        if (operation is not "remove" && !hasValue)
        {
            throw new JsonException($"JSON Patch operation '{operation}' requires a 'value' property.");
        }
 
        if (segments.Length == 0)
        {
            return operation switch
            {
                "remove" => null,
                _ => value?.DeepClone(),
            };
        }
 
        var parent = GetParent(current, segments);
        var finalSegment = segments[^1];
 
        switch (parent)
        {
            case JsonObject jsonObject:
                ApplyToObject(jsonObject, operation, finalSegment, value);
                break;
            case JsonArray jsonArray:
                ApplyToArray(jsonArray, operation, finalSegment, value);
                break;
            default:
                throw new JsonException("A JSON Patch path can only traverse objects and arrays.");
        }
 
        return current;
    }
 
    private static JsonNode GetParent(JsonNode? current, string[] segments)
    {
        var parent = current ?? throw new JsonException("A JSON Patch path cannot traverse a null value.");
 
        for (var index = 0; index < segments.Length - 1; index++)
        {
            var segment = segments[index];
            parent = parent switch
            {
                JsonObject jsonObject when jsonObject.TryGetPropertyValue(segment, out var child) && child is not null => child,
                JsonArray jsonArray => jsonArray[ParseArrayIndex(segment, jsonArray.Count, allowEnd: false)]
                    ?? throw new JsonException($"JSON Patch path segment '{segment}' refers to a null value."),
                _ => throw new JsonException($"JSON Patch path segment '{segment}' does not exist."),
            };
        }
 
        return parent;
    }
 
    private static void ApplyToObject(JsonObject target, string operation, string propertyName, JsonNode? value)
    {
        switch (operation)
        {
            case "add":
                target[propertyName] = value?.DeepClone();
                break;
            case "remove":
                if (!target.Remove(propertyName))
                {
                    throw new JsonException($"JSON Patch remove path refers to missing property '{propertyName}'.");
                }
                break;
            case "replace":
                if (!target.ContainsKey(propertyName))
                {
                    throw new JsonException($"JSON Patch replace path refers to missing property '{propertyName}'.");
                }
                target[propertyName] = value?.DeepClone();
                break;
        }
    }
 
    private static void ApplyToArray(JsonArray target, string operation, string indexText, JsonNode? value)
    {
        if (operation == "add" && indexText == "-")
        {
            target.Add(value?.DeepClone());
            return;
        }
 
        var index = ParseArrayIndex(indexText, target.Count, allowEnd: operation == "add");
        switch (operation)
        {
            case "add":
                target.Insert(index, value?.DeepClone());
                break;
            case "remove":
                target.RemoveAt(index);
                break;
            case "replace":
                target[index] = value?.DeepClone();
                break;
        }
    }
 
    private static int ParseArrayIndex(string indexText, int count, bool allowEnd)
    {
        if (indexText.Length == 0 ||
            (indexText.Length > 1 && indexText[0] == '0') ||
            !indexText.All(char.IsAsciiDigit) ||
            !int.TryParse(indexText, NumberStyles.None, CultureInfo.InvariantCulture, out var index) ||
            index < 0 ||
            index > count ||
            (!allowEnd && index == count))
        {
            throw new JsonException($"JSON Patch array index '{indexText}' is invalid for an array with {count} elements.");
        }
 
        return index;
    }
}