Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

options: implementing dry-mode #12

Draft
wants to merge 1 commit into
base: master
Choose a base branch
from
Draft

Conversation

FranciscoKurpiel
Copy link
Collaborator

Implement support for --dry-mode flag, which will validate all other parameters and exit. A callback function must be provided to specify what to do after validation. This callback function have access to the Parsed object, which allows getting information about the validation errors, and can be used to exit with any specific code.

Closes #5

@FranciscoKurpiel FranciscoKurpiel self-assigned this Aug 27, 2022
@github-actions
Copy link

go-cover-view

github.com/simplesurance/proteus/options.go
    1: package proteus
    2:
    3: import (
    4: 	"io"
    5:
    6: 	"github.com/simplesurance/proteus/plog"
    7: 	"github.com/simplesurance/proteus/sources"
    8: )
    9:
   10: // Option specifes options when creating a configuration parser.
   11: type Option func(*settings)
   12:
   13: type settings struct {
   14: 	providers   []sources.Provider
   15: 	loggerFn    plog.Logger
   16: 	onelineDesc string
   17:
   18: 	// auto-usage (aka --help)
   19: 	autoUsageExitFn func()
   20: 	autoUsageWriter io.Writer
   21:
   22: 	// dry-mode
   23: 	autoDryModeFn func(*Parsed)
   24: }
   25:
O  26: func (s *settings) apply(options ...Option) {
O  27: 	for _, o := range options {
O  28: 		o(s)
O  29: 	}
   30: }
   31:
   32: // WithProviders specifies from where the configuration should be read.
   33: // If not specified, proteus will use the equivalent to:
   34: //
   35: //	WithEnv(cfgflags.New(), cfgenv.New("CFG"))
   36: //
   37: // Providing this option override any previous configuration for providers.
O  38: func WithProviders(s ...sources.Provider) Option {
O  39: 	return func(p *settings) {
O  40: 		p.providers = s
O  41: 	}
   42: }
   43:
   44: // WithShortDescription species a short one-line description for
   45: // the application. Is used when generating help information.
X  46: func WithShortDescription(oneline string) Option {
X  47: 	return func(p *settings) {
X  48: 		p.onelineDesc = oneline
X  49: 	}
   50: }
   51:
   52: // WithAutoUsage will change how the --help parameter is parsed, allowing to
   53: // specify a writer, for the usage information and an "exit function".
   54: // If not specified, proteus will use stdout for writer and will use
   55: // os.Exit(0) as exit function.
X  56: func WithAutoUsage(writer io.Writer, exitFn func()) Option {
X  57: 	return func(p *settings) {
X  58: 		p.autoUsageExitFn = exitFn
X  59: 		p.autoUsageWriter = writer
X  60: 	}
   61: }
   62:
   63: // WithLogger provides a custom logger. By default logs are suppressed.
   64: //
   65: // Warning: the "Logger" interface is expected to change in the stable release.
O  66: func WithLogger(l plog.Logger) Option {
O  67: 	return func(p *settings) {
O  68: 		p.loggerFn = l
O  69: 	}
   70: }
   71:
   72: // WithPrintfLogger use the printf-style logFn function as logger.
X  73: func WithPrintfLogger(logFn func(format string, v ...any)) Option {
X  74: 	return func(p *settings) {
X  75: 		p.loggerFn = func(e plog.Entry) {
X  76: 			logFn("%-5s %s:%d %s\n",
X  77: 				e.Severity, e.Caller.File, e.Caller.LineNumber, e.Message)
X  78: 		}
   79: 	}
   80: }
   81:
   82: // WithDryMode add a special parameter "--dry-mode", that can only be provided
   83: // by command-line flags, and can be used to validate parameters without
   84: // executing the application. The provided callback function will be invoked
   85: // and will have access to the Parsed object, than can then be used to
   86: // print errors, dump variable values or exit with some status code.
   87: //
   88: // If the callback function does not exit, proteus will terminate the
   89: // application with 0 or 1, depending on the validation being successful or
   90: // not.
   91: //
   92: //	parsed, err := proteus.MustParse(&params,
   93: //		proteus.WithDryMode(func(parsed *proteus.Parsed) {
   94: //			if err := parsed.Valid(); err != nil {
   95: //				parsed.ErrUsage(os.Stdout, err)
   96: //				os.Exit(42)
   97: //			}
   98: //
   99: //			fmt.Println("The following parameters are found to be valid:")
  100: //			parsed.Dump(os.Stdout)
  101: //			os.Exit(0)
  102: //		}))
X 103: func WithDryMode(f func(*Parsed)) Option {
X 104: 	return func(p *settings) {
X 105: 		p.autoDryModeFn = f
X 106: 	}
  107: }

github.com/simplesurance/proteus/parser.go
    1: package proteus
    2:
    3: import (
    4: 	"errors"
    5: 	"fmt"
    6: 	"os"
    7: 	"reflect"
    8: 	"strings"
    9:
   10: 	"github.com/simplesurance/proteus/internal/consts"
   11: 	"github.com/simplesurance/proteus/plog"
   12: 	"github.com/simplesurance/proteus/sources"
   13: 	"github.com/simplesurance/proteus/sources/cfgenv"
   14: 	"github.com/simplesurance/proteus/sources/cfgflags"
   15: 	"github.com/simplesurance/proteus/types"
   16: )
   17:
   18: // MustParse receives on "config" a pointer to a struct that defines the
   19: // expected application parameters and loads the parameters values into it.
   20: // An example of a configuration is struct is as follows:
   21: //
   22: //	params := struct{
   23: //		Name      string                                       // simple parameter
   24: //		IsEnabled bool   `param:"is_enabled"`                  // rename parameter
   25: //		Password  string `param:"pwd,secret"`                  // rename and mark parameter as secret
   26: //		Port      uint16 `param:",optional"`                   // keep the name, mark as optional
   27: //		LogLevel  string `param_desc:"Cut-off level for logs"` // describes the parameter
   28: //		X         string `param:"-"`                           // ignore this field
   29: //	}{
   30: //		Port: 8080, // default value for optional parameter
   31: //	}
   32: //
   33: // The tag "param" has the format "name[,option]*", where name is either empty,
   34: // "-" or a lowercase arbitrary string containing a-z, 0-9, _ or -, starting with a-z and
   35: // terminating not with - or _.
   36: // The value "-" for the name result in the field being ignored. The empty
   37: // string value indicates to infer the parameter name from the struct name. The
   38: // inferred parameter name is the struct name in lowercase.
   39: // Option can be either "secret" or "optional". An option can be provided
   40: // without providing the name of the parameter by using an empty value for the
   41: // name, resulting in the "param" tag starting with ",".
   42: //
   43: // The tag "param_desc" is an arbitrary string describing what the parameter
   44: // is for. This will be shown to the user when usage information is requested.
   45: //
   46: // The provided struct can have any level of embedded structs. Embedded
   47: // structs are handled as if they were "flat":
   48: //
   49: //	type httpParams struct {
   50: //		Server string
   51: //		Port   uint16
   52: //	}
   53: //
   54: //	parmas := struct{
   55: //		httpParams
   56: //		LogLevel string
   57: //	}{}
   58: //
   59: // Is the same as:
   60: //
   61: //	params := struct {
   62: //		Server   string
   63: //		Port     uint16
   64: //		LogLevel string
   65: //	}{}
   66: //
   67: // Configuration structs can also have "xtypes". Xtypes provide support for
   68: // getting updates when parameter values change and other types-specific
   69: // optons.
   70: //
   71: //	params := struct{
   72: //		LogLevel *xtypes.OneOf
   73: //	}{
   74: //		OneOf: &xtypes.OneOf{
   75: //			Choices: []string{"debug", "info", "error"},
   76: //			Default: "info",
   77: //			UpdateFn: func(newVal string) {
   78: //				fmt.Printf("new log level: %s\n", newVal)
   79: //			}
   80: //		}
   81: //	}
   82: //
   83: // The "options" parameter provides further customization. The option
   84: // WithProviders() must be specified to define from what sources the parameters
   85: // must be read.
   86: //
   87: // The configuration struct can have named sub-structs (in opposition to
   88: // named, or embedded sub-structs, already mentioned above). The sub-structs
   89: // can be up to 1 level deep, and can be used to represent "parameter sets".
   90: // Two parameters can have the same name, as long as they belong to different
   91: // parameter sets. Example:
   92: //
   93: //	params := struct{
   94: //		Database struct {
   95: //			Host     string
   96: //			Username string
   97: //			Password string `param:,secret`
   98: //		}
   99: //		Tracing struct {
  100: //			Host     string
  101: //			Username string
  102: //			Password string `param:,secret`
  103: //		}
  104: //	}{}
  105: //
  106: // Complete usage example:
  107: //
  108: //	func main() {
  109: //		params := struct {
  110: //			X int
  111: //		}{}
  112: //
  113: //		parsed, err := proteus.MustParse(&params,
  114: //			proteus.WithAutoUsage(os.Stdout, "My Application", func() { os.Exit(0) }),
  115: //			proteus.WithProviders(
  116: //				cfgflags.New(),
  117: //				cfgenv.New("CFG"),
  118: //			))
  119: //		if err != nil {
  120: //			parsed.ErrUsage(os.Stderr, err)
  121: //			os.Exit(1)
  122: //		}
  123: //
  124: //		// "parsed" now have the parameter values
  125: //	}
  126: //
  127: // See godoc for more examples.
  128: //
  129: // A Parsed object is guaranteed to be always returned, even in case of error,
  130: // allowing the creation of useful error messages.
O 131: func MustParse(config any, options ...Option) (*Parsed, error) {
O 132: 	opts := settings{
O 133: 		providers: []sources.Provider{
O 134: 			cfgflags.New(),
O 135: 			cfgenv.New("CFG"),
O 136: 		},
X 137: 		loggerFn:        func(log plog.Entry) {}, // nop logger
X 138: 		autoUsageExitFn: func() { os.Exit(0) },
  139: 		autoUsageWriter: os.Stdout,
  140: 	}
O 141: 	opts.apply(options...)
O 142:
O 143: 	appConfig, err := inferConfigFromValue(config, opts)
X 144: 	if err != nil {
X 145: 		panic(fmt.Errorf("INVALID CONFIGURATION STRUCT: %v", err))
  146: 	}
  147:
X 148: 	if len(opts.providers) == 0 {
X 149: 		panic(fmt.Errorf("NO CONFIGURATION PROVIDER WAS PROVIDED"))
  150: 	}
  151:
O 152: 	ret := Parsed{
O 153: 		settings:      opts,
O 154: 		inferedConfig: appConfig,
O 155: 	}
O 156:
O 157: 	ret.protected.values = make([]types.ParamValues, len(opts.providers))
O 158:
X 159: 	if err := addSpecialFlags(appConfig, &ret, opts); err != nil {
X 160: 		return &ret, err
X 161: 	}
  162:
  163: 	// all optional xtypes must have valid default values
O 164: 	err = ret.validateXTypeOptionalDefaults()
X 165: 	if err != nil {
X 166: 		panic(fmt.Errorf("INVALID USAGE OF XTYPE: %v", err))
  167: 	}
  168:
  169: 	// start watching each configuration item on each provider
O 170: 	updaters := make([]*updater, len(opts.providers))
O 171: 	for ix, provider := range opts.providers {
O 172: 		updater := &updater{
O 173: 			parsed:         &ret,
O 174: 			providerIndex:  ix,
O 175: 			providerName:   fmt.Sprintf("%T", provider),
O 176: 			updatesEnabled: make(chan struct{})}
O 177:
O 178: 		updaters[ix] = updater
O 179:
O 180: 		initial, err := provider.Watch(
O 181: 			appConfig.paramInfo(provider.IsCommandLineFlag()),
O 182: 			updater)
X 183: 		if err != nil {
X 184: 			return &ret, err
X 185: 		}
  186:
  187: 		// use the updater to store the initial values; do NOT update the
  188: 		// "config" struct yet
O 189: 		updater.update(initial, false)
  190: 	}
  191:
O 192: 	if err := ret.valid(); err != nil {
O 193: 		return &ret, err
O 194: 	}
  195:
  196: 	// send values back to the user by updating the fields on the
  197: 	// "config" parameter
O 198: 	ret.refresh(true)
O 199:
O 200: 	// allow all sources to provide updates
O 201: 	for _, updater := range updaters {
O 202: 		close(updater.updatesEnabled)
O 203: 	}
  204:
O 205: 	return &ret, nil
  206: }
  207:
O 208: func inferConfigFromValue(value any, opts settings) (config, error) {
X 209: 	if reflect.ValueOf(value).Kind() != reflect.Ptr {
X 210: 		return nil, errors.New("configuration struct must be a pointer")
X 211: 	}
  212:
O 213: 	val := reflect.ValueOf(value)
X 214: 	if val.IsNil() {
X 215: 		return nil, errors.New("provided configuration struct is nil")
X 216: 	}
  217:
O 218: 	val = val.Elem()
O 219:
O 220: 	ret := config{"": paramSet{fields: map[string]paramSetField{}}}
O 221:
O 222: 	// each member of the configuration struct can be either:
O 223: 	// - parameter: meaning that values must be loaded into it
O 224: 	// - set of parameters: meaning that is a structure that contains more
O 225: 	//   parameter.
O 226: 	// - ignored: identified with: param:"-"
O 227: 	members, err := flatWalk("", "", val)
X 228: 	if err != nil {
X 229: 		return nil, fmt.Errorf("walking root fields of the configuration struct: %w", err)
X 230: 	}
  231:
O 232: 	var violations types.ErrViolations
O 233: 	for _, member := range members {
O 234: 		name, tag, err := parseParam(member.field, member.value)
X 235: 		if err != nil {
X 236: 			var paramViolations types.ErrViolations
X 237: 			if errors.As(err, &paramViolations) {
X 238: 				violations = append(violations, paramViolations...)
X 239: 				continue
  240: 			}
  241:
X 242: 			violations = append(violations, types.Violation{
X 243: 				Path:    member.Path,
X 244: 				Message: fmt.Sprintf("error reading struct tag: %v", err),
X 245: 			})
X 246: 			continue
  247: 		}
  248:
X 249: 		if name == "-" {
X 250: 			continue
  251: 		}
  252:
X 253: 		if !consts.ParamNameRE.MatchString(name) {
X 254: 			violations = append(violations, types.Violation{
X 255: 				Path: member.Path,
X 256: 				Message: fmt.Sprintf("Name %q is invalid for parameter or set (valid: %s)",
X 257: 					name, consts.ParamNameRE)})
X 258: 		}
  259:
O 260: 		tag.path = member.Path
O 261:
O 262: 		if tag.paramSet {
O 263: 			// is a set or parameters
O 264: 			d, err := parseParamSet(name, member.Path, member.value)
X 265: 			if err != nil {
X 266: 				var setViolations types.ErrViolations
X 267: 				if errors.As(err, &setViolations) {
X 268: 					violations = append(violations, setViolations...)
X 269: 					continue
  270: 				}
  271:
X 272: 				violations = append(violations, types.Violation{
X 273: 					Path:    member.Path,
X 274: 					SetName: name,
X 275: 					Message: fmt.Sprintf("parsing set: %v", err),
X 276: 				})
X 277: 				continue
  278: 			}
  279:
O 280: 			d.desc = tag.desc
O 281: 			ret[name] = d
O 282: 			continue
  283: 		}
  284:
  285: 		// is a parameter, add to root set
O 286: 		ret[""].fields[name] = tag
  287: 	}
  288:
X 289: 	if len(violations) > 0 {
X 290: 		return nil, violations
X 291: 	}
  292:
O 293: 	return ret, nil
  294: }
  295:
O 296: func parseParamSet(setName, setPath string, val reflect.Value) (paramSet, error) {
O 297: 	members, err := flatWalk(setName, setPath, val)
X 298: 	if err != nil {
X 299: 		return paramSet{}, err
X 300: 	}
  301:
O 302: 	ret := paramSet{
O 303: 		fields: make(map[string]paramSetField, len(members)),
O 304: 	}
O 305:
O 306: 	violations := types.ErrViolations{}
O 307: 	for _, member := range members {
O 308: 		paramName, tag, err := parseParam(member.field, member.value)
X 309: 		if err != nil {
X 310: 			violations = append(violations, types.Violation{
X 311: 				Path:    member.Path,
X 312: 				Message: err.Error(),
X 313: 			})
X 314: 			continue
  315: 		}
  316:
X 317: 		if paramName == "-" || tag.paramSet {
X 318: 			continue
  319: 		}
  320:
X 321: 		if !consts.ParamNameRE.MatchString(paramName) {
X 322: 			violations = append(violations, types.Violation{
X 323: 				Path:    member.Path,
X 324: 				SetName: setName,
X 325: 				Message: fmt.Sprintf("Name %q is invalid for parameter or set (valid: %s)",
X 326: 					paramName, consts.ParamNameRE)})
X 327: 		}
  328:
O 329: 		tag.path = member.Path
O 330: 		ret.fields[paramName] = tag
  331: 	}
  332:
X 333: 	if len(violations) > 0 {
X 334: 		return ret, violations
X 335: 	}
  336:
O 337: 	return ret, nil
  338: }
  339:
  340: func parseParam(structField reflect.StructField, fieldVal reflect.Value) (
  341: 	paramName string,
  342: 	_ paramSetField,
  343: 	_ error,
O 344: ) {
O 345: 	tagParam := structField.Tag.Get("param")
O 346: 	tagParamParts := strings.Split(tagParam, ",")
O 347: 	paramName = tagParamParts[0]
O 348:
O 349: 	ret := paramSetField{
O 350: 		desc: structField.Tag.Get("param_desc"),
O 351: 	}
O 352:
O 353: 	for _, tagOption := range tagParamParts[1:] {
O 354: 		switch tagOption {
O 355: 		case "optional":
O 356: 			ret.optional = true
X 357: 		case "secret":
X 358: 			ret.secret = true
X 359: 		default:
X 360: 			return paramName, ret, fmt.Errorf(
X 361: 				"option '%s' is invalid for tag 'param' in '%s'; valid options are optional|secret",
X 362: 				tagOption,
X 363: 				tagParam)
  364: 		}
  365: 	}
  366:
  367: 	// if the parameter name is not provided in the "param" tag field then
  368: 	// the name of the struct member in lowercase is used as parameter
  369: 	// name.
O 370: 	if paramName == "" {
O 371: 		paramName = strings.ToLower(structField.Name)
O 372: 	}
  373:
  374: 	// try to configure it as a "basic type"
O 375: 	err := configStandardCallbacks(&ret, fieldVal)
O 376: 	if err == nil {
O 377: 		ret.typ = describeType(fieldVal)
O 378: 		return paramName, ret, nil
O 379: 	}
  380:
  381: 	// try to configure it as an "xtype"
O 382: 	ok, err := isXType(structField.Type)
X 383: 	if err != nil {
X 384: 		return paramName, ret, err
X 385: 	}
  386:
O 387: 	if ok {
O 388: 		ret.isXtype = true
O 389:
O 390: 		if fieldVal.IsNil() {
O 391: 			fieldVal.Set(reflect.New(fieldVal.Type().Elem()))
O 392: 		}
  393:
O 394: 		ret.boolean = describeType(fieldVal) == "bool"
O 395: 		ret.typ = describeType(fieldVal)
O 396:
O 397: 		ret.validFn = toXType(fieldVal).ValueValid
O 398: 		ret.setValueFn = toXType(fieldVal).UnmarshalParam
O 399: 		ret.getDefaultFn = toXType(fieldVal).GetDefaultValue
O 400:
O 401: 		// some types know how to redact themselves (for example,
O 402: 		// xtype.URL know how to redact the password)
O 403: 		if redactor := toRedactor(fieldVal); redactor != nil {
O 404: 			ret.redactFn = redactor.RedactValue
O 405: 		} else {
O 406: 			ret.redactFn = func(s string) string { return s }
  407: 		}
  408:
O 409: 		return paramName, ret, nil
  410: 	}
  411:
  412: 	// if is a struct, assume it to be a parameter set
O 413: 	if fieldVal.Kind() == reflect.Struct {
O 414: 		ret.paramSet = true
O 415:
O 416: 		// parameter sets have no value, and the callback functions should
O 417: 		// not be called; install handlers to help debug in case of a mistake.
O 418: 		panicMessage := fmt.Sprintf("%q is a paramset, it have no value", paramName)
X 419: 		ret.validFn = func(v string) error { panic(panicMessage) }
X 420: 		ret.setValueFn = func(v *string) error { panic(panicMessage) }
X 421: 		ret.getDefaultFn = func() (string, error) { panic(panicMessage) }
X 422: 		ret.redactFn = func(s string) string { panic(panicMessage) }
  423:
O 424: 		return paramName, ret, nil
  425: 	}
  426:
X 427: 	return paramName, ret, fmt.Errorf("struct member %q is unsupported", paramName)
  428: }
  429:
  430: // addSpecialFlags register flags like "--help" that the caller might have
  431: // requested, that can only be provided by command-line flags, and that have
  432: // to be handled in a special way by proteus.
O 433: func addSpecialFlags(appConfig config, parsed *Parsed, opts settings) error {
O 434: 	var violations types.ErrViolations
O 435:
O 436: 	// --help
O 437: 	if opts.autoUsageExitFn != nil {
O 438: 		helpFlagName := "help"
O 439: 		helpFlagDescription := "Prints information about how to use this application"
O 440:
X 441: 		if conflictingParam, exists := appConfig.getParam("", helpFlagName); exists {
X 442: 			violations = append(violations, types.Violation{
X 443: 				ParamName: helpFlagName,
X 444: 				Path:      conflictingParam.path,
X 445: 				Message:   "The help parameter cannot be used when the auto-usage is requested",
X 446: 			})
O 447: 		} else {
O 448: 			appConfig[""].fields[helpFlagName] = paramSetField{
O 449: 				typ:       "bool",
O 450: 				optional:  true,
O 451: 				desc:      helpFlagDescription,
O 452: 				boolean:   true,
O 453: 				isSpecial: true,
O 454:
O 455: 				// when the --help flag is provided, the parsed object will
O 456: 				// try to determine if the value is valid. Generate the
O 457: 				// help usage instead of terminate the application.
X 458: 				validFn: func(v string) error {
X 459: 					parsed.Usage(opts.autoUsageWriter)
X 460: 					parsed.settings.autoUsageExitFn()
X 461:
X 462: 					fmt.Fprintln(opts.autoUsageWriter, "WARNING: the provided termination function did not terminated the application")
X 463: 					os.Exit(0)
X 464: 					return nil
X 465: 				},
X 466: 				setValueFn:   func(_ *string) error { return nil },
O 467: 				getDefaultFn: func() (string, error) { return "false", nil },
O 468: 				redactFn:     func(s string) string { return s },
  469: 			}
  470: 		}
  471: 	}
  472:
X 473: 	if opts.autoDryModeFn != nil {
X 474: 		appConfig[""].fields["dry-mode"] = paramSetField{
X 475: 			typ:       "bool",
X 476: 			optional:  true,
X 477: 			desc:      "Validates all other parameters and exit",
X 478: 			boolean:   true,
X 479: 			isSpecial: true,
X 480:
X 481: 			// when the --help flag is provided, the parsed object will
X 482: 			// try to determine if the value is valid. Generate the
X 483: 			// help usage instead of terminate the application.
X 484: 			validFn: func(v string) error {
X 485: 				parsed.Usage(opts.autoUsageWriter)
X 486: 				parsed.settings.autoUsageExitFn()
X 487:
X 488: 				fmt.Fprintln(opts.autoUsageWriter, "WARNING: the provided termination function did not terminated the application")
X 489: 				os.Exit(0)
X 490: 				return nil
X 491: 			},
X 492: 			setValueFn:   func(_ *string) error { return nil },
X 493: 			getDefaultFn: func() (string, error) { return "false", nil },
X 494: 			redactFn:     func(s string) string { return s },
  495: 		}
  496: 	}
  497:
X 498: 	if len(violations) > 0 {
X 499: 		return nil
X 500: 	}
  501:
O 502: 	return nil
  503: }
  504:
O 505: func describeType(val reflect.Value) string {
O 506: 	t := val.Type()
O 507: 	if ok, _ := isXType(t); ok {
O 508: 		return describeXType(val)
O 509: 	}
  510:
O 511: 	return t.Name()
  512: }

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Development

Successfully merging this pull request may close these issues.

Feature: add support for --dry-mode
1 participant