diff --git a/README.md b/README.md index 3727f80..8f5fbc7 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,5 @@ [![NuGet Version](https://img.shields.io/nuget/vpre/BinkyLabs.OpenApi.Overlays)](https://www.nuget.org/packages/BinkyLabs.OpenApi.Overlays) [![GitHub Actions Workflow Status](https://img.shields.io/github/actions/workflow/status/BinkyLabs/openapi-overlays-dotnet/dotnet.yml)](https://github.com/BinkyLabs/openapi-overlays-dotnet/actions/workflows/dotnet.yml) - # OpenAPI Overlay Libraries for dotnet This project provides a .NET implementation of the [OpenAPI Overlay Specification](https://spec.openapis.org/overlay/latest.html), allowing you to dynamically apply overlays (patches) to existing OpenAPI documents (v3.0+), following the official OpenAPI Overlay 1.0.0 specification. @@ -75,13 +74,28 @@ var jsonResult = textWriter.ToString(); // or use flush async if the underlying writer is a stream writer to a file or network stream ``` +## Experimental features + +This library implements the following experimental features: + +### Copy + +The [copy proposal](https://github.com/OAI/Overlay-Specification/pull/150) to the Overlay specification works similarly to the update action, except it sources its value from another node. This library adds a property under an experimental flag, serializes and deserializes the value, and applies a copy overlay to an OpenAPI document. + +```json +{ + "target": "$.info.title", + "description": "Copy description to title", + "x-copy": "$.info.description" +} +``` + ## Release notes The OpenAPI Overlay Libraries releases notes are available from the [CHANGELOG](CHANGELOG.md) ## Debugging - ## Contributing This project welcomes contributions and suggestions. Make sure you open an issue before sending any pull request to avoid any misunderstanding. diff --git a/src/lib/Models/OverlayAction.cs b/src/lib/Models/OverlayAction.cs index d51435e..df84f85 100644 --- a/src/lib/Models/OverlayAction.cs +++ b/src/lib/Models/OverlayAction.cs @@ -1,3 +1,4 @@ +using System.Diagnostics.CodeAnalysis; using System.Text.Json.Nodes; using BinkyLabs.OpenApi.Overlays.Reader; @@ -36,6 +37,15 @@ public class OverlayAction : IOverlaySerializable, IOverlayExtensible /// public JsonNode? Update { get; set; } + /// + /// A string value that indicates that the target object or array MUST be copied to the location indicated by this string, which MUST be a JSON Pointer. + /// This field is mutually exclusive with the and fields. + /// This field is experimental and not part of the OpenAPI Overlay specification v1.0.0. + /// This field is an implementation of the copy proposal. + /// + [Experimental("BOO001", UrlFormat = "https://github.com/OAI/Overlay-Specification/pull/150")] + public string? Copy { get; set; } + /// public IDictionary? Extensions { get; set; } @@ -54,123 +64,185 @@ public void SerializeAsV1(IOpenApiWriter writer) { writer.WriteOptionalObject("update", Update, (w, s) => w.WriteAny(s)); } +#pragma warning disable BOO001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed. + if (Copy != null) + { + writer.WriteProperty("x-copy", Copy); + } +#pragma warning restore BOO001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed. writer.WriteOverlayExtensions(Extensions, OverlaySpecVersion.Overlay1_0); writer.WriteEndObject(); } +#pragma warning disable BOO001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed. internal bool ApplyToDocument(JsonNode documentJsonNode, OverlayDiagnostic overlayDiagnostic, int index) { ArgumentNullException.ThrowIfNull(documentJsonNode); ArgumentNullException.ThrowIfNull(overlayDiagnostic); - string GetPointer() => $"$.actions[{index}]"; if (string.IsNullOrEmpty(Target)) { - overlayDiagnostic.Errors.Add(new OpenApiError(GetPointer(), "Target is required")); + overlayDiagnostic.Errors.Add(new OpenApiError(GetPointer(index), "Target is required")); return false; } - if (Remove is not true && Update is null) + if (Remove is not true && Update is null && string.IsNullOrEmpty(Copy)) { - overlayDiagnostic.Errors.Add(new OpenApiError(GetPointer(), "Either 'remove' or 'update' must be specified")); + overlayDiagnostic.Errors.Add(new OpenApiError(GetPointer(index), "At least one of 'remove', 'update' or 'x-copy' must be specified")); return false; } - if (Remove is true && Update is not null) + if (Remove is true ^ Update is not null ? !string.IsNullOrEmpty(Copy) : Remove is true) { - overlayDiagnostic.Errors.Add(new OpenApiError(GetPointer(), "'remove' and 'update' cannot be used together")); + overlayDiagnostic.Errors.Add(new OpenApiError(GetPointer(index), "At most one of 'remove', 'update' or 'x-copy' can be specified")); return false; } if (!JsonPath.TryParse(Target, out var jsonPath)) { - overlayDiagnostic.Errors.Add(new OpenApiError(GetPointer(), $"Invalid JSON Path: '{Target}'")); + overlayDiagnostic.Errors.Add(new OpenApiError(GetPointer(index), $"Invalid JSON Path: '{Target}'")); return false; } if (jsonPath.Evaluate(documentJsonNode) is not { } parseResult) { - overlayDiagnostic.Errors.Add(new OpenApiError(GetPointer(), $"Target not found: '{Target}'")); + overlayDiagnostic.Errors.Add(new OpenApiError(GetPointer(index), $"Target not found: '{Target}'")); return false; } - if (Update is not null) + if (!string.IsNullOrEmpty(Copy)) + { + return CopyNodes(parseResult, documentJsonNode, overlayDiagnostic, index); + } + else if (Update is not null) { foreach (var match in parseResult.Matches) { if (match.Value is null) { - overlayDiagnostic.Errors.Add(new OpenApiError(GetPointer(), $"Target '{Target}' does not point to a valid JSON node")); + overlayDiagnostic.Errors.Add(new OpenApiError(GetPointer(index), $"Target '{Target}' does not point to a valid JSON node")); return false; } MergeJsonNode(match.Value, Update, overlayDiagnostic); } + return true; } else if (Remove is true) { - var parentPathString = $"{(jsonPath.Scope is PathScope.Global ? "$" : "@")}{string.Concat(jsonPath.Segments[..^1].Select(static s => s.ToString()))}"; - if (!JsonPath.TryParse(parentPathString, out var parentPath)) + return RemoveNodes(documentJsonNode, jsonPath, overlayDiagnostic, index); + } + // we should never get here because of the earlier checks + throw new InvalidOperationException("The action must be either 'remove', 'update' or 'x-copy'"); + } + private bool CopyNodes(PathResult parseResult, JsonNode documentJsonNode, OverlayDiagnostic overlayDiagnostic, int index) + { + if (!JsonPath.TryParse(Copy!, out var copyPath)) + { + overlayDiagnostic.Errors.Add(new OpenApiError(GetPointer(index), $"Invalid copy JSON Path: '{Copy}'")); + return false; + } + if (copyPath.Evaluate(documentJsonNode) is not { } copyParseResult) + { + overlayDiagnostic.Errors.Add(new OpenApiError(GetPointer(index), $"Copy target not found: '{Copy}'")); + return false; + } + if (copyParseResult.Matches.Count < 1) + { + overlayDiagnostic.Errors.Add(new OpenApiError(GetPointer(index), $"Copy target '{Copy}' must point to at least one JSON node")); + return false; + } + + if (parseResult.Matches.Count != copyParseResult.Matches.Count) + { + overlayDiagnostic.Errors.Add(new OpenApiError(GetPointer(index), $"The number of matches for 'target' ({parseResult.Matches.Count}) and 'x-copy' ({copyParseResult.Matches.Count}) must be the same")); + return false; + } + for (var i = 0; i < copyParseResult.Matches.Count; i++) + { + var match = parseResult.Matches[i]; + if (match.Value is null) { - overlayDiagnostic.Errors.Add(new OpenApiError(GetPointer(), $"Invalid parent JSON Path: '{parentPathString}'")); + overlayDiagnostic.Errors.Add(new OpenApiError(GetPointer(index), $"Target '{Target}' does not point to a valid JSON node")); return false; } - if (parentPath.Evaluate(documentJsonNode) is not { } parentParseResult) + var copyMatch = copyParseResult.Matches[i]; + if (copyMatch.Value is null) { - overlayDiagnostic.Errors.Add(new OpenApiError(GetPointer(), $"Parent target not found: '{parentPathString}'")); + overlayDiagnostic.Errors.Add(new OpenApiError(GetPointer(index), $"Copy target '{Copy}' does not point to a valid JSON node")); return false; } - if (parentParseResult.Matches.Count < 1) + MergeJsonNode(match.Value, copyMatch.Value, overlayDiagnostic); + } + return true; + } +#pragma warning restore BOO001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed. + + private static string GetPointer(int index) => $"$.actions[{index}]"; + + private bool RemoveNodes(JsonNode documentJsonNode, JsonPath jsonPath, OverlayDiagnostic overlayDiagnostic, int index) + { + var parentPathString = $"{(jsonPath.Scope is PathScope.Global ? "$" : "@")}{string.Concat(jsonPath.Segments[..^1].Select(static s => s.ToString()))}"; + if (!JsonPath.TryParse(parentPathString, out var parentPath)) + { + overlayDiagnostic.Errors.Add(new OpenApiError(GetPointer(index), $"Invalid parent JSON Path: '{parentPathString}'")); + return false; + } + if (parentPath.Evaluate(documentJsonNode) is not { } parentParseResult) + { + overlayDiagnostic.Errors.Add(new OpenApiError(GetPointer(index), $"Parent target not found: '{parentPathString}'")); + return false; + } + if (parentParseResult.Matches.Count < 1) + { + overlayDiagnostic.Errors.Add(new OpenApiError(GetPointer(index), $"Parent target '{parentPathString}' must point to at least one JSON node")); + return false; + } + var lastSegment = jsonPath.Segments[^1] ?? throw new InvalidOperationException("Last segment of the JSON Path cannot be null"); + var lastSegmentPath = $"${lastSegment}"; + if (!JsonPath.TryParse(lastSegmentPath, out var lastSegmentPathParsed)) + { + overlayDiagnostic.Errors.Add(new OpenApiError(GetPointer(index), $"Invalid last segment JSON Path: '{lastSegmentPath}'")); + return false; + } + var parentPathEndsWithWildcard = parentPath.Segments[^1].Selectors.FirstOrDefault() is WildcardSelector; + var itemRemoved = false; + foreach (var parentMatch in parentParseResult.Matches) + { + if (parentMatch.Value is not JsonNode parentJsonNode) { - overlayDiagnostic.Errors.Add(new OpenApiError(GetPointer(), $"Parent target '{parentPathString}' must point to at least one JSON node")); + overlayDiagnostic.Errors.Add(new OpenApiError(GetPointer(index), $"Parent target '{parentPathString}' does not point to a valid JSON node")); return false; } - var lastSegment = jsonPath.Segments[^1] ?? throw new InvalidOperationException("Last segment of the JSON Path cannot be null"); - var lastSegmentPath = $"${lastSegment}"; - if (!JsonPath.TryParse(lastSegmentPath, out var lastSegmentPathParsed)) + if (lastSegmentPathParsed.Evaluate(parentJsonNode) is not { } lastSegmentParseResult) { - overlayDiagnostic.Errors.Add(new OpenApiError(GetPointer(), $"Invalid last segment JSON Path: '{lastSegmentPath}'")); + overlayDiagnostic.Errors.Add(new OpenApiError(GetPointer(index), $"Last segment target not found: '{lastSegmentPath}'")); return false; } - var parentPathEndsWithWildcard = parentPath.Segments[^1].Selectors.FirstOrDefault() is WildcardSelector; - var itemRemoved = false; - foreach (var parentMatch in parentParseResult.Matches) + if (lastSegmentParseResult.Matches.Count < 1) { - if (parentMatch.Value is not JsonNode parentJsonNode) - { - overlayDiagnostic.Errors.Add(new OpenApiError(GetPointer(), $"Parent target '{parentPathString}' does not point to a valid JSON node")); - return false; - } - if (lastSegmentPathParsed.Evaluate(parentJsonNode) is not { } lastSegmentParseResult) - { - overlayDiagnostic.Errors.Add(new OpenApiError(GetPointer(), $"Last segment target not found: '{lastSegmentPath}'")); - return false; - } - if (lastSegmentParseResult.Matches.Count < 1) - { - if (parentPathEndsWithWildcard && itemRemoved) - { - // If the parent path ends with a wildcard and we've already removed an item, - // it's acceptable for some segments to have no matches. - continue; - } - overlayDiagnostic.Errors.Add(new OpenApiError(GetPointer(), $"Last segment target '{lastSegmentPath}' must point to at least one JSON node")); - return false; - } - if (lastSegmentParseResult.Matches[0].Value is not JsonNode nodeToRemove) + if (parentPathEndsWithWildcard && itemRemoved) { - overlayDiagnostic.Errors.Add(new OpenApiError(GetPointer(), $"Last segment target '{lastSegmentPath}' does not point to a valid JSON node")); - return false; - } - if (!RemoveJsonNode(parentJsonNode, nodeToRemove, overlayDiagnostic, GetPointer)) - { - return false; + // If the parent path ends with a wildcard and we've already removed an item, + // it's acceptable for some segments to have no matches. + continue; } - itemRemoved = true; + overlayDiagnostic.Errors.Add(new OpenApiError(GetPointer(index), $"Last segment target '{lastSegmentPath}' must point to at least one JSON node")); + return false; + } + if (lastSegmentParseResult.Matches[0].Value is not JsonNode nodeToRemove) + { + overlayDiagnostic.Errors.Add(new OpenApiError(GetPointer(index), $"Last segment target '{lastSegmentPath}' does not point to a valid JSON node")); + return false; + } + if (!RemoveJsonNode(parentJsonNode, nodeToRemove, overlayDiagnostic, index)) + { + return false; } + itemRemoved = true; } return true; } - private bool RemoveJsonNode(JsonNode parentJsonNode, JsonNode nodeToRemove, OverlayDiagnostic overlayDiagnostic, Func getPointer) + + private bool RemoveJsonNode(JsonNode parentJsonNode, JsonNode nodeToRemove, OverlayDiagnostic overlayDiagnostic, int index) { ArgumentNullException.ThrowIfNull(parentJsonNode); ArgumentNullException.ThrowIfNull(nodeToRemove); ArgumentNullException.ThrowIfNull(overlayDiagnostic); - ArgumentNullException.ThrowIfNull(getPointer); if (parentJsonNode is JsonObject currentObject) { foreach (var kvp in currentObject) @@ -194,7 +266,7 @@ private bool RemoveJsonNode(JsonNode parentJsonNode, JsonNode nodeToRemove, Over } } } - overlayDiagnostic.Errors.Add(new OpenApiError(getPointer(), $"Target '{Target}' does not point to a valid JSON node")); + overlayDiagnostic.Errors.Add(new OpenApiError(GetPointer(index), $"Target '{Target}' does not point to a valid JSON node")); return false; } private static void MergeJsonNode(JsonNode target, JsonNode update, OverlayDiagnostic overlayDiagnostic) @@ -211,12 +283,45 @@ private static void MergeJsonNode(JsonNode target, JsonNode update, OverlayDiagn targetArray.Clear(); foreach (var item in updateArray) { - targetArray.Add(item); + targetArray.Add(item?.DeepClone()); } } + else if (target is JsonValue && update is JsonValue) + { + ReplaceValueInParent(target, update); + } else { - overlayDiagnostic.Errors.Add(new OpenApiError("Update", "Cannot merge non-object or non-array types")); + overlayDiagnostic.Errors.Add(new OpenApiError("Update", "Cannot merge incompatible types")); + } + } + + private static void ReplaceValueInParent(JsonNode target, JsonNode update) + { + var parent = target.Parent; + if (parent is JsonObject parentObject) + { + // Find the property name for this target + foreach (var kvp in parentObject) + { + if (kvp.Value == target) + { + parentObject[kvp.Key] = update.DeepClone(); + return; + } + } + } + else if (parent is JsonArray parentArray) + { + // Find the index for this target + for (int i = 0; i < parentArray.Count; i++) + { + if (parentArray[i] == target) + { + parentArray[i] = update.DeepClone(); + return; + } + } } } } \ No newline at end of file diff --git a/src/lib/PublicAPI.Unshipped.txt b/src/lib/PublicAPI.Unshipped.txt index 4c3f8af..336af33 100644 --- a/src/lib/PublicAPI.Unshipped.txt +++ b/src/lib/PublicAPI.Unshipped.txt @@ -14,6 +14,8 @@ BinkyLabs.OpenApi.Overlays.JsonNodeExtension.JsonNodeExtension(System.Text.Json. BinkyLabs.OpenApi.Overlays.JsonNodeExtension.Node.get -> System.Text.Json.Nodes.JsonNode! BinkyLabs.OpenApi.Overlays.JsonNodeExtension.Write(Microsoft.OpenApi.IOpenApiWriter! writer, BinkyLabs.OpenApi.Overlays.OverlaySpecVersion specVersion) -> void BinkyLabs.OpenApi.Overlays.OverlayAction +BinkyLabs.OpenApi.Overlays.OverlayAction.Copy.get -> string? +BinkyLabs.OpenApi.Overlays.OverlayAction.Copy.set -> void BinkyLabs.OpenApi.Overlays.OverlayAction.Description.get -> string? BinkyLabs.OpenApi.Overlays.OverlayAction.Description.set -> void BinkyLabs.OpenApi.Overlays.OverlayAction.Extensions.get -> System.Collections.Generic.IDictionary? diff --git a/src/lib/Reader/V1/OverlayActionDeserializer.cs b/src/lib/Reader/V1/OverlayActionDeserializer.cs index 5d4db17..6ff0786 100644 --- a/src/lib/Reader/V1/OverlayActionDeserializer.cs +++ b/src/lib/Reader/V1/OverlayActionDeserializer.cs @@ -16,7 +16,10 @@ internal static partial class OverlayV1Deserializer } } }, - { "update", (o, v) => o.Update = v.CreateAny() } + { "update", (o, v) => o.Update = v.CreateAny() }, +#pragma warning disable BOO001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed. + { "x-copy", (o, v) => o.Copy = v.GetScalarValue() }, +#pragma warning restore BOO001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed. }; public static readonly PatternFieldMap ActionPatternFields = new() { diff --git a/tests/lib/Serialization/OverlayActionTests.cs b/tests/lib/Serialization/OverlayActionTests.cs index 5f70e27..58d7ee1 100644 --- a/tests/lib/Serialization/OverlayActionTests.cs +++ b/tests/lib/Serialization/OverlayActionTests.cs @@ -8,6 +8,8 @@ using ParsingContext = BinkyLabs.OpenApi.Overlays.Reader.ParsingContext; +#pragma warning disable BOO001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed. + namespace BinkyLabs.OpenApi.Overlays.Tests; public class OverlayActionTests @@ -298,7 +300,7 @@ public void ApplyToDocument_ShouldFailNoRemoveOrUpdate() Assert.False(result); Assert.Single(overlayDiagnostic.Errors); Assert.Equal("$.actions[0]", overlayDiagnostic.Errors[0].Pointer); - Assert.Equal("Either 'remove' or 'update' must be specified", overlayDiagnostic.Errors[0].Message); + Assert.Equal("At least one of 'remove', 'update' or 'x-copy' must be specified", overlayDiagnostic.Errors[0].Message); } [Fact] public void ApplyToDocument_ShouldFailBothRemoveAndUpdate() @@ -317,7 +319,7 @@ public void ApplyToDocument_ShouldFailBothRemoveAndUpdate() Assert.False(result); Assert.Single(overlayDiagnostic.Errors); Assert.Equal("$.actions[0]", overlayDiagnostic.Errors[0].Pointer); - Assert.Equal("'remove' and 'update' cannot be used together", overlayDiagnostic.Errors[0].Message); + Assert.Equal("At most one of 'remove', 'update' or 'x-copy' can be specified", overlayDiagnostic.Errors[0].Message); } [Fact] public void ApplyToDocument_ShouldFailInvalidJsonPath() @@ -429,4 +431,385 @@ public void ApplyToDocument_ShouldUpdateANode() Assert.Equal("Updated API", jsonNode["info"]?["title"]?.GetValue()); Assert.Empty(overlayDiagnostic.Errors); } + + [Fact] + public void ApplyToDocument_ShouldCopySimpleValue() + { + var overlayAction = new OverlayAction + { + Target = "$.info.title", + Copy = "$.info.version" + }; + var jsonNode = JsonNode.Parse(""" + { + "info": { + "title": "Test API", + "version": "1.0.0" + } + } + """)!; + var overlayDiagnostic = new OverlayDiagnostic(); + + var result = overlayAction.ApplyToDocument(jsonNode, overlayDiagnostic, 0); + + Assert.True(result); + Assert.Equal("1.0.0", jsonNode["info"]?["title"]?.GetValue()); + Assert.Empty(overlayDiagnostic.Errors); + } + + [Fact] + public void ApplyToDocument_ShouldCopyComplexObject() + { + var overlayAction = new OverlayAction + { + Target = "$.paths['/users'].get.responses['200']", + Copy = "$.components.responses.UserResponse" + }; + var jsonNode = JsonNode.Parse(""" + { + "paths": { + "/users": { + "get": { + "responses": { + "200": { + "description": "Old description" + } + } + } + } + }, + "components": { + "responses": { + "UserResponse": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/User" + } + } + } + } + } + } + } + """)!; + var overlayDiagnostic = new OverlayDiagnostic(); + + var result = overlayAction.ApplyToDocument(jsonNode, overlayDiagnostic, 0); + + Assert.True(result); + Assert.Equal("Successful response", jsonNode["paths"]?["/users"]?["get"]?["responses"]?["200"]?["description"]?.GetValue()); + Assert.NotNull(jsonNode["paths"]?["/users"]?["get"]?["responses"]?["200"]?["content"]); + Assert.Empty(overlayDiagnostic.Errors); + } + + [Fact] + public void ApplyToDocument_ShouldCopyArrayElements() + { + var overlayAction = new OverlayAction + { + Target = "$.paths['/users'].get.tags", + Copy = "$.paths['/posts'].get.tags" + }; + var jsonNode = JsonNode.Parse(""" + { + "paths": { + "/users": { + "get": { + "tags": ["user"] + } + }, + "/posts": { + "get": { + "tags": ["post", "content"] + } + } + } + } + """)!; + var overlayDiagnostic = new OverlayDiagnostic(); + + var result = overlayAction.ApplyToDocument(jsonNode, overlayDiagnostic, 0); + + Assert.True(result); + var targetTags = jsonNode["paths"]?["/users"]?["get"]?["tags"]?.AsArray(); + Assert.NotNull(targetTags); + Assert.Equal(2, targetTags.Count); + Assert.Equal("post", targetTags[0]?.GetValue()); + Assert.Equal("content", targetTags[1]?.GetValue()); + Assert.Empty(overlayDiagnostic.Errors); + } + + [Fact] + public void ApplyToDocument_ShouldCopyMultipleMatches() + { + var overlayAction = new OverlayAction + { + Target = "$.paths[*].get.summary", + Copy = "$.paths[*].get.operationId" + }; + var jsonNode = JsonNode.Parse(""" + { + "paths": { + "/users": { + "get": { + "summary": "Get users", + "operationId": "listUsers" + } + }, + "/posts": { + "get": { + "summary": "Get posts", + "operationId": "listPosts" + } + } + } + } + """)!; + var overlayDiagnostic = new OverlayDiagnostic(); + + var result = overlayAction.ApplyToDocument(jsonNode, overlayDiagnostic, 0); + + Assert.True(result); + Assert.Equal("listUsers", jsonNode["paths"]?["/users"]?["get"]?["summary"]?.GetValue()); + Assert.Equal("listPosts", jsonNode["paths"]?["/posts"]?["get"]?["summary"]?.GetValue()); + Assert.Empty(overlayDiagnostic.Errors); + } + + [Fact] + public void ApplyToDocument_CopyShouldFailWithInvalidCopyPath() + { + var overlayAction = new OverlayAction + { + Target = "$.info.title", + Copy = "invalid path" + }; + var jsonNode = JsonNode.Parse(""" + { + "info": { + "title": "Test API" + } + } + """)!; + var overlayDiagnostic = new OverlayDiagnostic(); + + var result = overlayAction.ApplyToDocument(jsonNode, overlayDiagnostic, 0); + + Assert.False(result); + Assert.Single(overlayDiagnostic.Errors); + Assert.Equal("$.actions[0]", overlayDiagnostic.Errors[0].Pointer); + Assert.Equal("Invalid copy JSON Path: 'invalid path'", overlayDiagnostic.Errors[0].Message); + } + + [Fact] + public void ApplyToDocument_CopyShouldFailWhenCopyTargetNotFound() + { + var overlayAction = new OverlayAction + { + Target = "$.info.title", + Copy = "$.nonexistent.field" + }; + var jsonNode = JsonNode.Parse(""" + { + "info": { + "title": "Test API" + } + } + """)!; + var overlayDiagnostic = new OverlayDiagnostic(); + + var result = overlayAction.ApplyToDocument(jsonNode, overlayDiagnostic, 0); + + Assert.False(result); + Assert.Single(overlayDiagnostic.Errors); + Assert.Equal("$.actions[0]", overlayDiagnostic.Errors[0].Pointer); + Assert.Equal("Copy target '$.nonexistent.field' must point to at least one JSON node", overlayDiagnostic.Errors[0].Message); + } + + [Fact] + public void ApplyToDocument_CopyShouldFailWhenCopyTargetHasNoMatches() + { + var overlayAction = new OverlayAction + { + Target = "$.info.title", + Copy = "$.paths[*].get.nonexistent" + }; + var jsonNode = JsonNode.Parse(""" + { + "info": { + "title": "Test API" + }, + "paths": { + "/users": { + "get": { + "summary": "Get users" + } + } + } + } + """)!; + var overlayDiagnostic = new OverlayDiagnostic(); + + var result = overlayAction.ApplyToDocument(jsonNode, overlayDiagnostic, 0); + + Assert.False(result); + Assert.Single(overlayDiagnostic.Errors); + Assert.Equal("$.actions[0]", overlayDiagnostic.Errors[0].Pointer); + Assert.Equal("Copy target '$.paths[*].get.nonexistent' must point to at least one JSON node", overlayDiagnostic.Errors[0].Message); + } + + [Fact] + public void ApplyToDocument_CopyShouldFailWhenMatchCountsDiffer() + { + var overlayAction = new OverlayAction + { + Target = "$.paths[*].get.summary", + Copy = "$.info.title" + }; + var jsonNode = JsonNode.Parse(""" + { + "info": { + "title": "Test API" + }, + "paths": { + "/users": { + "get": { + "summary": "Get users" + } + }, + "/posts": { + "get": { + "summary": "Get posts" + } + } + } + } + """)!; + var overlayDiagnostic = new OverlayDiagnostic(); + + var result = overlayAction.ApplyToDocument(jsonNode, overlayDiagnostic, 0); + + Assert.False(result); + Assert.Single(overlayDiagnostic.Errors); + Assert.Equal("$.actions[0]", overlayDiagnostic.Errors[0].Pointer); + Assert.Equal("The number of matches for 'target' (2) and 'x-copy' (1) must be the same", overlayDiagnostic.Errors[0].Message); + } + + [Fact] + public void ApplyToDocument_CopyShouldFailWhenTargetPointsToNull() + { + var overlayAction = new OverlayAction + { + Target = "$.info.nonexistent", + Copy = "$.info.title" + }; + var jsonNode = JsonNode.Parse(""" + { + "info": { + "title": "Test API", + "nonexistent": null + } + } + """)!; + var overlayDiagnostic = new OverlayDiagnostic(); + + var result = overlayAction.ApplyToDocument(jsonNode, overlayDiagnostic, 0); + + Assert.False(result); + Assert.Single(overlayDiagnostic.Errors); + Assert.Equal("$.actions[0]", overlayDiagnostic.Errors[0].Pointer); + Assert.Equal("Target '$.info.nonexistent' does not point to a valid JSON node", overlayDiagnostic.Errors[0].Message); + } + + [Fact] + public void ApplyToDocument_CopyShouldFailWhenCopyTargetPointsToNull() + { + var overlayAction = new OverlayAction + { + Target = "$.info.title", + Copy = "$.info.nonexistent" + }; + var jsonNode = JsonNode.Parse(""" + { + "info": { + "title": "Test API", + "nonexistent": null + } + } + """)!; + var overlayDiagnostic = new OverlayDiagnostic(); + + var result = overlayAction.ApplyToDocument(jsonNode, overlayDiagnostic, 0); + + Assert.False(result); + Assert.Single(overlayDiagnostic.Errors); + Assert.Equal("$.actions[0]", overlayDiagnostic.Errors[0].Pointer); + Assert.Equal("Copy target '$.info.nonexistent' does not point to a valid JSON node", overlayDiagnostic.Errors[0].Message); + } + + [Fact] + public void ApplyToDocument_CopyShouldMergeObjectsCorrectly() + { + var overlayAction = new OverlayAction + { + Target = "$.info", + Copy = "$.components.info" + }; + var jsonNode = JsonNode.Parse(""" + { + "info": { + "title": "Original API", + "version": "1.0.0" + }, + "components": { + "info": { + "title": "Updated API", + "description": "New description" + } + } + } + """)!; + var overlayDiagnostic = new OverlayDiagnostic(); + + var result = overlayAction.ApplyToDocument(jsonNode, overlayDiagnostic, 0); + + Assert.True(result); + Assert.Equal("Updated API", jsonNode["info"]?["title"]?.GetValue()); + Assert.Equal("1.0.0", jsonNode["info"]?["version"]?.GetValue()); + Assert.Equal("New description", jsonNode["info"]?["description"]?.GetValue()); + Assert.Empty(overlayDiagnostic.Errors); + } + + [Fact] + public void SerializeAsV1_WithCopy_ShouldWriteCorrectJson() + { + // Arrange + var overlayAction = new OverlayAction + { + Target = "$.info.title", + Description = "Copy description to title", + Copy = "$.info.description" + }; + using var textWriter = new StringWriter(); + var writer = new OpenApiJsonWriter(textWriter); + + var expectedJson = +""" +{ + "target": "$.info.title", + "description": "Copy description to title", + "x-copy": "$.info.description" +} +"""; + + // Act + overlayAction.SerializeAsV1(writer); + var jsonResult = textWriter.ToString(); + var jsonResultObject = JsonNode.Parse(jsonResult); + var expectedJsonObject = JsonNode.Parse(expectedJson); + + // Assert + Assert.True(JsonNode.DeepEquals(jsonResultObject, expectedJsonObject), "The serialized JSON does not match the expected JSON."); + } } \ No newline at end of file