diff --git a/v2/pkg/engine/jsonschema/schema.go b/v2/pkg/engine/jsonschema/schema.go new file mode 100644 index 0000000000..852c19a002 --- /dev/null +++ b/v2/pkg/engine/jsonschema/schema.go @@ -0,0 +1,206 @@ +package jsonschema + +import ( + "encoding/json" +) + +// SchemaType represents the type of a JSON Schema property +type SchemaType string + +const ( + TypeObject SchemaType = "object" + TypeArray SchemaType = "array" + TypeString SchemaType = "string" + TypeNumber SchemaType = "number" + TypeInteger SchemaType = "integer" + TypeBoolean SchemaType = "boolean" + TypeNull SchemaType = "null" +) + +// JsonSchema represents a JSON Schema definition +type JsonSchema struct { + // Core schema fields + Type SchemaType `json:"type,omitempty"` + Properties map[string]*JsonSchema `json:"properties,omitempty"` + Required []string `json:"required,omitempty"` + AdditionalProperties *bool `json:"additionalProperties,omitempty"` + Description string `json:"description,omitempty"` + Nullable bool `json:"nullable,omitempty"` + + // Array-specific fields + Items *JsonSchema `json:"items,omitempty"` + + // Enum values + Enum []string `json:"enum,omitempty"` + + // Default value + Default interface{} `json:"default,omitempty"` + + // String-specific fields + Format string `json:"format,omitempty"` + + // Number-specific fields + Minimum *float64 `json:"minimum,omitempty"` + Maximum *float64 `json:"maximum,omitempty"` + + // Additional validation + Pattern string `json:"pattern,omitempty"` +} + +// MarshalJSON customizes JSON serialization to omit empty fields +func (s *JsonSchema) MarshalJSON() ([]byte, error) { + // Use a map to only include non-empty fields + m := make(map[string]interface{}) + + if s.Type != "" { + // Always use a single type, regardless of nullability + m["type"] = string(s.Type) + } + + if len(s.Properties) > 0 { + m["properties"] = s.Properties + } + + if len(s.Required) > 0 { + m["required"] = s.Required + } + + if s.AdditionalProperties != nil { + m["additionalProperties"] = *s.AdditionalProperties + } + + if s.Description != "" { + 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.Default != nil { + m["default"] = s.Default + } + + if s.Format != "" { + m["format"] = s.Format + } + + if s.Minimum != nil { + m["minimum"] = *s.Minimum + } + + if s.Maximum != nil { + m["maximum"] = *s.Maximum + } + + if s.Pattern != "" { + m["pattern"] = s.Pattern + } + + return json.Marshal(m) +} + +// NewObjectSchema creates a new schema for an object type +func NewObjectSchema() *JsonSchema { + additionalProps := false + + return &JsonSchema{ + Type: TypeObject, + Properties: make(map[string]*JsonSchema), + AdditionalProperties: &additionalProps, + Required: []string{}, + Nullable: true, // Default to nullable + } +} + +// NewAnySchema creates a schema representing any value (serialized as {} in JSON) +func NewAnySchema() *JsonSchema { + // This will represent as an empty object in JSON schema + return &JsonSchema{ + Nullable: true, // Default to nullable + } +} + +// NewArraySchema creates a new schema for an array type +func NewArraySchema(items *JsonSchema) *JsonSchema { + return &JsonSchema{ + Type: TypeArray, + Items: items, + Nullable: true, // Default to nullable + } +} + +// NewStringSchema creates a new schema for a string type +func NewStringSchema() *JsonSchema { + return &JsonSchema{ + Type: TypeString, + Nullable: true, // Default to nullable + } +} + +// NewIntegerSchema creates a new schema for an integer type +func NewIntegerSchema() *JsonSchema { + return &JsonSchema{ + Type: TypeInteger, + Nullable: true, // Default to nullable + } +} + +// NewNumberSchema creates a new schema for a number type +func NewNumberSchema() *JsonSchema { + return &JsonSchema{ + Type: TypeNumber, + Nullable: true, // Default to nullable + } +} + +// NewBooleanSchema creates a new schema for a boolean type +func NewBooleanSchema() *JsonSchema { + return &JsonSchema{ + Type: TypeBoolean, + Nullable: true, // Default to nullable + } +} + +// NewEnumSchema creates a new schema for an enum type +func NewEnumSchema(values []string) *JsonSchema { + return &JsonSchema{ + Type: TypeString, + Enum: values, + Nullable: true, // Default to nullable + } +} + +// WithDescription adds a description to the schema +func (s *JsonSchema) WithDescription(description string) *JsonSchema { + s.Description = description + return s +} + +// WithDefault adds a default value to the schema +func (s *JsonSchema) WithDefault(defaultValue interface{}) *JsonSchema { + s.Default = defaultValue + return s +} + +// WithFormat adds a format to a string schema +func (s *JsonSchema) WithFormat(format string) *JsonSchema { + s.Format = format + return s +} + +// WithNullable marks a schema as nullable +func (s *JsonSchema) WithNullable(nullable bool) *JsonSchema { + s.Nullable = nullable + return s +} diff --git a/v2/pkg/engine/jsonschema/schema_test.go b/v2/pkg/engine/jsonschema/schema_test.go new file mode 100644 index 0000000000..979e7bf7d9 --- /dev/null +++ b/v2/pkg/engine/jsonschema/schema_test.go @@ -0,0 +1,557 @@ +package jsonschema + +import ( + "encoding/json" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestJsonSchema_MarshalJSON(t *testing.T) { + t.Run("object schema", func(t *testing.T) { + // Create a complex nested schema + schema := NewObjectSchema() + schema.Description = "Test object schema" + + // Add string property with description and default + stringProp := NewStringSchema() + stringProp.Description = "A string property" + stringProp.Default = "default value" + schema.Properties["name"] = stringProp + schema.Required = append(schema.Required, "name") + + // Add integer property with minimum + intProp := NewIntegerSchema() + min := float64(0) + intProp.Minimum = &min + schema.Properties["age"] = intProp + schema.Required = append(schema.Required, "age") + + // Add enum property + enumValues := []string{"ONE", "TWO", "THREE"} + enumProp := NewEnumSchema(enumValues) + schema.Properties["category"] = enumProp + + // Add nested object property + nestedObj := NewObjectSchema() + nestedObj.Properties["street"] = NewStringSchema() + nestedObj.Properties["city"] = NewStringSchema() + nestedObj.Required = append(nestedObj.Required, "street") + schema.Properties["address"] = nestedObj + + // Add array property + arrayProp := NewArraySchema(NewStringSchema()) + schema.Properties["tags"] = arrayProp + + // Serialize to JSON + data, err := json.MarshalIndent(schema, "", " ") + require.NoError(t, err) + + // Define expected JSON schema + expectedJSON := `{ + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "A string property", + "default": "default value", + "nullable": true + }, + "age": { + "type": "integer", + "minimum": 0, + "nullable": true + }, + "category": { + "type": "string", + "enum": [ + "ONE", + "TWO", + "THREE" + ], + "nullable": true + }, + "address": { + "type": "object", + "properties": { + "street": { + "type": "string", + "nullable": true + }, + "city": { + "type": "string", + "nullable": true + } + }, + "required": [ + "street" + ], + "additionalProperties": false, + "nullable": true + }, + "tags": { + "type": "array", + "items": { + "type": "string", + "nullable": true + }, + "nullable": true + } + }, + "required": [ + "name", + "age" + ], + "additionalProperties": false, + "description": "Test object schema", + "nullable": true +}` + + // Compare actual JSON with expected JSON + assert.JSONEq(t, expectedJSON, string(data), "JSON schema does not match expected structure") + }) + + t.Run("nested schema", func(t *testing.T) { + // Create a schema with nested objects (previously would have used references) + rootSchema := NewObjectSchema() + rootSchema.Description = "Root schema" + + // Create a nested schema + nestedSchema := NewObjectSchema() + nestedSchema.Description = "Nested schema" + nestedSchema.Properties["value"] = NewStringSchema() + + // Add the nested schema as a property + rootSchema.Properties["nested"] = nestedSchema + + // Create an array of the nested schema + arraySchema := NewArraySchema(nestedSchema) + rootSchema.Properties["items"] = arraySchema + + // Serialize to JSON + data, err := json.Marshal(rootSchema) + require.NoError(t, err) + + // Parse it back to verify + var parsed map[string]interface{} + err = json.Unmarshal(data, &parsed) + require.NoError(t, err) + + // Verify structure - there should be no $ref + properties := parsed["properties"].(map[string]interface{}) + nestedProp := properties["nested"].(map[string]interface{}) + + // Check that it's properly inlined + assert.Equal(t, "object", nestedProp["type"]) + assert.Equal(t, "Nested schema", nestedProp["description"]) + assert.Contains(t, nestedProp, "properties") + + // Check the array contains the same schema inline + itemsProp := properties["items"].(map[string]interface{}) + assert.Equal(t, "array", itemsProp["type"]) + assert.Contains(t, itemsProp, "items") + + itemsSchema := itemsProp["items"].(map[string]interface{}) + assert.Equal(t, "object", itemsSchema["type"]) + assert.Equal(t, "Nested schema", itemsSchema["description"]) + }) +} + +func TestSchemaFeatures(t *testing.T) { + t.Run("enum schema", func(t *testing.T) { + // Test creating and validating enum schema + values := []string{"RED", "GREEN", "BLUE"} + schema := NewEnumSchema(values) + + // Test serialization + data, err := json.MarshalIndent(schema, "", " ") + require.NoError(t, err) + + // Define expected JSON schema + expectedJSON := `{ + "type": "string", + "enum": [ + "RED", + "GREEN", + "BLUE" + ], + "nullable": true +}` + + // Compare actual JSON with expected JSON + assert.JSONEq(t, expectedJSON, string(data), "JSON schema does not match expected structure") + }) + + t.Run("required fields", func(t *testing.T) { + // Create schema with required fields + schema := NewObjectSchema() + schema.Properties["id"] = NewStringSchema() + schema.Properties["name"] = NewStringSchema() + schema.Properties["age"] = NewIntegerSchema() + + // Mark id and age as required + schema.Required = []string{"id", "age"} + + // Serialize and check + data, err := json.MarshalIndent(schema, "", " ") + require.NoError(t, err) + + // Define expected JSON schema + expectedJSON := `{ + "type": "object", + "properties": { + "id": { + "type": "string", + "nullable": true + }, + "name": { + "type": "string", + "nullable": true + }, + "age": { + "type": "integer", + "nullable": true + } + }, + "required": [ + "id", + "age" + ], + "additionalProperties": false, + "nullable": true +}` + + // Compare actual JSON with expected JSON + assert.JSONEq(t, expectedJSON, string(data), "JSON schema does not match expected structure") + }) + + t.Run("numeric constraints", func(t *testing.T) { + // Test numeric constraints (min/max) + min := float64(0) + max := float64(100) + + // Integer schema + intSchema := NewIntegerSchema() + intSchema.Minimum = &min + intSchema.Maximum = &max + + data, err := json.MarshalIndent(intSchema, "", " ") + require.NoError(t, err) + + // Define expected JSON schema for integer + expectedIntJSON := `{ + "type": "integer", + "minimum": 0, + "maximum": 100, + "nullable": true +}` + + // Compare actual JSON with expected JSON + assert.JSONEq(t, expectedIntJSON, string(data), "Integer schema does not match expected structure") + + // Number schema + numSchema := NewNumberSchema() + numSchema.Minimum = &min + numSchema.Maximum = &max + + data, err = json.MarshalIndent(numSchema, "", " ") + require.NoError(t, err) + + // Define expected JSON schema for number + expectedNumJSON := `{ + "type": "number", + "minimum": 0, + "maximum": 100, + "nullable": true +}` + + // Compare actual JSON with expected JSON + assert.JSONEq(t, expectedNumJSON, string(data), "Number schema does not match expected structure") + }) + + t.Run("string format", func(t *testing.T) { + // Test string format + schema := NewStringSchema() + schema.Format = "email" + + data, err := json.Marshal(schema) + require.NoError(t, err) + + var parsed map[string]interface{} + err = json.Unmarshal(data, &parsed) + require.NoError(t, err) + + assert.Equal(t, "email", parsed["format"]) + }) + + t.Run("default values", func(t *testing.T) { + // Test default values for different types + stringSchema := NewStringSchema() + stringSchema.Default = "default string" + + intSchema := NewIntegerSchema() + intSchema.Default = 42 + + boolSchema := NewBooleanSchema() + boolSchema.Default = true + + // Test object with default values + objSchema := NewObjectSchema() + objSchema.Properties["str"] = stringSchema + objSchema.Properties["num"] = intSchema + objSchema.Properties["bool"] = boolSchema + + data, err := json.Marshal(objSchema) + require.NoError(t, err) + + var parsed map[string]interface{} + err = json.Unmarshal(data, &parsed) + require.NoError(t, err) + + properties := parsed["properties"].(map[string]interface{}) + + strProp := properties["str"].(map[string]interface{}) + assert.Equal(t, "default string", strProp["default"]) + + numProp := properties["num"].(map[string]interface{}) + assert.Equal(t, float64(42), numProp["default"]) + + boolProp := properties["bool"].(map[string]interface{}) + assert.Equal(t, true, boolProp["default"]) + }) + + t.Run("pattern validation", func(t *testing.T) { + // Test pattern validation for strings + schema := NewStringSchema() + schema.Pattern = "^[a-zA-Z0-9]+$" + + data, err := json.Marshal(schema) + require.NoError(t, err) + + var parsed map[string]interface{} + err = json.Unmarshal(data, &parsed) + require.NoError(t, err) + + assert.Equal(t, "^[a-zA-Z0-9]+$", parsed["pattern"]) + }) + + t.Run("nullable types", func(t *testing.T) { + // Test all nullable types + schemas := []*JsonSchema{ + NewObjectSchema(), + NewArraySchema(NewStringSchema()), + NewStringSchema(), + NewIntegerSchema(), + NewNumberSchema(), + NewBooleanSchema(), + NewEnumSchema([]string{"A", "B"}), + } + + for _, schema := range schemas { + data, err := json.Marshal(schema) + require.NoError(t, err) + + var parsed map[string]interface{} + err = json.Unmarshal(data, &parsed) + require.NoError(t, err) + + // For string type assertions, we expect the primary type (without null) + typeVal := parsed["type"].(string) + assert.NotEqual(t, "null", typeVal) + } + }) + + t.Run("fluent interface", func(t *testing.T) { + // Test fluent interface for building schemas + schema := NewStringSchema(). + WithDescription("A string with format and default"). + WithFormat("email"). + WithDefault("user@example.com") + + data, err := json.Marshal(schema) + require.NoError(t, err) + + var parsed map[string]interface{} + err = json.Unmarshal(data, &parsed) + require.NoError(t, err) + + assert.Equal(t, "A string with format and default", parsed["description"]) + assert.Equal(t, "email", parsed["format"]) + assert.Equal(t, "user@example.com", parsed["default"]) + }) + + t.Run("complex nested schema", func(t *testing.T) { + // Test a complex schema with all features + userSchema := NewObjectSchema() + userSchema.Description = "User schema with all features" + + // Required string with pattern + idSchema := NewStringSchema() + idSchema.Pattern = "^[a-zA-Z0-9]{8,}$" + userSchema.Properties["id"] = idSchema + userSchema.Required = append(userSchema.Required, "id") + + // String with format and default + emailSchema := NewStringSchema() + emailSchema.Format = "email" + emailSchema.Default = "user@example.com" + userSchema.Properties["email"] = emailSchema + + // Integer with constraints + min := float64(13) + ageSchema := NewIntegerSchema() + ageSchema.Minimum = &min + userSchema.Properties["age"] = ageSchema + + // Enum property + roleSchema := NewEnumSchema([]string{"ADMIN", "USER", "GUEST"}) + roleSchema.Default = "USER" + userSchema.Properties["role"] = roleSchema + + // Array of strings + tagsSchema := NewArraySchema(NewStringSchema()) + userSchema.Properties["tags"] = tagsSchema + + // Nested object + addressSchema := NewObjectSchema() + addressSchema.Properties["street"] = NewStringSchema() + addressSchema.Properties["city"] = NewStringSchema() + addressSchema.Required = append(addressSchema.Required, "street") + userSchema.Properties["address"] = addressSchema + + // Serialize the whole thing + data, err := json.MarshalIndent(userSchema, "", " ") + require.NoError(t, err) + + // Define expected JSON schema + expectedJSON := `{ + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^[a-zA-Z0-9]{8,}$", + "nullable": true + }, + "email": { + "type": "string", + "format": "email", + "default": "user@example.com", + "nullable": true + }, + "age": { + "type": "integer", + "minimum": 13, + "nullable": true + }, + "role": { + "type": "string", + "enum": [ + "ADMIN", + "USER", + "GUEST" + ], + "default": "USER", + "nullable": true + }, + "tags": { + "type": "array", + "items": { + "type": "string", + "nullable": true + }, + "nullable": true + }, + "address": { + "type": "object", + "properties": { + "street": { + "type": "string", + "nullable": true + }, + "city": { + "type": "string", + "nullable": true + } + }, + "required": [ + "street" + ], + "additionalProperties": false, + "nullable": true + } + }, + "required": [ + "id" + ], + "additionalProperties": false, + "description": "User schema with all features", + "nullable": true +}` + + // Compare actual JSON with expected JSON + assert.JSONEq(t, expectedJSON, string(data), "JSON schema does not match expected structure") + }) + + t.Run("nullable schema property", func(t *testing.T) { + // Test creating schemas with different nullable settings + + // Create a schema with nullable field + schema := NewObjectSchema() + schema.Properties["nullableString"] = NewStringSchema().WithNullable(true) + schema.Properties["nonNullableString"] = NewStringSchema().WithNullable(false) + + // By default, all types should be nullable + schema.Properties["defaultString"] = NewStringSchema() + + // Check that factory methods set nullable to true by default + intSchema := NewIntegerSchema() + assert.True(t, intSchema.Nullable) + + numSchema := NewNumberSchema() + assert.True(t, numSchema.Nullable) + + boolSchema := NewBooleanSchema() + assert.True(t, boolSchema.Nullable) + + enumSchema := NewEnumSchema([]string{"A", "B"}) + assert.True(t, enumSchema.Nullable) + + arraySchema := NewArraySchema(NewStringSchema()) + assert.True(t, arraySchema.Nullable) + + objSchema := NewObjectSchema() + assert.True(t, objSchema.Nullable) + + // Test serialization + data, err := json.Marshal(schema) + require.NoError(t, err) + + var parsed map[string]interface{} + err = json.Unmarshal(data, &parsed) + require.NoError(t, err) + + properties := parsed["properties"].(map[string]interface{}) + + // Explicitly nullable property should have nullable=true + nullableProp := properties["nullableString"].(map[string]interface{}) + assert.Equal(t, true, nullableProp["nullable"]) + + // Non-nullable property should not have nullable field (omitempty) + nonNullableProp := properties["nonNullableString"].(map[string]interface{}) + _, hasNullable := nonNullableProp["nullable"] + assert.False(t, hasNullable) + + // Default property should have nullable=true + defaultProp := properties["defaultString"].(map[string]interface{}) + assert.Equal(t, true, defaultProp["nullable"]) + + // Test WithNullable method + schema = NewStringSchema() + schema.WithNullable(true) + assert.True(t, schema.Nullable) + + schema.WithNullable(false) + assert.False(t, schema.Nullable) + }) +} diff --git a/v2/pkg/engine/jsonschema/variables_schema.go b/v2/pkg/engine/jsonschema/variables_schema.go new file mode 100644 index 0000000000..e70420e4ee --- /dev/null +++ b/v2/pkg/engine/jsonschema/variables_schema.go @@ -0,0 +1,502 @@ +package jsonschema + +import ( + "fmt" + + "github.com/wundergraph/graphql-go-tools/v2/pkg/ast" + "github.com/wundergraph/graphql-go-tools/v2/pkg/astvisitor" + "github.com/wundergraph/graphql-go-tools/v2/pkg/operationreport" +) + +// VariablesSchemaBuilder creates a unified JSON schema for the variables of a GraphQL operation +type VariablesSchemaBuilder struct { + operationDocument *ast.Document + definitionDocument *ast.Document + schema *JsonSchema + report *operationreport.Report + // Track recursion depth for each type to handle recursive types + recursionTracker map[string]int + maxRecursionDepth int +} + +// Ensure VariablesSchemaBuilder implements the necessary astvisitor interfaces +var ( + _ astvisitor.EnterDocumentVisitor = (*VariablesSchemaBuilder)(nil) + _ astvisitor.EnterVariableDefinitionVisitor = (*VariablesSchemaBuilder)(nil) +) + +// NewVariablesSchemaBuilder creates a new VariablesSchemaBuilder with default settings +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, + } +} + +// EnterDocument implements the astvisitor.EnterDocumentVisitor interface +func (v *VariablesSchemaBuilder) EnterDocument(operation, definition *ast.Document) { + if len(operation.OperationDefinitions) == 0 { + return + } + + v.schema = NewObjectSchema() + v.recursionTracker = make(map[string]int) // Reset recursion tracker for each build + + // Extract descriptions from root fields + var descriptions []string + + operationDefinition := operation.OperationDefinitions[0] + + // Process SelectionSet to extract field descriptions + if operationDefinition.HasSelections { + selectionSetRef := operationDefinition.SelectionSet + for _, selectionRef := range operation.SelectionSets[selectionSetRef].SelectionRefs { + selection := operation.Selections[selectionRef] + if selection.Kind == ast.SelectionKindField { + fieldName := operation.FieldNameString(selection.Ref) + + // Look up field in schema definition to get description + operationType := operationDefinition.OperationType + var rootTypeName string + + // Determine root type based on operation type + switch operationType { + case ast.OperationTypeQuery: + rootTypeName = "Query" + case ast.OperationTypeMutation: + rootTypeName = "Mutation" + case ast.OperationTypeSubscription: + rootTypeName = "Subscription" + default: + v.report.AddInternalError(fmt.Errorf("unsupported operation type %q", operationType)) + return + } + + rootType, exists := definition.Index.FirstNodeByNameStr(rootTypeName) + if exists && rootType.Kind == ast.NodeKindObjectTypeDefinition { + // Find the field in the root type + for _, fieldDefRef := range definition.ObjectTypeDefinitions[rootType.Ref].FieldsDefinition.Refs { + fieldDefName := definition.FieldDefinitionNameString(fieldDefRef) + + // Match field name + if fieldDefName == fieldName && definition.FieldDefinitions[fieldDefRef].Description.IsDefined { + description := definition.FieldDefinitionDescriptionString(fieldDefRef) + if description != "" { + descriptions = append(descriptions, description) + } + break + } + } + } + } + } + } + + // Set concatenated descriptions on root schema if any were found + if len(descriptions) > 0 { + v.schema.Description = "" + for i, desc := range descriptions { + if i > 0 { + v.schema.Description += " " + } + v.schema.Description += desc + } + } +} + +// EnterVariableDefinition implements the astvisitor.EnterVariableDefinitionVisitor interface +func (v *VariablesSchemaBuilder) EnterVariableDefinition(ref int) { + varName := v.operationDocument.VariableDefinitionNameString(ref) + typeRef := v.operationDocument.VariableDefinitions[ref].Type + + // Convert type to schema starting from the operation document + varSchema := v.processOperationTypeRef(typeRef) + + // Skip this variable if we reached maximum recursion depth + if varSchema == nil { + return + } + + // Add variable to required list if it's non-nullable + if v.operationDocument.TypeIsNonNull(typeRef) { + v.schema.Required = append(v.schema.Required, varName) + } + + // Set default value if exists + if v.operationDocument.VariableDefinitionHasDefaultValue(ref) { + defaultValue := v.operationDocument.VariableDefinitionDefaultValue(ref) + varSchema.Default = v.convertOperationValueToNative(defaultValue) + } + + // Force top-level object fields to be not nullable (Nullable=false) so they can't be null + // This ensures they appear as empty objects at minimum + if varSchema.Type == TypeObject { + // Setting Nullable to false means the field can't be null + // Since the nullable field is only included when true, this effectively removes it + // from the output JSON, which is what we want + varSchema.Nullable = false + } + + // Add variable to schema + v.schema.Properties[varName] = varSchema +} + +// GetSchema returns the built schema +func (v *VariablesSchemaBuilder) GetSchema() *JsonSchema { + // If we have required fields, the root schema cannot be nullable + if len(v.schema.Required) > 0 { + v.schema.Nullable = false + } + return v.schema +} + +// GetReport returns the report containing any errors +func (v *VariablesSchemaBuilder) GetReport() *operationreport.Report { + return v.report +} + +// Build traverses the operation and builds a unified JSON schema for its variables +func (v *VariablesSchemaBuilder) Build() (*JsonSchema, error) { + // Create a new walker for AST traversal + walker := astvisitor.NewDefaultWalker() + + // Register this builder as a visitor + walker.RegisterEnterDocumentVisitor(v) + walker.RegisterEnterVariableDefinitionVisitor(v) + + // Walk the AST + walker.Walk(v.operationDocument, v.definitionDocument, v.report) + + if v.report.HasErrors() { + return nil, fmt.Errorf("%s", v.report.Error()) + } + + return v.GetSchema(), nil +} + +// processOperationTypeRef processes a type reference from the operation document +func (v *VariablesSchemaBuilder) processOperationTypeRef(typeRef int) *JsonSchema { + switch v.operationDocument.Types[typeRef].TypeKind { + case ast.TypeKindNonNull: + ofType := v.operationDocument.Types[typeRef].OfType + schema := v.processOperationTypeRef(ofType) + if schema == nil { + return nil + } + // Non-null types are not nullable + schema.Nullable = false + return schema + + case ast.TypeKindList: + ofType := v.operationDocument.Types[typeRef].OfType + itemSchema := v.processOperationTypeRef(ofType) + if itemSchema == nil { + return nil + } + // If we're not in a non-null context, list is nullable + schema := NewArraySchema(itemSchema) + schema.Nullable = true + return schema + + case ast.TypeKindNamed: + typeName := v.operationDocument.TypeNameString(typeRef) + schema := v.processTypeByName(typeName) + if schema != nil { + // If we're not in a non-null context, named type is nullable + schema.Nullable = true + } + return schema + } + + return nil +} + +// processTypeByName processes a type by its name, looking it up in the definition document +func (v *VariablesSchemaBuilder) processTypeByName(typeName string) *JsonSchema { + // Handle built-in scalars + switch typeName { + case "String", "ID": + return NewStringSchema() + case "Int": + return NewIntegerSchema() + case "Float": + return NewNumberSchema() + case "Boolean": + return NewBooleanSchema() + } + + // For custom types, look up in the definition document + node, exists := v.definitionDocument.Index.FirstNodeByNameStr(typeName) + if !exists { + v.report.AddInternalError(fmt.Errorf("type %s is not defined", typeName)) + 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 + } + } + + // Process the type based on its kind + var schema *JsonSchema + switch node.Kind { + case ast.NodeKindEnumTypeDefinition: + schema = v.processEnumType(node) + + case ast.NodeKindInputObjectTypeDefinition: + schema = v.processInputObjectType(node) + + case ast.NodeKindScalarTypeDefinition: + schema = NewAnySchema() + + // Add description if available + if v.definitionDocument.ScalarTypeDefinitions[node.Ref].Description.IsDefined { + schema.Description = v.definitionDocument.ScalarTypeDefinitionDescriptionString(node.Ref) + } + + 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 schema +} + +// processEnumType processes an enum type definition +func (v *VariablesSchemaBuilder) processEnumType(node ast.Node) *JsonSchema { + values := make([]string, 0) + enumDef := v.definitionDocument.EnumTypeDefinitions[node.Ref] + + for _, valueRef := range enumDef.EnumValuesDefinition.Refs { + valueName := v.definitionDocument.EnumValueDefinitionNameString(valueRef) + values = append(values, valueName) + } + + schema := NewEnumSchema(values) + + // Add description if available + if enumDef.Description.IsDefined { + schema.Description = v.definitionDocument.EnumTypeDefinitionDescriptionString(node.Ref) + } + + return schema +} + +// processInputObjectType processes an input object type definition +func (v *VariablesSchemaBuilder) processInputObjectType(node ast.Node) *JsonSchema { + schema := NewObjectSchema() + inputDef := v.definitionDocument.InputObjectTypeDefinitions[node.Ref] + + // Set description if available + if inputDef.Description.IsDefined { + schema.Description = v.definitionDocument.InputObjectTypeDefinitionDescriptionString(node.Ref) + } + + if !inputDef.HasInputFieldsDefinition { + return schema + } + + // Process each input field + for _, fieldRef := range inputDef.InputFieldsDefinition.Refs { + v.processInputField(fieldRef, schema) + } + + return schema +} + +// processInputField processes a single input field +func (v *VariablesSchemaBuilder) processInputField(fieldRef int, schema *JsonSchema) { + fieldName := v.definitionDocument.InputValueDefinitionNameString(fieldRef) + fieldTypeRef := v.definitionDocument.InputValueDefinitionType(fieldRef) + + // Process the field type starting from the definition document + fieldSchema := v.processDefinitionTypeRef(fieldTypeRef) + + // Skip this field if we reached maximum recursion depth + if fieldSchema == nil { + return + } + + // Add to required list if non-nullable + if v.definitionDocument.TypeIsNonNull(fieldTypeRef) { + schema.Required = append(schema.Required, fieldName) + } + + // Set field description if exists + if v.definitionDocument.InputValueDefinitions[fieldRef].Description.IsDefined { + description := v.definitionDocument.InputValueDefinitionDescriptionString(fieldRef) + fieldSchema.Description = description + } + + // Set default value if exists + if v.definitionDocument.InputValueDefinitionHasDefaultValue(fieldRef) { + defaultValue := v.definitionDocument.InputValueDefinitionDefaultValue(fieldRef) + fieldSchema.Default = v.convertDefinitionValueToNative(defaultValue) + } + + // Add field to schema + schema.Properties[fieldName] = fieldSchema +} + +// processDefinitionTypeRef processes a type reference from the definition document +func (v *VariablesSchemaBuilder) processDefinitionTypeRef(typeRef int) *JsonSchema { + switch v.definitionDocument.Types[typeRef].TypeKind { + case ast.TypeKindNonNull: + ofType := v.definitionDocument.Types[typeRef].OfType + schema := v.processDefinitionTypeRef(ofType) + if schema == nil { + return nil + } + // Non-null types are not nullable + schema.Nullable = false + return schema + + case ast.TypeKindList: + ofType := v.definitionDocument.Types[typeRef].OfType + itemSchema := v.processDefinitionTypeRef(ofType) + if itemSchema == nil { + return nil + } + // If we're not in a non-null context, list is nullable + schema := NewArraySchema(itemSchema) + schema.Nullable = true + return schema + + case ast.TypeKindNamed: + typeName := v.definitionDocument.TypeNameString(typeRef) + schema := v.processTypeByName(typeName) + if schema != nil { + // If we're not in a non-null context, named type is nullable + schema.Nullable = true + } + return schema + } + + return nil +} + +// convertOperationValueToNative converts a GraphQL AST value from the operation document to a native Go value +func (v *VariablesSchemaBuilder) convertOperationValueToNative(value ast.Value) interface{} { + switch value.Kind { + case ast.ValueKindString: + return v.operationDocument.StringValueContentString(value.Ref) + case ast.ValueKindInteger: + return v.operationDocument.IntValueAsInt(value.Ref) + case ast.ValueKindFloat: + return v.operationDocument.FloatValueAsFloat32(value.Ref) + case ast.ValueKindBoolean: + return v.operationDocument.BooleanValue(value.Ref) + case ast.ValueKindNull: + return nil + case ast.ValueKindEnum: + return v.operationDocument.EnumValueNameString(value.Ref) + case ast.ValueKindList: + list := make([]interface{}, 0) + for _, itemRef := range v.operationDocument.ListValues[value.Ref].Refs { + item := v.operationDocument.Value(itemRef) + list = append(list, v.convertOperationValueToNative(item)) + } + return list + case ast.ValueKindObject: + obj := make(map[string]interface{}) + for _, fieldRef := range v.operationDocument.ObjectValues[value.Ref].Refs { + fieldName := v.operationDocument.ObjectFieldNameString(fieldRef) + fieldValue := v.operationDocument.ObjectFieldValue(fieldRef) + obj[fieldName] = v.convertOperationValueToNative(fieldValue) + } + return obj + } + + return nil +} + +// convertDefinitionValueToNative converts a GraphQL AST value from the definition document to a native Go value +func (v *VariablesSchemaBuilder) convertDefinitionValueToNative(value ast.Value) interface{} { + switch value.Kind { + case ast.ValueKindString: + return v.definitionDocument.StringValueContentString(value.Ref) + case ast.ValueKindInteger: + return v.definitionDocument.IntValueAsInt(value.Ref) + case ast.ValueKindFloat: + return v.definitionDocument.FloatValueAsFloat32(value.Ref) + case ast.ValueKindBoolean: + return v.definitionDocument.BooleanValue(value.Ref) + case ast.ValueKindNull: + return nil + case ast.ValueKindEnum: + return v.definitionDocument.EnumValueNameString(value.Ref) + case ast.ValueKindList: + list := make([]interface{}, 0) + for _, itemRef := range v.definitionDocument.ListValues[value.Ref].Refs { + item := v.definitionDocument.Value(itemRef) + list = append(list, v.convertDefinitionValueToNative(item)) + } + return list + case ast.ValueKindObject: + obj := make(map[string]interface{}) + for _, fieldRef := range v.definitionDocument.ObjectValues[value.Ref].Refs { + fieldName := v.definitionDocument.ObjectFieldNameString(fieldRef) + fieldValue := v.definitionDocument.ObjectFieldValue(fieldRef) + obj[fieldName] = v.convertDefinitionValueToNative(fieldValue) + } + return obj + } + + return nil +} + +// BuildJsonSchema builds a JSON schema for the variables of the given operation +// using the default recursion depth of 1 +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() +} diff --git a/v2/pkg/engine/jsonschema/variables_schema_test.go b/v2/pkg/engine/jsonschema/variables_schema_test.go new file mode 100644 index 0000000000..16919bd4f5 --- /dev/null +++ b/v2/pkg/engine/jsonschema/variables_schema_test.go @@ -0,0 +1,1570 @@ +package jsonschema + +import ( + "encoding/json" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/wundergraph/graphql-go-tools/v2/pkg/astparser" +) + +// scalarDefinitions contains the basic scalar types that need to be defined for tests +const scalarDefinitions = ` +scalar String +scalar Int +scalar Float +scalar Boolean +scalar ID +` + +func TestBuildJsonSchema(t *testing.T) { + t.Run("simple query with input object", func(t *testing.T) { + // Define schema + schemaSDL := scalarDefinitions + ` + schema { + query: Query + } + + type Query { + findEmployees(criteria: SearchInput): EmployeeResult + } + + type EmployeeResult { + details: EmployeeDetails + } + + type EmployeeDetails { + forename: String + } + + """Input criteria used to search for employees""" + input SearchInput { + name: String! + department: String + employmentStatus: EmploymentStatus + } + + enum EmploymentStatus { + FULL_TIME + PART_TIME + CONTRACTOR + INTERN + } + ` + + // Define operation + operationSDL := ` + query MyEmployees($criteria: SearchInput) { + findEmployees(criteria: $criteria) { + details { + forename + } + } + } + ` + + // Parse schema and operation + definitionDoc, report := astparser.ParseGraphqlDocumentString(schemaSDL) + require.False(t, report.HasErrors(), "schema parsing failed") + + operationDoc, report := astparser.ParseGraphqlDocumentString(operationSDL) + require.False(t, report.HasErrors(), "operation parsing failed") + + // Build JSON schema + schema, err := BuildJsonSchema(&operationDoc, &definitionDoc) + require.NoError(t, err) + + // Serialize schema to JSON + data, err := json.MarshalIndent(schema, "", " ") + require.NoError(t, err) + + // Define expected JSON schema + expectedJSON := `{ + "type": "object", + "properties": { + "criteria": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "department": { + "type": "string", + "nullable": true + }, + "employmentStatus": { + "type": "string", + "enum": [ + "FULL_TIME", + "PART_TIME", + "CONTRACTOR", + "INTERN" + ], + "nullable": true + } + }, + "required": [ + "name" + ], + "additionalProperties": false, + "description": "Input criteria used to search for employees", + "nullable": false + } + }, + "additionalProperties": false, + "nullable": true +}` + + // Compare actual JSON with expected JSON + assert.JSONEq(t, expectedJSON, string(data), "JSON schema does not match expected structure") + }) + + t.Run("query with nested input objects", func(t *testing.T) { + // Define schema with nested inputs + schemaSDL := scalarDefinitions + ` + schema { + query: Query + } + + type Query { + findEmployees(criteria: SearchInput): [Employee] + } + + type Employee { + id: ID! + name: String + } + + input SearchInput { + name: String + nested: NestedInput! + } + + input NestedInput { + hasChildren: Boolean + maritalStatus: MaritalStatus + nationality: Nationality! + } + + enum MaritalStatus { + MARRIED + ENGAGED + } + + enum Nationality { + AMERICAN + DUTCH + ENGLISH + GERMAN + INDIAN + SPANISH + UKRAINIAN + } + ` + + // Define operation + operationSDL := ` + query MyEmployees($criteria: SearchInput!) { + findEmployees(criteria: $criteria) { + id + } + } + ` + + // Parse schema and operation + definitionDoc, report := astparser.ParseGraphqlDocumentString(schemaSDL) + require.False(t, report.HasErrors(), "schema parsing failed") + + operationDoc, report := astparser.ParseGraphqlDocumentString(operationSDL) + require.False(t, report.HasErrors(), "operation parsing failed") + + // Build JSON schema + schema, err := BuildJsonSchema(&operationDoc, &definitionDoc) + require.NoError(t, err) + + // Serialize schema to JSON + data, err := json.MarshalIndent(schema, "", " ") + require.NoError(t, err) + + // Define expected JSON schema + expectedJSON := `{ + "type": "object", + "properties": { + "criteria": { + "type": "object", + "properties": { + "name": { + "type": "string", + "nullable": true + }, + "nested": { + "type": "object", + "properties": { + "hasChildren": { + "type": "boolean", + "nullable": true + }, + "maritalStatus": { + "type": "string", + "enum": [ + "MARRIED", + "ENGAGED" + ], + "nullable": true + }, + "nationality": { + "type": "string", + "enum": [ + "AMERICAN", + "DUTCH", + "ENGLISH", + "GERMAN", + "INDIAN", + "SPANISH", + "UKRAINIAN" + ] + } + }, + "required": [ + "nationality" + ], + "additionalProperties": false, + "nullable": false + } + }, + "required": [ + "nested" + ], + "additionalProperties": false, + "nullable": false + } + }, + "required": [ + "criteria" + ], + "additionalProperties": false, + "nullable": false +}` + + // Compare actual JSON with expected JSON + assert.JSONEq(t, expectedJSON, string(data), "JSON schema does not match expected structure") + }) + + t.Run("query with default values", func(t *testing.T) { + // Define schema with default values + schemaSDL := scalarDefinitions + ` + schema { + query: Query + } + + type Query { + getItems(filter: FilterInput): [Item] + } + + type Item { + id: ID + name: String + } + + input FilterInput { + limit: Int = 10 + includeDeleted: Boolean = false + status: Status = ACTIVE + } + + enum Status { + ACTIVE + PENDING + DELETED + } + ` + + // Define operation + operationSDL := ` + query GetItems($filter: FilterInput = {limit: 5}) { + getItems(filter: $filter) { + id + } + } + ` + + // Parse schema and operation + definitionDoc, report := astparser.ParseGraphqlDocumentString(schemaSDL) + require.False(t, report.HasErrors(), "schema parsing failed") + + operationDoc, report := astparser.ParseGraphqlDocumentString(operationSDL) + require.False(t, report.HasErrors(), "operation parsing failed") + + // Build JSON schema + schema, err := BuildJsonSchema(&operationDoc, &definitionDoc) + require.NoError(t, err) + + // Serialize schema to JSON + data, err := json.MarshalIndent(schema, "", " ") + require.NoError(t, err) + + // Verify schema structure + var parsed map[string]interface{} + err = json.Unmarshal(data, &parsed) + require.NoError(t, err) + + // Verify filter property with default values + properties := parsed["properties"].(map[string]interface{}) + filter := properties["filter"].(map[string]interface{}) + + // Verify top-level default value + assert.Equal(t, map[string]interface{}{"limit": float64(5)}, filter["default"]) + + // Verify filter properties + filterProps := filter["properties"].(map[string]interface{}) + + // Verify input object default values + limit := filterProps["limit"].(map[string]interface{}) + assert.Equal(t, float64(10), limit["default"]) + + includeDeleted := filterProps["includeDeleted"].(map[string]interface{}) + assert.Equal(t, false, includeDeleted["default"]) + + status := filterProps["status"].(map[string]interface{}) + assert.Equal(t, "ACTIVE", status["default"]) + }) + + t.Run("query with scalar arguments", func(t *testing.T) { + // Define schema with scalar arguments + schemaSDL := scalarDefinitions + ` + schema { + query: Query + } + + type Query { + getUser(id: ID!, includeProfile: Boolean): User + } + + type User { + id: ID! + name: String + age: Int + rating: Float + active: Boolean + } + ` + + // Define operation + operationSDL := ` + query GetUser($id: ID!, $includeProfile: Boolean = true, $age: Int, $rating: Float, $name: String) { + getUser(id: $id, includeProfile: $includeProfile) { + id + name + age + } + } + ` + + // Parse schema and operation + definitionDoc, report := astparser.ParseGraphqlDocumentString(schemaSDL) + require.False(t, report.HasErrors(), "schema parsing failed") + + operationDoc, report := astparser.ParseGraphqlDocumentString(operationSDL) + require.False(t, report.HasErrors(), "operation parsing failed") + + // Build JSON schema + schema, err := BuildJsonSchema(&operationDoc, &definitionDoc) + require.NoError(t, err) + + // Serialize schema to JSON + data, err := json.MarshalIndent(schema, "", " ") + require.NoError(t, err) + + // Verify schema structure + var parsed map[string]interface{} + err = json.Unmarshal(data, &parsed) + require.NoError(t, err) + + // Verify top-level structure + assert.Equal(t, "object", parsed["type"]) + properties := parsed["properties"].(map[string]interface{}) + + // Verify required fields + required := parsed["required"].([]interface{}) + assert.Contains(t, required, "id") + + // Verify ID property + id, ok := properties["id"].(map[string]interface{}) + require.True(t, ok) + assert.Equal(t, "string", id["type"]) + + // Verify includeProfile property + includeProfile, ok := properties["includeProfile"].(map[string]interface{}) + require.True(t, ok) + assert.Equal(t, "boolean", includeProfile["type"]) + assert.Equal(t, true, includeProfile["default"]) + + // Verify age property + age, ok := properties["age"].(map[string]interface{}) + require.True(t, ok) + assert.Equal(t, "integer", age["type"]) + + // Verify rating property + rating, ok := properties["rating"].(map[string]interface{}) + require.True(t, ok) + assert.Equal(t, "number", rating["type"]) + + // Verify name property + name, ok := properties["name"].(map[string]interface{}) + require.True(t, ok) + assert.Equal(t, "string", name["type"]) + }) + + t.Run("operation with field descriptions", func(t *testing.T) { + // Define schema with field descriptions + schemaSDL := scalarDefinitions + ` + schema { + query: Query + } + + type Query { + """Description for getUser field""" + getUser(id: ID!): User + + """Description for findUsers field""" + findUsers(filter: UserFilter): [User] + } + + type User { + id: ID! + name: String + } + + input UserFilter { + name: String + age: Int + } + ` + + // Define operation + operationSDL := ` + query GetUserInfo($id: ID!, $filter: UserFilter) { + getUser(id: $id) { + id + name + } + findUsers(filter: $filter) { + id + } + } + ` + + // Parse schema and operation + definitionDoc, report := astparser.ParseGraphqlDocumentString(schemaSDL) + require.False(t, report.HasErrors(), "schema parsing failed") + + operationDoc, report := astparser.ParseGraphqlDocumentString(operationSDL) + require.False(t, report.HasErrors(), "operation parsing failed") + + // Build JSON schema + schema, err := BuildJsonSchema(&operationDoc, &definitionDoc) + require.NoError(t, err) + + // Verify schema root description contains field descriptions + assert.Contains(t, schema.Description, "Description for getUser field") + assert.Contains(t, schema.Description, "Description for findUsers field") + }) + + t.Run("error handling for undefined types", func(t *testing.T) { + // Schema missing SearchInput definition + schemaSDL := scalarDefinitions + ` + schema { + query: Query + } + + type Query { + search(input: SearchInput): String + } + ` + + // Operation using SearchInput + operationSDL := ` + query Search($input: SearchInput) { + search(input: $input) + } + ` + + // Parse schema and operation + definitionDoc, report1 := astparser.ParseGraphqlDocumentString(schemaSDL) + require.False(t, report1.HasErrors(), "operation parsing failed") + + operationDoc, report2 := astparser.ParseGraphqlDocumentString(operationSDL) + require.False(t, report2.HasErrors(), "operation parsing failed") + + // Build should return error because type is not defined + builder := NewVariablesSchemaBuilder(&operationDoc, &definitionDoc) + + // Try to build schema for operation with undefined type + _, err := builder.Build() + assert.Error(t, err) + }) + + t.Run("comprehensive test for required arguments", func(t *testing.T) { + // Define schema with various required and optional fields + schemaSDL := scalarDefinitions + ` + schema { + query: Query + } + + type Query { + search(requiredArg: String!, optionalArg: Int): SearchResult + } + + type SearchResult { + id: ID! + } + + input RequiredArgsInput { + requiredField: String! + optionalField: Float + requiredNestedInput: RequiredNestedInput! + optionalNestedInput: OptionalNestedInput + } + + input RequiredNestedInput { + requiredInnerField: Boolean! + optionalInnerField: String + } + + input OptionalNestedInput { + innerField: Int + } + ` + + // Define operation + operationSDL := ` + query Search( + $requiredArg: String!, + $optionalArg: Int, + $requiredInput: RequiredArgsInput!, + $optionalInput: RequiredArgsInput + ) { + search(requiredArg: $requiredArg, optionalArg: $optionalArg) + } + ` + + // Parse schema and operation + definitionDoc, report := astparser.ParseGraphqlDocumentString(schemaSDL) + require.False(t, report.HasErrors(), "schema parsing failed") + + operationDoc, report := astparser.ParseGraphqlDocumentString(operationSDL) + require.False(t, report.HasErrors(), "operation parsing failed") + + // Build JSON schema + schema, err := BuildJsonSchema(&operationDoc, &definitionDoc) + require.NoError(t, err) + + // Serialize schema to JSON + data, err := json.MarshalIndent(schema, "", " ") + require.NoError(t, err) + + // Verify schema structure + var parsed map[string]interface{} + err = json.Unmarshal(data, &parsed) + require.NoError(t, err) + + // Verify top-level required fields + required, ok := parsed["required"].([]interface{}) + require.True(t, ok) + assert.Contains(t, required, "requiredArg") + assert.Contains(t, required, "requiredInput") + assert.NotContains(t, required, "optionalArg") + assert.NotContains(t, required, "optionalInput") + + // Verify properties + properties := parsed["properties"].(map[string]interface{}) + + // Check required input structure + requiredInput := properties["requiredInput"].(map[string]interface{}) + assert.Equal(t, "object", requiredInput["type"]) + + // Check required fields within input + inputRequired := requiredInput["required"].([]interface{}) + assert.Contains(t, inputRequired, "requiredField") + assert.Contains(t, inputRequired, "requiredNestedInput") + assert.NotContains(t, inputRequired, "optionalField") + assert.NotContains(t, inputRequired, "optionalNestedInput") + + // Check nested input structure + inputProperties := requiredInput["properties"].(map[string]interface{}) + requiredNestedInput := inputProperties["requiredNestedInput"].(map[string]interface{}) + assert.Equal(t, "object", requiredNestedInput["type"]) + + // Check required fields within nested input + nestedRequired := requiredNestedInput["required"].([]interface{}) + assert.Contains(t, nestedRequired, "requiredInnerField") + assert.NotContains(t, nestedRequired, "optionalInnerField") + }) + + t.Run("deeply nested types with mixed requirements", func(t *testing.T) { + // Define schema with deeply nested types + schemaSDL := scalarDefinitions + ` + schema { + query: Query + } + + type Query { + complexSearch(input: Level1Input): SearchResult + } + + type SearchResult { + id: ID! + } + + """Level 1 input description""" + input Level1Input { + field1: String + nested: Level2Input! + optionalArray: [String] + requiredArray: [Int]! + } + + """Level 2 input description""" + input Level2Input { + field2: Boolean + deeper: Level3Input! + arrayOfObjects: [Level3Input] + } + + """Level 3 input description""" + input Level3Input { + field3: Float + enumField: DeepEnum! + arrayOfArrays: [[String!]!] + } + + enum DeepEnum { + OPTION_1 + OPTION_2 + OPTION_3 + } + ` + + // Define operation + operationSDL := ` + query DeepSearch($input: Level1Input!) { + complexSearch(input: $input) + } + ` + + // Parse schema and operation + definitionDoc, report := astparser.ParseGraphqlDocumentString(schemaSDL) + require.False(t, report.HasErrors(), "schema parsing failed") + + operationDoc, report := astparser.ParseGraphqlDocumentString(operationSDL) + require.False(t, report.HasErrors(), "operation parsing failed") + + // Build JSON schema + schema, err := BuildJsonSchema(&operationDoc, &definitionDoc) + require.NoError(t, err) + + // Serialize schema to JSON + data, err := json.MarshalIndent(schema, "", " ") + require.NoError(t, err) + + // Define expected JSON schema + expectedJSON := `{ + "type": "object", + "properties": { + "input": { + "type": "object", + "properties": { + "field1": { + "type": "string", + "nullable": true + }, + "nested": { + "type": "object", + "properties": { + "field2": { + "type": "boolean", + "nullable": true + }, + "deeper": { + "type": "object", + "properties": { + "field3": { + "type": "number", + "nullable": true + }, + "enumField": { + "type": "string", + "enum": [ + "OPTION_1", + "OPTION_2", + "OPTION_3" + ] + }, + "arrayOfArrays": { + "type": "array", + "items": { + "type": "array", + "items": { + "type": "string" + } + }, + "nullable": true + } + }, + "required": [ + "enumField" + ], + "additionalProperties": false, + "description": "Level 3 input description", + "nullable": false + }, + "arrayOfObjects": { + "type": "array", + "items": { + "type": "object", + "properties": { + "field3": { + "type": "number", + "nullable": true + }, + "enumField": { + "type": "string", + "enum": [ + "OPTION_1", + "OPTION_2", + "OPTION_3" + ] + }, + "arrayOfArrays": { + "type": "array", + "items": { + "type": "array", + "items": { + "type": "string" + } + }, + "nullable": true + } + }, + "required": [ + "enumField" + ], + "additionalProperties": false, + "description": "Level 3 input description", + "nullable": true + }, + "nullable": true + } + }, + "required": [ + "deeper" + ], + "additionalProperties": false, + "description": "Level 2 input description", + "nullable": false + }, + "optionalArray": { + "type": "array", + "items": { + "type": "string", + "nullable": true + }, + "nullable": true + }, + "requiredArray": { + "type": "array", + "items": { + "type": "integer", + "nullable": true + } + } + }, + "required": [ + "nested", + "requiredArray" + ], + "additionalProperties": false, + "description": "Level 1 input description", + "nullable": false + } + }, + "required": [ + "input" + ], + "additionalProperties": false, + "nullable": false +}` + + // Compare actual JSON with expected JSON + assert.JSONEq(t, expectedJSON, string(data), "JSON schema does not match expected structure") + }) + + t.Run("recursive types with default recursion depth", 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! + name: String + value: Int + children: [RecursiveNode] + parent: RecursiveNode + } + ` + + // Define operation + operationSDL := ` + query ProcessTree($node: RecursiveNode!) { + processNode(node: $node) + } + ` + + // Parse schema and operation + definitionDoc, report := astparser.ParseGraphqlDocumentString(schemaSDL) + require.False(t, report.HasErrors(), "schema parsing failed") + + operationDoc, report := astparser.ParseGraphqlDocumentString(operationSDL) + require.False(t, report.HasErrors(), "operation parsing failed") + + // Build JSON schema with default recursion depth (1) + schema, err := BuildJsonSchema(&operationDoc, &definitionDoc) + require.NoError(t, err) + + // Verify we got a valid schema back + require.NotNil(t, schema, "Should have a valid schema") + + // Serialize to JSON to check it's valid + data, err := json.Marshal(schema) + require.NoError(t, err) + require.NotEmpty(t, data, "JSON serialization should not be empty") + + // Parse the JSON to verify it's valid + var result interface{} + err = json.Unmarshal(data, &result) + require.NoError(t, err, "Schema should be valid JSON") + + // Basic structure checks + jsonMap, ok := result.(map[string]interface{}) + require.True(t, ok, "Schema should be a JSON object") + + // Check top-level fields + assert.Equal(t, "object", jsonMap["type"], "Schema should be an object type") + assert.Contains(t, jsonMap, "properties", "Schema should have properties") + + // Log the schema for debugging + t.Logf("Default recursion depth schema: %v", string(data)) + }) + + t.Run("recursive types with custom recursion depth", 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! + name: String + value: Int + children: [RecursiveNode] + } + ` + + // Define operation + operationSDL := ` + query ProcessTree($node: RecursiveNode!) { + processNode(node: $node) + } + ` + + // Parse schema and operation + definitionDoc, report := astparser.ParseGraphqlDocumentString(schemaSDL) + require.False(t, report.HasErrors(), "schema parsing failed") + + 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) + require.NoError(t, err) + + // Convert both to JSON for analysis + customData, err := json.Marshal(customSchema) + require.NoError(t, err) + defaultData, err := json.Marshal(defaultSchema) + 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)) + }) + + t.Run("query with two nested arguments", func(t *testing.T) { + // Define schema with two complex input types + schemaSDL := scalarDefinitions + ` + schema { + query: Query + } + + type Query { + searchUsers(userFilter: UserFilter, orderBy: OrderByInput): [User] + } + + type User { + id: ID! + name: String + email: String + } + + """Input for filtering users""" + input UserFilter { + nameContains: String + emailDomain: String + status: UserStatus + metadata: MetadataInput + } + + """Input for ordering results""" + input OrderByInput { + field: OrderableField! + direction: SortDirection! + nullsPosition: NullsPosition + } + + input MetadataInput { + tags: [String!] + createdAfter: String + createdBefore: String + } + + enum UserStatus { + ACTIVE + INACTIVE + PENDING + } + + enum OrderableField { + NAME + EMAIL + CREATED_AT + UPDATED_AT + } + + enum SortDirection { + ASC + DESC + } + + enum NullsPosition { + FIRST + LAST + } + ` + + // Define operation using both input types + operationSDL := ` + query FindUsers($filter: UserFilter!, $order: OrderByInput!) { + searchUsers(userFilter: $filter, orderBy: $order) { + id + name + email + } + } + ` + + // Parse schema and operation + definitionDoc, report := astparser.ParseGraphqlDocumentString(schemaSDL) + require.False(t, report.HasErrors(), "schema parsing failed") + + operationDoc, report := astparser.ParseGraphqlDocumentString(operationSDL) + require.False(t, report.HasErrors(), "operation parsing failed") + + // Build JSON schema + schema, err := BuildJsonSchema(&operationDoc, &definitionDoc) + require.NoError(t, err) + + // Serialize schema to JSON + data, err := json.MarshalIndent(schema, "", " ") + require.NoError(t, err) + + // Verify schema structure + var parsed map[string]interface{} + err = json.Unmarshal(data, &parsed) + require.NoError(t, err) + + // Verify top-level structure + assert.Equal(t, "object", parsed["type"]) + + // Verify both inputs are required + required, ok := parsed["required"].([]interface{}) + require.True(t, ok) + assert.Contains(t, required, "filter") + assert.Contains(t, required, "order") + + // Verify properties exist + properties := parsed["properties"].(map[string]interface{}) + assert.Contains(t, properties, "filter") + assert.Contains(t, properties, "order") + + // Verify filter structure + filter := properties["filter"].(map[string]interface{}) + assert.Equal(t, "object", filter["type"]) + assert.Equal(t, "Input for filtering users", filter["description"]) + assert.Contains(t, filter["properties"], "metadata") + + // Verify order structure + order := properties["order"].(map[string]interface{}) + assert.Equal(t, "object", order["type"]) + assert.Equal(t, "Input for ordering results", order["description"]) + + // Verify order required fields + orderRequired := order["required"].([]interface{}) + assert.Contains(t, orderRequired, "field") + assert.Contains(t, orderRequired, "direction") + + // Verify enum values + orderProps := order["properties"].(map[string]interface{}) + direction := orderProps["direction"].(map[string]interface{}) + directionEnum := direction["enum"].([]interface{}) + assert.ElementsMatch(t, []interface{}{"ASC", "DESC"}, directionEnum) + }) + + t.Run("mutually recursive types", func(t *testing.T) { + // Define schema with mutually recursive input types + schemaSDL := scalarDefinitions + ` + schema { + query: Query + } + + type Query { + processA(a: TypeA): Boolean + } + + input TypeA { + id: ID! + name: String + b: TypeB + } + + input TypeB { + id: ID! + description: String + a: TypeA + } + ` + + // Define operation + operationSDL := ` + query ProcessA($a: TypeA!) { + processA(a: $a) + } + ` + + // Parse schema and operation + definitionDoc, report := astparser.ParseGraphqlDocumentString(schemaSDL) + require.False(t, report.HasErrors(), "schema parsing failed") + + operationDoc, report := astparser.ParseGraphqlDocumentString(operationSDL) + require.False(t, report.HasErrors(), "operation parsing failed") + + // Build JSON schema + schema, err := BuildJsonSchema(&operationDoc, &definitionDoc) + require.NoError(t, err) + + // Serialize schema to JSON + data, err := json.MarshalIndent(schema, "", " ") + require.NoError(t, err) + + // Define expected JSON schema - this may vary based on recursion depth setting + expectedJSON := `{ + "type": "object", + "properties": { + "a": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "name": { + "type": "string", + "nullable": true + }, + "b": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "description": { + "type": "string", + "nullable": true + } + }, + "required": [ + "id" + ], + "additionalProperties": false, + "nullable": true + } + }, + "required": [ + "id" + ], + "additionalProperties": false, + "nullable": false + } + }, + "required": [ + "a" + ], + "additionalProperties": false, + "nullable": false +}` + + // Compare actual JSON with expected JSON + assert.JSONEq(t, expectedJSON, string(data), "JSON schema does not match expected structure") + }) + + t.Run("correctly handles nullable and non-nullable fields", func(t *testing.T) { + // Define schema with a mix of nullable and non-nullable fields + schemaSDL := scalarDefinitions + ` + schema { + query: Query + } + + type Query { + findUser(input: UserInput): User + } + + type User { + id: ID! + name: String + } + + input UserInput { + id: ID + name: String! + age: Int + tags: [String] + requiredTags: [String]! + nonNullTags: [String!] + requiredNonNullTags: [String!]! + nested: NestedInput + requiredNested: NestedInput! + } + + input NestedInput { + field: String + requiredField: String! + } + ` + + // Define operation + operationSDL := ` + query FindUser($input: UserInput) { + findUser(input: $input) { + id + name + } + } + ` + + // Parse schema and operation + definitionDoc, report := astparser.ParseGraphqlDocumentString(schemaSDL) + require.False(t, report.HasErrors(), "schema parsing failed") + + operationDoc, report := astparser.ParseGraphqlDocumentString(operationSDL) + require.False(t, report.HasErrors(), "operation parsing failed") + + // Build JSON schema + schema, err := BuildJsonSchema(&operationDoc, &definitionDoc) + require.NoError(t, err) + + // Serialize schema to JSON + data, err := json.MarshalIndent(schema, "", " ") + require.NoError(t, err) + + // Define expected JSON schema + expectedJSON := `{ + "type": "object", + "properties": { + "input": { + "type": "object", + "properties": { + "id": { + "type": "string", + "nullable": true + }, + "name": { + "type": "string" + }, + "age": { + "type": "integer", + "nullable": true + }, + "tags": { + "type": "array", + "items": { + "type": "string", + "nullable": true + }, + "nullable": true + }, + "requiredTags": { + "type": "array", + "items": { + "type": "string", + "nullable": true + } + }, + "nonNullTags": { + "type": "array", + "items": { + "type": "string" + }, + "nullable": true + }, + "requiredNonNullTags": { + "type": "array", + "items": { + "type": "string" + } + }, + "nested": { + "type": "object", + "properties": { + "field": { + "type": "string", + "nullable": true + }, + "requiredField": { + "type": "string" + } + }, + "required": [ + "requiredField" + ], + "additionalProperties": false, + "nullable": true + }, + "requiredNested": { + "type": "object", + "properties": { + "field": { + "type": "string", + "nullable": true + }, + "requiredField": { + "type": "string" + } + }, + "required": [ + "requiredField" + ], + "additionalProperties": false, + "nullable": false + } + }, + "required": [ + "name", + "requiredTags", + "requiredNonNullTags", + "requiredNested" + ], + "additionalProperties": false, + "nullable": false + } + }, + "additionalProperties": false, + "nullable": true +}` + + // Compare actual JSON with expected JSON + assert.JSONEq(t, expectedJSON, string(data), "JSON schema does not match expected structure") + }) + + t.Run("root schema nullable based on required arguments", func(t *testing.T) { + // Define schema with required and optional arguments + schemaSDL := scalarDefinitions + ` + schema { + query: Query + } + + type Query { + findUser(id: ID!, name: String): User + } + + type User { + id: ID! + name: String + } + ` + + // Test case 1: Operation with required argument + operationWithRequired := ` + query GetUser($id: ID!) { + findUser(id: $id) { + id + name + } + } + ` + + // Test case 2: Operation with only optional arguments + operationOptionalOnly := ` + query GetUserByName($name: String) { + findUser(id: "fixed-id", name: $name) { + id + name + } + } + ` + + // Parse schema + definitionDoc, report := astparser.ParseGraphqlDocumentString(schemaSDL) + require.False(t, report.HasErrors(), "schema parsing failed") + + // Parse and test operation with required argument + operationDoc1, report := astparser.ParseGraphqlDocumentString(operationWithRequired) + require.False(t, report.HasErrors(), "operation parsing failed") + + schema1, err := BuildJsonSchema(&operationDoc1, &definitionDoc) + require.NoError(t, err) + + // Convert to JSON to check nullable field + data1, err := json.MarshalIndent(schema1, "", " ") + require.NoError(t, err) + + // Define expected JSON schema for required argument case + expectedJSON1 := `{ + "type": "object", + "properties": { + "id": { + "type": "string" + } + }, + "required": [ + "id" + ], + "additionalProperties": false, + "nullable": false +}` + + // Compare actual JSON with expected JSON + assert.JSONEq(t, expectedJSON1, string(data1), "Required argument schema does not match expected structure") + + // Parse and test operation with only optional arguments + operationDoc2, report := astparser.ParseGraphqlDocumentString(operationOptionalOnly) + require.False(t, report.HasErrors(), "operation parsing failed") + + schema2, err := BuildJsonSchema(&operationDoc2, &definitionDoc) + require.NoError(t, err) + + // Convert to JSON to check nullable field + data2, err := json.MarshalIndent(schema2, "", " ") + require.NoError(t, err) + + // Define expected JSON schema for optional argument case + expectedJSON2 := `{ + "type": "object", + "properties": { + "name": { + "type": "string", + "nullable": true + } + }, + "additionalProperties": false, + "nullable": true +}` + + // Compare actual JSON with expected JSON + assert.JSONEq(t, expectedJSON2, string(data2), "Optional argument schema does not match expected structure") + }) + + t.Run("top-level object fields are not nullable", func(t *testing.T) { + // Define schema + schemaSDL := scalarDefinitions + ` + schema { + query: Query + } + + type Query { + findEmployees(criteria: SearchInput): [Employee] + } + + type Employee { + id: ID! + isAvailable: Boolean + details: EmployeeDetails + } + + type EmployeeDetails { + forename: String + nationality: String + } + + input SearchInput { + name: String + department: String + } + ` + + // Define operation + operationSDL := ` + query MyEmployees($criteria: SearchInput) { + findEmployees(criteria: $criteria) { + id + isAvailable + details { + forename + nationality + } + } + } + ` + + // Parse schema and operation + definitionDoc, report := astparser.ParseGraphqlDocumentString(schemaSDL) + require.False(t, report.HasErrors(), "schema parsing failed") + + operationDoc, report := astparser.ParseGraphqlDocumentString(operationSDL) + require.False(t, report.HasErrors(), "operation parsing failed") + + // Build JSON schema + schema, err := BuildJsonSchema(&operationDoc, &definitionDoc) + require.NoError(t, err) + + // Serialize schema to JSON to check what's exported + data, err := json.MarshalIndent(schema, "", " ") + require.NoError(t, err) + + // Define expected JSON schema + expectedJSON := `{ + "type": "object", + "properties": { + "criteria": { + "type": "object", + "properties": { + "name": { + "type": "string", + "nullable": true + }, + "department": { + "type": "string", + "nullable": true + } + }, + "additionalProperties": false, + "nullable": false + } + }, + "additionalProperties": false, + "nullable": true +}` + + // Compare actual JSON with expected JSON + assert.JSONEq(t, expectedJSON, string(data), "JSON schema does not match expected structure") + }) + + t.Run("custom scalar types are represented as objects", func(t *testing.T) { + // Define schema with custom scalar types + schemaSDL := scalarDefinitions + ` + schema { + query: Query + } + + """ISO-8601 date time format""" + scalar DateTime + + """JSON object represented as string""" + scalar JSON + + type Query { + searchEvents(from: DateTime, filter: JSON): [Event] + } + + type Event { + id: ID! + timestamp: DateTime + data: JSON + } + ` + + // Define operation using custom scalar types + operationSDL := ` + query SearchEvents($from: DateTime, $filter: JSON) { + searchEvents(from: $from, filter: $filter) { + id + timestamp + data + } + } + ` + + // Parse schema and operation + definitionDoc, report := astparser.ParseGraphqlDocumentString(schemaSDL) + require.False(t, report.HasErrors(), "schema parsing failed") + + operationDoc, report := astparser.ParseGraphqlDocumentString(operationSDL) + require.False(t, report.HasErrors(), "operation parsing failed") + + // Build JSON schema + schema, err := BuildJsonSchema(&operationDoc, &definitionDoc) + require.NoError(t, err) + + // Serialize schema to JSON + data, err := json.MarshalIndent(schema, "", " ") + require.NoError(t, err) + + // Define expected JSON schema + expectedJSON := `{ + "type": "object", + "properties": { + "from": { + "nullable": true, + "description": "ISO-8601 date time format" + }, + "filter": { + "nullable": true, + "description": "JSON object represented as string" + } + }, + "additionalProperties": false, + "nullable": true +}` + + // Compare actual JSON with expected JSON + assert.JSONEq(t, expectedJSON, string(data), "JSON schema does not match expected structure") + }) +}