5 Commits

Author SHA1 Message Date
d197e90103 Merge pull request #172 from aykhans/refactor/minor-improvements
Rename Append to Merge, replace strings_Join with slice_Join, and auto-detect non-TTY output
2026-02-15 16:55:40 +04:00
ae054bb3d6 update docs 2026-02-15 16:52:52 +04:00
61af28a3d3 Override Methods and Bodies instead of appending in Config.Merge 2026-02-15 16:27:36 +04:00
665be5d98a update docs 2026-02-15 03:05:03 +04:00
d346067e8a Rename Append to Merge, replace strings_Join with slice_Join, and auto-detect non-TTY output
- Rename Append to Merge on Cookies, Headers, and Params types and simplify Parse to use direct append
- Replace strings_Join(sep, ...values) with slice_Join(slice, sep) for consistency with slice-based template functions
- Auto-enable quiet mode when stdout is not a terminal
- Remove ErrCLINoArgs check to allow running sarin without arguments
- Add -it flag to Docker examples in docs
2026-02-15 02:56:32 +04:00
11 changed files with 112 additions and 87 deletions

View File

@@ -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. 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 ## URL
Target URL. Must be HTTP or HTTPS. The URL path supports [templating](templating.md), allowing dynamic path generation per request. 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 ## 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 example:**
```yaml ```yaml
params: params:
key1: value1 key1: value1
key2: [value2, value3] key2: [value2, value3] # cycles between value2 and value3
# OR # OR
params: params:
- key1: value1 - 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:** **CLI example:**
```sh ```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:** **ENV example:**
@@ -257,26 +270,33 @@ SARIN_PARAM="key1=value1"
## Headers ## 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 example:**
```yaml ```yaml
headers: headers:
key1: value1 key1: value1
key2: [value2, value3] key2: [value2, value3] # cycles between value2 and value3
# OR # OR
headers: headers:
- key1: value1 - 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:** **CLI example:**
```sh ```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:** **ENV example:**
@@ -287,26 +307,33 @@ SARIN_HEADER="key1: value1"
## Cookies ## 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 example:**
```yaml ```yaml
cookies: cookies:
key1: value1 key1: value1
key2: [value2, value3] key2: [value2, value3] # cycles between value2 and value3
# OR # OR
cookies: cookies:
- key1: value1 - 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:** **CLI example:**
```sh ```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:** **ENV example:**

View File

@@ -134,20 +134,34 @@ headers:
</details> </details>
**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 ```sh
sarin -U http://example.com -r 1000 -c 10 \ sarin -U http://example.com -r 1000 -c 10 \
-H "X-Region: us-east" \ -H "X-Region: us-east" \
-H "X-Region: us-west" \ -H "X-Region: us-west"
-H "X-Region: eu-central"
``` ```
<details> <details>
<summary>YAML equivalent</summary> <summary>YAML equivalent</summary>
```yaml
url: http://example.com
requests: 1000
concurrency: 10
headers:
- X-Region: us-east
- X-Region: us-west
```
</details>
**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 ```yaml
url: http://example.com url: http://example.com
requests: 1000 requests: 1000
@@ -159,8 +173,6 @@ headers:
- eu-central - eu-central
``` ```
</details>
**Query parameters:** **Query parameters:**
```sh ```sh
@@ -187,7 +199,7 @@ params:
```sh ```sh
sarin -U http://example.com/users -r 1000 -c 10 \ 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" -P "fields=name,email"
``` ```
@@ -199,7 +211,7 @@ url: http://example.com/users
requests: 1000 requests: 1000
concurrency: 10 concurrency: 10
params: params:
id: "{{ fakeit_IntRange 1 1000 }}" id: "{{ fakeit_Number 1 1000 }}"
fields: "name,email" fields: "name,email"
``` ```
@@ -824,19 +836,19 @@ quiet: true
**Basic Docker usage:** **Basic Docker usage:**
```sh ```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:** **With local config file:**
```sh ```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:** **With remote config file:**
```sh ```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:** **Interactive mode with TTY:**

View File

@@ -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_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_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_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 ### Collection Functions
| Function | Description | Example | | Function | Description | Example |
| ----------------------------- | --------------------------------------------- | -------------------------------------------- | | ---------------------------------------- | --------------------------------------------- | -------------------------------------------------------- |
| `dict_Str(pairs ...string)` | Create string dictionary from key-value pairs | `{{ dict_Str "key1" "val1" "key2" "val2" }}` | | `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_Str(values ...string)` | Create string slice | `{{ slice_Str "a" "b" "c" }}` |
| `slice_Int(values ...int)` | Create int slice | `{{ slice_Int 1 2 3 }}` | | `slice_Join(slice []string, sep string)` | Join string slice with separator | `{{ slice_Join (slice_Str "a" "b" "c") "-" }}``a-b-c` |
| `slice_Uint(values ...uint)` | Create uint slice | `{{ slice_Uint 1 2 3 }}` | | `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 ### Body Functions
@@ -239,24 +239,24 @@ These functions are powered by [gofakeit](https://github.com/brianvoe/gofakeit)
### Address ### Address
| Function | Description | Example Output | | Function | Description | Example Output |
| --------------------------------------------------- | ---------------------------- | --------------------------------------------------- | | --------------------------------------------------- | ---------------------------- | ---------------------------------------------------- |
| `fakeit_City` | City name | `"Marcelside"` | | `fakeit_City` | City name | `"Marcelside"` |
| `fakeit_Country` | Country name | `"United States of America"` | | `fakeit_Country` | Country name | `"United States of America"` |
| `fakeit_CountryAbr` | Country abbreviation | `"US"` | | `fakeit_CountryAbr` | Country abbreviation | `"US"` |
| `fakeit_State` | State name | `"Illinois"` | | `fakeit_State` | State name | `"Illinois"` |
| `fakeit_StateAbr` | State abbreviation | `"IL"` | | `fakeit_StateAbr` | State abbreviation | `"IL"` |
| `fakeit_Street` | Full street | `"364 East Rapidsborough"` | | `fakeit_Street` | Full street | `"364 East Rapidsborough"` |
| `fakeit_StreetName` | Street name | `"View"` | | `fakeit_StreetName` | Street name | `"View"` |
| `fakeit_StreetNumber` | Street number | `"13645"` | | `fakeit_StreetNumber` | Street number | `"13645"` |
| `fakeit_StreetPrefix` | Street prefix | `"East"` | | `fakeit_StreetPrefix` | Street prefix | `"East"` |
| `fakeit_StreetSuffix` | Street suffix | `"Ave"` | | `fakeit_StreetSuffix` | Street suffix | `"Ave"` |
| `fakeit_Unit` | Unit | `"Apt 123"` | | `fakeit_Unit` | Unit | `"Apt 123"` |
| `fakeit_Zip` | ZIP code | `"13645"` | | `fakeit_Zip` | ZIP code | `"13645"` |
| `fakeit_Latitude` | Random latitude | `-73.534056` | | `fakeit_Latitude` | Random latitude | `-73.534056` |
| `fakeit_Longitude` | Random longitude | `-147.068112` | | `fakeit_Longitude` | Random longitude | `-147.068112` |
| `fakeit_LatitudeInRange(min float64, max float64)` | Latitude in specified range | `{{ fakeit_LatitudeInRange 0 90 }}``22.921026` | | `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` | | `fakeit_LongitudeInRange(min float64, max float64)` | Longitude in specified range | `{{ fakeit_LongitudeInRange 0 180 }}``122.471830` |
### Game ### Game
@@ -343,16 +343,16 @@ These functions are powered by [gofakeit](https://github.com/brianvoe/gofakeit)
### Text ### Text
| Function | Description | Example | | Function | Description | Example |
| ---------------------------------------------------------------------------------------- | ----------------------------------------------- | --------------------------------------------- | | ---------------------------------------------------------------------------------------- | ----------------------------------------------- | ----------------------------------------------------- |
| `fakeit_Sentence` | Random sentence | `{{ fakeit_Sentence }}` | | `fakeit_Sentence(wordCount ...int)` | Random sentence (optional word count) | `{{ fakeit_Sentence }}` or `{{ fakeit_Sentence 10 }}` |
| `fakeit_Paragraph` | Random paragraph | `{{ fakeit_Paragraph }}` | | `fakeit_Paragraph` | Random paragraph | `{{ fakeit_Paragraph }}` |
| `fakeit_LoremIpsumWord` | Lorem ipsum word | `"lorem"` | | `fakeit_LoremIpsumWord` | Lorem ipsum word | `"lorem"` |
| `fakeit_LoremIpsumSentence(wordCount int)` | Lorem ipsum sentence with specified word count | `{{ fakeit_LoremIpsumSentence 5 }}` | | `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_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_Question` | Random question | `"What is your name?"` |
| `fakeit_Quote` | Random quote | `"Life is what happens..."` | | `fakeit_Quote` | Random quote | `"Life is what happens..."` |
| `fakeit_Phrase` | Random phrase | `"a piece of cake"` | | `fakeit_Phrase` | Random phrase | `"a piece of cake"` |
### Foods ### Foods

View File

@@ -71,7 +71,6 @@ func (arg *stringSliceArg) Set(value string) error {
// Parse parses command-line arguments into a Config object. // Parse parses command-line arguments into a Config object.
// It can return the following errors: // It can return the following errors:
// - types.ErrCLINoArgs
// - types.CLIUnexpectedArgsError // - types.CLIUnexpectedArgsError
// - types.FieldParseErrors // - types.FieldParseErrors
func (parser ConfigCLIParser) Parse() (*Config, error) { func (parser ConfigCLIParser) Parse() (*Config, error) {
@@ -178,12 +177,6 @@ func (parser ConfigCLIParser) Parse() (*Config, error) {
panic(err) 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. // Check for any unexpected non-flag arguments remaining after parsing.
if args := flagSet.Args(); len(args) > 0 { if args := flagSet.Args(); len(args) > 0 {
return nil, types.NewCLIUnexpectedArgsError(args) return nil, types.NewCLIUnexpectedArgsError(args)

View File

@@ -275,7 +275,7 @@ func (config Config) Print() bool {
func (config *Config) Merge(newConfig *Config) { func (config *Config) Merge(newConfig *Config) {
config.Files = append(config.Files, newConfig.Files...) config.Files = append(config.Files, newConfig.Files...)
if len(newConfig.Methods) > 0 { if len(newConfig.Methods) > 0 {
config.Methods = append(config.Methods, newConfig.Methods...) config.Methods = newConfig.Methods
} }
if newConfig.URL != nil { if newConfig.URL != nil {
config.URL = newConfig.URL config.URL = newConfig.URL
@@ -317,7 +317,7 @@ func (config *Config) Merge(newConfig *Config) {
config.Cookies = append(config.Cookies, newConfig.Cookies...) config.Cookies = append(config.Cookies, newConfig.Cookies...)
} }
if len(newConfig.Bodies) != 0 { if len(newConfig.Bodies) != 0 {
config.Bodies = append(config.Bodies, newConfig.Bodies...) config.Bodies = newConfig.Bodies
} }
if len(newConfig.Proxies) != 0 { if len(newConfig.Proxies) != 0 {
config.Proxies.Append(newConfig.Proxies...) config.Proxies.Append(newConfig.Proxies...)
@@ -536,12 +536,6 @@ func ReadAllConfigs() *Config {
cliParser := NewConfigCLIParser(os.Args) cliParser := NewConfigCLIParser(os.Args)
cliConf, err := cliParser.Parse() cliConf, err := cliParser.Parse()
_ = utilsErr.MustHandle(err, _ = 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 { utilsErr.OnType(func(err types.CLIUnexpectedArgsError) error {
cliParser.PrintHelp() cliParser.PrintHelp()
fmt.Fprintln(os.Stderr, fmt.Fprintln(os.Stderr,

View File

@@ -3,6 +3,7 @@ package sarin
import ( import (
"context" "context"
"net/url" "net/url"
"os"
"strconv" "strconv"
"strings" "strings"
"sync" "sync"
@@ -13,6 +14,7 @@ import (
"github.com/charmbracelet/bubbles/spinner" "github.com/charmbracelet/bubbles/spinner"
tea "github.com/charmbracelet/bubbletea" tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss" "github.com/charmbracelet/lipgloss"
"github.com/charmbracelet/x/term"
"github.com/valyala/fasthttp" "github.com/valyala/fasthttp"
"go.aykhans.me/sarin/internal/script" "go.aykhans.me/sarin/internal/script"
"go.aykhans.me/sarin/internal/types" "go.aykhans.me/sarin/internal/types"
@@ -155,6 +157,10 @@ func (q sarin) Start(ctx context.Context) {
var messageChannel chan runtimeMessage var messageChannel chan runtimeMessage
var sendMessage messageSender var sendMessage messageSender
if !q.quiet && !term.IsTerminal(os.Stdout.Fd()) {
q.quiet = true
}
if q.quiet { if q.quiet {
sendMessage = func(level runtimeMessageLevel, text string) {} sendMessage = func(level runtimeMessageLevel, text string) {}
} else { } else {

View File

@@ -62,10 +62,6 @@ func NewDefaultTemplateFuncMap(randSource rand.Source, fileCache *FileCache) tem
}, },
"strings_TrimPrefix": strings.TrimPrefix, "strings_TrimPrefix": strings.TrimPrefix,
"strings_TrimSuffix": strings.TrimSuffix, "strings_TrimSuffix": strings.TrimSuffix,
"strings_Join": func(sep string, values ...string) string {
return strings.Join(values, sep)
},
// Dict // Dict
"dict_Str": func(values ...string) map[string]string { "dict_Str": func(values ...string) map[string]string {
dict := make(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_Str": func(values ...string) []string { return values },
"slice_Int": func(values ...int) []int { return values }, "slice_Int": func(values ...int) []int { return values },
"slice_Uint": func(values ...uint) []uint { return values }, "slice_Uint": func(values ...uint) []uint { return values },
"slice_Join": strings.Join,
// File // File
// file_Base64 reads a file (local or remote URL) and returns its Base64 encoded content. // file_Base64 reads a file (local or remote URL) and returns its Base64 encoded content.

View File

@@ -15,7 +15,7 @@ func (cookies Cookies) GetValue(key string) *[]string {
return nil return nil
} }
func (cookies *Cookies) Append(cookie ...Cookie) { func (cookies *Cookies) Merge(cookie ...Cookie) {
for _, c := range cookie { for _, c := range cookie {
if item := cookies.GetValue(c.Key); item != nil { if item := cookies.GetValue(c.Key); item != nil {
*item = append(*item, c.Value...) *item = append(*item, c.Value...)
@@ -27,7 +27,7 @@ func (cookies *Cookies) Append(cookie ...Cookie) {
func (cookies *Cookies) Parse(rawValues ...string) { func (cookies *Cookies) Parse(rawValues ...string) {
for _, rawValue := range rawValues { for _, rawValue := range rawValues {
cookies.Append(*ParseCookie(rawValue)) *cookies = append(*cookies, *ParseCookie(rawValue))
} }
} }

View File

@@ -250,10 +250,6 @@ func (e TemplateRenderError) Unwrap() error {
// ======================================== CLI ======================================== // ======================================== CLI ========================================
var (
ErrCLINoArgs = errors.New("CLI expects arguments but received none")
)
type CLIUnexpectedArgsError struct { type CLIUnexpectedArgsError struct {
Args []string Args []string
} }

View File

@@ -24,7 +24,7 @@ func (headers Headers) GetValue(key string) *[]string {
return nil return nil
} }
func (headers *Headers) Append(header ...Header) { func (headers *Headers) Merge(header ...Header) {
for _, h := range header { for _, h := range header {
if item := headers.GetValue(h.Key); item != nil { if item := headers.GetValue(h.Key); item != nil {
*item = append(*item, h.Value...) *item = append(*item, h.Value...)
@@ -36,7 +36,7 @@ func (headers *Headers) Append(header ...Header) {
func (headers *Headers) Parse(rawValues ...string) { func (headers *Headers) Parse(rawValues ...string) {
for _, rawValue := range rawValues { for _, rawValue := range rawValues {
headers.Append(*ParseHeader(rawValue)) *headers = append(*headers, *ParseHeader(rawValue))
} }
} }

View File

@@ -15,7 +15,7 @@ func (params Params) GetValue(key string) *[]string {
return nil return nil
} }
func (params *Params) Append(param ...Param) { func (params *Params) Merge(param ...Param) {
for _, p := range param { for _, p := range param {
if item := params.GetValue(p.Key); item != nil { if item := params.GetValue(p.Key); item != nil {
*item = append(*item, p.Value...) *item = append(*item, p.Value...)
@@ -27,7 +27,7 @@ func (params *Params) Append(param ...Param) {
func (params *Params) Parse(rawValues ...string) { func (params *Params) Parse(rawValues ...string) {
for _, rawValue := range rawValues { for _, rawValue := range rawValues {
params.Append(*ParseParam(rawValue)) *params = append(*params, *ParseParam(rawValue))
} }
} }