Skip to content

Commit cfed530

Browse files
authored
[API Explorer] Sort tags by x-displayName (#3143)
1 parent 66484bd commit cfed530

2 files changed

Lines changed: 275 additions & 0 deletions

File tree

src/Elastic.ApiExplorer/OpenApiGenerator.cs

Lines changed: 5 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 System;
56
using System.IO.Abstractions;
67
using System.Text.RegularExpressions;
78
using Elastic.ApiExplorer.Landing;
@@ -114,6 +115,10 @@ public LandingNavigationItem CreateNavigation(string apiUrlSuffix, OpenApiDocume
114115
var tag = new ApiTag(tagName, displayName, "", apis);
115116
tags.Add(tag);
116117
}
118+
119+
// Sort tags alphabetically by display name, fallback to canonical name
120+
tags = tags.OrderBy(t => t.DisplayName ?? t.Name, StringComparer.OrdinalIgnoreCase).ToList();
121+
117122
var classification = new ApiClassification(classGroup.Key, "", tags);
118123
classifications.Add(classification);
119124
}

tests/Elastic.ApiExplorer.Tests/TagMetadataTests.cs

Lines changed: 270 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -329,4 +329,274 @@ private static bool IsTagForName(TagNavigationItem tagItem, string expectedTagNa
329329
// Access the ApiTag model through the Index property
330330
return tagItem.Index.Model is ApiTag tag && tag.Name == expectedTagName;
331331
}
332+
333+
[Fact]
334+
public async Task Tags_WithMixedDisplayNames_SortedAlphabeticallyByDisplayName()
335+
{
336+
// Arrange - spec with mixed x-displayName and canonical names
337+
var openApiJson = /*lang=json*/ """
338+
{
339+
"openapi": "3.0.3",
340+
"info": {
341+
"title": "Test API",
342+
"version": "1.0.0"
343+
},
344+
"paths": {
345+
"/zebra": {
346+
"get": {
347+
"operationId": "zebra-op",
348+
"tags": ["zebra"],
349+
"responses": {"200": {"description": "Success"}}
350+
}
351+
},
352+
"/apple": {
353+
"get": {
354+
"operationId": "apple-op",
355+
"tags": ["apple"],
356+
"responses": {"200": {"description": "Success"}}
357+
}
358+
},
359+
"/charlie": {
360+
"get": {
361+
"operationId": "charlie-op",
362+
"tags": ["charlie"],
363+
"responses": {"200": {"description": "Success"}}
364+
}
365+
}
366+
},
367+
"tags": [
368+
{
369+
"name": "zebra",
370+
"x-displayName": "Animal Zoo"
371+
},
372+
{
373+
"name": "apple",
374+
"x-displayName": "Fruit Store"
375+
},
376+
{
377+
"name": "charlie"
378+
}
379+
]
380+
}
381+
""";
382+
383+
var (generator, openApiDocument) = await CreateGeneratorWithSpec(openApiJson);
384+
385+
// Act
386+
var navigation = generator.CreateNavigation("test", openApiDocument);
387+
388+
// Assert - should be sorted alphabetically by display name: "Animal Zoo", "charlie", "Fruit Store"
389+
navigation.NavigationItems.Should().HaveCount(3);
390+
391+
var tagItems = navigation.NavigationItems.OfType<TagNavigationItem>().ToList();
392+
tagItems.Should().HaveCount(3);
393+
394+
// Verify sorted order
395+
tagItems[0].NavigationTitle.Should().Be("Animal Zoo", "First tag should be 'Animal Zoo' (zebra with x-displayName)");
396+
tagItems[1].NavigationTitle.Should().Be("charlie", "Second tag should be 'charlie' (canonical name, no x-displayName)");
397+
tagItems[2].NavigationTitle.Should().Be("Fruit Store", "Third tag should be 'Fruit Store' (apple with x-displayName)");
398+
}
399+
400+
[Fact]
401+
public async Task Tags_CaseInsensitiveSorting_WorksCorrectly()
402+
{
403+
// Arrange - spec with case variations
404+
var openApiJson = /*lang=json*/ """
405+
{
406+
"openapi": "3.0.3",
407+
"info": {
408+
"title": "Test API",
409+
"version": "1.0.0"
410+
},
411+
"paths": {
412+
"/bravo": {
413+
"get": {
414+
"operationId": "bravo-op",
415+
"tags": ["bravo"],
416+
"responses": {"200": {"description": "Success"}}
417+
}
418+
},
419+
"/alpha": {
420+
"get": {
421+
"operationId": "alpha-op",
422+
"tags": ["alpha"],
423+
"responses": {"200": {"description": "Success"}}
424+
}
425+
}
426+
},
427+
"tags": [
428+
{
429+
"name": "bravo",
430+
"x-displayName": "beta Service"
431+
},
432+
{
433+
"name": "alpha",
434+
"x-displayName": "Alpha Service"
435+
}
436+
]
437+
}
438+
""";
439+
440+
var (generator, openApiDocument) = await CreateGeneratorWithSpec(openApiJson);
441+
442+
// Act
443+
var navigation = generator.CreateNavigation("test", openApiDocument);
444+
445+
// Assert - should be sorted case-insensitively: "Alpha Service", "beta Service"
446+
var tagItems = navigation.NavigationItems.OfType<TagNavigationItem>().ToList();
447+
tagItems.Should().HaveCount(2);
448+
449+
tagItems[0].NavigationTitle.Should().Be("Alpha Service", "Should sort case-insensitively");
450+
tagItems[1].NavigationTitle.Should().Be("beta Service", "Should sort case-insensitively");
451+
}
452+
453+
[Fact]
454+
public async Task Tags_OnlyCanonicalNames_SortedAlphabetically()
455+
{
456+
// Arrange - spec with no x-displayName values
457+
var openApiJson = /*lang=json*/ """
458+
{
459+
"openapi": "3.0.3",
460+
"info": {
461+
"title": "Test API",
462+
"version": "1.0.0"
463+
},
464+
"paths": {
465+
"/zebra": {
466+
"get": {
467+
"operationId": "zebra-op",
468+
"tags": ["zebra"],
469+
"responses": {"200": {"description": "Success"}}
470+
}
471+
},
472+
"/alpha": {
473+
"get": {
474+
"operationId": "alpha-op",
475+
"tags": ["alpha"],
476+
"responses": {"200": {"description": "Success"}}
477+
}
478+
},
479+
"/mike": {
480+
"get": {
481+
"operationId": "mike-op",
482+
"tags": ["mike"],
483+
"responses": {"200": {"description": "Success"}}
484+
}
485+
}
486+
},
487+
"tags": [
488+
{
489+
"name": "zebra",
490+
"description": "Zebra operations"
491+
},
492+
{
493+
"name": "alpha",
494+
"description": "Alpha operations"
495+
},
496+
{
497+
"name": "mike",
498+
"description": "Mike operations"
499+
}
500+
]
501+
}
502+
""";
503+
504+
var (generator, openApiDocument) = await CreateGeneratorWithSpec(openApiJson);
505+
506+
// Act
507+
var navigation = generator.CreateNavigation("test", openApiDocument);
508+
509+
// Assert - should be sorted alphabetically by canonical name: "alpha", "mike", "zebra"
510+
var tagItems = navigation.NavigationItems.OfType<TagNavigationItem>().ToList();
511+
tagItems.Should().HaveCount(3);
512+
513+
tagItems[0].NavigationTitle.Should().Be("alpha", "Should sort by canonical name");
514+
tagItems[1].NavigationTitle.Should().Be("mike", "Should sort by canonical name");
515+
tagItems[2].NavigationTitle.Should().Be("zebra", "Should sort by canonical name");
516+
}
517+
518+
[Fact]
519+
public async Task Tags_WithinClassification_SortedCorrectly()
520+
{
521+
// Arrange - Elasticsearch spec that creates MULTIPLE classifications so we get classification groups
522+
var openApiJson = /*lang=json,strict*/ """
523+
{
524+
"openapi": "3.0.3",
525+
"info": {
526+
"title": "Elasticsearch Request & Response Specification",
527+
"version": "1.0.0"
528+
},
529+
"paths": {
530+
"/search": {
531+
"get": {
532+
"operationId": "search-op",
533+
"tags": ["search"],
534+
"responses": {"200": {"description": "Success"}}
535+
}
536+
},
537+
"/indices": {
538+
"get": {
539+
"operationId": "indices-op",
540+
"tags": ["indices"],
541+
"responses": {"200": {"description": "Success"}}
542+
}
543+
},
544+
"/watcher": {
545+
"get": {
546+
"operationId": "watcher-op",
547+
"tags": ["watcher"],
548+
"responses": {"200": {"description": "Success"}}
549+
}
550+
},
551+
"/tasks": {
552+
"get": {
553+
"operationId": "tasks-op",
554+
"tags": ["tasks"],
555+
"responses": {"200": {"description": "Success"}}
556+
}
557+
}
558+
},
559+
"tags": [
560+
{
561+
"name": "search",
562+
"x-displayName": "Search API"
563+
},
564+
{
565+
"name": "indices",
566+
"x-displayName": "Indices Management"
567+
},
568+
{
569+
"name": "watcher",
570+
"x-displayName": "Watcher API"
571+
},
572+
{
573+
"name": "tasks",
574+
"x-displayName": "Task management"
575+
}
576+
]
577+
}
578+
""";
579+
580+
var (generator, openApiDocument) = await CreateGeneratorWithSpec(openApiJson);
581+
582+
// Act
583+
var navigation = generator.CreateNavigation("elasticsearch", openApiDocument);
584+
585+
// Assert - these should create multiple classifications:
586+
// "search" -> "common", "indices" -> "management", "watcher"/"tasks" -> "info"
587+
// We should get classification groups since there are multiple classifications
588+
var classificationItems = navigation.NavigationItems.OfType<ClassificationNavigationItem>().ToList();
589+
classificationItems.Should().HaveCountGreaterThan(1, "Should have multiple classification groups");
590+
591+
// Find the "info" classification which should contain watcher and tasks
592+
var infoClassification = classificationItems.FirstOrDefault(c => c.NavigationTitle == "info");
593+
infoClassification.Should().NotBeNull("Should have 'info' classification for watcher and tasks");
594+
595+
var tagItems = infoClassification.NavigationItems.OfType<TagNavigationItem>().ToList();
596+
tagItems.Should().HaveCount(2, "Info classification should have watcher and tasks");
597+
598+
// Expected sort order within info classification: "Task management", "Watcher API"
599+
tagItems[0].NavigationTitle.Should().Be("Task management", "Should sort by displayName");
600+
tagItems[1].NavigationTitle.Should().Be("Watcher API", "Should sort by displayName");
601+
}
332602
}

0 commit comments

Comments
 (0)