diff --git a/docs/data/openapi/api-explorer.md b/docs/data/openapi/api-explorer.md index 63ab48bef3..1d5c145e3a 100644 --- a/docs/data/openapi/api-explorer.md +++ b/docs/data/openapi/api-explorer.md @@ -175,8 +175,10 @@ remotely through the index. Versionless products (`versioning: serverless` and similar) render only the unversioned `/api/doc//` path even when the index lists historical monikers. When more than one -version is rendered, API pages show a simple version dropdown at the top of the left navigation -rail. The dropdown links to each version's landing page. +version is rendered, API pages show a version dropdown at the top of the left navigation +rail. The dropdown uses the same presentation as the documentation-group navigation +dropdown. It links to the same page in each version when that page exists, and falls back +to the version landing page when it does not. ### Smoke-test every CloudFront spec locally diff --git a/src/Elastic.ApiExplorer/Infrastructure/ApiCrossVersionPageIndex.cs b/src/Elastic.ApiExplorer/Infrastructure/ApiCrossVersionPageIndex.cs new file mode 100644 index 0000000000..8d919c2b41 --- /dev/null +++ b/src/Elastic.ApiExplorer/Infrastructure/ApiCrossVersionPageIndex.cs @@ -0,0 +1,77 @@ +// 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 Elastic.ApiExplorer.Model; +using Microsoft.OpenApi; + +namespace Elastic.ApiExplorer.Infrastructure; + +public sealed record VersionedOpenApiDocument(ResolvedApiVersion Version, OpenApiDocument Document); + +/// +/// Maps stable page identities (operation, tag, schema monikers) to the version monikers +/// where each identity exists across loaded OpenAPI documents. +/// +public sealed class ApiCrossVersionPageIndex +{ + private readonly Dictionary<(ApiPageVersionTargetKind Kind, string Identity), HashSet> _pages = []; + + public static ApiCrossVersionPageIndex Build(IReadOnlyList documents) + { + var index = new ApiCrossVersionPageIndex(); + foreach (var versioned in documents) + index.AddDocument(versioned.Version.Moniker, versioned.Document); + + return index; + } + + private void AddDocument(string versionMoniker, OpenApiDocument document) + { + foreach (var (route, pathItem) in document.Paths ?? []) + { + if (pathItem.Operations is null) + continue; + + foreach (var operation in pathItem.Operations.Values) + { + var operationMoniker = ApiUrlBuilder.OperationMoniker(operation.OperationId, route); + Add(ApiPageVersionTargetKind.Operation, operationMoniker, versionMoniker); + } + } + + if (document.Tags is not null) + { + foreach (var tag in document.Tags) + { + var tagSegment = ApiUrlBuilder.TagMoniker(tag.Name); + Add(ApiPageVersionTargetKind.Tag, tagSegment, versionMoniker); + } + } + + if (document.Components?.Schemas is { } schemas) + { + foreach (var schemaId in schemas.Keys) + { + var schemaMoniker = ApiUrlBuilder.SchemaMoniker(schemaId); + Add(ApiPageVersionTargetKind.Schema, schemaMoniker, versionMoniker); + } + } + } + + public bool Contains(ApiPageVersionTarget pageTarget, string versionMoniker) => + _pages.TryGetValue((pageTarget.Kind, pageTarget.Identity), out var versions) + && versions.Contains(versionMoniker); + + private void Add(ApiPageVersionTargetKind kind, string identity, string versionMoniker) + { + var key = (kind, identity); + if (!_pages.TryGetValue(key, out var versions)) + { + versions = [with(StringComparer.OrdinalIgnoreCase)]; + _pages[key] = versions; + } + + _ = versions.Add(versionMoniker); + } +} diff --git a/src/Elastic.ApiExplorer/Infrastructure/ApiPageVersionTarget.cs b/src/Elastic.ApiExplorer/Infrastructure/ApiPageVersionTarget.cs new file mode 100644 index 0000000000..f1cf6e25ef --- /dev/null +++ b/src/Elastic.ApiExplorer/Infrastructure/ApiPageVersionTarget.cs @@ -0,0 +1,33 @@ +// 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 Elastic.ApiExplorer.Landing; +using Elastic.ApiExplorer.Operations; +using Elastic.ApiExplorer.Types; +using Elastic.Documentation.Navigation; + +namespace Elastic.ApiExplorer.Infrastructure; + +public enum ApiPageVersionTargetKind +{ + Operation, + Tag, + Schema +} + +public sealed record ApiPageVersionTarget(ApiPageVersionTargetKind Kind, string Identity) +{ + public static ApiPageVersionTarget? FromNavigation(INavigationItem item) => + item switch + { + OperationNavigationItem operation => new( + ApiPageVersionTargetKind.Operation, + ApiUrlBuilder.OperationMoniker(operation.Model.Operation.OperationId, operation.Model.Route)), + TagNavigationItem tag => new(ApiPageVersionTargetKind.Tag, tag.Index.Model.TagUrlSegment), + SchemaNavigationItem schema => new( + ApiPageVersionTargetKind.Schema, + ApiUrlBuilder.SchemaMoniker(schema.Model.SchemaId)), + _ => null + }; +} diff --git a/src/Elastic.ApiExplorer/Infrastructure/ApiRenderContext.cs b/src/Elastic.ApiExplorer/Infrastructure/ApiRenderContext.cs index f8556f424f..71dbe9bc2c 100644 --- a/src/Elastic.ApiExplorer/Infrastructure/ApiRenderContext.cs +++ b/src/Elastic.ApiExplorer/Infrastructure/ApiRenderContext.cs @@ -14,8 +14,6 @@ namespace Elastic.ApiExplorer.Infrastructure; -public sealed record ApiVersionSwitcherItem(string Label, string Url, bool Selected); - public record ApiRenderContext( BuildContext BuildContext, OpenApiDocument Model, @@ -30,5 +28,5 @@ StaticFileContentHashProvider StaticFileContentHashProvider /// Logger for API Explorer rendering (e.g. OpenAPI extension parsing); optional when the host does not provide one. public ILogger? ApiExplorerLog { get; init; } - public IReadOnlyList VersionSwitcherItems { get; init; } = []; + public IReadOnlyList VersionSwitcherItems { get; init; } = []; } diff --git a/src/Elastic.ApiExplorer/Infrastructure/ApiUrlBuilder.cs b/src/Elastic.ApiExplorer/Infrastructure/ApiUrlBuilder.cs index 7401b2fb52..4aa5b2b988 100644 --- a/src/Elastic.ApiExplorer/Infrastructure/ApiUrlBuilder.cs +++ b/src/Elastic.ApiExplorer/Infrastructure/ApiUrlBuilder.cs @@ -62,6 +62,15 @@ public static string TagMoniker(string? tagName) return $"endpoint-{s}"; } + public static string PageUrl(string productRoot, ApiPageVersionTarget pageTarget) => + pageTarget.Kind switch + { + ApiPageVersionTargetKind.Operation => $"{productRoot}/operation/{pageTarget.Identity}", + ApiPageVersionTargetKind.Tag => $"{productRoot}/group/{pageTarget.Identity}", + ApiPageVersionTargetKind.Schema => $"{productRoot}/types/{pageTarget.Identity}", + _ => $"{productRoot}/" + }; + [GeneratedRegex(@"\s*\(([^)]+)\)")] private static partial Regex ParentheticalSuffixPattern(); } diff --git a/src/Elastic.ApiExplorer/Infrastructure/ApiVersionSwitcher.cs b/src/Elastic.ApiExplorer/Infrastructure/ApiVersionSwitcher.cs index d9241633d8..6981232daf 100644 --- a/src/Elastic.ApiExplorer/Infrastructure/ApiVersionSwitcher.cs +++ b/src/Elastic.ApiExplorer/Infrastructure/ApiVersionSwitcher.cs @@ -2,28 +2,83 @@ // 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 Elastic.Documentation.Site.Navigation; + namespace Elastic.ApiExplorer.Infrastructure; -public static class ApiVersionSwitcher +public sealed class ApiVersionSwitcherContext { - public static IReadOnlyList Build( - string? urlPathPrefix, + private readonly Dictionary _itemsByTarget = []; + private readonly IReadOnlyList _orderedMonikers; + private readonly IReadOnlyDictionary _productRootByMoniker; + + public ApiVersionSwitcherContext( string apiKey, IReadOnlyList monikers, - string currentMoniker) + string currentMoniker, + int currentMajor, + ApiCrossVersionPageIndex crossVersionIndex, + string? urlPathPrefix) + { + ApiKey = apiKey; + CurrentMoniker = currentMoniker; + CurrentMajor = currentMajor; + CrossVersionIndex = crossVersionIndex; + UrlPathPrefix = urlPathPrefix; + _orderedMonikers = [.. monikers.OrderByDescending(m => m == "main" ? int.MaxValue : ParseMajor(m))]; + _productRootByMoniker = _orderedMonikers.ToDictionary( + m => m, + m => ApiUrlBuilder.ProductRoot(urlPathPrefix, ApiUrlBuilder.ProductSuffix(apiKey, m))); + } + + public string ApiKey { get; } + public string CurrentMoniker { get; } + public int CurrentMajor { get; } + public ApiCrossVersionPageIndex CrossVersionIndex { get; } + public string? UrlPathPrefix { get; } + public bool HasMultipleVersions => _orderedMonikers.Count > 1; + + public IReadOnlyList GetItems(ApiPageVersionTarget? pageTarget) { - if (monikers.Count <= 1) + if (!HasMultipleVersions) return []; - return monikers - .OrderByDescending(m => m == "main" ? int.MaxValue : ParseMajor(m)) - .Select(m => new ApiVersionSwitcherItem( - Label: m == "main" ? "Latest" : $"{m}.x", - Url: $"{ApiUrlBuilder.ProductRoot(urlPathPrefix, ApiUrlBuilder.ProductSuffix(apiKey, m))}/", - Selected: m == currentMoniker)) + var key = PageTargetKey.From(pageTarget); + if (_itemsByTarget.TryGetValue(key, out var cached)) + return cached; + + var items = _orderedMonikers + .Select(m => new NavigationDropdownItem( + NavigationTitle: FormatLabel(m, CurrentMajor), + Url: BuildTargetUrl(m, pageTarget), + IsActive: m == CurrentMoniker)) .ToArray(); + _itemsByTarget[key] = items; + return items; } + private string BuildTargetUrl(string targetMoniker, ApiPageVersionTarget? pageTarget) + { + var productRoot = _productRootByMoniker[targetMoniker]; + if (pageTarget is null || !CrossVersionIndex.Contains(pageTarget, targetMoniker)) + return $"{productRoot}/"; + + return ApiUrlBuilder.PageUrl(productRoot, pageTarget); + } + + private static string FormatLabel(string moniker, int currentMajor) => + moniker == "main" + ? $"{currentMajor}.x (latest)" + : $"{moniker}.x"; + private static int ParseMajor(string moniker) => int.TryParse(moniker, out var major) ? major : 0; + + private readonly record struct PageTargetKey(ApiPageVersionTargetKind? Kind, string? Identity) + { + public static PageTargetKey From(ApiPageVersionTarget? pageTarget) => + pageTarget is null + ? new PageTargetKey(null, null) + : new PageTargetKey(pageTarget.Kind, pageTarget.Identity); + } } diff --git a/src/Elastic.ApiExplorer/Infrastructure/ApiViewModel.cs b/src/Elastic.ApiExplorer/Infrastructure/ApiViewModel.cs index 116d351fc2..f096d4b190 100644 --- a/src/Elastic.ApiExplorer/Infrastructure/ApiViewModel.cs +++ b/src/Elastic.ApiExplorer/Infrastructure/ApiViewModel.cs @@ -12,6 +12,7 @@ using Elastic.Documentation.Navigation; using Elastic.Documentation.Site; using Elastic.Documentation.Site.FileProviders; +using Elastic.Documentation.Site.Navigation; using Microsoft.AspNetCore.Html; using Microsoft.OpenApi; @@ -22,7 +23,7 @@ public record ApiTocItem(string Heading, string Slug, int Level = 2); public record ApiLayoutViewModel : GlobalLayoutViewModel { public required IReadOnlyList TocItems { get; init; } - public IReadOnlyList VersionSwitcherItems { get; init; } = []; + public IReadOnlyList VersionSwitcherItems { get; init; } = []; } public abstract class ApiViewModel(ApiRenderContext context) diff --git a/src/Elastic.ApiExplorer/OpenApiGenerator.cs b/src/Elastic.ApiExplorer/OpenApiGenerator.cs index 2c03c59246..03984c67b0 100644 --- a/src/Elastic.ApiExplorer/OpenApiGenerator.cs +++ b/src/Elastic.ApiExplorer/OpenApiGenerator.cs @@ -21,8 +21,6 @@ namespace Elastic.ApiExplorer; -internal sealed record VersionedOpenApiDocument(ResolvedApiVersion Version, OpenApiDocument Document); - /// /// Renders API explorer pages for every configured OpenAPI specification: builds the navigation /// tree via and writes each page to the output directory. @@ -64,12 +62,20 @@ public async Task Generate(Cancel ctx = default) continue; var monikers = versionedDocuments.Select(v => v.Version.Moniker).ToArray(); + var crossVersionIndex = ApiCrossVersionPageIndex.Build(versionedDocuments); + var currentMajor = apiConfig.Product.VersioningSystem?.Current.Major + ?? throw new InvalidOperationException($"Product '{apiConfig.ProductKey}' has no versioning system."); foreach (var versioned in versionedDocuments) { - var switcherItems = ApiVersionSwitcher.Build( - context.UrlPathPrefix, prefix, monikers, versioned.Version.Moniker); + var switcherContext = new ApiVersionSwitcherContext( + prefix, + monikers, + versioned.Version.Moniker, + currentMajor, + crossVersionIndex, + context.UrlPathPrefix); var apiUrlSuffix = ApiUrlBuilder.ProductSuffix(prefix, versioned.Version.Moniker); - await GenerateApiProduct(apiUrlSuffix, versioned.Document, apiConfig, switcherItems, ctx) + await GenerateApiProduct(apiUrlSuffix, versioned.Document, apiConfig, switcherContext, ctx) .ConfigureAwait(false); } @@ -192,57 +198,57 @@ private async Task GenerateApiCatalog(IReadOnlyList entries, Ca ApiExplorerLog = _logger }; - _ = await Render(navigation.Index, navigation.Index.Model, renderContext, navigationRenderer, ctx).ConfigureAwait(false); + _ = await Render(navigation.Index, navigation.Index.Model, renderContext, navigationRenderer, versionSwitcherContext: null, ctx).ConfigureAwait(false); } private async Task GenerateApiProduct( string prefix, OpenApiDocument openApiDocument, ResolvedApiConfiguration? apiConfig, - IReadOnlyList versionSwitcherItems, + ApiVersionSwitcherContext? versionSwitcherContext, Cancel ctx) { var navigation = CreateNavigation(prefix, openApiDocument, apiConfig); _logger.LogInformation("Generating OpenApiDocument {Title}", openApiDocument.Info?.Title ?? ""); - var navigationRenderer = new IsolatedBuildNavigationHtmlWriter(context, navigation); + var navigationRenderer = new IsolatedBuildNavigationHtmlWriter(context, navigation, suppressNavigationDropdown: true); var renderContext = new ApiRenderContext(context, openApiDocument, _contentHashProvider) { NavigationHtml = string.Empty, CurrentNavigation = navigation, MarkdownRenderer = markdownStringRenderer, - ApiExplorerLog = _logger, - VersionSwitcherItems = versionSwitcherItems + ApiExplorerLog = _logger }; - await RenderNavigationItems(renderContext, navigationRenderer, navigation, ctx).ConfigureAwait(false); + await RenderNavigationItems(renderContext, navigationRenderer, navigation, versionSwitcherContext, ctx).ConfigureAwait(false); } private async Task RenderNavigationItems( ApiRenderContext renderContext, IsolatedBuildNavigationHtmlWriter navigationRenderer, INavigationItem currentNavigation, + ApiVersionSwitcherContext? versionSwitcherContext, Cancel ctx) { if (currentNavigation is INodeNavigationItem node) { if (currentNavigation is not ClassificationNavigationItem) - _ = await Render(node, node.Index.Model, renderContext, navigationRenderer, ctx); + _ = await Render(node, node.Index.Model, renderContext, navigationRenderer, versionSwitcherContext, ctx); foreach (var child in node.NavigationItems) - await RenderNavigationItems(renderContext, navigationRenderer, child, ctx); + await RenderNavigationItems(renderContext, navigationRenderer, child, versionSwitcherContext, ctx); } else { _ = currentNavigation is ILeafNavigationItem leaf - ? await Render(leaf, leaf.Model, renderContext, navigationRenderer, ctx) + ? await Render(leaf, leaf.Model, renderContext, navigationRenderer, versionSwitcherContext, ctx) : throw new Exception($"Unknown navigation item type {currentNavigation.GetType()}"); } } private async Task Render(INavigationItem current, T page, ApiRenderContext renderContext, - IsolatedBuildNavigationHtmlWriter navigationRenderer, Cancel ctx) + IsolatedBuildNavigationHtmlWriter navigationRenderer, ApiVersionSwitcherContext? versionSwitcherContext, Cancel ctx) where T : INavigationModel, IPageRenderer { var outputFile = OutputFile(current); @@ -250,10 +256,12 @@ private async Task Render(INavigationItem current, T page, ApiRend outputFile.Directory.Create(); var navigationRenderResult = await navigationRenderer.RenderNavigation(current.NavigationRoot, current, ctx); + var versionSwitcherItems = versionSwitcherContext?.GetItems(ApiPageVersionTarget.FromNavigation(current)) ?? []; renderContext = renderContext with { CurrentNavigation = current, - NavigationHtml = navigationRenderResult.Html + NavigationHtml = navigationRenderResult.Html, + VersionSwitcherItems = versionSwitcherItems }; await using var stream = _writeFileSystem.FileStream.New(outputFile.FullName, FileMode.OpenOrCreate); await page.RenderAsync(stream, renderContext, ctx); diff --git a/src/Elastic.ApiExplorer/_Partials/Layout/_ApiPagesNav.cshtml b/src/Elastic.ApiExplorer/_Partials/Layout/_ApiPagesNav.cshtml index f8eb132bfc..02dcc45911 100644 --- a/src/Elastic.ApiExplorer/_Partials/Layout/_ApiPagesNav.cshtml +++ b/src/Elastic.ApiExplorer/_Partials/Layout/_ApiPagesNav.cshtml @@ -1,28 +1,53 @@ @inherits RazorSlice +@{ + var currentItem = Model.VersionSwitcherItems.SingleOrDefault(i => i.IsActive) + ?? Model.VersionSwitcherItems.FirstOrDefault(); +}