diff --git a/src/Elastic.ApiExplorer/OpenApiGenerator.cs b/src/Elastic.ApiExplorer/OpenApiGenerator.cs index 0b12ae923..1d67737cb 100644 --- a/src/Elastic.ApiExplorer/OpenApiGenerator.cs +++ b/src/Elastic.ApiExplorer/OpenApiGenerator.cs @@ -24,6 +24,18 @@ namespace Elastic.ApiExplorer; internal sealed record VersionedOpenApiDocument(ResolvedApiVersion Version, OpenApiDocument Document); +internal sealed record ResolvedProductDocuments( + IReadOnlyList Documents, + string? UnmatchedBaseFilesMoniker); + +internal sealed record ApiProductGeneration( + string Prefix, + OpenApiDocument Document, + ResolvedApiConfiguration? ApiConfig, + IReadOnlyList VersionSwitcherItems, + string Moniker, + bool EmitUnmatchedBaseFiles); + /// /// Renders API explorer pages for every configured OpenAPI specification: builds the navigation /// tree via and writes each page to the output directory. @@ -96,17 +108,26 @@ public Task GenerateCatalog(IReadOnlyList entries, Cancel ctx = ResolvedApiConfiguration apiConfig, Cancel ctx) { - var versionedDocuments = await ResolveDocumentsForProduct(prefix, apiConfig, ctx).ConfigureAwait(false); - if (versionedDocuments.Count == 0) + var resolved = await ResolveDocumentsForProduct(prefix, apiConfig, ctx).ConfigureAwait(false); + if (resolved.Documents.Count == 0) return null; + var versionedDocuments = resolved.Documents; var monikers = versionedDocuments.Select(v => v.Version.Moniker).ToArray(); foreach (var versioned in versionedDocuments) { var switcherItems = ApiVersionSwitcher.Build( context.UrlPathPrefix, prefix, monikers, versioned.Version.Moniker); var apiUrlSuffix = ApiUrlBuilder.ProductSuffix(prefix, versioned.Version.Moniker); - await GenerateApiProduct(apiUrlSuffix, versioned.Document, apiConfig, switcherItems, ctx) + await GenerateApiProduct( + new( + apiUrlSuffix, + versioned.Document, + apiConfig, + switcherItems, + versioned.Version.Moniker, + EmitUnmatchedBaseFiles: versioned.Version.Moniker == resolved.UnmatchedBaseFilesMoniker), + ctx) .ConfigureAwait(false); } @@ -121,9 +142,11 @@ await GenerateApiProduct(apiUrlSuffix, versioned.Document, apiConfig, switcherIt /// /// Resolves every OpenAPI document to render for one API key, including canonical main - /// and released numeric majors. Returns an empty list when nothing could be resolved. + /// and released numeric majors. Returns empty documents when nothing could be resolved. + /// is the declared latest + /// version only when that document actually resolved. /// - internal async Task> ResolveDocumentsForProduct( + internal async Task ResolveDocumentsForProduct( string apiKey, ResolvedApiConfiguration apiConfig, Cancel ctx) @@ -140,7 +163,7 @@ internal async Task> ResolveDocumentsFor : [.. versions]; if (versionsToRender.Length == 0) - return []; + return new([], null); if (!versionless && versionsToRender.All(v => v.Moniker != "main") && versions.Count > 0) { @@ -148,6 +171,10 @@ internal async Task> ResolveDocumentsFor $"Version index for API '{apiKey}' has no 'main' entry; the unversioned path will not be rendered."); } + var latestDeclared = versionsToRender.Any(v => v.Moniker == "main") + ? "main" + : versionsToRender[0].Moniker; + var results = new List(versionsToRender.Length); foreach (var version in versionsToRender) { @@ -158,18 +185,18 @@ internal async Task> ResolveDocumentsFor results.Add(new VersionedOpenApiDocument(version, document)); } - return results; + return ToResolvedProductDocuments(results, latestDeclared); } - private async Task> ResolveLocalMainOnly(IFileInfo localFile) + private async Task ResolveLocalMainOnly(IFileInfo localFile) { var document = await _openApiReader.ReadAsync(localFile).ConfigureAwait(false); if (document is null) - return []; + return new([], null); - return + VersionedOpenApiDocument[] documents = [ - new VersionedOpenApiDocument( + new( new ResolvedApiVersion { Moniker = "main", @@ -179,8 +206,16 @@ private async Task> ResolveLocalMainOnly }, document) ]; + return ToResolvedProductDocuments(documents, "main"); } + private static ResolvedProductDocuments ToResolvedProductDocuments( + IReadOnlyList documents, + string latestDeclared) => + new( + documents, + documents.Any(d => d.Version.Moniker == latestDeclared) ? latestDeclared : null); + private static bool IsVersionlessProduct(Product product) => product.VersioningSystem?.IsVersionless == true; @@ -222,26 +257,26 @@ private async Task GenerateApiCatalog(IReadOnlyList entries, Ca _ = await Render(navigation.Index, navigation.Index.Model, renderContext, navigationRenderer, ctx).ConfigureAwait(false); } - private async Task GenerateApiProduct( - string prefix, - OpenApiDocument openApiDocument, - ResolvedApiConfiguration? apiConfig, - IReadOnlyList versionSwitcherItems, - Cancel ctx) + private async Task GenerateApiProduct(ApiProductGeneration generation, Cancel ctx) { - var discovery = DiscoverSupplemental(openApiDocument, apiConfig); - var navigation = CreateNavigation(prefix, openApiDocument, apiConfig); - _logger.LogInformation("Generating OpenApiDocument {Title}", openApiDocument.Info?.Title ?? ""); + var discovery = DiscoverSupplemental(generation.Document, generation.ApiConfig); + ApiSupplementalValidator.Validate(discovery, new( + generation.Document, + context.Collector, + generation.Moniker, + EmitUnmatchedBaseFiles: generation.EmitUnmatchedBaseFiles)); + var navigation = CreateNavigation(generation.Prefix, generation.Document, generation.ApiConfig); + _logger.LogInformation("Generating OpenApiDocument {Title}", generation.Document.Info?.Title ?? ""); var navigationRenderer = new IsolatedBuildNavigationHtmlWriter(context, navigation); - var renderContext = new ApiRenderContext(context, openApiDocument, _contentHashProvider) + var renderContext = new ApiRenderContext(context, generation.Document, _contentHashProvider) { NavigationHtml = string.Empty, CurrentNavigation = navigation, MarkdownRenderer = markdownStringRenderer, ApiExplorerLog = _logger, - VersionSwitcherItems = versionSwitcherItems, + VersionSwitcherItems = generation.VersionSwitcherItems, OperationSupplemental = ApiSupplementalDoc.Load(discovery.Operations), TagSupplemental = ApiSupplementalDoc.Load(discovery.Tags) }; diff --git a/src/Elastic.ApiExplorer/Supplemental/ApiSupplementalDiscovery.cs b/src/Elastic.ApiExplorer/Supplemental/ApiSupplementalDiscovery.cs index 327423157..f15e60dab 100644 --- a/src/Elastic.ApiExplorer/Supplemental/ApiSupplementalDiscovery.cs +++ b/src/Elastic.ApiExplorer/Supplemental/ApiSupplementalDiscovery.cs @@ -39,9 +39,9 @@ public static ApiSupplementalDiscoveryResult Discover( public static ApiSupplementalDiscoveryResult Discover(IDirectoryInfo? folder, OpenApiDocument document) { - var (operationIds, tagNames) = CollectEntities(document); + var (operationsById, tagNames) = CollectEntities(document); var (tagBySlug, collisions) = IndexTags(tagNames); - return MatchFiles(folder, operationIds, tagBySlug, collisions); + return MatchFiles(folder, operationsById.Keys.ToHashSet(StringComparer.Ordinal), tagBySlug, collisions); } private static ApiSupplementalDiscoveryResult MatchFiles( @@ -98,9 +98,10 @@ private static ApiSupplementalDiscoveryResult MatchFiles( }; } - private static (HashSet OperationIds, HashSet TagNames) CollectEntities(OpenApiDocument document) + internal static (Dictionary OperationsById, HashSet TagNames) CollectEntities( + OpenApiDocument document) { - var operations = new HashSet(StringComparer.Ordinal); + var operations = new Dictionary(StringComparer.Ordinal); var tags = new HashSet(StringComparer.Ordinal); if (document.Tags is not null) @@ -120,7 +121,7 @@ private static (HashSet OperationIds, HashSet TagNames) CollectE foreach (var operation in path.Value.Operations.Values) { if (!string.IsNullOrWhiteSpace(operation.OperationId)) - _ = operations.Add(operation.OperationId); + operations[operation.OperationId] = operation; if (operation.Tags is null) continue; @@ -140,7 +141,7 @@ private static (HashSet OperationIds, HashSet TagNames) CollectE private static string? OperationTagName(OpenApiTagReference tagRef) => !string.IsNullOrEmpty(tagRef.Name) ? tagRef.Name : tagRef.Reference?.Id; - private static (Dictionary UniqueBySlug, IReadOnlyList Collisions) IndexTags( + internal static (Dictionary UniqueBySlug, IReadOnlyList Collisions) IndexTags( IReadOnlyCollection tagNames) { var bySlug = new Dictionary>(StringComparer.Ordinal); diff --git a/src/Elastic.ApiExplorer/Supplemental/ApiSupplementalValidator.cs b/src/Elastic.ApiExplorer/Supplemental/ApiSupplementalValidator.cs new file mode 100644 index 000000000..eb8360e36 --- /dev/null +++ b/src/Elastic.ApiExplorer/Supplemental/ApiSupplementalValidator.cs @@ -0,0 +1,202 @@ +// Licensed to Elasticsearch B.V under one or more agreements. +// Elasticsearch B.V licenses this file to you under the Apache 2.0 License. +// See the LICENSE file in the project root for more information + +using System.IO.Abstractions; +using Elastic.ApiExplorer.Model; +using Elastic.Documentation.Diagnostics; +using Microsoft.OpenApi; + +namespace Elastic.ApiExplorer.Supplemental; + +internal sealed record ApiSupplementalValidationRequest( + OpenApiDocument Document, + IDiagnosticsCollector Collector, + string Moniker, + bool EmitUnmatchedBaseFiles); + +internal static class ApiSupplementalValidator +{ + public static void Validate( + ApiSupplementalDiscoveryResult discovery, + ApiSupplementalValidationRequest request) + { + if (request.EmitUnmatchedBaseFiles) + EmitUnmatched(discovery.Unmatched, request.Collector, "the latest spec"); + + var (operationsById, tagNames) = ApiSupplementalDiscovery.CollectEntities(request.Document); + if (int.TryParse(request.Moniker, out var major)) + { + var (uniqueBySlug, _) = ApiSupplementalDiscovery.IndexTags(tagNames); + var tagSlugs = new HashSet(uniqueBySlug.Keys, StringComparer.Ordinal); + ValidateVersionSuffixed( + discovery.VersionSuffixed, major, operationsById, tagSlugs, request.Document, request.Collector); + } + + ValidateOperationOverrides(discovery.Operations, operationsById, request); + } + + private static void EmitUnmatched( + IReadOnlyList unmatched, + IDiagnosticsCollector collector, + string specLabel) + { + foreach (var file in unmatched) + EmitUnmatchedFile(file, collector, specLabel); + } + + private static void EmitUnmatchedFile(IFileInfo file, IDiagnosticsCollector collector, string specLabel) + { + var kind = file.Name.StartsWith("op-", StringComparison.OrdinalIgnoreCase) + ? "operationId" + : "tag"; + collector.EmitError(file, $"API supplemental file '{file.Name}' does not match any {kind} in {specLabel}"); + } + + private static void ValidateVersionSuffixed( + IReadOnlyList versionSuffixed, + int major, + IReadOnlyDictionary operationsById, + IReadOnlySet tagSlugs, + OpenApiDocument document, + IDiagnosticsCollector collector) + { + var analyzer = new SchemaAnalyzer(document); + var specLabel = $"version {major}"; + foreach (var versioned in versionSuffixed) + { + if (versioned.Name.VersionMajor != major) + continue; + + if (versioned.Name.Kind == ApiSupplementalKind.Operation) + { + if (!operationsById.TryGetValue(versioned.Name.Stem, out var operation)) + { + EmitUnmatchedFile(versioned.File, collector, specLabel); + continue; + } + + ValidateFileOverrides(versioned.File, operation, analyzer, collector, specLabel); + continue; + } + + if (!tagSlugs.Contains(versioned.Name.Stem)) + EmitUnmatchedFile(versioned.File, collector, specLabel); + } + } + + private static void ValidateOperationOverrides( + IReadOnlyDictionary operationFiles, + IReadOnlyDictionary operationsById, + ApiSupplementalValidationRequest request) + { + var analyzer = new SchemaAnalyzer(request.Document); + var specLabel = SpecLabel(request.Moniker); + foreach (var (operationId, file) in operationFiles) + { + if (!operationsById.TryGetValue(operationId, out var operation)) + continue; + + ValidateFileOverrides(file, operation, analyzer, request.Collector, specLabel); + } + } + + private static string SpecLabel(string moniker) => + int.TryParse(moniker, out var major) ? $"version {major}" : "the latest spec"; + + private static void ValidateFileOverrides( + IFileInfo file, + OpenApiOperation operation, + SchemaAnalyzer analyzer, + IDiagnosticsCollector collector, + string specLabel) + { + var doc = ApiSupplementalDoc.Parse(file.FileSystem.File.ReadAllText(file.FullName)); + if (doc is null) + return; + + ValidateOverrideKeys(file, operation, analyzer, collector, doc, specLabel); + } + + private static void ValidateOverrideKeys( + IFileInfo file, + OpenApiOperation operation, + SchemaAnalyzer analyzer, + IDiagnosticsCollector collector, + ApiSupplementalDoc doc, + string specLabel) + { + var operationId = operation.OperationId ?? ""; + if (doc.ParameterOverrides.Count > 0) + { + var parameterNames = ParameterNames(operation); + foreach (var key in doc.ParameterOverrides.Keys) + { + if (!parameterNames.Contains(key)) + collector.EmitError(file, $"API supplemental: Parameter '{key}' not found in operation '{operationId}' in {specLabel}"); + } + } + + if (doc.RequestBodyOverrides.Count > 0) + { + var bodyNames = RequestBodyFieldNames(analyzer, operation); + foreach (var key in doc.RequestBodyOverrides.Keys) + { + if (!bodyNames.Contains(key)) + collector.EmitError(file, $"API supplemental: Request body field '{key}' not found in operation '{operationId}' in {specLabel}"); + } + } + } + + private static HashSet ParameterNames(OpenApiOperation operation) + { + var names = new HashSet(StringComparer.OrdinalIgnoreCase); + foreach (var parameter in operation.Parameters ?? []) + { + if (!string.IsNullOrEmpty(parameter.Name)) + _ = names.Add(parameter.Name); + } + + return names; + } + + private static HashSet RequestBodyFieldNames(SchemaAnalyzer analyzer, OpenApiOperation operation) + { + var names = new HashSet(StringComparer.OrdinalIgnoreCase); + var schema = operation.RequestBody?.Content?.FirstOrDefault().Value?.Schema; + CollectFieldNames(analyzer, schema, names, []); + return names; + } + + private static void CollectFieldNames( + SchemaAnalyzer analyzer, + IOpenApiSchema? schema, + HashSet names, + HashSet visited) + { + var resolved = analyzer.ResolveSchema(schema); + if (resolved is null || !visited.Add(resolved)) + return; + + var properties = analyzer.GetSchemaProperties(resolved); + if (properties is not null) + { + foreach (var (name, child) in properties) + { + _ = names.Add(name); + CollectFieldNames(analyzer, child, names, visited); + } + } + + if (resolved.Items is not null) + CollectFieldNames(analyzer, resolved.Items, names, visited); + + if (resolved.AdditionalProperties is IOpenApiSchema additional) + CollectFieldNames(analyzer, additional, names, visited); + + foreach (var option in resolved.OneOf ?? []) + CollectFieldNames(analyzer, option, names, visited); + foreach (var option in resolved.AnyOf ?? []) + CollectFieldNames(analyzer, option, names, visited); + } +} diff --git a/tests/Elastic.ApiExplorer.Tests/OpenApiGeneratorCurrentSpecResolutionTests.cs b/tests/Elastic.ApiExplorer.Tests/OpenApiGeneratorCurrentSpecResolutionTests.cs index a7e8404e7..7840284c9 100644 --- a/tests/Elastic.ApiExplorer.Tests/OpenApiGeneratorCurrentSpecResolutionTests.cs +++ b/tests/Elastic.ApiExplorer.Tests/OpenApiGeneratorCurrentSpecResolutionTests.cs @@ -71,8 +71,8 @@ public async Task ResolveDocumentsForProduct_VersionlessLocalSpec_RendersLocalFi versionIndexClient, reader); - var documents = await generator.ResolveDocumentsForProduct( - "cloud-serverless", ApiConfig(product, localFile), TestContext.Current.CancellationToken); + var documents = (await generator.ResolveDocumentsForProduct( + "cloud-serverless", ApiConfig(product, localFile), TestContext.Current.CancellationToken)).Documents; documents.Should().ContainSingle().Which.Document.Should().BeSameAs(expectedDocument); handler.CallCount.Should().Be(0, "a versionless local spec must short-circuit remote version resolution"); @@ -112,8 +112,8 @@ public async Task ResolveDocumentsForProduct_NoLocalSpec_ResolvesRemoteMainThrou var errorsBeforeResolution = collector.Errors; - var documents = await generator.ResolveDocumentsForProduct( - "elasticsearch", ApiConfig(product), TestContext.Current.CancellationToken); + var documents = (await generator.ResolveDocumentsForProduct( + "elasticsearch", ApiConfig(product), TestContext.Current.CancellationToken)).Documents; documents.Should().ContainSingle().Which.Document.Should().BeSameAs(expectedDocument); handler.RequestedPaths.Should().BeEquivalentTo( @@ -145,8 +145,8 @@ public async Task ResolveDocumentsForProduct_NoLocalSpecAndIndexUnreachable_Retu reader); var errorsBeforeResolution = collector.Errors; - var documents = await generator.ResolveDocumentsForProduct( - "elasticsearch", ApiConfig(product), TestContext.Current.CancellationToken); + var documents = (await generator.ResolveDocumentsForProduct( + "elasticsearch", ApiConfig(product), TestContext.Current.CancellationToken)).Documents; documents.Should().BeEmpty(); collector.Errors.Should().BeGreaterThan(errorsBeforeResolution); diff --git a/tests/Elastic.ApiExplorer.Tests/OpenApiGeneratorMultiVersionTests.cs b/tests/Elastic.ApiExplorer.Tests/OpenApiGeneratorMultiVersionTests.cs index 1f85d086f..2390c8142 100644 --- a/tests/Elastic.ApiExplorer.Tests/OpenApiGeneratorMultiVersionTests.cs +++ b/tests/Elastic.ApiExplorer.Tests/OpenApiGeneratorMultiVersionTests.cs @@ -96,7 +96,7 @@ public async Task ResolveDocumentsForProduct_MultiMajorIndex_ResolvesMainAndNume SpecDocument("Elasticsearch 8")); var generator = CreateGenerator(context, versionIndexClient, reader); - var documents = await generator.ResolveDocumentsForProduct("elasticsearch", ApiConfig(product), TestContext.Current.CancellationToken); + var documents = (await generator.ResolveDocumentsForProduct("elasticsearch", ApiConfig(product), TestContext.Current.CancellationToken)).Documents; documents.Should().HaveCount(3); documents.Select(d => d.Version.Moniker).Should().BeEquivalentTo(["main", "9", "8"]); @@ -123,7 +123,7 @@ public async Task ResolveDocumentsForProduct_VersionlessProduct_RendersMainOnly( var generator = CreateGenerator(context, versionIndexClient, reader); var apiConfig = ApiConfig(product, specFileName: "elastic-cloud-serverless.yml", repository: "elastic/serverless-api-specification"); - var documents = await generator.ResolveDocumentsForProduct("cloud-serverless", apiConfig, TestContext.Current.CancellationToken); + var documents = (await generator.ResolveDocumentsForProduct("cloud-serverless", apiConfig, TestContext.Current.CancellationToken)).Documents; documents.Should().ContainSingle(); documents[0].Version.Moniker.Should().Be("main"); @@ -146,7 +146,7 @@ public async Task ResolveDocumentsForProduct_LocalMainAndRemoteHistoricalVersion .ReturnsLazily((Stream _, string _) => SpecDocument("Elasticsearch remote")); var generator = CreateGenerator(context, versionIndexClient, reader); - var documents = await generator.ResolveDocumentsForProduct("elasticsearch", ApiConfig(product, localFile), TestContext.Current.CancellationToken); + var documents = (await generator.ResolveDocumentsForProduct("elasticsearch", ApiConfig(product, localFile), TestContext.Current.CancellationToken)).Documents; documents.Should().HaveCount(3); documents.Should().ContainSingle(d => d.Version.Moniker == "main" && d.Document == localDocument); @@ -156,6 +156,44 @@ public async Task ResolveDocumentsForProduct_LocalMainAndRemoteHistoricalVersion A.CallTo(() => reader.ReadAsync(A._, "elasticsearch-openapi.json")).MustHaveHappened(2, Times.Exactly); } + [Fact] + public async Task ResolveDocumentsForProduct_MainFetchFails_DoesNotMarkOlderSpecForUnmatchedBaseFiles() + { + var collector = new DiagnosticsCollector([]); + var stack = TestHelpers.CreateStackVersionsConfiguration(currentMajor: 9); + var product = TestHelpers.CreateProduct("elasticsearch", stack.GetVersioningSystem(VersioningSystemId.Stack)); + var context = CreateContext(collector, stack, ProductsFor(product), GitForElasticsearch()); + var handler = new StubHandler(request => + { + var path = request.RequestUri!.AbsolutePath; + if (path.EndsWith("index.json", StringComparison.Ordinal)) + return IndexResponse(/*lang=json,strict*/ """ + { + "elastic/elasticsearch": { + "elasticsearch-openapi.json": { + "main": { "version": "main" }, + "9": { "version": "9.4" }, + "8": { "version": "8.19" } + } + } + } + """); + if (path.Contains("/main/", StringComparison.Ordinal)) + return new HttpResponseMessage(HttpStatusCode.NotFound); + return SpecResponse(); + }); + using var versionIndexClient = new VersionIndexClient(BaseUri, handler, maxAttempts: 1, sleep: (_, _) => Task.CompletedTask); + var reader = CreateSequentialReader( + SpecDocument("Elasticsearch 9"), + SpecDocument("Elasticsearch 8")); + var generator = CreateGenerator(context, versionIndexClient, reader); + + var resolved = await generator.ResolveDocumentsForProduct("elasticsearch", ApiConfig(product), TestContext.Current.CancellationToken); + + resolved.Documents.Select(d => d.Version.Moniker).Should().BeEquivalentTo(["9", "8"]); + resolved.UnmatchedBaseFilesMoniker.Should().BeNull(); + } + [Fact] public void CreateNavigation_VersionedSuffix_UsesVersionPrefixedUrls() { diff --git a/tests/Elastic.ApiExplorer.Tests/Supplemental/ApiSupplementalValidationTests.cs b/tests/Elastic.ApiExplorer.Tests/Supplemental/ApiSupplementalValidationTests.cs new file mode 100644 index 000000000..8cc094bf4 --- /dev/null +++ b/tests/Elastic.ApiExplorer.Tests/Supplemental/ApiSupplementalValidationTests.cs @@ -0,0 +1,327 @@ +// Licensed to Elasticsearch B.V under one or more agreements. +// Elasticsearch B.V licenses this file to you under the Apache 2.0 License. +// See the LICENSE file in the project root for more information + +using System.IO.Abstractions; +using System.IO.Abstractions.TestingHelpers; +using AwesomeAssertions; +using Elastic.ApiExplorer.Supplemental; +using Elastic.Documentation; +using Elastic.Documentation.Diagnostics; +using Microsoft.OpenApi; + +namespace Elastic.ApiExplorer.Tests.Supplemental; + +public class ApiSupplementalValidationTests(ApiExplorerFixture fixture) : IClassFixture +{ + private const string Folder = "/docs/api/fixture"; + + [Fact] + public void Validate_UnmatchedOperationFileOnLatest_EmitsErrorNamingFile() + { + var collector = Validate(FolderWith(("op-does-not-exist.md", "# supplemental")), fixture.Document, "main"); + + collector.ErrorMessages.Should().ContainSingle(m => + m.Contains("op-does-not-exist.md") && m.Contains("does not match any operationId in the latest spec")); + } + + [Fact] + public void Validate_UnmatchedTagFileOnLatest_EmitsError() + { + var collector = Validate(FolderWith(("tag-does-not-exist.md", "# supplemental")), fixture.Document, "main"); + + collector.ErrorMessages.Should().ContainSingle(m => + m.Contains("tag-does-not-exist.md") && m.Contains("does not match any tag in the latest spec")); + } + + [Fact] + public void Validate_IgnoredFileOnLatest_EmitsNoError() + { + var collector = Validate(FolderWith(("random-notes.md", "# notes")), fixture.Document, "main"); + + collector.Errors.Should().Be(0); + } + + [Fact] + public void Validate_KnownOperationWithNoUnknownKeys_EmitsNoError() + { + var collector = Validate(FolderWith(("op-search.md", "Returns hits that match the query.")), fixture.Document, "main"); + + collector.Errors.Should().Be(0); + } + + [Fact] + public void Validate_UnknownParameterOnLatest_EmitsErrorNamingOperationAndParameter() + { + var collector = Validate(FolderWith(("op-search.md", """ + ## Parameters + + : `nope` + Not a search parameter. + """)), fixture.Document, "main"); + + collector.ErrorMessages.Should().ContainSingle(m => + m.Contains("Parameter 'nope'") && m.Contains("operation 'search'") && m.Contains("the latest spec")); + } + + [Fact] + public void Validate_UnknownRequestBodyFieldOnLatest_EmitsError() + { + var collector = Validate(FolderWith(("op-search.md", """ + ## Request body + + : `query` + Known field. + + : `fields` + Also a known field. + + : `nope_field` + Not a request body field. + """)), fixture.Document, "main"); + + collector.ErrorMessages.Should().ContainSingle() + .Which.Should().Contain("Request body field 'nope_field'").And.Contain("operation 'search'") + .And.Contain("the latest spec"); + } + + [Fact] + public void Validate_NestedRequestBodyField_EmitsNoError() + { + var collector = Validate(FolderWith(("op-search.md", """ + ## Request body + + : `bool` + Nested under query; the renderer matches by leaf name. + """)), SpecWithNestedRequestBody("search", "query", "bool"), "main"); + + collector.Errors.Should().Be(0); + } + + [Fact] + public void Validate_ListedRealParameter_EmitsNoError() + { + var collector = Validate(FolderWith(("op-search.md", """ + ## Parameters + + : `q` + A query in the Lucene query string syntax. + """)), fixture.Document, "main"); + + collector.Errors.Should().Be(0); + } + + [Fact] + public void Validate_UnmatchedBaseFileOnOlderVersion_EmitsNoError() + { + var collector = Validate(FolderWith(("op-search.md", "# supplemental")), SpecWith("ping"), "8"); + + collector.Errors.Should().Be(0); + } + + [Fact] + public void Validate_UnmatchedBaseFileWhenLatestIsNumeric_EmitsError() + { + var collector = Validate( + FolderWith(("op-does-not-exist.md", "# supplemental")), + SpecWith("ping"), + "8", + emitUnmatchedBaseFiles: true); + + collector.ErrorMessages.Should().ContainSingle(m => + m.Contains("op-does-not-exist.md") && m.Contains("does not match any operationId in the latest spec")); + } + + [Fact] + public void Validate_UnmatchedBaseFileWhenLatestMonikerIsNonNumeric_EmitsError() + { + var collector = Validate( + FolderWith(("op-does-not-exist.md", "# supplemental")), + SpecWith("ping"), + "next", + emitUnmatchedBaseFiles: true); + + collector.ErrorMessages.Should().ContainSingle(m => + m.Contains("op-does-not-exist.md") && m.Contains("does not match any operationId in the latest spec")); + } + + [Fact] + public void Validate_UnknownParameterOnNonNumericLatest_EmitsError() + { + var collector = Validate(FolderWith(("op-search.md", """ + ## Parameters + + : `nope` + Not a search parameter. + """)), fixture.Document, "next"); + + collector.ErrorMessages.Should().ContainSingle(m => + m.Contains("Parameter 'nope'") && m.Contains("operation 'search'") && m.Contains("the latest spec")); + } + + [Fact] + public void Validate_UnknownParameterOnOlderVersionMatchedBaseFile_EmitsError() + { + var collector = Validate(FolderWith(("op-search.md", """ + ## Parameters + + : `pretty` + Removed in this version. + """)), SpecWith("search", "q"), "8"); + + collector.ErrorMessages.Should().ContainSingle(m => + m.Contains("Parameter 'pretty'") && m.Contains("operation 'search'") && m.Contains("version 8")); + } + + [Fact] + public void Validate_VersionSuffixedUnknownOperation_EmitsErrorNamingVersion() + { + var collector = Validate(FolderWith(("op-nope.v8.md", "# supplemental")), SpecWith("ping"), "8"); + + collector.ErrorMessages.Should().ContainSingle(m => + m.Contains("op-nope.v8.md") && m.Contains("does not match any operationId in version 8")); + } + + [Fact] + public void Validate_VersionSuffixedMatchingOperation_EmitsNoUnmatchedError() + { + var collector = Validate(FolderWith(("op-search.v8.md", "Returns hits that match the query.")), fixture.Document, "8"); + + collector.Errors.Should().Be(0); + } + + [Fact] + public void Validate_VersionSuffixedUnknownTag_EmitsErrorNamingVersion() + { + var collector = Validate(FolderWith(("tag-nope.v8.md", "# supplemental")), SpecWith("ping"), "8"); + + collector.ErrorMessages.Should().ContainSingle(m => + m.Contains("tag-nope.v8.md") && m.Contains("does not match any tag in version 8")); + } + + [Fact] + public void Validate_VersionSuffixedTagSlugCollision_EmitsError() + { + var spec = SpecWith("ping"); + spec.Tags = new HashSet + { + new() { Name = "foo bar" }, + new() { Name = "foo-bar" } + }; + + var collector = Validate(FolderWith(("tag-foo-bar.v8.md", "# supplemental")), spec, "8"); + + collector.ErrorMessages.Should().ContainSingle(m => + m.Contains("tag-foo-bar.v8.md") && m.Contains("does not match any tag in version 8")); + } + + [Fact] + public void Validate_VersionSuffixedUnknownParameter_EmitsError() + { + var collector = Validate(FolderWith(("op-search.v8.md", """ + ## Parameters + + : `nope` + Not a search parameter. + """)), fixture.Document, "8"); + + collector.ErrorMessages.Should().ContainSingle(m => + m.Contains("Parameter 'nope'") && m.Contains("operation 'search'") && m.Contains("version 8")); + } + + private static CapturingDiagnosticsCollector Validate( + IDirectoryInfo folder, + OpenApiDocument document, + string moniker) => + Validate(folder, document, moniker, emitUnmatchedBaseFiles: moniker == "main"); + + private static CapturingDiagnosticsCollector Validate( + IDirectoryInfo folder, + OpenApiDocument document, + string moniker, + bool emitUnmatchedBaseFiles) + { + var discovery = ApiSupplementalDiscovery.Discover(folder, document); + var collector = new CapturingDiagnosticsCollector(); + ApiSupplementalValidator.Validate(discovery, new( + document, + collector, + moniker, + EmitUnmatchedBaseFiles: emitUnmatchedBaseFiles)); + return collector; + } + + private static IDirectoryInfo FolderWith(params (string Name, string Body)[] files) + { + var data = files.ToDictionary(f => $"{Folder}/{f.Name}", f => new MockFileData(f.Body)); + return new MockFileSystem(data).DirectoryInfo.New(Folder); + } + + private static OpenApiDocument SpecWith(string operationId, params string[] parameterNames) => new() + { + Info = new OpenApiInfo { Title = "t", Version = "1" }, + Paths = new OpenApiPaths + { + ["/x"] = new OpenApiPathItem + { + Operations = new Dictionary + { + [HttpMethod.Get] = new() + { + OperationId = operationId, + Tags = new HashSet { new("core") }, + Parameters = parameterNames + .Select(name => (IOpenApiParameter)new OpenApiParameter { Name = name, In = ParameterLocation.Query }) + .ToList(), + Responses = new OpenApiResponses { ["200"] = new OpenApiResponse { Description = "ok" } } + } + } + } + } + }; + + private static OpenApiDocument SpecWithNestedRequestBody(string operationId, string parent, string nested) + { + var document = SpecWith(operationId); + document.Paths["/x"].Operations![HttpMethod.Get].RequestBody = new OpenApiRequestBody + { + Content = new Dictionary + { + ["application/json"] = new OpenApiMediaType + { + Schema = new OpenApiSchema + { + Properties = new Dictionary + { + [parent] = new OpenApiSchema + { + Properties = new Dictionary + { + [nested] = new OpenApiSchema { Type = JsonSchemaType.Object } + } + } + } + } + } + } + }; + return document; + } + + private sealed class CapturingDiagnosticsCollector() : DiagnosticsCollector([]) + { + private readonly List _captured = []; + + public IEnumerable ErrorMessages => + _captured.Where(d => d.Severity == Severity.Error).Select(d => d.Message); + + public override void Write(Diagnostic diagnostic) + { + IncrementSeverityCount(diagnostic); + _captured.Add(diagnostic); + } + + public override DiagnosticsCollector StartAsync(Cancel ctx) => this; + public override Task StopAsync(Cancel cancellationToken) => Task.CompletedTask; + } +}