Files
genqlient/generate/config.go
T
Ben KraftandGitHub ff790e1c06 Add an option to handle enums with ugly casing (#270)
There are a bunch of places in genqlient where we just kind of hope you
don't have ridiculous casing conflicts in your schema. Apparently with
enum values there are actual schemas that have this problem! Now we have
an option to disable. I ended up putting it all under `casing` instead
of in `bindings` so we can clearly document the list of algorithms we
support, and so we can have an `all_enums` value if you're working with
a schema with a lot of this; in the future we may want to add similar
behavior for types/fields, add more possible algorithms (e.g. to make
things unexported), etc.

I added tests for the new feature, although the way the tests are set up
it wasn't convenient to do so for a schema where this is actually
required. I also added a check for case conflicts that points you to
this option. (I don't want to do it automatically for reasons described
in the issue; mainly it just seemed a bit too magical.)

Fixes #265.
2023-05-07 12:15:13 -07:00

307 lines
9.1 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])
}
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,
}
}
}
}
}
}
}
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 ""
}