diff --git a/docs/configure/content-set/api-explorer.md b/docs/configure/content-set/api-explorer.md
index 7292ebde22..848fbef77b 100644
--- a/docs/configure/content-set/api-explorer.md
+++ b/docs/configure/content-set/api-explorer.md
@@ -15,7 +15,7 @@ This feature is still under development and the functionality described on this
Add the `api` key to your `docset.yml` file to enable the API Explorer. The key maps product names to OpenAPI JSON specification files.
Paths are relative to the folder that contains `docset.yml`.
-### Basic Configuration
+### Basic configuration
```yaml
api:
@@ -138,9 +138,60 @@ The API Explorer generates the following types of pages from your OpenAPI spec:
## OpenAPI extensions
-The API Explorer supports the following OpenAPI specification extensions to enhance navigation and display:
+The API Explorer supports some OpenAPI specification extensions to enhance navigation and display:
-### `x-displayName` for tags
+- [x-codeSamples](#x-codesamples)
+- [x-displayName](#x-displayname)
+- [x-req-auth](#x-req-auth)
+- [x-tagGroups](#x-taggroups)
+
+For background on OpenAPI vendor extensions, refer to [OpenAPI Specification](https://spec.openapis.org/oas/latest.html#specification-extensions).
+
+### Multi-language code examples [x-codesamples]
+
+When an OpenAPI operation includes the `x-codeSamples` extension, the API Explorer renders the code samples with a language selector tab. This lets users switch between available languages such as Console, cURL, Python, JavaScript, Ruby, PHP, and Java.
+
+The `x-codeSamples` extension is a JSON array of objects, each with a `lang` and `source` field:
+
+```json
+"x-codeSamples": [
+ { "lang": "Console", "source": "GET /_search" },
+ { "lang": "curl", "source": "curl -X GET ..." },
+ { "lang": "Python", "source": "resp = client.search()" }
+]
+```
+
+The code samples appear in a standalone "Code Examples" section on every operation page that has the extension, regardless of HTTP method. This means GET, DELETE, and other operations without a request body also display language tabs when `x-codeSamples` are present. When multiple languages are available, they appear as tabs. The selected language persists across operations and page navigations. When only one language is available, the example renders without a tab selector.
+
+Console is treated as the default language and appears first in the tab order when present.
+
+### Prerequisites [x-req-auth]
+
+Add the operation-level `x-req-auth` extension to list authentication or privilege requirements that users must satisfy before calling the API.
+The API Explorer renders these lines in a **Prerequisites** section on the operation page.
+
+`x-req-auth` is a JSON array of strings.
+Each non-empty string becomes one item in the prerequisites list (leading and trailing whitespace is trimmed).
+
+```json
+{
+ "get": {
+ "operationId": "get-snapshot",
+ "responses": { "200": { "description": "ok" } },
+ "x-req-auth": [
+ "Cluster privilege: `cluster:admin/snapshot`"
+ ]
+ }
+}
+```
+
+
+
+When prerequisites are present, **Prerequisites** also appears in the on-page table of contents (after **Paths**).
+When the extension is missing, empty, or not a JSON array, the section is omitted.
+Malformed values are skipped and the build may log a warning.
+
+### Tag labels [x-displayname]
Use the `x-displayName` extension (from [Redocly](https://redocly.com/docs-legacy/api-reference-docs/specification-extensions/x-display-name)) on tag objects to provide user-friendly display names in navigation and landing pages while maintaining stable URLs based on the canonical tag name.
@@ -167,7 +218,7 @@ Use the `x-displayName` extension (from [Redocly](https://redocly.com/docs-legac
- When `x-displayName` is absent, the canonical tag `name` is used as a fallback
- Navigation URLs and internal references always use the canonical tag `name` for stability
-### `x-tagGroups` for sidebar grouping
+### Tag groups [x-taggroups]
Use the document-level `x-tagGroups` extension (from [Redocly](https://redocly.com/docs-legacy/api-reference-docs/specification-extensions/x-tag-groups)) to define how tags are grouped in the API Explorer sidebar. Each group has a display `name` and a list of tag `name` values that belong to it. Group order in the array is the order of top-level sections in the navigation.
@@ -194,21 +245,3 @@ Use the document-level `x-tagGroups` extension (from [Redocly](https://redocly.c
- When `x-tagGroups` is present and valid, the API Explorer uses it as an additional level of grouping in the sidebar.
- When `x-tagGroups` is absent, tags are listed directly under the API root in a single flat layer.
- Any operation tag that is not listed under any group is still included: it appears under a fallback section named `unknown`, and the build logs a warning so you can fix the spec.
-
-### Multi-language code examples
-
-When an OpenAPI operation includes the `x-codeSamples` extension, the API Explorer renders the code samples with a language selector tab. This lets users switch between available languages such as Console, cURL, Python, JavaScript, Ruby, PHP, and Java.
-
-The `x-codeSamples` extension is a JSON array of objects, each with a `lang` and `source` field:
-
-```json
-"x-codeSamples": [
- { "lang": "Console", "source": "GET /_search" },
- { "lang": "curl", "source": "curl -X GET ..." },
- { "lang": "Python", "source": "resp = client.search()" }
-]
-```
-
-The code samples appear in a standalone "Code Examples" section on every operation page that has the extension, regardless of HTTP method. This means GET, DELETE, and other operations without a request body also display language tabs when `x-codeSamples` are present. When multiple languages are available, they appear as tabs. The selected language persists across operations and page navigations. When only one language is available, the example renders without a tab selector.
-
-Console is treated as the default language and appears first in the tab order when present.
diff --git a/src/Elastic.ApiExplorer/ApiRenderContext.cs b/src/Elastic.ApiExplorer/ApiRenderContext.cs
index 2779aa3030..516db203c1 100644
--- a/src/Elastic.ApiExplorer/ApiRenderContext.cs
+++ b/src/Elastic.ApiExplorer/ApiRenderContext.cs
@@ -8,6 +8,7 @@
using Elastic.Documentation.Navigation;
using Elastic.Documentation.Site.FileProviders;
using Elastic.Documentation.Site.Navigation;
+using Microsoft.Extensions.Logging;
using Microsoft.OpenApi;
namespace Elastic.ApiExplorer;
@@ -23,4 +24,6 @@ StaticFileContentHashProvider StaticFileContentHashProvider
public required INavigationItem CurrentNavigation { get; init; }
public required IMarkdownStringRenderer MarkdownRenderer { get; init; }
+ /// Logger for API Explorer rendering (e.g. OpenAPI extension parsing); optional when the host does not provide one.
+ public ILogger? ApiExplorerLog { get; init; }
}
diff --git a/src/Elastic.ApiExplorer/ApiViewModel.cs b/src/Elastic.ApiExplorer/ApiViewModel.cs
index 558d9f606a..bf4bb5cac7 100644
--- a/src/Elastic.ApiExplorer/ApiViewModel.cs
+++ b/src/Elastic.ApiExplorer/ApiViewModel.cs
@@ -32,6 +32,9 @@ public abstract partial class ApiViewModel(ApiRenderContext context)
public BuildContext BuildContext { get; } = context?.BuildContext ?? throw new ArgumentNullException(nameof(context), "BuildContext cannot be null");
public OpenApiDocument Document { get; } = context?.Model ?? throw new ArgumentNullException(nameof(context), "OpenApiDocument cannot be null");
+ /// Current API render context (OpenAPI model, nav, optional logging).
+ protected ApiRenderContext RenderContext { get; } = context ?? throw new ArgumentNullException(nameof(context));
+
public HtmlString RenderMarkdown(string? markdown)
{
diff --git a/src/Elastic.ApiExplorer/OpenApiGenerator.cs b/src/Elastic.ApiExplorer/OpenApiGenerator.cs
index 196e660c20..79e1996d16 100644
--- a/src/Elastic.ApiExplorer/OpenApiGenerator.cs
+++ b/src/Elastic.ApiExplorer/OpenApiGenerator.cs
@@ -405,7 +405,8 @@ private async Task GenerateApiProduct(string prefix, OpenApiDocument openApiDocu
{
NavigationHtml = string.Empty,
CurrentNavigation = navigation,
- MarkdownRenderer = markdownStringRenderer
+ MarkdownRenderer = markdownStringRenderer,
+ ApiExplorerLog = _logger
};
await RenderNavigationItems(prefix, renderContext, navigationRenderer, navigation, navigation, ctx);
diff --git a/src/Elastic.ApiExplorer/Operations/OpenApiXReqAuthParser.cs b/src/Elastic.ApiExplorer/Operations/OpenApiXReqAuthParser.cs
new file mode 100644
index 0000000000..aff21d43fc
--- /dev/null
+++ b/src/Elastic.ApiExplorer/Operations/OpenApiXReqAuthParser.cs
@@ -0,0 +1,66 @@
+// 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 System.Text.Json;
+using System.Text.Json.Nodes;
+using Elastic.Documentation.Extensions;
+using Microsoft.Extensions.Logging;
+using Microsoft.OpenApi;
+
+namespace Elastic.ApiExplorer.Operations;
+
+public static class OpenApiXReqAuthParser
+{
+ public const string ExtensionKey = "x-req-auth";
+
+ public static IReadOnlyList? TryGetPrerequisiteLines(
+ OpenApiOperation operation,
+ ILogger? log,
+ string? route,
+ string? operationId
+ )
+ {
+ if (operation.Extensions is null)
+ return null;
+
+ if (!operation.Extensions.TryGetValue(ExtensionKey, out var ext) || ext is not JsonNodeExtension jne)
+ return null;
+
+ try
+ {
+ if (jne.Node is not JsonArray array)
+ {
+ log?.LogWarning("Failed to parse {Extension} extension for operation {OperationId} on path {Path}: expected a JSON array", ExtensionKey, operationId, route);
+ return null;
+ }
+
+ var list = new List();
+ foreach (var node in array)
+ {
+ if (node is null)
+ continue;
+ var line = LineFromNode(node);
+ if (!string.IsNullOrWhiteSpace(line))
+ list.Add(line.Trim());
+ }
+
+ if (list.Count == 0)
+ return null;
+
+ return list;
+ }
+ catch (Exception ex)
+ {
+ log?.LogWarning(ex, "Failed to parse {Extension} extension for operation {OperationId} on path {Path}", ExtensionKey, operationId, route);
+ return null;
+ }
+ }
+
+ private static string LineFromNode(JsonNode node) =>
+ node is JsonValue value
+ ? value.GetValueKind() == JsonValueKind.String
+ ? value.GetValue() ?? ""
+ : value.ToString() ?? ""
+ : node.ToString() ?? "";
+}
diff --git a/src/Elastic.ApiExplorer/Operations/OperationView.cshtml b/src/Elastic.ApiExplorer/Operations/OperationView.cshtml
index a66879b836..0bd4487756 100644
--- a/src/Elastic.ApiExplorer/Operations/OperationView.cshtml
+++ b/src/Elastic.ApiExplorer/Operations/OperationView.cshtml
@@ -134,6 +134,26 @@
@{
var pathParameters = operation.Parameters?.Where(p => p.In == ParameterLocation.Path).ToArray() ?? [];
}
+ @{
+ var prerequisiteLines = Model.RequiredAuthItems;
+ }
+ @if (prerequisiteLines is { Count: > 0 })
+ {
+
+
+ @foreach (var line in prerequisiteLines)
+ {
+ - @Model.RenderMarkdown(line)
+ }
+
+ }
@if (pathParameters.Length > 0)
{
Path Parameters
diff --git a/src/Elastic.ApiExplorer/Operations/OperationViewModel.cs b/src/Elastic.ApiExplorer/Operations/OperationViewModel.cs
index 027a0fb84e..c570f02613 100644
--- a/src/Elastic.ApiExplorer/Operations/OperationViewModel.cs
+++ b/src/Elastic.ApiExplorer/Operations/OperationViewModel.cs
@@ -37,6 +37,14 @@ public class OperationViewModel(ApiRenderContext context) : ApiViewModel(context
///
public IReadOnlyList CodeSamples { get; private set; } = [];
+ public IReadOnlyList? RequiredAuthItems =>
+ OpenApiXReqAuthParser.TryGetPrerequisiteLines(
+ Operation.Operation,
+ RenderContext.ApiExplorerLog,
+ Operation.Route,
+ Operation.Operation.OperationId
+ );
+
protected override IReadOnlyList GetTocItems()
{
CodeSamples = ParseCodeSamples(Operation.Operation);
@@ -44,6 +52,9 @@ protected override IReadOnlyList GetTocItems()
var operation = Operation.Operation;
var tocItems = new List { new("Paths", "paths") };
+ if (RequiredAuthItems is { Count: > 0 })
+ tocItems.Add(new ApiTocItem("Prerequisites", "prerequisites"));
+
if (!string.IsNullOrWhiteSpace(operation.Description))
tocItems.Add(new ApiTocItem("Description", "description"));
diff --git a/tests/Elastic.ApiExplorer.Tests/Elastic.ApiExplorer.Tests.csproj b/tests/Elastic.ApiExplorer.Tests/Elastic.ApiExplorer.Tests.csproj
index fdef66023b..25b59aa823 100644
--- a/tests/Elastic.ApiExplorer.Tests/Elastic.ApiExplorer.Tests.csproj
+++ b/tests/Elastic.ApiExplorer.Tests/Elastic.ApiExplorer.Tests.csproj
@@ -14,4 +14,8 @@
+
+
+
+
diff --git a/tests/Elastic.ApiExplorer.Tests/TestData/elasticsearch-x-req-auth-cat-indices-sample.json b/tests/Elastic.ApiExplorer.Tests/TestData/elasticsearch-x-req-auth-cat-indices-sample.json
new file mode 100644
index 0000000000..12dd3517cc
--- /dev/null
+++ b/tests/Elastic.ApiExplorer.Tests/TestData/elasticsearch-x-req-auth-cat-indices-sample.json
@@ -0,0 +1,55 @@
+{
+ "openapi": "3.0.0",
+ "info": {
+ "title": "Elasticsearch sample (x-req-auth cat indices)",
+ "version": "1.0.0"
+ },
+ "paths": {
+ "/_cat/indices": {
+ "get": {
+ "tags": [
+ "cat"
+ ],
+ "summary": "Get index information",
+ "operationId": "cat-indices",
+ "responses": {
+ "200": {
+ "description": "OK"
+ }
+ },
+ "x-req-auth": [
+ "Index privileges: `monitor`\n",
+ "Cluster privileges: `monitor`\n"
+ ]
+ }
+ },
+ "/_cat/indices/{index}": {
+ "get": {
+ "tags": [
+ "cat"
+ ],
+ "summary": "Get index information",
+ "operationId": "cat-indices-1",
+ "parameters": [
+ {
+ "name": "index",
+ "in": "path",
+ "required": true,
+ "schema": {
+ "type": "string"
+ }
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "OK"
+ }
+ },
+ "x-req-auth": [
+ "Index privileges: `monitor`\n",
+ "Cluster privileges: `monitor`\n"
+ ]
+ }
+ }
+ }
+}
diff --git a/tests/Elastic.ApiExplorer.Tests/TestData/kibana-openapi-no-x-req-auth-sample.json b/tests/Elastic.ApiExplorer.Tests/TestData/kibana-openapi-no-x-req-auth-sample.json
new file mode 100644
index 0000000000..cb22222163
--- /dev/null
+++ b/tests/Elastic.ApiExplorer.Tests/TestData/kibana-openapi-no-x-req-auth-sample.json
@@ -0,0 +1,47 @@
+{
+ "openapi": "3.0.0",
+ "info": {
+ "title": "Kibana sample (no x-req-auth)",
+ "version": "1.0.0"
+ },
+ "paths": {
+ "/api/status": {
+ "get": {
+ "operationId": "get-status",
+ "responses": {
+ "200": {
+ "description": "OK"
+ }
+ }
+ }
+ },
+ "/api/foo": {
+ "get": {
+ "operationId": "foo-get",
+ "responses": {
+ "200": {
+ "description": "OK"
+ }
+ }
+ },
+ "post": {
+ "operationId": "foo-post",
+ "responses": {
+ "200": {
+ "description": "OK"
+ }
+ }
+ }
+ },
+ "/api/bar": {
+ "delete": {
+ "operationId": "bar-delete",
+ "responses": {
+ "200": {
+ "description": "OK"
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/tests/Elastic.ApiExplorer.Tests/XReqAuthTests.cs b/tests/Elastic.ApiExplorer.Tests/XReqAuthTests.cs
new file mode 100644
index 0000000000..3eb7de364f
--- /dev/null
+++ b/tests/Elastic.ApiExplorer.Tests/XReqAuthTests.cs
@@ -0,0 +1,184 @@
+// 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 AwesomeAssertions;
+using Elastic.ApiExplorer.Operations;
+using Microsoft.OpenApi;
+using Microsoft.OpenApi.Reader;
+
+namespace Elastic.ApiExplorer.Tests;
+
+public class XReqAuthTests
+{
+ private static string TestDataPath(string fileName) =>
+ Path.Join(AppContext.BaseDirectory, "TestData", fileName);
+
+ [Fact]
+ public async Task TryGetPrerequisiteLines_MinimalOpenApi3Spec_MatchesElasticsearchShape()
+ {
+ var json = /*lang=json,strict*/ """
+ {
+ "openapi": "3.0.0",
+ "info": { "title": "t", "version": "1" },
+ "paths": {
+ "/a": {
+ "get": {
+ "operationId": "op-a",
+ "responses": { "200": { "description": "ok" } },
+ "x-req-auth": [
+ "Index privileges: `monitor`\n",
+ "Cluster privileges: `monitor`\n"
+ ]
+ }
+ }
+ }
+ }
+ """;
+ var jsonPath = Path.Join(Path.GetTempPath(), $"xreqauth-{Guid.NewGuid():N}.json");
+ try
+ {
+ await File.WriteAllTextAsync(jsonPath, json, TestContext.Current.CancellationToken);
+ var loaded = await OpenApiDocument.LoadAsync(
+ jsonPath,
+ new OpenApiReaderSettings
+ {
+ LeaveStreamOpen = false
+ },
+ TestContext.Current.CancellationToken
+ );
+ var op = loaded.Document!.Paths!["/a"].Operations![HttpMethod.Get]!;
+
+ var lines = OpenApiXReqAuthParser.TryGetPrerequisiteLines(op, null, "/a", "op-a");
+
+ lines.Should().NotBeNull();
+ lines!.Should().HaveCount(2);
+ lines[0].Should().Contain("Index");
+ lines[0].Should().Contain("monitor");
+ lines[1].Should().Contain("Cluster");
+ lines[1].Should().Contain("monitor");
+ }
+ finally
+ {
+ if (File.Exists(jsonPath))
+ File.Delete(jsonPath);
+ }
+ }
+
+ [Fact]
+ public async Task TryGetPrerequisiteLines_EmptyArray_ReturnsNull()
+ {
+ var json = /*lang=json,strict*/ """
+ {
+ "openapi": "3.0.0",
+ "info": { "title": "t", "version": "1" },
+ "paths": {
+ "/a": {
+ "get": {
+ "operationId": "op-a",
+ "responses": { "200": { "description": "ok" } },
+ "x-req-auth": []
+ }
+ }
+ }
+ }
+ """;
+ var jsonPath = Path.Join(Path.GetTempPath(), $"xreqauth-{Guid.NewGuid():N}.json");
+ try
+ {
+ await File.WriteAllTextAsync(jsonPath, json, TestContext.Current.CancellationToken);
+ var loaded = await OpenApiDocument.LoadAsync(
+ jsonPath,
+ new OpenApiReaderSettings
+ {
+ LeaveStreamOpen = false
+ },
+ TestContext.Current.CancellationToken
+ );
+ var op = loaded.Document!.Paths!["/a"].Operations![HttpMethod.Get]!;
+
+ OpenApiXReqAuthParser.TryGetPrerequisiteLines(op, null, "/a", "op-a")
+ .Should().BeNull("empty x-req-auth should not show Prerequisites");
+ }
+ finally
+ {
+ if (File.Exists(jsonPath))
+ File.Delete(jsonPath);
+ }
+ }
+
+ [Fact]
+ public async Task ElasticsearchSample_CatIndicesOperations_HaveXReqAuth()
+ {
+ var specPath = TestDataPath("elasticsearch-x-req-auth-cat-indices-sample.json");
+ File.Exists(specPath).Should().BeTrue($"Fixture missing: {specPath}");
+
+ var loaded = await OpenApiDocument.LoadAsync(
+ specPath,
+ new OpenApiReaderSettings
+ {
+ LeaveStreamOpen = false
+ },
+ TestContext.Current.CancellationToken
+ );
+ var doc = loaded.Document!;
+ var getIndices = doc.Paths!["/_cat/indices"].Operations![HttpMethod.Get]!;
+ var a = OpenApiXReqAuthParser.TryGetPrerequisiteLines(getIndices, null, "/_cat/indices", getIndices.OperationId);
+ a.Should().NotBeNull();
+ a!.Should().NotBeEmpty();
+ a.Should().OnlyContain(s => !string.IsNullOrWhiteSpace(s));
+
+ var getIndicesIndex = doc.Paths!["/_cat/indices/{index}"].Operations![HttpMethod.Get]!;
+ var b = OpenApiXReqAuthParser.TryGetPrerequisiteLines(
+ getIndicesIndex,
+ null,
+ "/_cat/indices/{index}",
+ getIndicesIndex.OperationId
+ );
+ b.Should().NotBeNull();
+ b!.Should().NotBeEmpty();
+ }
+
+ [Fact]
+ public async Task KibanaStyleSample_FirstPathsLackXReqAuth()
+ {
+ var specPath = TestDataPath("kibana-openapi-no-x-req-auth-sample.json");
+ File.Exists(specPath).Should().BeTrue($"Fixture missing: {specPath}");
+
+ var loaded = await OpenApiDocument.LoadAsync(
+ specPath,
+ new OpenApiReaderSettings
+ {
+ LeaveStreamOpen = false
+ },
+ TestContext.Current.CancellationToken
+ );
+ var doc = loaded.Document!;
+
+ var opCount = 0;
+ var pathCount = 0;
+ foreach (var p in doc.Paths)
+ {
+ if (pathCount >= 3)
+ break;
+ pathCount++;
+ if (p.Value.Operations is null)
+ continue;
+ foreach (var httpOp in p.Value.Operations)
+ {
+ if (opCount >= 5)
+ goto done;
+ var lines = OpenApiXReqAuthParser.TryGetPrerequisiteLines(
+ httpOp.Value,
+ null,
+ p.Key,
+ httpOp.Value.OperationId
+ );
+ lines.Should().BeNull("sample Kibana-style spec has no x-req-auth on sampled operations");
+ opCount++;
+ }
+ }
+ done:
+ opCount.Should().BeGreaterThan(0, "sample should have at least one operation in the first 3 paths");
+ }
+}