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 @@ -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; }

/// <summary>Whether the value can be <c>null</c>; the type annotation leaves <c>null</c> out, so a badge says it.</summary>
public bool IsNullable { get; init; }
public required bool IsLast { get; init; }
public required bool IsRecursive { get; init; }

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -341,6 +342,8 @@ private IReadOnlyList<TypePageLink> 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)
Expand All @@ -353,6 +356,15 @@ private IReadOnlyList<TypePageLink> 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);
Expand All @@ -365,7 +377,7 @@ private IReadOnlyList<TypePageLink> BuildAlsoIncludes(TypeInfo typeInfo) =>
{
Kind = UnionDisplayKind.Badges,
Keyword = typeInfo.UnionKeyword,
DiscriminatorProperty = _analyzer.GetUnionDiscriminator(propSchema)?.PropertyName,
DiscriminatorProperty = discriminator,
Badges = badges.ToArray(),
MoreOptions = mixed ? MoreOptionsText(typeInfo) : null
};
Expand All @@ -374,6 +386,10 @@ private IReadOnlyList<TypePageLink> BuildAlsoIncludes(TypeInfo typeInfo) =>
return null;
}

/// <summary>Whether a type formula such as <c>number | string</c> names exactly these options, in any order.</summary>
private static bool NamesTheSameOptions(string typeName, IEnumerable<string> options) =>
new HashSet<string>(typeName.Split(" | ", StringSplitOptions.TrimEntries), StringComparer.Ordinal).SetEquals(options);

/// <summary>A union with literal members as well as object variants, e.g. a sort order: <c>asc</c>, <c>desc</c> or an object.</summary>
private static bool MixesLiteralsWithObjects(TypeInfo typeInfo, Expansion expansion) =>
typeInfo is { IsUnion: true, EnumValues.Length: > 0 } && expansion.HasUnionOptions;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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('|')
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,10 @@
{
<span class="required @SchemaHelpers.StatusCssClass">required</span>
}
@if (Model.IsNullable)
{
<span class="nullable-badge @SchemaHelpers.StatusCssClass" data-tippy-content="This value can be null">nullable</span>
}
@if (Model.ShowDeprecatedBadge)
{
<span class="deprecated-badge @SchemaHelpers.StatusCssClass">deprecated</span>
Expand Down
33 changes: 32 additions & 1 deletion src/Elastic.ApiExplorer/Model/SchemaAnalyzer.cs
Original file line number Diff line number Diff line change
Expand Up @@ -298,12 +298,43 @@ private static bool IsInlineObject(UnionOption option) =>
/// parent: <c>anyOf[anyOf[A, B], C]</c> offers A, B and C. Named unions stay one option, so cyclic references cannot recurse.
/// </summary>
private static IEnumerable<IOpenApiSchema> FlattenInlineUnions(IEnumerable<IOpenApiSchema> 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 };

/// <summary>
/// Whether a value of <paramref name="schema"/> can be <c>null</c>: its type lists <c>null</c> (OpenAPI 3.1, or 3.0's
/// <c>nullable: true</c> once read), or its union lets <c>null</c> through, nested unions included. The type annotation
/// leaves <c>null</c> out, so the row says it with a badge instead.
/// </summary>
public bool IsNullable(IOpenApiSchema? schema) => AllowsNull(schema, [with(ReferenceEqualityComparer.Instance)]);

/// <summary>
/// A union lets <c>null</c> through when any branch accepts it, a <c>oneOf</c> too. Read strictly, <c>null</c> matching
/// two <c>oneOf</c> branches fails it, but generated specs write <c>oneOf: [string | null, number | null]</c> for a
/// field the API accepts <c>null</c> for, and a missing badge would read as "never null".
/// </summary>
private bool AllowsNull(IOpenApiSchema? schema, HashSet<IOpenApiSchema> visited)
{
var resolved = ResolveSchema(schema) ?? schema;
// 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;

// 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<IOpenApiSchema>(visited, ReferenceEqualityComparer.Instance))
);
}

/// <summary>
Comment on lines +337 to 338

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Severity: P3 (Low)

Nested union branches that allow null are not recognized, so the explorer can omit the nullable badge for valid schemas.

Generated by Libra. React with 👍 or 👎 to give feedback on this comment.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in a4cef8e. Nullability now follows union branches all the way down, with a guard for unions that refer back to themselves. A null inside an inner oneOf/anyOf counts. On the Elasticsearch pages this adds the badge to predicted_value on the trained model inference response. BuildPropertyList_AnyUnionBranchAllowingNull_MakesTheFieldNullable covers the nested case.

Comment on lines +337 to 338

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Severity: P3 (Low)

A oneOf whose multiple branches accept null is incorrectly marked nullable even though oneOf rejects null when more than one branch matches.

Generated by Libra. React with 👍 or 👎 to give feedback on this comment.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

True by the letter of JSON Schema, but I kept a oneOf nullable when any branch allows null, and documented why in AllowsNull. Applying the strict rule removed the badge from 56 rows in the Kibana timeline APIs. For example, dateRange.end is oneOf: [{type: [string, null]}, {type: [number, null]}]. That shape comes from a generator that writes oneOf for what is really an anyOf, and the API accepts null there. Readers take a missing badge to mean "never null", so the strict reading would mislead them on exactly those fields. The test asserts this case explicitly.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the clarification. I agree that this is not a strict JSON Schema interpretation of oneOf: null matching multiple branches would fail the exclusivity constraint. The implementation explicitly chooses the API-facing interpretation for the generated-spec shape you describe, and documents the trade-off in AllowsNull; the test also asserts that the two-nullable-branches case should display as nullable. Given that evidence, I consider this finding addressed by the documented behavior rather than a remaining defect. Implementation and rationale · Regression test

Generated by Libra. React with 👍 or 👎 to give feedback on this comment.

/// 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.
Expand Down
9 changes: 9 additions & 0 deletions src/Elastic.Documentation.Site/Assets/api-docs.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
125 changes: 125 additions & 0 deletions tests/Elastic.ApiExplorer.Tests/NullableTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
// 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;

/// <summary>Values that can be <c>null</c> say so with a badge, and a type that names its options has no options row.</summary>
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_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()
{
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");
}
}
2 changes: 1 addition & 1 deletion tests/Elastic.ApiExplorer.Tests/UnionOptionTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down
Loading