Repository navigation
Flag nullable fields and drop options rows that repeat the type #4381
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Severity: P3 (Low) A Generated by Libra. React with 👍 or 👎 to give feedback on this comment.
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. True by the letter of JSON Schema, but I kept a There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 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. | ||
|
|
||
| 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"); | ||
| } | ||
| } |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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
nullinside an inneroneOf/anyOfcounts. On the Elasticsearch pages this adds the badge topredicted_valueon the trained model inference response.BuildPropertyList_AnyUnionBranchAllowingNull_MakesTheFieldNullablecovers the nested case.