NetActionResponseValidator.cs 10.8 KB
using System.Globalization;
using System.Text.Json;

namespace Rcs.Infrastructure.Services;

internal static class NetActionResponseValidator
{
    private static readonly JsonSerializerOptions RuleSerializerOptions = new()
    {
        PropertyNameCaseInsensitive = true
    };

    public static bool TryValidate(string responseContent, string validationRule, out string? errorMessage)
    {
        errorMessage = null;

        if (string.IsNullOrWhiteSpace(validationRule))
        {
            return true;
        }

        JsonDocument responseDocument;
        try
        {
            responseDocument = JsonDocument.Parse(responseContent);
        }
        catch (JsonException ex)
        {
            errorMessage = $"响应内容不是合法 JSON: {ex.Message}";
            return false;
        }

        using (responseDocument)
        {
            ResponseValidationDefinition? definition;
            try
            {
                definition = JsonSerializer.Deserialize<ResponseValidationDefinition>(validationRule, RuleSerializerOptions);
            }
            catch (JsonException ex)
            {
                errorMessage = $"响应校验规则不是合法 JSON: {ex.Message}";
                return false;
            }

            if (definition?.All == null)
            {
                errorMessage = "响应校验规则必须包含 all 数组";
                return false;
            }

            for (var i = 0; i < definition.All.Count; i++)
            {
                if (!TryEvaluateCondition(responseDocument.RootElement, definition.All[i], out var conditionError))
                {
                    errorMessage = $"规则[{i}] 校验失败: {conditionError}";
                    return false;
                }
            }

            return true;
        }
    }

    private static bool TryEvaluateCondition(
        JsonElement responseRoot,
        ResponseValidationCondition condition,
        out string errorMessage)
    {
        errorMessage = string.Empty;

        if (string.IsNullOrWhiteSpace(condition.Path))
        {
            errorMessage = "path 不能为空";
            return false;
        }

        if (string.IsNullOrWhiteSpace(condition.Op))
        {
            errorMessage = "op 不能为空";
            return false;
        }

        var operation = condition.Op.Trim().ToLowerInvariant();
        if (!TryResolvePath(responseRoot, condition.Path, out var actualValue))
        {
            errorMessage = $"路径 '{condition.Path}' 不存在";
            return false;
        }

        switch (operation)
        {
            case "exists":
                return true;
            case "eq":
                if (condition.Value.ValueKind == JsonValueKind.Undefined)
                {
                    errorMessage = $"路径 '{condition.Path}' 的 eq 规则缺少 value";
                    return false;
                }

                if (!JsonValuesEqual(actualValue, condition.Value))
                {
                    errorMessage =
                        $"路径 '{condition.Path}' 的实际值为 {FormatJsonValue(actualValue)},期望值为 {FormatJsonValue(condition.Value)}";
                    return false;
                }

                return true;
            case "ne":
                if (condition.Value.ValueKind == JsonValueKind.Undefined)
                {
                    errorMessage = $"路径 '{condition.Path}' 的 ne 规则缺少 value";
                    return false;
                }

                if (JsonValuesEqual(actualValue, condition.Value))
                {
                    errorMessage =
                        $"路径 '{condition.Path}' 的实际值为 {FormatJsonValue(actualValue)},不应等于 {FormatJsonValue(condition.Value)}";
                    return false;
                }

                return true;
            default:
                errorMessage = $"不支持的操作符 '{condition.Op}'";
                return false;
        }
    }

    private static bool TryResolvePath(JsonElement root, string path, out JsonElement value)
    {
        value = root;
        var index = 0;

        while (index < path.Length)
        {
            if (path[index] == '.')
            {
                index++;
                continue;
            }

            if (path[index] == '[')
            {
                if (!TryReadArrayIndex(path, ref index, out var arrayIndex))
                {
                    return false;
                }

                if (!TryGetArrayElement(value, arrayIndex, out value))
                {
                    return false;
                }

                continue;
            }

            var startIndex = index;
            while (index < path.Length && path[index] != '.' && path[index] != '[')
            {
                index++;
            }

            var propertyName = path[startIndex..index].Trim();
            if (string.IsNullOrWhiteSpace(propertyName))
            {
                return false;
            }

            if (!TryGetPropertyIgnoreCase(value, propertyName, out value))
            {
                return false;
            }
        }

        return true;
    }

    private static bool TryReadArrayIndex(string path, ref int index, out int arrayIndex)
    {
        arrayIndex = -1;
        var closingIndex = path.IndexOf(']', index + 1);
        if (closingIndex < 0)
        {
            return false;
        }

        var indexText = path[(index + 1)..closingIndex].Trim();
        if (!int.TryParse(indexText, NumberStyles.None, CultureInfo.InvariantCulture, out arrayIndex) ||
            arrayIndex < 0)
        {
            return false;
        }

        index = closingIndex + 1;
        return true;
    }

    private static bool TryGetArrayElement(JsonElement value, int index, out JsonElement element)
    {
        element = default;
        if (value.ValueKind != JsonValueKind.Array)
        {
            return false;
        }

        var currentIndex = 0;
        foreach (var item in value.EnumerateArray())
        {
            if (currentIndex == index)
            {
                element = item;
                return true;
            }

            currentIndex++;
        }

        return false;
    }

    private static bool TryGetPropertyIgnoreCase(JsonElement value, string propertyName, out JsonElement propertyValue)
    {
        propertyValue = default;
        if (value.ValueKind != JsonValueKind.Object)
        {
            return false;
        }

        foreach (var property in value.EnumerateObject())
        {
            if (string.Equals(property.Name, propertyName, StringComparison.OrdinalIgnoreCase))
            {
                propertyValue = property.Value;
                return true;
            }
        }

        return false;
    }

    private static bool JsonValuesEqual(JsonElement left, JsonElement right)
    {
        if (left.ValueKind == JsonValueKind.Number && right.ValueKind == JsonValueKind.Number)
        {
            return TryGetDecimal(left, out var leftDecimal) &&
                   TryGetDecimal(right, out var rightDecimal)
                ? leftDecimal == rightDecimal
                : left.GetDouble() == right.GetDouble();
        }

        if (left.ValueKind == JsonValueKind.True || left.ValueKind == JsonValueKind.False)
        {
            return (right.ValueKind == JsonValueKind.True || right.ValueKind == JsonValueKind.False) &&
                   left.GetBoolean() == right.GetBoolean();
        }

        if (left.ValueKind == JsonValueKind.String || right.ValueKind == JsonValueKind.String)
        {
            return left.ValueKind == JsonValueKind.String &&
                   right.ValueKind == JsonValueKind.String &&
                   string.Equals(left.GetString(), right.GetString(), StringComparison.Ordinal);
        }

        if (left.ValueKind == JsonValueKind.Null || right.ValueKind == JsonValueKind.Null)
        {
            return left.ValueKind == JsonValueKind.Null && right.ValueKind == JsonValueKind.Null;
        }

        if (left.ValueKind != right.ValueKind)
        {
            return false;
        }

        return left.ValueKind switch
        {
            JsonValueKind.Object => JsonObjectsEqual(left, right),
            JsonValueKind.Array => JsonArraysEqual(left, right),
            JsonValueKind.Undefined => right.ValueKind == JsonValueKind.Undefined,
            _ => string.Equals(left.GetRawText(), right.GetRawText(), StringComparison.Ordinal)
        };
    }

    private static bool TryGetDecimal(JsonElement value, out decimal number)
    {
        return value.TryGetDecimal(out number);
    }

    private static bool JsonObjectsEqual(JsonElement left, JsonElement right)
    {
        var rightProperties = new Dictionary<string, JsonElement>(StringComparer.Ordinal);
        var rightCount = 0;
        foreach (var property in right.EnumerateObject())
        {
            rightProperties[property.Name] = property.Value;
            rightCount++;
        }

        var leftCount = 0;
        foreach (var property in left.EnumerateObject())
        {
            leftCount++;
            if (!rightProperties.TryGetValue(property.Name, out var rightValue))
            {
                return false;
            }

            if (!JsonValuesEqual(property.Value, rightValue))
            {
                return false;
            }
        }

        return leftCount == rightCount;
    }

    private static bool JsonArraysEqual(JsonElement left, JsonElement right)
    {
        using var leftEnumerator = left.EnumerateArray();
        using var rightEnumerator = right.EnumerateArray();

        while (true)
        {
            var hasLeft = leftEnumerator.MoveNext();
            var hasRight = rightEnumerator.MoveNext();
            if (hasLeft != hasRight)
            {
                return false;
            }

            if (!hasLeft)
            {
                return true;
            }

            if (!JsonValuesEqual(leftEnumerator.Current, rightEnumerator.Current))
            {
                return false;
            }
        }
    }

    private static string FormatJsonValue(JsonElement value)
    {
        return value.ValueKind switch
        {
            JsonValueKind.String => value.GetString() ?? string.Empty,
            JsonValueKind.True => bool.TrueString.ToLowerInvariant(),
            JsonValueKind.False => bool.FalseString.ToLowerInvariant(),
            JsonValueKind.Null => "null",
            _ => value.GetRawText()
        };
    }

    private sealed class ResponseValidationDefinition
    {
        public List<ResponseValidationCondition> All { get; set; } = [];
    }

    private sealed class ResponseValidationCondition
    {
        public string Path { get; set; } = string.Empty;
        public string Op { get; set; } = string.Empty;
        public JsonElement Value { get; set; }
    }
}