From 99a0543f436ba3eaf8d231c494a707e096e67a8c Mon Sep 17 00:00:00 2001 From: Martijn Laarman Date: Fri, 2 Jan 2026 22:34:12 +0100 Subject: [PATCH 01/23] OpenAPI response and requests body support --- .gitignore | 2 + Directory.Packages.props | 4 +- src/Elastic.ApiExplorer/ApiViewModel.cs | 2 + .../Landing/LandingView.cshtml | 17 +- src/Elastic.ApiExplorer/OpenApiGenerator.cs | 79 ++ .../Operations/OperationView.cshtml | 854 +++++++++++++++++- .../Schemas/SchemaNavigationItem.cs | 73 ++ .../Schemas/SchemaView.cshtml | 619 +++++++++++++ .../Schemas/SchemaViewModel.cs | 12 + src/Elastic.ApiExplorer/_ViewImports.cshtml | 1 + .../Assets/api-docs.css | 369 +++++++- 11 files changed, 1980 insertions(+), 52 deletions(-) create mode 100644 src/Elastic.ApiExplorer/Schemas/SchemaNavigationItem.cs create mode 100644 src/Elastic.ApiExplorer/Schemas/SchemaView.cshtml create mode 100644 src/Elastic.ApiExplorer/Schemas/SchemaViewModel.cs 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..f5d0c410f 100644 --- a/src/Elastic.ApiExplorer/ApiViewModel.cs +++ b/src/Elastic.ApiExplorer/ApiViewModel.cs @@ -11,6 +11,7 @@ using Elastic.Documentation.Site; using Elastic.Documentation.Site.FileProviders; using Microsoft.AspNetCore.Html; +using Microsoft.OpenApi; namespace Elastic.ApiExplorer; @@ -21,6 +22,7 @@ public abstract class ApiViewModel(ApiRenderContext context) 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) => diff --git a/src/Elastic.ApiExplorer/Landing/LandingView.cshtml b/src/Elastic.ApiExplorer/Landing/LandingView.cshtml index f3fd31b30..04f5e86e7 100644 --- a/src/Elastic.ApiExplorer/Landing/LandingView.cshtml +++ b/src/Elastic.ApiExplorer/Landing/LandingView.cshtml @@ -1,6 +1,7 @@ @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 @@ -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/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..22ce20aef 100644 --- a/src/Elastic.ApiExplorer/Operations/OperationView.cshtml +++ b/src/Elastic.ApiExplorer/Operations/OperationView.cshtml @@ -6,52 +6,575 @@ @functions { public GlobalLayoutViewModel LayoutModel => Model.CreateGlobalLayoutModel(); - public string GetTypeName(JsonSchemaType? type) + private const int MaxDepth = 4; + + // Types that are known to be value types (resolve to primitives like string) + private 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 + private static readonly HashSet LinkedTypes = new(StringComparer.OrdinalIgnoreCase) + { + "QueryContainer", "AggregationContainer", "Aggregate" + }; + + private static string GetContainerPageUrl(string typeName) + { + // Only container types get their own pages + return 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! // Should not be called for non-container types + }; + } + + private static bool ShouldLinkToContainerPage(string typeName) + { + // Only actual container types have dedicated pages + return LinkedTypes.Contains(typeName); + } + + public string GetPrimitiveTypeName(JsonSchemaType? type) { - var typeName = ""; if (type is null) - return "unknown and null"; + return ""; + var typeName = ""; 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.Number)) + typeName = "number"; + else if (type.Value.HasFlag(JsonSchemaType.Null)) + typeName = "null"; else if (type.Value.HasFlag(JsonSchemaType.Object)) - { typeName = "object"; + + return typeName; + } + + public string FormatSchemaName(string schemaId) + { + var parts = schemaId.Split('.'); + return parts.Length > 0 ? parts[^1] : schemaId; + } + + public bool IsValueType(string typeName) => KnownValueTypes.Contains(typeName); + + public bool IsLinkedType(string typeName) => ShouldLinkToContainerPage(typeName); + + public string? GetValueTypeBase(IOpenApiSchema? schema) + { + if (schema is null) return null; + + // Check the schema's type + var primitiveType = GetPrimitiveTypeName(schema.Type); + if (!string.IsNullOrEmpty(primitiveType) && primitiveType != "object") + return primitiveType; + + return null; + } + + public record TypeInfo( + string TypeName, + string? SchemaRef, + bool IsArray, + bool IsObject, + bool IsValueType, + string? ValueTypeBase, + bool HasLink, + List<(string Name, string? Ref, bool IsObject)>? AnyOfOptions, + bool IsDictionary = false, + IOpenApiSchema? DictValueSchema = null, + bool IsEnum = false, + bool IsUnion = false, + string[]? EnumValues = null, + string[]? UnionOptions = null + ); + + public TypeInfo GetTypeInfo(IOpenApiSchema? schema, string? currentPropertyPath = null) + { + 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 = FormatSchemaName(refId); + var isArray = schema.Type?.HasFlag(JsonSchemaType.Array) ?? false; + var isValueType = IsValueType(typeName); + var valueTypeBase = isValueType ? 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 }); + string[]? enumValues = isEnum ? schemaRef.Enum?.Select(e => e?.ToString() ?? "").ToArray() : null; + + // Get union options from oneOf/anyOf + string[]? unionOptions = null; + if (isUnion) + { + var unionSchemas = schemaRef.OneOf is { Count: > 0 } ? schemaRef.OneOf : schemaRef.AnyOf; + var options = new List(); + foreach (var s in unionSchemas ?? []) + { + if (s is OpenApiSchemaReference unionRef) + { + options.Add(FormatSchemaName(unionRef.Reference?.Id ?? "unknown")); + } + else if (s?.Enum is { Count: > 0 } inlineEnum) + { + // String literal union - add enum values + foreach (var enumVal in inlineEnum) + options.Add(enumVal?.ToString() ?? ""); + } + else + { + options.Add(GetPrimitiveTypeName(s?.Type) ?? "unknown"); + } + } + unionOptions = options.ToArray(); + } + + return new TypeInfo(typeName, refId, isArray, !isValueType && !isEnum, isValueType, valueTypeBase, hasLink, null, false, null, isEnum, isUnion, enumValues, unionOptions); + } } - else if (type.Value.HasFlag(JsonSchemaType.Null)) - typeName = "null"; - else if (type.Value.HasFlag(JsonSchemaType.Number)) - typeName = "number"; - else + + // Check for oneOf/anyOf which often indicate union types + if (schema.OneOf is { Count: > 0 } oneOf) { + var options = oneOf.Where(s => s is not null).Select(s => { + var info = GetTypeInfo(s); + return (info.TypeName, info.SchemaRef, info.IsObject); + }).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); + } + + var typeNames = options.Select(o => o.TypeName).Distinct().ToArray(); + return new TypeInfo(string.Join(" | ", typeNames), null, false, false, false, null, false, null); } - if (type.Value.HasFlag(JsonSchemaType.Array)) - typeName += " array"; - return typeName; + if (schema.AnyOf is { Count: > 0 } anyOf) + { + var options = anyOf.Where(s => s is not null).Select(s => { + var info = GetTypeInfo(s); + return (info.TypeName, info.SchemaRef, info.IsObject); + }).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); + } + + var typeNames = options.Select(o => o.TypeName).Distinct().ToArray(); + return new TypeInfo(string.Join(" | ", typeNames), null, false, false, false, null, false, null); + } + + // 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 = FormatSchemaName(refId); + var isValueType = IsValueType(typeName); + var hasLink = IsLinkedType(typeName); + return new TypeInfo(typeName, refId, false, !isValueType, isValueType, null, hasLink, null); + } + } + } + + // Check for array items + if (schema.Type?.HasFlag(JsonSchemaType.Array) ?? false) + { + if (schema.Items is not null) + { + var itemInfo = GetTypeInfo(schema.Items); + return new TypeInfo(itemInfo.TypeName, itemInfo.SchemaRef, true, itemInfo.IsObject, itemInfo.IsValueType, itemInfo.ValueTypeBase, itemInfo.HasLink, null); + } + return new TypeInfo("unknown", null, true, false, false, null, false, null); + } + + // 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 IOpenApiSchema 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 = 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); } - public string GetTypeName(IOpenApiSchema propertyValue) + public IOpenApiSchema? ResolveSchema(IOpenApiSchema? schema) { - var typeName = string.Empty; - if (propertyValue.Type is not null) + if (schema is null) return null; + + // If it's a reference, get the target schema + if (schema is OpenApiSchemaReference schemaRef) + return schemaRef; + + // For allOf, merge properties from all schemas + if (schema.AllOf is { Count: > 0 }) + return schema; + + return schema; + } + + public IDictionary? GetSchemaProperties(IOpenApiSchema? schema) + { + if (schema is null) return null; + + // Handle schema references - resolve to get actual properties + if (schema is OpenApiSchemaReference schemaRef) + { + var refId = schemaRef.Reference?.Id; + if (!string.IsNullOrEmpty(refId)) + { + // Try to get the resolved schema from the document + if (Model.Document.Components?.Schemas?.TryGetValue(refId, out var resolvedSchema) == true) + { + return GetSchemaProperties(resolvedSchema); + } + } + // Fall through to try properties on the reference itself + } + + // 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; + } + + public IHtmlContent RenderSchemaType(IOpenApiSchema schema, HashSet? ancestorTypes = null) + { + var info = GetTypeInfo(schema); + var sb = new System.Text.StringBuilder(); + + // Check if this type actually has properties (for showing {} icon) + var hasActualProperties = GetSchemaProperties(schema)?.Count > 0; + + // Build the display text with [] for arrays, {} for objects, enum/union markers + if (info.IsArray) { - typeName = GetTypeName(propertyValue.Type); - if (typeName is not "object" and not "array") - return typeName; + sb.Append("[] "); + if (info.IsValueType && info.ValueTypeBase != null) + { + sb.Append($"{info.ValueTypeBase} "); + } + else if (info.IsEnum) + { + sb.Append("enum "); + } + else if (info.IsUnion) + { + sb.Append("union "); + } + else if (info.IsObject && !string.IsNullOrEmpty(info.SchemaRef) && (hasActualProperties || info.HasLink)) + { + sb.Append("{} "); + } + } + else if (info.IsDictionary) + { + // Dictionary uses a map icon and {} for object value types + sb.Append("map "); + var dictValueHasProps = info.DictValueSchema != null && GetSchemaProperties(info.DictValueSchema)?.Count > 0; + if (info.HasLink || dictValueHasProps) + { + sb.Append("{} "); + } + } + else if (info.IsEnum) + { + // Enum marker + sb.Append("enum "); } + else if (info.IsUnion) + { + // Union marker + sb.Append("union "); + } + else if (info.IsValueType && info.ValueTypeBase != null) + { + sb.Append($"{info.ValueTypeBase} "); + } + else if (info.IsObject && !string.IsNullOrEmpty(info.SchemaRef) && !info.HasLink && hasActualProperties) + { + // Regular objects get {} only if they have properties + sb.Append("{} "); + } + + var displayName = System.Web.HttpUtility.HtmlEncode(info.TypeName); + var titleAttr = !string.IsNullOrEmpty(info.SchemaRef) ? $" title=\"{info.SchemaRef}\"" : ""; + + // Add {} prefix for linked types (they have dedicated pages) + if (info.HasLink && !info.IsDictionary) + { + sb.Append("{} "); + } + + sb.Append($"{displayName}"); + + return new HtmlString($"{sb}"); + } - if (propertyValue.Schema is not null) - return propertyValue.Schema.ToString(); + public IHtmlContent RenderRecursiveBadge() + { + return new HtmlString("recursive"); + } + + public IHtmlContent RenderProperties(IOpenApiSchema? schema, ISet? required, string prefix, int depth, HashSet? ancestorTypes = null, bool isRequest = false) + { + var properties = GetSchemaProperties(schema); + if (properties is null || properties.Count == 0) + return HtmlString.Empty; + + var requiredProps = required ?? 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 isRequired = requiredProps.Contains(property.Key); + var typeInfo = GetTypeInfo(propSchema); + var propId = string.IsNullOrEmpty(prefix) ? property.Key : $"{prefix}-{property.Key}"; + var isLast = i == propArray.Length - 1; + var hasDescription = !string.IsNullOrWhiteSpace(propSchema.Description); + + // Check if this type appears in any ancestor (recursive detection) + var isRecursive = ancestorTypes != null && !string.IsNullOrEmpty(typeInfo.TypeName) && ancestorTypes.Contains(typeInfo.TypeName); + + // Also check dictionary value types for recursion (e.g., "aggregations" map string to AggregationContainer) + if (!isRecursive && typeInfo.IsDictionary && typeInfo.DictValueSchema != null) + { + var dictValueType = GetTypeInfo(typeInfo.DictValueSchema); + isRecursive = ancestorTypes != null && !string.IsNullOrEmpty(dictValueType.TypeName) && ancestorTypes.Contains(dictValueType.TypeName); + } + + // 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 < MaxDepth && GetSchemaProperties(propSchema)?.Count > 0; + + // For dictionaries, check if the value type has properties we should show + // Don't expand if the value type has a dedicated page (linked type) + var hasDictValueProps = typeInfo.IsDictionary && typeInfo.DictValueSchema != null + && depth < MaxDepth && !dictHasLinkedValue && GetSchemaProperties(typeInfo.DictValueSchema)?.Count > 0; - if (propertyValue.Enum is { Count: >0 } e) - return "enum"; + // 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 < MaxDepth + && GetSchemaProperties(arrayItemSchema)?.Count > 0; - return $"unknown value {typeName}"; + // Count nested properties + var nestedPropCount = 0; + if (hasNestedProps) + nestedPropCount = GetSchemaProperties(propSchema)?.Count ?? 0; + else if (hasDictValueProps && typeInfo.DictValueSchema != null) + nestedPropCount = GetSchemaProperties(typeInfo.DictValueSchema)?.Count ?? 0; + else if (hasArrayItemProps && arrayItemSchema != null) + nestedPropCount = GetSchemaProperties(arrayItemSchema)?.Count ?? 0; + + // Only show collapse toggle if more than 2 properties + var hasChildren = (hasNestedProps || hasDictValueProps || hasArrayItemProps) && !isRecursive; + var isCollapsible = hasChildren && nestedPropCount > 2; + + // Auto expand if < 5 properties, collapse if >= 5 + var defaultExpanded = nestedPropCount > 0 && nestedPropCount < 5; + +
+
+ + @property.Key + @RenderSchemaType(propSchema, ancestorTypes) + @if (isRequest && isRequired) + { + required + } + else if (!isRequest && !isRequired) + { + optional + } + @if (isRecursive) + { + @RenderRecursiveBadge() + } + +
+ @if (hasDescription) + { +
+ @Model.RenderMarkdown(propSchema.Description) +
+ } + @if (typeInfo.IsEnum && typeInfo.EnumValues is { Length: > 0 }) + { +
+ Values: + @foreach (var enumVal in typeInfo.EnumValues) + { + @enumVal + } +
+ } + @if (typeInfo.IsUnion && typeInfo.UnionOptions is { Length: > 0 }) + { +
+ One of: + @foreach (var unionOpt in typeInfo.UnionOptions) + { + @unionOpt + } +
+ } + @{ + // Add link to type page for linked types + var linkedTypeName = typeInfo.HasLink + ? (typeInfo.IsDictionary && typeInfo.DictValueSchema != null + ? GetTypeInfo(typeInfo.DictValueSchema).TypeName + : typeInfo.TypeName) + : null; + } + @if (!string.IsNullOrEmpty(linkedTypeName)) + { + var typePageUrl = GetContainerPageUrl(linkedTypeName); + + } + @if (isCollapsible) + { +
+ +
+ } + @if (!isRecursive) + { + // Build new ancestor set including current type + var newAncestors = ancestorTypes != null ? new HashSet(ancestorTypes) : new HashSet(); + if (!string.IsNullOrEmpty(typeInfo.TypeName) && typeInfo.IsObject) + { + // For dictionaries, add the value type name rather than "string to X" + if (typeInfo.IsDictionary && typeInfo.DictValueSchema != null) + { + var dictValueType = GetTypeInfo(typeInfo.DictValueSchema); + if (!string.IsNullOrEmpty(dictValueType.TypeName)) + newAncestors.Add(dictValueType.TypeName); + } + else + { + newAncestors.Add(typeInfo.TypeName); + } + } + + if (hasDictValueProps && typeInfo.DictValueSchema != null) + { + // Show dictionary with intermediary key level - key is always string for additionalProperties + var keyPropId = $"{propId}-string"; +
+
+
+
+ <string> + @RenderSchemaType(typeInfo.DictValueSchema, newAncestors) +
+
+ @RenderProperties(typeInfo.DictValueSchema, null, keyPropId, depth + 2, newAncestors, isRequest) +
+
+
+
+ } + else if (hasNestedProps) + { +
+ @RenderProperties(propSchema, null, propId, depth + 1, newAncestors, isRequest) +
+ } + else if (hasArrayItemProps && arrayItemSchema != null) + { + // Show array item properties +
+ @RenderProperties(arrayItemSchema, null, propId, depth + 1, newAncestors, isRequest) +
+ } + } +
+ } +
+ + return HtmlString.Empty; } } @{ @@ -73,9 +596,15 @@ ">

@operation.Summary

-

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

+ +

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

    @foreach (var overload in allOperations) { @@ -95,54 +624,285 @@ @if (pathParameters.Length > 0) {

    Path Parameters

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

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

    +

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

    + } + @{ 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)
    +
    @qs.Name
    +
    @Model.RenderMarkdown(qs.Description)
    }
    } @if (operation.RequestBody is not null) { -

    Request Body

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

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

    + var content = requestContent?.Value; if (!string.IsNullOrEmpty(operation.RequestBody.Description)) { -

    @operation.RequestBody.Description

    +

    @Model.RenderMarkdown(operation.RequestBody.Description)

    } - if (content?.Schema?.Properties is not null) + var requestSchema = content?.Schema; + if (requestSchema is not null) { -
    - @foreach (var property in content.Schema.Properties) + var props = GetSchemaProperties(requestSchema); + if (props is { Count: > 0 }) { - if (property.Value.Type is null) - { + @RenderProperties(requestSchema, null, "req", 0, null, isRequest: true) + } + else + { +

    Type: @RenderSchemaType(requestSchema)

    + } + } + } - } -
    @property.Key @GetTypeName(property.Value)
    -
    @Model.RenderMarkdown(property.Value.Description)
    + @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)) + { + @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) + { + if (contentType.Value?.Schema is not { } responseSchema) continue; + @if (!isSingleResponse) + { +

    Content-Type: @contentType.Key

    + } + var responseProps = GetSchemaProperties(responseSchema); + if (responseProps is { Count: > 0 }) + { + @RenderProperties(responseSchema, null, $"res-{statusCode}", 0) + } + else + { +

    Response Type: @RenderSchemaType(responseSchema)

    + } + } + } +
    } }
-