Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -394,6 +394,7 @@ private string ResolveTagClassification(string? tag, XTagGroups? xTagGroups, Has
return UnknownTagGroupName;
}

// The raw operationId stays the grouping key: humanized titles of distinct ids (get-script, get_script) can collide.
private static string? NullIfBlank(string? value) => string.IsNullOrWhiteSpace(value) ? null : value;

private static List<string> GetOrderedClassificationKeys(XTagGroups xTagGroups, HashSet<string> presentClassifications)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
using System.IO.Abstractions;
using Elastic.ApiExplorer.Model;
using Elastic.ApiExplorer.Operations;
using Elastic.Documentation.Extensions;
using YamlDotNet.RepresentationModel;

namespace Elastic.ApiExplorer.Navigation;
Expand Down Expand Up @@ -57,12 +58,5 @@ public static string GetNavigationTitle(IFileSystem readFileSystem, IFileInfo ma
}

/// <summary>Converts kebab-case/snake_case file names to a title-cased navigation title.</summary>
internal static string TitleCaseFromFileName(string fileName) =>
Path
.GetFileNameWithoutExtension(fileName)
.Replace('-', ' ')
.Replace('_', ' ')
.Split(' ', StringSplitOptions.RemoveEmptyEntries)
.Select(word => char.ToUpper(word[0]) + word[1..].ToLower())
.Aggregate((current, next) => $"{current} {next}");
internal static string TitleCaseFromFileName(string fileName) => Path.GetFileNameWithoutExtension(fileName).TitleCaseFromSlug();
}
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ public static string Write(ApiOperation apiOperation, OperationPageModel page, A
var markdown = new StringBuilder();
var apiBaseUrl = context.CurrentNavigation.NavigationRoot.Url;
var operation = apiOperation.Operation;
var title = OpenApiExtensionReader.GetSummary(operation) ?? apiOperation.ApiName;
var title = OpenApiExtensionReader.GetSummary(operation) ?? apiOperation.DisplayName;
ApiCommonMark.Heading(markdown, 1, title);
WriteBadges(markdown, operation, page);
WriteServers(markdown, page);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,12 @@ string ApiName
// rather than the spec; the sidebar badge must agree with it.
string IHttpMethodNavigationModel.HttpMethod => OperationEndpoint.Build(this, Operation.Description, []).Rows[0].Method;

/// <summary>The title shown for the operation; an operationId used as the grouping key is shown as readable words.</summary>
public string DisplayName =>
!string.IsNullOrWhiteSpace(Operation.OperationId) && string.Equals(ApiName, Operation.OperationId, StringComparison.Ordinal)
? ApiName.TitleCaseFromSlug()
: ApiName;

/// <summary>
/// The operation's parameters and the path item's, which apply to every operation on the path unless the operation
/// declares one of the same name and location itself.
Expand Down Expand Up @@ -70,7 +76,7 @@ public OperationNavigationItem(
{
NavigationRoot = root;
Model = apiOperation;
NavigationTitle = apiOperation.ApiName;
NavigationTitle = apiOperation.DisplayName;
Parent = parent;
Url = ApiUrlBuilder.OperationUrl(
urlPathPrefix,
Expand Down
11 changes: 11 additions & 0 deletions src/Elastic.Documentation/Extensions/StringExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -14,4 +14,15 @@ public static string OptionalWindowsReplace(this string relativePath)
relativePath = relativePath.Replace('\\', '/');
return relativePath;
}

/// <summary>Converts a kebab-case/snake_case identifier to a title-cased string ("get-script" becomes "Get Script").</summary>
public static string TitleCaseFromSlug(this string slug) =>
string.Join(
' ',
slug
.Replace('-', ' ')
.Replace('_', ' ')
.Split(' ', StringSplitOptions.RemoveEmptyEntries)
.Select(word => char.ToUpperInvariant(word[0]) + word[1..].ToLowerInvariant())
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ public async Task CreateNavigation_SingleTagOpenApiSpec_HasSidebarItems()
}

[Test]
public async Task CreateNavigation_BlankSummary_TitlesOperationByOperationId()
public async Task CreateNavigation_BlankSummary_TitlesOperationByHumanizedOperationId()
{
var configurationContext = TestHelpers.CreateConfigurationContext(new FileSystem());
var context = new BuildContext(
Expand All @@ -61,7 +61,30 @@ public async Task CreateNavigation_BlankSummary_TitlesOperationByOperationId()
var navigation = generator.CreateNavigation("blank", doc);

var titles = Flatten(navigation.NavigationItems).OfType<OperationNavigationItem>().Select(o => o.NavigationTitle).ToArray();
titles.Should().ContainSingle().Which.Should().Be("get-script");
titles.Should().ContainSingle().Which.Should().Be("Get Script");
}

[Test]
public async Task CreateNavigation_OperationIdsWithTheSameTitle_StaySeparatePages()
{
var configurationContext = TestHelpers.CreateConfigurationContext(new FileSystem());
var context = new BuildContext(
new DiagnosticsCollector([]),
DocumentationFileSystem.Resolve(Paths.WorkingDirectoryRoot.FullName),
configurationContext
);
var fs = new FileSystem();
var fi = fs.FileInfo.New(fs.Path.Combine(AppContext.BaseDirectory, "TestData", "colliding-operation-ids-openapi.json"));
var doc = await OpenApiReader.Instance.ReadAsync(fi);

var generator = new OpenApiGenerator(NullLoggerFactory.Instance, context, NoopMarkdownStringRenderer.Instance);
var navigation = generator.CreateNavigation("colliding", doc);

var operations = Flatten(navigation.NavigationItems).OfType<OperationNavigationItem>().ToArray();
operations.Should().HaveCount(2);
operations.Select(o => o.Url).Should().OnlyHaveUniqueItems();
operations.Should().OnlyContain(o => o.Siblings.Count == 0);
operations.Select(o => o.NavigationTitle).Should().AllBe("Get Script");
}

private static IEnumerable<INavigationItem> Flatten(IEnumerable<INavigationItem> items)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
{
"openapi": "3.0.3",
"info": { "title": "Colliding ids", "version": "1.0.0" },
"paths": {
"/api/first": {
"get": {
"operationId": "get-script",
"tags": ["connectors"],
"description": "Returns the first script.",
"responses": { "200": { "description": "OK" } }
}
},
"/api/second": {
"get": {
"operationId": "get_script",
"tags": ["connectors"],
"description": "Returns the second script.",
"responses": { "200": { "description": "OK" } }
}
}
}
}
19 changes: 19 additions & 0 deletions tests/Elastic.Documentation.Tests/StringExtensionsTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
// 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.Documentation.Extensions;

namespace Elastic.Documentation.Tests;

public class StringExtensionsTests
{
[Test]
[Arguments("get-actions-connector-oauth-callback-script", "Get Actions Connector Oauth Callback Script")]
[Arguments("async_search_delete", "Async Search Delete")]
[Arguments("GET-Script", "Get Script")]
[Arguments("-", "")]
public void TitleCaseFromSlug_KebabOrSnakeCase_ReturnsTitleCasedWords(string slug, string expected) =>
slug.TitleCaseFromSlug().Should().Be(expected);
}
Loading