From 221e9e37b89275a0bb999b9ab3de4ca80bfb90e3 Mon Sep 17 00:00:00 2001 From: lcawl Date: Wed, 22 Apr 2026 12:10:44 -0700 Subject: [PATCH 1/2] [API Explorer] Add support for x-req-auth --- docs/configure/content-set/api-explorer.md | 2 +- src/Elastic.ApiExplorer/ApiRenderContext.cs | 3 + src/Elastic.ApiExplorer/ApiViewModel.cs | 3 + src/Elastic.ApiExplorer/OpenApiGenerator.cs | 3 +- .../Operations/OpenApiXReqAuthParser.cs | 66 +++++++ .../Operations/OperationView.cshtml | 20 ++ .../Operations/OperationViewModel.cs | 11 ++ .../Elastic.ApiExplorer.Tests.csproj | 4 + ...csearch-x-req-auth-cat-indices-sample.json | 55 ++++++ .../kibana-openapi-no-x-req-auth-sample.json | 47 +++++ .../XReqAuthTests.cs | 184 ++++++++++++++++++ 11 files changed, 396 insertions(+), 2 deletions(-) create mode 100644 src/Elastic.ApiExplorer/Operations/OpenApiXReqAuthParser.cs create mode 100644 tests/Elastic.ApiExplorer.Tests/TestData/elasticsearch-x-req-auth-cat-indices-sample.json create mode 100644 tests/Elastic.ApiExplorer.Tests/TestData/kibana-openapi-no-x-req-auth-sample.json create mode 100644 tests/Elastic.ApiExplorer.Tests/XReqAuthTests.cs diff --git a/docs/configure/content-set/api-explorer.md b/docs/configure/content-set/api-explorer.md index 7292ebde22..6dfaef59aa 100644 --- a/docs/configure/content-set/api-explorer.md +++ b/docs/configure/content-set/api-explorer.md @@ -133,7 +133,7 @@ toc: The API Explorer generates the following types of pages from your OpenAPI spec: - **Landing page**: An overview of the API grouped by tag -- **Operation pages**: One page per API operation, with the HTTP method, path, parameters, request body, response schemas, and examples +- **Operation pages**: One page per API operation, with the HTTP method, path, optional **Prerequisites** (when `x-req-auth` is set), parameters, request body, response schemas, and examples - **Schema type pages**: Dedicated pages for complex shared types such as `QueryContainer` and `AggregationContainer` ## OpenAPI extensions 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 }) + { +

+ Prerequisites + + @Model.Operation.Route + + + +

+
    + @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"); + } +} From 48ac0d443585fe3fffa75c962984894d70135ffa Mon Sep 17 00:00:00 2001 From: lcawl Date: Wed, 22 Apr 2026 12:53:31 -0700 Subject: [PATCH 2/2] Edit api-explorer.md --- docs/configure/content-set/api-explorer.md | 79 +++++++++++++++------- 1 file changed, 56 insertions(+), 23 deletions(-) diff --git a/docs/configure/content-set/api-explorer.md b/docs/configure/content-set/api-explorer.md index 6dfaef59aa..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: @@ -133,14 +133,65 @@ toc: The API Explorer generates the following types of pages from your OpenAPI spec: - **Landing page**: An overview of the API grouped by tag -- **Operation pages**: One page per API operation, with the HTTP method, path, optional **Prerequisites** (when `x-req-auth` is set), parameters, request body, response schemas, and examples +- **Operation pages**: One page per API operation, with the HTTP method, path, parameters, request body, response schemas, and examples - **Schema type pages**: Dedicated pages for complex shared types such as `QueryContainer` and `AggregationContainer` ## 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.