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
103 changes: 103 additions & 0 deletions v2/pkg/engine/jsonschema/nullable_2020_12_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
package jsonschema

import (
"encoding/json"
"testing"

"github.com/santhosh-tekuri/jsonschema/v5"
"github.com/stretchr/testify/require"

"github.com/wundergraph/graphql-go-tools/v2/pkg/astparser"
)

// TestNullableFieldsAreJSONSchema2020_12 verifies that the generator expresses
// nullability in the JSON Schema 2020-12 form rather than the OpenAPI 3.0
// keyword `"nullable": true` (which standard validators silently ignore).
//
// Concretely: a payload that contains explicit `null` values for nullable
// scalar, enum, and recursive-ref fields must validate cleanly against the
// generated schema using a strict standard JSON Schema validator.
func TestNullableFieldsAreJSONSchema2020_12(t *testing.T) {
schemaSDL := scalarDefinitions + `
schema { query: Query }

type Query {
processFormula(tree: FormulaNodeInput): Boolean
doThing(input: ThingInput): Boolean
}

input ThingInput {
name: String
count: Int
rating: Float
active: Boolean
status: Status
}

enum Status { ACTIVE INACTIVE }

input FormulaNodeInput {
nodeType: NodeType!
left: FormulaNodeInput
right: FormulaNodeInput
value: Float
}

enum NodeType { CONSTANT BINARY_OPERATION }
`

operationSDL := `
query Run($tree: FormulaNodeInput, $input: ThingInput) {
processFormula(tree: $tree)
doThing(input: $input)
}
`

definitionDoc, report := astparser.ParseGraphqlDocumentString(schemaSDL)
require.False(t, report.HasErrors(), "schema parsing failed: %s", report.Error())

operationDoc, report := astparser.ParseGraphqlDocumentString(operationSDL)
require.False(t, report.HasErrors(), "operation parsing failed: %s", report.Error())

schema, err := BuildJsonSchema(&operationDoc, &definitionDoc)
require.NoError(t, err)

schemaJSON, err := json.Marshal(schema)
require.NoError(t, err)

compiled, err := jsonschema.CompileString("schema.json", string(schemaJSON))
require.NoError(t, err, "generated JSON schema should compile")

// Nullable scalars and enum: explicit null values must be accepted.
t.Run("explicit nulls accepted for nullable scalar and enum fields", func(t *testing.T) {
const payloadJSON = `{
"input": {
"name": null,
"count": null,
"rating": null,
"active": null,
"status": null
}
}`
var payload any
require.NoError(t, json.Unmarshal([]byte(payloadJSON), &payload))
require.NoError(t, compiled.Validate(payload),
"nullable scalar/enum fields must accept explicit null per JSON Schema 2020-12")
})

// Nullable recursive $ref: a leaf may explicitly set left/right to null
// (rather than omitting them) and the schema must accept it.
t.Run("explicit nulls accepted for nullable recursive ref fields", func(t *testing.T) {
const payloadJSON = `{
"tree": {
"nodeType": "BINARY_OPERATION",
"left": { "nodeType": "CONSTANT", "value": 1, "left": null, "right": null },
"right": null
}
}`
var payload any
require.NoError(t, json.Unmarshal([]byte(payloadJSON), &payload))
require.NoError(t, compiled.Validate(payload),
"nullable recursive ref fields must accept explicit null per JSON Schema 2020-12")
})
}
45 changes: 34 additions & 11 deletions v2/pkg/engine/jsonschema/schema.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,10 @@ type JsonSchema struct {
Required []string `json:"required,omitempty"`
AdditionalProperties *bool `json:"additionalProperties,omitempty"`
Description string `json:"description,omitempty"`
Nullable bool `json:"nullable,omitempty"`
// Nullable is tracked internally; serialization expresses nullability in the
// JSON Schema 2020-12 form (type-union, anyOf, or null in enum), not the
// OpenAPI 3.0 "nullable" keyword.
Nullable bool `json:"-"`

// Ref references a schema defined under the root "$defs" (e.g. "#/$defs/MyInput").
// Used to represent recursive input types, which cannot be inlined.
Expand Down Expand Up @@ -59,9 +62,19 @@ func (s *JsonSchema) MarshalJSON() ([]byte, error) {
// Use a map to only include non-empty fields
m := make(map[string]interface{})

// Nullability is expressed per JSON Schema 2020-12:
// - typed schemas: "type": [<type>, "null"]
// - enum schemas: null appended to the "enum" array
// - $ref schemas: {"anyOf": [{"$ref": ...}, {"type": "null"}]}
// rather than the OpenAPI 3.0 keyword "nullable: true", which standard
// validators ignore.

if s.Type != "" {
// Always use a single type, regardless of nullability
m["type"] = string(s.Type)
if s.Nullable {
m["type"] = []string{string(s.Type), "null"}
} else {
m["type"] = string(s.Type)
}
}

if len(s.Properties) > 0 {
Expand All @@ -80,18 +93,21 @@ func (s *JsonSchema) MarshalJSON() ([]byte, error) {
m["description"] = s.Description
}

// For object types, always include nullable field regardless of value
// For other types, only include nullable when it's true
if s.Type == TypeObject || s.Nullable {
m["nullable"] = s.Nullable
}

if s.Items != nil {
m["items"] = s.Items
}

if len(s.Enum) > 0 {
m["enum"] = s.Enum
if s.Nullable {
enum := make([]any, 0, len(s.Enum)+1)
for _, v := range s.Enum {
enum = append(enum, v)
}
enum = append(enum, nil)
m["enum"] = enum
} else {
m["enum"] = s.Enum
}
}

if s.Default != nil {
Expand All @@ -115,7 +131,14 @@ func (s *JsonSchema) MarshalJSON() ([]byte, error) {
}

if s.Ref != "" {
m["$ref"] = s.Ref
if s.Nullable {
m["anyOf"] = []map[string]string{
{"$ref": s.Ref},
{"type": "null"},
}
} else {
m["$ref"] = s.Ref
}
}

if len(s.Defs) > 0 {
Expand Down
Loading
Loading