mirror of
https://github.com/aykhans/sarin.git
synced 2026-02-28 14:59:14 +00:00
Compare commits
12 Commits
v1.0.1
...
c2ba1844ab
| Author | SHA1 | Date | |
|---|---|---|---|
| c2ba1844ab | |||
|
|
054e5fd253 | ||
| c3ea3a34ad | |||
|
|
c02a079d2a | ||
|
|
f78942bfb6 | ||
| 1369cb9f09 | |||
| 18662e6a64 | |||
| 81f08edc8d | |||
| a9738c0a11 | |||
| 76225884e6 | |||
| a512f3605d | |||
|
|
635c33008b |
@@ -29,7 +29,6 @@ linters:
|
|||||||
- errorlint
|
- errorlint
|
||||||
- exptostd
|
- exptostd
|
||||||
- fatcontext
|
- fatcontext
|
||||||
- forcetypeassert
|
|
||||||
- funcorder
|
- funcorder
|
||||||
- gocheckcompilerdirectives
|
- gocheckcompilerdirectives
|
||||||
- gocritic
|
- gocritic
|
||||||
|
|||||||
12
README.md
12
README.md
@@ -2,6 +2,10 @@
|
|||||||
|
|
||||||
## Sarin is a high-performance HTTP load testing tool built with Go and fasthttp.
|
## Sarin is a high-performance HTTP load testing tool built with Go and fasthttp.
|
||||||
|
|
||||||
|
[](https://pkg.go.dev/go.aykhans.me/sarin)
|
||||||
|
[](https://goreportcard.com/report/go.aykhans.me/sarin)
|
||||||
|
[](https://opensource.org/licenses/MIT)
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||

|

|
||||||
@@ -93,7 +97,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 +110,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)**.
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -15,7 +15,7 @@ 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`
|
||||||
|
|
||||||
|
|||||||
162
docs/examples.md
162
docs/examples.md
@@ -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:**
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -111,8 +112,62 @@ 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"` |
|
||||||
|
|||||||
16
go.mod
16
go.mod
@@ -4,7 +4,7 @@ go 1.25.5
|
|||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/brianvoe/gofakeit/v7 v7.14.0
|
github.com/brianvoe/gofakeit/v7 v7.14.0
|
||||||
github.com/charmbracelet/bubbles v0.21.0
|
github.com/charmbracelet/bubbles v0.21.1
|
||||||
github.com/charmbracelet/bubbletea v1.3.10
|
github.com/charmbracelet/bubbletea v1.3.10
|
||||||
github.com/charmbracelet/glamour v0.10.0
|
github.com/charmbracelet/glamour v0.10.0
|
||||||
github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834
|
github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834
|
||||||
@@ -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 (
|
||||||
@@ -23,12 +23,12 @@ require (
|
|||||||
github.com/aymerick/douceur v0.2.0 // indirect
|
github.com/aymerick/douceur v0.2.0 // indirect
|
||||||
github.com/charmbracelet/colorprofile v0.4.1 // indirect
|
github.com/charmbracelet/colorprofile v0.4.1 // indirect
|
||||||
github.com/charmbracelet/harmonica v0.2.0 // indirect
|
github.com/charmbracelet/harmonica v0.2.0 // indirect
|
||||||
github.com/charmbracelet/x/ansi v0.11.3 // indirect
|
github.com/charmbracelet/x/ansi v0.11.5 // indirect
|
||||||
github.com/charmbracelet/x/cellbuf v0.0.14 // indirect
|
github.com/charmbracelet/x/cellbuf v0.0.15 // indirect
|
||||||
github.com/charmbracelet/x/exp/slice v0.0.0-20260109001716-2fbdffcb221f // indirect
|
github.com/charmbracelet/x/exp/slice v0.0.0-20260109001716-2fbdffcb221f // indirect
|
||||||
github.com/clipperhouse/displaywidth v0.6.2 // indirect
|
github.com/clipperhouse/displaywidth v0.9.0 // indirect
|
||||||
github.com/clipperhouse/stringish v0.1.1 // indirect
|
github.com/clipperhouse/stringish v0.1.1 // indirect
|
||||||
github.com/clipperhouse/uax29/v2 v2.3.0 // indirect
|
github.com/clipperhouse/uax29/v2 v2.5.0 // indirect
|
||||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
|
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
|
||||||
github.com/dlclark/regexp2 v1.11.5 // indirect
|
github.com/dlclark/regexp2 v1.11.5 // indirect
|
||||||
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
|
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
|
||||||
@@ -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
|
||||||
)
|
)
|
||||||
|
|||||||
36
go.sum
36
go.sum
@@ -8,14 +8,14 @@ github.com/andybalholm/brotli v1.2.0 h1:ukwgCxwYrmACq68yiUqwIWnGY0cTPox/M94sVwTo
|
|||||||
github.com/andybalholm/brotli v1.2.0/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY=
|
github.com/andybalholm/brotli v1.2.0/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY=
|
||||||
github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
|
github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
|
||||||
github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
|
github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
|
||||||
github.com/aymanbagabas/go-udiff v0.2.0 h1:TK0fH4MteXUDspT88n8CKzvK0X9O2xu9yQjWpi6yML8=
|
github.com/aymanbagabas/go-udiff v0.3.1 h1:LV+qyBQ2pqe0u42ZsUEtPiCaUoqgA9gYRDs3vj1nolY=
|
||||||
github.com/aymanbagabas/go-udiff v0.2.0/go.mod h1:RE4Ex0qsGkTAJoQdQQCA0uG+nAzJO/pI/QwceO5fgrA=
|
github.com/aymanbagabas/go-udiff v0.3.1/go.mod h1:G0fsKmG+P6ylD0r6N/KgQD/nWzgfnl8ZBcNLgcbrw8E=
|
||||||
github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk=
|
github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk=
|
||||||
github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4=
|
github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4=
|
||||||
github.com/brianvoe/gofakeit/v7 v7.14.0 h1:R8tmT/rTDJmD2ngpqBL9rAKydiL7Qr2u3CXPqRt59pk=
|
github.com/brianvoe/gofakeit/v7 v7.14.0 h1:R8tmT/rTDJmD2ngpqBL9rAKydiL7Qr2u3CXPqRt59pk=
|
||||||
github.com/brianvoe/gofakeit/v7 v7.14.0/go.mod h1:QXuPeBw164PJCzCUZVmgpgHJ3Llj49jSLVkKPMtxtxA=
|
github.com/brianvoe/gofakeit/v7 v7.14.0/go.mod h1:QXuPeBw164PJCzCUZVmgpgHJ3Llj49jSLVkKPMtxtxA=
|
||||||
github.com/charmbracelet/bubbles v0.21.0 h1:9TdC97SdRVg/1aaXNVWfFH3nnLAwOXr8Fn6u6mfQdFs=
|
github.com/charmbracelet/bubbles v0.21.1 h1:nj0decPiixaZeL9diI4uzzQTkkz1kYY8+jgzCZXSmW0=
|
||||||
github.com/charmbracelet/bubbles v0.21.0/go.mod h1:HF+v6QUR4HkEpz62dx7ym2xc71/KBHg+zKwJtMw+qtg=
|
github.com/charmbracelet/bubbles v0.21.1/go.mod h1:HHvIYRCpbkCJw2yo0vNX1O5loCwSr9/mWS8GYSg50Sk=
|
||||||
github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw=
|
github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw=
|
||||||
github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4=
|
github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4=
|
||||||
github.com/charmbracelet/colorprofile v0.4.1 h1:a1lO03qTrSIRaK8c3JRxJDZOvhvIeSco3ej+ngLk1kk=
|
github.com/charmbracelet/colorprofile v0.4.1 h1:a1lO03qTrSIRaK8c3JRxJDZOvhvIeSco3ej+ngLk1kk=
|
||||||
@@ -26,22 +26,22 @@ github.com/charmbracelet/harmonica v0.2.0 h1:8NxJWRWg/bzKqqEaaeFNipOu77YR5t8aSwG
|
|||||||
github.com/charmbracelet/harmonica v0.2.0/go.mod h1:KSri/1RMQOZLbw7AHqgcBycp8pgJnQMYYT8QZRqZ1Ao=
|
github.com/charmbracelet/harmonica v0.2.0/go.mod h1:KSri/1RMQOZLbw7AHqgcBycp8pgJnQMYYT8QZRqZ1Ao=
|
||||||
github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834 h1:ZR7e0ro+SZZiIZD7msJyA+NjkCNNavuiPBLgerbOziE=
|
github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834 h1:ZR7e0ro+SZZiIZD7msJyA+NjkCNNavuiPBLgerbOziE=
|
||||||
github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834/go.mod h1:aKC/t2arECF6rNOnaKaVU6y4t4ZeHQzqfxedE/VkVhA=
|
github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834/go.mod h1:aKC/t2arECF6rNOnaKaVU6y4t4ZeHQzqfxedE/VkVhA=
|
||||||
github.com/charmbracelet/x/ansi v0.11.3 h1:6DcVaqWI82BBVM/atTyq6yBoRLZFBsnoDoX9GCu2YOI=
|
github.com/charmbracelet/x/ansi v0.11.5 h1:NBWeBpj/lJPE3Q5l+Lusa4+mH6v7487OP8K0r1IhRg4=
|
||||||
github.com/charmbracelet/x/ansi v0.11.3/go.mod h1:yI7Zslym9tCJcedxz5+WBq+eUGMJT0bM06Fqy1/Y4dI=
|
github.com/charmbracelet/x/ansi v0.11.5/go.mod h1:2JNYLgQUsyqaiLovhU2Rv/pb8r6ydXKS3NIttu3VGZQ=
|
||||||
github.com/charmbracelet/x/cellbuf v0.0.14 h1:iUEMryGyFTelKW3THW4+FfPgi4fkmKnnaLOXuc+/Kj4=
|
github.com/charmbracelet/x/cellbuf v0.0.15 h1:ur3pZy0o6z/R7EylET877CBxaiE1Sp1GMxoFPAIztPI=
|
||||||
github.com/charmbracelet/x/cellbuf v0.0.14/go.mod h1:P447lJl49ywBbil/KjCk2HexGh4tEY9LH0/1QrZZ9rA=
|
github.com/charmbracelet/x/cellbuf v0.0.15/go.mod h1:J1YVbR7MUuEGIFPCaaZ96KDl5NoS0DAWkskup+mOY+Q=
|
||||||
github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91 h1:payRxjMjKgx2PaCWLZ4p3ro9y97+TVLZNaRZgJwSVDQ=
|
github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91 h1:payRxjMjKgx2PaCWLZ4p3ro9y97+TVLZNaRZgJwSVDQ=
|
||||||
github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U=
|
github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U=
|
||||||
github.com/charmbracelet/x/exp/slice v0.0.0-20260109001716-2fbdffcb221f h1:kvAY8ffwhFuxWqtVI6+9E5vmgTApG96hswFLXJfsxHI=
|
github.com/charmbracelet/x/exp/slice v0.0.0-20260109001716-2fbdffcb221f h1:kvAY8ffwhFuxWqtVI6+9E5vmgTApG96hswFLXJfsxHI=
|
||||||
github.com/charmbracelet/x/exp/slice v0.0.0-20260109001716-2fbdffcb221f/go.mod h1:vqEfX6xzqW1pKKZUUiFOKg0OQ7bCh54Q2vR/tserrRA=
|
github.com/charmbracelet/x/exp/slice v0.0.0-20260109001716-2fbdffcb221f/go.mod h1:vqEfX6xzqW1pKKZUUiFOKg0OQ7bCh54Q2vR/tserrRA=
|
||||||
github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk=
|
github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk=
|
||||||
github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI=
|
github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI=
|
||||||
github.com/clipperhouse/displaywidth v0.6.2 h1:ZDpTkFfpHOKte4RG5O/BOyf3ysnvFswpyYrV7z2uAKo=
|
github.com/clipperhouse/displaywidth v0.9.0 h1:Qb4KOhYwRiN3viMv1v/3cTBlz3AcAZX3+y9OLhMtAtA=
|
||||||
github.com/clipperhouse/displaywidth v0.6.2/go.mod h1:R+kHuzaYWFkTm7xoMmK1lFydbci4X2CicfbGstSGg0o=
|
github.com/clipperhouse/displaywidth v0.9.0/go.mod h1:aCAAqTlh4GIVkhQnJpbL0T/WfcrJXHcj8C0yjYcjOZA=
|
||||||
github.com/clipperhouse/stringish v0.1.1 h1:+NSqMOr3GR6k1FdRhhnXrLfztGzuG+VuFDfatpWHKCs=
|
github.com/clipperhouse/stringish v0.1.1 h1:+NSqMOr3GR6k1FdRhhnXrLfztGzuG+VuFDfatpWHKCs=
|
||||||
github.com/clipperhouse/stringish v0.1.1/go.mod h1:v/WhFtE1q0ovMta2+m+UbpZ+2/HEXNWYXQgCt4hdOzA=
|
github.com/clipperhouse/stringish v0.1.1/go.mod h1:v/WhFtE1q0ovMta2+m+UbpZ+2/HEXNWYXQgCt4hdOzA=
|
||||||
github.com/clipperhouse/uax29/v2 v2.3.0 h1:SNdx9DVUqMoBuBoW3iLOj4FQv3dN5mDtuqwuhIGpJy4=
|
github.com/clipperhouse/uax29/v2 v2.5.0 h1:x7T0T4eTHDONxFJsL94uKNKPHrclyFI0lm7+w94cO8U=
|
||||||
github.com/clipperhouse/uax29/v2 v2.3.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g=
|
github.com/clipperhouse/uax29/v2 v2.5.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g=
|
||||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
|
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
|
||||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
github.com/dlclark/regexp2 v1.11.5 h1:Q/sSnsKerHeCkc/jSTNq1oCm7KiVgUMZRDUoRu0JQZQ=
|
github.com/dlclark/regexp2 v1.11.5 h1:Q/sSnsKerHeCkc/jSTNq1oCm7KiVgUMZRDUoRu0JQZQ=
|
||||||
@@ -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=
|
||||||
|
|||||||
@@ -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
102
internal/sarin/filecache.go
Normal 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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user