From c7b87f50bea71612f560a86f845ed23a21211171 Mon Sep 17 00:00:00 2001 From: StarpTech Date: Fri, 11 Apr 2025 17:56:08 +0200 Subject: [PATCH 01/16] feat: MCP graph server --- v2/pkg/engine/jsonschema/schema.go | 229 ++ v2/pkg/engine/jsonschema/schema_test.go | 460 ++++ v2/pkg/engine/jsonschema/variables_schema.go | 427 ++++ .../jsonschema/variables_schema_test.go | 976 ++++++++ v2/pkg/graphqljsonschema/jsonschema.go | 475 ---- v2/pkg/graphqljsonschema/jsonschema_test.go | 2021 ----------------- 6 files changed, 2092 insertions(+), 2496 deletions(-) create mode 100644 v2/pkg/engine/jsonschema/schema.go create mode 100644 v2/pkg/engine/jsonschema/schema_test.go create mode 100644 v2/pkg/engine/jsonschema/variables_schema.go create mode 100644 v2/pkg/engine/jsonschema/variables_schema_test.go delete mode 100644 v2/pkg/graphqljsonschema/jsonschema.go delete mode 100644 v2/pkg/graphqljsonschema/jsonschema_test.go diff --git a/v2/pkg/engine/jsonschema/schema.go b/v2/pkg/engine/jsonschema/schema.go new file mode 100644 index 0000000000..0840947455 --- /dev/null +++ b/v2/pkg/engine/jsonschema/schema.go @@ -0,0 +1,229 @@ +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"` + + // Array-specific fields + Items *JsonSchema `json:"items,omitempty"` + + // Enum values + Enum []interface{} `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 s.Properties != nil && len(s.Properties) > 0 { + m["properties"] = s.Properties + } + + if s.Required != nil && len(s.Required) > 0 { + m["required"] = s.Required + } + + if s.AdditionalProperties != nil { + m["additionalProperties"] = *s.AdditionalProperties + } + + if s.Description != "" { + m["description"] = s.Description + } + + if s.Items != nil { + m["items"] = s.Items + } + + if s.Enum != nil && 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{}, + } +} + +// NewArraySchema creates a new schema for an array type +func NewArraySchema(items *JsonSchema) *JsonSchema { + return &JsonSchema{ + Type: TypeArray, + Items: items, + } +} + +// NewStringSchema creates a new schema for a string type +func NewStringSchema() *JsonSchema { + return &JsonSchema{ + Type: TypeString, + } +} + +// NewIntegerSchema creates a new schema for an integer type +func NewIntegerSchema() *JsonSchema { + return &JsonSchema{ + Type: TypeInteger, + } +} + +// NewNumberSchema creates a new schema for a number type +func NewNumberSchema() *JsonSchema { + return &JsonSchema{ + Type: TypeNumber, + } +} + +// NewBooleanSchema creates a new schema for a boolean type +func NewBooleanSchema() *JsonSchema { + return &JsonSchema{ + Type: TypeBoolean, + } +} + +// NewEnumSchema creates a new schema for an enum type +func NewEnumSchema(values []interface{}) *JsonSchema { + return &JsonSchema{ + Type: TypeString, + Enum: values, + } +} + +// CloneSchema creates a deep copy of a schema +func CloneSchema(schema *JsonSchema) *JsonSchema { + if schema == nil { + return nil + } + + clone := &JsonSchema{ + Type: schema.Type, + Description: schema.Description, + Format: schema.Format, + Pattern: schema.Pattern, + Default: schema.Default, + } + + if schema.Properties != nil { + clone.Properties = make(map[string]*JsonSchema) + for k, v := range schema.Properties { + clone.Properties[k] = CloneSchema(v) + } + } + + if schema.Required != nil { + clone.Required = append([]string{}, schema.Required...) + } + + if schema.AdditionalProperties != nil { + additionalProps := *schema.AdditionalProperties + clone.AdditionalProperties = &additionalProps + } + + if schema.Items != nil { + clone.Items = CloneSchema(schema.Items) + } + + if schema.Enum != nil { + clone.Enum = append([]interface{}{}, schema.Enum...) + } + + if schema.Minimum != nil { + min := *schema.Minimum + clone.Minimum = &min + } + + if schema.Maximum != nil { + max := *schema.Maximum + clone.Maximum = &max + } + + return clone +} + +// 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 +} diff --git a/v2/pkg/engine/jsonschema/schema_test.go b/v2/pkg/engine/jsonschema/schema_test.go new file mode 100644 index 0000000000..36ce6aaca6 --- /dev/null +++ b/v2/pkg/engine/jsonschema/schema_test.go @@ -0,0 +1,460 @@ +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 := []interface{}{"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.Marshal(schema) + 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 + assert.Equal(t, "object", parsed["type"]) + assert.Equal(t, "Test object schema", parsed["description"]) + assert.Equal(t, false, parsed["additionalProperties"]) + + properties := parsed["properties"].(map[string]interface{}) + assert.Len(t, properties, 5) + + // Check string property + nameProp := properties["name"].(map[string]interface{}) + assert.Equal(t, "string", nameProp["type"]) + assert.Equal(t, "A string property", nameProp["description"]) + assert.Equal(t, "default value", nameProp["default"]) + + // Check integer property + ageProp := properties["age"].(map[string]interface{}) + assert.Equal(t, "integer", ageProp["type"]) + assert.Equal(t, float64(0), ageProp["minimum"]) + + // Check enum property + categoryProp := properties["category"].(map[string]interface{}) + assert.Equal(t, "string", categoryProp["type"]) + assert.Equal(t, []interface{}{"ONE", "TWO", "THREE"}, categoryProp["enum"]) + + // Check nested object + addressProp := properties["address"].(map[string]interface{}) + assert.Equal(t, "object", addressProp["type"]) + addressProps := addressProp["properties"].(map[string]interface{}) + assert.Len(t, addressProps, 2) + assert.Contains(t, addressProps, "street") + assert.Contains(t, addressProps, "city") + assert.Equal(t, []interface{}{"street"}, addressProp["required"]) + + // Check array property + tagsProp := properties["tags"].(map[string]interface{}) + assert.Equal(t, "array", tagsProp["type"]) + assert.NotNil(t, tagsProp["items"]) + + // Check required fields + assert.Equal(t, []interface{}{"name", "age"}, parsed["required"]) + }) + + 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 TestCloneSchema(t *testing.T) { + t.Run("clone complex schema", func(t *testing.T) { + // Create a complex schema to clone + original := NewObjectSchema() + original.Description = "Original schema" + + // Add properties + original.Properties["string"] = NewStringSchema() + original.Properties["number"] = NewNumberSchema() + + // Add enum + enumValues := []interface{}{"A", "B", "C"} + original.Properties["enum"] = NewEnumSchema(enumValues) + + // Add nested object + nested := NewObjectSchema() + nested.Properties["field"] = NewStringSchema() + original.Properties["nested"] = nested + + // Add array + original.Properties["array"] = NewArraySchema(NewIntegerSchema()) + + // Set required + original.Required = []string{"string", "number"} + + // Clone the schema + clone := CloneSchema(original) + + // Verify they're equal but not the same object + assert.NotSame(t, original, clone) + assert.Equal(t, original.Description, clone.Description) + assert.Equal(t, original.Type, clone.Type) + assert.Equal(t, original.Required, clone.Required) + + // Check properties are cloned + assert.Len(t, clone.Properties, len(original.Properties)) + for key, prop := range original.Properties { + clonedProp, exists := clone.Properties[key] + assert.True(t, exists) + assert.Equal(t, prop.Type, clonedProp.Type) + assert.NotSame(t, prop, clonedProp) + } + + // Modify the clone and verify the original is unchanged + clone.Description = "Modified clone" + clone.Properties["string"].Description = "Modified property" + clone.Required = append(clone.Required, "newRequired") + + assert.NotEqual(t, original.Description, clone.Description) + assert.NotEqual(t, original.Required, clone.Required) + assert.Empty(t, original.Properties["string"].Description) + }) +} + +func TestSchemaFeatures(t *testing.T) { + t.Run("enum schema", func(t *testing.T) { + // Test creating and validating enum schema + values := []interface{}{"RED", "GREEN", "BLUE"} + schema := NewEnumSchema(values) + + // Check structure + assert.Equal(t, TypeString, schema.Type) + assert.Equal(t, values, schema.Enum) + + // 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) + + assert.Equal(t, "string", parsed["type"]) + assert.Equal(t, []interface{}{"RED", "GREEN", "BLUE"}, parsed["enum"]) + }) + + 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.Marshal(schema) + require.NoError(t, err) + + var parsed map[string]interface{} + err = json.Unmarshal(data, &parsed) + require.NoError(t, err) + + required := parsed["required"].([]interface{}) + assert.Len(t, required, 2) + assert.Contains(t, required, "id") + assert.Contains(t, required, "age") + assert.NotContains(t, required, "name") + }) + + 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.Marshal(intSchema) + require.NoError(t, err) + + var parsed map[string]interface{} + err = json.Unmarshal(data, &parsed) + require.NoError(t, err) + + assert.Equal(t, float64(0), parsed["minimum"]) + assert.Equal(t, float64(100), parsed["maximum"]) + + // Number schema + numSchema := NewNumberSchema() + numSchema.Minimum = &min + numSchema.Maximum = &max + + data, err = json.Marshal(numSchema) + require.NoError(t, err) + + parsed = map[string]interface{}{} + err = json.Unmarshal(data, &parsed) + require.NoError(t, err) + + assert.Equal(t, float64(0), parsed["minimum"]) + assert.Equal(t, float64(100), parsed["maximum"]) + }) + + 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([]interface{}{"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([]interface{}{"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.Marshal(userSchema) + require.NoError(t, err) + + var parsed map[string]interface{} + err = json.Unmarshal(data, &parsed) + require.NoError(t, err) + + // Verify just a few key aspects + assert.Equal(t, "User schema with all features", parsed["description"]) + properties := parsed["properties"].(map[string]interface{}) + assert.Len(t, properties, 6) + assert.Contains(t, parsed["required"], "id") + + // Verify pattern on id + idProp := properties["id"].(map[string]interface{}) + assert.Equal(t, "^[a-zA-Z0-9]{8,}$", idProp["pattern"]) + + // Verify enum values + roleProp := properties["role"].(map[string]interface{}) + assert.Len(t, roleProp["enum"], 3) + assert.Equal(t, "USER", roleProp["default"]) + + // Verify nested object + addressProp := properties["address"].(map[string]interface{}) + addressProps := addressProp["properties"].(map[string]interface{}) + assert.Len(t, addressProps, 2) + assert.Equal(t, []interface{}{"street"}, addressProp["required"]) + }) +} diff --git a/v2/pkg/engine/jsonschema/variables_schema.go b/v2/pkg/engine/jsonschema/variables_schema.go new file mode 100644 index 0000000000..5fe8fdc8f7 --- /dev/null +++ b/v2/pkg/engine/jsonschema/variables_schema.go @@ -0,0 +1,427 @@ +package jsonschema + +import ( + "fmt" + + "github.com/wundergraph/graphql-go-tools/v2/pkg/ast" + "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 +} + +// 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, + } +} + +// Build traverses the operation and builds a unified JSON schema for its variables +func (v *VariablesSchemaBuilder) Build() (*JsonSchema, error) { + v.schema = NewObjectSchema() + v.recursionTracker = make(map[string]int) // Reset recursion tracker for each build + + // Extract descriptions from root fields + var descriptions []string + operationDefinition := v.operationDocument.OperationDefinitions[0] + + // Process SelectionSet to extract field descriptions + if operationDefinition.HasSelections { + selectionSetRef := operationDefinition.SelectionSet + for _, selectionRef := range v.operationDocument.SelectionSets[selectionSetRef].SelectionRefs { + selection := v.operationDocument.Selections[selectionRef] + if selection.Kind == ast.SelectionKindField { + fieldName := v.operationDocument.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: + return nil, fmt.Errorf("unsupported operation type %q", operationType) + } + + rootType, exists := v.definitionDocument.Index.FirstNodeByNameStr(rootTypeName) + if exists && rootType.Kind == ast.NodeKindObjectTypeDefinition { + // Find the field in the root type + for _, fieldDefRef := range v.definitionDocument.ObjectTypeDefinitions[rootType.Ref].FieldsDefinition.Refs { + fieldDefName := v.definitionDocument.FieldDefinitionNameString(fieldDefRef) + + // Match field name + if fieldDefName == fieldName && v.definitionDocument.FieldDefinitions[fieldDefRef].Description.IsDefined { + description := v.definitionDocument.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 + } + } + + if !v.operationDocument.OperationDefinitions[0].HasVariableDefinitions { + return v.schema, nil + } + + for _, varDefRef := range v.operationDocument.OperationDefinitions[0].VariableDefinitions.Refs { + v.processVariableDefinition(varDefRef) + } + + if v.report.HasErrors() { + return nil, fmt.Errorf("%s", v.report.Error()) + } + + return v.schema, nil +} + +// processVariableDefinition processes a single variable definition +func (v *VariablesSchemaBuilder) processVariableDefinition(varDefRef int) { + varName := v.operationDocument.VariableDefinitionNameString(varDefRef) + typeRef := v.operationDocument.VariableDefinitions[varDefRef].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(varDefRef) { + defaultValue := v.operationDocument.VariableDefinitionDefaultValue(varDefRef) + varSchema.Default = v.convertOperationValueToNative(defaultValue) + } + + // Add variable to schema + v.schema.Properties[varName] = varSchema +} + +// 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 + } + return schema + + case ast.TypeKindList: + ofType := v.operationDocument.Types[typeRef].OfType + itemSchema := v.processOperationTypeRef(ofType) + if itemSchema == nil { + return nil + } + return NewArraySchema(itemSchema) + + case ast.TypeKindNamed: + typeName := v.operationDocument.TypeNameString(typeRef) + return v.processTypeByName(typeName) + } + + 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": + return NewStringSchema() + case "Int": + return NewIntegerSchema() + case "Float": + return NewNumberSchema() + case "Boolean": + return NewBooleanSchema() + case "ID": + return NewStringSchema() + } + + // 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() + } + + // 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 + + // 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 + } + + // Defer the cleanup of the recursion tracker + defer func() { + if depth, ok := v.recursionTracker[typeName]; ok && depth > 1 { + v.recursionTracker[typeName]-- + } else { + delete(v.recursionTracker, typeName) + } + }() + } + + switch node.Kind { + case ast.NodeKindEnumTypeDefinition: + return v.processEnumType(node) + + case ast.NodeKindInputObjectTypeDefinition: + return v.processInputObjectType(node) + + case ast.NodeKindScalarTypeDefinition: + return NewStringSchema() + + default: + // If we can't determine the type, default to object + return NewObjectSchema() + } +} + +// processEnumType processes an enum type definition +func (v *VariablesSchemaBuilder) processEnumType(node ast.Node) *JsonSchema { + values := make([]interface{}, 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 + } + return schema + + case ast.TypeKindList: + ofType := v.definitionDocument.Types[typeRef].OfType + itemSchema := v.processDefinitionTypeRef(ofType) + if itemSchema == nil { + return nil + } + return NewArraySchema(itemSchema) + + case ast.TypeKindNamed: + typeName := v.definitionDocument.TypeNameString(typeRef) + return v.processTypeByName(typeName) + } + + 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..0055975e51 --- /dev/null +++ b/v2/pkg/engine/jsonschema/variables_schema_test.go @@ -0,0 +1,976 @@ +package jsonschema + +import ( + "encoding/json" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/wundergraph/graphql-go-tools/v2/pkg/astparser" + "github.com/wundergraph/graphql-go-tools/v2/pkg/operationreport" +) + +func TestBuildJsonSchema(t *testing.T) { + t.Run("simple query with input object", func(t *testing.T) { + // Define schema + schemaSDL := ` + 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) + + // 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 criteria property + criteria, ok := properties["criteria"].(map[string]interface{}) + require.True(t, ok) + assert.Equal(t, "object", criteria["type"]) + assert.Equal(t, "Input criteria used to search for employees", criteria["description"]) + + // Verify criteria properties + criteriaProps := criteria["properties"].(map[string]interface{}) + assert.Len(t, criteriaProps, 3) + + name := criteriaProps["name"].(map[string]interface{}) + assert.Equal(t, "string", name["type"]) + + department := criteriaProps["department"].(map[string]interface{}) + assert.Equal(t, "string", department["type"]) + + status := criteriaProps["employmentStatus"].(map[string]interface{}) + assert.Equal(t, "string", status["type"]) + statusEnum := status["enum"].([]interface{}) + assert.ElementsMatch(t, []interface{}{"FULL_TIME", "PART_TIME", "CONTRACTOR", "INTERN"}, statusEnum) + + // Verify required fields + criteriaRequired := criteria["required"].([]interface{}) + assert.ElementsMatch(t, []interface{}{"name"}, criteriaRequired) + + // Verify additionalProperties is false + assert.Equal(t, false, criteria["additionalProperties"]) + }) + + t.Run("query with nested input objects", func(t *testing.T) { + // Define schema with nested inputs + schemaSDL := ` + type Query { + findEmployees(criteria: SearchInput): [Employee] + } + + 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) + + // Verify schema structure + var parsed map[string]interface{} + err = json.Unmarshal(data, &parsed) + require.NoError(t, err) + + // Verify top-level required fields + assert.Contains(t, parsed["required"], "criteria") + + // Verify criteria structure + properties := parsed["properties"].(map[string]interface{}) + criteria := properties["criteria"].(map[string]interface{}) + criteriaProps := criteria["properties"].(map[string]interface{}) + + // Verify nested structure + nested := criteriaProps["nested"].(map[string]interface{}) + assert.Equal(t, "object", nested["type"]) + + // Verify nested is required + criteriaRequired := criteria["required"].([]interface{}) + assert.Contains(t, criteriaRequired, "nested") + + // Verify nested properties + nestedProps := nested["properties"].(map[string]interface{}) + assert.Len(t, nestedProps, 3) + + // Verify nationality is required in nested + nestedRequired := nested["required"].([]interface{}) + assert.Contains(t, nestedRequired, "nationality") + + // Verify enum in nested + nationality := nestedProps["nationality"].(map[string]interface{}) + assert.Equal(t, "string", nationality["type"]) + nationalityEnum := nationality["enum"].([]interface{}) + assert.Len(t, nationalityEnum, 7) + + maritalStatus := nestedProps["maritalStatus"].(map[string]interface{}) + assert.Equal(t, "string", maritalStatus["type"]) + maritalEnum := maritalStatus["enum"].([]interface{}) + assert.ElementsMatch(t, []interface{}{"MARRIED", "ENGAGED"}, maritalEnum) + }) + + t.Run("query with default values", func(t *testing.T) { + // Define schema with default values + schemaSDL := ` + type Query { + getItems(filter: FilterInput): [Item] + } + + 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 := ` + 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 := ` + 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 := ` + type Query { + search(input: SearchInput): String + } + ` + + // Operation using SearchInput + operationSDL := ` + query Search($input: SearchInput) { + search(input: $input) + } + ` + + // Parse schema and operation + definitionDoc, report := astparser.ParseGraphqlDocumentString(schemaSDL) + report = operationreport.Report{} // Reset report + + operationDoc, report := astparser.ParseGraphqlDocumentString(operationSDL) + require.False(t, report.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 := ` + 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 := ` + 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) + + // Verify schema structure + var parsed map[string]interface{} + err = json.Unmarshal(data, &parsed) + require.NoError(t, err) + + // Verify input is required + required := parsed["required"].([]interface{}) + assert.Contains(t, required, "input") + + // Get properties + properties := parsed["properties"].(map[string]interface{}) + input := properties["input"].(map[string]interface{}) + + // Verify level 1 description + assert.Equal(t, "Level 1 input description", input["description"]) + + // Verify level 1 required fields + level1Required := input["required"].([]interface{}) + assert.Contains(t, level1Required, "nested") + assert.Contains(t, level1Required, "requiredArray") + + // Verify level 1 properties + level1Properties := input["properties"].(map[string]interface{}) + + // Check array types + requiredArray := level1Properties["requiredArray"].(map[string]interface{}) + assert.Equal(t, "array", requiredArray["type"]) + assert.Equal(t, "integer", requiredArray["items"].(map[string]interface{})["type"]) + + // Verify level 2 + nested := level1Properties["nested"].(map[string]interface{}) + assert.Equal(t, "Level 2 input description", nested["description"]) + + // Verify level 2 required fields + level2Required := nested["required"].([]interface{}) + assert.Contains(t, level2Required, "deeper") + + // Verify level 2 properties + level2Properties := nested["properties"].(map[string]interface{}) + + // Verify level 3 + deeper := level2Properties["deeper"].(map[string]interface{}) + assert.Equal(t, "Level 3 input description", deeper["description"]) + + // Verify level 3 required fields + level3Required := deeper["required"].([]interface{}) + assert.Contains(t, level3Required, "enumField") + + // Verify level 3 properties + level3Properties := deeper["properties"].(map[string]interface{}) + + // Verify enum + enumField := level3Properties["enumField"].(map[string]interface{}) + assert.Equal(t, "string", enumField["type"]) + + enumValues := enumField["enum"].([]interface{}) + assert.Contains(t, enumValues, "OPTION_1") + assert.Contains(t, enumValues, "OPTION_2") + assert.Contains(t, enumValues, "OPTION_3") + + // Verify array of arrays + arrayOfArrays := level3Properties["arrayOfArrays"].(map[string]interface{}) + assert.Equal(t, "array", arrayOfArrays["type"]) + + innerArray := arrayOfArrays["items"].(map[string]interface{}) + assert.Equal(t, "array", innerArray["type"]) + assert.Equal(t, "string", innerArray["items"].(map[string]interface{})["type"]) + }) + + t.Run("recursive types with default recursion depth", func(t *testing.T) { + // Define schema with recursive input type + schemaSDL := ` + 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 := ` + 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 := ` + 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 := ` + 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) + jsonStr := string(data) + + // Check for base structure and non-recursive fields + var parsed map[string]interface{} + err = json.Unmarshal(data, &parsed) + require.NoError(t, err) + + // Verify required attribute a exists at the top level + properties, ok := parsed["properties"].(map[string]interface{}) + require.True(t, ok) + _, ok = properties["a"].(map[string]interface{}) + require.True(t, ok) + + // Check non-recursive fields in both types are present + assert.Contains(t, jsonStr, `"id":`) + assert.Contains(t, jsonStr, `"name":`) + assert.Contains(t, jsonStr, `"description":`) + + // Verify at least one a or b reference exists (showing some level of recursion was processed) + assert.True(t, strings.Contains(jsonStr, `"a":`) || strings.Contains(jsonStr, `"b":`), + "Should have at least one reference to a recursive field") + }) +} diff --git a/v2/pkg/graphqljsonschema/jsonschema.go b/v2/pkg/graphqljsonschema/jsonschema.go deleted file mode 100644 index 3d79120c05..0000000000 --- a/v2/pkg/graphqljsonschema/jsonschema.go +++ /dev/null @@ -1,475 +0,0 @@ -package graphqljsonschema - -import ( - "context" - "encoding/json" - "fmt" - - "github.com/buger/jsonparser" - "github.com/santhosh-tekuri/jsonschema/v5" - - "github.com/wundergraph/graphql-go-tools/v2/pkg/ast" -) - -type options struct { - overrides map[string]JsonSchema - path []string -} - -type Option func(opts *options) - -func WithOverrides(overrides map[string]JsonSchema) Option { - return func(opts *options) { - opts.overrides = overrides - } -} - -func WithPath(path []string) Option { - return func(opts *options) { - opts.path = path - } -} - -func FromTypeRef(operation, definition *ast.Document, typeRef int, opts ...Option) JsonSchema { - appliedOptions := &options{} - for _, opt := range opts { - opt(appliedOptions) - } - - var resolver *fromTypeRefResolver - if len(appliedOptions.overrides) > 0 { - resolver = &fromTypeRefResolver{ - overrides: appliedOptions.overrides, - } - } else { - resolver = &fromTypeRefResolver{ - overrides: map[string]JsonSchema{}, - } - } - - jsonSchema := resolver.fromTypeRef(operation, definition, typeRef, false) - return resolveJsonSchemaPath(jsonSchema, appliedOptions.path) -} - -func resolveJsonSchemaPath(jsonSchema JsonSchema, path []string) JsonSchema { - switch typedJsonSchema := jsonSchema.(type) { - case Object: - for i := 0; i < len(path); i++ { - propertyJsonSchema, exists := typedJsonSchema.Properties[path[i]] - if !exists { - return jsonSchema - } - jsonSchema = propertyJsonSchema - } - } - - return jsonSchema -} - -type fromTypeRefResolver struct { - overrides map[string]JsonSchema - defs *map[string]JsonSchema -} - -func (r *fromTypeRefResolver) fromTypeRef(operation, definition *ast.Document, typeRef int, field bool) JsonSchema { - - t := operation.Types[typeRef] - - nonNull := false - if operation.TypeIsNonNull(typeRef) { - t = operation.Types[t.OfType] - nonNull = true - } - - switch t.TypeKind { - case ast.TypeKindList: - var defs map[string]JsonSchema - isRoot := false - if r.defs == nil { - defs = make(map[string]JsonSchema, 48) - r.defs = &defs - isRoot = true - } - itemSchema := r.fromTypeRef(operation, definition, t.OfType, field) - arr := NewArray(itemSchema, nonNull) - if isRoot { - arr.Defs = defs - } - return arr - case ast.TypeKindNonNull: - panic("Should not be able to have multiple levels of non-null") - case ast.TypeKindNamed: - name := operation.Input.ByteSliceString(t.Name) - storeAsName := name - if nonNull { - storeAsName = fmt.Sprintf("%sNotNull", name) - } - - if schema, ok := r.overrides[name]; ok { - return schema - } - typeDefinitionNode, ok := definition.Index.FirstNodeByNameStr(name) - if !ok { - return NewAny() - } - if typeDefinitionNode.Kind == ast.NodeKindEnumTypeDefinition { - return NewString(nonNull) - } - if typeDefinitionNode.Kind == ast.NodeKindScalarTypeDefinition { - switch name { - case "Boolean": - return NewBoolean(nonNull) - case "String": - return NewString(nonNull) - case "ID": - return NewID(nonNull) - case "Int": - return NewInteger(nonNull) - case "Float": - return NewNumber(nonNull) - case "_Any": - return NewObjectAny(nonNull) - default: - return NewAny() - } - } - object := NewObject(nonNull) - isRootObject := false - if r.defs == nil { - isRootObject = true - object.Defs = make(map[string]JsonSchema, 48) - r.defs = &object.Defs - } - if !isRootObject { - if _, exists := (*r.defs)[storeAsName]; exists { - if !nonNull { - return NewNullableRef(storeAsName) - } - - return NewRef(storeAsName) - } - (*r.defs)[storeAsName] = object - } - if node, ok := definition.Index.FirstNodeByNameStr(name); ok { - switch node.Kind { - case ast.NodeKindInputObjectTypeDefinition: - for _, ref := range definition.InputObjectTypeDefinitions[node.Ref].InputFieldsDefinition.Refs { - fieldName := definition.Input.ByteSliceString(definition.InputValueDefinitions[ref].Name) - fieldType := definition.InputValueDefinitions[ref].Type - fieldSchema := r.fromTypeRef(definition, definition, fieldType, true) - object.Properties[fieldName] = fieldSchema - if definition.TypeIsNonNull(fieldType) { - object.Required = append(object.Required, fieldName) - } - } - case ast.NodeKindObjectTypeDefinition: - for _, ref := range definition.ObjectTypeDefinitions[node.Ref].FieldsDefinition.Refs { - fieldName := definition.Input.ByteSliceString(definition.FieldDefinitions[ref].Name) - fieldType := definition.FieldDefinitions[ref].Type - fieldSchema := r.fromTypeRef(definition, definition, fieldType, true) - object.Properties[fieldName] = fieldSchema - if definition.TypeIsNonNull(fieldType) { - object.Required = append(object.Required, fieldName) - } - } - } - } - if !isRootObject { - (*r.defs)[storeAsName] = object - - if !nonNull { - return NewNullableRef(storeAsName) - } - - return NewRef(storeAsName) - } - return object - } - - if field { - return NewObject(false) - } - return NewObject(nonNull) -} - -type Validator struct { - schema *jsonschema.Schema -} - -func NewValidatorFromSchema(schema JsonSchema) (*Validator, error) { - s, err := json.Marshal(schema) - if err != nil { - return nil, err - } - return NewValidatorFromString(string(s)) -} - -func MustNewValidatorFromSchema(schema JsonSchema) *Validator { - s, err := json.Marshal(schema) - if err != nil { - panic(err) - } - return MustNewValidatorFromString(string(s)) -} - -func NewValidatorFromString(schema string) (*Validator, error) { - sch, err := jsonschema.CompileString("schema.json", schema) - if err != nil { - return nil, err - } - return &Validator{ - schema: sch, - }, nil -} - -func MustNewValidatorFromString(schema string) *Validator { - validator, err := NewValidatorFromString(schema) - if err != nil { - panic(err) - } - return validator -} - -func (v *Validator) Validate(ctx context.Context, inputJSON []byte) error { - var value interface{} - if err := json.Unmarshal(inputJSON, &value); err != nil { - return err - } - if err := v.schema.Validate(value); err != nil { - return err - } - return nil -} - -func TopLevelType(schema string) (jsonparser.ValueType, error) { - sch, err := jsonschema.CompileString("schema.json", schema) - if err != nil { - return jsonparser.Unknown, err - } - switch sch.Types[0] { - case "boolean": - return jsonparser.Boolean, nil - case "string": - return jsonparser.String, nil - case "object": - return jsonparser.Object, nil - case "number": - return jsonparser.Number, nil - case "integer": - return jsonparser.Number, nil - case "null": - return jsonparser.Null, nil - case "array": - return jsonparser.Array, nil - default: - return jsonparser.NotExist, nil - } -} - -type Kind int - -const ( - StringKind Kind = iota + 1 - NumberKind - BooleanKind - IntegerKind - ObjectKind - ArrayKind - AnyKind - IDKind - RefKind - NullableRefKind - NullKind -) - -func maybeAppendNull(nonNull bool, types ...string) []string { - if nonNull { - return types - } - return append(types, "null") -} - -type JsonSchema interface { - Kind() Kind -} - -type Any struct{} - -func NewAny() Any { - return Any{} -} - -func (a Any) Kind() Kind { - return AnyKind -} - -type String struct { - Type []string `json:"type"` -} - -func (String) Kind() Kind { - return StringKind -} - -func NewString(nonNull bool) String { - return String{ - Type: maybeAppendNull(nonNull, "string"), - } -} - -type ID struct { - Type []string `json:"type"` -} - -func (ID) Kind() Kind { - return IDKind -} - -func NewID(nonNull bool) ID { - return ID{ - Type: maybeAppendNull(nonNull, "string", "integer"), - } -} - -type Boolean struct { - Type []string `json:"type"` -} - -func (Boolean) Kind() Kind { - return BooleanKind -} - -func NewBoolean(nonNull bool) Boolean { - return Boolean{ - Type: maybeAppendNull(nonNull, "boolean"), - } -} - -type Number struct { - Type []string `json:"type"` -} - -func NewNumber(nonNull bool) Number { - return Number{ - Type: maybeAppendNull(nonNull, "number"), - } -} - -func (Number) Kind() Kind { - return NumberKind -} - -type Integer struct { - Type []string `json:"type"` -} - -func (Integer) Kind() Kind { - return IntegerKind -} - -func NewInteger(nonNull bool) Integer { - return Integer{ - Type: maybeAppendNull(nonNull, "integer"), - } -} - -type Ref struct { - Ref string `json:"$ref"` -} - -func (Ref) Kind() Kind { - return RefKind -} - -func NewRef(definitionName string) Ref { - return Ref{ - Ref: fmt.Sprintf("#/$defs/%s", definitionName), - } -} - -type Object struct { - Type []string `json:"type"` - Properties map[string]JsonSchema `json:"properties,omitempty"` - Required []string `json:"required,omitempty"` - AdditionalProperties bool `json:"additionalProperties"` - Defs map[string]JsonSchema `json:"$defs,omitempty"` -} - -func (Object) Kind() Kind { - return ObjectKind -} - -func NewObject(nonNull bool) Object { - return Object{ - Type: maybeAppendNull(nonNull, "object"), - Properties: map[string]JsonSchema{}, - AdditionalProperties: false, - } -} - -func NewObjectAny(nonNull bool) Object { - return Object{ - Type: maybeAppendNull(nonNull, "object"), - Properties: map[string]JsonSchema{}, - AdditionalProperties: true, - } -} - -type Null struct { - Type []string `json:"type"` -} - -func (Null) Kind() Kind { - return AnyKind -} - -func NewNull() Null { - return Null{ - Type: []string{"null"}, - } -} - -type NullableRef struct { - AnyOf []JsonSchema `json:"anyOf"` -} - -func (NullableRef) Kind() Kind { - return NullableRefKind -} - -func NewNullableRef(definitionName string) NullableRef { - return NullableRef{ - AnyOf: []JsonSchema{ - NewNull(), - NewRef(definitionName), - }, - } -} - -/* - "owner":{ - "oneOf": [ - {"type": "null"}, - {"$ref":"#/definitions/id"} - ] - } - -*/ - -type Array struct { - Type []string `json:"type"` - Items JsonSchema `json:"items"` - MinItems *int `json:"minItems,omitempty"` - Defs map[string]JsonSchema `json:"$defs,omitempty"` -} - -func (Array) Kind() Kind { - return ArrayKind -} - -func NewArray(itemSchema JsonSchema, nonNull bool) Array { - return Array{ - Type: maybeAppendNull(nonNull, "array"), - Items: itemSchema, - } -} diff --git a/v2/pkg/graphqljsonschema/jsonschema_test.go b/v2/pkg/graphqljsonschema/jsonschema_test.go deleted file mode 100644 index 39a37293a8..0000000000 --- a/v2/pkg/graphqljsonschema/jsonschema_test.go +++ /dev/null @@ -1,2021 +0,0 @@ -package graphqljsonschema - -import ( - "bytes" - "context" - "encoding/json" - "testing" - - "github.com/stretchr/testify/assert" - - "github.com/wundergraph/graphql-go-tools/v2/pkg/internal/unsafeparser" -) - -func prettyPrint(s string) string { - // return s - buf := bytes.Buffer{} - err := json.Indent(&buf, []byte(s), "", " ") - if err != nil { - panic(err) - } - return buf.String() -} - -func runTest(schema, operation, expectedJsonSchema string, valid []string, invalid []string, opts ...Option) func(t *testing.T) { - return func(t *testing.T) { - definition := unsafeparser.ParseGraphqlDocumentString(schema) - operationDoc := unsafeparser.ParseGraphqlDocumentString(operation) - - variableDefinition := operationDoc.OperationDefinitions[0].VariableDefinitions.Refs[0] - varType := operationDoc.VariableDefinitions[variableDefinition].Type - - jsonSchemaDefinition := FromTypeRef(&operationDoc, &definition, varType, opts...) - actualSchema, err := json.Marshal(jsonSchemaDefinition) - assert.NoError(t, err) - assert.Equal(t, prettyPrint(expectedJsonSchema), prettyPrint(string(actualSchema))) - - validator, err := NewValidatorFromString(string(actualSchema)) - assert.NoError(t, err) - - for _, input := range valid { - assert.NoError(t, validator.Validate(context.Background(), []byte(input)), "Incorrectly judged invalid: %v", input) - } - - for _, input := range invalid { - assert.Error(t, validator.Validate(context.Background(), []byte(input)), "Incorrectly judged valid: %v", input) - } - } -} - -func TestJsonSchema(t *testing.T) { - t.Run("object", runTest( - `scalar String input Test { str: String }`, - `query ($input: Test){}`, - `{"type":["object","null"],"properties":{"str":{"type":["string","null"]}},"additionalProperties":false}`, - []string{ - `{"str":"validString"}`, - `{"str":null}`, - }, - []string{ - `{"str":true}`, - }, - )) - t.Run("string", runTest( - `scalar String input Test { str: String }`, - `query ($input: String){}`, - `{"type":["string","null"]}`, - []string{ - `"validString"`, - `null`, - }, - []string{ - `false`, - `true`, - `nope`, - }, - )) - t.Run("string (required)", runTest( - `scalar String type Query { rootField(str: String!): String! }`, - `query ($input: String!){ rootField(str: $input) }`, - `{"type":["string"]}`, - []string{ - `"validString"`, - }, - []string{ - `false`, - `true`, - `nope`, - `null`, - }, - )) - t.Run("id", runTest( - `scalar ID input Test { str: String }`, - `query ($input: ID){}`, - `{"type":["string","integer","null"]}`, - []string{ - `"validString"`, - `null`, - }, - []string{ - `false`, - `true`, - `nope`, - }, - )) - t.Run("array", runTest( - `scalar String`, - `query ($input: [String]){}`, - `{"type":["array","null"],"items":{"type":["string","null"]}}`, - []string{ - `null`, - `[]`, - `["validString1"]`, - `["validString1", "validString2"]`, - `["validString1", "validString2", null]`, - }, - []string{ - `"validString"`, - `false`, - }, - )) - t.Run("input object array", runTest( - `scalar String input StringInput { str: String }`, - `query ($input: [StringInput]){}`, - `{"type":["array","null"],"items":{"anyOf": [{"type":["null"]},{"$ref":"#/$defs/StringInput"}]},"$defs":{"StringInput":{"type":["object","null"],"properties":{"str":{"type":["string","null"]}},"additionalProperties":false}}}`, - []string{ - `null`, - `[]`, - `[{"str":"validString1"}]`, - `[{"str":"validString1"}, {"str":"validString2"}]`, - `[{"str":"validString1"}, {"str":"validString2"}, null]`, - }, - []string{ - `"validString"`, - `false`, - }, - )) - t.Run("nested input object both as required and not required", runTest( - `scalar String input StringInput { str: String } input Input { requireInput: StringInput! optionalInput: StringInput }`, - `query ($input: Input){}`, - `{"type":["object","null"],"properties":{"optionalInput":{"anyOf":[{"type":["null"]},{"$ref":"#/$defs/StringInput"}]},"requireInput":{"$ref":"#/$defs/StringInputNotNull"}},"required":["requireInput"],"additionalProperties":false,"$defs":{"StringInput":{"type":["object","null"],"properties":{"str":{"type":["string","null"]}},"additionalProperties":false},"StringInputNotNull":{"type":["object"],"properties":{"str":{"type":["string","null"]}},"additionalProperties":false}}}`, - []string{ - `{"optionalInput": {}, "requireInput": {"str":"validString"}}`, - `{"requireInput": {"str":"validString"}}`, - }, - []string{ - `{}`, - `{"optionalInput": {}}`, - }, - )) - t.Run("nested input object both as required and not required - reverse order", runTest( - `scalar String input StringInput { str: String } input Input { optionalInput: StringInput requireInput: StringInput! }`, - `query ($input: Input){}`, - `{"type":["object","null"],"properties":{"optionalInput":{"anyOf":[{"type":["null"]},{"$ref":"#/$defs/StringInput"}]},"requireInput":{"$ref":"#/$defs/StringInputNotNull"}},"required":["requireInput"],"additionalProperties":false,"$defs":{"StringInput":{"type":["object","null"],"properties":{"str":{"type":["string","null"]}},"additionalProperties":false},"StringInputNotNull":{"type":["object"],"properties":{"str":{"type":["string","null"]}},"additionalProperties":false}}}`, - []string{ - `{"optionalInput": {}, "requireInput": {"str":"validString"}}`, - `{"requireInput": {"str":"validString"}}`, - }, - []string{ - `{}`, - `{"optionalInput": {}}`, - }, - )) - t.Run("optional nested input object used twice", runTest( - `scalar String input StringInput { str: String } input Input { optionalInput1: StringInput optionalInput2: StringInput }`, - `query ($input: Input){}`, - `{"type":["object","null"],"properties":{"optionalInput1":{"anyOf":[{"type":["null"]},{"$ref":"#/$defs/StringInput"}]},"optionalInput2":{"anyOf":[{"type":["null"]},{"$ref":"#/$defs/StringInput"}]}},"additionalProperties":false,"$defs":{"StringInput":{"type":["object","null"],"properties":{"str":{"type":["string","null"]}},"additionalProperties":false}}}`, - []string{ - `{"optionalInput1": {}, "optionalInput2": {"str":"validString"}}`, - `{"optionalInput2": {"str":"validString"}}`, - `{}`, - }, - []string{}, - )) - t.Run("required nested input object used twice", runTest( - `scalar String input StringInput { str: String } input Input { requireInput1: StringInput! requireInput2: StringInput! }`, - `query ($input: Input){}`, - `{"type":["object","null"],"properties":{"requireInput1":{"$ref":"#/$defs/StringInputNotNull"},"requireInput2":{"$ref":"#/$defs/StringInputNotNull"}},"required":["requireInput1","requireInput2"],"additionalProperties":false,"$defs":{"StringInputNotNull":{"type":["object"],"properties":{"str":{"type":["string","null"]}},"additionalProperties":false}}}`, - []string{ - `{"requireInput1": {"str":"validString"}, "requireInput2": {"str":"validString"}}`, - }, - []string{ - `{"requireInput1": {"str":"validString"}}`, - `{}`, - }, - )) - t.Run("required array", runTest( - `scalar String`, - `query ($input: [String]!){}`, - `{"type":["array"],"items":{"type":["string","null"]}}`, - []string{ - `[]`, - `["validString1"]`, - `["validString1", "validString2"]`, - `["validString1", "validString2", null]`, - }, - []string{ - `"validString"`, - `false`, - `null`, - }, - )) - t.Run("required array element", runTest( - `scalar String`, - `query ($input: [String!]){}`, - `{"type":["array","null"],"items":{"type":["string"]}}`, - []string{ - `null`, - `[]`, - `["validString1"]`, - `["validString1", "validString2"]`, - }, - []string{ - `[null]`, - `["validString1", "validString2", null]`, - `"validString"`, - `false`, - }, - )) - t.Run("nested object", runTest( - `scalar String scalar Boolean input Test { str: String! nested: Nested } input Nested { boo: Boolean }`, - `query ($input: Test){}`, - `{"type":["object","null"],"properties":{"nested":{"anyOf":[{"type":["null"]},{"$ref":"#/$defs/Nested"}]},"str":{"type":["string"]}},"required":["str"],"additionalProperties":false,"$defs":{"Nested":{"type":["object","null"],"properties":{"boo":{"type":["boolean","null"]}},"additionalProperties":false}}}`, - []string{ - `null`, - `{"str":"validString"}`, - `{"str":"validString","nested":null}`, - `{"str":"validString","nested":{"boo":true}}`, - `{"str":"validString","nested":{"boo":null}}`, - `{"str":"validString","nested":{}}`, - }, - []string{ - `{"str":true}`, - `{"str":null}`, - `{"nested":{"boo":true}}`, - `{"str":"validString","nested":{"boo":123}}`, - }, - )) - t.Run("nested object with override", runTest( - `scalar String scalar Boolean input Test { str: String! override: Override } input Override { boo: Boolean }`, - `query ($input: Test){}`, - `{"type":["object","null"],"properties":{"override":{"type":["string","null"]},"str":{"type":["string"]}},"required":["str"],"additionalProperties":false}`, - []string{ - `null`, - `{"str":"validString"}`, - `{"str":"validString","override":"{\"boo\":true}"}`, - `{"str":"validString","override":null}`, - }, - []string{ - `{"str":true}`, - `{"str":null}`, - `{"override":{"boo":true}}`, - `{"str":"validString","override":{"boo":123}}`, - }, - WithOverrides(map[string]JsonSchema{ - "Override": NewString(false), - }), - )) - t.Run("recursive object", runTest( - `scalar String scalar Boolean input Test { str: String! nested: Nested } input Nested { boo: Boolean recursive: Test }`, - `query ($input: Test){}`, - `{"type":["object","null"],"properties":{"nested":{"anyOf":[{"type":["null"]},{"$ref":"#/$defs/Nested"}]},"str":{"type":["string"]}},"required":["str"],"additionalProperties":false,"$defs":{"Nested":{"type":["object","null"],"properties":{"boo":{"type":["boolean","null"]},"recursive":{"anyOf": [{"type":["null"]},{"$ref":"#/$defs/Test"}]}},"additionalProperties":false},"Test":{"type":["object","null"],"properties":{"nested":{"anyOf":[{"type":["null"]},{"$ref":"#/$defs/Nested"}]},"str":{"type":["string"]}},"required":["str"],"additionalProperties":false}}}`, - []string{ - `{"str":"validString"}`, - `{"str":"validString","nested":{"boo":true}}`, - `{"str":"validString","nested":{"boo":null}}`, - `{"str":"validString","nested":null}`, - }, - []string{ - `{"str":true}`, - `{"nested":{"boo":true}}`, - `{"str":"validString","nested":{"boo":123}}`, - }, - )) - t.Run("recursive object with multiple branches", runTest( - `scalar String scalar Boolean input Root { test: Test another: Another } input Test { str: String! nested: Nested } input Nested { boo: Boolean recursive: Test another: Another } input Another { boo: Boolean }`, - `query ($input: Root){}`, - `{"type":["object","null"],"properties":{"another":{"anyOf":[{"type":["null"]},{"$ref":"#/$defs/Another"}]},"test":{"anyOf":[{"type":["null"]},{"$ref":"#/$defs/Test"}]}},"additionalProperties":false,"$defs":{"Another":{"type":["object","null"],"properties":{"boo":{"type":["boolean","null"]}},"additionalProperties":false},"Nested":{"type":["object","null"],"properties":{"another":{"anyOf":[{"type":["null"]},{"$ref":"#/$defs/Another"}]},"boo":{"type":["boolean","null"]},"recursive":{"anyOf":[{"type":["null"]},{"$ref":"#/$defs/Test"}]}},"additionalProperties":false},"Test":{"type":["object","null"],"properties":{"nested":{"anyOf":[{"type":["null"]},{"$ref":"#/$defs/Nested"}]},"str":{"type":["string"]}},"required":["str"],"additionalProperties":false}}}`, - []string{ - `{"test":{"str":"validString"}}`, - `{"test":{"str":"validString","nested":{"boo":true}}}`, - }, - []string{ - `{"test":{"str":true}}`, - `{"test":{"nested":{"boo":true}}}`, - `{"test":{"str":"validString","nested":{"boo":123}}}`, - }, - )) - t.Run("complex recursive schema", runTest( - complexRecursiveSchema, - `query ($input: db_messagesWhereInput){}`, - complexRecursiveSchemaResult, - []string{}, - []string{}, - )) - t.Run("one level deep sub path", runTest( - "input Human { name: String! } scalar String", - "query ($human: Human!) { }", - `{"type":["string"]}`, - []string{ - `"John Doe"`, - }, - []string{ - `{"name":"John Doe"}`, - }, - WithPath([]string{"name"}), - )) - t.Run("multi level deep sub path", runTest( - "input Human { name: String! pet: Animal } scalar String type Animal { name: String! }", - "query ($human: Human!) { }", - `{"type":["string"]}`, - []string{ - `"Doggie"`, - }, - []string{ - `{"name":"Doggie"}`, - `{"pet":{"name":"Doggie"}}`, - }, - WithPath([]string{"pet", "name"}), - )) - t.Run("not defined scalar", runTest( - `input Container { name: MyScalar }`, - `query ($input: Container){}`, - `{"type":["object", "null"], "properties": {"name": {}}, "additionalProperties": false}`, - []string{}, - []string{}, - )) -} - -const complexRecursiveSchema = ` -scalar Int scalar String - -input db_NestedIntFilter { - equals: Int - in: [Int] - notIn: [Int] - lt: Int - lte: Int - gt: Int - gte: Int - not: db_NestedIntFilter -} - -input db_IntFilter { - equals: Int - in: [Int] - notIn: [Int] - lt: Int - lte: Int - gt: Int - gte: Int - not: db_NestedIntFilter -} - -enum db_QueryMode { - default - insensitive -} - -input db_NestedStringFilter { - equals: String - in: [String] - notIn: [String] - lt: String - lte: String - gt: String - gte: String - contains: String - startsWith: String - endsWith: String - not: db_NestedStringFilter -} - -input db_StringFilter { - equals: String - in: [String] - notIn: [String] - lt: String - lte: String - gt: String - gte: String - contains: String - startsWith: String - endsWith: String - mode: db_QueryMode - not: db_NestedStringFilter -} - -enum db_JsonNullValueFilter { - DbNull - JsonNull - AnyNull -} - -input db_JsonFilter { - equals: db_JsonNullValueFilter - not: db_JsonNullValueFilter -} - -input db_NestedDateTimeFilter { - equals: DateTime - in: [DateTime] - notIn: [DateTime] - lt: DateTime - lte: DateTime - gt: DateTime - gte: DateTime - not: db_NestedDateTimeFilter -} - -input db_DateTimeFilter { - equals: DateTime - in: [DateTime] - notIn: [DateTime] - lt: DateTime - lte: DateTime - gt: DateTime - gte: DateTime - not: db_NestedDateTimeFilter -} - -input db_MessagesListRelationFilter { - every: db_messagesWhereInput - some: db_messagesWhereInput - none: db_messagesWhereInput -} - -input db_usersWhereInput { - AND: db_usersWhereInput - OR: [db_usersWhereInput] - NOT: db_usersWhereInput - id: db_IntFilter - email: db_StringFilter - name: db_StringFilter - updatedat: db_DateTimeFilter - lastlogin: db_DateTimeFilter - pet: db_StringFilter - messages: db_MessagesListRelationFilter -} - -input db_UsersRelationFilter { - is: db_usersWhereInput - isNot: db_usersWhereInput -} - -input db_messagesWhereInput { - AND: db_messagesWhereInput - OR: [db_messagesWhereInput] - NOT: db_messagesWhereInput - id: db_IntFilter - user_id: db_IntFilter - message: db_StringFilter - payload: db_JsonFilter - users: db_UsersRelationFilter -} - -enum db_SortOrder { - asc - desc -} - -input db_messagesOrderByRelationAggregateInput { - _count: db_SortOrder -} - -input db_usersOrderByWithRelationInput { - id: db_SortOrder - email: db_SortOrder - name: db_SortOrder - updatedat: db_SortOrder - lastlogin: db_SortOrder - pet: db_SortOrder - messages: db_messagesOrderByRelationAggregateInput -} - -input db_messagesOrderByWithRelationInput { - id: db_SortOrder - user_id: db_SortOrder - message: db_SortOrder - payload: db_SortOrder - users: db_usersOrderByWithRelationInput -} - -input db_messagesWhereUniqueInput { - id: Int -} - -enum db_MessagesScalarFieldEnum { - id - user_id - message - payload -} - -type db_UsersCountOutputType { - messages: Int! - _join: Query! -} - -type db_users { - id: Int! - email: String! - name: String! - updatedat: DateTime! - lastlogin: DateTime! - pet: String! - messages(where: db_messagesWhereInput, orderBy: [db_messagesOrderByWithRelationInput], cursor: db_messagesWhereUniqueInput, take: Int, skip: Int, distinct: [db_MessagesScalarFieldEnum]): [db_messages] - _count: db_UsersCountOutputType - _join: Query! -} - -type db_messages { - id: Int! - user_id: Int! - message: String! - payload: db_Widgets! - users: db_users! - _join: Query! -} - -type db_MessagesCountAggregateOutputType { - id: Int! - user_id: Int! - message: Int! - payload: Int! - _all: Int! - _join: Query! -} - -type db_MessagesAvgAggregateOutputType { - id: Float - user_id: Float - _join: Query! -} - -type db_MessagesSumAggregateOutputType { - id: Int - user_id: Int - _join: Query! -} - -type db_MessagesMinAggregateOutputType { - id: Int - user_id: Int - message: String - _join: Query! -} - -type db_MessagesMaxAggregateOutputType { - id: Int - user_id: Int - message: String - _join: Query! -} - -type db_AggregateMessages { - _count: db_MessagesCountAggregateOutputType - _avg: db_MessagesAvgAggregateOutputType - _sum: db_MessagesSumAggregateOutputType - _min: db_MessagesMinAggregateOutputType - _max: db_MessagesMaxAggregateOutputType - _join: Query! -} - -input db_messagesCountOrderByAggregateInput { - id: db_SortOrder - user_id: db_SortOrder - message: db_SortOrder - payload: db_SortOrder -} - -input db_messagesAvgOrderByAggregateInput { - id: db_SortOrder - user_id: db_SortOrder -} - -input db_messagesMaxOrderByAggregateInput { - id: db_SortOrder - user_id: db_SortOrder - message: db_SortOrder -} - -input db_messagesMinOrderByAggregateInput { - id: db_SortOrder - user_id: db_SortOrder - message: db_SortOrder -} - -input db_messagesSumOrderByAggregateInput { - id: db_SortOrder - user_id: db_SortOrder -} - -input db_messagesOrderByWithAggregationInput { - id: db_SortOrder - user_id: db_SortOrder - message: db_SortOrder - payload: db_SortOrder - _count: db_messagesCountOrderByAggregateInput - _avg: db_messagesAvgOrderByAggregateInput - _max: db_messagesMaxOrderByAggregateInput - _min: db_messagesMinOrderByAggregateInput - _sum: db_messagesSumOrderByAggregateInput -} - -input db_NestedFloatFilter { - equals: Float - in: [Float] - notIn: [Float] - lt: Float - lte: Float - gt: Float - gte: Float - not: db_NestedFloatFilter -} - -input db_NestedIntWithAggregatesFilter { - equals: Int - in: [Int] - notIn: [Int] - lt: Int - lte: Int - gt: Int - gte: Int - not: db_NestedIntWithAggregatesFilter - _count: db_NestedIntFilter - _avg: db_NestedFloatFilter - _sum: db_NestedIntFilter - _min: db_NestedIntFilter - _max: db_NestedIntFilter -} - -input db_IntWithAggregatesFilter { - equals: Int - in: [Int] - notIn: [Int] - lt: Int - lte: Int - gt: Int - gte: Int - not: db_NestedIntWithAggregatesFilter - _count: db_NestedIntFilter - _avg: db_NestedFloatFilter - _sum: db_NestedIntFilter - _min: db_NestedIntFilter - _max: db_NestedIntFilter -} - -input db_NestedStringWithAggregatesFilter { - equals: String - in: [String] - notIn: [String] - lt: String - lte: String - gt: String - gte: String - contains: String - startsWith: String - endsWith: String - not: db_NestedStringWithAggregatesFilter - _count: db_NestedIntFilter - _min: db_NestedStringFilter - _max: db_NestedStringFilter -} - -input db_StringWithAggregatesFilter { - equals: String - in: [String] - notIn: [String] - lt: String - lte: String - gt: String - gte: String - contains: String - startsWith: String - endsWith: String - mode: db_QueryMode - not: db_NestedStringWithAggregatesFilter - _count: db_NestedIntFilter - _min: db_NestedStringFilter - _max: db_NestedStringFilter -} - -input db_NestedJsonFilter { - equals: db_JsonNullValueFilter - not: db_JsonNullValueFilter -} - -input db_JsonWithAggregatesFilter { - equals: db_JsonNullValueFilter - not: db_JsonNullValueFilter - _count: db_NestedIntFilter - _min: db_NestedJsonFilter - _max: db_NestedJsonFilter -} - -input db_messagesScalarWhereWithAggregatesInput { - AND: db_messagesScalarWhereWithAggregatesInput - OR: [db_messagesScalarWhereWithAggregatesInput] - NOT: db_messagesScalarWhereWithAggregatesInput - id: db_IntWithAggregatesFilter - user_id: db_IntWithAggregatesFilter - message: db_StringWithAggregatesFilter - payload: db_JsonWithAggregatesFilter -} - -type db_MessagesGroupByOutputType { - id: Int! - user_id: Int! - message: String! - payload: JSON! - _count: db_MessagesCountAggregateOutputType - _avg: db_MessagesAvgAggregateOutputType - _sum: db_MessagesSumAggregateOutputType - _min: db_MessagesMinAggregateOutputType - _max: db_MessagesMaxAggregateOutputType - _join: Query! -} - -input db_usersWhereUniqueInput { - id: Int - email: String -} - -enum db_UsersScalarFieldEnum { - id - email - name - updatedat - lastlogin - pet -} - -type db_UsersCountAggregateOutputType { - id: Int! - email: Int! - name: Int! - updatedat: Int! - lastlogin: Int! - pet: Int! - _all: Int! - _join: Query! -} - -type db_UsersAvgAggregateOutputType { - id: Float - _join: Query! -} - -type db_UsersSumAggregateOutputType { - id: Int - _join: Query! -} - -type db_UsersMinAggregateOutputType { - id: Int - email: String - name: String - updatedat: DateTime - lastlogin: DateTime - pet: String - _join: Query! -} - -type db_UsersMaxAggregateOutputType { - id: Int - email: String - name: String - updatedat: DateTime - lastlogin: DateTime - pet: String - _join: Query! -} - -type db_AggregateUsers { - _count: db_UsersCountAggregateOutputType - _avg: db_UsersAvgAggregateOutputType - _sum: db_UsersSumAggregateOutputType - _min: db_UsersMinAggregateOutputType - _max: db_UsersMaxAggregateOutputType - _join: Query! -} - -input db_usersCountOrderByAggregateInput { - id: db_SortOrder - email: db_SortOrder - name: db_SortOrder - updatedat: db_SortOrder - lastlogin: db_SortOrder - pet: db_SortOrder -} - -input db_usersAvgOrderByAggregateInput { - id: db_SortOrder -} - -input db_usersMaxOrderByAggregateInput { - id: db_SortOrder - email: db_SortOrder - name: db_SortOrder - updatedat: db_SortOrder - lastlogin: db_SortOrder - pet: db_SortOrder -} - -input db_usersMinOrderByAggregateInput { - id: db_SortOrder - email: db_SortOrder - name: db_SortOrder - updatedat: db_SortOrder - lastlogin: db_SortOrder - pet: db_SortOrder -} - -input db_usersSumOrderByAggregateInput { - id: db_SortOrder -} - -input db_usersOrderByWithAggregationInput { - id: db_SortOrder - email: db_SortOrder - name: db_SortOrder - updatedat: db_SortOrder - lastlogin: db_SortOrder - pet: db_SortOrder - _count: db_usersCountOrderByAggregateInput - _avg: db_usersAvgOrderByAggregateInput - _max: db_usersMaxOrderByAggregateInput - _min: db_usersMinOrderByAggregateInput - _sum: db_usersSumOrderByAggregateInput -} - -input db_NestedDateTimeWithAggregatesFilter { - equals: DateTime - in: [DateTime] - notIn: [DateTime] - lt: DateTime - lte: DateTime - gt: DateTime - gte: DateTime - not: db_NestedDateTimeWithAggregatesFilter - _count: db_NestedIntFilter - _min: db_NestedDateTimeFilter - _max: db_NestedDateTimeFilter -} - -input db_DateTimeWithAggregatesFilter { - equals: DateTime - in: [DateTime] - notIn: [DateTime] - lt: DateTime - lte: DateTime - gt: DateTime - gte: DateTime - not: db_NestedDateTimeWithAggregatesFilter - _count: db_NestedIntFilter - _min: db_NestedDateTimeFilter - _max: db_NestedDateTimeFilter -} - -input db_usersScalarWhereWithAggregatesInput { - AND: db_usersScalarWhereWithAggregatesInput - OR: [db_usersScalarWhereWithAggregatesInput] - NOT: db_usersScalarWhereWithAggregatesInput - id: db_IntWithAggregatesFilter - email: db_StringWithAggregatesFilter - name: db_StringWithAggregatesFilter - updatedat: db_DateTimeWithAggregatesFilter - lastlogin: db_DateTimeWithAggregatesFilter - pet: db_StringWithAggregatesFilter -} - -type db_UsersGroupByOutputType { - id: Int! - email: String! - name: String! - updatedat: DateTime! - lastlogin: DateTime! - pet: String! - _count: db_UsersCountAggregateOutputType - _avg: db_UsersAvgAggregateOutputType - _sum: db_UsersSumAggregateOutputType - _min: db_UsersMinAggregateOutputType - _max: db_UsersMaxAggregateOutputType - _join: Query! -} - -type Query { - db_findFirstmessages(where: db_messagesWhereInput, orderBy: [db_messagesOrderByWithRelationInput], cursor: db_messagesWhereUniqueInput, take: Int, skip: Int, distinct: [db_MessagesScalarFieldEnum]): db_messages - db_findManymessages(where: db_messagesWhereInput, orderBy: [db_messagesOrderByWithRelationInput], cursor: db_messagesWhereUniqueInput, take: Int, skip: Int, distinct: [db_MessagesScalarFieldEnum]): [db_messages]! - db_aggregatemessages(where: db_messagesWhereInput, orderBy: [db_messagesOrderByWithRelationInput], cursor: db_messagesWhereUniqueInput, take: Int, skip: Int): db_AggregateMessages! - db_groupBymessages(where: db_messagesWhereInput, orderBy: [db_messagesOrderByWithAggregationInput], by: [db_MessagesScalarFieldEnum]!, having: db_messagesScalarWhereWithAggregatesInput, take: Int, skip: Int): [db_MessagesGroupByOutputType]! - db_findUniquemessages(where: db_messagesWhereUniqueInput!): db_messages - db_findFirstusers(where: db_usersWhereInput, orderBy: [db_usersOrderByWithRelationInput], cursor: db_usersWhereUniqueInput, take: Int, skip: Int, distinct: [db_UsersScalarFieldEnum]): db_users - db_findManyusers(where: db_usersWhereInput, orderBy: [db_usersOrderByWithRelationInput], cursor: db_usersWhereUniqueInput, take: Int, skip: Int, distinct: [db_UsersScalarFieldEnum]): [db_users]! - db_aggregateusers(where: db_usersWhereInput, orderBy: [db_usersOrderByWithRelationInput], cursor: db_usersWhereUniqueInput, take: Int, skip: Int): db_AggregateUsers! - db_groupByusers(where: db_usersWhereInput, orderBy: [db_usersOrderByWithAggregationInput], by: [db_UsersScalarFieldEnum]!, having: db_usersScalarWhereWithAggregatesInput, take: Int, skip: Int): [db_UsersGroupByOutputType]! - db_findUniqueusers(where: db_usersWhereUniqueInput!): db_users -} - -input db_usersCreateWithoutMessagesInput { - email: String! - name: String! - updatedat: DateTime - lastlogin: DateTime - pet: String -} - -input db_usersCreateOrConnectWithoutMessagesInput { - where: db_usersWhereUniqueInput! - create: db_usersCreateWithoutMessagesInput! -} - -input db_usersCreateNestedOneWithoutMessagesInput { - create: db_usersCreateWithoutMessagesInput - connectOrCreate: db_usersCreateOrConnectWithoutMessagesInput - connect: db_usersWhereUniqueInput -} - -input db_messagesCreateInput { - message: String! - payload: db_WidgetsInput - users: db_usersCreateNestedOneWithoutMessagesInput! -} - -input db_StringFieldUpdateOperationsInput { - set: String -} - -input db_DateTimeFieldUpdateOperationsInput { - set: DateTime -} - -input db_usersUpdateWithoutMessagesInput { - email: db_StringFieldUpdateOperationsInput - name: db_StringFieldUpdateOperationsInput - updatedat: db_DateTimeFieldUpdateOperationsInput - lastlogin: db_DateTimeFieldUpdateOperationsInput - pet: db_StringFieldUpdateOperationsInput -} - -input db_usersUpsertWithoutMessagesInput { - update: db_usersUpdateWithoutMessagesInput! - create: db_usersCreateWithoutMessagesInput! -} - -input db_usersUpdateOneRequiredWithoutMessagesInput { - create: db_usersCreateWithoutMessagesInput - connectOrCreate: db_usersCreateOrConnectWithoutMessagesInput - upsert: db_usersUpsertWithoutMessagesInput - connect: db_usersWhereUniqueInput - update: db_usersUpdateWithoutMessagesInput -} - -input db_messagesUpdateInput { - message: db_StringFieldUpdateOperationsInput - payload: db_WidgetsInput - users: db_usersUpdateOneRequiredWithoutMessagesInput -} - -input db_messagesCreateManyInput { - id: Int - user_id: Int! - message: String! - payload: db_WidgetsInput -} - -type db_AffectedRowsOutput { - count: Int! - _join: Query! -} - -input db_messagesUpdateManyMutationInput { - message: db_StringFieldUpdateOperationsInput - payload: db_WidgetsInput -} - -input db_messagesCreateWithoutUsersInput { - message: String! - payload: db_WidgetsInput -} - -input db_messagesCreateOrConnectWithoutUsersInput { - where: db_messagesWhereUniqueInput! - create: db_messagesCreateWithoutUsersInput! -} - -input db_messagesCreateManyUsersInput { - id: Int - message: String! - payload: db_WidgetsInput -} - -input db_messagesCreateManyUsersInputEnvelope { - data: [db_messagesCreateManyUsersInput]! - skipDuplicates: Boolean -} - -input db_messagesCreateNestedManyWithoutUsersInput { - create: db_messagesCreateWithoutUsersInput - connectOrCreate: db_messagesCreateOrConnectWithoutUsersInput - createMany: db_messagesCreateManyUsersInputEnvelope - connect: db_messagesWhereUniqueInput -} - -input db_usersCreateInput { - email: String! - name: String! - updatedat: DateTime - lastlogin: DateTime - pet: String - messages: db_messagesCreateNestedManyWithoutUsersInput -} - -input db_messagesUpdateWithoutUsersInput { - message: db_StringFieldUpdateOperationsInput - payload: db_WidgetsInput -} - -input db_messagesUpsertWithWhereUniqueWithoutUsersInput { - where: db_messagesWhereUniqueInput! - update: db_messagesUpdateWithoutUsersInput! - create: db_messagesCreateWithoutUsersInput! -} - -input db_messagesUpdateWithWhereUniqueWithoutUsersInput { - where: db_messagesWhereUniqueInput! - data: db_messagesUpdateWithoutUsersInput! -} - -input db_messagesScalarWhereInput { - AND: db_messagesScalarWhereInput - OR: [db_messagesScalarWhereInput] - NOT: db_messagesScalarWhereInput - id: db_IntFilter - user_id: db_IntFilter - message: db_StringFilter - payload: db_JsonFilter -} - -input db_messagesUpdateManyWithWhereWithoutUsersInput { - where: db_messagesScalarWhereInput! - data: db_messagesUpdateManyMutationInput! -} - -input db_messagesUpdateManyWithoutUsersInput { - create: db_messagesCreateWithoutUsersInput - connectOrCreate: db_messagesCreateOrConnectWithoutUsersInput - upsert: db_messagesUpsertWithWhereUniqueWithoutUsersInput - createMany: db_messagesCreateManyUsersInputEnvelope - connect: db_messagesWhereUniqueInput - set: db_messagesWhereUniqueInput - disconnect: db_messagesWhereUniqueInput - delete: db_messagesWhereUniqueInput - update: db_messagesUpdateWithWhereUniqueWithoutUsersInput - updateMany: db_messagesUpdateManyWithWhereWithoutUsersInput - deleteMany: db_messagesScalarWhereInput -} - -input db_usersUpdateInput { - email: db_StringFieldUpdateOperationsInput - name: db_StringFieldUpdateOperationsInput - updatedat: db_DateTimeFieldUpdateOperationsInput - lastlogin: db_DateTimeFieldUpdateOperationsInput - pet: db_StringFieldUpdateOperationsInput - messages: db_messagesUpdateManyWithoutUsersInput -} - -input db_usersCreateManyInput { - id: Int - email: String! - name: String! - updatedat: DateTime - lastlogin: DateTime - pet: String -} - -input db_usersUpdateManyMutationInput { - email: db_StringFieldUpdateOperationsInput - name: db_StringFieldUpdateOperationsInput - updatedat: db_DateTimeFieldUpdateOperationsInput - lastlogin: db_DateTimeFieldUpdateOperationsInput - pet: db_StringFieldUpdateOperationsInput -} - -type Mutation { - db_createOnemessages(data: db_messagesCreateInput!): db_messages - db_upsertOnemessages(where: db_messagesWhereUniqueInput!, create: db_messagesCreateInput!, update: db_messagesUpdateInput!): db_messages - db_createManymessages(data: [db_messagesCreateManyInput]!, skipDuplicates: Boolean): db_AffectedRowsOutput - db_deleteOnemessages(where: db_messagesWhereUniqueInput!): db_messages - db_updateOnemessages(data: db_messagesUpdateInput!, where: db_messagesWhereUniqueInput!): db_messages - db_updateManymessages(data: db_messagesUpdateManyMutationInput!, where: db_messagesWhereInput): db_AffectedRowsOutput - db_deleteManymessages(where: db_messagesWhereInput): db_AffectedRowsOutput - db_createOneusers(data: db_usersCreateInput!): db_users - db_upsertOneusers(where: db_usersWhereUniqueInput!, create: db_usersCreateInput!, update: db_usersUpdateInput!): db_users - db_createManyusers(data: [db_usersCreateManyInput]!, skipDuplicates: Boolean): db_AffectedRowsOutput - db_deleteOneusers(where: db_usersWhereUniqueInput!): db_users - db_updateOneusers(data: db_usersUpdateInput!, where: db_usersWhereUniqueInput!): db_users - db_updateManyusers(data: db_usersUpdateManyMutationInput!, where: db_usersWhereInput): db_AffectedRowsOutput - db_deleteManyusers(where: db_usersWhereInput): db_AffectedRowsOutput -} - -scalar DateTime - -scalar JSON - -scalar UUID - -type db_Widget { - id: ID! - type: String! - name: String - options: JSON - x: Int! - y: Int! - width: Int! - height: Int! - _join: Query! -} - -type db_Widgets { - items: [db_Widget]! - _join: Query! -} - -input db_WidgetInput { - id: ID! - type: String! - name: String - options: JSON - x: Int! - y: Int! - width: Int! - height: Int! -} - -input db_WidgetsInput { - items: [db_WidgetInput]! -} -` - -const complexRecursiveSchemaResult = ` -{ - "type": [ - "object", - "null" - ], - "properties": { - "AND": { - "anyOf": [ - { - "type": [ - "null" - ] - }, - { - "$ref": "#/$defs/db_messagesWhereInput" - } - ] - }, - "NOT": { - "anyOf": [ - { - "type": [ - "null" - ] - }, - { - "$ref": "#/$defs/db_messagesWhereInput" - } - ] - }, - "OR": { - "type": [ - "array", - "null" - ], - "items": { - "anyOf": [ - { - "type": [ - "null" - ] - }, - { - "$ref": "#/$defs/db_messagesWhereInput" - } - ] - } - }, - "id": { - "anyOf": [ - { - "type": [ - "null" - ] - }, - { - "$ref": "#/$defs/db_IntFilter" - } - ] - }, - "message": { - "anyOf": [ - { - "type": [ - "null" - ] - }, - { - "$ref": "#/$defs/db_StringFilter" - } - ] - }, - "payload": { - "anyOf": [ - { - "type": [ - "null" - ] - }, - { - "$ref": "#/$defs/db_JsonFilter" - } - ] - }, - "user_id": { - "anyOf": [ - { - "type": [ - "null" - ] - }, - { - "$ref": "#/$defs/db_IntFilter" - } - ] - }, - "users": { - "anyOf": [ - { - "type": [ - "null" - ] - }, - { - "$ref": "#/$defs/db_UsersRelationFilter" - } - ] - } - }, - "additionalProperties": false, - "$defs": { - "db_DateTimeFilter": { - "type": [ - "object", - "null" - ], - "properties": { - "equals": {}, - "gt": {}, - "gte": {}, - "in": { - "type": [ - "array", - "null" - ], - "items": {} - }, - "lt": {}, - "lte": {}, - "not": { - "anyOf": [ - { - "type": [ - "null" - ] - }, - { - "$ref": "#/$defs/db_NestedDateTimeFilter" - } - ] - }, - "notIn": { - "type": [ - "array", - "null" - ], - "items": {} - } - }, - "additionalProperties": false - }, - "db_IntFilter": { - "type": [ - "object", - "null" - ], - "properties": { - "equals": { - "type": [ - "integer", - "null" - ] - }, - "gt": { - "type": [ - "integer", - "null" - ] - }, - "gte": { - "type": [ - "integer", - "null" - ] - }, - "in": { - "type": [ - "array", - "null" - ], - "items": { - "type": [ - "integer", - "null" - ] - } - }, - "lt": { - "type": [ - "integer", - "null" - ] - }, - "lte": { - "type": [ - "integer", - "null" - ] - }, - "not": { - "anyOf": [ - { - "type": [ - "null" - ] - }, - { - "$ref": "#/$defs/db_NestedIntFilter" - } - ] - }, - "notIn": { - "type": [ - "array", - "null" - ], - "items": { - "type": [ - "integer", - "null" - ] - } - } - }, - "additionalProperties": false - }, - "db_JsonFilter": { - "type": [ - "object", - "null" - ], - "properties": { - "equals": { - "type": [ - "string", - "null" - ] - }, - "not": { - "type": [ - "string", - "null" - ] - } - }, - "additionalProperties": false - }, - "db_MessagesListRelationFilter": { - "type": [ - "object", - "null" - ], - "properties": { - "every": { - "anyOf": [ - { - "type": [ - "null" - ] - }, - { - "$ref": "#/$defs/db_messagesWhereInput" - } - ] - }, - "none": { - "anyOf": [ - { - "type": [ - "null" - ] - }, - { - "$ref": "#/$defs/db_messagesWhereInput" - } - ] - }, - "some": { - "anyOf": [ - { - "type": [ - "null" - ] - }, - { - "$ref": "#/$defs/db_messagesWhereInput" - } - ] - } - }, - "additionalProperties": false - }, - "db_NestedDateTimeFilter": { - "type": [ - "object", - "null" - ], - "properties": { - "equals": {}, - "gt": {}, - "gte": {}, - "in": { - "type": [ - "array", - "null" - ], - "items": {} - }, - "lt": {}, - "lte": {}, - "not": { - "anyOf": [ - { - "type": [ - "null" - ] - }, - { - "$ref": "#/$defs/db_NestedDateTimeFilter" - } - ] - }, - "notIn": { - "type": [ - "array", - "null" - ], - "items": {} - } - }, - "additionalProperties": false - }, - "db_NestedIntFilter": { - "type": [ - "object", - "null" - ], - "properties": { - "equals": { - "type": [ - "integer", - "null" - ] - }, - "gt": { - "type": [ - "integer", - "null" - ] - }, - "gte": { - "type": [ - "integer", - "null" - ] - }, - "in": { - "type": [ - "array", - "null" - ], - "items": { - "type": [ - "integer", - "null" - ] - } - }, - "lt": { - "type": [ - "integer", - "null" - ] - }, - "lte": { - "type": [ - "integer", - "null" - ] - }, - "not": { - "anyOf": [ - { - "type": [ - "null" - ] - }, - { - "$ref": "#/$defs/db_NestedIntFilter" - } - ] - }, - "notIn": { - "type": [ - "array", - "null" - ], - "items": { - "type": [ - "integer", - "null" - ] - } - } - }, - "additionalProperties": false - }, - "db_NestedStringFilter": { - "type": [ - "object", - "null" - ], - "properties": { - "contains": { - "type": [ - "string", - "null" - ] - }, - "endsWith": { - "type": [ - "string", - "null" - ] - }, - "equals": { - "type": [ - "string", - "null" - ] - }, - "gt": { - "type": [ - "string", - "null" - ] - }, - "gte": { - "type": [ - "string", - "null" - ] - }, - "in": { - "type": [ - "array", - "null" - ], - "items": { - "type": [ - "string", - "null" - ] - } - }, - "lt": { - "type": [ - "string", - "null" - ] - }, - "lte": { - "type": [ - "string", - "null" - ] - }, - "not": { - "anyOf": [ - { - "type": [ - "null" - ] - }, - { - "$ref": "#/$defs/db_NestedStringFilter" - } - ] - }, - "notIn": { - "type": [ - "array", - "null" - ], - "items": { - "type": [ - "string", - "null" - ] - } - }, - "startsWith": { - "type": [ - "string", - "null" - ] - } - }, - "additionalProperties": false - }, - "db_StringFilter": { - "type": [ - "object", - "null" - ], - "properties": { - "contains": { - "type": [ - "string", - "null" - ] - }, - "endsWith": { - "type": [ - "string", - "null" - ] - }, - "equals": { - "type": [ - "string", - "null" - ] - }, - "gt": { - "type": [ - "string", - "null" - ] - }, - "gte": { - "type": [ - "string", - "null" - ] - }, - "in": { - "type": [ - "array", - "null" - ], - "items": { - "type": [ - "string", - "null" - ] - } - }, - "lt": { - "type": [ - "string", - "null" - ] - }, - "lte": { - "type": [ - "string", - "null" - ] - }, - "mode": { - "type": [ - "string", - "null" - ] - }, - "not": { - "anyOf": [ - { - "type": [ - "null" - ] - }, - { - "$ref": "#/$defs/db_NestedStringFilter" - } - ] - }, - "notIn": { - "type": [ - "array", - "null" - ], - "items": { - "type": [ - "string", - "null" - ] - } - }, - "startsWith": { - "type": [ - "string", - "null" - ] - } - }, - "additionalProperties": false - }, - "db_UsersRelationFilter": { - "type": [ - "object", - "null" - ], - "properties": { - "is": { - "anyOf": [ - { - "type": [ - "null" - ] - }, - { - "$ref": "#/$defs/db_usersWhereInput" - } - ] - }, - "isNot": { - "anyOf": [ - { - "type": [ - "null" - ] - }, - { - "$ref": "#/$defs/db_usersWhereInput" - } - ] - } - }, - "additionalProperties": false - }, - "db_messagesWhereInput": { - "type": [ - "object", - "null" - ], - "properties": { - "AND": { - "anyOf": [ - { - "type": [ - "null" - ] - }, - { - "$ref": "#/$defs/db_messagesWhereInput" - } - ] - }, - "NOT": { - "anyOf": [ - { - "type": [ - "null" - ] - }, - { - "$ref": "#/$defs/db_messagesWhereInput" - } - ] - }, - "OR": { - "type": [ - "array", - "null" - ], - "items": { - "anyOf": [ - { - "type": [ - "null" - ] - }, - { - "$ref": "#/$defs/db_messagesWhereInput" - } - ] - } - }, - "id": { - "anyOf": [ - { - "type": [ - "null" - ] - }, - { - "$ref": "#/$defs/db_IntFilter" - } - ] - }, - "message": { - "anyOf": [ - { - "type": [ - "null" - ] - }, - { - "$ref": "#/$defs/db_StringFilter" - } - ] - }, - "payload": { - "anyOf": [ - { - "type": [ - "null" - ] - }, - { - "$ref": "#/$defs/db_JsonFilter" - } - ] - }, - "user_id": { - "anyOf": [ - { - "type": [ - "null" - ] - }, - { - "$ref": "#/$defs/db_IntFilter" - } - ] - }, - "users": { - "anyOf": [ - { - "type": [ - "null" - ] - }, - { - "$ref": "#/$defs/db_UsersRelationFilter" - } - ] - } - }, - "additionalProperties": false - }, - "db_usersWhereInput": { - "type": [ - "object", - "null" - ], - "properties": { - "AND": { - "anyOf": [ - { - "type": [ - "null" - ] - }, - { - "$ref": "#/$defs/db_usersWhereInput" - } - ] - }, - "NOT": { - "anyOf": [ - { - "type": [ - "null" - ] - }, - { - "$ref": "#/$defs/db_usersWhereInput" - } - ] - }, - "OR": { - "type": [ - "array", - "null" - ], - "items": { - "anyOf": [ - { - "type": [ - "null" - ] - }, - { - "$ref": "#/$defs/db_usersWhereInput" - } - ] - } - }, - "email": { - "anyOf": [ - { - "type": [ - "null" - ] - }, - { - "$ref": "#/$defs/db_StringFilter" - } - ] - }, - "id": { - "anyOf": [ - { - "type": [ - "null" - ] - }, - { - "$ref": "#/$defs/db_IntFilter" - } - ] - }, - "lastlogin": { - "anyOf": [ - { - "type": [ - "null" - ] - }, - { - "$ref": "#/$defs/db_DateTimeFilter" - } - ] - }, - "messages": { - "anyOf": [ - { - "type": [ - "null" - ] - }, - { - "$ref": "#/$defs/db_MessagesListRelationFilter" - } - ] - }, - "name": { - "anyOf": [ - { - "type": [ - "null" - ] - }, - { - "$ref": "#/$defs/db_StringFilter" - } - ] - }, - "pet": { - "anyOf": [ - { - "type": [ - "null" - ] - }, - { - "$ref": "#/$defs/db_StringFilter" - } - ] - }, - "updatedat": { - "anyOf": [ - { - "type": [ - "null" - ] - }, - { - "$ref": "#/$defs/db_DateTimeFilter" - } - ] - } - }, - "additionalProperties": false - } - } -}` From bef34adb9301b88bca19d9246a7db4f96d261cd3 Mon Sep 17 00:00:00 2001 From: StarpTech Date: Fri, 11 Apr 2025 22:21:38 +0200 Subject: [PATCH 02/16] chore: proper nullable handling, graceful shutdown mcp --- v2/pkg/engine/jsonschema/schema.go | 36 ++- v2/pkg/engine/jsonschema/schema_test.go | 92 ++++++++ v2/pkg/engine/jsonschema/variables_schema.go | 33 ++- .../jsonschema/variables_schema_test.go | 213 ++++++++++++++++++ 4 files changed, 362 insertions(+), 12 deletions(-) diff --git a/v2/pkg/engine/jsonschema/schema.go b/v2/pkg/engine/jsonschema/schema.go index 0840947455..2a15011cc1 100644 --- a/v2/pkg/engine/jsonschema/schema.go +++ b/v2/pkg/engine/jsonschema/schema.go @@ -25,6 +25,7 @@ type JsonSchema struct { 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"` @@ -72,6 +73,11 @@ func (s *JsonSchema) MarshalJSON() ([]byte, error) { m["description"] = s.Description } + // Only include nullable field when it's true, omit when false + if s.Nullable { + m["nullable"] = true + } + if s.Items != nil { m["items"] = s.Items } @@ -112,50 +118,57 @@ func NewObjectSchema() *JsonSchema { Properties: make(map[string]*JsonSchema), AdditionalProperties: &additionalProps, Required: []string{}, + 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, + 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, + Type: TypeString, + Nullable: true, // Default to nullable } } // NewIntegerSchema creates a new schema for an integer type func NewIntegerSchema() *JsonSchema { return &JsonSchema{ - Type: TypeInteger, + Type: TypeInteger, + Nullable: true, // Default to nullable } } // NewNumberSchema creates a new schema for a number type func NewNumberSchema() *JsonSchema { return &JsonSchema{ - Type: TypeNumber, + Type: TypeNumber, + Nullable: true, // Default to nullable } } // NewBooleanSchema creates a new schema for a boolean type func NewBooleanSchema() *JsonSchema { return &JsonSchema{ - Type: TypeBoolean, + Type: TypeBoolean, + Nullable: true, // Default to nullable } } // NewEnumSchema creates a new schema for an enum type func NewEnumSchema(values []interface{}) *JsonSchema { return &JsonSchema{ - Type: TypeString, - Enum: values, + Type: TypeString, + Enum: values, + Nullable: true, // Default to nullable } } @@ -171,6 +184,7 @@ func CloneSchema(schema *JsonSchema) *JsonSchema { Format: schema.Format, Pattern: schema.Pattern, Default: schema.Default, + Nullable: schema.Nullable, } if schema.Properties != nil { @@ -227,3 +241,9 @@ 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 index 36ce6aaca6..cd63b640b8 100644 --- a/v2/pkg/engine/jsonschema/schema_test.go +++ b/v2/pkg/engine/jsonschema/schema_test.go @@ -193,6 +193,36 @@ func TestCloneSchema(t *testing.T) { assert.NotEqual(t, original.Required, clone.Required) assert.Empty(t, original.Properties["string"].Description) }) + + t.Run("clone preserves nullable field", func(t *testing.T) { + // Create schemas with different nullable settings + nullable := NewStringSchema().WithNullable(true) + nonNullable := NewStringSchema().WithNullable(false) + + // Clone the schemas + nullableClone := CloneSchema(nullable) + nonNullableClone := CloneSchema(nonNullable) + + // Verify nullable property is preserved + assert.True(t, nullableClone.Nullable) + assert.False(t, nonNullableClone.Nullable) + + // Create a complex schema with different nullable settings + complex := NewObjectSchema() + complex.Properties["nullableString"] = NewStringSchema().WithNullable(true) + complex.Properties["nonNullableString"] = NewStringSchema().WithNullable(false) + complex.Properties["nullableArray"] = NewArraySchema(NewStringSchema()).WithNullable(true) + complex.Properties["nonNullableArray"] = NewArraySchema(NewStringSchema()).WithNullable(false) + + // Clone the complex schema + complexClone := CloneSchema(complex) + + // Verify nullable settings are preserved for all properties + assert.True(t, complexClone.Properties["nullableString"].Nullable) + assert.False(t, complexClone.Properties["nonNullableString"].Nullable) + assert.True(t, complexClone.Properties["nullableArray"].Nullable) + assert.False(t, complexClone.Properties["nonNullableArray"].Nullable) + }) } func TestSchemaFeatures(t *testing.T) { @@ -457,4 +487,66 @@ func TestSchemaFeatures(t *testing.T) { assert.Len(t, addressProps, 2) assert.Equal(t, []interface{}{"street"}, addressProp["required"]) }) + + 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([]interface{}{"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 index 5fe8fdc8f7..4a2cb4784c 100644 --- a/v2/pkg/engine/jsonschema/variables_schema.go +++ b/v2/pkg/engine/jsonschema/variables_schema.go @@ -107,6 +107,11 @@ func (v *VariablesSchemaBuilder) Build() (*JsonSchema, error) { v.processVariableDefinition(varDefRef) } + // If we have required fields, the root schema cannot be nullable + if len(v.schema.Required) > 0 { + v.schema.Nullable = false + } + if v.report.HasErrors() { return nil, fmt.Errorf("%s", v.report.Error()) } @@ -151,6 +156,8 @@ func (v *VariablesSchemaBuilder) processOperationTypeRef(typeRef int) *JsonSchem if schema == nil { return nil } + // Non-null types are not nullable + schema.Nullable = false return schema case ast.TypeKindList: @@ -159,11 +166,19 @@ func (v *VariablesSchemaBuilder) processOperationTypeRef(typeRef int) *JsonSchem if itemSchema == nil { return nil } - return NewArraySchema(itemSchema) + // 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) - return v.processTypeByName(typeName) + 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 @@ -320,6 +335,8 @@ func (v *VariablesSchemaBuilder) processDefinitionTypeRef(typeRef int) *JsonSche if schema == nil { return nil } + // Non-null types are not nullable + schema.Nullable = false return schema case ast.TypeKindList: @@ -328,11 +345,19 @@ func (v *VariablesSchemaBuilder) processDefinitionTypeRef(typeRef int) *JsonSche if itemSchema == nil { return nil } - return NewArraySchema(itemSchema) + // 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) - return v.processTypeByName(typeName) + 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 diff --git a/v2/pkg/engine/jsonschema/variables_schema_test.go b/v2/pkg/engine/jsonschema/variables_schema_test.go index 0055975e51..f8fb335da6 100644 --- a/v2/pkg/engine/jsonschema/variables_schema_test.go +++ b/v2/pkg/engine/jsonschema/variables_schema_test.go @@ -973,4 +973,217 @@ func TestBuildJsonSchema(t *testing.T) { assert.True(t, strings.Contains(jsonStr, `"a":`) || strings.Contains(jsonStr, `"b":`), "Should have at least one reference to a recursive field") }) + + 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 := ` + 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) + + // Verify schema structure + var parsed map[string]interface{} + err = json.Unmarshal(data, &parsed) + require.NoError(t, err) + + // Get properties + properties := parsed["properties"].(map[string]interface{}) + input := properties["input"].(map[string]interface{}) + inputProps := input["properties"].(map[string]interface{}) + + // Verify nullable property is correctly set based on GraphQL nullability + + // Nullable scalar + id := inputProps["id"].(map[string]interface{}) + assert.Equal(t, "string", id["type"]) + assert.Equal(t, true, id["nullable"]) + + // Non-nullable scalar + name := inputProps["name"].(map[string]interface{}) + assert.Equal(t, "string", name["type"]) + assert.NotContains(t, name, "nullable") // Default is not nullable for required fields + + // Nullable scalar + age := inputProps["age"].(map[string]interface{}) + assert.Equal(t, "integer", age["type"]) + assert.Equal(t, true, age["nullable"]) + + // Nullable array + tags := inputProps["tags"].(map[string]interface{}) + assert.Equal(t, "array", tags["type"]) + assert.Equal(t, true, tags["nullable"]) + + // Non-nullable array with nullable items + requiredTags := inputProps["requiredTags"].(map[string]interface{}) + assert.Equal(t, "array", requiredTags["type"]) + assert.NotContains(t, requiredTags, "nullable") + + // Nullable array with non-nullable items + nonNullTags := inputProps["nonNullTags"].(map[string]interface{}) + assert.Equal(t, "array", nonNullTags["type"]) + assert.Equal(t, true, nonNullTags["nullable"]) + nonNullTagsItems := nonNullTags["items"].(map[string]interface{}) + assert.Equal(t, "string", nonNullTagsItems["type"]) + assert.NotContains(t, nonNullTagsItems, "nullable") + + // Non-nullable array with non-nullable items + requiredNonNullTags := inputProps["requiredNonNullTags"].(map[string]interface{}) + assert.Equal(t, "array", requiredNonNullTags["type"]) + assert.NotContains(t, requiredNonNullTags, "nullable") + requiredNonNullTagsItems := requiredNonNullTags["items"].(map[string]interface{}) + assert.Equal(t, "string", requiredNonNullTagsItems["type"]) + assert.NotContains(t, requiredNonNullTagsItems, "nullable") + + // Nullable object + nested := inputProps["nested"].(map[string]interface{}) + assert.Equal(t, "object", nested["type"]) + assert.Equal(t, true, nested["nullable"]) + + // Non-nullable object + requiredNested := inputProps["requiredNested"].(map[string]interface{}) + assert.Equal(t, "object", requiredNested["type"]) + assert.NotContains(t, requiredNested, "nullable") + + // Check nested fields + nestedProps := nested["properties"].(map[string]interface{}) + + // Nullable nested field + nestedField := nestedProps["field"].(map[string]interface{}) + assert.Equal(t, "string", nestedField["type"]) + assert.Equal(t, true, nestedField["nullable"]) + + // Non-nullable nested field + requiredNestedField := nestedProps["requiredField"].(map[string]interface{}) + assert.Equal(t, "string", requiredNestedField["type"]) + assert.NotContains(t, requiredNestedField, "nullable") + }) + + t.Run("root schema nullable based on required arguments", func(t *testing.T) { + // Define schema with required and optional arguments + schemaSDL := ` + 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.Marshal(schema1) + require.NoError(t, err) + + var parsed1 map[string]interface{} + err = json.Unmarshal(data1, &parsed1) + require.NoError(t, err) + + // Verify root schema is NOT nullable when there are required arguments + _, hasNullable1 := parsed1["nullable"] + assert.False(t, hasNullable1, "Root schema should not be nullable when there are required arguments") + // Verify required fields are present + required1, hasRequired1 := parsed1["required"].([]interface{}) + assert.True(t, hasRequired1) + assert.Contains(t, required1, "id") + + // 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.Marshal(schema2) + require.NoError(t, err) + + var parsed2 map[string]interface{} + err = json.Unmarshal(data2, &parsed2) + require.NoError(t, err) + + // Verify root schema IS nullable when there are only optional arguments + nullable2, hasNullable2 := parsed2["nullable"].(bool) + assert.True(t, hasNullable2) + assert.True(t, nullable2, "Root schema should be nullable when all arguments are optional") + }) } From 6e08818372933ec3fcb955a09feb2eb332382bba Mon Sep 17 00:00:00 2001 From: StarpTech Date: Fri, 11 Apr 2025 22:23:41 +0200 Subject: [PATCH 03/16] chore: readd deleted files --- v2/pkg/graphqljsonschema/jsonschema.go | 475 +++++ v2/pkg/graphqljsonschema/jsonschema_test.go | 2021 +++++++++++++++++++ 2 files changed, 2496 insertions(+) create mode 100644 v2/pkg/graphqljsonschema/jsonschema.go create mode 100644 v2/pkg/graphqljsonschema/jsonschema_test.go diff --git a/v2/pkg/graphqljsonschema/jsonschema.go b/v2/pkg/graphqljsonschema/jsonschema.go new file mode 100644 index 0000000000..3d79120c05 --- /dev/null +++ b/v2/pkg/graphqljsonschema/jsonschema.go @@ -0,0 +1,475 @@ +package graphqljsonschema + +import ( + "context" + "encoding/json" + "fmt" + + "github.com/buger/jsonparser" + "github.com/santhosh-tekuri/jsonschema/v5" + + "github.com/wundergraph/graphql-go-tools/v2/pkg/ast" +) + +type options struct { + overrides map[string]JsonSchema + path []string +} + +type Option func(opts *options) + +func WithOverrides(overrides map[string]JsonSchema) Option { + return func(opts *options) { + opts.overrides = overrides + } +} + +func WithPath(path []string) Option { + return func(opts *options) { + opts.path = path + } +} + +func FromTypeRef(operation, definition *ast.Document, typeRef int, opts ...Option) JsonSchema { + appliedOptions := &options{} + for _, opt := range opts { + opt(appliedOptions) + } + + var resolver *fromTypeRefResolver + if len(appliedOptions.overrides) > 0 { + resolver = &fromTypeRefResolver{ + overrides: appliedOptions.overrides, + } + } else { + resolver = &fromTypeRefResolver{ + overrides: map[string]JsonSchema{}, + } + } + + jsonSchema := resolver.fromTypeRef(operation, definition, typeRef, false) + return resolveJsonSchemaPath(jsonSchema, appliedOptions.path) +} + +func resolveJsonSchemaPath(jsonSchema JsonSchema, path []string) JsonSchema { + switch typedJsonSchema := jsonSchema.(type) { + case Object: + for i := 0; i < len(path); i++ { + propertyJsonSchema, exists := typedJsonSchema.Properties[path[i]] + if !exists { + return jsonSchema + } + jsonSchema = propertyJsonSchema + } + } + + return jsonSchema +} + +type fromTypeRefResolver struct { + overrides map[string]JsonSchema + defs *map[string]JsonSchema +} + +func (r *fromTypeRefResolver) fromTypeRef(operation, definition *ast.Document, typeRef int, field bool) JsonSchema { + + t := operation.Types[typeRef] + + nonNull := false + if operation.TypeIsNonNull(typeRef) { + t = operation.Types[t.OfType] + nonNull = true + } + + switch t.TypeKind { + case ast.TypeKindList: + var defs map[string]JsonSchema + isRoot := false + if r.defs == nil { + defs = make(map[string]JsonSchema, 48) + r.defs = &defs + isRoot = true + } + itemSchema := r.fromTypeRef(operation, definition, t.OfType, field) + arr := NewArray(itemSchema, nonNull) + if isRoot { + arr.Defs = defs + } + return arr + case ast.TypeKindNonNull: + panic("Should not be able to have multiple levels of non-null") + case ast.TypeKindNamed: + name := operation.Input.ByteSliceString(t.Name) + storeAsName := name + if nonNull { + storeAsName = fmt.Sprintf("%sNotNull", name) + } + + if schema, ok := r.overrides[name]; ok { + return schema + } + typeDefinitionNode, ok := definition.Index.FirstNodeByNameStr(name) + if !ok { + return NewAny() + } + if typeDefinitionNode.Kind == ast.NodeKindEnumTypeDefinition { + return NewString(nonNull) + } + if typeDefinitionNode.Kind == ast.NodeKindScalarTypeDefinition { + switch name { + case "Boolean": + return NewBoolean(nonNull) + case "String": + return NewString(nonNull) + case "ID": + return NewID(nonNull) + case "Int": + return NewInteger(nonNull) + case "Float": + return NewNumber(nonNull) + case "_Any": + return NewObjectAny(nonNull) + default: + return NewAny() + } + } + object := NewObject(nonNull) + isRootObject := false + if r.defs == nil { + isRootObject = true + object.Defs = make(map[string]JsonSchema, 48) + r.defs = &object.Defs + } + if !isRootObject { + if _, exists := (*r.defs)[storeAsName]; exists { + if !nonNull { + return NewNullableRef(storeAsName) + } + + return NewRef(storeAsName) + } + (*r.defs)[storeAsName] = object + } + if node, ok := definition.Index.FirstNodeByNameStr(name); ok { + switch node.Kind { + case ast.NodeKindInputObjectTypeDefinition: + for _, ref := range definition.InputObjectTypeDefinitions[node.Ref].InputFieldsDefinition.Refs { + fieldName := definition.Input.ByteSliceString(definition.InputValueDefinitions[ref].Name) + fieldType := definition.InputValueDefinitions[ref].Type + fieldSchema := r.fromTypeRef(definition, definition, fieldType, true) + object.Properties[fieldName] = fieldSchema + if definition.TypeIsNonNull(fieldType) { + object.Required = append(object.Required, fieldName) + } + } + case ast.NodeKindObjectTypeDefinition: + for _, ref := range definition.ObjectTypeDefinitions[node.Ref].FieldsDefinition.Refs { + fieldName := definition.Input.ByteSliceString(definition.FieldDefinitions[ref].Name) + fieldType := definition.FieldDefinitions[ref].Type + fieldSchema := r.fromTypeRef(definition, definition, fieldType, true) + object.Properties[fieldName] = fieldSchema + if definition.TypeIsNonNull(fieldType) { + object.Required = append(object.Required, fieldName) + } + } + } + } + if !isRootObject { + (*r.defs)[storeAsName] = object + + if !nonNull { + return NewNullableRef(storeAsName) + } + + return NewRef(storeAsName) + } + return object + } + + if field { + return NewObject(false) + } + return NewObject(nonNull) +} + +type Validator struct { + schema *jsonschema.Schema +} + +func NewValidatorFromSchema(schema JsonSchema) (*Validator, error) { + s, err := json.Marshal(schema) + if err != nil { + return nil, err + } + return NewValidatorFromString(string(s)) +} + +func MustNewValidatorFromSchema(schema JsonSchema) *Validator { + s, err := json.Marshal(schema) + if err != nil { + panic(err) + } + return MustNewValidatorFromString(string(s)) +} + +func NewValidatorFromString(schema string) (*Validator, error) { + sch, err := jsonschema.CompileString("schema.json", schema) + if err != nil { + return nil, err + } + return &Validator{ + schema: sch, + }, nil +} + +func MustNewValidatorFromString(schema string) *Validator { + validator, err := NewValidatorFromString(schema) + if err != nil { + panic(err) + } + return validator +} + +func (v *Validator) Validate(ctx context.Context, inputJSON []byte) error { + var value interface{} + if err := json.Unmarshal(inputJSON, &value); err != nil { + return err + } + if err := v.schema.Validate(value); err != nil { + return err + } + return nil +} + +func TopLevelType(schema string) (jsonparser.ValueType, error) { + sch, err := jsonschema.CompileString("schema.json", schema) + if err != nil { + return jsonparser.Unknown, err + } + switch sch.Types[0] { + case "boolean": + return jsonparser.Boolean, nil + case "string": + return jsonparser.String, nil + case "object": + return jsonparser.Object, nil + case "number": + return jsonparser.Number, nil + case "integer": + return jsonparser.Number, nil + case "null": + return jsonparser.Null, nil + case "array": + return jsonparser.Array, nil + default: + return jsonparser.NotExist, nil + } +} + +type Kind int + +const ( + StringKind Kind = iota + 1 + NumberKind + BooleanKind + IntegerKind + ObjectKind + ArrayKind + AnyKind + IDKind + RefKind + NullableRefKind + NullKind +) + +func maybeAppendNull(nonNull bool, types ...string) []string { + if nonNull { + return types + } + return append(types, "null") +} + +type JsonSchema interface { + Kind() Kind +} + +type Any struct{} + +func NewAny() Any { + return Any{} +} + +func (a Any) Kind() Kind { + return AnyKind +} + +type String struct { + Type []string `json:"type"` +} + +func (String) Kind() Kind { + return StringKind +} + +func NewString(nonNull bool) String { + return String{ + Type: maybeAppendNull(nonNull, "string"), + } +} + +type ID struct { + Type []string `json:"type"` +} + +func (ID) Kind() Kind { + return IDKind +} + +func NewID(nonNull bool) ID { + return ID{ + Type: maybeAppendNull(nonNull, "string", "integer"), + } +} + +type Boolean struct { + Type []string `json:"type"` +} + +func (Boolean) Kind() Kind { + return BooleanKind +} + +func NewBoolean(nonNull bool) Boolean { + return Boolean{ + Type: maybeAppendNull(nonNull, "boolean"), + } +} + +type Number struct { + Type []string `json:"type"` +} + +func NewNumber(nonNull bool) Number { + return Number{ + Type: maybeAppendNull(nonNull, "number"), + } +} + +func (Number) Kind() Kind { + return NumberKind +} + +type Integer struct { + Type []string `json:"type"` +} + +func (Integer) Kind() Kind { + return IntegerKind +} + +func NewInteger(nonNull bool) Integer { + return Integer{ + Type: maybeAppendNull(nonNull, "integer"), + } +} + +type Ref struct { + Ref string `json:"$ref"` +} + +func (Ref) Kind() Kind { + return RefKind +} + +func NewRef(definitionName string) Ref { + return Ref{ + Ref: fmt.Sprintf("#/$defs/%s", definitionName), + } +} + +type Object struct { + Type []string `json:"type"` + Properties map[string]JsonSchema `json:"properties,omitempty"` + Required []string `json:"required,omitempty"` + AdditionalProperties bool `json:"additionalProperties"` + Defs map[string]JsonSchema `json:"$defs,omitempty"` +} + +func (Object) Kind() Kind { + return ObjectKind +} + +func NewObject(nonNull bool) Object { + return Object{ + Type: maybeAppendNull(nonNull, "object"), + Properties: map[string]JsonSchema{}, + AdditionalProperties: false, + } +} + +func NewObjectAny(nonNull bool) Object { + return Object{ + Type: maybeAppendNull(nonNull, "object"), + Properties: map[string]JsonSchema{}, + AdditionalProperties: true, + } +} + +type Null struct { + Type []string `json:"type"` +} + +func (Null) Kind() Kind { + return AnyKind +} + +func NewNull() Null { + return Null{ + Type: []string{"null"}, + } +} + +type NullableRef struct { + AnyOf []JsonSchema `json:"anyOf"` +} + +func (NullableRef) Kind() Kind { + return NullableRefKind +} + +func NewNullableRef(definitionName string) NullableRef { + return NullableRef{ + AnyOf: []JsonSchema{ + NewNull(), + NewRef(definitionName), + }, + } +} + +/* + "owner":{ + "oneOf": [ + {"type": "null"}, + {"$ref":"#/definitions/id"} + ] + } + +*/ + +type Array struct { + Type []string `json:"type"` + Items JsonSchema `json:"items"` + MinItems *int `json:"minItems,omitempty"` + Defs map[string]JsonSchema `json:"$defs,omitempty"` +} + +func (Array) Kind() Kind { + return ArrayKind +} + +func NewArray(itemSchema JsonSchema, nonNull bool) Array { + return Array{ + Type: maybeAppendNull(nonNull, "array"), + Items: itemSchema, + } +} diff --git a/v2/pkg/graphqljsonschema/jsonschema_test.go b/v2/pkg/graphqljsonschema/jsonschema_test.go new file mode 100644 index 0000000000..39a37293a8 --- /dev/null +++ b/v2/pkg/graphqljsonschema/jsonschema_test.go @@ -0,0 +1,2021 @@ +package graphqljsonschema + +import ( + "bytes" + "context" + "encoding/json" + "testing" + + "github.com/stretchr/testify/assert" + + "github.com/wundergraph/graphql-go-tools/v2/pkg/internal/unsafeparser" +) + +func prettyPrint(s string) string { + // return s + buf := bytes.Buffer{} + err := json.Indent(&buf, []byte(s), "", " ") + if err != nil { + panic(err) + } + return buf.String() +} + +func runTest(schema, operation, expectedJsonSchema string, valid []string, invalid []string, opts ...Option) func(t *testing.T) { + return func(t *testing.T) { + definition := unsafeparser.ParseGraphqlDocumentString(schema) + operationDoc := unsafeparser.ParseGraphqlDocumentString(operation) + + variableDefinition := operationDoc.OperationDefinitions[0].VariableDefinitions.Refs[0] + varType := operationDoc.VariableDefinitions[variableDefinition].Type + + jsonSchemaDefinition := FromTypeRef(&operationDoc, &definition, varType, opts...) + actualSchema, err := json.Marshal(jsonSchemaDefinition) + assert.NoError(t, err) + assert.Equal(t, prettyPrint(expectedJsonSchema), prettyPrint(string(actualSchema))) + + validator, err := NewValidatorFromString(string(actualSchema)) + assert.NoError(t, err) + + for _, input := range valid { + assert.NoError(t, validator.Validate(context.Background(), []byte(input)), "Incorrectly judged invalid: %v", input) + } + + for _, input := range invalid { + assert.Error(t, validator.Validate(context.Background(), []byte(input)), "Incorrectly judged valid: %v", input) + } + } +} + +func TestJsonSchema(t *testing.T) { + t.Run("object", runTest( + `scalar String input Test { str: String }`, + `query ($input: Test){}`, + `{"type":["object","null"],"properties":{"str":{"type":["string","null"]}},"additionalProperties":false}`, + []string{ + `{"str":"validString"}`, + `{"str":null}`, + }, + []string{ + `{"str":true}`, + }, + )) + t.Run("string", runTest( + `scalar String input Test { str: String }`, + `query ($input: String){}`, + `{"type":["string","null"]}`, + []string{ + `"validString"`, + `null`, + }, + []string{ + `false`, + `true`, + `nope`, + }, + )) + t.Run("string (required)", runTest( + `scalar String type Query { rootField(str: String!): String! }`, + `query ($input: String!){ rootField(str: $input) }`, + `{"type":["string"]}`, + []string{ + `"validString"`, + }, + []string{ + `false`, + `true`, + `nope`, + `null`, + }, + )) + t.Run("id", runTest( + `scalar ID input Test { str: String }`, + `query ($input: ID){}`, + `{"type":["string","integer","null"]}`, + []string{ + `"validString"`, + `null`, + }, + []string{ + `false`, + `true`, + `nope`, + }, + )) + t.Run("array", runTest( + `scalar String`, + `query ($input: [String]){}`, + `{"type":["array","null"],"items":{"type":["string","null"]}}`, + []string{ + `null`, + `[]`, + `["validString1"]`, + `["validString1", "validString2"]`, + `["validString1", "validString2", null]`, + }, + []string{ + `"validString"`, + `false`, + }, + )) + t.Run("input object array", runTest( + `scalar String input StringInput { str: String }`, + `query ($input: [StringInput]){}`, + `{"type":["array","null"],"items":{"anyOf": [{"type":["null"]},{"$ref":"#/$defs/StringInput"}]},"$defs":{"StringInput":{"type":["object","null"],"properties":{"str":{"type":["string","null"]}},"additionalProperties":false}}}`, + []string{ + `null`, + `[]`, + `[{"str":"validString1"}]`, + `[{"str":"validString1"}, {"str":"validString2"}]`, + `[{"str":"validString1"}, {"str":"validString2"}, null]`, + }, + []string{ + `"validString"`, + `false`, + }, + )) + t.Run("nested input object both as required and not required", runTest( + `scalar String input StringInput { str: String } input Input { requireInput: StringInput! optionalInput: StringInput }`, + `query ($input: Input){}`, + `{"type":["object","null"],"properties":{"optionalInput":{"anyOf":[{"type":["null"]},{"$ref":"#/$defs/StringInput"}]},"requireInput":{"$ref":"#/$defs/StringInputNotNull"}},"required":["requireInput"],"additionalProperties":false,"$defs":{"StringInput":{"type":["object","null"],"properties":{"str":{"type":["string","null"]}},"additionalProperties":false},"StringInputNotNull":{"type":["object"],"properties":{"str":{"type":["string","null"]}},"additionalProperties":false}}}`, + []string{ + `{"optionalInput": {}, "requireInput": {"str":"validString"}}`, + `{"requireInput": {"str":"validString"}}`, + }, + []string{ + `{}`, + `{"optionalInput": {}}`, + }, + )) + t.Run("nested input object both as required and not required - reverse order", runTest( + `scalar String input StringInput { str: String } input Input { optionalInput: StringInput requireInput: StringInput! }`, + `query ($input: Input){}`, + `{"type":["object","null"],"properties":{"optionalInput":{"anyOf":[{"type":["null"]},{"$ref":"#/$defs/StringInput"}]},"requireInput":{"$ref":"#/$defs/StringInputNotNull"}},"required":["requireInput"],"additionalProperties":false,"$defs":{"StringInput":{"type":["object","null"],"properties":{"str":{"type":["string","null"]}},"additionalProperties":false},"StringInputNotNull":{"type":["object"],"properties":{"str":{"type":["string","null"]}},"additionalProperties":false}}}`, + []string{ + `{"optionalInput": {}, "requireInput": {"str":"validString"}}`, + `{"requireInput": {"str":"validString"}}`, + }, + []string{ + `{}`, + `{"optionalInput": {}}`, + }, + )) + t.Run("optional nested input object used twice", runTest( + `scalar String input StringInput { str: String } input Input { optionalInput1: StringInput optionalInput2: StringInput }`, + `query ($input: Input){}`, + `{"type":["object","null"],"properties":{"optionalInput1":{"anyOf":[{"type":["null"]},{"$ref":"#/$defs/StringInput"}]},"optionalInput2":{"anyOf":[{"type":["null"]},{"$ref":"#/$defs/StringInput"}]}},"additionalProperties":false,"$defs":{"StringInput":{"type":["object","null"],"properties":{"str":{"type":["string","null"]}},"additionalProperties":false}}}`, + []string{ + `{"optionalInput1": {}, "optionalInput2": {"str":"validString"}}`, + `{"optionalInput2": {"str":"validString"}}`, + `{}`, + }, + []string{}, + )) + t.Run("required nested input object used twice", runTest( + `scalar String input StringInput { str: String } input Input { requireInput1: StringInput! requireInput2: StringInput! }`, + `query ($input: Input){}`, + `{"type":["object","null"],"properties":{"requireInput1":{"$ref":"#/$defs/StringInputNotNull"},"requireInput2":{"$ref":"#/$defs/StringInputNotNull"}},"required":["requireInput1","requireInput2"],"additionalProperties":false,"$defs":{"StringInputNotNull":{"type":["object"],"properties":{"str":{"type":["string","null"]}},"additionalProperties":false}}}`, + []string{ + `{"requireInput1": {"str":"validString"}, "requireInput2": {"str":"validString"}}`, + }, + []string{ + `{"requireInput1": {"str":"validString"}}`, + `{}`, + }, + )) + t.Run("required array", runTest( + `scalar String`, + `query ($input: [String]!){}`, + `{"type":["array"],"items":{"type":["string","null"]}}`, + []string{ + `[]`, + `["validString1"]`, + `["validString1", "validString2"]`, + `["validString1", "validString2", null]`, + }, + []string{ + `"validString"`, + `false`, + `null`, + }, + )) + t.Run("required array element", runTest( + `scalar String`, + `query ($input: [String!]){}`, + `{"type":["array","null"],"items":{"type":["string"]}}`, + []string{ + `null`, + `[]`, + `["validString1"]`, + `["validString1", "validString2"]`, + }, + []string{ + `[null]`, + `["validString1", "validString2", null]`, + `"validString"`, + `false`, + }, + )) + t.Run("nested object", runTest( + `scalar String scalar Boolean input Test { str: String! nested: Nested } input Nested { boo: Boolean }`, + `query ($input: Test){}`, + `{"type":["object","null"],"properties":{"nested":{"anyOf":[{"type":["null"]},{"$ref":"#/$defs/Nested"}]},"str":{"type":["string"]}},"required":["str"],"additionalProperties":false,"$defs":{"Nested":{"type":["object","null"],"properties":{"boo":{"type":["boolean","null"]}},"additionalProperties":false}}}`, + []string{ + `null`, + `{"str":"validString"}`, + `{"str":"validString","nested":null}`, + `{"str":"validString","nested":{"boo":true}}`, + `{"str":"validString","nested":{"boo":null}}`, + `{"str":"validString","nested":{}}`, + }, + []string{ + `{"str":true}`, + `{"str":null}`, + `{"nested":{"boo":true}}`, + `{"str":"validString","nested":{"boo":123}}`, + }, + )) + t.Run("nested object with override", runTest( + `scalar String scalar Boolean input Test { str: String! override: Override } input Override { boo: Boolean }`, + `query ($input: Test){}`, + `{"type":["object","null"],"properties":{"override":{"type":["string","null"]},"str":{"type":["string"]}},"required":["str"],"additionalProperties":false}`, + []string{ + `null`, + `{"str":"validString"}`, + `{"str":"validString","override":"{\"boo\":true}"}`, + `{"str":"validString","override":null}`, + }, + []string{ + `{"str":true}`, + `{"str":null}`, + `{"override":{"boo":true}}`, + `{"str":"validString","override":{"boo":123}}`, + }, + WithOverrides(map[string]JsonSchema{ + "Override": NewString(false), + }), + )) + t.Run("recursive object", runTest( + `scalar String scalar Boolean input Test { str: String! nested: Nested } input Nested { boo: Boolean recursive: Test }`, + `query ($input: Test){}`, + `{"type":["object","null"],"properties":{"nested":{"anyOf":[{"type":["null"]},{"$ref":"#/$defs/Nested"}]},"str":{"type":["string"]}},"required":["str"],"additionalProperties":false,"$defs":{"Nested":{"type":["object","null"],"properties":{"boo":{"type":["boolean","null"]},"recursive":{"anyOf": [{"type":["null"]},{"$ref":"#/$defs/Test"}]}},"additionalProperties":false},"Test":{"type":["object","null"],"properties":{"nested":{"anyOf":[{"type":["null"]},{"$ref":"#/$defs/Nested"}]},"str":{"type":["string"]}},"required":["str"],"additionalProperties":false}}}`, + []string{ + `{"str":"validString"}`, + `{"str":"validString","nested":{"boo":true}}`, + `{"str":"validString","nested":{"boo":null}}`, + `{"str":"validString","nested":null}`, + }, + []string{ + `{"str":true}`, + `{"nested":{"boo":true}}`, + `{"str":"validString","nested":{"boo":123}}`, + }, + )) + t.Run("recursive object with multiple branches", runTest( + `scalar String scalar Boolean input Root { test: Test another: Another } input Test { str: String! nested: Nested } input Nested { boo: Boolean recursive: Test another: Another } input Another { boo: Boolean }`, + `query ($input: Root){}`, + `{"type":["object","null"],"properties":{"another":{"anyOf":[{"type":["null"]},{"$ref":"#/$defs/Another"}]},"test":{"anyOf":[{"type":["null"]},{"$ref":"#/$defs/Test"}]}},"additionalProperties":false,"$defs":{"Another":{"type":["object","null"],"properties":{"boo":{"type":["boolean","null"]}},"additionalProperties":false},"Nested":{"type":["object","null"],"properties":{"another":{"anyOf":[{"type":["null"]},{"$ref":"#/$defs/Another"}]},"boo":{"type":["boolean","null"]},"recursive":{"anyOf":[{"type":["null"]},{"$ref":"#/$defs/Test"}]}},"additionalProperties":false},"Test":{"type":["object","null"],"properties":{"nested":{"anyOf":[{"type":["null"]},{"$ref":"#/$defs/Nested"}]},"str":{"type":["string"]}},"required":["str"],"additionalProperties":false}}}`, + []string{ + `{"test":{"str":"validString"}}`, + `{"test":{"str":"validString","nested":{"boo":true}}}`, + }, + []string{ + `{"test":{"str":true}}`, + `{"test":{"nested":{"boo":true}}}`, + `{"test":{"str":"validString","nested":{"boo":123}}}`, + }, + )) + t.Run("complex recursive schema", runTest( + complexRecursiveSchema, + `query ($input: db_messagesWhereInput){}`, + complexRecursiveSchemaResult, + []string{}, + []string{}, + )) + t.Run("one level deep sub path", runTest( + "input Human { name: String! } scalar String", + "query ($human: Human!) { }", + `{"type":["string"]}`, + []string{ + `"John Doe"`, + }, + []string{ + `{"name":"John Doe"}`, + }, + WithPath([]string{"name"}), + )) + t.Run("multi level deep sub path", runTest( + "input Human { name: String! pet: Animal } scalar String type Animal { name: String! }", + "query ($human: Human!) { }", + `{"type":["string"]}`, + []string{ + `"Doggie"`, + }, + []string{ + `{"name":"Doggie"}`, + `{"pet":{"name":"Doggie"}}`, + }, + WithPath([]string{"pet", "name"}), + )) + t.Run("not defined scalar", runTest( + `input Container { name: MyScalar }`, + `query ($input: Container){}`, + `{"type":["object", "null"], "properties": {"name": {}}, "additionalProperties": false}`, + []string{}, + []string{}, + )) +} + +const complexRecursiveSchema = ` +scalar Int scalar String + +input db_NestedIntFilter { + equals: Int + in: [Int] + notIn: [Int] + lt: Int + lte: Int + gt: Int + gte: Int + not: db_NestedIntFilter +} + +input db_IntFilter { + equals: Int + in: [Int] + notIn: [Int] + lt: Int + lte: Int + gt: Int + gte: Int + not: db_NestedIntFilter +} + +enum db_QueryMode { + default + insensitive +} + +input db_NestedStringFilter { + equals: String + in: [String] + notIn: [String] + lt: String + lte: String + gt: String + gte: String + contains: String + startsWith: String + endsWith: String + not: db_NestedStringFilter +} + +input db_StringFilter { + equals: String + in: [String] + notIn: [String] + lt: String + lte: String + gt: String + gte: String + contains: String + startsWith: String + endsWith: String + mode: db_QueryMode + not: db_NestedStringFilter +} + +enum db_JsonNullValueFilter { + DbNull + JsonNull + AnyNull +} + +input db_JsonFilter { + equals: db_JsonNullValueFilter + not: db_JsonNullValueFilter +} + +input db_NestedDateTimeFilter { + equals: DateTime + in: [DateTime] + notIn: [DateTime] + lt: DateTime + lte: DateTime + gt: DateTime + gte: DateTime + not: db_NestedDateTimeFilter +} + +input db_DateTimeFilter { + equals: DateTime + in: [DateTime] + notIn: [DateTime] + lt: DateTime + lte: DateTime + gt: DateTime + gte: DateTime + not: db_NestedDateTimeFilter +} + +input db_MessagesListRelationFilter { + every: db_messagesWhereInput + some: db_messagesWhereInput + none: db_messagesWhereInput +} + +input db_usersWhereInput { + AND: db_usersWhereInput + OR: [db_usersWhereInput] + NOT: db_usersWhereInput + id: db_IntFilter + email: db_StringFilter + name: db_StringFilter + updatedat: db_DateTimeFilter + lastlogin: db_DateTimeFilter + pet: db_StringFilter + messages: db_MessagesListRelationFilter +} + +input db_UsersRelationFilter { + is: db_usersWhereInput + isNot: db_usersWhereInput +} + +input db_messagesWhereInput { + AND: db_messagesWhereInput + OR: [db_messagesWhereInput] + NOT: db_messagesWhereInput + id: db_IntFilter + user_id: db_IntFilter + message: db_StringFilter + payload: db_JsonFilter + users: db_UsersRelationFilter +} + +enum db_SortOrder { + asc + desc +} + +input db_messagesOrderByRelationAggregateInput { + _count: db_SortOrder +} + +input db_usersOrderByWithRelationInput { + id: db_SortOrder + email: db_SortOrder + name: db_SortOrder + updatedat: db_SortOrder + lastlogin: db_SortOrder + pet: db_SortOrder + messages: db_messagesOrderByRelationAggregateInput +} + +input db_messagesOrderByWithRelationInput { + id: db_SortOrder + user_id: db_SortOrder + message: db_SortOrder + payload: db_SortOrder + users: db_usersOrderByWithRelationInput +} + +input db_messagesWhereUniqueInput { + id: Int +} + +enum db_MessagesScalarFieldEnum { + id + user_id + message + payload +} + +type db_UsersCountOutputType { + messages: Int! + _join: Query! +} + +type db_users { + id: Int! + email: String! + name: String! + updatedat: DateTime! + lastlogin: DateTime! + pet: String! + messages(where: db_messagesWhereInput, orderBy: [db_messagesOrderByWithRelationInput], cursor: db_messagesWhereUniqueInput, take: Int, skip: Int, distinct: [db_MessagesScalarFieldEnum]): [db_messages] + _count: db_UsersCountOutputType + _join: Query! +} + +type db_messages { + id: Int! + user_id: Int! + message: String! + payload: db_Widgets! + users: db_users! + _join: Query! +} + +type db_MessagesCountAggregateOutputType { + id: Int! + user_id: Int! + message: Int! + payload: Int! + _all: Int! + _join: Query! +} + +type db_MessagesAvgAggregateOutputType { + id: Float + user_id: Float + _join: Query! +} + +type db_MessagesSumAggregateOutputType { + id: Int + user_id: Int + _join: Query! +} + +type db_MessagesMinAggregateOutputType { + id: Int + user_id: Int + message: String + _join: Query! +} + +type db_MessagesMaxAggregateOutputType { + id: Int + user_id: Int + message: String + _join: Query! +} + +type db_AggregateMessages { + _count: db_MessagesCountAggregateOutputType + _avg: db_MessagesAvgAggregateOutputType + _sum: db_MessagesSumAggregateOutputType + _min: db_MessagesMinAggregateOutputType + _max: db_MessagesMaxAggregateOutputType + _join: Query! +} + +input db_messagesCountOrderByAggregateInput { + id: db_SortOrder + user_id: db_SortOrder + message: db_SortOrder + payload: db_SortOrder +} + +input db_messagesAvgOrderByAggregateInput { + id: db_SortOrder + user_id: db_SortOrder +} + +input db_messagesMaxOrderByAggregateInput { + id: db_SortOrder + user_id: db_SortOrder + message: db_SortOrder +} + +input db_messagesMinOrderByAggregateInput { + id: db_SortOrder + user_id: db_SortOrder + message: db_SortOrder +} + +input db_messagesSumOrderByAggregateInput { + id: db_SortOrder + user_id: db_SortOrder +} + +input db_messagesOrderByWithAggregationInput { + id: db_SortOrder + user_id: db_SortOrder + message: db_SortOrder + payload: db_SortOrder + _count: db_messagesCountOrderByAggregateInput + _avg: db_messagesAvgOrderByAggregateInput + _max: db_messagesMaxOrderByAggregateInput + _min: db_messagesMinOrderByAggregateInput + _sum: db_messagesSumOrderByAggregateInput +} + +input db_NestedFloatFilter { + equals: Float + in: [Float] + notIn: [Float] + lt: Float + lte: Float + gt: Float + gte: Float + not: db_NestedFloatFilter +} + +input db_NestedIntWithAggregatesFilter { + equals: Int + in: [Int] + notIn: [Int] + lt: Int + lte: Int + gt: Int + gte: Int + not: db_NestedIntWithAggregatesFilter + _count: db_NestedIntFilter + _avg: db_NestedFloatFilter + _sum: db_NestedIntFilter + _min: db_NestedIntFilter + _max: db_NestedIntFilter +} + +input db_IntWithAggregatesFilter { + equals: Int + in: [Int] + notIn: [Int] + lt: Int + lte: Int + gt: Int + gte: Int + not: db_NestedIntWithAggregatesFilter + _count: db_NestedIntFilter + _avg: db_NestedFloatFilter + _sum: db_NestedIntFilter + _min: db_NestedIntFilter + _max: db_NestedIntFilter +} + +input db_NestedStringWithAggregatesFilter { + equals: String + in: [String] + notIn: [String] + lt: String + lte: String + gt: String + gte: String + contains: String + startsWith: String + endsWith: String + not: db_NestedStringWithAggregatesFilter + _count: db_NestedIntFilter + _min: db_NestedStringFilter + _max: db_NestedStringFilter +} + +input db_StringWithAggregatesFilter { + equals: String + in: [String] + notIn: [String] + lt: String + lte: String + gt: String + gte: String + contains: String + startsWith: String + endsWith: String + mode: db_QueryMode + not: db_NestedStringWithAggregatesFilter + _count: db_NestedIntFilter + _min: db_NestedStringFilter + _max: db_NestedStringFilter +} + +input db_NestedJsonFilter { + equals: db_JsonNullValueFilter + not: db_JsonNullValueFilter +} + +input db_JsonWithAggregatesFilter { + equals: db_JsonNullValueFilter + not: db_JsonNullValueFilter + _count: db_NestedIntFilter + _min: db_NestedJsonFilter + _max: db_NestedJsonFilter +} + +input db_messagesScalarWhereWithAggregatesInput { + AND: db_messagesScalarWhereWithAggregatesInput + OR: [db_messagesScalarWhereWithAggregatesInput] + NOT: db_messagesScalarWhereWithAggregatesInput + id: db_IntWithAggregatesFilter + user_id: db_IntWithAggregatesFilter + message: db_StringWithAggregatesFilter + payload: db_JsonWithAggregatesFilter +} + +type db_MessagesGroupByOutputType { + id: Int! + user_id: Int! + message: String! + payload: JSON! + _count: db_MessagesCountAggregateOutputType + _avg: db_MessagesAvgAggregateOutputType + _sum: db_MessagesSumAggregateOutputType + _min: db_MessagesMinAggregateOutputType + _max: db_MessagesMaxAggregateOutputType + _join: Query! +} + +input db_usersWhereUniqueInput { + id: Int + email: String +} + +enum db_UsersScalarFieldEnum { + id + email + name + updatedat + lastlogin + pet +} + +type db_UsersCountAggregateOutputType { + id: Int! + email: Int! + name: Int! + updatedat: Int! + lastlogin: Int! + pet: Int! + _all: Int! + _join: Query! +} + +type db_UsersAvgAggregateOutputType { + id: Float + _join: Query! +} + +type db_UsersSumAggregateOutputType { + id: Int + _join: Query! +} + +type db_UsersMinAggregateOutputType { + id: Int + email: String + name: String + updatedat: DateTime + lastlogin: DateTime + pet: String + _join: Query! +} + +type db_UsersMaxAggregateOutputType { + id: Int + email: String + name: String + updatedat: DateTime + lastlogin: DateTime + pet: String + _join: Query! +} + +type db_AggregateUsers { + _count: db_UsersCountAggregateOutputType + _avg: db_UsersAvgAggregateOutputType + _sum: db_UsersSumAggregateOutputType + _min: db_UsersMinAggregateOutputType + _max: db_UsersMaxAggregateOutputType + _join: Query! +} + +input db_usersCountOrderByAggregateInput { + id: db_SortOrder + email: db_SortOrder + name: db_SortOrder + updatedat: db_SortOrder + lastlogin: db_SortOrder + pet: db_SortOrder +} + +input db_usersAvgOrderByAggregateInput { + id: db_SortOrder +} + +input db_usersMaxOrderByAggregateInput { + id: db_SortOrder + email: db_SortOrder + name: db_SortOrder + updatedat: db_SortOrder + lastlogin: db_SortOrder + pet: db_SortOrder +} + +input db_usersMinOrderByAggregateInput { + id: db_SortOrder + email: db_SortOrder + name: db_SortOrder + updatedat: db_SortOrder + lastlogin: db_SortOrder + pet: db_SortOrder +} + +input db_usersSumOrderByAggregateInput { + id: db_SortOrder +} + +input db_usersOrderByWithAggregationInput { + id: db_SortOrder + email: db_SortOrder + name: db_SortOrder + updatedat: db_SortOrder + lastlogin: db_SortOrder + pet: db_SortOrder + _count: db_usersCountOrderByAggregateInput + _avg: db_usersAvgOrderByAggregateInput + _max: db_usersMaxOrderByAggregateInput + _min: db_usersMinOrderByAggregateInput + _sum: db_usersSumOrderByAggregateInput +} + +input db_NestedDateTimeWithAggregatesFilter { + equals: DateTime + in: [DateTime] + notIn: [DateTime] + lt: DateTime + lte: DateTime + gt: DateTime + gte: DateTime + not: db_NestedDateTimeWithAggregatesFilter + _count: db_NestedIntFilter + _min: db_NestedDateTimeFilter + _max: db_NestedDateTimeFilter +} + +input db_DateTimeWithAggregatesFilter { + equals: DateTime + in: [DateTime] + notIn: [DateTime] + lt: DateTime + lte: DateTime + gt: DateTime + gte: DateTime + not: db_NestedDateTimeWithAggregatesFilter + _count: db_NestedIntFilter + _min: db_NestedDateTimeFilter + _max: db_NestedDateTimeFilter +} + +input db_usersScalarWhereWithAggregatesInput { + AND: db_usersScalarWhereWithAggregatesInput + OR: [db_usersScalarWhereWithAggregatesInput] + NOT: db_usersScalarWhereWithAggregatesInput + id: db_IntWithAggregatesFilter + email: db_StringWithAggregatesFilter + name: db_StringWithAggregatesFilter + updatedat: db_DateTimeWithAggregatesFilter + lastlogin: db_DateTimeWithAggregatesFilter + pet: db_StringWithAggregatesFilter +} + +type db_UsersGroupByOutputType { + id: Int! + email: String! + name: String! + updatedat: DateTime! + lastlogin: DateTime! + pet: String! + _count: db_UsersCountAggregateOutputType + _avg: db_UsersAvgAggregateOutputType + _sum: db_UsersSumAggregateOutputType + _min: db_UsersMinAggregateOutputType + _max: db_UsersMaxAggregateOutputType + _join: Query! +} + +type Query { + db_findFirstmessages(where: db_messagesWhereInput, orderBy: [db_messagesOrderByWithRelationInput], cursor: db_messagesWhereUniqueInput, take: Int, skip: Int, distinct: [db_MessagesScalarFieldEnum]): db_messages + db_findManymessages(where: db_messagesWhereInput, orderBy: [db_messagesOrderByWithRelationInput], cursor: db_messagesWhereUniqueInput, take: Int, skip: Int, distinct: [db_MessagesScalarFieldEnum]): [db_messages]! + db_aggregatemessages(where: db_messagesWhereInput, orderBy: [db_messagesOrderByWithRelationInput], cursor: db_messagesWhereUniqueInput, take: Int, skip: Int): db_AggregateMessages! + db_groupBymessages(where: db_messagesWhereInput, orderBy: [db_messagesOrderByWithAggregationInput], by: [db_MessagesScalarFieldEnum]!, having: db_messagesScalarWhereWithAggregatesInput, take: Int, skip: Int): [db_MessagesGroupByOutputType]! + db_findUniquemessages(where: db_messagesWhereUniqueInput!): db_messages + db_findFirstusers(where: db_usersWhereInput, orderBy: [db_usersOrderByWithRelationInput], cursor: db_usersWhereUniqueInput, take: Int, skip: Int, distinct: [db_UsersScalarFieldEnum]): db_users + db_findManyusers(where: db_usersWhereInput, orderBy: [db_usersOrderByWithRelationInput], cursor: db_usersWhereUniqueInput, take: Int, skip: Int, distinct: [db_UsersScalarFieldEnum]): [db_users]! + db_aggregateusers(where: db_usersWhereInput, orderBy: [db_usersOrderByWithRelationInput], cursor: db_usersWhereUniqueInput, take: Int, skip: Int): db_AggregateUsers! + db_groupByusers(where: db_usersWhereInput, orderBy: [db_usersOrderByWithAggregationInput], by: [db_UsersScalarFieldEnum]!, having: db_usersScalarWhereWithAggregatesInput, take: Int, skip: Int): [db_UsersGroupByOutputType]! + db_findUniqueusers(where: db_usersWhereUniqueInput!): db_users +} + +input db_usersCreateWithoutMessagesInput { + email: String! + name: String! + updatedat: DateTime + lastlogin: DateTime + pet: String +} + +input db_usersCreateOrConnectWithoutMessagesInput { + where: db_usersWhereUniqueInput! + create: db_usersCreateWithoutMessagesInput! +} + +input db_usersCreateNestedOneWithoutMessagesInput { + create: db_usersCreateWithoutMessagesInput + connectOrCreate: db_usersCreateOrConnectWithoutMessagesInput + connect: db_usersWhereUniqueInput +} + +input db_messagesCreateInput { + message: String! + payload: db_WidgetsInput + users: db_usersCreateNestedOneWithoutMessagesInput! +} + +input db_StringFieldUpdateOperationsInput { + set: String +} + +input db_DateTimeFieldUpdateOperationsInput { + set: DateTime +} + +input db_usersUpdateWithoutMessagesInput { + email: db_StringFieldUpdateOperationsInput + name: db_StringFieldUpdateOperationsInput + updatedat: db_DateTimeFieldUpdateOperationsInput + lastlogin: db_DateTimeFieldUpdateOperationsInput + pet: db_StringFieldUpdateOperationsInput +} + +input db_usersUpsertWithoutMessagesInput { + update: db_usersUpdateWithoutMessagesInput! + create: db_usersCreateWithoutMessagesInput! +} + +input db_usersUpdateOneRequiredWithoutMessagesInput { + create: db_usersCreateWithoutMessagesInput + connectOrCreate: db_usersCreateOrConnectWithoutMessagesInput + upsert: db_usersUpsertWithoutMessagesInput + connect: db_usersWhereUniqueInput + update: db_usersUpdateWithoutMessagesInput +} + +input db_messagesUpdateInput { + message: db_StringFieldUpdateOperationsInput + payload: db_WidgetsInput + users: db_usersUpdateOneRequiredWithoutMessagesInput +} + +input db_messagesCreateManyInput { + id: Int + user_id: Int! + message: String! + payload: db_WidgetsInput +} + +type db_AffectedRowsOutput { + count: Int! + _join: Query! +} + +input db_messagesUpdateManyMutationInput { + message: db_StringFieldUpdateOperationsInput + payload: db_WidgetsInput +} + +input db_messagesCreateWithoutUsersInput { + message: String! + payload: db_WidgetsInput +} + +input db_messagesCreateOrConnectWithoutUsersInput { + where: db_messagesWhereUniqueInput! + create: db_messagesCreateWithoutUsersInput! +} + +input db_messagesCreateManyUsersInput { + id: Int + message: String! + payload: db_WidgetsInput +} + +input db_messagesCreateManyUsersInputEnvelope { + data: [db_messagesCreateManyUsersInput]! + skipDuplicates: Boolean +} + +input db_messagesCreateNestedManyWithoutUsersInput { + create: db_messagesCreateWithoutUsersInput + connectOrCreate: db_messagesCreateOrConnectWithoutUsersInput + createMany: db_messagesCreateManyUsersInputEnvelope + connect: db_messagesWhereUniqueInput +} + +input db_usersCreateInput { + email: String! + name: String! + updatedat: DateTime + lastlogin: DateTime + pet: String + messages: db_messagesCreateNestedManyWithoutUsersInput +} + +input db_messagesUpdateWithoutUsersInput { + message: db_StringFieldUpdateOperationsInput + payload: db_WidgetsInput +} + +input db_messagesUpsertWithWhereUniqueWithoutUsersInput { + where: db_messagesWhereUniqueInput! + update: db_messagesUpdateWithoutUsersInput! + create: db_messagesCreateWithoutUsersInput! +} + +input db_messagesUpdateWithWhereUniqueWithoutUsersInput { + where: db_messagesWhereUniqueInput! + data: db_messagesUpdateWithoutUsersInput! +} + +input db_messagesScalarWhereInput { + AND: db_messagesScalarWhereInput + OR: [db_messagesScalarWhereInput] + NOT: db_messagesScalarWhereInput + id: db_IntFilter + user_id: db_IntFilter + message: db_StringFilter + payload: db_JsonFilter +} + +input db_messagesUpdateManyWithWhereWithoutUsersInput { + where: db_messagesScalarWhereInput! + data: db_messagesUpdateManyMutationInput! +} + +input db_messagesUpdateManyWithoutUsersInput { + create: db_messagesCreateWithoutUsersInput + connectOrCreate: db_messagesCreateOrConnectWithoutUsersInput + upsert: db_messagesUpsertWithWhereUniqueWithoutUsersInput + createMany: db_messagesCreateManyUsersInputEnvelope + connect: db_messagesWhereUniqueInput + set: db_messagesWhereUniqueInput + disconnect: db_messagesWhereUniqueInput + delete: db_messagesWhereUniqueInput + update: db_messagesUpdateWithWhereUniqueWithoutUsersInput + updateMany: db_messagesUpdateManyWithWhereWithoutUsersInput + deleteMany: db_messagesScalarWhereInput +} + +input db_usersUpdateInput { + email: db_StringFieldUpdateOperationsInput + name: db_StringFieldUpdateOperationsInput + updatedat: db_DateTimeFieldUpdateOperationsInput + lastlogin: db_DateTimeFieldUpdateOperationsInput + pet: db_StringFieldUpdateOperationsInput + messages: db_messagesUpdateManyWithoutUsersInput +} + +input db_usersCreateManyInput { + id: Int + email: String! + name: String! + updatedat: DateTime + lastlogin: DateTime + pet: String +} + +input db_usersUpdateManyMutationInput { + email: db_StringFieldUpdateOperationsInput + name: db_StringFieldUpdateOperationsInput + updatedat: db_DateTimeFieldUpdateOperationsInput + lastlogin: db_DateTimeFieldUpdateOperationsInput + pet: db_StringFieldUpdateOperationsInput +} + +type Mutation { + db_createOnemessages(data: db_messagesCreateInput!): db_messages + db_upsertOnemessages(where: db_messagesWhereUniqueInput!, create: db_messagesCreateInput!, update: db_messagesUpdateInput!): db_messages + db_createManymessages(data: [db_messagesCreateManyInput]!, skipDuplicates: Boolean): db_AffectedRowsOutput + db_deleteOnemessages(where: db_messagesWhereUniqueInput!): db_messages + db_updateOnemessages(data: db_messagesUpdateInput!, where: db_messagesWhereUniqueInput!): db_messages + db_updateManymessages(data: db_messagesUpdateManyMutationInput!, where: db_messagesWhereInput): db_AffectedRowsOutput + db_deleteManymessages(where: db_messagesWhereInput): db_AffectedRowsOutput + db_createOneusers(data: db_usersCreateInput!): db_users + db_upsertOneusers(where: db_usersWhereUniqueInput!, create: db_usersCreateInput!, update: db_usersUpdateInput!): db_users + db_createManyusers(data: [db_usersCreateManyInput]!, skipDuplicates: Boolean): db_AffectedRowsOutput + db_deleteOneusers(where: db_usersWhereUniqueInput!): db_users + db_updateOneusers(data: db_usersUpdateInput!, where: db_usersWhereUniqueInput!): db_users + db_updateManyusers(data: db_usersUpdateManyMutationInput!, where: db_usersWhereInput): db_AffectedRowsOutput + db_deleteManyusers(where: db_usersWhereInput): db_AffectedRowsOutput +} + +scalar DateTime + +scalar JSON + +scalar UUID + +type db_Widget { + id: ID! + type: String! + name: String + options: JSON + x: Int! + y: Int! + width: Int! + height: Int! + _join: Query! +} + +type db_Widgets { + items: [db_Widget]! + _join: Query! +} + +input db_WidgetInput { + id: ID! + type: String! + name: String + options: JSON + x: Int! + y: Int! + width: Int! + height: Int! +} + +input db_WidgetsInput { + items: [db_WidgetInput]! +} +` + +const complexRecursiveSchemaResult = ` +{ + "type": [ + "object", + "null" + ], + "properties": { + "AND": { + "anyOf": [ + { + "type": [ + "null" + ] + }, + { + "$ref": "#/$defs/db_messagesWhereInput" + } + ] + }, + "NOT": { + "anyOf": [ + { + "type": [ + "null" + ] + }, + { + "$ref": "#/$defs/db_messagesWhereInput" + } + ] + }, + "OR": { + "type": [ + "array", + "null" + ], + "items": { + "anyOf": [ + { + "type": [ + "null" + ] + }, + { + "$ref": "#/$defs/db_messagesWhereInput" + } + ] + } + }, + "id": { + "anyOf": [ + { + "type": [ + "null" + ] + }, + { + "$ref": "#/$defs/db_IntFilter" + } + ] + }, + "message": { + "anyOf": [ + { + "type": [ + "null" + ] + }, + { + "$ref": "#/$defs/db_StringFilter" + } + ] + }, + "payload": { + "anyOf": [ + { + "type": [ + "null" + ] + }, + { + "$ref": "#/$defs/db_JsonFilter" + } + ] + }, + "user_id": { + "anyOf": [ + { + "type": [ + "null" + ] + }, + { + "$ref": "#/$defs/db_IntFilter" + } + ] + }, + "users": { + "anyOf": [ + { + "type": [ + "null" + ] + }, + { + "$ref": "#/$defs/db_UsersRelationFilter" + } + ] + } + }, + "additionalProperties": false, + "$defs": { + "db_DateTimeFilter": { + "type": [ + "object", + "null" + ], + "properties": { + "equals": {}, + "gt": {}, + "gte": {}, + "in": { + "type": [ + "array", + "null" + ], + "items": {} + }, + "lt": {}, + "lte": {}, + "not": { + "anyOf": [ + { + "type": [ + "null" + ] + }, + { + "$ref": "#/$defs/db_NestedDateTimeFilter" + } + ] + }, + "notIn": { + "type": [ + "array", + "null" + ], + "items": {} + } + }, + "additionalProperties": false + }, + "db_IntFilter": { + "type": [ + "object", + "null" + ], + "properties": { + "equals": { + "type": [ + "integer", + "null" + ] + }, + "gt": { + "type": [ + "integer", + "null" + ] + }, + "gte": { + "type": [ + "integer", + "null" + ] + }, + "in": { + "type": [ + "array", + "null" + ], + "items": { + "type": [ + "integer", + "null" + ] + } + }, + "lt": { + "type": [ + "integer", + "null" + ] + }, + "lte": { + "type": [ + "integer", + "null" + ] + }, + "not": { + "anyOf": [ + { + "type": [ + "null" + ] + }, + { + "$ref": "#/$defs/db_NestedIntFilter" + } + ] + }, + "notIn": { + "type": [ + "array", + "null" + ], + "items": { + "type": [ + "integer", + "null" + ] + } + } + }, + "additionalProperties": false + }, + "db_JsonFilter": { + "type": [ + "object", + "null" + ], + "properties": { + "equals": { + "type": [ + "string", + "null" + ] + }, + "not": { + "type": [ + "string", + "null" + ] + } + }, + "additionalProperties": false + }, + "db_MessagesListRelationFilter": { + "type": [ + "object", + "null" + ], + "properties": { + "every": { + "anyOf": [ + { + "type": [ + "null" + ] + }, + { + "$ref": "#/$defs/db_messagesWhereInput" + } + ] + }, + "none": { + "anyOf": [ + { + "type": [ + "null" + ] + }, + { + "$ref": "#/$defs/db_messagesWhereInput" + } + ] + }, + "some": { + "anyOf": [ + { + "type": [ + "null" + ] + }, + { + "$ref": "#/$defs/db_messagesWhereInput" + } + ] + } + }, + "additionalProperties": false + }, + "db_NestedDateTimeFilter": { + "type": [ + "object", + "null" + ], + "properties": { + "equals": {}, + "gt": {}, + "gte": {}, + "in": { + "type": [ + "array", + "null" + ], + "items": {} + }, + "lt": {}, + "lte": {}, + "not": { + "anyOf": [ + { + "type": [ + "null" + ] + }, + { + "$ref": "#/$defs/db_NestedDateTimeFilter" + } + ] + }, + "notIn": { + "type": [ + "array", + "null" + ], + "items": {} + } + }, + "additionalProperties": false + }, + "db_NestedIntFilter": { + "type": [ + "object", + "null" + ], + "properties": { + "equals": { + "type": [ + "integer", + "null" + ] + }, + "gt": { + "type": [ + "integer", + "null" + ] + }, + "gte": { + "type": [ + "integer", + "null" + ] + }, + "in": { + "type": [ + "array", + "null" + ], + "items": { + "type": [ + "integer", + "null" + ] + } + }, + "lt": { + "type": [ + "integer", + "null" + ] + }, + "lte": { + "type": [ + "integer", + "null" + ] + }, + "not": { + "anyOf": [ + { + "type": [ + "null" + ] + }, + { + "$ref": "#/$defs/db_NestedIntFilter" + } + ] + }, + "notIn": { + "type": [ + "array", + "null" + ], + "items": { + "type": [ + "integer", + "null" + ] + } + } + }, + "additionalProperties": false + }, + "db_NestedStringFilter": { + "type": [ + "object", + "null" + ], + "properties": { + "contains": { + "type": [ + "string", + "null" + ] + }, + "endsWith": { + "type": [ + "string", + "null" + ] + }, + "equals": { + "type": [ + "string", + "null" + ] + }, + "gt": { + "type": [ + "string", + "null" + ] + }, + "gte": { + "type": [ + "string", + "null" + ] + }, + "in": { + "type": [ + "array", + "null" + ], + "items": { + "type": [ + "string", + "null" + ] + } + }, + "lt": { + "type": [ + "string", + "null" + ] + }, + "lte": { + "type": [ + "string", + "null" + ] + }, + "not": { + "anyOf": [ + { + "type": [ + "null" + ] + }, + { + "$ref": "#/$defs/db_NestedStringFilter" + } + ] + }, + "notIn": { + "type": [ + "array", + "null" + ], + "items": { + "type": [ + "string", + "null" + ] + } + }, + "startsWith": { + "type": [ + "string", + "null" + ] + } + }, + "additionalProperties": false + }, + "db_StringFilter": { + "type": [ + "object", + "null" + ], + "properties": { + "contains": { + "type": [ + "string", + "null" + ] + }, + "endsWith": { + "type": [ + "string", + "null" + ] + }, + "equals": { + "type": [ + "string", + "null" + ] + }, + "gt": { + "type": [ + "string", + "null" + ] + }, + "gte": { + "type": [ + "string", + "null" + ] + }, + "in": { + "type": [ + "array", + "null" + ], + "items": { + "type": [ + "string", + "null" + ] + } + }, + "lt": { + "type": [ + "string", + "null" + ] + }, + "lte": { + "type": [ + "string", + "null" + ] + }, + "mode": { + "type": [ + "string", + "null" + ] + }, + "not": { + "anyOf": [ + { + "type": [ + "null" + ] + }, + { + "$ref": "#/$defs/db_NestedStringFilter" + } + ] + }, + "notIn": { + "type": [ + "array", + "null" + ], + "items": { + "type": [ + "string", + "null" + ] + } + }, + "startsWith": { + "type": [ + "string", + "null" + ] + } + }, + "additionalProperties": false + }, + "db_UsersRelationFilter": { + "type": [ + "object", + "null" + ], + "properties": { + "is": { + "anyOf": [ + { + "type": [ + "null" + ] + }, + { + "$ref": "#/$defs/db_usersWhereInput" + } + ] + }, + "isNot": { + "anyOf": [ + { + "type": [ + "null" + ] + }, + { + "$ref": "#/$defs/db_usersWhereInput" + } + ] + } + }, + "additionalProperties": false + }, + "db_messagesWhereInput": { + "type": [ + "object", + "null" + ], + "properties": { + "AND": { + "anyOf": [ + { + "type": [ + "null" + ] + }, + { + "$ref": "#/$defs/db_messagesWhereInput" + } + ] + }, + "NOT": { + "anyOf": [ + { + "type": [ + "null" + ] + }, + { + "$ref": "#/$defs/db_messagesWhereInput" + } + ] + }, + "OR": { + "type": [ + "array", + "null" + ], + "items": { + "anyOf": [ + { + "type": [ + "null" + ] + }, + { + "$ref": "#/$defs/db_messagesWhereInput" + } + ] + } + }, + "id": { + "anyOf": [ + { + "type": [ + "null" + ] + }, + { + "$ref": "#/$defs/db_IntFilter" + } + ] + }, + "message": { + "anyOf": [ + { + "type": [ + "null" + ] + }, + { + "$ref": "#/$defs/db_StringFilter" + } + ] + }, + "payload": { + "anyOf": [ + { + "type": [ + "null" + ] + }, + { + "$ref": "#/$defs/db_JsonFilter" + } + ] + }, + "user_id": { + "anyOf": [ + { + "type": [ + "null" + ] + }, + { + "$ref": "#/$defs/db_IntFilter" + } + ] + }, + "users": { + "anyOf": [ + { + "type": [ + "null" + ] + }, + { + "$ref": "#/$defs/db_UsersRelationFilter" + } + ] + } + }, + "additionalProperties": false + }, + "db_usersWhereInput": { + "type": [ + "object", + "null" + ], + "properties": { + "AND": { + "anyOf": [ + { + "type": [ + "null" + ] + }, + { + "$ref": "#/$defs/db_usersWhereInput" + } + ] + }, + "NOT": { + "anyOf": [ + { + "type": [ + "null" + ] + }, + { + "$ref": "#/$defs/db_usersWhereInput" + } + ] + }, + "OR": { + "type": [ + "array", + "null" + ], + "items": { + "anyOf": [ + { + "type": [ + "null" + ] + }, + { + "$ref": "#/$defs/db_usersWhereInput" + } + ] + } + }, + "email": { + "anyOf": [ + { + "type": [ + "null" + ] + }, + { + "$ref": "#/$defs/db_StringFilter" + } + ] + }, + "id": { + "anyOf": [ + { + "type": [ + "null" + ] + }, + { + "$ref": "#/$defs/db_IntFilter" + } + ] + }, + "lastlogin": { + "anyOf": [ + { + "type": [ + "null" + ] + }, + { + "$ref": "#/$defs/db_DateTimeFilter" + } + ] + }, + "messages": { + "anyOf": [ + { + "type": [ + "null" + ] + }, + { + "$ref": "#/$defs/db_MessagesListRelationFilter" + } + ] + }, + "name": { + "anyOf": [ + { + "type": [ + "null" + ] + }, + { + "$ref": "#/$defs/db_StringFilter" + } + ] + }, + "pet": { + "anyOf": [ + { + "type": [ + "null" + ] + }, + { + "$ref": "#/$defs/db_StringFilter" + } + ] + }, + "updatedat": { + "anyOf": [ + { + "type": [ + "null" + ] + }, + { + "$ref": "#/$defs/db_DateTimeFilter" + } + ] + } + }, + "additionalProperties": false + } + } +}` From f218226ac4da830aa223cffbde165cb3cd2be1aa Mon Sep 17 00:00:00 2001 From: StarpTech Date: Mon, 14 Apr 2025 01:47:46 +0200 Subject: [PATCH 04/16] chore: implement get_schema, execute_graphql and refactor code --- v2/pkg/engine/jsonschema/schema.go | 7 +- v2/pkg/engine/jsonschema/variables_schema.go | 9 ++ .../jsonschema/variables_schema_test.go | 84 ++++++++++++++++++- 3 files changed, 94 insertions(+), 6 deletions(-) diff --git a/v2/pkg/engine/jsonschema/schema.go b/v2/pkg/engine/jsonschema/schema.go index 2a15011cc1..3af261820d 100644 --- a/v2/pkg/engine/jsonschema/schema.go +++ b/v2/pkg/engine/jsonschema/schema.go @@ -73,9 +73,10 @@ func (s *JsonSchema) MarshalJSON() ([]byte, error) { m["description"] = s.Description } - // Only include nullable field when it's true, omit when false - if s.Nullable { - m["nullable"] = true + // 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 { diff --git a/v2/pkg/engine/jsonschema/variables_schema.go b/v2/pkg/engine/jsonschema/variables_schema.go index 4a2cb4784c..4a413761e7 100644 --- a/v2/pkg/engine/jsonschema/variables_schema.go +++ b/v2/pkg/engine/jsonschema/variables_schema.go @@ -143,6 +143,15 @@ func (v *VariablesSchemaBuilder) processVariableDefinition(varDefRef int) { 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 } diff --git a/v2/pkg/engine/jsonschema/variables_schema_test.go b/v2/pkg/engine/jsonschema/variables_schema_test.go index f8fb335da6..d5ebbb341d 100644 --- a/v2/pkg/engine/jsonschema/variables_schema_test.go +++ b/v2/pkg/engine/jsonschema/variables_schema_test.go @@ -1090,7 +1090,10 @@ func TestBuildJsonSchema(t *testing.T) { // Non-nullable object requiredNested := inputProps["requiredNested"].(map[string]interface{}) assert.Equal(t, "object", requiredNested["type"]) - assert.NotContains(t, requiredNested, "nullable") + // For object types, nullable should be included and be false for required fields + nullable, hasNullable := requiredNested["nullable"] + assert.True(t, hasNullable, "Required nested object should have nullable field") + assert.False(t, nullable.(bool), "Required nested object should have nullable=false") // Check nested fields nestedProps := nested["properties"].(map[string]interface{}) @@ -1103,6 +1106,7 @@ func TestBuildJsonSchema(t *testing.T) { // Non-nullable nested field requiredNestedField := nestedProps["requiredField"].(map[string]interface{}) assert.Equal(t, "string", requiredNestedField["type"]) + // Type string non-nullable still won't have nullable field assert.NotContains(t, requiredNestedField, "nullable") }) @@ -1159,8 +1163,10 @@ func TestBuildJsonSchema(t *testing.T) { require.NoError(t, err) // Verify root schema is NOT nullable when there are required arguments - _, hasNullable1 := parsed1["nullable"] - assert.False(t, hasNullable1, "Root schema should not be nullable when there are required arguments") + nullable1, hasNullable1 := parsed1["nullable"] + assert.True(t, hasNullable1, "Root schema should have nullable field when there are required arguments") + assert.False(t, nullable1.(bool), "Root schema should not be nullable when there are required arguments") + // Verify required fields are present required1, hasRequired1 := parsed1["required"].([]interface{}) assert.True(t, hasRequired1) @@ -1186,4 +1192,76 @@ func TestBuildJsonSchema(t *testing.T) { assert.True(t, hasNullable2) assert.True(t, nullable2, "Root schema should be nullable when all arguments are optional") }) + + t.Run("top-level object fields are not nullable", func(t *testing.T) { + // Define schema + schemaSDL := ` + 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) + + // Verify schema structure + var parsed map[string]interface{} + err = json.Unmarshal(data, &parsed) + require.NoError(t, err) + + // Verify properties + properties := parsed["properties"].(map[string]interface{}) + + // Check criteria object + criteria, ok := properties["criteria"].(map[string]interface{}) + require.True(t, ok) + assert.Equal(t, "object", criteria["type"]) + + // Verify criteria is not nullable by checking the nullable field is explicitly false + nullable, hasNullable := criteria["nullable"] + assert.True(t, hasNullable, "Top-level object field should have nullable field") + assert.False(t, nullable.(bool), "Top-level object field should have nullable=false") + }) } From c225f722fabfac1a7815d5da722c841d85fc9403 Mon Sep 17 00:00:00 2001 From: StarpTech Date: Tue, 15 Apr 2025 23:02:35 +0200 Subject: [PATCH 05/16] chore: fix lint --- v2/pkg/engine/jsonschema/schema.go | 6 +++--- v2/pkg/engine/jsonschema/variables_schema_test.go | 9 ++++----- 2 files changed, 7 insertions(+), 8 deletions(-) diff --git a/v2/pkg/engine/jsonschema/schema.go b/v2/pkg/engine/jsonschema/schema.go index 3af261820d..52a12cef33 100644 --- a/v2/pkg/engine/jsonschema/schema.go +++ b/v2/pkg/engine/jsonschema/schema.go @@ -57,11 +57,11 @@ func (s *JsonSchema) MarshalJSON() ([]byte, error) { m["type"] = string(s.Type) } - if s.Properties != nil && len(s.Properties) > 0 { + if len(s.Properties) > 0 { m["properties"] = s.Properties } - if s.Required != nil && len(s.Required) > 0 { + if len(s.Required) > 0 { m["required"] = s.Required } @@ -83,7 +83,7 @@ func (s *JsonSchema) MarshalJSON() ([]byte, error) { m["items"] = s.Items } - if s.Enum != nil && len(s.Enum) > 0 { + if len(s.Enum) > 0 { m["enum"] = s.Enum } diff --git a/v2/pkg/engine/jsonschema/variables_schema_test.go b/v2/pkg/engine/jsonschema/variables_schema_test.go index d5ebbb341d..5f3f08fc21 100644 --- a/v2/pkg/engine/jsonschema/variables_schema_test.go +++ b/v2/pkg/engine/jsonschema/variables_schema_test.go @@ -8,7 +8,6 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "github.com/wundergraph/graphql-go-tools/v2/pkg/astparser" - "github.com/wundergraph/graphql-go-tools/v2/pkg/operationreport" ) func TestBuildJsonSchema(t *testing.T) { @@ -424,11 +423,11 @@ func TestBuildJsonSchema(t *testing.T) { ` // Parse schema and operation - definitionDoc, report := astparser.ParseGraphqlDocumentString(schemaSDL) - report = operationreport.Report{} // Reset report + definitionDoc, report1 := astparser.ParseGraphqlDocumentString(schemaSDL) + require.False(t, report1.HasErrors(), "operation parsing failed") - operationDoc, report := astparser.ParseGraphqlDocumentString(operationSDL) - require.False(t, report.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) From 59d52afd092cd638c03392750ec71f9af05305ca Mon Sep 17 00:00:00 2001 From: StarpTech Date: Wed, 16 Apr 2025 10:45:58 +0200 Subject: [PATCH 06/16] chore: adopt visitor pattern --- v2/pkg/engine/jsonschema/variables_schema.go | 158 ++++++++++++------- 1 file changed, 98 insertions(+), 60 deletions(-) diff --git a/v2/pkg/engine/jsonschema/variables_schema.go b/v2/pkg/engine/jsonschema/variables_schema.go index 4a413761e7..08a83bfa8e 100644 --- a/v2/pkg/engine/jsonschema/variables_schema.go +++ b/v2/pkg/engine/jsonschema/variables_schema.go @@ -18,6 +18,17 @@ type VariablesSchemaBuilder struct { maxRecursionDepth int } +// Visitor interface for the GraphQL AST +type Visitor interface { + VisitDocument(operation, definition *ast.Document) + VisitVariableDefinition(ref int) + GetSchema() *JsonSchema + GetReport() *operationreport.Report +} + +// ensure VariablesSchemaBuilder implements Visitor +var _ Visitor = (*VariablesSchemaBuilder)(nil) + // NewVariablesSchemaBuilder creates a new VariablesSchemaBuilder with default settings func NewVariablesSchemaBuilder(operationDocument, definitionDocument *ast.Document) *VariablesSchemaBuilder { return NewVariablesSchemaBuilderWithOptions(operationDocument, definitionDocument, 3) @@ -35,52 +46,55 @@ func NewVariablesSchemaBuilderWithOptions(operationDocument, definitionDocument } } -// Build traverses the operation and builds a unified JSON schema for its variables -func (v *VariablesSchemaBuilder) Build() (*JsonSchema, error) { +// VisitDocument visits the operation and definition documents +func (v *VariablesSchemaBuilder) VisitDocument(operation, definition *ast.Document) { v.schema = NewObjectSchema() v.recursionTracker = make(map[string]int) // Reset recursion tracker for each build // Extract descriptions from root fields var descriptions []string - operationDefinition := v.operationDocument.OperationDefinitions[0] - - // Process SelectionSet to extract field descriptions - if operationDefinition.HasSelections { - selectionSetRef := operationDefinition.SelectionSet - for _, selectionRef := range v.operationDocument.SelectionSets[selectionSetRef].SelectionRefs { - selection := v.operationDocument.Selections[selectionRef] - if selection.Kind == ast.SelectionKindField { - fieldName := v.operationDocument.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: - return nil, fmt.Errorf("unsupported operation type %q", operationType) - } + if len(operation.OperationDefinitions) > 0 { + 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 := v.definitionDocument.Index.FirstNodeByNameStr(rootTypeName) - if exists && rootType.Kind == ast.NodeKindObjectTypeDefinition { - // Find the field in the root type - for _, fieldDefRef := range v.definitionDocument.ObjectTypeDefinitions[rootType.Ref].FieldsDefinition.Refs { - fieldDefName := v.definitionDocument.FieldDefinitionNameString(fieldDefRef) - - // Match field name - if fieldDefName == fieldName && v.definitionDocument.FieldDefinitions[fieldDefRef].Description.IsDefined { - description := v.definitionDocument.FieldDefinitionDescriptionString(fieldDefRef) - if description != "" { - descriptions = append(descriptions, description) + 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 } - break } } } @@ -98,29 +112,10 @@ func (v *VariablesSchemaBuilder) Build() (*JsonSchema, error) { v.schema.Description += desc } } - - if !v.operationDocument.OperationDefinitions[0].HasVariableDefinitions { - return v.schema, nil - } - - for _, varDefRef := range v.operationDocument.OperationDefinitions[0].VariableDefinitions.Refs { - v.processVariableDefinition(varDefRef) - } - - // If we have required fields, the root schema cannot be nullable - if len(v.schema.Required) > 0 { - v.schema.Nullable = false - } - - if v.report.HasErrors() { - return nil, fmt.Errorf("%s", v.report.Error()) - } - - return v.schema, nil } -// processVariableDefinition processes a single variable definition -func (v *VariablesSchemaBuilder) processVariableDefinition(varDefRef int) { +// VisitVariableDefinition visits a variable definition in the operation document +func (v *VariablesSchemaBuilder) VisitVariableDefinition(varDefRef int) { varName := v.operationDocument.VariableDefinitionNameString(varDefRef) typeRef := v.operationDocument.VariableDefinitions[varDefRef].Type @@ -156,6 +151,49 @@ func (v *VariablesSchemaBuilder) processVariableDefinition(varDefRef int) { v.schema.Properties[varName] = varSchema } +// Walk traverses the documents AST according to the visitor pattern +func Walk(v Visitor, operation, definition *ast.Document) { + // Visit the document first + v.VisitDocument(operation, definition) + + // If there are no operations or no variable definitions, we're done + if len(operation.OperationDefinitions) == 0 || + !operation.OperationDefinitions[0].HasVariableDefinitions { + return + } + + // Visit each variable definition + for _, varDefRef := range operation.OperationDefinitions[0].VariableDefinitions.Refs { + v.VisitVariableDefinition(varDefRef) + } +} + +// 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) { + // Walk the AST using the visitor pattern + Walk(v, v.operationDocument, v.definitionDocument) + + 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 { From 29060dce75a4d66f4fdd6ccc776754fa67ecd74c Mon Sep 17 00:00:00 2001 From: StarpTech Date: Wed, 16 Apr 2025 10:53:29 +0200 Subject: [PATCH 07/16] chore: use string for enums --- v2/pkg/engine/jsonschema/schema.go | 6 +++--- v2/pkg/engine/jsonschema/schema_test.go | 12 ++++++------ v2/pkg/engine/jsonschema/variables_schema.go | 2 +- 3 files changed, 10 insertions(+), 10 deletions(-) diff --git a/v2/pkg/engine/jsonschema/schema.go b/v2/pkg/engine/jsonschema/schema.go index 52a12cef33..17c1af8063 100644 --- a/v2/pkg/engine/jsonschema/schema.go +++ b/v2/pkg/engine/jsonschema/schema.go @@ -31,7 +31,7 @@ type JsonSchema struct { Items *JsonSchema `json:"items,omitempty"` // Enum values - Enum []interface{} `json:"enum,omitempty"` + Enum []string `json:"enum,omitempty"` // Default value Default interface{} `json:"default,omitempty"` @@ -165,7 +165,7 @@ func NewBooleanSchema() *JsonSchema { } // NewEnumSchema creates a new schema for an enum type -func NewEnumSchema(values []interface{}) *JsonSchema { +func NewEnumSchema(values []string) *JsonSchema { return &JsonSchema{ Type: TypeString, Enum: values, @@ -209,7 +209,7 @@ func CloneSchema(schema *JsonSchema) *JsonSchema { } if schema.Enum != nil { - clone.Enum = append([]interface{}{}, schema.Enum...) + clone.Enum = append([]string{}, schema.Enum...) } if schema.Minimum != nil { diff --git a/v2/pkg/engine/jsonschema/schema_test.go b/v2/pkg/engine/jsonschema/schema_test.go index cd63b640b8..e9873a6583 100644 --- a/v2/pkg/engine/jsonschema/schema_test.go +++ b/v2/pkg/engine/jsonschema/schema_test.go @@ -29,7 +29,7 @@ func TestJsonSchema_MarshalJSON(t *testing.T) { schema.Required = append(schema.Required, "age") // Add enum property - enumValues := []interface{}{"ONE", "TWO", "THREE"} + enumValues := []string{"ONE", "TWO", "THREE"} enumProp := NewEnumSchema(enumValues) schema.Properties["category"] = enumProp @@ -152,7 +152,7 @@ func TestCloneSchema(t *testing.T) { original.Properties["number"] = NewNumberSchema() // Add enum - enumValues := []interface{}{"A", "B", "C"} + enumValues := []string{"A", "B", "C"} original.Properties["enum"] = NewEnumSchema(enumValues) // Add nested object @@ -228,7 +228,7 @@ func TestCloneSchema(t *testing.T) { func TestSchemaFeatures(t *testing.T) { t.Run("enum schema", func(t *testing.T) { // Test creating and validating enum schema - values := []interface{}{"RED", "GREEN", "BLUE"} + values := []string{"RED", "GREEN", "BLUE"} schema := NewEnumSchema(values) // Check structure @@ -383,7 +383,7 @@ func TestSchemaFeatures(t *testing.T) { NewIntegerSchema(), NewNumberSchema(), NewBooleanSchema(), - NewEnumSchema([]interface{}{"A", "B"}), + NewEnumSchema([]string{"A", "B"}), } for _, schema := range schemas { @@ -443,7 +443,7 @@ func TestSchemaFeatures(t *testing.T) { userSchema.Properties["age"] = ageSchema // Enum property - roleSchema := NewEnumSchema([]interface{}{"ADMIN", "USER", "GUEST"}) + roleSchema := NewEnumSchema([]string{"ADMIN", "USER", "GUEST"}) roleSchema.Default = "USER" userSchema.Properties["role"] = roleSchema @@ -509,7 +509,7 @@ func TestSchemaFeatures(t *testing.T) { boolSchema := NewBooleanSchema() assert.True(t, boolSchema.Nullable) - enumSchema := NewEnumSchema([]interface{}{"A", "B"}) + enumSchema := NewEnumSchema([]string{"A", "B"}) assert.True(t, enumSchema.Nullable) arraySchema := NewArraySchema(NewStringSchema()) diff --git a/v2/pkg/engine/jsonschema/variables_schema.go b/v2/pkg/engine/jsonschema/variables_schema.go index 08a83bfa8e..264b7426cf 100644 --- a/v2/pkg/engine/jsonschema/variables_schema.go +++ b/v2/pkg/engine/jsonschema/variables_schema.go @@ -299,7 +299,7 @@ func (v *VariablesSchemaBuilder) processTypeByName(typeName string) *JsonSchema // processEnumType processes an enum type definition func (v *VariablesSchemaBuilder) processEnumType(node ast.Node) *JsonSchema { - values := make([]interface{}, 0) + values := make([]string, 0) enumDef := v.definitionDocument.EnumTypeDefinitions[node.Ref] for _, valueRef := range enumDef.EnumValuesDefinition.Refs { From cc1c4a81173a952b7c62677dff51ce84cc84230c Mon Sep 17 00:00:00 2001 From: StarpTech Date: Wed, 16 Apr 2025 11:21:42 +0200 Subject: [PATCH 08/16] chore: remove leftovers --- v2/pkg/engine/jsonschema/schema.go | 52 --------------- v2/pkg/engine/jsonschema/schema_test.go | 84 ------------------------- 2 files changed, 136 deletions(-) diff --git a/v2/pkg/engine/jsonschema/schema.go b/v2/pkg/engine/jsonschema/schema.go index 17c1af8063..b16eb02be6 100644 --- a/v2/pkg/engine/jsonschema/schema.go +++ b/v2/pkg/engine/jsonschema/schema.go @@ -173,58 +173,6 @@ func NewEnumSchema(values []string) *JsonSchema { } } -// CloneSchema creates a deep copy of a schema -func CloneSchema(schema *JsonSchema) *JsonSchema { - if schema == nil { - return nil - } - - clone := &JsonSchema{ - Type: schema.Type, - Description: schema.Description, - Format: schema.Format, - Pattern: schema.Pattern, - Default: schema.Default, - Nullable: schema.Nullable, - } - - if schema.Properties != nil { - clone.Properties = make(map[string]*JsonSchema) - for k, v := range schema.Properties { - clone.Properties[k] = CloneSchema(v) - } - } - - if schema.Required != nil { - clone.Required = append([]string{}, schema.Required...) - } - - if schema.AdditionalProperties != nil { - additionalProps := *schema.AdditionalProperties - clone.AdditionalProperties = &additionalProps - } - - if schema.Items != nil { - clone.Items = CloneSchema(schema.Items) - } - - if schema.Enum != nil { - clone.Enum = append([]string{}, schema.Enum...) - } - - if schema.Minimum != nil { - min := *schema.Minimum - clone.Minimum = &min - } - - if schema.Maximum != nil { - max := *schema.Maximum - clone.Maximum = &max - } - - return clone -} - // WithDescription adds a description to the schema func (s *JsonSchema) WithDescription(description string) *JsonSchema { s.Description = description diff --git a/v2/pkg/engine/jsonschema/schema_test.go b/v2/pkg/engine/jsonschema/schema_test.go index e9873a6583..ac91c217a5 100644 --- a/v2/pkg/engine/jsonschema/schema_test.go +++ b/v2/pkg/engine/jsonschema/schema_test.go @@ -141,90 +141,6 @@ func TestJsonSchema_MarshalJSON(t *testing.T) { }) } -func TestCloneSchema(t *testing.T) { - t.Run("clone complex schema", func(t *testing.T) { - // Create a complex schema to clone - original := NewObjectSchema() - original.Description = "Original schema" - - // Add properties - original.Properties["string"] = NewStringSchema() - original.Properties["number"] = NewNumberSchema() - - // Add enum - enumValues := []string{"A", "B", "C"} - original.Properties["enum"] = NewEnumSchema(enumValues) - - // Add nested object - nested := NewObjectSchema() - nested.Properties["field"] = NewStringSchema() - original.Properties["nested"] = nested - - // Add array - original.Properties["array"] = NewArraySchema(NewIntegerSchema()) - - // Set required - original.Required = []string{"string", "number"} - - // Clone the schema - clone := CloneSchema(original) - - // Verify they're equal but not the same object - assert.NotSame(t, original, clone) - assert.Equal(t, original.Description, clone.Description) - assert.Equal(t, original.Type, clone.Type) - assert.Equal(t, original.Required, clone.Required) - - // Check properties are cloned - assert.Len(t, clone.Properties, len(original.Properties)) - for key, prop := range original.Properties { - clonedProp, exists := clone.Properties[key] - assert.True(t, exists) - assert.Equal(t, prop.Type, clonedProp.Type) - assert.NotSame(t, prop, clonedProp) - } - - // Modify the clone and verify the original is unchanged - clone.Description = "Modified clone" - clone.Properties["string"].Description = "Modified property" - clone.Required = append(clone.Required, "newRequired") - - assert.NotEqual(t, original.Description, clone.Description) - assert.NotEqual(t, original.Required, clone.Required) - assert.Empty(t, original.Properties["string"].Description) - }) - - t.Run("clone preserves nullable field", func(t *testing.T) { - // Create schemas with different nullable settings - nullable := NewStringSchema().WithNullable(true) - nonNullable := NewStringSchema().WithNullable(false) - - // Clone the schemas - nullableClone := CloneSchema(nullable) - nonNullableClone := CloneSchema(nonNullable) - - // Verify nullable property is preserved - assert.True(t, nullableClone.Nullable) - assert.False(t, nonNullableClone.Nullable) - - // Create a complex schema with different nullable settings - complex := NewObjectSchema() - complex.Properties["nullableString"] = NewStringSchema().WithNullable(true) - complex.Properties["nonNullableString"] = NewStringSchema().WithNullable(false) - complex.Properties["nullableArray"] = NewArraySchema(NewStringSchema()).WithNullable(true) - complex.Properties["nonNullableArray"] = NewArraySchema(NewStringSchema()).WithNullable(false) - - // Clone the complex schema - complexClone := CloneSchema(complex) - - // Verify nullable settings are preserved for all properties - assert.True(t, complexClone.Properties["nullableString"].Nullable) - assert.False(t, complexClone.Properties["nonNullableString"].Nullable) - assert.True(t, complexClone.Properties["nullableArray"].Nullable) - assert.False(t, complexClone.Properties["nonNullableArray"].Nullable) - }) -} - func TestSchemaFeatures(t *testing.T) { t.Run("enum schema", func(t *testing.T) { // Test creating and validating enum schema From 0a8bf5edc3f5dc6444987cbf58cbcf1dd8c549bb Mon Sep 17 00:00:00 2001 From: StarpTech Date: Wed, 16 Apr 2025 11:41:24 +0200 Subject: [PATCH 09/16] chore: use empty object to represent scalars --- v2/pkg/engine/jsonschema/variables_schema.go | 2 +- .../jsonschema/variables_schema_test.go | 53 +++++++++++++++++++ 2 files changed, 54 insertions(+), 1 deletion(-) diff --git a/v2/pkg/engine/jsonschema/variables_schema.go b/v2/pkg/engine/jsonschema/variables_schema.go index 264b7426cf..5fdfd1b6de 100644 --- a/v2/pkg/engine/jsonschema/variables_schema.go +++ b/v2/pkg/engine/jsonschema/variables_schema.go @@ -289,7 +289,7 @@ func (v *VariablesSchemaBuilder) processTypeByName(typeName string) *JsonSchema return v.processInputObjectType(node) case ast.NodeKindScalarTypeDefinition: - return NewStringSchema() + return NewObjectSchema() default: // If we can't determine the type, default to object diff --git a/v2/pkg/engine/jsonschema/variables_schema_test.go b/v2/pkg/engine/jsonschema/variables_schema_test.go index 5f3f08fc21..59bf4a66be 100644 --- a/v2/pkg/engine/jsonschema/variables_schema_test.go +++ b/v2/pkg/engine/jsonschema/variables_schema_test.go @@ -1263,4 +1263,57 @@ func TestBuildJsonSchema(t *testing.T) { assert.True(t, hasNullable, "Top-level object field should have nullable field") assert.False(t, nullable.(bool), "Top-level object field should have nullable=false") }) + + t.Run("custom scalar types are represented as objects", func(t *testing.T) { + // Define schema with custom scalar types + schemaSDL := ` + scalar DateTime + 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) + + // Check the direct schema objects + fromSchema := schema.Properties["from"] + require.NotNil(t, fromSchema, "from property should exist") + assert.Equal(t, TypeObject, fromSchema.Type, "Custom scalar should be an object type") + + filterSchema := schema.Properties["filter"] + require.NotNil(t, filterSchema, "filter property should exist") + assert.Equal(t, TypeObject, filterSchema.Type, "Custom scalar should be an object type") + + // Verify Properties are initialized + assert.NotNil(t, fromSchema.Properties, "Properties map should be initialized") + assert.NotNil(t, filterSchema.Properties, "Properties map should be initialized") + }) } From c04fd6baf8e70d3636045804de2340582e87b45a Mon Sep 17 00:00:00 2001 From: StarpTech Date: Wed, 16 Apr 2025 11:54:15 +0200 Subject: [PATCH 10/16] chore: improve tests readability --- v2/pkg/engine/jsonschema/schema_test.go | 297 ++++--- .../jsonschema/variables_schema_test.go | 753 +++++++++++------- 2 files changed, 653 insertions(+), 397 deletions(-) diff --git a/v2/pkg/engine/jsonschema/schema_test.go b/v2/pkg/engine/jsonschema/schema_test.go index ac91c217a5..979e7bf7d9 100644 --- a/v2/pkg/engine/jsonschema/schema_test.go +++ b/v2/pkg/engine/jsonschema/schema_test.go @@ -45,54 +45,71 @@ func TestJsonSchema_MarshalJSON(t *testing.T) { schema.Properties["tags"] = arrayProp // Serialize to JSON - data, err := json.Marshal(schema) - require.NoError(t, err) - - // Parse it back to verify - var parsed map[string]interface{} - err = json.Unmarshal(data, &parsed) + data, err := json.MarshalIndent(schema, "", " ") require.NoError(t, err) - // Verify structure - assert.Equal(t, "object", parsed["type"]) - assert.Equal(t, "Test object schema", parsed["description"]) - assert.Equal(t, false, parsed["additionalProperties"]) - - properties := parsed["properties"].(map[string]interface{}) - assert.Len(t, properties, 5) - - // Check string property - nameProp := properties["name"].(map[string]interface{}) - assert.Equal(t, "string", nameProp["type"]) - assert.Equal(t, "A string property", nameProp["description"]) - assert.Equal(t, "default value", nameProp["default"]) - - // Check integer property - ageProp := properties["age"].(map[string]interface{}) - assert.Equal(t, "integer", ageProp["type"]) - assert.Equal(t, float64(0), ageProp["minimum"]) - - // Check enum property - categoryProp := properties["category"].(map[string]interface{}) - assert.Equal(t, "string", categoryProp["type"]) - assert.Equal(t, []interface{}{"ONE", "TWO", "THREE"}, categoryProp["enum"]) - - // Check nested object - addressProp := properties["address"].(map[string]interface{}) - assert.Equal(t, "object", addressProp["type"]) - addressProps := addressProp["properties"].(map[string]interface{}) - assert.Len(t, addressProps, 2) - assert.Contains(t, addressProps, "street") - assert.Contains(t, addressProps, "city") - assert.Equal(t, []interface{}{"street"}, addressProp["required"]) - - // Check array property - tagsProp := properties["tags"].(map[string]interface{}) - assert.Equal(t, "array", tagsProp["type"]) - assert.NotNil(t, tagsProp["items"]) - - // Check required fields - assert.Equal(t, []interface{}{"name", "age"}, parsed["required"]) + // 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) { @@ -147,20 +164,23 @@ func TestSchemaFeatures(t *testing.T) { values := []string{"RED", "GREEN", "BLUE"} schema := NewEnumSchema(values) - // Check structure - assert.Equal(t, TypeString, schema.Type) - assert.Equal(t, values, schema.Enum) - // Test serialization - data, err := json.Marshal(schema) + data, err := json.MarshalIndent(schema, "", " ") require.NoError(t, err) - var parsed map[string]interface{} - err = json.Unmarshal(data, &parsed) - require.NoError(t, err) - - assert.Equal(t, "string", parsed["type"]) - assert.Equal(t, []interface{}{"RED", "GREEN", "BLUE"}, parsed["enum"]) + // 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) { @@ -174,18 +194,36 @@ func TestSchemaFeatures(t *testing.T) { schema.Required = []string{"id", "age"} // Serialize and check - data, err := json.Marshal(schema) + data, err := json.MarshalIndent(schema, "", " ") require.NoError(t, err) - var parsed map[string]interface{} - err = json.Unmarshal(data, &parsed) - require.NoError(t, err) - - required := parsed["required"].([]interface{}) - assert.Len(t, required, 2) - assert.Contains(t, required, "id") - assert.Contains(t, required, "age") - assert.NotContains(t, required, "name") + // 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) { @@ -198,30 +236,38 @@ func TestSchemaFeatures(t *testing.T) { intSchema.Minimum = &min intSchema.Maximum = &max - data, err := json.Marshal(intSchema) + data, err := json.MarshalIndent(intSchema, "", " ") require.NoError(t, err) - var parsed map[string]interface{} - err = json.Unmarshal(data, &parsed) - require.NoError(t, err) + // Define expected JSON schema for integer + expectedIntJSON := `{ + "type": "integer", + "minimum": 0, + "maximum": 100, + "nullable": true +}` - assert.Equal(t, float64(0), parsed["minimum"]) - assert.Equal(t, float64(100), parsed["maximum"]) + // 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.Marshal(numSchema) + data, err = json.MarshalIndent(numSchema, "", " ") require.NoError(t, err) - parsed = map[string]interface{}{} - err = json.Unmarshal(data, &parsed) - require.NoError(t, err) + // Define expected JSON schema for number + expectedNumJSON := `{ + "type": "number", + "minimum": 0, + "maximum": 100, + "nullable": true +}` - assert.Equal(t, float64(0), parsed["minimum"]) - assert.Equal(t, float64(100), parsed["maximum"]) + // 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) { @@ -375,33 +421,76 @@ func TestSchemaFeatures(t *testing.T) { userSchema.Properties["address"] = addressSchema // Serialize the whole thing - data, err := json.Marshal(userSchema) + data, err := json.MarshalIndent(userSchema, "", " ") require.NoError(t, err) - var parsed map[string]interface{} - err = json.Unmarshal(data, &parsed) - require.NoError(t, err) - - // Verify just a few key aspects - assert.Equal(t, "User schema with all features", parsed["description"]) - properties := parsed["properties"].(map[string]interface{}) - assert.Len(t, properties, 6) - assert.Contains(t, parsed["required"], "id") - - // Verify pattern on id - idProp := properties["id"].(map[string]interface{}) - assert.Equal(t, "^[a-zA-Z0-9]{8,}$", idProp["pattern"]) - - // Verify enum values - roleProp := properties["role"].(map[string]interface{}) - assert.Len(t, roleProp["enum"], 3) - assert.Equal(t, "USER", roleProp["default"]) - - // Verify nested object - addressProp := properties["address"].(map[string]interface{}) - addressProps := addressProp["properties"].(map[string]interface{}) - assert.Len(t, addressProps, 2) - assert.Equal(t, []interface{}{"street"}, addressProp["required"]) + // 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) { diff --git a/v2/pkg/engine/jsonschema/variables_schema_test.go b/v2/pkg/engine/jsonschema/variables_schema_test.go index 59bf4a66be..fc9796edbb 100644 --- a/v2/pkg/engine/jsonschema/variables_schema_test.go +++ b/v2/pkg/engine/jsonschema/variables_schema_test.go @@ -2,7 +2,6 @@ package jsonschema import ( "encoding/json" - "strings" "testing" "github.com/stretchr/testify/assert" @@ -67,42 +66,45 @@ func TestBuildJsonSchema(t *testing.T) { 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 criteria property - criteria, ok := properties["criteria"].(map[string]interface{}) - require.True(t, ok) - assert.Equal(t, "object", criteria["type"]) - assert.Equal(t, "Input criteria used to search for employees", criteria["description"]) - - // Verify criteria properties - criteriaProps := criteria["properties"].(map[string]interface{}) - assert.Len(t, criteriaProps, 3) - - name := criteriaProps["name"].(map[string]interface{}) - assert.Equal(t, "string", name["type"]) - - department := criteriaProps["department"].(map[string]interface{}) - assert.Equal(t, "string", department["type"]) - - status := criteriaProps["employmentStatus"].(map[string]interface{}) - assert.Equal(t, "string", status["type"]) - statusEnum := status["enum"].([]interface{}) - assert.ElementsMatch(t, []interface{}{"FULL_TIME", "PART_TIME", "CONTRACTOR", "INTERN"}, statusEnum) - - // Verify required fields - criteriaRequired := criteria["required"].([]interface{}) - assert.ElementsMatch(t, []interface{}{"name"}, criteriaRequired) - - // Verify additionalProperties is false - assert.Equal(t, false, criteria["additionalProperties"]) + // 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) { @@ -163,45 +165,68 @@ func TestBuildJsonSchema(t *testing.T) { 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 - assert.Contains(t, parsed["required"], "criteria") - - // Verify criteria structure - properties := parsed["properties"].(map[string]interface{}) - criteria := properties["criteria"].(map[string]interface{}) - criteriaProps := criteria["properties"].(map[string]interface{}) - - // Verify nested structure - nested := criteriaProps["nested"].(map[string]interface{}) - assert.Equal(t, "object", nested["type"]) - - // Verify nested is required - criteriaRequired := criteria["required"].([]interface{}) - assert.Contains(t, criteriaRequired, "nested") - - // Verify nested properties - nestedProps := nested["properties"].(map[string]interface{}) - assert.Len(t, nestedProps, 3) - - // Verify nationality is required in nested - nestedRequired := nested["required"].([]interface{}) - assert.Contains(t, nestedRequired, "nationality") - - // Verify enum in nested - nationality := nestedProps["nationality"].(map[string]interface{}) - assert.Equal(t, "string", nationality["type"]) - nationalityEnum := nationality["enum"].([]interface{}) - assert.Len(t, nationalityEnum, 7) - - maritalStatus := nestedProps["maritalStatus"].(map[string]interface{}) - assert.Equal(t, "string", maritalStatus["type"]) - maritalEnum := maritalStatus["enum"].([]interface{}) - assert.ElementsMatch(t, []interface{}{"MARRIED", "ENGAGED"}, maritalEnum) + // 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) { @@ -592,73 +617,136 @@ func TestBuildJsonSchema(t *testing.T) { 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 input is required - required := parsed["required"].([]interface{}) - assert.Contains(t, required, "input") - - // Get properties - properties := parsed["properties"].(map[string]interface{}) - input := properties["input"].(map[string]interface{}) - - // Verify level 1 description - assert.Equal(t, "Level 1 input description", input["description"]) - - // Verify level 1 required fields - level1Required := input["required"].([]interface{}) - assert.Contains(t, level1Required, "nested") - assert.Contains(t, level1Required, "requiredArray") - - // Verify level 1 properties - level1Properties := input["properties"].(map[string]interface{}) - - // Check array types - requiredArray := level1Properties["requiredArray"].(map[string]interface{}) - assert.Equal(t, "array", requiredArray["type"]) - assert.Equal(t, "integer", requiredArray["items"].(map[string]interface{})["type"]) - - // Verify level 2 - nested := level1Properties["nested"].(map[string]interface{}) - assert.Equal(t, "Level 2 input description", nested["description"]) - - // Verify level 2 required fields - level2Required := nested["required"].([]interface{}) - assert.Contains(t, level2Required, "deeper") - - // Verify level 2 properties - level2Properties := nested["properties"].(map[string]interface{}) - - // Verify level 3 - deeper := level2Properties["deeper"].(map[string]interface{}) - assert.Equal(t, "Level 3 input description", deeper["description"]) - - // Verify level 3 required fields - level3Required := deeper["required"].([]interface{}) - assert.Contains(t, level3Required, "enumField") - - // Verify level 3 properties - level3Properties := deeper["properties"].(map[string]interface{}) - - // Verify enum - enumField := level3Properties["enumField"].(map[string]interface{}) - assert.Equal(t, "string", enumField["type"]) - - enumValues := enumField["enum"].([]interface{}) - assert.Contains(t, enumValues, "OPTION_1") - assert.Contains(t, enumValues, "OPTION_2") - assert.Contains(t, enumValues, "OPTION_3") - - // Verify array of arrays - arrayOfArrays := level3Properties["arrayOfArrays"].(map[string]interface{}) - assert.Equal(t, "array", arrayOfArrays["type"]) - - innerArray := arrayOfArrays["items"].(map[string]interface{}) - assert.Equal(t, "array", innerArray["type"]) - assert.Equal(t, "string", innerArray["items"].(map[string]interface{})["type"]) + // 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) { @@ -950,27 +1038,55 @@ func TestBuildJsonSchema(t *testing.T) { // Serialize schema to JSON data, err := json.MarshalIndent(schema, "", " ") require.NoError(t, err) - jsonStr := string(data) - - // Check for base structure and non-recursive fields - var parsed map[string]interface{} - err = json.Unmarshal(data, &parsed) - require.NoError(t, err) - - // Verify required attribute a exists at the top level - properties, ok := parsed["properties"].(map[string]interface{}) - require.True(t, ok) - _, ok = properties["a"].(map[string]interface{}) - require.True(t, ok) - // Check non-recursive fields in both types are present - assert.Contains(t, jsonStr, `"id":`) - assert.Contains(t, jsonStr, `"name":`) - assert.Contains(t, jsonStr, `"description":`) - - // Verify at least one a or b reference exists (showing some level of recursion was processed) - assert.True(t, strings.Contains(jsonStr, `"a":`) || strings.Contains(jsonStr, `"b":`), - "Should have at least one reference to a recursive field") + // 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) { @@ -1028,85 +1144,103 @@ func TestBuildJsonSchema(t *testing.T) { 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) - - // Get properties - properties := parsed["properties"].(map[string]interface{}) - input := properties["input"].(map[string]interface{}) - inputProps := input["properties"].(map[string]interface{}) - - // Verify nullable property is correctly set based on GraphQL nullability - - // Nullable scalar - id := inputProps["id"].(map[string]interface{}) - assert.Equal(t, "string", id["type"]) - assert.Equal(t, true, id["nullable"]) - - // Non-nullable scalar - name := inputProps["name"].(map[string]interface{}) - assert.Equal(t, "string", name["type"]) - assert.NotContains(t, name, "nullable") // Default is not nullable for required fields - - // Nullable scalar - age := inputProps["age"].(map[string]interface{}) - assert.Equal(t, "integer", age["type"]) - assert.Equal(t, true, age["nullable"]) - - // Nullable array - tags := inputProps["tags"].(map[string]interface{}) - assert.Equal(t, "array", tags["type"]) - assert.Equal(t, true, tags["nullable"]) - - // Non-nullable array with nullable items - requiredTags := inputProps["requiredTags"].(map[string]interface{}) - assert.Equal(t, "array", requiredTags["type"]) - assert.NotContains(t, requiredTags, "nullable") - - // Nullable array with non-nullable items - nonNullTags := inputProps["nonNullTags"].(map[string]interface{}) - assert.Equal(t, "array", nonNullTags["type"]) - assert.Equal(t, true, nonNullTags["nullable"]) - nonNullTagsItems := nonNullTags["items"].(map[string]interface{}) - assert.Equal(t, "string", nonNullTagsItems["type"]) - assert.NotContains(t, nonNullTagsItems, "nullable") - - // Non-nullable array with non-nullable items - requiredNonNullTags := inputProps["requiredNonNullTags"].(map[string]interface{}) - assert.Equal(t, "array", requiredNonNullTags["type"]) - assert.NotContains(t, requiredNonNullTags, "nullable") - requiredNonNullTagsItems := requiredNonNullTags["items"].(map[string]interface{}) - assert.Equal(t, "string", requiredNonNullTagsItems["type"]) - assert.NotContains(t, requiredNonNullTagsItems, "nullable") - - // Nullable object - nested := inputProps["nested"].(map[string]interface{}) - assert.Equal(t, "object", nested["type"]) - assert.Equal(t, true, nested["nullable"]) - - // Non-nullable object - requiredNested := inputProps["requiredNested"].(map[string]interface{}) - assert.Equal(t, "object", requiredNested["type"]) - // For object types, nullable should be included and be false for required fields - nullable, hasNullable := requiredNested["nullable"] - assert.True(t, hasNullable, "Required nested object should have nullable field") - assert.False(t, nullable.(bool), "Required nested object should have nullable=false") - - // Check nested fields - nestedProps := nested["properties"].(map[string]interface{}) - - // Nullable nested field - nestedField := nestedProps["field"].(map[string]interface{}) - assert.Equal(t, "string", nestedField["type"]) - assert.Equal(t, true, nestedField["nullable"]) - - // Non-nullable nested field - requiredNestedField := nestedProps["requiredField"].(map[string]interface{}) - assert.Equal(t, "string", requiredNestedField["type"]) - // Type string non-nullable still won't have nullable field - assert.NotContains(t, requiredNestedField, "nullable") + // 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) { @@ -1154,22 +1288,26 @@ func TestBuildJsonSchema(t *testing.T) { require.NoError(t, err) // Convert to JSON to check nullable field - data1, err := json.Marshal(schema1) + data1, err := json.MarshalIndent(schema1, "", " ") require.NoError(t, err) - var parsed1 map[string]interface{} - err = json.Unmarshal(data1, &parsed1) - require.NoError(t, err) - - // Verify root schema is NOT nullable when there are required arguments - nullable1, hasNullable1 := parsed1["nullable"] - assert.True(t, hasNullable1, "Root schema should have nullable field when there are required arguments") - assert.False(t, nullable1.(bool), "Root schema should not be nullable when there are required arguments") - - // Verify required fields are present - required1, hasRequired1 := parsed1["required"].([]interface{}) - assert.True(t, hasRequired1) - assert.Contains(t, required1, "id") + // 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) @@ -1179,17 +1317,24 @@ func TestBuildJsonSchema(t *testing.T) { require.NoError(t, err) // Convert to JSON to check nullable field - data2, err := json.Marshal(schema2) + data2, err := json.MarshalIndent(schema2, "", " ") require.NoError(t, err) - var parsed2 map[string]interface{} - err = json.Unmarshal(data2, &parsed2) - require.NoError(t, err) - - // Verify root schema IS nullable when there are only optional arguments - nullable2, hasNullable2 := parsed2["nullable"].(bool) - assert.True(t, hasNullable2) - assert.True(t, nullable2, "Root schema should be nullable when all arguments are optional") + // 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) { @@ -1245,23 +1390,32 @@ func TestBuildJsonSchema(t *testing.T) { 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 properties - properties := parsed["properties"].(map[string]interface{}) - - // Check criteria object - criteria, ok := properties["criteria"].(map[string]interface{}) - require.True(t, ok) - assert.Equal(t, "object", criteria["type"]) - - // Verify criteria is not nullable by checking the nullable field is explicitly false - nullable, hasNullable := criteria["nullable"] - assert.True(t, hasNullable, "Top-level object field should have nullable field") - assert.False(t, nullable.(bool), "Top-level object field should have nullable=false") + // 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) { @@ -1303,17 +1457,30 @@ func TestBuildJsonSchema(t *testing.T) { schema, err := BuildJsonSchema(&operationDoc, &definitionDoc) require.NoError(t, err) - // Check the direct schema objects - fromSchema := schema.Properties["from"] - require.NotNil(t, fromSchema, "from property should exist") - assert.Equal(t, TypeObject, fromSchema.Type, "Custom scalar should be an object type") - - filterSchema := schema.Properties["filter"] - require.NotNil(t, filterSchema, "filter property should exist") - assert.Equal(t, TypeObject, filterSchema.Type, "Custom scalar should be an object type") + // Serialize schema to JSON + data, err := json.MarshalIndent(schema, "", " ") + require.NoError(t, err) - // Verify Properties are initialized - assert.NotNil(t, fromSchema.Properties, "Properties map should be initialized") - assert.NotNil(t, filterSchema.Properties, "Properties map should be initialized") + // Define expected JSON schema + expectedJSON := `{ + "type": "object", + "properties": { + "from": { + "type": "object", + "additionalProperties": false, + "nullable": false + }, + "filter": { + "type": "object", + "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") }) } From 77b353d4c108cd8c8cdc5ab996eecdec65b805dd Mon Sep 17 00:00:00 2001 From: StarpTech Date: Wed, 16 Apr 2025 12:01:26 +0200 Subject: [PATCH 11/16] chore: handle scalars correct, consider type comments --- v2/pkg/engine/jsonschema/schema.go | 8 ++++++++ v2/pkg/engine/jsonschema/variables_schema.go | 13 ++++++++++--- v2/pkg/engine/jsonschema/variables_schema_test.go | 13 +++++++------ 3 files changed, 25 insertions(+), 9 deletions(-) diff --git a/v2/pkg/engine/jsonschema/schema.go b/v2/pkg/engine/jsonschema/schema.go index b16eb02be6..852c19a002 100644 --- a/v2/pkg/engine/jsonschema/schema.go +++ b/v2/pkg/engine/jsonschema/schema.go @@ -123,6 +123,14 @@ func NewObjectSchema() *JsonSchema { } } +// 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{ diff --git a/v2/pkg/engine/jsonschema/variables_schema.go b/v2/pkg/engine/jsonschema/variables_schema.go index 5fdfd1b6de..265b1b3508 100644 --- a/v2/pkg/engine/jsonschema/variables_schema.go +++ b/v2/pkg/engine/jsonschema/variables_schema.go @@ -289,11 +289,18 @@ func (v *VariablesSchemaBuilder) processTypeByName(typeName string) *JsonSchema return v.processInputObjectType(node) case ast.NodeKindScalarTypeDefinition: - return NewObjectSchema() + 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 object - return NewObjectSchema() + // If we can't determine the type, default to any + return NewAnySchema() } } diff --git a/v2/pkg/engine/jsonschema/variables_schema_test.go b/v2/pkg/engine/jsonschema/variables_schema_test.go index fc9796edbb..bcf38a540d 100644 --- a/v2/pkg/engine/jsonschema/variables_schema_test.go +++ b/v2/pkg/engine/jsonschema/variables_schema_test.go @@ -1421,7 +1421,10 @@ func TestBuildJsonSchema(t *testing.T) { t.Run("custom scalar types are represented as objects", func(t *testing.T) { // Define schema with custom scalar types schemaSDL := ` + """ISO-8601 date time format""" scalar DateTime + + """JSON object represented as string""" scalar JSON type Query { @@ -1466,14 +1469,12 @@ func TestBuildJsonSchema(t *testing.T) { "type": "object", "properties": { "from": { - "type": "object", - "additionalProperties": false, - "nullable": false + "nullable": true, + "description": "ISO-8601 date time format" }, "filter": { - "type": "object", - "additionalProperties": false, - "nullable": false + "nullable": true, + "description": "JSON object represented as string" } }, "additionalProperties": false, From aa2e3890c52601ab1717eef26266c4ad2af1cc8e Mon Sep 17 00:00:00 2001 From: StarpTech Date: Thu, 17 Apr 2025 10:58:22 +0200 Subject: [PATCH 12/16] chore: correctly implement visitor --- v2/pkg/engine/jsonschema/variables_schema.go | 60 ++++----- .../jsonschema/variables_schema_test.go | 115 +++++++++++++++--- 2 files changed, 122 insertions(+), 53 deletions(-) diff --git a/v2/pkg/engine/jsonschema/variables_schema.go b/v2/pkg/engine/jsonschema/variables_schema.go index 265b1b3508..e01cdf8409 100644 --- a/v2/pkg/engine/jsonschema/variables_schema.go +++ b/v2/pkg/engine/jsonschema/variables_schema.go @@ -4,6 +4,7 @@ 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" ) @@ -18,16 +19,11 @@ type VariablesSchemaBuilder struct { maxRecursionDepth int } -// Visitor interface for the GraphQL AST -type Visitor interface { - VisitDocument(operation, definition *ast.Document) - VisitVariableDefinition(ref int) - GetSchema() *JsonSchema - GetReport() *operationreport.Report -} - -// ensure VariablesSchemaBuilder implements Visitor -var _ Visitor = (*VariablesSchemaBuilder)(nil) +// 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 { @@ -46,8 +42,8 @@ func NewVariablesSchemaBuilderWithOptions(operationDocument, definitionDocument } } -// VisitDocument visits the operation and definition documents -func (v *VariablesSchemaBuilder) VisitDocument(operation, definition *ast.Document) { +// EnterDocument implements the astvisitor.EnterDocumentVisitor interface +func (v *VariablesSchemaBuilder) EnterDocument(operation, definition *ast.Document) { v.schema = NewObjectSchema() v.recursionTracker = make(map[string]int) // Reset recursion tracker for each build @@ -114,10 +110,10 @@ func (v *VariablesSchemaBuilder) VisitDocument(operation, definition *ast.Docume } } -// VisitVariableDefinition visits a variable definition in the operation document -func (v *VariablesSchemaBuilder) VisitVariableDefinition(varDefRef int) { - varName := v.operationDocument.VariableDefinitionNameString(varDefRef) - typeRef := v.operationDocument.VariableDefinitions[varDefRef].Type +// 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) @@ -133,8 +129,8 @@ func (v *VariablesSchemaBuilder) VisitVariableDefinition(varDefRef int) { } // Set default value if exists - if v.operationDocument.VariableDefinitionHasDefaultValue(varDefRef) { - defaultValue := v.operationDocument.VariableDefinitionDefaultValue(varDefRef) + if v.operationDocument.VariableDefinitionHasDefaultValue(ref) { + defaultValue := v.operationDocument.VariableDefinitionDefaultValue(ref) varSchema.Default = v.convertOperationValueToNative(defaultValue) } @@ -151,23 +147,6 @@ func (v *VariablesSchemaBuilder) VisitVariableDefinition(varDefRef int) { v.schema.Properties[varName] = varSchema } -// Walk traverses the documents AST according to the visitor pattern -func Walk(v Visitor, operation, definition *ast.Document) { - // Visit the document first - v.VisitDocument(operation, definition) - - // If there are no operations or no variable definitions, we're done - if len(operation.OperationDefinitions) == 0 || - !operation.OperationDefinitions[0].HasVariableDefinitions { - return - } - - // Visit each variable definition - for _, varDefRef := range operation.OperationDefinitions[0].VariableDefinitions.Refs { - v.VisitVariableDefinition(varDefRef) - } -} - // GetSchema returns the built schema func (v *VariablesSchemaBuilder) GetSchema() *JsonSchema { // If we have required fields, the root schema cannot be nullable @@ -184,8 +163,15 @@ func (v *VariablesSchemaBuilder) GetReport() *operationreport.Report { // Build traverses the operation and builds a unified JSON schema for its variables func (v *VariablesSchemaBuilder) Build() (*JsonSchema, error) { - // Walk the AST using the visitor pattern - Walk(v, v.operationDocument, v.definitionDocument) + // 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()) diff --git a/v2/pkg/engine/jsonschema/variables_schema_test.go b/v2/pkg/engine/jsonschema/variables_schema_test.go index bcf38a540d..16919bd4f5 100644 --- a/v2/pkg/engine/jsonschema/variables_schema_test.go +++ b/v2/pkg/engine/jsonschema/variables_schema_test.go @@ -9,10 +9,23 @@ import ( "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 := ` + schemaSDL := scalarDefinitions + ` + schema { + query: Query + } + type Query { findEmployees(criteria: SearchInput): EmployeeResult } @@ -109,11 +122,20 @@ func TestBuildJsonSchema(t *testing.T) { t.Run("query with nested input objects", func(t *testing.T) { // Define schema with nested inputs - schemaSDL := ` + schemaSDL := scalarDefinitions + ` + schema { + query: Query + } + type Query { findEmployees(criteria: SearchInput): [Employee] } + type Employee { + id: ID! + name: String + } + input SearchInput { name: String nested: NestedInput! @@ -231,11 +253,20 @@ func TestBuildJsonSchema(t *testing.T) { t.Run("query with default values", func(t *testing.T) { // Define schema with default values - schemaSDL := ` + 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 @@ -301,7 +332,11 @@ func TestBuildJsonSchema(t *testing.T) { t.Run("query with scalar arguments", func(t *testing.T) { // Define schema with scalar arguments - schemaSDL := ` + schemaSDL := scalarDefinitions + ` + schema { + query: Query + } + type Query { getUser(id: ID!, includeProfile: Boolean): User } @@ -383,7 +418,11 @@ func TestBuildJsonSchema(t *testing.T) { t.Run("operation with field descriptions", func(t *testing.T) { // Define schema with field descriptions - schemaSDL := ` + schemaSDL := scalarDefinitions + ` + schema { + query: Query + } + type Query { """Description for getUser field""" getUser(id: ID!): User @@ -434,7 +473,11 @@ func TestBuildJsonSchema(t *testing.T) { t.Run("error handling for undefined types", func(t *testing.T) { // Schema missing SearchInput definition - schemaSDL := ` + schemaSDL := scalarDefinitions + ` + schema { + query: Query + } + type Query { search(input: SearchInput): String } @@ -464,7 +507,11 @@ func TestBuildJsonSchema(t *testing.T) { t.Run("comprehensive test for required arguments", func(t *testing.T) { // Define schema with various required and optional fields - schemaSDL := ` + schemaSDL := scalarDefinitions + ` + schema { + query: Query + } + type Query { search(requiredArg: String!, optionalArg: Int): SearchResult } @@ -557,7 +604,11 @@ func TestBuildJsonSchema(t *testing.T) { t.Run("deeply nested types with mixed requirements", func(t *testing.T) { // Define schema with deeply nested types - schemaSDL := ` + schemaSDL := scalarDefinitions + ` + schema { + query: Query + } + type Query { complexSearch(input: Level1Input): SearchResult } @@ -751,7 +802,11 @@ func TestBuildJsonSchema(t *testing.T) { t.Run("recursive types with default recursion depth", func(t *testing.T) { // Define schema with recursive input type - schemaSDL := ` + schemaSDL := scalarDefinitions + ` + schema { + query: Query + } + type Query { processNode(node: RecursiveNode): Boolean } @@ -811,7 +866,11 @@ func TestBuildJsonSchema(t *testing.T) { t.Run("recursive types with custom recursion depth", func(t *testing.T) { // Define schema with recursive input type - schemaSDL := ` + schemaSDL := scalarDefinitions + ` + schema { + query: Query + } + type Query { processNode(node: RecursiveNode): Boolean } @@ -873,7 +932,11 @@ func TestBuildJsonSchema(t *testing.T) { t.Run("query with two nested arguments", func(t *testing.T) { // Define schema with two complex input types - schemaSDL := ` + schemaSDL := scalarDefinitions + ` + schema { + query: Query + } + type Query { searchUsers(userFilter: UserFilter, orderBy: OrderByInput): [User] } @@ -999,7 +1062,11 @@ func TestBuildJsonSchema(t *testing.T) { t.Run("mutually recursive types", func(t *testing.T) { // Define schema with mutually recursive input types - schemaSDL := ` + schemaSDL := scalarDefinitions + ` + schema { + query: Query + } + type Query { processA(a: TypeA): Boolean } @@ -1091,7 +1158,11 @@ func TestBuildJsonSchema(t *testing.T) { 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 := ` + schemaSDL := scalarDefinitions + ` + schema { + query: Query + } + type Query { findUser(input: UserInput): User } @@ -1245,7 +1316,11 @@ func TestBuildJsonSchema(t *testing.T) { t.Run("root schema nullable based on required arguments", func(t *testing.T) { // Define schema with required and optional arguments - schemaSDL := ` + schemaSDL := scalarDefinitions + ` + schema { + query: Query + } + type Query { findUser(id: ID!, name: String): User } @@ -1339,7 +1414,11 @@ func TestBuildJsonSchema(t *testing.T) { t.Run("top-level object fields are not nullable", func(t *testing.T) { // Define schema - schemaSDL := ` + schemaSDL := scalarDefinitions + ` + schema { + query: Query + } + type Query { findEmployees(criteria: SearchInput): [Employee] } @@ -1420,7 +1499,11 @@ func TestBuildJsonSchema(t *testing.T) { t.Run("custom scalar types are represented as objects", func(t *testing.T) { // Define schema with custom scalar types - schemaSDL := ` + schemaSDL := scalarDefinitions + ` + schema { + query: Query + } + """ISO-8601 date time format""" scalar DateTime From b2e117fc7e33b411f4e547cdea11ae0e9e5fcf38 Mon Sep 17 00:00:00 2001 From: Dustin Deus Date: Thu, 17 Apr 2025 17:13:29 +0200 Subject: [PATCH 13/16] Update v2/pkg/engine/jsonschema/variables_schema.go Co-authored-by: Ludwig --- v2/pkg/engine/jsonschema/variables_schema.go | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/v2/pkg/engine/jsonschema/variables_schema.go b/v2/pkg/engine/jsonschema/variables_schema.go index e01cdf8409..a805a27893 100644 --- a/v2/pkg/engine/jsonschema/variables_schema.go +++ b/v2/pkg/engine/jsonschema/variables_schema.go @@ -49,7 +49,9 @@ func (v *VariablesSchemaBuilder) EnterDocument(operation, definition *ast.Docume // Extract descriptions from root fields var descriptions []string - if len(operation.OperationDefinitions) > 0 { + if len(operation.OperationDefinitions) == 0 { + return + } operationDefinition := operation.OperationDefinitions[0] // Process SelectionSet to extract field descriptions From 9d4f78cdd491742ef0284948d883a8799dd25597 Mon Sep 17 00:00:00 2001 From: StarpTech Date: Thu, 17 Apr 2025 17:53:21 +0200 Subject: [PATCH 14/16] chore: avoid defer in walker --- v2/pkg/engine/jsonschema/variables_schema.go | 43 ++++++++++++-------- 1 file changed, 25 insertions(+), 18 deletions(-) diff --git a/v2/pkg/engine/jsonschema/variables_schema.go b/v2/pkg/engine/jsonschema/variables_schema.go index a805a27893..5053c29973 100644 --- a/v2/pkg/engine/jsonschema/variables_schema.go +++ b/v2/pkg/engine/jsonschema/variables_schema.go @@ -223,7 +223,7 @@ func (v *VariablesSchemaBuilder) processOperationTypeRef(typeRef int) *JsonSchem func (v *VariablesSchemaBuilder) processTypeByName(typeName string) *JsonSchema { // Handle built-in scalars switch typeName { - case "String": + case "String", "ID": return NewStringSchema() case "Int": return NewIntegerSchema() @@ -231,8 +231,6 @@ func (v *VariablesSchemaBuilder) processTypeByName(typeName string) *JsonSchema return NewNumberSchema() case "Boolean": return NewBooleanSchema() - case "ID": - return NewStringSchema() } // For custom types, look up in the definition document @@ -242,6 +240,8 @@ 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] @@ -249,6 +249,7 @@ func (v *VariablesSchemaBuilder) processTypeByName(typeName string) *JsonSchema // 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 { @@ -257,39 +258,45 @@ func (v *VariablesSchemaBuilder) processTypeByName(typeName string) *JsonSchema } else { // First time seeing this type v.recursionTracker[typeName] = 1 + shouldCleanupTracker = true } - - // Defer the cleanup of the recursion tracker - defer func() { - if depth, ok := v.recursionTracker[typeName]; ok && depth > 1 { - v.recursionTracker[typeName]-- - } else { - delete(v.recursionTracker, typeName) - } - }() } + // Process the type based on its kind + var schema *JsonSchema switch node.Kind { case ast.NodeKindEnumTypeDefinition: - return v.processEnumType(node) + schema = v.processEnumType(node) case ast.NodeKindInputObjectTypeDefinition: - return v.processInputObjectType(node) + schema = 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 - return NewAnySchema() + 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 From c95cb111c0ee6538c11d4ca1c6948b8d78aa17a9 Mon Sep 17 00:00:00 2001 From: StarpTech Date: Thu, 17 Apr 2025 18:13:00 +0200 Subject: [PATCH 15/16] chore: fix merge issue --- v2/pkg/engine/jsonschema/variables_schema.go | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/v2/pkg/engine/jsonschema/variables_schema.go b/v2/pkg/engine/jsonschema/variables_schema.go index 5053c29973..dec087ff69 100644 --- a/v2/pkg/engine/jsonschema/variables_schema.go +++ b/v2/pkg/engine/jsonschema/variables_schema.go @@ -49,9 +49,7 @@ func (v *VariablesSchemaBuilder) EnterDocument(operation, definition *ast.Docume // Extract descriptions from root fields var descriptions []string - if len(operation.OperationDefinitions) == 0 { - return - } + if len(operation.OperationDefinitions) > 0 { operationDefinition := operation.OperationDefinitions[0] // Process SelectionSet to extract field descriptions From 705a07b20e6b870cd03c679ff499dee25a635c90 Mon Sep 17 00:00:00 2001 From: StarpTech Date: Thu, 17 Apr 2025 18:32:57 +0200 Subject: [PATCH 16/16] chore: early return --- v2/pkg/engine/jsonschema/variables_schema.go | 83 ++++++++++---------- 1 file changed, 43 insertions(+), 40 deletions(-) diff --git a/v2/pkg/engine/jsonschema/variables_schema.go b/v2/pkg/engine/jsonschema/variables_schema.go index dec087ff69..e70420e4ee 100644 --- a/v2/pkg/engine/jsonschema/variables_schema.go +++ b/v2/pkg/engine/jsonschema/variables_schema.go @@ -44,53 +44,56 @@ func NewVariablesSchemaBuilderWithOptions(operationDocument, definitionDocument // 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 - if len(operation.OperationDefinitions) > 0 { - 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 + 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 } } }