Skip to content

Commit

Permalink
feat: Support fallback to json struct tag names (#108)
Browse files Browse the repository at this point in the history
* Stabilize marshal output

* feat: Support fallback to json struct tag names

This change introduces the ability for `goql` to fall-back to using
`json` struct tag field names (via an option). By supporting this, we'll
be able to use the models generated by `gqlgen` in our repositories
automatically with `goql`, essentially allowing us to get a complete
golang GraphQL client "for free" in each service that's using `gqlgen`.
  • Loading branch information
lelandbatey authored Jan 25, 2024
1 parent 50e373f commit ade48d5
Show file tree
Hide file tree
Showing 6 changed files with 227 additions and 113 deletions.
12 changes: 10 additions & 2 deletions do.go
Original file line number Diff line number Diff line change
Expand Up @@ -116,11 +116,19 @@ func (c *Client) doStruct(ctx context.Context, operationType int, operation *Ope
// or mutation.
switch operationType {
case opQuery:
if queryStr, err = MarshalQuery(operation.OperationType, operation.Fields); err != nil {
if queryStr, err = MarshalQueryWithOptions(
operation.OperationType,
operation.Fields,
c.marshalOpts...,
); err != nil {
return err
}
case opMutation:
if queryStr, err = MarshalMutation(operation.OperationType, operation.Fields); err != nil {
if queryStr, err = MarshalMutationWithOptions(
operation.OperationType,
operation.Fields,
c.marshalOpts...,
); err != nil {
return err
}
}
Expand Down
1 change: 1 addition & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -6,4 +6,5 @@ require (
github.com/getoutreach/gobox v1.73.2
github.com/google/go-cmp v0.5.9
github.com/pkg/errors v0.9.1
github.com/pmezard/go-difflib v1.0.0
)
2 changes: 2 additions & 0 deletions go.sum

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

22 changes: 18 additions & 4 deletions goql.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ type Client struct {
url string
httpClient *http.Client
errorMapper ErrorMapper
marshalOpts []marshalOption
}

// ClientOptions is the type passed to NewClient that allows for configuration of the client.
Expand All @@ -31,16 +32,23 @@ type Client struct {
// to give more context to the callee. If omitted or nil the Errors type will be returned
// in the case of any errors that came from the GraphQL server. See the documentation for
// the Errors type for more information as to what can be done with this mapping function.
//
// UseJSONTagNameAsFallback indicates whether goql should fall back on using the `json`
// struct tags if there's no `goql` struct tags when marshaling a struct into a query. If
// true, only the name of the field is inferred from the JSON struct tag, not any other
// attribute such as alias, include, or keep. Default value is false.
type ClientOptions struct {
HTTPClient *http.Client
ErrorMapper ErrorMapper
HTTPClient *http.Client
ErrorMapper ErrorMapper
UseJSONTagNameAsFallback bool
}

// DefaultClientOptions is a variable that can be passed for the ClientOptions when calling
// NewClient that will trigger use of all of the default options.
var DefaultClientOptions = ClientOptions{
HTTPClient: nil,
ErrorMapper: nil,
HTTPClient: nil,
ErrorMapper: nil,
UseJSONTagNameAsFallback: false,
}

// defaultErrorMapper shallow returns the Errors type that came from the response of a GraphQL
Expand All @@ -63,10 +71,16 @@ func NewClient(clientURL string, options ClientOptions) *Client {
options.ErrorMapper = defaultErrorMapper
}

marshOpts := []marshalOption{}
if options.UseJSONTagNameAsFallback {
marshOpts = append(marshOpts, OptFallbackJSONTag)
}

return &Client{
url: clientURL,
httpClient: options.HTTPClient,
errorMapper: options.ErrorMapper,
marshalOpts: marshOpts,
}
}

Expand Down
127 changes: 110 additions & 17 deletions query.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package goql

import (
"errors"
"fmt"
"io"
"reflect"
Expand Down Expand Up @@ -153,6 +154,11 @@ func argsFromTokens(tokens []token) ([]string, error) {
// len(tokens) might be too big, but it's at least the max size it could be.
argsMap := make(map[string]string, len(tokens))

// we want to ensure these args are always in the same ouput order as they were in the input
// order (first appearance wins). By having a sorted order of the keys, we achieve stable
// marshal output
argOrder := []string{}

// Make sure we don't duplicate variables if they're used more than once, and if
// they are used more than once, validate their types are the same.
for _, token := range tokens {
Expand All @@ -164,13 +170,15 @@ func argsFromTokens(tokens []token) ([]string, error) {
}

argsMap[token.Arg] = token.Kind
argOrder = append(argOrder, token.Arg)
}

// This slice will contain values in the form of $<arg>: <Type> which can be joined
// with strings.Join(args, ", ") by the caller to achieve the correct format.
args := make([]string, 0, len(argsMap))

for arg, kind := range argsMap {
for _, arg := range argOrder {
kind := argsMap[arg]
args = append(args, fmt.Sprintf("$%s: %s", arg, kind))
}

Expand Down Expand Up @@ -328,13 +336,23 @@ func splitTag(tag string) []string {
return split
}

var errSkipFieldFromTag = errors.New("tag on this struct field indicates this field should be skipped")

type tagParser func(reflect.StructTag) (field, error)

// parseTag takes the value of a graphql tag and parses it into various declarations
// and directives.
func parseTag(tag string) (field, error) { //nolint:funlen
func parseTag(tag reflect.StructTag) (field, error) { //nolint:funlen
var f field
var alias string

for _, item := range splitTag(tag) {
goqlTag := tag.Get(structTag)

if goqlTag == "-" {
return field{}, errSkipFieldFromTag
}

for _, item := range splitTag(goqlTag) {
item = strings.TrimSpace(item)

switch {
Expand Down Expand Up @@ -362,7 +380,7 @@ func parseTag(tag string) (field, error) { //nolint:funlen
case item == keepTag:
f.Keep = true
default:
return field{}, fmt.Errorf("failed to parse tag \"%s\"", tag)
return field{}, fmt.Errorf("failed to parse tag %q", goqlTag)
}
}

Expand All @@ -378,14 +396,31 @@ func parseTag(tag string) (field, error) { //nolint:funlen
for i := 1; i < len(f.Directives); i++ {
x, y := &f.Directives[i], f.Directives[j]
if x.Type == y.Type {
return field{}, fmt.Errorf("duplicate directive in tag \"%s\"", x.Type)
return field{}, fmt.Errorf("duplicate directive in tag %q", x.Type)
}
j++
}

return f, nil
}

func parseTagSupportingJSON(tag reflect.StructTag) (field, error) {
f, err := parseTag(tag)
if err != nil {
return field{}, err
}
if tag.Get(structTag) != "" {
return f, nil
}

// this way of getting the name of a field according to the JSON tag comes straight from the
// internals of the Go JSON module impl:
// https://cs.opensource.google/go/go/+/b3acaa8230e95c232a6f5c30eb7619a0c859ab16:src/encoding/json/tags.go;l=18
jsonname, _, _ := strings.Cut(tag.Get("json"), ",")
f.Decl.Name = jsonname
return f, nil
}

// parseDecl takes a declaration retrieved from a graphql struct tag and parses it
// into a Declaration.
func parseDecl(s string) declaration {
Expand Down Expand Up @@ -450,7 +485,7 @@ func parseDirective(s string) (directive, error) {
type node struct {
Name string
Type reflect.Type
Tag string
Tag reflect.StructTag
}

// visit defines a function signature used when "visiting" each node in a tree
Expand Down Expand Up @@ -483,15 +518,10 @@ func listFields(st reflect.Type) []node {
continue
}

tag := field.Tag.Get(structTag)
if tag == "-" {
continue
}

fields = append(fields, node{
Name: field.Name,
Type: deref(field.Type),
Tag: tag,
Tag: field.Tag,
})
}
return fields
Expand Down Expand Up @@ -561,18 +591,79 @@ func deref(t reflect.Type) reflect.Type {
return t
}

// optstruct holds onto state useful when applying options
type optStruct struct {
tp tagParser
}

// marshalOption is the type for our functional option for the marshal functions of GoQL. It's
// intentionally not publicly exported, as we only want to support our first-party options.
type marshalOption func(*optStruct)

// OptGoqlTagsOnly will cause the marshalling procedure of goql to *only* look at the `goql` struct
// tags when marshalling a struct into a GQL operation. The behavior caused by this option is the
// default behavior of goql, this option is provided for explicitness sake. If you would like
// marshalling to use `goql` struct tags and then to _fall back_ on JSON struct tags if no goql tags
// are present on a struct, then use the OptFallbackJSONTag option.
func OptGoqlTagsOnly(opt *optStruct) {
opt.tp = parseTag
}

// OptFallbackJSONTag causes the marshalling of structs to queries to still respect goql struct tags
// in the same way as default, but if no `goql` struct tag is present, marshalling will try to
// derive the name-in-GQL of that struct field from a `json` struct tag, if a `json` struct tag is
// present. If no `goql` struct tag AND no `json` struct tag are present, then marshalling defaults
// to the same toLowerCamelCase approach as always.
func OptFallbackJSONTag(opt *optStruct) {
opt.tp = parseTagSupportingJSON
}

// MarshalQuery takes a variable that must be a struct type and constructs a GraphQL
// operation using it's fields and graphql struct tags that can be used as a GraphQL
// query operation.
func MarshalQuery(q interface{}, fields Fields) (string, error) {
return marshal(q, "query", fields)
return MarshalQueryWithOptions(q, fields, OptGoqlTagsOnly)
}

// MarshalMutation takes a variable that must be a struct type and constructs a GraphQL
// operation using it's fields and graphql struct tags that can be used as a GraphQL
// mutation operation.
func MarshalMutation(q interface{}, fields Fields) (string, error) {
return marshal(q, "mutation", fields)
return MarshalMutationWithOptions(q, fields, OptGoqlTagsOnly)
}

// MarshalQueryWithOptions takes a variable that must be a struct type and constructs a GraphQL
// operation using it's fields and graphql struct tags that can be used as a GraphQL query
// operation. Additionally, MarshalQueryWithOptions accepts an array of functional options to
// change the marshalling behavior.
func MarshalQueryWithOptions(q interface{}, fields Fields, opts ...marshalOption) (string, error) {
o := optStruct{}
// by putting OptGoqlTagsOnly at the front, we ensure it'll be overridden by subsequent
// user-provided options
opts = append([]marshalOption{OptGoqlTagsOnly}, opts...)
for _, opt := range opts {
if opt != nil {
opt(&o)
}
}
return marshal(q, "query", fields, o.tp)
}

// MarshalMutationWithOptions takes a variable that must be a struct type and constructs a GraphQL
// operation using it's fields and graphql struct tags that can be used as a GraphQL mutation
// operation. Additionally, MarshalMutationWithOptions accepts an array of functional options to
// change the marshalling behavior.
func MarshalMutationWithOptions(q interface{}, fields Fields, opts ...marshalOption) (string, error) {
o := optStruct{}
// by putting OptGoqlTagsOnly at the front, we ensure it'll be overridden by subsequent
// user-provided options
opts = append([]marshalOption{OptGoqlTagsOnly}, opts...)
for _, opt := range opts {
if opt != nil {
opt(&o)
}
}
return marshal(q, "mutation", fields, o.tp)
}

// cache stores the resulting tree of types who have already been through the marshaling
Expand All @@ -583,7 +674,7 @@ var cache sync.Map
// using it's fields and graphql struct tags. The wrapper variable defines what type of
// GraphQL operation will be returned ("query" or "mutation", although this is not
// explicitly checked since this function is only called from within this package).
func marshal(q interface{}, wrapper string, fields Fields) (string, error) { //nolint:funlen
func marshal(q interface{}, wrapper string, fields Fields, tagParse tagParser) (string, error) { //nolint:funlen
var operation *field
rt := reflect.TypeOf(q)

Expand All @@ -601,8 +692,10 @@ func marshal(q interface{}, wrapper string, fields Fields) (string, error) { //n
// and their tokens which are used to create the GraphQL operation.
visitFn := func(n *node) error {
if n != nil {
f, err := parseTag(n.Tag)
if err != nil {
f, err := tagParse(n.Tag)
if err != nil && errors.Is(err, errSkipFieldFromTag) {
return nil
} else if err != nil {
return err
}

Expand Down
Loading

0 comments on commit ade48d5

Please sign in to comment.