From 0dd576c8ccf440fbf5caa364474d0789c35fae86 Mon Sep 17 00:00:00 2001 From: William Kennedy Date: Sun, 5 May 2019 11:30:40 +0200 Subject: [PATCH 01/13] code cleanup --- conf.go | 63 +++++++++++++++------------- envsource.go | 2 + fields.go | 60 ++++++++++++++++---------- configfilesource.go => filesource.go | 21 +++++----- flagsource.go | 49 ++++++++++++++-------- names.go | 18 ++++---- options.go | 4 +- print.go | 2 +- process.go | 6 ++- usage.go | 18 +++++--- 10 files changed, 144 insertions(+), 99 deletions(-) rename configfilesource.go => filesource.go (75%) diff --git a/conf.go b/conf.go index 28b2dea..2713198 100644 --- a/conf.go +++ b/conf.go @@ -7,24 +7,25 @@ import ( ) var ( + // ErrInvalidStruct indicates that a configuration struct is not the correct type. ErrInvalidStruct = errors.New("configuration must be a struct pointer") ) type context struct { - confFlag string + confFlag string // TODO: Is using conf redudant? confFile string sources []Source } -// Parse parses configuration into the provided struct +// Parse parses configuration into the provided struct. func Parse(confStruct interface{}, options ...Option) error { _, err := ParseWithArgs(confStruct, options...) return err } // ParseWithArgs parses configuration into the provided struct, returning the -// remaining args after flag parsing +// remaining args after flag parsing. func ParseWithArgs(confStruct interface{}, options ...Option) ([]string, error) { var c context for _, option := range options { @@ -35,16 +36,13 @@ func ParseWithArgs(confStruct interface{}, options ...Option) ([]string, error) if err != nil { return nil, err } - if len(fields) == 0 { return nil, errors.New("no settable flags found in struct") } - sources := make([]Source, 0, 3) - - // Process flags and create flag source. If help is requested, print useage - // and exit. - fs, args, err := newFlagSource(fields, []string{c.confFlag}) + // Process flags and create a flag source. + // If help is requested, print useage and exit. + flagSource, args, err := newFlagSource(fields, []string{c.confFlag}) switch err { case nil: case errHelpWanted: @@ -54,42 +52,50 @@ func ParseWithArgs(confStruct interface{}, options ...Option) ([]string, error) return nil, err } - sources = append(sources, fs) + // Start collection the set of sources we need to check. + var sources []Source + sources = append(sources, flagSource) - // create config file source, if specified + // Create the file source, if specified to do so. Then + // add the source to the collection of sources. if c.confFile != "" || c.confFlag != "" { configFile := c.confFile fromFlag := false - // if there's a config file flag, and it's set, use that filename instead - if configFileFromFlags, ok := fs.Get([]string{c.confFlag}); ok { + + // If there's a config file flag, and it's set, use that filename instead. + if configFileFromFlags, ok := flagSource.Get([]string{c.confFlag}); ok { configFile = configFileFromFlags fromFlag = true } - cs, err := newConfSource(configFile) - if err != nil { - if os.IsNotExist(err) { + + // Create a file source for this config file. + fileSource, err := newFileSource(configFile) + switch { + case err != nil: + switch { + case os.IsNotExist(err): + // The file doesn't exist. If it was specified by a flag, treat this // as an error, since presumably the user either made a mistake, or - // the file they deliberately specified isn't there + // the file they deliberately specified isn't there. if fromFlag { return nil, err } - } else { + default: return nil, err } - } else { - sources = append(sources, cs) + default: + sources = append(sources, fileSource) } } - // create env souce - es := new(envSource) - sources = append(sources, es) + // Add the environment source to the list by default. + sources = append(sources, &envSource{}) - // append any additional sources + // TODO: WERE ARE THESE COMING FROM? Append any additional source. sources = append(sources, c.sources...) - // process all fields + // Process all fields. for _, field := range fields { var value string var found bool @@ -129,8 +135,8 @@ type processError struct { err error } -func (e *processError) Error() string { - return fmt.Sprintf("conf: error assigning to field %s: converting '%s' to type %s. details: %s", e.fieldName, e.value, e.typeName, e.err) +func (pe *processError) Error() string { + return fmt.Sprintf("conf: error assigning to field %s: converting '%s' to type %s. details: %s", pe.fieldName, pe.value, pe.typeName, pe.err) } // Source represents a source of configuration data. Sources requiring @@ -138,7 +144,8 @@ func (e *processError) Error() string { // loaded so that sources further down the chain are not queried if they're // not going to be needed. type Source interface { + // Get takes a location specified by a key and returns a string and whether - // or not the value was set in the source + // or not the value was set in the source. Get(key []string) (value string, found bool) } diff --git a/envsource.go b/envsource.go index 4108cd1..7395cb7 100644 --- a/envsource.go +++ b/envsource.go @@ -4,6 +4,8 @@ import "os" type envSource struct{} +// Get returns the stringfied value stored at the specified key +// from the environment. func (e *envSource) Get(key []string) (string, bool) { varName := getEnvName(key) return os.LookupEnv(varName) diff --git a/fields.go b/fields.go index 8f3f489..259fbfa 100644 --- a/fields.go +++ b/fields.go @@ -6,30 +6,31 @@ import ( "strings" ) -// field maintains information about a field in the configuration struct +// field maintains information about a field in the configuration struct. type field struct { name string key []string field reflect.Value options fieldOptions - // important for flag parsing or any other source where booleans might be - // treated specially + + // Important for flag parsing or any other source where booleans might be + // treated specially. boolField bool - // for usage + + // For usage ... TODO: I need more. flagName string envName string } type fieldOptions struct { - //allow for alternate name, perhaps - short rune + short rune // Allow for alternate name, perhaps. help string defaultStr string noprint bool required bool } -// extractFields uses reflection to examine the struct and generate the keys +// extractFields uses reflection to examine the struct and generate the keys. func extractFields(prefix []string, target interface{}) ([]field, error) { if prefix == nil { prefix = []string{} @@ -45,48 +46,56 @@ func extractFields(prefix []string, target interface{}) ([]field, error) { } targetType := s.Type() - fields := []field{} + var fields []field for i := 0; i < s.NumField(); i++ { f := s.Field(i) structField := targetType.Field(i) - // get the conf tags associated with this item (if any) + // Get the conf tags associated with this item (if any). fieldTags := structField.Tag.Get("conf") - // if it's ignored or can't be set, move on + // If it's ignored or can't be set, move on. if !f.CanSet() || fieldTags == "-" { continue } fieldName := structField.Name - // break name into constituent pieces via CamelCase parser + + // Break name into constituent pieces via CamelCase parser. fieldKey := append(prefix, camelSplit(fieldName)...) - // get and options + // Get and options. TODO: Need more. fieldOpts, err := parseTag(fieldTags) if err != nil { return nil, fmt.Errorf("conf: error parsing tags for field %s: %s", fieldName, err) } - // Drill down through pointers until we bottom out at type or nil + // Drill down through pointers until we bottom out at type or nil. for f.Kind() == reflect.Ptr { if f.IsNil() { - // not a struct, leave it alone + + // It's not a struct so leave it alone. if f.Type().Elem().Kind() != reflect.Struct { break } - // It is a struct, zero it out + + // It is a struct so zero it out. f.Set(reflect.New(f.Type().Elem())) } f = f.Elem() } - // if we've found a struct, drill down, appending fields as we go - if f.Kind() == reflect.Struct { - // skip if it can deserialize itself + switch { + + // If we've found a struct, drill down, appending fields as we go. + case f.Kind() == reflect.Struct: + + // Skip if it can deserialize itself. if setterFrom(f) == nil && textUnmarshaler(f) == nil && binaryUnmarshaler(f) == nil { - // prefix for any subkeys is the fieldKey, unless it's anonymous, then it's just the prefix so far + + // Prefix for any subkeys is the fieldKey, unless it's + // anonymous, then it's just the prefix so far. innerPrefix := fieldKey if structField.Anonymous { innerPrefix = prefix @@ -99,8 +108,7 @@ func extractFields(prefix []string, target interface{}) ([]field, error) { } fields = append(fields, innerFields...) } - } else { - // append the field + default: fields = append(fields, field{ name: fieldName, key: fieldKey, @@ -112,18 +120,21 @@ func extractFields(prefix []string, target interface{}) ([]field, error) { }) } } + return fields, nil } func parseTag(tagStr string) (fieldOptions, error) { - f := fieldOptions{} + var f fieldOptions if tagStr == "" { return f, nil } + tagParts := strings.Split(tagStr, ",") for _, tagPart := range tagParts { vals := strings.SplitN(tagPart, ":", 2) tagProp := vals[0] + switch len(vals) { case 1: switch tagProp { @@ -148,13 +159,16 @@ func parseTag(tagStr string) (fieldOptions, error) { case "help": f.help = tagPropVal } + default: + // TODO: Do we check for integrity issues here? } } - // sanity check + // Perform a sanity check. switch { case f.required && f.defaultStr != "": return f, fmt.Errorf("cannot set both `required` and `default`") } + return f, nil } diff --git a/configfilesource.go b/filesource.go similarity index 75% rename from configfilesource.go rename to filesource.go index d4b10d3..b5036d4 100644 --- a/configfilesource.go +++ b/filesource.go @@ -6,25 +6,25 @@ import ( "strings" ) -// confSource is a source for config files in an extremely simple format. Each +// fileSource is a source for config files in an extremely simple format. Each // line is tokenized as a single key/value pair. The first whitespace-delimited // token in the line is interpreted as the flag name, and all remaining tokens // are interpreted as the value. Any leading hyphens on the flag name are // ignored. -type confSource struct { +type fileSource struct { m map[string]string } -func newConfSource(filename string) (*confSource, error) { +func newFileSource(filename string) (*fileSource, error) { m := make(map[string]string) - cf, err := os.Open(filename) + file, err := os.Open(filename) if err != nil { return nil, err } - defer cf.Close() + defer file.Close() - s := bufio.NewScanner(cf) + s := bufio.NewScanner(file) for s.Scan() { line := strings.TrimSpace(s.Text()) if line == "" { @@ -52,14 +52,13 @@ func newConfSource(filename string) (*confSource, error) { m[name] = value } - return &confSource{ - m: m, - }, nil + + return &fileSource{m: m}, nil } // Get returns the stringfied value stored at the specified key in the plain -// config file -func (p *confSource) Get(key []string) (string, bool) { +// config file. +func (p *fileSource) Get(key []string) (string, bool) { k := getEnvName(key) value, ok := p.m[k] return value, ok diff --git a/flagsource.go b/flagsource.go index 806542b..b57a27b 100644 --- a/flagsource.go +++ b/flagsource.go @@ -6,21 +6,25 @@ import ( "os" ) +var errHelpWanted = errors.New("help wanted") + type flagSource struct { found map[string]string } -var errHelpWanted = errors.New("help wanted") - -// TODO?: make missing flags optionally throw error +// TODO: make missing flags optionally throw error? func newFlagSource(fields []field, exempt []string) (*flagSource, []string, error) { + + // TODO: If this is small, it could help keep it on the stack if these variables + // are not leaking. But we are talking about such a small data set. + // Let's discuss for readability. found := make(map[string]string, len(fields)) expected := make(map[string]*field, len(fields)) shorts := make(map[string]string, len(fields)) exemptFlags := make(map[string]struct{}, len(exempt)) - // some flags are special, like for specifying a config file flag, which - // we definitely want to inspect, but don't represent field data + // Some flags are special, like for specifying a config file flag, which + // we definitely want to inspect, but don't represent field data. for _, exemptFlag := range exempt { if exemptFlag != "" { exemptFlags[exemptFlag] = struct{}{} @@ -38,17 +42,22 @@ func newFlagSource(fields []field, exempt []string) (*flagSource, []string, erro copy(args, os.Args[1:]) if len(args) != 0 { - //adapted from 'flag' package + + // Adapted from the 'flag' package. for { if len(args) == 0 { break } - // look at the next arg + + // Look at the next arg. s := args[0] - // if it's too short or doesn't begin with a `-`, assume we're at the end of the flags + + // If it's too short or doesn't begin with a `-`, assume we're at + // the end of the flags. if len(s) < 2 || s[0] != '-' { break } + numMinuses := 1 if s[1] == '-' { numMinuses++ @@ -62,7 +71,7 @@ func newFlagSource(fields []field, exempt []string) (*flagSource, []string, erro return nil, nil, fmt.Errorf("bad flag syntax: %s", s) } - // it's a flag. does it have an argument? + // It's a flag. Does it have an argument? args = args[1:] hasValue := false value := "" @@ -74,6 +83,7 @@ func newFlagSource(fields []field, exempt []string) (*flagSource, []string, erro break } } + if name == "help" || name == "h" || name == "?" { return nil, nil, errHelpWanted } @@ -88,17 +98,20 @@ func newFlagSource(fields []field, exempt []string) (*flagSource, []string, erro } } - // if we don't have a value yet, it's possible the flag was not in the + // If we don't have a value yet, it's possible the flag was not in the // -flag=value format which means it might still have a value which would be - // the next argument, provided the next argument isn't a flag + // the next argument, provided the next argument isn't a flag. if !hasValue { if len(args) > 0 && args[0][0] != '-' { - // doesn't look like a flag. Must be a value + + // Doesn't look like a flag. Must be a value. value, args = args[0], args[1:] } else { - // we wanted a value but found the end or another flag. The only time this is okay - // is if this is a boolean flag, in which case `-flag` is okay, because it is assumed - // to be the same as `-flag true` + + // we wanted a value but found the end or another flag. The + // only time this is okay is if this is a boolean flag, in + // which case `-flag` is okay, because it is assumed to be + // the same as `-flag true`. if expected[name].boolField { value = "true" } else { @@ -110,11 +123,11 @@ func newFlagSource(fields []field, exempt []string) (*flagSource, []string, erro } } - return &flagSource{ - found: found, - }, args, nil + return &flagSource{found: found}, args, nil } +// Get returns the stringfied value stored at the specified key +// from the flag source. func (f *flagSource) Get(key []string) (string, bool) { flagStr := getFlagName(key) val, found := f.found[flagStr] diff --git a/names.go b/names.go index 2b20091..b6890ca 100644 --- a/names.go +++ b/names.go @@ -13,7 +13,7 @@ func getFlagName(key []string) string { return strings.ToLower(strings.Join(key, `-`)) } -// split string based on camel case +// Split a string based on camel case. func camelSplit(src string) []string { if src == "" { return []string{} @@ -28,20 +28,23 @@ func camelSplit(src string) []string { lastIdx := 0 out := []string{} - // split into fields based on class of unicode character + // Split into fields based on class of unicode character. for i, r := range runes { class := charClass(r) - // if the class has transitioned + + // If the class has transitioned. if class != lastClass { - // if going from uppercase to lowercase, we want to retain the last + + // If going from uppercase to lowercase, we want to retain the last // uppercase letter for names like FOOBar, which should split to - // FOO Bar - if lastClass == classUpper && class != classNumber { + // FOO Bar. + switch { + case lastClass == classUpper && class != classNumber: if i-lastIdx > 1 { out = append(out, string(runes[lastIdx:i-1])) lastIdx = i - 1 } - } else { + default: out = append(out, string(runes[lastIdx:i])) lastIdx = i } @@ -51,7 +54,6 @@ func camelSplit(src string) []string { out = append(out, string(runes[lastIdx:])) } lastClass = class - } return out diff --git a/options.go b/options.go index be41dae..b7a4cd4 100644 --- a/options.go +++ b/options.go @@ -1,6 +1,6 @@ package conf -// Option represents a change to the default parsing +// Option represents a change to the default parsing. type Option func(c *context) // WithConfigFile tells parse to attempt to read from the specified file, if it @@ -22,7 +22,7 @@ func WithConfigFileFlag(flagname string) Option { } } -// WithSource adds additional configuration sources for configuration parsing +// WithSource adds additional configuration sources for configuration parsing. func WithSource(source Source) Option { return func(c *context) { c.sources = append(c.sources, source) diff --git a/print.go b/print.go index 883abb4..8e13f66 100644 --- a/print.go +++ b/print.go @@ -6,7 +6,7 @@ import ( ) // String returns a stringified version of the provided conf-tagged -// struct, minus any fields tagged with `noprint` +// struct, minus any fields tagged with `noprint`. func String(v interface{}) (string, error) { fields, err := extractFields(nil, v) if err != nil { diff --git a/process.go b/process.go index 30f53d4..7f22e8f 100644 --- a/process.go +++ b/process.go @@ -12,7 +12,7 @@ import ( func processField(value string, field reflect.Value) error { typ := field.Type() - // look for Set method + // Look for a Set method. setter := setterFrom(field) if setter != nil { return setter.Set(value) @@ -110,10 +110,12 @@ func processField(value string, field reflect.Value) error { } func interfaceFrom(field reflect.Value, fn func(interface{}, *bool)) { - // it may be impossible for a struct field to fail this check + + // It may be impossible for a struct field to fail this check. if !field.CanInterface() { return } + var ok bool fn(field.Interface(), &ok) if !ok && field.CanAddr() { diff --git a/usage.go b/usage.go index b84f53d..e36d310 100644 --- a/usage.go +++ b/usage.go @@ -11,12 +11,12 @@ import ( func printUsage(fields []field, c context) { - // sort the fields, by their long name + // sort the fields, by their long name. sort.SliceStable(fields, func(i, j int) bool { return fields[i].flagName < fields[j].flagName }) - // put conf and help last + // Put confif and help last. if c.confFlag != "" { confFlagField := field{ flagName: c.confFlag, @@ -27,6 +27,7 @@ func printUsage(fields []field, c context) { } fields = append(fields, confFlagField) } + fields = append(fields, field{ flagName: "help", boolField: true, @@ -55,6 +56,7 @@ func printUsage(fields []field, c context) { fmt.Fprintf(w, " %s\t\t\n", help) } } + w.Flush() fmt.Fprintf(os.Stderr, "\n") if c.confFile != "" { @@ -75,9 +77,10 @@ func printUsage(fields []field, c context) { // 'true' value and their absence with a 'false' value. If a type cannot be // determined, it will simply give the name "value". Slices will be annotated // as ",[Type...]", where "Type" is whatever type name was chosen. -// (adapted from package flag) +// (adapted from package flag). func getTypeAndHelp(f *field) (name string, usage string) { - // Look for a single-quoted name + + // Look for a single-quoted name. usage = f.options.help for i := 0; i < len(usage); i++ { if usage[i] == '\'' { @@ -94,11 +97,13 @@ func getTypeAndHelp(f *field) (name string, usage string) { var isSlice bool if f.field.IsValid() { t := f.field.Type() - // if it's a pointer, we want to deref + + // If it's a pointer, we want to deref. if t.Kind() == reflect.Ptr { t = t.Elem() } - // if it's a slice, we want the type of the slice elements + + // If it's a slice, we want the type of the slice elements. if t.Kind() == reflect.Slice { t = t.Elem() isSlice = true @@ -130,6 +135,7 @@ func getTypeAndHelp(f *field) (name string, usage string) { } } } + switch { case isSlice: name = fmt.Sprintf("<%s>,[%s...]", name, name) From 598cf4fc5e35d52c2fe5c77ec59f48253fb68082 Mon Sep 17 00:00:00 2001 From: William Kennedy Date: Sun, 5 May 2019 13:54:57 +0200 Subject: [PATCH 02/13] clean up code changes --- conf.go | 159 ++++++----------- conf_test.go | 298 +++++++++++--------------------- envsource.go | 12 -- fields.go | 212 ++++++++++++++++++++++- names.go | 79 --------- options.go | 30 ---- print.go | 27 --- process.go | 145 ---------------- source/env.go | 52 ++++++ filesource.go => source/file.go | 22 +-- flagsource.go => source/flag.go | 89 +++------- usage.go | 23 +-- 12 files changed, 462 insertions(+), 686 deletions(-) delete mode 100644 envsource.go delete mode 100644 names.go delete mode 100644 options.go delete mode 100644 print.go delete mode 100644 process.go create mode 100644 source/env.go rename filesource.go => source/file.go (64%) rename flagsource.go => source/flag.go (56%) diff --git a/conf.go b/conf.go index 2713198..9c88407 100644 --- a/conf.go +++ b/conf.go @@ -3,117 +3,73 @@ package conf import ( "errors" "fmt" - "os" + "strings" ) -var ( +// ErrInvalidStruct indicates that a configuration struct is not the correct type. +var ErrInvalidStruct = errors.New("configuration must be a struct pointer") - // ErrInvalidStruct indicates that a configuration struct is not the correct type. - ErrInvalidStruct = errors.New("configuration must be a struct pointer") -) +// A fieldError occurs when an error occurs updating an individual field +// in the provided struct value. +type fieldError struct { + fieldName string + typeName string + value string + err error +} -type context struct { - confFlag string // TODO: Is using conf redudant? - confFile string - sources []Source +func (err *fieldError) Error() string { + return fmt.Sprintf("conf: error assigning to field %s: converting '%s' to type %s. details: %s", err.fieldName, err.value, err.typeName, err.err) } -// Parse parses configuration into the provided struct. -func Parse(confStruct interface{}, options ...Option) error { - _, err := ParseWithArgs(confStruct, options...) - return err +// Source represents a source of configuration data. Sources requiring +// the pre-fetching and processing of several values should ideally be lazily- +// loaded so that sources further down the chain are not queried if they're +// not going to be needed. +type Source interface { + + // Get takes the field key and attempts to locate that key in its + // configuration data. Returns true if found with the value. + Get(key []string) (string, bool) } -// ParseWithArgs parses configuration into the provided struct, returning the -// remaining args after flag parsing. -func ParseWithArgs(confStruct interface{}, options ...Option) ([]string, error) { - var c context - for _, option := range options { - option(&c) - } +// Parse parses configuration into the provided struct. +func Parse(cfgStruct interface{}, sources ...Source) error { - fields, err := extractFields(nil, confStruct) + // Get the list of fields from the configuration struct to process. + fields, err := extractFields(nil, cfgStruct) if err != nil { - return nil, err + return err } if len(fields) == 0 { - return nil, errors.New("no settable flags found in struct") - } - - // Process flags and create a flag source. - // If help is requested, print useage and exit. - flagSource, args, err := newFlagSource(fields, []string{c.confFlag}) - switch err { - case nil: - case errHelpWanted: - printUsage(fields, c) - os.Exit(1) - default: - return nil, err - } - - // Start collection the set of sources we need to check. - var sources []Source - sources = append(sources, flagSource) - - // Create the file source, if specified to do so. Then - // add the source to the collection of sources. - if c.confFile != "" || c.confFlag != "" { - configFile := c.confFile - fromFlag := false - - // If there's a config file flag, and it's set, use that filename instead. - if configFileFromFlags, ok := flagSource.Get([]string{c.confFlag}); ok { - configFile = configFileFromFlags - fromFlag = true - } - - // Create a file source for this config file. - fileSource, err := newFileSource(configFile) - switch { - case err != nil: - switch { - case os.IsNotExist(err): - - // The file doesn't exist. If it was specified by a flag, treat this - // as an error, since presumably the user either made a mistake, or - // the file they deliberately specified isn't there. - if fromFlag { - return nil, err - } - default: - return nil, err - } - default: - sources = append(sources, fileSource) - } + return errors.New("no fields identified in config struct") } - // Add the environment source to the list by default. - sources = append(sources, &envSource{}) - - // TODO: WERE ARE THESE COMING FROM? Append any additional source. - sources = append(sources, c.sources...) - - // Process all fields. + // Process all fields found in the config struct provided. for _, field := range fields { var value string var found bool + + // Process each field against all sources. for _, source := range sources { value, found = source.Get(field.key) if found { break } } + + // If this key is not provided, check if required or use default. if !found { if field.options.required { - return nil, fmt.Errorf("required field %s is missing value", field.name) + return fmt.Errorf("required field %s is missing value", field.name) } value = field.options.defaultStr } + + // If this config field will be set to it's zero value, return an error. if value != "" { if err := processField(value, field.field); err != nil { - return nil, &processError{ + return &fieldError{ fieldName: field.name, typeName: field.field.Type().String(), value: value, @@ -123,29 +79,28 @@ func ParseWithArgs(confStruct interface{}, options ...Option) ([]string, error) } } - return args, nil -} - -// A processError occurs when an environment variable cannot be converted to -// the type required by a struct field during assignment. -type processError struct { - fieldName string - typeName string - value string - err error + return nil } -func (pe *processError) Error() string { - return fmt.Sprintf("conf: error assigning to field %s: converting '%s' to type %s. details: %s", pe.fieldName, pe.value, pe.typeName, pe.err) -} +// String returns a stringified version of the provided conf-tagged +// struct, minus any fields tagged with `noprint`. +func String(v interface{}) (string, error) { + fields, err := extractFields(nil, v) + if err != nil { + return "", err + } -// Source represents a source of configuration data. Sources requiring -// the pre-fetching and processing of several values should ideally be lazily- -// loaded so that sources further down the chain are not queried if they're -// not going to be needed. -type Source interface { + var s strings.Builder + for i, field := range fields { + if !field.options.noprint { + s.WriteString(field.envName) + s.WriteString("=") + s.WriteString(fmt.Sprintf("%v", field.field.Interface())) + if i < len(fields)-1 { + s.WriteString(" ") + } + } + } - // Get takes a location specified by a key and returns a string and whether - // or not the value was set in the source. - Get(key []string) (value string, found bool) + return s.String(), nil } diff --git a/conf_test.go b/conf_test.go index 090840c..9f51f64 100644 --- a/conf_test.go +++ b/conf_test.go @@ -1,230 +1,142 @@ -package conf +package conf_test import ( - "io/ioutil" "os" "testing" -) -type simpleConf struct { - TestInt int - TestString string - TestBool bool -} + "github.com/flowchartsman/conf" + "github.com/flowchartsman/conf/source" + "github.com/google/go-cmp/cmp" +) -func TestSimpleParseFlags(t *testing.T) { - prepArgs( - "--test-int", "1", - "--test-string", "s", - "--test-bool") - prepEnv() - var c simpleConf - err := Parse(&c) - assert(t, err == nil) - assert(t, c.TestInt == 1) - assert(t, c.TestString == "s") - assert(t, c.TestBool) -} +const ( + success = "\u2713" + failed = "\u2717" +) -func TestSimpleParseEnv(t *testing.T) { - prepArgs() - prepEnv( - "TEST_INT", "1", - "TEST_STRING", "s", - "TEST_BOOL", "TRUE", - ) - var c simpleConf - err := Parse(&c) - assert(t, err == nil) - assert(t, c.TestInt == 1) - assert(t, c.TestString == "s") - assert(t, c.TestBool) -} +func TestParseFlags(t *testing.T) { + type config struct { + TestInt int + TestString string + TestBool bool + } + + tests := []struct { + name string + args []string + }{ + {"basic", []string{"--test-int", "1", "--test-string", "s", "--test-bool"}}, + } + + t.Log("Given the need to parse command line arguments.") + { + for i, tt := range tests { + t.Logf("\tTest: %d\tWhen checking these arguments %s", i, tt.args) + { + flag, err := source.NewFlag(tt.args) + if err != nil { + t.Fatalf("\t%s\tShould be able to call NewFlag : %s.", failed, err) + } + t.Logf("\t%s\tShould be able to call NewFlag.", success) + + var cfg config + err = conf.Parse(&cfg, flag) + if err != nil { + t.Fatalf("\t%s\tShould be able to Parse arguments : %s.", failed, err) + } + t.Logf("\t%s\tShould be able to Parse arguments.", success) + + want := config{ + TestInt: 1, + TestString: "s", + TestBool: true, + } + if diff := cmp.Diff(want, cfg); diff != "" { + t.Fatalf("\t%s\tShould have properly initialized struct value\n%s", failed, diff) + } + t.Logf("\t%s\tShould have properly initialized struct value.", success) + } + } + } +} + +func TestParseEnv(t *testing.T) { + type config struct { + TestInt int + TestString string + TestBool bool + } -func TestSimpleFile(t *testing.T) { - prepArgs() - prepEnv() - testFile, err := ioutil.TempFile("", "conf-test") - if err != nil { - panic("error creating temp file for test: " + err.Error()) + tests := []struct { + name string + vars map[string]string + }{ + {"basic", map[string]string{"TEST_INT": "1", "TEST_STRING": "s", "TEST_BOOL": "TRUE"}}, } - defer os.Remove(testFile.Name()) - testFile.Write([]byte(`TEST_INT 1 -TEST_STRING s -TEST_BOOL TRUE -`)) - err = testFile.Close() - if err != nil { - panic("error closing temp file for test: " + err.Error()) + + t.Log("Given the need to parse environmental variables.") + { + for i, tt := range tests { + t.Logf("\tTest: %d\tWhen checking these environmental variables %s", i, tt.vars) + { + os.Clearenv() + for k, v := range tt.vars { + os.Setenv(k, v) + } + + env, err := source.NewEnv("TEST") + if err != nil { + t.Fatalf("\t%s\tShould be able to call NewEnv : %s.", failed, err) + } + t.Logf("\t%s\tShould be able to call NewEnv.", success) + + var cfg config + err = conf.Parse(&cfg, env) + if err != nil { + t.Fatalf("\t%s\tShould be able to Parse arguments : %s.", failed, err) + } + t.Logf("\t%s\tShould be able to Parse arguments.", success) + + want := config{ + TestInt: 1, + TestString: "s", + TestBool: true, + } + if diff := cmp.Diff(want, cfg); diff != "" { + t.Fatalf("\t%s\tShould have properly initialized struct value\n%s", failed, diff) + } + t.Logf("\t%s\tShould have properly initialized struct value.", success) + } + } } - var c simpleConf - err = Parse(&c, - WithConfigFile(testFile.Name()), - ) - assert(t, err == nil) - assert(t, c.TestInt == 1) - assert(t, c.TestString == "s") - assert(t, c.TestBool) } -func TestSimpleSourcePriority(t *testing.T) { - type simpleConfPriority struct { - TestInt int - TestIntTwo int - TestIntThree int - } - prepEnv( - "TEST_INT", "1", - "TEST_INT_TWO", "1", - "TEST_INT_THREE", "1", - ) - testFile, err := ioutil.TempFile("", "conf-test") - if err != nil { - panic("error creating temp file for test: " + err.Error()) - } - defer os.Remove(testFile.Name()) - testFile.Write([]byte(`TEST_INT_TWO 2 -TEST_INT_THREE 2 - `)) - err = testFile.Close() - if err != nil { - panic("error closing temp file for test: " + err.Error()) - } - prepArgs( - "--test-int-three", "3", - ) - var c simpleConfPriority - err = Parse(&c, - WithConfigFile(testFile.Name()), - ) - assert(t, err == nil) - assert(t, c.TestInt == 1) - assert(t, c.TestIntTwo == 2) - assert(t, c.TestIntThree == 3) +func TestParseFile(t *testing.T) { +} + +func TestMultiSource(t *testing.T) { } func TestParseNonRefIsError(t *testing.T) { - prepArgs() - prepEnv() - var c simpleConf - err := Parse(c) - assert(t, err == ErrInvalidStruct) } func TestParseNonStructIsError(t *testing.T) { - prepArgs() - prepEnv() - var s string - err := Parse(&s) - assert(t, err == ErrInvalidStruct) } func TestSkipedFieldIsSkipped(t *testing.T) { - type skipTest struct { - TestString string `conf:"-"` - TestInt int - } - var c skipTest - prepArgs() - prepEnv( - "TEST_STRING", "no", - "TEST_INT", "1", - ) - err := Parse(&c) - - assert(t, err == nil) - assert(t, c.TestString == "") - assert(t, c.TestInt == 1) } func TestTagMissingValueIsError(t *testing.T) { - type bad struct { - TestBad string `conf:"default:"` - } - var c bad - prepArgs() - prepEnv() - err := Parse(&c) - - assert(t, err.Error() == `conf: error parsing tags for field TestBad: tag "default" missing a value`) } func TestBadShortTagIsError(t *testing.T) { - type badShort struct { - TestBad string `conf:"short:ab"` - } - var c badShort - prepArgs() - prepEnv() - err := Parse(&c) - - assert(t, err.Error() == `conf: error parsing tags for field TestBad: short value must be a single rune, got "ab"`) } func TestCannotSetRequiredAndDefaultTags(t *testing.T) { - type badShort struct { - TestBad string `conf:"required,default:n"` - } - var c badShort - prepArgs() - prepEnv() - err := Parse(&c) - - assert(t, err.Error() == "conf: error parsing tags for field TestBad: cannot set both `required` and `default`") } func TestHierarchicalFieldNames(t *testing.T) { - type conf1 struct { - FieldOne string - } - type conf2 struct { - One conf1 - FieldTwo string - } - var c conf2 - prepArgs("--one-field-one=1") - prepEnv("FIELD_TWO", "2") - err := Parse(&c) - assert(t, err == nil) - assert(t, c.One.FieldOne == "1") - assert(t, c.FieldTwo == "2") } func TestEmbeddedFieldNames(t *testing.T) { - type Conf1 struct { - FieldOne string - } - type conf2 struct { - Conf1 - FieldTwo string - } - var c conf2 - - prepEnv("FIELD_ONE", "1") - prepArgs("--field-two=2") - err := Parse(&c) - assert(t, err == nil) - assert(t, c.FieldOne == "1") - assert(t, c.FieldTwo == "2") -} - -func prepEnv(keyvals ...string) { - if len(keyvals)%2 != 0 { - panic("prepENV must have even number of keyvals") - } - os.Clearenv() - for i := 0; i < len(keyvals); i += 2 { - os.Setenv(keyvals[i], keyvals[i+1]) - } -} - -func prepArgs(args ...string) { - os.Args = append([]string{"testing"}, args...) -} - -func assert(t *testing.T, testresult bool) { - t.Helper() - if !testresult { - t.Fatal() - } } diff --git a/envsource.go b/envsource.go deleted file mode 100644 index 7395cb7..0000000 --- a/envsource.go +++ /dev/null @@ -1,12 +0,0 @@ -package conf - -import "os" - -type envSource struct{} - -// Get returns the stringfied value stored at the specified key -// from the environment. -func (e *envSource) Get(key []string) (string, bool) { - varName := getEnvName(key) - return os.LookupEnv(varName) -} diff --git a/fields.go b/fields.go index 259fbfa..b330b23 100644 --- a/fields.go +++ b/fields.go @@ -1,9 +1,13 @@ package conf import ( + "encoding" "fmt" "reflect" + "strconv" "strings" + "time" + "unicode" ) // field maintains information about a field in the configuration struct. @@ -13,8 +17,8 @@ type field struct { field reflect.Value options fieldOptions - // Important for flag parsing or any other source where booleans might be - // treated specially. + // Important for flag parsing or any other source where + // booleans might be treated specially. boolField bool // For usage ... TODO: I need more. @@ -112,8 +116,8 @@ func extractFields(prefix []string, target interface{}) ([]field, error) { fields = append(fields, field{ name: fieldName, key: fieldKey, - flagName: getFlagName(fieldKey), - envName: getEnvName(fieldKey), + flagName: strings.ToLower(strings.Join(fieldKey, `-`)), + envName: strings.ToLower(strings.Join(fieldKey, `-`)), field: f, options: fieldOpts, boolField: f.Kind() == reflect.Bool, @@ -172,3 +176,203 @@ func parseTag(tagStr string) (fieldOptions, error) { return f, nil } + +// camelSplit takes a string based on camel case and splits it. +func camelSplit(src string) []string { + if src == "" { + return []string{} + } + if len(src) < 2 { + return []string{src} + } + + runes := []rune(src) + + lastClass := charClass(runes[0]) + lastIdx := 0 + out := []string{} + + // Split into fields based on class of unicode character. + for i, r := range runes { + class := charClass(r) + + // If the class has transitioned. + if class != lastClass { + + // If going from uppercase to lowercase, we want to retain the last + // uppercase letter for names like FOOBar, which should split to + // FOO Bar. + switch { + case lastClass == classUpper && class != classNumber: + if i-lastIdx > 1 { + out = append(out, string(runes[lastIdx:i-1])) + lastIdx = i - 1 + } + default: + out = append(out, string(runes[lastIdx:i])) + lastIdx = i + } + } + + if i == len(runes)-1 { + out = append(out, string(runes[lastIdx:])) + } + lastClass = class + } + + return out +} + +func processField(value string, field reflect.Value) error { + typ := field.Type() + + // Look for a Set method. + setter := setterFrom(field) + if setter != nil { + return setter.Set(value) + } + + if t := textUnmarshaler(field); t != nil { + return t.UnmarshalText([]byte(value)) + } + + if b := binaryUnmarshaler(field); b != nil { + return b.UnmarshalBinary([]byte(value)) + } + + if typ.Kind() == reflect.Ptr { + typ = typ.Elem() + if field.IsNil() { + field.Set(reflect.New(typ)) + } + field = field.Elem() + } + + switch typ.Kind() { + case reflect.String: + field.SetString(value) + case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64: + var ( + val int64 + err error + ) + if field.Kind() == reflect.Int64 && typ.PkgPath() == "time" && typ.Name() == "Duration" { + var d time.Duration + d, err = time.ParseDuration(value) + val = int64(d) + } else { + val, err = strconv.ParseInt(value, 0, typ.Bits()) + } + if err != nil { + return err + } + + field.SetInt(val) + case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64: + val, err := strconv.ParseUint(value, 0, typ.Bits()) + if err != nil { + return err + } + field.SetUint(val) + case reflect.Bool: + val, err := strconv.ParseBool(value) + if err != nil { + return err + } + field.SetBool(val) + case reflect.Float32, reflect.Float64: + val, err := strconv.ParseFloat(value, typ.Bits()) + if err != nil { + return err + } + field.SetFloat(val) + case reflect.Slice: + vals := strings.Split(value, ",") + sl := reflect.MakeSlice(typ, len(vals), len(vals)) + for i, val := range vals { + err := processField(val, sl.Index(i)) + if err != nil { + return err + } + } + field.Set(sl) + case reflect.Map: + mp := reflect.MakeMap(typ) + if len(strings.TrimSpace(value)) != 0 { + pairs := strings.Split(value, ",") + for _, pair := range pairs { + kvpair := strings.Split(pair, ":") + if len(kvpair) != 2 { + return fmt.Errorf("invalid map item: %q", pair) + } + k := reflect.New(typ.Key()).Elem() + err := processField(kvpair[0], k) + if err != nil { + return err + } + v := reflect.New(typ.Elem()).Elem() + err = processField(kvpair[1], v) + if err != nil { + return err + } + mp.SetMapIndex(k, v) + } + } + field.Set(mp) + } + return nil +} + +func interfaceFrom(field reflect.Value, fn func(interface{}, *bool)) { + + // It may be impossible for a struct field to fail this check. + if !field.CanInterface() { + return + } + + var ok bool + fn(field.Interface(), &ok) + if !ok && field.CanAddr() { + fn(field.Addr().Interface(), &ok) + } +} + +// Setter is implemented by types can self-deserialize values. +// Any type that implements flag.Value also implements Setter. +type Setter interface { + Set(value string) error +} + +func setterFrom(field reflect.Value) (s Setter) { + interfaceFrom(field, func(v interface{}, ok *bool) { s, *ok = v.(Setter) }) + return s +} + +func textUnmarshaler(field reflect.Value) (t encoding.TextUnmarshaler) { + interfaceFrom(field, func(v interface{}, ok *bool) { t, *ok = v.(encoding.TextUnmarshaler) }) + return t +} + +func binaryUnmarshaler(field reflect.Value) (b encoding.BinaryUnmarshaler) { + interfaceFrom(field, func(v interface{}, ok *bool) { b, *ok = v.(encoding.BinaryUnmarshaler) }) + return b +} + +const ( + classLower int = iota + classUpper + classNumber + classOther +) + +func charClass(r rune) int { + switch { + case unicode.IsLower(r): + return classLower + case unicode.IsUpper(r): + return classUpper + case unicode.IsDigit(r): + return classNumber + } + return classOther +} diff --git a/names.go b/names.go deleted file mode 100644 index b6890ca..0000000 --- a/names.go +++ /dev/null @@ -1,79 +0,0 @@ -package conf - -import ( - "strings" - "unicode" -) - -func getEnvName(key []string) string { - return strings.ToUpper(strings.Join(key, `_`)) -} - -func getFlagName(key []string) string { - return strings.ToLower(strings.Join(key, `-`)) -} - -// Split a string based on camel case. -func camelSplit(src string) []string { - if src == "" { - return []string{} - } - if len(src) < 2 { - return []string{src} - } - - runes := []rune(src) - - lastClass := charClass(runes[0]) - lastIdx := 0 - out := []string{} - - // Split into fields based on class of unicode character. - for i, r := range runes { - class := charClass(r) - - // If the class has transitioned. - if class != lastClass { - - // If going from uppercase to lowercase, we want to retain the last - // uppercase letter for names like FOOBar, which should split to - // FOO Bar. - switch { - case lastClass == classUpper && class != classNumber: - if i-lastIdx > 1 { - out = append(out, string(runes[lastIdx:i-1])) - lastIdx = i - 1 - } - default: - out = append(out, string(runes[lastIdx:i])) - lastIdx = i - } - } - - if i == len(runes)-1 { - out = append(out, string(runes[lastIdx:])) - } - lastClass = class - } - - return out -} - -const ( - classLower int = iota - classUpper - classNumber - classOther -) - -func charClass(r rune) int { - switch { - case unicode.IsLower(r): - return classLower - case unicode.IsUpper(r): - return classUpper - case unicode.IsDigit(r): - return classNumber - } - return classOther -} diff --git a/options.go b/options.go deleted file mode 100644 index b7a4cd4..0000000 --- a/options.go +++ /dev/null @@ -1,30 +0,0 @@ -package conf - -// Option represents a change to the default parsing. -type Option func(c *context) - -// WithConfigFile tells parse to attempt to read from the specified file, if it -// is found. -func WithConfigFile(filename string) Option { - return func(c *context) { - c.confFile = filename - } -} - -// WithConfigFileFlag tells parse to look for a flag called `flagname` and, if -// it is found, to attempt to load configuration from this file. If the flag -// is specified, it will override the value provided to WithConfigFile, if that -// has been specified. If the file is not found, the program will exit with an -// error. -func WithConfigFileFlag(flagname string) Option { - return func(c *context) { - c.confFlag = flagname - } -} - -// WithSource adds additional configuration sources for configuration parsing. -func WithSource(source Source) Option { - return func(c *context) { - c.sources = append(c.sources, source) - } -} diff --git a/print.go b/print.go deleted file mode 100644 index 8e13f66..0000000 --- a/print.go +++ /dev/null @@ -1,27 +0,0 @@ -package conf - -import ( - "fmt" - "strings" -) - -// String returns a stringified version of the provided conf-tagged -// struct, minus any fields tagged with `noprint`. -func String(v interface{}) (string, error) { - fields, err := extractFields(nil, v) - if err != nil { - return "", err - } - var s strings.Builder - for i, field := range fields { - if !field.options.noprint { - s.WriteString(field.envName) - s.WriteString("=") - s.WriteString(fmt.Sprintf("%v", field.field.Interface())) - if i < len(fields)-1 { - s.WriteString(" ") - } - } - } - return s.String(), nil -} diff --git a/process.go b/process.go deleted file mode 100644 index 7f22e8f..0000000 --- a/process.go +++ /dev/null @@ -1,145 +0,0 @@ -package conf - -import ( - "encoding" - "fmt" - "reflect" - "strconv" - "strings" - "time" -) - -func processField(value string, field reflect.Value) error { - typ := field.Type() - - // Look for a Set method. - setter := setterFrom(field) - if setter != nil { - return setter.Set(value) - } - - if t := textUnmarshaler(field); t != nil { - return t.UnmarshalText([]byte(value)) - } - - if b := binaryUnmarshaler(field); b != nil { - return b.UnmarshalBinary([]byte(value)) - } - - if typ.Kind() == reflect.Ptr { - typ = typ.Elem() - if field.IsNil() { - field.Set(reflect.New(typ)) - } - field = field.Elem() - } - - switch typ.Kind() { - case reflect.String: - field.SetString(value) - case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64: - var ( - val int64 - err error - ) - if field.Kind() == reflect.Int64 && typ.PkgPath() == "time" && typ.Name() == "Duration" { - var d time.Duration - d, err = time.ParseDuration(value) - val = int64(d) - } else { - val, err = strconv.ParseInt(value, 0, typ.Bits()) - } - if err != nil { - return err - } - - field.SetInt(val) - case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64: - val, err := strconv.ParseUint(value, 0, typ.Bits()) - if err != nil { - return err - } - field.SetUint(val) - case reflect.Bool: - val, err := strconv.ParseBool(value) - if err != nil { - return err - } - field.SetBool(val) - case reflect.Float32, reflect.Float64: - val, err := strconv.ParseFloat(value, typ.Bits()) - if err != nil { - return err - } - field.SetFloat(val) - case reflect.Slice: - vals := strings.Split(value, ",") - sl := reflect.MakeSlice(typ, len(vals), len(vals)) - for i, val := range vals { - err := processField(val, sl.Index(i)) - if err != nil { - return err - } - } - field.Set(sl) - case reflect.Map: - mp := reflect.MakeMap(typ) - if len(strings.TrimSpace(value)) != 0 { - pairs := strings.Split(value, ",") - for _, pair := range pairs { - kvpair := strings.Split(pair, ":") - if len(kvpair) != 2 { - return fmt.Errorf("invalid map item: %q", pair) - } - k := reflect.New(typ.Key()).Elem() - err := processField(kvpair[0], k) - if err != nil { - return err - } - v := reflect.New(typ.Elem()).Elem() - err = processField(kvpair[1], v) - if err != nil { - return err - } - mp.SetMapIndex(k, v) - } - } - field.Set(mp) - } - return nil -} - -func interfaceFrom(field reflect.Value, fn func(interface{}, *bool)) { - - // It may be impossible for a struct field to fail this check. - if !field.CanInterface() { - return - } - - var ok bool - fn(field.Interface(), &ok) - if !ok && field.CanAddr() { - fn(field.Addr().Interface(), &ok) - } -} - -// Setter is implemented by types can self-deserialize values. -// Any type that implements flag.Value also implements Setter. -type Setter interface { - Set(value string) error -} - -func setterFrom(field reflect.Value) (s Setter) { - interfaceFrom(field, func(v interface{}, ok *bool) { s, *ok = v.(Setter) }) - return s -} - -func textUnmarshaler(field reflect.Value) (t encoding.TextUnmarshaler) { - interfaceFrom(field, func(v interface{}, ok *bool) { t, *ok = v.(encoding.TextUnmarshaler) }) - return t -} - -func binaryUnmarshaler(field reflect.Value) (b encoding.BinaryUnmarshaler) { - interfaceFrom(field, func(v interface{}, ok *bool) { b, *ok = v.(encoding.BinaryUnmarshaler) }) - return b -} diff --git a/source/env.go b/source/env.go new file mode 100644 index 0000000..857fd1e --- /dev/null +++ b/source/env.go @@ -0,0 +1,52 @@ +package source + +import ( + "errors" + "fmt" + "os" + "strings" +) + +// Env is a source for environmental variables. +type Env struct { + m map[string]string +} + +// NewEnv accepts a namespace and parses the environment into a Env for +// use by the configuration package. +func NewEnv(namespace string) (*Env, error) { + m := make(map[string]string) + + // Get the lists of available environment variables. + envs := os.Environ() + if len(envs) == 0 { + return nil, errors.New("no environment variables found") + } + + // Create the uppercase version to meet the standard {NAMESPACE_} format. + uspace := fmt.Sprintf("%s_", strings.ToUpper(namespace)) + + // Loop and match each variable using the uppercase namespace. + for _, val := range envs { + if !strings.HasPrefix(val, uspace) { + continue + } + + idx := strings.Index(val, "=") + m[strings.ToUpper(strings.TrimPrefix(val[0:idx], uspace))] = val[idx+1:] + } + + // Did we find any keys for this namespace? + if len(m) == 0 { + return nil, fmt.Errorf("namespace %q was not found", namespace) + } + + return &Env{m: m}, nil +} + +// Get implements the confg.Source interface. It returns the stringfied value +// stored at the specified key from the environment. +func (e *Env) Get(key []string) (string, bool) { + env := strings.ToUpper(strings.Join(key, `_`)) + return os.LookupEnv(env) +} diff --git a/filesource.go b/source/file.go similarity index 64% rename from filesource.go rename to source/file.go index b5036d4..3bbda65 100644 --- a/filesource.go +++ b/source/file.go @@ -1,4 +1,4 @@ -package conf +package source import ( "bufio" @@ -6,16 +6,18 @@ import ( "strings" ) -// fileSource is a source for config files in an extremely simple format. Each +// File is a source for config files in an extremely simple format. Each // line is tokenized as a single key/value pair. The first whitespace-delimited // token in the line is interpreted as the flag name, and all remaining tokens // are interpreted as the value. Any leading hyphens on the flag name are // ignored. -type fileSource struct { +type File struct { m map[string]string } -func newFileSource(filename string) (*fileSource, error) { +// NewFile accepts a filename and parses the contents into a File for +// use by the configuration package. +func NewFile(filename string) (*File, error) { m := make(map[string]string) file, err := os.Open(filename) @@ -53,13 +55,13 @@ func newFileSource(filename string) (*fileSource, error) { m[name] = value } - return &fileSource{m: m}, nil + return &File{m: m}, nil } -// Get returns the stringfied value stored at the specified key in the plain -// config file. -func (p *fileSource) Get(key []string) (string, bool) { - k := getEnvName(key) - value, ok := p.m[k] +// Get implements the confg.Source interface. It returns the stringfied value +// stored at the specified key in the plain config file. +func (f *File) Get(key []string) (string, bool) { + k := strings.ToUpper(strings.Join(key, `_`)) + value, ok := f.m[k] return value, ok } diff --git a/flagsource.go b/source/flag.go similarity index 56% rename from flagsource.go rename to source/flag.go index b57a27b..345cce2 100644 --- a/flagsource.go +++ b/source/flag.go @@ -1,49 +1,26 @@ -package conf +package source import ( "errors" "fmt" - "os" + "strings" ) -var errHelpWanted = errors.New("help wanted") +// ErrHelpWanted provides an indication help was requested. +var ErrHelpWanted = errors.New("help wanted") -type flagSource struct { - found map[string]string +// Flag is a source for command line arguments. +type Flag struct { + m map[string]string } -// TODO: make missing flags optionally throw error? -func newFlagSource(fields []field, exempt []string) (*flagSource, []string, error) { - - // TODO: If this is small, it could help keep it on the stack if these variables - // are not leaking. But we are talking about such a small data set. - // Let's discuss for readability. - found := make(map[string]string, len(fields)) - expected := make(map[string]*field, len(fields)) - shorts := make(map[string]string, len(fields)) - exemptFlags := make(map[string]struct{}, len(exempt)) - - // Some flags are special, like for specifying a config file flag, which - // we definitely want to inspect, but don't represent field data. - for _, exemptFlag := range exempt { - if exemptFlag != "" { - exemptFlags[exemptFlag] = struct{}{} - } - } - - for i, field := range fields { - expected[field.flagName] = &fields[i] - if field.options.short != 0 { - shorts[string(field.options.short)] = field.flagName - } - } - - args := make([]string, len(os.Args)-1) - copy(args, os.Args[1:]) +// NewFlag parsing a string of command line arguments. NewFlag will return +// ErrHelpWanted, if the help flag is identifyed. This code is adapted +// from the Go standard library flag package. +func NewFlag(args []string) (*Flag, error) { + m := make(map[string]string) if len(args) != 0 { - - // Adapted from the 'flag' package. for { if len(args) == 0 { break @@ -66,9 +43,10 @@ func newFlagSource(fields []field, exempt []string) (*flagSource, []string, erro break } } + name := s[numMinuses:] if len(name) == 0 || name[0] == '-' || name[0] == '=' { - return nil, nil, fmt.Errorf("bad flag syntax: %s", s) + return nil, fmt.Errorf("bad flag syntax: %s", s) } // It's a flag. Does it have an argument? @@ -85,17 +63,7 @@ func newFlagSource(fields []field, exempt []string) (*flagSource, []string, erro } if name == "help" || name == "h" || name == "?" { - return nil, nil, errHelpWanted - } - - if long, ok := shorts[name]; ok { - name = long - } - - if expected[name] == nil { - if _, ok := exemptFlags[name]; !ok { - return nil, nil, fmt.Errorf("flag provided but not defined: -%s", name) - } + return nil, ErrHelpWanted } // If we don't have a value yet, it's possible the flag was not in the @@ -108,29 +76,24 @@ func newFlagSource(fields []field, exempt []string) (*flagSource, []string, erro value, args = args[0], args[1:] } else { - // we wanted a value but found the end or another flag. The - // only time this is okay is if this is a boolean flag, in - // which case `-flag` is okay, because it is assumed to be - // the same as `-flag true`. - if expected[name].boolField { - value = "true" - } else { - return nil, nil, fmt.Errorf("flag needs an argument: -%s", name) - } + // We assume this is a boolean flag. + value = "true" } } - found[name] = value + + // Store the flag/value pair. + m[name] = value } } - return &flagSource{found: found}, args, nil + return &Flag{m: m}, nil } -// Get returns the stringfied value stored at the specified key -// from the flag source. -func (f *flagSource) Get(key []string) (string, bool) { - flagStr := getFlagName(key) - val, found := f.found[flagStr] +// Get implements the confg.Source interface. Returns the stringfied value +// stored at the specified key from the flag source. +func (f *Flag) Get(key []string) (string, bool) { + k := strings.ToLower(strings.Join(key, `-`)) + val, found := f.m[k] return val, found } diff --git a/usage.go b/usage.go index e36d310..e2011c8 100644 --- a/usage.go +++ b/usage.go @@ -9,25 +9,13 @@ import ( "text/tabwriter" ) -func printUsage(fields []field, c context) { +func printUsage(fields []field) { - // sort the fields, by their long name. + // Sort the fields by their long name. sort.SliceStable(fields, func(i, j int) bool { return fields[i].flagName < fields[j].flagName }) - // Put confif and help last. - if c.confFlag != "" { - confFlagField := field{ - flagName: c.confFlag, - } - if c.confFile != "" { - confFlagField.options.defaultStr = c.confFile - confFlagField.options.help = "the 'filename' to load configuration from" - } - fields = append(fields, confFlagField) - } - fields = append(fields, field{ flagName: "help", boolField: true, @@ -59,13 +47,6 @@ func printUsage(fields []field, c context) { w.Flush() fmt.Fprintf(os.Stderr, "\n") - if c.confFile != "" { - fmt.Fprintf(os.Stderr, "FILES\n %s\n %s", c.confFile, "The system-wide configuration file") - if c.confFlag != "" { - fmt.Fprintf(os.Stderr, ` (overridden by --%s)`, c.confFlag) - } - fmt.Fprint(os.Stderr, "\n\n") - } } // getTypeAndHelp extracts the type and help message for a single field for From d9833441b3e91940761b3320ad32edbfc2cbd4b9 Mon Sep 17 00:00:00 2001 From: William Kennedy Date: Mon, 6 May 2019 10:03:23 +0200 Subject: [PATCH 03/13] test cleanup to write multi-source tests --- conf_test.go | 173 +++++++++++++++++++++++++++++---------------------- 1 file changed, 99 insertions(+), 74 deletions(-) diff --git a/conf_test.go b/conf_test.go index 9f51f64..8fce6c4 100644 --- a/conf_test.go +++ b/conf_test.go @@ -1,6 +1,9 @@ package conf_test import ( + "errors" + "fmt" + "io/ioutil" "os" "testing" @@ -14,53 +17,49 @@ const ( failed = "\u2717" ) -func TestParseFlags(t *testing.T) { - type config struct { - TestInt int - TestString string - TestBool bool - } - - tests := []struct { - name string - args []string - }{ - {"basic", []string{"--test-int", "1", "--test-string", "s", "--test-bool"}}, - } - - t.Log("Given the need to parse command line arguments.") - { - for i, tt := range tests { - t.Logf("\tTest: %d\tWhen checking these arguments %s", i, tt.args) - { - flag, err := source.NewFlag(tt.args) - if err != nil { - t.Fatalf("\t%s\tShould be able to call NewFlag : %s.", failed, err) - } - t.Logf("\t%s\tShould be able to call NewFlag.", success) - - var cfg config - err = conf.Parse(&cfg, flag) - if err != nil { - t.Fatalf("\t%s\tShould be able to Parse arguments : %s.", failed, err) - } - t.Logf("\t%s\tShould be able to Parse arguments.", success) +const ( + ENV = iota + 1 + FLAG + FILE +) - want := config{ - TestInt: 1, - TestString: "s", - TestBool: true, - } - if diff := cmp.Diff(want, cfg); diff != "" { - t.Fatalf("\t%s\tShould have properly initialized struct value\n%s", failed, diff) - } - t.Logf("\t%s\tShould have properly initialized struct value.", success) - } +// NewSource returns an initialized source for a given type. +func NewSource(src int, v interface{}) (conf.Source, error) { + switch src { + case ENV: + vars := v.(map[string]string) + os.Clearenv() + for k, v := range vars { + os.Setenv(k, v) } + return source.NewEnv("TEST") + + case FLAG: + args := v.([]string) + return source.NewFlag(args) + + case FILE: + d := v.(struct { + file *os.File + vars map[string]string + }) + var vars string + for k, v := range d.vars { + vars += fmt.Sprintf("%s %s\n", k, v) + } + if _, err := d.file.WriteString(vars); err != nil { + return nil, err + } + if err := d.file.Close(); err != nil { + return nil, err + } + return source.NewFile(d.file.Name()) } + + return nil, errors.New("invalid source provided") } -func TestParseEnv(t *testing.T) { +func TestBasicParse(t *testing.T) { type config struct { TestInt int TestString string @@ -69,51 +68,77 @@ func TestParseEnv(t *testing.T) { tests := []struct { name string - vars map[string]string + src int + args interface{} }{ - {"basic", map[string]string{"TEST_INT": "1", "TEST_STRING": "s", "TEST_BOOL": "TRUE"}}, + {"basic-flag", FLAG, []string{"--test-int", "1", "--test-string", "s", "--test-bool"}}, + {"basic-env", ENV, map[string]string{"TEST_INT": "1", "TEST_STRING": "s", "TEST_BOOL": "TRUE"}}, + {"basic-file", FILE, map[string]string{"TEST_INT": "1", "TEST_STRING": "s", "TEST_BOOL": "TRUE"}}, } - t.Log("Given the need to parse environmental variables.") + t.Log("Given the need to parse configuration.") { for i, tt := range tests { - t.Logf("\tTest: %d\tWhen checking these environmental variables %s", i, tt.vars) + t.Logf("\tTest: %d\tWhen checking this %d with arguments %s", i, tt.src, tt.args) { - os.Clearenv() - for k, v := range tt.vars { - os.Setenv(k, v) + f := func(t *testing.T) { + var source conf.Source + + switch tt.src { + case ENV, FLAG: + var err error + source, err = NewSource(tt.src, tt.args) + if err != nil { + t.Fatalf("\t%s\tShould be able to call NewFlag : %s.", failed, err) + } + t.Logf("\t%s\tShould be able to call NewFlag.", success) + + case FILE: + tf, err := ioutil.TempFile("", "conf-test") + if err != nil { + t.Fatalf("\t%s\tShould be able to create a temp file : %s.", failed, err) + } + t.Logf("\t%s\tShould be able to create a temp file.", success) + defer os.Remove(tf.Name()) + + d := struct { + file *os.File + vars map[string]string + }{ + file: tf, + vars: tt.args.(map[string]string), + } + + source, err = NewSource(tt.src, d) + if err != nil { + t.Fatalf("\t%s\tShould be able to call NewFlag : %s.", failed, err) + } + t.Logf("\t%s\tShould be able to call NewFlag.", success) + } + + var cfg config + if err := conf.Parse(&cfg, source); err != nil { + t.Fatalf("\t%s\tShould be able to Parse arguments : %s.", failed, err) + } + t.Logf("\t%s\tShould be able to Parse arguments.", success) + + want := config{ + TestInt: 1, + TestString: "s", + TestBool: true, + } + if diff := cmp.Diff(want, cfg); diff != "" { + t.Fatalf("\t%s\tShould have properly initialized struct value\n%s", failed, diff) + } + t.Logf("\t%s\tShould have properly initialized struct value.", success) } - env, err := source.NewEnv("TEST") - if err != nil { - t.Fatalf("\t%s\tShould be able to call NewEnv : %s.", failed, err) - } - t.Logf("\t%s\tShould be able to call NewEnv.", success) - - var cfg config - err = conf.Parse(&cfg, env) - if err != nil { - t.Fatalf("\t%s\tShould be able to Parse arguments : %s.", failed, err) - } - t.Logf("\t%s\tShould be able to Parse arguments.", success) - - want := config{ - TestInt: 1, - TestString: "s", - TestBool: true, - } - if diff := cmp.Diff(want, cfg); diff != "" { - t.Fatalf("\t%s\tShould have properly initialized struct value\n%s", failed, diff) - } - t.Logf("\t%s\tShould have properly initialized struct value.", success) + t.Run(tt.name, f) } } } } -func TestParseFile(t *testing.T) { -} - func TestMultiSource(t *testing.T) { } From 047acad449a45f05eb67d5a687b9a829c18188ca Mon Sep 17 00:00:00 2001 From: William Kennedy Date: Mon, 6 May 2019 11:02:50 +0200 Subject: [PATCH 04/13] refactoring tests --- conf.go | 58 ++++++++------ conf_test.go | 203 ++++++++++++++++++++++++++++++++++--------------- source/env.go | 4 +- source/file.go | 4 +- source/flag.go | 4 +- 5 files changed, 181 insertions(+), 92 deletions(-) diff --git a/conf.go b/conf.go index 9c88407..cd68a06 100644 --- a/conf.go +++ b/conf.go @@ -22,19 +22,17 @@ func (err *fieldError) Error() string { return fmt.Sprintf("conf: error assigning to field %s: converting '%s' to type %s. details: %s", err.fieldName, err.value, err.typeName, err.err) } -// Source represents a source of configuration data. Sources requiring -// the pre-fetching and processing of several values should ideally be lazily- -// loaded so that sources further down the chain are not queried if they're -// not going to be needed. -type Source interface { +// Sourcer provides the ability to source data from a configuration source. +// Consider the use of lazy-loading for sourcing large datasets or systems. +type Sourcer interface { - // Get takes the field key and attempts to locate that key in its + // Source takes the field key and attempts to locate that key in its // configuration data. Returns true if found with the value. - Get(key []string) (string, bool) + Source(key []string) (string, bool) } // Parse parses configuration into the provided struct. -func Parse(cfgStruct interface{}, sources ...Source) error { +func Parse(cfgStruct interface{}, sources ...Sourcer) error { // Get the list of fields from the configuration struct to process. fields, err := extractFields(nil, cfgStruct) @@ -47,27 +45,32 @@ func Parse(cfgStruct interface{}, sources ...Source) error { // Process all fields found in the config struct provided. for _, field := range fields { - var value string - var found bool - // Process each field against all sources. - for _, source := range sources { - value, found = source.Get(field.key) - if found { - break + // Set any default value into the struct for this field. + if field.options.defaultStr != "" { + if err := processField(field.options.defaultStr, field.field); err != nil { + return &fieldError{ + fieldName: field.name, + typeName: field.field.Type().String(), + value: field.options.defaultStr, + err: err, + } } } - // If this key is not provided, check if required or use default. - if !found { - if field.options.required { - return fmt.Errorf("required field %s is missing value", field.name) + // Process each field against all sources. + var provided bool + for _, sourcer := range sources { + if sourcer == nil { + continue + } + + var value string + if value, provided = sourcer.Source(field.key); !provided { + continue } - value = field.options.defaultStr - } - // If this config field will be set to it's zero value, return an error. - if value != "" { + // A value was found so update the struct value with it. if err := processField(value, field.field); err != nil { return &fieldError{ fieldName: field.name, @@ -77,6 +80,15 @@ func Parse(cfgStruct interface{}, sources ...Source) error { } } } + + // If this key is not provided by any source, check if it was + // required to be provided. + if !provided && field.options.required { + return fmt.Errorf("required field %s is missing value", field.name) + } + + // TODO : If this config field will be set to it's zero value, return an error. + // ANDY I NEED TO UNDERSTAND WHY YOU HAD THIS. SOME PEOPLE LIKE TO BE EXPLICIT. } return nil diff --git a/conf_test.go b/conf_test.go index 8fce6c4..8e836c2 100644 --- a/conf_test.go +++ b/conf_test.go @@ -18,18 +18,24 @@ const ( ) const ( - ENV = iota + 1 + DEFAULT = iota + ENV FLAG FILE ) +var srcNames = []string{"DEFAULT", "ENV", "FLAG", "FILE"} + // NewSource returns an initialized source for a given type. -func NewSource(src int, v interface{}) (conf.Source, error) { +func NewSource(src int, v interface{}) (conf.Sourcer, error) { switch src { + case DEFAULT: + return nil, nil + case ENV: - vars := v.(map[string]string) + args := v.(map[string]string) os.Clearenv() - for k, v := range vars { + for k, v := range args { os.Setenv(k, v) } return source.NewEnv("TEST") @@ -39,21 +45,23 @@ func NewSource(src int, v interface{}) (conf.Source, error) { return source.NewFlag(args) case FILE: - d := v.(struct { - file *os.File - vars map[string]string - }) + args := v.(map[string]string) + tf, err := ioutil.TempFile("", "conf-test") + if err != nil { + return nil, err + } + defer os.Remove(tf.Name()) var vars string - for k, v := range d.vars { + for k, v := range args { vars += fmt.Sprintf("%s %s\n", k, v) } - if _, err := d.file.WriteString(vars); err != nil { + if _, err := tf.WriteString(vars); err != nil { return nil, err } - if err := d.file.Close(); err != nil { + if err := tf.Close(); err != nil { return nil, err } - return source.NewFile(d.file.Name()) + return source.NewFile(tf.Name()) } return nil, errors.New("invalid source provided") @@ -61,73 +69,105 @@ func NewSource(src int, v interface{}) (conf.Source, error) { func TestBasicParse(t *testing.T) { type config struct { - TestInt int - TestString string - TestBool bool + TestInt int `conf:"default:9"` + TestString string `conf:"default:B"` + TestBool bool `conf:"default:true"` } tests := []struct { name string src int args interface{} + want config }{ - {"basic-flag", FLAG, []string{"--test-int", "1", "--test-string", "s", "--test-bool"}}, - {"basic-env", ENV, map[string]string{"TEST_INT": "1", "TEST_STRING": "s", "TEST_BOOL": "TRUE"}}, - {"basic-file", FILE, map[string]string{"TEST_INT": "1", "TEST_STRING": "s", "TEST_BOOL": "TRUE"}}, + {"basic-default", DEFAULT, nil, config{9, "B", true}}, + {"basic-env", ENV, map[string]string{"TEST_INT": "1", "TEST_STRING": "s", "TEST_BOOL": "TRUE"}, config{1, "s", true}}, + {"basic-flag", FLAG, []string{"--test-int", "1", "--test-string", "s", "--test-bool"}, config{1, "s", true}}, + {"basic-file", FILE, map[string]string{"TEST_INT": "1", "TEST_STRING": "s", "TEST_BOOL": "TRUE"}, config{1, "s", true}}, } - t.Log("Given the need to parse configuration.") + t.Log("Given the need to parse basic configuration.") { for i, tt := range tests { - t.Logf("\tTest: %d\tWhen checking this %d with arguments %s", i, tt.src, tt.args) + t.Logf("\tTest: %d\tWhen checking %s with arguments %s", i, srcNames[tt.src], tt.args) { f := func(t *testing.T) { - var source conf.Source + sourcer, err := NewSource(tt.src, tt.args) + if err != nil { + t.Fatalf("\t%s\tShould be able to create a new %s source : %s.", failed, srcNames[tt.src], err) + } + t.Logf("\t%s\tShould be able to create a new %s source.", success, srcNames[tt.src]) - switch tt.src { - case ENV, FLAG: - var err error - source, err = NewSource(tt.src, tt.args) - if err != nil { - t.Fatalf("\t%s\tShould be able to call NewFlag : %s.", failed, err) - } - t.Logf("\t%s\tShould be able to call NewFlag.", success) + var cfg config + if err := conf.Parse(&cfg, sourcer); err != nil { + t.Fatalf("\t%s\tShould be able to Parse arguments : %s.", failed, err) + } + t.Logf("\t%s\tShould be able to Parse arguments.", success) - case FILE: - tf, err := ioutil.TempFile("", "conf-test") - if err != nil { - t.Fatalf("\t%s\tShould be able to create a temp file : %s.", failed, err) - } - t.Logf("\t%s\tShould be able to create a temp file.", success) - defer os.Remove(tf.Name()) - - d := struct { - file *os.File - vars map[string]string - }{ - file: tf, - vars: tt.args.(map[string]string), - } + if diff := cmp.Diff(tt.want, cfg); diff != "" { + t.Fatalf("\t%s\tShould have properly initialized struct value\n%s", failed, diff) + } + t.Logf("\t%s\tShould have properly initialized struct value.", success) + } + + t.Run(tt.name, f) + } + } + } +} + +func TestMultiSource(t *testing.T) { + type config struct { + TestInt int + TestString string + TestBool bool + } + + tests := []struct { + name string + sources []struct { + src int + args interface{} + } + want config + }{ + { + name: "basic-env-flag", + sources: []struct { + src int + args interface{} + }{ + {ENV, map[string]string{"TEST_INT": "1", "TEST_STRING": "s", "TEST_BOOL": "TRUE"}}, + {FLAG, []string{"--test-int", "2", "--test-bool", "FALSE"}}, + }, + want: config{2, "s", false}, + }, + } + + t.Log("Given the need to parse multi-source configurations.") + { + for i, tt := range tests { + t.Logf("\tTest: %d\tWhen checking %d sources", i, len(tt.sources)) + { + f := func(t *testing.T) { + var cfg config - source, err = NewSource(tt.src, d) + sources := make([]conf.Sourcer, len(tt.sources)) + for i, ttt := range tt.sources { + sourcer, err := NewSource(ttt.src, ttt.args) if err != nil { - t.Fatalf("\t%s\tShould be able to call NewFlag : %s.", failed, err) + t.Fatalf("\t%s\tShould be able to create a new %s source : %s.", failed, srcNames[ttt.src], err) } - t.Logf("\t%s\tShould be able to call NewFlag.", success) + t.Logf("\t%s\tShould be able to create a new %s source.", success, srcNames[ttt.src]) + sources[i] = sourcer } - var cfg config - if err := conf.Parse(&cfg, source); err != nil { + if err := conf.Parse(&cfg, sources...); err != nil { t.Fatalf("\t%s\tShould be able to Parse arguments : %s.", failed, err) } t.Logf("\t%s\tShould be able to Parse arguments.", success) - want := config{ - TestInt: 1, - TestString: "s", - TestBool: true, - } - if diff := cmp.Diff(want, cfg); diff != "" { + if diff := cmp.Diff(tt.want, cfg); diff != "" { t.Fatalf("\t%s\tShould have properly initialized struct value\n%s", failed, diff) } t.Logf("\t%s\tShould have properly initialized struct value.", success) @@ -139,21 +179,58 @@ func TestBasicParse(t *testing.T) { } } -func TestMultiSource(t *testing.T) { -} +func TestParseErrors(t *testing.T) { + t.Log("Given the need to validate errors that can occur with Parse.") + { + t.Logf("\tTest: %d\tWhen passing bad values to Parse.", 0) + { + f := func(t *testing.T) { + var cfg struct { + TestInt int + TestString string + TestBool bool + } + err := conf.Parse(cfg) + if err == nil { + t.Fatalf("\t%s\tShould NOT be able to accept a value by value.", failed) + } + t.Logf("\t%s\tShould NOT be able to accept a value by value.", success) + } + t.Run("not-by-ref", f) -func TestParseNonRefIsError(t *testing.T) { -} + f = func(t *testing.T) { + var cfg []string + err := conf.Parse(cfg) + if err == nil { + t.Fatalf("\t%s\tShould NOT be able to pass anything but a struct value.", failed) + } + t.Logf("\t%s\tShould NOT be able to pass anything but a struct value.", success) + } + t.Run("no-struct-value", f) + } -func TestParseNonStructIsError(t *testing.T) { + t.Logf("\tTest: %d\tWhen bad tags to Parse.", 1) + { + f := func(t *testing.T) { + var cfg struct { + TestInt int `conf:"default:"` + TestString string + TestBool bool + } + err := conf.Parse(&cfg) + if err == nil { + t.Fatalf("\t%s\tShould NOT be able to accept tag missing value.", failed) + } + t.Logf("\t%s\tShould NOT be able to accept tag missing value.", success) + } + t.Run("tag-missing-value", f) + } + } } func TestSkipedFieldIsSkipped(t *testing.T) { } -func TestTagMissingValueIsError(t *testing.T) { -} - func TestBadShortTagIsError(t *testing.T) { } diff --git a/source/env.go b/source/env.go index 857fd1e..ce80a18 100644 --- a/source/env.go +++ b/source/env.go @@ -44,9 +44,9 @@ func NewEnv(namespace string) (*Env, error) { return &Env{m: m}, nil } -// Get implements the confg.Source interface. It returns the stringfied value +// Source implements the confg.Sourcer interface. It returns the stringfied value // stored at the specified key from the environment. -func (e *Env) Get(key []string) (string, bool) { +func (e *Env) Source(key []string) (string, bool) { env := strings.ToUpper(strings.Join(key, `_`)) return os.LookupEnv(env) } diff --git a/source/file.go b/source/file.go index 3bbda65..9f287df 100644 --- a/source/file.go +++ b/source/file.go @@ -58,9 +58,9 @@ func NewFile(filename string) (*File, error) { return &File{m: m}, nil } -// Get implements the confg.Source interface. It returns the stringfied value +// Source implements the confg.Sourcer interface. It returns the stringfied value // stored at the specified key in the plain config file. -func (f *File) Get(key []string) (string, bool) { +func (f *File) Source(key []string) (string, bool) { k := strings.ToUpper(strings.Join(key, `_`)) value, ok := f.m[k] return value, ok diff --git a/source/flag.go b/source/flag.go index 345cce2..be14c30 100644 --- a/source/flag.go +++ b/source/flag.go @@ -89,9 +89,9 @@ func NewFlag(args []string) (*Flag, error) { return &Flag{m: m}, nil } -// Get implements the confg.Source interface. Returns the stringfied value +// Source implements the confg.Sourcer interface. Returns the stringfied value // stored at the specified key from the flag source. -func (f *Flag) Get(key []string) (string, bool) { +func (f *Flag) Source(key []string) (string, bool) { k := strings.ToLower(strings.Join(key, `-`)) val, found := f.m[k] return val, found From 406207c6d433c6831a138ca9677505d8e704a081 Mon Sep 17 00:00:00 2001 From: William Kennedy Date: Mon, 6 May 2019 13:05:02 +0200 Subject: [PATCH 05/13] fixing issues discovered when adding short support --- conf_test.go | 91 +++++++++++++++++++++++++++++++++++++++++---------- fields.go | 17 +++++++--- source/env.go | 10 +++--- 3 files changed, 93 insertions(+), 25 deletions(-) diff --git a/conf_test.go b/conf_test.go index 8e836c2..fae4f8d 100644 --- a/conf_test.go +++ b/conf_test.go @@ -36,7 +36,7 @@ func NewSource(src int, v interface{}) (conf.Sourcer, error) { args := v.(map[string]string) os.Clearenv() for k, v := range args { - os.Setenv(k, v) + os.Setenv("TEST_"+k, v) } return source.NewEnv("TEST") @@ -69,9 +69,9 @@ func NewSource(src int, v interface{}) (conf.Sourcer, error) { func TestBasicParse(t *testing.T) { type config struct { - TestInt int `conf:"default:9"` - TestString string `conf:"default:B"` - TestBool bool `conf:"default:true"` + AnInt int `conf:"default:9"` + AString string `conf:"default:B,short:s"` + Bool bool } tests := []struct { @@ -80,10 +80,10 @@ func TestBasicParse(t *testing.T) { args interface{} want config }{ - {"basic-default", DEFAULT, nil, config{9, "B", true}}, - {"basic-env", ENV, map[string]string{"TEST_INT": "1", "TEST_STRING": "s", "TEST_BOOL": "TRUE"}, config{1, "s", true}}, - {"basic-flag", FLAG, []string{"--test-int", "1", "--test-string", "s", "--test-bool"}, config{1, "s", true}}, - {"basic-file", FILE, map[string]string{"TEST_INT": "1", "TEST_STRING": "s", "TEST_BOOL": "TRUE"}, config{1, "s", true}}, + {"basic-default", DEFAULT, nil, config{9, "B", false}}, + {"basic-env", ENV, map[string]string{"TEST_ANINT": "1", "TEST_S": "s", "TEST_BOOL": "TRUE"}, config{1, "s", true}}, + {"basic-flag", FLAG, []string{"--an-int", "1", "-s", "s", "--bool"}, config{1, "s", true}}, + {"basic-file", FILE, map[string]string{"AN_INT": "1", "S": "s", "BOOL": "TRUE"}, config{1, "s", true}}, } t.Log("Given the need to parse basic configuration.") @@ -118,9 +118,9 @@ func TestBasicParse(t *testing.T) { func TestMultiSource(t *testing.T) { type config struct { - TestInt int - TestString string - TestBool bool + AnInt int `conf:"default:9"` + AString string `conf:"default:B,short:s"` + Bool bool } tests := []struct { @@ -137,8 +137,8 @@ func TestMultiSource(t *testing.T) { src int args interface{} }{ - {ENV, map[string]string{"TEST_INT": "1", "TEST_STRING": "s", "TEST_BOOL": "TRUE"}}, - {FLAG, []string{"--test-int", "2", "--test-bool", "FALSE"}}, + {ENV, map[string]string{"TEST_ANINT": "1", "TEST_S": "s", "TEST_BOOL": "TRUE"}}, + {FLAG, []string{"--an-int", "2", "-s", "s", "--bool", "false"}}, }, want: config{2, "s", false}, }, @@ -179,6 +179,52 @@ func TestMultiSource(t *testing.T) { } } +func TestFlagParse(t *testing.T) { + type config struct { + AnInt int `conf:"short:i"` + AString string `conf:"default:B"` + Bool bool `conf:"default:true"` + } + + tests := []struct { + name string + src int + args interface{} + want config + }{ + {"basic-flag", FLAG, []string{"-i", "1", "--a-string", "s", "--bool"}, config{1, "s", true}}, + } + + t.Log("Given the need to parse basic configuration.") + { + for i, tt := range tests { + t.Logf("\tTest: %d\tWhen checking %s with arguments %s", i, srcNames[tt.src], tt.args) + { + f := func(t *testing.T) { + sourcer, err := NewSource(tt.src, tt.args) + if err != nil { + t.Fatalf("\t%s\tShould be able to create a new %s source : %s.", failed, srcNames[tt.src], err) + } + t.Logf("\t%s\tShould be able to create a new %s source.", success, srcNames[tt.src]) + + var cfg config + if err := conf.Parse(&cfg, sourcer); err != nil { + t.Fatalf("\t%s\tShould be able to Parse arguments : %s.", failed, err) + } + t.Logf("\t%s\tShould be able to Parse arguments.", success) + + if diff := cmp.Diff(tt.want, cfg); diff != "" { + t.Fatalf("\t%s\tShould have properly initialized struct value\n%s", failed, diff) + } + t.Logf("\t%s\tShould have properly initialized struct value.", success) + } + + t.Run(tt.name, f) + } + } + } +} + func TestParseErrors(t *testing.T) { t.Log("Given the need to validate errors that can occur with Parse.") { @@ -206,7 +252,7 @@ func TestParseErrors(t *testing.T) { } t.Logf("\t%s\tShould NOT be able to pass anything but a struct value.", success) } - t.Run("no-struct-value", f) + t.Run("not-struct-value", f) } t.Logf("\tTest: %d\tWhen bad tags to Parse.", 1) @@ -224,6 +270,20 @@ func TestParseErrors(t *testing.T) { t.Logf("\t%s\tShould NOT be able to accept tag missing value.", success) } t.Run("tag-missing-value", f) + + f = func(t *testing.T) { + var cfg struct { + TestInt int `conf:"short:ab"` + TestString string + TestBool bool + } + err := conf.Parse(&cfg) + if err == nil { + t.Fatalf("\t%s\tShould NOT be able to accept invalid short tag.", failed) + } + t.Logf("\t%s\tShould NOT be able to accept invalid short tag.", success) + } + t.Run("tag-bad-short", f) } } } @@ -231,9 +291,6 @@ func TestParseErrors(t *testing.T) { func TestSkipedFieldIsSkipped(t *testing.T) { } -func TestBadShortTagIsError(t *testing.T) { -} - func TestCannotSetRequiredAndDefaultTags(t *testing.T) { } diff --git a/fields.go b/fields.go index b330b23..e40d026 100644 --- a/fields.go +++ b/fields.go @@ -27,7 +27,7 @@ type field struct { } type fieldOptions struct { - short rune // Allow for alternate name, perhaps. + short rune help string defaultStr string noprint bool @@ -66,15 +66,24 @@ func extractFields(prefix []string, target interface{}) ([]field, error) { fieldName := structField.Name - // Break name into constituent pieces via CamelCase parser. - fieldKey := append(prefix, camelSplit(fieldName)...) - // Get and options. TODO: Need more. fieldOpts, err := parseTag(fieldTags) if err != nil { return nil, fmt.Errorf("conf: error parsing tags for field %s: %s", fieldName, err) } + // Generate the field key for source lookup. + var fieldKey []string + if fieldOpts.short == 0 { + + // Break name into constituent pieces via CamelCase parser. + fieldKey = append(prefix, camelSplit(fieldName)...) + } else { + + // Use the short name that was specified. + fieldKey = []string{string(fieldOpts.short)} + } + // Drill down through pointers until we bottom out at type or nil. for f.Kind() == reflect.Ptr { if f.IsNil() { diff --git a/source/env.go b/source/env.go index ce80a18..65a5e0f 100644 --- a/source/env.go +++ b/source/env.go @@ -9,7 +9,8 @@ import ( // Env is a source for environmental variables. type Env struct { - m map[string]string + namespace string + m map[string]string } // NewEnv accepts a namespace and parses the environment into a Env for @@ -41,12 +42,13 @@ func NewEnv(namespace string) (*Env, error) { return nil, fmt.Errorf("namespace %q was not found", namespace) } - return &Env{m: m}, nil + return &Env{namespace: namespace, m: m}, nil } // Source implements the confg.Sourcer interface. It returns the stringfied value // stored at the specified key from the environment. func (e *Env) Source(key []string) (string, bool) { - env := strings.ToUpper(strings.Join(key, `_`)) - return os.LookupEnv(env) + k := e.namespace + "_" + strings.ToUpper(strings.Join(key, ``)) + v, ok := e.m[k] + return v, ok } From afc8483418ef677ba8635e047b9a17af9bf51650 Mon Sep 17 00:00:00 2001 From: William Kennedy Date: Mon, 6 May 2019 13:58:17 +0200 Subject: [PATCH 06/13] example tests for usage --- conf.go | 10 ++++++ conf_test.go | 100 ++++++++++++++++++++++++++++++++++++++++++++++++--- usage.go | 13 ++++--- 3 files changed, 114 insertions(+), 9 deletions(-) diff --git a/conf.go b/conf.go index cd68a06..81583a8 100644 --- a/conf.go +++ b/conf.go @@ -116,3 +116,13 @@ func String(v interface{}) (string, error) { return s.String(), nil } + +// Usage provides output to display the config usage on the command line. +func Usage(v interface{}) (string, error) { + fields, err := extractFields(nil, v) + if err != nil { + return "", err + } + + return fmtUsage(fields), nil +} diff --git a/conf_test.go b/conf_test.go index fae4f8d..1aac9a3 100644 --- a/conf_test.go +++ b/conf_test.go @@ -72,6 +72,7 @@ func TestBasicParse(t *testing.T) { AnInt int `conf:"default:9"` AString string `conf:"default:B,short:s"` Bool bool + Skip []float64 `conf:"-"` } tests := []struct { @@ -80,10 +81,10 @@ func TestBasicParse(t *testing.T) { args interface{} want config }{ - {"basic-default", DEFAULT, nil, config{9, "B", false}}, - {"basic-env", ENV, map[string]string{"TEST_ANINT": "1", "TEST_S": "s", "TEST_BOOL": "TRUE"}, config{1, "s", true}}, - {"basic-flag", FLAG, []string{"--an-int", "1", "-s", "s", "--bool"}, config{1, "s", true}}, - {"basic-file", FILE, map[string]string{"AN_INT": "1", "S": "s", "BOOL": "TRUE"}, config{1, "s", true}}, + {"basic-default", DEFAULT, nil, config{9, "B", false, nil}}, + {"basic-env", ENV, map[string]string{"TEST_ANINT": "1", "TEST_S": "s", "TEST_BOOL": "TRUE"}, config{1, "s", true, nil}}, + {"basic-flag", FLAG, []string{"--an-int", "1", "-s", "s", "--bool"}, config{1, "s", true, nil}}, + {"basic-file", FILE, map[string]string{"AN_INT": "1", "S": "s", "BOOL": "TRUE"}, config{1, "s", true, nil}}, } t.Log("Given the need to parse basic configuration.") @@ -288,6 +289,97 @@ func TestParseErrors(t *testing.T) { } } +func ExampleString() { + type config struct { + AnInt int `conf:"default:9"` + AString string `conf:"default:B,short:s"` + Bool bool + Skip []float64 `conf:"-"` + } + + test := struct { + name string + src int + args interface{} + }{ + name: "basic-env", + src: ENV, + args: map[string]string{"TEST_ANINT": "1", "TEST_S": "s", "TEST_BOOL": "TRUE"}, + } + + sourcer, err := NewSource(test.src, test.args) + if err != nil { + fmt.Print(err) + return + } + + var cfg config + if err := conf.Parse(&cfg, sourcer); err != nil { + fmt.Print(err) + return + } + + out, err := conf.String(&cfg) + if err != nil { + fmt.Print(err) + return + } + + fmt.Print(out) + + // Output: + // an-int=1 s=s bool=true +} + +func ExampleUsage() { + type config struct { + AnInt int `conf:"default:9"` + AString string `conf:"default:B,short:s"` + Bool bool + Skip []float64 `conf:"-"` + } + + test := struct { + name string + src int + args interface{} + }{ + name: "basic-env", + src: ENV, + args: map[string]string{"TEST_ANINT": "1", "TEST_S": "s", "TEST_BOOL": "TRUE"}, + } + + sourcer, err := NewSource(test.src, test.args) + if err != nil { + fmt.Print(err) + return + } + + var cfg config + if err := conf.Parse(&cfg, sourcer); err != nil { + fmt.Print(err) + return + } + + out, err := conf.Usage(&cfg) + if err != nil { + fmt.Print(err) + return + } + + fmt.Print(out) + + // Output: + // Usage: conf.test [options] [arguments] + + // OPTIONS + // --an-int/$an-int (default: 9) + // --bool/$bool + // --s/-s/$s (default: B) + // --help/-h + // display this help message +} + func TestSkipedFieldIsSkipped(t *testing.T) { } diff --git a/usage.go b/usage.go index e2011c8..c35d27a 100644 --- a/usage.go +++ b/usage.go @@ -3,13 +3,15 @@ package conf import ( "fmt" "os" + "path" "reflect" "sort" "strings" "text/tabwriter" ) -func printUsage(fields []field) { +func fmtUsage(fields []field) string { + var sb strings.Builder // Sort the fields by their long name. sort.SliceStable(fields, func(i, j int) bool { @@ -24,11 +26,12 @@ func printUsage(fields []field) { help: "display this help message", }}) - fmt.Fprintf(os.Stderr, "Usage: %s [options] [arguments]\n\n", os.Args[0]) + _, file := path.Split(os.Args[0]) + fmt.Fprintf(&sb, "Usage: %s [options] [arguments]\n\n", file) - fmt.Fprintln(os.Stderr, "OPTIONS") + fmt.Fprintln(&sb, "OPTIONS") w := new(tabwriter.Writer) - w.Init(os.Stderr, 0, 4, 2, ' ', tabwriter.TabIndent) + w.Init(&sb, 0, 4, 2, ' ', tabwriter.TabIndent) for _, f := range fields { typeName, help := getTypeAndHelp(&f) @@ -46,7 +49,7 @@ func printUsage(fields []field) { } w.Flush() - fmt.Fprintf(os.Stderr, "\n") + return sb.String() } // getTypeAndHelp extracts the type and help message for a single field for From a0a148c8d6cacf7941a5be4c9a2bcf1b0674554c Mon Sep 17 00:00:00 2001 From: William Kennedy Date: Mon, 6 May 2019 14:40:12 +0200 Subject: [PATCH 07/13] output tests --- conf.go | 10 ++++- conf_test.go | 106 +++++++++++++++++++++++++++++---------------------- fields.go | 13 +------ 3 files changed, 72 insertions(+), 57 deletions(-) diff --git a/conf.go b/conf.go index 81583a8..995190a 100644 --- a/conf.go +++ b/conf.go @@ -65,8 +65,16 @@ func Parse(cfgStruct interface{}, sources ...Sourcer) error { continue } + // If a short name was provided, that needs to be used. + var key []string + if field.options.short == 0 { + key = field.key + } else { + key = []string{string(field.options.short)} + } + var value string - if value, provided = sourcer.Source(field.key); !provided { + if value, provided = sourcer.Source(key); !provided { continue } diff --git a/conf_test.go b/conf_test.go index 1aac9a3..84c4bce 100644 --- a/conf_test.go +++ b/conf_test.go @@ -5,6 +5,7 @@ import ( "fmt" "io/ioutil" "os" + "strings" "testing" "github.com/flowchartsman/conf" @@ -289,49 +290,71 @@ func TestParseErrors(t *testing.T) { } } -func ExampleString() { - type config struct { - AnInt int `conf:"default:9"` - AString string `conf:"default:B,short:s"` - Bool bool - Skip []float64 `conf:"-"` - } - - test := struct { - name string - src int - args interface{} - }{ - name: "basic-env", - src: ENV, - args: map[string]string{"TEST_ANINT": "1", "TEST_S": "s", "TEST_BOOL": "TRUE"}, - } +func TestUsage(t *testing.T) { + t.Log("Given the need validate usage output.") + { + t.Logf("\tTest: %d\tWhen using a basic struct.", 0) + { + type config struct { + AnInt int `conf:"default:9"` + AString string `conf:"default:B,short:s"` + Bool bool + Skip []float64 `conf:"-"` + } - sourcer, err := NewSource(test.src, test.args) - if err != nil { - fmt.Print(err) - return - } + test := struct { + name string + src int + args interface{} + }{ + name: "basic-env", + src: ENV, + args: map[string]string{"TEST_ANINT": "1", "TEST_S": "s", "TEST_BOOL": "TRUE"}, + } - var cfg config - if err := conf.Parse(&cfg, sourcer); err != nil { - fmt.Print(err) - return - } + sourcer, err := NewSource(test.src, test.args) + if err != nil { + fmt.Print(err) + return + } - out, err := conf.String(&cfg) - if err != nil { - fmt.Print(err) - return - } + var cfg config + if err := conf.Parse(&cfg, sourcer); err != nil { + fmt.Print(err) + return + } - fmt.Print(out) + got, err := conf.Usage(&cfg) + if err != nil { + fmt.Print(err) + return + } - // Output: - // an-int=1 s=s bool=true + got = strings.TrimRight(got, " \n") + want := `Usage: conf.test [options] [arguments] + +OPTIONS + --a-string/-s/$a-string (default: B) + --an-int/$an-int (default: 9) + --bool/$bool + --help/-h + display this help message` + + bGot := []byte(got) + bWant := []byte(want) + if diff := cmp.Diff(bGot, bWant); diff != "" { + t.Log("got:\n", got) + t.Log("\n", bGot) + t.Log("wait:\n", want) + t.Log("\n", bWant) + t.Fatalf("\t%s\tShould match byte for byte the output.", failed) + } + t.Logf("\t%s\tShould match byte for byte the output.", success) + } + } } -func ExampleUsage() { +func ExampleString() { type config struct { AnInt int `conf:"default:9"` AString string `conf:"default:B,short:s"` @@ -361,7 +384,7 @@ func ExampleUsage() { return } - out, err := conf.Usage(&cfg) + out, err := conf.String(&cfg) if err != nil { fmt.Print(err) return @@ -370,14 +393,7 @@ func ExampleUsage() { fmt.Print(out) // Output: - // Usage: conf.test [options] [arguments] - - // OPTIONS - // --an-int/$an-int (default: 9) - // --bool/$bool - // --s/-s/$s (default: B) - // --help/-h - // display this help message + // an-int=1 a-string=s bool=true } func TestSkipedFieldIsSkipped(t *testing.T) { diff --git a/fields.go b/fields.go index e40d026..a6866ed 100644 --- a/fields.go +++ b/fields.go @@ -72,17 +72,8 @@ func extractFields(prefix []string, target interface{}) ([]field, error) { return nil, fmt.Errorf("conf: error parsing tags for field %s: %s", fieldName, err) } - // Generate the field key for source lookup. - var fieldKey []string - if fieldOpts.short == 0 { - - // Break name into constituent pieces via CamelCase parser. - fieldKey = append(prefix, camelSplit(fieldName)...) - } else { - - // Use the short name that was specified. - fieldKey = []string{string(fieldOpts.short)} - } + // Generate the field key. This could be ignored for short in options. + fieldKey := append(prefix, camelSplit(fieldName)...) // Drill down through pointers until we bottom out at type or nil. for f.Kind() == reflect.Ptr { From 14eee66734e0794eb2af10d7c745f1e1daaae3d0 Mon Sep 17 00:00:00 2001 From: William Kennedy Date: Mon, 6 May 2019 14:49:06 +0200 Subject: [PATCH 08/13] added required tests --- conf_test.go | 22 ++++++++++++++++++---- 1 file changed, 18 insertions(+), 4 deletions(-) diff --git a/conf_test.go b/conf_test.go index 84c4bce..4e8855f 100644 --- a/conf_test.go +++ b/conf_test.go @@ -183,7 +183,7 @@ func TestMultiSource(t *testing.T) { func TestFlagParse(t *testing.T) { type config struct { - AnInt int `conf:"short:i"` + AnInt int `conf:"required,short:i"` AString string `conf:"default:B"` Bool bool `conf:"default:true"` } @@ -287,6 +287,23 @@ func TestParseErrors(t *testing.T) { } t.Run("tag-bad-short", f) } + + t.Logf("\tTest: %d\tWhen required values are missing.", 2) + { + f := func(t *testing.T) { + var cfg struct { + TestInt int `conf:"required, default:1"` + TestString string + TestBool bool + } + err := conf.Parse(&cfg) + if err == nil { + t.Fatalf("\t%s\tShould fail for missing required value.", failed) + } + t.Logf("\t%s\tShould fail for missing required value.", success) + } + t.Run("required-missing-value", f) + } } } @@ -399,9 +416,6 @@ func ExampleString() { func TestSkipedFieldIsSkipped(t *testing.T) { } -func TestCannotSetRequiredAndDefaultTags(t *testing.T) { -} - func TestHierarchicalFieldNames(t *testing.T) { } From 9ad43f493643e4efa25758c7a05708f5a52fbdc1 Mon Sep 17 00:00:00 2001 From: William Kennedy Date: Mon, 6 May 2019 16:54:30 +0200 Subject: [PATCH 09/13] finished porting existing tests --- conf.go | 10 +++++----- conf_test.go | 40 ++++++++++++++++++++-------------------- source/env.go | 7 +++---- 3 files changed, 28 insertions(+), 29 deletions(-) diff --git a/conf.go b/conf.go index 995190a..0752c59 100644 --- a/conf.go +++ b/conf.go @@ -9,16 +9,16 @@ import ( // ErrInvalidStruct indicates that a configuration struct is not the correct type. var ErrInvalidStruct = errors.New("configuration must be a struct pointer") -// A fieldError occurs when an error occurs updating an individual field +// A FieldError occurs when an error occurs updating an individual field // in the provided struct value. -type fieldError struct { +type FieldError struct { fieldName string typeName string value string err error } -func (err *fieldError) Error() string { +func (err *FieldError) Error() string { return fmt.Sprintf("conf: error assigning to field %s: converting '%s' to type %s. details: %s", err.fieldName, err.value, err.typeName, err.err) } @@ -49,7 +49,7 @@ func Parse(cfgStruct interface{}, sources ...Sourcer) error { // Set any default value into the struct for this field. if field.options.defaultStr != "" { if err := processField(field.options.defaultStr, field.field); err != nil { - return &fieldError{ + return &FieldError{ fieldName: field.name, typeName: field.field.Type().String(), value: field.options.defaultStr, @@ -80,7 +80,7 @@ func Parse(cfgStruct interface{}, sources ...Sourcer) error { // A value was found so update the struct value with it. if err := processField(value, field.field); err != nil { - return &fieldError{ + return &FieldError{ fieldName: field.name, typeName: field.field.Type().String(), value: value, diff --git a/conf_test.go b/conf_test.go index 4e8855f..df789fc 100644 --- a/conf_test.go +++ b/conf_test.go @@ -37,7 +37,7 @@ func NewSource(src int, v interface{}) (conf.Sourcer, error) { args := v.(map[string]string) os.Clearenv() for k, v := range args { - os.Setenv("TEST_"+k, v) + os.Setenv(k, v) } return source.NewEnv("TEST") @@ -68,12 +68,21 @@ func NewSource(src int, v interface{}) (conf.Sourcer, error) { return nil, errors.New("invalid source provided") } -func TestBasicParse(t *testing.T) { +func TestParse(t *testing.T) { + type ip struct { + Name string `conf:"default:localhost"` + IP string `conf:"default:127.0.0.0"` + } + type Embed struct { + Name string `conf:"default:bill"` + } type config struct { AnInt int `conf:"default:9"` AString string `conf:"default:B,short:s"` Bool bool - Skip []float64 `conf:"-"` + Skip string `conf:"-"` + IP ip + Embed } tests := []struct { @@ -82,16 +91,16 @@ func TestBasicParse(t *testing.T) { args interface{} want config }{ - {"basic-default", DEFAULT, nil, config{9, "B", false, nil}}, - {"basic-env", ENV, map[string]string{"TEST_ANINT": "1", "TEST_S": "s", "TEST_BOOL": "TRUE"}, config{1, "s", true, nil}}, - {"basic-flag", FLAG, []string{"--an-int", "1", "-s", "s", "--bool"}, config{1, "s", true, nil}}, - {"basic-file", FILE, map[string]string{"AN_INT": "1", "S": "s", "BOOL": "TRUE"}, config{1, "s", true, nil}}, + {"default", DEFAULT, nil, config{9, "B", false, "", ip{"localhost", "127.0.0.0"}, Embed{"bill"}}}, + {"env", ENV, map[string]string{"TEST_AN_INT": "1", "TEST_S": "s", "TEST_BOOL": "TRUE", "TEST_SKIP": "SKIP", "TEST_IP_NAME": "local", "TEST_NAME": "andy"}, config{1, "s", true, "", ip{"local", "127.0.0.0"}, Embed{"andy"}}}, + {"flag", FLAG, []string{"--an-int", "1", "-s", "s", "--bool", "--skip", "skip", "--ip-name", "local", "--name", "andy"}, config{1, "s", true, "", ip{"local", "127.0.0.0"}, Embed{"andy"}}}, + {"file", FILE, map[string]string{"AN_INT": "1", "S": "s", "BOOL": "TRUE", "SKIP": "skip", "IP_NAME": "local", "NAME": "andy"}, config{1, "s", true, "", ip{"local", "127.0.0.0"}, Embed{"andy"}}}, } t.Log("Given the need to parse basic configuration.") { for i, tt := range tests { - t.Logf("\tTest: %d\tWhen checking %s with arguments %s", i, srcNames[tt.src], tt.args) + t.Logf("\tTest: %d\tWhen checking %s with arguments %v", i, srcNames[tt.src], tt.args) { f := func(t *testing.T) { sourcer, err := NewSource(tt.src, tt.args) @@ -139,7 +148,7 @@ func TestMultiSource(t *testing.T) { src int args interface{} }{ - {ENV, map[string]string{"TEST_ANINT": "1", "TEST_S": "s", "TEST_BOOL": "TRUE"}}, + {ENV, map[string]string{"TEST_AN_INT": "1", "TEST_S": "s", "TEST_BOOL": "TRUE"}}, {FLAG, []string{"--an-int", "2", "-s", "s", "--bool", "false"}}, }, want: config{2, "s", false}, @@ -227,7 +236,7 @@ func TestFlagParse(t *testing.T) { } } -func TestParseErrors(t *testing.T) { +func TestErrors(t *testing.T) { t.Log("Given the need to validate errors that can occur with Parse.") { t.Logf("\tTest: %d\tWhen passing bad values to Parse.", 0) @@ -386,7 +395,7 @@ func ExampleString() { }{ name: "basic-env", src: ENV, - args: map[string]string{"TEST_ANINT": "1", "TEST_S": "s", "TEST_BOOL": "TRUE"}, + args: map[string]string{"TEST_AN_INT": "1", "TEST_S": "s", "TEST_BOOL": "TRUE"}, } sourcer, err := NewSource(test.src, test.args) @@ -412,12 +421,3 @@ func ExampleString() { // Output: // an-int=1 a-string=s bool=true } - -func TestSkipedFieldIsSkipped(t *testing.T) { -} - -func TestHierarchicalFieldNames(t *testing.T) { -} - -func TestEmbeddedFieldNames(t *testing.T) { -} diff --git a/source/env.go b/source/env.go index 65a5e0f..fb696db 100644 --- a/source/env.go +++ b/source/env.go @@ -9,8 +9,7 @@ import ( // Env is a source for environmental variables. type Env struct { - namespace string - m map[string]string + m map[string]string } // NewEnv accepts a namespace and parses the environment into a Env for @@ -42,13 +41,13 @@ func NewEnv(namespace string) (*Env, error) { return nil, fmt.Errorf("namespace %q was not found", namespace) } - return &Env{namespace: namespace, m: m}, nil + return &Env{m: m}, nil } // Source implements the confg.Sourcer interface. It returns the stringfied value // stored at the specified key from the environment. func (e *Env) Source(key []string) (string, bool) { - k := e.namespace + "_" + strings.ToUpper(strings.Join(key, ``)) + k := strings.ToUpper(strings.Join(key, `_`)) v, ok := e.m[k] return v, ok } From d5d8c780303d0b7d4d813c131c70f05bfe228514 Mon Sep 17 00:00:00 2001 From: William Kennedy Date: Mon, 6 May 2019 17:37:08 +0200 Subject: [PATCH 10/13] tests complete --- conf_test.go | 283 ++++++++++++++++++++++++--------------------------- 1 file changed, 133 insertions(+), 150 deletions(-) diff --git a/conf_test.go b/conf_test.go index df789fc..270a0ae 100644 --- a/conf_test.go +++ b/conf_test.go @@ -7,6 +7,7 @@ import ( "os" "strings" "testing" + "time" "github.com/flowchartsman/conf" "github.com/flowchartsman/conf/source" @@ -27,74 +28,52 @@ const ( var srcNames = []string{"DEFAULT", "ENV", "FLAG", "FILE"} -// NewSource returns an initialized source for a given type. -func NewSource(src int, v interface{}) (conf.Sourcer, error) { - switch src { - case DEFAULT: - return nil, nil - - case ENV: - args := v.(map[string]string) - os.Clearenv() - for k, v := range args { - os.Setenv(k, v) - } - return source.NewEnv("TEST") - - case FLAG: - args := v.([]string) - return source.NewFlag(args) - - case FILE: - args := v.(map[string]string) - tf, err := ioutil.TempFile("", "conf-test") - if err != nil { - return nil, err - } - defer os.Remove(tf.Name()) - var vars string - for k, v := range args { - vars += fmt.Sprintf("%s %s\n", k, v) - } - if _, err := tf.WriteString(vars); err != nil { - return nil, err - } - if err := tf.Close(); err != nil { - return nil, err - } - return source.NewFile(tf.Name()) - } - - return nil, errors.New("invalid source provided") +type ip struct { + Name string `conf:"default:localhost"` + IP string `conf:"default:127.0.0.0"` +} +type Embed struct { + Name string `conf:"default:bill"` + Duration time.Duration `conf:"default:1s"` +} +type config struct { + AnInt int `conf:"default:9"` + AString string `conf:"default:B,short:s"` + Bool bool + Skip string `conf:"-"` + IP ip + Embed } -func TestParse(t *testing.T) { - type ip struct { - Name string `conf:"default:localhost"` - IP string `conf:"default:127.0.0.0"` - } - type Embed struct { - Name string `conf:"default:bill"` - } - type config struct { - AnInt int `conf:"default:9"` - AString string `conf:"default:B,short:s"` - Bool bool - Skip string `conf:"-"` - IP ip - Embed - } +// ============================================================================= +func TestParse(t *testing.T) { tests := []struct { name string src int args interface{} want config }{ - {"default", DEFAULT, nil, config{9, "B", false, "", ip{"localhost", "127.0.0.0"}, Embed{"bill"}}}, - {"env", ENV, map[string]string{"TEST_AN_INT": "1", "TEST_S": "s", "TEST_BOOL": "TRUE", "TEST_SKIP": "SKIP", "TEST_IP_NAME": "local", "TEST_NAME": "andy"}, config{1, "s", true, "", ip{"local", "127.0.0.0"}, Embed{"andy"}}}, - {"flag", FLAG, []string{"--an-int", "1", "-s", "s", "--bool", "--skip", "skip", "--ip-name", "local", "--name", "andy"}, config{1, "s", true, "", ip{"local", "127.0.0.0"}, Embed{"andy"}}}, - {"file", FILE, map[string]string{"AN_INT": "1", "S": "s", "BOOL": "TRUE", "SKIP": "skip", "IP_NAME": "local", "NAME": "andy"}, config{1, "s", true, "", ip{"local", "127.0.0.0"}, Embed{"andy"}}}, + { + "default", DEFAULT, + nil, + config{9, "B", false, "", ip{"localhost", "127.0.0.0"}, Embed{"bill", time.Second}}, + }, + { + "env", ENV, + map[string]string{"TEST_AN_INT": "1", "TEST_S": "s", "TEST_BOOL": "TRUE", "TEST_SKIP": "SKIP", "TEST_IP_NAME": "local", "TEST_NAME": "andy", "TEST_DURATION": "1m"}, + config{1, "s", true, "", ip{"local", "127.0.0.0"}, Embed{"andy", time.Minute}}, + }, + { + "flag", FLAG, + []string{"--an-int", "1", "-s", "s", "--bool", "--skip", "skip", "--ip-name", "local", "--name", "andy", "--duration", "1m"}, + config{1, "s", true, "", ip{"local", "127.0.0.0"}, Embed{"andy", time.Minute}}, + }, + { + "file", FILE, + map[string]string{"AN_INT": "1", "S": "s", "BOOL": "TRUE", "SKIP": "skip", "IP_NAME": "local", "NAME": "andy", "DURATION": "1m"}, + config{1, "s", true, "", ip{"local", "127.0.0.0"}, Embed{"andy", time.Minute}}, + }, } t.Log("Given the need to parse basic configuration.") @@ -128,12 +107,6 @@ func TestParse(t *testing.T) { } func TestMultiSource(t *testing.T) { - type config struct { - AnInt int `conf:"default:9"` - AString string `conf:"default:B,short:s"` - Bool bool - } - tests := []struct { name string sources []struct { @@ -148,10 +121,16 @@ func TestMultiSource(t *testing.T) { src int args interface{} }{ - {ENV, map[string]string{"TEST_AN_INT": "1", "TEST_S": "s", "TEST_BOOL": "TRUE"}}, - {FLAG, []string{"--an-int", "2", "-s", "s", "--bool", "false"}}, + { + ENV, + map[string]string{"TEST_S": "s", "TEST_BOOL": "TRUE", "TEST_IP_NAME": "local", "TEST_NAME": "andy", "TEST_DURATION": "1m"}, + }, + { + FLAG, + []string{"--an-int", "2", "--bool", "--skip", "skip", "--name", "jack", "--duration", "1ms"}, + }, }, - want: config{2, "s", false}, + want: config{2, "s", true, "", ip{"local", "127.0.0.0"}, Embed{"jack", time.Millisecond}}, }, } @@ -190,52 +169,6 @@ func TestMultiSource(t *testing.T) { } } -func TestFlagParse(t *testing.T) { - type config struct { - AnInt int `conf:"required,short:i"` - AString string `conf:"default:B"` - Bool bool `conf:"default:true"` - } - - tests := []struct { - name string - src int - args interface{} - want config - }{ - {"basic-flag", FLAG, []string{"-i", "1", "--a-string", "s", "--bool"}, config{1, "s", true}}, - } - - t.Log("Given the need to parse basic configuration.") - { - for i, tt := range tests { - t.Logf("\tTest: %d\tWhen checking %s with arguments %s", i, srcNames[tt.src], tt.args) - { - f := func(t *testing.T) { - sourcer, err := NewSource(tt.src, tt.args) - if err != nil { - t.Fatalf("\t%s\tShould be able to create a new %s source : %s.", failed, srcNames[tt.src], err) - } - t.Logf("\t%s\tShould be able to create a new %s source.", success, srcNames[tt.src]) - - var cfg config - if err := conf.Parse(&cfg, sourcer); err != nil { - t.Fatalf("\t%s\tShould be able to Parse arguments : %s.", failed, err) - } - t.Logf("\t%s\tShould be able to Parse arguments.", success) - - if diff := cmp.Diff(tt.want, cfg); diff != "" { - t.Fatalf("\t%s\tShould have properly initialized struct value\n%s", failed, diff) - } - t.Logf("\t%s\tShould have properly initialized struct value.", success) - } - - t.Run(tt.name, f) - } - } - } -} - func TestErrors(t *testing.T) { t.Log("Given the need to validate errors that can occur with Parse.") { @@ -251,7 +184,7 @@ func TestErrors(t *testing.T) { if err == nil { t.Fatalf("\t%s\tShould NOT be able to accept a value by value.", failed) } - t.Logf("\t%s\tShould NOT be able to accept a value by value.", success) + t.Logf("\t%s\tShould NOT be able to accept a value by value : %s", success, err) } t.Run("not-by-ref", f) @@ -261,7 +194,7 @@ func TestErrors(t *testing.T) { if err == nil { t.Fatalf("\t%s\tShould NOT be able to pass anything but a struct value.", failed) } - t.Logf("\t%s\tShould NOT be able to pass anything but a struct value.", success) + t.Logf("\t%s\tShould NOT be able to pass anything but a struct value : %s", success, err) } t.Run("not-struct-value", f) } @@ -278,7 +211,7 @@ func TestErrors(t *testing.T) { if err == nil { t.Fatalf("\t%s\tShould NOT be able to accept tag missing value.", failed) } - t.Logf("\t%s\tShould NOT be able to accept tag missing value.", success) + t.Logf("\t%s\tShould NOT be able to accept tag missing value : %s", success, err) } t.Run("tag-missing-value", f) @@ -292,7 +225,7 @@ func TestErrors(t *testing.T) { if err == nil { t.Fatalf("\t%s\tShould NOT be able to accept invalid short tag.", failed) } - t.Logf("\t%s\tShould NOT be able to accept invalid short tag.", success) + t.Logf("\t%s\tShould NOT be able to accept invalid short tag : %s", success, err) } t.Run("tag-bad-short", f) } @@ -309,35 +242,45 @@ func TestErrors(t *testing.T) { if err == nil { t.Fatalf("\t%s\tShould fail for missing required value.", failed) } - t.Logf("\t%s\tShould fail for missing required value.", success) + t.Logf("\t%s\tShould fail for missing required value : %s", success, err) } t.Run("required-missing-value", f) } + + t.Logf("\tTest: %d\tWhen struct has no fields.", 2) + { + f := func(t *testing.T) { + var cfg struct { + testInt int `conf:"required, default:1"` + testString string + testBool bool + } + err := conf.Parse(&cfg) + if err == nil { + t.Fatalf("\t%s\tShould fail for struct with no exported fields.", failed) + } + t.Logf("\t%s\tShould fail for struct with no exported fields : %s", success, err) + } + t.Run("struct-missing-fields", f) + } } } func TestUsage(t *testing.T) { + test := struct { + name string + src int + args interface{} + }{ + name: "one-example", + src: ENV, + args: map[string]string{"TEST_AN_INT": "1", "TEST_S": "s", "TEST_BOOL": "TRUE", "TEST_SKIP": "SKIP", "TEST_IP_NAME": "local", "TEST_NAME": "andy", "TEST_DURATION": "1m"}, + } + t.Log("Given the need validate usage output.") { t.Logf("\tTest: %d\tWhen using a basic struct.", 0) { - type config struct { - AnInt int `conf:"default:9"` - AString string `conf:"default:B,short:s"` - Bool bool - Skip []float64 `conf:"-"` - } - - test := struct { - name string - src int - args interface{} - }{ - name: "basic-env", - src: ENV, - args: map[string]string{"TEST_ANINT": "1", "TEST_S": "s", "TEST_BOOL": "TRUE"}, - } - sourcer, err := NewSource(test.src, test.args) if err != nil { fmt.Print(err) @@ -360,10 +303,14 @@ func TestUsage(t *testing.T) { want := `Usage: conf.test [options] [arguments] OPTIONS - --a-string/-s/$a-string (default: B) - --an-int/$an-int (default: 9) - --bool/$bool - --help/-h + --a-string/-s/$a-string (default: B) + --an-int/$an-int (default: 9) + --bool/$bool + --duration/$duration (default: 1s) + --ip-ip/$ip-ip (default: 127.0.0.0) + --ip-name/$ip-name (default: localhost) + --name/$name (default: bill) + --help/-h display this help message` bGot := []byte(got) @@ -381,21 +328,14 @@ OPTIONS } func ExampleString() { - type config struct { - AnInt int `conf:"default:9"` - AString string `conf:"default:B,short:s"` - Bool bool - Skip []float64 `conf:"-"` - } - test := struct { name string src int args interface{} }{ - name: "basic-env", + name: "one-example", src: ENV, - args: map[string]string{"TEST_AN_INT": "1", "TEST_S": "s", "TEST_BOOL": "TRUE"}, + args: map[string]string{"TEST_AN_INT": "1", "TEST_S": "s", "TEST_BOOL": "TRUE", "TEST_SKIP": "SKIP", "TEST_IP_NAME": "local", "TEST_NAME": "andy", "TEST_DURATION": "1m"}, } sourcer, err := NewSource(test.src, test.args) @@ -419,5 +359,48 @@ func ExampleString() { fmt.Print(out) // Output: - // an-int=1 a-string=s bool=true + // an-int=1 a-string=s bool=true ip-name=local ip-ip=127.0.0.0 name=andy duration=1m0s +} + +// ============================================================================= + +// NewSource returns an initialized source for a given type. +func NewSource(src int, v interface{}) (conf.Sourcer, error) { + switch src { + case DEFAULT: + return nil, nil + + case ENV: + args := v.(map[string]string) + os.Clearenv() + for k, v := range args { + os.Setenv(k, v) + } + return source.NewEnv("TEST") + + case FLAG: + args := v.([]string) + return source.NewFlag(args) + + case FILE: + args := v.(map[string]string) + tf, err := ioutil.TempFile("", "conf-test") + if err != nil { + return nil, err + } + defer os.Remove(tf.Name()) + var vars string + for k, v := range args { + vars += fmt.Sprintf("%s %s\n", k, v) + } + if _, err := tf.WriteString(vars); err != nil { + return nil, err + } + if err := tf.Close(); err != nil { + return nil, err + } + return source.NewFile(tf.Name()) + } + + return nil, errors.New("invalid source provided") } From 8cb77873b25641d6c68ed0c70bfaa6fb36591f67 Mon Sep 17 00:00:00 2001 From: William Kennedy Date: Sat, 25 May 2019 22:04:19 +0200 Subject: [PATCH 11/13] changes to support overrides for env and flags --- README.md | 124 ++++++++---------- conf.go | 63 +++++----- conf_test.go | 235 +++++++++-------------------------- doc.go | 66 ++++++++++ fields.go | 51 +++++--- source/env.go | 53 -------- source/file.go | 67 ---------- source/flag.go => sources.go | 79 ++++++++++-- usage.go | 53 ++++---- 9 files changed, 337 insertions(+), 454 deletions(-) create mode 100644 doc.go delete mode 100644 source/env.go delete mode 100644 source/file.go rename source/flag.go => sources.go (60%) diff --git a/README.md b/README.md index 939c34c..162cc61 100644 --- a/README.md +++ b/README.md @@ -1,85 +1,65 @@ -# conf +Package conf provides support for using environmental variables and command +line arguments for configuration. -Simple, self-documenting, struct-driven configuration with flag generation and zero dependencies. +It is compatible with the GNU extensions to the POSIX recommendations +for command-line options. See +http://www.gnu.org/software/libc/manual/html_node/Argument-Syntax.html -## Overview -`conf` provides a simple method to drive structured configuration from types and fields, with automatic flag and usage generation. +There are no hard bindings for this package. This package takes a struct +value and parses it for both the environment and flags. It supports several tags +to customize the flag options. -## Usage -```go -package main + default - Provides the default value for the help + env - Allows for overriding the default variable name. + flag - Allows for overriding the default flag name. + short - Denotes a shorthand option for the flag. + noprint - Denotes to not include the field in any display string. + required - Denotes a value must be provided. + help - Provides a description for the help. -import ( - "log" - "time" +The field name and any parent struct name will be used for the long form of +the command name unless the name is overridden. - "github.com/flowchartsman/conf" -) - -type myConfig struct { - Sub subConfig - TimeToWait time.Duration `conf:"help:how long to wait,short:c,required"` - Password string `conf:"help:the database password to use,noprint"` - DNSServer *string `conf:"help:the address of the dns server to use,default:127.0.0.1"` - Debug bool `conf:"help:enable debug mode"` - DBServers []string `conf:"help:a list of mirror 'host's to contact"` -} - -type subConfig struct { - Value int `conf:"help: I am a subvalue"` -} - -func main() { - log.SetFlags(0) - var c myConfig - err := conf.Parse(&c, - conf.WithConfigFile("/etc/test.conf"), - conf.WithConfigFileFlag("conf")) - if err != nil { - log.Fatal(err) +As an example, this config struct: +``` + type ip struct { + Name string `conf:"default:localhost,env:IP_NAME_VAR"` + IP string `conf:"default:127.0.0.0"` + } + type Embed struct { + Name string `conf:"default:bill"` + Duration time.Duration `conf:"default:1s,flag:e-dur,short:d"` + } + type config struct { + AnInt int `conf:"default:9"` + AString string `conf:"default:B,short:s"` + Bool bool + Skip string `conf:"-"` + IP ip + Embed } - log.Println(conf.String(&c)) -} ``` - +Would produce the following usage output: ``` -$ ./conftest -h -Usage: ./conftest [options] [arguments] +Usage: conf.test [options] [arguments] OPTIONS - --db-servers ,[host...] DB_SERVERS - a list of mirror hosts to contact - --debug enable debug mode DEBUG - --dns-server DNS_SERVER - the address of the dns server to use - (default: 127.0.0.1) - --password PASSWORD - the database password to use - (noprint) - --sub-value SUB_VALUE - I am a subvalue - --time-to-wait, -c TIME_TO_WAIT - how long to wait - (required) - --conf filename - the filename to load configuration from - (default: /etc/test.conf) - --help, -h display this help message - -FILES - /etc/test.conf - The system-wide configuration file (overridden by --conf) - -$ ./conftest -required field TimeToWait is missing value -$ ./conftest --time-to-wait 5s --sub-value 1 --password I4mInvisbl3! --db-servers 127.0.0.1,127.0.0.2 --dns-server 1.1.1.1 -SUB_VALUE=1 TIME_TO_WAIT=5s DNS_SERVER=1.1.1.1 DEBUG=false DB_SERVERS=[127.0.0.1 127.0.0.2] + --a-string/-s/$A_STRING (default: B) + --an-int/$AN_INT (default: 9) + --bool/$BOOL + --e-dur/-d/$DURATION (default: 1s) + --ip-ip/$IP_IP (default: 127.0.0.0) + --ip-name/$IP_NAME_VAR (default: localhost) + --name/$NAME (default: bill) + --help/-h + display this help message ``` -## note -This library is still in **alpha**. It needs docs, full coverage testing, and poking to find edgecases. +The API is a single call to `Parse` +``` + // Parse(args []string, namespace string, cfgStruct interface{}, sources ...Sourcer) error -## shoulders -This library takes inspiration (and some code) from some great work by some great engineers. These are credited in the license, but more detail soon. -- [kelseyhightower/envconfig](https://github.com/kelseyhightower/envconfig) -- [peterbourgon/ff](https://github.com/peterbourgon/ff) + if err := conf.Parse(os.Args, "CRUD", &cfg); err != nil { + log.Fatalf("main : Parsing Config : %v", err) + } +``` \ No newline at end of file diff --git a/conf.go b/conf.go index 0752c59..14b4dbe 100644 --- a/conf.go +++ b/conf.go @@ -28,11 +28,21 @@ type Sourcer interface { // Source takes the field key and attempts to locate that key in its // configuration data. Returns true if found with the value. - Source(key []string) (string, bool) + Source(fld field) (string, bool) } // Parse parses configuration into the provided struct. -func Parse(cfgStruct interface{}, sources ...Sourcer) error { +func Parse(args []string, namespace string, cfgStruct interface{}, sources ...Sourcer) error { + + // Create the flag source. + flag, err := newSourceFlag(args) + if err != nil { + return err + } + + // Append default sources to any provided list. + sources = append(sources, newSourceEnv(namespace)) + sources = append(sources, flag) // Get the list of fields from the configuration struct to process. fields, err := extractFields(nil, cfgStruct) @@ -47,12 +57,12 @@ func Parse(cfgStruct interface{}, sources ...Sourcer) error { for _, field := range fields { // Set any default value into the struct for this field. - if field.options.defaultStr != "" { - if err := processField(field.options.defaultStr, field.field); err != nil { + if field.options.defaultVal != "" { + if err := processField(field.options.defaultVal, field.field); err != nil { return &FieldError{ fieldName: field.name, typeName: field.field.Type().String(), - value: field.options.defaultStr, + value: field.options.defaultVal, err: err, } } @@ -65,16 +75,8 @@ func Parse(cfgStruct interface{}, sources ...Sourcer) error { continue } - // If a short name was provided, that needs to be used. - var key []string - if field.options.short == 0 { - key = field.key - } else { - key = []string{string(field.options.short)} - } - var value string - if value, provided = sourcer.Source(key); !provided { + if value, provided = sourcer.Source(field); !provided { continue } @@ -94,14 +96,21 @@ func Parse(cfgStruct interface{}, sources ...Sourcer) error { if !provided && field.options.required { return fmt.Errorf("required field %s is missing value", field.name) } - - // TODO : If this config field will be set to it's zero value, return an error. - // ANDY I NEED TO UNDERSTAND WHY YOU HAD THIS. SOME PEOPLE LIKE TO BE EXPLICIT. } return nil } +// Usage provides output to display the config usage on the command line. +func Usage(v interface{}) (string, error) { + fields, err := extractFields(nil, v) + if err != nil { + return "", err + } + + return fmtUsage(fields), nil +} + // String returns a stringified version of the provided conf-tagged // struct, minus any fields tagged with `noprint`. func String(v interface{}) (string, error) { @@ -111,26 +120,16 @@ func String(v interface{}) (string, error) { } var s strings.Builder - for i, field := range fields { - if !field.options.noprint { - s.WriteString(field.envName) + for i, fld := range fields { + if !fld.options.noprint { + s.WriteString(flagUsage(fld)) s.WriteString("=") - s.WriteString(fmt.Sprintf("%v", field.field.Interface())) + s.WriteString(fmt.Sprintf("%v", fld.field.Interface())) if i < len(fields)-1 { - s.WriteString(" ") + s.WriteString("\n") } } } return s.String(), nil } - -// Usage provides output to display the config usage on the command line. -func Usage(v interface{}) (string, error) { - fields, err := extractFields(nil, v) - if err != nil { - return "", err - } - - return fmtUsage(fields), nil -} diff --git a/conf_test.go b/conf_test.go index 270a0ae..679da37 100644 --- a/conf_test.go +++ b/conf_test.go @@ -1,16 +1,13 @@ package conf_test import ( - "errors" "fmt" - "io/ioutil" "os" "strings" "testing" "time" "github.com/flowchartsman/conf" - "github.com/flowchartsman/conf/source" "github.com/google/go-cmp/cmp" ) @@ -19,22 +16,13 @@ const ( failed = "\u2717" ) -const ( - DEFAULT = iota - ENV - FLAG - FILE -) - -var srcNames = []string{"DEFAULT", "ENV", "FLAG", "FILE"} - type ip struct { - Name string `conf:"default:localhost"` + Name string `conf:"default:localhost,env:IP_NAME_VAR"` IP string `conf:"default:127.0.0.0"` } type Embed struct { Name string `conf:"default:bill"` - Duration time.Duration `conf:"default:1s"` + Duration time.Duration `conf:"default:1s,flag:e-dur,short:d"` } type config struct { AnInt int `conf:"default:9"` @@ -50,109 +38,49 @@ type config struct { func TestParse(t *testing.T) { tests := []struct { name string - src int - args interface{} + envs map[string]string + args []string want config }{ { - "default", DEFAULT, + "default", + nil, nil, config{9, "B", false, "", ip{"localhost", "127.0.0.0"}, Embed{"bill", time.Second}}, }, { - "env", ENV, - map[string]string{"TEST_AN_INT": "1", "TEST_S": "s", "TEST_BOOL": "TRUE", "TEST_SKIP": "SKIP", "TEST_IP_NAME": "local", "TEST_NAME": "andy", "TEST_DURATION": "1m"}, + "env", + map[string]string{"TEST_AN_INT": "1", "TEST_A_STRING": "s", "TEST_BOOL": "TRUE", "TEST_SKIP": "SKIP", "TEST_IP_NAME_VAR": "local", "TEST_NAME": "andy", "TEST_DURATION": "1m"}, + nil, config{1, "s", true, "", ip{"local", "127.0.0.0"}, Embed{"andy", time.Minute}}, }, { - "flag", FLAG, - []string{"--an-int", "1", "-s", "s", "--bool", "--skip", "skip", "--ip-name", "local", "--name", "andy", "--duration", "1m"}, + "flag", + nil, + []string{"--an-int", "1", "-s", "s", "--bool", "--skip", "skip", "--ip-name", "local", "--name", "andy", "--e-dur", "1m"}, config{1, "s", true, "", ip{"local", "127.0.0.0"}, Embed{"andy", time.Minute}}, }, { - "file", FILE, - map[string]string{"AN_INT": "1", "S": "s", "BOOL": "TRUE", "SKIP": "skip", "IP_NAME": "local", "NAME": "andy", "DURATION": "1m"}, - config{1, "s", true, "", ip{"local", "127.0.0.0"}, Embed{"andy", time.Minute}}, + "multi", + map[string]string{"TEST_A_STRING": "s", "TEST_BOOL": "TRUE", "TEST_IP_NAME_VAR": "local", "TEST_NAME": "andy", "TEST_DURATION": "1m"}, + []string{"--an-int", "2", "--bool", "--skip", "skip", "--name", "jack", "-d", "1ms"}, + config{2, "s", true, "", ip{"local", "127.0.0.0"}, Embed{"jack", time.Millisecond}}, }, } t.Log("Given the need to parse basic configuration.") { for i, tt := range tests { - t.Logf("\tTest: %d\tWhen checking %s with arguments %v", i, srcNames[tt.src], tt.args) + t.Logf("\tTest: %d\tWhen checking with arguments %v", i, tt.args) { - f := func(t *testing.T) { - sourcer, err := NewSource(tt.src, tt.args) - if err != nil { - t.Fatalf("\t%s\tShould be able to create a new %s source : %s.", failed, srcNames[tt.src], err) - } - t.Logf("\t%s\tShould be able to create a new %s source.", success, srcNames[tt.src]) - - var cfg config - if err := conf.Parse(&cfg, sourcer); err != nil { - t.Fatalf("\t%s\tShould be able to Parse arguments : %s.", failed, err) - } - t.Logf("\t%s\tShould be able to Parse arguments.", success) - - if diff := cmp.Diff(tt.want, cfg); diff != "" { - t.Fatalf("\t%s\tShould have properly initialized struct value\n%s", failed, diff) - } - t.Logf("\t%s\tShould have properly initialized struct value.", success) + os.Clearenv() + for k, v := range tt.envs { + os.Setenv(k, v) } - t.Run(tt.name, f) - } - } - } -} - -func TestMultiSource(t *testing.T) { - tests := []struct { - name string - sources []struct { - src int - args interface{} - } - want config - }{ - { - name: "basic-env-flag", - sources: []struct { - src int - args interface{} - }{ - { - ENV, - map[string]string{"TEST_S": "s", "TEST_BOOL": "TRUE", "TEST_IP_NAME": "local", "TEST_NAME": "andy", "TEST_DURATION": "1m"}, - }, - { - FLAG, - []string{"--an-int", "2", "--bool", "--skip", "skip", "--name", "jack", "--duration", "1ms"}, - }, - }, - want: config{2, "s", true, "", ip{"local", "127.0.0.0"}, Embed{"jack", time.Millisecond}}, - }, - } - - t.Log("Given the need to parse multi-source configurations.") - { - for i, tt := range tests { - t.Logf("\tTest: %d\tWhen checking %d sources", i, len(tt.sources)) - { f := func(t *testing.T) { var cfg config - - sources := make([]conf.Sourcer, len(tt.sources)) - for i, ttt := range tt.sources { - sourcer, err := NewSource(ttt.src, ttt.args) - if err != nil { - t.Fatalf("\t%s\tShould be able to create a new %s source : %s.", failed, srcNames[ttt.src], err) - } - t.Logf("\t%s\tShould be able to create a new %s source.", success, srcNames[ttt.src]) - sources[i] = sourcer - } - - if err := conf.Parse(&cfg, sources...); err != nil { + if err := conf.Parse(tt.args, "TEST", &cfg); err != nil { t.Fatalf("\t%s\tShould be able to Parse arguments : %s.", failed, err) } t.Logf("\t%s\tShould be able to Parse arguments.", success) @@ -180,7 +108,7 @@ func TestErrors(t *testing.T) { TestString string TestBool bool } - err := conf.Parse(cfg) + err := conf.Parse(nil, "TEST", cfg) if err == nil { t.Fatalf("\t%s\tShould NOT be able to accept a value by value.", failed) } @@ -190,7 +118,7 @@ func TestErrors(t *testing.T) { f = func(t *testing.T) { var cfg []string - err := conf.Parse(cfg) + err := conf.Parse(nil, "TEST", &cfg) if err == nil { t.Fatalf("\t%s\tShould NOT be able to pass anything but a struct value.", failed) } @@ -207,7 +135,7 @@ func TestErrors(t *testing.T) { TestString string TestBool bool } - err := conf.Parse(&cfg) + err := conf.Parse(nil, "TEST", &cfg) if err == nil { t.Fatalf("\t%s\tShould NOT be able to accept tag missing value.", failed) } @@ -221,7 +149,7 @@ func TestErrors(t *testing.T) { TestString string TestBool bool } - err := conf.Parse(&cfg) + err := conf.Parse(nil, "TEST", &cfg) if err == nil { t.Fatalf("\t%s\tShould NOT be able to accept invalid short tag.", failed) } @@ -238,7 +166,7 @@ func TestErrors(t *testing.T) { TestString string TestBool bool } - err := conf.Parse(&cfg) + err := conf.Parse(nil, "TEST", &cfg) if err == nil { t.Fatalf("\t%s\tShould fail for missing required value.", failed) } @@ -255,7 +183,7 @@ func TestErrors(t *testing.T) { testString string testBool bool } - err := conf.Parse(&cfg) + err := conf.Parse(nil, "TEST", &cfg) if err == nil { t.Fatalf("\t%s\tShould fail for struct with no exported fields.", failed) } @@ -267,28 +195,25 @@ func TestErrors(t *testing.T) { } func TestUsage(t *testing.T) { - test := struct { + tt := struct { name string - src int - args interface{} + envs map[string]string }{ name: "one-example", - src: ENV, - args: map[string]string{"TEST_AN_INT": "1", "TEST_S": "s", "TEST_BOOL": "TRUE", "TEST_SKIP": "SKIP", "TEST_IP_NAME": "local", "TEST_NAME": "andy", "TEST_DURATION": "1m"}, + envs: map[string]string{"TEST_AN_INT": "1", "TEST_A_STRING": "s", "TEST_BOOL": "TRUE", "TEST_SKIP": "SKIP", "TEST_IP_NAME_VAR": "local", "TEST_NAME": "andy", "TEST_DURATION": "1m"}, } t.Log("Given the need validate usage output.") { t.Logf("\tTest: %d\tWhen using a basic struct.", 0) { - sourcer, err := NewSource(test.src, test.args) - if err != nil { - fmt.Print(err) - return + os.Clearenv() + for k, v := range tt.envs { + os.Setenv(k, v) } var cfg config - if err := conf.Parse(&cfg, sourcer); err != nil { + if err := conf.Parse(nil, "TEST", &cfg); err != nil { fmt.Print(err) return } @@ -303,22 +228,24 @@ func TestUsage(t *testing.T) { want := `Usage: conf.test [options] [arguments] OPTIONS - --a-string/-s/$a-string (default: B) - --an-int/$an-int (default: 9) - --bool/$bool - --duration/$duration (default: 1s) - --ip-ip/$ip-ip (default: 127.0.0.0) - --ip-name/$ip-name (default: localhost) - --name/$name (default: bill) - --help/-h - display this help message` - + --a-string/-s/$A_STRING (default: B) + --an-int/$AN_INT (default: 9) + --bool/$BOOL + --e-dur/-d/$DURATION (default: 1s) + --ip-ip/$IP_IP (default: 127.0.0.0) + --ip-name/$IP_NAME_VAR (default: localhost) + --name/$NAME (default: bill) + --help/-h + display this help message` + + got = strings.ReplaceAll(got, " ", "") + want = strings.ReplaceAll(want, " ", "") bGot := []byte(got) bWant := []byte(want) if diff := cmp.Diff(bGot, bWant); diff != "" { t.Log("got:\n", got) t.Log("\n", bGot) - t.Log("wait:\n", want) + t.Log("want:\n", want) t.Log("\n", bWant) t.Fatalf("\t%s\tShould match byte for byte the output.", failed) } @@ -328,24 +255,21 @@ OPTIONS } func ExampleString() { - test := struct { + tt := struct { name string - src int - args interface{} + envs map[string]string }{ name: "one-example", - src: ENV, - args: map[string]string{"TEST_AN_INT": "1", "TEST_S": "s", "TEST_BOOL": "TRUE", "TEST_SKIP": "SKIP", "TEST_IP_NAME": "local", "TEST_NAME": "andy", "TEST_DURATION": "1m"}, + envs: map[string]string{"TEST_AN_INT": "1", "TEST_S": "s", "TEST_BOOL": "TRUE", "TEST_SKIP": "SKIP", "TEST_IP_NAME": "local", "TEST_NAME": "andy", "TEST_DURATION": "1m"}, } - sourcer, err := NewSource(test.src, test.args) - if err != nil { - fmt.Print(err) - return + os.Clearenv() + for k, v := range tt.envs { + os.Setenv(k, v) } var cfg config - if err := conf.Parse(&cfg, sourcer); err != nil { + if err := conf.Parse(nil, "TEST", &cfg); err != nil { fmt.Print(err) return } @@ -359,48 +283,11 @@ func ExampleString() { fmt.Print(out) // Output: - // an-int=1 a-string=s bool=true ip-name=local ip-ip=127.0.0.0 name=andy duration=1m0s -} - -// ============================================================================= - -// NewSource returns an initialized source for a given type. -func NewSource(src int, v interface{}) (conf.Sourcer, error) { - switch src { - case DEFAULT: - return nil, nil - - case ENV: - args := v.(map[string]string) - os.Clearenv() - for k, v := range args { - os.Setenv(k, v) - } - return source.NewEnv("TEST") - - case FLAG: - args := v.([]string) - return source.NewFlag(args) - - case FILE: - args := v.(map[string]string) - tf, err := ioutil.TempFile("", "conf-test") - if err != nil { - return nil, err - } - defer os.Remove(tf.Name()) - var vars string - for k, v := range args { - vars += fmt.Sprintf("%s %s\n", k, v) - } - if _, err := tf.WriteString(vars); err != nil { - return nil, err - } - if err := tf.Close(); err != nil { - return nil, err - } - return source.NewFile(tf.Name()) - } - - return nil, errors.New("invalid source provided") + // --an-int=1 + // --a-string/-s=B + // --bool=true + // --ip-name=localhost + // --ip-ip=127.0.0.0 + // --name=andy + // --e-dur/-d=1m0s } diff --git a/doc.go b/doc.go new file mode 100644 index 0000000..d59d3be --- /dev/null +++ b/doc.go @@ -0,0 +1,66 @@ +/* +Package conf provides support for using environmental variables and command +line arguments for configuration. + +It is compatible with the GNU extensions to the POSIX recommendations +for command-line options. See +http://www.gnu.org/software/libc/manual/html_node/Argument-Syntax.html + +There are no hard bindings for this package. This package takes a struct +value and parses it for both the environment and flags. It supports several tags +to customize the flag options. + + default - Provides the default value for the help + env - Allows for overriding the default variable name. + flag - Allows for overriding the default flag name. + short - Denotes a shorthand option for the flag. + noprint - Denotes to not include the field in any display string. + required - Denotes a value must be provided. + help - Provides a description for the help. + +The field name and any parent struct name will be used for the long form of +the command name unless the name is overridden. + +As an example, this config struct: + + type ip struct { + Name string `conf:"default:localhost,env:IP_NAME_VAR"` + IP string `conf:"default:127.0.0.0"` + } + type Embed struct { + Name string `conf:"default:bill"` + Duration time.Duration `conf:"default:1s,flag:e-dur,short:d"` + } + type config struct { + AnInt int `conf:"default:9"` + AString string `conf:"default:B,short:s"` + Bool bool + Skip string `conf:"-"` + IP ip + Embed + } + +Would produce the following usage output: + +Usage: conf.test [options] [arguments] + +OPTIONS + --a-string/-s/$A_STRING (default: B) + --an-int/$AN_INT (default: 9) + --bool/$BOOL + --e-dur/-d/$DURATION (default: 1s) + --ip-ip/$IP_IP (default: 127.0.0.0) + --ip-name/$IP_NAME_VAR (default: localhost) + --name/$NAME (default: bill) + --help/-h + display this help message + +The API is a single call to `Parse` + + // Parse(args []string, namespace string, cfgStruct interface{}, sources ...Sourcer) error + + if err := conf.Parse(os.Args, "CRUD", &cfg); err != nil { + log.Fatalf("main : Parsing Config : %v", err) + } +*/ +package conf diff --git a/fields.go b/fields.go index a6866ed..efd100e 100644 --- a/fields.go +++ b/fields.go @@ -13,25 +13,24 @@ import ( // field maintains information about a field in the configuration struct. type field struct { name string - key []string + flagKey []string + envKey []string field reflect.Value options fieldOptions // Important for flag parsing or any other source where // booleans might be treated specially. boolField bool - - // For usage ... TODO: I need more. - flagName string - envName string } type fieldOptions struct { - short rune - help string - defaultStr string - noprint bool - required bool + help string + defaultVal string + envName string + flagName string + shortFlagChar rune + noprint bool + required bool } // extractFields uses reflection to examine the struct and generate the keys. @@ -72,7 +71,7 @@ func extractFields(prefix []string, target interface{}) ([]field, error) { return nil, fmt.Errorf("conf: error parsing tags for field %s: %s", fieldName, err) } - // Generate the field key. This could be ignored for short in options. + // Generate the field key. This could be ignored. fieldKey := append(prefix, camelSplit(fieldName)...) // Drill down through pointers until we bottom out at type or nil. @@ -113,15 +112,25 @@ func extractFields(prefix []string, target interface{}) ([]field, error) { fields = append(fields, innerFields...) } default: - fields = append(fields, field{ + envKey := fieldKey + if fieldOpts.envName != "" { + envKey = strings.Split(fieldOpts.envName, "_") + } + + flagKey := fieldKey + if fieldOpts.flagName != "" { + flagKey = strings.Split(fieldOpts.flagName, "-") + } + + fld := field{ name: fieldName, - key: fieldKey, - flagName: strings.ToLower(strings.Join(fieldKey, `-`)), - envName: strings.ToLower(strings.Join(fieldKey, `-`)), + envKey: envKey, + flagKey: flagKey, field: f, options: fieldOpts, boolField: f.Kind() == reflect.Bool, - }) + } + fields = append(fields, fld) } } @@ -157,9 +166,13 @@ func parseTag(tagStr string) (fieldOptions, error) { if len([]rune(tagPropVal)) != 1 { return f, fmt.Errorf("short value must be a single rune, got %q", tagPropVal) } - f.short = []rune(tagPropVal)[0] + f.shortFlagChar = []rune(tagPropVal)[0] case "default": - f.defaultStr = tagPropVal + f.defaultVal = tagPropVal + case "env": + f.envName = tagPropVal + case "flag": + f.flagName = tagPropVal case "help": f.help = tagPropVal } @@ -170,7 +183,7 @@ func parseTag(tagStr string) (fieldOptions, error) { // Perform a sanity check. switch { - case f.required && f.defaultStr != "": + case f.required && f.defaultVal != "": return f, fmt.Errorf("cannot set both `required` and `default`") } diff --git a/source/env.go b/source/env.go deleted file mode 100644 index fb696db..0000000 --- a/source/env.go +++ /dev/null @@ -1,53 +0,0 @@ -package source - -import ( - "errors" - "fmt" - "os" - "strings" -) - -// Env is a source for environmental variables. -type Env struct { - m map[string]string -} - -// NewEnv accepts a namespace and parses the environment into a Env for -// use by the configuration package. -func NewEnv(namespace string) (*Env, error) { - m := make(map[string]string) - - // Get the lists of available environment variables. - envs := os.Environ() - if len(envs) == 0 { - return nil, errors.New("no environment variables found") - } - - // Create the uppercase version to meet the standard {NAMESPACE_} format. - uspace := fmt.Sprintf("%s_", strings.ToUpper(namespace)) - - // Loop and match each variable using the uppercase namespace. - for _, val := range envs { - if !strings.HasPrefix(val, uspace) { - continue - } - - idx := strings.Index(val, "=") - m[strings.ToUpper(strings.TrimPrefix(val[0:idx], uspace))] = val[idx+1:] - } - - // Did we find any keys for this namespace? - if len(m) == 0 { - return nil, fmt.Errorf("namespace %q was not found", namespace) - } - - return &Env{m: m}, nil -} - -// Source implements the confg.Sourcer interface. It returns the stringfied value -// stored at the specified key from the environment. -func (e *Env) Source(key []string) (string, bool) { - k := strings.ToUpper(strings.Join(key, `_`)) - v, ok := e.m[k] - return v, ok -} diff --git a/source/file.go b/source/file.go deleted file mode 100644 index 9f287df..0000000 --- a/source/file.go +++ /dev/null @@ -1,67 +0,0 @@ -package source - -import ( - "bufio" - "os" - "strings" -) - -// File is a source for config files in an extremely simple format. Each -// line is tokenized as a single key/value pair. The first whitespace-delimited -// token in the line is interpreted as the flag name, and all remaining tokens -// are interpreted as the value. Any leading hyphens on the flag name are -// ignored. -type File struct { - m map[string]string -} - -// NewFile accepts a filename and parses the contents into a File for -// use by the configuration package. -func NewFile(filename string) (*File, error) { - m := make(map[string]string) - - file, err := os.Open(filename) - if err != nil { - return nil, err - } - defer file.Close() - - s := bufio.NewScanner(file) - for s.Scan() { - line := strings.TrimSpace(s.Text()) - if line == "" { - continue // skip empties - } - - if line[0] == '#' { - continue // skip comments - } - - var ( - name string - value string - index = strings.IndexRune(line, ' ') - ) - if index < 0 { - name, value = line, "true" // boolean option - } else { - name, value = line[:index], strings.TrimSpace(line[index:]) - } - - if i := strings.Index(value, " #"); i >= 0 { - value = strings.TrimSpace(value[:i]) - } - - m[name] = value - } - - return &File{m: m}, nil -} - -// Source implements the confg.Sourcer interface. It returns the stringfied value -// stored at the specified key in the plain config file. -func (f *File) Source(key []string) (string, bool) { - k := strings.ToUpper(strings.Join(key, `_`)) - value, ok := f.m[k] - return value, ok -} diff --git a/source/flag.go b/sources.go similarity index 60% rename from source/flag.go rename to sources.go index be14c30..436a5c2 100644 --- a/source/flag.go +++ b/sources.go @@ -1,23 +1,65 @@ -package source +package conf import ( "errors" "fmt" + "os" "strings" ) +// env is a source for environmental variables. +type env struct { + m map[string]string +} + +// newSourceEnv accepts a namespace and parses the environment into a Env for +// use by the configuration package. +func newSourceEnv(namespace string) *env { + m := make(map[string]string) + + // Create the uppercase version to meet the standard {NAMESPACE_} format. + uspace := fmt.Sprintf("%s_", strings.ToUpper(namespace)) + + // Loop and match each variable using the uppercase namespace. + for _, val := range os.Environ() { + if !strings.HasPrefix(val, uspace) { + continue + } + + idx := strings.Index(val, "=") + m[strings.ToUpper(strings.TrimPrefix(val[0:idx], uspace))] = val[idx+1:] + } + + return &env{m: m} +} + +// Source implements the confg.Sourcer interface. It returns the stringfied value +// stored at the specified key from the environment. +func (e *env) Source(fld field) (string, bool) { + k := strings.ToUpper(strings.Join(fld.envKey, `_`)) + v, ok := e.m[k] + return v, ok +} + +// envUsage constructs a usage string for the environment variable. +func envUsage(fld field) string { + return "$" + strings.ToUpper(strings.Join(fld.envKey, `_`)) +} + +// ============================================================================= + // ErrHelpWanted provides an indication help was requested. var ErrHelpWanted = errors.New("help wanted") -// Flag is a source for command line arguments. -type Flag struct { +// flag is a source for command line arguments. +type flag struct { m map[string]string } -// NewFlag parsing a string of command line arguments. NewFlag will return -// ErrHelpWanted, if the help flag is identifyed. This code is adapted +// newSourceFlag parsing a string of command line arguments. NewFlag will return +// errHelpWanted, if the help flag is identifyed. This code is adapted // from the Go standard library flag package. -func NewFlag(args []string) (*Flag, error) { +func newSourceFlag(args []string) (*flag, error) { m := make(map[string]string) if len(args) != 0 { @@ -86,17 +128,36 @@ func NewFlag(args []string) (*Flag, error) { } } - return &Flag{m: m}, nil + return &flag{m: m}, nil } // Source implements the confg.Sourcer interface. Returns the stringfied value // stored at the specified key from the flag source. -func (f *Flag) Source(key []string) (string, bool) { - k := strings.ToLower(strings.Join(key, `-`)) +func (f *flag) Source(fld field) (string, bool) { + if fld.options.shortFlagChar != 0 { + flagKey := []string{string(fld.options.shortFlagChar)} + k := strings.ToLower(strings.Join(flagKey, `-`)) + if val, found := f.m[k]; found { + return val, found + } + } + + k := strings.ToLower(strings.Join(fld.flagKey, `-`)) val, found := f.m[k] return val, found } +// flagUsage constructs a usage string for the flag argument. +func flagUsage(fld field) string { + usage := "--" + strings.ToLower(strings.Join(fld.flagKey, `-`)) + if fld.options.shortFlagChar != 0 { + flagKey := []string{string(fld.options.shortFlagChar)} + usage += "/-" + strings.ToLower(strings.Join(flagKey, `-`)) + } + + return usage +} + /* Portions Copyright (c) 2009 The Go Authors. All rights reserved. diff --git a/usage.go b/usage.go index c35d27a..d6567e9 100644 --- a/usage.go +++ b/usage.go @@ -13,17 +13,18 @@ import ( func fmtUsage(fields []field) string { var sb strings.Builder - // Sort the fields by their long name. + // Sort the fields by their name. sort.SliceStable(fields, func(i, j int) bool { - return fields[i].flagName < fields[j].flagName + return fields[i].name < fields[j].name }) fields = append(fields, field{ - flagName: "help", + name: "help", boolField: true, + flagKey: []string{"help"}, options: fieldOptions{ - short: 'h', - help: "display this help message", + shortFlagChar: 'h', + help: "display this help message", }}) _, file := path.Split(os.Args[0]) @@ -33,16 +34,15 @@ func fmtUsage(fields []field) string { w := new(tabwriter.Writer) w.Init(&sb, 0, 4, 2, ' ', tabwriter.TabIndent) - for _, f := range fields { - typeName, help := getTypeAndHelp(&f) - fmt.Fprintf(w, " --%s", f.flagName) - if f.options.short != 0 { - fmt.Fprintf(w, "/-%s", string(f.options.short)) - } - if f.envName != "" { - fmt.Fprintf(w, "/$%s", f.envName) + for _, fld := range fields { + fmt.Fprintf(w, " %s", flagUsage(fld)) + + if fld.name != "help" { + fmt.Fprintf(w, "/%s\t", envUsage(fld)) } - fmt.Fprintf(w, " %s\t%s\t\n", typeName, getOptString(f)) + + typeName, help := getTypeAndHelp(&fld) + fmt.Fprintf(w, " %s\t%s\t\n", typeName, getOptString(fld)) if help != "" { fmt.Fprintf(w, " %s\t\t\n", help) } @@ -62,10 +62,10 @@ func fmtUsage(fields []field) string { // determined, it will simply give the name "value". Slices will be annotated // as ",[Type...]", where "Type" is whatever type name was chosen. // (adapted from package flag). -func getTypeAndHelp(f *field) (name string, usage string) { +func getTypeAndHelp(fld *field) (name string, usage string) { // Look for a single-quoted name. - usage = f.options.help + usage = fld.options.help for i := 0; i < len(usage); i++ { if usage[i] == '\'' { for j := i + 1; j < len(usage); j++ { @@ -79,8 +79,8 @@ func getTypeAndHelp(f *field) (name string, usage string) { } var isSlice bool - if f.field.IsValid() { - t := f.field.Type() + if fld.field.IsValid() { + t := fld.field.Type() // If it's a pointer, we want to deref. if t.Kind() == reflect.Ptr { @@ -97,14 +97,11 @@ func getTypeAndHelp(f *field) (name string, usage string) { if name == "" { switch t.Kind() { case reflect.Bool: - if !isSlice { - return "", usage - } - name = "" + name = "bool" case reflect.Float32, reflect.Float64: name = "float" case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64: - typ := f.field.Type() + typ := fld.field.Type() if typ.PkgPath() == "time" && typ.Name() == "Duration" { name = "duration" } else { @@ -130,16 +127,16 @@ func getTypeAndHelp(f *field) (name string, usage string) { return } -func getOptString(f field) string { +func getOptString(fld field) string { opts := make([]string, 0, 3) - if f.options.required { + if fld.options.required { opts = append(opts, "required") } - if f.options.noprint { + if fld.options.noprint { opts = append(opts, "noprint") } - if f.options.defaultStr != "" { - opts = append(opts, fmt.Sprintf("default: %s", f.options.defaultStr)) + if fld.options.defaultVal != "" { + opts = append(opts, fmt.Sprintf("default: %s", fld.options.defaultVal)) } if len(opts) > 0 { return fmt.Sprintf("(%s)", strings.Join(opts, `,`)) From cf899ba3d9a954e33e7fd114cdc116f80edb3263 Mon Sep 17 00:00:00 2001 From: William Kennedy Date: Wed, 5 Jun 2019 10:57:08 -0400 Subject: [PATCH 12/13] fixes on usage --- README.md | 16 ++++++++-------- conf.go | 4 ++-- conf_test.go | 33 ++++++++++++++------------------- doc.go | 14 +++++++------- sources.go | 4 ++-- usage.go | 14 ++++---------- 6 files changed, 37 insertions(+), 48 deletions(-) diff --git a/README.md b/README.md index 162cc61..e4a75d5 100644 --- a/README.md +++ b/README.md @@ -44,13 +44,13 @@ Would produce the following usage output: Usage: conf.test [options] [arguments] OPTIONS - --a-string/-s/$A_STRING (default: B) - --an-int/$AN_INT (default: 9) - --bool/$BOOL - --e-dur/-d/$DURATION (default: 1s) - --ip-ip/$IP_IP (default: 127.0.0.0) - --ip-name/$IP_NAME_VAR (default: localhost) - --name/$NAME (default: bill) + --an-int/$CRUD_AN_INT (default: 9) + --a-string/-s/$CRUD_A_STRING (default: B) + --bool/$CRUD_BOOL + --ip-name/$CRUD_IP_NAME_VAR (default: localhost) + --ip-ip/$CRUD_IP_IP (default: 127.0.0.0) + --name/$CRUD_NAME (default: bill) + --e-dur/-d/$CRUD_DURATION (default: 1s) --help/-h display this help message ``` @@ -62,4 +62,4 @@ The API is a single call to `Parse` if err := conf.Parse(os.Args, "CRUD", &cfg); err != nil { log.Fatalf("main : Parsing Config : %v", err) } -``` \ No newline at end of file +``` diff --git a/conf.go b/conf.go index 14b4dbe..2b72696 100644 --- a/conf.go +++ b/conf.go @@ -102,13 +102,13 @@ func Parse(args []string, namespace string, cfgStruct interface{}, sources ...So } // Usage provides output to display the config usage on the command line. -func Usage(v interface{}) (string, error) { +func Usage(namespace string, v interface{}) (string, error) { fields, err := extractFields(nil, v) if err != nil { return "", err } - return fmtUsage(fields), nil + return fmtUsage(namespace, fields), nil } // String returns a stringified version of the provided conf-tagged diff --git a/conf_test.go b/conf_test.go index 679da37..8ea35fa 100644 --- a/conf_test.go +++ b/conf_test.go @@ -218,7 +218,7 @@ func TestUsage(t *testing.T) { return } - got, err := conf.Usage(&cfg) + got, err := conf.Usage("TEST", &cfg) if err != nil { fmt.Print(err) return @@ -228,26 +228,21 @@ func TestUsage(t *testing.T) { want := `Usage: conf.test [options] [arguments] OPTIONS - --a-string/-s/$A_STRING (default: B) - --an-int/$AN_INT (default: 9) - --bool/$BOOL - --e-dur/-d/$DURATION (default: 1s) - --ip-ip/$IP_IP (default: 127.0.0.0) - --ip-name/$IP_NAME_VAR (default: localhost) - --name/$NAME (default: bill) - --help/-h + --an-int/$TEST_AN_INT (default: 9) + --a-string/-s/$TEST_A_STRING (default: B) + --bool/$TEST_BOOL + --ip-name/$TEST_IP_NAME_VAR (default: localhost) + --ip-ip/$TEST_IP_IP (default: 127.0.0.0) + --name/$TEST_NAME (default: bill) + --e-dur/-d/$TEST_DURATION (default: 1s) + --help/-h display this help message` - got = strings.ReplaceAll(got, " ", "") - want = strings.ReplaceAll(want, " ", "") - bGot := []byte(got) - bWant := []byte(want) - if diff := cmp.Diff(bGot, bWant); diff != "" { - t.Log("got:\n", got) - t.Log("\n", bGot) - t.Log("want:\n", want) - t.Log("\n", bWant) - t.Fatalf("\t%s\tShould match byte for byte the output.", failed) + gotS := strings.Split(got, "\n") + wantS := strings.Split(want, "\n") + if diff := cmp.Diff(gotS, wantS); diff != "" { + t.Errorf("\t%s\tShould match the output byte for byte. See diff:", failed) + t.Log(diff) } t.Logf("\t%s\tShould match byte for byte the output.", success) } diff --git a/doc.go b/doc.go index d59d3be..69d05bb 100644 --- a/doc.go +++ b/doc.go @@ -45,13 +45,13 @@ Would produce the following usage output: Usage: conf.test [options] [arguments] OPTIONS - --a-string/-s/$A_STRING (default: B) - --an-int/$AN_INT (default: 9) - --bool/$BOOL - --e-dur/-d/$DURATION (default: 1s) - --ip-ip/$IP_IP (default: 127.0.0.0) - --ip-name/$IP_NAME_VAR (default: localhost) - --name/$NAME (default: bill) + --an-int/$CRUD_AN_INT (default: 9) + --a-string/-s/$CRUD_A_STRING (default: B) + --bool/$CRUD_BOOL + --ip-name/$CRUD_IP_NAME_VAR (default: localhost) + --ip-ip/$CRUD_IP_IP (default: 127.0.0.0) + --name/$CRUD_NAME (default: bill) + --e-dur/-d/$CRUD_DURATION (default: 1s) --help/-h display this help message diff --git a/sources.go b/sources.go index 436a5c2..83486c5 100644 --- a/sources.go +++ b/sources.go @@ -42,8 +42,8 @@ func (e *env) Source(fld field) (string, bool) { } // envUsage constructs a usage string for the environment variable. -func envUsage(fld field) string { - return "$" + strings.ToUpper(strings.Join(fld.envKey, `_`)) +func envUsage(namespace string, fld field) string { + return "$" + strings.ToUpper(namespace) + "_" + strings.ToUpper(strings.Join(fld.envKey, `_`)) } // ============================================================================= diff --git a/usage.go b/usage.go index d6567e9..12e8758 100644 --- a/usage.go +++ b/usage.go @@ -5,19 +5,13 @@ import ( "os" "path" "reflect" - "sort" "strings" "text/tabwriter" ) -func fmtUsage(fields []field) string { +func fmtUsage(namespace string, fields []field) string { var sb strings.Builder - // Sort the fields by their name. - sort.SliceStable(fields, func(i, j int) bool { - return fields[i].name < fields[j].name - }) - fields = append(fields, field{ name: "help", boolField: true, @@ -38,13 +32,13 @@ func fmtUsage(fields []field) string { fmt.Fprintf(w, " %s", flagUsage(fld)) if fld.name != "help" { - fmt.Fprintf(w, "/%s\t", envUsage(fld)) + fmt.Fprintf(w, "/%s\t", envUsage(namespace, fld)) } typeName, help := getTypeAndHelp(&fld) - fmt.Fprintf(w, " %s\t%s\t\n", typeName, getOptString(fld)) + fmt.Fprintf(w, "%s\t%s\n", typeName, getOptString(fld)) if help != "" { - fmt.Fprintf(w, " %s\t\t\n", help) + fmt.Fprintf(w, " %s\n", help) } } From 3e9ba8a334047e8cb997702448165a37790ba3e9 Mon Sep 17 00:00:00 2001 From: William Kennedy Date: Thu, 6 Jun 2019 09:36:02 -0400 Subject: [PATCH 13/13] support for command arguments --- README.md | 29 ++++++++++++++++++++++++++ conf.go | 24 ++++++++++++++++++++++ conf_test.go | 58 ++++++++++++++++++++++++++++++++++++++++++++++++++++ doc.go | 25 +++++++++++++++++++++- sources.go | 5 +++-- usage.go | 20 ++++++++++++++++-- 6 files changed, 156 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index e4a75d5..8c392ad 100644 --- a/README.md +++ b/README.md @@ -63,3 +63,32 @@ The API is a single call to `Parse` log.Fatalf("main : Parsing Config : %v", err) } ``` + +Additionally, if the config struct has a field of the slice type `conf.Args` +then it will be populated with any remaining arguments from the command line +after flags have been processed. + +For example a program with a config struct like this: + +``` +var cfg struct { + Port int + Args conf.Args +} +``` + +If that program is executed from the command line like this: + +``` +$ my-program --port=9000 serve http +``` + +Then the `cfg.Args` field will contain the string values `["serve", "http"]`. +The `Args` type has a method `Num` for convenient access to these arguments +such as this: + +``` +arg0 := cfg.Args.Num(0) // "serve" +arg1 := cfg.Args.Num(1) // "http" +arg2 := cfg.Args.Num(2) // "" empty string: not enough arguments +``` diff --git a/conf.go b/conf.go index 2b72696..ba248b9 100644 --- a/conf.go +++ b/conf.go @@ -3,6 +3,7 @@ package conf import ( "errors" "fmt" + "reflect" "strings" ) @@ -56,6 +57,14 @@ func Parse(args []string, namespace string, cfgStruct interface{}, sources ...So // Process all fields found in the config struct provided. for _, field := range fields { + // If the field is supposed to hold the leftover args then copy them in + // from the flags source. + if field.field.Type() == argsT { + args := reflect.ValueOf(Args(flag.args)) + field.field.Set(args) + continue + } + // Set any default value into the struct for this field. if field.options.defaultVal != "" { if err := processField(field.options.defaultVal, field.field); err != nil { @@ -133,3 +142,18 @@ func String(v interface{}) (string, error) { return s.String(), nil } + +// Args holds command line arguments after flags have been parsed. +type Args []string + +// argsT is used by Parse and Usage to detect struct fields of the Args type. +var argsT = reflect.TypeOf(Args{}) + +// Num returns the i'th argument in the Args slice. It returns an empty string +// the request element is not present. +func (a Args) Num(i int) string { + if i < 0 || i >= len(a) { + return "" + } + return a[i] +} diff --git a/conf_test.go b/conf_test.go index 8ea35fa..e2912ea 100644 --- a/conf_test.go +++ b/conf_test.go @@ -97,6 +97,34 @@ func TestParse(t *testing.T) { } } +func TestParse_Args(t *testing.T) { + t.Log("Given the need to capture remaining command line arguments after flags.") + { + type configArgs struct { + Port int + Args conf.Args + } + + args := []string{"--port", "9000", "migrate", "seed"} + + want := configArgs{ + Port: 9000, + Args: conf.Args{"migrate", "seed"}, + } + + var cfg configArgs + if err := conf.Parse(args, "TEST", &cfg); err != nil { + t.Fatalf("\t%s\tShould be able to Parse arguments : %s.", failed, err) + } + t.Logf("\t%s\tShould be able to Parse arguments.", success) + + if diff := cmp.Diff(want, cfg); diff != "" { + t.Fatalf("\t%s\tShould have properly initialized struct value\n%s", failed, diff) + } + t.Logf("\t%s\tShould have properly initialized struct value.", success) + } +} + func TestErrors(t *testing.T) { t.Log("Given the need to validate errors that can occur with Parse.") { @@ -236,6 +264,36 @@ OPTIONS --name/$TEST_NAME (default: bill) --e-dur/-d/$TEST_DURATION (default: 1s) --help/-h + display this help message` + + gotS := strings.Split(got, "\n") + wantS := strings.Split(want, "\n") + if diff := cmp.Diff(gotS, wantS); diff != "" { + t.Errorf("\t%s\tShould match the output byte for byte. See diff:", failed) + t.Log(diff) + } + t.Logf("\t%s\tShould match byte for byte the output.", success) + } + + t.Logf("\tTest: %d\tWhen using a struct with arguments.", 1) + { + var cfg struct { + Port int + Args conf.Args + } + + got, err := conf.Usage("TEST", &cfg) + if err != nil { + fmt.Print(err) + return + } + + got = strings.TrimRight(got, " \n") + want := `Usage: conf.test [options] [arguments] + +OPTIONS + --port/$TEST_PORT + --help/-h display this help message` gotS := strings.Split(got, "\n") diff --git a/doc.go b/doc.go index 69d05bb..829807c 100644 --- a/doc.go +++ b/doc.go @@ -55,12 +55,35 @@ OPTIONS --help/-h display this help message -The API is a single call to `Parse` +The API is a single call to Parse // Parse(args []string, namespace string, cfgStruct interface{}, sources ...Sourcer) error if err := conf.Parse(os.Args, "CRUD", &cfg); err != nil { log.Fatalf("main : Parsing Config : %v", err) } + +Additionally, if the config struct has a field of the slice type conf.Args +then it will be populated with any remaining arguments from the command line +after flags have been processed. + +For example a program with a config struct like this: + + var cfg struct { + Port int + Args conf.Args + } + +If that program is executed from the command line like this: + + $ my-program --port=9000 serve http + +Then the cfg.Args field will contain the string values ["serve", "http"]. +The Args type has a method Num for convenient access to these arguments +such as this: + + arg0 := cfg.Args.Num(0) // "serve" + arg1 := cfg.Args.Num(1) // "http" + arg2 := cfg.Args.Num(2) // "" empty string: not enough arguments */ package conf diff --git a/sources.go b/sources.go index 83486c5..22f8488 100644 --- a/sources.go +++ b/sources.go @@ -53,7 +53,8 @@ var ErrHelpWanted = errors.New("help wanted") // flag is a source for command line arguments. type flag struct { - m map[string]string + m map[string]string + args []string } // newSourceFlag parsing a string of command line arguments. NewFlag will return @@ -128,7 +129,7 @@ func newSourceFlag(args []string) (*flag, error) { } } - return &flag{m: m}, nil + return &flag{m: m, args: args}, nil } // Source implements the confg.Sourcer interface. Returns the stringfied value diff --git a/usage.go b/usage.go index 12e8758..0bdd68a 100644 --- a/usage.go +++ b/usage.go @@ -15,6 +15,7 @@ func fmtUsage(namespace string, fields []field) string { fields = append(fields, field{ name: "help", boolField: true, + field: reflect.ValueOf(true), flagKey: []string{"help"}, options: fieldOptions{ shortFlagChar: 'h', @@ -29,14 +30,29 @@ func fmtUsage(namespace string, fields []field) string { w.Init(&sb, 0, 4, 2, ' ', tabwriter.TabIndent) for _, fld := range fields { + + // Skip printing usage info for fields that just hold arguments. + if fld.field.Type() == argsT { + continue + } + fmt.Fprintf(w, " %s", flagUsage(fld)) + // Do not display env vars for help since they aren't respected. if fld.name != "help" { - fmt.Fprintf(w, "/%s\t", envUsage(namespace, fld)) + fmt.Fprintf(w, "/%s", envUsage(namespace, fld)) } typeName, help := getTypeAndHelp(&fld) - fmt.Fprintf(w, "%s\t%s\n", typeName, getOptString(fld)) + + // Do not display type info for help because it would show but our + // parsing does not really treat --help as a boolean field. Its presence + // always indicates true even if they do --help=false. + if fld.name != "help" { + fmt.Fprintf(w, "\t%s", typeName) + } + + fmt.Fprintf(w, "\t%s\n", getOptString(fld)) if help != "" { fmt.Fprintf(w, " %s\n", help) }