From 5a059073c158a82ec6d5e9914627c5e25b50ff2c Mon Sep 17 00:00:00 2001 From: Jan Calanog Date: Wed, 26 Aug 2026 14:15:47 +0200 Subject: [PATCH 01/13] ApiExplorer: fail the build on invalid supplemental files Unmatched op/tag files and unknown parameter or request-body keys must error at generate time so authors catch typos before publish. Co-authored-by: Cursor --- src/Elastic.ApiExplorer/OpenApiGenerator.cs | 4 +- .../Supplemental/ApiSupplementalDiscovery.cs | 11 +- .../Supplemental/ApiSupplementalValidator.cs | 161 ++++++++++++++ .../ApiSupplementalValidationTests.cs | 201 ++++++++++++++++++ 4 files changed, 371 insertions(+), 6 deletions(-) create mode 100644 src/Elastic.ApiExplorer/Supplemental/ApiSupplementalValidator.cs create mode 100644 tests/Elastic.ApiExplorer.Tests/Supplemental/ApiSupplementalValidationTests.cs diff --git a/src/Elastic.ApiExplorer/OpenApiGenerator.cs b/src/Elastic.ApiExplorer/OpenApiGenerator.cs index 0b12ae923..536949be3 100644 --- a/src/Elastic.ApiExplorer/OpenApiGenerator.cs +++ b/src/Elastic.ApiExplorer/OpenApiGenerator.cs @@ -106,7 +106,7 @@ public Task GenerateCatalog(IReadOnlyList entries, Cancel ctx = 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(apiUrlSuffix, versioned.Document, apiConfig, switcherItems, versioned.Version.Moniker, ctx) .ConfigureAwait(false); } @@ -227,9 +227,11 @@ private async Task GenerateApiProduct( OpenApiDocument openApiDocument, ResolvedApiConfiguration? apiConfig, IReadOnlyList versionSwitcherItems, + string moniker, Cancel ctx) { var discovery = DiscoverSupplemental(openApiDocument, apiConfig); + ApiSupplementalValidator.Validate(discovery, openApiDocument, context.Collector, moniker); var navigation = CreateNavigation(prefix, openApiDocument, apiConfig); _logger.LogInformation("Generating OpenApiDocument {Title}", openApiDocument.Info?.Title ?? ""); diff --git a/src/Elastic.ApiExplorer/Supplemental/ApiSupplementalDiscovery.cs b/src/Elastic.ApiExplorer/Supplemental/ApiSupplementalDiscovery.cs index 327423157..61fe736df 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; diff --git a/src/Elastic.ApiExplorer/Supplemental/ApiSupplementalValidator.cs b/src/Elastic.ApiExplorer/Supplemental/ApiSupplementalValidator.cs new file mode 100644 index 000000000..394d342a8 --- /dev/null +++ b/src/Elastic.ApiExplorer/Supplemental/ApiSupplementalValidator.cs @@ -0,0 +1,161 @@ +// 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.Infrastructure; +using Elastic.ApiExplorer.Model; +using Elastic.Documentation.Diagnostics; +using Microsoft.OpenApi; + +namespace Elastic.ApiExplorer.Supplemental; + +internal static class ApiSupplementalValidator +{ + public static void Validate( + ApiSupplementalDiscoveryResult discovery, + OpenApiDocument document, + IDiagnosticsCollector collector, + string moniker) + { + var (operationsById, tagNames) = ApiSupplementalDiscovery.CollectEntities(document); + if (moniker == "main") + { + EmitUnmatched(discovery.Unmatched, collector, "the latest spec"); + ValidateOperationOverrides(discovery.Operations, operationsById, document, collector); + return; + } + + if (!int.TryParse(moniker, out var major)) + return; + + var tagSlugs = new HashSet(tagNames.Select(ApiUrlBuilder.TagSlug), StringComparer.Ordinal); + ValidateVersionSuffixed(discovery.VersionSuffixed, major, operationsById, tagSlugs, document, collector); + } + + 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); + continue; + } + + if (!tagSlugs.Contains(versioned.Name.Stem)) + EmitUnmatchedFile(versioned.File, collector, specLabel); + } + } + + private static void ValidateOperationOverrides( + IReadOnlyDictionary operationFiles, + IReadOnlyDictionary operationsById, + OpenApiDocument document, + IDiagnosticsCollector collector) + { + var analyzer = new SchemaAnalyzer(document); + foreach (var (operationId, file) in operationFiles) + { + if (!operationsById.TryGetValue(operationId, out var operation)) + continue; + + ValidateFileOverrides(file, operation, analyzer, collector); + } + } + + private static void ValidateFileOverrides( + IFileInfo file, + OpenApiOperation operation, + SchemaAnalyzer analyzer, + IDiagnosticsCollector collector) + { + var doc = ApiSupplementalDoc.Parse(file.FileSystem.File.ReadAllText(file.FullName)); + if (doc is null) + return; + + ValidateOverrideKeys(file, operation, analyzer, collector, doc); + } + + private static void ValidateOverrideKeys( + IFileInfo file, + OpenApiOperation operation, + SchemaAnalyzer analyzer, + IDiagnosticsCollector collector, + ApiSupplementalDoc doc) + { + 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}'"); + } + } + + 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}'"); + } + } + } + + 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 schema = operation.RequestBody?.Content?.FirstOrDefault().Value?.Schema; + var properties = analyzer.GetSchemaProperties(schema); + return new HashSet(properties?.Keys ?? [], StringComparer.OrdinalIgnoreCase); + } +} diff --git a/tests/Elastic.ApiExplorer.Tests/Supplemental/ApiSupplementalValidationTests.cs b/tests/Elastic.ApiExplorer.Tests/Supplemental/ApiSupplementalValidationTests.cs new file mode 100644 index 000000000..2afe4b657 --- /dev/null +++ b/tests/Elastic.ApiExplorer.Tests/Supplemental/ApiSupplementalValidationTests.cs @@ -0,0 +1,201 @@ +// 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'")); + } + + [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'"); + } + + [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_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_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'")); + } + + private static CapturingDiagnosticsCollector Validate( + IDirectoryInfo folder, + OpenApiDocument document, + string moniker) + { + var discovery = ApiSupplementalDiscovery.Discover(folder, document); + var collector = new CapturingDiagnosticsCollector(); + ApiSupplementalValidator.Validate(discovery, document, collector, moniker); + 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) => 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") }, + Responses = new OpenApiResponses { ["200"] = new OpenApiResponse { Description = "ok" } } + } + } + } + } + }; + + 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; + } +} From 106618da1103b45af6e7998a0d2427bba3916684 Mon Sep 17 00:00:00 2001 From: Jan Calanog Date: Wed, 26 Aug 2026 14:30:54 +0200 Subject: [PATCH 02/13] fix: validate base-file override keys on older versions (per review by @github-actions) Co-authored-by: Cursor --- .../Supplemental/ApiSupplementalValidator.cs | 13 ++++++------- .../ApiSupplementalValidationTests.cs | 19 ++++++++++++++++++- 2 files changed, 24 insertions(+), 8 deletions(-) diff --git a/src/Elastic.ApiExplorer/Supplemental/ApiSupplementalValidator.cs b/src/Elastic.ApiExplorer/Supplemental/ApiSupplementalValidator.cs index 394d342a8..3efcdfbae 100644 --- a/src/Elastic.ApiExplorer/Supplemental/ApiSupplementalValidator.cs +++ b/src/Elastic.ApiExplorer/Supplemental/ApiSupplementalValidator.cs @@ -20,17 +20,16 @@ public static void Validate( { var (operationsById, tagNames) = ApiSupplementalDiscovery.CollectEntities(document); if (moniker == "main") - { EmitUnmatched(discovery.Unmatched, collector, "the latest spec"); - ValidateOperationOverrides(discovery.Operations, operationsById, document, collector); - return; + else if (int.TryParse(moniker, out var major)) + { + var tagSlugs = new HashSet(tagNames.Select(ApiUrlBuilder.TagSlug), StringComparer.Ordinal); + ValidateVersionSuffixed(discovery.VersionSuffixed, major, operationsById, tagSlugs, document, collector); } - - if (!int.TryParse(moniker, out var major)) + else return; - var tagSlugs = new HashSet(tagNames.Select(ApiUrlBuilder.TagSlug), StringComparer.Ordinal); - ValidateVersionSuffixed(discovery.VersionSuffixed, major, operationsById, tagSlugs, document, collector); + ValidateOperationOverrides(discovery.Operations, operationsById, document, collector); } private static void EmitUnmatched( diff --git a/tests/Elastic.ApiExplorer.Tests/Supplemental/ApiSupplementalValidationTests.cs b/tests/Elastic.ApiExplorer.Tests/Supplemental/ApiSupplementalValidationTests.cs index 2afe4b657..e0e609e02 100644 --- a/tests/Elastic.ApiExplorer.Tests/Supplemental/ApiSupplementalValidationTests.cs +++ b/tests/Elastic.ApiExplorer.Tests/Supplemental/ApiSupplementalValidationTests.cs @@ -105,6 +105,20 @@ public void Validate_UnmatchedBaseFileOnOlderVersion_EmitsNoError() collector.Errors.Should().Be(0); } + [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'")); + } + [Fact] public void Validate_VersionSuffixedUnknownOperation_EmitsErrorNamingVersion() { @@ -162,7 +176,7 @@ private static IDirectoryInfo FolderWith(params (string Name, string Body)[] fil return new MockFileSystem(data).DirectoryInfo.New(Folder); } - private static OpenApiDocument SpecWith(string operationId) => new() + private static OpenApiDocument SpecWith(string operationId, params string[] parameterNames) => new() { Info = new OpenApiInfo { Title = "t", Version = "1" }, Paths = new OpenApiPaths @@ -175,6 +189,9 @@ private static IDirectoryInfo FolderWith(params (string Name, string Body)[] fil { 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" } } } } From a81c1b5b349a6373589422a8975a09bbf356b30d Mon Sep 17 00:00:00 2001 From: Jan Calanog Date: Wed, 26 Aug 2026 14:44:43 +0200 Subject: [PATCH 03/13] fix: emit unmatched base files when main is absent (per review by @github-actions) Numeric-only products never hit the main unmatched check, so invalid op-/tag- files could slip through. Co-Authored-By: Claude Sonnet 4.6 (1M context) Co-authored-by: Cursor --- src/Elastic.ApiExplorer/OpenApiGenerator.cs | 14 ++++++++++++-- .../Supplemental/ApiSupplementalValidator.cs | 14 +++++++++----- .../ApiSupplementalValidationTests.cs | 19 +++++++++++++++++-- 3 files changed, 38 insertions(+), 9 deletions(-) diff --git a/src/Elastic.ApiExplorer/OpenApiGenerator.cs b/src/Elastic.ApiExplorer/OpenApiGenerator.cs index 536949be3..1573d08ab 100644 --- a/src/Elastic.ApiExplorer/OpenApiGenerator.cs +++ b/src/Elastic.ApiExplorer/OpenApiGenerator.cs @@ -101,12 +101,20 @@ public Task GenerateCatalog(IReadOnlyList entries, Cancel ctx = return null; var monikers = versionedDocuments.Select(v => v.Version.Moniker).ToArray(); + var hasMain = monikers.Contains("main"); 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, versioned.Version.Moniker, ctx) + await GenerateApiProduct( + apiUrlSuffix, + versioned.Document, + apiConfig, + switcherItems, + versioned.Version.Moniker, + emitUnmatchedBaseFiles: !hasMain && versioned.Version.Moniker == monikers[0], + ctx) .ConfigureAwait(false); } @@ -228,10 +236,12 @@ private async Task GenerateApiProduct( ResolvedApiConfiguration? apiConfig, IReadOnlyList versionSwitcherItems, string moniker, + bool emitUnmatchedBaseFiles, Cancel ctx) { var discovery = DiscoverSupplemental(openApiDocument, apiConfig); - ApiSupplementalValidator.Validate(discovery, openApiDocument, context.Collector, moniker); + ApiSupplementalValidator.Validate( + discovery, openApiDocument, context.Collector, moniker, emitUnmatchedBaseFiles: emitUnmatchedBaseFiles); var navigation = CreateNavigation(prefix, openApiDocument, apiConfig); _logger.LogInformation("Generating OpenApiDocument {Title}", openApiDocument.Info?.Title ?? ""); diff --git a/src/Elastic.ApiExplorer/Supplemental/ApiSupplementalValidator.cs b/src/Elastic.ApiExplorer/Supplemental/ApiSupplementalValidator.cs index 3efcdfbae..f93457f84 100644 --- a/src/Elastic.ApiExplorer/Supplemental/ApiSupplementalValidator.cs +++ b/src/Elastic.ApiExplorer/Supplemental/ApiSupplementalValidator.cs @@ -16,18 +16,22 @@ public static void Validate( ApiSupplementalDiscoveryResult discovery, OpenApiDocument document, IDiagnosticsCollector collector, - string moniker) + string moniker, + bool emitUnmatchedBaseFiles = false) { var (operationsById, tagNames) = ApiSupplementalDiscovery.CollectEntities(document); - if (moniker == "main") + var isNumeric = int.TryParse(moniker, out var major); + if (moniker != "main" && !isNumeric) + return; + + if (moniker == "main" || emitUnmatchedBaseFiles) EmitUnmatched(discovery.Unmatched, collector, "the latest spec"); - else if (int.TryParse(moniker, out var major)) + + if (isNumeric) { var tagSlugs = new HashSet(tagNames.Select(ApiUrlBuilder.TagSlug), StringComparer.Ordinal); ValidateVersionSuffixed(discovery.VersionSuffixed, major, operationsById, tagSlugs, document, collector); } - else - return; ValidateOperationOverrides(discovery.Operations, operationsById, document, collector); } diff --git a/tests/Elastic.ApiExplorer.Tests/Supplemental/ApiSupplementalValidationTests.cs b/tests/Elastic.ApiExplorer.Tests/Supplemental/ApiSupplementalValidationTests.cs index e0e609e02..f54e5d7a9 100644 --- a/tests/Elastic.ApiExplorer.Tests/Supplemental/ApiSupplementalValidationTests.cs +++ b/tests/Elastic.ApiExplorer.Tests/Supplemental/ApiSupplementalValidationTests.cs @@ -105,6 +105,19 @@ public void Validate_UnmatchedBaseFileOnOlderVersion_EmitsNoError() 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_UnknownParameterOnOlderVersionMatchedBaseFile_EmitsError() { @@ -162,11 +175,13 @@ Not a search parameter. private static CapturingDiagnosticsCollector Validate( IDirectoryInfo folder, OpenApiDocument document, - string moniker) + string moniker, + bool emitUnmatchedBaseFiles = false) { var discovery = ApiSupplementalDiscovery.Discover(folder, document); var collector = new CapturingDiagnosticsCollector(); - ApiSupplementalValidator.Validate(discovery, document, collector, moniker); + ApiSupplementalValidator.Validate( + discovery, document, collector, moniker, emitUnmatchedBaseFiles: emitUnmatchedBaseFiles); return collector; } From bd16aaa0e6518386e25449c8eae9da2b2c6f2338 Mon Sep 17 00:00:00 2001 From: Jan Calanog Date: Wed, 26 Aug 2026 15:14:35 +0200 Subject: [PATCH 04/13] fix: emit unmatched base files for non-numeric latest monikers (per review by @github-actions) The unmatched flag was ignored when the first rendered version was not main or numeric. Co-Authored-By: Claude Sonnet 4.6 (1M context) Co-authored-by: Cursor --- .../Supplemental/ApiSupplementalValidator.cs | 8 ++++---- .../Supplemental/ApiSupplementalValidationTests.cs | 13 +++++++++++++ 2 files changed, 17 insertions(+), 4 deletions(-) diff --git a/src/Elastic.ApiExplorer/Supplemental/ApiSupplementalValidator.cs b/src/Elastic.ApiExplorer/Supplemental/ApiSupplementalValidator.cs index f93457f84..9f6838145 100644 --- a/src/Elastic.ApiExplorer/Supplemental/ApiSupplementalValidator.cs +++ b/src/Elastic.ApiExplorer/Supplemental/ApiSupplementalValidator.cs @@ -19,14 +19,14 @@ public static void Validate( string moniker, bool emitUnmatchedBaseFiles = false) { - var (operationsById, tagNames) = ApiSupplementalDiscovery.CollectEntities(document); + if (moniker == "main" || emitUnmatchedBaseFiles) + EmitUnmatched(discovery.Unmatched, collector, "the latest spec"); + var isNumeric = int.TryParse(moniker, out var major); if (moniker != "main" && !isNumeric) return; - if (moniker == "main" || emitUnmatchedBaseFiles) - EmitUnmatched(discovery.Unmatched, collector, "the latest spec"); - + var (operationsById, tagNames) = ApiSupplementalDiscovery.CollectEntities(document); if (isNumeric) { var tagSlugs = new HashSet(tagNames.Select(ApiUrlBuilder.TagSlug), StringComparer.Ordinal); diff --git a/tests/Elastic.ApiExplorer.Tests/Supplemental/ApiSupplementalValidationTests.cs b/tests/Elastic.ApiExplorer.Tests/Supplemental/ApiSupplementalValidationTests.cs index f54e5d7a9..3fadb812b 100644 --- a/tests/Elastic.ApiExplorer.Tests/Supplemental/ApiSupplementalValidationTests.cs +++ b/tests/Elastic.ApiExplorer.Tests/Supplemental/ApiSupplementalValidationTests.cs @@ -118,6 +118,19 @@ public void Validate_UnmatchedBaseFileWhenLatestIsNumeric_EmitsError() 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_UnknownParameterOnOlderVersionMatchedBaseFile_EmitsError() { From e57bca09e7e9b4e1ae0ddf22afc765aec253bfcc Mon Sep 17 00:00:00 2001 From: Jan Calanog Date: Wed, 26 Aug 2026 15:34:16 +0200 Subject: [PATCH 05/13] fix: validate overrides on every spec (per review by @github-actions) Remove the moniker early return so unmatched and override checks are independent. Version-suffixed files stay numeric-only. Co-Authored-By: Claude Sonnet 4.6 (1M context) Co-authored-by: Cursor --- src/Elastic.ApiExplorer/OpenApiGenerator.cs | 3 ++- .../Supplemental/ApiSupplementalValidator.cs | 10 +++------ .../ApiSupplementalValidationTests.cs | 22 +++++++++++++++++-- 3 files changed, 25 insertions(+), 10 deletions(-) diff --git a/src/Elastic.ApiExplorer/OpenApiGenerator.cs b/src/Elastic.ApiExplorer/OpenApiGenerator.cs index 1573d08ab..fecb3ffc0 100644 --- a/src/Elastic.ApiExplorer/OpenApiGenerator.cs +++ b/src/Elastic.ApiExplorer/OpenApiGenerator.cs @@ -113,7 +113,8 @@ await GenerateApiProduct( apiConfig, switcherItems, versioned.Version.Moniker, - emitUnmatchedBaseFiles: !hasMain && versioned.Version.Moniker == monikers[0], + emitUnmatchedBaseFiles: versioned.Version.Moniker == "main" + || (!hasMain && versioned.Version.Moniker == monikers[0]), ctx) .ConfigureAwait(false); } diff --git a/src/Elastic.ApiExplorer/Supplemental/ApiSupplementalValidator.cs b/src/Elastic.ApiExplorer/Supplemental/ApiSupplementalValidator.cs index 9f6838145..f96344e35 100644 --- a/src/Elastic.ApiExplorer/Supplemental/ApiSupplementalValidator.cs +++ b/src/Elastic.ApiExplorer/Supplemental/ApiSupplementalValidator.cs @@ -17,17 +17,13 @@ public static void Validate( OpenApiDocument document, IDiagnosticsCollector collector, string moniker, - bool emitUnmatchedBaseFiles = false) + bool emitUnmatchedBaseFiles) { - if (moniker == "main" || emitUnmatchedBaseFiles) + if (emitUnmatchedBaseFiles) EmitUnmatched(discovery.Unmatched, collector, "the latest spec"); - var isNumeric = int.TryParse(moniker, out var major); - if (moniker != "main" && !isNumeric) - return; - var (operationsById, tagNames) = ApiSupplementalDiscovery.CollectEntities(document); - if (isNumeric) + if (int.TryParse(moniker, out var major)) { var tagSlugs = new HashSet(tagNames.Select(ApiUrlBuilder.TagSlug), StringComparer.Ordinal); ValidateVersionSuffixed(discovery.VersionSuffixed, major, operationsById, tagSlugs, document, collector); diff --git a/tests/Elastic.ApiExplorer.Tests/Supplemental/ApiSupplementalValidationTests.cs b/tests/Elastic.ApiExplorer.Tests/Supplemental/ApiSupplementalValidationTests.cs index 3fadb812b..842f1195d 100644 --- a/tests/Elastic.ApiExplorer.Tests/Supplemental/ApiSupplementalValidationTests.cs +++ b/tests/Elastic.ApiExplorer.Tests/Supplemental/ApiSupplementalValidationTests.cs @@ -131,6 +131,20 @@ public void Validate_UnmatchedBaseFileWhenLatestMonikerIsNonNumeric_EmitsError() 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'")); + } + [Fact] public void Validate_UnknownParameterOnOlderVersionMatchedBaseFile_EmitsError() { @@ -189,12 +203,16 @@ private static CapturingDiagnosticsCollector Validate( IDirectoryInfo folder, OpenApiDocument document, string moniker, - bool emitUnmatchedBaseFiles = false) + bool? emitUnmatchedBaseFiles = null) { var discovery = ApiSupplementalDiscovery.Discover(folder, document); var collector = new CapturingDiagnosticsCollector(); ApiSupplementalValidator.Validate( - discovery, document, collector, moniker, emitUnmatchedBaseFiles: emitUnmatchedBaseFiles); + discovery, + document, + collector, + moniker, + emitUnmatchedBaseFiles: emitUnmatchedBaseFiles ?? moniker == "main"); return collector; } From 7bb1472a0cbd243c4d9e1552650edda212d19201 Mon Sep 17 00:00:00 2001 From: Jan Calanog Date: Wed, 26 Aug 2026 15:37:52 +0200 Subject: [PATCH 06/13] fix: add parentheses for analyzer clarity Co-Authored-By: Claude Sonnet 4.6 (1M context) Co-authored-by: Cursor --- .../Supplemental/ApiSupplementalValidationTests.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/Elastic.ApiExplorer.Tests/Supplemental/ApiSupplementalValidationTests.cs b/tests/Elastic.ApiExplorer.Tests/Supplemental/ApiSupplementalValidationTests.cs index 842f1195d..ded2c70c0 100644 --- a/tests/Elastic.ApiExplorer.Tests/Supplemental/ApiSupplementalValidationTests.cs +++ b/tests/Elastic.ApiExplorer.Tests/Supplemental/ApiSupplementalValidationTests.cs @@ -212,7 +212,7 @@ private static CapturingDiagnosticsCollector Validate( document, collector, moniker, - emitUnmatchedBaseFiles: emitUnmatchedBaseFiles ?? moniker == "main"); + emitUnmatchedBaseFiles: emitUnmatchedBaseFiles ?? (moniker == "main")); return collector; } From 772ca70acfcfa00c4d39c92463634b4ee70bef21 Mon Sep 17 00:00:00 2001 From: Jan Calanog Date: Wed, 26 Aug 2026 15:51:48 +0200 Subject: [PATCH 07/13] fix: accept nested request-body override keys (per review by @copilot-pull-request-reviewer) The renderer matches overrides by leaf name at any depth, so top-level-only validation was a false error. Co-Authored-By: Claude Sonnet 4.6 (1M context) Co-authored-by: Cursor --- .../Supplemental/ApiSupplementalValidator.cs | 37 ++++++++++++++++- .../ApiSupplementalValidationTests.cs | 41 +++++++++++++++++++ 2 files changed, 76 insertions(+), 2 deletions(-) diff --git a/src/Elastic.ApiExplorer/Supplemental/ApiSupplementalValidator.cs b/src/Elastic.ApiExplorer/Supplemental/ApiSupplementalValidator.cs index f96344e35..613e66f94 100644 --- a/src/Elastic.ApiExplorer/Supplemental/ApiSupplementalValidator.cs +++ b/src/Elastic.ApiExplorer/Supplemental/ApiSupplementalValidator.cs @@ -153,8 +153,41 @@ private static HashSet ParameterNames(OpenApiOperation operation) private static HashSet RequestBodyFieldNames(SchemaAnalyzer analyzer, OpenApiOperation operation) { + var names = new HashSet(StringComparer.OrdinalIgnoreCase); var schema = operation.RequestBody?.Content?.FirstOrDefault().Value?.Schema; - var properties = analyzer.GetSchemaProperties(schema); - return new HashSet(properties?.Keys ?? [], StringComparer.OrdinalIgnoreCase); + 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/Supplemental/ApiSupplementalValidationTests.cs b/tests/Elastic.ApiExplorer.Tests/Supplemental/ApiSupplementalValidationTests.cs index ded2c70c0..3894fc0a8 100644 --- a/tests/Elastic.ApiExplorer.Tests/Supplemental/ApiSupplementalValidationTests.cs +++ b/tests/Elastic.ApiExplorer.Tests/Supplemental/ApiSupplementalValidationTests.cs @@ -84,6 +84,19 @@ Not a request body field. .Which.Should().Contain("Request body field 'nope_field'").And.Contain("operation 'search'"); } + [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() { @@ -245,6 +258,34 @@ private static IDirectoryInfo FolderWith(params (string Name, string Body)[] fil } }; + 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 = []; From 4125d46ef4dfa8f98b57ed60c767f9d540354e03 Mon Sep 17 00:00:00 2001 From: Jan Calanog Date: Wed, 26 Aug 2026 15:52:41 +0200 Subject: [PATCH 08/13] fix: group supplemental validation args in a request record (per review by @copilot-pull-request-reviewer) Validate was over the four-parameter limit. Private helpers that already exceeded it are unchanged. Co-Authored-By: Claude Sonnet 4.6 (1M context) Co-authored-by: Cursor --- src/Elastic.ApiExplorer/OpenApiGenerator.cs | 7 ++++-- .../Supplemental/ApiSupplementalValidator.cs | 24 +++++++++++-------- .../ApiSupplementalValidationTests.cs | 5 ++-- 3 files changed, 21 insertions(+), 15 deletions(-) diff --git a/src/Elastic.ApiExplorer/OpenApiGenerator.cs b/src/Elastic.ApiExplorer/OpenApiGenerator.cs index fecb3ffc0..8595bbc28 100644 --- a/src/Elastic.ApiExplorer/OpenApiGenerator.cs +++ b/src/Elastic.ApiExplorer/OpenApiGenerator.cs @@ -241,8 +241,11 @@ private async Task GenerateApiProduct( Cancel ctx) { var discovery = DiscoverSupplemental(openApiDocument, apiConfig); - ApiSupplementalValidator.Validate( - discovery, openApiDocument, context.Collector, moniker, emitUnmatchedBaseFiles: emitUnmatchedBaseFiles); + ApiSupplementalValidator.Validate(discovery, new( + openApiDocument, + context.Collector, + moniker, + EmitUnmatchedBaseFiles: emitUnmatchedBaseFiles)); var navigation = CreateNavigation(prefix, openApiDocument, apiConfig); _logger.LogInformation("Generating OpenApiDocument {Title}", openApiDocument.Info?.Title ?? ""); diff --git a/src/Elastic.ApiExplorer/Supplemental/ApiSupplementalValidator.cs b/src/Elastic.ApiExplorer/Supplemental/ApiSupplementalValidator.cs index 613e66f94..76705cf1b 100644 --- a/src/Elastic.ApiExplorer/Supplemental/ApiSupplementalValidator.cs +++ b/src/Elastic.ApiExplorer/Supplemental/ApiSupplementalValidator.cs @@ -10,26 +10,30 @@ 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, - OpenApiDocument document, - IDiagnosticsCollector collector, - string moniker, - bool emitUnmatchedBaseFiles) + ApiSupplementalValidationRequest request) { - if (emitUnmatchedBaseFiles) - EmitUnmatched(discovery.Unmatched, collector, "the latest spec"); + if (request.EmitUnmatchedBaseFiles) + EmitUnmatched(discovery.Unmatched, request.Collector, "the latest spec"); - var (operationsById, tagNames) = ApiSupplementalDiscovery.CollectEntities(document); - if (int.TryParse(moniker, out var major)) + var (operationsById, tagNames) = ApiSupplementalDiscovery.CollectEntities(request.Document); + if (int.TryParse(request.Moniker, out var major)) { var tagSlugs = new HashSet(tagNames.Select(ApiUrlBuilder.TagSlug), StringComparer.Ordinal); - ValidateVersionSuffixed(discovery.VersionSuffixed, major, operationsById, tagSlugs, document, collector); + ValidateVersionSuffixed( + discovery.VersionSuffixed, major, operationsById, tagSlugs, request.Document, request.Collector); } - ValidateOperationOverrides(discovery.Operations, operationsById, document, collector); + ValidateOperationOverrides(discovery.Operations, operationsById, request.Document, request.Collector); } private static void EmitUnmatched( diff --git a/tests/Elastic.ApiExplorer.Tests/Supplemental/ApiSupplementalValidationTests.cs b/tests/Elastic.ApiExplorer.Tests/Supplemental/ApiSupplementalValidationTests.cs index 3894fc0a8..c3b741532 100644 --- a/tests/Elastic.ApiExplorer.Tests/Supplemental/ApiSupplementalValidationTests.cs +++ b/tests/Elastic.ApiExplorer.Tests/Supplemental/ApiSupplementalValidationTests.cs @@ -220,12 +220,11 @@ private static CapturingDiagnosticsCollector Validate( { var discovery = ApiSupplementalDiscovery.Discover(folder, document); var collector = new CapturingDiagnosticsCollector(); - ApiSupplementalValidator.Validate( - discovery, + ApiSupplementalValidator.Validate(discovery, new( document, collector, moniker, - emitUnmatchedBaseFiles: emitUnmatchedBaseFiles ?? (moniker == "main")); + EmitUnmatchedBaseFiles: emitUnmatchedBaseFiles ?? (moniker == "main"))); return collector; } From 99eadcbbc6470ca3f73ed22608c1ca8ffa633997 Mon Sep 17 00:00:00 2001 From: Jan Calanog Date: Wed, 26 Aug 2026 15:53:18 +0200 Subject: [PATCH 09/13] fix: split validation test helper overloads (per review by @copilot-pull-request-reviewer) Keep the default-latest path at three parameters and the explicit unmatched-file flag at four. Co-Authored-By: Claude Sonnet 4.6 (1M context) Co-authored-by: Cursor --- .../Supplemental/ApiSupplementalValidationTests.cs | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/tests/Elastic.ApiExplorer.Tests/Supplemental/ApiSupplementalValidationTests.cs b/tests/Elastic.ApiExplorer.Tests/Supplemental/ApiSupplementalValidationTests.cs index c3b741532..4a689c0e0 100644 --- a/tests/Elastic.ApiExplorer.Tests/Supplemental/ApiSupplementalValidationTests.cs +++ b/tests/Elastic.ApiExplorer.Tests/Supplemental/ApiSupplementalValidationTests.cs @@ -212,11 +212,17 @@ Not a search parameter. m.Contains("Parameter 'nope'") && m.Contains("operation 'search'")); } + 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 = null) + bool emitUnmatchedBaseFiles) { var discovery = ApiSupplementalDiscovery.Discover(folder, document); var collector = new CapturingDiagnosticsCollector(); @@ -224,7 +230,7 @@ private static CapturingDiagnosticsCollector Validate( document, collector, moniker, - EmitUnmatchedBaseFiles: emitUnmatchedBaseFiles ?? (moniker == "main"))); + EmitUnmatchedBaseFiles: emitUnmatchedBaseFiles)); return collector; } From 6000881964c0970b858bb57de83d4a2223d1739f Mon Sep 17 00:00:00 2001 From: Jan Calanog Date: Wed, 26 Aug 2026 16:04:00 +0200 Subject: [PATCH 10/13] fix: pass per-version generation as a record (per review by @copilot-pull-request-reviewer) GenerateApiProduct was over the four-parameter limit after this PR added moniker and unmatched-file policy. Co-Authored-By: Claude Sonnet 4.6 (1M context) Co-authored-by: Cursor --- src/Elastic.ApiExplorer/OpenApiGenerator.cs | 48 +++++++++++---------- 1 file changed, 25 insertions(+), 23 deletions(-) diff --git a/src/Elastic.ApiExplorer/OpenApiGenerator.cs b/src/Elastic.ApiExplorer/OpenApiGenerator.cs index 8595bbc28..683d09070 100644 --- a/src/Elastic.ApiExplorer/OpenApiGenerator.cs +++ b/src/Elastic.ApiExplorer/OpenApiGenerator.cs @@ -24,6 +24,14 @@ namespace Elastic.ApiExplorer; internal sealed record VersionedOpenApiDocument(ResolvedApiVersion Version, OpenApiDocument Document); +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. @@ -108,13 +116,14 @@ public Task GenerateCatalog(IReadOnlyList entries, Cancel ctx = context.UrlPathPrefix, prefix, monikers, versioned.Version.Moniker); var apiUrlSuffix = ApiUrlBuilder.ProductSuffix(prefix, versioned.Version.Moniker); await GenerateApiProduct( - apiUrlSuffix, - versioned.Document, - apiConfig, - switcherItems, - versioned.Version.Moniker, - emitUnmatchedBaseFiles: versioned.Version.Moniker == "main" - || (!hasMain && versioned.Version.Moniker == monikers[0]), + new( + apiUrlSuffix, + versioned.Document, + apiConfig, + switcherItems, + versioned.Version.Moniker, + EmitUnmatchedBaseFiles: versioned.Version.Moniker == "main" + || (!hasMain && versioned.Version.Moniker == monikers[0])), ctx) .ConfigureAwait(false); } @@ -231,33 +240,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, - string moniker, - bool emitUnmatchedBaseFiles, - Cancel ctx) + private async Task GenerateApiProduct(ApiProductGeneration generation, Cancel ctx) { - var discovery = DiscoverSupplemental(openApiDocument, apiConfig); + var discovery = DiscoverSupplemental(generation.Document, generation.ApiConfig); ApiSupplementalValidator.Validate(discovery, new( - openApiDocument, + generation.Document, context.Collector, - moniker, - EmitUnmatchedBaseFiles: emitUnmatchedBaseFiles)); - var navigation = CreateNavigation(prefix, openApiDocument, apiConfig); - _logger.LogInformation("Generating OpenApiDocument {Title}", openApiDocument.Info?.Title ?? ""); + 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) }; From 1f41a6f2d3237c049c30b204e4482c27e40a91be Mon Sep 17 00:00:00 2001 From: Jan Calanog Date: Wed, 26 Aug 2026 16:17:33 +0200 Subject: [PATCH 11/13] fix: emit unmatched base files only on the declared latest spec (per review by @copilot-pull-request-reviewer) If main fails to fetch, do not treat an older resolved version as latest. That would fail base files for operations that exist only on main. Co-Authored-By: Claude Sonnet 4.6 (1M context) Co-authored-by: Cursor --- src/Elastic.ApiExplorer/OpenApiGenerator.cs | 43 ++++++++++++------ ...nApiGeneratorCurrentSpecResolutionTests.cs | 12 ++--- .../OpenApiGeneratorMultiVersionTests.cs | 44 +++++++++++++++++-- 3 files changed, 77 insertions(+), 22 deletions(-) diff --git a/src/Elastic.ApiExplorer/OpenApiGenerator.cs b/src/Elastic.ApiExplorer/OpenApiGenerator.cs index 683d09070..1d67737cb 100644 --- a/src/Elastic.ApiExplorer/OpenApiGenerator.cs +++ b/src/Elastic.ApiExplorer/OpenApiGenerator.cs @@ -24,6 +24,10 @@ 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, @@ -104,12 +108,12 @@ 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(); - var hasMain = monikers.Contains("main"); foreach (var versioned in versionedDocuments) { var switcherItems = ApiVersionSwitcher.Build( @@ -122,8 +126,7 @@ await GenerateApiProduct( apiConfig, switcherItems, versioned.Version.Moniker, - EmitUnmatchedBaseFiles: versioned.Version.Moniker == "main" - || (!hasMain && versioned.Version.Moniker == monikers[0])), + EmitUnmatchedBaseFiles: versioned.Version.Moniker == resolved.UnmatchedBaseFilesMoniker), ctx) .ConfigureAwait(false); } @@ -139,9 +142,11 @@ await GenerateApiProduct( /// /// 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) @@ -158,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) { @@ -166,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) { @@ -176,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", @@ -197,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; 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() { From 33a5bb12b31affa96ad63e691f89632bd223ce59 Mon Sep 17 00:00:00 2001 From: Jan Calanog Date: Wed, 26 Aug 2026 16:36:39 +0200 Subject: [PATCH 12/13] fix: reject versioned tag files whose slug collides (per review by @copilot-pull-request-reviewer) Version-suffixed tag checks now use the same unique-slug index as discovery, so tag-foo-bar.v8.md cannot match both foo bar and foo-bar. Co-Authored-By: Claude Sonnet 4.6 (1M context) Co-authored-by: Cursor --- .../Supplemental/ApiSupplementalDiscovery.cs | 2 +- .../Supplemental/ApiSupplementalValidator.cs | 4 ++-- .../ApiSupplementalValidationTests.cs | 16 ++++++++++++++++ 3 files changed, 19 insertions(+), 3 deletions(-) diff --git a/src/Elastic.ApiExplorer/Supplemental/ApiSupplementalDiscovery.cs b/src/Elastic.ApiExplorer/Supplemental/ApiSupplementalDiscovery.cs index 61fe736df..f15e60dab 100644 --- a/src/Elastic.ApiExplorer/Supplemental/ApiSupplementalDiscovery.cs +++ b/src/Elastic.ApiExplorer/Supplemental/ApiSupplementalDiscovery.cs @@ -141,7 +141,7 @@ internal static (Dictionary OperationsById, HashSet !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 index 76705cf1b..2450d7ac4 100644 --- a/src/Elastic.ApiExplorer/Supplemental/ApiSupplementalValidator.cs +++ b/src/Elastic.ApiExplorer/Supplemental/ApiSupplementalValidator.cs @@ -3,7 +3,6 @@ // See the LICENSE file in the project root for more information using System.IO.Abstractions; -using Elastic.ApiExplorer.Infrastructure; using Elastic.ApiExplorer.Model; using Elastic.Documentation.Diagnostics; using Microsoft.OpenApi; @@ -28,7 +27,8 @@ public static void Validate( var (operationsById, tagNames) = ApiSupplementalDiscovery.CollectEntities(request.Document); if (int.TryParse(request.Moniker, out var major)) { - var tagSlugs = new HashSet(tagNames.Select(ApiUrlBuilder.TagSlug), StringComparer.Ordinal); + var (uniqueBySlug, _) = ApiSupplementalDiscovery.IndexTags(tagNames); + var tagSlugs = new HashSet(uniqueBySlug.Keys, StringComparer.Ordinal); ValidateVersionSuffixed( discovery.VersionSuffixed, major, operationsById, tagSlugs, request.Document, request.Collector); } diff --git a/tests/Elastic.ApiExplorer.Tests/Supplemental/ApiSupplementalValidationTests.cs b/tests/Elastic.ApiExplorer.Tests/Supplemental/ApiSupplementalValidationTests.cs index 4a689c0e0..ce7eb5688 100644 --- a/tests/Elastic.ApiExplorer.Tests/Supplemental/ApiSupplementalValidationTests.cs +++ b/tests/Elastic.ApiExplorer.Tests/Supplemental/ApiSupplementalValidationTests.cs @@ -198,6 +198,22 @@ public void Validate_VersionSuffixedUnknownTag_EmitsErrorNamingVersion() 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() { From f8d03e17cc779d28719d07b1ed690640ae0f233d Mon Sep 17 00:00:00 2001 From: Jan Calanog Date: Wed, 26 Aug 2026 16:37:55 +0200 Subject: [PATCH 13/13] fix: name the spec version in override-key errors (per review by @copilot-pull-request-reviewer) A base file can be valid on main and fail on an older major. The diagnostic now says which spec rejected the key. Co-Authored-By: Claude Sonnet 4.6 (1M context) Co-authored-by: Cursor --- .../Supplemental/ApiSupplementalValidator.cs | 27 +++++++++++-------- .../ApiSupplementalValidationTests.cs | 11 ++++---- 2 files changed, 22 insertions(+), 16 deletions(-) diff --git a/src/Elastic.ApiExplorer/Supplemental/ApiSupplementalValidator.cs b/src/Elastic.ApiExplorer/Supplemental/ApiSupplementalValidator.cs index 2450d7ac4..eb8360e36 100644 --- a/src/Elastic.ApiExplorer/Supplemental/ApiSupplementalValidator.cs +++ b/src/Elastic.ApiExplorer/Supplemental/ApiSupplementalValidator.cs @@ -33,7 +33,7 @@ public static void Validate( discovery.VersionSuffixed, major, operationsById, tagSlugs, request.Document, request.Collector); } - ValidateOperationOverrides(discovery.Operations, operationsById, request.Document, request.Collector); + ValidateOperationOverrides(discovery.Operations, operationsById, request); } private static void EmitUnmatched( @@ -76,7 +76,7 @@ private static void ValidateVersionSuffixed( continue; } - ValidateFileOverrides(versioned.File, operation, analyzer, collector); + ValidateFileOverrides(versioned.File, operation, analyzer, collector, specLabel); continue; } @@ -88,30 +88,34 @@ private static void ValidateVersionSuffixed( private static void ValidateOperationOverrides( IReadOnlyDictionary operationFiles, IReadOnlyDictionary operationsById, - OpenApiDocument document, - IDiagnosticsCollector collector) + ApiSupplementalValidationRequest request) { - var analyzer = new SchemaAnalyzer(document); + 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, collector); + 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) + 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); + ValidateOverrideKeys(file, operation, analyzer, collector, doc, specLabel); } private static void ValidateOverrideKeys( @@ -119,7 +123,8 @@ private static void ValidateOverrideKeys( OpenApiOperation operation, SchemaAnalyzer analyzer, IDiagnosticsCollector collector, - ApiSupplementalDoc doc) + ApiSupplementalDoc doc, + string specLabel) { var operationId = operation.OperationId ?? ""; if (doc.ParameterOverrides.Count > 0) @@ -128,7 +133,7 @@ private static void ValidateOverrideKeys( foreach (var key in doc.ParameterOverrides.Keys) { if (!parameterNames.Contains(key)) - collector.EmitError(file, $"API supplemental: Parameter '{key}' not found in operation '{operationId}'"); + collector.EmitError(file, $"API supplemental: Parameter '{key}' not found in operation '{operationId}' in {specLabel}"); } } @@ -138,7 +143,7 @@ private static void ValidateOverrideKeys( 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}'"); + collector.EmitError(file, $"API supplemental: Request body field '{key}' not found in operation '{operationId}' in {specLabel}"); } } } diff --git a/tests/Elastic.ApiExplorer.Tests/Supplemental/ApiSupplementalValidationTests.cs b/tests/Elastic.ApiExplorer.Tests/Supplemental/ApiSupplementalValidationTests.cs index ce7eb5688..8cc094bf4 100644 --- a/tests/Elastic.ApiExplorer.Tests/Supplemental/ApiSupplementalValidationTests.cs +++ b/tests/Elastic.ApiExplorer.Tests/Supplemental/ApiSupplementalValidationTests.cs @@ -61,7 +61,7 @@ Not a search parameter. """)), fixture.Document, "main"); collector.ErrorMessages.Should().ContainSingle(m => - m.Contains("Parameter 'nope'") && m.Contains("operation 'search'")); + m.Contains("Parameter 'nope'") && m.Contains("operation 'search'") && m.Contains("the latest spec")); } [Fact] @@ -81,7 +81,8 @@ Not a request body field. """)), fixture.Document, "main"); collector.ErrorMessages.Should().ContainSingle() - .Which.Should().Contain("Request body field 'nope_field'").And.Contain("operation 'search'"); + .Which.Should().Contain("Request body field 'nope_field'").And.Contain("operation 'search'") + .And.Contain("the latest spec"); } [Fact] @@ -155,7 +156,7 @@ Not a search parameter. """)), fixture.Document, "next"); collector.ErrorMessages.Should().ContainSingle(m => - m.Contains("Parameter 'nope'") && m.Contains("operation 'search'")); + m.Contains("Parameter 'nope'") && m.Contains("operation 'search'") && m.Contains("the latest spec")); } [Fact] @@ -169,7 +170,7 @@ Removed in this version. """)), SpecWith("search", "q"), "8"); collector.ErrorMessages.Should().ContainSingle(m => - m.Contains("Parameter 'pretty'") && m.Contains("operation 'search'")); + m.Contains("Parameter 'pretty'") && m.Contains("operation 'search'") && m.Contains("version 8")); } [Fact] @@ -225,7 +226,7 @@ Not a search parameter. """)), fixture.Document, "8"); collector.ErrorMessages.Should().ContainSingle(m => - m.Contains("Parameter 'nope'") && m.Contains("operation 'search'")); + m.Contains("Parameter 'nope'") && m.Contains("operation 'search'") && m.Contains("version 8")); } private static CapturingDiagnosticsCollector Validate(