Skip to content

Commit

Permalink
graphql: permit field method arguments to be structs
Browse files Browse the repository at this point in the history
This uses the same logic as for input objects, now exported as
`ConvertValueMap` so it may be used in `FieldResolver`s.

Other than basic type checks, the arguments are not structurally checked
like field return types are. However, like with the field return type
checks, these can be incrementally added without changing API surface.
I've opened #33 to track the validation.

Fixes #18
  • Loading branch information
zombiezen committed Dec 24, 2019
1 parent bdccfc2 commit 2f4f8d7
Show file tree
Hide file tree
Showing 5 changed files with 187 additions and 69 deletions.
7 changes: 6 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,20 +14,25 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- `SelectionSet` has a new method `HasAny` to check for multiple fields at
once. ([#28][])
- `Value` has a new method `Convert` that converts GraphQL values into Go
values.
values. A new function `ConvertValueMap` converts a `map[string]graphql.Value`
into a Go struct.

[#28]: https://github.com/zombiezen/graphql-server/issues/28
[graphql-go-app]: https://github.com/zombiezen/graphql-go-app

### Changed

- Field methods may now use custom structs in place of `map[string]graphql.Value`
to read arguments. `Server` will automatically call `ConvertValueMap` to
populate the struct before the field method is called. ([#18][])
- `*SelectionSet.Has` and `*SelectionSet.OnlyUses` now permit dotted field
syntax to more conveniently check nested fields. ([#31][])
- If an object implements the new `FieldResolver` interface, then the
`ResolveField` will be called to dispatch all field executions. This makes it
easier to implement stub types or integrate custom data sources with the
server. ([#30][])

[#18]: https://github.com/zombiezen/graphql-server/issues/18
[#30]: https://github.com/zombiezen/graphql-server/issues/30
[#31]: https://github.com/zombiezen/graphql-server/issues/31

Expand Down
100 changes: 72 additions & 28 deletions graphql/convert.go
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,10 @@ import (
func (v Value) Convert(dst interface{}) error {
dstValue := reflect.ValueOf(dst)
if dstValue.Kind() != reflect.Ptr {
return xerrors.Errorf("convert GraphQL value: argument not a pointer")
return xerrors.Errorf("convert GraphQL value: argument not a pointer (got %v)", dstValue.Type())
}
if dstValue.IsNil() {
return xerrors.Errorf("convert GraphQL value: argument is nil")
}
if err := v.convert(dstValue.Elem()); err != nil {
return xerrors.Errorf("convert GraphQL value: %w", err)
Expand Down Expand Up @@ -81,9 +84,6 @@ func (v Value) convert(dst reflect.Value) error {
goType = elemType
kind = elemType.Kind()
}
convertErr := func() error {
return xerrors.Errorf("cannot assign value of type %v to Go type %v", v.typ, goType)
}
switch val := v.val.(type) {
case string:
if u, ok := interfaceValueForAssertions(dst).(encoding.TextUnmarshaler); ok {
Expand All @@ -94,7 +94,7 @@ func (v Value) convert(dst reflect.Value) error {
return nil
}
if v.typ.isEnum() {
return convertErr()
return newConversionError(goType, v.typ)
}
valueErr := func() error {
return xerrors.Errorf("cannot convert %q to %v", val, goType)
Expand Down Expand Up @@ -122,11 +122,11 @@ func (v Value) convert(dst reflect.Value) error {
return valueErr()
}
default:
return convertErr()
return newConversionError(goType, v.typ)
}
case []Value:
if kind != reflect.Slice {
return convertErr()
return newConversionError(goType, v.typ)
}
dst.Set(reflect.MakeSlice(goType, 0, len(val)))
for dst.Len() < len(val) {
Expand All @@ -147,44 +147,84 @@ func (v Value) convert(dst reflect.Value) error {
return nil
}
if kind != reflect.Struct {
return convertErr()
return newConversionError(goType, v.typ)
}
for _, f := range val {
fieldIndex, err := findConvertField(goType, f.Key)
if err != nil {
return err
}
if err := f.Value.convert(dst.Field(fieldIndex)); err != nil {
return xerrors.Errorf("field %s: %w", f.Key)
return xerrors.Errorf("field %s: %w", f.Key, err)
}
}
case map[string]Value:
if goType == valueMapGoType {
m := make(map[string]Value, len(val))
for k, v := range val {
m[k] = v
}
dst.Set(reflect.ValueOf(m))
return nil
}
if kind != reflect.Struct {
return convertErr()
}
for k, v := range val {
fieldIndex, err := findConvertField(goType, k)
if err != nil {
return err
}
if err := v.convert(dst.Field(fieldIndex)); err != nil {
return xerrors.Errorf("field %s: %w", k)
}
if err := convertValueMap(dst, val, v.typ); err != nil {
return err
}
default:
panic("unknown type in Value")
}
return nil
}

// ConvertValueMap converts a map of GraphQL values into a Go value.
// dst must be a non-nil pointer to a struct with matching fields or a
// map[string]graphql.Value.
//
// During conversion to a struct, the values in the map will be converted
// (as if by Convert) into the struct field with the same name, ignoring case.
// An error will be returned if a key in the map does not match exactly one
// field in the Go struct.
//
// Conversion to a map[string]graphql.Value will simply copy the map.
func ConvertValueMap(dst interface{}, m map[string]Value) error {
dstValue := reflect.ValueOf(dst)
if dstValue.Kind() != reflect.Ptr {
return xerrors.Errorf("convert GraphQL value map: argument not a pointer (got %v)", dstValue.Type())
}
if dstValue.IsNil() {
return xerrors.Errorf("convert GraphQL value map: argument is nil")
}
if err := convertValueMap(dstValue.Elem(), m, nil); err != nil {
return xerrors.Errorf("convert GraphQL value map: %w", err)
}
return nil
}

func convertValueMap(dst reflect.Value, valMap map[string]Value, graphqlType *gqlType) error {
goType := dst.Type()
if goType == valueMapGoType {
m := make(map[string]Value, len(valMap))
for k, v := range valMap {
m[k] = v
}
dst.Set(reflect.ValueOf(m))
return nil
}
if goType.Kind() != reflect.Struct {
if graphqlType == nil {
return xerrors.Errorf("cannot convert into %v", goType)
}
return newConversionError(goType, graphqlType)
}
for k, v := range valMap {
fieldIndex, err := findConvertField(goType, k)
if err != nil {
return err
}
if err := v.convert(dst.Field(fieldIndex)); err != nil {
return xerrors.Errorf("field %s: %w", k, err)
}
}
return nil
}

func canConvertFromValueMap(t reflect.Type) bool {
k := t.Kind()
return t == valueMapGoType || k == reflect.Struct || (k == reflect.Ptr && t.Elem().Kind() == reflect.Struct)
}

// findConvertField returns the field index of a Go struct that's suitable for
// the given GraphQL field name.
func findConvertField(goType reflect.Type, name string) (int, error) {
Expand All @@ -210,3 +250,7 @@ func findConvertField(goType reflect.Type, name string) (int, error) {
}
return index, nil
}

func newConversionError(goType reflect.Type, graphqlType *gqlType) error {
return xerrors.Errorf("cannot assign value of type %v to Go type %v", graphqlType, goType)
}
16 changes: 9 additions & 7 deletions graphql/doc.go
Original file line number Diff line number Diff line change
Expand Up @@ -32,15 +32,17 @@ Next, the server checks for a method with the same name as the field. Field
methods must have the following signature (with square brackets indicating
optional elements):
func (foo *Foo) Bar([ctx context.Context,] [args map[string]graphql.Value,] [sel *graphql.SelectionSet]) (ResultType[, error])
func (foo *Foo) Bar([ctx context.Context,] [args ArgsType,] [sel *graphql.SelectionSet]) (ResultType[, error])
The ctx parameter will have a Context deriving from the one passed to Execute.
The args parameter will be a map filled with the arguments passed to the field.
The sel parameter is only passed to fields that return an object or list of
objects type and permits the method to peek into what fields will be evaluated
on its return value. This is useful for avoiding querying for data that won't
be used in the response. The method must be exported, but otherwise methods are
matched with fields ignoring case.
The args parameter can be of type map[string]graphql.Value, S, or *S, where S is
a struct type with fields for all of the arguments. See ConvertValueMap for a
description of how this parameter is derived from the field arguments. The sel
parameter is only passed to fields that return an object or list of objects type
and permits the method to peek into what fields will be evaluated on its return
value. This is useful for avoiding querying for data that won't be used in the
response. The method must be exported, but otherwise methods are matched with
fields ignoring case.
Lastly, if the object is a Go struct and the field takes no arguments, then the
server will read the value from an exported struct field with the same name
Expand Down
38 changes: 38 additions & 0 deletions graphql/graphql_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,8 @@ func TestExecute(t *testing.T) {
requiredArg(echo: String!): String!
requiredArgWithDefault(echo: String! = "xyzzy"): String!
enumArg(direction: Direction!): String!
convertedArgsMethod(echo: String): String!
convertedPointerArgsMethod(echo: String): String!
nilErrorMethod: String
errorMethod: String
Expand Down Expand Up @@ -418,6 +420,30 @@ func TestExecute(t *testing.T) {
{},
},
},
{
name: "ConvertedArgsMethod/Value",
queryObject: func(e errorfer) interface{} {
return new(testQueryStruct)
},
request: Request{
Query: `{ convertedArgsMethod(echo: "ohai") }`,
},
want: []fieldExpectations{
{key: "convertedArgsMethod", value: valueExpectations{scalar: "ohaiohai"}},
},
},
{
name: "ConvertedArgsMethod/Pointer",
queryObject: func(e errorfer) interface{} {
return new(testQueryStruct)
},
request: Request{
Query: `{ convertedPointerArgsMethod(echo: "ohai") }`,
},
want: []fieldExpectations{
{key: "convertedPointerArgsMethod", value: valueExpectations{scalar: "ohaiohai"}},
},
},
{
name: "List/Nonempty",
queryObject: func(e errorfer) interface{} {
Expand Down Expand Up @@ -1251,6 +1277,18 @@ func (q *testQueryStruct) EnumArg(args map[string]Value) string {
return args["direction"].Scalar()
}

type convertedArgs struct {
Echo string
}

func (q *testQueryStruct) ConvertedArgsMethod(args convertedArgs) string {
return args.Echo + args.Echo
}

func (q *testQueryStruct) ConvertedPointerArgsMethod(args *convertedArgs) string {
return args.Echo + args.Echo
}

func (q *testQueryStruct) NilErrorMethod() (string, error) {
return "xyzzy", nil
}
Expand Down
Loading

0 comments on commit 2f4f8d7

Please sign in to comment.