10 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
3f2147ec6c Merge pull request #162 from aykhans/feat/url-path-templating
Add URL path templating support with validation and documentation
2026-01-11 21:42:37 +04:00
92d0c5e003 Revise feature support table in README
Updated the supported and not supported features table for clarity.
2026-01-11 21:40:38 +04:00
27bc8f2e96 Add URL path templating support with validation and documentation 2026-01-11 19:05:58 +04:00
12 changed files with 673 additions and 199 deletions

View File

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

View File

@@ -18,13 +18,13 @@
Sarin is designed for efficient HTTP load testing with minimal resource consumption. It prioritizes simplicity—features like templating add zero overhead when unused. Sarin is designed for efficient HTTP load testing with minimal resource consumption. It prioritizes simplicity—features like templating add zero overhead when unused.
| ✅ Supported | ❌ Not Supported | | ✅ Supported | ❌ Not Supported |
| ---------------------------------------------------- | --------------------------------- | | ---------------------------------------------------------- | --------------------------------- |
| High-performance with low memory footprint | Detailed response body analysis | | High-performance with low memory footprint | Detailed response body analysis |
| Long-running duration/count based tests | Extensive response statistics | | Long-running duration/count based tests | Extensive response statistics |
| Dynamic requests via 320+ template functions | Web UI or complex TUI | | Dynamic requests via 320+ template functions | Web UI or complex TUI |
| Multiple proxy protocols (HTTP/HTTPS/SOCKS5/SOCKS5H) | Scripting or multi-step scenarios | | Multiple proxy protocols<br>(HTTP, HTTPS, SOCKS5, SOCKS5H) | Scripting or multi-step scenarios |
| Flexible config (CLI, ENV, YAML) | HTTP/2, HTTP/3, WebSocket, gRPC | | Flexible config (CLI, ENV, YAML) | HTTP/2, HTTP/3, WebSocket, gRPC |
## Installation ## Installation
@@ -74,18 +74,6 @@ Send 10,000 GET requests with 50 concurrent connections and a random User-Agent
sarin -U http://example.com -r 10_000 -c 50 -H "User-Agent: {{ fakeit_UserAgent }}" sarin -U http://example.com -r 10_000 -c 50 -H "User-Agent: {{ fakeit_UserAgent }}"
``` ```
Example output:
```
┌──────────┬───────┬──────────┬───────────┬─────────┬───────────┬──────────┬───────────┐
│ Response │ Count │ Min │ Max │ Average │ P90 │ P95 │ P99 │
├──────────┼───────┼──────────┼───────────┼─────────┼───────────┼──────────┼───────────┤
│ 200 │ 10000 │ 78.038ms │ 288.153ms │ 94.71ms │ 103.078ms │ 131.08ms │ 269.218ms │
├──────────┼───────┼──────────┼───────────┼─────────┼───────────┼──────────┼───────────┤
│ Total │ 10000 │ 78.038ms │ 288.153ms │ 94.71ms │ 103.078ms │ 131.08ms │ 269.218ms │
└──────────┴───────┴──────────┴───────────┴─────────┴───────────┴──────────┴───────────┘
```
Run a 5-minute duration-based test: Run a 5-minute duration-based test:
```sh ```sh
@@ -105,22 +93,22 @@ 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)**.
## Templating ## Templating
Sarin supports Go templates in methods, bodies, headers, params, cookies, and values. Use the 320+ built-in functions to generate dynamic data for each request. Sarin supports Go templates in URL paths, methods, bodies, headers, params, cookies, and values. Use the 320+ built-in functions to generate dynamic data for each request.
**Example:** **Example:**
```sh ```sh
sarin -U http://example.com/users \ sarin -U "http://example.com/users/{{ fakeit_UUID }}" -r 1000 -c 10 \
-V "ID={{ fakeit_UUID }}" \ -V "REQUEST_ID={{ fakeit_UUID }}" \
-H "X-Request-ID: {{ .Values.ID }}" \ -H "X-Request-ID: {{ .Values.REQUEST_ID }}" \
-B '{"id": "{{ .Values.ID }}"}' -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,44 +43,87 @@ 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
Target URL. Must be HTTP or HTTPS. Target URL. Must be HTTP or HTTPS. The URL path supports [templating](templating.md), allowing dynamic path generation per request.
> **Note:** Templating is only supported in the URL path. Host and scheme must be static.
**Example with dynamic path:**
```yaml
url: http://example.com/users/{{ fakeit_UUID }}/profile
```
**CLI example with dynamic path:**
```sh
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:**
@@ -153,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:**
@@ -182,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:**
@@ -212,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:**
@@ -242,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:**
@@ -272,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

@@ -6,9 +6,10 @@ This guide provides practical examples for common Sarin use cases.
- [Basic Usage](#basic-usage) - [Basic Usage](#basic-usage)
- [Request-Based vs Duration-Based Tests](#request-based-vs-duration-based-tests) - [Request-Based vs Duration-Based Tests](#request-based-vs-duration-based-tests)
- [Dynamic Requests with Templating](#dynamic-requests-with-templating)
- [Headers, Cookies, and Parameters](#headers-cookies-and-parameters) - [Headers, Cookies, and Parameters](#headers-cookies-and-parameters)
- [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)
@@ -108,9 +109,162 @@ concurrency: 100
</details> </details>
## Headers, Cookies, and Parameters
**Custom headers:**
```sh
sarin -U http://example.com -r 1000 -c 10 \
-H "Authorization: Bearer token123" \
-H "X-Custom-Header: value"
```
<details>
<summary>YAML equivalent</summary>
```yaml
url: http://example.com
requests: 1000
concurrency: 10
headers:
Authorization: Bearer token123
X-Custom-Header: value
```
</details>
**Random headers from multiple values:**
> **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.
```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"
```
<details>
<summary>YAML equivalent</summary>
```yaml
url: http://example.com
requests: 1000
concurrency: 10
headers:
X-Region:
- us-east
- us-west
- eu-central
```
</details>
**Query parameters:**
```sh
sarin -U http://example.com/search -r 1000 -c 10 \
-P "query=test" \
-P "limit=10"
```
<details>
<summary>YAML equivalent</summary>
```yaml
url: http://example.com/search
requests: 1000
concurrency: 10
params:
query: "test"
limit: 10
```
</details>
**Dynamic query parameters:**
```sh
sarin -U http://example.com/users -r 1000 -c 10 \
-P "id={{ fakeit_IntRange 1 1000 }}" \
-P "fields=name,email"
```
<details>
<summary>YAML equivalent</summary>
```yaml
url: http://example.com/users
requests: 1000
concurrency: 10
params:
id: "{{ fakeit_IntRange 1 1000 }}"
fields: "name,email"
```
</details>
**Cookies:**
```sh
sarin -U http://example.com -r 1000 -c 10 \
-C "session_id=abc123" \
-C "user_id={{ fakeit_UUID }}"
```
<details>
<summary>YAML equivalent</summary>
```yaml
url: http://example.com
requests: 1000
concurrency: 10
cookies:
session_id: abc123
user_id: "{{ fakeit_UUID }}"
```
</details>
## Dynamic Requests with Templating ## Dynamic Requests with Templating
Generate a random User-Agent for each request: **Dynamic URL paths:**
Test different resource endpoints with random IDs:
```sh
sarin -U "http://example.com/users/{{ fakeit_UUID }}/profile" -r 1000 -c 10
```
<details>
<summary>YAML equivalent</summary>
```yaml
url: http://example.com/users/{{ fakeit_UUID }}/profile
requests: 1000
concurrency: 10
```
</details>
Test with random numeric IDs:
```sh
sarin -U "http://example.com/products/{{ fakeit_Number 1 10000 }}" -r 1000 -c 10
```
<details>
<summary>YAML equivalent</summary>
```yaml
url: http://example.com/products/{{ fakeit_Number 1 10000 }}
requests: 1000
concurrency: 10
```
</details>
**Generate a random User-Agent for each request:**
```sh ```sh
sarin -U http://example.com -r 1000 -c 10 \ sarin -U http://example.com -r 1000 -c 10 \
@@ -206,123 +360,6 @@ body: '{"ip": "{{ fakeit_IPv4Address }}", "timestamp": "{{ fakeit_Date }}", "act
> For the complete list of 320+ template functions, see the **[Templating Guide](templating.md)**. > For the complete list of 320+ template functions, see the **[Templating Guide](templating.md)**.
## Headers, Cookies, and Parameters
**Custom headers:**
```sh
sarin -U http://example.com -r 1000 -c 10 \
-H "Authorization: Bearer token123" \
-H "X-Custom-Header: value"
```
<details>
<summary>YAML equivalent</summary>
```yaml
url: http://example.com
requests: 1000
concurrency: 10
headers:
Authorization: Bearer token123
X-Custom-Header: value
```
</details>
**Random headers from multiple values:**
> **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.
```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"
```
<details>
<summary>YAML equivalent</summary>
```yaml
url: http://example.com
requests: 1000
concurrency: 10
headers:
X-Region:
- us-east
- us-west
- eu-central
```
</details>
**Query parameters:**
```sh
sarin -U http://example.com/search -r 1000 -c 10 \
-P "query=test" \
-P "limit=10"
```
<details>
<summary>YAML equivalent</summary>
```yaml
url: http://example.com/search
requests: 1000
concurrency: 10
params:
query: test
limit: "10"
```
</details>
**Dynamic query parameters:**
```sh
sarin -U http://example.com/users -r 1000 -c 10 \
-P "id={{ fakeit_IntRange 1 1000 }}" \
-P "fields=name,email"
```
<details>
<summary>YAML equivalent</summary>
```yaml
url: http://example.com/users
requests: 1000
concurrency: 10
params:
id: "{{ fakeit_IntRange 1 1000 }}"
fields: name,email
```
</details>
**Cookies:**
```sh
sarin -U http://example.com -r 1000 -c 10 \
-C "session_id=abc123" \
-C "user_id={{ fakeit_UUID }}"
```
<details>
<summary>YAML equivalent</summary>
```yaml
url: http://example.com
requests: 1000
concurrency: 10
cookies:
session_id: abc123
user_id: "{{ fakeit_UUID }}"
```
</details>
## Request Bodies ## Request Bodies
**Simple JSON body:** **Simple JSON body:**
@@ -420,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>
@@ -431,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>
@@ -441,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>
@@ -452,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

@@ -1,6 +1,8 @@
# Templating # Templating
Sarin supports Go templates in methods, bodies, headers, params, cookies, and values. Sarin supports Go templates in URL paths, methods, bodies, headers, params, cookies, and values.
> **Note:** Templating in URL host and scheme is not supported. Only the path portion of the URL can contain templates.
## Table of Contents ## Table of Contents
@@ -9,6 +11,7 @@ Sarin supports Go templates in methods, bodies, headers, params, cookies, and va
- [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)
@@ -108,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
@@ -530,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

@@ -180,16 +180,31 @@ func validateTemplateValues(values []string, funcMap template.FuncMap) []types.F
return validationErrors return validationErrors
} }
func validateTemplateURLPath(urlPath string, funcMap template.FuncMap) []types.FieldValidationError {
if err := validateTemplateString(urlPath, funcMap); err != nil {
return []types.FieldValidationError{
types.NewFieldValidationError("URL.Path", urlPath, err),
}
}
return nil
}
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
// Validate URL path
if config.URL != nil {
allErrors = append(allErrors, validateTemplateURLPath(config.URL.Path, funcMap)...)
}
// Validate methods // Validate methods
allErrors = append(allErrors, validateTemplateMethods(config.Methods, funcMap)...) allErrors = append(allErrors, validateTemplateMethods(config.Methods, funcMap)...)

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,52 +34,64 @@ 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)
methodGenerator, isMethodGeneratorDynamic := NewMethodGeneratorFunc(localRand, methods, templateFuncMap) methodGenerator, isMethodGeneratorDynamic := NewMethodGeneratorFunc(localRand, methods, templateFuncMap)
paramsGenerator, isParamsGeneratorDynamic := NewParamsGeneratorFunc(localRand, params, templateFuncMap) paramsGenerator, isParamsGeneratorDynamic := NewParamsGeneratorFunc(localRand, params, templateFuncMap)
headersGenerator, isHeadersGeneratorDynamic := NewHeadersGeneratorFunc(localRand, headers, templateFuncMap) headersGenerator, isHeadersGeneratorDynamic := NewHeadersGeneratorFunc(localRand, headers, templateFuncMap)
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)
var (
data valuesData
path string
err error
)
return func(req *fasthttp.Request) error { return func(req *fasthttp.Request) error {
req.SetRequestURI(requestURL.Path)
req.Header.SetHost(requestURL.Host) req.Header.SetHost(requestURL.Host)
data, err := valuesGenerator() data, err = valuesGenerator()
if err != nil { if err != nil {
return err return err
} }
if err := methodGenerator(req, data); err != nil { path, err = pathGenerator(data)
if err != nil {
return err
}
req.SetRequestURI(path)
if err = methodGenerator(req, data); err != nil {
return err return err
} }
bodyTemplateFuncMapData.ClearFormDataContenType() bodyTemplateFuncMapData.ClearFormDataContenType()
if err := bodyGenerator(req, data); err != nil { if err = bodyGenerator(req, data); err != nil {
return err return err
} }
if err := headersGenerator(req, data); err != nil { if err = headersGenerator(req, data); err != nil {
return err return err
} }
if bodyTemplateFuncMapData.GetFormDataContenType() != "" { if bodyTemplateFuncMapData.GetFormDataContenType() != "" {
req.Header.Add("Content-Type", bodyTemplateFuncMapData.GetFormDataContenType()) req.Header.Add("Content-Type", bodyTemplateFuncMapData.GetFormDataContenType())
} }
if err := paramsGenerator(req, data); err != nil { if err = paramsGenerator(req, data); err != nil {
return err return err
} }
if err := cookiesGenerator(req, data); err != nil { if err = cookiesGenerator(req, data); err != nil {
return err return err
} }
@@ -87,7 +99,8 @@ func NewRequestGenerator(
req.URI().SetScheme("https") req.URI().SetScheme("https")
} }
return nil return nil
}, isMethodGeneratorDynamic || }, isPathGeneratorDynamic ||
isMethodGeneratorDynamic ||
isParamsGeneratorDynamic || isParamsGeneratorDynamic ||
isHeadersGeneratorDynamic || isHeadersGeneratorDynamic ||
isCookiesGeneratorDynamic || isCookiesGeneratorDynamic ||

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
} }
} }