diff --git a/README.md b/README.md
index 3727f80..8f5fbc7 100644
--- a/README.md
+++ b/README.md
@@ -1,6 +1,5 @@
[](https://www.nuget.org/packages/BinkyLabs.OpenApi.Overlays) [](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