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.
307 lines
9.1 KiB
Go
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 ""
|
|
}
|