Skip to content

Commit 063db83

Browse files
authored
Merge pull request #105 from BinkyLabs/feat/copy-action
feat/copy action
2 parents 1849ab2 + ff85ba7 commit 063db83

File tree

5 files changed

+570
-63
lines changed

5 files changed

+570
-63
lines changed

README.md

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
[![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)
22

3-
43
# OpenAPI Overlay Libraries for dotnet
54

65
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();
7574
// or use flush async if the underlying writer is a stream writer to a file or network stream
7675
```
7776

77+
## Experimental features
78+
79+
This library implements the following experimental features:
80+
81+
### Copy
82+
83+
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.
84+
85+
```json
86+
{
87+
"target": "$.info.title",
88+
"description": "Copy description to title",
89+
"x-copy": "$.info.description"
90+
}
91+
```
92+
7893
## Release notes
7994

8095
The OpenAPI Overlay Libraries releases notes are available from the [CHANGELOG](CHANGELOG.md)
8196

8297
## Debugging
8398

84-
8599
## Contributing
86100

87101
This project welcomes contributions and suggestions. Make sure you open an issue before sending any pull request to avoid any misunderstanding.

src/lib/Models/OverlayAction.cs

Lines changed: 163 additions & 58 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
using System.Diagnostics.CodeAnalysis;
12
using System.Text.Json.Nodes;
23

34
using BinkyLabs.OpenApi.Overlays.Reader;
@@ -36,6 +37,15 @@ public class OverlayAction : IOverlaySerializable, IOverlayExtensible
3637
/// </summary>
3738
public JsonNode? Update { get; set; }
3839

40+
/// <summary>
41+
/// 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.
42+
/// This field is mutually exclusive with the <see cref="Remove"/> and <see cref="Update"/> fields.
43+
/// This field is experimental and not part of the OpenAPI Overlay specification v1.0.0.
44+
/// This field is an implementation of <see href="https://github.com/OAI/Overlay-Specification/pull/150">the copy proposal</see>.
45+
/// </summary>
46+
[Experimental("BOO001", UrlFormat = "https://github.com/OAI/Overlay-Specification/pull/150")]
47+
public string? Copy { get; set; }
48+
3949
/// <inheritdoc/>
4050
public IDictionary<string, IOverlayExtension>? Extensions { get; set; }
4151

@@ -54,123 +64,185 @@ public void SerializeAsV1(IOpenApiWriter writer)
5464
{
5565
writer.WriteOptionalObject("update", Update, (w, s) => w.WriteAny(s));
5666
}
67+
#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.
68+
if (Copy != null)
69+
{
70+
writer.WriteProperty("x-copy", Copy);
71+
}
72+
#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.
5773

5874
writer.WriteOverlayExtensions(Extensions, OverlaySpecVersion.Overlay1_0);
5975
writer.WriteEndObject();
6076
}
6177

78+
#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.
6279
internal bool ApplyToDocument(JsonNode documentJsonNode, OverlayDiagnostic overlayDiagnostic, int index)
6380
{
6481
ArgumentNullException.ThrowIfNull(documentJsonNode);
6582
ArgumentNullException.ThrowIfNull(overlayDiagnostic);
66-
string GetPointer() => $"$.actions[{index}]";
6783
if (string.IsNullOrEmpty(Target))
6884
{
69-
overlayDiagnostic.Errors.Add(new OpenApiError(GetPointer(), "Target is required"));
85+
overlayDiagnostic.Errors.Add(new OpenApiError(GetPointer(index), "Target is required"));
7086
return false;
7187
}
72-
if (Remove is not true && Update is null)
88+
if (Remove is not true && Update is null && string.IsNullOrEmpty(Copy))
7389
{
74-
overlayDiagnostic.Errors.Add(new OpenApiError(GetPointer(), "Either 'remove' or 'update' must be specified"));
90+
overlayDiagnostic.Errors.Add(new OpenApiError(GetPointer(index), "At least one of 'remove', 'update' or 'x-copy' must be specified"));
7591
return false;
7692
}
77-
if (Remove is true && Update is not null)
93+
if (Remove is true ^ Update is not null ? !string.IsNullOrEmpty(Copy) : Remove is true)
7894
{
79-
overlayDiagnostic.Errors.Add(new OpenApiError(GetPointer(), "'remove' and 'update' cannot be used together"));
95+
overlayDiagnostic.Errors.Add(new OpenApiError(GetPointer(index), "At most one of 'remove', 'update' or 'x-copy' can be specified"));
8096
return false;
8197
}
8298
if (!JsonPath.TryParse(Target, out var jsonPath))
8399
{
84-
overlayDiagnostic.Errors.Add(new OpenApiError(GetPointer(), $"Invalid JSON Path: '{Target}'"));
100+
overlayDiagnostic.Errors.Add(new OpenApiError(GetPointer(index), $"Invalid JSON Path: '{Target}'"));
85101
return false;
86102
}
87103
if (jsonPath.Evaluate(documentJsonNode) is not { } parseResult)
88104
{
89-
overlayDiagnostic.Errors.Add(new OpenApiError(GetPointer(), $"Target not found: '{Target}'"));
105+
overlayDiagnostic.Errors.Add(new OpenApiError(GetPointer(index), $"Target not found: '{Target}'"));
90106
return false;
91107
}
92-
if (Update is not null)
108+
if (!string.IsNullOrEmpty(Copy))
109+
{
110+
return CopyNodes(parseResult, documentJsonNode, overlayDiagnostic, index);
111+
}
112+
else if (Update is not null)
93113
{
94114
foreach (var match in parseResult.Matches)
95115
{
96116
if (match.Value is null)
97117
{
98-
overlayDiagnostic.Errors.Add(new OpenApiError(GetPointer(), $"Target '{Target}' does not point to a valid JSON node"));
118+
overlayDiagnostic.Errors.Add(new OpenApiError(GetPointer(index), $"Target '{Target}' does not point to a valid JSON node"));
99119
return false;
100120
}
101121
MergeJsonNode(match.Value, Update, overlayDiagnostic);
102122
}
123+
return true;
103124
}
104125
else if (Remove is true)
105126
{
106-
var parentPathString = $"{(jsonPath.Scope is PathScope.Global ? "$" : "@")}{string.Concat(jsonPath.Segments[..^1].Select(static s => s.ToString()))}";
107-
if (!JsonPath.TryParse(parentPathString, out var parentPath))
127+
return RemoveNodes(documentJsonNode, jsonPath, overlayDiagnostic, index);
128+
}
129+
// we should never get here because of the earlier checks
130+
throw new InvalidOperationException("The action must be either 'remove', 'update' or 'x-copy'");
131+
}
132+
private bool CopyNodes(PathResult parseResult, JsonNode documentJsonNode, OverlayDiagnostic overlayDiagnostic, int index)
133+
{
134+
if (!JsonPath.TryParse(Copy!, out var copyPath))
135+
{
136+
overlayDiagnostic.Errors.Add(new OpenApiError(GetPointer(index), $"Invalid copy JSON Path: '{Copy}'"));
137+
return false;
138+
}
139+
if (copyPath.Evaluate(documentJsonNode) is not { } copyParseResult)
140+
{
141+
overlayDiagnostic.Errors.Add(new OpenApiError(GetPointer(index), $"Copy target not found: '{Copy}'"));
142+
return false;
143+
}
144+
if (copyParseResult.Matches.Count < 1)
145+
{
146+
overlayDiagnostic.Errors.Add(new OpenApiError(GetPointer(index), $"Copy target '{Copy}' must point to at least one JSON node"));
147+
return false;
148+
}
149+
150+
if (parseResult.Matches.Count != copyParseResult.Matches.Count)
151+
{
152+
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"));
153+
return false;
154+
}
155+
for (var i = 0; i < copyParseResult.Matches.Count; i++)
156+
{
157+
var match = parseResult.Matches[i];
158+
if (match.Value is null)
108159
{
109-
overlayDiagnostic.Errors.Add(new OpenApiError(GetPointer(), $"Invalid parent JSON Path: '{parentPathString}'"));
160+
overlayDiagnostic.Errors.Add(new OpenApiError(GetPointer(index), $"Target '{Target}' does not point to a valid JSON node"));
110161
return false;
111162
}
112-
if (parentPath.Evaluate(documentJsonNode) is not { } parentParseResult)
163+
var copyMatch = copyParseResult.Matches[i];
164+
if (copyMatch.Value is null)
113165
{
114-
overlayDiagnostic.Errors.Add(new OpenApiError(GetPointer(), $"Parent target not found: '{parentPathString}'"));
166+
overlayDiagnostic.Errors.Add(new OpenApiError(GetPointer(index), $"Copy target '{Copy}' does not point to a valid JSON node"));
115167
return false;
116168
}
117-
if (parentParseResult.Matches.Count < 1)
169+
MergeJsonNode(match.Value, copyMatch.Value, overlayDiagnostic);
170+
}
171+
return true;
172+
}
173+
#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.
174+
175+
private static string GetPointer(int index) => $"$.actions[{index}]";
176+
177+
private bool RemoveNodes(JsonNode documentJsonNode, JsonPath jsonPath, OverlayDiagnostic overlayDiagnostic, int index)
178+
{
179+
var parentPathString = $"{(jsonPath.Scope is PathScope.Global ? "$" : "@")}{string.Concat(jsonPath.Segments[..^1].Select(static s => s.ToString()))}";
180+
if (!JsonPath.TryParse(parentPathString, out var parentPath))
181+
{
182+
overlayDiagnostic.Errors.Add(new OpenApiError(GetPointer(index), $"Invalid parent JSON Path: '{parentPathString}'"));
183+
return false;
184+
}
185+
if (parentPath.Evaluate(documentJsonNode) is not { } parentParseResult)
186+
{
187+
overlayDiagnostic.Errors.Add(new OpenApiError(GetPointer(index), $"Parent target not found: '{parentPathString}'"));
188+
return false;
189+
}
190+
if (parentParseResult.Matches.Count < 1)
191+
{
192+
overlayDiagnostic.Errors.Add(new OpenApiError(GetPointer(index), $"Parent target '{parentPathString}' must point to at least one JSON node"));
193+
return false;
194+
}
195+
var lastSegment = jsonPath.Segments[^1] ?? throw new InvalidOperationException("Last segment of the JSON Path cannot be null");
196+
var lastSegmentPath = $"${lastSegment}";
197+
if (!JsonPath.TryParse(lastSegmentPath, out var lastSegmentPathParsed))
198+
{
199+
overlayDiagnostic.Errors.Add(new OpenApiError(GetPointer(index), $"Invalid last segment JSON Path: '{lastSegmentPath}'"));
200+
return false;
201+
}
202+
var parentPathEndsWithWildcard = parentPath.Segments[^1].Selectors.FirstOrDefault() is WildcardSelector;
203+
var itemRemoved = false;
204+
foreach (var parentMatch in parentParseResult.Matches)
205+
{
206+
if (parentMatch.Value is not JsonNode parentJsonNode)
118207
{
119-
overlayDiagnostic.Errors.Add(new OpenApiError(GetPointer(), $"Parent target '{parentPathString}' must point to at least one JSON node"));
208+
overlayDiagnostic.Errors.Add(new OpenApiError(GetPointer(index), $"Parent target '{parentPathString}' does not point to a valid JSON node"));
120209
return false;
121210
}
122-
var lastSegment = jsonPath.Segments[^1] ?? throw new InvalidOperationException("Last segment of the JSON Path cannot be null");
123-
var lastSegmentPath = $"${lastSegment}";
124-
if (!JsonPath.TryParse(lastSegmentPath, out var lastSegmentPathParsed))
211+
if (lastSegmentPathParsed.Evaluate(parentJsonNode) is not { } lastSegmentParseResult)
125212
{
126-
overlayDiagnostic.Errors.Add(new OpenApiError(GetPointer(), $"Invalid last segment JSON Path: '{lastSegmentPath}'"));
213+
overlayDiagnostic.Errors.Add(new OpenApiError(GetPointer(index), $"Last segment target not found: '{lastSegmentPath}'"));
127214
return false;
128215
}
129-
var parentPathEndsWithWildcard = parentPath.Segments[^1].Selectors.FirstOrDefault() is WildcardSelector;
130-
var itemRemoved = false;
131-
foreach (var parentMatch in parentParseResult.Matches)
216+
if (lastSegmentParseResult.Matches.Count < 1)
132217
{
133-
if (parentMatch.Value is not JsonNode parentJsonNode)
134-
{
135-
overlayDiagnostic.Errors.Add(new OpenApiError(GetPointer(), $"Parent target '{parentPathString}' does not point to a valid JSON node"));
136-
return false;
137-
}
138-
if (lastSegmentPathParsed.Evaluate(parentJsonNode) is not { } lastSegmentParseResult)
139-
{
140-
overlayDiagnostic.Errors.Add(new OpenApiError(GetPointer(), $"Last segment target not found: '{lastSegmentPath}'"));
141-
return false;
142-
}
143-
if (lastSegmentParseResult.Matches.Count < 1)
144-
{
145-
if (parentPathEndsWithWildcard && itemRemoved)
146-
{
147-
// If the parent path ends with a wildcard and we've already removed an item,
148-
// it's acceptable for some segments to have no matches.
149-
continue;
150-
}
151-
overlayDiagnostic.Errors.Add(new OpenApiError(GetPointer(), $"Last segment target '{lastSegmentPath}' must point to at least one JSON node"));
152-
return false;
153-
}
154-
if (lastSegmentParseResult.Matches[0].Value is not JsonNode nodeToRemove)
218+
if (parentPathEndsWithWildcard && itemRemoved)
155219
{
156-
overlayDiagnostic.Errors.Add(new OpenApiError(GetPointer(), $"Last segment target '{lastSegmentPath}' does not point to a valid JSON node"));
157-
return false;
158-
}
159-
if (!RemoveJsonNode(parentJsonNode, nodeToRemove, overlayDiagnostic, GetPointer))
160-
{
161-
return false;
220+
// If the parent path ends with a wildcard and we've already removed an item,
221+
// it's acceptable for some segments to have no matches.
222+
continue;
162223
}
163-
itemRemoved = true;
224+
overlayDiagnostic.Errors.Add(new OpenApiError(GetPointer(index), $"Last segment target '{lastSegmentPath}' must point to at least one JSON node"));
225+
return false;
226+
}
227+
if (lastSegmentParseResult.Matches[0].Value is not JsonNode nodeToRemove)
228+
{
229+
overlayDiagnostic.Errors.Add(new OpenApiError(GetPointer(index), $"Last segment target '{lastSegmentPath}' does not point to a valid JSON node"));
230+
return false;
231+
}
232+
if (!RemoveJsonNode(parentJsonNode, nodeToRemove, overlayDiagnostic, index))
233+
{
234+
return false;
164235
}
236+
itemRemoved = true;
165237
}
166238
return true;
167239
}
168-
private bool RemoveJsonNode(JsonNode parentJsonNode, JsonNode nodeToRemove, OverlayDiagnostic overlayDiagnostic, Func<string> getPointer)
240+
241+
private bool RemoveJsonNode(JsonNode parentJsonNode, JsonNode nodeToRemove, OverlayDiagnostic overlayDiagnostic, int index)
169242
{
170243
ArgumentNullException.ThrowIfNull(parentJsonNode);
171244
ArgumentNullException.ThrowIfNull(nodeToRemove);
172245
ArgumentNullException.ThrowIfNull(overlayDiagnostic);
173-
ArgumentNullException.ThrowIfNull(getPointer);
174246
if (parentJsonNode is JsonObject currentObject)
175247
{
176248
foreach (var kvp in currentObject)
@@ -194,7 +266,7 @@ private bool RemoveJsonNode(JsonNode parentJsonNode, JsonNode nodeToRemove, Over
194266
}
195267
}
196268
}
197-
overlayDiagnostic.Errors.Add(new OpenApiError(getPointer(), $"Target '{Target}' does not point to a valid JSON node"));
269+
overlayDiagnostic.Errors.Add(new OpenApiError(GetPointer(index), $"Target '{Target}' does not point to a valid JSON node"));
198270
return false;
199271
}
200272
private static void MergeJsonNode(JsonNode target, JsonNode update, OverlayDiagnostic overlayDiagnostic)
@@ -211,12 +283,45 @@ private static void MergeJsonNode(JsonNode target, JsonNode update, OverlayDiagn
211283
targetArray.Clear();
212284
foreach (var item in updateArray)
213285
{
214-
targetArray.Add(item);
286+
targetArray.Add(item?.DeepClone());
215287
}
216288
}
289+
else if (target is JsonValue && update is JsonValue)
290+
{
291+
ReplaceValueInParent(target, update);
292+
}
217293
else
218294
{
219-
overlayDiagnostic.Errors.Add(new OpenApiError("Update", "Cannot merge non-object or non-array types"));
295+
overlayDiagnostic.Errors.Add(new OpenApiError("Update", "Cannot merge incompatible types"));
296+
}
297+
}
298+
299+
private static void ReplaceValueInParent(JsonNode target, JsonNode update)
300+
{
301+
var parent = target.Parent;
302+
if (parent is JsonObject parentObject)
303+
{
304+
// Find the property name for this target
305+
foreach (var kvp in parentObject)
306+
{
307+
if (kvp.Value == target)
308+
{
309+
parentObject[kvp.Key] = update.DeepClone();
310+
return;
311+
}
312+
}
313+
}
314+
else if (parent is JsonArray parentArray)
315+
{
316+
// Find the index for this target
317+
for (int i = 0; i < parentArray.Count; i++)
318+
{
319+
if (parentArray[i] == target)
320+
{
321+
parentArray[i] = update.DeepClone();
322+
return;
323+
}
324+
}
220325
}
221326
}
222327
}

src/lib/PublicAPI.Unshipped.txt

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,8 @@ BinkyLabs.OpenApi.Overlays.JsonNodeExtension.JsonNodeExtension(System.Text.Json.
1414
BinkyLabs.OpenApi.Overlays.JsonNodeExtension.Node.get -> System.Text.Json.Nodes.JsonNode!
1515
BinkyLabs.OpenApi.Overlays.JsonNodeExtension.Write(Microsoft.OpenApi.IOpenApiWriter! writer, BinkyLabs.OpenApi.Overlays.OverlaySpecVersion specVersion) -> void
1616
BinkyLabs.OpenApi.Overlays.OverlayAction
17+
BinkyLabs.OpenApi.Overlays.OverlayAction.Copy.get -> string?
18+
BinkyLabs.OpenApi.Overlays.OverlayAction.Copy.set -> void
1719
BinkyLabs.OpenApi.Overlays.OverlayAction.Description.get -> string?
1820
BinkyLabs.OpenApi.Overlays.OverlayAction.Description.set -> void
1921
BinkyLabs.OpenApi.Overlays.OverlayAction.Extensions.get -> System.Collections.Generic.IDictionary<string!, BinkyLabs.OpenApi.Overlays.IOverlayExtension!>?

src/lib/Reader/V1/OverlayActionDeserializer.cs

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,10 @@ internal static partial class OverlayV1Deserializer
1616
}
1717
}
1818
},
19-
{ "update", (o, v) => o.Update = v.CreateAny() }
19+
{ "update", (o, v) => o.Update = v.CreateAny() },
20+
#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.
21+
{ "x-copy", (o, v) => o.Copy = v.GetScalarValue() },
22+
#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.
2023
};
2124
public static readonly PatternFieldMap<OverlayAction> ActionPatternFields = new()
2225
{

0 commit comments

Comments
 (0)