Skip to content
Open
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
394 changes: 272 additions & 122 deletions docs/data/openapi/api-explorer.md

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion src/Elastic.ApiExplorer/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ Components/ Reusable View+ViewModel widgets embedded by more than o
PropertyTree/ The collapsible property listing: ApiProperty view-model tree,
ApiPropertyTreeBuilder (maps Model → view models), TypeAnnotation.
_Partials/ Its templates: _PropertyItem, _PropertyList, _UnionOptions, _SchemaType,
_ValidationConstraints, _RecursiveBadge.
_ValidationConstraints.

Landing/ Slice: product landing, tag landing and intro/outro markdown pages.
Operations/ Slice: operation pages (ApiOperation/ApiEndpoint, page model, view).
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -126,7 +126,7 @@ public record ApiProperty
public required bool IsLast { get; init; }
public required bool IsRecursive { get; init; }

/// <summary>Whether the row shows the <c>required</c>/<c>optional</c> tag for request or response context.</summary>
/// <summary>Whether the row is in a request body (vs response); kept for callers that branch on context.</summary>
public required bool IsRequest { get; init; }

public required TypeAnnotation Type { get; init; }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,6 @@ public sealed record PropertyTreeScope
public bool IsRequest { get; init; }
public int Depth { get; init; }
public IReadOnlySet<string>? Ancestors { get; init; }
public IReadOnlyDictionary<string, string>? DescriptionOverrides { get; init; }

/// <summary>Overrides the schema's own required set at the top level; never inherited by children.</summary>
public ISet<string>? RequiredProperties { get; init; }
Expand Down Expand Up @@ -141,15 +140,6 @@ public static IReadOnlyList<ConstraintDisplay> BuildConstraints(IOpenApiSchema s

private bool HasActualProperties(IOpenApiSchema? schema) => _analyzer.GetSchemaProperties(schema)?.Count > 0;

private HtmlString RenderDescription(string name, string? specDescription, PropertyTreeScope scope)
{
// ponytail: match property Name only. Nested paths if authors need them.
var description = scope.IsRequest
&& scope.DescriptionOverrides is { Count: > 0 }
&& scope.DescriptionOverrides.TryGetValue(name, out var overrideText) ? overrideText : specDescription;
return string.IsNullOrWhiteSpace(description) ? HtmlString.Empty : options.RenderMarkdown(description);
}

private ApiProperty BuildProperty(PropertyRow row, PropertyTreeScope scope)
{
var (_, propSchema, typeInfo, _, _, _, isRecursive) = row;
Expand All @@ -166,14 +156,17 @@ private ApiProperty BuildProperty(PropertyRow row, PropertyTreeScope scope)
IsRecursive = isRecursive,
IsRequest = scope.IsRequest,
Type = BuildAnnotation(typeInfo, HasActualProperties(propSchema)),
DescriptionHtml = RenderDescription(row.Name, propSchema.Description, scope),
DescriptionHtml = string.IsNullOrWhiteSpace(propSchema.Description)
? HtmlString.Empty
: options.RenderMarkdown(propSchema.Description),
ShowDeprecatedBadge = options.ShowDeprecated && propSchema.Deprecated,
Availability = options.ShowVersionInfo ? AvailabilityBadgeHelper.FromSchema(propSchema, options.VersionsConfiguration) : null,
ExternalDocs = BuildExternalDocs(propSchema, typeInfo),
Constraints = BuildConstraints(propSchema),
EnumValues = typeInfo is { IsEnum: true, EnumValues.Length: > 0 } ? typeInfo.EnumValues : [],
Union = typeInfo.IsUnion ? BuildUnionDisplay(propSchema, typeInfo, expansion) : null,
ArrayItemTypeName = string.IsNullOrEmpty(typeInfo.ArrayItemType) ? null : typeInfo.ArrayItemType,
// Type annotation already reads "array of …"; skip the redundant "Array of:" row.
ArrayItemTypeName = null,
TypeLink = BuildTypeLink(typeInfo, expansion),
IsCollapsible = expansion.IsCollapsible,
DefaultExpanded = expansion.DefaultExpanded,
Expand Down Expand Up @@ -693,18 +686,37 @@ private static TypeAnnotation BuildAnnotation(TypeInfo typeInfo, bool hasActualP

if (typeInfo.IsArray)
{
spans.Add(new TypeSpan("[] ", "array-icon"));
spans.Add(new TypeSpan("array of ", "array-keyword"));
AppendArrayKeywordSpans(spans, typeInfo, hasActualProperties);
if (typeInfo.HasLink)
spans.Add(new TypeSpan("{} ", "object-icon"));
spans.Add(
new TypeSpan(
PluralizeArrayItemTypeName(typeName),
Title: string.IsNullOrEmpty(typeInfo.SchemaRef) ? null : typeInfo.SchemaRef
)
);
return new TypeAnnotation(spans);
}
else
AppendScalarKeywordSpans(spans, typeInfo, hasActualProperties);

AppendScalarKeywordSpans(spans, typeInfo, hasActualProperties);

if (typeInfo.HasLink)
spans.Add(new TypeSpan("{} ", "object-icon"));
spans.Add(new TypeSpan(typeName, Title: string.IsNullOrEmpty(typeInfo.SchemaRef) ? null : typeInfo.SchemaRef));
return new TypeAnnotation(spans);
}

private static string PluralizeArrayItemTypeName(string typeName) => typeName switch
{
"string" => "strings",
"integer" => "integers",
"number" => "numbers",
"boolean" => "booleans",
"object" => "objects",
_ => typeName
};

private static void AppendArrayKeywordSpans(List<TypeSpan> spans, TypeInfo typeInfo, bool hasActualProperties)
{
if (typeInfo.IsValueType && !string.IsNullOrEmpty(typeInfo.ValueTypeBase))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,18 +7,10 @@
<a href="#@Model.AnchorId">
<code>@Model.Name</code>
@(await RenderPartialAsync<_SchemaType, TypeAnnotation>(Model.Type))
@if (Model.IsRequest && Model.IsRequired)
@if (Model.IsRequired)
{
<span class="required">required</span>
}
else if (!Model.IsRequest && !Model.IsRequired)
{
<span class="optional">optional</span>
}
@if (Model.IsRecursive)
{
@(await RenderPartialAsync<_RecursiveBadge>())
}
@if (Model.ShowDeprecatedBadge)
{
<span class="deprecated-badge">deprecated</span>
Expand Down

This file was deleted.

76 changes: 76 additions & 0 deletions src/Elastic.ApiExplorer/Infrastructure/ApiBreadcrumb.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
// 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.Documentation.Navigation;

namespace Elastic.ApiExplorer.Infrastructure;

/// <summary>One crumb. <see cref="Url"/> is null for the current page (not a link).</summary>
public sealed record ApiBreadcrumb(string Title, string? Url)
{
public bool IsCurrent => Url is null;
}

/// <summary>
/// Visible crumbs plus optional overflow (EUI Page breadcrumbs, max 4).
/// When overflowing: first two, ellipsis, last two.
/// </summary>
public sealed record ApiBreadcrumbTrail(
IReadOnlyList<ApiBreadcrumb> Head,
IReadOnlyList<ApiBreadcrumb> Overflow,
IReadOnlyList<ApiBreadcrumb> Tail
)
{
public static readonly ApiBreadcrumbTrail Empty = new([], [], []);

public bool HasOverflow => Overflow.Count > 0;

public bool IsEmpty => Head.Count == 0 && Tail.Count == 0;
}

public sealed record ApiBreadcrumbsView(ApiBreadcrumbTrail Trail, string HxAttributes);

public static class ApiBreadcrumbBuilder
{
public const int MaxVisible = 4;

public static ApiBreadcrumbTrail Build(INavigationItem current, string currentTitle, string? rootTitle)
{
var items = Collect(current, currentTitle, rootTitle);
if (items.Count == 0)
return ApiBreadcrumbTrail.Empty;
return Split(items);
}

internal static IReadOnlyList<ApiBreadcrumb> Collect(INavigationItem current, string currentTitle, string? rootTitle)
{
var items = new List<ApiBreadcrumb>();
foreach (var parent in current.GetParents().Reverse())
{
if (parent.Hidden)
continue;
if (string.Equals(parent.Url, current.Url, StringComparison.Ordinal))
continue;
if (string.Equals(parent.NavigationTitle, currentTitle, StringComparison.OrdinalIgnoreCase))
continue;

var title = parent.Parent is null && !string.IsNullOrWhiteSpace(rootTitle) ? rootTitle : parent.NavigationTitle;
if (string.IsNullOrWhiteSpace(title))
continue;
items.Add(new ApiBreadcrumb(title, parent.Url));
}

var currentLabel = string.IsNullOrWhiteSpace(currentTitle) ? current.NavigationTitle : currentTitle;
items.Add(new ApiBreadcrumb(currentLabel, null));
return items;
}

internal static ApiBreadcrumbTrail Split(IReadOnlyList<ApiBreadcrumb> items)
{
if (items.Count <= MaxVisible)
return new ApiBreadcrumbTrail(items, [], []);

return new ApiBreadcrumbTrail(items.Take(2).ToArray(), items.Skip(2).Take(items.Count - 4).ToArray(), items.TakeLast(2).ToArray());
}
}
10 changes: 10 additions & 0 deletions src/Elastic.ApiExplorer/Infrastructure/ApiCodeSampleModel.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
// 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;

namespace Elastic.ApiExplorer.Infrastructure;

/// <summary>Multi-language code sample widget with a header language selector.</summary>
public record ApiCodeSampleModel(string IdPrefix, IReadOnlyList<CodeSample> Samples);
14 changes: 0 additions & 14 deletions src/Elastic.ApiExplorer/Infrastructure/ApiRenderContext.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,8 @@
// 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.Collections.Frozen;
using Elastic.ApiExplorer.Model;
using Elastic.ApiExplorer.Operations;
using Elastic.ApiExplorer.Supplemental;
using Elastic.Documentation;
using Elastic.Documentation.Configuration;
using Elastic.Documentation.Navigation;
Expand All @@ -32,16 +30,4 @@ StaticFileContentHashProvider StaticFileContentHashProvider
public ILogger? ApiExplorerLog { get; init; }

public IReadOnlyList<ApiVersionSwitcherItem> VersionSwitcherItems { get; init; } = [];

internal IReadOnlyDictionary<string, ApiSupplementalDoc> OperationSupplemental
{
get;
init;
} = FrozenDictionary<string, ApiSupplementalDoc>.Empty;

internal IReadOnlyDictionary<string, ApiSupplementalDoc> TagSupplemental
{
get;
init;
} = FrozenDictionary<string, ApiSupplementalDoc>.Empty;
}
20 changes: 18 additions & 2 deletions src/Elastic.ApiExplorer/Infrastructure/ApiViewModel.cs
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,18 @@ public record ApiTocItem(string Heading, string Slug, int Level = 2);
public record ApiLayoutViewModel : GlobalLayoutViewModel
{
public required IReadOnlyList<ApiTocItem> TocItems { get; init; }

/// <summary>When set, operation pages render examples in the right rail instead of the in-page TOC.</summary>
public OperationExamplesPanelModel? ExamplesPanel { get; init; }

public required ApiBreadcrumbTrail Breadcrumbs { get; init; }
public IReadOnlyList<ApiVersionSwitcherItem> VersionSwitcherItems { get; init; } = [];

/// <summary>
/// Preload hint for API links. Body already hx-boosts into <c>#main-container</c>,
/// so the examples rail swaps with the article without a dedicated OOB provider.
/// </summary>
public string HxAttributes => $" preload=\"{Htmx.Preload}\"";
}

public abstract class ApiViewModel(ApiRenderContext context)
Expand All @@ -49,6 +60,9 @@ public abstract class ApiViewModel(ApiRenderContext context)
/// <summary>When set, drives <see cref="GlobalLayoutViewModel.Title"/> for this page (e.g. intro/outro markdown). Does not affect <see cref="GlobalLayoutViewModel.HeaderTitle"/> which stays as the API product name.</summary>
protected virtual string? LayoutPageTitle => null;

/// <summary>Last breadcrumb label. Defaults to <see cref="LayoutPageTitle"/> or the nav title.</summary>
protected virtual string BreadcrumbCurrentTitle => LayoutPageTitle ?? CurrentNavigationItem.NavigationTitle;

private string? GetGitHubDocsUrl()
{
var repo = BuildContext.Git.RepositoryName;
Expand All @@ -74,15 +88,17 @@ public ApiLayoutViewModel CreateGlobalLayoutModel()
Previous = null,
Next = null,
NavigationHtml = NavigationHtml,
NavigationActiveUrl = CurrentNavigationItem.Url,
UrlPathPrefix = BuildContext.UrlPathPrefix,
AllowIndexing = BuildContext.AllowIndexing,
CanonicalBaseUrl = BuildContext.CanonicalBaseUrl,
GoogleTagManager = new GoogleTagManagerConfiguration(),
Optimizely = new OptimizelyConfiguration(),
GoogleTagManager = BuildContext.GoogleTagManager,
Optimizely = BuildContext.Optimizely,
Features = new FeatureFlags([]),
StaticFileContentHashProvider = StaticFileContentHashProvider,
BuildType = BuildContext.BuildType,
TocItems = GetTocItems(),
Breadcrumbs = ApiBreadcrumbBuilder.Build(CurrentNavigationItem, BreadcrumbCurrentTitle, Document.Info?.Title),
VersionSwitcherItems = RenderContext.VersionSwitcherItems,
// Header properties for isolated mode
HeaderTitle = docTitle,
Expand Down
82 changes: 2 additions & 80 deletions src/Elastic.ApiExplorer/Infrastructure/SectionHeader.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2,87 +2,9 @@
// 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.Collections.Frozen;
using Elastic.ApiExplorer.Model;
using Elastic.ApiExplorer.Operations;
using Elastic.ApiExplorer.Supplemental;
using Microsoft.AspNetCore.Html;

namespace Elastic.ApiExplorer.Infrastructure;

/// <summary>
/// A page section heading. <paramref name="Route"/> adds the operation-page section navigation
/// buttons; <paramref name="ContentTypeBadge"/> adds a content-type badge next to the title.
/// A page section heading. <paramref name="ContentTypeBadge"/> adds a content-type badge next to the title.
/// </summary>
public record SectionHeader(string Title, string Anchor, string? Route = null, string? ContentTypeBadge = null);

/// <summary>A leftover <c>##</c> section from a supplemental file, pre-rendered for the view.</summary>
public record ApiPostSection(string Heading, string Anchor, HtmlString BodyHtml)
{
internal static readonly FrozenSet<string> OperationReservedAnchors = FrozenSet.ToFrozenSet(
[
"paths",
"prerequisites",
"description",
"query-params",
"request-body",
"response",
"responses",
"code-examples",
"request-examples",
"response-examples",
"examples-jump-btn"
],
StringComparer.Ordinal
);

internal static IReadOnlyList<ApiPostSection> From(ApiRenderContext context, IReadOnlyList<ApiSupplementalSection> sections)
{
if (sections.Count == 0)
return [];

var used = OperationReservedAnchors.ToHashSet(StringComparer.Ordinal);
var result = new List<ApiPostSection>(sections.Count);
foreach (var s in sections)
{
var (title, explicitId) = SplitHeading(s.Heading);
var anchor = ResolveAnchor(title, explicitId, used);
result.Add(new ApiPostSection(title, anchor, ApiMarkdown.Render(context, s.Body)));
}

return result;
}

internal static (string Title, string? ExplicitId) SplitHeading(string heading)
{
var trimmed = heading.Trim();
var start = trimmed.LastIndexOf("{#", StringComparison.Ordinal);
if (start < 0 || !trimmed.EndsWith('}'))
return (trimmed, null);

var id = trimmed[(start + 2)..^1];
if (id.Length == 0 || id.Contains(' ') || id.Contains('{') || id.Contains('}'))
return (trimmed, null);

var title = trimmed[..start].Trim();
return (title.Length == 0 ? trimmed : title, id);
}

internal static string AnchorFor(string heading) => heading.Trim().ToLowerInvariant().Replace(' ', '-');

internal static string ResolveAnchor(string title, string? explicitId, ISet<string> used) =>
UniqueAnchor(explicitId ?? AnchorFor(title), used);

internal static string UniqueAnchor(string baseAnchor, ISet<string> used)
{
if (used.Add(baseAnchor))
return baseAnchor;

for (var n = 2; ; n++)
{
var candidate = $"{baseAnchor}-{n}";
if (used.Add(candidate))
return candidate;
}
}
}
public record SectionHeader(string Title, string Anchor, string? ContentTypeBadge = null);
Loading
Loading