From 77df15d4cb877d32460fd7707754af06b3e09f3c Mon Sep 17 00:00:00 2001 From: John Starich Date: Wed, 26 Jun 2024 23:03:53 -0500 Subject: [PATCH 1/2] Add failing test for directive default value --- introspection_test.go | 44 +++++- testdata/introspect_default_values.json | 200 ++++++++++++++++++++++++ 2 files changed, 243 insertions(+), 1 deletion(-) create mode 100644 testdata/introspect_default_values.json diff --git a/introspection_test.go b/introspection_test.go index 8d2e11e..c830e89 100644 --- a/introspection_test.go +++ b/introspection_test.go @@ -3,6 +3,7 @@ package graphql import ( "bytes" "context" + _ "embed" "encoding/json" "errors" "fmt" @@ -1033,7 +1034,6 @@ func TestIntrospectWithHTTPClient(t *testing.T) { opt := IntrospectWithHTTPClient(row.Client) queryer = opt.Apply(queryer).(*SingleRequestQueryer) assert.Equal(t, row.Client, queryer.queryer.Client) - }) } } @@ -1518,3 +1518,45 @@ query { `) assert.Nil(t, err, "Spreading object fragment on matching interface should be allowed") } + +//go:embed testdata/introspect_default_values.json +var introspectionDefaultValuesJSON string + +// TestIntrospectDirectivesDefaultValue verifies fix for https://github.com/nautilus/graphql/issues/40 +func TestIntrospectDirectivesDefaultValue(t *testing.T) { + t.Parallel() + + schema, err := IntrospectAPI(&mockJSONQueryer{ + JSONResult: introspectionDefaultValuesJSON, + }) + require.NoError(t, err) + + var schemaBuffer bytes.Buffer + formatter.NewFormatter(&schemaBuffer).FormatSchema(schema) + + expectedSchema, err := gqlparser.LoadSchema(&ast.Source{Input: ` +directive @hello( + foo: String! = "foo" + bar: Int = 1 + baz: Boolean = true + biff: Float = 1.23 + boo: [String] = ["boo"] + bah: HelloInput = {humbug: "humbug"} + blah: HelloEnum = HELLO_1 +) on FIELD_DEFINITION + +input HelloInput { + humbug: String +} + +enum HelloEnum { + HELLO_1 + HELLO_2 + HELLO_3 +} + `}) + require.NoError(t, err) + var expectedBuffer bytes.Buffer + formatter.NewFormatter(&expectedBuffer).FormatSchema(expectedSchema) + assert.Equal(t, expectedBuffer.String(), schemaBuffer.String()) +} diff --git a/testdata/introspect_default_values.json b/testdata/introspect_default_values.json new file mode 100644 index 0000000..41835a2 --- /dev/null +++ b/testdata/introspect_default_values.json @@ -0,0 +1,200 @@ +{ + "__schema": { + "queryType": { + "name": "Query" + }, + "mutationType": null, + "subscriptionType": null, + "types": [ + { + "kind": "SCALAR", + "name": "Boolean", + "description": "The `Boolean` scalar type represents `true` or `false`.", + "fields": null, + "inputFields": null, + "interfaces": null, + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "SCALAR", + "name": "Float", + "description": "The `Float` scalar type represents signed double-precision fractional values as specified by [IEEE 754](http://en.wikipedia.org/wiki/IEEE_floating_point).", + "fields": null, + "inputFields": null, + "interfaces": null, + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "ENUM", + "name": "HelloEnum", + "description": null, + "fields": null, + "inputFields": null, + "interfaces": null, + "enumValues": [ + { + "name": "HELLO_1", + "description": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "HELLO_2", + "description": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "HELLO_3", + "description": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "possibleTypes": null + }, + { + "kind": "INPUT_OBJECT", + "name": "HelloInput", + "description": null, + "fields": null, + "inputFields": [ + { + "name": "humbug", + "description": null, + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null + } + ], + "interfaces": null, + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "SCALAR", + "name": "ID", + "description": "The `ID` scalar type represents a unique identifier, often used to refetch an object or as key for a cache. The ID type appears in a JSON response as a String; however, it is not intended to be human-readable. When expected as an input type, any string (such as `\"4\"`) or integer (such as `4`) input value will be accepted as an ID.", + "fields": null, + "inputFields": null, + "interfaces": null, + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "SCALAR", + "name": "Int", + "description": "The `Int` scalar type represents non-fractional signed whole numeric values. Int can represent values between -(2^31) and 2^31 - 1.", + "fields": null, + "inputFields": null, + "interfaces": null, + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "SCALAR", + "name": "String", + "description": "The `String` scalar type represents textual data, represented as UTF-8 character sequences. The String type is most often used by GraphQL to represent free-form human-readable text.", + "fields": null, + "inputFields": null, + "interfaces": null, + "enumValues": null, + "possibleTypes": null + } + ], + "directives": [ + { + "name": "hello", + "description": null, + "locations": [ + "FIELD_DEFINITION" + ], + "args": [ + { + "name": "foo", + "description": null, + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "defaultValue": "\"foo\"" + }, + { + "name": "bar", + "description": null, + "type": { + "kind": "SCALAR", + "name": "Int", + "ofType": null + }, + "defaultValue": "1" + }, + { + "name": "baz", + "description": null, + "type": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + }, + "defaultValue": "true" + }, + { + "name": "biff", + "description": null, + "type": { + "kind": "SCALAR", + "name": "Float", + "ofType": null + }, + "defaultValue": "1.23" + }, + { + "name": "boo", + "description": null, + "type": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "defaultValue": "[\"boo\"]" + }, + { + "name": "bah", + "description": null, + "type": { + "kind": "INPUT_OBJECT", + "name": "HelloInput", + "ofType": null + }, + "defaultValue": "{humbug: \"humbug\"}" + }, + { + "name": "blah", + "description": null, + "type": { + "kind": "ENUM", + "name": "HelloEnum", + "ofType": null + }, + "defaultValue": "HELLO_1" + } + ] + } + ] + } +} From 6540b44f95da5b0e47fb4ed2cb156bc211cd11b1 Mon Sep 17 00:00:00 2001 From: John Starich Date: Fri, 28 Jun 2024 01:02:43 -0500 Subject: [PATCH 2/2] Fix missing argument default values --- introspection.go | 53 ++++++++++++++++++++++++++++++++++++++++-------- 1 file changed, 44 insertions(+), 9 deletions(-) diff --git a/introspection.go b/introspection.go index 7ca2bb3..c09b8a4 100644 --- a/introspection.go +++ b/introspection.go @@ -6,6 +6,7 @@ import ( "net/http" "github.com/pkg/errors" + "github.com/vektah/gqlparser/v2" "github.com/vektah/gqlparser/v2/ast" ) @@ -91,7 +92,6 @@ func IntrospectWithRetrier(retrier Retrier) *IntrospectOptions { // IntrospectRemoteSchema is used to build a RemoteSchema by firing the introspection query // at a remote service and reconstructing the schema object from the response func IntrospectRemoteSchema(url string, opts ...*IntrospectOptions) (*RemoteSchema, error) { - // introspect the schema at the designated url schema, err := IntrospectAPI(NewSingleRequestQueryer(url), opts...) if err != nil { @@ -244,7 +244,6 @@ func IntrospectAPI(queryer Queryer, opts ...*IntrospectOptions) (*ast.Schema, er } if len(remoteType.Interfaces) > 0 { - // each interface value needs to be added to the list for _, iFace := range remoteType.Interfaces { // if there is no name @@ -271,11 +270,15 @@ func IntrospectAPI(queryer Queryer, opts ...*IntrospectOptions) (*ast.Schema, er for _, field := range remoteType.Fields { // add the field to the list + args, err := introspectionConvertArgList(field.Args) + if err != nil { + return nil, err + } fields = append(fields, &ast.FieldDefinition{ Name: field.Name, Type: introspectionUnmarshalTypeRef(&field.Type), Description: field.Description, - Arguments: introspectionConvertArgList(field.Args), + Arguments: args, }) } @@ -306,11 +309,15 @@ func IntrospectAPI(queryer Queryer, opts ...*IntrospectOptions) (*ast.Schema, er } // save the directive definition to the schema + args, err := introspectionConvertArgList(directive.Args) + if err != nil { + return nil, err + } schema.Directives[directive.Name] = &ast.DirectiveDefinition{ Position: &ast.Position{Src: &ast.Source{}}, Name: directive.Name, Description: directive.Description, - Arguments: introspectionConvertArgList(directive.Args), + Arguments: args, Locations: locations, } switch directive.Name { @@ -332,19 +339,24 @@ func addPossibleTypeOnce(schema *ast.Schema, name string, definition *ast.Defini schema.AddPossibleType(name, definition) } -func introspectionConvertArgList(args []IntrospectionInputValue) ast.ArgumentDefinitionList { +func introspectionConvertArgList(args []IntrospectionInputValue) (ast.ArgumentDefinitionList, error) { result := ast.ArgumentDefinitionList{} // we need to add each argument to the field for _, argument := range args { + defaultValue, err := introspectionUnmarshalArgumentDefaultValue(argument) + if err != nil { + return nil, err + } result = append(result, &ast.ArgumentDefinition{ - Name: argument.Name, - Description: argument.Description, - Type: introspectionUnmarshalTypeRef(&argument.Type), + Name: argument.Name, + Description: argument.Description, + Type: introspectionUnmarshalTypeRef(&argument.Type), + DefaultValue: defaultValue, }) } - return result + return result, nil } func introspectionUnmarshalType(schemaType IntrospectionQueryFullType) *ast.Definition { @@ -387,6 +399,29 @@ func introspectionUnmarshalType(schemaType IntrospectionQueryFullType) *ast.Defi return definition } +// introspectionUnmarshalArgumentDefaultValue returns the *ast.Value form of an argument's default value. +// +// The tricky part here is the default value comes in as a string, so it's non-trivial to unmarshal. +// This takes advantage of gqlparser's loose validation when parsing an argument's default value even if the type is wrong (to avoid including full definitions of custom types). +// This validation will likely become stricter with time -- hopefully the library will provide some additional tools to parse the default value separately. +func introspectionUnmarshalArgumentDefaultValue(argument IntrospectionInputValue) (*ast.Value, error) { + if argument.DefaultValue == "" { + return nil, nil + } + const inputValueQuery = ` +type Query { + field(input: String = %s): String +} +` + schema, err := gqlparser.LoadSchema(&ast.Source{ + Input: fmt.Sprintf(inputValueQuery, argument.DefaultValue), + }) + if err != nil { + return nil, err + } + return schema.Query.Fields.ForName("field").Arguments.ForName("input").DefaultValue, nil +} + // a mapping of marshaled directive locations to their parsed equivalent var directiveLocationMap map[string]ast.DirectiveLocation