add env parser

This commit is contained in:
2025-09-04 22:43:52 +04:00
parent fd7c4c6454
commit 81383d1ea7
10 changed files with 1466 additions and 30 deletions

View File

@@ -0,0 +1,7 @@
package parser
import "github.com/aykhans/dodo/pkg/config"
type IParser interface {
Parse() (*config.Config, error)
}

303
pkg/config/parser/cli.go Normal file
View File

@@ -0,0 +1,303 @@
package parser
import (
"errors"
"flag"
"fmt"
"net/url"
"strings"
"time"
"github.com/aykhans/dodo/pkg/config"
"github.com/aykhans/dodo/pkg/types"
"github.com/aykhans/dodo/pkg/utils"
)
const cliUsageText = `Usage:
dodo [flags]
Examples:
Simple usage:
dodo -u https://example.com -o 1m
Usage with config file:
dodo -f /path/to/config/file/config.json
Usage with all flags:
dodo -f /path/to/config/file/config.json \
-u https://example.com -m POST \
-d 10 -r 1000 -o 3m -t 3s \
-b "body1" -body "body2" \
-H "header1:value1" -header "header2:value2" \
-p "param1=value1" -param "param2=value2" \
-c "cookie1=value1" -cookie "cookie2=value2" \
-x "http://proxy.example.com:8080" -proxy "socks5://proxy2.example.com:8080" \
-skip-verify -y
Flags:
-h, -help help for dodo
-v, -version version for dodo
-y, -yes bool Answer yes to all questions (default %v)
-f, -config-file string Path to the local config file or http(s) URL of the config file
-d, -dodos uint Number of dodos(threads) (default %d)
-r, -requests uint Number of total requests
-o, -duration Time Maximum duration for the test (e.g. 30s, 1m, 5h)
-t, -timeout Time Timeout for each request (e.g. 400ms, 15s, 1m10s) (default %v)
-u, -url string URL for stress testing
-m, -method string HTTP Method for the request (default %s)
-b, -body [string] Body for the request (e.g. "body text")
-p, -param [string] Parameter for the request (e.g. "key1=value1")
-H, -header [string] Header for the request (e.g. "key1: value1")
-c, -cookie [string] Cookie for the request (e.g. "key1=value1")
-x, -proxy [string] Proxy for the request (e.g. "http://proxy.example.com:8080")
-skip-verify bool Skip SSL/TLS certificate verification (default %v)`
var _ IParser = ConfigCLIParser{}
type ConfigCLIParser struct {
args []string
}
func NewConfigCLIParser(args []string) *ConfigCLIParser {
if args == nil {
args = []string{}
}
return &ConfigCLIParser{args: args}
}
type stringSliceArg []string
func (arg *stringSliceArg) String() string {
return strings.Join(*arg, ",")
}
func (arg *stringSliceArg) Set(value string) error {
*arg = append(*arg, value)
return nil
}
// Parse parses command-line arguments into a Config object.
// It can return the following errors:
// - types.ErrCLINoArgs
// - types.CLIUnexpectedArgsError
// - types.FieldParseErrors
func (parser ConfigCLIParser) Parse() (*config.Config, error) {
flagSet := flag.NewFlagSet("dodo", flag.ExitOnError)
flagSet.Usage = func() { parser.PrintHelp() }
var (
config = &config.Config{}
configFiles = stringSliceArg{}
yes bool
skipVerify bool
method string
urlInput string
dodosCount uint
requestCount uint
duration time.Duration
timeout time.Duration
params = stringSliceArg{}
headers = stringSliceArg{}
cookies = stringSliceArg{}
bodies = stringSliceArg{}
proxies = stringSliceArg{}
)
{
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")
flagSet.BoolVar(&skipVerify, "skip-verify", false, "Skip SSL/TLS certificate verification")
flagSet.StringVar(&method, "method", "", "HTTP Method")
flagSet.StringVar(&method, "m", "", "HTTP Method")
flagSet.StringVar(&urlInput, "url", "", "URL to send the request")
flagSet.StringVar(&urlInput, "u", "", "URL to send the request")
flagSet.UintVar(&dodosCount, "dodos", 0, "Number of dodos(threads)")
flagSet.UintVar(&dodosCount, "d", 0, "Number of dodos(threads)")
flagSet.UintVar(&requestCount, "requests", 0, "Number of total requests")
flagSet.UintVar(&requestCount, "r", 0, "Number of total requests")
flagSet.DurationVar(&duration, "duration", 0, "Maximum duration of the test")
flagSet.DurationVar(&duration, "o", 0, "Maximum duration of the test")
flagSet.DurationVar(&timeout, "timeout", 0, "Timeout for each request (e.g. 400ms, 15s, 1m10s)")
flagSet.DurationVar(&timeout, "t", 0, "Timeout for each request (e.g. 400ms, 15s, 1m10s)")
flagSet.Var(&params, "param", "URL parameter to send with the request")
flagSet.Var(&params, "p", "URL parameter to send with the request")
flagSet.Var(&headers, "header", "Header to send with the request")
flagSet.Var(&headers, "H", "Header to send with the request")
flagSet.Var(&cookies, "cookie", "Cookie to send with the request")
flagSet.Var(&cookies, "c", "Cookie to send with the request")
flagSet.Var(&bodies, "body", "Body to send with the request")
flagSet.Var(&bodies, "b", "Body to send with the request")
flagSet.Var(&proxies, "proxy", "Proxy to use for the request")
flagSet.Var(&proxies, "x", "Proxy to use for the request")
}
// Parse the specific arguments provided to the parser, skipping the program name.
if err := flagSet.Parse(parser.args[1:]); err != nil {
panic(err)
}
// Check if no flags were set and no non-flag arguments were provided.
// This covers cases where `dodo` is run without any meaningful arguments.
if flagSet.NFlag() == 0 && len(flagSet.Args()) == 0 {
return nil, types.ErrCLINoArgs
}
// Check for any unexpected non-flag arguments remaining after parsing.
if args := flagSet.Args(); len(args) > 0 {
return nil, types.NewCLIUnexpectedArgsError(args)
}
var fieldParseErrors []types.FieldParseError
// Iterate over flags that were explicitly set on the command line.
flagSet.Visit(func(flagVar *flag.Flag) {
switch flagVar.Name {
case "config-file", "f":
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":
config.SkipVerify = utils.ToPtr(skipVerify)
case "method", "m":
config.Method = utils.ToPtr(method)
case "url", "u":
urlParsed, err := url.Parse(urlInput)
if err != nil {
fieldParseErrors = append(fieldParseErrors, *types.NewFieldParseError("url", urlInput, err))
} else {
config.URL = urlParsed
}
case "dodos", "d":
config.DodosCount = utils.ToPtr(dodosCount)
case "requests", "r":
config.RequestCount = utils.ToPtr(requestCount)
case "duration", "o":
config.Duration = utils.ToPtr(duration)
case "timeout", "t":
config.Timeout = utils.ToPtr(timeout)
case "param", "p":
config.Params.Parse(params...)
case "header", "H":
config.Headers.Parse(headers...)
case "cookie", "c":
config.Cookies.Parse(cookies...)
case "body", "b":
config.Bodies.Parse(bodies...)
case "proxy", "x":
for i, proxy := range proxies {
err := config.Proxies.Parse(proxy)
if err != nil {
fieldParseErrors = append(
fieldParseErrors,
*types.NewFieldParseError(fmt.Sprintf("proxy[%d]", i), proxy, err),
)
}
}
}
})
if len(fieldParseErrors) > 0 {
return nil, types.NewFieldParseErrors(fieldParseErrors)
}
return config, nil
}
func (parser ConfigCLIParser) PrintHelp() {
fmt.Printf(
cliUsageText+"\n",
config.Defaults.Yes,
config.Defaults.DodosCount,
config.Defaults.RequestTimeout,
config.Defaults.Method,
config.Defaults.SkipVerify,
)
}
// CLIYesOrNoReader reads a yes or no answer from the command line.
// It prompts the user with the given message and default value,
// and returns true if the user answers "y" or "Y", and false otherwise.
// If there is an error while reading the input, it returns false.
// If the user simply presses enter without providing any input,
// it returns the default value specified by the `def` parameter.
func CLIYesOrNoReader(message string, def bool) bool {
var answer string
defaultMessage := "Y/n"
if !def {
defaultMessage = "y/N"
}
fmt.Printf("%s [%s]: ", message, defaultMessage)
if _, err := fmt.Scanln(&answer); err != nil {
if err.Error() == "unexpected newline" {
return def
}
return false
}
if answer == "" {
return def
}
return answer == "y" || answer == "Y"
}

View File

@@ -0,0 +1,759 @@
package parser
import (
"bytes"
"io"
"net/url"
"os"
"testing"
"time"
"github.com/aykhans/dodo/pkg/config"
"github.com/aykhans/dodo/pkg/types"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestNewConfigCLIParser(t *testing.T) {
t.Run("NewConfigCLIParser with valid args", func(t *testing.T) {
args := []string{"dodo", "-u", "https://example.com"}
parser := NewConfigCLIParser(args)
require.NotNil(t, parser)
assert.Equal(t, args, parser.args)
})
t.Run("NewConfigCLIParser with nil args", func(t *testing.T) {
parser := NewConfigCLIParser(nil)
require.NotNil(t, parser)
assert.Equal(t, []string{}, parser.args)
})
t.Run("NewConfigCLIParser with empty args", func(t *testing.T) {
args := []string{}
parser := NewConfigCLIParser(args)
require.NotNil(t, parser)
assert.Equal(t, args, parser.args)
})
}
func TestStringSliceArg(t *testing.T) {
t.Run("stringSliceArg String method", func(t *testing.T) {
arg := stringSliceArg{"value1", "value2", "value3"}
assert.Equal(t, "value1,value2,value3", arg.String())
})
t.Run("stringSliceArg String with empty slice", func(t *testing.T) {
arg := stringSliceArg{}
assert.Empty(t, arg.String())
})
t.Run("stringSliceArg String with single value", func(t *testing.T) {
arg := stringSliceArg{"single"}
assert.Equal(t, "single", arg.String())
})
t.Run("stringSliceArg Set method", func(t *testing.T) {
arg := &stringSliceArg{}
err := arg.Set("first")
require.NoError(t, err)
assert.Equal(t, stringSliceArg{"first"}, *arg)
err = arg.Set("second")
require.NoError(t, err)
assert.Equal(t, stringSliceArg{"first", "second"}, *arg)
})
t.Run("stringSliceArg Set with empty string", func(t *testing.T) {
arg := &stringSliceArg{}
err := arg.Set("")
require.NoError(t, err)
assert.Equal(t, stringSliceArg{""}, *arg)
})
}
func TestConfigCLIParser_Parse(t *testing.T) {
t.Run("Parse with no arguments returns ErrCLINoArgs", func(t *testing.T) {
parser := NewConfigCLIParser([]string{"dodo"})
config, err := parser.Parse()
assert.Nil(t, config)
require.ErrorIs(t, err, types.ErrCLINoArgs)
})
t.Run("Parse with unexpected arguments returns CLIUnexpectedArgsError", func(t *testing.T) {
parser := NewConfigCLIParser([]string{"dodo", "unexpected", "args"})
config, err := parser.Parse()
assert.Nil(t, config)
var cliErr types.CLIUnexpectedArgsError
require.ErrorAs(t, err, &cliErr)
assert.Equal(t, []string{"unexpected", "args"}, cliErr.Args)
})
t.Run("Parse with valid URL", func(t *testing.T) {
parser := NewConfigCLIParser([]string{"dodo", "-u", "https://example.com"})
config, err := parser.Parse()
require.NoError(t, err)
require.NotNil(t, config)
require.NotNil(t, config.URL)
assert.Equal(t, "https://example.com", config.URL.String())
})
t.Run("Parse with invalid URL returns FieldParseErrors", func(t *testing.T) {
parser := NewConfigCLIParser([]string{"dodo", "-u", "://invalid-url"})
config, err := parser.Parse()
assert.Nil(t, config)
var fieldErr types.FieldParseErrors
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) {
parser := NewConfigCLIParser([]string{"dodo", "-m", "POST"})
config, err := parser.Parse()
require.NoError(t, err)
require.NotNil(t, config)
require.NotNil(t, config.Method)
assert.Equal(t, "POST", *config.Method)
})
t.Run("Parse with yes flag", func(t *testing.T) {
parser := NewConfigCLIParser([]string{"dodo", "-y"})
config, err := parser.Parse()
require.NoError(t, err)
require.NotNil(t, config)
require.NotNil(t, config.Yes)
assert.True(t, *config.Yes)
})
t.Run("Parse with skip-verify flag", func(t *testing.T) {
parser := NewConfigCLIParser([]string{"dodo", "-skip-verify"})
config, err := parser.Parse()
require.NoError(t, err)
require.NotNil(t, config)
require.NotNil(t, config.SkipVerify)
assert.True(t, *config.SkipVerify)
})
t.Run("Parse with dodos count", func(t *testing.T) {
parser := NewConfigCLIParser([]string{"dodo", "-d", "5"})
config, err := parser.Parse()
require.NoError(t, err)
require.NotNil(t, config)
require.NotNil(t, config.DodosCount)
assert.Equal(t, uint(5), *config.DodosCount)
})
t.Run("Parse with request count", func(t *testing.T) {
parser := NewConfigCLIParser([]string{"dodo", "-r", "1000"})
config, err := parser.Parse()
require.NoError(t, err)
require.NotNil(t, config)
require.NotNil(t, config.RequestCount)
assert.Equal(t, uint(1000), *config.RequestCount)
})
t.Run("Parse with duration", func(t *testing.T) {
parser := NewConfigCLIParser([]string{"dodo", "-o", "5m"})
config, err := parser.Parse()
require.NoError(t, err)
require.NotNil(t, config)
require.NotNil(t, config.Duration)
assert.Equal(t, 5*time.Minute, *config.Duration)
})
t.Run("Parse with timeout", func(t *testing.T) {
parser := NewConfigCLIParser([]string{"dodo", "-t", "30s"})
config, err := parser.Parse()
require.NoError(t, err)
require.NotNil(t, config)
require.NotNil(t, config.Timeout)
assert.Equal(t, 30*time.Second, *config.Timeout)
})
t.Run("Parse with parameters", func(t *testing.T) {
parser := NewConfigCLIParser([]string{"dodo", "-p", "key1=value1", "-p", "key2=value2"})
config, err := parser.Parse()
require.NoError(t, err)
require.NotNil(t, config)
assert.Len(t, config.Params, 2)
assert.Equal(t, "key1", config.Params[0].Key)
assert.Equal(t, []string{"value1"}, config.Params[0].Value)
assert.Equal(t, "key2", config.Params[1].Key)
assert.Equal(t, []string{"value2"}, config.Params[1].Value)
})
t.Run("Parse with headers", func(t *testing.T) {
parser := NewConfigCLIParser([]string{"dodo", "-H", "Content-Type: application/json", "-H", "Authorization: Bearer token"})
config, err := parser.Parse()
require.NoError(t, err)
require.NotNil(t, config)
assert.Len(t, config.Headers, 2)
assert.Equal(t, "Content-Type", config.Headers[0].Key)
assert.Equal(t, []string{"application/json"}, config.Headers[0].Value)
assert.Equal(t, "Authorization", config.Headers[1].Key)
assert.Equal(t, []string{"Bearer token"}, config.Headers[1].Value)
})
t.Run("Parse with cookies", func(t *testing.T) {
parser := NewConfigCLIParser([]string{"dodo", "-c", "session=abc123", "-c", "user=john"})
config, err := parser.Parse()
require.NoError(t, err)
require.NotNil(t, config)
assert.Len(t, config.Cookies, 2)
assert.Equal(t, "session", config.Cookies[0].Key)
assert.Equal(t, []string{"abc123"}, config.Cookies[0].Value)
assert.Equal(t, "user", config.Cookies[1].Key)
assert.Equal(t, []string{"john"}, config.Cookies[1].Value)
})
t.Run("Parse with bodies", func(t *testing.T) {
parser := NewConfigCLIParser([]string{"dodo", "-b", "body1", "-b", "body2"})
config, err := parser.Parse()
require.NoError(t, err)
require.NotNil(t, config)
assert.Len(t, config.Bodies, 2)
assert.Equal(t, types.Body("body1"), config.Bodies[0])
assert.Equal(t, types.Body("body2"), config.Bodies[1])
})
t.Run("Parse with valid proxies", func(t *testing.T) {
parser := NewConfigCLIParser([]string{"dodo", "-x", "http://proxy1.example.com:8080", "-x", "socks5://proxy2.example.com:1080"})
config, err := parser.Parse()
require.NoError(t, err)
require.NotNil(t, config)
assert.Len(t, config.Proxies, 2)
assert.Equal(t, "http://proxy1.example.com:8080", config.Proxies[0].String())
assert.Equal(t, "socks5://proxy2.example.com:1080", config.Proxies[1].String())
})
t.Run("Parse with invalid proxy returns FieldParseErrors", func(t *testing.T) {
parser := NewConfigCLIParser([]string{"dodo", "-x", "://invalid-proxy"})
config, err := parser.Parse()
assert.Nil(t, config)
var fieldErr types.FieldParseErrors
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) {
parser := NewConfigCLIParser([]string{"dodo", "-x", "http://valid.example.com:8080", "-x", "://invalid"})
config, err := parser.Parse()
assert.Nil(t, config)
var fieldErr types.FieldParseErrors
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) {
parser := NewConfigCLIParser([]string{
"dodo",
"--url", "https://example.com",
"--method", "POST",
"--yes",
"--skip-verify",
"--dodos", "3",
"--requests", "500",
"--duration", "1m",
"--timeout", "10s",
})
config, err := parser.Parse()
require.NoError(t, err)
require.NotNil(t, config)
assert.Equal(t, "https://example.com", config.URL.String())
assert.Equal(t, "POST", *config.Method)
assert.True(t, *config.Yes)
assert.True(t, *config.SkipVerify)
assert.Equal(t, uint(3), *config.DodosCount)
assert.Equal(t, uint(500), *config.RequestCount)
assert.Equal(t, time.Minute, *config.Duration)
assert.Equal(t, 10*time.Second, *config.Timeout)
})
t.Run("Parse with config-file flag valid YAML", func(t *testing.T) {
parser := NewConfigCLIParser([]string{"dodo", "-f", "/path/to/config.yaml"})
config, err := parser.Parse()
require.NoError(t, err)
require.NotNil(t, config)
assert.Len(t, config.Files, 1)
})
t.Run("Parse with config-file flag using long form", func(t *testing.T) {
parser := NewConfigCLIParser([]string{"dodo", "--config-file", "/path/to/config.yml"})
config, err := parser.Parse()
require.NoError(t, err)
require.NotNil(t, config)
assert.Len(t, config.Files, 1)
})
t.Run("Parse with config-file flag invalid extension", func(t *testing.T) {
parser := NewConfigCLIParser([]string{"dodo", "-f", "/path/to/config"})
config, err := parser.Parse()
assert.Nil(t, config)
var fieldErr types.FieldParseErrors
require.ErrorAs(t, err, &fieldErr)
assert.Len(t, fieldErr.Errors, 1)
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")
})
t.Run("Parse with config-file flag unsupported file type", func(t *testing.T) {
parser := NewConfigCLIParser([]string{"dodo", "-f", "/path/to/config.json"})
config, err := parser.Parse()
assert.Nil(t, config)
var fieldErr types.FieldParseErrors
require.ErrorAs(t, err, &fieldErr)
assert.Len(t, fieldErr.Errors, 1)
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")
})
t.Run("Parse with config-file flag remote URL", func(t *testing.T) {
parser := NewConfigCLIParser([]string{"dodo", "-f", "https://example.com/config.yaml"})
config, err := parser.Parse()
require.NoError(t, err)
require.NotNil(t, config)
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) {
parser := NewConfigCLIParser([]string{
"dodo",
"-u", "https://api.example.com/test",
"-m", "PUT",
"-y",
"-skip-verify",
"-d", "10",
"-r", "2000",
"-o", "30m",
"-t", "5s",
"-p", "apikey=123",
"-H", "Content-Type: application/json",
"-c", "session=token123",
"-b", `{"data": "test"}`,
"-x", "http://proxy.example.com:3128",
"-f", "/path/to/config.yaml",
})
config, err := parser.Parse()
require.NoError(t, err)
require.NotNil(t, config)
// Verify all fields are set correctly
assert.Equal(t, "https://api.example.com/test", config.URL.String())
assert.Equal(t, "PUT", *config.Method)
assert.True(t, *config.Yes)
assert.True(t, *config.SkipVerify)
assert.Equal(t, uint(10), *config.DodosCount)
assert.Equal(t, uint(2000), *config.RequestCount)
assert.Equal(t, 30*time.Minute, *config.Duration)
assert.Equal(t, 5*time.Second, *config.Timeout)
assert.Len(t, config.Params, 1)
assert.Equal(t, "apikey", config.Params[0].Key)
assert.Len(t, config.Headers, 1)
assert.Equal(t, "Content-Type", config.Headers[0].Key)
assert.Len(t, config.Cookies, 1)
assert.Equal(t, "session", config.Cookies[0].Key)
assert.Len(t, config.Bodies, 1)
assert.Equal(t, types.Body(`{"data": "test"}`), config.Bodies[0]) //nolint:testifylint
assert.Len(t, config.Proxies, 1)
assert.Equal(t, "http://proxy.example.com:3128", config.Proxies[0].String())
assert.Len(t, config.Files, 1)
})
t.Run("Parse with multiple field parse errors", func(t *testing.T) {
parser := NewConfigCLIParser([]string{
"dodo",
"-u", "://invalid-url",
"-x", "://invalid-proxy1",
"-x", "://invalid-proxy2",
})
config, err := parser.Parse()
assert.Nil(t, config)
var fieldErr types.FieldParseErrors
require.ErrorAs(t, err, &fieldErr)
assert.Len(t, fieldErr.Errors, 3)
// Check error fields
fields := make(map[string]bool)
for _, parseErr := range fieldErr.Errors {
fields[parseErr.Field] = true
}
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]"])
})
}
func TestConfigCLIParser_PrintHelp(t *testing.T) {
t.Run("PrintHelp outputs expected content", func(t *testing.T) {
// Capture stdout
oldStdout := os.Stdout
reader, writer, _ := os.Pipe()
os.Stdout = writer
parser := NewConfigCLIParser([]string{"dodo"})
parser.PrintHelp()
// Restore stdout and read output
writer.Close()
os.Stdout = oldStdout
var buf bytes.Buffer
io.Copy(&buf, reader)
output := buf.String()
// Verify help text contains expected elements
assert.Contains(t, output, "Usage:")
assert.Contains(t, output, "dodo [flags]")
assert.Contains(t, output, "Examples:")
assert.Contains(t, output, "Flags:")
assert.Contains(t, output, "-h, -help")
assert.Contains(t, output, "-v, -version")
assert.Contains(t, output, "-u, -url")
assert.Contains(t, output, "-m, -method")
assert.Contains(t, output, "-d, -dodos")
assert.Contains(t, output, "-r, -requests")
assert.Contains(t, output, "-t, -timeout")
assert.Contains(t, output, "-b, -body")
assert.Contains(t, output, "-H, -header")
assert.Contains(t, output, "-p, -param")
assert.Contains(t, output, "-c, -cookie")
assert.Contains(t, output, "-x, -proxy")
assert.Contains(t, output, "-skip-verify")
assert.Contains(t, output, "-y, -yes")
assert.Contains(t, output, "-f, -config-file")
// Verify default values are included
assert.Contains(t, output, config.Defaults.Method)
assert.Contains(t, output, "1") // DodosCount default
assert.Contains(t, output, "10s") // RequestTimeout default
assert.Contains(t, output, "false") // Yes default
assert.Contains(t, output, "false") // SkipVerify default
})
}
func TestCLIYesOrNoReader(t *testing.T) {
t.Run("CLIYesOrNoReader with 'y' input returns true", func(t *testing.T) {
// Redirect stdin
oldStdin := os.Stdin
reader, writer, _ := os.Pipe()
os.Stdin = reader
// Write input and close writer
writer.WriteString("y\n")
writer.Close()
result := CLIYesOrNoReader("Test question", false)
// Restore stdin
os.Stdin = oldStdin
assert.True(t, result)
})
t.Run("CLIYesOrNoReader with 'Y' input returns true", func(t *testing.T) {
// Redirect stdin
oldStdin := os.Stdin
reader, writer, _ := os.Pipe()
os.Stdin = reader
// Write input and close writer
writer.WriteString("Y\n")
writer.Close()
result := CLIYesOrNoReader("Test question", false)
// Restore stdin
os.Stdin = oldStdin
assert.True(t, result)
})
t.Run("CLIYesOrNoReader with 'n' input returns false", func(t *testing.T) {
// Redirect stdin
oldStdin := os.Stdin
reader, writer, _ := os.Pipe()
os.Stdin = reader
// Write input and close writer
writer.WriteString("n\n")
writer.Close()
result := CLIYesOrNoReader("Test question", true)
// Restore stdin
os.Stdin = oldStdin
assert.False(t, result)
})
t.Run("CLIYesOrNoReader with empty input returns default", func(t *testing.T) {
// Redirect stdin
oldStdin := os.Stdin
reader, writer, _ := os.Pipe()
os.Stdin = reader
// Write just newline and close writer
writer.WriteString("\n")
writer.Close()
// Test with default true
result := CLIYesOrNoReader("Test question", true)
os.Stdin = oldStdin
assert.True(t, result)
})
t.Run("CLIYesOrNoReader with empty input returns default false", func(t *testing.T) {
// Redirect stdin
oldStdin := os.Stdin
reader, writer, _ := os.Pipe()
os.Stdin = reader
// Write just newline and close writer
writer.WriteString("\n")
writer.Close()
// Test with default false
result := CLIYesOrNoReader("Test question", false)
os.Stdin = oldStdin
assert.False(t, result)
})
t.Run("CLIYesOrNoReader with other input returns false", func(t *testing.T) {
// Redirect stdin
oldStdin := os.Stdin
reader, writer, _ := os.Pipe()
os.Stdin = reader
// Write other input and close writer
writer.WriteString("maybe\n")
writer.Close()
result := CLIYesOrNoReader("Test question", true)
// Restore stdin
os.Stdin = oldStdin
assert.False(t, result)
})
t.Run("CLIYesOrNoReader message format with default true", func(t *testing.T) {
// Capture stdout to verify message format
oldStdout := os.Stdout
stdoutReader, stdoutWriter, _ := os.Pipe()
os.Stdout = stdoutWriter
// Redirect stdin
oldStdin := os.Stdin
stdinReader, stdinWriter, _ := os.Pipe()
os.Stdin = stdinReader
// Write input and close writer
stdinWriter.WriteString("y\n")
stdinWriter.Close()
CLIYesOrNoReader("Continue?", true)
// Restore stdin and stdout
os.Stdin = oldStdin
stdoutWriter.Close()
os.Stdout = oldStdout
// Read output
var buf bytes.Buffer
io.Copy(&buf, stdoutReader)
output := buf.String()
assert.Contains(t, output, "Continue? [Y/n]:")
})
t.Run("CLIYesOrNoReader message format with default false", func(t *testing.T) {
// Capture stdout to verify message format
oldStdout := os.Stdout
stdoutReader, stdoutWriter, _ := os.Pipe()
os.Stdout = stdoutWriter
// Redirect stdin
oldStdin := os.Stdin
stdinReader, stdinWriter, _ := os.Pipe()
os.Stdin = stdinReader
// Write input and close writer
stdinWriter.WriteString("n\n")
stdinWriter.Close()
CLIYesOrNoReader("Delete files?", false)
// Restore stdin and stdout
os.Stdin = oldStdin
stdoutWriter.Close()
os.Stdout = oldStdout
// Read output
var buf bytes.Buffer
io.Copy(&buf, stdoutReader)
output := buf.String()
assert.Contains(t, output, "Delete files? [y/N]:")
})
}
func TestConfigCLIParser_EdgeCases(t *testing.T) {
t.Run("Parse with zero duration", func(t *testing.T) {
parser := NewConfigCLIParser([]string{"dodo", "-o", "0s"})
config, err := parser.Parse()
require.NoError(t, err)
require.NotNil(t, config)
require.NotNil(t, config.Duration)
assert.Equal(t, time.Duration(0), *config.Duration)
})
t.Run("Parse with zero timeout", func(t *testing.T) {
parser := NewConfigCLIParser([]string{"dodo", "-t", "0s"})
config, err := parser.Parse()
require.NoError(t, err)
require.NotNil(t, config)
require.NotNil(t, config.Timeout)
assert.Equal(t, time.Duration(0), *config.Timeout)
})
t.Run("Parse with zero dodos count", func(t *testing.T) {
parser := NewConfigCLIParser([]string{"dodo", "-d", "0"})
config, err := parser.Parse()
require.NoError(t, err)
require.NotNil(t, config)
require.NotNil(t, config.DodosCount)
assert.Equal(t, uint(0), *config.DodosCount)
})
t.Run("Parse with zero request count", func(t *testing.T) {
parser := NewConfigCLIParser([]string{"dodo", "-r", "0"})
config, err := parser.Parse()
require.NoError(t, err)
require.NotNil(t, config)
require.NotNil(t, config.RequestCount)
assert.Equal(t, uint(0), *config.RequestCount)
})
t.Run("Parse with empty string values", func(t *testing.T) {
parser := NewConfigCLIParser([]string{
"dodo",
"-m", "",
"-p", "",
"-H", "",
"-c", "",
"-b", "",
})
config, err := parser.Parse()
require.NoError(t, err)
require.NotNil(t, config)
assert.Empty(t, *config.Method)
assert.Len(t, config.Params, 1)
assert.Empty(t, config.Params[0].Key)
assert.Len(t, config.Headers, 1)
assert.Empty(t, config.Headers[0].Key)
assert.Len(t, config.Cookies, 1)
assert.Empty(t, config.Cookies[0].Key)
assert.Len(t, config.Bodies, 1)
assert.Equal(t, types.Body(""), config.Bodies[0])
})
t.Run("Parse with complex URL", func(t *testing.T) {
complexURL := "https://user:pass@api.example.com:8080/v1/endpoint?param=value&other=test#fragment"
parser := NewConfigCLIParser([]string{"dodo", "-u", complexURL})
config, err := parser.Parse()
require.NoError(t, err)
require.NotNil(t, config)
require.NotNil(t, config.URL)
parsedURL, parseErr := url.Parse(complexURL)
require.NoError(t, parseErr)
assert.Equal(t, parsedURL, config.URL)
})
t.Run("Parse with repeated same flags overrides previous values", func(t *testing.T) {
parser := NewConfigCLIParser([]string{
"dodo",
"-m", "GET",
"-m", "POST", // This should override the previous
"-d", "1",
"-d", "5", // This should override the previous
})
config, err := parser.Parse()
require.NoError(t, err)
require.NotNil(t, config)
assert.Equal(t, "POST", *config.Method)
assert.Equal(t, uint(5), *config.DodosCount)
})
}

236
pkg/config/parser/env.go Normal file
View File

@@ -0,0 +1,236 @@
package parser
import (
"errors"
"fmt"
"net/url"
"os"
"time"
"github.com/aykhans/dodo/pkg/config"
"github.com/aykhans/dodo/pkg/types"
"github.com/aykhans/dodo/pkg/utils"
)
var _ IParser = ConfigENVParser{}
type ConfigENVParser struct {
envPrefix string
}
func NewConfigENVParser(envPrefix string) *ConfigENVParser {
return &ConfigENVParser{envPrefix}
}
// Parse parses env arguments into a Config object.
// It can return the following errors:
// - types.FieldParseErrors
func (parser ConfigENVParser) Parse() (*config.Config, error) {
var (
config = &config.Config{}
fieldParseErrors []types.FieldParseError
)
if configFile := parser.getEnv("CONFIG_FILE"); configFile != "" {
configFileParsed, err := types.ParseConfigFile(configFile)
_ = utils.HandleErrorOrDie(err,
utils.OnSentinelError(types.ErrConfigFileExtensionNotFound, func(err error) error {
fieldParseErrors = append(
fieldParseErrors,
*types.NewFieldParseError(
parser.getFullEnvName("CONFIG_FILE"),
configFile,
errors.New("file extension not found"),
),
)
return nil
}),
utils.OnCustomError(func(err types.RemoteConfigFileParseError) error {
fieldParseErrors = append(
fieldParseErrors,
*types.NewFieldParseError(
parser.getFullEnvName("CONFIG_FILE"),
configFile,
fmt.Errorf("parse error: %w", err),
),
)
return nil
}),
utils.OnCustomError(func(err types.UnknownConfigFileTypeError) error {
fieldParseErrors = append(
fieldParseErrors,
*types.NewFieldParseError(
parser.getFullEnvName("CONFIG_FILE"),
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)
}
}
if yes := parser.getEnv("YES"); yes != "" {
yesParsed, err := utils.ParseString[bool](yes)
if err != nil {
fieldParseErrors = append(
fieldParseErrors,
*types.NewFieldParseError(
parser.getFullEnvName("YES"),
yes,
errors.New("invalid value for boolean, expected 'true' or 'false'"),
),
)
} else {
config.Yes = &yesParsed
}
}
if skipVerify := parser.getEnv("SKIP_VERIFY"); skipVerify != "" {
skipVerifyParsed, err := utils.ParseString[bool](skipVerify)
if err != nil {
fieldParseErrors = append(
fieldParseErrors,
*types.NewFieldParseError(
parser.getFullEnvName("SKIP_VERIFY"),
skipVerify,
errors.New("invalid value for boolean, expected 'true' or 'false'"),
),
)
} else {
config.SkipVerify = &skipVerifyParsed
}
}
if method := parser.getEnv("METHOD"); method != "" {
config.Method = &method
}
if urlEnv := parser.getEnv("URL"); urlEnv != "" {
urlEnvParsed, err := url.Parse(urlEnv)
if err != nil {
fieldParseErrors = append(
fieldParseErrors,
*types.NewFieldParseError(parser.getFullEnvName("URL"), urlEnv, err),
)
} else {
config.URL = urlEnvParsed
}
}
if dodos := parser.getEnv("DODOS"); dodos != "" {
dodosParsed, err := utils.ParseString[uint](dodos)
if err != nil {
fieldParseErrors = append(
fieldParseErrors,
*types.NewFieldParseError(
parser.getFullEnvName("DODOS"),
dodos,
errors.New("invalid value for unsigned integer"),
),
)
} else {
config.DodosCount = &dodosParsed
}
}
if requests := parser.getEnv("REQUESTS"); requests != "" {
requestsParsed, err := utils.ParseString[uint](requests)
if err != nil {
fieldParseErrors = append(
fieldParseErrors,
*types.NewFieldParseError(
parser.getFullEnvName("REQUESTS"),
requests,
errors.New("invalid value for unsigned integer"),
),
)
} else {
config.RequestCount = &requestsParsed
}
}
if duration := parser.getEnv("DURATION"); duration != "" {
durationParsed, err := utils.ParseString[time.Duration](duration)
if err != nil {
fieldParseErrors = append(
fieldParseErrors,
*types.NewFieldParseError(
parser.getFullEnvName("DURATION"),
duration,
errors.New("invalid value duration, expected a duration string (e.g., '10s', '1h30m')"),
),
)
} else {
config.Duration = &durationParsed
}
}
if timeout := parser.getEnv("TIMEOUT"); timeout != "" {
timeoutParsed, err := utils.ParseString[time.Duration](timeout)
if err != nil {
fieldParseErrors = append(
fieldParseErrors,
*types.NewFieldParseError(
parser.getFullEnvName("TIMEOUT"),
timeout,
errors.New("invalid value duration, expected a duration string (e.g., '10s', '1h30m')"),
),
)
} else {
config.Timeout = &timeoutParsed
}
}
if param := parser.getEnv("PARAM"); param != "" {
config.Params.Parse(param)
}
if header := parser.getEnv("HEADER"); header != "" {
config.Headers.Parse(header)
}
if cookie := parser.getEnv("COOKIE"); cookie != "" {
config.Cookies.Parse(cookie)
}
if body := parser.getEnv("BODY"); body != "" {
config.Bodies.Parse(body)
}
if proxy := parser.getEnv("PROXY"); proxy != "" {
err := config.Proxies.Parse(proxy)
if err != nil {
fieldParseErrors = append(
fieldParseErrors,
*types.NewFieldParseError(
parser.getFullEnvName("PROXY"),
proxy,
err,
),
)
}
}
if len(fieldParseErrors) > 0 {
return nil, types.NewFieldParseErrors(fieldParseErrors)
}
return config, nil
}
func (parser ConfigENVParser) getFullEnvName(envName string) string {
if parser.envPrefix == "" {
return envName
}
return parser.envPrefix + "_" + envName
}
func (parser ConfigENVParser) getEnv(envName string) string {
return os.Getenv(parser.getFullEnvName(envName))
}

File diff suppressed because it is too large Load Diff