Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,9 @@ public record ApiProperty

public TypePageLink? TypeLink { get; init; }

/// <summary>Further schemas an <c>allOf</c> merges in; the row's type names only the first.</summary>
public IReadOnlyList<TypePageLink> AlsoIncludes { get; init; } = [];

public required bool IsCollapsible { get; init; }
public required bool DefaultExpanded { get; init; }
public required int NestedCount { get; init; }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 })
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -382,6 +383,12 @@ private static TypeAnnotation WithTypeLink(TypeAnnotation type, TypePageLink? ty
return new TypeAnnotation(spans);
}

private IReadOnlyList<TypePageLink> 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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,28 @@
</a>
</dd>
}
@if (Model.AlsoIncludes.Count > 0)
{
<dd class="also-includes">
<span class="values-label">Also includes:</span>
@for (var i = 0; i < Model.AlsoIncludes.Count; i++)
{
var included = Model.AlsoIncludes[i];
if (i > 0)
{
<text>, </text>
}
if (included.Url is { Length: > 0 } includedUrl)
{
<code class="schema-type"><a href="@includedUrl" class="@SchemaHelpers.LinkedCssClass" data-hx-boost="false">@included.TypeName</a></code>
}
else
{
<code class="schema-type">@included.TypeName</code>
}
}
</dd>
}
@(await RenderPartialAsync<_EnumValues, EnumValueList>(new EnumValueList(Model.EnumValues)))
@if (Model.Union is not null)
{
Expand Down
25 changes: 24 additions & 1 deletion src/Elastic.ApiExplorer/Model/SchemaAnalyzer.cs
Original file line number Diff line number Diff line change
Expand Up @@ -514,7 +514,8 @@ private TypeInfo ClassifyType(IOpenApiSchema? schema)
named.IsValueType,
named.ValueTypeBase,
IsLinkedType(named.TypeName),
null
null,
AlsoIncludes: GetComposedTypes(refSchemas)
);
}
}
Expand Down Expand Up @@ -633,6 +634,28 @@ private bool TrySplitAllOfUnion(IList<IOpenApiSchema> allOf, out AllOfUnion spli
return false;
}

/// <summary>The object schemas after the first <c>$ref</c> of an <c>allOf</c>; the first one names the type.</summary>
private List<ComposedType>? GetComposedTypes(OpenApiSchemaReference[] refSchemas)
{
var composed = new List<ComposedType>();
var primaryName = SchemaHelpers.FormatSchemaName(refSchemas[0].Reference.Id ?? "");
foreach (var reference in refSchemas.Skip(1))
Comment thread
akira28 marked this conversation as resolved.
{
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;
}

/// <summary>Each object variant carries the shared base members, so it expands to base plus its own properties.</summary>
private TypeInfo ClassifyAllOfUnion(AllOfUnion split)
{
Expand Down
7 changes: 6 additions & 1 deletion src/Elastic.ApiExplorer/Model/TypeInfo.cs
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,9 @@ namespace Elastic.ApiExplorer.Model;
/// </summary>
public record UnionOption(string Name, string? Ref, bool IsObject, IOpenApiSchema? Schema);

/// <summary>A named schema merged into a type through <c>allOf</c>, beyond the one that names the type.</summary>
public record ComposedType(string Name, bool HasLink);

/// <summary>
/// Unified type information record used by both OperationView and SchemaView.
/// Contains all metadata needed for rendering schema types.
Expand All @@ -31,6 +34,7 @@ public record UnionOption(string Name, string? Ref, bool IsObject, IOpenApiSchem
/// <param name="EnumValues">Every literal the value can take, from <see cref="SchemaAnalyzer.GetEnumValues"/>; set for unions and arrays of enums too.</param>
/// <param name="UnionOptions">String array of union option names for display.</param>
/// <param name="ArrayItemType">The primitive item type for arrays of primitives.</param>
/// <param name="AlsoIncludes">Further named schemas an <c>allOf</c> merges in after the first <c>$ref</c>, which names the type.</param>
public record TypeInfo(
string TypeName,
string? SchemaRef,
Expand All @@ -46,5 +50,6 @@ public record TypeInfo(
bool IsUnion = false,
string[]? EnumValues = null,
string[]? UnionOptions = null,
string? ArrayItemType = null
string? ArrayItemType = null,
List<ComposedType>? AlsoIncludes = null
);
67 changes: 67 additions & 0 deletions tests/Elastic.ApiExplorer.Tests/ApiPropertyTreeBuilderTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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($"<p>{s}</p>"), 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);
}
}
}
Loading