diff --git a/v2/pkg/engine/jsonschema/recursive_input_test.go b/v2/pkg/engine/jsonschema/recursive_input_test.go new file mode 100644 index 0000000000..1913ca4c74 --- /dev/null +++ b/v2/pkg/engine/jsonschema/recursive_input_test.go @@ -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") +} diff --git a/v2/pkg/engine/jsonschema/schema.go b/v2/pkg/engine/jsonschema/schema.go index 852c19a002..21e230274c 100644 --- a/v2/pkg/engine/jsonschema/schema.go +++ b/v2/pkg/engine/jsonschema/schema.go @@ -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"` + // Array-specific fields Items *JsonSchema `json:"items,omitempty"` @@ -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) } @@ -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 diff --git a/v2/pkg/engine/jsonschema/variables_schema.go b/v2/pkg/engine/jsonschema/variables_schema.go index 31986bd55c..da36da60d0 100644 --- a/v2/pkg/engine/jsonschema/variables_schema.go +++ b/v2/pkg/engine/jsonschema/variables_schema.go @@ -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 @@ -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), } } @@ -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 @@ -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 } @@ -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 } @@ -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 @@ -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 } @@ -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() } diff --git a/v2/pkg/engine/jsonschema/variables_schema_test.go b/v2/pkg/engine/jsonschema/variables_schema_test.go index 79283a22c8..68046cc142 100644 --- a/v2/pkg/engine/jsonschema/variables_schema_test.go +++ b/v2/pkg/engine/jsonschema/variables_schema_test.go @@ -865,17 +865,17 @@ func TestBuildJsonSchema(t *testing.T) { t.Logf("Default recursion depth schema: %v", string(data)) }) - t.Run("recursive types with custom recursion depth", func(t *testing.T) { + t.Run("recursive types are emitted via $ref and $defs", func(t *testing.T) { // Define schema with recursive input type schemaSDL := scalarDefinitions + ` schema { query: Query } - + type Query { processNode(node: RecursiveNode): Boolean } - + """A node that can contain child nodes of the same type""" input RecursiveNode { id: ID! @@ -899,36 +899,17 @@ func TestBuildJsonSchema(t *testing.T) { operationDoc, report := astparser.ParseGraphqlDocumentString(operationSDL) require.False(t, report.HasErrors(), "operation parsing failed") - // Build JSON schema with custom recursion depth (3) - customDepth := 3 - customSchema, err := BuildJsonSchemaWithOptions(&operationDoc, &definitionDoc, customDepth) - require.NoError(t, err) - - // Build JSON schema with default recursion depth (1) for comparison - defaultSchema, err := BuildJsonSchema(&operationDoc, &definitionDoc) + schema, err := BuildJsonSchema(&operationDoc, &definitionDoc) require.NoError(t, err) - // Convert both to JSON for analysis - customData, err := json.Marshal(customSchema) - require.NoError(t, err) - defaultData, err := json.Marshal(defaultSchema) + data, err := json.Marshal(schema) require.NoError(t, err) - // Verify both are valid schemas - require.NotEmpty(t, customData, "Custom schema JSON should not be empty") - require.NotEmpty(t, defaultData, "Default schema JSON should not be empty") - - // Verify the custom schema is different (likely larger) than the default - assert.NotEqual(t, string(customData), string(defaultData), - "Custom recursion depth schema should differ from default schema") - - // Simple size check - custom schema should be larger due to more recursion - assert.Greater(t, len(customData), len(defaultData), - "Custom schema should be larger than default due to deeper recursion") - - // Log the schemas for debugging - t.Logf("Custom recursion depth schema size: %d bytes", len(customData)) - t.Logf("Default recursion depth schema size: %d bytes", len(defaultData)) + // The recursive type is defined once under "$defs" and referenced via "$ref". + require.Contains(t, schema.Defs, "RecursiveNode", + "recursive input type should be defined under $defs") + assert.Contains(t, string(data), `"$ref":"#/$defs/RecursiveNode"`, + "recursive input type should be referenced via $ref") }) t.Run("query with two nested arguments", func(t *testing.T) { @@ -1107,11 +1088,22 @@ func TestBuildJsonSchema(t *testing.T) { data, err := json.MarshalIndent(schema, "", " ") require.NoError(t, err) - // Define expected JSON schema - this may vary based on recursion depth setting + // Mutually recursive input types (TypeA <-> TypeB) are emitted once each + // under "$defs" and referenced via "$ref", so nesting is permitted to any depth. expectedJSON := `{ "type": "object", "properties": { "a": { + "$ref": "#/$defs/TypeA" + } + }, + "required": [ + "a" + ], + "additionalProperties": false, + "nullable": false, + "$defs": { + "TypeA": { "type": "object", "properties": { "id": { @@ -1122,20 +1114,7 @@ func TestBuildJsonSchema(t *testing.T) { "nullable": true }, "b": { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "description": { - "type": "string", - "nullable": true - } - }, - "required": [ - "id" - ], - "additionalProperties": false, + "$ref": "#/$defs/TypeB", "nullable": true } }, @@ -1143,14 +1122,30 @@ func TestBuildJsonSchema(t *testing.T) { "id" ], "additionalProperties": false, - "nullable": false + "nullable": true + }, + "TypeB": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "description": { + "type": "string", + "nullable": true + }, + "a": { + "$ref": "#/$defs/TypeA", + "nullable": true + } + }, + "required": [ + "id" + ], + "additionalProperties": false, + "nullable": true } - }, - "required": [ - "a" - ], - "additionalProperties": false, - "nullable": false + } }` // Compare actual JSON with expected JSON