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

// TestRecursiveInputAcceptsNestedPayload verifies that a self-recursive GraphQL
// input type produces a JSON Schema that accepts arbitrarily nested payloads.
//
// A self-recursive input type cannot be represented by inlining and truncating
// at a fixed recursion depth: the recursive fields get dropped from the schema,
// and because every object is emitted with `additionalProperties: false`, a
// valid nested payload is then rejected at the validation boundary with
// "additional properties '...' not allowed". The schema must instead reference
// the recursive type so that nesting is permitted to any depth.
func TestRecursiveInputAcceptsNestedPayload(t *testing.T) {
schemaSDL := scalarDefinitions + `
schema { query: Query }

type Query {
createColumn(input: ColumnInput!): Boolean
}

input ColumnInput {
node: FormulaNodeInput!
}

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

enum NodeType {
CONSTANT
BINARY_OPERATION
}
`

operationSDL := `
query CreateColumn($input: ColumnInput!) {
createColumn(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")

// A depth-2 expression tree: the inner BINARY_OPERATION node has its own
// left/right children, exercising recursion beyond a single level.
const payloadJSON = `{
"input": {
"node": {
"nodeType": "BINARY_OPERATION",
"left": {
"nodeType": "BINARY_OPERATION",
"left": { "nodeType": "CONSTANT", "value": 1 },
"right": { "nodeType": "CONSTANT", "value": 2 }
},
"right": { "nodeType": "CONSTANT", "value": 3 }
}
}
}`

var payload any
require.NoError(t, json.Unmarshal([]byte(payloadJSON), &payload))

err = compiled.Validate(payload)
require.NoError(t, err, "valid nested recursive payload must be accepted by the generated schema")
}
28 changes: 28 additions & 0 deletions v2/pkg/engine/jsonschema/schema.go
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,13 @@ type JsonSchema struct {
Description string `json:"description,omitempty"`
Nullable bool `json:"nullable,omitempty"`

// Ref references a schema defined under the root "$defs" (e.g. "#/$defs/MyInput").
// Used to represent recursive input types, which cannot be inlined.
Ref string `json:"$ref,omitempty"`
// Defs holds reusable schema definitions, referenced via Ref. Only populated
// on the root schema.
Defs map[string]*JsonSchema `json:"$defs,omitempty"`

Comment thread
asoorm marked this conversation as resolved.
// Array-specific fields
Items *JsonSchema `json:"items,omitempty"`

Expand Down Expand Up @@ -107,6 +114,14 @@ func (s *JsonSchema) MarshalJSON() ([]byte, error) {
m["pattern"] = s.Pattern
}

if s.Ref != "" {
m["$ref"] = s.Ref
}

if len(s.Defs) > 0 {
m["$defs"] = s.Defs
}

return json.Marshal(m)
}

Expand All @@ -123,6 +138,19 @@ func NewObjectSchema() *JsonSchema {
}
}

// NewRefSchema creates a schema that references a definition under the root "$defs".
func NewRefSchema(typeName string) *JsonSchema {
return &JsonSchema{
Ref: defsRef(typeName),
Nullable: true, // Default to nullable; callers adjust based on context
}
}

// defsRef returns the JSON Pointer to a definition under the root "$defs".
func defsRef(typeName string) string {
return "#/$defs/" + typeName
}

// NewAnySchema creates a schema representing any value (serialized as {} in JSON)
func NewAnySchema() *JsonSchema {
// This will represent as an empty object in JSON schema
Expand Down
156 changes: 93 additions & 63 deletions v2/pkg/engine/jsonschema/variables_schema.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,9 +14,13 @@ type VariablesSchemaBuilder struct {
definitionDocument *ast.Document
schema *JsonSchema
report *operationreport.Report
// Track recursion depth for each type to handle recursive types
recursionTracker map[string]int
maxRecursionDepth int
// recursiveTypes holds the names of input types that are self- or mutually
// recursive. They are emitted once under the root "$defs" and referenced via
// "$ref" instead of being inlined, which supports arbitrary nesting depth.
recursiveTypes map[string]bool
// defs accumulates schemas for recursive input types; attached to the root
// schema as "$defs".
defs map[string]*JsonSchema
}

// Ensure VariablesSchemaBuilder implements the necessary astvisitor interfaces
Expand All @@ -25,20 +29,15 @@ var (
_ astvisitor.EnterVariableDefinitionVisitor = (*VariablesSchemaBuilder)(nil)
)

// NewVariablesSchemaBuilder creates a new VariablesSchemaBuilder with default settings
// NewVariablesSchemaBuilder creates a new VariablesSchemaBuilder.
func NewVariablesSchemaBuilder(operationDocument, definitionDocument *ast.Document) *VariablesSchemaBuilder {
return NewVariablesSchemaBuilderWithOptions(operationDocument, definitionDocument, 3)
}

// NewVariablesSchemaBuilderWithOptions creates a new VariablesSchemaBuilder with custom options
func NewVariablesSchemaBuilderWithOptions(operationDocument, definitionDocument *ast.Document, maxRecursionDepth int) *VariablesSchemaBuilder {
return &VariablesSchemaBuilder{
operationDocument: operationDocument,
definitionDocument: definitionDocument,
schema: NewObjectSchema(),
report: &operationreport.Report{},
recursionTracker: make(map[string]int),
maxRecursionDepth: maxRecursionDepth,
recursiveTypes: make(map[string]bool),
defs: make(map[string]*JsonSchema),
}
}

Expand All @@ -49,7 +48,8 @@ func (v *VariablesSchemaBuilder) EnterDocument(operation, definition *ast.Docume
}

v.schema = NewObjectSchema()
v.recursionTracker = make(map[string]int) // Reset recursion tracker for each build
v.defs = make(map[string]*JsonSchema) // Reset defs for each build
v.recursiveTypes = v.computeRecursiveInputTypes() // Identify recursive input types

// Extract descriptions from root fields
var descriptions []string
Expand Down Expand Up @@ -121,7 +121,7 @@ func (v *VariablesSchemaBuilder) EnterVariableDefinition(ref int) {
// Convert type to schema starting from the operation document
varSchema := v.processOperationTypeRef(typeRef)

// Skip this variable if we reached maximum recursion depth
// Skip this variable if its type could not be resolved to a schema
if varSchema == nil {
return
}
Expand Down Expand Up @@ -160,6 +160,10 @@ func (v *VariablesSchemaBuilder) GetSchema() *JsonSchema {
if len(v.schema.Required) > 0 {
v.schema.Nullable = false
}
// Attach definitions for any recursive input types referenced via "$ref"
if len(v.defs) > 0 {
v.schema.Defs = v.defs
}
return v.schema
}

Expand Down Expand Up @@ -245,63 +249,96 @@ func (v *VariablesSchemaBuilder) processTypeByName(typeName string) *JsonSchema
return NewObjectSchema()
}

var shouldCleanupTracker bool

// Check recursion depth for complex types that could be recursive
if node.Kind == ast.NodeKindEnumTypeDefinition || node.Kind == ast.NodeKindInputObjectTypeDefinition {
currentDepth, exists := v.recursionTracker[typeName]
if exists {
// We've seen this type before
currentDepth++
v.recursionTracker[typeName] = currentDepth
shouldCleanupTracker = true

// If we've hit our recursion limit, return nil to signal field removal
if currentDepth > v.maxRecursionDepth {
return nil
}
} else {
// First time seeing this type
v.recursionTracker[typeName] = 1
shouldCleanupTracker = true
}
// Recursive input types are emitted once under "$defs" and referenced via
// "$ref" so that nesting is permitted to any depth.
if node.Kind == ast.NodeKindInputObjectTypeDefinition && v.recursiveTypes[typeName] {
v.ensureDef(typeName, node)
return NewRefSchema(typeName)
}

// Process the type based on its kind
var schema *JsonSchema
switch node.Kind {
case ast.NodeKindEnumTypeDefinition:
schema = v.processEnumType(node)
return v.processEnumType(node)

case ast.NodeKindInputObjectTypeDefinition:
schema = v.processInputObjectType(node)
return v.processInputObjectType(node)

case ast.NodeKindScalarTypeDefinition:
schema = NewAnySchema()

schema := NewAnySchema()
// Add description if available
if v.definitionDocument.ScalarTypeDefinitions[node.Ref].Description.IsDefined {
schema.Description = v.definitionDocument.ScalarTypeDefinitionDescriptionString(node.Ref)
}
return schema

default:
// If we can't determine the type, default to any
schema = NewAnySchema()
}

// Clean up the recursion tracker before returning
if shouldCleanupTracker {
currentDepth := v.recursionTracker[typeName]
if currentDepth > 1 {
// Decrement the depth as we're exiting the recursion
v.recursionTracker[typeName]--
} else {
// Remove the type from the tracker if depth is 1
delete(v.recursionTracker, typeName)
return NewAnySchema()
}
}

// computeRecursiveInputTypes returns the set of input object type names that are
// self- or mutually-recursive, i.e. reachable from themselves by following input
// field type references. These are the types that must be referenced via "$ref"
// rather than inlined.
func (v *VariablesSchemaBuilder) computeRecursiveInputTypes() map[string]bool {
def := v.definitionDocument

// Build the dependency graph between input object types.
dependencies := make(map[string][]string, len(def.InputObjectTypeDefinitions))
for ref := range def.InputObjectTypeDefinitions {
name := def.InputObjectTypeDefinitionNameString(ref)
inputDef := def.InputObjectTypeDefinitions[ref]
if !inputDef.HasInputFieldsDefinition {
dependencies[name] = nil
continue
}
for _, fieldRef := range inputDef.InputFieldsDefinition.Refs {
fieldType := def.InputValueDefinitionType(fieldRef)
dependencies[name] = append(dependencies[name], def.ResolveTypeNameString(fieldType))
}
}

return schema
recursive := make(map[string]bool)
for start := range dependencies {
if reachableFromSelf(start, dependencies) {
recursive[start] = true
}
}
return recursive
}

// reachableFromSelf reports whether start can reach itself by following the given
// type dependencies (detecting both self- and mutual recursion).
func reachableFromSelf(start string, dependencies map[string][]string) bool {
visited := make(map[string]bool)
stack := append([]string(nil), dependencies[start]...)
for len(stack) > 0 {
current := stack[len(stack)-1]
stack = stack[:len(stack)-1]
if current == start {
return true
}
if visited[current] {
continue
}
visited[current] = true
stack = append(stack, dependencies[current]...)
}
return false
}

// ensureDef generates the schema for a recursive input type once and stores it
// under "$defs". A placeholder is registered before the body is generated so that
// self-references encountered during generation resolve to a "$ref" rather than
// recursing infinitely.
func (v *VariablesSchemaBuilder) ensureDef(typeName string, node ast.Node) {
if _, ok := v.defs[typeName]; ok {
return
}
v.defs[typeName] = NewObjectSchema() // placeholder to break the recursion
v.defs[typeName] = v.processInputObjectType(node)
}

// processEnumType processes an enum type definition
Expand Down Expand Up @@ -354,7 +391,7 @@ func (v *VariablesSchemaBuilder) processInputField(fieldRef int, schema *JsonSch
// Process the field type starting from the definition document
fieldSchema := v.processDefinitionTypeRef(fieldTypeRef)

// Skip this field if we reached maximum recursion depth
// Skip this field if its type could not be resolved to a schema
if fieldSchema == nil {
return
}
Expand Down Expand Up @@ -487,20 +524,13 @@ func (v *VariablesSchemaBuilder) convertDefinitionValueToNative(value ast.Value)
return nil
}

// BuildJsonSchema builds a JSON schema for the variables of the given operation
// using the default recursion depth of 1
// BuildJsonSchema builds a JSON schema for the variables of the given operation.
// Recursive input types are represented via "$ref"/"$defs" and support arbitrary
// nesting depth.
func BuildJsonSchema(operationDocument, definitionDocument *ast.Document) (*JsonSchema, error) {
return BuildJsonSchemaWithOptions(operationDocument, definitionDocument, 1)
}

// BuildJsonSchemaWithOptions builds a JSON schema for the variables of the given operation
// with a custom recursion depth limit
func BuildJsonSchemaWithOptions(operationDocument, definitionDocument *ast.Document, maxRecursionDepth int) (*JsonSchema, error) {
if len(operationDocument.OperationDefinitions) == 0 {
return nil, fmt.Errorf("no operations found in document")
}

builder := NewVariablesSchemaBuilderWithOptions(operationDocument, definitionDocument, maxRecursionDepth)

return builder.Build()
return NewVariablesSchemaBuilder(operationDocument, definitionDocument).Build()
}
Loading
Loading