2 Commits

12 changed files with 787 additions and 130 deletions

View File

@@ -59,7 +59,6 @@ linters:
- inamedparam
- interfacebloat
- intrange
- ireturn
- loggercheck
- makezero
- mirror
@@ -110,6 +109,7 @@ linters:
- perfsprint
- errcheck
- gosec
- gocyclo
- path: _test\.go$
linters:

View File

@@ -40,6 +40,9 @@ func main() {
func printValidationErrors(parserName string, errors ...types.FieldParseError) {
for _, fieldErr := range errors {
utils.PrintErr(text.FgRed, "[%s] Field '%s': %v", parserName, fieldErr.Field, fieldErr.Err)
if fieldErr.Value == "" {
utils.PrintErr(text.FgYellow, "[%s] Field '%s': %v", parserName, fieldErr.Field, fieldErr.Err)
}
utils.PrintErr(text.FgYellow, "[%s] Field '%s' (%s): %v", parserName, fieldErr.Field, fieldErr.Value, fieldErr.Err)
}
}

View File

@@ -53,8 +53,7 @@ Flags:
-skip-verify bool Skip SSL/TLS certificate verification (default %v)`
type ConfigCLIParser struct {
args []string
configFile *types.ConfigFile
args []string
}
func NewConfigCLIParser(args []string) *ConfigCLIParser {
@@ -64,10 +63,6 @@ func NewConfigCLIParser(args []string) *ConfigCLIParser {
return &ConfigCLIParser{args: args}
}
func (parser ConfigCLIParser) GetConfigFile() *types.ConfigFile {
return parser.configFile
}
type stringSliceArg []string
func (arg *stringSliceArg) String() string {
@@ -91,7 +86,7 @@ func (parser *ConfigCLIParser) Parse() (*Config, error) {
var (
config = &Config{}
configFile string
configFiles = stringSliceArg{}
yes bool
skipVerify bool
method string
@@ -108,8 +103,8 @@ func (parser *ConfigCLIParser) Parse() (*Config, error) {
)
{
flagSet.StringVar(&configFile, "config-file", "", "Config file")
flagSet.StringVar(&configFile, "f", "", "Config file")
flagSet.Var(&configFiles, "config-file", "Config file")
flagSet.Var(&configFiles, "f", "Config file")
flagSet.BoolVar(&yes, "yes", false, "Answer yes to all questions")
flagSet.BoolVar(&yes, "y", false, "Answer yes to all questions")
@@ -171,28 +166,50 @@ func (parser *ConfigCLIParser) Parse() (*Config, error) {
flagSet.Visit(func(flagVar *flag.Flag) {
switch flagVar.Name {
case "config-file", "f":
var err error
parser.configFile, err = types.ParseConfigFile(configFile)
_ = utils.HandleErrorOrDie(err,
utils.OnSentinelError(types.ErrConfigFileExtensionNotFound, func(err error) error {
fieldParseErrors = append(fieldParseErrors, *types.NewFieldParseError("config-file", errors.New("file extension not found")))
return nil
}),
utils.OnCustomError(func(err types.RemoteConfigFileParseError) error {
fieldParseErrors = append(fieldParseErrors, *types.NewFieldParseError("config-file", fmt.Errorf("parse error: %w", err)))
return nil
}),
utils.OnCustomError(func(err types.UnknownConfigFileTypeError) error {
fieldParseErrors = append(
fieldParseErrors,
*types.NewFieldParseError(
"config-file",
fmt.Errorf("file type '%s' not supported (supported types: %s)", err.Type, types.ConfigFileTypeYAML.String()),
),
)
return nil
}),
)
for i, configFile := range configFiles {
configFileParsed, err := types.ParseConfigFile(configFile)
_ = utils.HandleErrorOrDie(err,
utils.OnSentinelError(types.ErrConfigFileExtensionNotFound, func(err error) error {
fieldParseErrors = append(
fieldParseErrors,
*types.NewFieldParseError(
fmt.Sprintf("config-file[%d]", i),
configFile,
errors.New("file extension not found"),
),
)
return nil
}),
utils.OnCustomError(func(err types.RemoteConfigFileParseError) error {
fieldParseErrors = append(
fieldParseErrors,
*types.NewFieldParseError(
fmt.Sprintf("config-file[%d]", i),
configFile,
fmt.Errorf("parse error: %w", err),
),
)
return nil
}),
utils.OnCustomError(func(err types.UnknownConfigFileTypeError) error {
fieldParseErrors = append(
fieldParseErrors,
*types.NewFieldParseError(
fmt.Sprintf("config-file[%d]", i),
configFile,
fmt.Errorf("file type '%s' not supported (supported types: %s)", err.Type, types.ConfigFileTypeYAML),
),
)
return nil
}),
)
if err == nil {
config.Files = append(config.Files, *configFileParsed)
}
}
case "yes", "y":
config.Yes = utils.ToPtr(yes)
case "skip-verify":
@@ -202,7 +219,7 @@ func (parser *ConfigCLIParser) Parse() (*Config, error) {
case "url", "u":
urlParsed, err := url.Parse(urlInput)
if err != nil {
fieldParseErrors = append(fieldParseErrors, *types.NewFieldParseError("url", err))
fieldParseErrors = append(fieldParseErrors, *types.NewFieldParseError("url", urlInput, err))
} else {
config.URL = urlParsed
}
@@ -228,7 +245,7 @@ func (parser *ConfigCLIParser) Parse() (*Config, error) {
if err != nil {
fieldParseErrors = append(
fieldParseErrors,
*types.NewFieldParseError(fmt.Sprintf("proxy[%d]", i), err),
*types.NewFieldParseError(fmt.Sprintf("proxy[%d]", i), proxy, err),
)
}
}

View File

@@ -20,7 +20,6 @@ func TestNewConfigCLIParser(t *testing.T) {
require.NotNil(t, parser)
assert.Equal(t, args, parser.args)
assert.Nil(t, parser.configFile)
})
t.Run("NewConfigCLIParser with nil args", func(t *testing.T) {
@@ -28,7 +27,6 @@ func TestNewConfigCLIParser(t *testing.T) {
require.NotNil(t, parser)
assert.Equal(t, []string{}, parser.args)
assert.Nil(t, parser.configFile)
})
t.Run("NewConfigCLIParser with empty args", func(t *testing.T) {
@@ -37,21 +35,6 @@ func TestNewConfigCLIParser(t *testing.T) {
require.NotNil(t, parser)
assert.Equal(t, args, parser.args)
assert.Nil(t, parser.configFile)
})
}
func TestConfigCLIParser_GetConfigFile(t *testing.T) {
t.Run("GetConfigFile returns nil when no config file is set", func(t *testing.T) {
parser := NewConfigCLIParser([]string{"dodo"})
assert.Nil(t, parser.GetConfigFile())
})
t.Run("GetConfigFile returns config file when set", func(t *testing.T) {
parser := NewConfigCLIParser([]string{"dodo"})
expectedConfigFile := &types.ConfigFile{}
parser.configFile = expectedConfigFile
assert.Equal(t, expectedConfigFile, parser.GetConfigFile())
})
}
@@ -130,6 +113,7 @@ func TestConfigCLIParser_Parse(t *testing.T) {
require.ErrorAs(t, err, &fieldErr)
assert.Len(t, fieldErr.Errors, 1)
assert.Equal(t, "url", fieldErr.Errors[0].Field)
assert.Equal(t, "://invalid-url", fieldErr.Errors[0].Value)
})
t.Run("Parse with method flag", func(t *testing.T) {
@@ -272,6 +256,7 @@ func TestConfigCLIParser_Parse(t *testing.T) {
require.ErrorAs(t, err, &fieldErr)
assert.Len(t, fieldErr.Errors, 1)
assert.Equal(t, "proxy[0]", fieldErr.Errors[0].Field)
assert.Equal(t, "://invalid-proxy", fieldErr.Errors[0].Value)
})
t.Run("Parse with mixed valid and invalid proxies", func(t *testing.T) {
@@ -283,6 +268,7 @@ func TestConfigCLIParser_Parse(t *testing.T) {
require.ErrorAs(t, err, &fieldErr)
assert.Len(t, fieldErr.Errors, 1)
assert.Equal(t, "proxy[1]", fieldErr.Errors[0].Field)
assert.Equal(t, "://invalid", fieldErr.Errors[0].Value)
})
t.Run("Parse with long flag names", func(t *testing.T) {
@@ -318,7 +304,7 @@ func TestConfigCLIParser_Parse(t *testing.T) {
require.NoError(t, err)
require.NotNil(t, config)
assert.NotNil(t, parser.GetConfigFile())
assert.Len(t, config.Files, 1)
})
t.Run("Parse with config-file flag using long form", func(t *testing.T) {
@@ -327,7 +313,7 @@ func TestConfigCLIParser_Parse(t *testing.T) {
require.NoError(t, err)
require.NotNil(t, config)
assert.NotNil(t, parser.GetConfigFile())
assert.Len(t, config.Files, 1)
})
t.Run("Parse with config-file flag invalid extension", func(t *testing.T) {
@@ -338,7 +324,8 @@ func TestConfigCLIParser_Parse(t *testing.T) {
var fieldErr types.FieldParseErrors
require.ErrorAs(t, err, &fieldErr)
assert.Len(t, fieldErr.Errors, 1)
assert.Equal(t, "config-file", fieldErr.Errors[0].Field)
assert.Equal(t, "config-file[0]", fieldErr.Errors[0].Field)
assert.Equal(t, "/path/to/config", fieldErr.Errors[0].Value)
assert.Contains(t, fieldErr.Errors[0].Err.Error(), "file extension not found")
})
@@ -350,7 +337,8 @@ func TestConfigCLIParser_Parse(t *testing.T) {
var fieldErr types.FieldParseErrors
require.ErrorAs(t, err, &fieldErr)
assert.Len(t, fieldErr.Errors, 1)
assert.Equal(t, "config-file", fieldErr.Errors[0].Field)
assert.Equal(t, "config-file[0]", fieldErr.Errors[0].Field)
assert.Equal(t, "/path/to/config.json", fieldErr.Errors[0].Value)
assert.Contains(t, fieldErr.Errors[0].Err.Error(), "file type")
assert.Contains(t, fieldErr.Errors[0].Err.Error(), "not supported")
})
@@ -361,7 +349,16 @@ func TestConfigCLIParser_Parse(t *testing.T) {
require.NoError(t, err)
require.NotNil(t, config)
assert.NotNil(t, parser.GetConfigFile())
assert.Len(t, config.Files, 1)
})
t.Run("Parse with multiple config files", func(t *testing.T) {
parser := NewConfigCLIParser([]string{"dodo", "-f", "/path/config1.yaml", "-f", "/path/config2.yml"})
config, err := parser.Parse()
require.NoError(t, err)
require.NotNil(t, config)
assert.Len(t, config.Files, 2)
})
t.Run("Parse with all flags combined", func(t *testing.T) {
@@ -412,7 +409,7 @@ func TestConfigCLIParser_Parse(t *testing.T) {
assert.Len(t, config.Proxies, 1)
assert.Equal(t, "http://proxy.example.com:3128", config.Proxies[0].String())
assert.NotNil(t, parser.GetConfigFile())
assert.Len(t, config.Files, 1)
})
t.Run("Parse with multiple field parse errors", func(t *testing.T) {
@@ -437,6 +434,15 @@ func TestConfigCLIParser_Parse(t *testing.T) {
assert.True(t, fields["url"])
assert.True(t, fields["proxy[0]"])
assert.True(t, fields["proxy[1]"])
// Check error values
values := make(map[string]string)
for _, parseErr := range fieldErr.Errors {
values[parseErr.Field] = parseErr.Value
}
assert.Equal(t, "://invalid-url", values["url"])
assert.Equal(t, "://invalid-proxy1", values["proxy[0]"])
assert.Equal(t, "://invalid-proxy2", values["proxy[1]"])
})
}

View File

@@ -29,6 +29,7 @@ var Defaults = struct {
var SupportedProxySchemes []string = []string{"http", "socks5", "socks5h"}
type Config struct {
Files []types.ConfigFile
Method *string
URL *url.URL
Timeout *time.Duration
@@ -49,6 +50,7 @@ func NewConfig() *Config {
}
func (config *Config) MergeConfig(newConfig *Config) {
config.Files = append(config.Files, newConfig.Files...)
if newConfig.Method != nil {
config.Method = newConfig.Method
}

View File

@@ -23,6 +23,7 @@ func TestMergeConfig(t *testing.T) {
newDuration := 2 * time.Minute
config := &Config{
Files: []types.ConfigFile{},
Method: utils.ToPtr("GET"),
URL: originalURL,
Timeout: &originalTimeout,
@@ -39,6 +40,7 @@ func TestMergeConfig(t *testing.T) {
}
newConfig := &Config{
Files: []types.ConfigFile{},
Method: utils.ToPtr("POST"),
URL: newURL,
Timeout: &newTimeout,
@@ -76,6 +78,7 @@ func TestMergeConfig(t *testing.T) {
originalTimeout := 5 * time.Second
config := &Config{
Files: []types.ConfigFile{},
Method: utils.ToPtr("GET"),
URL: originalURL,
Timeout: &originalTimeout,
@@ -85,6 +88,7 @@ func TestMergeConfig(t *testing.T) {
newURL, _ := url.Parse("https://new.example.com")
newConfig := &Config{
Files: []types.ConfigFile{},
URL: newURL,
DodosCount: utils.ToPtr(uint(10)),
}
@@ -103,6 +107,7 @@ func TestMergeConfig(t *testing.T) {
originalTimeout := 5 * time.Second
config := &Config{
Files: []types.ConfigFile{},
Method: utils.ToPtr("GET"),
URL: originalURL,
Timeout: &originalTimeout,
@@ -112,6 +117,7 @@ func TestMergeConfig(t *testing.T) {
}
newConfig := &Config{
Files: []types.ConfigFile{},
Method: nil,
URL: nil,
Timeout: nil,
@@ -132,7 +138,9 @@ func TestMergeConfig(t *testing.T) {
})
t.Run("MergeConfig with empty slices", func(t *testing.T) {
configFile, _ := types.ParseConfigFile("original.yml")
config := &Config{
Files: []types.ConfigFile{*configFile},
Params: types.Params{{Key: "original", Value: []string{"value"}}},
Headers: types.Headers{{Key: "Original-Header", Value: []string{"original"}}},
Cookies: types.Cookies{{Key: "originalCookie", Value: []string{"originalValue"}}},
@@ -141,6 +149,7 @@ func TestMergeConfig(t *testing.T) {
}
newConfig := &Config{
Files: []types.ConfigFile{},
Params: types.Params{},
Headers: types.Headers{},
Cookies: types.Cookies{},
@@ -150,6 +159,7 @@ func TestMergeConfig(t *testing.T) {
config.MergeConfig(newConfig)
assert.Equal(t, []types.ConfigFile{*configFile}, config.Files, "Empty Files should not override")
assert.Equal(t, types.Params{{Key: "original", Value: []string{"value"}}}, config.Params, "Empty Params should not override")
assert.Equal(t, types.Headers{{Key: "Original-Header", Value: []string{"original"}}}, config.Headers, "Empty Headers should not override")
assert.Equal(t, types.Cookies{{Key: "originalCookie", Value: []string{"originalValue"}}}, config.Cookies, "Empty Cookies should not override")
@@ -157,6 +167,28 @@ func TestMergeConfig(t *testing.T) {
assert.Equal(t, types.Proxies{}, config.Proxies, "Empty Proxies should not override")
})
t.Run("MergeConfig with Files field", func(t *testing.T) {
configFile1, _ := types.ParseConfigFile("config1.yml")
configFile2, _ := types.ParseConfigFile("config2.yaml")
config := &Config{
Files: []types.ConfigFile{*configFile1},
Method: utils.ToPtr("GET"),
Headers: types.Headers{{Key: "Original-Header", Value: []string{"original"}}},
}
newConfig := &Config{
Files: []types.ConfigFile{*configFile2},
Method: utils.ToPtr("POST"),
}
config.MergeConfig(newConfig)
assert.Equal(t, "POST", *config.Method, "Method should be updated")
assert.Equal(t, []types.ConfigFile{*configFile1, *configFile2}, config.Files, "Files should be appended")
assert.Equal(t, types.Headers{{Key: "Original-Header", Value: []string{"original"}}}, config.Headers, "Headers should remain unchanged")
})
t.Run("MergeConfig on empty original config", func(t *testing.T) {
config := &Config{}
@@ -165,6 +197,7 @@ func TestMergeConfig(t *testing.T) {
newDuration := 2 * time.Minute
newConfig := &Config{
Files: []types.ConfigFile{},
Method: utils.ToPtr("POST"),
URL: newURL,
Timeout: &newTimeout,
@@ -246,6 +279,7 @@ func TestSetDefaults(t *testing.T) {
t.Run("SetDefaults adds User-Agent when missing", func(t *testing.T) {
config := &Config{
Files: []types.ConfigFile{},
Headers: types.Headers{{Key: "Content-Type", Value: []string{"application/json"}}},
}
@@ -268,6 +302,7 @@ func TestSetDefaults(t *testing.T) {
t.Run("SetDefaults with partial config", func(t *testing.T) {
config := &Config{
Files: []types.ConfigFile{},
Method: utils.ToPtr("PUT"),
Yes: utils.ToPtr(true),
}
@@ -306,6 +341,7 @@ func TestSetDefaults(t *testing.T) {
t.Run("SetDefaults with empty Headers initializes correctly", func(t *testing.T) {
config := &Config{
Files: []types.ConfigFile{},
Headers: types.Headers{},
}

View File

@@ -6,39 +6,19 @@ import (
"strings"
)
type ConfigFileType int
type ConfigFileType string
const (
ConfigFileTypeYAML ConfigFileType = iota
ConfigFileTypeYAML ConfigFileType = "yaml/yml"
)
func (t ConfigFileType) String() string {
switch t {
case ConfigFileTypeYAML:
return "yaml/yml"
default:
return "unknown"
}
}
type ConfigFileLocationType int
type ConfigFileLocationType string
const (
ConfigFileLocationLocal ConfigFileLocationType = iota
ConfigFileLocationRemote
ConfigFileLocationLocal ConfigFileLocationType = "local"
ConfigFileLocationRemote ConfigFileLocationType = "remote"
)
func (l ConfigFileLocationType) String() string {
switch l {
case ConfigFileLocationLocal:
return "local"
case ConfigFileLocationRemote:
return "remote"
default:
return "unknown"
}
}
type ConfigFile struct {
path string
_type ConfigFileType

View File

@@ -7,35 +7,6 @@ import (
"github.com/stretchr/testify/require"
)
func TestConfigFileType_String(t *testing.T) {
t.Run("ConfigFileTypeYAML returns yaml", func(t *testing.T) {
configType := ConfigFileTypeYAML
assert.Equal(t, "yaml/yml", configType.String())
})
t.Run("Unknown config file type returns unknown", func(t *testing.T) {
configType := ConfigFileType(999)
assert.Equal(t, "unknown", configType.String())
})
}
func TestConfigFileLocationType_String(t *testing.T) {
t.Run("ConfigFileLocationLocal returns local", func(t *testing.T) {
locationType := ConfigFileLocationLocal
assert.Equal(t, "local", locationType.String())
})
t.Run("ConfigFileLocationRemote returns remote", func(t *testing.T) {
locationType := ConfigFileLocationRemote
assert.Equal(t, "remote", locationType.String())
})
t.Run("Unknown location type returns unknown", func(t *testing.T) {
locationType := ConfigFileLocationType(999)
assert.Equal(t, "unknown", locationType.String())
})
}
func TestConfigFile_String(t *testing.T) {
t.Run("String returns the file path", func(t *testing.T) {
configFile := ConfigFile{path: "/path/to/config.yaml"}

View File

@@ -22,14 +22,15 @@ var (
type FieldParseError struct {
Field string
Value string
Err error
}
func NewFieldParseError(field string, err error) *FieldParseError {
func NewFieldParseError(field string, value string, err error) *FieldParseError {
if err == nil {
err = ErrNoError
}
return &FieldParseError{field, err}
return &FieldParseError{field, value, err}
}
func (e FieldParseError) Error() string {

View File

@@ -10,7 +10,7 @@ import (
func TestFieldParseError_Error(t *testing.T) {
t.Run("Error returns formatted message", func(t *testing.T) {
originalErr := errors.New("invalid value")
fieldErr := NewFieldParseError("username", originalErr)
fieldErr := NewFieldParseError("username", "testuser", originalErr)
expected := "Field 'username' parse failed: invalid value"
assert.Equal(t, expected, fieldErr.Error())
@@ -18,14 +18,14 @@ func TestFieldParseError_Error(t *testing.T) {
t.Run("Error with empty field name", func(t *testing.T) {
originalErr := errors.New("test error")
fieldErr := NewFieldParseError("", originalErr)
fieldErr := NewFieldParseError("", "somevalue", originalErr)
expected := "Field '' parse failed: test error"
assert.Equal(t, expected, fieldErr.Error())
})
t.Run("Error with nil underlying error", func(t *testing.T) {
fieldErr := NewFieldParseError("field", nil)
fieldErr := NewFieldParseError("field", "value123", nil)
expected := "Field 'field' parse failed: no error (internal)"
assert.Equal(t, expected, fieldErr.Error())
@@ -35,13 +35,13 @@ func TestFieldParseError_Error(t *testing.T) {
func TestFieldParseError_Unwrap(t *testing.T) {
t.Run("Unwrap returns original error", func(t *testing.T) {
originalErr := errors.New("original error")
fieldErr := NewFieldParseError("field", originalErr)
fieldErr := NewFieldParseError("field", "value", originalErr)
assert.Equal(t, originalErr, fieldErr.Unwrap())
})
t.Run("Unwrap with nil error", func(t *testing.T) {
fieldErr := NewFieldParseError("field", nil)
fieldErr := NewFieldParseError("field", "value", nil)
assert.Equal(t, ErrNoError, fieldErr.Unwrap())
})
@@ -50,16 +50,18 @@ func TestFieldParseError_Unwrap(t *testing.T) {
func TestNewFieldParseError(t *testing.T) {
t.Run("Creates FieldParseError with correct values", func(t *testing.T) {
originalErr := errors.New("test error")
fieldErr := NewFieldParseError("testField", originalErr)
fieldErr := NewFieldParseError("testField", "testValue", originalErr)
assert.Equal(t, "testField", fieldErr.Field)
assert.Equal(t, "testValue", fieldErr.Value)
assert.Equal(t, originalErr, fieldErr.Err)
})
t.Run("Creates FieldParseError with ErrNoError when nil passed", func(t *testing.T) {
fieldErr := NewFieldParseError("testField", nil)
fieldErr := NewFieldParseError("testField", "testValue", nil)
assert.Equal(t, "testField", fieldErr.Field)
assert.Equal(t, "testValue", fieldErr.Value)
assert.Equal(t, ErrNoError, fieldErr.Err)
})
}
@@ -72,7 +74,7 @@ func TestFieldParseErrors_Error(t *testing.T) {
})
t.Run("Error with single error returns single error message", func(t *testing.T) {
fieldErr := *NewFieldParseError("field1", errors.New("error1"))
fieldErr := *NewFieldParseError("field1", "value1", errors.New("error1"))
fieldErrors := NewFieldParseErrors([]FieldParseError{fieldErr})
expected := "Field 'field1' parse failed: error1"
@@ -80,9 +82,9 @@ func TestFieldParseErrors_Error(t *testing.T) {
})
t.Run("Error with multiple errors returns concatenated messages", func(t *testing.T) {
fieldErr1 := *NewFieldParseError("field1", errors.New("error1"))
fieldErr2 := *NewFieldParseError("field2", errors.New("error2"))
fieldErr3 := *NewFieldParseError("field3", errors.New("error3"))
fieldErr1 := *NewFieldParseError("field1", "value1", errors.New("error1"))
fieldErr2 := *NewFieldParseError("field2", "value2", errors.New("error2"))
fieldErr3 := *NewFieldParseError("field3", "value3", errors.New("error3"))
fieldErrors := NewFieldParseErrors([]FieldParseError{fieldErr1, fieldErr2, fieldErr3})
expected := "Field 'field1' parse failed: error1\nField 'field2' parse failed: error2\nField 'field3' parse failed: error3"
@@ -90,8 +92,8 @@ func TestFieldParseErrors_Error(t *testing.T) {
})
t.Run("Error with two errors", func(t *testing.T) {
fieldErr1 := *NewFieldParseError("username", errors.New("too short"))
fieldErr2 := *NewFieldParseError("email", errors.New("invalid format"))
fieldErr1 := *NewFieldParseError("username", "john", errors.New("too short"))
fieldErr2 := *NewFieldParseError("email", "invalid", errors.New("invalid format"))
fieldErrors := NewFieldParseErrors([]FieldParseError{fieldErr1, fieldErr2})
expected := "Field 'username' parse failed: too short\nField 'email' parse failed: invalid format"
@@ -101,8 +103,8 @@ func TestFieldParseErrors_Error(t *testing.T) {
func TestNewFieldParseErrors(t *testing.T) {
t.Run("Creates FieldParseErrors with correct values", func(t *testing.T) {
fieldErr1 := *NewFieldParseError("field1", errors.New("error1"))
fieldErr2 := *NewFieldParseError("field2", errors.New("error2"))
fieldErr1 := *NewFieldParseError("field1", "value1", errors.New("error1"))
fieldErr2 := *NewFieldParseError("field2", "value2", errors.New("error2"))
fieldErrors := NewFieldParseErrors([]FieldParseError{fieldErr1, fieldErr2})
assert.Len(t, fieldErrors.Errors, 2)
@@ -258,7 +260,7 @@ func TestErrorConstants(t *testing.T) {
func TestErrorImplementsErrorInterface(t *testing.T) {
t.Run("FieldParseError implements error interface", func(t *testing.T) {
var err error = NewFieldParseError("field", errors.New("test"))
var err error = NewFieldParseError("field", "value", errors.New("test"))
assert.Error(t, err)
})

111
pkg/utils/parse.go Normal file
View File

@@ -0,0 +1,111 @@
package utils
import (
"fmt"
"net/url"
"strconv"
"time"
)
// ParseString attempts to parse the input string `s` into a value of the specified type T.
// If parsing the string `s` fails for a supported type, it returns the zero value of T
// and the parsing error.
// /nolint:forcetypeassert,wrapcheck
func ParseString[
T string | int | int8 | int16 | int32 | int64 | uint | uint8 | uint16 | uint32 | uint64 | float64 | bool | time.Duration | url.URL,
](rawValue string) (T, error) {
var value T
switch any(value).(type) {
case int:
i, err := strconv.Atoi(rawValue)
if err != nil {
return value, err
}
value = any(i).(T)
case int8:
i, err := strconv.ParseInt(rawValue, 10, 8)
if err != nil {
return value, err
}
value = any(int8(i)).(T)
case int16:
i, err := strconv.ParseInt(rawValue, 10, 16)
if err != nil {
return value, err
}
value = any(int16(i)).(T)
case int32:
i, err := strconv.ParseInt(rawValue, 10, 32)
if err != nil {
return value, err
}
value = any(int32(i)).(T)
case int64:
i, err := strconv.ParseInt(rawValue, 10, 64)
if err != nil {
return value, err
}
value = any(i).(T)
case uint:
u, err := strconv.ParseUint(rawValue, 10, 0)
if err != nil {
return value, err
}
value = any(uint(u)).(T)
case uint8:
u, err := strconv.ParseUint(rawValue, 10, 8)
if err != nil {
return value, err
}
value = any(uint8(u)).(T)
case uint16:
u, err := strconv.ParseUint(rawValue, 10, 16)
if err != nil {
return value, err
}
value = any(uint16(u)).(T)
case uint32:
u, err := strconv.ParseUint(rawValue, 10, 32)
if err != nil {
return value, err
}
value = any(uint32(u)).(T)
case uint64:
u, err := strconv.ParseUint(rawValue, 10, 64)
if err != nil {
return value, err
}
value = any(u).(T)
case float64:
f, err := strconv.ParseFloat(rawValue, 64)
if err != nil {
return value, err
}
value = any(f).(T)
case bool:
b, err := strconv.ParseBool(rawValue)
if err != nil {
return value, err
}
value = any(b).(T)
case string:
value = any(rawValue).(T)
case time.Duration:
d, err := time.ParseDuration(rawValue)
if err != nil {
return value, err
}
value = any(d).(T)
case url.URL:
u, err := url.Parse(rawValue)
if err != nil {
return value, err
}
value = any(*u).(T)
default:
return value, fmt.Errorf("unsupported type: %T", value)
}
return value, nil
}

528
pkg/utils/parse_test.go Normal file
View File

@@ -0,0 +1,528 @@
package utils
import (
"net/url"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestParseString(t *testing.T) {
t.Run("ParseString to string", func(t *testing.T) {
tests := []struct {
name string
input string
expected string
}{
{"empty string", "", ""},
{"simple string", "hello", "hello"},
{"string with spaces", "hello world", "hello world"},
{"numeric string", "123", "123"},
{"special characters", "!@#$%^&*()", "!@#$%^&*()"},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
result, err := ParseString[string](test.input)
require.NoError(t, err)
assert.Equal(t, test.expected, result)
})
}
})
t.Run("ParseString to int", func(t *testing.T) {
tests := []struct {
name string
input string
expected int
expectError bool
}{
{"positive int", "42", 42, false},
{"negative int", "-42", -42, false},
{"zero", "0", 0, false},
{"invalid int", "abc", 0, true},
{"float string", "3.14", 0, true},
{"empty string", "", 0, true},
{"overflow string", "99999999999999999999", 0, true},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
result, err := ParseString[int](test.input)
if test.expectError {
assert.Error(t, err)
} else {
require.NoError(t, err)
assert.Equal(t, test.expected, result)
}
})
}
})
t.Run("ParseString to int8", func(t *testing.T) {
tests := []struct {
name string
input string
expected int8
expectError bool
}{
{"valid int8", "127", 127, false},
{"min int8", "-128", -128, false},
{"overflow int8", "128", 0, true},
{"underflow int8", "-129", 0, true},
{"invalid", "abc", 0, true},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
result, err := ParseString[int8](test.input)
if test.expectError {
assert.Error(t, err)
} else {
require.NoError(t, err)
assert.Equal(t, test.expected, result)
}
})
}
})
t.Run("ParseString to int16", func(t *testing.T) {
tests := []struct {
name string
input string
expected int16
expectError bool
}{
{"valid int16", "32767", 32767, false},
{"min int16", "-32768", -32768, false},
{"overflow int16", "32768", 0, true},
{"underflow int16", "-32769", 0, true},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
result, err := ParseString[int16](test.input)
if test.expectError {
assert.Error(t, err)
} else {
require.NoError(t, err)
assert.Equal(t, test.expected, result)
}
})
}
})
t.Run("ParseString to int32", func(t *testing.T) {
tests := []struct {
name string
input string
expected int32
expectError bool
}{
{"valid int32", "2147483647", 2147483647, false},
{"min int32", "-2147483648", -2147483648, false},
{"overflow int32", "2147483648", 0, true},
{"underflow int32", "-2147483649", 0, true},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
result, err := ParseString[int32](test.input)
if test.expectError {
assert.Error(t, err)
} else {
require.NoError(t, err)
assert.Equal(t, test.expected, result)
}
})
}
})
t.Run("ParseString to int64", func(t *testing.T) {
tests := []struct {
name string
input string
expected int64
expectError bool
}{
{"valid int64", "9223372036854775807", 9223372036854775807, false},
{"min int64", "-9223372036854775808", -9223372036854775808, false},
{"large number", "123456789012345", 123456789012345, false},
{"invalid", "not a number", 0, true},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
result, err := ParseString[int64](test.input)
if test.expectError {
assert.Error(t, err)
} else {
require.NoError(t, err)
assert.Equal(t, test.expected, result)
}
})
}
})
t.Run("ParseString to uint", func(t *testing.T) {
tests := []struct {
name string
input string
expected uint
expectError bool
}{
{"valid uint", "42", 42, false},
{"zero", "0", 0, false},
{"large uint", "4294967295", 4294967295, false},
{"negative", "-1", 0, true},
{"invalid", "abc", 0, true},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
result, err := ParseString[uint](test.input)
if test.expectError {
assert.Error(t, err)
} else {
require.NoError(t, err)
assert.Equal(t, test.expected, result)
}
})
}
})
t.Run("ParseString to uint8", func(t *testing.T) {
tests := []struct {
name string
input string
expected uint8
expectError bool
}{
{"valid uint8", "255", 255, false},
{"min uint8", "0", 0, false},
{"overflow uint8", "256", 0, true},
{"negative", "-1", 0, true},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
result, err := ParseString[uint8](test.input)
if test.expectError {
assert.Error(t, err)
} else {
require.NoError(t, err)
assert.Equal(t, test.expected, result)
}
})
}
})
t.Run("ParseString to uint16", func(t *testing.T) {
tests := []struct {
name string
input string
expected uint16
expectError bool
}{
{"valid uint16", "65535", 65535, false},
{"min uint16", "0", 0, false},
{"overflow uint16", "65536", 0, true},
{"negative", "-1", 0, true},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
result, err := ParseString[uint16](test.input)
if test.expectError {
assert.Error(t, err)
} else {
require.NoError(t, err)
assert.Equal(t, test.expected, result)
}
})
}
})
t.Run("ParseString to uint32", func(t *testing.T) {
tests := []struct {
name string
input string
expected uint32
expectError bool
}{
{"valid uint32", "4294967295", 4294967295, false},
{"min uint32", "0", 0, false},
{"overflow uint32", "4294967296", 0, true},
{"negative", "-1", 0, true},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
result, err := ParseString[uint32](test.input)
if test.expectError {
assert.Error(t, err)
} else {
require.NoError(t, err)
assert.Equal(t, test.expected, result)
}
})
}
})
t.Run("ParseString to uint64", func(t *testing.T) {
tests := []struct {
name string
input string
expected uint64
expectError bool
}{
{"valid uint64", "18446744073709551615", 18446744073709551615, false},
{"min uint64", "0", 0, false},
{"large number", "123456789012345", 123456789012345, false},
{"negative", "-1", 0, true},
{"invalid", "not a number", 0, true},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
result, err := ParseString[uint64](test.input)
if test.expectError {
assert.Error(t, err)
} else {
require.NoError(t, err)
assert.Equal(t, test.expected, result)
}
})
}
})
t.Run("ParseString to float64", func(t *testing.T) {
tests := []struct {
name string
input string
expected float64
expectError bool
}{
{"integer", "42", 42.0, false},
{"decimal", "3.14159", 3.14159, false},
{"negative", "-2.5", -2.5, false},
{"scientific notation", "1.23e10", 1.23e10, false},
{"zero", "0", 0.0, false},
{"invalid", "not a number", 0, true},
{"empty", "", 0, true},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
result, err := ParseString[float64](test.input)
if test.expectError {
assert.Error(t, err)
} else {
require.NoError(t, err)
assert.InDelta(t, test.expected, result, 0.0001)
}
})
}
})
t.Run("ParseString to bool", func(t *testing.T) {
tests := []struct {
name string
input string
expected bool
expectError bool
}{
{"true lowercase", "true", true, false},
{"True mixed case", "True", true, false},
{"TRUE uppercase", "TRUE", true, false},
{"1 as true", "1", true, false},
{"false lowercase", "false", false, false},
{"False mixed case", "False", false, false},
{"FALSE uppercase", "FALSE", false, false},
{"0 as false", "0", false, false},
{"invalid", "yes", false, true},
{"empty", "", false, true},
{"numeric non-binary", "2", false, true},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
result, err := ParseString[bool](test.input)
if test.expectError {
assert.Error(t, err)
} else {
require.NoError(t, err)
assert.Equal(t, test.expected, result)
}
})
}
})
t.Run("ParseString to time.Duration", func(t *testing.T) {
tests := []struct {
name string
input string
expected time.Duration
expectError bool
}{
{"seconds", "10s", 10 * time.Second, false},
{"minutes", "5m", 5 * time.Minute, false},
{"hours", "2h", 2 * time.Hour, false},
{"combined", "1h30m45s", time.Hour + 30*time.Minute + 45*time.Second, false},
{"milliseconds", "500ms", 500 * time.Millisecond, false},
{"microseconds", "100us", 100 * time.Microsecond, false},
{"nanoseconds", "50ns", 50 * time.Nanosecond, false},
{"negative", "-5s", -5 * time.Second, false},
{"invalid", "5x", 0, true},
{"empty", "", 0, true},
{"no unit", "100", 0, true},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
result, err := ParseString[time.Duration](test.input)
if test.expectError {
assert.Error(t, err)
} else {
require.NoError(t, err)
assert.Equal(t, test.expected, result)
}
})
}
})
t.Run("ParseString to url.URL", func(t *testing.T) {
tests := []struct {
name string
input string
checkFunc func(t *testing.T, u url.URL)
expectError bool
}{
{
name: "http URL",
input: "http://example.com",
checkFunc: func(t *testing.T, u url.URL) {
t.Helper()
assert.Equal(t, "http", u.Scheme)
assert.Equal(t, "example.com", u.Host)
},
expectError: false,
},
{
name: "https URL with path",
input: "https://example.com/path/to/resource",
checkFunc: func(t *testing.T, u url.URL) {
t.Helper()
assert.Equal(t, "https", u.Scheme)
assert.Equal(t, "example.com", u.Host)
assert.Equal(t, "/path/to/resource", u.Path)
},
expectError: false,
},
{
name: "URL with query parameters",
input: "https://example.com/search?q=test&page=1",
checkFunc: func(t *testing.T, u url.URL) {
t.Helper()
assert.Equal(t, "https", u.Scheme)
assert.Equal(t, "example.com", u.Host)
assert.Equal(t, "/search", u.Path)
assert.Equal(t, "q=test&page=1", u.RawQuery)
},
expectError: false,
},
{
name: "URL with port",
input: "http://localhost:8080/api",
checkFunc: func(t *testing.T, u url.URL) {
t.Helper()
assert.Equal(t, "http", u.Scheme)
assert.Equal(t, "localhost:8080", u.Host)
assert.Equal(t, "/api", u.Path)
},
expectError: false,
},
{
name: "URL with fragment",
input: "https://example.com/page#section",
checkFunc: func(t *testing.T, u url.URL) {
t.Helper()
assert.Equal(t, "https", u.Scheme)
assert.Equal(t, "example.com", u.Host)
assert.Equal(t, "/page", u.Path)
assert.Equal(t, "section", u.Fragment)
},
expectError: false,
},
{
name: "relative path",
input: "/path/to/resource",
checkFunc: func(t *testing.T, u url.URL) {
t.Helper()
assert.Empty(t, u.Scheme)
assert.Empty(t, u.Host)
assert.Equal(t, "/path/to/resource", u.Path)
},
expectError: false,
},
{
name: "empty string",
input: "",
checkFunc: func(t *testing.T, u url.URL) {
t.Helper()
assert.Empty(t, u.String())
},
expectError: false,
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
result, err := ParseString[url.URL](test.input)
if test.expectError {
assert.Error(t, err)
} else {
require.NoError(t, err)
if test.checkFunc != nil {
test.checkFunc(t, result)
}
}
})
}
})
t.Run("Edge cases", func(t *testing.T) {
t.Run("whitespace handling for numeric types", func(t *testing.T) {
result, err := ParseString[int](" 42 ")
require.Error(t, err)
assert.Equal(t, 0, result)
})
t.Run("leading zeros for int", func(t *testing.T) {
result, err := ParseString[int]("007")
require.NoError(t, err)
assert.Equal(t, 7, result)
})
t.Run("plus sign for positive numbers", func(t *testing.T) {
result, err := ParseString[int]("+42")
require.NoError(t, err)
assert.Equal(t, 42, result)
})
t.Run("case sensitivity for bool", func(t *testing.T) {
testCases := []string{"t", "T", "f", "F"}
for _, tc := range testCases {
result, err := ParseString[bool](tc)
require.NoError(t, err)
if tc == "t" || tc == "T" {
assert.True(t, result)
} else {
assert.False(t, result)
}
}
})
})
}