diff --git a/src/Elastic.ApiExplorer/Components/PropertyTree/ApiPropertyTreeBuilder.Variants.cs b/src/Elastic.ApiExplorer/Components/PropertyTree/ApiPropertyTreeBuilder.Variants.cs index b3e24e6ca3..93b32ccb8e 100644 --- a/src/Elastic.ApiExplorer/Components/PropertyTree/ApiPropertyTreeBuilder.Variants.cs +++ b/src/Elastic.ApiExplorer/Components/PropertyTree/ApiPropertyTreeBuilder.Variants.cs @@ -36,6 +36,16 @@ public partial class ApiPropertyTreeBuilder _shapes ); + var selector = discriminator?.PropertyName is { Length: > 0 } declared + ? declared + : _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() + ); var usedIds = new HashSet(StringComparer.Ordinal); var optionIds = variantsToRender.Select( v => UniqueId($"{scope.Prefix}-variant-{v.Name.ToLowerInvariant().Replace(" ", "-").Replace("[]", "-array")}", usedIds) @@ -87,7 +97,8 @@ scope with AncestorRefs = newAncestors, RequiredProperties = null, Owner = VariantDisplayName(variant) - } + }, + selector ) ?? new ApiPropertyList([]) : null, diff --git a/src/Elastic.ApiExplorer/Components/PropertyTree/ApiPropertyTreeBuilder.cs b/src/Elastic.ApiExplorer/Components/PropertyTree/ApiPropertyTreeBuilder.cs index 6ca4afe382..9e24a15465 100644 --- a/src/Elastic.ApiExplorer/Components/PropertyTree/ApiPropertyTreeBuilder.cs +++ b/src/Elastic.ApiExplorer/Components/PropertyTree/ApiPropertyTreeBuilder.cs @@ -74,12 +74,16 @@ private sealed record PropertyRow( string? Example = null ); - /// Builds the property rows for a schema; null when it has no renderable properties. - public ApiPropertyList? BuildPropertyList(IOpenApiSchema? schema, PropertyTreeScope scope) + /// Builds the property rows for a schema, first; null when it has no renderable properties. + 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; diff --git a/src/Elastic.ApiExplorer/Model/SchemaAnalyzer.cs b/src/Elastic.ApiExplorer/Model/SchemaAnalyzer.cs index 4cfce9a108..5301dcc3bf 100644 --- a/src/Elastic.ApiExplorer/Model/SchemaAnalyzer.cs +++ b/src/Elastic.ApiExplorer/Model/SchemaAnalyzer.cs @@ -394,6 +394,10 @@ private List LabelObjects(List options) .Count() == constants.Count ); + /// The property each of the object fixes to a value of its own, null when none does. + public string? GetSelectingProperty(IEnumerable members) => + SelectingConstant([.. members.OfType().Select(ConstantFields)]); + /// The fields an object fixes to a single value (type: eql), in property order. private Dictionary ConstantFields(IOpenApiSchema member) { diff --git a/tests/Elastic.ApiExplorer.Tests/ApiBodyContentTests.cs b/tests/Elastic.ApiExplorer.Tests/ApiBodyContentTests.cs index 1d2b6ee6c2..c252e8a79e 100644 --- a/tests/Elastic.ApiExplorer.Tests/ApiBodyContentTests.cs +++ b/tests/Elastic.ApiExplorer.Tests/ApiBodyContentTests.cs @@ -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] diff --git a/tests/Elastic.ApiExplorer.Tests/UnionVariantTests.cs b/tests/Elastic.ApiExplorer.Tests/UnionVariantTests.cs index 7fe609b61c..b3ee828bb4 100644 --- a/tests/Elastic.ApiExplorer.Tests/UnionVariantTests.cs +++ b/tests/Elastic.ApiExplorer.Tests/UnionVariantTests.cs @@ -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() {