Skip to content

Commit e375dc7

Browse files
authored
[API explorer] Add support for x-tagGroups (#3160)
1 parent 06e45f4 commit e375dc7

3 files changed

Lines changed: 311 additions & 85 deletions

File tree

docs/configure/content-set/api-explorer.md

Lines changed: 30 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -119,7 +119,7 @@ The API Explorer supports the following OpenAPI specification extensions to enha
119119

120120
### `x-displayName` for tags
121121

122-
Use the `x-displayName` extension on tag objects to provide user-friendly display names in navigation and landing pages while maintaining stable URLs based on the canonical tag name.
122+
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.
123123

124124
```json
125125
{
@@ -139,7 +139,35 @@ Use the `x-displayName` extension on tag objects to provide user-friendly displa
139139
```
140140

141141
**Behavior:**
142+
142143
- When `x-displayName` is present, it's used for navigation titles and section headings in the API Explorer
143144
- When `x-displayName` is absent, the canonical tag `name` is used as a fallback
144145
- Navigation URLs and internal references always use the canonical tag `name` for stability
145-
- This extension follows the [Redocly specification extension pattern](https://redocly.com/docs-legacy/api-reference-docs/specification-extensions/x-display-name)
146+
147+
### `x-tagGroups` for sidebar grouping
148+
149+
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.
150+
151+
```json
152+
{
153+
"openapi": "3.0.3",
154+
"info": { "title": "Example", "version": "1.0.0" },
155+
"paths": {},
156+
"x-tagGroups": [
157+
{
158+
"name": "Search & Document APIs",
159+
"tags": ["search", "document", "eql", "esql", "sql"]
160+
},
161+
{
162+
"name": "Cluster Management",
163+
"tags": ["indices", "cluster", "snapshot"]
164+
}
165+
]
166+
}
167+
```
168+
169+
**Behavior:**
170+
171+
- When `x-tagGroups` is present and valid, the API Explorer uses it as an additional level of grouping in the sidebar.
172+
- When `x-tagGroups` is absent, tags are listed directly under the API root in a single flat layer.
173+
- 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.

src/Elastic.ApiExplorer/OpenApiGenerator.cs

Lines changed: 104 additions & 71 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44

55
using System;
66
using System.IO.Abstractions;
7+
using System.Text.Json.Nodes;
78
using System.Text.RegularExpressions;
89
using Elastic.ApiExplorer.Landing;
910
using Elastic.ApiExplorer.Operations;
@@ -44,6 +45,9 @@ public record ApiEndpoint(List<ApiOperation> Operations, string? Name) : IApiGro
4445

4546
public class OpenApiGenerator(ILoggerFactory logFactory, BuildContext context, IMarkdownStringRenderer markdownStringRenderer)
4647
{
48+
private const string TagOnlyClassificationKey = "__api_explorer_tag_only__";
49+
private const string UnknownTagGroupName = "unknown";
50+
4751
private readonly ILogger _logger = logFactory.CreateLogger<OpenApiGenerator>();
4852
private readonly IFileSystem _writeFileSystem = context.WriteFileSystem;
4953
private readonly StaticFileContentHashProvider _contentHashProvider = new(new EmbeddedOrPhysicalFileProvider(context));
@@ -56,6 +60,8 @@ public LandingNavigationItem CreateNavigation(string apiUrlSuffix, OpenApiDocume
5660

5761
// Parse x-displayName from OpenAPI tags for user-friendly display names
5862
var tagDisplayNames = ParseTagDisplayNames(openApiDocument);
63+
var xTagGroups = TryParseXTagGroups(openApiDocument);
64+
var orphanTagsLogged = new HashSet<string>(StringComparer.Ordinal);
5965

6066
var ops = openApiDocument.Paths
6167
.SelectMany(p => (p.Value.Operations ?? []).Select(op => (Path: p, Operation: op)))
@@ -70,11 +76,7 @@ public LandingNavigationItem CreateNavigation(string apiUrlSuffix, OpenApiDocume
7076
? anyApi.Node.GetValue<string>()
7177
: null;
7278
var tag = op.Value.Tags?.FirstOrDefault()?.Reference.Id;
73-
var tagClassification = (extensions?.TryGetValue("x-tag-group", out var g) ?? false) && g is JsonNodeExtension anyTagGroup
74-
? anyTagGroup.Node.GetValue<string>()
75-
: openApiDocument.Info.Title == "Elasticsearch Request & Response Specification"
76-
? ClassifyElasticsearchTag(tag ?? "unknown")
77-
: "unknown";
79+
var tagClassification = ResolveTagClassification(tag, xTagGroups, orphanTagsLogged);
7880

7981
var apiString = ns is null
8082
? api ?? op.Value.Summary ?? Guid.NewGuid().ToString("N") : $"{ns}.{api}";
@@ -91,11 +93,20 @@ public LandingNavigationItem CreateNavigation(string apiUrlSuffix, OpenApiDocume
9193

9294
// intermediate grouping of models to create the navigation tree
9395
// this is two-phased because we need to know if an endpoint has one or more operations
96+
var presentClassifications = ops.Select(o => o.Classification).ToHashSet();
97+
var orderedClassificationKeys = xTagGroups is null
98+
? [TagOnlyClassificationKey]
99+
: GetOrderedClassificationKeys(xTagGroups, presentClassifications);
100+
94101
var classifications = new List<ApiClassification>();
95-
foreach (var classGroup in ops.GroupBy(o => o.Classification))
102+
foreach (var classKey in orderedClassificationKeys)
96103
{
104+
var classOps = ops.Where(o => o.Classification == classKey).ToArray();
105+
if (classOps.Length == 0)
106+
continue;
107+
97108
var tags = new List<ApiTag>();
98-
foreach (var tagGroup in classGroup.GroupBy(o => o.Tag))
109+
foreach (var tagGroup in classOps.GroupBy(o => o.Tag))
99110
{
100111
var apis = new List<ApiEndpoint>();
101112
foreach (var apiGroup in tagGroup.GroupBy(o => o.Api))
@@ -119,15 +130,14 @@ public LandingNavigationItem CreateNavigation(string apiUrlSuffix, OpenApiDocume
119130
// Sort tags alphabetically by display name, fallback to canonical name
120131
tags = tags.OrderBy(t => t.DisplayName ?? t.Name, StringComparer.OrdinalIgnoreCase).ToList();
121132

122-
var classification = new ApiClassification(classGroup.Key, "", tags);
123-
classifications.Add(classification);
133+
classifications.Add(new ApiClassification(classKey, "", tags));
124134
}
125135

126136
var topLevelNavigationItems = new List<IApiGroupingNavigationItem<IApiGroupingModel, INavigationItem>>();
127137
var hasClassifications = classifications.Count > 1;
128138
foreach (var classification in classifications)
129139
{
130-
if (hasClassifications && classification.Name != "common")
140+
if (hasClassifications)
131141
{
132142
var classificationNavigationItem = new ClassificationNavigationItem(classification, rootNavigation, rootNavigation);
133143
var tagNavigationItems = new List<IApiGroupingNavigationItem<IApiGroupingModel, INavigationItem>>();
@@ -406,72 +416,95 @@ private static string FormatSchemaDisplayName(string schemaId)
406416
return parts.Length > 0 ? parts[^1] : schemaId;
407417
}
408418

409-
private static string ClassifyElasticsearchTag(string tag)
419+
private string ResolveTagClassification(string? tag, XTagGroupsDocument? xTagGroups, HashSet<string> orphanTagsLogged)
410420
{
411-
#pragma warning disable IDE0066
412-
switch (tag)
413-
#pragma warning restore IDE0066
421+
if (xTagGroups is null)
422+
return TagOnlyClassificationKey;
423+
424+
var tagName = tag ?? UnknownTagGroupName;
425+
if (xTagGroups.TagToGroup.TryGetValue(tagName, out var group))
426+
return group;
427+
428+
if (orphanTagsLogged.Add(tagName))
414429
{
415-
case "sql":
416-
case "eql":
417-
case "esql":
418-
case "search":
419-
case "document":
420-
return "common";
421-
422-
case "autoscaling":
423-
case "ccr":
424-
case "indices":
425-
case "data stream":
426-
case "ilm":
427-
case "slm":
428-
case "cluster":
429-
case "rollup":
430-
case "searchable_snapshots":
431-
case "shutdown":
432-
case "snapshot":
433-
case "script":
434-
case "search_application":
435-
case "connector":
436-
return "management";
437-
438-
case "cat":
439-
case "license":
440-
case "info":
441-
case "tasks":
442-
case "xpack":
443-
case "health_report":
444-
case "features":
445-
case "migration":
446-
case "watcher":
447-
return "info";
448-
449-
450-
case "ml trained model":
451-
case "ml anomaly":
452-
case "ml data frame":
453-
case "ml":
454-
case "inference":
455-
case "text_structure":
456-
case "query_rules":
457-
case "analytics":
458-
case "graph":
459-
return "ai/ml";
460-
461-
case "ingest":
462-
case "enrich":
463-
case "transform":
464-
case "fleet":
465-
case "logstash":
466-
case "synonyms":
467-
return "ingest";
468-
469-
case "security":
470-
return "security";
430+
_logger.LogWarning(
431+
"OpenAPI tag '{TagName}' is not listed in any x-tagGroups entry; navigation will group it under '{UnknownGroup}'.",
432+
tagName,
433+
UnknownTagGroupName);
471434
}
472-
return "unknown";
435+
436+
return UnknownTagGroupName;
473437
}
474438

439+
private static List<string> GetOrderedClassificationKeys(XTagGroupsDocument xTagGroups, HashSet<string> presentClassifications)
440+
{
441+
var ordered = new List<string>();
442+
foreach (var g in xTagGroups.OrderedGroupNames)
443+
{
444+
if (presentClassifications.Contains(g))
445+
ordered.Add(g);
446+
}
447+
448+
if (presentClassifications.Contains(UnknownTagGroupName) && !ordered.Contains(UnknownTagGroupName))
449+
ordered.Add(UnknownTagGroupName);
450+
451+
foreach (var c in presentClassifications)
452+
{
453+
if (!ordered.Contains(c))
454+
ordered.Add(c);
455+
}
456+
457+
return ordered;
458+
}
459+
460+
private static XTagGroupsDocument? TryParseXTagGroups(OpenApiDocument openApiDocument)
461+
{
462+
if (openApiDocument.Extensions?.TryGetValue("x-tagGroups", out var extension) != true || extension is not JsonNodeExtension jsonExt)
463+
return null;
464+
465+
if (jsonExt.Node is not JsonArray array || array.Count == 0)
466+
return null;
467+
468+
var orderedGroupNames = new List<string>();
469+
var tagToGroup = new Dictionary<string, string>(StringComparer.Ordinal);
470+
471+
foreach (var element in array)
472+
{
473+
if (element is not JsonObject groupObj)
474+
continue;
475+
476+
if (!groupObj.TryGetPropertyValue("name", out var nameNode))
477+
continue;
478+
479+
var groupName = nameNode?.GetValue<string>();
480+
if (string.IsNullOrWhiteSpace(groupName))
481+
continue;
482+
483+
if (!orderedGroupNames.Contains(groupName))
484+
orderedGroupNames.Add(groupName);
485+
486+
if (!groupObj.TryGetPropertyValue("tags", out var tagsNode) || tagsNode is not JsonArray tagNames)
487+
continue;
488+
489+
foreach (var tagElement in tagNames)
490+
{
491+
var tagName = tagElement?.GetValue<string>();
492+
if (string.IsNullOrEmpty(tagName))
493+
continue;
494+
495+
if (!tagToGroup.ContainsKey(tagName))
496+
tagToGroup[tagName] = groupName;
497+
}
498+
}
499+
500+
if (orderedGroupNames.Count == 0 || tagToGroup.Count == 0)
501+
return null;
502+
503+
return new XTagGroupsDocument(orderedGroupNames, tagToGroup);
504+
}
505+
506+
private sealed record XTagGroupsDocument(IReadOnlyList<string> OrderedGroupNames, IReadOnlyDictionary<string, string> TagToGroup);
507+
475508
/// <summary>
476509
/// Parses x-displayName extensions from OpenAPI tag objects to build a mapping of tag names to display names.
477510
/// Falls back to the canonical tag name when no x-displayName is present.

0 commit comments

Comments
 (0)