diff --git a/README.md b/README.md index d93da67..fd25a31 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ # OpenAPI Overlay Library & CLI 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. +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 and 1.1.0 specification. The library enables developers to programmatically apply overlays, validate them, and generate updated OpenAPI documents without relying on third-party tools like Swagger. @@ -112,17 +112,7 @@ var jsonResult = textWriter.ToString(); 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" -} -``` +No experimental features are implemented at this moment. ## Release notes diff --git a/src/lib/Interfaces/IOverlayReader.cs b/src/lib/Interfaces/IOverlayReader.cs index a67634d..9fbcfbf 100644 --- a/src/lib/Interfaces/IOverlayReader.cs +++ b/src/lib/Interfaces/IOverlayReader.cs @@ -16,11 +16,10 @@ public interface IOverlayReader /// Async method to read the stream and parse it into an OpenAPI document. /// /// The stream input. - /// Location of where the document that is getting loaded is saved. /// The OpenApi reader settings. /// Propagates notification that an operation should be canceled. /// A task that represents the asynchronous operation, containing the read result. - Task ReadAsync(Stream input, Uri location, OverlayReaderSettings settings, CancellationToken cancellationToken = default); + Task ReadAsync(Stream input, OverlayReaderSettings settings, CancellationToken cancellationToken = default); /// /// Reads the stream and returns a JsonNode representation of the input. diff --git a/src/lib/Interfaces/IOverlaySerializable.cs b/src/lib/Interfaces/IOverlaySerializable.cs index 4f139b0..eb52e91 100644 --- a/src/lib/Interfaces/IOverlaySerializable.cs +++ b/src/lib/Interfaces/IOverlaySerializable.cs @@ -5,6 +5,10 @@ namespace BinkyLabs.OpenApi.Overlays; /// /// Represents an OpenAPI overlay element that comes with serialization functionality. /// +/// +/// This interface defines methods for serializing an object to different versions of the OpenAPI Overlay specification. +/// This interface should only be implemented by this library, and not by external consumers as we will add new methods without bumping major versions which will lead to source breaking changes. +/// public interface IOverlaySerializable { /// @@ -12,4 +16,9 @@ public interface IOverlaySerializable /// /// A Microsoft.OpenAPI writer void SerializeAsV1(IOpenApiWriter writer); + /// + /// Serializes the object to the OpenAPI Overlay v1.1 format. + /// + /// A Microsoft.OpenAPI writer + void SerializeAsV1_1(IOpenApiWriter writer); } \ No newline at end of file diff --git a/src/lib/Interfaces/IOverlayVersionService.cs b/src/lib/Interfaces/IOverlayVersionService.cs index 9c6d49a..2056f7a 100644 --- a/src/lib/Interfaces/IOverlayVersionService.cs +++ b/src/lib/Interfaces/IOverlayVersionService.cs @@ -24,7 +24,6 @@ internal interface IOverlayVersionService /// Converts a generic RootNode instance into a strongly typed OverlayDocument /// /// RootNode containing the information to be converted into an OpenAPI Document - /// Location of where the document that is getting loaded is saved /// Instance of OverlayDocument populated with data from rootNode - OverlayDocument LoadDocument(RootNode rootNode, Uri location); + OverlayDocument LoadDocument(RootNode rootNode); } \ No newline at end of file diff --git a/src/lib/Models/OverlayAction.cs b/src/lib/Models/OverlayAction.cs index 0d0502a..f331ddd 100644 --- a/src/lib/Models/OverlayAction.cs +++ b/src/lib/Models/OverlayAction.cs @@ -40,20 +40,17 @@ public class OverlayAction : IOverlaySerializable, IOverlayExtensible /// /// 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; } - /// - /// Serializes the action object as an OpenAPI Overlay v1.0.0 JSON object. - /// - /// The OpenAPI writer to use for serialization. - public void SerializeAsV1(IOpenApiWriter writer) + /// + public void SerializeAsV1(IOpenApiWriter writer) => SerializeInternal(writer, OverlaySpecVersion.Overlay1_0, "x-copy"); + /// + public void SerializeAsV1_1(IOpenApiWriter writer) => SerializeInternal(writer, OverlaySpecVersion.Overlay1_1, "copy"); + private void SerializeInternal(IOpenApiWriter writer, OverlaySpecVersion version, string copyFieldName) { writer.WriteStartObject(); writer.WriteRequiredProperty("target", Target); @@ -64,19 +61,15 @@ 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); + writer.WriteProperty(copyFieldName, 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.WriteOverlayExtensions(Extensions, version); 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. - private (bool, JsonPath?, PathResult?) ValidateBeforeApplying(JsonNode documentJsonNode, OverlayDiagnostic overlayDiagnostic, int index) { if (string.IsNullOrEmpty(Target)) @@ -193,7 +186,6 @@ private bool CopyNodes(PathResult parseResult, JsonNode documentJsonNode, Overla } 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}]"; diff --git a/src/lib/Models/OverlayDocument.cs b/src/lib/Models/OverlayDocument.cs index ff1869e..df77430 100644 --- a/src/lib/Models/OverlayDocument.cs +++ b/src/lib/Models/OverlayDocument.cs @@ -15,9 +15,9 @@ namespace BinkyLabs.OpenApi.Overlays; public class OverlayDocument : IOverlaySerializable, IOverlayExtensible { /// - /// Gets or sets the overlay version. Default is "1.0.0". + /// Gets or sets the overlay version. Default is "1.1.0". /// - public string? Overlay { get; internal set; } = "1.0.0"; + public string? Overlay { get; internal set; } = "1.1.0"; /// /// Gets or sets the overlay info object. @@ -37,26 +37,31 @@ public class OverlayDocument : IOverlaySerializable, IOverlayExtensible /// public IDictionary? Extensions { get; set; } - /// - /// Serializes the overlay document as an OpenAPI Overlay v1.0.0 JSON object. - /// - /// The OpenAPI writer to use for serialization. - public void SerializeAsV1(IOpenApiWriter writer) + /// + public void SerializeAsV1(IOpenApiWriter writer) => SerializeInternal(writer, OverlaySpecVersion.Overlay1_0, (w, obj) => obj.SerializeAsV1(w)); + /// + public void SerializeAsV1_1(IOpenApiWriter writer) => SerializeInternal(writer, OverlaySpecVersion.Overlay1_1, (w, obj) => obj.SerializeAsV1_1(w)); + private void SerializeInternal(IOpenApiWriter writer, OverlaySpecVersion version, Action serializeAction) { writer.WriteStartObject(); - writer.WriteRequiredProperty("overlay", "1.0.0"); + writer.WriteRequiredProperty("overlay", SpecVersionToStringMap[version]); if (Info != null) { - writer.WriteRequiredObject("info", Info, (w, obj) => obj.SerializeAsV1(w)); + writer.WriteRequiredObject("info", Info, serializeAction); } writer.WriteProperty("extends", Extends); if (Actions != null) { - writer.WriteRequiredCollection("actions", Actions, (w, action) => action.SerializeAsV1(w)); + writer.WriteRequiredCollection("actions", Actions, serializeAction); } - writer.WriteOverlayExtensions(Extensions, OverlaySpecVersion.Overlay1_0); + writer.WriteOverlayExtensions(Extensions, version); writer.WriteEndObject(); } + private static readonly Dictionary SpecVersionToStringMap = new() + { + { OverlaySpecVersion.Overlay1_0, "1.0.0" }, + { OverlaySpecVersion.Overlay1_1, "1.1.0" }, + }; /// /// Parses a local file path or Url into an Open API document. diff --git a/src/lib/Models/OverlayInfo.cs b/src/lib/Models/OverlayInfo.cs index 3ebb254..a4ee4c7 100644 --- a/src/lib/Models/OverlayInfo.cs +++ b/src/lib/Models/OverlayInfo.cs @@ -19,19 +19,32 @@ public class OverlayInfo : IOverlaySerializable, IOverlayExtensible /// public string? Version { get; set; } + /// + /// Gets or sets the description of the overlay. + /// + public string? Description { get; set; } + /// public IDictionary? Extensions { get; set; } - /// - /// Serializes the info object as an OpenAPI Overlay v1.0.0 JSON object. - /// - /// The OpenAPI writer to use for serialization. - public void SerializeAsV1(IOpenApiWriter writer) + /// + public void SerializeAsV1(IOpenApiWriter writer) => SerializeInternal(writer, OverlaySpecVersion.Overlay1_0); + /// + public void SerializeAsV1_1(IOpenApiWriter writer) => SerializeInternal(writer, OverlaySpecVersion.Overlay1_1); + private void SerializeInternal(IOpenApiWriter writer, OverlaySpecVersion version) { writer.WriteStartObject(); writer.WriteProperty("title", Title); writer.WriteProperty("version", Version); - writer.WriteOverlayExtensions(Extensions, OverlaySpecVersion.Overlay1_0); + + // Handle version-specific description field name + if (Description != null) + { + var descriptionFieldName = version == OverlaySpecVersion.Overlay1_0 ? "x-description" : "description"; + writer.WriteProperty(descriptionFieldName, Description); + } + + writer.WriteOverlayExtensions(Extensions, version); writer.WriteEndObject(); } } \ No newline at end of file diff --git a/src/lib/Models/OverlayVersion.cs b/src/lib/Models/OverlayVersion.cs index be82b58..b3bc6fd 100644 --- a/src/lib/Models/OverlayVersion.cs +++ b/src/lib/Models/OverlayVersion.cs @@ -11,4 +11,9 @@ public enum OverlaySpecVersion /// See: https://spec.openapis.org/overlay/v1.0.0.html /// Overlay1_0 = 1, + /// + /// The OpenAPI Overlay version 1.1.0 + /// See: https://spec.openapis.org/overlay/v1.1.0.html + /// + Overlay1_1 = 2 } \ No newline at end of file diff --git a/src/lib/PublicAPI.Unshipped.txt b/src/lib/PublicAPI.Unshipped.txt index e2d2d2e..f8e8d15 100644 --- a/src/lib/PublicAPI.Unshipped.txt +++ b/src/lib/PublicAPI.Unshipped.txt @@ -6,9 +6,10 @@ BinkyLabs.OpenApi.Overlays.IOverlayExtension BinkyLabs.OpenApi.Overlays.IOverlayExtension.Write(Microsoft.OpenApi.IOpenApiWriter! writer, BinkyLabs.OpenApi.Overlays.OverlaySpecVersion specVersion) -> void BinkyLabs.OpenApi.Overlays.IOverlayReader BinkyLabs.OpenApi.Overlays.IOverlayReader.GetJsonNodeFromStreamAsync(System.IO.Stream! input, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task! -BinkyLabs.OpenApi.Overlays.IOverlayReader.ReadAsync(System.IO.Stream! input, System.Uri! location, BinkyLabs.OpenApi.Overlays.OverlayReaderSettings! settings, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task! +BinkyLabs.OpenApi.Overlays.IOverlayReader.ReadAsync(System.IO.Stream! input, BinkyLabs.OpenApi.Overlays.OverlayReaderSettings! settings, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task! BinkyLabs.OpenApi.Overlays.IOverlaySerializable BinkyLabs.OpenApi.Overlays.IOverlaySerializable.SerializeAsV1(Microsoft.OpenApi.IOpenApiWriter! writer) -> void +BinkyLabs.OpenApi.Overlays.IOverlaySerializable.SerializeAsV1_1(Microsoft.OpenApi.IOpenApiWriter! writer) -> void BinkyLabs.OpenApi.Overlays.JsonNodeExtension BinkyLabs.OpenApi.Overlays.JsonNodeExtension.JsonNodeExtension(System.Text.Json.Nodes.JsonNode! jsonNode) -> void BinkyLabs.OpenApi.Overlays.JsonNodeExtension.Node.get -> System.Text.Json.Nodes.JsonNode! @@ -24,6 +25,7 @@ BinkyLabs.OpenApi.Overlays.OverlayAction.OverlayAction() -> void BinkyLabs.OpenApi.Overlays.OverlayAction.Remove.get -> bool? BinkyLabs.OpenApi.Overlays.OverlayAction.Remove.set -> void BinkyLabs.OpenApi.Overlays.OverlayAction.SerializeAsV1(Microsoft.OpenApi.IOpenApiWriter! writer) -> void +BinkyLabs.OpenApi.Overlays.OverlayAction.SerializeAsV1_1(Microsoft.OpenApi.IOpenApiWriter! writer) -> void BinkyLabs.OpenApi.Overlays.OverlayAction.Target.get -> string? BinkyLabs.OpenApi.Overlays.OverlayAction.Target.set -> void BinkyLabs.OpenApi.Overlays.OverlayAction.Update.get -> System.Text.Json.Nodes.JsonNode? @@ -66,6 +68,7 @@ BinkyLabs.OpenApi.Overlays.OverlayDocument.Info.set -> void BinkyLabs.OpenApi.Overlays.OverlayDocument.Overlay.get -> string? BinkyLabs.OpenApi.Overlays.OverlayDocument.OverlayDocument() -> void BinkyLabs.OpenApi.Overlays.OverlayDocument.SerializeAsV1(Microsoft.OpenApi.IOpenApiWriter! writer) -> void +BinkyLabs.OpenApi.Overlays.OverlayDocument.SerializeAsV1_1(Microsoft.OpenApi.IOpenApiWriter! writer) -> void BinkyLabs.OpenApi.Overlays.OverlayException BinkyLabs.OpenApi.Overlays.OverlayException.OverlayException() -> void BinkyLabs.OpenApi.Overlays.OverlayException.OverlayException(string! message) -> void @@ -74,10 +77,13 @@ BinkyLabs.OpenApi.Overlays.OverlayException.Pointer.get -> string? BinkyLabs.OpenApi.Overlays.OverlayException.Pointer.set -> void BinkyLabs.OpenApi.Overlays.OverlayExtensibleExtensions BinkyLabs.OpenApi.Overlays.OverlayInfo +BinkyLabs.OpenApi.Overlays.OverlayInfo.Description.get -> string? +BinkyLabs.OpenApi.Overlays.OverlayInfo.Description.set -> void BinkyLabs.OpenApi.Overlays.OverlayInfo.Extensions.get -> System.Collections.Generic.IDictionary? BinkyLabs.OpenApi.Overlays.OverlayInfo.Extensions.set -> void BinkyLabs.OpenApi.Overlays.OverlayInfo.OverlayInfo() -> void BinkyLabs.OpenApi.Overlays.OverlayInfo.SerializeAsV1(Microsoft.OpenApi.IOpenApiWriter! writer) -> void +BinkyLabs.OpenApi.Overlays.OverlayInfo.SerializeAsV1_1(Microsoft.OpenApi.IOpenApiWriter! writer) -> void BinkyLabs.OpenApi.Overlays.OverlayInfo.Title.get -> string? BinkyLabs.OpenApi.Overlays.OverlayInfo.Title.set -> void BinkyLabs.OpenApi.Overlays.OverlayInfo.Version.get -> string? @@ -85,7 +91,7 @@ BinkyLabs.OpenApi.Overlays.OverlayInfo.Version.set -> void BinkyLabs.OpenApi.Overlays.OverlayJsonReader BinkyLabs.OpenApi.Overlays.OverlayJsonReader.GetJsonNodeFromStreamAsync(System.IO.Stream! input, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task! BinkyLabs.OpenApi.Overlays.OverlayJsonReader.OverlayJsonReader() -> void -BinkyLabs.OpenApi.Overlays.OverlayJsonReader.ReadAsync(System.IO.Stream! input, System.Uri! location, BinkyLabs.OpenApi.Overlays.OverlayReaderSettings! settings, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task! +BinkyLabs.OpenApi.Overlays.OverlayJsonReader.ReadAsync(System.IO.Stream! input, BinkyLabs.OpenApi.Overlays.OverlayReaderSettings! settings, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task! BinkyLabs.OpenApi.Overlays.OverlayReaderException BinkyLabs.OpenApi.Overlays.OverlayReaderException.OverlayReaderException() -> void BinkyLabs.OpenApi.Overlays.OverlayReaderException.OverlayReaderException(string! message) -> void @@ -104,10 +110,11 @@ BinkyLabs.OpenApi.Overlays.OverlayReaderSettings.Readers.init -> void BinkyLabs.OpenApi.Overlays.OverlayReaderSettings.TryAddReader(string! format, BinkyLabs.OpenApi.Overlays.IOverlayReader! reader) -> bool BinkyLabs.OpenApi.Overlays.OverlaySpecVersion BinkyLabs.OpenApi.Overlays.OverlaySpecVersion.Overlay1_0 = 1 -> BinkyLabs.OpenApi.Overlays.OverlaySpecVersion +BinkyLabs.OpenApi.Overlays.OverlaySpecVersion.Overlay1_1 = 2 -> BinkyLabs.OpenApi.Overlays.OverlaySpecVersion BinkyLabs.OpenApi.Overlays.OverlayYamlReader BinkyLabs.OpenApi.Overlays.OverlayYamlReader.GetJsonNodeFromStreamAsync(System.IO.Stream! input, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task! BinkyLabs.OpenApi.Overlays.OverlayYamlReader.OverlayYamlReader() -> void -BinkyLabs.OpenApi.Overlays.OverlayYamlReader.ReadAsync(System.IO.Stream! input, System.Uri! location, BinkyLabs.OpenApi.Overlays.OverlayReaderSettings! settings, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task! +BinkyLabs.OpenApi.Overlays.OverlayYamlReader.ReadAsync(System.IO.Stream! input, BinkyLabs.OpenApi.Overlays.OverlayReaderSettings! settings, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task! BinkyLabs.OpenApi.Overlays.Reader.OverlayDiagnostic BinkyLabs.OpenApi.Overlays.Reader.OverlayDiagnostic.Errors.get -> System.Collections.Generic.IList! BinkyLabs.OpenApi.Overlays.Reader.OverlayDiagnostic.Errors.set -> void @@ -127,7 +134,7 @@ BinkyLabs.OpenApi.Overlays.Reader.ParsingContext.ExtensionParsers.get -> System. BinkyLabs.OpenApi.Overlays.Reader.ParsingContext.ExtensionParsers.set -> void BinkyLabs.OpenApi.Overlays.Reader.ParsingContext.GetFromTempStorage(string! key, object? scope = null) -> T? BinkyLabs.OpenApi.Overlays.Reader.ParsingContext.GetLocation() -> string! -BinkyLabs.OpenApi.Overlays.Reader.ParsingContext.Parse(System.Text.Json.Nodes.JsonNode! jsonNode, System.Uri! location) -> BinkyLabs.OpenApi.Overlays.OverlayDocument! +BinkyLabs.OpenApi.Overlays.Reader.ParsingContext.Parse(System.Text.Json.Nodes.JsonNode! jsonNode) -> BinkyLabs.OpenApi.Overlays.OverlayDocument! BinkyLabs.OpenApi.Overlays.Reader.ParsingContext.ParseFragment(System.Text.Json.Nodes.JsonNode! jsonNode, BinkyLabs.OpenApi.Overlays.OverlaySpecVersion version) -> T? BinkyLabs.OpenApi.Overlays.Reader.ParsingContext.ParsingContext(BinkyLabs.OpenApi.Overlays.Reader.OverlayDiagnostic! diagnostic) -> void BinkyLabs.OpenApi.Overlays.Reader.ParsingContext.PopLoop(string! loopid) -> void diff --git a/src/lib/Reader/BaseOverlayVersionService.cs b/src/lib/Reader/BaseOverlayVersionService.cs new file mode 100644 index 0000000..d3a3b78 --- /dev/null +++ b/src/lib/Reader/BaseOverlayVersionService.cs @@ -0,0 +1,38 @@ +// Licensed under the MIT license. + +using Microsoft.OpenApi; + +namespace BinkyLabs.OpenApi.Overlays.Reader; + +/// +/// Base class for overlay version services providing common functionality. +/// +internal abstract class BaseOverlayVersionService : IOverlayVersionService +{ + /// + /// Dictionary of type loaders for different overlay elements. + /// + protected abstract Dictionary> Loaders { get; } + + /// + /// Loads an OpenAPI Element from a document fragment + /// + /// Type of element to load + /// document fragment node + /// Instance of OpenAPIElement + public T? LoadElement(ParseNode node) where T : IOpenApiElement + { + if (Loaders.TryGetValue(typeof(T), out var loader) && loader(node) is T result) + { + return result; + } + return default; + } + + /// + /// Converts a generic RootNode instance into a strongly typed OverlayDocument + /// + /// RootNode containing the information to be converted into an OpenAPI Document + /// Instance of OverlayDocument populated with data from rootNode + public abstract OverlayDocument LoadDocument(RootNode rootNode); +} \ No newline at end of file diff --git a/src/lib/Reader/OverlayJsonReader.cs b/src/lib/Reader/OverlayJsonReader.cs index 851c14f..9d2e442 100644 --- a/src/lib/Reader/OverlayJsonReader.cs +++ b/src/lib/Reader/OverlayJsonReader.cs @@ -19,11 +19,9 @@ public class OverlayJsonReader : IOverlayReader /// Parses the JsonNode input into an Open API document. /// /// The JsonNode input. - /// Location of where the document that is getting loaded is saved /// The Reader settings to be used during parsing. /// internal ReadResult Read(JsonNode jsonNode, - Uri location, OverlayReaderSettings settings) { ArgumentNullException.ThrowIfNull(jsonNode); @@ -41,7 +39,7 @@ internal ReadResult Read(JsonNode jsonNode, try { // Parse the OpenAPI Document - document = context.Parse(jsonNode, location); + document = context.Parse(jsonNode); } catch (OpenApiException ex) { @@ -76,12 +74,10 @@ internal ReadResult Read(JsonNode jsonNode, /// Reads the stream input asynchronously and parses it into an Open API document. /// /// Memory stream containing OpenAPI description to parse. - /// Location of where the document that is getting loaded is saved /// The Reader settings to be used during parsing. /// Propagates notifications that operations should be cancelled. /// public async Task ReadAsync(Stream input, - Uri location, OverlayReaderSettings settings, CancellationToken cancellationToken = default) { @@ -107,7 +103,7 @@ public async Task ReadAsync(Stream input, }; } - return Read(jsonNode, location, settings); + return Read(jsonNode, settings); } /// diff --git a/src/lib/Reader/OverlayModelFactory.cs b/src/lib/Reader/OverlayModelFactory.cs index a4cddfa..7428ad4 100644 --- a/src/lib/Reader/OverlayModelFactory.cs +++ b/src/lib/Reader/OverlayModelFactory.cs @@ -91,27 +91,12 @@ public static async Task ParseAsync(string input, private static readonly Lazy DefaultReaderSettings = new(() => new OverlayReaderSettings()); - private static async Task InternalLoadAsync(Stream input, string format, OverlayReaderSettings settings, CancellationToken cancellationToken = default) + private static Task InternalLoadAsync(Stream input, string format, OverlayReaderSettings settings, CancellationToken cancellationToken = default) { settings ??= DefaultReaderSettings.Value; var reader = settings.GetReader(format); - // Handle URI creation more safely for file paths - Uri location; - if (input is FileStream fileStream) - { - // Convert to absolute path and then create a file URI to handle relative paths correctly - var absolutePath = Path.GetFullPath(fileStream.Name); - location = new Uri(absolutePath, UriKind.Absolute); - } - else - { - location = new Uri(OpenApiConstants.BaseRegistryUri); - } - - var readResult = await reader.ReadAsync(input, location, settings, cancellationToken).ConfigureAwait(false); - - return readResult; + return reader.ReadAsync(input, settings, cancellationToken); } private static async Task<(Stream, string?)> RetrieveStreamAndFormatAsync(string url, OverlayReaderSettings settings, CancellationToken token = default) diff --git a/src/lib/Reader/OverlayYamlReader.cs b/src/lib/Reader/OverlayYamlReader.cs index 2779b7f..3233abd 100644 --- a/src/lib/Reader/OverlayYamlReader.cs +++ b/src/lib/Reader/OverlayYamlReader.cs @@ -19,7 +19,6 @@ public class OverlayYamlReader : IOverlayReader private const int copyBufferSize = 4096; private static readonly OverlayJsonReader _jsonReader = new(); private ReadResult Read(MemoryStream input, - Uri location, OverlayReaderSettings settings) { ArgumentNullException.ThrowIfNull(input); @@ -49,40 +48,37 @@ private ReadResult Read(MemoryStream input, }; } - return Read(jsonNode, location, settings); + return Read(jsonNode, settings); } private ReadResult Read(JsonNode jsonNode, - Uri location, OverlayReaderSettings settings) { - return _jsonReader.Read(jsonNode, location, settings); + return _jsonReader.Read(jsonNode, settings); } /// /// Reads the stream input asynchronously and parses it into an Open API document. /// /// Memory stream containing OpenAPI description to parse. - /// Location of where the document that is getting loaded is saved /// The Reader settings to be used during parsing. /// Propagates notifications that operations should be cancelled. /// public async Task ReadAsync(Stream input, - Uri location, OverlayReaderSettings settings, CancellationToken cancellationToken = default) { ArgumentNullException.ThrowIfNull(input); if (input is MemoryStream memoryStream) { - return Read(memoryStream, location, settings); + return Read(memoryStream, settings); } else { using var preparedStream = new MemoryStream(); await input.CopyToAsync(preparedStream, copyBufferSize, cancellationToken).ConfigureAwait(false); preparedStream.Position = 0; - return Read(preparedStream, location, settings); + return Read(preparedStream, settings); } } diff --git a/src/lib/Reader/ParseNodes/FixedFieldMap.cs b/src/lib/Reader/ParseNodes/FixedFieldMap.cs index f660748..047745a 100644 --- a/src/lib/Reader/ParseNodes/FixedFieldMap.cs +++ b/src/lib/Reader/ParseNodes/FixedFieldMap.cs @@ -1,12 +1,21 @@  // Licensed under the MIT license. -using System; -using System.Collections.Generic; - namespace BinkyLabs.OpenApi.Overlays.Reader { internal class FixedFieldMap : Dictionary> { + public FixedFieldMap() : base() + { + + } + public FixedFieldMap(FixedFieldMap source) : base(source) + { + + } + public FixedFieldMap(FixedFieldMap source, HashSet except) : base(source.Where(kv => !except.Contains(kv.Key))) + { + + } } } \ No newline at end of file diff --git a/src/lib/Reader/ParsingContext.cs b/src/lib/Reader/ParsingContext.cs index 50904f7..aeaaaeb 100644 --- a/src/lib/Reader/ParsingContext.cs +++ b/src/lib/Reader/ParsingContext.cs @@ -1,15 +1,12 @@  // Licensed under the MIT license. -using System; -using System.Collections.Generic; -using System.Linq; using System.Text.Json.Nodes; using BinkyLabs.OpenApi.Overlays.Reader.V1; +using BinkyLabs.OpenApi.Overlays.Reader.V1_1; using Microsoft.OpenApi; -using Microsoft.OpenApi.Reader; namespace BinkyLabs.OpenApi.Overlays.Reader; @@ -54,14 +51,14 @@ public ParsingContext(OverlayDiagnostic diagnostic) Diagnostic = diagnostic; } private const string OverlayV1Version = "1.0.0"; + private const string OverlayV1_1Version = "1.1.0"; /// /// Initiates the parsing process. Not thread safe and should only be called once on a parsing context /// /// Set of Json nodes to parse. - /// Location of where the document that is getting loaded is saved /// An OverlayDocument populated based on the passed yamlDocument - public OverlayDocument Parse(JsonNode jsonNode, Uri location) + public OverlayDocument Parse(JsonNode jsonNode) { RootNode = new RootNode(this, jsonNode); @@ -72,11 +69,17 @@ public OverlayDocument Parse(JsonNode jsonNode, Uri location) switch (inputVersion) { case string version when OverlayV1Version.Equals(version, StringComparison.OrdinalIgnoreCase): - VersionService = new OverlayV1VersionService(Diagnostic); - doc = VersionService.LoadDocument(RootNode, location); + VersionService = new OverlayV1VersionService(); + doc = VersionService.LoadDocument(RootNode); this.Diagnostic.SpecificationVersion = OverlaySpecVersion.Overlay1_0; ValidateRequiredFields(doc, version); break; + case string version when OverlayV1_1Version.Equals(version, StringComparison.OrdinalIgnoreCase): + VersionService = new OverlayV1_1VersionService(); + doc = VersionService.LoadDocument(RootNode); + this.Diagnostic.SpecificationVersion = OverlaySpecVersion.Overlay1_1; + ValidateRequiredFields(doc, version); + break; default: throw new OpenApiUnsupportedSpecVersionException(inputVersion); @@ -100,7 +103,11 @@ public OverlayDocument Parse(JsonNode jsonNode, Uri location) switch (version) { case OverlaySpecVersion.Overlay1_0: - VersionService = new OverlayV1VersionService(Diagnostic); + VersionService = new OverlayV1VersionService(); + element = this.VersionService.LoadElement(node); + break; + case OverlaySpecVersion.Overlay1_1: + VersionService = new OverlayV1_1VersionService(); element = this.VersionService.LoadElement(node); break; default: diff --git a/src/lib/Reader/V1/OverlayActionDeserializer.cs b/src/lib/Reader/V1/OverlayActionDeserializer.cs index 6ff0786..9a30aaf 100644 --- a/src/lib/Reader/V1/OverlayActionDeserializer.cs +++ b/src/lib/Reader/V1/OverlayActionDeserializer.cs @@ -17,19 +17,20 @@ internal static partial class OverlayV1Deserializer } }, { "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() + public static PatternFieldMap GetActionPatternFields(OverlaySpecVersion version) => + new() { - {s => s.StartsWith(OverlayConstants.ExtensionFieldNamePrefix, StringComparison.OrdinalIgnoreCase), (o, k, n) => o.AddExtension(k,LoadExtension(k, n))} + {s => s.StartsWith(OverlayConstants.ExtensionFieldNamePrefix, StringComparison.OrdinalIgnoreCase), (o, k, n) => o.AddExtension(k,LoadExtension(k, n, version))} }; - public static OverlayAction LoadAction(ParseNode node) + public static readonly PatternFieldMap ActionPatternFields = GetActionPatternFields(OverlaySpecVersion.Overlay1_0); + public static OverlayAction LoadAction(ParseNode node) => LoadActionInternal(node, ActionFixedFields, ActionPatternFields); + public static OverlayAction LoadActionInternal(ParseNode node, FixedFieldMap actionFixedFields, PatternFieldMap actionPatternFields) { var mapNode = node.CheckMapNode("Action"); var action = new OverlayAction(); - ParseMap(mapNode, action, ActionFixedFields, ActionPatternFields); + ParseMap(mapNode, action, actionFixedFields, actionPatternFields); return action; } diff --git a/src/lib/Reader/V1/OverlayDocumentDeserializer.cs b/src/lib/Reader/V1/OverlayDocumentDeserializer.cs index 4567ed6..7c4885c 100644 --- a/src/lib/Reader/V1/OverlayDocumentDeserializer.cs +++ b/src/lib/Reader/V1/OverlayDocumentDeserializer.cs @@ -11,22 +11,19 @@ internal static partial class OverlayV1Deserializer { "info", (o, v) => o.Info = LoadInfo(v) }, { "actions", (o, v) => o.Actions = v.CreateList(LoadAction) } }; - public static readonly PatternFieldMap DocumentPatternFields = new() + public static PatternFieldMap GetDocumentPatternFields(OverlaySpecVersion version) => + new() { - {s => s.StartsWith(OverlayConstants.ExtensionFieldNamePrefix, StringComparison.OrdinalIgnoreCase), (o, k, n) => o.AddExtension(k,LoadExtension(k, n))} + {s => s.StartsWith(OverlayConstants.ExtensionFieldNamePrefix, StringComparison.OrdinalIgnoreCase), (o, k, n) => o.AddExtension(k, LoadExtension(k, n, version))} }; - public static OverlayDocument LoadOverlayDocument(RootNode rootNode, Uri location) - { - var document = new OverlayDocument(); - ParseMap(rootNode.GetMap(), document, DocumentFixedFields, DocumentPatternFields); - return document; - } - public static OverlayDocument LoadDocument(ParseNode node) + public static readonly PatternFieldMap DocumentPatternFields = GetDocumentPatternFields(OverlaySpecVersion.Overlay1_0); + public static OverlayDocument LoadDocument(ParseNode node) => LoadDocumentInternal(node, DocumentFixedFields, DocumentPatternFields); + public static OverlayDocument LoadDocumentInternal(ParseNode node, FixedFieldMap documentFixedFields, PatternFieldMap documentPatternFields) { var mapNode = node.CheckMapNode("Document"); - var info = new OverlayDocument(); - ParseMap(mapNode, info, DocumentFixedFields, DocumentPatternFields); + var document = new OverlayDocument(); + ParseMap(mapNode, document, documentFixedFields, documentPatternFields); - return info; + return document; } } \ No newline at end of file diff --git a/src/lib/Reader/V1/OverlayInfoDeserializer.cs b/src/lib/Reader/V1/OverlayInfoDeserializer.cs index 64de2fb..259a72d 100644 --- a/src/lib/Reader/V1/OverlayInfoDeserializer.cs +++ b/src/lib/Reader/V1/OverlayInfoDeserializer.cs @@ -7,17 +7,21 @@ internal static partial class OverlayV1Deserializer public static readonly FixedFieldMap InfoFixedFields = new() { { "title", (o, v) => o.Title = v.GetScalarValue() }, - { "version", (o, v) => o.Version = v.GetScalarValue() } + { "version", (o, v) => o.Version = v.GetScalarValue() }, + { "x-description", (o, v) => o.Description = v.GetScalarValue() } }; - public static readonly PatternFieldMap InfoPatternFields = new() + public static PatternFieldMap GetInfoPatternFields(OverlaySpecVersion version) => + new() { - {s => s.StartsWith(OverlayConstants.ExtensionFieldNamePrefix, StringComparison.OrdinalIgnoreCase), (o, k, n) => o.AddExtension(k,LoadExtension(k, n))} + {s => s.StartsWith(OverlayConstants.ExtensionFieldNamePrefix, StringComparison.OrdinalIgnoreCase), (o, k, n) => o.AddExtension(k, LoadExtension(k, n, version))} }; - public static OverlayInfo LoadInfo(ParseNode node) + public static readonly PatternFieldMap InfoPatternFields = GetInfoPatternFields(OverlaySpecVersion.Overlay1_0); + public static OverlayInfo LoadInfo(ParseNode node) => LoadInfoInternal(node, InfoFixedFields, InfoPatternFields); + public static OverlayInfo LoadInfoInternal(ParseNode node, FixedFieldMap infoFixedFields, PatternFieldMap infoPatternFields) { var mapNode = node.CheckMapNode("Info"); var info = new OverlayInfo(); - ParseMap(mapNode, info, InfoFixedFields, InfoPatternFields); + ParseMap(mapNode, info, infoFixedFields, infoPatternFields); return info; } diff --git a/src/lib/Reader/V1/OverlayV1Deserializer.cs b/src/lib/Reader/V1/OverlayV1Deserializer.cs index 8b1756c..eb9b511 100644 --- a/src/lib/Reader/V1/OverlayV1Deserializer.cs +++ b/src/lib/Reader/V1/OverlayV1Deserializer.cs @@ -1,12 +1,10 @@ using System.Text.Json.Nodes; -using Microsoft.OpenApi; - namespace BinkyLabs.OpenApi.Overlays.Reader.V1; internal static partial class OverlayV1Deserializer { - private static void ParseMap( + internal static void ParseMap( MapNode? mapNode, T domainObject, FixedFieldMap fixedFieldMap, @@ -27,10 +25,10 @@ public static JsonNode LoadAny(ParseNode node) { return node.CreateAny(); } - private static IOverlayExtension LoadExtension(string name, ParseNode node) + private static IOverlayExtension LoadExtension(string name, ParseNode node, OverlaySpecVersion version) { if (node.Context.ExtensionParsers is not null && node.Context.ExtensionParsers.TryGetValue(name, out var parser) && parser( - node.CreateAny(), OverlaySpecVersion.Overlay1_0) is { } result) + node.CreateAny(), version) is { } result) { return result; } diff --git a/src/lib/Reader/V1/OverlayV1VersionService.cs b/src/lib/Reader/V1/OverlayV1VersionService.cs index 836089c..0aeb1e8 100644 --- a/src/lib/Reader/V1/OverlayV1VersionService.cs +++ b/src/lib/Reader/V1/OverlayV1VersionService.cs @@ -1,29 +1,16 @@  // Licensed under the MIT license. -using System; -using System.Collections.Generic; - using Microsoft.OpenApi; -using Microsoft.OpenApi.Reader; namespace BinkyLabs.OpenApi.Overlays.Reader.V1; /// /// The version service for the Overlay 1.0 specification. /// -internal class OverlayV1VersionService : IOverlayVersionService +internal class OverlayV1VersionService : BaseOverlayVersionService { - - /// - /// Create Parsing Context - /// - /// Provide instance for diagnostic object for collecting and accessing information about the parsing. - public OverlayV1VersionService(OverlayDiagnostic diagnostic) - { - } - - private readonly Dictionary> _loaders = new Dictionary> + private static readonly Dictionary> _loaders = new() { [typeof(JsonNodeExtension)] = OverlayV1Deserializer.LoadAny, [typeof(OverlayAction)] = OverlayV1Deserializer.LoadAction, @@ -31,19 +18,10 @@ public OverlayV1VersionService(OverlayDiagnostic diagnostic) [typeof(OverlayInfo)] = OverlayV1Deserializer.LoadInfo, }; - public OverlayDocument LoadDocument(RootNode rootNode, Uri location) - { - return OverlayV1Deserializer.LoadOverlayDocument(rootNode, location); - } + protected override Dictionary> Loaders => _loaders; - public T? LoadElement(ParseNode node) where T : IOpenApiElement + public override OverlayDocument LoadDocument(RootNode rootNode) { - if (Loaders.TryGetValue(typeof(T), out var loader) && loader(node) is T result) - { - return result; - } - return default; + return OverlayV1Deserializer.LoadDocument(rootNode.GetMap()); } - - internal Dictionary> Loaders => _loaders; } \ No newline at end of file diff --git a/src/lib/Reader/V1_1/OverlayActionDeserializer.cs b/src/lib/Reader/V1_1/OverlayActionDeserializer.cs new file mode 100644 index 0000000..9ec8107 --- /dev/null +++ b/src/lib/Reader/V1_1/OverlayActionDeserializer.cs @@ -0,0 +1,13 @@ +using BinkyLabs.OpenApi.Overlays.Reader.V1; + +namespace BinkyLabs.OpenApi.Overlays.Reader.V1_1; + +internal static partial class OverlayV1_1Deserializer +{ + public static readonly FixedFieldMap ActionFixedFields = new(OverlayV1Deserializer.ActionFixedFields, ["x-copy"]) + { + { "copy", (o, v) => o.Copy = v.GetScalarValue() }, + }; + public static readonly PatternFieldMap ActionPatternFields = OverlayV1Deserializer.GetActionPatternFields(OverlaySpecVersion.Overlay1_1); + public static OverlayAction LoadAction(ParseNode node) => OverlayV1Deserializer.LoadActionInternal(node, ActionFixedFields, ActionPatternFields); +} \ No newline at end of file diff --git a/src/lib/Reader/V1_1/OverlayDocumentDeserializer.cs b/src/lib/Reader/V1_1/OverlayDocumentDeserializer.cs new file mode 100644 index 0000000..f74c288 --- /dev/null +++ b/src/lib/Reader/V1_1/OverlayDocumentDeserializer.cs @@ -0,0 +1,14 @@ +using BinkyLabs.OpenApi.Overlays.Reader.V1; + +namespace BinkyLabs.OpenApi.Overlays.Reader.V1_1; + +internal static partial class OverlayV1_1Deserializer +{ + public static readonly FixedFieldMap DocumentFixedFields = new(OverlayV1Deserializer.DocumentFixedFields, ["info", "actions"]) + { + { "info", (o, v) => o.Info = LoadInfo(v) }, + { "actions", (o, v) => o.Actions = v.CreateList(LoadAction) } + }; + public static readonly PatternFieldMap DocumentPatternFields = OverlayV1Deserializer.GetDocumentPatternFields(OverlaySpecVersion.Overlay1_1); + public static OverlayDocument LoadDocument(ParseNode node) => OverlayV1Deserializer.LoadDocumentInternal(node, DocumentFixedFields, DocumentPatternFields); +} \ No newline at end of file diff --git a/src/lib/Reader/V1_1/OverlayInfoDeserializer.cs b/src/lib/Reader/V1_1/OverlayInfoDeserializer.cs new file mode 100644 index 0000000..a001f7b --- /dev/null +++ b/src/lib/Reader/V1_1/OverlayInfoDeserializer.cs @@ -0,0 +1,15 @@ +using BinkyLabs.OpenApi.Overlays.Reader.V1; + +using Microsoft.OpenApi; + +namespace BinkyLabs.OpenApi.Overlays.Reader.V1_1; + +internal static partial class OverlayV1_1Deserializer +{ + public static readonly FixedFieldMap InfoFixedFields = new(OverlayV1Deserializer.InfoFixedFields, ["x-description"]) + { + { "description", (o, v) => o.Description = v.GetScalarValue() } + }; + public static readonly PatternFieldMap InfoPatternFields = OverlayV1Deserializer.GetInfoPatternFields(OverlaySpecVersion.Overlay1_1); + public static OverlayInfo LoadInfo(ParseNode node) => OverlayV1Deserializer.LoadInfoInternal(node, InfoFixedFields, InfoPatternFields); +} \ No newline at end of file diff --git a/src/lib/Reader/V1_1/OverlayV1_1VersionService.cs b/src/lib/Reader/V1_1/OverlayV1_1VersionService.cs new file mode 100644 index 0000000..6237512 --- /dev/null +++ b/src/lib/Reader/V1_1/OverlayV1_1VersionService.cs @@ -0,0 +1,29 @@ + +// Licensed under the MIT license. + +using BinkyLabs.OpenApi.Overlays.Reader.V1; + +using Microsoft.OpenApi; + +namespace BinkyLabs.OpenApi.Overlays.Reader.V1_1; + +/// +/// The version service for the Overlay 1.1 specification. +/// +internal class OverlayV1_1VersionService : BaseOverlayVersionService +{ + private static readonly Dictionary> _loaders = new() + { + [typeof(JsonNodeExtension)] = OverlayV1Deserializer.LoadAny, + [typeof(OverlayAction)] = OverlayV1_1Deserializer.LoadAction, + [typeof(OverlayDocument)] = OverlayV1_1Deserializer.LoadDocument, + [typeof(OverlayInfo)] = OverlayV1_1Deserializer.LoadInfo, + }; + + protected override Dictionary> Loaders => _loaders; + + public override OverlayDocument LoadDocument(RootNode rootNode) + { + return OverlayV1_1Deserializer.LoadDocument(rootNode.GetMap()); + } +} \ No newline at end of file diff --git a/tests/lib/Serialization/OverlayInfoTests.cs b/tests/lib/Serialization/OverlayInfoTests.cs deleted file mode 100644 index 9ca33e3..0000000 --- a/tests/lib/Serialization/OverlayInfoTests.cs +++ /dev/null @@ -1,68 +0,0 @@ -using System.Text.Json.Nodes; - -using BinkyLabs.OpenApi.Overlays.Reader; -using BinkyLabs.OpenApi.Overlays.Reader.V1; - -using Microsoft.OpenApi; -using Microsoft.OpenApi.Reader; - -using ParsingContext = BinkyLabs.OpenApi.Overlays.Reader.ParsingContext; - -namespace BinkyLabs.OpenApi.Overlays.Tests; - -public class OverlayInfoTests -{ - [Fact] - public void SerializeAsV1_ShouldWriteCorrectJson() - { - // Arrange - var overlayInfo = new OverlayInfo - { - Title = "Test Overlay", - Version = "1.0.0" - }; - using var textWriter = new StringWriter(); - var writer = new OpenApiJsonWriter(textWriter); - - var expectedJson = -""" -{ - "title": "Test Overlay", - "version": "1.0.0" -} -"""; - - // Act - overlayInfo.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."); - } - - [Fact] - public void Deserialize_ShouldSetPropertiesCorrectly() - { - // Arrange - var json = """ - { - "title": "Test Overlay", - "version": "1.0.0" - } - """; - var jsonNode = JsonNode.Parse(json)!; - var parsingContext = new ParsingContext(new()); - var parseNode = new MapNode(parsingContext, jsonNode); - - - // Act - var overlayInfo = OverlayV1Deserializer.LoadInfo(parseNode); - - // Assert - Assert.Equal("Test Overlay", overlayInfo.Title); - Assert.Equal("1.0.0", overlayInfo.Version); - } -} \ No newline at end of file diff --git a/tests/lib/Serialization/OverlayActionTests.cs b/tests/lib/Serialization/V1/OverlayActionTests.cs similarity index 99% rename from tests/lib/Serialization/OverlayActionTests.cs rename to tests/lib/Serialization/V1/OverlayActionTests.cs index 2fab144..4eae94a 100644 --- a/tests/lib/Serialization/OverlayActionTests.cs +++ b/tests/lib/Serialization/V1/OverlayActionTests.cs @@ -8,11 +8,9 @@ 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 +public class OverlayActionV1Tests { [Fact] public void SerializeAsV1_ShouldWriteCorrectJson() diff --git a/tests/lib/Serialization/OverlayDocumentTests.cs b/tests/lib/Serialization/V1/OverlayDocumentTests.cs similarity index 99% rename from tests/lib/Serialization/OverlayDocumentTests.cs rename to tests/lib/Serialization/V1/OverlayDocumentTests.cs index 7c9ad83..0f03c7b 100644 --- a/tests/lib/Serialization/OverlayDocumentTests.cs +++ b/tests/lib/Serialization/V1/OverlayDocumentTests.cs @@ -10,7 +10,7 @@ namespace BinkyLabs.OpenApi.Overlays.Tests; -public sealed class OverlayDocumentTests +public sealed class OverlayDocumentV1Tests { [Fact] public void SerializeAsV1_ShouldWriteCorrectJson() @@ -372,7 +372,7 @@ public async Task LoadAsync_WithValidFilePath_ReturnsReadResult() } """; - var tempFile = @"./ValidFile.json"; + var tempFile = Path.ChangeExtension(Path.GetTempFileName(), ".json"); await File.WriteAllTextAsync(tempFile, json); // Act diff --git a/tests/lib/Serialization/V1/OverlayInfoTests.cs b/tests/lib/Serialization/V1/OverlayInfoTests.cs new file mode 100644 index 0000000..26f5d15 --- /dev/null +++ b/tests/lib/Serialization/V1/OverlayInfoTests.cs @@ -0,0 +1,148 @@ +using System.Text.Json.Nodes; + +using BinkyLabs.OpenApi.Overlays.Reader; +using BinkyLabs.OpenApi.Overlays.Reader.V1; + +using Microsoft.OpenApi; +using Microsoft.OpenApi.Reader; + +using ParsingContext = BinkyLabs.OpenApi.Overlays.Reader.ParsingContext; + +namespace BinkyLabs.OpenApi.Overlays.Tests; + +public class OverlayInfoV1Tests +{ + [Fact] + public void SerializeAsV1_ShouldWriteCorrectJson() + { + // Arrange + var overlayInfo = new OverlayInfo + { + Title = "Test Overlay", + Version = "1.0.0" + }; + using var textWriter = new StringWriter(); + var writer = new OpenApiJsonWriter(textWriter); + + var expectedJson = +""" +{ + "title": "Test Overlay", + "version": "1.0.0" +} +"""; + + // Act + overlayInfo.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."); + } + + [Fact] + public void SerializeAsV1_WithDescription_ShouldWriteCorrectJson() + { + // Arrange + var overlayInfo = new OverlayInfo + { + Title = "Test Overlay", + Version = "1.0.0", + Description = "Test overlay description" + }; + using var textWriter = new StringWriter(); + var writer = new OpenApiJsonWriter(textWriter); + + var expectedJson = +""" +{ + "title": "Test Overlay", + "version": "1.0.0", + "x-description": "Test overlay description" +} +"""; + + // Act + overlayInfo.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."); + } + + [Fact] + public void Deserialize_ShouldSetPropertiesCorrectly() + { + // Arrange + var json = """ + { + "title": "Test Overlay", + "version": "1.0.0" + } + """; + var jsonNode = JsonNode.Parse(json)!; + var parsingContext = new ParsingContext(new()); + var parseNode = new MapNode(parsingContext, jsonNode); + + + // Act + var overlayInfo = OverlayV1Deserializer.LoadInfo(parseNode); + + // Assert + Assert.Equal("Test Overlay", overlayInfo.Title); + Assert.Equal("1.0.0", overlayInfo.Version); + } + + [Fact] + public void Deserialize_WithDescription_ShouldSetPropertiesCorrectly() + { + // Arrange + var json = """ + { + "title": "Test Overlay", + "version": "1.0.0", + "x-description": "Test overlay description" + } + """; + var jsonNode = JsonNode.Parse(json)!; + var parsingContext = new ParsingContext(new()); + var parseNode = new MapNode(parsingContext, jsonNode); + + // Act + var overlayInfo = OverlayV1Deserializer.LoadInfo(parseNode); + + // Assert + Assert.Equal("Test Overlay", overlayInfo.Title); + Assert.Equal("1.0.0", overlayInfo.Version); + Assert.Equal("Test overlay description", overlayInfo.Description); + } + + [Fact] + public void Deserialize_WithStandardDescription_ShouldIgnoreField() + { + // Arrange - In V1, "description" field should be ignored, only "x-description" is recognized + var json = """ + { + "title": "Test Overlay", + "version": "1.0.0", + "description": "This should be ignored in V1" + } + """; + var jsonNode = JsonNode.Parse(json)!; + var parsingContext = new ParsingContext(new()); + var parseNode = new MapNode(parsingContext, jsonNode); + + // Act + var overlayInfo = OverlayV1Deserializer.LoadInfo(parseNode); + + // Assert + Assert.Equal("Test Overlay", overlayInfo.Title); + Assert.Equal("1.0.0", overlayInfo.Version); + Assert.Null(overlayInfo.Description); // Should be null because "description" is not recognized in V1 + } +} \ No newline at end of file diff --git a/tests/lib/Serialization/V1_1/OverlayActionTests.cs b/tests/lib/Serialization/V1_1/OverlayActionTests.cs new file mode 100644 index 0000000..5e732e3 --- /dev/null +++ b/tests/lib/Serialization/V1_1/OverlayActionTests.cs @@ -0,0 +1,1058 @@ +using System.Text.Json.Nodes; + +using BinkyLabs.OpenApi.Overlays.Reader; +using BinkyLabs.OpenApi.Overlays.Reader.V1_1; + +using Microsoft.OpenApi; +using Microsoft.OpenApi.Reader; + +using ParsingContext = BinkyLabs.OpenApi.Overlays.Reader.ParsingContext; + +namespace BinkyLabs.OpenApi.Overlays.Tests; + +public class OverlayActionV1_1Tests +{ + [Fact] + public void SerializeAsV1_1_ShouldWriteCorrectJson() + { + // Arrange + var overlayAction = new OverlayAction + { + Target = "Test Target", + Description = "Test Description", + Remove = true + }; + using var textWriter = new StringWriter(); + var writer = new OpenApiJsonWriter(textWriter); + + var expectedJson = +""" +{ + "target": "Test Target", + "description": "Test Description", + "remove": true +} +"""; + + // Act + overlayAction.SerializeAsV1_1(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."); + } + + [Fact] + public void SerializeAsV1_1_WithUpdate_ShouldWriteCorrectJson() + { + // Arrange + var updateNode = JsonNode.Parse(""" + { + "summary": "Updated summary", + "description": "Updated description", + "operationId": "updateOperation" + } + """); + + var overlayAction = new OverlayAction + { + Target = "Test Target", + Description = "Test Description", + Remove = false, + Update = updateNode + }; + using var textWriter = new StringWriter(); + var writer = new OpenApiJsonWriter(textWriter); + + var expectedJson = +""" +{ + "target": "Test Target", + "description": "Test Description", + "update": { + "summary": "Updated summary", + "description": "Updated description", + "operationId": "updateOperation" + } +} +"""; + + // Act + overlayAction.SerializeAsV1_1(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."); + } + + [Fact] + public void SerializeAsV1_1_WithUpdateArray_ShouldWriteCorrectJson() + { + // Arrange + var updateNode = JsonNode.Parse(""" + ["tag1", "tag2", "tag3"] + """); + + var overlayAction = new OverlayAction + { + Target = "Test Target", + Description = "Test Description", + Update = updateNode + }; + using var textWriter = new StringWriter(); + var writer = new OpenApiJsonWriter(textWriter); + + var expectedJson = +""" +{ + "target": "Test Target", + "description": "Test Description", + "update": ["tag1", "tag2", "tag3"] +} +"""; + + // Act + overlayAction.SerializeAsV1_1(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."); + } + + [Fact] + public void Deserialize_ShouldSetPropertiesCorrectly() + { + // Arrange + var json = """ + { + "target": "Test Target", + "description": "Test Description", + "remove": true + } + """; + var jsonNode = JsonNode.Parse(json)!; + var parsingContext = new ParsingContext(new()); + var parseNode = new MapNode(parsingContext, jsonNode); + + + // Act + var overlayAction = OverlayV1_1Deserializer.LoadAction(parseNode); + + // Assert + Assert.Equal("Test Target", overlayAction.Target); + Assert.Equal("Test Description", overlayAction.Description); + Assert.True(overlayAction.Remove); + } + + [Fact] + public void Deserialize_WithUpdate_ShouldSetPropertiesCorrectly() + { + // Arrange + var json = """ + { + "target": "Test Target", + "description": "Test Description", + "remove": false, + "update": { + "summary": "Updated summary", + "description": "Updated description", + "operationId": "updateOperation" + } + } + """; + var jsonNode = JsonNode.Parse(json)!; + var parsingContext = new ParsingContext(new()); + var parseNode = new MapNode(parsingContext, jsonNode); + + // Act + var overlayAction = OverlayV1_1Deserializer.LoadAction(parseNode); + + // Assert + Assert.Equal("Test Target", overlayAction.Target); + Assert.Equal("Test Description", overlayAction.Description); + Assert.False(overlayAction.Remove); + Assert.NotNull(overlayAction.Update); + + var updateObject = overlayAction.Update.AsObject(); + Assert.Equal("Updated summary", updateObject["summary"]?.GetValue()); + Assert.Equal("Updated description", updateObject["description"]?.GetValue()); + Assert.Equal("updateOperation", updateObject["operationId"]?.GetValue()); + } + + [Fact] + public void Deserialize_WithUpdateArray_ShouldSetPropertiesCorrectly() + { + // Arrange + var json = """ + { + "target": "Test Target", + "description": "Test Description", + "update": ["tag1", "tag2", "tag3"] + } + """; + var jsonNode = JsonNode.Parse(json)!; + var parsingContext = new ParsingContext(new()); + var parseNode = new MapNode(parsingContext, jsonNode); + + // Act + var overlayAction = OverlayV1_1Deserializer.LoadAction(parseNode); + + // Assert + Assert.Equal("Test Target", overlayAction.Target); + Assert.Equal("Test Description", overlayAction.Description); + Assert.NotNull(overlayAction.Update); + + var updateArray = overlayAction.Update.AsArray(); + Assert.Equal(3, updateArray.Count); + Assert.Equal("tag1", updateArray[0]?.GetValue()); + Assert.Equal("tag2", updateArray[1]?.GetValue()); + Assert.Equal("tag3", updateArray[2]?.GetValue()); + } + + [Fact] + public void Deserialize_WithUpdatePrimitiveValue_ShouldSetPropertiesCorrectly() + { + // Arrange + var json = """ + { + "target": "Test Target", + "description": "Test Description", + "update": "simple string value" + } + """; + var jsonNode = JsonNode.Parse(json)!; + var parsingContext = new ParsingContext(new()); + var parseNode = new MapNode(parsingContext, jsonNode); + + // Act + var overlayAction = OverlayV1_1Deserializer.LoadAction(parseNode); + + // Assert + Assert.Equal("Test Target", overlayAction.Target); + Assert.Equal("Test Description", overlayAction.Description); + Assert.NotNull(overlayAction.Update); + Assert.Equal("simple string value", overlayAction.Update.GetValue()); + } + [Fact] + public void ApplyToDocument_ShouldFailNoNullJsonNode() + { + var overlayAction = new OverlayAction + { + Target = "Test Target", + Remove = true + }; + JsonNode? jsonNode = null; + var overlayDiagnostic = new OverlayDiagnostic(); + + Assert.Throws(() => overlayAction.ApplyToDocument(jsonNode!, overlayDiagnostic, 0)); + } + [Fact] + public void ApplyToDocument_ShouldFailNoDiagnostics() + { + var overlayAction = new OverlayAction + { + Target = "Test Target", + Remove = true + }; + var jsonNode = JsonNode.Parse("{}")!; + OverlayDiagnostic? overlayDiagnostic = null; + + Assert.Throws(() => overlayAction.ApplyToDocument(jsonNode, overlayDiagnostic!, 0)); + } + [Fact] + public void ApplyToDocument_ShouldFailNoTarget() + { + var overlayAction = new OverlayAction + { + Remove = true + }; + var jsonNode = JsonNode.Parse("{}")!; + 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 is required", overlayDiagnostic.Errors[0].Message); + } + [Fact] + public void ApplyToDocument_ShouldFailNoRemoveOrUpdate() + { + var overlayAction = new OverlayAction + { + Target = "Test Target" + }; + var jsonNode = JsonNode.Parse("{}")!; + 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("At least one of 'remove', 'update' or 'x-copy' must be specified", overlayDiagnostic.Errors[0].Message); + } + [Fact] + public void ApplyToDocument_ShouldFailBothRemoveAndUpdate() + { + var overlayAction = new OverlayAction + { + Target = "Test Target", + Remove = true, + Update = JsonNode.Parse("{}") + }; + var jsonNode = JsonNode.Parse("{}")!; + 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("At most one of 'remove', 'update' or 'x-copy' can be specified", overlayDiagnostic.Errors[0].Message); + } + [Fact] + public void ApplyToDocument_ShouldFailInvalidJsonPath() + { + var overlayAction = new OverlayAction + { + Target = "Test Target", + Remove = true + }; + var jsonNode = JsonNode.Parse("{}")!; + 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 JSON Path: 'Test Target'", overlayDiagnostic.Errors[0].Message); + } + [Fact] + public void ApplyToDocument_ShouldRemoveANode() + { + var overlayAction = new OverlayAction + { + Target = "$.info.title", + Remove = true + }; + 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.Null(jsonNode["info"]?["title"]); + Assert.Empty(overlayDiagnostic.Errors); + } + [Fact] + public void ApplyToDocument_ShouldRemoveANodeAndNotErrorInWildcard() + { + var overlayAction = new OverlayAction + { + Target = "$.components.schemas['Foo'].anyOf[*].default", + Remove = true + }; + var jsonNode = JsonNode.Parse(""" + { + "info": { + "title": "Test API", + "version": "1.0.0" + }, + "components": { + "schemas": { + "Foo": { + "anyOf": [ + { + "default": "value1" + }, + { + "type": "string" + } + ] + } + } + } + } + """)!; + var overlayDiagnostic = new OverlayDiagnostic(); + + var result = overlayAction.ApplyToDocument(jsonNode, overlayDiagnostic, 0); + + Assert.True(result); + Assert.Null(jsonNode["components"]?["schemas"]?["Foo"]?["anyOf"]?[0]?["default"]); + Assert.Null(jsonNode["components"]?["schemas"]?["Foo"]?["anyOf"]?[1]?["default"]); + Assert.Equal("string", jsonNode["components"]?["schemas"]?["Foo"]?["anyOf"]?[1]?["type"]?.GetValue()); + Assert.Empty(overlayDiagnostic.Errors); + } + [Fact] + public void ApplyToDocument_ShouldUpdateANode() + { + var overlayAction = new OverlayAction + { + Target = "$.info", + Update = JsonNode.Parse(""" + { + "title": "Updated API" + } + """) + }; + 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("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(3, targetTags.Count); + Assert.Equal("user", targetTags[0]?.GetValue()); + Assert.Equal("post", targetTags[1]?.GetValue()); + Assert.Equal("content", targetTags[2]?.GetValue()); + Assert.Empty(overlayDiagnostic.Errors); + } + + [Fact] + public void ApplyToDocument_ShouldCopyMultipleMatches() + { + var overlayAction = new OverlayAction + { + Target = "$.paths[*].get.summary", + Copy = "$.info.title" + }; + var jsonNode = JsonNode.Parse(""" + { + "info": { + "title": "My API" + }, + "paths": { + "/users": { + "get": { + "summary": "Get users" + } + }, + "/posts": { + "get": { + "summary": "Get posts" + } + } + } + } + """)!; + var overlayDiagnostic = new OverlayDiagnostic(); + + var result = overlayAction.ApplyToDocument(jsonNode, overlayDiagnostic, 0); + + Assert.True(result); + Assert.Equal("My API", jsonNode["paths"]?["/users"]?["get"]?["summary"]?.GetValue()); + Assert.Equal("My API", 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 JSON Path '$.nonexistent.field' must match exactly one result, but matched 0", 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 JSON Path '$.paths[*].get.nonexistent' must match exactly one result, but matched 0", overlayDiagnostic.Errors[0].Message); + } + + [Fact] + public void ApplyToDocument_CopyShouldFailWhenCopyHasMultipleMatches() + { + 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.False(result); + Assert.Single(overlayDiagnostic.Errors); + Assert.Equal("$.actions[0]", overlayDiagnostic.Errors[0].Pointer); + Assert.Equal("Copy JSON Path '$.paths[*].get.operationId' must match exactly one result, but matched 2", 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_1_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", + "copy": "$.info.description" +} +"""; + + // Act + overlayAction.SerializeAsV1_1(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."); + } + + [Fact] + public void ApplyToDocument_WorksWithSubSequentRemoval() + { + // Given + var firstAction = new OverlayAction + { + Target = "$..[?(@['$ref'] == '#/components/schemas/Foo')]", + Update = JsonNode.Parse( +""" +{ + "anyOf": + [ + { + "$ref": "#/components/schemas/Foo" + }, + { + "type": "null" + } + ] +} +""" + ) + }; + var secondAction = new OverlayAction + { + Target = "$..[?(@['$ref'] == '#/components/schemas/Foo' && @.anyOf)]['$ref']", + Remove = true, + }; + + var documentJson = +""" +{ + "openapi": "3.0.0", + "info": { + "title": "(title)", + "version": "0.0.0" + }, + "tags": [], + "paths": {}, + "components": { + "schemas": { + "Bar": { + "type": "object", + "required": [ + "foo" + ], + "properties": { + "foo": { + "$ref": "#/components/schemas/Foo" + } + } + }, + "Baz": { + "type": "object", + "required": [ + "foo" + ], + "properties": { + "foo": { + "$ref": "#/components/schemas/Foo" + } + } + }, + "Foo": { + "type": "object" + } + } + } +} +"""; + var jsonNode = JsonNode.Parse(documentJson)!; + var overlayDiagnostic = new OverlayDiagnostic(); + + // When + var result = firstAction.ApplyToDocument(jsonNode, overlayDiagnostic, 0); + + // Then + Assert.True(result); + Assert.Empty(overlayDiagnostic.Errors); + var barFoo = Assert.IsType(jsonNode["components"]?["schemas"]?["Bar"]?["properties"]?["foo"]); + var bazFoo = Assert.IsType(jsonNode["components"]?["schemas"]?["Baz"]?["properties"]?["foo"]); + var barAnyOf = Assert.IsType(barFoo["anyOf"]); + var bazAnyOf = Assert.IsType(bazFoo["anyOf"]); + Assert.Equal(2, barAnyOf.Count); + Assert.Equal(2, bazAnyOf.Count); + Assert.True(JsonNode.DeepEquals(barAnyOf, bazAnyOf)); + Assert.NotNull(barFoo["$ref"]); + Assert.NotNull(bazFoo["$ref"]); + + result = secondAction.ApplyToDocument(jsonNode, overlayDiagnostic, 1); + + Assert.True(result); + Assert.Empty(overlayDiagnostic.Errors); + Assert.Null(barFoo["$ref"]); + Assert.Null(bazFoo["$ref"]); + } + + [Fact] + public void ApplyToDocument_CopyShouldCopySingleSourceToMultipleTargets() + { + // This test validates the new behavior where a single copy source + // is copied to multiple targets + var overlayAction = new OverlayAction + { + Target = "$.paths[*].get.operationId", + Copy = "$.info.title" + }; + var jsonNode = JsonNode.Parse(""" + { + "info": { + "title": "Common Title" + }, + "paths": { + "/users": { + "get": { + "operationId": "oldOperationId1" + } + }, + "/posts": { + "get": { + "operationId": "oldOperationId2" + } + }, + "/comments": { + "get": { + "operationId": "oldOperationId3" + } + } + } + } + """)!; + var overlayDiagnostic = new OverlayDiagnostic(); + + var result = overlayAction.ApplyToDocument(jsonNode, overlayDiagnostic, 0); + + Assert.True(result); + Assert.Empty(overlayDiagnostic.Errors); + // All three targets should now have the same value from the single copy source + Assert.Equal("Common Title", jsonNode["paths"]?["/users"]?["get"]?["operationId"]?.GetValue()); + Assert.Equal("Common Title", jsonNode["paths"]?["/posts"]?["get"]?["operationId"]?.GetValue()); + Assert.Equal("Common Title", jsonNode["paths"]?["/comments"]?["get"]?["operationId"]?.GetValue()); + } + + [Fact] + public void ApplyToDocument_CopyShouldFailWhenCopyMatchesZeroResults() + { + 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 JSON Path '$.paths[*].get.nonexistent' must match exactly one result, but matched 0", overlayDiagnostic.Errors[0].Message); + } + + [Fact] + public void ApplyToDocument_CopyShouldFailWhenCopyMatchesTwoResults() + { + var overlayAction = new OverlayAction + { + Target = "$.info.title", + Copy = "$.paths[*].get.summary" + }; + 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("Copy JSON Path '$.paths[*].get.summary' must match exactly one result, but matched 2", overlayDiagnostic.Errors[0].Message); + } + + [Fact] + public void ApplyToDocument_CopyShouldSucceedWithExactlyOneMatch() + { + var overlayAction = new OverlayAction + { + Target = "$.paths['/users'].get.summary", + Copy = "$.info.description" + }; + var jsonNode = JsonNode.Parse(""" + { + "info": { + "title": "Test API", + "description": "API Description" + }, + "paths": { + "/users": { + "get": { + "summary": "Old summary" + } + } + } + } + """)!; + var overlayDiagnostic = new OverlayDiagnostic(); + + var result = overlayAction.ApplyToDocument(jsonNode, overlayDiagnostic, 0); + + Assert.True(result); + Assert.Empty(overlayDiagnostic.Errors); + Assert.Equal("API Description", jsonNode["paths"]?["/users"]?["get"]?["summary"]?.GetValue()); + } +} \ No newline at end of file diff --git a/tests/lib/Serialization/V1_1/OverlayDocumentTests.cs b/tests/lib/Serialization/V1_1/OverlayDocumentTests.cs new file mode 100644 index 0000000..5b49d9e --- /dev/null +++ b/tests/lib/Serialization/V1_1/OverlayDocumentTests.cs @@ -0,0 +1,561 @@ +using System.Text; +using System.Text.Json; +using System.Text.Json.Nodes; +using System.Threading.Tasks; + +using BinkyLabs.OpenApi.Overlays.Reader; +using BinkyLabs.OpenApi.Overlays.Reader.V1_1; + +using Microsoft.OpenApi; + +using ParsingContext = BinkyLabs.OpenApi.Overlays.Reader.ParsingContext; + +namespace BinkyLabs.OpenApi.Overlays.Tests; + +public sealed class OverlayDocumentV1_1Tests +{ + [Fact] + public void SerializeAsV1_1_ShouldWriteCorrectJson() + { + // Arrange + var overlayDocument = new OverlayDocument + { + Info = new OverlayInfo + { + Title = "Test Overlay", + Version = "1.0.0" + }, + Extends = "x-extends", + Actions = + [ + new OverlayAction + { + Target = "Test Target", + Description = "Test Description", + Remove = true + } + ], + Extensions = new Dictionary + { + { "x-custom-extension", new JsonNodeExtension(new JsonObject { { "someProperty", "someValue" } }) } + } + }; + using var textWriter = new StringWriter(); + var writer = new OpenApiJsonWriter(textWriter); + + var expectedJson = """ + { + "overlay": "1.1.0", + "info": { + "title": "Test Overlay", + "version": "1.0.0" + }, + "extends": "x-extends", + "actions": [ + { + "target": "Test Target", + "description": "Test Description", + "remove": true + } + ], + "x-custom-extension": { + "someProperty": "someValue" + } + } + """; + + // Act + overlayDocument.SerializeAsV1_1(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."); + } + + [Fact] + public void Deserialize_ShouldSetPropertiesCorrectly() + { + // Arrange + var json = """ + { + "overlay": "1.0.0", + "info": { + "title": "Test Overlay", + "version": "2.0.0" + }, + "extends": "x-extends", + "actions": [ + { + "target": "Test Target", + "description": "Test Description", + "remove": true + },{ + "target": "Test Target 2", + "description": "Test Description 2", + "remove": false + } + ], + "x-custom-extension": { + "someProperty": "someValue" + } + } + """; + var jsonNode = JsonNode.Parse(json)!; + var parsingContext = new ParsingContext(new()); + var parseNode = new MapNode(parsingContext, jsonNode); + + // Act + var overlayDocument = OverlayV1_1Deserializer.LoadDocument(parseNode); + + // Assert + Assert.NotNull(overlayDocument); + Assert.Equal("1.0.0", overlayDocument.Overlay); + Assert.Equal("Test Overlay", overlayDocument.Info?.Title); + Assert.Equal("2.0.0", overlayDocument.Info?.Version); + Assert.Equal("x-extends", overlayDocument.Extends); + Assert.NotNull(overlayDocument.Extensions); + Assert.True(overlayDocument.Extensions!.ContainsKey("x-custom-extension")); + var extensionNodeValue = Assert.IsType(overlayDocument.Extensions["x-custom-extension"]); + var extensionValue = extensionNodeValue.Node; + var someProperty = Assert.IsType(extensionValue["someProperty"], exactMatch: false); + Assert.Equal("someValue", someProperty.GetValue()); + + // Assert the 2 action + Assert.NotNull(overlayDocument.Actions); + Assert.Equal(2, overlayDocument.Actions.Count); + Assert.Equal("Test Target", overlayDocument.Actions[0].Target); + Assert.Equal("Test Description", overlayDocument.Actions[0].Description); + Assert.True(overlayDocument.Actions[0].Remove); + Assert.Equal("Test Target 2", overlayDocument.Actions[1].Target); + Assert.Equal("Test Description 2", overlayDocument.Actions[1].Description); + Assert.False(overlayDocument.Actions[1].Remove); + } + + [Fact] + public void SerializeAsV1_1_WithUpdate_ShouldWriteCorrectJson() + { + // Arrange + var updateNode = JsonNode.Parse(""" + { + "summary": "Updated summary", + "description": "Updated description" + } + """); + + var overlayDocument = new OverlayDocument + { + Info = new OverlayInfo + { + Title = "Test Overlay", + Version = "1.0.0" + }, + Extends = "x-extends", + Actions = + [ + new OverlayAction + { + Target = "Test Target", + Description = "Test Description", + Update = updateNode + } + ] + }; + using var textWriter = new StringWriter(); + var writer = new OpenApiJsonWriter(textWriter); + + + var expectedJson = """ + { + "overlay": "1.1.0", + "info": { + "title": "Test Overlay", + "version": "1.0.0" + }, + "extends": "x-extends", + "actions": [ + { + "target": "Test Target", + "description": "Test Description", + "update": { + "summary": "Updated summary", + "description": "Updated description" + } + } + ] + } + """; + + // Act + overlayDocument.SerializeAsV1_1(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."); + } + + [Fact] + public void Deserialize_WithUpdate_ShouldSetPropertiesCorrectly() + { + // Arrange + var json = """ + { + "overlay": "1.0.0", + "info": { + "title": "Test Overlay", + "version": "2.0.0" + }, + "extends": "x-extends", + "actions": [ + { + "target": "Test Target", + "description": "Test Description", + "update": { + "summary": "Updated summary", + "description": "Updated description" + } + },{ + "target": "Test Target 2", + "description": "Test Description 2", + "update": ["tag1", "tag2"] + } + ] + } + """; + var jsonNode = JsonNode.Parse(json)!; + var parsingContext = new ParsingContext(new()); + var parseNode = new MapNode(parsingContext, jsonNode); + + // Act + var overlayDocument = OverlayV1_1Deserializer.LoadDocument(parseNode); + + // Assert + Assert.NotNull(overlayDocument); + Assert.Equal("1.0.0", overlayDocument.Overlay); + Assert.Equal("Test Overlay", overlayDocument.Info?.Title); + Assert.Equal("2.0.0", overlayDocument.Info?.Version); + Assert.Equal("x-extends", overlayDocument.Extends); + + // Assert the 2 actions + Assert.NotNull(overlayDocument.Actions); + Assert.Equal(2, overlayDocument.Actions.Count); + + // First action with object update + Assert.Equal("Test Target", overlayDocument.Actions[0].Target); + Assert.Equal("Test Description", overlayDocument.Actions[0].Description); + var updateProperty = overlayDocument.Actions[0].Update; + Assert.NotNull(updateProperty); + var updateObject = updateProperty.AsObject(); + Assert.Equal("Updated summary", updateObject["summary"]?.GetValue()); + Assert.Equal("Updated description", updateObject["description"]?.GetValue()); + + // Second action with array update + Assert.Equal("Test Target 2", overlayDocument.Actions[1].Target); + Assert.Equal("Test Description 2", overlayDocument.Actions[1].Description); + var updatePropertyArray = overlayDocument.Actions[1].Update; + Assert.NotNull(updatePropertyArray); + var updateArray = updatePropertyArray.AsArray(); + Assert.Equal(2, updateArray.Count); + Assert.Equal("tag1", updateArray[0]?.GetValue()); + Assert.Equal("tag2", updateArray[1]?.GetValue()); + } + [Fact] + public async Task Load_WithValidMemoryStream_ReturnsReadResultAsync() + { + // Arrange + var json = """ + { + "openapi": "3.0.0", + "overlay": "1.0.0", + "info": { + "title": "Test Overlay", + "version": "2.0.0" + }, + "extends": "x-extends", + "x-custom-extension": { + "someProperty": "someValue" + }, + "actions": [ + { + "target": "Test Target", + "description": "Test Description", + "update": { + "summary": "Updated summary", + "description": "Updated description" + } + },{ + "target": "Test Target 2", + "description": "Test Description 2", + "update": ["tag1", "tag2"] + } + ] + } + """; + using var stream = new MemoryStream(Encoding.UTF8.GetBytes(json)); + + // Act + var (overlayDocument, _) = await OverlayDocument.LoadFromStreamAsync(stream); + + // Assert + Assert.NotNull(overlayDocument); + Assert.Equal("2.0.0", overlayDocument.Info?.Version); + Assert.Equal("Test Overlay", overlayDocument.Info?.Title); + Assert.Equal("1.0.0", overlayDocument.Overlay); + Assert.Equal("x-extends", overlayDocument.Extends); + Assert.NotNull(overlayDocument.Extensions); + Assert.True(overlayDocument.Extensions.ContainsKey("x-custom-extension")); + var extension = overlayDocument.Extensions["x-custom-extension"]; + var jsonNodeExtension = Assert.IsType(extension); + var node = jsonNodeExtension.Node; + Assert.NotNull(node); + Assert.Equal("someValue", node["someProperty"]?.GetValue()); + + // Assert the 2 actions + Assert.NotNull(overlayDocument.Actions); + Assert.Equal(2, overlayDocument.Actions.Count); + + // First action with object update + Assert.Equal("Test Target", overlayDocument.Actions[0].Target); + Assert.Equal("Test Description", overlayDocument.Actions[0].Description); + var updateProperty = overlayDocument.Actions[0].Update; + Assert.NotNull(updateProperty); + var updateObject = updateProperty.AsObject(); + Assert.Equal("Updated summary", updateObject["summary"]?.GetValue()); + Assert.Equal("Updated description", updateObject["description"]?.GetValue()); + + // Second action with array update + Assert.Equal("Test Target 2", overlayDocument.Actions[1].Target); + Assert.Equal("Test Description 2", overlayDocument.Actions[1].Description); + var updatePropertyArray = overlayDocument.Actions[1].Update; + Assert.NotNull(updatePropertyArray); + var updateArray = updatePropertyArray.AsArray(); + Assert.Equal(2, updateArray.Count); + Assert.Equal("tag1", updateArray[0]?.GetValue()); + Assert.Equal("tag2", updateArray[1]?.GetValue()); + + } + + + [Fact] + public async Task LoadAsync_WithValidFilePath_ReturnsReadResult() + { + // Arrange + var json = """ + { + "openapi": "3.0.0", + "overlay": "1.0.0", + "info": { + "title": "Test Overlay", + "version": "2.0.0" + }, + "extends": "x-extends", + "x-custom-extension": { + "someProperty": "someValue" + }, + "actions": [ + { + "target": "Test Target", + "description": "Test Description", + "update": { + "summary": "Updated summary", + "description": "Updated description" + } + },{ + "target": "Test Target 2", + "description": "Test Description 2", + "update": ["tag1", "tag2"] + } + ] + } + """; + + var tempFile = Path.ChangeExtension(Path.GetTempFileName(), ".json"); + await File.WriteAllTextAsync(tempFile, json); + + // Act + var (overlayDocument, _) = await OverlayDocument.LoadFromUrlAsync(tempFile); + + // Assert + Assert.NotNull(overlayDocument); + Assert.Equal("2.0.0", overlayDocument.Info?.Version); + Assert.Equal("Test Overlay", overlayDocument.Info?.Title); + Assert.Equal("1.0.0", overlayDocument.Overlay); + Assert.Equal("x-extends", overlayDocument.Extends); + Assert.NotNull(overlayDocument.Extensions); + Assert.True(overlayDocument.Extensions.ContainsKey("x-custom-extension")); + var extension = overlayDocument.Extensions["x-custom-extension"]; + var jsonNodeExtension = Assert.IsType(extension); + var node = jsonNodeExtension.Node; + Assert.NotNull(node); + Assert.Equal("someValue", node["someProperty"]?.GetValue()); + + // Assert the 2 actions + Assert.NotNull(overlayDocument.Actions); + Assert.Equal(2, overlayDocument.Actions.Count); + + // First action with object update + Assert.Equal("Test Target", overlayDocument.Actions[0].Target); + Assert.Equal("Test Description", overlayDocument.Actions[0].Description); + var updateProperty = overlayDocument.Actions[0].Update; + Assert.NotNull(updateProperty); + var updateObject = updateProperty.AsObject(); + Assert.Equal("Updated summary", updateObject["summary"]?.GetValue()); + Assert.Equal("Updated description", updateObject["description"]?.GetValue()); + + // Second action with array update + Assert.Equal("Test Target 2", overlayDocument.Actions[1].Target); + Assert.Equal("Test Description 2", overlayDocument.Actions[1].Description); + var updatePropertyArray = overlayDocument.Actions[1].Update; + Assert.NotNull(updatePropertyArray); + var updateArray = updatePropertyArray.AsArray(); + Assert.Equal(2, updateArray.Count); + Assert.Equal("tag1", updateArray[0]?.GetValue()); + Assert.Equal("tag2", updateArray[1]?.GetValue()); + } + + [Fact] + public async Task Parse_WithValidJson_ReturnsReadResultJson() + { + // Arrange + var json = """ + { + "overlay": "1.0.0", + "info": { + "title": "Test Overlay", + "version": "1.0.0" + }, + "actions": [ + { + "target": "Test Target", + "description": "Test Description", + "remove": true + } + ] + } + """; + + // Act + var (overlayDocument, _) = await OverlayDocument.ParseAsync(json); + + // Assert + Assert.NotNull(overlayDocument); + Assert.Equal("1.0.0", overlayDocument.Overlay); + Assert.Equal("Test Overlay", overlayDocument.Info?.Title); + Assert.Equal("1.0.0", overlayDocument.Info?.Version); + Assert.NotNull(overlayDocument.Actions); + Assert.Single(overlayDocument.Actions); + Assert.Equal("Test Target", overlayDocument.Actions[0].Target); + Assert.Equal("Test Description", overlayDocument.Actions[0].Description); + Assert.True(overlayDocument.Actions[0].Remove); + } + + [Fact] + public async Task Parse_WithValidJson_ReturnsReadResultYamlAsync() + { + // Arrange + var yaml = """ + overlay: 1.0.0 + info: + title: Test Overlay + version: 1.0.0 + actions: + - target: Test Target + description: Test Description + remove: true + """; + + // Act + var (overlayDocument, _) = await OverlayDocument.ParseAsync(yaml); + + // Assert + Assert.NotNull(overlayDocument); + Assert.Equal("1.0.0", overlayDocument.Overlay); + Assert.Equal("Test Overlay", overlayDocument.Info?.Title); + Assert.Equal("1.0.0", overlayDocument.Info?.Version); + Assert.NotNull(overlayDocument.Actions); + Assert.Single(overlayDocument.Actions); + Assert.Equal("Test Target", overlayDocument.Actions[0].Target); + Assert.Equal("Test Description", overlayDocument.Actions[0].Description); + Assert.True(overlayDocument.Actions[0].Remove); + } + [Fact] + public void CombineWithThrowsOnEmptyInput() + { + var overlayDocument = new OverlayDocument(); + + Assert.Throws(() => overlayDocument.CombineWith(null!)); + } + [Fact] + public void UsesMetadataFromLastDocumentWhenCombiningOverlays() + { + // Given + var overlayDocument1 = new OverlayDocument + { + Info = new OverlayInfo + { + Title = "Overlay 1", + Version = "1.0.0" + }, + Extends = "base.yaml" + }; + var overlayDocument2 = new OverlayDocument + { + Info = new OverlayInfo + { + Title = "Overlay 2", + Version = "1.0.1" + }, + Extends = "base2.yaml" + }; + + // When + var result = overlayDocument1.CombineWith(overlayDocument2); + + // Then + Assert.Equal("Overlay 2", result.Info?.Title); + Assert.Equal("1.0.1", result.Info?.Version); + Assert.Equal("base2.yaml", result.Extends); + Assert.NotNull(result.Actions); + Assert.Empty(result.Actions); + } + [Fact] + public void CombinesActionsInTheRightOrder() + { + // Given + var overlayDocument1 = new OverlayDocument + { + Actions = + [ + new OverlayAction { Target = "Target1", Description = "Description1", Remove = false }, + new OverlayAction { Target = "Target2", Description = "Description2", Remove = true } + ] + }; + var overlayDocument2 = new OverlayDocument + { + Actions = + [ + new OverlayAction { Target = "Target3", Description = "Description3", Remove = false } + ] + }; + + // When + var result = overlayDocument1.CombineWith(overlayDocument2); + + // Then + Assert.NotNull(result.Actions); + Assert.Equal(3, result.Actions.Count); + Assert.Equal("Target1", result.Actions[0].Target); + Assert.Equal("Description1", result.Actions[0].Description); + Assert.False(result.Actions[0].Remove); + Assert.Equal("Target2", result.Actions[1].Target); + Assert.Equal("Description2", result.Actions[1].Description); + Assert.True(result.Actions[1].Remove); + Assert.Equal("Target3", result.Actions[2].Target); + Assert.Equal("Description3", result.Actions[2].Description); + Assert.False(result.Actions[2].Remove); + } +} \ No newline at end of file diff --git a/tests/lib/Serialization/V1_1/OverlayInfoTests.cs b/tests/lib/Serialization/V1_1/OverlayInfoTests.cs new file mode 100644 index 0000000..c2ac9d8 --- /dev/null +++ b/tests/lib/Serialization/V1_1/OverlayInfoTests.cs @@ -0,0 +1,173 @@ +using System.Text.Json.Nodes; + +using BinkyLabs.OpenApi.Overlays.Reader; +using BinkyLabs.OpenApi.Overlays.Reader.V1_1; + +using Microsoft.OpenApi; +using Microsoft.OpenApi.Reader; + +using ParsingContext = BinkyLabs.OpenApi.Overlays.Reader.ParsingContext; + +namespace BinkyLabs.OpenApi.Overlays.Tests; + +public class OverlayInfoV1_1Tests +{ + [Fact] + public void SerializeAsV1_1_ShouldWriteCorrectJson() + { + // Arrange + var overlayInfo = new OverlayInfo + { + Title = "Test Overlay", + Version = "1.0.0" + }; + using var textWriter = new StringWriter(); + var writer = new OpenApiJsonWriter(textWriter); + + var expectedJson = +""" +{ + "title": "Test Overlay", + "version": "1.0.0" +} +"""; + + // Act + overlayInfo.SerializeAsV1_1(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."); + } + + [Fact] + public void SerializeAsV1_1_WithDescription_ShouldWriteCorrectJson() + { + // Arrange + var overlayInfo = new OverlayInfo + { + Title = "Test Overlay", + Version = "1.1.0", + Description = "Test overlay description" + }; + using var textWriter = new StringWriter(); + var writer = new OpenApiJsonWriter(textWriter); + + var expectedJson = +""" +{ + "title": "Test Overlay", + "version": "1.1.0", + "description": "Test overlay description" +} +"""; + + // Act + overlayInfo.SerializeAsV1_1(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."); + } + + [Fact] + public void Deserialize_ShouldSetPropertiesCorrectly() + { + // Arrange + var json = """ + { + "title": "Test Overlay", + "version": "1.1.0" + } + """; + var jsonNode = JsonNode.Parse(json)!; + var parsingContext = new ParsingContext(new()); + var parseNode = new MapNode(parsingContext, jsonNode); + + + // Act + var overlayInfo = OverlayV1_1Deserializer.LoadInfo(parseNode); + + // Assert + Assert.Equal("Test Overlay", overlayInfo.Title); + Assert.Equal("1.1.0", overlayInfo.Version); + } + + [Fact] + public void Deserialize_WithDescription_ShouldSetPropertiesCorrectly() + { + // Arrange + var json = """ + { + "title": "Test Overlay", + "version": "1.1.0", + "description": "Test overlay description" + } + """; + var jsonNode = JsonNode.Parse(json)!; + var parsingContext = new ParsingContext(new()); + var parseNode = new MapNode(parsingContext, jsonNode); + + // Act + var overlayInfo = OverlayV1_1Deserializer.LoadInfo(parseNode); + + // Assert + Assert.Equal("Test Overlay", overlayInfo.Title); + Assert.Equal("1.1.0", overlayInfo.Version); + Assert.Equal("Test overlay description", overlayInfo.Description); + } + + [Fact] + public void Deserialize_WithExtensionDescription_ShouldIgnoreXDescription() + { + // Arrange - V1.1 should ignore x-description field + var json = """ + { + "title": "Test Overlay", + "version": "1.1.0", + "x-description": "Test overlay description via extension" + } + """; + var jsonNode = JsonNode.Parse(json)!; + var parsingContext = new ParsingContext(new()); + var parseNode = new MapNode(parsingContext, jsonNode); + + // Act + var overlayInfo = OverlayV1_1Deserializer.LoadInfo(parseNode); + + // Assert + Assert.Equal("Test Overlay", overlayInfo.Title); + Assert.Equal("1.1.0", overlayInfo.Version); + Assert.Null(overlayInfo.Description); // x-description should be ignored in V1.1 + } + + [Fact] + public void Deserialize_WithBothDescriptions_ShouldUseStandardDescriptionOnly() + { + // Arrange - When both "description" and "x-description" are present, only "description" should be used (x-description ignored) + var json = """ + { + "title": "Test Overlay", + "version": "1.1.0", + "description": "Standard description", + "x-description": "Extension description" + } + """; + var jsonNode = JsonNode.Parse(json)!; + var parsingContext = new ParsingContext(new()); + var parseNode = new MapNode(parsingContext, jsonNode); + + // Act + var overlayInfo = OverlayV1_1Deserializer.LoadInfo(parseNode); + + // Assert + Assert.Equal("Test Overlay", overlayInfo.Title); + Assert.Equal("1.1.0", overlayInfo.Version); + Assert.Equal("Standard description", overlayInfo.Description); // Only standard field is recognized + } +} \ No newline at end of file diff --git a/tests/lib/Serialization/V1_1/UpgradeTests.cs b/tests/lib/Serialization/V1_1/UpgradeTests.cs new file mode 100644 index 0000000..11688d2 --- /dev/null +++ b/tests/lib/Serialization/V1_1/UpgradeTests.cs @@ -0,0 +1,85 @@ +using System.Text.Json.Nodes; + +using Microsoft.OpenApi; + +namespace BinkyLabs.OpenApi.Overlays.Tests; + +public class UpgradeV1_1Tests +{ + [Fact] + public async Task UpgradesAV1DocumentToV1_1Async() + { + // Given + var inputJson = + """ + { + "overlay": "1.0.0", + "info": { + "title": "Test Overlay", + "version": "1.0.0" + }, + "actions": [ + { + "target": "$.info.title", + "description": "Copy description to title", + "x-copy": "$.info.description" + } + ] + } + """; + // When + var (document, _) = await OverlayDocument.ParseAsync(inputJson); + Assert.NotNull(document); + + using var textWriter = new StringWriter(); + var writer = new OpenApiJsonWriter(textWriter); + document.SerializeAsV1_1(writer); + var jsonResult = textWriter.ToString(); + var jsonResultObject = JsonNode.Parse(jsonResult); + var expectedJsonObject = JsonNode.Parse(inputJson); + expectedJsonObject!["overlay"] = "1.1.0"; + expectedJsonObject!["actions"]![0]!["copy"] = expectedJsonObject["actions"]![0]!["x-copy"]!.DeepClone(); + expectedJsonObject["actions"]![0]!.AsObject().Remove("x-copy"); + + // Then + Assert.True(JsonNode.DeepEquals(jsonResultObject, expectedJsonObject), "The upgraded JSON does not match the expected JSON."); + } + [Fact] + public async Task DowngradesAV1_1DocumentToV1_0Async() + { + // Given + var inputJson = + """ + { + "overlay": "1.1.0", + "info": { + "title": "Test Overlay", + "version": "1.1.0" + }, + "actions": [ + { + "target": "$.info.title", + "description": "Copy description to title", + "copy": "$.info.description" + } + ] + } + """; + // When + var (document, _) = await OverlayDocument.ParseAsync(inputJson); + Assert.NotNull(document); + + using var textWriter = new StringWriter(); + var writer = new OpenApiJsonWriter(textWriter); + document.SerializeAsV1(writer); + var jsonResult = textWriter.ToString(); + var jsonResultObject = JsonNode.Parse(jsonResult); + var expectedJsonObject = JsonNode.Parse(inputJson); + expectedJsonObject!["overlay"] = "1.0.0"; + expectedJsonObject!["actions"]![0]!["x-copy"] = expectedJsonObject["actions"]![0]!["copy"]!.DeepClone(); + expectedJsonObject["actions"]![0]!.AsObject().Remove("copy"); + + // Then + Assert.True(JsonNode.DeepEquals(jsonResultObject, expectedJsonObject), "The downgraded JSON does not match the expected JSON."); + } +} \ No newline at end of file