From e4508f0729e1a30001f054711ea0e6e60ef6ca17 Mon Sep 17 00:00:00 2001 From: Andrea De Pirro Date: Fri, 16 Oct 2026 13:22:46 +0200 Subject: [PATCH 1/2] List the discriminator property first in API union variants A variant's fields now start with the property that selects it, declared by `discriminator` or fixed to a single enum value in every variant. Before, a `type` property could sit last among ten fields. Co-Authored-By: Claude Sonnet 5.5 --- .../ApiPropertyTreeBuilder.Variants.cs | 6 ++- .../PropertyTree/ApiPropertyTreeBuilder.cs | 10 +++-- .../Model/SchemaAnalyzer.cs | 4 ++ .../ApiBodyContentTests.cs | 2 +- .../UnionVariantTests.cs | 39 +++++++++++++++++++ 5 files changed, 56 insertions(+), 5 deletions(-) diff --git a/src/Elastic.ApiExplorer/Components/PropertyTree/ApiPropertyTreeBuilder.Variants.cs b/src/Elastic.ApiExplorer/Components/PropertyTree/ApiPropertyTreeBuilder.Variants.cs index b3e24e6ca3..4d850e3972 100644 --- a/src/Elastic.ApiExplorer/Components/PropertyTree/ApiPropertyTreeBuilder.Variants.cs +++ b/src/Elastic.ApiExplorer/Components/PropertyTree/ApiPropertyTreeBuilder.Variants.cs @@ -36,6 +36,9 @@ public partial class ApiPropertyTreeBuilder _shapes ); + var selector = discriminator?.PropertyName is { Length: > 0 } declared + ? declared + : _analyzer.GetSelectingProperty(variantsToRender.Where(v => !v.IsArray).Select(v => v.Schema)); var usedIds = new HashSet(StringComparer.Ordinal); var optionIds = variantsToRender.Select( v => UniqueId($"{scope.Prefix}-variant-{v.Name.ToLowerInvariant().Replace(" ", "-").Replace("[]", "-array")}", usedIds) @@ -87,7 +90,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..cb0feb34d9 100644 --- a/tests/Elastic.ApiExplorer.Tests/UnionVariantTests.cs +++ b/tests/Elastic.ApiExplorer.Tests/UnionVariantTests.cs @@ -53,6 +53,45 @@ 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] public async Task BuildPropertyList_AnyOfRequiredOnlyMembers_SaysWhichFieldsAreRequired() { From 697537778f3b6eb300c6426453f06300209e3556 Mon Sep 17 00:00:00 2001 From: Andrea De Pirro Date: Fri, 16 Oct 2026 13:26:56 +0200 Subject: [PATCH 2/2] Infer the union selector from distinct object shapes A scalar variant no longer blocks the selector, and an array of objects counts through its items, matching how `LabelObjects` counts shapes. Co-Authored-By: Claude Sonnet 5.5 --- .../ApiPropertyTreeBuilder.Variants.cs | 9 ++- .../UnionVariantTests.cs | 71 +++++++++++++++++++ 2 files changed, 79 insertions(+), 1 deletion(-) diff --git a/src/Elastic.ApiExplorer/Components/PropertyTree/ApiPropertyTreeBuilder.Variants.cs b/src/Elastic.ApiExplorer/Components/PropertyTree/ApiPropertyTreeBuilder.Variants.cs index 4d850e3972..93b32ccb8e 100644 --- a/src/Elastic.ApiExplorer/Components/PropertyTree/ApiPropertyTreeBuilder.Variants.cs +++ b/src/Elastic.ApiExplorer/Components/PropertyTree/ApiPropertyTreeBuilder.Variants.cs @@ -38,7 +38,14 @@ public partial class ApiPropertyTreeBuilder var selector = discriminator?.PropertyName is { Length: > 0 } declared ? declared - : _analyzer.GetSelectingProperty(variantsToRender.Where(v => !v.IsArray).Select(v => v.Schema)); + : _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) diff --git a/tests/Elastic.ApiExplorer.Tests/UnionVariantTests.cs b/tests/Elastic.ApiExplorer.Tests/UnionVariantTests.cs index cb0feb34d9..b3ee828bb4 100644 --- a/tests/Elastic.ApiExplorer.Tests/UnionVariantTests.cs +++ b/tests/Elastic.ApiExplorer.Tests/UnionVariantTests.cs @@ -92,6 +92,77 @@ public async Task BuildPropertyList_VariantSelectedByProperty_ListsThatPropertyF 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() {