diff --git a/docs/configuration.md b/docs/configuration.md index 489998b..2cceee7 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -105,6 +105,12 @@ SARIN_CONFIG_FILE=/config1.yaml sarin -f /config2.yaml -f https://example.com/co If all four files define `url`, the value from `config3.yaml` wins. +**Merge behavior by field:** + +- **Scalar fields** (`url`, `requests`, `duration`, `timeout`, `concurrency`, etc.) — higher priority overrides lower priority +- **Method and Body** — higher priority overrides lower priority (no merging) +- **Headers, Params, Cookies, Proxies, Values, Lua, and Js** — accumulated across all config files + ## URL Target URL. Must be HTTP or HTTPS. The URL path supports [templating](templating.md), allowing dynamic path generation per request. @@ -227,26 +233,33 @@ SARIN_BODY='{"product": "car"}' ## Params -URL query parameters. If multiple values are provided for a key, Sarin cycles through them in order, starting from a random index for each request. Supports [templating](templating.md). +URL query parameters. Supports [templating](templating.md). + +When the same key appears as **separate entries** (in CLI or config file), all values are sent in every request. When multiple values are specified as an **array on a single key** (config file only), Sarin cycles through them. **YAML example:** ```yaml params: key1: value1 - key2: [value2, value3] + key2: [value2, value3] # cycles between value2 and value3 # OR params: - key1: value1 - - key2: [value2, value3] + - key2: [value2, value3] # cycles between value2 and value3 + +# To send both values in every request, use separate entries: +params: + - key2: value2 + - key2: value3 # both sent in every request ``` **CLI example:** ```sh --param "key1=value1" -param "key2=value2" -param "key2=value3" +-param "key1=value1" -param "key2=value2" -param "key2=value3" # sends both value2 and value3 ``` **ENV example:** @@ -257,26 +270,33 @@ SARIN_PARAM="key1=value1" ## Headers -HTTP headers. If multiple values are provided for a key, Sarin cycles through them in order, starting from a random index for each request. Supports [templating](templating.md). +HTTP headers. Supports [templating](templating.md). + +When the same key appears as **separate entries** (in CLI or config file), all values are sent in every request. When multiple values are specified as an **array on a single key** (config file only), Sarin cycles through them. **YAML example:** ```yaml headers: key1: value1 - key2: [value2, value3] + key2: [value2, value3] # cycles between value2 and value3 # OR headers: - key1: value1 - - key2: [value2, value3] + - key2: [value2, value3] # cycles between value2 and value3 + +# To send both values in every request, use separate entries: +headers: + - key2: value2 + - key2: value3 # both sent in every request ``` **CLI example:** ```sh --header "key1: value1" -header "key2: value2" -header "key2: value3" +-header "key1: value1" -header "key2: value2" -header "key2: value3" # sends both value2 and value3 ``` **ENV example:** @@ -287,26 +307,33 @@ SARIN_HEADER="key1: value1" ## Cookies -HTTP cookies. If multiple values are provided for a key, Sarin cycles through them in order, starting from a random index for each request. Supports [templating](templating.md). +HTTP cookies. Supports [templating](templating.md). + +When the same key appears as **separate entries** (in CLI or config file), all values are sent in every request. When multiple values are specified as an **array on a single key** (config file only), Sarin cycles through them. **YAML example:** ```yaml cookies: key1: value1 - key2: [value2, value3] + key2: [value2, value3] # cycles between value2 and value3 # OR cookies: - key1: value1 - - key2: [value2, value3] + - key2: [value2, value3] # cycles between value2 and value3 + +# To send both values in every request, use separate entries: +cookies: + - key2: value2 + - key2: value3 # both sent in every request ``` **CLI example:** ```sh --cookie "key1=value1" -cookie "key2=value2" -cookie "key2=value3" +-cookie "key1=value1" -cookie "key2=value2" -cookie "key2=value3" # sends both value2 and value3 ``` **ENV example:** diff --git a/docs/examples.md b/docs/examples.md index eeb75b2..14b59ad 100644 --- a/docs/examples.md +++ b/docs/examples.md @@ -134,20 +134,34 @@ headers: -**Random headers from multiple values:** +**Multiple values for the same header (all sent in every request):** -> **Note:** When multiple values are provided for the same header, Sarin starts at a random index and cycles through all values in order. Once the cycle completes, it picks a new random starting point. This ensures all values are used while maintaining some randomness. +> **Note:** When the same key appears as separate entries (in CLI or config file), all values are sent in every request. ```sh sarin -U http://example.com -r 1000 -c 10 \ -H "X-Region: us-east" \ - -H "X-Region: us-west" \ - -H "X-Region: eu-central" + -H "X-Region: us-west" ```
YAML equivalent +```yaml +url: http://example.com +requests: 1000 +concurrency: 10 +headers: + - X-Region: us-east + - X-Region: us-west +``` + +
+ +**Cycling headers from multiple values (config file only):** + +> **Note:** When multiple values are specified as an array on a single key, Sarin starts at a random index and cycles through all values in order. Once the cycle completes, it picks a new random starting point. + ```yaml url: http://example.com requests: 1000 @@ -159,8 +173,6 @@ headers: - eu-central ``` - - **Query parameters:** ```sh @@ -187,7 +199,7 @@ params: ```sh sarin -U http://example.com/users -r 1000 -c 10 \ - -P "id={{ fakeit_IntRange 1 1000 }}" \ + -P "id={{ fakeit_Number 1 1000 }}" \ -P "fields=name,email" ``` @@ -199,7 +211,7 @@ url: http://example.com/users requests: 1000 concurrency: 10 params: - id: "{{ fakeit_IntRange 1 1000 }}" + id: "{{ fakeit_Number 1 1000 }}" fields: "name,email" ``` @@ -824,19 +836,19 @@ quiet: true **Basic Docker usage:** ```sh -docker run --rm aykhans/sarin -U http://example.com -r 1000 -c 10 +docker run -it --rm aykhans/sarin -U http://example.com -r 1000 -c 10 ``` **With local config file:** ```sh -docker run --rm -v $(pwd)/config.yaml:/config.yaml aykhans/sarin -f /config.yaml +docker run -it --rm -v $(pwd)/config.yaml:/config.yaml aykhans/sarin -f /config.yaml ``` **With remote config file:** ```sh -docker run --rm aykhans/sarin -f https://example.com/config.yaml +docker run -it --rm aykhans/sarin -f https://example.com/config.yaml ``` **Interactive mode with TTY:** diff --git a/docs/templating.md b/docs/templating.md index 4ff3e49..a77f2b9 100644 --- a/docs/templating.md +++ b/docs/templating.md @@ -98,16 +98,16 @@ sarin -U http://example.com/users \ | `strings_Truncate(s string, n int)` | Truncate to `n` characters with ellipsis | `{{ strings_Truncate "hello world" 5 }}` → `hello...` | | `strings_TrimPrefix(s string, prefix string)` | Remove prefix from string | `{{ strings_TrimPrefix "hello" "he" }}` → `llo` | | `strings_TrimSuffix(s string, suffix string)` | Remove suffix from string | `{{ strings_TrimSuffix "hello" "lo" }}` → `hel` | -| `strings_Join(sep string, values ...string)` | Join strings with separator | `{{ strings_Join "-" "a" "b" "c" }}` → `a-b-c` | ### Collection Functions -| Function | Description | Example | -| ----------------------------- | --------------------------------------------- | -------------------------------------------- | -| `dict_Str(pairs ...string)` | Create string dictionary from key-value pairs | `{{ dict_Str "key1" "val1" "key2" "val2" }}` | -| `slice_Str(values ...string)` | Create string slice | `{{ slice_Str "a" "b" "c" }}` | -| `slice_Int(values ...int)` | Create int slice | `{{ slice_Int 1 2 3 }}` | -| `slice_Uint(values ...uint)` | Create uint slice | `{{ slice_Uint 1 2 3 }}` | +| Function | Description | Example | +| ---------------------------------------- | --------------------------------------------- | -------------------------------------------------------- | +| `dict_Str(pairs ...string)` | Create string dictionary from key-value pairs | `{{ dict_Str "key1" "val1" "key2" "val2" }}` | +| `slice_Str(values ...string)` | Create string slice | `{{ slice_Str "a" "b" "c" }}` | +| `slice_Join(slice []string, sep string)` | Join string slice with separator | `{{ slice_Join (slice_Str "a" "b" "c") "-" }}` → `a-b-c` | +| `slice_Int(values ...int)` | Create int slice | `{{ slice_Int 1 2 3 }}` | +| `slice_Uint(values ...uint)` | Create uint slice | `{{ slice_Uint 1 2 3 }}` | ### Body Functions @@ -239,24 +239,24 @@ These functions are powered by [gofakeit](https://github.com/brianvoe/gofakeit) ### Address -| Function | Description | Example Output | -| --------------------------------------------------- | ---------------------------- | --------------------------------------------------- | -| `fakeit_City` | City name | `"Marcelside"` | -| `fakeit_Country` | Country name | `"United States of America"` | -| `fakeit_CountryAbr` | Country abbreviation | `"US"` | -| `fakeit_State` | State name | `"Illinois"` | -| `fakeit_StateAbr` | State abbreviation | `"IL"` | -| `fakeit_Street` | Full street | `"364 East Rapidsborough"` | -| `fakeit_StreetName` | Street name | `"View"` | -| `fakeit_StreetNumber` | Street number | `"13645"` | -| `fakeit_StreetPrefix` | Street prefix | `"East"` | -| `fakeit_StreetSuffix` | Street suffix | `"Ave"` | -| `fakeit_Unit` | Unit | `"Apt 123"` | -| `fakeit_Zip` | ZIP code | `"13645"` | -| `fakeit_Latitude` | Random latitude | `-73.534056` | -| `fakeit_Longitude` | Random longitude | `-147.068112` | -| `fakeit_LatitudeInRange(min float64, max float64)` | Latitude in specified range | `{{ fakeit_LatitudeInRange 0 90 }}` → `22.921026` | -| `fakeit_LongitudeInRange(min float64, max float64)` | Longitude in specified range | `{{ fakeit_LongitudeInRange 0 180 }}` → `-8.170450` | +| Function | Description | Example Output | +| --------------------------------------------------- | ---------------------------- | ---------------------------------------------------- | +| `fakeit_City` | City name | `"Marcelside"` | +| `fakeit_Country` | Country name | `"United States of America"` | +| `fakeit_CountryAbr` | Country abbreviation | `"US"` | +| `fakeit_State` | State name | `"Illinois"` | +| `fakeit_StateAbr` | State abbreviation | `"IL"` | +| `fakeit_Street` | Full street | `"364 East Rapidsborough"` | +| `fakeit_StreetName` | Street name | `"View"` | +| `fakeit_StreetNumber` | Street number | `"13645"` | +| `fakeit_StreetPrefix` | Street prefix | `"East"` | +| `fakeit_StreetSuffix` | Street suffix | `"Ave"` | +| `fakeit_Unit` | Unit | `"Apt 123"` | +| `fakeit_Zip` | ZIP code | `"13645"` | +| `fakeit_Latitude` | Random latitude | `-73.534056` | +| `fakeit_Longitude` | Random longitude | `-147.068112` | +| `fakeit_LatitudeInRange(min float64, max float64)` | Latitude in specified range | `{{ fakeit_LatitudeInRange 0 90 }}` → `22.921026` | +| `fakeit_LongitudeInRange(min float64, max float64)` | Longitude in specified range | `{{ fakeit_LongitudeInRange 0 180 }}` → `122.471830` | ### Game @@ -343,16 +343,16 @@ These functions are powered by [gofakeit](https://github.com/brianvoe/gofakeit) ### Text -| Function | Description | Example | -| ---------------------------------------------------------------------------------------- | ----------------------------------------------- | --------------------------------------------- | -| `fakeit_Sentence` | Random sentence | `{{ fakeit_Sentence }}` | -| `fakeit_Paragraph` | Random paragraph | `{{ fakeit_Paragraph }}` | -| `fakeit_LoremIpsumWord` | Lorem ipsum word | `"lorem"` | -| `fakeit_LoremIpsumSentence(wordCount int)` | Lorem ipsum sentence with specified word count | `{{ fakeit_LoremIpsumSentence 5 }}` | -| `fakeit_LoremIpsumParagraph(paragraphs int, sentences int, words int, separator string)` | Lorem ipsum paragraphs with specified structure | `{{ fakeit_LoremIpsumParagraph 1 3 5 "\n" }}` | -| `fakeit_Question` | Random question | `"What is your name?"` | -| `fakeit_Quote` | Random quote | `"Life is what happens..."` | -| `fakeit_Phrase` | Random phrase | `"a piece of cake"` | +| Function | Description | Example | +| ---------------------------------------------------------------------------------------- | ----------------------------------------------- | ----------------------------------------------------- | +| `fakeit_Sentence(wordCount ...int)` | Random sentence (optional word count) | `{{ fakeit_Sentence }}` or `{{ fakeit_Sentence 10 }}` | +| `fakeit_Paragraph` | Random paragraph | `{{ fakeit_Paragraph }}` | +| `fakeit_LoremIpsumWord` | Lorem ipsum word | `"lorem"` | +| `fakeit_LoremIpsumSentence(wordCount int)` | Lorem ipsum sentence with specified word count | `{{ fakeit_LoremIpsumSentence 5 }}` | +| `fakeit_LoremIpsumParagraph(paragraphs int, sentences int, words int, separator string)` | Lorem ipsum paragraphs with specified structure | `{{ fakeit_LoremIpsumParagraph 1 3 5 "\n" }}` | +| `fakeit_Question` | Random question | `"What is your name?"` | +| `fakeit_Quote` | Random quote | `"Life is what happens..."` | +| `fakeit_Phrase` | Random phrase | `"a piece of cake"` | ### Foods diff --git a/internal/config/cli.go b/internal/config/cli.go index 47aa8e6..0dd5895 100644 --- a/internal/config/cli.go +++ b/internal/config/cli.go @@ -71,7 +71,6 @@ func (arg *stringSliceArg) Set(value string) error { // 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) { @@ -178,12 +177,6 @@ func (parser ConfigCLIParser) Parse() (*Config, error) { 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) diff --git a/internal/config/config.go b/internal/config/config.go index a70bfb5..42b7ff7 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -275,7 +275,7 @@ func (config Config) Print() bool { 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...) + config.Methods = newConfig.Methods } if newConfig.URL != nil { config.URL = newConfig.URL @@ -317,7 +317,7 @@ func (config *Config) Merge(newConfig *Config) { config.Cookies = append(config.Cookies, newConfig.Cookies...) } if len(newConfig.Bodies) != 0 { - config.Bodies = append(config.Bodies, newConfig.Bodies...) + config.Bodies = newConfig.Bodies } if len(newConfig.Proxies) != 0 { config.Proxies.Append(newConfig.Proxies...) @@ -536,12 +536,6 @@ func ReadAllConfigs() *Config { 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, diff --git a/internal/sarin/sarin.go b/internal/sarin/sarin.go index 437665a..b3e7780 100644 --- a/internal/sarin/sarin.go +++ b/internal/sarin/sarin.go @@ -3,6 +3,7 @@ package sarin import ( "context" "net/url" + "os" "strconv" "strings" "sync" @@ -13,6 +14,7 @@ import ( "github.com/charmbracelet/bubbles/spinner" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" + "github.com/charmbracelet/x/term" "github.com/valyala/fasthttp" "go.aykhans.me/sarin/internal/script" "go.aykhans.me/sarin/internal/types" @@ -155,6 +157,10 @@ func (q sarin) Start(ctx context.Context) { var messageChannel chan runtimeMessage var sendMessage messageSender + if !q.quiet && !term.IsTerminal(os.Stdout.Fd()) { + q.quiet = true + } + if q.quiet { sendMessage = func(level runtimeMessageLevel, text string) {} } else { diff --git a/internal/sarin/template.go b/internal/sarin/template.go index 754bcfd..c91a49a 100644 --- a/internal/sarin/template.go +++ b/internal/sarin/template.go @@ -62,10 +62,6 @@ func NewDefaultTemplateFuncMap(randSource rand.Source, fileCache *FileCache) tem }, "strings_TrimPrefix": strings.TrimPrefix, "strings_TrimSuffix": strings.TrimSuffix, - "strings_Join": func(sep string, values ...string) string { - return strings.Join(values, sep) - }, - // Dict "dict_Str": func(values ...string) map[string]string { dict := make(map[string]string) @@ -83,6 +79,7 @@ func NewDefaultTemplateFuncMap(randSource rand.Source, fileCache *FileCache) tem "slice_Str": func(values ...string) []string { return values }, "slice_Int": func(values ...int) []int { return values }, "slice_Uint": func(values ...uint) []uint { return values }, + "slice_Join": strings.Join, // File // file_Base64 reads a file (local or remote URL) and returns its Base64 encoded content. diff --git a/internal/types/cookie.go b/internal/types/cookie.go index 927f900..e0d4969 100644 --- a/internal/types/cookie.go +++ b/internal/types/cookie.go @@ -15,7 +15,7 @@ func (cookies Cookies) GetValue(key string) *[]string { return nil } -func (cookies *Cookies) Append(cookie ...Cookie) { +func (cookies *Cookies) Merge(cookie ...Cookie) { for _, c := range cookie { if item := cookies.GetValue(c.Key); item != nil { *item = append(*item, c.Value...) @@ -27,7 +27,7 @@ func (cookies *Cookies) Append(cookie ...Cookie) { func (cookies *Cookies) Parse(rawValues ...string) { for _, rawValue := range rawValues { - cookies.Append(*ParseCookie(rawValue)) + *cookies = append(*cookies, *ParseCookie(rawValue)) } } diff --git a/internal/types/errors.go b/internal/types/errors.go index a8a2332..6bc6a76 100644 --- a/internal/types/errors.go +++ b/internal/types/errors.go @@ -250,10 +250,6 @@ func (e TemplateRenderError) Unwrap() error { // ======================================== CLI ======================================== -var ( - ErrCLINoArgs = errors.New("CLI expects arguments but received none") -) - type CLIUnexpectedArgsError struct { Args []string } diff --git a/internal/types/header.go b/internal/types/header.go index 9215218..5695689 100644 --- a/internal/types/header.go +++ b/internal/types/header.go @@ -24,7 +24,7 @@ func (headers Headers) GetValue(key string) *[]string { return nil } -func (headers *Headers) Append(header ...Header) { +func (headers *Headers) Merge(header ...Header) { for _, h := range header { if item := headers.GetValue(h.Key); item != nil { *item = append(*item, h.Value...) @@ -36,7 +36,7 @@ func (headers *Headers) Append(header ...Header) { func (headers *Headers) Parse(rawValues ...string) { for _, rawValue := range rawValues { - headers.Append(*ParseHeader(rawValue)) + *headers = append(*headers, *ParseHeader(rawValue)) } } diff --git a/internal/types/param.go b/internal/types/param.go index d043396..4e6621b 100644 --- a/internal/types/param.go +++ b/internal/types/param.go @@ -15,7 +15,7 @@ func (params Params) GetValue(key string) *[]string { return nil } -func (params *Params) Append(param ...Param) { +func (params *Params) Merge(param ...Param) { for _, p := range param { if item := params.GetValue(p.Key); item != nil { *item = append(*item, p.Value...) @@ -27,7 +27,7 @@ func (params *Params) Append(param ...Param) { func (params *Params) Parse(rawValues ...string) { for _, rawValue := range rawValues { - params.Append(*ParseParam(rawValue)) + *params = append(*params, *ParseParam(rawValue)) } }