Skip to content

Commit 7a0b6ae

Browse files
szabosteveMpdreamz
andauthored
[API explorer] Replace raw x-state badges with applies_to popover (#3026)
* Replace raw x-state badges with applies_to popover in API explorer Parse x-state extension values into structured lifecycle and version data, then render them using the existing applies-to-popover web component instead of plain version-badge spans. This makes endpoint availability badges consistent with the rest of the documentation site. Made-with: Cursor * Fix version regex and null-config fallback in AvailabilityBadgeHelper Broaden version regex from "Added in X.Y.Z" to any semver pattern so deprecated/removed versions are also captured. Treat missing stack version config as released rather than planned, so badges fall back to the parsed lifecycle/version instead of mislabeling as "Planned". Made-with: Cursor * Project x-state into applies_to lifecycle format, delegate parsing to AppliesCollection Address MPdreamz review: instead of custom ParseLifecycle/ParseVersion methods, convert x-state strings to the lifecycle format ("ga 7.7.0", "preview", etc.) and delegate parsing to AppliesCollection.TryParse. This reuses the existing applies_to parsing infrastructure. A direct reference to Elastic.Markdown is not possible (circular dependency), so AppliesCollection from Elastic.Documentation is used directly. Made-with: Cursor * Use TryGetValue for safe JSON extraction, reorder lifecycle detection Use TryGetValue<string>() instead of GetValue<string>() to gracefully handle malformed x-state data. Reorder lifecycle detection so terminal states (removed, deprecated) and preview are checked before "generally available" to prevent keyword shadowing. Also match "preview" without requiring the "tech" prefix. Made-with: Cursor * Return null for unrecognized x-state strings with no version Don't default to GA for strings that match no lifecycle keyword and contain no version. Only assume GA when a version is present without an explicit lifecycle (e.g. "Added in 7.7.0"). Unrecognized strings with nothing displayable now produce no badge. Made-with: Cursor * Remove redundant cast to fix IDE0004 build error Made-with: Cursor --------- Co-authored-by: Martijn Laarman <Mpdreamz@gmail.com>
1 parent 5decf52 commit 7a0b6ae

5 files changed

Lines changed: 247 additions & 20 deletions

File tree

Lines changed: 190 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,190 @@
1+
// Licensed to Elasticsearch B.V under one or more agreements.
2+
// Elasticsearch B.V licenses this file to you under the Apache 2.0 License.
3+
// See the LICENSE file in the project root for more information
4+
5+
using System.Text.Json.Nodes;
6+
using System.Text.RegularExpressions;
7+
using Elastic.Documentation;
8+
using Elastic.Documentation.AppliesTo;
9+
using Elastic.Documentation.Configuration.Versions;
10+
using Elastic.Documentation.Diagnostics;
11+
using Microsoft.OpenApi;
12+
13+
namespace Elastic.ApiExplorer.Operations;
14+
15+
/// <summary>
16+
/// Badge data needed by the applies-to-popover web component.
17+
/// </summary>
18+
public sealed record AvailabilityBadgeData(
19+
string BadgeKey,
20+
string BadgeLifecycleText,
21+
string BadgeVersion,
22+
string LifecycleClass,
23+
string LifecycleName,
24+
bool ShowLifecycleName,
25+
bool ShowVersion
26+
);
27+
28+
/// <summary>
29+
/// Parses x-state extension values into structured badge data for the applies-to-popover web component.
30+
/// Projects x-state into the applies_to lifecycle format and delegates parsing to <see cref="AppliesCollection"/>.
31+
/// </summary>
32+
public static partial class AvailabilityBadgeHelper
33+
{
34+
[GeneratedRegex(@"(\d+\.\d+\.\d+)")]
35+
private static partial Regex SemVersionRegex();
36+
37+
/// <summary>
38+
/// Extracts badge data from an OpenAPI operation's x-state extension.
39+
/// Returns null when no displayable availability information exists.
40+
/// </summary>
41+
public static AvailabilityBadgeData? FromOperation(OpenApiOperation operation, VersionsConfiguration? versionsConfig) =>
42+
FromExtensions(operation.Extensions, versionsConfig);
43+
44+
/// <summary>
45+
/// Extracts badge data from an OpenAPI schema's x-state extension.
46+
/// Returns null when no displayable availability information exists.
47+
/// </summary>
48+
public static AvailabilityBadgeData? FromSchema(IOpenApiSchema schema, VersionsConfiguration? versionsConfig) =>
49+
FromExtensions(schema.Extensions, versionsConfig);
50+
51+
private static AvailabilityBadgeData? FromExtensions(
52+
IDictionary<string, IOpenApiExtension>? extensions,
53+
VersionsConfiguration? versionsConfig)
54+
{
55+
if (extensions is null || !extensions.TryGetValue("x-state", out var stateExtension))
56+
return null;
57+
58+
if (stateExtension is not JsonNodeExtension jsonNodeExtension)
59+
return null;
60+
61+
if (jsonNodeExtension.Node is not JsonValue jsonValue
62+
|| !jsonValue.TryGetValue<string>(out var stateValue)
63+
|| string.IsNullOrEmpty(stateValue))
64+
return null;
65+
66+
var lifecycleString = ProjectToLifecycleFormat(stateValue);
67+
if (lifecycleString is null)
68+
return null;
69+
70+
var diagnostics = new List<(Severity, string)>();
71+
if (!AppliesCollection.TryParse(lifecycleString, diagnostics, out var appliesCollection) || appliesCollection is null)
72+
return null;
73+
74+
var applicableTo = new ApplicableTo { Stack = appliesCollection };
75+
76+
return BuildBadgeData(applicableTo, versionsConfig);
77+
}
78+
79+
/// <summary>
80+
/// Converts an x-state string (e.g. "Added in 7.7.0", "Technical preview",
81+
/// "Generally available; added in 9.1.0") into the lifecycle format
82+
/// understood by <see cref="AppliesCollection.TryParse"/> (e.g. "ga 7.7.0", "preview").
83+
/// </summary>
84+
internal static string? ProjectToLifecycleFormat(string xState)
85+
{
86+
var lower = xState.ToLowerInvariant();
87+
88+
var lifecycle = lower switch
89+
{
90+
_ when lower.Contains("removed") => "removed",
91+
_ when lower.Contains("deprecated") => "deprecated",
92+
_ when lower.Contains("beta") => "beta",
93+
_ when lower.Contains("preview") => "preview",
94+
_ when lower.Contains("generally available") => "ga",
95+
_ => null
96+
};
97+
98+
var versionMatch = SemVersionRegex().Match(xState);
99+
if (versionMatch.Success)
100+
return $"{lifecycle ?? "ga"} {versionMatch.Groups[1].Value}";
101+
102+
return lifecycle;
103+
}
104+
105+
private static AvailabilityBadgeData? BuildBadgeData(
106+
ApplicableTo applicableTo,
107+
VersionsConfiguration? versionsConfig)
108+
{
109+
if (applicableTo.Stack is null)
110+
return null;
111+
112+
var applicability = applicableTo.Stack.First();
113+
var lifecycleClass = applicability.GetLifeCycleName().ToLowerInvariant().Replace(" ", "-");
114+
var lifecycleName = applicability.GetLifeCycleName();
115+
116+
var versionDisplay = "";
117+
var showVersion = false;
118+
var showLifecycleName = applicability.Lifecycle != ProductLifecycle.GenerallyAvailable;
119+
var badgeLifecycleText = "";
120+
121+
var version = applicability.Version;
122+
if (version is not null && version != AllVersionsSpec.Instance)
123+
{
124+
var currentVersion = GetCurrentStackVersion(versionsConfig);
125+
var isReleased = currentVersion is null || version.Min <= currentVersion;
126+
127+
if (isReleased)
128+
{
129+
versionDisplay = FormatVersion(version);
130+
showVersion = !string.IsNullOrEmpty(versionDisplay);
131+
132+
if (applicability.Lifecycle == ProductLifecycle.Removed && versionDisplay.EndsWith('+'))
133+
versionDisplay = versionDisplay.TrimEnd('+');
134+
}
135+
else
136+
{
137+
badgeLifecycleText = applicability.Lifecycle switch
138+
{
139+
ProductLifecycle.Deprecated => "Deprecation planned",
140+
ProductLifecycle.Removed => "Removal planned",
141+
_ => "Planned"
142+
};
143+
showLifecycleName = false;
144+
}
145+
}
146+
147+
return new AvailabilityBadgeData(
148+
BadgeKey: "Stack",
149+
BadgeLifecycleText: badgeLifecycleText,
150+
BadgeVersion: versionDisplay,
151+
LifecycleClass: lifecycleClass,
152+
LifecycleName: lifecycleName,
153+
ShowLifecycleName: showLifecycleName,
154+
ShowVersion: showVersion
155+
);
156+
}
157+
158+
private static SemVersion? GetCurrentStackVersion(VersionsConfiguration? versionsConfig)
159+
{
160+
if (versionsConfig is null)
161+
return null;
162+
163+
try
164+
{
165+
var versioningSystem = versionsConfig.GetVersioningSystem(VersioningSystemId.Stack);
166+
return versioningSystem.Current;
167+
}
168+
catch (ArgumentException)
169+
{
170+
return null;
171+
}
172+
}
173+
174+
private static string FormatVersion(VersionSpec versionSpec)
175+
{
176+
var min = versionSpec.Min;
177+
var minVersion = versionSpec.ShowMinPatch
178+
? $"{min.Major}.{min.Minor}.{min.Patch}"
179+
: $"{min.Major}.{min.Minor}";
180+
181+
return versionSpec.Kind switch
182+
{
183+
VersionSpecKind.GreaterThanOrEqual => $"{minVersion}+",
184+
VersionSpecKind.Exact => minVersion,
185+
VersionSpecKind.Range when versionSpec.Max is { } max =>
186+
$"{minVersion}-{(versionSpec.ShowMaxPatch ? $"{max.Major}.{max.Minor}.{max.Patch}" : $"{max.Major}.{max.Minor}")}",
187+
_ => minVersion
188+
};
189+
}
190+
}

src/Elastic.ApiExplorer/Operations/OperationView.cshtml

Lines changed: 24 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
@using Elastic.ApiExplorer.Operations
33
@using Elastic.ApiExplorer.Schema
44
@using Elastic.ApiExplorer.Shared
5+
@using Elastic.Documentation.Configuration.Versions
56
@using Microsoft.OpenApi
67
@inherits RazorSliceHttpResult<Elastic.ApiExplorer.Operations.OperationViewModel>
78
@implements IUsesLayout<Elastic.ApiExplorer._Layout, ApiLayoutViewModel>
@@ -34,7 +35,8 @@
3435
ShowVersionInfo = true,
3536
ShowExternalDocs = true,
3637
UseHiddenUntilFound = true,
37-
CollapseMode = CollapseMode.AlwaysCollapsed
38+
CollapseMode = CollapseMode.AlwaysCollapsed,
39+
VersionsConfiguration = Model.BuildContext.VersionsConfiguration
3840
};
3941

4042
// Helper to create SchemaTypeContext
@@ -74,16 +76,28 @@
7476
{
7577
<span class="beta-badge">Beta</span>
7678
}
77-
@{
78-
var versionInfo = (operation.Extensions?.TryGetValue("x-state", out var stateValue) == true && stateValue is JsonNodeExtension stateExt) ? stateExt.Node.GetValue<string>() : null;
79-
}
80-
@if (!string.IsNullOrEmpty(versionInfo))
81-
{
82-
<span class="version-badge">@versionInfo</span>
83-
}
8479
</h1>
85-
<span style="background:cyan; display:none;" id="debug-version">DEBUG: Extensions=@(operation.Extensions != null ? string.Join(",", operation.Extensions.Keys) : "null"), x-state=@(versionInfo ?? "null")</span>
86-
<span style="background:yellow; display:none;" id="debug-externaldocs">DEBUG: ExternalDocs=@(operation.ExternalDocs != null ? "exists" : "null"), Url=@(operation.ExternalDocs?.Url?.ToString() ?? "null")</span>
80+
@{
81+
var badgeData = AvailabilityBadgeHelper.FromOperation(operation, Model.BuildContext.VersionsConfiguration);
82+
}
83+
@if (badgeData is not null)
84+
{
85+
<p class="applies applies-block">
86+
<applies-to-popover
87+
badge-key="@badgeData.BadgeKey"
88+
badge-lifecycle-text="@badgeData.BadgeLifecycleText"
89+
badge-version="@badgeData.BadgeVersion"
90+
lifecycle-class="@badgeData.LifecycleClass"
91+
lifecycle-name="@badgeData.LifecycleName"
92+
show-lifecycle-name="@badgeData.ShowLifecycleName.ToString().ToLowerInvariant()"
93+
show-version="@badgeData.ShowVersion.ToString().ToLowerInvariant()"
94+
has-multiple-lifecycles="false"
95+
popover-data=""
96+
show-popover="false"
97+
is-inline="false"
98+
></applies-to-popover>
99+
</p>
100+
}
87101
@{
88102
// Servers can be at operation level or document level
89103
var servers = operation.Servers is { Count: > 0 } ? operation.Servers : Model.Document.Servers;

src/Elastic.ApiExplorer/Schema/RenderContext.cs

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
// Elasticsearch B.V licenses this file to you under the Apache 2.0 License.
33
// See the LICENSE file in the project root for more information
44

5+
using Elastic.Documentation.Configuration.Versions;
56
using Microsoft.AspNetCore.Html;
67
using Microsoft.OpenApi;
78

@@ -65,6 +66,9 @@ public record PropertyRenderContext
6566

6667
/// <summary>How collapsed sections should be expanded by default.</summary>
6768
public CollapseMode CollapseMode { get; init; } = CollapseMode.AlwaysCollapsed;
69+
70+
/// <summary>Versions configuration for rendering availability badges.</summary>
71+
public VersionsConfiguration? VersionsConfiguration { get; init; }
6872
}
6973

7074
/// <summary>
@@ -146,5 +150,8 @@ public record UnionVariantsContext
146150

147151
/// <summary>Maximum depth for property expansion.</summary>
148152
public int MaxDepth { get; init; } = SchemaHelpers.MaxDepth;
153+
154+
/// <summary>Versions configuration for rendering availability badges.</summary>
155+
public VersionsConfiguration? VersionsConfiguration { get; init; }
149156
}
150157

src/Elastic.ApiExplorer/Shared/_PropertyItem.cshtml

Lines changed: 24 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -126,14 +126,26 @@
126126
{
127127
<span class="deprecated-badge">deprecated</span>
128128
}
129-
@if (ctx.ShowVersionInfo)
129+
@if (ctx.ShowVersionInfo)
130+
{
131+
var propBadgeData = Elastic.ApiExplorer.Operations.AvailabilityBadgeHelper.FromSchema(propSchema, ctx.VersionsConfiguration);
132+
if (propBadgeData is not null)
130133
{
131-
var propVersionInfo = (propSchema.Extensions?.TryGetValue("x-state", out var propStateValue) == true && propStateValue is JsonNodeExtension propStateExt) ? propStateExt.Node.GetValue<string>() : null;
132-
if (!string.IsNullOrEmpty(propVersionInfo))
133-
{
134-
<span class="version-badge">@propVersionInfo</span>
135-
}
134+
<applies-to-popover
135+
badge-key="@propBadgeData.BadgeKey"
136+
badge-lifecycle-text="@propBadgeData.BadgeLifecycleText"
137+
badge-version="@propBadgeData.BadgeVersion"
138+
lifecycle-class="@propBadgeData.LifecycleClass"
139+
lifecycle-name="@propBadgeData.LifecycleName"
140+
show-lifecycle-name="@propBadgeData.ShowLifecycleName.ToString().ToLowerInvariant()"
141+
show-version="@propBadgeData.ShowVersion.ToString().ToLowerInvariant()"
142+
has-multiple-lifecycles="false"
143+
popover-data=""
144+
show-popover="false"
145+
is-inline="true"
146+
></applies-to-popover>
136147
}
148+
}
137149
</a>
138150
</dt>
139151
@if (hasDescription)
@@ -331,7 +343,8 @@
331343
ShowVersionInfo = ctx.ShowVersionInfo,
332344
ShowExternalDocs = ctx.ShowExternalDocs,
333345
UseHiddenUntilFound = ctx.UseHiddenUntilFound,
334-
CollapseMode = ctx.CollapseMode
346+
CollapseMode = ctx.CollapseMode,
347+
VersionsConfiguration = ctx.VersionsConfiguration
335348
};
336349

337350
var useHidden = ctx.UseHiddenUntilFound && isCollapsible && !defaultExpanded;
@@ -428,7 +441,8 @@
428441
RenderMarkdown = ctx.RenderMarkdown,
429442
UseHiddenUntilFound = ctx.UseHiddenUntilFound,
430443
CollapseMode = ctx.CollapseMode,
431-
MaxDepth = ctx.MaxDepth
444+
MaxDepth = ctx.MaxDepth,
445+
VersionsConfiguration = ctx.VersionsConfiguration
432446
};
433447
<div class="nested-properties" id="@Model.PropId-children">
434448
@(await RenderPartialAsync<_UnionOptions, UnionVariantsContext>(unionCtx))
@@ -447,7 +461,8 @@
447461
RenderMarkdown = ctx.RenderMarkdown,
448462
UseHiddenUntilFound = ctx.UseHiddenUntilFound,
449463
CollapseMode = ctx.CollapseMode,
450-
MaxDepth = ctx.MaxDepth
464+
MaxDepth = ctx.MaxDepth,
465+
VersionsConfiguration = ctx.VersionsConfiguration
451466
};
452467
if (useHidden)
453468
{

src/Elastic.ApiExplorer/Shared/_UnionOptions.cshtml

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -163,7 +163,8 @@
163163
ShowVersionInfo = true,
164164
ShowExternalDocs = true,
165165
UseHiddenUntilFound = Model.UseHiddenUntilFound,
166-
CollapseMode = Model.CollapseMode
166+
CollapseMode = Model.CollapseMode,
167+
VersionsConfiguration = Model.VersionsConfiguration
167168
};
168169
var useHidden = Model.UseHiddenUntilFound && isCollapsible && !defaultExpanded;
169170

0 commit comments

Comments
 (0)