diff --git a/README.md b/README.md index fde408e..3ca1d24 100644 --- a/README.md +++ b/README.md @@ -49,7 +49,7 @@ fmt.Println("you are", viewerResp.Viewer.MyName) //go:generate go run github.com/Khan/genqlient ``` -For a complete working example, see `example/`. +For a complete working example, see `example/`. For configuration options, see `go doc github.com/Khan/genqlient/generate.Config`. TODO: document this a bit more, including different ways to specify queries, options once we have those, etc. diff --git a/generate/config.go b/generate/config.go index 3436963..7f6e58c 100644 --- a/generate/config.go +++ b/generate/config.go @@ -18,27 +18,48 @@ var defaultConfig = &Config{ } type Config struct { - // The package name for the output code; defaults to the directory name of - // Generated - Package string `yaml:"package"` // The filename with the GraphQL schema (in SDL format); defaults to // schema.graphql // TODO: Allow fetching a schema via introspection (will need to figure out // how to convert that to SDL). Schema string `yaml:"schema"` + // Filenames or globs with the queries; defaults to queries.graphql. // // These may be .graphql files, containing the queries in SDL format, or // Go files, in which case any string-literal starting with (optional // whitespace and) the string "# @genqlient" will be extracted as a query. Queries []string `yaml:"queries"` + + // If set, a file at this path will be generated containing the exact + // operations that genqlient will send to the server. + // + // This is useful for systems which require queries to be explicitly + // safelisted, especially for cases like queries involving fragments where + // it may not exactly match the input queries. The JSON is an object of + // the form + // {"operations": [{ + // "operationName": "operationname", + // "query": "query operationName { ... }", + // }]} + // Keys may be added in the future. + // + // By default, no such file is written. + ExportOperations string `yaml:"export_operations"` + // The filename to which to write the generated code; defaults to // generated.go Generated string `yaml:"generated"` + + // The package name for the output code; defaults to the directory name of + // Generated + Package string `yaml:"package"` + // Set to the fully-qualified name of a type which generated helpers should // accept and use as the context.Context for HTTP requests. Defaults to // context.Context; set to the empty string to omit context entirely. ContextType string `yaml:"context_type"` + // If set, a snippet of Go code to get a *graphql.Client from the context // (which will be named ctx). For example, this might do // ctx.Value(myKey).(*graphql.Client). If omitted, client must be diff --git a/generate/example_test.go b/generate/example_test.go index 92ca75c..af18669 100644 --- a/generate/example_test.go +++ b/generate/example_test.go @@ -21,19 +21,21 @@ func TestGenerateExample(t *testing.T) { t.Fatal(err) } - code, err := Generate(config) + generated, err := Generate(config) if err != nil { t.Fatal(err) } - expectedCode, err := ioutil.ReadFile(config.Generated) - if err != nil { - t.Fatal(err) - } + for filename, content := range generated { + expectedContent, err := ioutil.ReadFile(filename) + if err != nil { + t.Fatal(err) + } - if !bytes.Equal(code, expectedCode) { - t.Errorf( - "diffs to generated code:\n---actual---\n%v\n---expected---\n%v", - string(code), string(expectedCode)) + if !bytes.Equal(content, expectedContent) { + t.Errorf( + "diffs to %v:\n---actual---\n%v\n---expected---\n%v", + filename, string(content), string(expectedContent)) + } } } diff --git a/generate/generate.go b/generate/generate.go index 031f2cd..2552524 100644 --- a/generate/generate.go +++ b/generate/generate.go @@ -2,6 +2,7 @@ package generate import ( "bytes" + "encoding/json" "fmt" "go/format" "sort" @@ -26,19 +27,24 @@ type generator struct { schema *ast.Schema } +// JSON tags in operation are for ExportOperations (see Config for details). type operation struct { // The type of the operation (query, mutation, or subscription). - Type ast.Operation + Type ast.Operation `json:"-"` // The name of the operation, from GraphQL. - Name string + Name string `json:"operationName"` // The documentation for the operation, from GraphQL. - Doc string + Doc string `json:"-"` // The body of the operation to send. - Body string + Body string `json:"query"` // The arguments to the operation. - Args []argument + Args []argument `json:"-"` // The type-name for the operation's response type. - ResponseName string + ResponseName string `json:"-"` +} + +type exportedOperations struct { + Operations []operation `json:"operations"` } type argument struct { @@ -143,7 +149,8 @@ func (g *generator) addOperation(op *ast.OperationDefinition) error { return nil } -func Generate(config *Config) ([]byte, error) { +// Generate returns a map from absolute-path filename to generated content. +func Generate(config *Config) (map[string][]byte, error) { schema, err := getSchema(config.Schema) if err != nil { return nil, err @@ -181,5 +188,17 @@ func Generate(config *Config) ([]byte, error) { err, string(unformatted)) } - return formatted, nil + retval := map[string][]byte{ + config.Generated: formatted, + } + + if config.ExportOperations != "" { + retval[config.ExportOperations], err = json.Marshal( + exportedOperations{Operations: g.Operations}) + if err != nil { + return nil, fmt.Errorf("unable to export queries: %v", err) + } + } + + return retval, nil } diff --git a/generate/generate_test.go b/generate/generate_test.go index fb3216e..d375630 100644 --- a/generate/generate_test.go +++ b/generate/generate_test.go @@ -25,11 +25,11 @@ func readFile(t *testing.T, filename string, allowNotExist bool) string { return string(data) } -func gofmt(src string) (string, error) { +func gofmt(filename, src string) (string, error) { src = strings.TrimSpace(src) formatted, err := format.Source([]byte(src)) if err != nil { - return src, fmt.Errorf("go parse error: %w", err) + return src, fmt.Errorf("go parse error in %v: %w", filename, err) } return string(formatted), nil } @@ -60,34 +60,48 @@ func TestGenerate(t *testing.T) { continue } goFilename := graphqlFilename + ".go" + queriesFilename := graphqlFilename + ".json" t.Run(graphqlFilename, func(t *testing.T) { - expectedGoCode, err := gofmt(readFile(t, goFilename, update)) - if err != nil { - t.Fatal(err) - } - - goCode, err := Generate(&Config{ - Schema: filepath.Join(dataDir, "schema.graphql"), - Queries: []string{filepath.Join(dataDir, graphqlFilename)}, - Package: "test", + generated, err := Generate(&Config{ + Schema: filepath.Join(dataDir, "schema.graphql"), + Queries: []string{filepath.Join(dataDir, graphqlFilename)}, + Package: "test", + Generated: goFilename, + ExportOperations: queriesFilename, }) if err != nil { t.Fatal(err) } - if string(goCode) != expectedGoCode { - t.Errorf("got:\n%v\nwant:\n%v\n", string(goCode), expectedGoCode) - if update { - t.Log("Updating testdata dir to match") - err = ioutil.WriteFile(filepath.Join(dataDir, goFilename), goCode, 0o644) + for filename, content := range generated { + expectedContent := readFile(t, filename, update) + if strings.HasSuffix(filename, ".go") { + fmted, err := gofmt(filename, expectedContent) if err != nil { - t.Errorf("Unable to update testdata dir: %v", err) + // Ignore gofmt errors if we are updating + if !update { + t.Fatal(err) + } + } else { + expectedContent = fmted } } - } - // TODO(benkraft): Also check that the code at least builds! + if string(content) != expectedContent { + t.Errorf("mismatch in %v\ngot:\n%v\nwant:\n%v\n", + filename, string(content), expectedContent) + if update { + t.Log("Updating testdata dir to match") + err = ioutil.WriteFile(filepath.Join(dataDir, filename), content, 0o644) + if err != nil { + t.Errorf("Unable to update testdata dir: %v", err) + } + } + } + + // TODO(benkraft): Also check that the code at least builds! + } }) } } diff --git a/generate/main.go b/generate/main.go index 0dd347a..d0cf898 100644 --- a/generate/main.go +++ b/generate/main.go @@ -13,22 +13,24 @@ func readConfigGenerateAndWrite(configFilename string) error { return err } - code, err := Generate(config) + generated, err := Generate(config) if err != nil { return err } - err = os.MkdirAll(filepath.Dir(config.Generated), 0o755) - if err != nil { - return fmt.Errorf( - "could not create parent directory for generated file %v: %v", - config.Generated, err) - } + for filename, content := range generated { + err = os.MkdirAll(filepath.Dir(filename), 0o755) + if err != nil { + return fmt.Errorf( + "could not create parent directory for generated file %v: %v", + filename, err) + } - err = ioutil.WriteFile(config.Generated, code, 0o644) - if err != nil { - return fmt.Errorf("could not write generated file %v: %v", - config.Generated, err) + err = ioutil.WriteFile(filename, content, 0o644) + if err != nil { + return fmt.Errorf("could not write generated file %v: %v", + filename, err) + } } return nil } diff --git a/generate/testdata/queries/InputObject.graphql.json b/generate/testdata/queries/InputObject.graphql.json new file mode 100644 index 0000000..5e5a766 --- /dev/null +++ b/generate/testdata/queries/InputObject.graphql.json @@ -0,0 +1 @@ +{"operations":[{"operationName":"InputObjectQuery","query":"\nquery InputObjectQuery ($query: UserQueryInput) {\n\tuser(query: $query) {\n\t\tid\n\t}\n}\n"}]} \ No newline at end of file diff --git a/generate/testdata/queries/InterfaceNoFragments.graphql.json b/generate/testdata/queries/InterfaceNoFragments.graphql.json new file mode 100644 index 0000000..ca57037 --- /dev/null +++ b/generate/testdata/queries/InterfaceNoFragments.graphql.json @@ -0,0 +1 @@ +{"operations":[{"operationName":"InterfaceNoFragmentsQuery","query":"\nquery InterfaceNoFragmentsQuery {\n\troot {\n\t\tid\n\t\tname\n\t\tchildren {\n\t\t\tid\n\t\t\tname\n\t\t}\n\t}\n}\n"}]} \ No newline at end of file diff --git a/generate/testdata/queries/ListInput.graphql.json b/generate/testdata/queries/ListInput.graphql.json new file mode 100644 index 0000000..de85190 --- /dev/null +++ b/generate/testdata/queries/ListInput.graphql.json @@ -0,0 +1 @@ +{"operations":[{"operationName":"ListInputQuery","query":"\nquery ListInputQuery ($names: [String]) {\n\tuser(query: {names:$names}) {\n\t\tid\n\t}\n}\n"}]} \ No newline at end of file diff --git a/generate/testdata/queries/QueryWithAlias.graphql.json b/generate/testdata/queries/QueryWithAlias.graphql.json new file mode 100644 index 0000000..194ff96 --- /dev/null +++ b/generate/testdata/queries/QueryWithAlias.graphql.json @@ -0,0 +1 @@ +{"operations":[{"operationName":"QueryWithAlias","query":"\nquery QueryWithAlias {\n\tUser: user {\n\t\tID: id\n\t}\n}\n"}]} \ No newline at end of file diff --git a/generate/testdata/queries/QueryWithDoubleAlias.graphql.json b/generate/testdata/queries/QueryWithDoubleAlias.graphql.json new file mode 100644 index 0000000..1709bf2 --- /dev/null +++ b/generate/testdata/queries/QueryWithDoubleAlias.graphql.json @@ -0,0 +1 @@ +{"operations":[{"operationName":"QueryWithDoubleAlias","query":"\nquery QueryWithDoubleAlias {\n\tuser {\n\t\tID: id\n\t\tAlsoID: id\n\t}\n}\n"}]} \ No newline at end of file diff --git a/generate/testdata/queries/QueryWithEnums.graphql.json b/generate/testdata/queries/QueryWithEnums.graphql.json new file mode 100644 index 0000000..2262eff --- /dev/null +++ b/generate/testdata/queries/QueryWithEnums.graphql.json @@ -0,0 +1 @@ +{"operations":[{"operationName":"QueryWithEnums","query":"\nquery QueryWithEnums {\n\tuser {\n\t\troles\n\t}\n\totherUser: user {\n\t\troles\n\t}\n}\n"}]} \ No newline at end of file diff --git a/generate/testdata/queries/QueryWithSlices.graphql.json b/generate/testdata/queries/QueryWithSlices.graphql.json new file mode 100644 index 0000000..14119b9 --- /dev/null +++ b/generate/testdata/queries/QueryWithSlices.graphql.json @@ -0,0 +1 @@ +{"operations":[{"operationName":"QueryWithSlices","query":"\nquery QueryWithSlices {\n\tuser {\n\t\temails\n\t\temailsOrNull\n\t\temailsWithNulls\n\t\temailsWithNullsOrNull\n\t}\n}\n"}]} \ No newline at end of file diff --git a/generate/testdata/queries/QueryWithStructs.graphql.json b/generate/testdata/queries/QueryWithStructs.graphql.json new file mode 100644 index 0000000..c7852fe --- /dev/null +++ b/generate/testdata/queries/QueryWithStructs.graphql.json @@ -0,0 +1 @@ +{"operations":[{"operationName":"QueryWithStructs","query":"\nquery QueryWithStructs {\n\tuser {\n\t\tauthMethods {\n\t\t\tprovider\n\t\t\temail\n\t\t}\n\t}\n}\n"}]} \ No newline at end of file diff --git a/generate/testdata/queries/SimpleInput.graphql.json b/generate/testdata/queries/SimpleInput.graphql.json new file mode 100644 index 0000000..f2015f0 --- /dev/null +++ b/generate/testdata/queries/SimpleInput.graphql.json @@ -0,0 +1 @@ +{"operations":[{"operationName":"SimpleInputQuery","query":"\nquery SimpleInputQuery ($name: String!) {\n\tuser(query: {name:$name}) {\n\t\tid\n\t}\n}\n"}]} \ No newline at end of file diff --git a/generate/testdata/queries/SimpleQuery.graphql.json b/generate/testdata/queries/SimpleQuery.graphql.json new file mode 100644 index 0000000..6ba92f5 --- /dev/null +++ b/generate/testdata/queries/SimpleQuery.graphql.json @@ -0,0 +1 @@ +{"operations":[{"operationName":"SimpleQuery","query":"\nquery SimpleQuery {\n\tuser {\n\t\tid\n\t}\n}\n"}]} \ No newline at end of file diff --git a/generate/testdata/queries/TypeName.graphql.json b/generate/testdata/queries/TypeName.graphql.json new file mode 100644 index 0000000..4ce96d2 --- /dev/null +++ b/generate/testdata/queries/TypeName.graphql.json @@ -0,0 +1 @@ +{"operations":[{"operationName":"TypeNameQuery","query":"\nquery TypeNameQuery {\n\tuser {\n\t\t__typename\n\t\tid\n\t}\n}\n"}]} \ No newline at end of file diff --git a/generate/testdata/queries/UnionNoFragments.graphql.json b/generate/testdata/queries/UnionNoFragments.graphql.json new file mode 100644 index 0000000..e3682a8 --- /dev/null +++ b/generate/testdata/queries/UnionNoFragments.graphql.json @@ -0,0 +1 @@ +{"operations":[{"operationName":"UnionNoFragmentsQuery","query":"\nquery UnionNoFragmentsQuery {\n\trandomLeaf {\n\t\t__typename\n\t}\n}\n"}]} \ No newline at end of file diff --git a/generate/testdata/queries/UsesEnumTwice.graphql.json b/generate/testdata/queries/UsesEnumTwice.graphql.json new file mode 100644 index 0000000..5c49d9a --- /dev/null +++ b/generate/testdata/queries/UsesEnumTwice.graphql.json @@ -0,0 +1 @@ +{"operations":[{"operationName":"UsesEnumTwiceQuery","query":"\nquery UsesEnumTwiceQuery {\n\tMe: user {\n\t\troles\n\t}\n\tOtherUser: user {\n\t\troles\n\t}\n}\n"}]} \ No newline at end of file diff --git a/generate/testdata/queries/unexported.graphql.json b/generate/testdata/queries/unexported.graphql.json new file mode 100644 index 0000000..55a8472 --- /dev/null +++ b/generate/testdata/queries/unexported.graphql.json @@ -0,0 +1 @@ +{"operations":[{"operationName":"unexported","query":"\nquery unexported ($query: UserQueryInput) {\n\tuser(query: $query) {\n\t\tid\n\t}\n}\n"}]} \ No newline at end of file diff --git a/graphql/client.go b/graphql/client.go index 827367e..b236b3c 100644 --- a/graphql/client.go +++ b/graphql/client.go @@ -71,7 +71,7 @@ func NewClient(endpoint string, httpClient *http.Client) Client { type payload struct { Query string `json:"query"` - Variables map[string]interface{} `json:"variables"` + Variables map[string]interface{} `json:"variables,omitempty"` // OpName is only required if there are multiple queries in the document, // but we set it unconditionally, because that's easier. OpName string `json:"operationName"`