65d934a705
I set these tests up in #270, but I realized there's a lot more we could test unrelated to that PR. In this commit I add some more tests. They're not really exhaustive yet, but they did catch a few bugs, which I fixed: - we weren't validating the package-name if you do set it (only if we guess it) - If you omitted `generated`, you would try to write generated code to the directory containing `genqlient.yaml`, which makes no sense; now we default to `generated.go`. In the real world it's probably good to set explicitly, but it's actually very convenient in tests that we don't have to, and maybe in small projects too.
316 lines
9.4 KiB
Go
316 lines
9.4 KiB
Go
package generate
|
|
|
|
import (
|
|
_ "embed"
|
|
"fmt"
|
|
"go/token"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
|
|
"golang.org/x/tools/go/packages"
|
|
"gopkg.in/yaml.v2"
|
|
)
|
|
|
|
var cfgFilenames = []string{".genqlient.yml", ".genqlient.yaml", "genqlient.yml", "genqlient.yaml"}
|
|
|
|
// Config represents genqlient's configuration, generally read from
|
|
// genqlient.yaml.
|
|
//
|
|
// Callers must call [Config.ValidateAndFillDefaults] before using the config.
|
|
type Config struct {
|
|
// The following fields are documented in the [genqlient.yaml docs].
|
|
//
|
|
// [genqlient.yaml docs]: https://github.com/Khan/genqlient/blob/main/docs/genqlient.yaml
|
|
Schema StringList `yaml:"schema"`
|
|
Operations StringList `yaml:"operations"`
|
|
Generated string `yaml:"generated"`
|
|
Package string `yaml:"package"`
|
|
ExportOperations string `yaml:"export_operations"`
|
|
ContextType string `yaml:"context_type"`
|
|
ClientGetter string `yaml:"client_getter"`
|
|
Bindings map[string]*TypeBinding `yaml:"bindings"`
|
|
PackageBindings []*PackageBinding `yaml:"package_bindings"`
|
|
Casing Casing `yaml:"casing"`
|
|
Optional string `yaml:"optional"`
|
|
OptionalGenericType string `yaml:"optional_generic_type"`
|
|
StructReferences bool `yaml:"use_struct_references"`
|
|
Extensions bool `yaml:"use_extensions"`
|
|
|
|
// Set to true to use features that aren't fully ready to use.
|
|
//
|
|
// This is primarily intended for genqlient's own tests. These features
|
|
// are likely BROKEN and come with NO EXPECTATION OF COMPATIBILITY. Use
|
|
// them at your own risk!
|
|
AllowBrokenFeatures bool `yaml:"allow_broken_features"`
|
|
|
|
// The directory of the config-file (relative to which all the other paths
|
|
// are resolved). Set by ValidateAndFillDefaults.
|
|
baseDir string
|
|
}
|
|
|
|
// A TypeBinding represents a Go type to which genqlient will bind a particular
|
|
// GraphQL type, and is documented further in the [genqlient.yaml docs].
|
|
//
|
|
// [genqlient.yaml docs]: https://github.com/Khan/genqlient/blob/main/docs/genqlient.yaml
|
|
type TypeBinding struct {
|
|
Type string `yaml:"type"`
|
|
ExpectExactFields string `yaml:"expect_exact_fields"`
|
|
Marshaler string `yaml:"marshaler"`
|
|
Unmarshaler string `yaml:"unmarshaler"`
|
|
}
|
|
|
|
// A PackageBinding represents a Go package for which genqlient will
|
|
// automatically generate [TypeBinding] values, and is documented further in
|
|
// the [genqlient.yaml docs].
|
|
//
|
|
// [genqlient.yaml docs]: https://github.com/Khan/genqlient/blob/main/docs/genqlient.yaml
|
|
type PackageBinding struct {
|
|
Package string `yaml:"package"`
|
|
}
|
|
|
|
// CasingAlgorithm represents a way that genqlient can handle casing, and is
|
|
// documented further in the [genqlient.yaml docs].
|
|
//
|
|
// [genqlient.yaml docs]: https://github.com/Khan/genqlient/blob/main/docs/genqlient.yaml
|
|
type CasingAlgorithm string
|
|
|
|
const (
|
|
CasingDefault CasingAlgorithm = "default"
|
|
CasingRaw CasingAlgorithm = "raw"
|
|
)
|
|
|
|
func (algo CasingAlgorithm) validate() error {
|
|
switch algo {
|
|
case CasingDefault, CasingRaw:
|
|
return nil
|
|
default:
|
|
return errorf(nil, "unknown casing algorithm: %s", algo)
|
|
}
|
|
}
|
|
|
|
// Casing wraps the casing-related options, and is documented further in
|
|
// the [genqlient.yaml docs].
|
|
//
|
|
// [genqlient.yaml docs]: https://github.com/Khan/genqlient/blob/main/docs/genqlient.yaml
|
|
type Casing struct {
|
|
AllEnums CasingAlgorithm `yaml:"all_enums"`
|
|
Enums map[string]CasingAlgorithm `yaml:"enums"`
|
|
}
|
|
|
|
func (casing *Casing) validate() error {
|
|
if casing.AllEnums != "" {
|
|
if err := casing.AllEnums.validate(); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
for _, algo := range casing.Enums {
|
|
if err := algo.validate(); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (casing *Casing) forEnum(graphQLTypeName string) CasingAlgorithm {
|
|
if specificConfig, ok := casing.Enums[graphQLTypeName]; ok {
|
|
return specificConfig
|
|
}
|
|
if casing.AllEnums != "" {
|
|
return casing.AllEnums
|
|
}
|
|
return CasingDefault
|
|
}
|
|
|
|
// pathJoin is like filepath.Join but 1) it only takes two argsuments,
|
|
// and b) if the second argument is an absolute path the first argument
|
|
// is ignored (similar to how python's os.path.join() works).
|
|
func pathJoin(a, b string) string {
|
|
if filepath.IsAbs(b) {
|
|
return b
|
|
}
|
|
return filepath.Join(a, b)
|
|
}
|
|
|
|
// ValidateAndFillDefaults ensures that the configuration is valid, and fills
|
|
// in any options that were unspecified.
|
|
//
|
|
// The argument is the directory relative to which paths will be interpreted,
|
|
// typically the directory of the config file.
|
|
func (c *Config) ValidateAndFillDefaults(baseDir string) error {
|
|
c.baseDir = baseDir
|
|
for i := range c.Schema {
|
|
c.Schema[i] = pathJoin(baseDir, c.Schema[i])
|
|
}
|
|
for i := range c.Operations {
|
|
c.Operations[i] = pathJoin(baseDir, c.Operations[i])
|
|
}
|
|
if c.Generated == "" {
|
|
c.Generated = "generated.go"
|
|
}
|
|
c.Generated = pathJoin(baseDir, c.Generated)
|
|
if c.ExportOperations != "" {
|
|
c.ExportOperations = pathJoin(baseDir, c.ExportOperations)
|
|
}
|
|
|
|
if c.ContextType == "" {
|
|
c.ContextType = "context.Context"
|
|
}
|
|
|
|
if c.Optional != "" && c.Optional != "value" && c.Optional != "pointer" && c.Optional != "generic" {
|
|
return errorf(nil, "optional must be one of: 'value' (default), 'pointer', or 'generic'")
|
|
}
|
|
|
|
if c.Optional == "generic" && c.OptionalGenericType == "" {
|
|
return errorf(nil, "if optional is set to 'generic', optional_generic_type must be set to the fully"+
|
|
"qualified name of a type with a single generic parameter"+
|
|
"\nExample: \"github.com/Org/Repo/optional.Value\"")
|
|
}
|
|
|
|
if c.Package != "" {
|
|
if !token.IsIdentifier(c.Package) {
|
|
// No need for link here -- if you're already setting the package
|
|
// you know where to set the package.
|
|
return errorf(nil, "invalid package in genqlient.yaml: '%v' is not a valid identifier", c.Package)
|
|
}
|
|
} else {
|
|
abs, err := filepath.Abs(c.Generated)
|
|
if err != nil {
|
|
return errorf(nil, "unable to guess package-name: %v"+
|
|
"\nSet package name in genqlient.yaml"+
|
|
"\nExample: https://github.com/Khan/genqlient/blob/main/example/genqlient.yaml#L6", err)
|
|
}
|
|
|
|
base := filepath.Base(filepath.Dir(abs))
|
|
if !token.IsIdentifier(base) {
|
|
return errorf(nil, "unable to guess package-name: '%v' is not a valid identifier"+
|
|
"\nSet package name in genqlient.yaml"+
|
|
"\nExample: https://github.com/Khan/genqlient/blob/main/example/genqlient.yaml#L6", base)
|
|
}
|
|
|
|
c.Package = base
|
|
}
|
|
|
|
if len(c.PackageBindings) > 0 {
|
|
for _, binding := range c.PackageBindings {
|
|
if strings.HasSuffix(binding.Package, ".go") {
|
|
// total heuristic -- but this is an easy mistake to make and
|
|
// results in rather bizarre behavior from go/packages.
|
|
return errorf(nil,
|
|
"package %v looks like a file, but should be a package-name",
|
|
binding.Package)
|
|
}
|
|
|
|
mode := packages.NeedDeps | packages.NeedTypes
|
|
pkgs, err := packages.Load(&packages.Config{
|
|
Mode: mode,
|
|
}, binding.Package)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
if c.Bindings == nil {
|
|
c.Bindings = map[string]*TypeBinding{}
|
|
}
|
|
|
|
for _, pkg := range pkgs {
|
|
p := pkg.Types
|
|
if p == nil || p.Scope() == nil || p.Scope().Len() == 0 {
|
|
return errorf(nil, "unable to bind package %s: no types found", binding.Package)
|
|
}
|
|
|
|
for _, typ := range p.Scope().Names() {
|
|
if token.IsExported(typ) {
|
|
// Check if type is manual bindings
|
|
_, exist := c.Bindings[typ]
|
|
if !exist {
|
|
pathType := fmt.Sprintf("%s.%s", p.Path(), typ)
|
|
c.Bindings[typ] = &TypeBinding{
|
|
Type: pathType,
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
if err := c.Casing.validate(); err != nil {
|
|
return err
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// ReadAndValidateConfig reads the configuration from the given file, validates
|
|
// it, and returns it.
|
|
func ReadAndValidateConfig(filename string) (*Config, error) {
|
|
text, err := os.ReadFile(filename)
|
|
if err != nil {
|
|
return nil, errorf(nil, "unreadable config file %v: %v", filename, err)
|
|
}
|
|
|
|
var config Config
|
|
err = yaml.UnmarshalStrict(text, &config)
|
|
if err != nil {
|
|
return nil, errorf(nil, "invalid config file %v: %v", filename, err)
|
|
}
|
|
|
|
err = config.ValidateAndFillDefaults(filepath.Dir(filename))
|
|
if err != nil {
|
|
return nil, errorf(nil, "invalid config file %v: %v", filename, err)
|
|
}
|
|
|
|
return &config, nil
|
|
}
|
|
|
|
// ReadAndValidateConfigFromDefaultLocations looks for a config file in the
|
|
// current directory, and all parent directories walking up the tree. The
|
|
// closest config file will be returned.
|
|
func ReadAndValidateConfigFromDefaultLocations() (*Config, error) {
|
|
cfgFile, err := findCfg()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return ReadAndValidateConfig(cfgFile)
|
|
}
|
|
|
|
//go:embed default_genqlient.yaml
|
|
var defaultConfig []byte
|
|
|
|
func initConfig(filename string) error {
|
|
return os.WriteFile(filename, defaultConfig, 0o644)
|
|
}
|
|
|
|
// findCfg searches for the config file in this directory and all parents up the tree
|
|
// looking for the closest match
|
|
func findCfg() (string, error) {
|
|
dir, err := os.Getwd()
|
|
if err != nil {
|
|
return "", errorf(nil, "unable to get working dir to findCfg: %v", err)
|
|
}
|
|
|
|
cfg := findCfgInDir(dir)
|
|
|
|
for cfg == "" && dir != filepath.Dir(dir) {
|
|
dir = filepath.Dir(dir)
|
|
cfg = findCfgInDir(dir)
|
|
}
|
|
|
|
if cfg == "" {
|
|
return "", os.ErrNotExist
|
|
}
|
|
|
|
return cfg, nil
|
|
}
|
|
|
|
func findCfgInDir(dir string) string {
|
|
for _, cfgName := range cfgFilenames {
|
|
path := pathJoin(dir, cfgName)
|
|
if _, err := os.Stat(path); err == nil {
|
|
return path
|
|
}
|
|
}
|
|
return ""
|
|
}
|