From 14a8239fbeaa7d21e05c65f528c374ca9b4b399b Mon Sep 17 00:00:00 2001 From: Andrea De Pirro Date: Fri, 9 Oct 2026 15:36:24 +0200 Subject: [PATCH 1/2] Flag nullable fields and drop options rows that repeat the type A field such as `max_score` read "union number | string" and then "One of: number or string" below it, on 193 rows across 64 pages. A union whose type already names exactly its options, with no discriminator and no variants to list, no longer repeats them. The explorer dropped `null` from types, so nothing said that a value can be null, on 1,160 schemas in the Elasticsearch and Kibana specs. A muted "nullable" badge now follows "required" on such rows, and the Markdown export marks them too. A schema is nullable when its type lists `null`, which also covers OpenAPI 3.0's `nullable: true`, or when a union member allows it. A `{ "type": "null" }` union member is no longer offered as an option. Co-Authored-By: Claude Opus 5.5 --- .../Components/PropertyTree/ApiProperty.cs | 3 + .../PropertyTree/ApiPropertyMarkdown.cs | 2 + .../PropertyTree/ApiPropertyTreeBuilder.cs | 18 +++- .../Components/PropertyTree/ListingContent.cs | 1 + .../_Partials/_PropertyItem.cshtml | 4 + .../Model/SchemaAnalyzer.cs | 20 +++- .../Assets/api-docs.css | 9 ++ .../NullableTests.cs | 91 +++++++++++++++++++ .../UnionOptionTests.cs | 2 +- 9 files changed, 147 insertions(+), 3 deletions(-) create mode 100644 tests/Elastic.ApiExplorer.Tests/NullableTests.cs diff --git a/src/Elastic.ApiExplorer/Components/PropertyTree/ApiProperty.cs b/src/Elastic.ApiExplorer/Components/PropertyTree/ApiProperty.cs index 7f99e136a2..53e6600a70 100644 --- a/src/Elastic.ApiExplorer/Components/PropertyTree/ApiProperty.cs +++ b/src/Elastic.ApiExplorer/Components/PropertyTree/ApiProperty.cs @@ -147,6 +147,9 @@ public record ApiProperty public required string AnchorId { get; init; } public required int Depth { get; init; } public required bool IsRequired { get; init; } + + /// Whether the value can be null; the type annotation leaves null out, so a badge says it. + public bool IsNullable { get; init; } public required bool IsLast { get; init; } public required bool IsRecursive { get; init; } diff --git a/src/Elastic.ApiExplorer/Components/PropertyTree/ApiPropertyMarkdown.cs b/src/Elastic.ApiExplorer/Components/PropertyTree/ApiPropertyMarkdown.cs index 9b2c3fa1a2..5f585db518 100644 --- a/src/Elastic.ApiExplorer/Components/PropertyTree/ApiPropertyMarkdown.cs +++ b/src/Elastic.ApiExplorer/Components/PropertyTree/ApiPropertyMarkdown.cs @@ -70,6 +70,8 @@ private static void WriteProperty(StringBuilder markdown, ApiProperty property, if (property.IsRequest) _ = markdown.Append(property.IsRequired ? " — required" : " — optional"); + if (property.IsNullable) + _ = markdown.Append(" — nullable"); if (property.ShowDeprecatedBadge) _ = markdown.Append(" — deprecated"); if (property.IsRecursive) diff --git a/src/Elastic.ApiExplorer/Components/PropertyTree/ApiPropertyTreeBuilder.cs b/src/Elastic.ApiExplorer/Components/PropertyTree/ApiPropertyTreeBuilder.cs index 515f156057..63ba040199 100644 --- a/src/Elastic.ApiExplorer/Components/PropertyTree/ApiPropertyTreeBuilder.cs +++ b/src/Elastic.ApiExplorer/Components/PropertyTree/ApiPropertyTreeBuilder.cs @@ -205,6 +205,7 @@ private ApiProperty BuildProperty(PropertyRow row, PropertyTreeScope scope) AnchorId = row.AnchorId, Depth = scope.Depth, IsRequired = row.IsRequired, + IsNullable = _analyzer.IsNullable(propSchema), IsLast = row.IsLast, IsRecursive = isRecursive, IsRequest = scope.IsRequest, @@ -341,6 +342,8 @@ private IReadOnlyList BuildAlsoIncludes(TypeInfo typeInfo) => if (expansion.ArrayUnion is { } arrayUnion && (namesItsOptions || arrayUnion.Expands)) return null; + var discriminator = _analyzer.GetUnionDiscriminator(propSchema)?.PropertyName; + var sortedOptions = (typeInfo.UnionOptions ?? []) .DistinctBy(o => o.Name) .OrderByDescending(o => o.IsArray) @@ -353,6 +356,15 @@ private IReadOnlyList BuildAlsoIncludes(TypeInfo typeInfo) => .DistinctBy(static o => o.Text) .ToArray(); + // Any other union whose type already reads `number | string` would only repeat it, unless the row has more to say. + if ( + namesItsOptions + && !expansion.HasUnionOptions + && discriminator is null + && NamesTheSameOptions(typeInfo.TypeName!, sortedOptions.Select(static o => o.Text)) + ) + return null; + if (sortedOptions.Length > 0 || expansion.HasUnionOptions) { var mixed = MixesLiteralsWithObjects(typeInfo, expansion); @@ -365,7 +377,7 @@ private IReadOnlyList BuildAlsoIncludes(TypeInfo typeInfo) => { Kind = UnionDisplayKind.Badges, Keyword = typeInfo.UnionKeyword, - DiscriminatorProperty = _analyzer.GetUnionDiscriminator(propSchema)?.PropertyName, + DiscriminatorProperty = discriminator, Badges = badges.ToArray(), MoreOptions = mixed ? MoreOptionsText(typeInfo) : null }; @@ -374,6 +386,10 @@ private IReadOnlyList BuildAlsoIncludes(TypeInfo typeInfo) => return null; } + /// Whether a type formula such as number | string names exactly these options, in any order. + private static bool NamesTheSameOptions(string typeName, IEnumerable options) => + new HashSet(typeName.Split(" | ", StringSplitOptions.TrimEntries), StringComparer.Ordinal).SetEquals(options); + /// A union with literal members as well as object variants, e.g. a sort order: asc, desc or an object. private static bool MixesLiteralsWithObjects(TypeInfo typeInfo, Expansion expansion) => typeInfo is { IsUnion: true, EnumValues.Length: > 0 } && expansion.HasUnionOptions; diff --git a/src/Elastic.ApiExplorer/Components/PropertyTree/ListingContent.cs b/src/Elastic.ApiExplorer/Components/PropertyTree/ListingContent.cs index 6f376813d4..2939476272 100644 --- a/src/Elastic.ApiExplorer/Components/PropertyTree/ListingContent.cs +++ b/src/Elastic.ApiExplorer/Components/PropertyTree/ListingContent.cs @@ -71,6 +71,7 @@ private static void Append(StringBuilder text, ApiPropertyList? properties, Page .Append(property.Type.Text) .Append('|') .Append(property.IsRequired) + .Append(property.IsNullable) .Append('|') .Append(property.IsRecursive) .Append('|') diff --git a/src/Elastic.ApiExplorer/Components/PropertyTree/_Partials/_PropertyItem.cshtml b/src/Elastic.ApiExplorer/Components/PropertyTree/_Partials/_PropertyItem.cshtml index c6fad20e76..b5891d9a48 100644 --- a/src/Elastic.ApiExplorer/Components/PropertyTree/_Partials/_PropertyItem.cshtml +++ b/src/Elastic.ApiExplorer/Components/PropertyTree/_Partials/_PropertyItem.cshtml @@ -13,6 +13,10 @@ { required } + @if (Model.IsNullable) + { + nullable + } @if (Model.ShowDeprecatedBadge) { deprecated diff --git a/src/Elastic.ApiExplorer/Model/SchemaAnalyzer.cs b/src/Elastic.ApiExplorer/Model/SchemaAnalyzer.cs index 5e0048666a..0886cf6a11 100644 --- a/src/Elastic.ApiExplorer/Model/SchemaAnalyzer.cs +++ b/src/Elastic.ApiExplorer/Model/SchemaAnalyzer.cs @@ -298,12 +298,30 @@ private static bool IsInlineObject(UnionOption option) => /// parent: anyOf[anyOf[A, B], C] offers A, B and C. Named unions stay one option, so cyclic references cannot recurse. /// private static IEnumerable FlattenInlineUnions(IEnumerable members) => - members.SelectMany( + members + // A `{ "type": "null" }` member is no option to pick: it makes the value nullable (see IsNullable). + .Where(static m => !IsNullOnly(m)).SelectMany( m => m is not OpenApiSchemaReference && m.Properties is not { Count: > 0 } && UnionSchemas.TryGet(m, out _, out var inner) ? FlattenInlineUnions(inner) : [m] ); + private static bool IsNullOnly(IOpenApiSchema member) => member is OpenApiSchema { Type: JsonSchemaType.Null }; + + /// + /// Whether a value of can be null: its type lists null (OpenAPI 3.1, or 3.0's + /// nullable: true once read), or a oneOf/anyOf member allows it. The type annotation leaves + /// null out, so the row says it with a badge instead. + /// + public bool IsNullable(IOpenApiSchema? schema) + { + var resolved = ResolveSchema(schema) ?? schema; + if (resolved?.Type?.HasFlag(JsonSchemaType.Null) == true) + return true; + var members = (resolved?.OneOf ?? []).Concat(resolved?.AnyOf ?? []); + return members.Any(m => (ResolveSchema(m) ?? m).Type?.HasFlag(JsonSchemaType.Null) == true); + } + /// /// Whether a union option lists anything when expanded: properties of its own (an array option's come from its items), /// a map value with properties, or a nested union option that does. diff --git a/src/Elastic.Documentation.Site/Assets/api-docs.css b/src/Elastic.Documentation.Site/Assets/api-docs.css index eeb4e09bd2..007965df56 100644 --- a/src/Elastic.Documentation.Site/Assets/api-docs.css +++ b/src/Elastic.Documentation.Site/Assets/api-docs.css @@ -1811,6 +1811,15 @@ a.type-linked:hover { color: #f04e98; } +/* Muted, unlike required: it describes the value rather than asking the reader for something. */ +.nullable-badge.type-status { + color: #516381; + padding: 0; + background-color: transparent; + font-size: 13px; + line-height: 16px; +} + /* Auth scheme link — compact outlined button with a leading icon */ .api-page-intro .api-security-badges .api-auth-button { display: inline-flex; diff --git a/tests/Elastic.ApiExplorer.Tests/NullableTests.cs b/tests/Elastic.ApiExplorer.Tests/NullableTests.cs new file mode 100644 index 0000000000..4022f5defd --- /dev/null +++ b/tests/Elastic.ApiExplorer.Tests/NullableTests.cs @@ -0,0 +1,91 @@ +// Licensed to Elasticsearch B.V under one or more agreements. +// Elasticsearch B.V licenses this file to you under the Apache 2.0 License. +// See the LICENSE file in the project root for more information + +using AwesomeAssertions; +using Elastic.ApiExplorer.Components.PropertyTree; +using static Elastic.ApiExplorer.Tests.TestSpecs; + +namespace Elastic.ApiExplorer.Tests; + +/// Values that can be null say so with a badge, and a type that names its options has no options row. +public class NullableTests +{ + [Test] + public async Task BuildPropertyList_NullableMembers_AreFlaggedAndNotRepeated() + { + var json = + """ + { + "openapi": "3.1.0", + "info": { "title": "t", "version": "1" }, + "paths": {}, + "components": { + "schemas": { + "Cat": { "type": "object", "properties": { "lives": { "type": "integer" } } }, + "Dog": { "type": "object", "properties": { "barks": { "type": "boolean" } } }, + "Holder": { + "type": "object", + "properties": { + "max_score": { "oneOf": [ { "type": "number" }, { "type": ["null", "string"] } ] }, + "name": { "type": ["string", "null"] }, + "count": { "type": "integer" }, + "pet": { "oneOf": [ { "$ref": "#/components/schemas/Cat" }, { "type": "null" } ] }, + "kind": { + "oneOf": [ { "type": "string" }, { "type": "integer" } ], + "discriminator": { "propertyName": "type" } + } + } + } + } + } + } + """; + var document = await LoadSpecAsync(json); + + var list = BuilderFor(document).BuildPropertyList(document.Components!.Schemas!["Holder"], new PropertyTreeScope { Prefix = "" })!; + + ApiProperty Row(string name) => list.Items.Single(p => p.Name == name); + + Row("max_score").Type.Text.Should().Be("union number | string"); + Row("max_score").Union.Should().BeNull("the type already reads number | string"); + Row("max_score").IsNullable.Should().BeTrue("a member allows null"); + Row("name").IsNullable.Should().BeTrue(); + Row("name").Type.Text.Should().Be("string", "null shows as a badge, not in the type"); + Row("count").IsNullable.Should().BeFalse(); + Row("pet").IsNullable.Should().BeTrue(); + Row("pet").Union?.Badges.Select(b => b.Text).Should().NotContain("null", "a null member is no option to pick"); + Row("kind").Union!.DiscriminatorProperty.Should().Be("type", "a row with more to say than its type keeps its options row"); + + var markdown = new System.Text.StringBuilder(); + ApiPropertyMarkdown.WriteList(markdown, new ApiPropertyList([Row("name")]), "/api/doc/fixture"); + markdown.ToString().Should().Contain("— nullable"); + } + + [Test] + public async Task BuildPropertyList_OpenApi30Nullable_IsFlagged() + { + var json = + """ + { + "openapi": "3.0.3", + "info": { "title": "t", "version": "1" }, + "paths": {}, + "components": { + "schemas": { + "Holder": { "type": "object", "properties": { "note": { "type": "string", "nullable": true } } } + } + } + } + """; + var document = await LoadSpecAsync(json); + + var note = BuilderFor(document).BuildPropertyList( + document.Components!.Schemas!["Holder"], + new PropertyTreeScope { Prefix = "" } + )!.Items.Single(); + + note.IsNullable.Should().BeTrue(); + note.Type.Text.Should().Be("string"); + } +} diff --git a/tests/Elastic.ApiExplorer.Tests/UnionOptionTests.cs b/tests/Elastic.ApiExplorer.Tests/UnionOptionTests.cs index 3d5969b4ef..31c4fa4760 100644 --- a/tests/Elastic.ApiExplorer.Tests/UnionOptionTests.cs +++ b/tests/Elastic.ApiExplorer.Tests/UnionOptionTests.cs @@ -78,7 +78,7 @@ public async Task BuildPropertyList_InlineUnionWithAlias_NamesTheAliasInItsType( var property = await PropertyAsync("node_or_count"); property.Type.Text.Should().Be("union NodeId | integer"); - Badges(property).Should().Equal("NodeId", "integer"); + property.Union.Should().BeNull("the type already reads NodeId | integer, so an options row would only repeat it"); } [Test] From a4cef8e9972cda6ceb2a53038f4c3ce0d9765176 Mon Sep 17 00:00:00 2001 From: Andrea De Pirro Date: Fri, 9 Oct 2026 15:52:28 +0200 Subject: [PATCH 2/2] Find null through nested unions when flagging a field nullable A union member that is itself a union with a `null` branch did not count, so `predicted_value` on the trained model inference response lost its nullable badge. Nullability now follows union branches all the way down, with a guard for unions that refer back to themselves. A `oneOf` counts as nullable when any branch allows `null`, as an `anyOf` does. Read strictly, `null` matching two branches fails a `oneOf`, but generated specs write `oneOf: [string | null, number | null]` for fields the API accepts `null` for, such as the Kibana timeline `dateRange.end`. Co-Authored-By: Claude Opus 5.5 --- .../Model/SchemaAnalyzer.cs | 25 ++++++++++---- .../NullableTests.cs | 34 +++++++++++++++++++ 2 files changed, 53 insertions(+), 6 deletions(-) diff --git a/src/Elastic.ApiExplorer/Model/SchemaAnalyzer.cs b/src/Elastic.ApiExplorer/Model/SchemaAnalyzer.cs index 0886cf6a11..7c28a37244 100644 --- a/src/Elastic.ApiExplorer/Model/SchemaAnalyzer.cs +++ b/src/Elastic.ApiExplorer/Model/SchemaAnalyzer.cs @@ -310,16 +310,29 @@ private static IEnumerable FlattenInlineUnions(IEnumerable /// Whether a value of can be null: its type lists null (OpenAPI 3.1, or 3.0's - /// nullable: true once read), or a oneOf/anyOf member allows it. The type annotation leaves - /// null out, so the row says it with a badge instead. + /// nullable: true once read), or its union lets null through, nested unions included. The type annotation + /// leaves null out, so the row says it with a badge instead. /// - public bool IsNullable(IOpenApiSchema? schema) + public bool IsNullable(IOpenApiSchema? schema) => AllowsNull(schema, [with(ReferenceEqualityComparer.Instance)]); + + /// + /// A union lets null through when any branch accepts it, a oneOf too. Read strictly, null matching + /// two oneOf branches fails it, but generated specs write oneOf: [string | null, number | null] for a + /// field the API accepts null for, and a missing badge would read as "never null". + /// + private bool AllowsNull(IOpenApiSchema? schema, HashSet visited) { var resolved = ResolveSchema(schema) ?? schema; - if (resolved?.Type?.HasFlag(JsonSchemaType.Null) == true) + // A union that refers back to itself adds nothing on the second visit. + if (resolved is null || !visited.Add(resolved)) + return false; + if (resolved.Type?.HasFlag(JsonSchemaType.Null) == true) return true; - var members = (resolved?.OneOf ?? []).Concat(resolved?.AnyOf ?? []); - return members.Any(m => (ResolveSchema(m) ?? m).Type?.HasFlag(JsonSchemaType.Null) == true); + + // Each branch is checked on its own path, so a schema two branches share counts for both. + return (resolved.OneOf ?? []).Concat(resolved.AnyOf ?? []).Any( + m => AllowsNull(m, new HashSet(visited, ReferenceEqualityComparer.Instance)) + ); } /// diff --git a/tests/Elastic.ApiExplorer.Tests/NullableTests.cs b/tests/Elastic.ApiExplorer.Tests/NullableTests.cs index 4022f5defd..97a080587c 100644 --- a/tests/Elastic.ApiExplorer.Tests/NullableTests.cs +++ b/tests/Elastic.ApiExplorer.Tests/NullableTests.cs @@ -62,6 +62,40 @@ public async Task BuildPropertyList_NullableMembers_AreFlaggedAndNotRepeated() markdown.ToString().Should().Contain("— nullable"); } + [Test] + public async Task BuildPropertyList_AnyUnionBranchAllowingNull_MakesTheFieldNullable() + { + var json = + """ + { + "openapi": "3.1.0", + "info": { "title": "t", "version": "1" }, + "paths": {}, + "components": { + "schemas": { + "Holder": { + "type": "object", + "properties": { + "nested": { "oneOf": [ { "type": "integer" }, { "anyOf": [ { "type": "string" }, { "type": "null" } ] } ] }, + "oneOfTwice": { "oneOf": [ { "type": ["string", "null"] }, { "type": ["integer", "null"] } ] }, + "anyOfTwice": { "anyOf": [ { "type": ["string", "null"] }, { "type": ["integer", "null"] } ] } + } + } + } + } + } + """; + var document = await LoadSpecAsync(json); + + var list = BuilderFor(document).BuildPropertyList(document.Components!.Schemas!["Holder"], new PropertyTreeScope { Prefix = "" })!; + + bool Nullable(string name) => list.Items.Single(p => p.Name == name).IsNullable; + + Nullable("nested").Should().BeTrue("null passes through the inner anyOf"); + Nullable("oneOfTwice").Should().BeTrue("generated specs write a nullable field this way, so it reads as nullable"); + Nullable("anyOfTwice").Should().BeTrue("an anyOf lets null through when any branch accepts it"); + } + [Test] public async Task BuildPropertyList_OpenApi30Nullable_IsFlagged() {