7 Commits

Author SHA1 Message Date
1369cb9f09 Merge pull request #165 from aykhans/feat/file-upload-support
Add file upload support with body_FormData and file_Base64 functions
2026-01-17 21:19:19 +04:00
18662e6a64 Add file upload examples and fix templating.md table of contents 2026-01-17 21:18:37 +04:00
81f08edc8d Add file upload support with body_FormData and file_Base64 functions
- Add FileCache for caching local files and remote URLs in memory
- Update body_FormData to accept variadic key-value pairs with file support
  - Use @ prefix for file paths (local or HTTP/HTTPS URLs)
  - Use @@ to escape literal @ values
- Add file_Base64 function for Base64 encoding files
- Update documentation with new syntax and examples
2026-01-17 20:27:22 +04:00
a9738c0a11 Merge pull request #164 from aykhans/docs/improvements
Fix config priority order (CLI > YAML > ENV), clarify multi-value cycling behavior, and improve documentation examples
2026-01-16 23:17:28 +04:00
76225884e6 Fix config priority order (CLI > YAML > ENV), clarify multi-value cycling behavior, and improve documentation examples 2026-01-16 23:15:12 +04:00
a512f3605d Merge pull request #163 from aykhans/dependabot/go_modules/golang.org/x/net-0.49.0
Bump golang.org/x/net from 0.48.0 to 0.49.0
2026-01-13 11:24:54 +04:00
dependabot[bot]
635c33008b Bump golang.org/x/net from 0.48.0 to 0.49.0
Bumps [golang.org/x/net](https://github.com/golang/net) from 0.48.0 to 0.49.0.
- [Commits](https://github.com/golang/net/compare/v0.48.0...v0.49.0)

---
updated-dependencies:
- dependency-name: golang.org/x/net
  dependency-version: 0.49.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-01-13 04:22:03 +00:00
12 changed files with 460 additions and 52 deletions

View File

@@ -29,7 +29,6 @@ linters:
- errorlint - errorlint
- exptostd - exptostd
- fatcontext - fatcontext
- forcetypeassert
- funcorder - funcorder
- gocheckcompilerdirectives - gocheckcompilerdirectives
- gocritic - gocritic

View File

@@ -93,7 +93,7 @@ For more usage examples, see the **[Examples Guide](docs/examples.md)**.
Sarin supports environment variables, CLI flags, and YAML files. When the same option is specified in multiple sources, the following priority order applies: Sarin supports environment variables, CLI flags, and YAML files. When the same option is specified in multiple sources, the following priority order applies:
``` ```
YAML (Highest) > CLI Flags > Environment Variables (Lowest) CLI Flags (Highest) > YAML > Environment Variables (Lowest)
``` ```
For detailed documentation on all configuration options (URL, method, timeout, concurrency, headers, cookies, proxy, etc.), see the **[Configuration Guide](docs/configuration.md)**. For detailed documentation on all configuration options (URL, method, timeout, concurrency, headers, cookies, proxy, etc.), see the **[Configuration Guide](docs/configuration.md)**.
@@ -106,9 +106,9 @@ Sarin supports Go templates in URL paths, methods, bodies, headers, params, cook
```sh ```sh
sarin -U "http://example.com/users/{{ fakeit_UUID }}" -r 1000 -c 10 \ sarin -U "http://example.com/users/{{ fakeit_UUID }}" -r 1000 -c 10 \
-V "RequestID={{ fakeit_UUID }}" \ -V "REQUEST_ID={{ fakeit_UUID }}" \
-H "X-Request-ID: {{ .Values.RequestID }}" \ -H "X-Request-ID: {{ .Values.REQUEST_ID }}" \
-B '{"request_id": "{{ .Values.RequestID }}"}' -B '{"request_id": "{{ .Values.REQUEST_ID }}"}'
``` ```
For the complete templating guide and functions reference, see the **[Templating Guide](docs/templating.md)**. For the complete templating guide and functions reference, see the **[Templating Guide](docs/templating.md)**.

View File

@@ -5,7 +5,7 @@ Sarin supports environment variables, CLI flags, and YAML files. However, they a
When the same option is specified in multiple sources, the following priority order applies: When the same option is specified in multiple sources, the following priority order applies:
``` ```
YAML (Highest) > CLI Flags > Environment Variables (Lowest) CLI Flags (Highest) > YAML > Environment Variables (Lowest)
``` ```
Use `-s` or `--show-config` to see the final merged configuration before sending requests. Use `-s` or `--show-config` to see the final merged configuration before sending requests.
@@ -14,8 +14,8 @@ Use `-s` or `--show-config` to see the final merged configuration before sending
> **Note:** For CLI flags with `string / []string` type, the flag can be used once with a single value or multiple times to provide multiple values. > **Note:** For CLI flags with `string / []string` type, the flag can be used once with a single value or multiple times to provide multiple values.
| Name | YAML | CLI | ENV | Default | Description | | Name | YAML | CLI | ENV | Default | Description |
| --------------------------- | ----------------------------------- | --------------------------------------------- | -------------------------------- | ------- | ---------------------------- | | --------------------------- | ----------------------------------- | -------------------------------------------- | -------------------------------- | ------- | ---------------------------- |
| [Help](#help) | - | `-help` / `-h` | - | - | Show help message | | [Help](#help) | - | `-help` / `-h` | - | - | Show help message |
| [Version](#version) | - | `-version` / `-v` | - | - | Show version and build info | | [Version](#version) | - | `-version` / `-v` | - | - | Show version and build info |
| [Show Config](#show-config) | `showConfig`<br>(boolean) | `-show-config` / `-s`<br>(boolean) | `SARIN_SHOW_CONFIG`<br>(boolean) | `false` | Show merged configuration | | [Show Config](#show-config) | `showConfig`<br>(boolean) | `-show-config` / `-s`<br>(boolean) | `SARIN_SHOW_CONFIG`<br>(boolean) | `false` | Show merged configuration |
@@ -43,36 +43,65 @@ Use `-s` or `--show-config` to see the final merged configuration before sending
Show help message. Show help message.
```sh
sarin -help
```
## Version ## Version
Show version and build information. Show version and build information.
```sh
sarin -version
```
## Show Config ## Show Config
Show the final merged configuration before sending requests. Show the final merged configuration before sending requests.
```sh
sarin -show-config
```
## Config File ## Config File
Path to configuration file(s). Supports local paths and remote URLs. Path to configuration file(s). Supports local paths and remote URLs.
If multiple config files are specified, they are merged in order. Later files override earlier ones. **Priority Rules:**
1. **CLI flags** (`-f`) have highest priority, processed left to right (rightmost wins)
2. **Included files** (via `configFile` property) are processed with lower priority than their parent
3. **Environment variable** (`SARIN_CONFIG_FILE`) has lowest priority
**Example:** **Example:**
```yaml ```yaml
# config2.yaml # config2.yaml
configFile: /config4.yaml configFile: /config4.yaml
url: http://from-config2.com
``` ```
```sh ```sh
SARIN_CONFIG_FILE=/config1.yaml sarin -f /config2.yaml -f https://example.com/config3.yaml SARIN_CONFIG_FILE=/config1.yaml sarin -f /config2.yaml -f https://example.com/config3.yaml
``` ```
In this example, all 4 config files are read and merged with the following priority: **Resolution order (lowest to highest priority):**
``` | Source | File | Priority |
config3.yaml > config2.yaml > config4.yaml > config1.yaml | ------------------------ | ------------ | -------- |
``` | ENV (SARIN_CONFIG_FILE) | config1.yaml | Lowest |
| Included by config2.yaml | config4.yaml | ↑ |
| CLI -f (first) | config2.yaml | ↑ |
| CLI -f (second) | config3.yaml | Highest |
**Why this order?**
- `config1.yaml` comes from ENV → lowest priority
- `config2.yaml` comes from CLI → higher than ENV
- `config4.yaml` is included BY `config2.yaml` → inherits position below its parent
- `config3.yaml` comes from CLI after `config2.yaml` → highest priority
If all four files define `url`, the value from `config3.yaml` wins.
## URL ## URL
@@ -94,7 +123,7 @@ sarin -U "http://example.com/users/{{ fakeit_UUID }}" -r 1000 -c 10
## Method ## Method
HTTP method(s). If multiple values are provided, Sarin cycles through them randomly for each request. Supports [templating](templating.md). HTTP method(s). If multiple values are provided, Sarin cycles through them in order, starting from a random index for each request. Supports [templating](templating.md).
**YAML example:** **YAML example:**
@@ -167,7 +196,7 @@ Skip TLS certificate verification.
## Body ## Body
Request body. If multiple values are provided, Sarin cycles through them randomly for each request. Supports [templating](templating.md). Request body. If multiple values are provided, Sarin cycles through them in order, starting from a random index for each request. Supports [templating](templating.md).
**YAML example:** **YAML example:**
@@ -196,7 +225,7 @@ SARIN_BODY='{"product": "car"}'
## Params ## Params
URL query parameters. If multiple values are provided for a key, Sarin cycles through them randomly for each request. Supports [templating](templating.md). 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).
**YAML example:** **YAML example:**
@@ -226,7 +255,7 @@ SARIN_PARAM="key1=value1"
## Headers ## Headers
HTTP headers. If multiple values are provided for a key, Sarin cycles through them randomly for each request. Supports [templating](templating.md). 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).
**YAML example:** **YAML example:**
@@ -256,7 +285,7 @@ SARIN_HEADER="key1: value1"
## Cookies ## Cookies
HTTP cookies. If multiple values are provided for a key, Sarin cycles through them randomly for each request. Supports [templating](templating.md). 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).
**YAML example:** **YAML example:**
@@ -286,7 +315,7 @@ SARIN_COOKIE="key1=value1"
## Proxy ## Proxy
Proxy URL(s). If multiple values are provided, Sarin cycles through them randomly for each request. Proxy URL(s). If multiple values are provided, Sarin cycles through them in order, starting from a random index for each request.
Supported protocols: `http`, `https`, `socks5`, `socks5h` Supported protocols: `http`, `https`, `socks5`, `socks5h`

View File

@@ -9,6 +9,7 @@ This guide provides practical examples for common Sarin use cases.
- [Headers, Cookies, and Parameters](#headers-cookies-and-parameters) - [Headers, Cookies, and Parameters](#headers-cookies-and-parameters)
- [Dynamic Requests with Templating](#dynamic-requests-with-templating) - [Dynamic Requests with Templating](#dynamic-requests-with-templating)
- [Request Bodies](#request-bodies) - [Request Bodies](#request-bodies)
- [File Uploads](#file-uploads)
- [Using Proxies](#using-proxies) - [Using Proxies](#using-proxies)
- [Output Formats](#output-formats) - [Output Formats](#output-formats)
- [Docker Usage](#docker-usage) - [Docker Usage](#docker-usage)
@@ -175,8 +176,8 @@ url: http://example.com/search
requests: 1000 requests: 1000
concurrency: 10 concurrency: 10
params: params:
query: test query: "test"
limit: "10" limit: 10
``` ```
</details> </details>
@@ -198,7 +199,7 @@ requests: 1000
concurrency: 10 concurrency: 10
params: params:
id: "{{ fakeit_IntRange 1 1000 }}" id: "{{ fakeit_IntRange 1 1000 }}"
fields: name,email fields: "name,email"
``` ```
</details> </details>
@@ -456,7 +457,7 @@ body: |
```sh ```sh
sarin -U http://example.com/api/upload -r 1000 -c 10 \ sarin -U http://example.com/api/upload -r 1000 -c 10 \
-M POST \ -M POST \
-B '{{ body_FormData (dict_Str "username" "john" "email" "john@example.com") }}' -B '{{ body_FormData "username" "john" "email" "john@example.com" }}'
``` ```
<details> <details>
@@ -467,7 +468,7 @@ url: http://example.com/api/upload
requests: 1000 requests: 1000
concurrency: 10 concurrency: 10
method: POST method: POST
body: '{{ body_FormData (dict_Str "username" "john" "email" "john@example.com") }}' body: '{{ body_FormData "username" "john" "email" "john@example.com" }}'
``` ```
</details> </details>
@@ -477,7 +478,7 @@ body: '{{ body_FormData (dict_Str "username" "john" "email" "john@example.com")
```sh ```sh
sarin -U http://example.com/api/users -r 1000 -c 10 \ sarin -U http://example.com/api/users -r 1000 -c 10 \
-M POST \ -M POST \
-B '{{ body_FormData (dict_Str "name" (fakeit_Name) "email" (fakeit_Email) "phone" (fakeit_Phone)) }}' -B '{{ body_FormData "name" (fakeit_Name) "email" (fakeit_Email) "phone" (fakeit_Phone) }}'
``` ```
<details> <details>
@@ -488,13 +489,160 @@ url: http://example.com/api/users
requests: 1000 requests: 1000
concurrency: 10 concurrency: 10
method: POST method: POST
body: '{{ body_FormData (dict_Str "name" (fakeit_Name) "email" (fakeit_Email) "phone" (fakeit_Phone)) }}' body: '{{ body_FormData "name" (fakeit_Name) "email" (fakeit_Email) "phone" (fakeit_Phone) }}'
``` ```
</details> </details>
> **Note:** `body_FormData` automatically sets the `Content-Type` header to `multipart/form-data` with the appropriate boundary. > **Note:** `body_FormData` automatically sets the `Content-Type` header to `multipart/form-data` with the appropriate boundary.
## File Uploads
**File upload with multipart form data:**
Upload a local file:
```sh
sarin -U http://example.com/api/upload -r 100 -c 10 \
-M POST \
-B '{{ body_FormData "title" "My Document" "document" "@/path/to/file.pdf" }}'
```
<details>
<summary>YAML equivalent</summary>
```yaml
url: http://example.com/api/upload
requests: 100
concurrency: 10
method: POST
body: '{{ body_FormData "title" "My Document" "document" "@/path/to/file.pdf" }}'
```
</details>
**Multiple file uploads (same field name):**
```sh
sarin -U http://example.com/api/upload -r 100 -c 10 \
-M POST \
-B '{{ body_FormData "files" "@/path/to/file1.pdf" "files" "@/path/to/file2.pdf" }}'
```
<details>
<summary>YAML equivalent</summary>
```yaml
url: http://example.com/api/upload
requests: 100
concurrency: 10
method: POST
body: |
{{ body_FormData
"files" "@/path/to/file1.pdf"
"files" "@/path/to/file2.pdf"
}}
```
</details>
**Multiple file uploads (different field names):**
```sh
sarin -U http://example.com/api/upload -r 100 -c 10 \
-M POST \
-B '{{ body_FormData "avatar" "@/path/to/photo.jpg" "resume" "@/path/to/cv.pdf" "cover_letter" "@/path/to/letter.docx" }}'
```
<details>
<summary>YAML equivalent</summary>
```yaml
url: http://example.com/api/upload
requests: 100
concurrency: 10
method: POST
body: |
{{ body_FormData
"avatar" "@/path/to/photo.jpg"
"resume" "@/path/to/cv.pdf"
"cover_letter" "@/path/to/letter.docx"
}}
```
</details>
**File from URL:**
```sh
sarin -U http://example.com/api/upload -r 100 -c 10 \
-M POST \
-B '{{ body_FormData "image" "@https://example.com/photo.jpg" }}'
```
<details>
<summary>YAML equivalent</summary>
```yaml
url: http://example.com/api/upload
requests: 100
concurrency: 10
method: POST
body: '{{ body_FormData "image" "@https://example.com/photo.jpg" }}'
```
</details>
> **Note:** Files (local and remote) are cached in memory after the first read, so they are not re-read for every request.
**Base64 encoded file in JSON body (local file):**
```sh
sarin -U http://example.com/api/upload -r 100 -c 10 \
-M POST \
-H "Content-Type: application/json" \
-B '{"file": "{{ file_Base64 "/path/to/file.pdf" }}", "filename": "document.pdf"}'
```
<details>
<summary>YAML equivalent</summary>
```yaml
url: http://example.com/api/upload
requests: 100
concurrency: 10
method: POST
headers:
Content-Type: application/json
body: '{"file": "{{ file_Base64 "/path/to/file.pdf" }}", "filename": "document.pdf"}'
```
</details>
**Base64 encoded file in JSON body (remote URL):**
```sh
sarin -U http://example.com/api/upload -r 100 -c 10 \
-M POST \
-H "Content-Type: application/json" \
-B '{"image": "{{ file_Base64 "https://example.com/photo.jpg" }}", "filename": "photo.jpg"}'
```
<details>
<summary>YAML equivalent</summary>
```yaml
url: http://example.com/api/upload
requests: 100
concurrency: 10
method: POST
headers:
Content-Type: application/json
body: '{"image": "{{ file_Base64 "https://example.com/photo.jpg" }}", "filename": "photo.jpg"}'
```
</details>
## Using Proxies ## Using Proxies
**Single HTTP proxy:** **Single HTTP proxy:**

View File

@@ -11,6 +11,7 @@ Sarin supports Go templates in URL paths, methods, bodies, headers, params, cook
- [String Functions](#string-functions) - [String Functions](#string-functions)
- [Collection Functions](#collection-functions) - [Collection Functions](#collection-functions)
- [Body Functions](#body-functions) - [Body Functions](#body-functions)
- [File Functions](#file-functions)
- [Fake Data Functions](#fake-data-functions) - [Fake Data Functions](#fake-data-functions)
- [File](#file) - [File](#file)
- [ID](#id) - [ID](#id)
@@ -110,9 +111,63 @@ sarin -U http://example.com/users \
### Body Functions ### Body Functions
| Function | Description | Example | | Function | Description | Example |
| ----------------------------------------- | ------------------------------------------------------------------------ | -------------------------------------------------- | | -------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------- |
| `body_FormData(fields map[string]string)` | Create multipart form data. Automatically sets the `Content-Type` header | `{{ body_FormData (dict_Str "field1" "value1") }}` | | `body_FormData(pairs ...string)` | Create multipart form data from key-value pairs. Automatically sets the `Content-Type` header. Values starting with `@` are treated as file references (local path or URL). Use `@@` to escape literal `@`. | `{{ body_FormData "field1" "value1" "file" "@/path/to/file.pdf" }}` |
**`body_FormData` Details:**
```yaml
# Text fields only
body: '{{ body_FormData "username" "john" "email" "john@example.com" }}'
# Single file upload
body: '{{ body_FormData "document" "@/path/to/file.pdf" }}'
# File from URL
body: '{{ body_FormData "image" "@https://example.com/photo.jpg" }}'
# Mixed text fields and files
body: |
{{ body_FormData
"title" "My Report"
"author" "John Doe"
"cover" "@/path/to/cover.jpg"
"document" "@/path/to/report.pdf"
}}
# Multiple files with same field name
body: |
{{ body_FormData
"files" "@/path/to/file1.pdf"
"files" "@/path/to/file2.pdf"
}}
# Escape @ for literal value (sends "@username")
body: '{{ body_FormData "twitter" "@@username" }}'
```
> **Note:** Files are cached in memory after the first read. Subsequent requests reuse the cached content, avoiding repeated disk/network I/O.
### File Functions
| Function | Description | Example |
| ---------------------------- | --------------------------------------------------------------------------------------------------------- | --------------------------------------- |
| `file_Base64(source string)` | Read a file (local path or URL) and return its Base64 encoded content. Files are cached after first read. | `{{ file_Base64 "/path/to/file.pdf" }}` |
**`file_Base64` Details:**
```yaml
# Local file as Base64 in JSON body
body: '{"file": "{{ file_Base64 "/path/to/document.pdf" }}", "filename": "document.pdf"}'
# Remote file as Base64
body: '{"image": "{{ file_Base64 "https://example.com/photo.jpg" }}"}'
# Combined with values for reuse
values: "FILE_DATA={{ file_Base64 \"/path/to/file.bin\" }}"
body: '{"data": "{{ .Values.FILE_DATA }}"}'
```
## Fake Data Functions ## Fake Data Functions
@@ -532,7 +587,7 @@ These functions are powered by [gofakeit](https://github.com/brianvoe/gofakeit)
| Function | Description | Example | | Function | Description | Example |
| ------------------------------------- | ------------------------------- | --------------------------------------------------------------- | | ------------------------------------- | ------------------------------- | --------------------------------------------------------------- |
| `fakeit_Digit` | Single random digit | `"0"` | | `fakeit_Digit` | Single random digit | `"0"` |
| `fakeit_DigitN(n uint)` | Generate `n` random digits | `{{ fakeit_DigitN 5 }}``"0136459948"` | | `fakeit_DigitN(n uint)` | Generate `n` random digits | `{{ fakeit_DigitN 5 }}``"71364"` |
| `fakeit_Letter` | Single random letter | `"g"` | | `fakeit_Letter` | Single random letter | `"g"` |
| `fakeit_LetterN(n uint)` | Generate `n` random letters | `{{ fakeit_LetterN 10 }}``"gbRMaRxHki"` | | `fakeit_LetterN(n uint)` | Generate `n` random letters | `{{ fakeit_LetterN 10 }}``"gbRMaRxHki"` |
| `fakeit_Lexify(pattern string)` | Replace `?` with random letters | `{{ fakeit_Lexify "?????@??????.com" }}``"billy@mister.com"` | | `fakeit_Lexify(pattern string)` | Replace `?` with random letters | `{{ fakeit_Lexify "?????@??????.com" }}``"billy@mister.com"` |

6
go.mod
View File

@@ -13,7 +13,7 @@ require (
github.com/valyala/fasthttp v1.69.0 github.com/valyala/fasthttp v1.69.0
go.aykhans.me/utils v1.0.7 go.aykhans.me/utils v1.0.7
go.yaml.in/yaml/v4 v4.0.0-rc.3 go.yaml.in/yaml/v4 v4.0.0-rc.3
golang.org/x/net v0.48.0 golang.org/x/net v0.49.0
) )
require ( require (
@@ -50,6 +50,6 @@ require (
github.com/yuin/goldmark v1.7.16 // indirect github.com/yuin/goldmark v1.7.16 // indirect
github.com/yuin/goldmark-emoji v1.0.6 // indirect github.com/yuin/goldmark-emoji v1.0.6 // indirect
golang.org/x/sys v0.40.0 // indirect golang.org/x/sys v0.40.0 // indirect
golang.org/x/term v0.38.0 // indirect golang.org/x/term v0.39.0 // indirect
golang.org/x/text v0.32.0 // indirect golang.org/x/text v0.33.0 // indirect
) )

12
go.sum
View File

@@ -101,15 +101,15 @@ go.yaml.in/yaml/v4 v4.0.0-rc.3 h1:3h1fjsh1CTAPjW7q/EMe+C8shx5d8ctzZTrLcs/j8Go=
go.yaml.in/yaml/v4 v4.0.0-rc.3/go.mod h1:aZqd9kCMsGL7AuUv/m/PvWLdg5sjJsZ4oHDEnfPPfY0= go.yaml.in/yaml/v4 v4.0.0-rc.3/go.mod h1:aZqd9kCMsGL7AuUv/m/PvWLdg5sjJsZ4oHDEnfPPfY0=
golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI= golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI=
golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo= golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo=
golang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU= golang.org/x/net v0.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o=
golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY= golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8=
golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ= golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ=
golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/term v0.38.0 h1:PQ5pkm/rLO6HnxFR7N2lJHOZX6Kez5Y1gDSJla6jo7Q= golang.org/x/term v0.39.0 h1:RclSuaJf32jOqZz74CkPA9qFuVTX7vhLlpfj/IGWlqY=
golang.org/x/term v0.38.0/go.mod h1:bSEAKrOT1W+VSu9TSCMtoGEOUcKxOKgl3LE5QEF/xVg= golang.org/x/term v0.39.0/go.mod h1:yxzUCTP/U+FzoxfdKmLaA0RV1WgE0VY7hXBwKtY/4ww=
golang.org/x/text v0.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU= golang.org/x/text v0.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE=
golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY= golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

View File

@@ -191,11 +191,12 @@ func validateTemplateURLPath(urlPath string, funcMap template.FuncMap) []types.F
func ValidateTemplates(config *Config) []types.FieldValidationError { func ValidateTemplates(config *Config) []types.FieldValidationError {
// Create template function map using the same functions as sarin package // Create template function map using the same functions as sarin package
// Use nil for fileCache during validation - templates are only parsed, not executed
randSource := sarin.NewDefaultRandSource() randSource := sarin.NewDefaultRandSource()
funcMap := sarin.NewDefaultTemplateFuncMap(randSource) funcMap := sarin.NewDefaultTemplateFuncMap(randSource, nil)
bodyFuncMapData := &sarin.BodyTemplateFuncMapData{} bodyFuncMapData := &sarin.BodyTemplateFuncMapData{}
bodyFuncMap := sarin.NewDefaultBodyTemplateFuncMap(randSource, bodyFuncMapData) bodyFuncMap := sarin.NewDefaultBodyTemplateFuncMap(randSource, bodyFuncMapData, nil)
var allErrors []types.FieldValidationError var allErrors []types.FieldValidationError

102
internal/sarin/filecache.go Normal file
View File

@@ -0,0 +1,102 @@
package sarin
import (
"fmt"
"io"
"net/http"
"os"
"path"
"path/filepath"
"strings"
"sync"
"time"
)
// CachedFile holds the cached content and metadata of a file.
type CachedFile struct {
Content []byte
Filename string
}
type FileCache struct {
cache sync.Map // map[string]*CachedFile
requestTimeout time.Duration
}
func NewFileCache(requestTimeout time.Duration) *FileCache {
return &FileCache{
requestTimeout: requestTimeout,
}
}
// GetOrLoad retrieves a file from cache or loads it using the provided source.
// The source can be a local file path or an HTTP/HTTPS URL.
func (fc *FileCache) GetOrLoad(source string) (*CachedFile, error) {
if val, ok := fc.cache.Load(source); ok {
return val.(*CachedFile), nil
}
var (
content []byte
filename string
err error
)
if strings.HasPrefix(source, "http://") || strings.HasPrefix(source, "https://") {
content, filename, err = fc.fetchURL(source)
} else {
content, filename, err = fc.readLocalFile(source)
}
if err != nil {
return nil, err
}
file := &CachedFile{Content: content, Filename: filename}
// LoadOrStore handles race condition - if another goroutine
// cached it first, we get theirs (no duplicate storage)
actual, _ := fc.cache.LoadOrStore(source, file)
return actual.(*CachedFile), nil
}
func (fc *FileCache) readLocalFile(filePath string) ([]byte, string, error) {
content, err := os.ReadFile(filePath) //nolint:gosec
if err != nil {
return nil, "", fmt.Errorf("failed to read file %s: %w", filePath, err)
}
return content, filepath.Base(filePath), nil
}
func (fc *FileCache) fetchURL(url string) ([]byte, string, error) {
client := &http.Client{
Timeout: fc.requestTimeout,
}
resp, err := client.Get(url)
if err != nil {
return nil, "", fmt.Errorf("failed to fetch URL %s: %w", url, err)
}
defer resp.Body.Close() //nolint:errcheck
if resp.StatusCode != http.StatusOK {
return nil, "", fmt.Errorf("failed to fetch URL %s: HTTP %d", url, resp.StatusCode)
}
content, err := io.ReadAll(resp.Body)
if err != nil {
return nil, "", fmt.Errorf("failed to read response body from %s: %w", url, err)
}
// Extract filename from URL path
filename := path.Base(url)
if filename == "" || filename == "/" || filename == "." {
filename = "downloaded_file"
}
// Remove query string from filename if present
if idx := strings.Index(filename, "?"); idx != -1 {
filename = filename[:idx]
}
return content, filename, nil
}

View File

@@ -34,11 +34,12 @@ func NewRequestGenerator(
cookies types.Cookies, cookies types.Cookies,
bodies []string, bodies []string,
values []string, values []string,
fileCache *FileCache,
) (RequestGenerator, bool) { ) (RequestGenerator, bool) {
randSource := NewDefaultRandSource() randSource := NewDefaultRandSource()
//nolint:gosec // G404: Using non-cryptographic rand for load testing, not security //nolint:gosec // G404: Using non-cryptographic rand for load testing, not security
localRand := rand.New(randSource) localRand := rand.New(randSource)
templateFuncMap := NewDefaultTemplateFuncMap(randSource) templateFuncMap := NewDefaultTemplateFuncMap(randSource, fileCache)
pathGenerator, isPathGeneratorDynamic := createTemplateFunc(requestURL.Path, templateFuncMap) pathGenerator, isPathGeneratorDynamic := createTemplateFunc(requestURL.Path, templateFuncMap)
methodGenerator, isMethodGeneratorDynamic := NewMethodGeneratorFunc(localRand, methods, templateFuncMap) methodGenerator, isMethodGeneratorDynamic := NewMethodGeneratorFunc(localRand, methods, templateFuncMap)
@@ -47,7 +48,7 @@ func NewRequestGenerator(
cookiesGenerator, isCookiesGeneratorDynamic := NewCookiesGeneratorFunc(localRand, cookies, templateFuncMap) cookiesGenerator, isCookiesGeneratorDynamic := NewCookiesGeneratorFunc(localRand, cookies, templateFuncMap)
bodyTemplateFuncMapData := &BodyTemplateFuncMapData{} bodyTemplateFuncMapData := &BodyTemplateFuncMapData{}
bodyTemplateFuncMap := NewDefaultBodyTemplateFuncMap(randSource, bodyTemplateFuncMapData) bodyTemplateFuncMap := NewDefaultBodyTemplateFuncMap(randSource, bodyTemplateFuncMapData, fileCache)
bodyGenerator, isBodyGeneratorDynamic := NewBodyGeneratorFunc(localRand, bodies, bodyTemplateFuncMap) bodyGenerator, isBodyGeneratorDynamic := NewBodyGeneratorFunc(localRand, bodies, bodyTemplateFuncMap)
valuesGenerator := NewValuesGeneratorFunc(values, templateFuncMap) valuesGenerator := NewValuesGeneratorFunc(values, templateFuncMap)

View File

@@ -51,6 +51,7 @@ type sarin struct {
hostClients []*fasthttp.HostClient hostClients []*fasthttp.HostClient
responses *SarinResponseData responses *SarinResponseData
fileCache *FileCache
} }
// NewSarin creates a new sarin instance for load testing. // NewSarin creates a new sarin instance for load testing.
@@ -101,6 +102,7 @@ func NewSarin(
collectStats: collectStats, collectStats: collectStats,
dryRun: dryRun, dryRun: dryRun,
hostClients: hostClients, hostClients: hostClients,
fileCache: NewFileCache(time.Second * 10),
} }
if collectStats { if collectStats {
@@ -191,7 +193,7 @@ func (q sarin) Worker(
defer fasthttp.ReleaseRequest(req) defer fasthttp.ReleaseRequest(req)
defer fasthttp.ReleaseResponse(resp) defer fasthttp.ReleaseResponse(resp)
requestGenerator, isDynamic := NewRequestGenerator(q.methods, q.requestURL, q.params, q.headers, q.cookies, q.bodies, q.values) requestGenerator, isDynamic := NewRequestGenerator(q.methods, q.requestURL, q.params, q.headers, q.cookies, q.bodies, q.values, q.fileCache)
if q.dryRun { if q.dryRun {
switch { switch {

View File

@@ -2,6 +2,8 @@ package sarin
import ( import (
"bytes" "bytes"
"encoding/base64"
"errors"
"math/rand/v2" "math/rand/v2"
"mime/multipart" "mime/multipart"
"strings" "strings"
@@ -12,7 +14,7 @@ import (
"github.com/brianvoe/gofakeit/v7" "github.com/brianvoe/gofakeit/v7"
) )
func NewDefaultTemplateFuncMap(randSource rand.Source) template.FuncMap { func NewDefaultTemplateFuncMap(randSource rand.Source, fileCache *FileCache) template.FuncMap {
fakeit := gofakeit.NewFaker(randSource, false) fakeit := gofakeit.NewFaker(randSource, false)
return template.FuncMap{ return template.FuncMap{
@@ -82,6 +84,21 @@ func NewDefaultTemplateFuncMap(randSource rand.Source) template.FuncMap {
"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 },
// File
// file_Base64 reads a file (local or remote URL) and returns its Base64 encoded content.
// Usage: {{ file_Base64 "/path/to/file.pdf" }}
// {{ file_Base64 "https://example.com/image.png" }}
"file_Base64": func(source string) (string, error) {
if fileCache == nil {
return "", errors.New("file cache is not initialized")
}
cached, err := fileCache.GetOrLoad(source)
if err != nil {
return "", err
}
return base64.StdEncoding.EncodeToString(cached.Content), nil
},
// Fakeit / File // Fakeit / File
// "fakeit_CSV": fakeit.CSV(nil), // "fakeit_CSV": fakeit.CSV(nil),
// "fakeit_JSON": fakeit.JSON(nil), // "fakeit_JSON": fakeit.JSON(nil),
@@ -542,21 +559,75 @@ func (data *BodyTemplateFuncMapData) ClearFormDataContenType() {
data.formDataContenType = "" data.formDataContenType = ""
} }
func NewDefaultBodyTemplateFuncMap(randSource rand.Source, data *BodyTemplateFuncMapData) template.FuncMap { func NewDefaultBodyTemplateFuncMap(
funcMap := NewDefaultTemplateFuncMap(randSource) randSource rand.Source,
data *BodyTemplateFuncMapData,
fileCache *FileCache,
) template.FuncMap {
funcMap := NewDefaultTemplateFuncMap(randSource, fileCache)
if data != nil { if data != nil {
funcMap["body_FormData"] = func(kv map[string]string) string { // body_FormData creates a multipart/form-data body from key-value pairs.
// Usage: {{ body_FormData "field1" "value1" "field2" "value2" ... }}
//
// Values starting with "@" are treated as file references:
// - "@/path/to/file.txt" - local file
// - "@http://example.com/file" - remote file via HTTP
// - "@https://example.com/file" - remote file via HTTPS
//
// To send a literal string starting with "@", escape it with "@@":
// - "@@literal" sends "@literal"
//
// Example with mixed text and files:
// {{ body_FormData "name" "John" "avatar" "@/path/to/photo.jpg" "doc" "@https://example.com/file.pdf" }}
funcMap["body_FormData"] = func(pairs ...string) (string, error) {
if len(pairs)%2 != 0 {
return "", errors.New("body_FormData requires an even number of arguments (key-value pairs)")
}
var multipartData bytes.Buffer var multipartData bytes.Buffer
writer := multipart.NewWriter(&multipartData) writer := multipart.NewWriter(&multipartData)
data.formDataContenType = writer.FormDataContentType() data.formDataContenType = writer.FormDataContentType()
for k, v := range kv { for i := 0; i < len(pairs); i += 2 {
_ = writer.WriteField(k, v) key := pairs[i]
val := pairs[i+1]
switch {
case strings.HasPrefix(val, "@@"):
// Escaped @ - send as literal string without first @
if err := writer.WriteField(key, val[1:]); err != nil {
return "", err
}
case strings.HasPrefix(val, "@"):
// File (local path or remote URL)
if fileCache == nil {
return "", errors.New("file cache is not initialized")
}
source := val[1:]
cached, err := fileCache.GetOrLoad(source)
if err != nil {
return "", err
}
part, err := writer.CreateFormFile(key, cached.Filename)
if err != nil {
return "", err
}
if _, err := part.Write(cached.Content); err != nil {
return "", err
}
default:
// Regular text field
if err := writer.WriteField(key, val); err != nil {
return "", err
}
}
} }
_ = writer.Close() if err := writer.Close(); err != nil {
return multipartData.String() return "", err
}
return multipartData.String(), nil
} }
} }