From 9e58f79bd86dc4bb77ff43efcab0e1d127c4b096 Mon Sep 17 00:00:00 2001 From: Andrea De Pirro Date: Tue, 6 Oct 2026 08:34:27 +0200 Subject: [PATCH 1/2] List every schema an allOf merges in the API Explorer An `allOf` with several `$ref` members showed only the first as the type, so the others were invisible. Property rows now add an "Also includes" line for the remaining object schemas, in HTML and Markdown. Co-Authored-By: Claude Sonnet 5.5 --- .../Components/PropertyTree/ApiProperty.cs | 3 + .../PropertyTree/ApiPropertyMarkdown.cs | 13 ++++ .../PropertyTree/ApiPropertyTreeBuilder.cs | 7 +++ .../_Partials/_PropertyItem.cshtml | 22 +++++++ .../Model/SchemaAnalyzer.cs | 19 +++++- src/Elastic.ApiExplorer/Model/TypeInfo.cs | 7 ++- .../ApiPropertyTreeBuilderTests.cs | 59 +++++++++++++++++++ 7 files changed, 128 insertions(+), 2 deletions(-) diff --git a/src/Elastic.ApiExplorer/Components/PropertyTree/ApiProperty.cs b/src/Elastic.ApiExplorer/Components/PropertyTree/ApiProperty.cs index 213fcbe36a..6f5f9b72ab 100644 --- a/src/Elastic.ApiExplorer/Components/PropertyTree/ApiProperty.cs +++ b/src/Elastic.ApiExplorer/Components/PropertyTree/ApiProperty.cs @@ -145,6 +145,9 @@ public record ApiProperty public TypePageLink? TypeLink { get; init; } + /// Further schemas an allOf merges in; the row's type names only the first. + public IReadOnlyList AlsoIncludes { get; init; } = []; + public required bool IsCollapsible { get; init; } public required bool DefaultExpanded { get; init; } public required int NestedCount { get; init; } diff --git a/src/Elastic.ApiExplorer/Components/PropertyTree/ApiPropertyMarkdown.cs b/src/Elastic.ApiExplorer/Components/PropertyTree/ApiPropertyMarkdown.cs index be9d24fb42..d048dc29fd 100644 --- a/src/Elastic.ApiExplorer/Components/PropertyTree/ApiPropertyMarkdown.cs +++ b/src/Elastic.ApiExplorer/Components/PropertyTree/ApiPropertyMarkdown.cs @@ -74,9 +74,22 @@ private static void WriteProperty(StringBuilder markdown, ApiProperty property, if (property.TypeLink is { Url: { Length: > 0 } url }) WriteNestedLine(markdown, depth, $"See {ApiCommonMark.Link(property.TypeLink.TypeName, url)}"); + WriteAlsoIncludes(markdown, property, depth); + WriteChildren(markdown, property, apiBaseUrl, depth); } + private static void WriteAlsoIncludes(StringBuilder markdown, ApiProperty property, int depth) + { + if (property.AlsoIncludes.Count == 0) + return; + + var names = property.AlsoIncludes.Select( + t => t.Url is { Length: > 0 } url ? ApiCommonMark.Link(t.TypeName, url) : $"`{t.TypeName}`" + ); + WriteNestedLine(markdown, depth, "Also includes: " + string.Join(", ", names)); + } + private static void WriteArrayItemType(StringBuilder markdown, ApiProperty property, int depth) { if (property.ArrayItemTypeName is { Length: > 0 }) diff --git a/src/Elastic.ApiExplorer/Components/PropertyTree/ApiPropertyTreeBuilder.cs b/src/Elastic.ApiExplorer/Components/PropertyTree/ApiPropertyTreeBuilder.cs index ac33bae017..4793276120 100644 --- a/src/Elastic.ApiExplorer/Components/PropertyTree/ApiPropertyTreeBuilder.cs +++ b/src/Elastic.ApiExplorer/Components/PropertyTree/ApiPropertyTreeBuilder.cs @@ -216,6 +216,7 @@ private ApiProperty BuildProperty(PropertyRow row, PropertyTreeScope scope) // Type annotation already reads "[] …"; skip the redundant "Array of:" row. ArrayItemTypeName = null, TypeLink = typeLink, + AlsoIncludes = BuildAlsoIncludes(typeInfo), IsCollapsible = expansion.IsCollapsible, DefaultExpanded = expansion.DefaultExpanded, NestedCount = expansion.NestedCount, @@ -382,6 +383,12 @@ private static TypeAnnotation WithTypeLink(TypeAnnotation type, TypePageLink? ty return new TypeAnnotation(spans); } + private IReadOnlyList BuildAlsoIncludes(TypeInfo typeInfo) => + typeInfo.AlsoIncludes?.Select( + c => new TypePageLink(c.Name, c.HasLink ? SchemaHelpers.GetContainerPageUrl(options.ApiRootUrl, c.Name) : null) + ).ToArray() + ?? []; + private TypePageLink? BuildTypeLink(TypeInfo typeInfo, Expansion expansion) { string? linkedTypeName = null; diff --git a/src/Elastic.ApiExplorer/Components/PropertyTree/_Partials/_PropertyItem.cshtml b/src/Elastic.ApiExplorer/Components/PropertyTree/_Partials/_PropertyItem.cshtml index 1a28a0d3ef..388bd8bb98 100644 --- a/src/Elastic.ApiExplorer/Components/PropertyTree/_Partials/_PropertyItem.cshtml +++ b/src/Elastic.ApiExplorer/Components/PropertyTree/_Partials/_PropertyItem.cshtml @@ -36,6 +36,28 @@ } + @if (Model.AlsoIncludes.Count > 0) + { +
+ Also includes: + @for (var i = 0; i < Model.AlsoIncludes.Count; i++) + { + var included = Model.AlsoIncludes[i]; + if (i > 0) + { + , + } + if (included.Url is { Length: > 0 } includedUrl) + { + @included.TypeName + } + else + { + @included.TypeName + } + } +
+ } @(await RenderPartialAsync<_EnumValues, EnumValueList>(new EnumValueList(Model.EnumValues))) @if (Model.Union is not null) { diff --git a/src/Elastic.ApiExplorer/Model/SchemaAnalyzer.cs b/src/Elastic.ApiExplorer/Model/SchemaAnalyzer.cs index 43af679d88..0ef0012e50 100644 --- a/src/Elastic.ApiExplorer/Model/SchemaAnalyzer.cs +++ b/src/Elastic.ApiExplorer/Model/SchemaAnalyzer.cs @@ -514,7 +514,8 @@ private TypeInfo ClassifyType(IOpenApiSchema? schema) named.IsValueType, named.ValueTypeBase, IsLinkedType(named.TypeName), - null + null, + AlsoIncludes: GetComposedTypes(refSchemas) ); } } @@ -633,6 +634,22 @@ private bool TrySplitAllOfUnion(IList allOf, out AllOfUnion spli return false; } + /// The object schemas after the first $ref of an allOf; the first one names the type. + private List? GetComposedTypes(OpenApiSchemaReference[] refSchemas) + { + var composed = new List(); + foreach (var reference in refSchemas.Skip(1)) + { + var name = SchemaHelpers.FormatSchemaName(reference.Reference.Id ?? ""); + var target = ResolveSchema(reference) ?? reference; + if (string.IsNullOrEmpty(name) || target.Enum is { Count: > 0 } || ClassifyNamedSchema(name, target).IsPrimitiveAlias) + continue; + if (composed.All(c => c.Name != name)) + composed.Add(new ComposedType(name, IsLinkedType(name))); + } + return composed.Count > 0 ? composed : null; + } + /// Each object variant carries the shared base members, so it expands to base plus its own properties. private TypeInfo ClassifyAllOfUnion(AllOfUnion split) { diff --git a/src/Elastic.ApiExplorer/Model/TypeInfo.cs b/src/Elastic.ApiExplorer/Model/TypeInfo.cs index aa89441dc3..1ac22778e4 100644 --- a/src/Elastic.ApiExplorer/Model/TypeInfo.cs +++ b/src/Elastic.ApiExplorer/Model/TypeInfo.cs @@ -12,6 +12,9 @@ namespace Elastic.ApiExplorer.Model; /// public record UnionOption(string Name, string? Ref, bool IsObject, IOpenApiSchema? Schema); +/// A named schema merged into a type through allOf, beyond the one that names the type. +public record ComposedType(string Name, bool HasLink); + /// /// Unified type information record used by both OperationView and SchemaView. /// Contains all metadata needed for rendering schema types. @@ -31,6 +34,7 @@ public record UnionOption(string Name, string? Ref, bool IsObject, IOpenApiSchem /// Every literal the value can take, from ; set for unions and arrays of enums too. /// String array of union option names for display. /// The primitive item type for arrays of primitives. +/// Further named schemas an allOf merges in after the first $ref, which names the type. public record TypeInfo( string TypeName, string? SchemaRef, @@ -46,5 +50,6 @@ public record TypeInfo( bool IsUnion = false, string[]? EnumValues = null, string[]? UnionOptions = null, - string? ArrayItemType = null + string? ArrayItemType = null, + List? AlsoIncludes = null ); diff --git a/tests/Elastic.ApiExplorer.Tests/ApiPropertyTreeBuilderTests.cs b/tests/Elastic.ApiExplorer.Tests/ApiPropertyTreeBuilderTests.cs index 5a655e023c..88a11dd248 100644 --- a/tests/Elastic.ApiExplorer.Tests/ApiPropertyTreeBuilderTests.cs +++ b/tests/Elastic.ApiExplorer.Tests/ApiPropertyTreeBuilderTests.cs @@ -742,4 +742,63 @@ public async Task BuildPropertyList_AllOfWithOneOfMember_ExpandsVariantsSharingB File.Delete(path); } } + + [Test] + public async Task BuildPropertyList_AllOfWithSeveralRefs_ListsTheOtherSchemasAsAlsoIncludes() + { + var json = + """ + { + "openapi": "3.0.3", + "info": { "title": "t", "version": "1" }, + "paths": {}, + "components": { + "schemas": { + "Base": { "type": "object", "properties": { "id": { "type": "string" } } }, + "Timestamps": { "type": "object", "properties": { "created": { "type": "string" } } }, + "Mode": { "type": "string", "enum": ["a", "b"] }, + "Holder": { + "type": "object", + "properties": { + "merged": { + "allOf": [ + { "$ref": "#/components/schemas/Base" }, + { "$ref": "#/components/schemas/Timestamps" }, + { "$ref": "#/components/schemas/Mode" } + ] + }, + "single": { "allOf": [ { "$ref": "#/components/schemas/Base" } ] } + } + } + } + } + } + """; + var path = Path.Join(Path.GetTempPath(), $"allof-multi-{Guid.NewGuid():N}.json"); + await File.WriteAllTextAsync(path, json, TestContext.Current!.Execution.CancellationToken); + try + { + var loaded = await OpenApiDocument.LoadAsync( + path, + new OpenApiReaderSettings { LeaveStreamOpen = false }, + TestContext.Current!.Execution.CancellationToken + ); + var document = loaded.Document!; + var builder = new ApiPropertyTreeBuilder( + document, + new PropertyDisplayOptions { RenderMarkdown = s => new HtmlString($"

{s}

"), ApiRootUrl = "/api/doc/fixture" } + ); + + var list = builder.BuildPropertyList(document.Components!.Schemas!["Holder"], new PropertyTreeScope { Prefix = "" }); + + var merged = list!.Items.Single(p => p.Name == "merged"); + merged.AlsoIncludes.Select(t => t.TypeName).Should().Equal("Timestamps"); + list.Items.Single(p => p.Name == "single").AlsoIncludes.Should().BeEmpty(); + } + finally + { + if (File.Exists(path)) + File.Delete(path); + } + } } From 3c0a6d8700a2dca756fd80e48bfac4956b3aa1d9 Mon Sep 17 00:00:00 2001 From: Andrea De Pirro Date: Tue, 6 Oct 2026 15:10:27 +0200 Subject: [PATCH 2/2] Skip the primary schema when listing what an allOf also includes An `allOf` that repeated its first `$ref` listed that schema under "Also includes", although it already names the type. The list now leaves out the primary schema. Co-Authored-By: Claude Sonnet 5.5 --- src/Elastic.ApiExplorer/Model/SchemaAnalyzer.cs | 8 +++++++- .../ApiPropertyTreeBuilderTests.cs | 8 ++++++++ 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/src/Elastic.ApiExplorer/Model/SchemaAnalyzer.cs b/src/Elastic.ApiExplorer/Model/SchemaAnalyzer.cs index 0ef0012e50..b01b1c5292 100644 --- a/src/Elastic.ApiExplorer/Model/SchemaAnalyzer.cs +++ b/src/Elastic.ApiExplorer/Model/SchemaAnalyzer.cs @@ -638,11 +638,17 @@ private bool TrySplitAllOfUnion(IList allOf, out AllOfUnion spli private List? GetComposedTypes(OpenApiSchemaReference[] refSchemas) { var composed = new List(); + var primaryName = SchemaHelpers.FormatSchemaName(refSchemas[0].Reference.Id ?? ""); foreach (var reference in refSchemas.Skip(1)) { var name = SchemaHelpers.FormatSchemaName(reference.Reference.Id ?? ""); var target = ResolveSchema(reference) ?? reference; - if (string.IsNullOrEmpty(name) || target.Enum is { Count: > 0 } || ClassifyNamedSchema(name, target).IsPrimitiveAlias) + if ( + string.IsNullOrEmpty(name) + || name == primaryName + || target.Enum is { Count: > 0 } + || ClassifyNamedSchema(name, target).IsPrimitiveAlias + ) continue; if (composed.All(c => c.Name != name)) composed.Add(new ComposedType(name, IsLinkedType(name))); diff --git a/tests/Elastic.ApiExplorer.Tests/ApiPropertyTreeBuilderTests.cs b/tests/Elastic.ApiExplorer.Tests/ApiPropertyTreeBuilderTests.cs index 88a11dd248..fd66dd3fff 100644 --- a/tests/Elastic.ApiExplorer.Tests/ApiPropertyTreeBuilderTests.cs +++ b/tests/Elastic.ApiExplorer.Tests/ApiPropertyTreeBuilderTests.cs @@ -767,6 +767,13 @@ public async Task BuildPropertyList_AllOfWithSeveralRefs_ListsTheOtherSchemasAsA { "$ref": "#/components/schemas/Mode" } ] }, + "repeated": { + "allOf": [ + { "$ref": "#/components/schemas/Base" }, + { "$ref": "#/components/schemas/Base" }, + { "$ref": "#/components/schemas/Timestamps" } + ] + }, "single": { "allOf": [ { "$ref": "#/components/schemas/Base" } ] } } } @@ -793,6 +800,7 @@ public async Task BuildPropertyList_AllOfWithSeveralRefs_ListsTheOtherSchemasAsA var merged = list!.Items.Single(p => p.Name == "merged"); merged.AlsoIncludes.Select(t => t.TypeName).Should().Equal("Timestamps"); + list.Items.Single(p => p.Name == "repeated").AlsoIncludes.Select(t => t.TypeName).Should().Equal("Timestamps"); list.Items.Single(p => p.Name == "single").AlsoIncludes.Should().BeEmpty(); } finally