Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
5a05907
ApiExplorer: fail the build on invalid supplemental files
reakaleek Aug 26, 2026
106618d
fix: validate base-file override keys on older versions (per review b…
reakaleek Aug 26, 2026
a81c1b5
fix: emit unmatched base files when main is absent (per review by @gi…
reakaleek Aug 26, 2026
25e9dbb
Merge branch 'main' into cursor/8325fadd
reakaleek Aug 26, 2026
bd16aaa
fix: emit unmatched base files for non-numeric latest monikers (per r…
reakaleek Aug 26, 2026
e57bca0
fix: validate overrides on every spec (per review by @github-actions)
reakaleek Aug 26, 2026
7bb1472
fix: add parentheses for analyzer clarity
reakaleek Aug 26, 2026
772ca70
fix: accept nested request-body override keys (per review by @copilot…
reakaleek Aug 26, 2026
4125d46
fix: group supplemental validation args in a request record (per revi…
reakaleek Aug 26, 2026
99eadcb
fix: split validation test helper overloads (per review by @copilot-p…
reakaleek Aug 26, 2026
6000881
fix: pass per-version generation as a record (per review by @copilot-…
reakaleek Aug 26, 2026
1f41a6f
fix: emit unmatched base files only on the declared latest spec (per …
reakaleek Aug 26, 2026
33a5bb1
fix: reject versioned tag files whose slug collides (per review by @c…
reakaleek Aug 26, 2026
f8d03e1
fix: name the spec version in override-key errors (per review by @cop…
reakaleek Aug 26, 2026
62f84b5
Merge branch 'main' into cursor/8325fadd
reakaleek Aug 26, 2026
b9f7bef
Merge branch 'main' into cursor/8325fadd
reakaleek Aug 27, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
79 changes: 57 additions & 22 deletions src/Elastic.ApiExplorer/OpenApiGenerator.cs
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,18 @@ namespace Elastic.ApiExplorer;

internal sealed record VersionedOpenApiDocument(ResolvedApiVersion Version, OpenApiDocument Document);

internal sealed record ResolvedProductDocuments(
IReadOnlyList<VersionedOpenApiDocument> Documents,
string? UnmatchedBaseFilesMoniker);

internal sealed record ApiProductGeneration(
string Prefix,
OpenApiDocument Document,
ResolvedApiConfiguration? ApiConfig,
IReadOnlyList<ApiVersionSwitcherItem> VersionSwitcherItems,
string Moniker,
bool EmitUnmatchedBaseFiles);

/// <summary>
/// Renders API explorer pages for every configured OpenAPI specification: builds the navigation
/// tree via <see cref="ApiNavigationBuilder"/> and writes each page to the output directory.
Expand Down Expand Up @@ -96,17 +108,26 @@ public Task GenerateCatalog(IReadOnlyList<ApiCatalogEntry> 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);
}

Expand All @@ -121,9 +142,11 @@ await GenerateApiProduct(apiUrlSuffix, versioned.Document, apiConfig, switcherIt

/// <summary>
/// Resolves every OpenAPI document to render for one API key, including canonical <c>main</c>
/// 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.
/// <see cref="ResolvedProductDocuments.UnmatchedBaseFilesMoniker"/> is the declared latest
/// version only when that document actually resolved.
/// </summary>
internal async Task<IReadOnlyList<VersionedOpenApiDocument>> ResolveDocumentsForProduct(
internal async Task<ResolvedProductDocuments> ResolveDocumentsForProduct(
string apiKey,
ResolvedApiConfiguration apiConfig,
Cancel ctx)
Expand All @@ -140,14 +163,18 @@ internal async Task<IReadOnlyList<VersionedOpenApiDocument>> ResolveDocumentsFor
: [.. versions];

if (versionsToRender.Length == 0)
return [];
return new([], null);

if (!versionless && versionsToRender.All(v => v.Moniker != "main") && versions.Count > 0)
{
context.Collector.EmitGlobalWarning(
$"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<VersionedOpenApiDocument>(versionsToRender.Length);
foreach (var version in versionsToRender)
{
Expand All @@ -158,18 +185,18 @@ internal async Task<IReadOnlyList<VersionedOpenApiDocument>> ResolveDocumentsFor
results.Add(new VersionedOpenApiDocument(version, document));
}

return results;
return ToResolvedProductDocuments(results, latestDeclared);
}

private async Task<IReadOnlyList<VersionedOpenApiDocument>> ResolveLocalMainOnly(IFileInfo localFile)
private async Task<ResolvedProductDocuments> 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",
Expand All @@ -179,8 +206,16 @@ private async Task<IReadOnlyList<VersionedOpenApiDocument>> ResolveLocalMainOnly
},
document)
];
return ToResolvedProductDocuments(documents, "main");
}

private static ResolvedProductDocuments ToResolvedProductDocuments(
IReadOnlyList<VersionedOpenApiDocument> documents,
string latestDeclared) =>
new(
documents,
documents.Any(d => d.Version.Moniker == latestDeclared) ? latestDeclared : null);

private static bool IsVersionlessProduct(Product product) =>
product.VersioningSystem?.IsVersionless == true;

Expand Down Expand Up @@ -222,26 +257,26 @@ private async Task GenerateApiCatalog(IReadOnlyList<ApiCatalogEntry> 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<ApiVersionSwitcherItem> 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 ?? "<no 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 ?? "<no 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)
};
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -98,9 +98,10 @@ private static ApiSupplementalDiscoveryResult MatchFiles(
};
}

private static (HashSet<string> OperationIds, HashSet<string> TagNames) CollectEntities(OpenApiDocument document)
internal static (Dictionary<string, OpenApiOperation> OperationsById, HashSet<string> TagNames) CollectEntities(
OpenApiDocument document)
{
var operations = new HashSet<string>(StringComparer.Ordinal);
var operations = new Dictionary<string, OpenApiOperation>(StringComparer.Ordinal);
var tags = new HashSet<string>(StringComparer.Ordinal);

if (document.Tags is not null)
Expand All @@ -120,7 +121,7 @@ private static (HashSet<string> OperationIds, HashSet<string> 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;
Expand All @@ -140,7 +141,7 @@ private static (HashSet<string> OperationIds, HashSet<string> TagNames) CollectE
private static string? OperationTagName(OpenApiTagReference tagRef) =>
!string.IsNullOrEmpty(tagRef.Name) ? tagRef.Name : tagRef.Reference?.Id;

private static (Dictionary<string, string> UniqueBySlug, IReadOnlyList<TagSlugCollision> Collisions) IndexTags(
internal static (Dictionary<string, string> UniqueBySlug, IReadOnlyList<TagSlugCollision> Collisions) IndexTags(
IReadOnlyCollection<string> tagNames)
{
var bySlug = new Dictionary<string, List<string>>(StringComparer.Ordinal);
Expand Down
Loading
Loading