v1.0.0: here we go again

This commit is contained in:
2026-01-10 17:06:25 +04:00
parent 25d4762a3c
commit 2d7ba34cb8
68 changed files with 6805 additions and 4548 deletions

285
internal/config/cli.go Normal file
View File

@@ -0,0 +1,285 @@
package config
import (
"flag"
"fmt"
"net/url"
"os"
"strings"
"time"
"go.aykhans.me/sarin/internal/types"
versionpkg "go.aykhans.me/sarin/internal/version"
"go.aykhans.me/utils/common"
)
const cliUsageText = `Usage:
sarin [flags]
Simple usage:
sarin -U https://example.com -d 1m
Usage with all flags:
sarin -s -q -z -o json -f ./config.yaml -c 50 -r 100_000 -d 2m30s \
-U https://example.com \
-M POST \
-V "sharedUUID={{ fakeit_UUID }}" \
-B '{"product": "car"}' \
-P "id={{ .Values.sharedUUID }}" \
-H "User-Agent: {{ fakeit_UserAgent }}" -H "Accept: */*" \
-C "token={{ .Values.sharedUUID }}" \
-X "http://proxy.example.com" \
-T 3s \
-I
Flags:
General Config:
-h, -help Help for sarin
-v, -version Version for sarin
-s, -show-config bool Show the final config after parsing all sources (default %v)
-f, -config-file string Path to the config file (local file / http URL)
-c, -concurrency uint Number of concurrent requests (default %d)
-r, -requests uint Number of total requests
-d, -duration time Maximum duration for the test (e.g. 30s, 1m, 5h)
-q, -quiet bool Hide the progress bar and runtime logs (default %v)
-o, -output string Output format (possible values: table, json, yaml, none) (default '%v')
-z, -dry-run bool Run without sending requests (default %v)
Request Config:
-U, -url string Target URL for the request
-M, -method []string HTTP method for the request (default %s)
-B, -body []string Body for the request (e.g. "body text")
-P, -param []string URL 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")
-V, -values []string List of values for templating (e.g. "key1=value1")
-T, -timeout time Timeout for the request (e.g. 400ms, 3s, 1m10s) (default %v)
-I, -insecure 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, error) {
flagSet := flag.NewFlagSet("sarin", flag.ExitOnError)
flagSet.Usage = func() { parser.PrintHelp() }
var (
config = &Config{}
// General config
version bool
showConfig bool
configFiles = stringSliceArg{}
concurrency uint
requestCount uint64
duration time.Duration
quiet bool
output string
dryRun bool
// Request config
urlInput string
methods = stringSliceArg{}
bodies = stringSliceArg{}
params = stringSliceArg{}
headers = stringSliceArg{}
cookies = stringSliceArg{}
proxies = stringSliceArg{}
values = stringSliceArg{}
timeout time.Duration
insecure bool
)
{
// General config
flagSet.BoolVar(&version, "version", false, "Version for sarin")
flagSet.BoolVar(&version, "v", false, "Version for sarin")
flagSet.BoolVar(&showConfig, "show-config", false, "Show the final config after parsing all sources")
flagSet.BoolVar(&showConfig, "s", false, "Show the final config after parsing all sources")
flagSet.Var(&configFiles, "config-file", "Path to the config file")
flagSet.Var(&configFiles, "f", "Path to the config file")
flagSet.UintVar(&concurrency, "concurrency", 0, "Number of concurrent requests")
flagSet.UintVar(&concurrency, "c", 0, "Number of concurrent requests")
flagSet.Uint64Var(&requestCount, "requests", 0, "Number of total requests")
flagSet.Uint64Var(&requestCount, "r", 0, "Number of total requests")
flagSet.DurationVar(&duration, "duration", 0, "Maximum duration for the test")
flagSet.DurationVar(&duration, "d", 0, "Maximum duration for the test")
flagSet.BoolVar(&quiet, "quiet", false, "Hide the progress bar and runtime logs")
flagSet.BoolVar(&quiet, "q", false, "Hide the progress bar and runtime logs")
flagSet.StringVar(&output, "output", "", "Output format (possible values: table, json, yaml, none)")
flagSet.StringVar(&output, "o", "", "Output format (possible values: table, json, yaml, none)")
flagSet.BoolVar(&dryRun, "dry-run", false, "Run without sending requests")
flagSet.BoolVar(&dryRun, "z", false, "Run without sending requests")
// Request config
flagSet.StringVar(&urlInput, "url", "", "Target URL for the request")
flagSet.StringVar(&urlInput, "U", "", "Target URL for the request")
flagSet.Var(&methods, "method", "HTTP method for the request")
flagSet.Var(&methods, "M", "HTTP method for the request")
flagSet.Var(&bodies, "body", "Body for the request")
flagSet.Var(&bodies, "B", "Body for the request")
flagSet.Var(&params, "param", "URL parameter for the request")
flagSet.Var(&params, "P", "URL parameter for the request")
flagSet.Var(&headers, "header", "Header for the request")
flagSet.Var(&headers, "H", "Header for the request")
flagSet.Var(&cookies, "cookie", "Cookie for the request")
flagSet.Var(&cookies, "C", "Cookie for the request")
flagSet.Var(&proxies, "proxy", "Proxy for the request")
flagSet.Var(&proxies, "X", "Proxy for the request")
flagSet.Var(&values, "values", "List of values for templating")
flagSet.Var(&values, "V", "List of values for templating")
flagSet.DurationVar(&timeout, "timeout", 0, "Timeout for the request (e.g. 400ms, 15s, 1m10s)")
flagSet.DurationVar(&timeout, "T", 0, "Timeout for the request (e.g. 400ms, 15s, 1m10s)")
flagSet.BoolVar(&insecure, "insecure", false, "Skip SSL/TLS certificate verification")
flagSet.BoolVar(&insecure, "I", false, "Skip SSL/TLS certificate verification")
}
// 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 `sarin` 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)
}
if version {
fmt.Printf("Version: %s\nGit Commit: %s\nBuild Date: %s\nGo Version: %s\n",
versionpkg.Version, versionpkg.GitCommit, versionpkg.BuildDate, versionpkg.GoVersion)
os.Exit(0)
}
var fieldParseErrors []types.FieldParseError
// Iterate over flags that were explicitly set on the command line.
flagSet.Visit(func(flagVar *flag.Flag) {
switch flagVar.Name {
// General config
case "show-config", "s":
config.ShowConfig = common.ToPtr(showConfig)
case "config-file", "f":
for _, configFile := range configFiles {
config.Files = append(config.Files, *types.ParseConfigFile(configFile))
}
case "concurrency", "c":
config.Concurrency = common.ToPtr(concurrency)
case "requests", "r":
config.Requests = common.ToPtr(requestCount)
case "duration", "d":
config.Duration = common.ToPtr(duration)
case "quiet", "q":
config.Quiet = common.ToPtr(quiet)
case "output", "o":
config.Output = common.ToPtr(ConfigOutputType(output))
case "dry-run", "z":
config.DryRun = common.ToPtr(dryRun)
// Request config
case "url", "U":
urlParsed, err := url.Parse(urlInput)
if err != nil {
fieldParseErrors = append(fieldParseErrors, types.NewFieldParseError("url", urlInput, err))
} else {
config.URL = urlParsed
}
case "method", "M":
config.Methods = append(config.Methods, methods...)
case "body", "B":
config.Bodies = append(config.Bodies, bodies...)
case "param", "P":
config.Params.Parse(params...)
case "header", "H":
config.Headers.Parse(headers...)
case "cookie", "C":
config.Cookies.Parse(cookies...)
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),
)
}
}
case "values", "V":
config.Values = append(config.Values, values...)
case "timeout", "T":
config.Timeout = common.ToPtr(timeout)
case "insecure", "I":
config.Insecure = common.ToPtr(insecure)
}
})
if len(fieldParseErrors) > 0 {
return nil, types.NewFieldParseErrors(fieldParseErrors)
}
return config, nil
}
func (parser ConfigCLIParser) PrintHelp() {
fmt.Printf(
cliUsageText+"\n",
Defaults.ShowConfig,
Defaults.Concurrency,
Defaults.Quiet,
Defaults.Output,
Defaults.DryRun,
Defaults.Method,
Defaults.RequestTimeout,
Defaults.Insecure,
)
}

757
internal/config/config.go Normal file
View File

@@ -0,0 +1,757 @@
package config
import (
"errors"
"fmt"
"net/url"
"os"
"slices"
"strconv"
"strings"
"time"
"github.com/charmbracelet/bubbles/viewport"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/glamour"
"github.com/charmbracelet/glamour/styles"
"github.com/charmbracelet/lipgloss"
"github.com/charmbracelet/x/term"
"go.aykhans.me/sarin/internal/types"
"go.aykhans.me/sarin/internal/version"
"go.aykhans.me/utils/common"
utilsErr "go.aykhans.me/utils/errors"
"go.yaml.in/yaml/v4"
)
var Defaults = struct {
UserAgent string
Method string
RequestTimeout time.Duration
Concurrency uint
ShowConfig bool
Quiet bool
Insecure bool
Output ConfigOutputType
DryRun bool
}{
UserAgent: "Sarin/" + version.Version,
Method: "GET",
RequestTimeout: time.Second * 10,
Concurrency: 1,
ShowConfig: false,
Quiet: false,
Insecure: false,
Output: ConfigOutputTypeTable,
DryRun: false,
}
var (
ValidProxySchemes = []string{"http", "https", "socks5", "socks5h"}
ValidRequestURLSchemes = []string{"http", "https"}
)
var (
StyleYellow = lipgloss.NewStyle().Foreground(lipgloss.Color("220"))
StyleRed = lipgloss.NewStyle().Foreground(lipgloss.Color("196"))
)
type IParser interface {
Parse() (*Config, error)
}
type ConfigOutputType string
var (
ConfigOutputTypeTable ConfigOutputType = "table"
ConfigOutputTypeJSON ConfigOutputType = "json"
ConfigOutputTypeYAML ConfigOutputType = "yaml"
ConfigOutputTypeNone ConfigOutputType = "none"
)
type Config struct {
ShowConfig *bool `yaml:"showConfig,omitempty"`
Files []types.ConfigFile `yaml:"files,omitempty"`
Methods []string `yaml:"methods,omitempty"`
URL *url.URL `yaml:"url,omitempty"`
Timeout *time.Duration `yaml:"timeout,omitempty"`
Concurrency *uint `yaml:"concurrency,omitempty"`
Requests *uint64 `yaml:"requests,omitempty"`
Duration *time.Duration `yaml:"duration,omitempty"`
Quiet *bool `yaml:"quiet,omitempty"`
Output *ConfigOutputType `yaml:"output,omitempty"`
Insecure *bool `yaml:"insecure,omitempty"`
DryRun *bool `yaml:"dryRun,omitempty"`
Params types.Params `yaml:"params,omitempty"`
Headers types.Headers `yaml:"headers,omitempty"`
Cookies types.Cookies `yaml:"cookies,omitempty"`
Bodies []string `yaml:"bodies,omitempty"`
Proxies types.Proxies `yaml:"proxies,omitempty"`
Values []string `yaml:"values,omitempty"`
}
func NewConfig() *Config {
return &Config{}
}
func (config Config) MarshalYAML() (any, error) {
const randomValueComment = "Cycles through all values, with a new random start each round"
toNode := func(v any) *yaml.Node {
node := &yaml.Node{}
_ = node.Encode(v)
return node
}
addField := func(content *[]*yaml.Node, key string, value *yaml.Node, comment string) {
if value.Kind == 0 || (value.Kind == yaml.ScalarNode && value.Value == "") ||
(value.Kind == yaml.SequenceNode && len(value.Content) == 0) {
return
}
keyNode := &yaml.Node{Kind: yaml.ScalarNode, Value: key, LineComment: comment}
*content = append(*content, keyNode, value)
}
addStringSlice := func(content *[]*yaml.Node, key string, items []string, withComment bool) {
comment := ""
if withComment && len(items) > 1 {
comment = randomValueComment
}
switch len(items) {
case 1:
addField(content, key, toNode(items[0]), "")
default:
addField(content, key, toNode(items), comment)
}
}
marshalKeyValues := func(items []types.KeyValue[string, []string]) *yaml.Node {
seqNode := &yaml.Node{Kind: yaml.SequenceNode}
for _, item := range items {
keyNode := &yaml.Node{Kind: yaml.ScalarNode, Value: item.Key}
var valueNode *yaml.Node
switch len(item.Value) {
case 1:
valueNode = &yaml.Node{Kind: yaml.ScalarNode, Value: item.Value[0]}
default:
valueNode = &yaml.Node{Kind: yaml.SequenceNode}
for _, v := range item.Value {
valueNode.Content = append(valueNode.Content, &yaml.Node{Kind: yaml.ScalarNode, Value: v})
}
if len(item.Value) > 1 {
keyNode.LineComment = randomValueComment
}
}
mapNode := &yaml.Node{Kind: yaml.MappingNode, Content: []*yaml.Node{keyNode, valueNode}}
seqNode.Content = append(seqNode.Content, mapNode)
}
return seqNode
}
root := &yaml.Node{Kind: yaml.MappingNode}
content := &root.Content
if config.ShowConfig != nil {
addField(content, "showConfig", toNode(*config.ShowConfig), "")
}
addStringSlice(content, "method", config.Methods, true)
if config.URL != nil {
addField(content, "url", toNode(config.URL.String()), "")
}
if config.Timeout != nil {
addField(content, "timeout", toNode(*config.Timeout), "")
}
if config.Concurrency != nil {
addField(content, "concurrency", toNode(*config.Concurrency), "")
}
if config.Requests != nil {
addField(content, "requests", toNode(*config.Requests), "")
}
if config.Duration != nil {
addField(content, "duration", toNode(*config.Duration), "")
}
if config.Quiet != nil {
addField(content, "quiet", toNode(*config.Quiet), "")
}
if config.Output != nil {
addField(content, "output", toNode(string(*config.Output)), "")
}
if config.Insecure != nil {
addField(content, "insecure", toNode(*config.Insecure), "")
}
if config.DryRun != nil {
addField(content, "dryRun", toNode(*config.DryRun), "")
}
if len(config.Params) > 0 {
items := make([]types.KeyValue[string, []string], len(config.Params))
for i, p := range config.Params {
items[i] = types.KeyValue[string, []string](p)
}
addField(content, "params", marshalKeyValues(items), "")
}
if len(config.Headers) > 0 {
items := make([]types.KeyValue[string, []string], len(config.Headers))
for i, h := range config.Headers {
items[i] = types.KeyValue[string, []string](h)
}
addField(content, "headers", marshalKeyValues(items), "")
}
if len(config.Cookies) > 0 {
items := make([]types.KeyValue[string, []string], len(config.Cookies))
for i, c := range config.Cookies {
items[i] = types.KeyValue[string, []string](c)
}
addField(content, "cookies", marshalKeyValues(items), "")
}
addStringSlice(content, "body", config.Bodies, true)
if len(config.Proxies) > 0 {
proxyStrings := make([]string, len(config.Proxies))
for i, p := range config.Proxies {
proxyStrings[i] = p.String()
}
addStringSlice(content, "proxy", proxyStrings, true)
}
addStringSlice(content, "values", config.Values, false)
return root, nil
}
func (config Config) Print() bool {
configYAML, err := yaml.Marshal(config)
if err != nil {
fmt.Fprintln(os.Stderr, StyleRed.Render("Error marshaling config to yaml: "+err.Error()))
os.Exit(1)
}
// Pipe mode: output raw content directly
if !term.IsTerminal(os.Stdout.Fd()) {
fmt.Println(string(configYAML))
os.Exit(0)
}
style := styles.TokyoNightStyleConfig
style.Document.Margin = common.ToPtr[uint](0)
style.CodeBlock.Margin = common.ToPtr[uint](0)
renderer, err := glamour.NewTermRenderer(
glamour.WithStyles(style),
glamour.WithWordWrap(0),
)
if err != nil {
fmt.Fprintln(os.Stderr, StyleRed.Render(err.Error()))
os.Exit(1)
}
content, err := renderer.Render("```yaml\n" + string(configYAML) + "```")
if err != nil {
fmt.Fprintln(os.Stderr, StyleRed.Render(err.Error()))
os.Exit(1)
}
p := tea.NewProgram(
printConfigModel{content: strings.Trim(content, "\n"), rawContent: configYAML},
tea.WithAltScreen(),
tea.WithMouseCellMotion(),
)
m, err := p.Run()
if err != nil {
fmt.Fprintln(os.Stderr, StyleRed.Render(err.Error()))
os.Exit(1)
}
return m.(printConfigModel).start //nolint:forcetypeassert // m is guaranteed to be of type printConfigModel as it was the only model passed to tea.NewProgram
}
func (config *Config) Merge(newConfig *Config) {
config.Files = append(config.Files, newConfig.Files...)
if len(newConfig.Methods) > 0 {
config.Methods = append(config.Methods, newConfig.Methods...)
}
if newConfig.URL != nil {
config.URL = newConfig.URL
}
if newConfig.Timeout != nil {
config.Timeout = newConfig.Timeout
}
if newConfig.Concurrency != nil {
config.Concurrency = newConfig.Concurrency
}
if newConfig.Requests != nil {
config.Requests = newConfig.Requests
}
if newConfig.Duration != nil {
config.Duration = newConfig.Duration
}
if newConfig.ShowConfig != nil {
config.ShowConfig = newConfig.ShowConfig
}
if newConfig.Quiet != nil {
config.Quiet = newConfig.Quiet
}
if newConfig.Output != nil {
config.Output = newConfig.Output
}
if newConfig.Insecure != nil {
config.Insecure = newConfig.Insecure
}
if newConfig.DryRun != nil {
config.DryRun = newConfig.DryRun
}
if len(newConfig.Params) != 0 {
config.Params = append(config.Params, newConfig.Params...)
}
if len(newConfig.Headers) != 0 {
config.Headers = append(config.Headers, newConfig.Headers...)
}
if len(newConfig.Cookies) != 0 {
config.Cookies = append(config.Cookies, newConfig.Cookies...)
}
if len(newConfig.Bodies) != 0 {
config.Bodies = append(config.Bodies, newConfig.Bodies...)
}
if len(newConfig.Proxies) != 0 {
config.Proxies.Append(newConfig.Proxies...)
}
if len(newConfig.Values) != 0 {
config.Values = append(config.Values, newConfig.Values...)
}
}
func (config *Config) SetDefaults() {
if config.URL != nil && len(config.URL.Query()) > 0 {
urlParams := types.Params{}
for key, values := range config.URL.Query() {
for _, value := range values {
urlParams = append(urlParams, types.Param{
Key: key,
Value: []string{value},
})
}
}
config.Params = append(urlParams, config.Params...)
config.URL.RawQuery = ""
}
if len(config.Methods) == 0 {
config.Methods = []string{Defaults.Method}
}
if config.Timeout == nil {
config.Timeout = &Defaults.RequestTimeout
}
if config.Concurrency == nil {
config.Concurrency = common.ToPtr(Defaults.Concurrency)
}
if config.ShowConfig == nil {
config.ShowConfig = common.ToPtr(Defaults.ShowConfig)
}
if config.Quiet == nil {
config.Quiet = common.ToPtr(Defaults.Quiet)
}
if config.Insecure == nil {
config.Insecure = common.ToPtr(Defaults.Insecure)
}
if config.DryRun == nil {
config.DryRun = common.ToPtr(Defaults.DryRun)
}
if !config.Headers.Has("User-Agent") {
config.Headers = append(config.Headers, types.Header{Key: "User-Agent", Value: []string{Defaults.UserAgent}})
}
if config.Output == nil {
config.Output = common.ToPtr(Defaults.Output)
}
}
// Validate validates the config fields.
// It can return the following errors:
// - types.FieldValidationErrors
func (config Config) Validate() error {
validationErrors := make([]types.FieldValidationError, 0)
if len(config.Methods) == 0 {
validationErrors = append(validationErrors, types.NewFieldValidationError("Method", "", errors.New("method is required")))
}
switch {
case config.URL == nil:
validationErrors = append(validationErrors, types.NewFieldValidationError("URL", "", errors.New("URL is required")))
case !slices.Contains(ValidRequestURLSchemes, config.URL.Scheme):
validationErrors = append(validationErrors, types.NewFieldValidationError("URL", config.URL.String(), fmt.Errorf("URL scheme must be one of: %s", strings.Join(ValidRequestURLSchemes, ", "))))
case config.URL.Host == "":
validationErrors = append(validationErrors, types.NewFieldValidationError("URL", config.URL.String(), errors.New("URL must have a host")))
}
switch {
case config.Concurrency == nil:
validationErrors = append(validationErrors, types.NewFieldValidationError("Concurrency", "", errors.New("concurrency count is required")))
case *config.Concurrency == 0:
validationErrors = append(validationErrors, types.NewFieldValidationError("Concurrency", "0", errors.New("concurrency must be greater than 0")))
case *config.Concurrency > 100_000_000:
validationErrors = append(validationErrors, types.NewFieldValidationError("Concurrency", strconv.FormatUint(uint64(*config.Concurrency), 10), errors.New("concurrency must not exceed 100,000,000")))
}
switch {
case config.Requests == nil && config.Duration == nil:
validationErrors = append(validationErrors, types.NewFieldValidationError("Requests / Duration", "", errors.New("either request count or duration must be specified")))
case (config.Requests != nil && config.Duration != nil) && (*config.Requests == 0 && *config.Duration == 0):
validationErrors = append(validationErrors, types.NewFieldValidationError("Requests / Duration", "0", errors.New("both request count and duration cannot be zero")))
case config.Requests != nil && config.Duration == nil && *config.Requests == 0:
validationErrors = append(validationErrors, types.NewFieldValidationError("Requests", "0", errors.New("request count must be greater than 0")))
case config.Requests == nil && config.Duration != nil && *config.Duration == 0:
validationErrors = append(validationErrors, types.NewFieldValidationError("Duration", "0", errors.New("duration must be greater than 0")))
}
if *config.Timeout < 1 {
validationErrors = append(validationErrors, types.NewFieldValidationError("Timeout", "0", errors.New("timeout must be greater than 0")))
}
if config.ShowConfig == nil {
validationErrors = append(validationErrors, types.NewFieldValidationError("ShowConfig", "", errors.New("showConfig field is required")))
}
if config.Quiet == nil {
validationErrors = append(validationErrors, types.NewFieldValidationError("Quiet", "", errors.New("quiet field is required")))
}
if config.Output == nil {
validationErrors = append(validationErrors, types.NewFieldValidationError("Output", "", errors.New("output field is required")))
} else {
switch *config.Output {
case "":
validationErrors = append(validationErrors, types.NewFieldValidationError("Output", "", errors.New("output field is required")))
case ConfigOutputTypeTable, ConfigOutputTypeJSON, ConfigOutputTypeYAML, ConfigOutputTypeNone:
default:
validOutputs := []string{string(ConfigOutputTypeTable), string(ConfigOutputTypeJSON), string(ConfigOutputTypeYAML), string(ConfigOutputTypeNone)}
validationErrors = append(validationErrors,
types.NewFieldValidationError(
"Output",
string(*config.Output),
fmt.Errorf(
"output type must be one of: %s",
strings.Join(validOutputs, ", "),
),
),
)
}
}
if config.Insecure == nil {
validationErrors = append(validationErrors, types.NewFieldValidationError("Insecure", "", errors.New("insecure field is required")))
}
if config.DryRun == nil {
validationErrors = append(validationErrors, types.NewFieldValidationError("DryRun", "", errors.New("dryRun field is required")))
}
for i, proxy := range config.Proxies {
if !slices.Contains(ValidProxySchemes, proxy.Scheme) {
validationErrors = append(
validationErrors,
types.NewFieldValidationError(
fmt.Sprintf("Proxy[%d]", i),
proxy.String(),
fmt.Errorf("proxy scheme must be one of: %v", ValidProxySchemes),
),
)
}
}
templateErrors := ValidateTemplates(&config)
validationErrors = append(validationErrors, templateErrors...)
if len(validationErrors) > 0 {
return types.NewFieldValidationErrors(validationErrors)
}
return nil
}
func ReadAllConfigs() *Config {
envParser := NewConfigENVParser("SARIN")
envConfig, err := envParser.Parse()
_ = utilsErr.MustHandle(err,
utilsErr.OnType(func(err types.FieldParseErrors) error {
printParseErrors("ENV", err.Errors...)
fmt.Println()
os.Exit(1)
return nil
}),
)
cliParser := NewConfigCLIParser(os.Args)
cliConf, err := cliParser.Parse()
_ = utilsErr.MustHandle(err,
utilsErr.OnSentinel(types.ErrCLINoArgs, func(err error) error {
cliParser.PrintHelp()
fmt.Fprintln(os.Stderr, StyleYellow.Render("\nNo arguments provided."))
os.Exit(1)
return nil
}),
utilsErr.OnType(func(err types.CLIUnexpectedArgsError) error {
cliParser.PrintHelp()
fmt.Fprintln(os.Stderr,
StyleYellow.Render(
"\nUnexpected CLI arguments provided: ",
)+strings.Join(err.Args, ", "),
)
os.Exit(1)
return nil
}),
utilsErr.OnType(func(err types.FieldParseErrors) error {
cliParser.PrintHelp()
fmt.Println()
printParseErrors("CLI", err.Errors...)
os.Exit(1)
return nil
}),
)
for _, configFile := range append(envConfig.Files, cliConf.Files...) {
fileConfig, err := parseConfigFile(configFile, 10)
_ = utilsErr.MustHandle(err,
utilsErr.OnType(func(err types.ConfigFileReadError) error {
cliParser.PrintHelp()
fmt.Fprintln(os.Stderr,
StyleYellow.Render(
fmt.Sprintf("\nFailed to read config file (%s): ", configFile.Path())+err.Error(),
),
)
os.Exit(1)
return nil
}),
utilsErr.OnType(func(err types.UnmarshalError) error {
fmt.Fprintln(os.Stderr,
StyleYellow.Render(
fmt.Sprintf("\nFailed to parse config file (%s): ", configFile.Path())+err.Error(),
),
)
os.Exit(1)
return nil
}),
utilsErr.OnType(func(err types.FieldParseErrors) error {
printParseErrors(fmt.Sprintf("CONFIG FILE '%s'", configFile.Path()), err.Errors...)
os.Exit(1)
return nil
}),
)
envConfig.Merge(fileConfig)
}
envConfig.Merge(cliConf)
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
}
innerFileConfig.Merge(fileConfig)
fileConfig = innerFileConfig
}
return fileConfig, nil
}
func printParseErrors(parserName string, errors ...types.FieldParseError) {
for _, fieldErr := range errors {
if fieldErr.Value == "" {
fmt.Fprintln(os.Stderr,
StyleYellow.Render(fmt.Sprintf("[%s] Field '%s': ", parserName, fieldErr.Field))+fieldErr.Err.Error(),
)
} else {
fmt.Fprintln(os.Stderr,
StyleYellow.Render(fmt.Sprintf("[%s] Field '%s' (%s): ", parserName, fieldErr.Field, fieldErr.Value))+fieldErr.Err.Error(),
)
}
}
}
const (
scrollbarWidth = 1
scrollbarBottomSpace = 1
statusDisplayTime = 3 * time.Second
)
var (
printConfigBorderStyle = func() lipgloss.Border {
b := lipgloss.RoundedBorder()
return b
}()
printConfigHelpStyle = lipgloss.NewStyle().BorderStyle(printConfigBorderStyle).Padding(0, 1)
printConfigSuccessStatusStyle = lipgloss.NewStyle().BorderStyle(printConfigBorderStyle).Padding(0, 1).Foreground(lipgloss.Color("10"))
printConfigErrorStatusStyle = lipgloss.NewStyle().BorderStyle(printConfigBorderStyle).Padding(0, 1).Foreground(lipgloss.Color("9"))
printConfigKeyStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("12")).Bold(true)
printConfigDescStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("252"))
)
type printConfigClearStatusMsg struct{}
type printConfigModel struct {
viewport viewport.Model
content string
rawContent []byte
statusMsg string
ready bool
start bool
}
func (m printConfigModel) Init() tea.Cmd { return nil }
func (m printConfigModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
var cmd tea.Cmd
switch msg := msg.(type) {
case tea.KeyMsg:
switch msg.String() {
case "ctrl+c", "esc":
return m, tea.Quit
case "ctrl+s":
return m.saveContent()
case "enter":
m.start = true
return m, tea.Quit
}
case printConfigClearStatusMsg:
m.statusMsg = ""
return m, nil
case tea.WindowSizeMsg:
m.handleResize(msg)
}
m.viewport, cmd = m.viewport.Update(msg)
return m, cmd
}
func (m printConfigModel) View() string {
if !m.ready {
return "\n Initializing..."
}
content := lipgloss.JoinHorizontal(lipgloss.Top, m.viewport.View(), m.scrollbar())
return fmt.Sprintf("%s\n%s\n%s", m.headerView(), content, m.footerView())
}
func (m *printConfigModel) saveContent() (printConfigModel, tea.Cmd) {
filename := fmt.Sprintf("sarin_config_%s.yaml", time.Now().Format("2006-01-02_15-04-05"))
if err := os.WriteFile(filename, m.rawContent, 0600); err != nil {
m.statusMsg = printConfigErrorStatusStyle.Render("✗ Error saving file: " + err.Error())
} else {
m.statusMsg = printConfigSuccessStatusStyle.Render("✓ Saved to " + filename)
}
return *m, tea.Tick(statusDisplayTime, func(time.Time) tea.Msg { return printConfigClearStatusMsg{} })
}
func (m *printConfigModel) handleResize(msg tea.WindowSizeMsg) {
headerHeight := lipgloss.Height(m.headerView())
footerHeight := lipgloss.Height(m.footerView())
height := msg.Height - headerHeight - footerHeight
width := msg.Width - scrollbarWidth
if !m.ready {
m.viewport = viewport.New(width, height)
m.viewport.SetContent(m.contentWithLineNumbers())
m.ready = true
} else {
m.viewport.Width = width
m.viewport.Height = height
}
}
func (m printConfigModel) headerView() string {
var title string
if m.statusMsg != "" {
title = ("" + m.statusMsg)
} else {
sep := printConfigDescStyle.Render(" / ")
help := printConfigKeyStyle.Render("ENTER") + printConfigDescStyle.Render(" start") + sep +
printConfigKeyStyle.Render("CTRL+S") + printConfigDescStyle.Render(" save") + sep +
printConfigKeyStyle.Render("ESC") + printConfigDescStyle.Render(" exit")
title = printConfigHelpStyle.Render(help)
}
line := strings.Repeat("─", max(0, m.viewport.Width+scrollbarWidth-lipgloss.Width(title)))
return lipgloss.JoinHorizontal(lipgloss.Center, title, line)
}
func (m printConfigModel) footerView() string {
return strings.Repeat("─", m.viewport.Width+scrollbarWidth)
}
func (m printConfigModel) contentWithLineNumbers() string {
lines := strings.Split(m.content, "\n")
width := len(strconv.Itoa(len(lines)))
lineNumStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("246"))
var sb strings.Builder
for i, line := range lines {
lineNum := lineNumStyle.Render(fmt.Sprintf("%*d", width, i+1))
sb.WriteString(lineNum)
sb.WriteString(" ")
sb.WriteString(line)
if i < len(lines)-1 {
sb.WriteByte('\n')
}
}
return sb.String()
}
func (m printConfigModel) scrollbar() string {
height := m.viewport.Height
trackHeight := height - scrollbarBottomSpace
totalLines := m.viewport.TotalLineCount()
if totalLines <= height {
return strings.Repeat(" \n", trackHeight) + " "
}
thumbSize := max(1, (height*trackHeight)/totalLines)
thumbPos := int(m.viewport.ScrollPercent() * float64(trackHeight-thumbSize))
var sb strings.Builder
for i := range trackHeight {
if i >= thumbPos && i < thumbPos+thumbSize {
sb.WriteByte('\xe2') // █ (U+2588)
sb.WriteByte('\x96')
sb.WriteByte('\x88')
} else {
sb.WriteByte('\xe2') // ░ (U+2591)
sb.WriteByte('\x96')
sb.WriteByte('\x91')
}
sb.WriteByte('\n')
}
sb.WriteByte(' ')
return sb.String()
}

235
internal/config/env.go Normal file
View File

@@ -0,0 +1,235 @@
package config
import (
"errors"
"net/url"
"os"
"time"
"go.aykhans.me/sarin/internal/types"
"go.aykhans.me/utils/common"
utilsParse "go.aykhans.me/utils/parser"
)
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, error) {
var (
config = &Config{}
fieldParseErrors []types.FieldParseError
)
if showConfig := parser.getEnv("SHOW_CONFIG"); showConfig != "" {
showConfigParsed, err := utilsParse.ParseString[bool](showConfig)
if err != nil {
fieldParseErrors = append(
fieldParseErrors,
types.NewFieldParseError(
parser.getFullEnvName("SHOW_CONFIG"),
showConfig,
errors.New("invalid value for boolean, expected 'true' or 'false'"),
),
)
} else {
config.ShowConfig = &showConfigParsed
}
}
if configFile := parser.getEnv("CONFIG_FILE"); configFile != "" {
config.Files = append(config.Files, *types.ParseConfigFile(configFile))
}
if quiet := parser.getEnv("QUIET"); quiet != "" {
quietParsed, err := utilsParse.ParseString[bool](quiet)
if err != nil {
fieldParseErrors = append(
fieldParseErrors,
types.NewFieldParseError(
parser.getFullEnvName("QUIET"),
quiet,
errors.New("invalid value for boolean, expected 'true' or 'false'"),
),
)
} else {
config.Quiet = &quietParsed
}
}
if output := parser.getEnv("OUTPUT"); output != "" {
config.Output = common.ToPtr(ConfigOutputType(output))
}
if insecure := parser.getEnv("INSECURE"); insecure != "" {
insecureParsed, err := utilsParse.ParseString[bool](insecure)
if err != nil {
fieldParseErrors = append(
fieldParseErrors,
types.NewFieldParseError(
parser.getFullEnvName("INSECURE"),
insecure,
errors.New("invalid value for boolean, expected 'true' or 'false'"),
),
)
} else {
config.Insecure = &insecureParsed
}
}
if dryRun := parser.getEnv("DRY_RUN"); dryRun != "" {
dryRunParsed, err := utilsParse.ParseString[bool](dryRun)
if err != nil {
fieldParseErrors = append(
fieldParseErrors,
types.NewFieldParseError(
parser.getFullEnvName("DRY_RUN"),
dryRun,
errors.New("invalid value for boolean, expected 'true' or 'false'"),
),
)
} else {
config.DryRun = &dryRunParsed
}
}
if method := parser.getEnv("METHOD"); method != "" {
config.Methods = []string{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 concurrency := parser.getEnv("CONCURRENCY"); concurrency != "" {
concurrencyParsed, err := utilsParse.ParseString[uint](concurrency)
if err != nil {
fieldParseErrors = append(
fieldParseErrors,
types.NewFieldParseError(
parser.getFullEnvName("CONCURRENCY"),
concurrency,
errors.New("invalid value for unsigned integer"),
),
)
} else {
config.Concurrency = &concurrencyParsed
}
}
if requests := parser.getEnv("REQUESTS"); requests != "" {
requestsParsed, err := utilsParse.ParseString[uint64](requests)
if err != nil {
fieldParseErrors = append(
fieldParseErrors,
types.NewFieldParseError(
parser.getFullEnvName("REQUESTS"),
requests,
errors.New("invalid value for unsigned integer"),
),
)
} else {
config.Requests = &requestsParsed
}
}
if duration := parser.getEnv("DURATION"); duration != "" {
durationParsed, err := utilsParse.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 := utilsParse.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 = []string{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 values := parser.getEnv("VALUES"); values != "" {
config.Values = []string{values}
}
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))
}

280
internal/config/file.go Normal file
View File

@@ -0,0 +1,280 @@
package config
import (
"context"
"fmt"
"io"
"net/http"
"net/url"
"os"
"path/filepath"
"strings"
"time"
"go.aykhans.me/sarin/internal/types"
"go.aykhans.me/utils/common"
"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) {
ctx, cancel := context.WithTimeout(context.Background(), time.Second*30)
defer cancel()
configFileData, err := fetchFile(ctx, parser.configFile.Path())
if err != nil {
return nil, types.NewConfigFileReadError(err)
}
switch parser.configFile.Type() {
case types.ConfigFileTypeYAML, types.ConfigFileTypeUnknown:
return parser.ParseYAML(configFileData)
default:
panic("unhandled config file type")
}
}
// fetchFile retrieves file contents from a local path or HTTP/HTTPS URL.
func fetchFile(ctx context.Context, src string) ([]byte, error) {
if strings.HasPrefix(src, "http://") || strings.HasPrefix(src, "https://") {
return fetchHTTP(ctx, src)
}
return fetchLocal(src)
}
// fetchHTTP downloads file contents from an HTTP/HTTPS URL.
func fetchHTTP(ctx context.Context, url string) ([]byte, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return nil, fmt.Errorf("failed to create request: %w", err)
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
return nil, fmt.Errorf("failed to fetch file: %w", err)
}
defer resp.Body.Close() //nolint:errcheck
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("failed to fetch file: HTTP %d %s", resp.StatusCode, resp.Status)
}
data, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("failed to read response body: %w", err)
}
return data, nil
}
// fetchLocal reads file contents from the local filesystem.
// It resolves relative paths from the current working directory.
func fetchLocal(src string) ([]byte, error) {
path := src
if !filepath.IsAbs(src) {
pwd, err := os.Getwd()
if err != nil {
return nil, fmt.Errorf("failed to get working directory: %w", err)
}
path = filepath.Join(pwd, src)
}
data, err := os.ReadFile(path) //nolint:gosec
if err != nil {
return nil, fmt.Errorf("failed to read file: %w", err)
}
return data, nil
}
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)
}
}
// keyValuesField handles flexible YAML formats for key-value pairs.
// Supported formats:
// - Sequence of maps: [{key1: value1}, {key2: [value2, value3]}]
// - Single map: {key1: value1, key2: [value2, value3]}
//
// Values can be either a single string or an array of strings.
type keyValuesField []types.KeyValue[string, []string]
func (kv *keyValuesField) UnmarshalYAML(node *yaml.Node) error {
switch node.Kind {
case yaml.MappingNode:
// Handle single map: {key1: value1, key2: [value2]}
return kv.unmarshalMapping(node)
case yaml.SequenceNode:
// Handle sequence of maps: [{key1: value1}, {key2: value2}]
for _, item := range node.Content {
if item.Kind != yaml.MappingNode {
return fmt.Errorf("expected a mapping in sequence, but got %v", item.Kind)
}
if err := kv.unmarshalMapping(item); err != nil {
return err
}
}
return nil
default:
return fmt.Errorf("expected a mapping or sequence of mappings, but got %v", node.Kind)
}
}
func (kv *keyValuesField) unmarshalMapping(node *yaml.Node) error {
// MappingNode content is [key1, value1, key2, value2, ...]
for i := 0; i < len(node.Content); i += 2 {
keyNode := node.Content[i]
valueNode := node.Content[i+1]
if keyNode.Kind != yaml.ScalarNode {
return fmt.Errorf("expected a string key, but got %v", keyNode.Kind)
}
key := keyNode.Value
var values []string
switch valueNode.Kind {
case yaml.ScalarNode:
values = []string{valueNode.Value}
case yaml.SequenceNode:
for _, v := range valueNode.Content {
if v.Kind != yaml.ScalarNode {
return fmt.Errorf("expected string values in array for key %q, but got %v", key, v.Kind)
}
values = append(values, v.Value)
}
default:
return fmt.Errorf("expected a string or array of strings for key %q, but got %v", key, valueNode.Kind)
}
*kv = append(*kv, types.KeyValue[string, []string]{Key: key, Value: values})
}
return nil
}
type configYAML struct {
ConfigFiles stringOrSliceField `yaml:"configFile"`
Method stringOrSliceField `yaml:"method"`
URL *string `yaml:"url"`
Timeout *time.Duration `yaml:"timeout"`
Concurrency *uint `yaml:"concurrency"`
RequestCount *uint64 `yaml:"requests"`
Duration *time.Duration `yaml:"duration"`
Quiet *bool `yaml:"quiet"`
Output *string `yaml:"output"`
Insecure *bool `yaml:"insecure"`
ShowConfig *bool `yaml:"showConfig"`
DryRun *bool `yaml:"dryRun"`
Params keyValuesField `yaml:"params"`
Headers keyValuesField `yaml:"headers"`
Cookies keyValuesField `yaml:"cookies"`
Bodies stringOrSliceField `yaml:"body"`
Proxies stringOrSliceField `yaml:"proxy"`
Values stringOrSliceField `yaml:"values"`
}
// 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.Methods = append(config.Methods, parsedData.Method...)
config.Timeout = parsedData.Timeout
config.Concurrency = parsedData.Concurrency
config.Requests = parsedData.RequestCount
config.Duration = parsedData.Duration
config.ShowConfig = parsedData.ShowConfig
config.Quiet = parsedData.Quiet
if parsedData.Output != nil {
config.Output = common.ToPtr(ConfigOutputType(*parsedData.Output))
}
config.Insecure = parsedData.Insecure
config.DryRun = parsedData.DryRun
for _, kv := range parsedData.Params {
config.Params = append(config.Params, types.Param(kv))
}
for _, kv := range parsedData.Headers {
config.Headers = append(config.Headers, types.Header(kv))
}
for _, kv := range parsedData.Cookies {
config.Cookies = append(config.Cookies, types.Cookie(kv))
}
config.Bodies = append(config.Bodies, parsedData.Bodies...)
config.Values = append(config.Values, parsedData.Values...)
if len(parsedData.ConfigFiles) > 0 {
for _, configFile := range parsedData.ConfigFiles {
config.Files = append(config.Files, *types.ParseConfigFile(configFile))
}
}
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
}

View File

@@ -0,0 +1,212 @@
package config
import (
"fmt"
"text/template"
"go.aykhans.me/sarin/internal/sarin"
"go.aykhans.me/sarin/internal/types"
)
func validateTemplateString(value string, funcMap template.FuncMap) error {
if value == "" {
return nil
}
_, err := template.New("").Funcs(funcMap).Parse(value)
if err != nil {
return fmt.Errorf("template parse error: %w", err)
}
return nil
}
func validateTemplateMethods(methods []string, funcMap template.FuncMap) []types.FieldValidationError {
var validationErrors []types.FieldValidationError
for i, method := range methods {
if err := validateTemplateString(method, funcMap); err != nil {
validationErrors = append(
validationErrors,
types.NewFieldValidationError(
fmt.Sprintf("Method[%d]", i),
method,
err,
),
)
}
}
return validationErrors
}
func validateTemplateParams(params types.Params, funcMap template.FuncMap) []types.FieldValidationError {
var validationErrors []types.FieldValidationError
for paramIndex, param := range params {
// Validate param key
if err := validateTemplateString(param.Key, funcMap); err != nil {
validationErrors = append(
validationErrors,
types.NewFieldValidationError(
fmt.Sprintf("Param[%d].Key", paramIndex),
param.Key,
err,
),
)
}
// Validate param values
for valueIndex, value := range param.Value {
if err := validateTemplateString(value, funcMap); err != nil {
validationErrors = append(
validationErrors,
types.NewFieldValidationError(
fmt.Sprintf("Param[%d].Value[%d]", paramIndex, valueIndex),
value,
err,
),
)
}
}
}
return validationErrors
}
func validateTemplateHeaders(headers types.Headers, funcMap template.FuncMap) []types.FieldValidationError {
var validationErrors []types.FieldValidationError
for headerIndex, header := range headers {
// Validate header key
if err := validateTemplateString(header.Key, funcMap); err != nil {
validationErrors = append(
validationErrors,
types.NewFieldValidationError(
fmt.Sprintf("Header[%d].Key", headerIndex),
header.Key,
err,
),
)
}
// Validate header values
for valueIndex, value := range header.Value {
if err := validateTemplateString(value, funcMap); err != nil {
validationErrors = append(
validationErrors,
types.NewFieldValidationError(
fmt.Sprintf("Header[%d].Value[%d]", headerIndex, valueIndex),
value,
err,
),
)
}
}
}
return validationErrors
}
func validateTemplateCookies(cookies types.Cookies, funcMap template.FuncMap) []types.FieldValidationError {
var validationErrors []types.FieldValidationError
for cookieIndex, cookie := range cookies {
// Validate cookie key
if err := validateTemplateString(cookie.Key, funcMap); err != nil {
validationErrors = append(
validationErrors,
types.NewFieldValidationError(
fmt.Sprintf("Cookie[%d].Key", cookieIndex),
cookie.Key,
err,
),
)
}
// Validate cookie values
for valueIndex, value := range cookie.Value {
if err := validateTemplateString(value, funcMap); err != nil {
validationErrors = append(
validationErrors,
types.NewFieldValidationError(
fmt.Sprintf("Cookie[%d].Value[%d]", cookieIndex, valueIndex),
value,
err,
),
)
}
}
}
return validationErrors
}
func validateTemplateBodies(bodies []string, funcMap template.FuncMap) []types.FieldValidationError {
var validationErrors []types.FieldValidationError
for i, body := range bodies {
if err := validateTemplateString(body, funcMap); err != nil {
validationErrors = append(
validationErrors,
types.NewFieldValidationError(
fmt.Sprintf("Body[%d]", i),
body,
err,
),
)
}
}
return validationErrors
}
func validateTemplateValues(values []string, funcMap template.FuncMap) []types.FieldValidationError {
var validationErrors []types.FieldValidationError
for i, value := range values {
if err := validateTemplateString(value, funcMap); err != nil {
validationErrors = append(
validationErrors,
types.NewFieldValidationError(
fmt.Sprintf("Values[%d]", i),
value,
err,
),
)
}
}
return validationErrors
}
func ValidateTemplates(config *Config) []types.FieldValidationError {
// Create template function map using the same functions as sarin package
randSource := sarin.NewDefaultRandSource()
funcMap := sarin.NewDefaultTemplateFuncMap(randSource)
bodyFuncMapData := &sarin.BodyTemplateFuncMapData{}
bodyFuncMap := sarin.NewDefaultBodyTemplateFuncMap(randSource, bodyFuncMapData)
var allErrors []types.FieldValidationError
// Validate methods
allErrors = append(allErrors, validateTemplateMethods(config.Methods, funcMap)...)
// Validate params
allErrors = append(allErrors, validateTemplateParams(config.Params, funcMap)...)
// Validate headers
allErrors = append(allErrors, validateTemplateHeaders(config.Headers, funcMap)...)
// Validate cookies
allErrors = append(allErrors, validateTemplateCookies(config.Cookies, funcMap)...)
// Validate bodies
allErrors = append(allErrors, validateTemplateBodies(config.Bodies, bodyFuncMap)...)
// Validate values
allErrors = append(allErrors, validateTemplateValues(config.Values, funcMap)...)
return allErrors
}