diff --git a/.gitignore b/.gitignore index 699e86128..ab41821e9 100644 --- a/.gitignore +++ b/.gitignore @@ -4,6 +4,8 @@ ## Get latest from https://github.com/github/gitignore/blob/main/VisualStudio.gitignore .artifacts +.claude + # User-specific files *.rsuser diff --git a/Directory.Packages.props b/Directory.Packages.props index e81473df3..c09581970 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -40,7 +40,7 @@ - + @@ -111,4 +111,4 @@ - + \ No newline at end of file diff --git a/src/Elastic.ApiExplorer/ApiViewModel.cs b/src/Elastic.ApiExplorer/ApiViewModel.cs index 048190738..7ad6d673e 100644 --- a/src/Elastic.ApiExplorer/ApiViewModel.cs +++ b/src/Elastic.ApiExplorer/ApiViewModel.cs @@ -2,6 +2,7 @@ // 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.RegularExpressions; using Elastic.Documentation; using Elastic.Documentation.Configuration; using Elastic.Documentation.Configuration.Assembler; @@ -11,23 +12,44 @@ using Elastic.Documentation.Site; using Elastic.Documentation.Site.FileProviders; using Microsoft.AspNetCore.Html; +using Microsoft.OpenApi; namespace Elastic.ApiExplorer; -public abstract class ApiViewModel(ApiRenderContext context) +public record ApiTocItem(string Heading, string Slug, int Level = 2); + +public record ApiLayoutViewModel : GlobalLayoutViewModel +{ + public required IReadOnlyList TocItems { get; init; } +} + +public abstract partial class ApiViewModel(ApiRenderContext context) { public string NavigationHtml { get; } = context.NavigationHtml; public StaticFileContentHashProvider StaticFileContentHashProvider { get; } = context.StaticFileContentHashProvider; public INavigationItem CurrentNavigationItem { get; } = context.CurrentNavigation; public IMarkdownStringRenderer MarkdownRenderer { get; } = context.MarkdownRenderer; public BuildContext BuildContext { get; } = context.BuildContext; + public OpenApiDocument Document { get; } = context.Model; + + + public HtmlString RenderMarkdown(string? markdown) + { + if (string.IsNullOrEmpty(markdown)) + return new HtmlString(string.Empty); + // Escape mustache-style patterns by wrapping in backticks (inline code won't process substitutions) + var escaped = MustachePattern().Replace(markdown, match => $"`{match.Value}`"); + return new HtmlString(MarkdownRenderer.Render(escaped, null)); + } - public HtmlString RenderMarkdown(string? markdown) => - new(string.IsNullOrEmpty(markdown) ? string.Empty : MarkdownRenderer.Render(markdown, null)); + // Regex to match mustache-style patterns like {{var}} or {{{var}}} that conflict with docs-builder substitutions + [GeneratedRegex(@"\{\{\{?[^}]+\}?\}\}")] + private static partial Regex MustachePattern(); + protected virtual IReadOnlyList GetTocItems() => []; - public GlobalLayoutViewModel CreateGlobalLayoutModel() => + public ApiLayoutViewModel CreateGlobalLayoutModel() => new() { DocsBuilderVersion = ShortId.Create(BuildContext.Version), @@ -43,6 +65,7 @@ public GlobalLayoutViewModel CreateGlobalLayoutModel() => CanonicalBaseUrl = BuildContext.CanonicalBaseUrl, GoogleTagManager = new GoogleTagManagerConfiguration(), Features = new FeatureFlags([]), - StaticFileContentHashProvider = StaticFileContentHashProvider + StaticFileContentHashProvider = StaticFileContentHashProvider, + TocItems = GetTocItems() }; } diff --git a/src/Elastic.ApiExplorer/Landing/LandingView.cshtml b/src/Elastic.ApiExplorer/Landing/LandingView.cshtml index f3fd31b30..50daa89ac 100644 --- a/src/Elastic.ApiExplorer/Landing/LandingView.cshtml +++ b/src/Elastic.ApiExplorer/Landing/LandingView.cshtml @@ -1,11 +1,12 @@ @inherits RazorSliceHttpResult @using Elastic.ApiExplorer.Landing @using Elastic.ApiExplorer.Operations +@using Elastic.ApiExplorer.Schemas @using Elastic.Documentation.Navigation @using Elastic.Documentation.Site.Navigation -@implements IUsesLayout +@implements IUsesLayout @functions { - public GlobalLayoutViewModel LayoutModel => Model.CreateGlobalLayoutModel(); + public ApiLayoutViewModel LayoutModel => Model.CreateGlobalLayoutModel(); private IHtmlContent RenderOp(IReadOnlyCollection endpointOperations) { @@ -70,9 +71,23 @@ @RenderOp([operation]) } + else if (navigationItem is SchemaCategoryNavigationItem schemaCategory) + { + +

@(schemaCategory.NavigationTitle)

+ + @RenderProduct(schemaCategory) + } + else if (navigationItem is SchemaNavigationItem schemaItem) + { + + @(schemaItem.NavigationTitle) + @schemaItem.Model.SchemaId + + } else { - throw new Exception($"Unexpected type: {item.GetType().FullName}"); + throw new Exception($"Unexpected type: {navigationItem.GetType().FullName}"); } } } diff --git a/src/Elastic.ApiExplorer/Layout/_ApiToc.cshtml b/src/Elastic.ApiExplorer/Layout/_ApiToc.cshtml new file mode 100644 index 000000000..3f6aa6e7f --- /dev/null +++ b/src/Elastic.ApiExplorer/Layout/_ApiToc.cshtml @@ -0,0 +1,27 @@ +@inherits RazorSlice + diff --git a/src/Elastic.ApiExplorer/OpenApiGenerator.cs b/src/Elastic.ApiExplorer/OpenApiGenerator.cs index 51c98df66..6d0153731 100644 --- a/src/Elastic.ApiExplorer/OpenApiGenerator.cs +++ b/src/Elastic.ApiExplorer/OpenApiGenerator.cs @@ -6,6 +6,7 @@ using System.Text.RegularExpressions; using Elastic.ApiExplorer.Landing; using Elastic.ApiExplorer.Operations; +using Elastic.ApiExplorer.Schemas; using Elastic.Documentation; using Elastic.Documentation.Configuration; using Elastic.Documentation.Navigation; @@ -174,6 +175,9 @@ group tagGroup by classificationGroup.Key else CreateTagNavigationItems(apiUrlSuffix, classification, rootNavigation, rootNavigation, topLevelNavigationItems); } + // Add schema type pages for shared types + CreateSchemaNavigationItems(apiUrlSuffix, openApiDocument, rootNavigation, topLevelNavigationItems); + rootNavigation.NavigationItems = topLevelNavigationItems; return rootNavigation; } @@ -317,6 +321,81 @@ IFileInfo OutputFile(INavigationItem currentNavigation) } } + private void CreateSchemaNavigationItems( + string apiUrlSuffix, + OpenApiDocument openApiDocument, + LandingNavigationItem rootNavigation, + List> topLevelNavigationItems + ) + { + var schemas = openApiDocument.Components?.Schemas; + if (schemas is null || schemas.Count == 0) + return; + + var typesCategory = new SchemaCategory("Types", "Shared type definitions"); + var typesCategoryNav = new SchemaCategoryNavigationItem(typesCategory, rootNavigation, rootNavigation); + var categoryNavigationItems = new List>(); + + // Query DSL - only show QueryContainer (individual queries are shown as properties within it) + var queryContainerSchema = schemas + .FirstOrDefault(s => s.Key == "_types.query_dsl.QueryContainer"); + + if (queryContainerSchema.Value is not null) + { + var queryCategory = new SchemaCategory("Query DSL", "Query type definitions"); + var queryCategoryNav = new SchemaCategoryNavigationItem(queryCategory, rootNavigation, typesCategoryNav); + var apiSchema = new ApiSchema(queryContainerSchema.Key, "QueryContainer", "query-dsl", queryContainerSchema.Value); + var queryNavigationItems = new List + { + new SchemaNavigationItem(context.UrlPathPrefix, apiUrlSuffix, apiSchema, rootNavigation, queryCategoryNav) + }; + queryCategoryNav.NavigationItems = queryNavigationItems; + categoryNavigationItems.Add(queryCategoryNav); + } + + // Aggregations - only show AggregationContainer and Aggregate + var aggContainerSchema = schemas + .FirstOrDefault(s => s.Key == "_types.aggregations.AggregationContainer"); + + var aggregateSchema = schemas + .FirstOrDefault(s => s.Key == "_types.aggregations.Aggregate"); + + if (aggContainerSchema.Value is not null || aggregateSchema.Value is not null) + { + var aggCategory = new SchemaCategory("Aggregations", "Aggregation type definitions"); + var aggCategoryNav = new SchemaCategoryNavigationItem(aggCategory, rootNavigation, typesCategoryNav); + var aggNavigationItems = new List(); + + if (aggContainerSchema.Value is not null) + { + var apiSchema = new ApiSchema(aggContainerSchema.Key, "AggregationContainer", "aggregations", aggContainerSchema.Value); + aggNavigationItems.Add(new SchemaNavigationItem(context.UrlPathPrefix, apiUrlSuffix, apiSchema, rootNavigation, aggCategoryNav)); + } + + if (aggregateSchema.Value is not null) + { + var apiSchema = new ApiSchema(aggregateSchema.Key, "Aggregate", "aggregations", aggregateSchema.Value); + aggNavigationItems.Add(new SchemaNavigationItem(context.UrlPathPrefix, apiUrlSuffix, apiSchema, rootNavigation, aggCategoryNav)); + } + + aggCategoryNav.NavigationItems = aggNavigationItems; + categoryNavigationItems.Add(aggCategoryNav); + } + + if (categoryNavigationItems.Count > 0) + { + typesCategoryNav.NavigationItems = categoryNavigationItems; + topLevelNavigationItems.Add(typesCategoryNav); + } + } + + private static string FormatSchemaDisplayName(string schemaId) + { + // Convert schema IDs like "_types.query_dsl.QueryContainer" to "QueryContainer" + var parts = schemaId.Split('.'); + return parts.Length > 0 ? parts[^1] : schemaId; + } + private static string ClassifyElasticsearchTag(string tag) { #pragma warning disable IDE0066 diff --git a/src/Elastic.ApiExplorer/Operations/OperationView.cshtml b/src/Elastic.ApiExplorer/Operations/OperationView.cshtml index 377a9f1b5..c1768c613 100644 --- a/src/Elastic.ApiExplorer/Operations/OperationView.cshtml +++ b/src/Elastic.ApiExplorer/Operations/OperationView.cshtml @@ -1,58 +1,52 @@ @using Elastic.ApiExplorer.Landing @using Elastic.ApiExplorer.Operations +@using Elastic.ApiExplorer.Schema +@using Elastic.ApiExplorer.Shared @using Microsoft.OpenApi @inherits RazorSliceHttpResult -@implements IUsesLayout +@implements IUsesLayout @functions { - public GlobalLayoutViewModel LayoutModel => Model.CreateGlobalLayoutModel(); + public ApiLayoutViewModel LayoutModel => Model.CreateGlobalLayoutModel(); - public string GetTypeName(JsonSchemaType? type) - { - var typeName = ""; - if (type is null) - return "unknown and null"; - - if (type.Value.HasFlag(JsonSchemaType.Boolean)) - typeName = "boolean"; - else if (type.Value.HasFlag(JsonSchemaType.Integer)) - typeName = "integer"; - else if (type.Value.HasFlag(JsonSchemaType.String)) - typeName = "string"; - else if (type.Value.HasFlag(JsonSchemaType.Object)) - { - typeName = "object"; - } - else if (type.Value.HasFlag(JsonSchemaType.Null)) - typeName = "null"; - else if (type.Value.HasFlag(JsonSchemaType.Number)) - typeName = "number"; - else - { - } + // Schema analyzer instance - created lazily + private SchemaAnalyzer? _analyzer; + private SchemaAnalyzer Analyzer => _analyzer ??= new SchemaAnalyzer(Model.Document); - if (type.Value.HasFlag(JsonSchemaType.Array)) - typeName += " array"; - return typeName; - } + // Delegate schema analysis to Analyzer + private IOpenApiSchema? ResolveSchema(IOpenApiSchema? schema) => Analyzer.ResolveSchema(schema); + private IDictionary? GetSchemaProperties(IOpenApiSchema? schema) => Analyzer.GetSchemaProperties(schema); + private TypeInfo GetTypeInfo(IOpenApiSchema? schema) => Analyzer.GetTypeInfo(schema); - public string GetTypeName(IOpenApiSchema propertyValue) - { - var typeName = string.Empty; - if (propertyValue.Type is not null) + // Helper to create PropertyRenderContext for OperationView + private PropertyRenderContext CreatePropertyContext(IOpenApiSchema? schema, string prefix, bool isRequest = false) + => new PropertyRenderContext { - typeName = GetTypeName(propertyValue.Type); - if (typeName is not "object" and not "array") - return typeName; - } - - if (propertyValue.Schema is not null) - return propertyValue.Schema.ToString(); + Schema = schema, + RequiredProperties = null, + Prefix = prefix, + Depth = 0, + AncestorTypes = null, + IsRequest = isRequest, + Analyzer = Analyzer, + RenderMarkdown = Model.RenderMarkdown, + // OperationView defaults - all features enabled + ShowDeprecated = true, + ShowVersionInfo = true, + ShowExternalDocs = true, + UseHiddenUntilFound = true, + CollapseMode = CollapseMode.AlwaysCollapsed + }; - if (propertyValue.Enum is { Count: >0 } e) - return "enum"; + // Helper to create SchemaTypeContext + private static readonly TypeInfo _defaultTypeInfo = new("unknown", null, false, false, false, null, false, null); - return $"unknown value {typeName}"; - } + private SchemaTypeContext CreateSchemaTypeContext(IOpenApiSchema schema) + => new SchemaTypeContext + { + Schema = schema, + TypeInfo = GetTypeInfo(schema) ?? _defaultTypeInfo, + HasActualProperties = GetSchemaProperties(schema)?.Count > 0 + }; } @{ var self = Model.CurrentNavigationItem as OperationNavigationItem; @@ -66,25 +60,73 @@ var operation = Model.Operation.Operation; } -
-

@operation.Summary

-

- @(Model.RenderMarkdown(operation.Description)) -

+

+ @operation.Summary + @if (operation.Deprecated == true) + { + deprecated + } + @{ + var isBeta = operation.Extensions?.TryGetValue("x-beta", out var betaValue) == true && betaValue is System.Text.Json.Nodes.JsonNode betaNode && betaNode.GetValue(); + } + @if (isBeta) + { + Beta + } + @{ + var versionInfo = operation.Extensions?.TryGetValue("x-state", out var stateValue) == true && stateValue is System.Text.Json.Nodes.JsonNode stateNode ? stateNode.ToString() : null; + } + @if (!string.IsNullOrEmpty(versionInfo)) + { + @versionInfo + } +

+ + + @{ + // Servers can be at operation level or document level + var servers = operation.Servers is { Count: > 0 } ? operation.Servers : Model.Document.Servers; + } + @if (servers is { Count: > 0 }) + { +
+ Server@(servers.Count > 1 ? "s" : ""): + @foreach (var server in servers) + { + + @server.Url + @if (!string.IsNullOrEmpty(server.Description)) + { + (@server.Description) + } + + } +
+ } + +

+ Paths + + @Model.Operation.Route + + + +

    @foreach (var overload in allOperations) { var method = overload.Model.OperationType.ToString().ToLowerInvariant(); var current = overload.Model.Route == Model.Operation.Route && overload.Model.OperationType == Model.Operation.OperationType ? "current" : ""; -
  • + var isDeprecated = overload.Model.Operation?.Deprecated == true; +
  • @method.ToUpperInvariant() - @overload.Model.Route + @overload.Model.Route + @if (isDeprecated) + { + deprecated + }
  • } @@ -95,54 +137,423 @@ @if (pathParameters.Length > 0) {

    Path Parameters

    -
    +
    @foreach (var path in pathParameters) { -
    @path.Name
    -
    @Model.RenderMarkdown(path.Description)
    +
    +
    + + @path.Name + @if (path.Deprecated == true) + { + deprecated + } + +
    +
    @Model.RenderMarkdown(path.Description)
    +
    }
    } + + @if (!string.IsNullOrWhiteSpace(operation.Description)) + { +

    + Description + + @Model.Operation.Route + + + +

    +

    + @(Model.RenderMarkdown(operation.Description)) +

    + @if (operation.ExternalDocs?.Url != null) + { + var externalDocsUrl = operation.ExternalDocs.Url.ToString(); + var isElasticDocs = externalDocsUrl.Contains("www.elastic.co/docs") || externalDocsUrl.Contains("elastic.co/guide"); + + @(isElasticDocs ? "Read the reference documentation" : "External documentation") + + } + } + else if (operation.ExternalDocs?.Url != null) + { + var externalDocsUrl = operation.ExternalDocs.Url.ToString(); + var isElasticDocs = externalDocsUrl.Contains("www.elastic.co/docs") || externalDocsUrl.Contains("elastic.co/guide"); + + @(isElasticDocs ? "Read the reference documentation" : "External documentation") + + } + + @if (operation.Security is { Count: > 0 }) + { +
    + Authorization: + @foreach (var requirement in operation.Security) + { + @foreach (var scheme in requirement) + { + + @scheme.Key.Name + @if (scheme.Value is { Count: > 0 }) + { + (@string.Join(", ", scheme.Value)) + } + + } + } +
    + } + @{ var queryStringParameters = operation.Parameters?.Where(p => p.In == ParameterLocation.Query).ToArray() ?? []; } @if (queryStringParameters.Length > 0) { -

    Query String Parameters

    -
    - @foreach (var path in queryStringParameters) +

    + Query String Parameters + + @Model.Operation.Route + + + +

    +
    + @foreach (var qs in queryStringParameters) { -
    @path.Name
    -
    @Model.RenderMarkdown(path.Description)
    + var qsSchema = qs.Schema; + var resolvedQsSchema = qsSchema != null ? ResolveSchema(qsSchema) : null; + + // Collect enum values from direct enum, resolved enum, or union of string literals + var qsEnumValues = new List(); + if (qsSchema?.Enum is { Count: > 0 }) + qsEnumValues.AddRange(qsSchema.Enum.Select(e => e?.ToString()?.Trim('"') ?? "").Where(e => !string.IsNullOrEmpty(e))); + else if (resolvedQsSchema?.Enum is { Count: > 0 }) + qsEnumValues.AddRange(resolvedQsSchema.Enum.Select(e => e?.ToString()?.Trim('"') ?? "").Where(e => !string.IsNullOrEmpty(e))); + + // Check for oneOf/anyOf with string literals (union enums) + if (qsEnumValues.Count == 0) + { + var unionSchemas = resolvedQsSchema?.OneOf is { Count: > 0 } ? resolvedQsSchema.OneOf + : resolvedQsSchema?.AnyOf is { Count: > 0 } ? resolvedQsSchema.AnyOf + : null; + if (unionSchemas != null) + { + qsEnumValues.AddRange( + unionSchemas + .Select(ResolveSchema) + .Where(resolved => resolved?.Enum is { Count: > 0 }) + .SelectMany(resolved => resolved!.Enum! + .Select(e => e?.ToString()?.Trim('"') ?? "") + .Where(e => !string.IsNullOrEmpty(e)))); + } + } + + // Get TypeInfo for union options display + var qsTypeInfo = qsSchema != null ? GetTypeInfo(qsSchema) : null; + + // Collect union option names for "One of:" display + var qsUnionOptions = new List(); + if (qsTypeInfo?.AnyOfOptions is { Count: > 0 }) + { + qsUnionOptions.AddRange(qsTypeInfo.AnyOfOptions.Select(o => o.Name).Where(n => !string.IsNullOrEmpty(n))); + } + else if (qsTypeInfo?.UnionOptions is { Length: > 0 }) + { + qsUnionOptions.AddRange(qsTypeInfo.UnionOptions.Where(n => !string.IsNullOrEmpty(n))); + } + +
    +
    + + @qs.Name + @if (qsSchema != null) + { + @(await RenderPartialAsync<_SchemaType, SchemaTypeContext>(CreateSchemaTypeContext(qsSchema))) + } + @if (qs.Deprecated == true) + { + deprecated + } + +
    +
    @Model.RenderMarkdown(qs.Description)
    + @if (qsSchema != null) + { + @ValidationConstraintsRenderer.Render(qsSchema) + } + @if (qsUnionOptions.Count > 0 && qsEnumValues.Count == 0) + { + var primitiveTypes = new HashSet(StringComparer.OrdinalIgnoreCase) + { "boolean", "number", "string", "integer", "object", "null", "array" }; +
    + One of: + @{var optIndex = 0;} + @foreach (var unionOpt in qsUnionOptions) + { + if (optIndex > 0) + { + or + } + var isTypeOption = primitiveTypes.Contains(unionOpt) || + primitiveTypes.Contains(unionOpt.TrimEnd('[', ']')) || + char.IsUpper(unionOpt[0]) || unionOpt.EndsWith("[]"); + @unionOpt + optIndex++; + } +
    + } + @if (qsEnumValues.Count > 0) + { +
    + Values: + @foreach (var enumVal in qsEnumValues) + { + @enumVal + } +
    + } +
    }
    } @if (operation.RequestBody is not null) { -

    Request Body

    - var content = operation.RequestBody.Content?.FirstOrDefault().Value; + var requestContentEntry = operation.RequestBody.Content?.FirstOrDefault(); + var requestContentType = requestContentEntry?.Key ?? "application/json"; + var requestMediaType = requestContentEntry?.Value; +

    + Request Body + @requestContentType + + @Model.Operation.Route + + + +

    if (!string.IsNullOrEmpty(operation.RequestBody.Description)) { -

    @operation.RequestBody.Description

    +

    @Model.RenderMarkdown(operation.RequestBody.Description)

    } - if (content?.Schema?.Properties is not null) + var requestSchema = requestMediaType?.Schema; + if (requestSchema is not null) { -
    - @foreach (var property in content.Schema.Properties) + var props = GetSchemaProperties(requestSchema); + if (props is { Count: > 0 }) + { + @(await RenderPartialAsync<_PropertyList, PropertyRenderContext>(CreatePropertyContext(requestSchema, "req", isRequest: true))) + } + else + { +

    Type: @(await RenderPartialAsync<_SchemaType, SchemaTypeContext>(CreateSchemaTypeContext(requestSchema)))

    + } + } + } + + @if (operation.Responses is { Count: > 0 }) + { + var isSingleResponse = operation.Responses.Count == 1; + var singleResponse = isSingleResponse ? operation.Responses.First() : default; + var singleContentType = isSingleResponse && singleResponse.Value?.Content?.Count > 0 + ? singleResponse.Value.Content.First().Key + : null; + +

    + @(isSingleResponse ? "Response" : "Responses") + @if (!string.IsNullOrEmpty(singleContentType)) { - if (property.Value.Type is null) + @singleContentType + } + + @Model.Operation.Route + + + +

    + @foreach (var response in operation.Responses) + { + var statusCode = response.Key; + var responseValue = response.Value; + if (responseValue is null) continue; + var statusClass = statusCode.StartsWith("2") ? "success" : statusCode.StartsWith("4") || statusCode.StartsWith("5") ? "error" : "info"; +
    + @if (!isSingleResponse) { +

    + @statusCode + @if (!string.IsNullOrEmpty(responseValue.Description)) + { + @responseValue.Description + } +

    + } + @if (responseValue.Content is { Count: > 0 }) + { + foreach (var contentType in responseValue.Content.Where(ct => ct.Value?.Schema is not null)) + { + var responseSchema = contentType.Value!.Schema!; + @if (!isSingleResponse) + { +

    Content-Type: @contentType.Key

    + } + var responseProps = GetSchemaProperties(responseSchema); + var responseTypeInfo = GetTypeInfo(responseSchema); + + // For arrays, check if the item type has properties we should render + IOpenApiSchema? arrayItemSchema = null; + IDictionary? arrayItemProps = null; + if (responseTypeInfo.IsArray) + { + // Try getting Items - handle schema references which may need explicit resolution + arrayItemSchema = responseSchema.Items; + if (arrayItemSchema is null && responseSchema is OpenApiSchemaReference respSchemaRef) + { + var respRefId = respSchemaRef.Reference?.Id; + if (!string.IsNullOrEmpty(respRefId) && + Model.Document.Components?.Schemas?.TryGetValue(respRefId, out var resolvedRespSchema) == true) + { + arrayItemSchema = resolvedRespSchema.Items; + } + } + if (arrayItemSchema is not null) + { + arrayItemProps = GetSchemaProperties(arrayItemSchema); + } + } + if (responseProps is { Count: > 0 }) + { + @(await RenderPartialAsync<_PropertyList, PropertyRenderContext>(CreatePropertyContext(responseSchema, $"res-{statusCode}"))) + } + else if (arrayItemProps is { Count: > 0 } && arrayItemSchema is not null) + { + // Array of objects - show the type annotation and render item properties +

    Response Type: @(await RenderPartialAsync<_SchemaType, SchemaTypeContext>(CreateSchemaTypeContext(responseSchema)))

    + @(await RenderPartialAsync<_PropertyList, PropertyRenderContext>(CreatePropertyContext(arrayItemSchema, $"res-{statusCode}"))) + } + else + { +

    Response Type: @(await RenderPartialAsync<_SchemaType, SchemaTypeContext>(CreateSchemaTypeContext(responseSchema)))

    + } + } } -
    @property.Key @GetTypeName(property.Value)
    -
    @Model.RenderMarkdown(property.Value.Description)
    - } -
    + @if (responseValue.Headers is { Count: > 0 }) + { +
    +
    Response Headers
    +
    + @foreach (var header in responseValue.Headers) + { + + } +
    +
    + } +
} } - - - + // Check for examples in the response + var successResponse = operation.Responses?.FirstOrDefault(r => r.Key.StartsWith("2")).Value; + var responseContent = successResponse?.Content?.FirstOrDefault().Value; + var responseExamples = responseContent?.Examples; + } + @if (requestExamples is { Count: > 0 }) + { +

+ Request Examples + + @Model.Operation.Route + + + +

+ @foreach (var example in requestExamples) + { +
+

@(string.IsNullOrEmpty(example.Value?.Summary) ? example.Key : example.Value.Summary)

+ @if (!string.IsNullOrEmpty(example.Value?.Description)) + { +
@Model.RenderMarkdown(example.Value.Description)
+ } + @if (example.Value?.Value is not null) + { +
@example.Value.Value.ToString()
+ } + @if (!string.IsNullOrEmpty(example.Value?.ExternalValue)) + { +

External example: @example.Value.ExternalValue

+ } +
+ } + } + @if (responseExamples is { Count: > 0 }) + { +

+ Response Examples + + @Model.Operation.Route + + + +

+ @foreach (var example in responseExamples) + { +
+

@(string.IsNullOrEmpty(example.Value?.Summary) ? example.Key : example.Value.Summary)

+ @if (!string.IsNullOrEmpty(example.Value?.Description)) + { +
@Model.RenderMarkdown(example.Value.Description)
+ } + @if (example.Value?.Value is not null) + { +
@example.Value.Value.ToString()
+ } + @if (!string.IsNullOrEmpty(example.Value?.ExternalValue)) + { +

External example: @example.Value.ExternalValue

+ } +
+ } + } + @{ + var hasExamples = requestExamples is { Count: > 0 } || responseExamples is { Count: > 0 }; + var examplesAnchor = requestExamples is { Count: > 0 } ? "request-examples" : "response-examples"; + } + @if (hasExamples) + { + + + Examples + + } + diff --git a/src/Elastic.ApiExplorer/Operations/OperationViewModel.cs b/src/Elastic.ApiExplorer/Operations/OperationViewModel.cs index 21c41e992..9865b1228 100644 --- a/src/Elastic.ApiExplorer/Operations/OperationViewModel.cs +++ b/src/Elastic.ApiExplorer/Operations/OperationViewModel.cs @@ -2,10 +2,43 @@ // 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 Microsoft.OpenApi; + namespace Elastic.ApiExplorer.Operations; public class OperationViewModel(ApiRenderContext context) : ApiViewModel(context) { public required ApiOperation Operation { get; init; } + protected override IReadOnlyList GetTocItems() + { + var operation = Operation.Operation; + var tocItems = new List { new("Paths", "paths") }; + + if (!string.IsNullOrWhiteSpace(operation.Description)) + tocItems.Add(new ApiTocItem("Description", "description")); + + var queryParams = operation.Parameters?.Where(p => p.In == ParameterLocation.Query).ToArray() ?? []; + if (queryParams.Length > 0) + tocItems.Add(new ApiTocItem("Query String Parameters", "query-params")); + + if (operation.RequestBody is not null) + tocItems.Add(new ApiTocItem("Request Body", "request-body")); + + if (operation.Responses is { Count: > 0 }) + tocItems.Add(new ApiTocItem(operation.Responses.Count == 1 ? "Response" : "Responses", "responses")); + + // Request body examples + var reqContent = operation.RequestBody?.Content?.FirstOrDefault().Value; + if (reqContent?.Examples is { Count: > 0 }) + tocItems.Add(new ApiTocItem("Request Examples", "request-examples")); + + // Response examples + var successResp = operation.Responses?.FirstOrDefault(r => r.Key.StartsWith('2')).Value; + var respContent = successResp?.Content?.FirstOrDefault().Value; + if (respContent?.Examples is { Count: > 0 }) + tocItems.Add(new ApiTocItem("Response Examples", "response-examples")); + + return tocItems; + } } diff --git a/src/Elastic.ApiExplorer/Schema/RenderContext.cs b/src/Elastic.ApiExplorer/Schema/RenderContext.cs new file mode 100644 index 000000000..0fa419149 --- /dev/null +++ b/src/Elastic.ApiExplorer/Schema/RenderContext.cs @@ -0,0 +1,150 @@ +// 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 Microsoft.AspNetCore.Html; +using Microsoft.OpenApi; + +namespace Elastic.ApiExplorer.Schema; + +/// +/// Controls how property sections collapse/expand by default. +/// +public enum CollapseMode +{ + /// Properties always start collapsed when toggle is shown (OperationView behavior). + AlwaysCollapsed, + + /// Depth-based: depth 0 collapsed, deeper levels expand if less than 5 properties (SchemaView behavior). + DepthBased +} + +/// +/// Context for rendering a property list. +/// +public record PropertyRenderContext +{ + /// The schema containing properties to render. + public required IOpenApiSchema? Schema { get; init; } + + /// Set of required property names. + public required ISet? RequiredProperties { get; init; } + + /// ID prefix for property anchors. + public required string Prefix { get; init; } + + /// Current nesting depth. + public required int Depth { get; init; } + + /// Set of ancestor type names for recursion detection. + public required HashSet? AncestorTypes { get; init; } + + /// Whether rendering request properties (shows "required" badge) vs response (shows "optional"). + public required bool IsRequest { get; init; } + + /// The schema analyzer for type resolution. + public required SchemaAnalyzer Analyzer { get; init; } + + /// Function to render markdown to HTML. + public required Func RenderMarkdown { get; init; } + + /// Maximum depth for property expansion. + public int MaxDepth { get; init; } = SchemaHelpers.MaxDepth; + + /// Whether to show deprecated badges on deprecated properties. + public bool ShowDeprecated { get; init; } = true; + + /// Whether to show version badges from x-state extension. + public bool ShowVersionInfo { get; init; } = true; + + /// Whether to show external documentation links. + public bool ShowExternalDocs { get; init; } = true; + + /// Whether to use hidden="until-found" for collapsed sections (enables browser find-in-page). + public bool UseHiddenUntilFound { get; init; } = true; + + /// How collapsed sections should be expanded by default. + public CollapseMode CollapseMode { get; init; } = CollapseMode.AlwaysCollapsed; +} + +/// +/// Context for rendering a single property item. +/// +public record PropertyItemContext +{ + /// The property name/key. + public required string PropertyName { get; init; } + + /// The property's schema. + public required IOpenApiSchema PropertySchema { get; init; } + + /// Type information for the property. + public required TypeInfo TypeInfo { get; init; } + + /// The HTML ID for the property anchor. + public required string PropId { get; init; } + + /// Whether this property is required. + public required bool IsRequired { get; init; } + + /// Whether this is the last property in the list. + public required bool IsLast { get; init; } + + /// Whether this type is recursive (appears in ancestors). + public required bool IsRecursive { get; init; } + + /// The parent rendering context. + public required PropertyRenderContext ParentContext { get; init; } +} + +/// +/// Context for rendering a schema type annotation. +/// +public record SchemaTypeContext +{ + /// The schema to render type information for. + public required IOpenApiSchema Schema { get; init; } + + /// Pre-computed type information. + public required TypeInfo TypeInfo { get; init; } + + /// Whether the schema has actual properties (for showing {} icon). + public required bool HasActualProperties { get; init; } +} + +/// +/// Context for rendering union variants. +/// +public record UnionVariantsContext +{ + /// The union options to render. + public required List Options { get; init; } + + /// ID prefix for variant anchors. + public required string Prefix { get; init; } + + /// Current nesting depth. + public required int Depth { get; init; } + + /// Set of ancestor type names for recursion detection. + public required HashSet? AncestorTypes { get; init; } + + /// Whether rendering request properties. + public required bool IsRequest { get; init; } + + /// The schema analyzer for type resolution. + public required SchemaAnalyzer Analyzer { get; init; } + + /// Function to render markdown to HTML. + public required Func RenderMarkdown { get; init; } + + /// Whether to use hidden="until-found" for collapsed sections (enables browser find-in-page). + public bool UseHiddenUntilFound { get; init; } = true; + + /// How collapsed sections should be expanded by default. + public CollapseMode CollapseMode { get; init; } = CollapseMode.AlwaysCollapsed; + + /// Maximum depth for property expansion. + public int MaxDepth { get; init; } = SchemaHelpers.MaxDepth; +} + diff --git a/src/Elastic.ApiExplorer/Schema/SchemaAnalyzer.cs b/src/Elastic.ApiExplorer/Schema/SchemaAnalyzer.cs new file mode 100644 index 000000000..407dda908 --- /dev/null +++ b/src/Elastic.ApiExplorer/Schema/SchemaAnalyzer.cs @@ -0,0 +1,525 @@ +// 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 Microsoft.OpenApi; + +namespace Elastic.ApiExplorer.Schema; + +/// +/// Analyzes OpenAPI schemas and provides type information. +/// Requires access to the OpenApiDocument for resolving schema references. +/// +/// +/// Creates a new SchemaAnalyzer. +/// +/// The OpenAPI document for resolving schema references. +/// Optional current page type to prevent self-linking on schema pages. +public class SchemaAnalyzer(OpenApiDocument document, string? currentPageType = null) +{ + /// + /// Checks if a type should link to its container page, considering the current page. + /// + private bool IsLinkedType(string typeName) => + SchemaHelpers.ShouldLinkToContainerPage(typeName, currentPageType); + + /// + /// Resolves a schema reference to its target schema. + /// + public IOpenApiSchema? ResolveSchema(IOpenApiSchema? schema) + { + if (schema is null) + return null; + + // If it's a reference, resolve from Components.Schemas + if (schema is OpenApiSchemaReference schemaRef) + { + var refId = schemaRef.Reference.Id; + if (!string.IsNullOrEmpty(refId) && document.Components?.Schemas?.TryGetValue(refId, out var resolved) == true) + return resolved; + return schemaRef; + } + + return schema; + } + + /// + /// Gets the properties from a schema, resolving references and handling allOf composition. + /// + public IDictionary? GetSchemaProperties(IOpenApiSchema? schema) + { + if (schema is null) + return null; + + // Handle schema references - resolve to get actual properties + if (schema is OpenApiSchemaReference schemaRef) + { + // OpenApiSchemaReference proxies to the target schema + // Try direct property access first (proxied) + if (schemaRef.Properties is { Count: > 0 }) + return schemaRef.Properties; + + // Try resolving via Reference.Id + var refId = schemaRef.Reference.Id; + if (!string.IsNullOrEmpty(refId)) + { + // Try to get the resolved schema from the document + if (document.Components?.Schemas?.TryGetValue(refId, out var resolvedSchema) == true) + return GetSchemaProperties(resolvedSchema); + } + // Fall through to try other schema properties + } + + // Direct properties + if (schema.Properties is { Count: > 0 }) + return schema.Properties; + + // For allOf, collect properties from all schemas + if (schema.AllOf is { Count: > 0 } allOf) + { + var props = new Dictionary(); + foreach (var subSchema in allOf) + { + var subProps = GetSchemaProperties(subSchema); + if (subProps is not null) + { + foreach (var prop in subProps) + _ = props.TryAdd(prop.Key, prop.Value); + } + } + return props.Count > 0 ? props : null; + } + + return null; + } + + /// + /// Gets the union options from a schema's oneOf/anyOf, if present. + /// + public List GetNestedUnionOptions(IOpenApiSchema? schema) + { + var result = new List(); + if (schema == null) + return result; + + IList? unionSchemas = null; + + // First try the schema directly (OpenApiSchemaReference proxies OneOf/AnyOf) + if (schema.OneOf is { Count: > 0 }) + unionSchemas = schema.OneOf; + else if (schema.AnyOf is { Count: > 0 }) + unionSchemas = schema.AnyOf; + + // If not found and it's a reference, resolve and try again + if (unionSchemas == null && schema is OpenApiSchemaReference schemaRef) + { + var refId = schemaRef.Reference.Id; + if (!string.IsNullOrEmpty(refId) && + document.Components?.Schemas?.TryGetValue(refId, out var resolved) == true) + { + if (resolved.OneOf is { Count: > 0 }) + unionSchemas = resolved.OneOf; + else if (resolved.AnyOf is { Count: > 0 }) + unionSchemas = resolved.AnyOf; + } + } + + if (unionSchemas == null) + return result; + + foreach (var s in unionSchemas) + { + if (s is OpenApiSchemaReference unionRef) + { + var typeName = SchemaHelpers.FormatSchemaName(unionRef.Reference?.Id ?? "unknown"); + result.Add(new UnionOption(typeName, unionRef.Reference?.Id, !SchemaHelpers.IsValueType(typeName), s)); + } + else if (s.Type?.HasFlag(JsonSchemaType.Array) == true && s.Items != null) + { + var itemInfo = GetTypeInfo(s.Items); + result.Add(new UnionOption($"{itemInfo.TypeName}[]", itemInfo.SchemaRef, itemInfo.IsObject, s)); + } + else + { + // Could be an inline schema or wrapped reference - try to get type info + var info = GetTypeInfo(s); + if (!string.IsNullOrEmpty(info.SchemaRef)) + result.Add(new UnionOption(info.TypeName, info.SchemaRef, info.IsObject, s)); + else + { + var primName = SchemaHelpers.GetPrimitiveTypeName(s.Type); + if (string.IsNullOrEmpty(primName)) + primName = "unknown"; + result.Add(new UnionOption(primName, null, false, s)); + } + } + } + + return result; + } + + /// + /// Checks if a union option has properties, using fallback resolution if needed. + /// Also recursively checks nested unions. + /// + public bool UnionOptionHasProperties(UnionOption option) + { + if (option.Schema == null) + return false; + + // For non-object types, check if they're nested unions with object options + if (!option.IsObject) + { + // Check if this is a union type that might contain objects + var nestedOptions = GetNestedUnionOptions(option.Schema); + return nestedOptions.Any(UnionOptionHasProperties); + } + + // Try to get properties directly first + var props = GetSchemaProperties(option.Schema); + if (props?.Count > 0) + return true; + + // For schema references, try resolving via the Ref ID or the schema reference itself + var refId = option.Ref; + if (string.IsNullOrEmpty(refId) && option.Schema is OpenApiSchemaReference schemaRef) + refId = schemaRef.Reference.Id; + + if (!string.IsNullOrEmpty(refId) && + document.Components?.Schemas?.TryGetValue(refId, out var resolvedSchema) == true) + { + props = GetSchemaProperties(resolvedSchema); + if (props?.Count > 0) + return true; + + // Check if the resolved schema is itself a union + // Try the original schema reference first (OpenApiSchemaReference proxies OneOf/AnyOf) + var nestedOptions = GetNestedUnionOptions(option.Schema); + if (nestedOptions.Count == 0) + { + // Fallback to resolved schema + nestedOptions = GetNestedUnionOptions(resolvedSchema); + } + if (nestedOptions.Any(UnionOptionHasProperties)) + return true; + } + + // Try finding by name pattern (e.g., "SourceFilter" -> look for schemas ending with ".SourceFilter") + if (document.Components?.Schemas != null) + { + var baseName = option.Name.EndsWith("[]") ? option.Name[..^2] : option.Name; + var matchingSchema = document.Components.Schemas + .FirstOrDefault(kvp => kvp.Key.EndsWith("." + baseName) || kvp.Key == baseName); + if (matchingSchema.Value != null) + { + props = GetSchemaProperties(matchingSchema.Value); + if (props?.Count > 0) + return true; + + // Check if the matched schema is itself a union + var nestedOptions = GetNestedUnionOptions(matchingSchema.Value); + if (nestedOptions.Any(UnionOptionHasProperties)) + return true; + } + } + + return false; + } + + /// + /// Flattens nested unions to get all leaf options (options with direct properties, not union wrappers). + /// + public List FlattenUnionOptions(List options) + { + var result = new List(); + + foreach (var option in options.Where(o => o.Schema != null)) + { + var baseName = option.Name.EndsWith("[]") ? option.Name[..^2] : option.Name; + var isArray = option.Name.EndsWith("[]"); + var schema = option.Schema!; // Schema is guaranteed non-null by Where filter + + // For array types, we need to look at the Items schema + var schemaToCheck = schema; + if (schema.Type?.HasFlag(JsonSchemaType.Array) == true && schema.Items != null) + schemaToCheck = schema.Items; + + // Check if this option has direct properties + var hasDirectProps = false; + var resolvedSchema = schemaToCheck; + + var props = GetSchemaProperties(schemaToCheck); + if (props?.Count > 0) + hasDirectProps = true; + else if (schemaToCheck is OpenApiSchemaReference schemaRef) + { + var refId = schemaRef.Reference?.Id; + if (!string.IsNullOrEmpty(refId) && + document.Components?.Schemas?.TryGetValue(refId, out var resolved) == true) + { + resolvedSchema = resolved; + props = GetSchemaProperties(resolved); + if (props?.Count > 0) + hasDirectProps = true; + } + } + + if (!hasDirectProps && document.Components?.Schemas != null) + { + var matchingSchema = document.Components.Schemas + .FirstOrDefault(kvp => kvp.Key.EndsWith("." + baseName) || kvp.Key == baseName); + if (matchingSchema.Value != null) + { + resolvedSchema = matchingSchema.Value; + props = GetSchemaProperties(matchingSchema.Value); + if (props?.Count > 0) + hasDirectProps = true; + } + } + + if (hasDirectProps) + { + // This option has properties, add it to results + // For arrays, keep the original schema so we render the right type + result.Add(new UnionOption(option.Name, option.Ref, option.IsObject, resolvedSchema)); + } + else if (resolvedSchema != null) + { + // Check if this is a nested union that we should expand + // Try the original schema first (OpenApiSchemaReference proxies OneOf/AnyOf correctly) + var nestedOptions = GetNestedUnionOptions(schemaToCheck); + if (nestedOptions.Count == 0) + { + // Fallback to resolved schema + nestedOptions = GetNestedUnionOptions(resolvedSchema); + } + if (nestedOptions.Count > 0) + { + // Recursively flatten nested union, carrying the array suffix if needed + var flattenedNested = FlattenUnionOptions(nestedOptions); + foreach (var nested in flattenedNested) + { + // If the parent was an array and the nested option isn't, add array suffix + var nestedName = nested.Name; + if (isArray && !nestedName.EndsWith("[]")) + nestedName = $"{nestedName}[]"; + result.Add(new UnionOption(nestedName, nested.Ref, nested.IsObject, nested.Schema)); + } + } + } + } + + return result; + } + + /// + /// Gets comprehensive type information for a schema. + /// + public TypeInfo GetTypeInfo(IOpenApiSchema? schema) + { + if (schema is null) + return new TypeInfo("unknown", null, false, false, false, null, false, null); + + // Check if this is a schema reference + if (schema is OpenApiSchemaReference schemaRef) + { + var refId = schemaRef.Reference.Id; + if (!string.IsNullOrEmpty(refId)) + { + var typeName = SchemaHelpers.FormatSchemaName(refId); + var isArray = schema.Type?.HasFlag(JsonSchemaType.Array) ?? false; + + // Check if this is a value type - either from the known list or by detecting it's a primitive alias + var isValueType = SchemaHelpers.IsValueType(typeName); + var primitiveAliasType = !isValueType ? SchemaHelpers.GetPrimitiveAliasType(schemaRef) : null; + if (!string.IsNullOrEmpty(primitiveAliasType)) + isValueType = true; + + var valueTypeBase = isValueType ? (primitiveAliasType ?? SchemaHelpers.GetValueTypeBase(schemaRef) ?? "string") : null; + var hasLink = IsLinkedType(typeName); + + // Check if the schema reference is an enum or union + // OpenApiSchemaReference proxies to resolved schema properties + var isEnum = schemaRef.Enum is { Count: > 0 }; + var isUnion = !isEnum && (schemaRef.OneOf is { Count: > 0 } || schemaRef.AnyOf is { Count: > 0 }); + var enumValues = isEnum ? schemaRef.Enum?.Select(e => e.ToString()).ToArray() : null; + + // Check if the referenced type is an array of primitives + string? arrayItemType = null; + if (isArray) + { + // Try getting Items from the proxy first + var itemSchema = schemaRef.Items; + + // If Items is null, try resolving the schema explicitly + if (itemSchema is null && + document.Components?.Schemas?.TryGetValue(refId, out var resolvedArraySchema) == true) + itemSchema = resolvedArraySchema.Items; + + if (itemSchema is not null) + { + var itemInfo = GetTypeInfo(itemSchema); + // If the item is not an object and not a linked type, it's a primitive array + if (itemInfo is { IsObject: false, HasLink: false }) + { + if (string.IsNullOrEmpty(itemInfo.SchemaRef)) + arrayItemType = itemInfo.TypeName; + } + } + } + + // Get union options from oneOf/anyOf + string[]? unionOptions = null; + List? anyOfOptions = null; + if (isUnion) + { + var unionSchemas = schemaRef.OneOf is { Count: > 0 } ? schemaRef.OneOf : schemaRef.AnyOf; + var options = new List(); + var anyOfList = new List(); + foreach (var s in unionSchemas ?? []) + { + if (s is OpenApiSchemaReference unionRef) + { + var unionTypeName = SchemaHelpers.FormatSchemaName(unionRef.Reference?.Id ?? "unknown"); + options.Add(unionTypeName); + // Also add to anyOfOptions for potential expansion + anyOfList.Add(new UnionOption(unionTypeName, unionRef.Reference?.Id, !SchemaHelpers.IsValueType(unionTypeName), s)); + } + else if (s.Enum is { Count: > 0 } inlineEnum) + { + // String literal union - add enum values + foreach (var enumVal in inlineEnum) + options.Add(enumVal.ToString()); + } + else if (s.Type?.HasFlag(JsonSchemaType.Array) == true && s.Items != null) + { + // Array type - get the item type and add [] suffix + var itemInfo = GetTypeInfo(s.Items); + var arrayTypeName = $"{itemInfo.TypeName}[]"; + options.Add(arrayTypeName); + // Arrays of objects are expandable + anyOfList.Add(new UnionOption(arrayTypeName, itemInfo.SchemaRef, itemInfo.IsObject, s)); + } + else + { + var primName = SchemaHelpers.GetPrimitiveTypeName(s.Type); + if (string.IsNullOrEmpty(primName)) + primName = "unknown"; + options.Add(primName); + // Primitives are not objects + anyOfList.Add(new UnionOption(primName, null, false, s)); + } + } + unionOptions = options.ToArray(); + anyOfOptions = anyOfList.Count > 0 ? anyOfList : null; + } + + return new TypeInfo(typeName, refId, isArray, !isValueType && !isEnum, isValueType, valueTypeBase, hasLink, anyOfOptions, false, null, isEnum, isUnion, enumValues, unionOptions, arrayItemType); + } + } + + // Check for oneOf/anyOf which often indicate union types + if (schema.OneOf is { Count: > 0 } oneOf) + { + var options = oneOf.Select(s => + { + var info = GetTypeInfo(s); + // Include [] suffix for array types + var displayName = info.IsArray ? $"{info.TypeName}[]" : info.TypeName; + return new UnionOption(displayName, info.SchemaRef, info.IsObject, s); + }).ToList(); + + var hasObjectOptions = options.Any(o => o.IsObject); + if (hasObjectOptions && options.Count > 1) + { + // Return anyOf options for potential tab rendering + return new TypeInfo("oneOf", null, false, true, false, null, false, options, IsUnion: true); + } + + var typeNames = options.Select(o => o.Name).Distinct().ToArray(); + return new TypeInfo(string.Join(" | ", typeNames), null, false, false, false, null, false, options, IsUnion: true); + } + + if (schema.AnyOf is { Count: > 0 } anyOf) + { + var options = anyOf.Select(s => + { + var info = GetTypeInfo(s); + // Include [] suffix for array types + var displayName = info.IsArray ? $"{info.TypeName}[]" : info.TypeName; + return new UnionOption(displayName, info.SchemaRef, info.IsObject, s); + }).ToList(); + + var hasObjectOptions = options.Any(o => o.IsObject); + if (hasObjectOptions && options.Count > 1) + { + // Return anyOf options for potential tab rendering + return new TypeInfo("anyOf", null, false, true, false, null, false, options, IsUnion: true); + } + + var typeNames = options.Select(o => o.Name).Distinct().ToArray(); + return new TypeInfo(string.Join(" | ", typeNames), null, false, false, false, null, false, options, IsUnion: true); + } + + // Check for allOf (usually inheritance/composition) + if (schema.AllOf is { Count: > 0 } allOf) + { + var refSchemas = allOf.OfType().ToArray(); + if (refSchemas.Length > 0) + { + var refId = refSchemas[0].Reference.Id; + if (!string.IsNullOrEmpty(refId)) + { + var typeName = SchemaHelpers.FormatSchemaName(refId); + var isValueType = SchemaHelpers.IsValueType(typeName); + var primitiveAliasType = !isValueType ? SchemaHelpers.GetPrimitiveAliasType(refSchemas[0]) : null; + if (!string.IsNullOrEmpty(primitiveAliasType)) + isValueType = true; + var valueTypeBase = isValueType ? (primitiveAliasType ?? SchemaHelpers.GetValueTypeBase(refSchemas[0]) ?? "string") : null; + var hasLink = IsLinkedType(typeName); + return new TypeInfo(typeName, refId, false, !isValueType, isValueType, valueTypeBase, hasLink, null); + } + } + } + + // Check for array items + if (schema.Type?.HasFlag(JsonSchemaType.Array) ?? false) + { + if (schema.Items is not null) + { + var itemInfo = GetTypeInfo(schema.Items); + // If the item is not an object and not a linked type, it's a primitive array + var isPrimitiveArray = itemInfo is not { IsObject: false, HasLink: false } || !string.IsNullOrEmpty(itemInfo.SchemaRef); + var arrayItemType = isPrimitiveArray ? itemInfo.TypeName : null; + return new TypeInfo(itemInfo.TypeName, itemInfo.SchemaRef, true, itemInfo.IsObject, itemInfo.IsValueType, itemInfo.ValueTypeBase, itemInfo.HasLink, null, ArrayItemType: arrayItemType); + } + return new TypeInfo("unknown", null, true, false, false, null, false, null, ArrayItemType: "unknown"); + } + + // Check for enum + if (schema.Enum is { Count: > 0 }) + { + var enumValues = schema.Enum.Select(e => e.ToString()).Take(5).ToArray(); + return new TypeInfo("enum", null, false, false, false, null, false, null, false, null, true, false, enumValues); + } + + // Check for additionalProperties (dictionary-like objects) + if (schema.AdditionalProperties is { } addProps) + { + var valueInfo = GetTypeInfo(addProps); + // Pass valueInfo.HasLink so we know if the dictionary value type has a dedicated page + return new TypeInfo($"string to {valueInfo.TypeName}", valueInfo.SchemaRef, false, true, false, null, valueInfo.HasLink, null, true, addProps); + } + + // Check if it has properties (inline object) + if (schema.Properties is { Count: > 0 }) + return new TypeInfo("object", null, false, true, false, null, false, null); + + // Primitive type + var primitiveName = SchemaHelpers.GetPrimitiveTypeName(schema.Type); + if (!string.IsNullOrEmpty(primitiveName)) + return new TypeInfo(primitiveName, null, false, primitiveName == "object", false, null, false, null); + + return new TypeInfo("object", null, false, true, false, null, false, null); + } +} diff --git a/src/Elastic.ApiExplorer/Schema/SchemaHelpers.cs b/src/Elastic.ApiExplorer/Schema/SchemaHelpers.cs new file mode 100644 index 000000000..b4c0652fd --- /dev/null +++ b/src/Elastic.ApiExplorer/Schema/SchemaHelpers.cs @@ -0,0 +1,169 @@ +// 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 Microsoft.OpenApi; + +namespace Elastic.ApiExplorer.Schema; + +/// +/// Shared static utilities for OpenAPI schema rendering. +/// +public static class SchemaHelpers +{ + /// + /// Maximum depth for recursive property rendering. + /// + public const int MaxDepth = 100; + + /// + /// Types that are known to be value types (resolve to primitives like string). + /// + public static readonly HashSet KnownValueTypes = new(StringComparer.OrdinalIgnoreCase) + { + "Field", "Fields", "Id", "Ids", "IndexName", "Indices", "Name", "Names", + "Routing", "VersionNumber", "SequenceNumber", "PropertyName", "RelationName", + "TaskId", "ScrollId", "SuggestionName", "Duration", "DateMath", "Fuzziness", + "GeoHashPrecision", "Distance", "TimeOfDay", "MinimumShouldMatch", "Script", + "ByteSize", "Percentage", "Stringifiedboolean", "ExpandWildcards", "float", "Stringifiedinteger", + // Numeric value types + "uint", "ulong", "long", "int", "short", "ushort", "byte", "sbyte", "double", "decimal" + }; + + /// + /// Types that have dedicated pages we can link to. + /// Only container types get their own pages - individual queries/aggregations are rendered inline. + /// + public static readonly HashSet LinkedTypes = new(StringComparer.OrdinalIgnoreCase) + { + "QueryContainer", "AggregationContainer", "Aggregate" + }; + + /// + /// Primitive/generic type names that are not named schema types. + /// These should not be considered for recursive type detection since they + /// represent generic types rather than specific schema references. + /// + public static readonly HashSet PrimitiveTypeNames = new(StringComparer.OrdinalIgnoreCase) + { + "boolean", "number", "string", "integer", "object", "null", "array" + }; + + /// + /// Gets the URL for a container type's dedicated page. + /// + public static string? GetContainerPageUrl(string typeName) => typeName switch + { + "QueryContainer" => "/api/elasticsearch/types/_types-query_dsl-querycontainer", + "AggregationContainer" => "/api/elasticsearch/types/_types-aggregations-aggregationcontainer", + "Aggregate" => "/api/elasticsearch/types/_types-aggregations-aggregate", + _ => null + }; + + /// + /// Determines if a type should link to its container page. + /// + /// The type name to check. + /// Optional current page type to prevent self-linking. + public static bool ShouldLinkToContainerPage(string typeName, string? currentPageType = null) + { + if (!LinkedTypes.Contains(typeName)) + return false; + + // Prevent self-linking on schema pages + if (!string.IsNullOrEmpty(currentPageType) && + typeName.Equals(currentPageType, StringComparison.OrdinalIgnoreCase)) + return false; + + return true; + } + + /// + /// Converts a JsonSchemaType to a human-readable primitive type name. + /// + public static string GetPrimitiveTypeName(JsonSchemaType? type) + { + if (type is null) + return ""; + + if (type.Value.HasFlag(JsonSchemaType.Boolean)) + return "boolean"; + if (type.Value.HasFlag(JsonSchemaType.Integer)) + return "integer"; + if (type.Value.HasFlag(JsonSchemaType.String)) + return "string"; + if (type.Value.HasFlag(JsonSchemaType.Number)) + return "number"; + if (type.Value.HasFlag(JsonSchemaType.Null)) + return "null"; + if (type.Value.HasFlag(JsonSchemaType.Object)) + return "object"; + + return ""; + } + + /// + /// Extracts the display name from a full schema ID (e.g., "_types.query_dsl.QueryContainer" -> "QueryContainer"). + /// + public static string FormatSchemaName(string schemaId) + { + var parts = schemaId.Split('.'); + return parts.Length > 0 ? parts[^1] : schemaId; + } + + /// + /// Checks if a type name represents a known value type. + /// + public static bool IsValueType(string typeName) => KnownValueTypes.Contains(typeName); + + /// + /// Checks if a type name is a primitive/generic type name (not a named schema type). + /// Primitive types like "object", "string", etc. should not be used for recursive type detection. + /// + public static bool IsPrimitiveTypeName(string typeName) => PrimitiveTypeNames.Contains(typeName); + + /// + /// Gets the primitive type base for a value type schema. + /// + public static string? GetValueTypeBase(IOpenApiSchema? schema) + { + if (schema is null) + return null; + + var primitiveType = GetPrimitiveTypeName(schema.Type); + if (!string.IsNullOrEmpty(primitiveType) && primitiveType != "object") + return primitiveType; + + return null; + } + + /// + /// Determines if a schema is a "primitive alias" - a named type that simply wraps a primitive type. + /// This detects types like "Cases_case_description" that are defined as just "type: string". + /// + /// The schema to check (typically a resolved schema reference). + /// The primitive type name if this is a primitive alias, null otherwise. + public static string? GetPrimitiveAliasType(IOpenApiSchema? schema) + { + if (schema is null) + return null; + + // If it has properties, additionalProperties, or composition, it's not a simple primitive alias + if (schema.Properties is { Count: > 0 }) + return null; + if (schema.AdditionalProperties is not null) + return null; + if (schema.OneOf is { Count: > 0 } || schema.AnyOf is { Count: > 0 } || schema.AllOf is { Count: > 0 }) + return null; + // Enums are not primitive aliases - they have special rendering + if (schema.Enum is { Count: > 0 }) + return null; + + // Check if it has a simple primitive type + var primitiveType = GetPrimitiveTypeName(schema.Type); + if (!string.IsNullOrEmpty(primitiveType) && primitiveType != "object") + return primitiveType; + + return null; + } +} diff --git a/src/Elastic.ApiExplorer/Schema/TypeInfo.cs b/src/Elastic.ApiExplorer/Schema/TypeInfo.cs new file mode 100644 index 000000000..e8691100f --- /dev/null +++ b/src/Elastic.ApiExplorer/Schema/TypeInfo.cs @@ -0,0 +1,54 @@ +// 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 Microsoft.OpenApi; + +namespace Elastic.ApiExplorer.Schema; + +/// +/// Represents a union option with full schema information. +/// +public record UnionOption( + string Name, + string? Ref, + bool IsObject, + IOpenApiSchema? Schema +); + +/// +/// Unified type information record used by both OperationView and SchemaView. +/// Contains all metadata needed for rendering schema types. +/// +/// The display name of the type. +/// The schema reference ID, if applicable. +/// Whether this is an array type. +/// Whether this is an object type (has properties). +/// Whether this is a known value type (resolves to primitive). +/// The primitive base type for value types. +/// Whether this type has a dedicated page to link to. +/// Union options with schema references for potential expansion. +/// Whether this is a dictionary/map type (additionalProperties). +/// The schema for dictionary value types. +/// Whether this is an enum type. +/// Whether this is a union type (oneOf/anyOf). +/// The enum values, if this is an enum type. +/// String array of union option names for display. +/// The primitive item type for arrays of primitives. +public record TypeInfo( + string TypeName, + string? SchemaRef, + bool IsArray, + bool IsObject, + bool IsValueType, + string? ValueTypeBase, + bool HasLink, + List? AnyOfOptions, + bool IsDictionary = false, + IOpenApiSchema? DictValueSchema = null, + bool IsEnum = false, + bool IsUnion = false, + string[]? EnumValues = null, + string[]? UnionOptions = null, + string? ArrayItemType = null +); diff --git a/src/Elastic.ApiExplorer/Schema/ValidationConstraintsRenderer.cs b/src/Elastic.ApiExplorer/Schema/ValidationConstraintsRenderer.cs new file mode 100644 index 000000000..378a37fb1 --- /dev/null +++ b/src/Elastic.ApiExplorer/Schema/ValidationConstraintsRenderer.cs @@ -0,0 +1,69 @@ +// 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 Microsoft.AspNetCore.Html; +using Microsoft.OpenApi; + +namespace Elastic.ApiExplorer.Schema; + +/// +/// Renders validation constraints for OpenAPI schema properties. +/// +public static class ValidationConstraintsRenderer +{ + /// + /// Renders validation constraints for a schema as HTML. + /// + public static IHtmlContent Render(IOpenApiSchema schema) + { + var constraints = new List(); + + // Default value + if (schema.Default != null) + { + var defaultStr = schema.Default.ToString(); + if (!string.IsNullOrEmpty(defaultStr)) + constraints.Add($"default: {System.Web.HttpUtility.HtmlEncode(defaultStr)}"); + } + + // String constraints + if (schema.MinLength.HasValue) + constraints.Add($"min length: {schema.MinLength.Value}"); + if (schema.MaxLength.HasValue) + constraints.Add($"max length: {schema.MaxLength.Value}"); + if (!string.IsNullOrEmpty(schema.Pattern)) + constraints.Add($"pattern: {System.Web.HttpUtility.HtmlEncode(schema.Pattern)}"); + + // Numeric constraints (these are strings in OpenAPI library) + if (!string.IsNullOrEmpty(schema.Minimum)) + constraints.Add($"min: {schema.Minimum}"); + if (!string.IsNullOrEmpty(schema.Maximum)) + constraints.Add($"max: {schema.Maximum}"); + if (!string.IsNullOrEmpty(schema.ExclusiveMinimum)) + constraints.Add($"exclusive min: {schema.ExclusiveMinimum}"); + if (!string.IsNullOrEmpty(schema.ExclusiveMaximum)) + constraints.Add($"exclusive max: {schema.ExclusiveMaximum}"); + if (schema.MultipleOf.HasValue) + constraints.Add($"multiple of: {schema.MultipleOf.Value}"); + + // Array constraints + if (schema.MinItems.HasValue) + constraints.Add($"min items: {schema.MinItems.Value}"); + if (schema.MaxItems.HasValue) + constraints.Add($"max items: {schema.MaxItems.Value}"); + if (schema.UniqueItems == true) + constraints.Add("unique items"); + + if (constraints.Count == 0) + return HtmlString.Empty; + + var sb = new System.Text.StringBuilder(); + _ = sb.Append("
Constraints:"); + foreach (var constraint in constraints) + _ = sb.Append($"{constraint}"); + _ = sb.Append("
"); + + return new HtmlString(sb.ToString()); + } +} diff --git a/src/Elastic.ApiExplorer/Schemas/SchemaNavigationItem.cs b/src/Elastic.ApiExplorer/Schemas/SchemaNavigationItem.cs new file mode 100644 index 000000000..96dd44ebf --- /dev/null +++ b/src/Elastic.ApiExplorer/Schemas/SchemaNavigationItem.cs @@ -0,0 +1,73 @@ +// 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.IO.Abstractions; +using Elastic.ApiExplorer.Landing; +using Elastic.Documentation.Extensions; +using Elastic.Documentation.Navigation; +using Microsoft.OpenApi; +using RazorSlices; + +namespace Elastic.ApiExplorer.Schemas; + +public record ApiSchema(string SchemaId, string DisplayName, string Category, IOpenApiSchema Schema) : IApiModel +{ + // For aggregations, we may have both an Aggregation and Aggregate type + public IOpenApiSchema? RelatedAggregate { get; init; } + public IOpenApiSchema? RelatedAggregation { get; init; } + + public async Task RenderAsync(FileSystemStream stream, ApiRenderContext context, Cancel ctx = default) + { + var viewModel = new SchemaViewModel(context) + { + Schema = this + }; + var slice = SchemaView.Create(viewModel); + await slice.RenderAsync(stream, cancellationToken: ctx); + } +} + +public class SchemaNavigationItem : ILeafNavigationItem +{ + public SchemaNavigationItem( + string? urlPathPrefix, + string apiUrlSuffix, + ApiSchema apiSchema, + IRootNavigationItem root, + IApiGroupingNavigationItem parent + ) + { + NavigationRoot = root; + Model = apiSchema; + NavigationTitle = apiSchema.DisplayName; + Parent = parent; + var moniker = apiSchema.SchemaId.Replace('.', '-').ToLowerInvariant(); + Url = $"{urlPathPrefix?.TrimEnd('/')}/api/{apiUrlSuffix}/types/{moniker}"; + Id = ShortId.Create(Url); + } + + public IRootNavigationItem NavigationRoot { get; } + public string Id { get; } + public ApiSchema Model { get; } + public string Url { get; } + public bool Hidden { get; set; } + public string NavigationTitle { get; } + public INodeNavigationItem? Parent { get; set; } + public int NavigationIndex { get; set; } +} + +public record SchemaCategory(string Name, string Description) : IApiGroupingModel +{ + public Task RenderAsync(FileSystemStream stream, ApiRenderContext context, CancellationToken ctx = default) => Task.CompletedTask; +} + +public class SchemaCategoryNavigationItem( + SchemaCategory category, + IRootNavigationItem root, + IApiGroupingNavigationItem parent +) : ApiGroupingNavigationItem(category, root, parent) +{ + public override string NavigationTitle { get; } = category.Name; + public override string Id { get; } = ShortId.Create("schema-category", category.Name); +} diff --git a/src/Elastic.ApiExplorer/Schemas/SchemaView.cshtml b/src/Elastic.ApiExplorer/Schemas/SchemaView.cshtml new file mode 100644 index 000000000..8ebb43d3c --- /dev/null +++ b/src/Elastic.ApiExplorer/Schemas/SchemaView.cshtml @@ -0,0 +1,215 @@ +@using Elastic.ApiExplorer.Landing +@using Elastic.ApiExplorer.Schema +@using Elastic.ApiExplorer.Schemas +@using Elastic.ApiExplorer.Shared +@using Microsoft.OpenApi +@inherits RazorSliceHttpResult +@implements IUsesLayout +@functions { + public ApiLayoutViewModel LayoutModel => Model.CreateGlobalLayoutModel(); + + // Schema analyzer instance - created lazily with current page type for self-link prevention + private SchemaAnalyzer? _analyzer; + private SchemaAnalyzer Analyzer => _analyzer ??= new SchemaAnalyzer(Model.Document, _currentPageType); + + // Delegate schema analysis to Analyzer + private IDictionary? GetSchemaProperties(IOpenApiSchema? schema) => Analyzer.GetSchemaProperties(schema); + private TypeInfo GetTypeInfo(IOpenApiSchema? schema) => Analyzer.GetTypeInfo(schema); + + // Current page type is set from the model to prevent self-linking + private string? _currentPageType; + + // Helper to create PropertyRenderContext for SchemaView + private PropertyRenderContext CreatePropertyContext(IOpenApiSchema? schema, string prefix, HashSet? ancestors = null) + => new PropertyRenderContext + { + Schema = schema, + RequiredProperties = schema?.Required, + Prefix = prefix, + Depth = 0, + AncestorTypes = ancestors, + IsRequest = false, + Analyzer = Analyzer, + RenderMarkdown = Model.RenderMarkdown, + // SchemaView defaults - disable OperationView-specific features + ShowDeprecated = false, + ShowVersionInfo = false, + ShowExternalDocs = false, + UseHiddenUntilFound = false, + CollapseMode = CollapseMode.DepthBased + }; + + // Helper to create SchemaTypeContext + private SchemaTypeContext CreateSchemaTypeContext(IOpenApiSchema schema) + => new SchemaTypeContext + { + Schema = schema, + TypeInfo = GetTypeInfo(schema), + HasActualProperties = GetSchemaProperties(schema)?.Count > 0 + }; + + // Helper to create UnionVariantsContext for oneOf/anyOf + private UnionVariantsContext CreateUnionContext(IList unionSchemas, string prefix, HashSet? ancestors = null) + { + var options = unionSchemas.Where(s => s is not null).Select(s => + { + var info = GetTypeInfo(s); + var displayName = info.IsArray ? $"{info.TypeName}[]" : info.TypeName; + return new UnionOption(displayName, info.SchemaRef, info.IsObject, s); + }).ToList(); + + return new UnionVariantsContext + { + Options = options, + Prefix = prefix, + Depth = 0, + AncestorTypes = ancestors, + IsRequest = false, + Analyzer = Analyzer, + RenderMarkdown = Model.RenderMarkdown, + UseHiddenUntilFound = false, + CollapseMode = CollapseMode.DepthBased + }; + } +} +@{ + var schema = Model.Schema; + var openApiSchema = schema.Schema; + var isAggregation = schema.Category == "aggregations"; + + // Set the current page type for self-link prevention + _currentPageType = schema.DisplayName; + + // Check if this is a container type that represents a dictionary + var dictionaryTypeName = schema.DisplayName switch + { + "AggregationContainer" => "Dictionary", + "Aggregate" => "Dictionary", + _ => null + }; + + // Initialize ancestor set with the root type name to detect recursive references + var rootAncestors = new HashSet { schema.DisplayName }; + + // Determine which sections exist + var hasDescription = !string.IsNullOrEmpty(openApiSchema.Description); + var hasEnum = openApiSchema.Enum is { Count: > 0 }; + var hasOneOf = openApiSchema.OneOf is { Count: > 0 }; + var hasAnyOf = openApiSchema.AnyOf is { Count: > 0 }; + var hasAggRequest = isAggregation && schema.RelatedAggregation is not null; + var hasProperties = !hasAggRequest && GetSchemaProperties(openApiSchema) is { Count: > 0 }; + var hasAggResponse = isAggregation && schema.RelatedAggregate is not null; + var hasAdditionalProps = openApiSchema.AdditionalProperties is IOpenApiSchema; + var hasExample = openApiSchema.Example is not null; +} + +
+

@schema.DisplayName

+

@schema.SchemaId

+ + @if (!string.IsNullOrEmpty(dictionaryTypeName)) + { +

+ (map) + @dictionaryTypeName +

+

This type represents a dictionary mapping string keys to @schema.DisplayName values.

+ } + + @if (hasDescription) + { +

+ Description +

+

@Model.RenderMarkdown(openApiSchema.Description)

+ @if (openApiSchema.ExternalDocs?.Url != null) + { + var externalDocsUrl = openApiSchema.ExternalDocs.Url.ToString(); + var isElasticDocs = externalDocsUrl.Contains("www.elastic.co/docs") || externalDocsUrl.Contains("elastic.co/guide"); + + @(isElasticDocs ? "Read the reference documentation" : "External documentation") + + } + } + else if (openApiSchema.ExternalDocs?.Url != null) + { + var externalDocsUrl = openApiSchema.ExternalDocs.Url.ToString(); + var isElasticDocs = externalDocsUrl.Contains("www.elastic.co/docs") || externalDocsUrl.Contains("elastic.co/guide"); + + @(isElasticDocs ? "Read the reference documentation" : "External documentation") + + } + + @if (hasEnum) + { +

+ Enum Values +

+
    + @foreach (var enumValue in openApiSchema.Enum!) + { +
  • @enumValue
  • + } +
+ } + + @if (hasOneOf) + { +

+ Union Types (oneOf) +

+

This type can be one of the following:

+ @(await RenderPartialAsync<_UnionOptions, UnionVariantsContext>(CreateUnionContext(openApiSchema.OneOf!, "oneof", rootAncestors))) + } + + @if (hasAnyOf) + { +

+ Union Types (anyOf) +

+

This type can be any of the following:

+ @(await RenderPartialAsync<_UnionOptions, UnionVariantsContext>(CreateUnionContext(openApiSchema.AnyOf!, "anyof", rootAncestors))) + } + + @* For aggregations, show both aggregation request and aggregate response *@ + @if (hasAggRequest) + { +

+ Aggregation Request +

+

Properties for configuring the @(schema.DisplayName) aggregation:

+ @(await RenderPartialAsync<_PropertyList, PropertyRenderContext>(CreatePropertyContext(schema.RelatedAggregation, "agg", rootAncestors))) + } + else if (hasProperties) + { +

+ Properties +

+ @(await RenderPartialAsync<_PropertyList, PropertyRenderContext>(CreatePropertyContext(openApiSchema, "", rootAncestors))) + } + + @if (hasAggResponse) + { +

+ Aggregate Response +

+

Properties returned in the @(schema.DisplayName) aggregate response:

+ @(await RenderPartialAsync<_PropertyList, PropertyRenderContext>(CreatePropertyContext(schema.RelatedAggregate, "result", rootAncestors))) + } + + @if (hasAdditionalProps && openApiSchema.AdditionalProperties is IOpenApiSchema addPropsSchema) + { +

+ Additional Properties +

+

This type allows additional properties of type: @(await RenderPartialAsync<_SchemaType, SchemaTypeContext>(CreateSchemaTypeContext(addPropsSchema)))

+ } + + @if (hasExample) + { +

+ Example +

+
@openApiSchema.Example
+ } +
diff --git a/src/Elastic.ApiExplorer/Schemas/SchemaViewModel.cs b/src/Elastic.ApiExplorer/Schemas/SchemaViewModel.cs new file mode 100644 index 000000000..5e1b738b4 --- /dev/null +++ b/src/Elastic.ApiExplorer/Schemas/SchemaViewModel.cs @@ -0,0 +1,144 @@ +// 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 Elastic.ApiExplorer.Landing; +using Microsoft.OpenApi; + +namespace Elastic.ApiExplorer.Schemas; + +public class SchemaViewModel(ApiRenderContext context) : ApiViewModel(context) +{ + public required ApiSchema Schema { get; init; } + + protected override IReadOnlyList GetTocItems() + { + var openApiSchema = Schema.Schema; + var isAggregation = Schema.Category == "aggregations"; + var tocItems = new List(); + + // Description + if (!string.IsNullOrEmpty(openApiSchema.Description)) + tocItems.Add(new ApiTocItem("Description", "description")); + + // Enum values + if (openApiSchema.Enum is { Count: > 0 }) + tocItems.Add(new ApiTocItem("Enum Values", "enum-values")); + + // Union types (oneOf or anyOf) + if (openApiSchema.OneOf is { Count: > 0 } || openApiSchema.AnyOf is { Count: > 0 }) + tocItems.Add(new ApiTocItem("Union Types", "union-types")); + + // Aggregation request (for aggregations) + if (isAggregation && Schema.RelatedAggregation is not null) + { + tocItems.Add(new ApiTocItem("Aggregation Request", "aggregation-request")); + // Add top-level properties nested under Aggregation Request + var aggProps = GetSchemaPropertyNames(Schema.RelatedAggregation); + foreach (var propName in aggProps) + tocItems.Add(new ApiTocItem(propName, $"agg-{propName}", 3)); + } + // Properties (for non-aggregations with properties) + else if (HasSchemaProperties(openApiSchema)) + { + tocItems.Add(new ApiTocItem("Properties", "properties")); + // Add top-level properties nested under Properties + var props = GetSchemaPropertyNames(openApiSchema); + foreach (var propName in props) + tocItems.Add(new ApiTocItem(propName, propName, 3)); + } + + // Aggregate response (for aggregations) + if (isAggregation && Schema.RelatedAggregate is not null) + { + tocItems.Add(new ApiTocItem("Aggregate Response", "aggregate-response")); + // Add top-level properties nested under Aggregate Response + var resultProps = GetSchemaPropertyNames(Schema.RelatedAggregate); + foreach (var propName in resultProps) + tocItems.Add(new ApiTocItem(propName, $"result-{propName}", 3)); + } + + // Additional properties + if (openApiSchema.AdditionalProperties is not null) + tocItems.Add(new ApiTocItem("Additional Properties", "additional-properties")); + + // Example + if (openApiSchema.Example is not null) + tocItems.Add(new ApiTocItem("Example", "example")); + + return tocItems; + } + + /// + /// Gets the property names from a schema, resolving references and AllOf as needed. + /// + private IEnumerable GetSchemaPropertyNames(IOpenApiSchema? schema) + { + if (schema is null) + return []; + + // Handle schema references + if (schema is OpenApiSchemaReference schemaRef) + { + if (schemaRef.Properties is { Count: > 0 }) + return schemaRef.Properties.Keys; + + var refId = schemaRef.Reference?.Id; + if (!string.IsNullOrEmpty(refId) && + Document.Components?.Schemas?.TryGetValue(refId, out var resolvedSchema) == true) + { + return GetSchemaPropertyNames(resolvedSchema); + } + } + + // Direct properties + if (schema.Properties is { Count: > 0 }) + return schema.Properties.Keys; + + // For allOf, collect property names from all schemas + if (schema.AllOf is { Count: > 0 }) + { + var allProps = new List(); + foreach (var subSchema in schema.AllOf) + allProps.AddRange(GetSchemaPropertyNames(subSchema)); + return allProps.Distinct(); + } + + return []; + } + + /// + /// Checks if a schema has properties, resolving references and AllOf as needed. + /// + private bool HasSchemaProperties(IOpenApiSchema? schema) + { + if (schema is null) + return false; + + // Handle schema references - resolve to get actual properties + if (schema is OpenApiSchemaReference schemaRef) + { + // Try direct property access first (proxied) + if (schemaRef.Properties is { Count: > 0 }) + return true; + + // Try resolving via Reference.Id + var refId = schemaRef.Reference?.Id; + if (!string.IsNullOrEmpty(refId) && + Document.Components?.Schemas?.TryGetValue(refId, out var resolvedSchema) == true) + { + return HasSchemaProperties(resolvedSchema); + } + } + + // Direct properties + if (schema.Properties is { Count: > 0 }) + return true; + + // For allOf, check if any sub-schema has properties + if (schema.AllOf is { Count: > 0 }) + return schema.AllOf.Any(HasSchemaProperties); + + return false; + } +} diff --git a/src/Elastic.ApiExplorer/Shared/_PropertyItem.cshtml b/src/Elastic.ApiExplorer/Shared/_PropertyItem.cshtml new file mode 100644 index 000000000..e85ff0019 --- /dev/null +++ b/src/Elastic.ApiExplorer/Shared/_PropertyItem.cshtml @@ -0,0 +1,489 @@ +@inherits RazorSlice +@{ + var ctx = Model.ParentContext; + var propSchema = Model.PropertySchema; + var typeInfo = Model.TypeInfo; + var depth = ctx.Depth; + var hasDescription = !string.IsNullOrWhiteSpace(propSchema.Description); + + // For dictionaries with linked value types, typeInfo.HasLink is true + var dictHasLinkedValue = typeInfo.IsDictionary && typeInfo.HasLink; + + // Determine if we should show nested properties + // Don't expand if the type has a dedicated page (linked type) + var hasNestedProps = typeInfo.IsObject && !typeInfo.HasLink && depth < ctx.MaxDepth && ctx.Analyzer.GetSchemaProperties(propSchema)?.Count > 0; + + // For dictionaries, check if the value type has properties we should show + var hasDictValueProps = typeInfo.IsDictionary && typeInfo.DictValueSchema != null + && depth < ctx.MaxDepth && !dictHasLinkedValue && ctx.Analyzer.GetSchemaProperties(typeInfo.DictValueSchema)?.Count > 0; + + // For arrays, check if the item type has properties we should show + var arrayItemSchema = typeInfo.IsArray && propSchema.Items != null ? propSchema.Items : null; + var hasArrayItemProps = arrayItemSchema != null && !typeInfo.HasLink && depth < ctx.MaxDepth + && ctx.Analyzer.GetSchemaProperties(arrayItemSchema)?.Count > 0; + + // Detect simple X | X[] unions + var isSimpleArrayUnion = false; + string? simpleUnionBaseName = null; + if (typeInfo.IsUnion && typeInfo.AnyOfOptions != null && typeInfo.AnyOfOptions.Count > 0) + { + var unionOptionNames = new List(); + unionOptionNames.AddRange(typeInfo.AnyOfOptions.Select(o => o.Name)); + if (typeInfo.UnionOptions != null) + unionOptionNames.AddRange(typeInfo.UnionOptions); + var distinctNames = unionOptionNames.Distinct().ToArray(); + + if (distinctNames.Length == 2) + { + var baseNames = distinctNames.Select(n => n.EndsWith("[]") ? n[..^2] : n).Distinct().ToArray(); + if (baseNames.Length == 1 && !string.IsNullOrEmpty(baseNames[0])) + { + isSimpleArrayUnion = true; + simpleUnionBaseName = baseNames[0]; + } + } + } + + // For simple X | X[] unions, don't expand tree - show inline instead + var hasUnionOptions = typeInfo.IsUnion && typeInfo.AnyOfOptions != null && depth < ctx.MaxDepth + && !isSimpleArrayUnion + && typeInfo.AnyOfOptions.Any(ctx.Analyzer.UnionOptionHasProperties); + + // For simple unions, check if the base type has properties we should expand (when NOT linked) + var simpleUnionHasExpandableProps = false; + IOpenApiSchema? simpleUnionSchema = null; + List? simpleUnionNestedOptions = null; + if (isSimpleArrayUnion && !string.IsNullOrEmpty(simpleUnionBaseName) && depth < ctx.MaxDepth) + { + var baseOption = typeInfo.AnyOfOptions!.FirstOrDefault(o => o.Name == simpleUnionBaseName); + if (baseOption?.Schema != null) + { + var baseTypeInfo = ctx.Analyzer.GetTypeInfo(baseOption.Schema); + if (!baseTypeInfo.HasLink && ctx.Analyzer.UnionOptionHasProperties(baseOption)) + { + simpleUnionHasExpandableProps = true; + simpleUnionSchema = baseOption.Schema; + + var directProps = ctx.Analyzer.GetSchemaProperties(baseOption.Schema); + if (directProps == null || directProps.Count == 0) + { + simpleUnionNestedOptions = ctx.Analyzer.GetNestedUnionOptions(baseOption.Schema); + if (simpleUnionNestedOptions.Count == 0) + { + var refId = baseOption.Ref; + if (string.IsNullOrEmpty(refId) && baseOption.Schema is OpenApiSchemaReference schemaRef) + refId = schemaRef.Reference.Id; + // Note: Would need Document access to resolve by refId - simplified here + } + } + } + } + } + + // Count nested properties + var nestedPropCount = 0; + if (hasNestedProps) + nestedPropCount = ctx.Analyzer.GetSchemaProperties(propSchema)?.Count ?? 0; + else if (hasDictValueProps) + nestedPropCount = ctx.Analyzer.GetSchemaProperties(typeInfo.DictValueSchema)?.Count ?? 0; + else if (hasArrayItemProps) + nestedPropCount = ctx.Analyzer.GetSchemaProperties(arrayItemSchema)?.Count ?? 0; + else if (hasUnionOptions) + nestedPropCount = typeInfo.AnyOfOptions!.Count(ctx.Analyzer.UnionOptionHasProperties); + else if (simpleUnionHasExpandableProps && simpleUnionNestedOptions is { Count: > 0 }) + nestedPropCount = simpleUnionNestedOptions.Count(ctx.Analyzer.UnionOptionHasProperties); + else if (simpleUnionHasExpandableProps && simpleUnionSchema != null) + nestedPropCount = ctx.Analyzer.GetSchemaProperties(simpleUnionSchema)?.Count ?? 0; + + var hasChildren = (hasNestedProps || hasDictValueProps || hasArrayItemProps || hasUnionOptions || simpleUnionHasExpandableProps) && !Model.IsRecursive; + var isCollapsible = hasChildren && nestedPropCount > 1 && !hasUnionOptions && !hasDictValueProps; + + // Compute default expanded based on CollapseMode + var defaultExpanded = ctx.CollapseMode == CollapseMode.DepthBased + ? (depth == 0 ? false : (nestedPropCount > 0 && nestedPropCount < 5)) + : false; + + // Prepare SchemaTypeContext for type rendering + var hasActualProperties = ctx.Analyzer.GetSchemaProperties(propSchema)?.Count > 0; + var schemaTypeCtx = new SchemaTypeContext + { + Schema = propSchema, + TypeInfo = typeInfo, + HasActualProperties = hasActualProperties + }; +} +
+
+ + @Model.PropertyName + @(await RenderPartialAsync<_SchemaType, SchemaTypeContext>(schemaTypeCtx)) + @if (ctx.IsRequest && Model.IsRequired) + { + required + } + else if (!ctx.IsRequest && !Model.IsRequired) + { + optional + } + @if (Model.IsRecursive) + { + @(await RenderPartialAsync<_RecursiveBadge>()) + } + @if (ctx.ShowDeprecated && propSchema.Deprecated == true) + { + deprecated + } + @if (ctx.ShowVersionInfo) + { + var propVersionInfo = propSchema.Extensions?.TryGetValue("x-state", out var propStateValue) == true && propStateValue is System.Text.Json.Nodes.JsonNode propStateNode ? propStateNode.ToString() : null; + if (!string.IsNullOrEmpty(propVersionInfo)) + { + @propVersionInfo + } + } + +
+ @if (hasDescription) + { +
+ @ctx.RenderMarkdown(propSchema.Description) +
+ } + @if (ctx.ShowExternalDocs && propSchema.ExternalDocs?.Url != null && !typeInfo.HasLink) + { + var externalDocsUrl = propSchema.ExternalDocs.Url.ToString(); + var isElasticDocs = externalDocsUrl.Contains("www.elastic.co/docs") || externalDocsUrl.Contains("elastic.co/guide"); +
+ + @(isElasticDocs ? "Read the reference documentation" : "External documentation") + +
+ } + @ValidationConstraintsRenderer.Render(propSchema) + @if (typeInfo.IsEnum && typeInfo.EnumValues is { Length: > 0 }) + { +
+ Values: + @foreach (var enumVal in typeInfo.EnumValues) + { + @enumVal + } +
+ } + @if (typeInfo.IsUnion) + { + // Merge AnyOfOptions names with UnionOptions for complete coverage + var unionOptionNames = new List(); + if (typeInfo.AnyOfOptions != null && typeInfo.AnyOfOptions.Count > 0) + unionOptionNames.AddRange(typeInfo.AnyOfOptions.Select(o => o.Name)); + if (typeInfo.UnionOptions != null) + unionOptionNames.AddRange(typeInfo.UnionOptions); + var distinctOptions = unionOptionNames.Distinct().ToArray(); + + var sortedOptions = distinctOptions + .OrderByDescending(o => o.EndsWith("[]")) + .ToArray(); + + var primitiveTypeNames = new HashSet(StringComparer.OrdinalIgnoreCase) + { "boolean", "number", "string", "integer", "object", "null", "array" }; + var allEnumLike = sortedOptions.Length > 0 && sortedOptions.All(o => + !o.EndsWith("[]") && + !string.IsNullOrEmpty(o) && + !primitiveTypeNames.Contains(o) && + (char.IsLower(o[0]) || o.All(c => !char.IsLetter(c) || char.IsLower(c) || c == '_'))); + + if (allEnumLike) + { +
+ Values: + @foreach (var enumVal in sortedOptions) + { + @enumVal + } +
+ } + else if (isSimpleArrayUnion && !string.IsNullOrEmpty(simpleUnionBaseName)) + { + var baseTypeOption = typeInfo.AnyOfOptions!.FirstOrDefault(o => o.Name == simpleUnionBaseName); + var isObjectType = baseTypeOption?.IsObject ?? false; + var baseSchema = baseTypeOption?.Schema; + var baseTypeInfo = baseSchema != null ? ctx.Analyzer.GetTypeInfo(baseSchema) : null; + var isBaseValueType = baseTypeInfo?.IsValueType ?? false; + var valueTypePrefix = isBaseValueType && !string.IsNullOrEmpty(baseTypeInfo?.ValueTypeBase) + ? baseTypeInfo.ValueTypeBase + " " + : ""; +
+ One of: + + + @if (isObjectType) + { + {} + } + @valueTypePrefix@simpleUnionBaseName + + or + + [] + @if (isObjectType) + { + {} + } + @valueTypePrefix@simpleUnionBaseName + + +
+ } + else if (sortedOptions.Length > 0 || hasUnionOptions) + { + var badgeOptions = hasUnionOptions ? Array.Empty() : sortedOptions; + var primitiveTypes = new HashSet(StringComparer.OrdinalIgnoreCase) + { "boolean", "number", "string", "integer", "object", "null", "array" }; + +
+ One of: + @if (propSchema.Discriminator?.PropertyName != null) + { + (determined by @propSchema.Discriminator.PropertyName) + } + @{var badgeIndex = 0;} + @foreach (var unionOpt in badgeOptions) + { + if (badgeIndex > 0) + { + or + } + var isTypeOption = primitiveTypes.Contains(unionOpt) || + primitiveTypes.Contains(unionOpt.TrimEnd('[', ']')) || + char.IsUpper(unionOpt[0]) || unionOpt.EndsWith("[]"); + @unionOpt + badgeIndex++; + } +
+ } + } + @if (!string.IsNullOrEmpty(typeInfo.ArrayItemType)) + { +
+ Array of: + @typeInfo.ArrayItemType +
+ } + @{ + string? linkedTypeName = null; + if (typeInfo.HasLink) + { + linkedTypeName = typeInfo.IsDictionary && typeInfo.DictValueSchema != null + ? ctx.Analyzer.GetTypeInfo(typeInfo.DictValueSchema).TypeName + : typeInfo.TypeName; + } + else if (isSimpleArrayUnion && !string.IsNullOrEmpty(simpleUnionBaseName)) + { + var baseOption = typeInfo.AnyOfOptions!.FirstOrDefault(o => o.Name == simpleUnionBaseName); + if (baseOption?.Schema != null) + { + var baseTypeInfo = ctx.Analyzer.GetTypeInfo(baseOption.Schema); + if (baseTypeInfo.HasLink) + linkedTypeName = simpleUnionBaseName; + } + } + } + @if (!string.IsNullOrEmpty(linkedTypeName)) + { + var typePageUrl = SchemaHelpers.GetContainerPageUrl(linkedTypeName); + + } + @if (isCollapsible) + { +
+ +
+ } + @if (!Model.IsRecursive) + { + // Build new ancestor set including current type + var newAncestors = ctx.AncestorTypes != null ? new HashSet(ctx.AncestorTypes) : new HashSet(); + if (!string.IsNullOrEmpty(typeInfo.TypeName) && typeInfo.IsObject) + { + if (typeInfo.IsDictionary && typeInfo.DictValueSchema != null) + { + var dictValueType = ctx.Analyzer.GetTypeInfo(typeInfo.DictValueSchema); + if (!string.IsNullOrEmpty(dictValueType.TypeName)) + newAncestors.Add(dictValueType.TypeName); + } + else + { + newAncestors.Add(typeInfo.TypeName); + } + } + + // Create child context for nested rendering + var childContext = new PropertyRenderContext + { + Schema = null, // Will be set per-case below + RequiredProperties = null, + Prefix = Model.PropId, + Depth = depth + 1, + AncestorTypes = newAncestors, + IsRequest = ctx.IsRequest, + Analyzer = ctx.Analyzer, + RenderMarkdown = ctx.RenderMarkdown, + MaxDepth = ctx.MaxDepth, + ShowDeprecated = ctx.ShowDeprecated, + ShowVersionInfo = ctx.ShowVersionInfo, + ShowExternalDocs = ctx.ShowExternalDocs, + UseHiddenUntilFound = ctx.UseHiddenUntilFound, + CollapseMode = ctx.CollapseMode + }; + + var useHidden = ctx.UseHiddenUntilFound && isCollapsible && !defaultExpanded; + + if (hasDictValueProps) + { + var keyPropId = $"{Model.PropId}-string"; + var dictIsCollapsible = nestedPropCount > 1; + var dictDefaultExpanded = ctx.CollapseMode == CollapseMode.DepthBased + ? (depth + 1 == 0 ? false : (nestedPropCount > 0 && nestedPropCount < 5)) + : false; + var dictChildCtx = childContext with { Schema = typeInfo.DictValueSchema!, Prefix = keyPropId, Depth = depth + 2 }; + var dictValueTypeInfo = ctx.Analyzer.GetTypeInfo(typeInfo.DictValueSchema!); + var dictValueSchemaTypeCtx = new SchemaTypeContext + { + Schema = typeInfo.DictValueSchema!, + TypeInfo = dictValueTypeInfo, + HasActualProperties = ctx.Analyzer.GetSchemaProperties(typeInfo.DictValueSchema)?.Count > 0 + }; +
+
+
+
+ <string> + @(await RenderPartialAsync<_SchemaType, SchemaTypeContext>(dictValueSchemaTypeCtx)) +
+ @if (dictIsCollapsible) + { +
+ +
+ } + @if (ctx.UseHiddenUntilFound && dictIsCollapsible && !dictDefaultExpanded) + { + + } + else + { +
+ @(await RenderPartialAsync<_PropertyList, PropertyRenderContext>(dictChildCtx)) +
+ } +
+
+
+ } + else if (hasNestedProps) + { + var nestedCtx = childContext with { Schema = propSchema }; + if (useHidden) + { + + } + else + { +
+ @(await RenderPartialAsync<_PropertyList, PropertyRenderContext>(nestedCtx)) +
+ } + } + else if (hasArrayItemProps) + { + var arrayCtx = childContext with { Schema = arrayItemSchema }; + if (useHidden) + { + + } + else + { +
+ @(await RenderPartialAsync<_PropertyList, PropertyRenderContext>(arrayCtx)) +
+ } + } + else if (hasUnionOptions) + { + var unionCtx = new UnionVariantsContext + { + Options = typeInfo.AnyOfOptions!, + Prefix = Model.PropId, + Depth = depth + 1, + AncestorTypes = newAncestors, + IsRequest = ctx.IsRequest, + Analyzer = ctx.Analyzer, + RenderMarkdown = ctx.RenderMarkdown, + UseHiddenUntilFound = ctx.UseHiddenUntilFound, + CollapseMode = ctx.CollapseMode, + MaxDepth = ctx.MaxDepth + }; +
+ @(await RenderPartialAsync<_UnionOptions, UnionVariantsContext>(unionCtx)) +
+ } + else if (simpleUnionHasExpandableProps && simpleUnionNestedOptions is { Count: > 0 }) + { + var unionCtx = new UnionVariantsContext + { + Options = simpleUnionNestedOptions, + Prefix = Model.PropId, + Depth = depth + 1, + AncestorTypes = newAncestors, + IsRequest = ctx.IsRequest, + Analyzer = ctx.Analyzer, + RenderMarkdown = ctx.RenderMarkdown, + UseHiddenUntilFound = ctx.UseHiddenUntilFound, + CollapseMode = ctx.CollapseMode, + MaxDepth = ctx.MaxDepth + }; + if (useHidden) + { + + } + else + { +
+ @(await RenderPartialAsync<_UnionOptions, UnionVariantsContext>(unionCtx)) +
+ } + } + else if (simpleUnionHasExpandableProps && simpleUnionSchema != null) + { + var simpleUnionCtx = childContext with { Schema = simpleUnionSchema }; + if (useHidden) + { + + } + else + { +
+ @(await RenderPartialAsync<_PropertyList, PropertyRenderContext>(simpleUnionCtx)) +
+ } + } + } +
diff --git a/src/Elastic.ApiExplorer/Shared/_PropertyList.cshtml b/src/Elastic.ApiExplorer/Shared/_PropertyList.cshtml new file mode 100644 index 000000000..194959dc0 --- /dev/null +++ b/src/Elastic.ApiExplorer/Shared/_PropertyList.cshtml @@ -0,0 +1,98 @@ +@using Elastic.ApiExplorer.Schema +@inherits RazorSlice +@{ + var properties = Model.Analyzer.GetSchemaProperties(Model.Schema); + if (properties is null || properties.Count == 0) + { + return; + } + + var requiredProps = Model.RequiredProperties ?? Model.Schema?.Required ?? new HashSet(); + var propArray = properties.ToArray(); +} +
+ @for (var i = 0; i < propArray.Length; i++) + { + var property = propArray[i]; + var propSchema = property.Value; + if (propSchema is null) continue; + + var typeInfo = Model.Analyzer.GetTypeInfo(propSchema); + var propId = string.IsNullOrEmpty(Model.Prefix) ? property.Key : $"{Model.Prefix}-{property.Key}"; + + // Check if this type appears in any ancestor (recursive detection) + // Only consider named types - primitive types like "object", "string" cannot be recursive + var isRecursive = Model.AncestorTypes != null && !string.IsNullOrEmpty(typeInfo.TypeName) + && !SchemaHelpers.IsPrimitiveTypeName(typeInfo.TypeName) && Model.AncestorTypes.Contains(typeInfo.TypeName); + + // Also check array item types for recursion (e.g., QueryContainer[] when on QueryContainer page) + if (!isRecursive && typeInfo.IsArray && propSchema.Items != null) + { + var arrayItemType = Model.Analyzer.GetTypeInfo(propSchema.Items); + isRecursive = Model.AncestorTypes != null && !string.IsNullOrEmpty(arrayItemType.TypeName) + && !SchemaHelpers.IsPrimitiveTypeName(arrayItemType.TypeName) && Model.AncestorTypes.Contains(arrayItemType.TypeName); + } + + // Also check dictionary value types for recursion + if (!isRecursive && typeInfo.IsDictionary && typeInfo.DictValueSchema != null) + { + var dictValueType = Model.Analyzer.GetTypeInfo(typeInfo.DictValueSchema); + isRecursive = Model.AncestorTypes != null && !string.IsNullOrEmpty(dictValueType.TypeName) + && !SchemaHelpers.IsPrimitiveTypeName(dictValueType.TypeName) && Model.AncestorTypes.Contains(dictValueType.TypeName); + } + + // Also check union option types for recursion (e.g., QueryContainer | QueryContainer[] when on QueryContainer page) + if (!isRecursive && typeInfo.IsUnion && typeInfo.AnyOfOptions != null && Model.AncestorTypes != null) + { + isRecursive = typeInfo.AnyOfOptions + .Select(option => option.Name.EndsWith("[]") ? option.Name[..^2] : option.Name) + .Any(baseName => !string.IsNullOrEmpty(baseName) && !SchemaHelpers.IsPrimitiveTypeName(baseName) && Model.AncestorTypes.Contains(baseName)); + } + + // Also check schema's direct oneOf/anyOf for recursion (in case typeInfo doesn't capture it) + if (!isRecursive && Model.AncestorTypes != null) + { + var unionSchemas = propSchema.OneOf ?? propSchema.AnyOf; + if (unionSchemas is { Count: > 0 }) + { + foreach (var unionSchema in unionSchemas) + { + if (unionSchema == null) continue; + var unionTypeInfo = Model.Analyzer.GetTypeInfo(unionSchema); + var typeName = unionTypeInfo.TypeName; + // Strip [] suffix for array types + var baseName = typeName?.EndsWith("[]") == true ? typeName[..^2] : typeName; + if (!string.IsNullOrEmpty(baseName) && !SchemaHelpers.IsPrimitiveTypeName(baseName) && Model.AncestorTypes.Contains(baseName)) + { + isRecursive = true; + break; + } + // Also check if the union option is an array pointing to an ancestor type + if (unionTypeInfo.IsArray && unionSchema.Items != null) + { + var itemTypeInfo = Model.Analyzer.GetTypeInfo(unionSchema.Items); + if (!string.IsNullOrEmpty(itemTypeInfo.TypeName) && !SchemaHelpers.IsPrimitiveTypeName(itemTypeInfo.TypeName) && Model.AncestorTypes.Contains(itemTypeInfo.TypeName)) + { + isRecursive = true; + break; + } + } + } + } + } + + var itemContext = new PropertyItemContext + { + PropertyName = property.Key, + PropertySchema = propSchema, + TypeInfo = typeInfo, + PropId = propId, + IsRequired = requiredProps.Contains(property.Key), + IsLast = i == propArray.Length - 1, + IsRecursive = isRecursive, + ParentContext = Model + }; + + @(await RenderPartialAsync<_PropertyItem, PropertyItemContext>(itemContext)) + } +
diff --git a/src/Elastic.ApiExplorer/Shared/_RecursiveBadge.cshtml b/src/Elastic.ApiExplorer/Shared/_RecursiveBadge.cshtml new file mode 100644 index 000000000..cd177b6d7 --- /dev/null +++ b/src/Elastic.ApiExplorer/Shared/_RecursiveBadge.cshtml @@ -0,0 +1,2 @@ +@inherits RazorSlice +recursive diff --git a/src/Elastic.ApiExplorer/Shared/_SchemaType.cshtml b/src/Elastic.ApiExplorer/Shared/_SchemaType.cshtml new file mode 100644 index 000000000..7f0699e2a --- /dev/null +++ b/src/Elastic.ApiExplorer/Shared/_SchemaType.cshtml @@ -0,0 +1,102 @@ +@using Elastic.ApiExplorer.Schema +@using Microsoft.AspNetCore.Html +@inherits RazorSlice +@{ + string result; + + if (Model == null || Model.TypeInfo == null) + { + result = "unknown"; + } + else + { + var typeInfo = Model.TypeInfo; + var typeName = typeInfo.TypeName ?? "unknown"; + var sb = new System.Text.StringBuilder(); + + // Build the display text with [] for arrays, {} for objects, enum/union markers + if (typeInfo.IsArray) + { + sb.Append("[] "); + if (typeInfo.IsValueType && !string.IsNullOrEmpty(typeInfo.ValueTypeBase)) + { + sb.Append(""); + sb.Append(System.Web.HttpUtility.HtmlEncode(typeInfo.ValueTypeBase)); + sb.Append(" "); + } + else if (typeInfo.IsEnum) + { + sb.Append("enum "); + } + else if (typeInfo.IsUnion) + { + sb.Append("union "); + } + else if (typeInfo.IsObject && !string.IsNullOrEmpty(typeInfo.SchemaRef) && (Model.HasActualProperties || typeInfo.HasLink)) + { + sb.Append("{} "); + } + } + else if (typeInfo.IsDictionary) + { + // Dictionary uses "map string to TypeName" format + var valueTypeName = typeName.StartsWith("string to ") ? typeName.Substring("string to ".Length) : typeName; + if (string.IsNullOrEmpty(valueTypeName)) + { + valueTypeName = "unknown"; + } + + sb.Append("map "); + sb.Append("string"); + sb.Append(" to "); + if (typeInfo.HasLink || Model.HasActualProperties) + { + sb.Append("{} "); + } + sb.Append(""); + sb.Append(System.Web.HttpUtility.HtmlEncode(valueTypeName)); + sb.Append(""); + } + else if (typeInfo.IsEnum) + { + sb.Append("enum "); + } + else if (typeInfo.IsUnion) + { + sb.Append("union "); + } + else if (typeInfo.IsValueType && !string.IsNullOrEmpty(typeInfo.ValueTypeBase)) + { + sb.Append(""); + sb.Append(System.Web.HttpUtility.HtmlEncode(typeInfo.ValueTypeBase)); + sb.Append(" "); + } + else if (typeInfo.IsObject && !string.IsNullOrEmpty(typeInfo.SchemaRef) && !typeInfo.HasLink && Model.HasActualProperties) + { + sb.Append("{} "); + } + + // Only add the type name span if not a dictionary (dictionaries have their own format) + if (!typeInfo.IsDictionary) + { + var displayName = System.Web.HttpUtility.HtmlEncode(typeName) ?? "unknown"; + var schemaRef = typeInfo.SchemaRef ?? ""; + var titleAttr = !string.IsNullOrEmpty(schemaRef) ? " title=\"" + System.Web.HttpUtility.HtmlEncode(schemaRef) + "\"" : ""; + + // Add {} prefix for linked types (they have dedicated pages) + if (typeInfo.HasLink) + { + sb.Append("{} "); + } + + sb.Append(""); + sb.Append(displayName); + sb.Append(""); + } + + result = sb.ToString(); + } +} +@(new HtmlString(result)) diff --git a/src/Elastic.ApiExplorer/Shared/_UnionOptions.cshtml b/src/Elastic.ApiExplorer/Shared/_UnionOptions.cshtml new file mode 100644 index 000000000..fd19819df --- /dev/null +++ b/src/Elastic.ApiExplorer/Shared/_UnionOptions.cshtml @@ -0,0 +1,203 @@ +@using Elastic.ApiExplorer.Schema +@using Microsoft.OpenApi +@inherits RazorSlice +@{ + var options = Model.Options; + if (options.Count == 0) + { + return; + } + + // If ALL options are value types (no objects), don't render tree nodes + // The "One of:" badges are sufficient for simple unions like boolean | number + var hasObjectOption = options.Any(o => o.IsObject); + if (!hasObjectOption) + { + return; + } + + // Sort: array variants first, then non-array variants + // Group by base name to pair X[] with X + var sortedOptions = options + .GroupBy(o => o.Name.EndsWith("[]") ? o.Name[..^2] : o.Name) + .SelectMany(g => { + var items = g.ToList(); + // Sort within group: array first, then non-array + return items.OrderByDescending(o => o.Name.EndsWith("[]")); + }) + .ToList(); + + // Build list of variants to render - include ALL types (both objects and primitives) + var variantsToRender = new List<(string Name, string BaseName, bool IsArray, bool IsObject, IOpenApiSchema? Schema, IDictionary? Props)>(); + + // Group by base name + var typeGroups = sortedOptions + .GroupBy(o => o.Name.EndsWith("[]") ? o.Name[..^2] : o.Name) + .ToDictionary(g => g.Key, g => g.ToList()); + + foreach (var (baseName, variants) in typeGroups) + { + // Find the non-array variant to get schema info (or use array if no non-array) + var primaryOption = variants.FirstOrDefault(o => !o.Name.EndsWith("[]")); + if (primaryOption?.Schema is null) + primaryOption = variants.First(); + + IOpenApiSchema? schemaToRender = primaryOption?.Schema; + IDictionary? optionProps = null; + + // Try to get properties for object types + if (primaryOption?.IsObject == true && schemaToRender != null) + { + optionProps = Model.Analyzer.GetSchemaProperties(schemaToRender); + + if (optionProps is null || optionProps.Count == 0) + { + var refId = primaryOption.Ref; + if (string.IsNullOrEmpty(refId) && schemaToRender is OpenApiSchemaReference schemaRef) + refId = schemaRef.Reference?.Id; + + // Note: We don't have access to Document here, so we can't resolve by refId + // The properties should already be resolved by SchemaAnalyzer + } + } + + // Check if this type has both array and non-array variants + var hasArrayVariant = variants.Any(v => v.Name.EndsWith("[]")); + var hasNonArrayVariant = variants.Any(v => !v.Name.EndsWith("[]")); + + if (hasArrayVariant && hasNonArrayVariant) + { + // Render both: array first (no properties), then non-array (with properties) + variantsToRender.Add(($"{baseName}[]", baseName, true, primaryOption?.IsObject ?? false, schemaToRender, optionProps)); + variantsToRender.Add((baseName, baseName, false, primaryOption?.IsObject ?? false, schemaToRender, optionProps)); + } + else if (hasArrayVariant) + { + variantsToRender.Add(($"{baseName}[]", baseName, true, primaryOption?.IsObject ?? false, schemaToRender, optionProps)); + } + else + { + variantsToRender.Add((baseName, baseName, false, primaryOption?.IsObject ?? false, schemaToRender, optionProps)); + } + } + + // If no variants to render, nothing to show + if (variantsToRender.Count == 0) + { + return; + } + + // Check if we should collapse the union options (more than 2 variants) + var shouldCollapseUnion = variantsToRender.Count > 2; + var unionContainerId = $"{Model.Prefix}-union-options"; +} +
+ @if (shouldCollapseUnion) + { +
+ +
+ } +
+ @for (var i = 0; i < variantsToRender.Count; i++) + { + var variant = variantsToRender[i]; + var variantName = variant.Name; + var baseName = variant.BaseName; + var isArrayVariant = variant.IsArray; + var isObjectType = variant.IsObject; + var schemaToRender = variant.Schema; + var optionProps = variant.Props; + var hasProperties = optionProps != null && optionProps.Count > 0; + + var optionId = $"{Model.Prefix}-variant-{variantName.ToLowerInvariant().Replace(" ", "-").Replace("[]", "-array")}"; + + // Only show properties for non-array variant when both exist (to avoid duplication) + var hasBothVariants = variantsToRender.Count(v => v.BaseName == baseName) > 1; + var showProperties = hasProperties && (!isArrayVariant || !hasBothVariants); + + // Build ancestor set including current variant type + var newAncestors = Model.AncestorTypes != null ? new HashSet(Model.AncestorTypes) : new HashSet(); + if (!string.IsNullOrEmpty(baseName)) + newAncestors.Add(baseName); + + // Add separator before each variant except the first + @if (i > 0) + { +
or
+ } + + // Render as type label (no tree connectors) + // Object types with properties are collapsible + var nestedPropCount = optionProps?.Count ?? 0; + var isCollapsible = showProperties && nestedPropCount > 1; + var defaultExpanded = Model.CollapseMode == CollapseMode.DepthBased + ? (Model.Depth == 0 ? false : (nestedPropCount > 0 && nestedPropCount < 5)) + : false; + +
+
+ @if (isArrayVariant) + { + [] + } + @if (isObjectType) + { + {} + } + @{ + // Remove [] suffix from name when we already show [] icon prefix + var displayVariantName = isArrayVariant && variantName.EndsWith("[]") ? variantName[..^2] : variantName; + } + @displayVariantName +
+ @if (showProperties && schemaToRender != null) + { + var childContext = new PropertyRenderContext + { + Schema = schemaToRender, + RequiredProperties = null, + Prefix = optionId, + Depth = Model.Depth + 1, + AncestorTypes = newAncestors, + IsRequest = Model.IsRequest, + Analyzer = Model.Analyzer, + RenderMarkdown = Model.RenderMarkdown, + MaxDepth = Model.MaxDepth, + ShowDeprecated = true, + ShowVersionInfo = true, + ShowExternalDocs = true, + UseHiddenUntilFound = Model.UseHiddenUntilFound, + CollapseMode = Model.CollapseMode + }; + var useHidden = Model.UseHiddenUntilFound && isCollapsible && !defaultExpanded; + + if (isCollapsible) + { +
+ +
+ } + if (useHidden) + { + + } + else + { +
+ @(await RenderPartialAsync<_PropertyList, PropertyRenderContext>(childContext)) +
+ } + } +
+ } +
+
diff --git a/src/Elastic.ApiExplorer/_Layout.cshtml b/src/Elastic.ApiExplorer/_Layout.cshtml index 11aa06596..917566144 100644 --- a/src/Elastic.ApiExplorer/_Layout.cshtml +++ b/src/Elastic.ApiExplorer/_Layout.cshtml @@ -1,26 +1,28 @@ -@inherits RazorLayoutSlice +@inherits RazorLayoutSlice @implements IUsesLayout @functions { public GlobalLayoutViewModel LayoutModel => Model; } -
- @await RenderPartialAsync(_PagesNav.Create(Model)) -
-
-
-
-
+ @await RenderPartialAsync(_PagesNav.Create(Model)) +
+
+
+
+
+
+
+
Loading
-
Loading
-
-
- - @await RenderBodyAsync() -
-
+
+ + @await RenderBodyAsync() +
+ +
+ @await RenderPartialAsync(_ApiToc.Create(Model.TocItems.ToArray()))
diff --git a/src/Elastic.ApiExplorer/_ViewImports.cshtml b/src/Elastic.ApiExplorer/_ViewImports.cshtml index 549dc572d..2645147bc 100644 --- a/src/Elastic.ApiExplorer/_ViewImports.cshtml +++ b/src/Elastic.ApiExplorer/_ViewImports.cshtml @@ -4,11 +4,15 @@ @using Microsoft.AspNetCore.Html @using Microsoft.AspNetCore.Razor @using Microsoft.AspNetCore.Http.HttpResults +@using Microsoft.OpenApi @using RazorSlices @using Elastic.Documentation.Site @using Elastic.Documentation.Site.Layout @using Elastic.ApiExplorer +@using Elastic.ApiExplorer.Layout @using Elastic.ApiExplorer.Endpoints +@using Elastic.ApiExplorer.Schema +@using Elastic.ApiExplorer.Shared @tagHelperPrefix __disable_tagHelpers__: @removeTagHelper *, Microsoft.AspNetCore.Mvc.Razor diff --git a/src/Elastic.Documentation.Site/Assets/api-docs.css b/src/Elastic.Documentation.Site/Assets/api-docs.css index 5e0bf16e7..983e09afe 100644 --- a/src/Elastic.Documentation.Site/Assets/api-docs.css +++ b/src/Elastic.Documentation.Site/Assets/api-docs.css @@ -159,7 +159,8 @@ margin-left: calc(var(--spacing) * 2); display: inline-block; } -#elastic-api-v3 { +#elastic-api-v3, +#schema-definition { dt a { @apply no-underline; code { @@ -170,9 +171,981 @@ @apply pt-4; } h3 { - @apply border-b-grey-20 border-b-1 pb-2; + @apply border-b-grey-20 mt-6 border-b-1 pb-2; } h4 { @apply border-b-grey-20 border-b-1 pb-2; } } + +/* Property list with tree-like nested indentation */ +.property-list { + @apply mt-2; + position: relative; + + dt { + @apply flex items-baseline gap-1; + padding-top: calc(var(--spacing) * 3); + padding-bottom: 0; + margin: 0; + line-height: 1.4; + + a { + @apply flex items-baseline gap-2 no-underline; + } + + code:first-of-type { + @apply text-ink font-bold; + } + } + + dd { + @apply text-grey-80; + padding: calc(var(--spacing) * 2) 0 0 0; + padding-left: calc(var(--spacing) * 2); + margin: 0; + line-height: 1.4; + font-size: 0.9em; + + p { + margin: 0; + } + + /* Hide empty dd elements */ + &:empty { + display: none; + } + } + + /* Property item wrapper - handles tree connectors */ + .property-item { + position: relative; + padding-left: 0; + padding-top: 0; + margin-top: 0; + + &.depth-0 { + margin-top: 0.125rem; + } + + &.depth-1 { + padding-left: 1.25rem; + } + &.depth-2 { + padding-left: 1.25rem; + } + &.depth-3 { + padding-left: 1.25rem; + } + &.depth-4 { + padding-left: 1.25rem; + } + + /* Vertical line for siblings below - extends through to next sibling */ + &.depth-1:not(.last-sibling), + &.depth-2:not(.last-sibling), + &.depth-3:not(.last-sibling), + &.depth-4:not(.last-sibling) { + &::after { + content: ''; + position: absolute; + left: 0.375rem; + top: calc(var(--spacing) * 2 + 0.4rem); + bottom: calc(-1 * var(--spacing) * 3 - 0.125rem); + border-left: 1px solid #cbd5e1; + } + } + + /* L-shaped branch connector - horizontal aligns to middle of text */ + &.depth-1, + &.depth-2, + &.depth-3, + &.depth-4 { + &::before { + content: ''; + position: absolute; + left: 0.375rem; + height: calc(var(--spacing) * 5 + 0.525rem); + width: 0.5rem; + border-left: 1px solid #cbd5e1; + border-bottom: 1px solid #cbd5e1; + border-bottom-left-radius: 3px; + } + } + } + + /* Nested properties container */ + .nested-properties { + position: relative; + margin-left: 0; + overflow: visible; + + /* Reset nested dl margins */ + .property-list { + margin-top: 0; + overflow: visible; + position: relative; + } + } + + /* Expand/collapse toggle row - aligned with nested properties */ + .expand-toggle-row { + padding: calc(var(--spacing) * 1) 0; + padding-left: 1.25rem; /* Same as depth-1 indentation */ + margin: 0; + margin-top: calc(var(--spacing) * 2); + position: relative; + display: flex; + align-items: center; + + /* Vertical line - always visible when has children, extends to button center */ + &::before { + content: ''; + position: absolute; + left: 0.375rem; + top: 0; + height: 50%; + width: 0; + border-left: 1px solid #cbd5e1; + opacity: 0; + transition: opacity 0.1s ease; + } + + /* Horizontal connector - only when collapsed */ + &::after { + content: ''; + position: absolute; + left: 0.375rem; + top: 50%; + width: 0.5rem; + border-top: 1px solid #cbd5e1; + opacity: 0; + transition: opacity 0.1s ease; + } + } + + /* Expanded state - show vertical line extending through the row */ + .property-item.expanded > .expand-toggle-row::before { + opacity: 1; + height: 100%; /* Extend to bottom of toggle row */ + } + + /* Continuation line from expand toggle down to nested properties */ + .property-item.expanded > .nested-properties::before { + content: ''; + position: absolute; + left: 0.375rem; + top: 0; + height: calc( + var(--spacing) * 5 + 0.4rem + ); /* Connect to first child's L-connector */ + border-left: 1px solid #cbd5e1; + } + + /* Collapsed state - show both vertical and horizontal connector */ + .property-item.collapsed > .expand-toggle-row::before, + .property-item.collapsed > .expand-toggle-row::after { + opacity: 1; + } + + /* Expand/collapse toggle button - default grey (expanded state) */ + .expand-toggle { + @apply text-grey-60 bg-grey-10 border-grey-20 cursor-pointer rounded border; + padding: 0.25rem 0.5rem; + font-size: 0.8rem; + line-height: 1.2; + display: inline-flex; + align-items: center; + gap: 0.35rem; + transition: all 0.1s ease; + } + + /* Collapsed state - green button */ + .property-item.collapsed > .expand-toggle-row > .expand-toggle { + @apply text-green-90 bg-green-10 border-green-30; + } + + .expand-toggle:hover { + @apply text-blue-elastic bg-blue-elastic-10 border-blue-elastic-30; + } + + .toggle-icon { + font-weight: 700; + font-size: 0.85rem; + } + + .toggle-label { + font-weight: 500; + } + + /* Collapsed state - hide nested properties using hidden="until-found" for searchability */ + /* The hidden attribute handles visibility; this is a fallback for unsupported browsers */ + .property-item.collapsed > .nested-properties:not([hidden='until-found']) { + display: none; + } + + /* Expanded state - show nested properties */ + .property-item.expanded > .nested-properties { + display: block; + } + + /* Ensure hidden="until-found" elements don't show borders/padding from container */ + .nested-properties[hidden='until-found'] { + content-visibility: hidden; + } +} + +/* Schema type styling - visually distinct from property names */ +.schema-type { + @apply text-blue-elastic border-0 bg-transparent p-0 text-xs font-normal; +} + +/* Required badge */ +.required { + @apply text-red-70 bg-red-10 rounded px-1 py-0.5 text-xs font-medium; +} + +/* Optional badge - more subtle than required */ +.optional { + @apply text-grey-60 bg-grey-10 rounded px-1 py-0.5 text-xs font-normal; +} + +/* Recursive badge - amber/yellow with icon */ +.recursive { + @apply text-yellow-90 bg-yellow-10 border-yellow-30 rounded border px-1.5 py-0.5 text-xs font-medium; +} +.recursive::before { + content: '↻ '; +} + +/* Deprecated badge - red warning style */ +.deprecated-badge { + @apply text-red-70 bg-red-10 rounded px-1 py-0.5 text-xs font-medium; +} + +/* Deprecated property/parameter styling - strikethrough */ +.property-item.deprecated > dt > a > code:first-child, +.query-param.deprecated > dt > a > code:first-child, +.path-param.deprecated > dt > a > code:first-child { + @apply line-through opacity-70; +} + +/* Deprecated path styling in api-url-listing */ +.api-url-listing .api-url-list-item.deprecated .api-url { + @apply line-through opacity-70; +} + +.api-url-listing .api-url-list-item.deprecated .deprecated-badge { + @apply ml-2; +} + +/* Version badge - subtle grey */ +.version-badge { + @apply text-grey-60 bg-grey-10 rounded px-1 py-0.5 text-xs font-normal; +} + +/* Beta badge - orange/amber warning */ +.beta-badge { + @apply text-yellow-90 bg-yellow-10 border-yellow-30 rounded border px-1 py-0.5 text-xs font-medium; +} + +/* Reference documentation button */ +.docs-reference-btn { + @apply inline-flex items-center gap-2 px-4 py-2 text-sm font-medium; + @apply bg-blue-elastic-10 text-blue-elastic border-blue-elastic-30 rounded-md border; + @apply hover:bg-blue-elastic-20 hover:border-blue-elastic-40 transition-colors; + margin-top: 0.75rem; +} +.docs-reference-btn::before { + content: '📖'; + font-size: 1em; +} +/* External links get an arrow indicator */ +.docs-reference-btn[target='_blank']::after { + content: '↗'; + font-size: 0.85em; + margin-left: 0.25rem; +} + +/* Smaller variant for property rows */ +.docs-reference-btn-sm { + @apply px-3 py-1.5 text-xs; + margin-top: 0.5rem; +} + +/* Container for external docs in property items */ +.external-docs-row { + margin-top: 0.25rem; +} + +/* Legacy external documentation link (keeping for backwards compat) */ +.external-docs-link { + @apply text-blue-elastic inline-flex items-center gap-1 text-sm hover:underline; +} +.external-docs-link::before { + content: '📖'; + font-size: 0.9em; +} + +/* Inline external docs link (for properties) - legacy */ +.external-docs-inline { + @apply text-blue-elastic text-xs hover:underline; + margin-left: 0.25rem; +} +.external-docs-inline::before { + content: '↗'; +} + +/* Discriminator note styling */ +.discriminator-note { + @apply text-grey-60 text-xs italic; +} + +/* Object type icon */ +.object-icon { + @apply text-grey-60 font-normal; + font-size: 1.2em; + font-variant: small-caps; +} + +/* Array type icon */ +.array-icon { + @apply text-green-70 font-normal; + font-size: 1.2em; + font-variant: small-caps; +} + +/* Dictionary/map type icon - same color as object icon */ +.dict-icon { + @apply text-grey-60 font-normal; + font-size: 1.2em; + font-variant: small-caps; +} + +/* Enum type icon - purple to match enum values */ +.enum-icon { + @apply text-purple-70 font-normal; + font-size: 1.2em; + font-variant: small-caps; +} + +/* Union type icon - orange/yellow for distinction */ +.union-icon { + @apply text-yellow-90 font-normal; + font-size: 1.2em; + font-variant: small-caps; +} + +/* Enum values and union options display */ +.enum-values, +.union-options { + @apply mt-1 flex flex-wrap items-center gap-1; + padding-left: calc(var(--spacing) * 2); +} + +.values-label { + @apply text-grey-60 mr-1 text-xs; +} + +.enum-value { + @apply text-purple-70 bg-purple-10 rounded px-1.5 py-0.5 text-xs; +} + +.union-option { + @apply text-yellow-90 bg-yellow-10 rounded px-1.5 py-0.5 text-xs; +} + +/* Validation constraints display */ +.validation-constraints { + @apply mt-1 flex flex-wrap items-center gap-1; + padding-left: calc(var(--spacing) * 2); +} + +.constraints-label { + @apply text-grey-60 mr-1 text-xs; +} + +.constraint { + @apply text-grey-70 bg-grey-10 rounded px-1.5 py-0.5 text-xs; + + code { + @apply text-grey-80 bg-grey-20 rounded px-1 py-0 font-mono text-xs; + } +} + +/* Security requirements display */ +.security-requirements { + @apply mt-4 mb-4 flex flex-wrap items-center gap-2 rounded p-3; + @apply bg-blue-elastic-10 border-blue-elastic-20 border; +} + +.security-label { + @apply text-blue-elastic text-sm font-medium; +} + +.security-scheme { + @apply flex items-center gap-1; + + code { + @apply text-blue-elastic rounded bg-white px-1.5 py-0.5 font-mono text-xs; + } +} + +.security-scopes { + @apply text-grey-60 text-xs; +} + +/* Response headers display */ +.response-headers { + @apply border-grey-20 mt-4 border-t pt-4; + + h5 { + @apply text-grey-80 mb-2 text-sm font-medium; + } +} + +.header-item { + @apply mb-2; +} + +/* Server info display */ +.server-info { + @apply mt-2 mb-4 flex flex-wrap items-center gap-2 text-sm; +} + +.server-label { + @apply text-grey-60 font-medium; +} + +.server-url { + @apply flex items-center gap-1; + + code { + @apply text-grey-80 bg-grey-10 rounded px-1.5 py-0.5 font-mono text-xs; + } +} + +.server-description { + @apply text-grey-50 text-xs; +} + +/* Union type option - styled like regular types, not enum values */ +.union-type-option { + @apply text-blue-elastic bg-blue-elastic-10 rounded px-1.5 py-0.5 text-xs; +} + +/* Map keyword styling */ +.map-keyword { + @apply text-grey-50 font-normal; + font-size: 0.85em; +} + +/* Value type keyword styling - for discovered primitive aliases */ +.value-type-keyword { + @apply text-blue-elastic border-0 bg-transparent p-0 text-xs font-normal; +} + +/* Inline union display for simple X | X[] unions */ +.union-inline-row { + @apply mt-1 flex flex-wrap items-center gap-1; + padding-left: calc(var(--spacing) * 2); +} + +.union-inline-options { + @apply flex items-center gap-2; +} + +.union-inline-option { + @apply flex items-center gap-1; +} + +.union-inline-separator { + @apply text-grey-50 text-xs; +} + +/* Primitive array type display */ +.primitive-array { + @apply mt-1 flex flex-wrap items-center gap-1; + padding-left: calc(var(--spacing) * 2); +} + +.primitive-type { + @apply text-blue-elastic bg-blue-elastic-10 rounded px-1.5 py-0.5 text-xs; +} + +/* Union variants container for expandable union type options */ +.union-variants-container { + @apply mt-1; + padding-left: calc(var(--spacing) * 2); + position: relative; +} + +/* Non-collapsible union variants (just the content wrapper) */ +.union-variants-container:not(.collapsible) > .union-variants { + /* No additional styling needed */ +} + +/* Collapsible union variants container */ +.union-variants-container.collapsible { + /* Container for tree structure */ +} + +/* Union collapse toggle row - with tree connectors */ +.union-collapse-toggle { + padding: calc(var(--spacing) * 1) 0; + padding-left: 1.25rem; + margin: 0; + position: relative; + display: flex; + align-items: center; + + /* Vertical line - extends to button center */ + &::before { + content: ''; + position: absolute; + left: 0.375rem; + top: 0; + height: 50%; + width: 0; + border-left: 1px solid #cbd5e1; + opacity: 0; + transition: opacity 0.1s ease; + } + + /* Horizontal connector - only when collapsed */ + &::after { + content: ''; + position: absolute; + left: 0.375rem; + top: 50%; + width: 0.5rem; + border-top: 1px solid #cbd5e1; + opacity: 0; + transition: opacity 0.1s ease; + } + + /* Button styling */ + .union-group-toggle { + @apply text-grey-60 bg-grey-10 border-grey-20 cursor-pointer rounded border; + padding: 0.25rem 0.5rem; + font-size: 0.8rem; + line-height: 1.2; + display: inline-flex; + align-items: center; + gap: 0.35rem; + transition: all 0.1s ease; + } + + .union-group-toggle:hover { + @apply text-blue-elastic bg-blue-elastic-10 border-blue-elastic-30; + } +} + +/* Expanded state - show vertical line extending through the toggle row */ +.union-variants-container.expanded > .union-collapse-toggle::before { + opacity: 1; + height: 100%; /* Extend to bottom of toggle row */ +} + +/* Collapsed state - show both vertical and horizontal connector, green button */ +.union-variants-container.collapsed > .union-collapse-toggle::before, +.union-variants-container.collapsed > .union-collapse-toggle::after { + opacity: 1; +} + +.union-variants-container.collapsed + > .union-collapse-toggle + .union-group-toggle { + @apply text-green-90 bg-green-10 border-green-30; +} + +/* Union variants content - the actual list of type options */ +.union-variants-content { + position: relative; +} + +/* Hidden state for collapsed union content */ +.union-variants-container.collapsed + > .union-variants-content:not([hidden='until-found']) { + display: none; +} + +/* Ensure hidden="until-found" elements work correctly */ +.union-variants-content[hidden='until-found'] { + content-visibility: hidden; +} + +.union-variants { + /* Variants list styling */ +} + +/* Union variant item - type label without tree connectors */ +.union-variant-item { + padding: calc(var(--spacing) * 1) 0; + + /* Collapsed state - hide nested properties using hidden="until-found" for searchability */ + /* The hidden attribute handles visibility; this is a fallback for unsupported browsers */ + &.collapsed > .nested-properties:not([hidden='until-found']) { + display: none; + } + + /* Expanded state - show nested properties */ + &.expanded > .nested-properties { + display: block; + } + + /* Nested properties */ + .nested-properties { + margin-top: 0; + } +} + +.union-variant-label { + @apply flex items-center gap-1; +} + +/* Union expand toggle row - on new line with tree connector when collapsed */ +.union-expand-toggle { + padding: calc(var(--spacing) * 1) 0; + padding-left: 1.25rem; + margin: 0; + position: relative; + display: flex; + align-items: center; + + /* Vertical line - extends to button center */ + &::before { + content: ''; + position: absolute; + left: 0.375rem; + top: 0; + height: 50%; + width: 0; + border-left: 1px solid #cbd5e1; + opacity: 0; + transition: opacity 0.1s ease; + } + + /* Horizontal connector - only when collapsed */ + &::after { + content: ''; + position: absolute; + left: 0.375rem; + top: 50%; + width: 0.5rem; + border-top: 1px solid #cbd5e1; + opacity: 0; + transition: opacity 0.1s ease; + } + + /* Default button style (expanded state - grey) */ + .expand-toggle { + @apply text-grey-60 bg-grey-10 border-grey-20 cursor-pointer rounded border; + padding: 0.25rem 0.5rem; + font-size: 0.8rem; + line-height: 1.2; + display: inline-flex; + align-items: center; + gap: 0.35rem; + transition: all 0.1s ease; + } + + .expand-toggle:hover { + @apply text-blue-elastic bg-blue-elastic-10 border-blue-elastic-30; + } +} + +/* Union expanded state - show vertical line extending through the toggle row */ +.union-variant-item.expanded > .union-expand-toggle::before { + opacity: 1; + height: 100%; /* Extend to bottom of toggle row */ +} + +/* Continuation line from union expand toggle down to nested properties */ +.union-variant-item.expanded > .nested-properties { + position: relative; +} + +.union-variant-item.expanded > .nested-properties::before { + content: ''; + position: absolute; + left: 0.375rem; + top: 0; + height: calc( + var(--spacing) * 5 + 0.4rem + ); /* Connect to first child's L-connector */ + border-left: 1px solid #cbd5e1; +} + +/* Union collapsed state - show both vertical and horizontal connector, green button */ +.union-variant-item.collapsed > .union-expand-toggle::before, +.union-variant-item.collapsed > .union-expand-toggle::after { + opacity: 1; +} + +.union-variant-item.collapsed > .union-expand-toggle .expand-toggle { + @apply text-green-90 bg-green-10 border-green-30; +} + +/* Union separator between type options */ +.union-separator { + @apply text-grey-40 text-xs; + padding: calc(var(--spacing) * 0.5) 0; + display: flex; + align-items: center; + gap: 0.5rem; +} + +.union-separator::before, +.union-separator::after { + content: ''; + flex: 1; + max-width: 2rem; + border-top: 1px dashed #cbd5e1; +} + +.variant-description { + @apply text-grey-60 mt-1 text-sm; +} + +/* Schema ID display */ +.schema-id { + @apply text-grey-60 mb-4 text-sm; + + code { + @apply bg-grey-10 rounded px-2 py-1; + } +} + +/* Schema type info for container types */ +.schema-type-info { + @apply mb-4 flex items-center gap-2; +} + +/* Type link inside schema-type */ +.schema-type .type-link { + @apply text-blue-elastic no-underline; +} +.schema-type .type-link:hover { + @apply underline; +} + +/* Inline code in property descriptions - more distinct */ +.property-list dd code:not(.enum-value):not(.schema-type) { + @apply text-yellow-90 bg-yellow-10 rounded px-1 py-0.5 text-xs; + font-weight: 500; +} + +/* Type definition link */ +.type-definition-link { + @apply text-blue-elastic mt-2 block text-sm italic; +} + +/* Enum/value list styling - improve code: description format */ +.property-list dd ul { + @apply mt-2 pl-0; + list-style: none; +} + +.property-list dd ul li { + @apply relative mb-2 pl-4; +} + +.property-list dd ul li::before { + content: '•'; + @apply text-grey-40 absolute left-0; +} + +/* Style the value code in list items - more prominent */ +.property-list dd ul li > code:first-child { + @apply text-purple-70 bg-purple-10 font-semibold; +} + +/* Type link in description - styled as button */ +.type-link-description { + @apply mt-1; + padding-left: calc(var(--spacing) * 2); + + a { + @apply text-blue-elastic text-sm no-underline; + @apply bg-blue-elastic-10 rounded px-2 py-1; + @apply inline-flex items-center gap-1; + @apply border-blue-elastic-30 border; + transition: + background-color 0.15s ease, + border-color 0.15s ease; + } + a:hover { + @apply bg-blue-elastic-20 border-blue-elastic-40; + } +} + +/* Sticky section headers - require proper scroll context */ +/* Override parent container overflow for API pages to enable sticky headers */ +#content-container:has(#elastic-api-v3), +#content-container:has(#schema-definition) { + overflow: visible !important; +} + +#elastic-api-v3, +#schema-definition { + overflow: visible; +} + +#elastic-api-v3 h3, +#schema-definition h3 { + @apply bg-white; + position: sticky; + top: 72px; /* Offset for fixed header */ + z-index: 10; + padding-top: 1rem; + padding-bottom: 0.5rem; + margin-top: 0; +} + +/* Section header with navigation */ +.section-header { + display: flex; + justify-content: space-between; + align-items: center; +} + +.section-nav { + display: flex; + align-items: center; + gap: 0.25rem; +} + +.section-nav-btn { + @apply text-grey-50 bg-grey-10 border-grey-20 rounded border; + width: 28px; + height: 28px; + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + font-size: 0.7rem; + transition: all 0.15s ease; +} + +.section-nav-btn:hover { + @apply text-grey-80 bg-grey-20 border-grey-30; +} + +.section-nav-btn:active { + @apply bg-grey-30; +} + +/* Section path indicator - right aligned before nav buttons, text style */ +.section-path { + @apply text-grey-50 mr-2 text-xs; + display: inline-flex; + align-items: center; + height: 28px; /* Match button height */ + vertical-align: middle; + opacity: 0; + transition: opacity 0.2s ease; +} + +/* Show section-path when scrolled (controlled via JS) */ +.section-header.scrolled .section-path { + opacity: 1; +} + +/* Content-type badge for request/response headers */ +.content-type-badge { + @apply text-grey-60 bg-grey-10 ml-2 rounded px-2 py-0.5 text-xs; + font-weight: 500; +} + +/* API description - smaller font like property descriptions */ +#elastic-api-v3 > p, +#elastic-api-v3 h3[data-section='description'] + p { + @apply text-grey-80; + font-size: 0.9em; + line-height: 1.5; +} + +/* Example blocks */ +.example-block { + @apply mb-6; + position: relative; +} + +/* Example description - smaller font like property descriptions */ +.example-description { + @apply text-grey-80; + font-size: 0.9em; + line-height: 1.5; + margin-bottom: 0.75rem; + + p { + margin: 0; + } + + code { + @apply text-yellow-90 bg-yellow-10 rounded px-1 py-0.5 text-xs; + font-weight: 500; + } +} + +/* Example h4 titles - sticky within example sections */ +.example-block h4 { + @apply border-b-grey-20 border-b-1 bg-white; + position: sticky; + top: 120px; /* Below h3 sticky header */ + z-index: 5; + padding-top: 0.5rem; + padding-bottom: 0.5rem; + margin-top: 0; + margin-bottom: 0.5rem; +} + +/* Sticky Examples button - bottom right of content area */ +.examples-jump-btn { + @apply bg-blue-elastic rounded-full shadow-lg; + color: white !important; + position: fixed; + bottom: 2rem; + /* Align with right edge of content area (account for TOC sidebar) */ + right: calc(var(--max-sidebar-width) + 2rem); + padding: 0.75rem 1.25rem; + font-size: 0.875rem; + font-weight: 500; + cursor: pointer; + z-index: 50; + display: flex; + align-items: center; + gap: 0.5rem; + transition: all 0.2s ease; + opacity: 0; + transform: translateY(1rem); + pointer-events: none; + text-decoration: none; +} + +.examples-jump-btn.visible { + opacity: 1; + transform: translateY(0); + pointer-events: auto; +} + +.examples-jump-btn:hover { + @apply bg-blue-elastic-80; + color: white !important; + transform: translateY(-2px); + box-shadow: 0 6px 20px rgba(0, 0, 0, 0.15); + text-decoration: none; +} + +.examples-jump-btn .icon { + font-size: 1rem; + color: white !important; +} + +/* Hide button on smaller screens where TOC is hidden */ +@media (max-width: 1023px) { + .examples-jump-btn { + right: 2rem; + } +} diff --git a/src/Elastic.Documentation.Site/Assets/api-docs.ts b/src/Elastic.Documentation.Site/Assets/api-docs.ts new file mode 100644 index 000000000..f594ca0e0 --- /dev/null +++ b/src/Elastic.Documentation.Site/Assets/api-docs.ts @@ -0,0 +1,549 @@ +/** + * API Documentation interactive features + * Handles expand/collapse toggles, scroll state, and find-in-page support + * for both OperationView and SchemaView pages. + */ + +// Check if hidden="until-found" is supported (for find-in-page in collapsed sections) +const supportsHiddenUntilFound = 'onbeforematch' in document.body + +/** + * Expand a property item and all its ancestors + */ +function expandPropertyItem(propertyItem: HTMLElement): void { + if (!propertyItem) return + + const toggleBtn = propertyItem.querySelector( + ':scope > .expand-toggle-row > .expand-toggle' + ) + const nestedProps = propertyItem.querySelector( + ':scope > .nested-properties' + ) + + propertyItem.classList.remove('collapsed') + propertyItem.classList.add('expanded') + + if (toggleBtn) { + toggleBtn.setAttribute('aria-expanded', 'true') + const toggleIcon = toggleBtn.querySelector('.toggle-icon') + const toggleLabel = toggleBtn.querySelector('.toggle-label') + const propCount = toggleLabel?.textContent?.match(/\d+/)?.[0] || '' + if (toggleIcon) toggleIcon.textContent = '−' + if (toggleLabel) + toggleLabel.textContent = `Hide ${propCount} properties` + } + + if (nestedProps) { + nestedProps.removeAttribute('hidden') + } + + // Recursively expand parent property items + const parentItem = propertyItem.parentElement?.closest( + '.property-item, .union-variant-item' + ) + if (parentItem) { + if (parentItem.classList.contains('union-variant-item')) { + expandUnionVariantItem(parentItem) + } else { + expandPropertyItem(parentItem) + } + } +} + +/** + * Expand a union variant item and all its ancestors + */ +function expandUnionVariantItem(variantItem: HTMLElement): void { + if (!variantItem) return + + const toggleBtn = variantItem.querySelector( + ':scope > .union-expand-toggle > .expand-toggle' + ) + const nestedProps = variantItem.querySelector( + ':scope > .nested-properties' + ) + + variantItem.classList.remove('collapsed') + variantItem.classList.add('expanded') + + if (toggleBtn) { + const toggleIcon = toggleBtn.querySelector('.toggle-icon') + const toggleLabel = toggleBtn.querySelector('.toggle-label') + const propCount = toggleLabel?.textContent?.match(/\d+/)?.[0] || '' + if (toggleIcon) toggleIcon.textContent = '−' + if (toggleLabel) + toggleLabel.textContent = `Hide ${propCount} properties` + } + + if (nestedProps) { + nestedProps.removeAttribute('hidden') + } + + // Recursively expand parent items + const parentItem = variantItem.parentElement?.closest( + '.property-item, .union-variant-item' + ) + if (parentItem) { + if (parentItem.classList.contains('union-variant-item')) { + expandUnionVariantItem(parentItem) + } else { + expandPropertyItem(parentItem) + } + } +} + +/** + * Expand a union variants container and all its ancestors + */ +function expandUnionContainer(container: HTMLElement): void { + if (!container) return + + const toggleBtn = container.querySelector( + ':scope > .union-collapse-toggle > .union-group-toggle' + ) + const variantsContent = container.querySelector( + ':scope > .union-variants-content' + ) + + container.classList.remove('collapsed') + container.classList.add('expanded') + + if (toggleBtn) { + const toggleIcon = toggleBtn.querySelector('.toggle-icon') + const toggleLabel = toggleBtn.querySelector('.toggle-label') + const optionCount = toggleLabel?.textContent?.match(/\d+/)?.[0] || '' + if (toggleIcon) toggleIcon.textContent = '−' + if (toggleLabel) + toggleLabel.textContent = `Hide ${optionCount} type options` + } + + if (variantsContent) { + variantsContent.removeAttribute('hidden') + } + + // Recursively expand parent items + const parentItem = container.parentElement?.closest( + '.property-item, .union-variant-item' + ) + if (parentItem) { + if (parentItem.classList.contains('union-variant-item')) { + expandUnionVariantItem(parentItem) + } else { + expandPropertyItem(parentItem) + } + } +} + +/** + * Initialize API docs for OperationView pages + */ +function initOperationView(section: HTMLElement): void { + // Add beforematch event listeners for hidden="until-found" elements + // When find-in-page matches content inside collapsed sections, expand them + if (supportsHiddenUntilFound) { + section + .querySelectorAll( + '.nested-properties[hidden="until-found"]' + ) + .forEach((nestedProps) => { + nestedProps.addEventListener('beforematch', function () { + const parentItem = nestedProps.parentElement + if (parentItem?.classList.contains('union-variant-item')) { + expandUnionVariantItem(parentItem) + } else if ( + parentItem?.classList.contains('property-item') + ) { + expandPropertyItem(parentItem) + } + }) + }) + + // Add beforematch event listeners for union variants content + section + .querySelectorAll( + '.union-variants-content[hidden="until-found"]' + ) + .forEach((variantsContent) => { + variantsContent.addEventListener('beforematch', function () { + const container = variantsContent.parentElement + if ( + container?.classList.contains( + 'union-variants-container' + ) + ) { + expandUnionContainer(container) + } + }) + }) + } + + // Scroll detection for section-path visibility + let triggerElement: Element | null = null + + // First check for Path Parameters header + const pathParamsHeader = section.querySelector('h4') + if ( + pathParamsHeader && + pathParamsHeader.textContent?.includes('Path Parameters') + ) { + triggerElement = pathParamsHeader + } else { + // Fall back to the paths section URL listing + const pathsHeader = section.querySelector('h3[data-section="paths"]') + if (pathsHeader) { + triggerElement = pathsHeader.nextElementSibling + } + } + + function updateScrollState(): void { + if (!triggerElement) return + + const triggerBottom = triggerElement.getBoundingClientRect().bottom + const isScrolled = triggerBottom < 100 // 100px from top of viewport + + section.querySelectorAll('h3.section-header').forEach((header) => { + if (isScrolled) { + header.classList.add('scrolled') + } else { + header.classList.remove('scrolled') + } + }) + } + + // Examples jump button visibility + const examplesBtn = document.getElementById('examples-jump-btn') + const examplesSection = section.querySelector( + 'h3[data-section="request-examples"], h3[data-section="response-examples"]' + ) + + function updateExamplesButtonVisibility(): void { + if (!examplesBtn || !examplesSection) return + + const examplesTop = examplesSection.getBoundingClientRect().top + const viewportHeight = window.innerHeight + + // Show button when examples are below the fold (not visible yet) + if (examplesTop > viewportHeight) { + examplesBtn.classList.add('visible') + } else { + examplesBtn.classList.remove('visible') + } + } + + // Throttled scroll handler + let ticking = false + window.addEventListener('scroll', function () { + if (!ticking) { + window.requestAnimationFrame(function () { + updateScrollState() + updateExamplesButtonVisibility() + ticking = false + }) + ticking = true + } + }) + + // Initial check + updateScrollState() + updateExamplesButtonVisibility() + + // Click handler for OperationView-specific elements + section.addEventListener('click', function (e) { + const target = e.target as HTMLElement + + // Handle union group toggle buttons (collapse/expand all union options) + const unionGroupToggle = target.closest( + '.union-group-toggle' + ) + if (unionGroupToggle) { + e.preventDefault() + e.stopPropagation() + + const container = unionGroupToggle.closest( + '.union-variants-container' + ) + if (!container) return + + const isExpanded = container.classList.contains('expanded') + const toggleIcon = unionGroupToggle.querySelector('.toggle-icon') + const toggleLabel = unionGroupToggle.querySelector('.toggle-label') + const variantsContent = container.querySelector( + ':scope > .union-variants-content' + ) + const optionCount = + toggleLabel?.textContent?.match(/\d+/)?.[0] || '' + + if (isExpanded) { + container.classList.remove('expanded') + container.classList.add('collapsed') + if (toggleIcon) toggleIcon.textContent = '+' + if (toggleLabel) + toggleLabel.textContent = `Show ${optionCount} type options` + if (variantsContent && supportsHiddenUntilFound) { + variantsContent.setAttribute('hidden', 'until-found') + } + } else { + container.classList.remove('collapsed') + container.classList.add('expanded') + if (toggleIcon) toggleIcon.textContent = '−' + if (toggleLabel) + toggleLabel.textContent = `Hide ${optionCount} type options` + if (variantsContent) { + variantsContent.removeAttribute('hidden') + } + } + return + } + + // Handle section navigation buttons + const navBtn = target.closest('.section-nav-btn') + if (navBtn) { + e.preventDefault() + e.stopPropagation() + + const direction = navBtn.getAttribute('data-dir') + const headers = Array.from( + section.querySelectorAll('h3.section-header') + ) + const currentHeader = navBtn.closest('h3') + const currentIndex = headers.indexOf(currentHeader as HTMLElement) + + if (currentIndex === -1) return + + let targetIndex: number + if (direction === 'up') { + targetIndex = + currentIndex > 0 ? currentIndex - 1 : headers.length - 1 + } else { + targetIndex = + currentIndex < headers.length - 1 ? currentIndex + 1 : 0 + } + + const targetHeader = headers[targetIndex] + if (targetHeader) { + const offset = 80 + const targetPosition = + targetHeader.getBoundingClientRect().top + + window.scrollY - + offset + window.scrollTo({ top: targetPosition, behavior: 'smooth' }) + } + return + } + + // Handle union variant expand/collapse + const toggleBtn = target.closest('.expand-toggle') + if (toggleBtn) { + const unionToggleRow = toggleBtn.closest('.union-expand-toggle') + if (unionToggleRow) { + e.preventDefault() + e.stopPropagation() + + const unionVariantItem = toggleBtn.closest( + '.union-variant-item' + ) + if (!unionVariantItem) return + + const isExpanded = + unionVariantItem.classList.contains('expanded') + const toggleIcon = toggleBtn.querySelector('.toggle-icon') + const toggleLabel = toggleBtn.querySelector('.toggle-label') + const nestedProps = unionVariantItem.querySelector( + ':scope > .nested-properties' + ) + const propCount = + toggleLabel?.textContent?.match(/\d+/)?.[0] || '' + + if (isExpanded) { + unionVariantItem.classList.remove('expanded') + unionVariantItem.classList.add('collapsed') + if (toggleIcon) toggleIcon.textContent = '+' + if (toggleLabel) + toggleLabel.textContent = `Show ${propCount} properties` + if (nestedProps && supportsHiddenUntilFound) { + nestedProps.setAttribute('hidden', 'until-found') + } + } else { + unionVariantItem.classList.remove('collapsed') + unionVariantItem.classList.add('expanded') + if (toggleIcon) toggleIcon.textContent = '−' + if (toggleLabel) + toggleLabel.textContent = `Hide ${propCount} properties` + if (nestedProps) { + nestedProps.removeAttribute('hidden') + } + } + } + } + }) +} + +// Track if global handlers have been initialized +let globalHandlersInitialized = false + +/** + * Initialize global click handlers for expand/collapse functionality + * Uses event delegation at document level so it works after HTMX content swaps + */ +function initGlobalClickHandlers(): void { + if (globalHandlersInitialized) return + globalHandlersInitialized = true + + document.addEventListener('click', function (e) { + const target = e.target as HTMLElement + + // Only handle clicks within API doc sections + const apiSection = target.closest( + '#elastic-api-v3, #schema-definition' + ) as HTMLElement + if (!apiSection) return + + // Handle union group toggle buttons (collapse/expand all union options) + const unionGroupToggle = target.closest( + '.union-group-toggle' + ) + if (unionGroupToggle) { + e.preventDefault() + e.stopPropagation() + + const container = unionGroupToggle.closest( + '.union-variants-container' + ) + if (!container) return + + const isExpanded = container.classList.contains('expanded') + const toggleIcon = unionGroupToggle.querySelector('.toggle-icon') + const toggleLabel = unionGroupToggle.querySelector('.toggle-label') + const variantsContent = container.querySelector( + ':scope > .union-variants-content' + ) + const optionCount = + toggleLabel?.textContent?.match(/\d+/)?.[0] || '' + + if (isExpanded) { + container.classList.remove('expanded') + container.classList.add('collapsed') + if (toggleIcon) toggleIcon.textContent = '+' + if (toggleLabel) + toggleLabel.textContent = `Show ${optionCount} type options` + if (variantsContent && supportsHiddenUntilFound) { + variantsContent.setAttribute('hidden', 'until-found') + } + } else { + container.classList.remove('collapsed') + container.classList.add('expanded') + if (toggleIcon) toggleIcon.textContent = '−' + if (toggleLabel) + toggleLabel.textContent = `Hide ${optionCount} type options` + if (variantsContent) { + variantsContent.removeAttribute('hidden') + } + } + return + } + + // Handle union variant expand/collapse + const toggleBtn = target.closest('.expand-toggle') + if (toggleBtn) { + const unionToggleRow = toggleBtn.closest('.union-expand-toggle') + if (unionToggleRow) { + e.preventDefault() + e.stopPropagation() + + const unionVariantItem = toggleBtn.closest( + '.union-variant-item' + ) + if (!unionVariantItem) return + + const isExpanded = + unionVariantItem.classList.contains('expanded') + const toggleIcon = toggleBtn.querySelector('.toggle-icon') + const toggleLabel = toggleBtn.querySelector('.toggle-label') + const nestedProps = unionVariantItem.querySelector( + ':scope > .nested-properties' + ) + const propCount = + toggleLabel?.textContent?.match(/\d+/)?.[0] || '' + + if (isExpanded) { + unionVariantItem.classList.remove('expanded') + unionVariantItem.classList.add('collapsed') + if (toggleIcon) toggleIcon.textContent = '+' + if (toggleLabel) + toggleLabel.textContent = `Show ${propCount} properties` + if (nestedProps && supportsHiddenUntilFound) { + nestedProps.setAttribute('hidden', 'until-found') + } + } else { + unionVariantItem.classList.remove('collapsed') + unionVariantItem.classList.add('expanded') + if (toggleIcon) toggleIcon.textContent = '−' + if (toggleLabel) + toggleLabel.textContent = `Hide ${propCount} properties` + if (nestedProps) { + nestedProps.removeAttribute('hidden') + } + } + return + } + + // Handle property item expand/collapse toggle buttons + // Skip if this is a union toggle (already handled above) + if (toggleBtn.closest('.union-group-toggle')) return + + e.preventDefault() + e.stopPropagation() + + const propertyItem = + toggleBtn.closest('.property-item') + if (!propertyItem) return + + const isExpanded = propertyItem.classList.contains('expanded') + const toggleIcon = toggleBtn.querySelector('.toggle-icon') + const toggleLabel = toggleBtn.querySelector('.toggle-label') + const nestedProps = propertyItem.querySelector( + ':scope > .nested-properties' + ) + const propCount = toggleLabel?.textContent?.match(/\d+/)?.[0] || '' + + if (isExpanded) { + propertyItem.classList.remove('expanded') + propertyItem.classList.add('collapsed') + toggleBtn.setAttribute('aria-expanded', 'false') + if (toggleIcon) toggleIcon.textContent = '+' + if (toggleLabel) + toggleLabel.textContent = `Show ${propCount} properties` + // Set hidden="until-found" for find-in-page searchability + if (nestedProps && supportsHiddenUntilFound) { + nestedProps.setAttribute('hidden', 'until-found') + } + } else { + propertyItem.classList.remove('collapsed') + propertyItem.classList.add('expanded') + toggleBtn.setAttribute('aria-expanded', 'true') + if (toggleIcon) toggleIcon.textContent = '−' + if (toggleLabel) + toggleLabel.textContent = `Hide ${propCount} properties` + // Remove hidden attribute when expanding + if (nestedProps) { + nestedProps.removeAttribute('hidden') + } + } + } + }) +} + +/** + * Initialize API documentation interactivity + * Call this after page load or HTMX content swap + */ +export function initApiDocs(): void { + // Initialize global click handlers once (uses event delegation) + initGlobalClickHandlers() + + // Check for OperationView page - initialize view-specific features + const operationSection = document.getElementById('elastic-api-v3') + if (operationSection) { + initOperationView(operationSection) + } +} diff --git a/src/Elastic.Documentation.Site/Assets/main.ts b/src/Elastic.Documentation.Site/Assets/main.ts index 38f9fcfd6..9100861dd 100644 --- a/src/Elastic.Documentation.Site/Assets/main.ts +++ b/src/Elastic.Documentation.Site/Assets/main.ts @@ -1,3 +1,4 @@ +import { initApiDocs } from './api-docs' import { initAppliesSwitch } from './applies-switch' import { initCopyButton } from './copybutton' import { initHighlight } from './hljs' @@ -101,6 +102,7 @@ document.addEventListener('htmx:load', function (event: HtmxEvent) { initTabs() initAppliesSwitch() initMath() + initApiDocs() // We do this so that the navigation is not initialized twice if (isLazyLoadNavigationEnabled) { diff --git a/src/Elastic.Documentation.Site/Assets/toc-nav.ts b/src/Elastic.Documentation.Site/Assets/toc-nav.ts index eadcf2e46..9c07ba786 100644 --- a/src/Elastic.Documentation.Site/Assets/toc-nav.ts +++ b/src/Elastic.Documentation.Site/Assets/toc-nav.ts @@ -12,7 +12,10 @@ interface TocElements { const HEADING_OFFSET = 34 * 4 function initializeTocElements(): TocElements { - const headings = $$('#markdown-content h2, #markdown-content h3') + // Support both regular docs (#markdown-content) and API docs (#elastic-api-v3) + const headings = $$( + '#markdown-content h2, #markdown-content h3, #elastic-api-v3 h3[data-section]' + ) const tocLinks = $$('#toc-nav li>a') as HTMLAnchorElement[] const tocContainer = $('#toc-nav .toc-progress-container') as HTMLDivElement const progressIndicator = $( @@ -22,6 +25,19 @@ function initializeTocElements(): TocElements { return { headings, tocLinks, tocContainer, progressIndicator } } +// Get the anchor ID for a heading element +// Supports both regular docs (.heading-wrapper with id) and API docs (data-section attribute) +function getHeadingAnchorId(heading: Element): string | null { + // For API docs: h3[data-section] + const dataSection = heading.getAttribute('data-section') + if (dataSection) { + return dataSection + } + // For regular docs: .heading-wrapper parent with id + const wrapper = heading.closest('.heading-wrapper') + return wrapper?.id || null +} + // Find the current TOC links based on visible headings // It can return multiple links because headings in a tab can have the same position function findCurrentTocLinks(elements: TocElements): HTMLAnchorElement[] { @@ -34,10 +50,9 @@ function findCurrentTocLinks(elements: TocElements): HTMLAnchorElement[] { currentTocLinks = [] } currentTop = rect.top + const anchorId = getHeadingAnchorId(heading) const foundLink = elements.tocLinks.find( - (link) => - link.getAttribute('href') === - `#${heading.closest('.heading-wrapper')?.id}` + (link) => link.getAttribute('href') === `#${anchorId}` ) if (foundLink) { currentTocLinks.push(foundLink) @@ -67,19 +82,26 @@ function handleBottomScroll(elements: TocElements) { if (visibleHeadings.length === 0) return const firstHeading = visibleHeadings[0] const lastHeading = visibleHeadings[visibleHeadings.length - 1] - const firstLink = elements.tocLinks - .find( - (link) => - link.getAttribute('href') === - `#${firstHeading.parentElement?.id}` + const firstAnchorId = getHeadingAnchorId(firstHeading) + const lastAnchorId = getHeadingAnchorId(lastHeading) + + // Find all TOC links for visible headings and mark them as current + const visibleLinks = visibleHeadings + .map((h) => getHeadingAnchorId(h)) + .filter((id): id is string => id !== null) + .map((id) => + elements.tocLinks.find( + (link) => link.getAttribute('href') === `#${id}` + ) ) + .filter((link): link is HTMLAnchorElement => link !== undefined) + updateCurrentClass(elements.tocLinks, visibleLinks) + + const firstLink = elements.tocLinks + .find((link) => link.getAttribute('href') === `#${firstAnchorId}`) ?.closest('li') const lastLink = elements.tocLinks - .find( - (link) => - link.getAttribute('href') === - `#${lastHeading.parentElement?.id}` - ) + .find((link) => link.getAttribute('href') === `#${lastAnchorId}`) ?.closest('li') if (firstLink && lastLink && elements.tocContainer) { const tocRect = elements.tocContainer.getBoundingClientRect() @@ -102,6 +124,14 @@ function updateProgressIndicatorPosition( indicator.style.height = `${height}px` } +function updateCurrentClass( + allLinks: HTMLAnchorElement[], + currentLinks: HTMLAnchorElement[] +) { + allLinks.forEach((link) => link.classList.remove('current')) + currentLinks.forEach((link) => link.classList.add('current')) +} + function updateIndicator(elements: TocElements) { if (!elements.tocContainer) return @@ -110,6 +140,9 @@ function updateIndicator(elements: TocElements) { document.documentElement.scrollHeight - 10 const currentTocLinks = findCurrentTocLinks(elements) + // Update the current class on TOC links + updateCurrentClass(elements.tocLinks, currentTocLinks) + if (isAtBottom) { handleBottomScroll(elements) } else if (currentTocLinks.length > 0) {