From 25fb628c9ece3c18b869e8a5bc09592e5b488961 Mon Sep 17 00:00:00 2001 From: Vincent Biret Date: Mon, 1 Jun 2026 09:53:00 -0400 Subject: [PATCH 1/3] test(coverage): exclude test assembly from merged reports Exclude the Microsoft.OpenApi.Tests assembly from coverage collection and add focused OpenApiYamlReader tests for non-memory streams and fragment parsing so the published merged report reflects product code coverage. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../OpenApiYamlReaderTests.cs | 111 ++++++++++++++++++ .../Properties/AssemblyInfo.cs | 3 + 2 files changed, 114 insertions(+) create mode 100644 test/Microsoft.OpenApi.Readers.Tests/OpenApiYamlReaderTests.cs diff --git a/test/Microsoft.OpenApi.Readers.Tests/OpenApiYamlReaderTests.cs b/test/Microsoft.OpenApi.Readers.Tests/OpenApiYamlReaderTests.cs new file mode 100644 index 000000000..6d66430da --- /dev/null +++ b/test/Microsoft.OpenApi.Readers.Tests/OpenApiYamlReaderTests.cs @@ -0,0 +1,111 @@ +using System; +using System.IO; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.OpenApi.Reader; +using Microsoft.OpenApi.YamlReader; +using Xunit; + +namespace Microsoft.OpenApi.Readers.Tests; + +public class OpenApiYamlReaderTests +{ + private static readonly Uri DocumentLocation = new("https://contoso.test/openapi.yaml"); + + [Fact] + public async Task ReadAsyncParsesDocumentsFromNonMemoryStreams() + { + var reader = new OpenApiYamlReader(); + await using var stream = new NonMemoryStream(CreateStream( + """ + openapi: 3.0.1 + info: + title: Sample API + version: 1.0.0 + paths: {} + """)); + + var result = await reader.ReadAsync(stream, DocumentLocation, SettingsFixture.ReaderSettings, CancellationToken.None); + + Assert.NotNull(result.Document); + Assert.Equal("Sample API", result.Document.Info.Title); + Assert.Equal(OpenApiConstants.Yaml, result.Diagnostic.Format); + } + + [Fact] + public void ReadThrowsWhenYamlDoesNotContainADocument() + { + var reader = new OpenApiYamlReader(); + using var stream = CreateStream(string.Empty); + + var exception = Assert.Throws(() => reader.Read(stream, DocumentLocation, SettingsFixture.ReaderSettings)); + + Assert.Equal("No documents found in the YAML stream.", exception.Message); + } + + [Fact] + public void ReadFragmentParsesSchemaFragments() + { + var reader = new OpenApiYamlReader(); + using var stream = CreateStream( + """ + type: string + description: A reusable schema + """); + + var schema = reader.ReadFragment( + stream, + OpenApiSpecVersion.OpenApi3_0, + new OpenApiDocument(), + out var diagnostic); + + Assert.NotNull(schema); + Assert.Empty(diagnostic.Errors); + Assert.Equal(JsonSchemaType.String, schema.Type); + Assert.Equal("A reusable schema", schema.Description); + } + + [Fact] + public void ReadThrowsWhenSettingsIsNull() + { + var reader = new OpenApiYamlReader(); + using var stream = CreateStream("openapi: 3.0.1"); + + Assert.Throws(() => reader.Read(stream, DocumentLocation, null!)); + } + + private static MemoryStream CreateStream(string yaml) + { + return new MemoryStream(Encoding.UTF8.GetBytes(yaml)); + } + + private sealed class NonMemoryStream(Stream innerStream) : Stream + { + public override bool CanRead => innerStream.CanRead; + public override bool CanSeek => innerStream.CanSeek; + public override bool CanWrite => innerStream.CanWrite; + public override long Length => innerStream.Length; + public override long Position + { + get => innerStream.Position; + set => innerStream.Position = value; + } + + public override void Flush() => innerStream.Flush(); + public override int Read(byte[] buffer, int offset, int count) => innerStream.Read(buffer, offset, count); + public override long Seek(long offset, SeekOrigin origin) => innerStream.Seek(offset, origin); + public override void SetLength(long value) => innerStream.SetLength(value); + public override void Write(byte[] buffer, int offset, int count) => innerStream.Write(buffer, offset, count); + public override ValueTask DisposeAsync() => innerStream.DisposeAsync(); + protected override void Dispose(bool disposing) + { + if (disposing) + { + innerStream.Dispose(); + } + + base.Dispose(disposing); + } + } +} diff --git a/test/Microsoft.OpenApi.Tests/Properties/AssemblyInfo.cs b/test/Microsoft.OpenApi.Tests/Properties/AssemblyInfo.cs index 62f4c0e52..c6c6211fb 100644 --- a/test/Microsoft.OpenApi.Tests/Properties/AssemblyInfo.cs +++ b/test/Microsoft.OpenApi.Tests/Properties/AssemblyInfo.cs @@ -1,3 +1,6 @@ // Copyright (c) Microsoft Corporation. All rights reserved. // Licensed under the MIT license. +using System.Diagnostics.CodeAnalysis; + +[assembly: ExcludeFromCodeCoverage] From b8ad0226bc71028792d0b66ac78647d28870ce59 Mon Sep 17 00:00:00 2001 From: Vincent Biret Date: Mon, 1 Jun 2026 09:59:24 -0400 Subject: [PATCH 2/3] test(coverage): add reference and reader edge tests Add focused tests for CopyReferences, OpenApiSecurityRequirement deserialization, and small uncovered exception/helper types without broad fixture duplication. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../OpenApiSecurityRequirementTests.cs | 71 ++++++++++++ .../Exceptions/OpenApiWriterExceptionTests.cs | 27 +++++ .../Models/OpenApiReferenceErrorTests.cs | 37 +++++++ .../Reader/AnyListFieldMapParameterTests.cs | 33 ++++++ .../Services/CopyReferencesTests.cs | 103 ++++++++++++++++++ 5 files changed, 271 insertions(+) create mode 100644 test/Microsoft.OpenApi.Readers.Tests/V2Tests/OpenApiSecurityRequirementTests.cs create mode 100644 test/Microsoft.OpenApi.Tests/Exceptions/OpenApiWriterExceptionTests.cs create mode 100644 test/Microsoft.OpenApi.Tests/Models/OpenApiReferenceErrorTests.cs create mode 100644 test/Microsoft.OpenApi.Tests/Reader/AnyListFieldMapParameterTests.cs create mode 100644 test/Microsoft.OpenApi.Tests/Services/CopyReferencesTests.cs diff --git a/test/Microsoft.OpenApi.Readers.Tests/V2Tests/OpenApiSecurityRequirementTests.cs b/test/Microsoft.OpenApi.Readers.Tests/V2Tests/OpenApiSecurityRequirementTests.cs new file mode 100644 index 000000000..b2113d2a7 --- /dev/null +++ b/test/Microsoft.OpenApi.Readers.Tests/V2Tests/OpenApiSecurityRequirementTests.cs @@ -0,0 +1,71 @@ +using System.Collections.Generic; +using System.IO; +using System.Text.Json.Nodes; +using Microsoft.OpenApi.Reader; +using Microsoft.OpenApi.Reader.V2; +using Microsoft.OpenApi.YamlReader; +using SharpYaml.Serialization; +using Xunit; + +namespace Microsoft.OpenApi.Readers.Tests.V2Tests; + +[Collection("DefaultSettings")] +public class OpenApiSecurityRequirementTests +{ + [Fact] + public void LoadSecurityRequirementResolvesScopesForKnownSchemes() + { + var node = LoadYamlNode( + """ + petstore_auth: + - write:pets + - read:pets + """); + var hostDocument = new OpenApiDocument + { + Components = new OpenApiComponents + { + SecuritySchemes = new Dictionary + { + ["petstore_auth"] = new OpenApiSecurityScheme { Type = SecuritySchemeType.OAuth2 } + } + } + }; + var context = new ParsingContext(new OpenApiDiagnostic()); + + var requirement = OpenApiV2Deserializer.LoadSecurityRequirement(node, hostDocument, context); + + var resolvedScheme = Assert.Single(requirement); + Assert.Equal("petstore_auth", resolvedScheme.Key.Reference.Id); + Assert.Equal(["write:pets", "read:pets"], resolvedScheme.Value); + Assert.Empty(context.Diagnostic.Errors); + } + + [Fact] + public void LoadSecurityRequirementCreatesUnresolvedReferenceWhenSchemeIsMissing() + { + var node = LoadYamlNode( + """ + petstore_auth: + - write:pets + """); + var hostDocument = new OpenApiDocument(); + var context = new ParsingContext(new OpenApiDiagnostic()); + + var requirement = OpenApiV2Deserializer.LoadSecurityRequirement(node, hostDocument, context); + + var unresolvedScheme = Assert.Single(requirement); + Assert.Equal("petstore_auth", unresolvedScheme.Key.Reference.Id); + Assert.True(unresolvedScheme.Key.UnresolvedReference); + Assert.Equal(["write:pets"], unresolvedScheme.Value); + Assert.Empty(context.Diagnostic.Errors); + } + + private static JsonNode LoadYamlNode(string yaml) + { + using var reader = new StringReader(yaml); + var yamlStream = new YamlStream(); + yamlStream.Load(reader); + return yamlStream.Documents[0].RootNode.ToJsonNode(); + } +} diff --git a/test/Microsoft.OpenApi.Tests/Exceptions/OpenApiWriterExceptionTests.cs b/test/Microsoft.OpenApi.Tests/Exceptions/OpenApiWriterExceptionTests.cs new file mode 100644 index 000000000..9829af020 --- /dev/null +++ b/test/Microsoft.OpenApi.Tests/Exceptions/OpenApiWriterExceptionTests.cs @@ -0,0 +1,27 @@ +using System; +using Xunit; + +namespace Microsoft.OpenApi.Tests.Exceptions; + +public class OpenApiWriterExceptionTests +{ + [Fact] + public void DefaultConstructorUsesTheGenericWriterMessage() + { + var exception = new OpenApiWriterException(); + + Assert.Equal(SRResource.OpenApiWriterExceptionGenericError, exception.Message); + Assert.Null(exception.InnerException); + } + + [Fact] + public void ConstructorPreservesMessageAndInnerException() + { + var innerException = new InvalidOperationException("boom"); + + var exception = new OpenApiWriterException("writer failed", innerException); + + Assert.Equal("writer failed", exception.Message); + Assert.Same(innerException, exception.InnerException); + } +} diff --git a/test/Microsoft.OpenApi.Tests/Models/OpenApiReferenceErrorTests.cs b/test/Microsoft.OpenApi.Tests/Models/OpenApiReferenceErrorTests.cs new file mode 100644 index 000000000..1e0ecd2e8 --- /dev/null +++ b/test/Microsoft.OpenApi.Tests/Models/OpenApiReferenceErrorTests.cs @@ -0,0 +1,37 @@ +using Xunit; + +namespace Microsoft.OpenApi.Tests.Models; + +public class OpenApiReferenceErrorTests +{ + [Fact] + public void ConstructorCopiesMessageAndPointerFromException() + { + var exception = new OpenApiException("Reference could not be resolved") + { + Pointer = "#/components/schemas/Pet" + }; + + var error = new OpenApiReferenceError(exception); + + Assert.Equal(exception.Message, error.Message); + Assert.Equal(exception.Pointer, error.Pointer); + Assert.Null(error.Reference); + } + + [Fact] + public void ConstructorStoresTheReferenceThatFailedResolution() + { + var reference = new BaseOpenApiReference + { + Id = "Pet", + Type = ReferenceType.Schema + }; + + var error = new OpenApiReferenceError(reference, "Missing component"); + + Assert.Equal("Missing component", error.Message); + Assert.Equal(string.Empty, error.Pointer); + Assert.Same(reference, error.Reference); + } +} diff --git a/test/Microsoft.OpenApi.Tests/Reader/AnyListFieldMapParameterTests.cs b/test/Microsoft.OpenApi.Tests/Reader/AnyListFieldMapParameterTests.cs new file mode 100644 index 000000000..30c48741f --- /dev/null +++ b/test/Microsoft.OpenApi.Tests/Reader/AnyListFieldMapParameterTests.cs @@ -0,0 +1,33 @@ +using System.Collections.Generic; +using System.Text.Json.Nodes; +using Xunit; +using Microsoft.OpenApi.Reader; + +namespace Microsoft.OpenApi.Tests.Reader; + +public class AnyListFieldMapParameterTests +{ + [Fact] + public void ConstructorStoresPropertyDelegates() + { + var schema = new OpenApiSchema { Type = JsonSchemaType.Array }; + var values = new List { JsonValue.Create("value")! }; + var owner = new TestOwner { Schema = schema }; + var parameter = new AnyListFieldMapParameter( + static current => current.Values, + static (current, currentValues) => current.Values = currentValues, + static current => current.Schema); + + parameter.PropertySetter(owner, values); + + Assert.Same(values, parameter.PropertyGetter(owner)); + Assert.NotNull(parameter.SchemaGetter); + Assert.Same(schema, parameter.SchemaGetter(owner)); + } + + private sealed class TestOwner + { + public List Values { get; set; } = []; + public OpenApiSchema Schema { get; set; } = new(); + } +} diff --git a/test/Microsoft.OpenApi.Tests/Services/CopyReferencesTests.cs b/test/Microsoft.OpenApi.Tests/Services/CopyReferencesTests.cs new file mode 100644 index 000000000..f612c226a --- /dev/null +++ b/test/Microsoft.OpenApi.Tests/Services/CopyReferencesTests.cs @@ -0,0 +1,103 @@ +using System.Collections.Generic; +using System.Net.Http; +using Xunit; + +namespace Microsoft.OpenApi.Tests.Services; + +public class CopyReferencesTests +{ + [Fact] + public void VisitCopiesResolvedReferenceTargetsIntoMatchingComponentCollections() + { + var callback = new OpenApiCallback + { + PathItems = new Dictionary + { + [RuntimeExpression.Build("{$request.body#/callbackUrl}")] = new OpenApiPathItem + { + Operations = new Dictionary + { + [HttpMethod.Post] = new() + { + Responses = new OpenApiResponses + { + ["200"] = new OpenApiResponse { Description = "ok" } + } + } + } + } + } + }; + var link = new OpenApiLink { OperationId = "getUser" }; + var requestBody = new OpenApiRequestBody + { + Required = true, + Content = new Dictionary + { + ["application/json"] = new OpenApiMediaType() + } + }; + var securityScheme = new OpenApiSecurityScheme + { + Type = SecuritySchemeType.ApiKey, + Name = "api-key", + In = ParameterLocation.Header + }; + + var source = new OpenApiDocument + { + Components = new OpenApiComponents + { + Callbacks = new Dictionary { ["callback"] = callback }, + Links = new Dictionary { ["link"] = link }, + RequestBodies = new Dictionary { ["body"] = requestBody }, + SecuritySchemes = new Dictionary { ["scheme"] = securityScheme } + } + }; + source.RegisterComponents(); + var target = new OpenApiDocument(); + var visitor = new CopyReferences(target); + + visitor.Visit((IOpenApiReferenceHolder)new OpenApiCallbackReference("callback", source)); + visitor.Visit((IOpenApiReferenceHolder)new OpenApiLinkReference("link", source)); + visitor.Visit((IOpenApiReferenceHolder)new OpenApiRequestBodyReference("body", source)); + visitor.Visit((IOpenApiReferenceHolder)new OpenApiSecuritySchemeReference("scheme", source)); + + Assert.Same(callback, Assert.Single(visitor.Components.Callbacks).Value); + Assert.Same(link, Assert.Single(visitor.Components.Links).Value); + Assert.Same(requestBody, Assert.Single(visitor.Components.RequestBodies).Value); + Assert.Same(securityScheme, Assert.Single(visitor.Components.SecuritySchemes).Value); + + Assert.NotNull(target.Components); + Assert.NotNull(target.Components.Callbacks); + Assert.NotNull(target.Components.Links); + Assert.NotNull(target.Components.RequestBodies); + Assert.NotNull(target.Components.SecuritySchemes); + } + + [Fact] + public void VisitCopiesSchemaTargetsOnceAndIgnoresMissingReferences() + { + var schema = new OpenApiSchema { Type = JsonSchemaType.Object }; + var source = new OpenApiDocument + { + Components = new OpenApiComponents + { + Schemas = new Dictionary { ["Pet"] = schema } + } + }; + source.RegisterComponents(); + var visitor = new CopyReferences(new OpenApiDocument()); + + visitor.Visit((IOpenApiReferenceHolder)new OpenApiLinkReference("missing", source)); + + var schemaReference = new OpenApiSchemaReference("Pet", source); + visitor.Visit((IOpenApiSchema)schemaReference); + visitor.Visit((IOpenApiSchema)schemaReference); + + Assert.Null(visitor.Components.Links); + var copiedSchema = Assert.Single(visitor.Components.Schemas); + Assert.Equal("Pet", copiedSchema.Key); + Assert.Same(schema, copiedSchema.Value); + } +} From b65bbe573b4971525f953255775f72806ba036a5 Mon Sep 17 00:00:00 2001 From: Vincent Biret Date: Mon, 1 Jun 2026 10:19:16 -0400 Subject: [PATCH 3/3] test(coverage): add reader and walker edge tests Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../OpenApiSpecVersionHelperTests.cs | 31 ++ .../Services/OpenApiFilterServiceTests.cs | 66 ++++ .../StatsVisitorTests.cs | 110 +++++++ .../Utilities/SettingsUtilitiesTests.cs | 44 +++ .../V2Tests/OpenApiDocumentFixupTests.cs | 106 +++++++ .../Reader/BaseOpenApiVersionServiceTests.cs | 76 +++++ .../Reader/OpenApiJsonReaderTests.cs | 105 +++++++ .../Walkers/OpenApiWalkerRichDocumentTests.cs | 289 ++++++++++++++++++ 8 files changed, 827 insertions(+) create mode 100644 test/Microsoft.OpenApi.Hidi.Tests/OpenApiSpecVersionHelperTests.cs create mode 100644 test/Microsoft.OpenApi.Hidi.Tests/StatsVisitorTests.cs create mode 100644 test/Microsoft.OpenApi.Hidi.Tests/Utilities/SettingsUtilitiesTests.cs create mode 100644 test/Microsoft.OpenApi.Readers.Tests/V2Tests/OpenApiDocumentFixupTests.cs create mode 100644 test/Microsoft.OpenApi.Tests/Reader/BaseOpenApiVersionServiceTests.cs create mode 100644 test/Microsoft.OpenApi.Tests/Reader/OpenApiJsonReaderTests.cs create mode 100644 test/Microsoft.OpenApi.Tests/Walkers/OpenApiWalkerRichDocumentTests.cs diff --git a/test/Microsoft.OpenApi.Hidi.Tests/OpenApiSpecVersionHelperTests.cs b/test/Microsoft.OpenApi.Hidi.Tests/OpenApiSpecVersionHelperTests.cs new file mode 100644 index 000000000..da7da8e7a --- /dev/null +++ b/test/Microsoft.OpenApi.Hidi.Tests/OpenApiSpecVersionHelperTests.cs @@ -0,0 +1,31 @@ +#nullable enable +using System; +using Microsoft.OpenApi.Hidi; +using Xunit; + +namespace Microsoft.OpenApi.Hidi.Tests; + +public class OpenApiSpecVersionHelperTests +{ + [Theory] + [InlineData("2.0", OpenApiSpecVersion.OpenApi2_0)] + [InlineData("3.0", OpenApiSpecVersion.OpenApi3_0)] + [InlineData("3.1", OpenApiSpecVersion.OpenApi3_1)] + [InlineData("3.2", OpenApiSpecVersion.OpenApi3_2)] + [InlineData("4.0", OpenApiSpecVersion.OpenApi3_2)] + public void TryParseOpenApiSpecVersionReturnsExpectedVersion(string version, OpenApiSpecVersion expectedVersion) + { + var result = OpenApiSpecVersionHelper.TryParseOpenApiSpecVersion(version); + + Assert.Equal(expectedVersion, result); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData("abc")] + public void TryParseOpenApiSpecVersionThrowsForInvalidValues(string? version) + { + Assert.Throws(() => OpenApiSpecVersionHelper.TryParseOpenApiSpecVersion(version!)); + } +} diff --git a/test/Microsoft.OpenApi.Hidi.Tests/Services/OpenApiFilterServiceTests.cs b/test/Microsoft.OpenApi.Hidi.Tests/Services/OpenApiFilterServiceTests.cs index 483deaf25..8f8aa0c8e 100644 --- a/test/Microsoft.OpenApi.Hidi.Tests/Services/OpenApiFilterServiceTests.cs +++ b/test/Microsoft.OpenApi.Hidi.Tests/Services/OpenApiFilterServiceTests.cs @@ -221,6 +221,72 @@ public void ThrowsInvalidOperationExceptionInCreatePredicateWhenInvalidArguments Assert.Equal("Cannot specify both operationIds and tags at the same time.", message2); } + [Fact] + public void ThrowsInvalidOperationExceptionWhenRequestUrlsAreCombinedWithOtherFilters() + { + var requestUrls = new Dictionary> + { + ["/users"] = ["GET"] + }; + + var message = Assert.Throws(() => + OpenApiFilterService.CreatePredicate("users.user.ListUser", null, requestUrls, _openApiDocumentMock)).Message; + + Assert.Equal("Cannot filter by Postman collection and either operationIds and tags at the same time.", message); + } + + [Fact] + public void ThrowsWhenPredicateDoesNotMatchAnyPath() + { + var source = new OpenApiDocument + { + Info = new() { Title = "Test", Version = "1.0" }, + Paths = new() + { + ["/test"] = new OpenApiPathItem + { + Operations = new() + { + [HttpMethod.Get] = new OpenApiOperation { OperationId = "getTest" } + } + } + } + }; + + var subset = OpenApiFilterService.CreateFilteredDocument(source, static (_, _, _) => false); + + Assert.Empty(subset.Paths); + } + + [Fact] + public void CreatePredicateMatchesAbsoluteUrlsWhenSourceHasNoServers() + { + var source = new OpenApiDocument + { + Info = new() { Title = "Test", Version = "v1" }, + Paths = new() + { + ["/users"] = new OpenApiPathItem + { + Operations = new() + { + [HttpMethod.Get] = new OpenApiOperation { OperationId = "listUsers" } + } + } + } + }; + var requestUrls = new Dictionary> + { + ["https://graph.contoso.com/users"] = ["GET"] + }; + + var predicate = OpenApiFilterService.CreatePredicate(requestUrls: requestUrls, source: source); + var subset = OpenApiFilterService.CreateFilteredDocument(source, predicate); + + Assert.Single(subset.Paths); + Assert.True(subset.Paths.ContainsKey("/users")); + } + [Fact] public async Task CopiesOverAllReferencedComponentsToTheSubsetDocumentCorrectly() { diff --git a/test/Microsoft.OpenApi.Hidi.Tests/StatsVisitorTests.cs b/test/Microsoft.OpenApi.Hidi.Tests/StatsVisitorTests.cs new file mode 100644 index 000000000..763540269 --- /dev/null +++ b/test/Microsoft.OpenApi.Hidi.Tests/StatsVisitorTests.cs @@ -0,0 +1,110 @@ +using System; +using System.Collections.Generic; +using System.Net.Http; +using Xunit; + +namespace Microsoft.OpenApi.Hidi.Tests; + +public class StatsVisitorTests +{ + [Fact] + public void GetStatisticsReportReflectsVisitedElements() + { + var document = new OpenApiDocument + { + Paths = new() + { + ["/pets"] = new OpenApiPathItem + { + Operations = new() + { + [HttpMethod.Post] = new OpenApiOperation + { + Parameters = + [ + new OpenApiParameter + { + Name = "expand", + In = ParameterLocation.Query, + Schema = new OpenApiSchema { Type = JsonSchemaType.String } + } + ], + RequestBody = new OpenApiRequestBody + { + Content = new Dictionary + { + ["application/json"] = new OpenApiMediaType + { + Schema = new OpenApiSchema + { + Type = JsonSchemaType.Object, + Properties = new Dictionary + { + ["name"] = new OpenApiSchema { Type = JsonSchemaType.String } + } + } + } + } + }, + Responses = new OpenApiResponses + { + ["200"] = new OpenApiResponse + { + Headers = new Dictionary + { + ["x-rate-limit"] = new OpenApiHeader + { + Schema = new OpenApiSchema { Type = JsonSchemaType.Integer } + } + }, + Links = new Dictionary + { + ["next"] = new OpenApiLink() + } + } + }, + Callbacks = new Dictionary + { + ["onData"] = new OpenApiCallback + { + PathItems = new Dictionary + { + [RuntimeExpression.Build("$request.body#/callbackUrl")] = new OpenApiPathItem + { + Operations = new() + { + [HttpMethod.Post] = new OpenApiOperation + { + Responses = new OpenApiResponses + { + ["202"] = new OpenApiResponse { Description = "Accepted" } + } + } + } + } + } + } + } + } + } + } + } + }; + + var visitor = new StatsVisitor(); + new OpenApiWalker(visitor).Walk(document); + var report = visitor.GetStatisticsReport(); + + Assert.Equal(2, visitor.PathItemCount); + Assert.Equal(2, visitor.OperationCount); + Assert.Equal(1, visitor.ParameterCount); + Assert.Equal(1, visitor.RequestBodyCount); + Assert.Equal(2, visitor.ResponseCount); + Assert.Equal(1, visitor.LinkCount); + Assert.Equal(1, visitor.CallbackCount); + Assert.Equal(4, visitor.SchemaCount); + Assert.Contains("Path Items: 2", report, StringComparison.Ordinal); + Assert.Contains("Callbacks: 1", report, StringComparison.Ordinal); + Assert.Contains("Schemas: 4", report, StringComparison.Ordinal); + } +} diff --git a/test/Microsoft.OpenApi.Hidi.Tests/Utilities/SettingsUtilitiesTests.cs b/test/Microsoft.OpenApi.Hidi.Tests/Utilities/SettingsUtilitiesTests.cs new file mode 100644 index 000000000..aa31367ae --- /dev/null +++ b/test/Microsoft.OpenApi.Hidi.Tests/Utilities/SettingsUtilitiesTests.cs @@ -0,0 +1,44 @@ +using System; +using System.Collections.Generic; +using Microsoft.Extensions.Configuration; +using Microsoft.OpenApi.Hidi.Utilities; +using Microsoft.OpenApi.OData; +using Xunit; + +namespace Microsoft.OpenApi.Hidi.Tests; + +public class SettingsUtilitiesTests +{ + [Fact] + public void GetOpenApiConvertSettingsThrowsWhenConfigurationIsNull() + { + Assert.Throws(() => SettingsUtilities.GetOpenApiConvertSettings(null!, null)); + } + + [Fact] + public void GetOpenApiConvertSettingsUsesMetadataVersionWhenSectionIsMissing() + { + var configuration = new ConfigurationBuilder().AddInMemoryCollection(new Dictionary()).Build(); + + var settings = SettingsUtilities.GetOpenApiConvertSettings(configuration, "2.1"); + + Assert.Equal("2.1", settings.SemVerVersion); + } + + [Fact] + public void GetOpenApiConvertSettingsBindsConfiguredValuesOverMetadataVersion() + { + var configuration = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary + { + [$"{nameof(OpenApiConvertSettings)}:{nameof(OpenApiConvertSettings.SemVerVersion)}"] = "3.0", + [$"{nameof(OpenApiConvertSettings)}:{nameof(OpenApiConvertSettings.EnablePagination)}"] = bool.TrueString + }) + .Build(); + + var settings = SettingsUtilities.GetOpenApiConvertSettings(configuration, "2.1"); + + Assert.Equal("3.0", settings.SemVerVersion); + Assert.True(settings.EnablePagination); + } +} diff --git a/test/Microsoft.OpenApi.Readers.Tests/V2Tests/OpenApiDocumentFixupTests.cs b/test/Microsoft.OpenApi.Readers.Tests/V2Tests/OpenApiDocumentFixupTests.cs new file mode 100644 index 000000000..2fe466848 --- /dev/null +++ b/test/Microsoft.OpenApi.Readers.Tests/V2Tests/OpenApiDocumentFixupTests.cs @@ -0,0 +1,106 @@ +using System; +using System.IO; +using System.Net.Http; +using System.Text; +using Microsoft.OpenApi.Reader; +using Microsoft.OpenApi.YamlReader; +using Xunit; + +namespace Microsoft.OpenApi.Readers.Tests.V2Tests; + +public class OpenApiDocumentFixupTests +{ + private static readonly Uri DocumentLocation = new("https://contoso.test/swagger.yaml"); + + [Fact] + public void ReadCreatesServersFromHostBasePathAndSchemes() + { + var result = ReadDocument( + """ + swagger: "2.0" + info: + title: Sample API + version: "1.0" + host: api.contoso.com:443 + basePath: /v1/ + schemes: + - https + paths: {} + """); + + Assert.NotNull(result.Document); + Assert.Empty(result.Diagnostic.Errors); + var server = Assert.Single(result.Document.Servers); + Assert.Equal("https://api.contoso.com/v1", server.Url); + } + + [Theory] + [InlineData("https://api.contoso.com")] + [InlineData("api contoso com")] + public void ReadAddsDiagnosticErrorWhenHostIsInvalid(string host) + { + var result = ReadDocument( + $$""" + swagger: "2.0" + info: + title: Sample API + version: "1.0" + host: {{host}} + paths: {} + """); + + Assert.NotNull(result.Document); + var error = Assert.Single(result.Diagnostic.Errors); + Assert.Contains("Invalid host", error.Message, StringComparison.Ordinal); + Assert.Empty(result.Document.Servers); + } + + [Fact] + public void ReadMovesReferencedBodyParametersToRequestBodies() + { + var result = ReadDocument( + """ + swagger: "2.0" + info: + title: Sample API + version: "1.0" + paths: + /pets: + post: + parameters: + - $ref: '#/parameters/PetBody' + responses: + '200': + description: ok + parameters: + PetBody: + name: pet + in: body + required: true + schema: + type: object + properties: + name: + type: string + """); + + Assert.NotNull(result.Document); + Assert.Empty(result.Diagnostic.Errors); + var requestBody = Assert.IsType(result.Document.Paths["/pets"].Operations[HttpMethod.Post].RequestBody); + Assert.Equal("PetBody", requestBody.Reference.Id); + Assert.NotNull(result.Document.Components); + var componentRequestBody = Assert.IsType(result.Document.Components.RequestBodies["PetBody"]); + Assert.True(componentRequestBody.Required); + Assert.Contains("application/json", componentRequestBody.Content.Keys); + Assert.Empty(result.Document.Paths["/pets"].Operations[HttpMethod.Post].Parameters); + } + + private static ReadResult ReadDocument(string yaml) + { + var settings = new OpenApiReaderSettings(); + settings.AddYamlReader(); + using var stream = new MemoryStream(Encoding.UTF8.GetBytes(yaml)); + + return new OpenApiYamlReader().Read(stream, DocumentLocation, settings); + } +} diff --git a/test/Microsoft.OpenApi.Tests/Reader/BaseOpenApiVersionServiceTests.cs b/test/Microsoft.OpenApi.Tests/Reader/BaseOpenApiVersionServiceTests.cs new file mode 100644 index 000000000..7542bd6f8 --- /dev/null +++ b/test/Microsoft.OpenApi.Tests/Reader/BaseOpenApiVersionServiceTests.cs @@ -0,0 +1,76 @@ +#nullable enable +using System; +using System.Collections.Generic; +using System.Text.Json.Nodes; +using Microsoft.OpenApi.Reader; +using Xunit; + +namespace Microsoft.OpenApi.Tests.Reader; + +public class BaseOpenApiVersionServiceTests +{ + [Fact] + public void LoadElementReturnsLoadedElementWhenACompatibleLoaderExists() + { + var service = new TestVersionService(new Dictionary> + { + [typeof(OpenApiInfo)] = static (_, _, _) => new OpenApiInfo { Title = "Sample" } + }); + + var info = service.LoadElement(new JsonObject(), new OpenApiDocument(), new ParsingContext(new OpenApiDiagnostic())); + + Assert.NotNull(info); + Assert.Equal("Sample", info.Title); + } + + [Fact] + public void LoadElementReturnsNullWhenLoaderReturnsADifferentType() + { + var service = new TestVersionService(new Dictionary> + { + [typeof(OpenApiInfo)] = static (_, _, _) => new OpenApiContact { Name = "Contoso" } + }); + + var info = service.LoadElement(new JsonObject(), new OpenApiDocument(), new ParsingContext(new OpenApiDiagnostic())); + + Assert.Null(info); + } + + [Fact] + public void LoadElementReturnsNullWhenNoLoaderIsRegistered() + { + var service = new TestVersionService([]); + + var info = service.LoadElement(new JsonObject(), new OpenApiDocument(), new ParsingContext(new OpenApiDiagnostic())); + + Assert.Null(info); + } + + [Theory] + [InlineData("""{"$ref":"#/components/schemas/Pet"}""", "description", null)] + [InlineData("""{"$ref":"#/components/schemas/Pet","description":"A pet"}""", "description", "A pet")] + [InlineData("""{"$ref":"#/components/schemas/Pet","summary":"ignored"}""", "description", null)] + public void GetReferenceScalarValuesReturnsExpectedScalarValue(string json, string scalarValue, string? expectedValue) + { + var service = new TestVersionService([]); + var jsonObject = JsonNode.Parse(json)!.AsObject(); + + var value = service.GetReferenceScalarValues(jsonObject, scalarValue); + + Assert.Equal(expectedValue, value); + } + + private sealed class TestVersionService(Dictionary> loaders) + : BaseOpenApiVersionService(new OpenApiDiagnostic()) + { + internal override Dictionary> Loaders { get; } = loaders; + + public override OpenApiDocument LoadDocument(JsonNode jsonNode, Uri location, ParsingContext context) + { + return new OpenApiDocument + { + BaseUri = location + }; + } + } +} diff --git a/test/Microsoft.OpenApi.Tests/Reader/OpenApiJsonReaderTests.cs b/test/Microsoft.OpenApi.Tests/Reader/OpenApiJsonReaderTests.cs new file mode 100644 index 000000000..8b0fac733 --- /dev/null +++ b/test/Microsoft.OpenApi.Tests/Reader/OpenApiJsonReaderTests.cs @@ -0,0 +1,105 @@ +using System; +using System.IO; +using System.Text; +using System.Text.Json.Nodes; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.OpenApi.Reader; +using Microsoft.OpenApi.Validations; +using Xunit; + +namespace Microsoft.OpenApi.Tests.Reader; + +public class OpenApiJsonReaderTests +{ + private static readonly Uri DocumentLocation = new("https://contoso.test/openapi.json"); + + [Fact] + public void ReadReturnsDiagnosticWhenJsonIsInvalid() + { + var reader = new OpenApiJsonReader(); + using var stream = CreateStream("{"); + + var result = reader.Read(stream, DocumentLocation, new OpenApiReaderSettings()); + + Assert.Null(result.Document); + var error = Assert.Single(result.Diagnostic.Errors); + Assert.Equal(OpenApiConstants.Json, result.Diagnostic.Format); + Assert.Contains("Expected depth to be zero at the end of the JSON payload.", error.Message, StringComparison.Ordinal); + } + + [Fact] + public async Task ReadAsyncReturnsDiagnosticWhenJsonIsInvalid() + { + var reader = new OpenApiJsonReader(); + await using var stream = CreateStream("{"); + + var result = await reader.ReadAsync(stream, DocumentLocation, new OpenApiReaderSettings(), CancellationToken.None); + + Assert.Null(result.Document); + Assert.Single(result.Diagnostic.Errors); + Assert.Equal(OpenApiConstants.Json, result.Diagnostic.Format); + } + + [Fact] + public void ReadValidatesParsedDocumentAgainstConfiguredRules() + { + var ruleSet = new ValidationRuleSet(); + ruleSet.Add(typeof(OpenApiDocument), new ValidationRule("AlwaysFail", static (context, _) => context.CreateError("rule", "Document failed validation."))); + var reader = new OpenApiJsonReader(); + using var stream = CreateStream("""{"openapi":"3.0.1","info":{"title":"Sample","version":"1.0.0"},"paths":{}}"""); + + var result = reader.Read(stream, DocumentLocation, new OpenApiReaderSettings { RuleSet = ruleSet }); + + Assert.NotNull(result.Document); + var error = Assert.Single(result.Diagnostic.Errors); + Assert.Equal("#/", error.Pointer); + Assert.Equal("Document failed validation.", error.Message); + } + + [Fact] + public void ReadReturnsDiagnosticWhenRootNodeCannotBeParsedAsDocument() + { + var reader = new OpenApiJsonReader(); + + var result = reader.Read(JsonNode.Parse("[]")!, DocumentLocation, new OpenApiReaderSettings()); + + Assert.Null(result.Document); + var error = Assert.Single(result.Diagnostic.Errors); + Assert.Contains("Expected scalar value.", error.Message, StringComparison.Ordinal); + } + + [Fact] + public void ReadFragmentReturnsDiagnosticWhenJsonNodeCannotBeParsedAsSchema() + { + var reader = new OpenApiJsonReader(); + var input = JsonNode.Parse("[]")!; + + var schema = reader.ReadFragment( + input, + OpenApiSpecVersion.OpenApi3_0, + new OpenApiDocument(), + out var diagnostic); + + Assert.Null(schema); + var error = Assert.Single(diagnostic.Errors); + Assert.Contains("schema must be a map/object", error.Message, StringComparison.Ordinal); + } + + [Fact] + public void ReadFragmentReturnsDiagnosticWhenJsonIsInvalid() + { + var reader = new OpenApiJsonReader(); + using var stream = CreateStream("{"); + + var schema = reader.ReadFragment(stream, OpenApiSpecVersion.OpenApi3_0, new OpenApiDocument(), out var diagnostic); + + Assert.Null(schema); + Assert.Single(diagnostic.Errors); + } + + private static MemoryStream CreateStream(string json) + { + return new MemoryStream(Encoding.UTF8.GetBytes(json)); + } +} diff --git a/test/Microsoft.OpenApi.Tests/Walkers/OpenApiWalkerRichDocumentTests.cs b/test/Microsoft.OpenApi.Tests/Walkers/OpenApiWalkerRichDocumentTests.cs new file mode 100644 index 000000000..846119182 --- /dev/null +++ b/test/Microsoft.OpenApi.Tests/Walkers/OpenApiWalkerRichDocumentTests.cs @@ -0,0 +1,289 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Net.Http; +using System.Text.Json.Nodes; +using Xunit; + +namespace Microsoft.OpenApi.Tests.Walkers; + +public class OpenApiWalkerRichDocumentTests +{ + [Fact] + public void WalkTraversesRichDocumentsAcrossComponentsWebhooksAndExtensions() + { + var document = CreateDocument(); + var visitor = new RichWalkerVisitor(); + var walker = new OpenApiWalker(visitor); + + walker.Walk(document); + + Assert.Contains("#/servers/0/variables/tenant", visitor.Locations); + Assert.Contains("#/paths/~1pets/post/callbacks/onData/$request.body#~1callbackUrl/post/responses/202", visitor.Locations); + Assert.Contains("#/webhooks/petCreated/post/requestBody/content/application~1json/schema", visitor.Locations); + Assert.Contains("#/components/requestBodies/PetBody/content/application~1json/schema/properties/name", visitor.Locations); + Assert.Contains("#/components/headers/RateLimit/examples/detailed", visitor.Locations); + Assert.Contains("#/components/links/NextPage/server", visitor.Locations); + Assert.Contains("#/components/examples/PetExample", visitor.Locations); + Assert.Contains("#/externalDocs", visitor.Locations); + Assert.Contains("referenceAt: #/paths/~1pets/post/tags/0", visitor.Locations); + Assert.Contains("referenceAt: #/paths/~1pets/post/tags/1", visitor.Locations); + Assert.Contains("#/paths/~1pets/post/x-operation", visitor.Locations); + Assert.Contains("referenceAt: #/paths/~1pets/post/requestBody", visitor.Locations); + Assert.Contains("referenceAt: #/paths/~1pets/post/responses/200/headers/x-rate-limit", visitor.Locations); + Assert.Contains("referenceAt: #/paths/~1pets/post/responses/200/content/application~1json", visitor.Locations); + Assert.Contains("referenceAt: #/paths/~1pets/post/security/0", visitor.Locations); + } + + private static OpenApiDocument CreateDocument() + { + var document = new OpenApiDocument + { + Info = new OpenApiInfo + { + Title = "Pets", + Version = "1.0.0", + Contact = new OpenApiContact { Name = "Contoso" }, + License = new OpenApiLicense { Name = "MIT" } + }, + Servers = + [ + new OpenApiServer + { + Url = "https://{tenant}.contoso.com", + Variables = new Dictionary + { + ["tenant"] = new() + { + Default = "prod" + } + } + } + ], + ExternalDocs = new OpenApiExternalDocs + { + Url = new Uri("https://contoso.test/docs") + }, + Tags = new HashSet + { + new() { Name = "pets" }, + new() { Name = "store" } + }, + Paths = new OpenApiPaths + { + ["/pets"] = new OpenApiPathItem + { + Operations = new Dictionary + { + [HttpMethod.Post] = new() + { + RequestBody = new OpenApiRequestBodyReference("PetBody"), + Responses = new OpenApiResponses + { + ["200"] = new OpenApiResponse + { + Headers = new Dictionary + { + ["x-rate-limit"] = new OpenApiHeaderReference("RateLimit") + }, + Content = new Dictionary + { + ["application/json"] = new OpenApiMediaTypeReference("PetMediaType") + } + } + }, + Callbacks = new Dictionary + { + ["onData"] = new OpenApiCallback + { + PathItems = new Dictionary + { + [RuntimeExpression.Build("$request.body#/callbackUrl")] = new OpenApiPathItem + { + Operations = new Dictionary + { + [HttpMethod.Post] = new() + { + Responses = new OpenApiResponses + { + ["202"] = new OpenApiResponse + { + Description = "Accepted" + } + } + } + } + } + } + } + }, + Security = + [ + new OpenApiSecurityRequirement + { + [new OpenApiSecuritySchemeReference("oauth")] = ["pets.read"] + } + ], + Extensions = new Dictionary + { + ["x-operation"] = new JsonNodeExtension("tracked") + } + } + } + } + }, + Webhooks = new Dictionary + { + ["petCreated"] = new OpenApiPathItem + { + Operations = new Dictionary + { + [HttpMethod.Post] = new() + { + RequestBody = new OpenApiRequestBody + { + Content = new Dictionary + { + ["application/json"] = new OpenApiMediaType + { + Schema = new OpenApiSchema + { + Type = JsonSchemaType.String + } + } + } + }, + Responses = new OpenApiResponses + { + ["204"] = new OpenApiResponse + { + Description = "No content" + } + } + } + } + } + }, + Components = new OpenApiComponents + { + RequestBodies = new Dictionary + { + ["PetBody"] = new OpenApiRequestBody + { + Required = true, + Content = new Dictionary + { + ["application/json"] = new OpenApiMediaType + { + Schema = new OpenApiSchema + { + Type = JsonSchemaType.Object, + Properties = new Dictionary + { + ["name"] = new OpenApiSchema { Type = JsonSchemaType.String } + }, + AdditionalProperties = new OpenApiSchema { Type = JsonSchemaType.String }, + Not = new OpenApiSchema { Type = JsonSchemaType.Boolean }, + AllOf = [new OpenApiSchemaReference("Pet")], + AnyOf = [new OpenApiSchemaReference("Pet")], + OneOf = [new OpenApiSchemaReference("Cat")], + Discriminator = new OpenApiDiscriminator + { + PropertyName = "kind", + Mapping = new Dictionary + { + ["cat"] = new("Cat") + } + } + } + } + } + } + }, + Headers = new Dictionary + { + ["RateLimit"] = new OpenApiHeader + { + Schema = new OpenApiSchema { Type = JsonSchemaType.Integer }, + Example = JsonValue.Create(100), + Examples = new Dictionary + { + ["detailed"] = new OpenApiExample + { + Value = JsonValue.Create(200) + } + } + } + }, + Links = new Dictionary + { + ["NextPage"] = new OpenApiLink + { + Server = new OpenApiServer + { + Url = "https://next.contoso.com" + } + } + }, + Examples = new Dictionary + { + ["PetExample"] = new OpenApiExample + { + Value = JsonNode.Parse("""{"name":"Fluffy"}""") + } + }, + MediaTypes = new Dictionary + { + ["PetMediaType"] = new OpenApiMediaTypeReference("PetMediaType") + }, + SecuritySchemes = new Dictionary + { + ["oauth"] = new OpenApiSecurityScheme + { + Type = SecuritySchemeType.OAuth2 + } + }, + Schemas = new Dictionary + { + ["Pet"] = new OpenApiSchema + { + Type = JsonSchemaType.Object + }, + ["Cat"] = new OpenApiSchema + { + Type = JsonSchemaType.Object + } + } + } + }; + + document.Paths["/pets"].Operations[HttpMethod.Post].Tags = new HashSet + { + new("pets", document), + new("store", document) + }; + + return document; + } + + private sealed class RichWalkerVisitor : OpenApiVisitorBase + { + public List Locations { get; } = []; + + public override void Visit(OpenApiExternalDocs externalDocs) => Locations.Add(PathString); + public override void Visit(OpenApiServer server) => Locations.Add(PathString); + public override void Visit(OpenApiServerVariable serverVariable) => Locations.Add(PathString); + public override void Visit(IOpenApiRequestBody requestBody) => Locations.Add(PathString); + public override void Visit(IOpenApiResponse response) => Locations.Add(PathString); + public override void Visit(IOpenApiMediaType mediaType) => Locations.Add(PathString); + public override void Visit(IOpenApiSchema schema) => Locations.Add(PathString); + public override void Visit(IOpenApiCallback callback) => Locations.Add(PathString); + public override void Visit(IOpenApiLink link) => Locations.Add(PathString); + public override void Visit(IOpenApiExample example) => Locations.Add(PathString); + public override void Visit(IOpenApiExtension extension) => Locations.Add(PathString); + public override void Visit(JsonNode node) => Locations.Add(PathString); + public override void Visit(OpenApiTag tag) => Locations.Add(PathString); + public override void Visit(OpenApiTagReference tag) => Locations.Add(PathString); + public override void Visit(IOpenApiReferenceHolder referenceable) => Locations.Add("referenceAt: " + PathString); + } +}