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..b01b1c5292 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,28 @@ 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();
+ 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)
+ || 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)));
+ }
+ 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..fd66dd3fff 100644
--- a/tests/Elastic.ApiExplorer.Tests/ApiPropertyTreeBuilderTests.cs
+++ b/tests/Elastic.ApiExplorer.Tests/ApiPropertyTreeBuilderTests.cs
@@ -742,4 +742,71 @@ 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" }
+ ]
+ },
+ "repeated": {
+ "allOf": [
+ { "$ref": "#/components/schemas/Base" },
+ { "$ref": "#/components/schemas/Base" },
+ { "$ref": "#/components/schemas/Timestamps" }
+ ]
+ },
+ "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 == "repeated").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);
+ }
+ }
}