Files
genqlient/generate/config.go
T
Dylan R. Johnston c61d7acaa5 Add "generic" option to the "optional" configuration for handling nullable types (#252)
This is an implementation for #251, it adds a new `"generic"` option for
the `"optional"` configuration, and a companion type
`"optional_generic_type"` which is a fully qualified type with a
placeholder `%` for the generic parameter.

Co-authored-by: Dylan R. Johnston <dylan.johnston@familyzone.com>
Co-authored-by: Ben Kraft <ben@benkraft.org>
2023-05-06 10:40:03 -07:00

249 lines
7.7 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"`
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"`
}
// 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])
}
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 == "" {
abs, err := filepath.Abs(c.Generated)
if err != nil {
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", 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,
}
}
}
}
}
}
}
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 ""
}