Skip to content
Closed
53 changes: 52 additions & 1 deletion v2/pkg/engine/jsonschema/schema.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"`
Expand Down Expand Up @@ -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
}
43 changes: 43 additions & 0 deletions v2/pkg/engine/jsonschema/schema_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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())
})
}
62 changes: 56 additions & 6 deletions v2/pkg/engine/jsonschema/variables_schema.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)
}
Expand Down Expand Up @@ -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()
}
Loading
Loading