github.com/speakeasy-api/sdk-gen-config@v1.14.2/io.go (about)

     1  package config
     2  
     3  import (
     4  	"bufio"
     5  	"bytes"
     6  	"crypto/md5"
     7  	"encoding/hex"
     8  	"errors"
     9  	"fmt"
    10  	"io/fs"
    11  	"os"
    12  	"path/filepath"
    13  
    14  	"github.com/speakeasy-api/sdk-gen-config/workspace"
    15  	"gopkg.in/yaml.v3"
    16  )
    17  
    18  const (
    19  	configFile = "gen.yaml"
    20  	lockFile   = "gen.lock"
    21  )
    22  
    23  type Config struct {
    24  	Config     *Configuration
    25  	ConfigPath string
    26  	LockFile   *LockFile
    27  }
    28  
    29  type FS interface {
    30  	fs.ReadFileFS
    31  	fs.StatFS
    32  	WriteFile(name string, data []byte, perm os.FileMode) error
    33  }
    34  
    35  type Option func(*options)
    36  
    37  type (
    38  	GetLanguageDefaultFunc func(string, bool) (*LanguageConfig, error)
    39  	TransformerFunc        func(*Config) (*Config, error)
    40  )
    41  
    42  type options struct {
    43  	FS                     FS
    44  	UpgradeFunc            UpgradeFunc
    45  	getLanguageDefaultFunc GetLanguageDefaultFunc
    46  	langs                  []string
    47  	transformerFunc        TransformerFunc
    48  	dontWrite              bool
    49  }
    50  
    51  func WithFileSystem(fs FS) Option {
    52  	return func(o *options) {
    53  		o.FS = fs
    54  	}
    55  }
    56  
    57  func WithDontWrite() Option {
    58  	return func(o *options) {
    59  		o.dontWrite = true
    60  	}
    61  }
    62  
    63  func WithUpgradeFunc(f UpgradeFunc) Option {
    64  	return func(o *options) {
    65  		o.UpgradeFunc = f
    66  	}
    67  }
    68  
    69  func WithLanguageDefaultFunc(f GetLanguageDefaultFunc) Option {
    70  	return func(o *options) {
    71  		o.getLanguageDefaultFunc = f
    72  	}
    73  }
    74  
    75  func WithLanguages(langs ...string) Option {
    76  	return func(o *options) {
    77  		o.langs = langs
    78  	}
    79  }
    80  
    81  func WithTransformerFunc(f TransformerFunc) Option {
    82  	return func(o *options) {
    83  		o.transformerFunc = f
    84  	}
    85  }
    86  
    87  func FindConfigFile(dir string, fileSystem FS) (*workspace.FindWorkspaceResult, error) {
    88  	configRes, err := workspace.FindWorkspace(dir, workspace.FindWorkspaceOptions{
    89  		FindFile:     configFile,
    90  		AllowOutside: true,
    91  		Recursive:    true,
    92  		FS:           fileSystem,
    93  	})
    94  	if err != nil {
    95  		if errors.Is(err, fs.ErrNotExist) {
    96  			configRes = &workspace.FindWorkspaceResult{
    97  				Path: filepath.Join(dir, workspace.SpeakeasyFolder, configFile),
    98  			}
    99  		} else {
   100  			return nil, err
   101  		}
   102  	}
   103  
   104  	return configRes, nil
   105  }
   106  
   107  func Load(dir string, opts ...Option) (*Config, error) {
   108  	o := applyOptions(opts)
   109  
   110  	newConfig := false
   111  	newSDK := false
   112  	newForLang := map[string]bool{}
   113  
   114  	// Find existing config file
   115  	configRes, err := FindConfigFile(dir, o.FS)
   116  	if err != nil {
   117  		return nil, err
   118  	}
   119  	if configRes.Data == nil {
   120  		newConfig = true
   121  		newSDK = true
   122  
   123  		for _, lang := range o.langs {
   124  			newForLang[lang] = true
   125  		}
   126  	}
   127  
   128  	// Make sure to use the same workspace dir type as the config file
   129  	workspaceDir := filepath.Base(filepath.Dir(configRes.Path))
   130  	if workspaceDir != workspace.SpeakeasyFolder && workspaceDir != workspace.GenFolder {
   131  		workspaceDir = workspace.SpeakeasyFolder
   132  	}
   133  
   134  	newLockFile := false
   135  	lockFileRes, err := workspace.FindWorkspace(filepath.Join(dir, workspaceDir), workspace.FindWorkspaceOptions{
   136  		FindFile: lockFile,
   137  		FS:       o.FS,
   138  	})
   139  	if err != nil {
   140  		if !errors.Is(err, fs.ErrNotExist) {
   141  			return nil, fmt.Errorf("could not read gen.lock: %w", err)
   142  		}
   143  		lockFileRes = &workspace.FindWorkspaceResult{
   144  			Path: filepath.Join(dir, workspaceDir, lockFile),
   145  		}
   146  		newLockFile = true
   147  	}
   148  
   149  	if !newConfig {
   150  		// Unmarshal config file and check version
   151  		cfgMap := map[string]any{}
   152  		if err := yaml.Unmarshal(configRes.Data, &cfgMap); err != nil {
   153  			return nil, fmt.Errorf("could not unmarshal gen.yaml: %w", err)
   154  		}
   155  
   156  		var lockFileMap map[string]any
   157  		lockFilePresent := false
   158  		if lockFileRes.Data != nil {
   159  			if err := yaml.Unmarshal(lockFileRes.Data, &lockFileMap); err != nil {
   160  				return nil, fmt.Errorf("could not unmarshal gen.lock: %w", err)
   161  			}
   162  			lockFilePresent = true
   163  		}
   164  
   165  		version := ""
   166  
   167  		v, ok := cfgMap["configVersion"]
   168  		if ok {
   169  			version, ok = v.(string)
   170  			if !ok {
   171  				version = ""
   172  			}
   173  		}
   174  
   175  		// If we aren't upgrading we assume if we are missing a lock file then this is a new SDK
   176  		if version == Version {
   177  			newSDK = newSDK || newLockFile
   178  		}
   179  
   180  		if version != Version && o.UpgradeFunc != nil {
   181  			// Upgrade config file if version is different and write it
   182  			cfgMap, lockFileMap, err = upgrade(version, cfgMap, lockFileMap, o.UpgradeFunc)
   183  			if err != nil {
   184  				return nil, err
   185  			}
   186  
   187  			// Write back out to disk and update data
   188  			configRes.Data, err = write(configRes.Path, cfgMap, o)
   189  			if err != nil {
   190  				return nil, err
   191  			}
   192  
   193  			if lockFileMap != nil {
   194  				lockFileRes.Data, err = write(lockFileRes.Path, lockFileMap, o)
   195  				if err != nil {
   196  					return nil, err
   197  				}
   198  			}
   199  		}
   200  
   201  		if lockFileMap != nil {
   202  			if lockFileMap["features"] == nil && version != "" {
   203  				for _, lang := range o.langs {
   204  					newForLang[lang] = true
   205  				}
   206  			} else if features, ok := lockFileMap["features"].(map[string]interface{}); ok {
   207  				for _, lang := range o.langs {
   208  					if _, ok := features[lang]; !ok {
   209  						newForLang[lang] = true
   210  					}
   211  				}
   212  			}
   213  		} else if !lockFilePresent {
   214  			for _, lang := range o.langs {
   215  				newForLang[lang] = true
   216  			}
   217  		}
   218  	}
   219  
   220  	requiredDefaults := map[string]bool{}
   221  	for _, lang := range o.langs {
   222  		requiredDefaults[lang] = newForLang[lang]
   223  	}
   224  
   225  	defaultCfg, err := GetDefaultConfig(newSDK, o.getLanguageDefaultFunc, requiredDefaults)
   226  	if err != nil {
   227  		return nil, err
   228  	}
   229  
   230  	cfg, err := GetDefaultConfig(newSDK, o.getLanguageDefaultFunc, requiredDefaults)
   231  	if err != nil {
   232  		return nil, err
   233  	}
   234  
   235  	// If this is a totally new config, we need to write out to disk for following operations
   236  	if newConfig && o.UpgradeFunc != nil {
   237  		// Write new cfg
   238  		configRes.Data, err = write(configRes.Path, cfg, o)
   239  		if err != nil {
   240  			return nil, err
   241  		}
   242  	}
   243  
   244  	if lockFileRes.Data == nil && o.UpgradeFunc != nil {
   245  		lockFile := NewLockFile()
   246  		lockFileRes.Data, err = write(lockFileRes.Path, lockFile, o)
   247  		if err != nil {
   248  			return nil, err
   249  		}
   250  	}
   251  
   252  	// Okay finally able to unmarshal the config file into expected struct
   253  	if err := yaml.Unmarshal(configRes.Data, cfg); err != nil {
   254  		return nil, fmt.Errorf("could not unmarshal gen.yaml: %w", err)
   255  	}
   256  
   257  	var lockFile LockFile
   258  	if err := yaml.Unmarshal(lockFileRes.Data, &lockFile); err != nil {
   259  		return nil, fmt.Errorf("could not unmarshal gen.lock: %w", err)
   260  	}
   261  
   262  	cfg.New = newForLang
   263  
   264  	// Maps are overwritten by unmarshal, so we need to ensure that the defaults are set
   265  	for lang, langCfg := range defaultCfg.Languages {
   266  		if _, ok := cfg.Languages[lang]; !ok {
   267  			cfg.Languages[lang] = langCfg
   268  		}
   269  
   270  		for k, v := range langCfg.Cfg {
   271  			if cfg.Languages[lang].Cfg == nil {
   272  				langCfg = cfg.Languages[lang]
   273  				langCfg.Cfg = map[string]interface{}{}
   274  				cfg.Languages[lang] = langCfg
   275  			}
   276  
   277  			if _, ok := cfg.Languages[lang].Cfg[k]; !ok {
   278  				cfg.Languages[lang].Cfg[k] = v
   279  			}
   280  		}
   281  	}
   282  
   283  	if lockFile.Features == nil {
   284  		lockFile.Features = make(map[string]map[string]string)
   285  	}
   286  
   287  	config := &Config{
   288  		Config:     cfg,
   289  		ConfigPath: configRes.Path,
   290  		LockFile:   &lockFile,
   291  	}
   292  
   293  	if o.transformerFunc != nil {
   294  		config, err = o.transformerFunc(config)
   295  		if err != nil {
   296  			return nil, err
   297  		}
   298  	}
   299  
   300  	if o.UpgradeFunc != nil {
   301  		// Finally write out the files to solidfy any defaults, upgrades or transformations
   302  		if _, err := write(configRes.Path, config.Config, o); err != nil {
   303  			return nil, err
   304  		}
   305  		if _, err := write(lockFileRes.Path, config.LockFile, o); err != nil {
   306  			return nil, err
   307  		}
   308  	}
   309  
   310  	return config, nil
   311  }
   312  
   313  func GetTemplateVersion(dir, target string, opts ...Option) (string, error) {
   314  	o := applyOptions(opts)
   315  
   316  	configRes, err := FindConfigFile(dir, o.FS)
   317  	if err != nil {
   318  		return "", err
   319  	}
   320  	if configRes.Data == nil {
   321  		return "", nil
   322  	}
   323  
   324  	cfg := &Configuration{}
   325  	if err := yaml.Unmarshal(configRes.Data, cfg); err != nil {
   326  		return "", fmt.Errorf("could not unmarshal gen.yaml: %w", err)
   327  	}
   328  
   329  	if cfg.Languages == nil {
   330  		return "", nil
   331  	}
   332  
   333  	langCfg, ok := cfg.Languages[target]
   334  	if !ok {
   335  		return "", nil
   336  	}
   337  
   338  	tv, ok := langCfg.Cfg["templateVersion"]
   339  	if !ok {
   340  		return "", nil
   341  	}
   342  
   343  	return tv.(string), nil
   344  }
   345  
   346  func SaveConfig(dir string, cfg *Configuration, opts ...Option) error {
   347  	o := applyOptions(opts)
   348  
   349  	configRes, err := FindConfigFile(dir, o.FS)
   350  	if err != nil {
   351  		return err
   352  	}
   353  
   354  	if _, err := write(configRes.Path, cfg, o); err != nil {
   355  		return err
   356  	}
   357  
   358  	return nil
   359  }
   360  
   361  func SaveLockFile(dir string, lf *LockFile, opts ...Option) error {
   362  	o := applyOptions(opts)
   363  
   364  	lockFileRes, err := workspace.FindWorkspace(dir, workspace.FindWorkspaceOptions{
   365  		FindFile: lockFile,
   366  		FS:       o.FS,
   367  	})
   368  	if err != nil {
   369  		if !errors.Is(err, fs.ErrNotExist) {
   370  			return err
   371  		}
   372  		lockFileRes = &workspace.FindWorkspaceResult{
   373  			Path: filepath.Join(dir, workspace.SpeakeasyFolder, lockFile),
   374  		}
   375  	}
   376  
   377  	if _, err := write(lockFileRes.Path, lf, o); err != nil {
   378  		return err
   379  	}
   380  
   381  	return nil
   382  }
   383  
   384  func GetConfigChecksum(dir string, opts ...Option) (string, error) {
   385  	o := applyOptions(opts)
   386  
   387  	configRes, err := FindConfigFile(dir, o.FS)
   388  	if err != nil {
   389  		return "", err
   390  	}
   391  	if configRes.Data == nil {
   392  		return "", nil
   393  	}
   394  
   395  	hash := md5.Sum(configRes.Data)
   396  	return hex.EncodeToString(hash[:]), nil
   397  }
   398  
   399  func write(path string, cfg any, o *options) ([]byte, error) {
   400  	var b bytes.Buffer
   401  	buf := bufio.NewWriter(&b)
   402  
   403  	e := yaml.NewEncoder(buf)
   404  	e.SetIndent(2)
   405  	if err := e.Encode(cfg); err != nil {
   406  		return nil, fmt.Errorf("could not marshal gen.yaml: %w", err)
   407  	}
   408  
   409  	if err := buf.Flush(); err != nil {
   410  		return nil, fmt.Errorf("could not marshal gen.yaml: %w", err)
   411  	}
   412  
   413  	data := b.Bytes()
   414  
   415  	if o.dontWrite {
   416  		return data, nil
   417  	}
   418  
   419  	writeFileFunc := os.WriteFile
   420  	if o.FS != nil {
   421  		writeFileFunc = o.FS.WriteFile
   422  	}
   423  
   424  	if err := writeFileFunc(path, data, os.ModePerm); err != nil {
   425  		return nil, fmt.Errorf("could not write gen.yaml: %w", err)
   426  	}
   427  
   428  	return data, nil
   429  }
   430  
   431  func applyOptions(opts []Option) *options {
   432  	o := &options{
   433  		FS:    nil,
   434  		langs: []string{},
   435  	}
   436  	for _, opt := range opts {
   437  		opt(o)
   438  	}
   439  
   440  	return o
   441  }