add config file parser

This commit is contained in:
2025-09-06 23:39:10 +04:00
parent c3292dee5f
commit 5cc13cfe7e
15 changed files with 1517 additions and 90 deletions

View File

@@ -36,7 +36,6 @@ linters:
- embeddedstructfieldcheck
- errchkjson
- errorlint
- exhaustive
- exptostd
- fatcontext
- forcetypeassert
@@ -66,7 +65,6 @@ linters:
- nilerr
- nilnesserr
- nilnil
- noctx
- nonamedreturns
- nosprintfhostport
- perfsprint
@@ -99,6 +97,7 @@ linters:
varnamelen:
ignore-decls:
- i int
- w http.ResponseWriter
exclusions:
rules:

View File

@@ -1,61 +1,11 @@
package main
import (
"fmt"
"os"
"github.com/aykhans/dodo/pkg/config/parser"
"github.com/aykhans/dodo/pkg/types"
"github.com/aykhans/dodo/pkg/utils"
"github.com/jedib0t/go-pretty/v6/text"
"github.com/aykhans/dodo/pkg/config"
"github.com/k0kubun/pp/v3"
)
func main() {
envParser := parser.NewConfigENVParser("DODO")
envConfig, err := envParser.Parse()
_ = utils.HandleErrorOrDie(err,
utils.OnCustomError(func(err types.FieldParseErrors) error {
printValidationErrors("ENV", err.Errors...)
fmt.Println()
os.Exit(1)
return nil
}),
)
cliParser := parser.NewConfigCLIParser(os.Args)
cliConf, err := cliParser.Parse()
_ = utils.HandleErrorOrDie(err,
utils.OnSentinelError(types.ErrCLINoArgs, func(err error) error {
cliParser.PrintHelp()
utils.PrintErrAndExit(text.FgRed, 1, "\nNo arguments provided.")
return nil
}),
utils.OnCustomError(func(err types.CLIUnexpectedArgsError) error {
cliParser.PrintHelp()
utils.PrintErrAndExit(text.FgRed, 1, "\nUnexpected CLI arguments provided: %v", err.Args)
return nil
}),
utils.OnCustomError(func(err types.FieldParseErrors) error {
cliParser.PrintHelp()
fmt.Println()
printValidationErrors("CLI", err.Errors...)
fmt.Println()
os.Exit(1)
return nil
}),
)
envConfig.Merge(cliConf)
pp.Println(cliConf) //nolint
pp.Println(envConfig) //nolint
}
func printValidationErrors(parserName string, errors ...types.FieldParseError) {
for _, fieldErr := range errors {
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)
}
cfg := config.ReadAllConfigs()
pp.Println(cfg) //nolint
}

1
go.mod
View File

@@ -6,6 +6,7 @@ require (
github.com/jedib0t/go-pretty/v6 v6.6.8
github.com/k0kubun/pp/v3 v3.5.0
github.com/stretchr/testify v1.10.0
go.yaml.in/yaml/v4 v4.0.0-rc.2
)
require (

2
go.sum
View File

@@ -17,6 +17,8 @@ github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
go.yaml.in/yaml/v4 v4.0.0-rc.2 h1:/FrI8D64VSr4HtGIlUtlFMGsm7H7pWTbj6vOLVZcA6s=
go.yaml.in/yaml/v4 v4.0.0-rc.2/go.mod h1:aZqd9kCMsGL7AuUv/m/PvWLdg5sjJsZ4oHDEnfPPfY0=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc=
golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=

View File

@@ -1,4 +1,4 @@
package parser
package config
import (
"flag"
@@ -7,7 +7,6 @@ import (
"strings"
"time"
"github.com/aykhans/dodo/pkg/config"
"github.com/aykhans/dodo/pkg/types"
"github.com/aykhans/dodo/pkg/utils"
)
@@ -81,13 +80,13 @@ func (arg *stringSliceArg) Set(value string) error {
// - types.ErrCLINoArgs
// - types.CLIUnexpectedArgsError
// - types.FieldParseErrors
func (parser ConfigCLIParser) Parse() (*config.Config, error) {
func (parser ConfigCLIParser) Parse() (*Config, error) {
flagSet := flag.NewFlagSet("dodo", flag.ExitOnError)
flagSet.Usage = func() { parser.PrintHelp() }
var (
config = &config.Config{}
config = &Config{}
configFiles = stringSliceArg{}
yes bool
skipVerify bool
@@ -242,11 +241,11 @@ func (parser ConfigCLIParser) Parse() (*config.Config, error) {
func (parser ConfigCLIParser) PrintHelp() {
fmt.Printf(
cliUsageText+"\n",
config.Defaults.Yes,
config.Defaults.DodosCount,
config.Defaults.RequestTimeout,
config.Defaults.Method,
config.Defaults.SkipVerify,
Defaults.Yes,
Defaults.DodosCount,
Defaults.RequestTimeout,
Defaults.Method,
Defaults.SkipVerify,
)
}

View File

@@ -1,4 +1,4 @@
package parser
package config
import (
"bytes"
@@ -8,7 +8,6 @@ import (
"testing"
"time"
"github.com/aykhans/dodo/pkg/config"
"github.com/aykhans/dodo/pkg/types"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
@@ -479,7 +478,7 @@ func TestConfigCLIParser_PrintHelp(t *testing.T) {
assert.Contains(t, output, "-f, -config-file")
// Verify default values are included
assert.Contains(t, output, config.Defaults.Method)
assert.Contains(t, output, Defaults.Method)
assert.Contains(t, output, "1") // DodosCount default
assert.Contains(t, output, "10s") // RequestTimeout default
assert.Contains(t, output, "false") // Yes default

View File

@@ -1,11 +1,14 @@
package config
import (
"fmt"
"net/url"
"os"
"time"
"github.com/aykhans/dodo/pkg/types"
"github.com/aykhans/dodo/pkg/utils"
"github.com/jedib0t/go-pretty/v6/text"
)
const VERSION string = "1.0.0"
@@ -26,7 +29,11 @@ var Defaults = struct {
SkipVerify: false,
}
var SupportedProxySchemes []string = []string{"http", "socks5", "socks5h"}
var SupportedProxySchemes = []string{"http", "socks5", "socks5h"}
type IParser interface {
Parse() (*Config, error)
}
type Config struct {
Files []types.ConfigFile
@@ -112,3 +119,102 @@ func (config *Config) SetDefaults() {
config.Headers = append(config.Headers, types.Header{Key: "User-Agent", Value: []string{Defaults.UserAgent}})
}
}
func ReadAllConfigs() *Config {
envParser := NewConfigENVParser("DODO")
envConfig, err := envParser.Parse()
_ = utils.HandleErrorOrDie(err,
utils.OnCustomError(func(err types.FieldParseErrors) error {
printValidationErrors("ENV", err.Errors...)
fmt.Println()
os.Exit(1)
return nil
}),
)
cliParser := NewConfigCLIParser(os.Args)
cliConf, err := cliParser.Parse()
_ = utils.HandleErrorOrDie(err,
utils.OnSentinelError(types.ErrCLINoArgs, func(err error) error {
cliParser.PrintHelp()
utils.PrintErrAndExit(text.FgYellow, 1, "\nNo arguments provided.")
return nil
}),
utils.OnCustomError(func(err types.CLIUnexpectedArgsError) error {
cliParser.PrintHelp()
utils.PrintErrAndExit(text.FgYellow, 1, "\nUnexpected CLI arguments provided: %v", err.Args)
return nil
}),
utils.OnCustomError(func(err types.FieldParseErrors) error {
cliParser.PrintHelp()
fmt.Println()
printValidationErrors("CLI", err.Errors...)
os.Exit(1)
return nil
}),
)
envConfig.Merge(cliConf)
for _, configFile := range envConfig.Files {
fileConfig, err := parseConfigFile(configFile, 10)
_ = utils.HandleErrorOrDie(err,
utils.OnCustomError(func(err types.ConfigFileReadError) error {
cliParser.PrintHelp()
utils.PrintErrAndExit(text.FgYellow, 1, "\nFailed to read config file '%s': %v", configFile.Path(), err)
return nil
}),
utils.OnCustomError(func(err types.UnmarshalError) error {
utils.PrintErrAndExit(text.FgYellow, 1, "\nFailed to unmarshal config file '%s': %v", configFile.Path(), err)
return nil
}),
utils.OnCustomError(func(err types.FieldParseErrors) error {
printValidationErrors(fmt.Sprintf("CONFIG FILE '%s'", configFile.Path()), err.Errors...)
os.Exit(1)
return nil
}),
)
envConfig.Merge(fileConfig)
}
return envConfig
}
// parseConfigFile recursively parses a config file and its nested files up to maxDepth levels.
// Returns the merged configuration or an error if parsing fails.
// It can return the following errors:
// - types.ConfigFileReadError
// - types.UnmarshalError
// - types.FieldParseErrors
func parseConfigFile(configFile types.ConfigFile, maxDepth int) (*Config, error) {
configFileParser := NewConfigFileParser(configFile)
fileConfig, err := configFileParser.Parse()
if err != nil {
return nil, err
}
if maxDepth <= 0 {
return fileConfig, nil
}
for _, c := range fileConfig.Files {
innerFileConfig, err := parseConfigFile(c, maxDepth-1)
if err != nil {
return nil, err
}
fileConfig.Merge(innerFileConfig)
}
return fileConfig, nil
}
func printValidationErrors(parserName string, errors ...types.FieldParseError) {
for _, fieldErr := range errors {
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

@@ -1,6 +1,7 @@
package config
import (
"errors"
"net/url"
"testing"
"time"
@@ -525,3 +526,83 @@ func TestMergeConfig_AppendBehavior(t *testing.T) {
assert.Len(t, config.Proxies, originalProxies, "Empty proxies should not change existing proxies")
})
}
func TestParseConfigFile(t *testing.T) {
t.Run("parseConfigFile with maxDepth 0", func(t *testing.T) {
// Create a mock config file
configFile, _ := types.ParseConfigFile("test.yaml")
// Since we can't actually test file reading without a real file,
// we'll test the function's behavior with maxDepth
config, err := parseConfigFile(*configFile, 0)
// The function will return an error because the file doesn't exist
require.Error(t, err)
assert.Nil(t, config)
})
t.Run("parseConfigFile returns ConfigFileReadError", func(t *testing.T) {
configFile, _ := types.ParseConfigFile("/nonexistent/file.yaml")
config, err := parseConfigFile(*configFile, 1)
require.Error(t, err)
assert.Nil(t, config)
// Check if error is of type ConfigFileReadError
var readErr types.ConfigFileReadError
assert.ErrorAs(t, err, &readErr)
})
}
func TestPrintValidationErrors(t *testing.T) {
t.Run("printValidationErrors with empty value", func(t *testing.T) {
// This function prints to stdout, so we can't easily test its output
// But we can test that it doesn't panic
errors := []types.FieldParseError{
{
Field: "test_field",
Value: "",
Err: errors.New("test error"),
},
}
// Should not panic
assert.NotPanics(t, func() {
printValidationErrors("TEST", errors...)
})
})
t.Run("printValidationErrors with value", func(t *testing.T) {
errors := []types.FieldParseError{
{
Field: "test_field",
Value: "test_value",
Err: errors.New("test error"),
},
}
// Should not panic
assert.NotPanics(t, func() {
printValidationErrors("TEST", errors...)
})
})
t.Run("printValidationErrors with multiple errors", func(t *testing.T) {
errors := []types.FieldParseError{
{
Field: "field1",
Value: "value1",
Err: errors.New("error1"),
},
{
Field: "field2",
Value: "",
Err: errors.New("error2"),
},
}
// Should not panic
assert.NotPanics(t, func() {
printValidationErrors("TEST", errors...)
})
})
}

View File

@@ -1,4 +1,4 @@
package parser
package config
import (
"errors"
@@ -7,7 +7,6 @@ import (
"os"
"time"
"github.com/aykhans/dodo/pkg/config"
"github.com/aykhans/dodo/pkg/types"
"github.com/aykhans/dodo/pkg/utils"
)
@@ -25,9 +24,9 @@ func NewConfigENVParser(envPrefix string) *ConfigENVParser {
// Parse parses env arguments into a Config object.
// It can return the following errors:
// - types.FieldParseErrors
func (parser ConfigENVParser) Parse() (*config.Config, error) {
func (parser ConfigENVParser) Parse() (*Config, error) {
var (
config = &config.Config{}
config = &Config{}
fieldParseErrors []types.FieldParseError
)

View File

@@ -1,4 +1,4 @@
package parser
package config
import (
"net/url"

184
pkg/config/file.go Normal file
View File

@@ -0,0 +1,184 @@
package config
import (
"errors"
"fmt"
"io"
"net/http"
"net/url"
"os"
"time"
"github.com/aykhans/dodo/pkg/types"
"github.com/aykhans/dodo/pkg/utils"
"go.yaml.in/yaml/v4"
)
var _ IParser = ConfigFileParser{}
type ConfigFileParser struct {
configFile types.ConfigFile
}
func NewConfigFileParser(configFile types.ConfigFile) *ConfigFileParser {
return &ConfigFileParser{configFile}
}
// Parse parses config file arguments into a Config object.
// It can return the following errors:
// - types.ConfigFileReadError
// - types.UnmarshalError
// - types.FieldParseErrors
func (parser ConfigFileParser) Parse() (*Config, error) {
var err error
var configFileData []byte
switch parser.configFile.LocationType() {
case types.ConfigFileLocationLocal:
configFileData, err = os.ReadFile(parser.configFile.Path())
if err != nil {
return nil, types.NewConfigFileReadError(err)
}
case types.ConfigFileLocationRemote:
resp, err := http.Get(parser.configFile.Path())
if err != nil {
return nil, types.NewConfigFileReadError(err)
}
defer resp.Body.Close() //nolint:errcheck
if resp.StatusCode != http.StatusOK {
return nil, types.NewConfigFileReadError(errors.New("failed to retrieve remote config file: " + resp.Status))
}
configFileData, err = io.ReadAll(resp.Body)
if err != nil {
return nil, types.NewConfigFileReadError(err)
}
default:
panic("unhandled config file location type")
}
switch parser.configFile.Type() {
case types.ConfigFileTypeYAML, types.ConfigFileTypeUnknown:
return parser.ParseYAML(configFileData)
default:
panic("unhandled config file type")
}
}
type stringOrSliceField []string
func (ss *stringOrSliceField) UnmarshalYAML(node *yaml.Node) error {
switch node.Kind {
case yaml.ScalarNode:
// Handle single string value
*ss = []string{node.Value}
return nil
case yaml.SequenceNode:
// Handle array of strings
var slice []string
if err := node.Decode(&slice); err != nil {
return err //nolint:wrapcheck
}
*ss = slice
return nil
default:
return fmt.Errorf("expected a string or a sequence of strings, but got %v", node.Kind)
}
}
type configYAML struct {
Files stringOrSliceField `yaml:"files"`
Method *string `yaml:"method"`
URL *string `yaml:"url"`
Timeout *time.Duration `yaml:"timeout"`
DodosCount *uint `yaml:"dodos"`
RequestCount *uint `yaml:"requests"`
Duration *time.Duration `yaml:"duration"`
Yes *bool `yaml:"yes"`
SkipVerify *bool `yaml:"skipVerify"`
Params stringOrSliceField `yaml:"params"`
Headers stringOrSliceField `yaml:"headers"`
Cookies stringOrSliceField `yaml:"cookies"`
Bodies stringOrSliceField `yaml:"body"`
Proxies stringOrSliceField `yaml:"proxy"`
}
// ParseYAML parses YAML config file arguments into a Config object.
// It can return the following errors:
// - types.UnmarshalError
// - types.FieldParseErrors
func (parser ConfigFileParser) ParseYAML(data []byte) (*Config, error) {
var (
config = &Config{}
parsedData = &configYAML{}
)
err := yaml.Unmarshal(data, &parsedData)
if err != nil {
return nil, types.NewUnmarshalError(err)
}
var fieldParseErrors []types.FieldParseError
config.Method = parsedData.Method
config.Timeout = parsedData.Timeout
config.DodosCount = parsedData.DodosCount
config.RequestCount = parsedData.RequestCount
config.Duration = parsedData.Duration
config.Yes = parsedData.Yes
config.SkipVerify = parsedData.SkipVerify
config.Params.Parse(parsedData.Params...)
config.Headers.Parse(parsedData.Headers...)
config.Cookies.Parse(parsedData.Cookies...)
config.Bodies.Parse(parsedData.Bodies...)
if len(parsedData.Files) > 0 {
for i, configFile := range parsedData.Files {
configFileParsed, err := types.ParseConfigFile(configFile)
_ = utils.HandleErrorOrDie(err,
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
}),
)
if err == nil {
config.Files = append(config.Files, *configFileParsed)
}
}
}
if parsedData.URL != nil {
urlParsed, err := url.Parse(*parsedData.URL)
if err != nil {
fieldParseErrors = append(fieldParseErrors, *types.NewFieldParseError("url", *parsedData.URL, err))
} else {
config.URL = urlParsed
}
}
for i, proxy := range parsedData.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
}

1060
pkg/config/file_test.go Normal file

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -63,6 +63,25 @@ func (e FieldParseErrors) Error() string {
return errorString
}
type UnmarshalError struct {
error error
}
func NewUnmarshalError(err error) UnmarshalError {
if err == nil {
err = ErrNoError
}
return UnmarshalError{err}
}
func (e UnmarshalError) Error() string {
return "Unmarshal error: " + e.error.Error()
}
func (e UnmarshalError) Unwrap() error {
return e.error
}
// ======================================== CLI ========================================
type CLIUnexpectedArgsError struct {

View File

@@ -205,20 +205,50 @@ func TestNewRemoteConfigFileParseError(t *testing.T) {
})
}
func TestErrorConstants(t *testing.T) {
t.Run("ErrNoError has correct message", func(t *testing.T) {
expected := "no error (internal)"
assert.Equal(t, expected, ErrNoError.Error())
func TestUnmarshalError_Error(t *testing.T) {
t.Run("Error returns formatted message", func(t *testing.T) {
originalErr := errors.New("yaml parsing failed")
err := NewUnmarshalError(originalErr)
expected := "Unmarshal error: yaml parsing failed"
assert.Equal(t, expected, err.Error())
})
t.Run("ErrCLINoArgs has correct message", func(t *testing.T) {
expected := "CLI expects arguments but received none"
assert.Equal(t, expected, ErrCLINoArgs.Error())
t.Run("Error with nil underlying error", func(t *testing.T) {
err := NewUnmarshalError(nil)
expected := "Unmarshal error: no error (internal)"
assert.Equal(t, expected, err.Error())
})
}
func TestUnmarshalError_Unwrap(t *testing.T) {
t.Run("Unwrap returns original error", func(t *testing.T) {
originalErr := errors.New("original error")
err := NewUnmarshalError(originalErr)
assert.Equal(t, originalErr, err.Unwrap())
})
t.Run("ErrCLIUnexpectedArgs has correct message", func(t *testing.T) {
expected := "CLI received unexpected arguments"
assert.Equal(t, expected, ErrCLIUnexpectedArgs.Error())
t.Run("Unwrap with nil error", func(t *testing.T) {
err := NewUnmarshalError(nil)
assert.Equal(t, ErrNoError, err.Unwrap())
})
}
func TestNewUnmarshalError(t *testing.T) {
t.Run("Creates UnmarshalError with correct values", func(t *testing.T) {
originalErr := errors.New("test error")
err := NewUnmarshalError(originalErr)
assert.Equal(t, originalErr, err.error)
})
t.Run("Creates UnmarshalError with ErrNoError when nil passed", func(t *testing.T) {
err := NewUnmarshalError(nil)
assert.Equal(t, ErrNoError, err.error)
})
}
@@ -242,4 +272,9 @@ func TestErrorImplementsErrorInterface(t *testing.T) {
var err error = NewRemoteConfigFileParseError(errors.New("test"))
assert.Error(t, err)
})
t.Run("UnmarshalError implements error interface", func(t *testing.T) {
var err error = NewUnmarshalError(errors.New("test"))
assert.Error(t, err)
})
}