Files
Ben Kraft 65d934a705 Add some more tests for config validation, and fix some gaps (#274)
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.
2023-05-07 17:52:08 -07:00

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 ""
}