Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
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
6 changes: 4 additions & 2 deletions docs/data/openapi/api-explorer.md
Original file line number Diff line number Diff line change
Expand Up @@ -175,8 +175,10 @@ remotely through the index.

Versionless products (`versioning: serverless` and similar) render only the unversioned
`/api/doc/<key>/` 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

Expand Down
Original file line number Diff line number Diff line change
@@ -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);

/// <summary>
/// Maps stable page identities (operation, tag, schema monikers) to the version monikers
/// where each identity exists across loaded OpenAPI documents.
/// </summary>
public sealed class ApiCrossVersionPageIndex
{
private readonly Dictionary<(ApiPageVersionTargetKind Kind, string Identity), HashSet<string>> _pages = [];

public static ApiCrossVersionPageIndex Build(IReadOnlyList<VersionedOpenApiDocument> 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);
}
}
33 changes: 33 additions & 0 deletions src/Elastic.ApiExplorer/Infrastructure/ApiPageVersionTarget.cs
Original file line number Diff line number Diff line change
@@ -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
};
}
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -30,5 +28,5 @@ StaticFileContentHashProvider StaticFileContentHashProvider
/// <summary>Logger for API Explorer rendering (e.g. OpenAPI extension parsing); optional when the host does not provide one.</summary>
public ILogger? ApiExplorerLog { get; init; }

public IReadOnlyList<ApiVersionSwitcherItem> VersionSwitcherItems { get; init; } = [];
public IReadOnlyList<NavigationDropdownItem> VersionSwitcherItems { get; init; } = [];
}
9 changes: 9 additions & 0 deletions src/Elastic.ApiExplorer/Infrastructure/ApiUrlBuilder.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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();
}
77 changes: 66 additions & 11 deletions src/Elastic.ApiExplorer/Infrastructure/ApiVersionSwitcher.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<ApiVersionSwitcherItem> Build(
string? urlPathPrefix,
private readonly Dictionary<PageTargetKey, NavigationDropdownItem[]> _itemsByTarget = [];
private readonly IReadOnlyList<string> _orderedMonikers;
private readonly IReadOnlyDictionary<string, string> _productRootByMoniker;

public ApiVersionSwitcherContext(
string apiKey,
IReadOnlyList<string> 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<NavigationDropdownItem> 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);
}
}
3 changes: 2 additions & 1 deletion src/Elastic.ApiExplorer/Infrastructure/ApiViewModel.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand All @@ -22,7 +23,7 @@ public record ApiTocItem(string Heading, string Slug, int Level = 2);
public record ApiLayoutViewModel : GlobalLayoutViewModel
{
public required IReadOnlyList<ApiTocItem> TocItems { get; init; }
public IReadOnlyList<ApiVersionSwitcherItem> VersionSwitcherItems { get; init; } = [];
public IReadOnlyList<NavigationDropdownItem> VersionSwitcherItems { get; init; } = [];
}

public abstract class ApiViewModel(ApiRenderContext context)
Expand Down
40 changes: 24 additions & 16 deletions src/Elastic.ApiExplorer/OpenApiGenerator.cs
Original file line number Diff line number Diff line change
Expand Up @@ -21,8 +21,6 @@

namespace Elastic.ApiExplorer;

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

/// <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 @@ -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);
}

Expand Down Expand Up @@ -192,68 +198,70 @@ private async Task GenerateApiCatalog(IReadOnlyList<ApiCatalogEntry> 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<ApiVersionSwitcherItem> versionSwitcherItems,
ApiVersionSwitcherContext? versionSwitcherContext,
Cancel ctx)
{
var navigation = CreateNavigation(prefix, openApiDocument, apiConfig);
_logger.LogInformation("Generating OpenApiDocument {Title}", openApiDocument.Info?.Title ?? "<no 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<IApiModel, INavigationItem> 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<IApiModel> 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<IFileInfo> Render<T>(INavigationItem current, T page, ApiRenderContext renderContext,
IsolatedBuildNavigationHtmlWriter navigationRenderer, Cancel ctx)
IsolatedBuildNavigationHtmlWriter navigationRenderer, ApiVersionSwitcherContext? versionSwitcherContext, Cancel ctx)
where T : INavigationModel, IPageRenderer<ApiRenderContext>
{
var outputFile = OutputFile(current);
if (!outputFile.Directory!.Exists)
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);
Expand Down
Loading
Loading