github.com/nikron/prototool@v1.3.0/internal/settings/config_provider.go (about)

     1  // Copyright (c) 2018 Uber Technologies, Inc.
     2  //
     3  // Permission is hereby granted, free of charge, to any person obtaining a copy
     4  // of this software and associated documentation files (the "Software"), to deal
     5  // in the Software without restriction, including without limitation the rights
     6  // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
     7  // copies of the Software, and to permit persons to whom the Software is
     8  // furnished to do so, subject to the following conditions:
     9  //
    10  // The above copyright notice and this permission notice shall be included in
    11  // all copies or substantial portions of the Software.
    12  //
    13  // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
    14  // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
    15  // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
    16  // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
    17  // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
    18  // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
    19  // THE SOFTWARE.
    20  
    21  package settings
    22  
    23  import (
    24  	"bytes"
    25  	"encoding/json"
    26  	"fmt"
    27  	"io/ioutil"
    28  	"os"
    29  	"path/filepath"
    30  	"sort"
    31  	"strings"
    32  
    33  	"github.com/uber/prototool/internal/strs"
    34  	"go.uber.org/zap"
    35  	"gopkg.in/yaml.v2"
    36  )
    37  
    38  type configProvider struct {
    39  	logger *zap.Logger
    40  }
    41  
    42  func newConfigProvider(options ...ConfigProviderOption) *configProvider {
    43  	configProvider := &configProvider{
    44  		logger: zap.NewNop(),
    45  	}
    46  	for _, option := range options {
    47  		option(configProvider)
    48  	}
    49  	return configProvider
    50  }
    51  
    52  func (c *configProvider) GetForDir(dirPath string) (Config, error) {
    53  	filePath, err := c.GetFilePathForDir(dirPath)
    54  	if err != nil {
    55  		return Config{}, err
    56  	}
    57  	if filePath == "" {
    58  		return Config{}, nil
    59  	}
    60  	return c.Get(filePath)
    61  }
    62  
    63  func (c *configProvider) GetFilePathForDir(dirPath string) (string, error) {
    64  	if !filepath.IsAbs(dirPath) {
    65  		return "", fmt.Errorf("%s is not an absolute path", dirPath)
    66  	}
    67  	return getFilePathForDir(filepath.Clean(dirPath))
    68  }
    69  
    70  func (c *configProvider) Get(filePath string) (Config, error) {
    71  	if !filepath.IsAbs(filePath) {
    72  		return Config{}, fmt.Errorf("%s is not an absolute path", filePath)
    73  	}
    74  	filePath = filepath.Clean(filePath)
    75  	return get(filePath)
    76  }
    77  
    78  func (c *configProvider) GetForData(dirPath string, externalConfigData string) (Config, error) {
    79  	if !filepath.IsAbs(dirPath) {
    80  		return Config{}, fmt.Errorf("%s is not an absolute path", dirPath)
    81  	}
    82  	dirPath = filepath.Clean(dirPath)
    83  	var externalConfig ExternalConfig
    84  	if err := jsonUnmarshalStrict([]byte(externalConfigData), &externalConfig); err != nil {
    85  		return Config{}, err
    86  	}
    87  	return externalConfigToConfig(externalConfig, dirPath)
    88  }
    89  
    90  func (c *configProvider) GetExcludePrefixesForDir(dirPath string) ([]string, error) {
    91  	if !filepath.IsAbs(dirPath) {
    92  		return nil, fmt.Errorf("%s is not an absolute path", dirPath)
    93  	}
    94  	dirPath = filepath.Clean(dirPath)
    95  	return getExcludePrefixesForDir(dirPath)
    96  }
    97  
    98  func (c *configProvider) GetExcludePrefixesForData(dirPath string, externalConfigData string) ([]string, error) {
    99  	if !filepath.IsAbs(dirPath) {
   100  		return nil, fmt.Errorf("%s is not an absolute path", dirPath)
   101  	}
   102  	dirPath = filepath.Clean(dirPath)
   103  	var externalConfig ExternalConfig
   104  	if err := jsonUnmarshalStrict([]byte(externalConfigData), &externalConfig); err != nil {
   105  		return nil, err
   106  	}
   107  	return getExcludePrefixes(externalConfig.Excludes, dirPath)
   108  }
   109  
   110  // getFilePathForDir tries to find a file named by one of the ConfigFilenames starting in the
   111  // given directory, and going up a directory until hitting root.
   112  //
   113  // The directory must be an absolute path.
   114  //
   115  // If no such file is found, "" is returned.
   116  // If multiple files named by one of the ConfigFilenames are found in the same
   117  // directory, error is returned.
   118  func getFilePathForDir(dirPath string) (string, error) {
   119  	for {
   120  		filePath, err := getSingleFilePathForDir(dirPath)
   121  		if err != nil {
   122  			return "", err
   123  		}
   124  		if filePath != "" {
   125  			return filePath, nil
   126  		}
   127  		if dirPath == "/" {
   128  			return "", nil
   129  		}
   130  		dirPath = filepath.Dir(dirPath)
   131  	}
   132  }
   133  
   134  // getSingleFilePathForDir gets the file named by one of the ConfigFilenames in the
   135  // given directory. Having multiple such files results in an error being returned. If no file is
   136  // found, this returns "".
   137  func getSingleFilePathForDir(dirPath string) (string, error) {
   138  	var filePaths []string
   139  	for _, configFilename := range ConfigFilenames {
   140  		filePath := filepath.Join(dirPath, configFilename)
   141  		if _, err := os.Stat(filePath); err == nil {
   142  			filePaths = append(filePaths, filePath)
   143  		}
   144  	}
   145  	switch len(filePaths) {
   146  	case 0:
   147  		return "", nil
   148  	case 1:
   149  		return filePaths[0], nil
   150  	default:
   151  		return "", fmt.Errorf("multiple configuration files in the same directory: %v", filePaths)
   152  	}
   153  }
   154  
   155  // get reads the config at the given path.
   156  //
   157  // This is expected to be in YAML or JSON format, which is denoted by the file extension.
   158  func get(filePath string) (Config, error) {
   159  	externalConfig, err := getExternalConfig(filePath)
   160  	if err != nil {
   161  		return Config{}, err
   162  	}
   163  	return externalConfigToConfig(externalConfig, filepath.Dir(filePath))
   164  }
   165  
   166  func getExternalConfig(filePath string) (ExternalConfig, error) {
   167  	data, err := ioutil.ReadFile(filePath)
   168  	if err != nil {
   169  		return ExternalConfig{}, err
   170  	}
   171  	if len(data) == 0 {
   172  		return ExternalConfig{}, nil
   173  	}
   174  	externalConfig := ExternalConfig{}
   175  	switch filepath.Ext(filePath) {
   176  	case ".json":
   177  		if err := jsonUnmarshalStrict(data, &externalConfig); err != nil {
   178  			return ExternalConfig{}, err
   179  		}
   180  		return externalConfig, nil
   181  	case ".yaml":
   182  		if err := yaml.UnmarshalStrict(data, &externalConfig); err != nil {
   183  			return ExternalConfig{}, err
   184  		}
   185  		return externalConfig, nil
   186  	default:
   187  		return ExternalConfig{}, fmt.Errorf("unknown config file extension, must be .json or .yaml: %s", filePath)
   188  	}
   189  }
   190  
   191  // externalConfigToConfig converts an ExternalConfig to a Config.
   192  //
   193  // This will return a valid Config, or an error.
   194  func externalConfigToConfig(e ExternalConfig, dirPath string) (Config, error) {
   195  	excludePrefixes, err := getExcludePrefixes(e.Excludes, dirPath)
   196  	if err != nil {
   197  		return Config{}, err
   198  	}
   199  	includePaths := make([]string, 0, len(e.Protoc.Includes))
   200  	for _, includePath := range strs.DedupeSort(e.Protoc.Includes, nil) {
   201  		if !filepath.IsAbs(includePath) {
   202  			includePath = filepath.Join(dirPath, includePath)
   203  		}
   204  		includePath = filepath.Clean(includePath)
   205  		includePaths = append(includePaths, includePath)
   206  	}
   207  	ignoreIDToFilePaths := make(map[string][]string)
   208  	for _, ignore := range e.Lint.Ignores {
   209  		id := strings.ToUpper(ignore.ID)
   210  		for _, protoFilePath := range ignore.Files {
   211  			if !filepath.IsAbs(protoFilePath) {
   212  				protoFilePath = filepath.Join(dirPath, protoFilePath)
   213  			}
   214  			protoFilePath = filepath.Clean(protoFilePath)
   215  			if _, ok := ignoreIDToFilePaths[id]; !ok {
   216  				ignoreIDToFilePaths[id] = make([]string, 0)
   217  			}
   218  			ignoreIDToFilePaths[id] = append(ignoreIDToFilePaths[id], protoFilePath)
   219  		}
   220  	}
   221  
   222  	genPlugins := make([]GenPlugin, len(e.Gen.Plugins))
   223  	for i, plugin := range e.Gen.Plugins {
   224  		genPluginType, err := ParseGenPluginType(plugin.Type)
   225  		if err != nil {
   226  			return Config{}, err
   227  		}
   228  		if plugin.Output == "" {
   229  			return Config{}, fmt.Errorf("output path required for plugin %s", plugin.Name)
   230  		}
   231  		var relPath, absPath string
   232  		if filepath.IsAbs(plugin.Output) {
   233  			absPath = filepath.Clean(plugin.Output)
   234  			relPath, err = filepath.Rel(dirPath, absPath)
   235  			if err != nil {
   236  				return Config{}, fmt.Errorf("failed to resolve plugin %q output absolute path %q to a relative path with base %q: %v", plugin.Name, absPath, dirPath, err)
   237  			}
   238  		} else {
   239  			relPath = plugin.Output
   240  			absPath = filepath.Clean(filepath.Join(dirPath, relPath))
   241  		}
   242  		genPlugins[i] = GenPlugin{
   243  			Name:  plugin.Name,
   244  			Path:  plugin.Path,
   245  			Type:  genPluginType,
   246  			Flags: plugin.Flags,
   247  			OutputPath: OutputPath{
   248  				RelPath: relPath,
   249  				AbsPath: absPath,
   250  			},
   251  		}
   252  	}
   253  	sort.Slice(genPlugins, func(i int, j int) bool { return genPlugins[i].Name < genPlugins[j].Name })
   254  
   255  	createDirPathToBasePackage := make(map[string]string)
   256  	for _, pkg := range e.Create.Packages {
   257  		relDirPath := pkg.Directory
   258  		basePackage := pkg.Name
   259  		if relDirPath == "" {
   260  			return Config{}, fmt.Errorf("directory for create package is empty")
   261  		}
   262  		if basePackage == "" {
   263  			return Config{}, fmt.Errorf("name for create package is empty")
   264  		}
   265  		if filepath.IsAbs(relDirPath) {
   266  			return Config{}, fmt.Errorf("directory for create package must be relative: %s", relDirPath)
   267  		}
   268  		createDirPathToBasePackage[filepath.Clean(filepath.Join(dirPath, relDirPath))] = basePackage
   269  	}
   270  	// to make testing easier
   271  	if len(createDirPathToBasePackage) == 0 {
   272  		createDirPathToBasePackage = nil
   273  	}
   274  
   275  	config := Config{
   276  		DirPath:         dirPath,
   277  		ExcludePrefixes: excludePrefixes,
   278  		Compile: CompileConfig{
   279  			ProtobufVersion:       e.Protoc.Version,
   280  			IncludePaths:          includePaths,
   281  			IncludeWellKnownTypes: true, // Always include the well-known types.
   282  			AllowUnusedImports:    e.Protoc.AllowUnusedImports,
   283  		},
   284  		Create: CreateConfig{
   285  			DirPathToBasePackage: createDirPathToBasePackage,
   286  		},
   287  		Lint: LintConfig{
   288  			IncludeIDs:          strs.DedupeSort(e.Lint.Rules.Add, strings.ToUpper),
   289  			ExcludeIDs:          strs.DedupeSort(e.Lint.Rules.Remove, strings.ToUpper),
   290  			NoDefault:           e.Lint.Rules.NoDefault,
   291  			IgnoreIDToFilePaths: ignoreIDToFilePaths,
   292  		},
   293  		Gen: GenConfig{
   294  			GoPluginOptions: GenGoPluginOptions{
   295  				ImportPath:     e.Gen.GoOptions.ImportPath,
   296  				ExtraModifiers: e.Gen.GoOptions.ExtraModifiers,
   297  			},
   298  			Plugins: genPlugins,
   299  		},
   300  	}
   301  
   302  	for _, genPlugin := range config.Gen.Plugins {
   303  		// TODO: technically protoc-gen-protoc-gen-foo is a valid
   304  		// plugin binary with name protoc-gen-foo, but do we want
   305  		// to error if protoc-gen- is a prefix of a name?
   306  		// I think this will be a common enough mistake that we
   307  		// can remove this later. Or, do we want names to include
   308  		// the protoc-gen- part?
   309  		if strings.HasPrefix(genPlugin.Name, "protoc-gen-") {
   310  			return Config{}, fmt.Errorf("plugin name provided was %s, do not include the protoc-gen- prefix", genPlugin.Name)
   311  		}
   312  		if _, ok := _genPluginTypeToString[genPlugin.Type]; !ok {
   313  			return Config{}, fmt.Errorf("unknown GenPluginType: %v", genPlugin.Type)
   314  		}
   315  		if (genPlugin.Type.IsGo() || genPlugin.Type.IsGogo()) && config.Gen.GoPluginOptions.ImportPath == "" {
   316  			return Config{}, fmt.Errorf("go plugin %s specified but no import path provided", genPlugin.Name)
   317  		}
   318  	}
   319  
   320  	if intersection := strs.Intersection(config.Lint.IncludeIDs, config.Lint.ExcludeIDs); len(intersection) > 0 {
   321  		return Config{}, fmt.Errorf("config had intersection of %v between lint_include and lint_exclude", intersection)
   322  	}
   323  	return config, nil
   324  }
   325  
   326  func getExcludePrefixesForDir(dirPath string) ([]string, error) {
   327  	filePath, err := getSingleFilePathForDir(dirPath)
   328  	if err != nil {
   329  		return nil, err
   330  	}
   331  	if filePath == "" {
   332  		return []string{}, nil
   333  	}
   334  	externalConfig, err := getExternalConfig(filePath)
   335  	if err != nil {
   336  		return nil, err
   337  	}
   338  	return getExcludePrefixes(externalConfig.Excludes, dirPath)
   339  }
   340  
   341  func getExcludePrefixes(excludes []string, dirPath string) ([]string, error) {
   342  	excludePrefixes := make([]string, 0, len(excludes))
   343  	for _, excludePrefix := range strs.DedupeSort(excludes, nil) {
   344  		if !filepath.IsAbs(excludePrefix) {
   345  			excludePrefix = filepath.Join(dirPath, excludePrefix)
   346  		}
   347  		excludePrefix = filepath.Clean(excludePrefix)
   348  		if excludePrefix == dirPath {
   349  			return nil, fmt.Errorf("cannot exclude directory of config file: %s", dirPath)
   350  		}
   351  		if !strings.HasPrefix(excludePrefix, dirPath) {
   352  			return nil, fmt.Errorf("cannot exclude directory outside of config file directory %s: %s", dirPath, excludePrefix)
   353  		}
   354  		excludePrefixes = append(excludePrefixes, excludePrefix)
   355  	}
   356  	return excludePrefixes, nil
   357  }
   358  
   359  // jsonUnmarshalStrict makes sure there are no unknown fields when unmarshalling.
   360  // This matches what yaml.UnmarshalStrict does basically.
   361  // json.Unmarshal allows unknown fields.
   362  func jsonUnmarshalStrict(data []byte, v interface{}) error {
   363  	decoder := json.NewDecoder(bytes.NewReader(data))
   364  	decoder.DisallowUnknownFields()
   365  	return decoder.Decode(v)
   366  }