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); + } + } }