diff --git a/v2/pkg/engine/jsonschema/schema.go b/v2/pkg/engine/jsonschema/schema.go index 6481edc142..9303dc45f1 100644 --- a/v2/pkg/engine/jsonschema/schema.go +++ b/v2/pkg/engine/jsonschema/schema.go @@ -17,7 +17,8 @@ const ( TypeNull SchemaType = "null" ) -// JsonSchema represents a JSON Schema definition +// JsonSchema represents a JSON Schema definition. +// When adding a reference-typed field (map, slice, pointer), update Clone or it will alias. type JsonSchema struct { // Core schema fields Type SchemaType `json:"type,omitempty"` @@ -255,3 +256,53 @@ func (s *JsonSchema) WithNullable(nullable bool) *JsonSchema { s.Nullable = nullable return s } + +// Clone returns a deep copy of the schema. Callers that hand out schemas from +// a shared map (e.g. scalar overrides) must clone per use: nullability belongs +// to each usage site, and the builder records it by mutating Nullable on the +// schema it returns. Without a per-use copy, two variables of the same mapped +// scalar alias one object and the last-processed variable's nullability +// overwrites the first (guarded by the "same overridden scalar at two +// nullabilities" regression test). +// This is a hand-written copy on purpose: a marshal/unmarshal round-trip would +// silently drop Nullable (tagged json:"-"), and a shallow struct copy would +// still share Properties/Items/Defs. +// Clone returns nil if s is nil. +func (s *JsonSchema) Clone() *JsonSchema { + if s == nil { + return nil + } + clone := *s + if s.Properties != nil { + clone.Properties = make(map[string]*JsonSchema, len(s.Properties)) + for k, v := range s.Properties { + clone.Properties[k] = v.Clone() + } + } + if s.Required != nil { + clone.Required = append([]string(nil), s.Required...) + } + if s.AdditionalProperties != nil { + val := *s.AdditionalProperties + clone.AdditionalProperties = &val + } + if s.Defs != nil { + clone.Defs = make(map[string]*JsonSchema, len(s.Defs)) + for k, v := range s.Defs { + clone.Defs[k] = v.Clone() + } + } + clone.Items = s.Items.Clone() + if s.Enum != nil { + clone.Enum = append([]string(nil), s.Enum...) + } + if s.Minimum != nil { + val := *s.Minimum + clone.Minimum = &val + } + if s.Maximum != nil { + val := *s.Maximum + clone.Maximum = &val + } + return &clone +} diff --git a/v2/pkg/engine/jsonschema/schema_test.go b/v2/pkg/engine/jsonschema/schema_test.go index af48a4e205..0cbc265ea4 100644 --- a/v2/pkg/engine/jsonschema/schema_test.go +++ b/v2/pkg/engine/jsonschema/schema_test.go @@ -616,3 +616,46 @@ func TestSchemaFeatures(t *testing.T) { assert.False(t, schema.Nullable) }) } + +func TestJsonSchemaClone(t *testing.T) { + t.Run("mutating the clone does not affect the original", func(t *testing.T) { + additionalProps := false + minimum := 1.0 + original := &JsonSchema{ + Type: TypeObject, + Properties: map[string]*JsonSchema{"name": NewStringSchema()}, + Required: []string{"name"}, + AdditionalProperties: &additionalProps, + Description: "original", + Nullable: true, + Items: NewStringSchema(), + Enum: []string{"a", "b"}, + Minimum: &minimum, + } + + clone := original.Clone() + + clone.Nullable = false + clone.Description = "mutated" + clone.Properties["name"].Type = TypeInteger + clone.Required[0] = "changed" + *clone.AdditionalProperties = true + clone.Items.Type = TypeBoolean + clone.Enum[0] = "z" + *clone.Minimum = 99 + + assert.True(t, original.Nullable) + assert.Equal(t, "original", original.Description) + assert.Equal(t, TypeString, original.Properties["name"].Type) + assert.Equal(t, "name", original.Required[0]) + assert.False(t, *original.AdditionalProperties) + assert.Equal(t, TypeString, original.Items.Type) + assert.Equal(t, "a", original.Enum[0]) + assert.Equal(t, 1.0, *original.Minimum) + }) + + t.Run("nil receiver returns nil", func(t *testing.T) { + var s *JsonSchema + assert.Nil(t, s.Clone()) + }) +} diff --git a/v2/pkg/engine/jsonschema/variables_schema.go b/v2/pkg/engine/jsonschema/variables_schema.go index 2944e4ec0e..30d63bad09 100644 --- a/v2/pkg/engine/jsonschema/variables_schema.go +++ b/v2/pkg/engine/jsonschema/variables_schema.go @@ -2,6 +2,7 @@ package jsonschema import ( "fmt" + "sort" "github.com/wundergraph/graphql-go-tools/v2/pkg/ast" "github.com/wundergraph/graphql-go-tools/v2/pkg/astvisitor" @@ -21,6 +22,23 @@ type VariablesSchemaBuilder struct { // defs accumulates schemas for recursive input types; attached to the root // schema as "$defs". defs map[string]*JsonSchema + // scalarSchemas overrides the schema emitted per custom scalar type name. + scalarSchemas map[string]*JsonSchema + // defaultedScalars records custom scalars that fell back to the string + // default, so callers can surface missing mappings. + defaultedScalars map[string]bool +} + +// VariablesSchemaOption configures a VariablesSchemaBuilder. +type VariablesSchemaOption func(*VariablesSchemaBuilder) + +// WithScalarSchemas overrides the JSON schema emitted for custom scalar types, +// keyed by scalar type name. Unmapped custom scalars default to "string". +// Built-in scalars (String, ID, Int, Float, Boolean) cannot be overridden. +func WithScalarSchemas(schemas map[string]*JsonSchema) VariablesSchemaOption { + return func(v *VariablesSchemaBuilder) { + v.scalarSchemas = schemas + } } // Ensure VariablesSchemaBuilder implements the necessary astvisitor interfaces @@ -30,15 +48,22 @@ var ( ) // NewVariablesSchemaBuilder creates a new VariablesSchemaBuilder. -func NewVariablesSchemaBuilder(operationDocument, definitionDocument *ast.Document) *VariablesSchemaBuilder { - return &VariablesSchemaBuilder{ +func NewVariablesSchemaBuilder(operationDocument, definitionDocument *ast.Document, opts ...VariablesSchemaOption) *VariablesSchemaBuilder { + v := &VariablesSchemaBuilder{ operationDocument: operationDocument, definitionDocument: definitionDocument, schema: NewObjectSchema(), report: &operationreport.Report{}, recursiveTypes: make(map[string]bool), defs: make(map[string]*JsonSchema), + defaultedScalars: make(map[string]bool), + } + + for _, opt := range opts { + opt(v) } + + return v } // EnterDocument implements the astvisitor.EnterDocumentVisitor interface @@ -49,6 +74,7 @@ func (v *VariablesSchemaBuilder) EnterDocument(operation, definition *ast.Docume v.schema = NewObjectSchema() v.defs = make(map[string]*JsonSchema) // Reset defs for each build + v.defaultedScalars = make(map[string]bool) // Reset defaulted scalars for each build v.recursiveTypes = v.computeRecursiveInputTypes() // Identify recursive input types // Extract descriptions from root fields @@ -174,6 +200,17 @@ func (v *VariablesSchemaBuilder) GetReport() *operationreport.Report { return v.report } +// DefaultedScalars returns the sorted names of custom scalars that fell back +// to the default "string" schema during the last build. +func (v *VariablesSchemaBuilder) DefaultedScalars() []string { + names := make([]string, 0, len(v.defaultedScalars)) + for name := range v.defaultedScalars { + names = append(names, name) + } + sort.Strings(names) + return names +} + // Build traverses the operation and builds a unified JSON schema for its variables func (v *VariablesSchemaBuilder) Build() (*JsonSchema, error) { // Create a new walker for AST traversal @@ -267,8 +304,21 @@ func (v *VariablesSchemaBuilder) processTypeByName(typeName string) *JsonSchema return v.processInputObjectType(node) case ast.NodeKindScalarTypeDefinition: - schema := NewAnySchema() - // Add description if available + if override, ok := v.scalarSchemas[typeName]; ok { + // Clone per use: the builder mutates Nullable on returned schemas + // depending on each variable's non-null context. + schema := override.Clone() + if schema.Description == "" && v.definitionDocument.ScalarTypeDefinitions[node.Ref].Description.IsDefined { + schema.Description = v.definitionDocument.ScalarTypeDefinitionDescriptionString(node.Ref) + } + return schema + } + // Custom scalars are opaque to JSON Schema. Emit a best-effort "string" + // type: MCP/LLM tool consumers reject or degrade on untyped properties, + // and opaque scalars are overwhelmingly strings on the wire. Callers can + // override per scalar via WithScalarSchemas. + v.defaultedScalars[typeName] = true + schema := NewStringSchema() if v.definitionDocument.ScalarTypeDefinitions[node.Ref].Description.IsDefined { schema.Description = v.definitionDocument.ScalarTypeDefinitionDescriptionString(node.Ref) } @@ -534,10 +584,10 @@ func (v *VariablesSchemaBuilder) convertDefinitionValueToNative(value ast.Value) // BuildJsonSchema builds a JSON schema for the variables of the given operation. // Recursive input types are represented via "$ref"/"$defs" and support arbitrary // nesting depth. -func BuildJsonSchema(operationDocument, definitionDocument *ast.Document) (*JsonSchema, error) { +func BuildJsonSchema(operationDocument, definitionDocument *ast.Document, opts ...VariablesSchemaOption) (*JsonSchema, error) { if len(operationDocument.OperationDefinitions) == 0 { return nil, fmt.Errorf("no operations found in document") } - return NewVariablesSchemaBuilder(operationDocument, definitionDocument).Build() + return NewVariablesSchemaBuilder(operationDocument, definitionDocument, opts...).Build() } diff --git a/v2/pkg/engine/jsonschema/variables_schema_test.go b/v2/pkg/engine/jsonschema/variables_schema_test.go index 57ef88a6fa..c465a19763 100644 --- a/v2/pkg/engine/jsonschema/variables_schema_test.go +++ b/v2/pkg/engine/jsonschema/variables_schema_test.go @@ -1554,7 +1554,7 @@ func TestBuildJsonSchema(t *testing.T) { 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) { + t.Run("custom scalar variables with descriptions default to string type", func(t *testing.T) { // Define schema with custom scalar types schemaSDL := scalarDefinitions + ` schema { @@ -1609,10 +1609,12 @@ func TestBuildJsonSchema(t *testing.T) { "additionalProperties": false, "properties": { "filter": { - "description": "JSON object represented as string" + "description": "JSON object represented as string", + "type": ["string", "null"] }, "from": { - "description": "ISO-8601 date time format" + "description": "ISO-8601 date time format", + "type": ["string", "null"] } }, "type": "object" @@ -1681,4 +1683,283 @@ func TestBuildJsonSchema(t *testing.T) { assert.JSONEq(t, expectedJSON, string(data)) }) + + t.Run("query with custom scalar variables defaults to string type", func(t *testing.T) { + schemaSDL := scalarDefinitions + ` + schema { + query: Query + } + + """An opaque pagination cursor""" + scalar Cursor + + type Query { + items(after: Cursor, first: Int!): String + } + ` + operation := ` + query Items($after: Cursor, $first: Int!) { + items(after: $after, first: $first) + } + ` + definitionDoc, report := astparser.ParseGraphqlDocumentString(schemaSDL) + require.False(t, report.HasErrors(), "schema parsing failed") + + operationDoc, report := astparser.ParseGraphqlDocumentString(operation) + require.False(t, report.HasErrors(), "operation parsing failed") + + schema, err := BuildJsonSchema(&operationDoc, &definitionDoc) + require.NoError(t, err) + + actualJSON, err := json.Marshal(schema) + require.NoError(t, err) + + expectedJSON := `{ + "additionalProperties": false, + "properties": { + "after": { + "description": "An opaque pagination cursor", + "type": [ + "string", + "null" + ] + }, + "first": { + "type": "integer" + } + }, + "required": [ + "first" + ], + "type": "object" +}` + + assert.JSONEq(t, expectedJSON, string(actualJSON)) + }) + + t.Run("input object field with custom scalar defaults to string type", func(t *testing.T) { + schemaSDL := scalarDefinitions + ` + schema { + query: Query + } + + scalar Cursor + + input Pagination { + after: Cursor + first: Int! + } + + type Query { + items(page: Pagination!): String + } + ` + operation := ` + query Items($page: Pagination!) { + items(page: $page) + } + ` + definitionDoc, report := astparser.ParseGraphqlDocumentString(schemaSDL) + require.False(t, report.HasErrors(), "schema parsing failed") + + operationDoc, report := astparser.ParseGraphqlDocumentString(operation) + require.False(t, report.HasErrors(), "operation parsing failed") + + schema, err := BuildJsonSchema(&operationDoc, &definitionDoc) + require.NoError(t, err) + + actualJSON, err := json.Marshal(schema) + require.NoError(t, err) + + expectedJSON := `{ + "additionalProperties": false, + "properties": { + "page": { + "additionalProperties": false, + "properties": { + "after": { + "type": [ + "string", + "null" + ] + }, + "first": { + "type": "integer" + } + }, + "required": [ + "first" + ], + "type": "object" + } + }, + "required": [ + "page" + ], + "type": "object" +}` + + assert.JSONEq(t, expectedJSON, string(actualJSON)) + }) + + t.Run("overridden scalar emits the mapped schema and unmapped scalars keep the string default", func(t *testing.T) { + schemaSDL := scalarDefinitions + ` + schema { + query: Query + } + + scalar JSON + scalar Cursor + + type Query { + search(filter: JSON!, after: Cursor, cursor: Cursor!) : String + } + ` + operation := ` + query Search($filter: JSON!, $after: Cursor, $cursor: Cursor!) { + search(filter: $filter, after: $after, cursor: $cursor) + } + ` + definitionDoc, report := astparser.ParseGraphqlDocumentString(schemaSDL) + require.False(t, report.HasErrors(), "schema parsing failed") + + operationDoc, report := astparser.ParseGraphqlDocumentString(operation) + require.False(t, report.HasErrors(), "operation parsing failed") + + overrides := map[string]*JsonSchema{ + "JSON": {Type: TypeObject, Description: "Arbitrary JSON object"}, + } + + schema, err := BuildJsonSchema(&operationDoc, &definitionDoc, WithScalarSchemas(overrides)) + require.NoError(t, err) + + actualJSON, err := json.Marshal(schema) + require.NoError(t, err) + + // - JSON! is mapped to object and non-null context strips the null union + // - after/cursor prove per-use cloning: same scalar, different nullability + expectedJSON := `{ + "additionalProperties": false, + "properties": { + "after": { + "type": [ + "string", + "null" + ] + }, + "cursor": { + "type": "string" + }, + "filter": { + "description": "Arbitrary JSON object", + "type": "object" + } + }, + "required": [ + "filter", + "cursor" + ], + "type": "object" +}` + assert.JSONEq(t, expectedJSON, string(actualJSON)) + }) + + // Guards the Clone-per-use invariant: if the override branch stops cloning, + // both variables below alias one *JsonSchema and the last-processed + // variable's nullability overwrites the first, failing this test. + t.Run("same overridden scalar at two nullabilities yields independent schemas", func(t *testing.T) { + // The override type is deliberately non-object: object-typed top-level + // variables are unconditionally forced non-nullable elsewhere (see + // EnterVariableDefinition), which would mask the nullability leak this + // test exists to catch. + schemaSDL := scalarDefinitions + ` + schema { + query: Query + } + + scalar BigInt + + type Query { + search(filter: BigInt!, meta: BigInt) : String + } + ` + operation := ` + query Search($filter: BigInt!, $meta: BigInt) { + search(filter: $filter, meta: $meta) + } + ` + definitionDoc, report := astparser.ParseGraphqlDocumentString(schemaSDL) + require.False(t, report.HasErrors(), "schema parsing failed") + + operationDoc, report := astparser.ParseGraphqlDocumentString(operation) + require.False(t, report.HasErrors(), "operation parsing failed") + + overrides := map[string]*JsonSchema{ + "BigInt": {Type: TypeInteger}, + } + + schema, err := BuildJsonSchema(&operationDoc, &definitionDoc, WithScalarSchemas(overrides)) + require.NoError(t, err) + + actualJSON, err := json.Marshal(schema) + require.NoError(t, err) + + // Both variables resolve the same override map entry. Without cloning + // per use, the second-processed variable's Nullable mutation would leak + // into the first via the shared *JsonSchema pointer. + expectedJSON := `{ + "additionalProperties": false, + "properties": { + "filter": { + "type": "integer" + }, + "meta": { + "type": [ + "integer", + "null" + ] + } + }, + "required": [ + "filter" + ], + "type": "object" +}` + assert.JSONEq(t, expectedJSON, string(actualJSON)) + }) + + t.Run("DefaultedScalars reports unmapped custom scalars once, sorted", func(t *testing.T) { + schemaSDL := scalarDefinitions + ` + schema { + query: Query + } + + scalar JSON + scalar Cursor + scalar BigInt + + type Query { + search(filter: JSON!, after: Cursor, before: Cursor, size: BigInt) : String + } + ` + operation := ` + query Search($filter: JSON!, $after: Cursor, $before: Cursor, $size: BigInt) { + search(filter: $filter, after: $after, before: $before, size: $size) + } + ` + definitionDoc, report := astparser.ParseGraphqlDocumentString(schemaSDL) + require.False(t, report.HasErrors(), "schema parsing failed") + + operationDoc, report := astparser.ParseGraphqlDocumentString(operation) + require.False(t, report.HasErrors(), "operation parsing failed") + + builder := NewVariablesSchemaBuilder(&operationDoc, &definitionDoc, WithScalarSchemas(map[string]*JsonSchema{ + "JSON": {Type: TypeObject}, + })) + _, err := builder.Build() + require.NoError(t, err) + + // JSON is mapped -> not reported; Cursor used twice -> reported once + assert.Equal(t, []string{"BigInt", "Cursor"}, builder.DefaultedScalars()) + }) }