Skip to content
Merged
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 @@ -36,6 +36,16 @@ public partial class ApiPropertyTreeBuilder
_shapes
);

var selector = discriminator?.PropertyName is { Length: > 0 } declared
? declared
Comment thread
infra-vault-gh-plugin-prod[bot] marked this conversation as resolved.
: _analyzer.GetSelectingProperty(
// One schema per object shape, as LabelObjects counts them: X and X[] share one, a scalar has none.
variantsToRender
.Where(v => v is { IsObject: true, Props: not null })
.Select(v => v.Schema)
.Distinct(ReferenceEqualityComparer.Instance)
.Cast<IOpenApiSchema?>()
);
var usedIds = new HashSet<string>(StringComparer.Ordinal);
var optionIds = variantsToRender.Select(
v => UniqueId($"{scope.Prefix}-variant-{v.Name.ToLowerInvariant().Replace(" ", "-").Replace("[]", "-array")}", usedIds)
Expand Down Expand Up @@ -87,7 +97,8 @@ scope with
AncestorRefs = newAncestors,
RequiredProperties = null,
Owner = VariantDisplayName(variant)
}
},
selector
)
?? new ApiPropertyList([])
: null,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -74,12 +74,16 @@ private sealed record PropertyRow(
string? Example = null
);

/// <summary>Builds the property rows for a schema; null when it has no renderable properties.</summary>
public ApiPropertyList? BuildPropertyList(IOpenApiSchema? schema, PropertyTreeScope scope)
/// <summary>Builds the property rows for a schema, <paramref name="leadingProperty"/> first; null when it has no renderable properties.</summary>
public ApiPropertyList? BuildPropertyList(IOpenApiSchema? schema, PropertyTreeScope scope, string? leadingProperty = null)
{
var effective = _analyzer.Flatten(schema);
var properties = effective.Properties;
var propArray = properties.Where(p => p.Value is not null && IsListed(p.Value, scope)).ToArray();
// A union variant lists the property that selects it first, so the choice is the first thing its fields say.
var propArray = properties
.Where(p => p.Value is not null && IsListed(p.Value, scope))
.OrderBy(p => p.Key != leadingProperty)
.ToArray();
if (propArray.Length == 0)
return null;

Expand Down
4 changes: 4 additions & 0 deletions src/Elastic.ApiExplorer/Model/SchemaAnalyzer.cs
Original file line number Diff line number Diff line change
Expand Up @@ -394,6 +394,10 @@ private List<UnionOption> LabelObjects(List<UnionOption> options)
.Count() == constants.Count
);

/// <summary>The property each of the object <paramref name="members"/> fixes to a value of its own, null when none does.</summary>
public string? GetSelectingProperty(IEnumerable<IOpenApiSchema?> members) =>
SelectingConstant([.. members.OfType<IOpenApiSchema>().Select(ConstantFields)]);

/// <summary>The fields an object fixes to a single value (<c>type: eql</c>), in property order.</summary>
private Dictionary<string, string> ConstantFields(IOpenApiSchema member)
{
Expand Down
2 changes: 1 addition & 1 deletion tests/Elastic.ApiExplorer.Tests/ApiBodyContentTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -170,7 +170,7 @@ public async Task BuildUnionVariants_AllOfUnionWithDiscriminatorMapping_KeepsNam

variants!.Variants.Select(v => v.DisplayName).Should().Equal("{ lives }", "{ barks }");
variants.Variants.Select(v => v.DiscriminatorLabel).Should().Equal("kind: feline", "kind: canine");
variants.Variants[0].Properties!.Items.Select(p => p.Name).Should().Equal("id", "kind", "lives");
variants.Variants[0].Properties!.Items.Select(p => p.Name).Should().Equal("kind", "id", "lives");
}

[Test]
Expand Down
110 changes: 110 additions & 0 deletions tests/Elastic.ApiExplorer.Tests/UnionVariantTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,116 @@ public async Task BuildPropertyList_UnionWithOwnProperties_ListsTheSharedPropert
pet.Children.Variants!.Variants.Select(v => v.DisplayName).Should().Equal("{ lives }", "{ barks }");
}

[Test]
[Arguments(true)]
[Arguments(false)]
public async Task BuildPropertyList_VariantSelectedByProperty_ListsThatPropertyFirst(bool declared)
{
var discriminator = declared ? "\"discriminator\": { \"propertyName\": \"type\" }," : "";
var json =
$$"""
{
"openapi": "3.0.3",
"info": { "title": "t", "version": "1" },
"paths": {},
"components": {
"schemas": {
"Cat": { "type": "object", "properties": { "lives": { "type": "integer" }, "type": { "type": "string", "enum": ["cat"] } } },
"Dog": { "type": "object", "properties": { "barks": { "type": "boolean" }, "type": { "type": "string", "enum": ["dog"] } } },
"Holder": {
"type": "object",
"properties": {
"pet": {
{{discriminator}}
"oneOf": [ { "$ref": "#/components/schemas/Cat" }, { "$ref": "#/components/schemas/Dog" } ]
}
}
}
}
}
}
""";
var document = await LoadSpecAsync(json);
var builder = BuilderFor(document);

var list = builder.BuildPropertyList(document.Components!.Schemas!["Holder"], new PropertyTreeScope { Prefix = "" });

var variants = list!.Items.Single(p => p.Name == "pet").Children.Variants!.Variants;
variants.Select(v => v.Properties!.Items.Select(p => p.Name).First()).Should().Equal("type", "type");
variants[0].Properties!.Items.Select(p => p.Name).Should().Equal("type", "lives");
}

[Test]
[Arguments("""{ "type": "string" }""", "pet")]
[Arguments("""{ "type": "array", "items": { "$ref": "#/components/schemas/Cat" } }""", "pet")]
public async Task BuildPropertyList_UnionOfObjectsWithOtherForms_ListsTheSelectingPropertyFirst(string extra, string property)
{
var json =
$$"""
{
"openapi": "3.0.3",
"info": { "title": "t", "version": "1" },
"paths": {},
"components": {
"schemas": {
"Cat": { "type": "object", "properties": { "lives": { "type": "integer" }, "type": { "type": "string", "enum": ["cat"] } } },
"Dog": { "type": "object", "properties": { "barks": { "type": "boolean" }, "type": { "type": "string", "enum": ["dog"] } } },
"Holder": {
"type": "object",
"properties": {
"pet": { "oneOf": [ { "$ref": "#/components/schemas/Cat" }, { "$ref": "#/components/schemas/Dog" }, {{extra}} ] }
}
}
}
}
}
""";
var document = await LoadSpecAsync(json);
var builder = BuilderFor(document);

var list = builder.BuildPropertyList(document.Components!.Schemas!["Holder"], new PropertyTreeScope { Prefix = "" });

var variants = list!.Items.Single(p => p.Name == property).Children.Variants!.Variants;
variants.Where(v => v.Properties is not null).Should().OnlyContain(v => v.Properties!.Items.First().Name == "type");
}

[Test]
public async Task BuildPropertyList_UnionOfObjectArrays_ListsTheSelectingPropertyFirst()
{
var json =
"""
{
"openapi": "3.0.3",
"info": { "title": "t", "version": "1" },
"paths": {},
"components": {
"schemas": {
"Cat": { "type": "object", "properties": { "lives": { "type": "integer" }, "type": { "type": "string", "enum": ["cat"] } } },
"Dog": { "type": "object", "properties": { "barks": { "type": "boolean" }, "type": { "type": "string", "enum": ["dog"] } } },
"Holder": {
"type": "object",
"properties": {
"pets": {
"oneOf": [
{ "type": "array", "items": { "$ref": "#/components/schemas/Cat" } },
{ "type": "array", "items": { "$ref": "#/components/schemas/Dog" } }
]
}
}
}
}
}
}
""";
var document = await LoadSpecAsync(json);
var builder = BuilderFor(document);

var list = builder.BuildPropertyList(document.Components!.Schemas!["Holder"], new PropertyTreeScope { Prefix = "" });

var variants = list!.Items.Single(p => p.Name == "pets").Children.Variants!.Variants;
variants.Should().HaveCount(2).And.OnlyContain(v => v.Properties!.Items.First().Name == "type");
}

[Test]
public async Task BuildPropertyList_AnyOfRequiredOnlyMembers_SaysWhichFieldsAreRequired()
{
Expand Down
Loading