mirror of
https://github.com/aykhans/sarin.git
synced 2026-02-27 22:39:13 +00:00
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
This commit is contained in:
@@ -29,7 +29,6 @@ linters:
|
||||
- errorlint
|
||||
- exptostd
|
||||
- fatcontext
|
||||
- forcetypeassert
|
||||
- funcorder
|
||||
- gocheckcompilerdirectives
|
||||
- gocritic
|
||||
|
||||
106
docs/examples.md
106
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)
|
||||
- [Dynamic Requests with Templating](#dynamic-requests-with-templating)
|
||||
- [Request Bodies](#request-bodies)
|
||||
- [File Uploads](#file-uploads)
|
||||
- [Using Proxies](#using-proxies)
|
||||
- [Output Formats](#output-formats)
|
||||
- [Docker Usage](#docker-usage)
|
||||
@@ -456,7 +457,7 @@ body: |
|
||||
```sh
|
||||
sarin -U http://example.com/api/upload -r 1000 -c 10 \
|
||||
-M POST \
|
||||
-B '{{ body_FormData (dict_Str "username" "john" "email" "john@example.com") }}'
|
||||
-B '{{ body_FormData "username" "john" "email" "john@example.com" }}'
|
||||
```
|
||||
|
||||
<details>
|
||||
@@ -467,7 +468,7 @@ url: http://example.com/api/upload
|
||||
requests: 1000
|
||||
concurrency: 10
|
||||
method: POST
|
||||
body: '{{ body_FormData (dict_Str "username" "john" "email" "john@example.com") }}'
|
||||
body: '{{ body_FormData "username" "john" "email" "john@example.com" }}'
|
||||
```
|
||||
|
||||
</details>
|
||||
@@ -477,7 +478,7 @@ body: '{{ body_FormData (dict_Str "username" "john" "email" "john@example.com")
|
||||
```sh
|
||||
sarin -U http://example.com/api/users -r 1000 -c 10 \
|
||||
-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>
|
||||
@@ -488,13 +489,110 @@ url: http://example.com/api/users
|
||||
requests: 1000
|
||||
concurrency: 10
|
||||
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>
|
||||
|
||||
> **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:**
|
||||
|
||||
```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>
|
||||
|
||||
**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:**
|
||||
|
||||
```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>
|
||||
|
||||
## Using Proxies
|
||||
|
||||
**Single HTTP proxy:**
|
||||
|
||||
@@ -110,9 +110,63 @@ sarin -U http://example.com/users \
|
||||
|
||||
### Body Functions
|
||||
|
||||
| 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") }}` |
|
||||
| Function | Description | Example |
|
||||
| ------------------------------ | ------------------------------------------------------------------------ | ------------------------------------------------ |
|
||||
| `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
|
||||
|
||||
|
||||
@@ -191,11 +191,12 @@ func validateTemplateURLPath(urlPath string, funcMap template.FuncMap) []types.F
|
||||
|
||||
func ValidateTemplates(config *Config) []types.FieldValidationError {
|
||||
// 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()
|
||||
funcMap := sarin.NewDefaultTemplateFuncMap(randSource)
|
||||
funcMap := sarin.NewDefaultTemplateFuncMap(randSource, nil)
|
||||
|
||||
bodyFuncMapData := &sarin.BodyTemplateFuncMapData{}
|
||||
bodyFuncMap := sarin.NewDefaultBodyTemplateFuncMap(randSource, bodyFuncMapData)
|
||||
bodyFuncMap := sarin.NewDefaultBodyTemplateFuncMap(randSource, bodyFuncMapData, nil)
|
||||
|
||||
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,
|
||||
bodies []string,
|
||||
values []string,
|
||||
fileCache *FileCache,
|
||||
) (RequestGenerator, bool) {
|
||||
randSource := NewDefaultRandSource()
|
||||
//nolint:gosec // G404: Using non-cryptographic rand for load testing, not security
|
||||
localRand := rand.New(randSource)
|
||||
templateFuncMap := NewDefaultTemplateFuncMap(randSource)
|
||||
templateFuncMap := NewDefaultTemplateFuncMap(randSource, fileCache)
|
||||
|
||||
pathGenerator, isPathGeneratorDynamic := createTemplateFunc(requestURL.Path, templateFuncMap)
|
||||
methodGenerator, isMethodGeneratorDynamic := NewMethodGeneratorFunc(localRand, methods, templateFuncMap)
|
||||
@@ -47,7 +48,7 @@ func NewRequestGenerator(
|
||||
cookiesGenerator, isCookiesGeneratorDynamic := NewCookiesGeneratorFunc(localRand, cookies, templateFuncMap)
|
||||
|
||||
bodyTemplateFuncMapData := &BodyTemplateFuncMapData{}
|
||||
bodyTemplateFuncMap := NewDefaultBodyTemplateFuncMap(randSource, bodyTemplateFuncMapData)
|
||||
bodyTemplateFuncMap := NewDefaultBodyTemplateFuncMap(randSource, bodyTemplateFuncMapData, fileCache)
|
||||
bodyGenerator, isBodyGeneratorDynamic := NewBodyGeneratorFunc(localRand, bodies, bodyTemplateFuncMap)
|
||||
|
||||
valuesGenerator := NewValuesGeneratorFunc(values, templateFuncMap)
|
||||
|
||||
@@ -51,6 +51,7 @@ type sarin struct {
|
||||
|
||||
hostClients []*fasthttp.HostClient
|
||||
responses *SarinResponseData
|
||||
fileCache *FileCache
|
||||
}
|
||||
|
||||
// NewSarin creates a new sarin instance for load testing.
|
||||
@@ -101,6 +102,7 @@ func NewSarin(
|
||||
collectStats: collectStats,
|
||||
dryRun: dryRun,
|
||||
hostClients: hostClients,
|
||||
fileCache: NewFileCache(time.Second * 10),
|
||||
}
|
||||
|
||||
if collectStats {
|
||||
@@ -191,7 +193,7 @@ func (q sarin) Worker(
|
||||
defer fasthttp.ReleaseRequest(req)
|
||||
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 {
|
||||
switch {
|
||||
|
||||
@@ -2,6 +2,8 @@ package sarin
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"math/rand/v2"
|
||||
"mime/multipart"
|
||||
"strings"
|
||||
@@ -12,7 +14,7 @@ import (
|
||||
"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)
|
||||
|
||||
return template.FuncMap{
|
||||
@@ -82,6 +84,21 @@ func NewDefaultTemplateFuncMap(randSource rand.Source) template.FuncMap {
|
||||
"slice_Int": func(values ...int) []int { 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_CSV": fakeit.CSV(nil),
|
||||
// "fakeit_JSON": fakeit.JSON(nil),
|
||||
@@ -542,21 +559,75 @@ func (data *BodyTemplateFuncMapData) ClearFormDataContenType() {
|
||||
data.formDataContenType = ""
|
||||
}
|
||||
|
||||
func NewDefaultBodyTemplateFuncMap(randSource rand.Source, data *BodyTemplateFuncMapData) template.FuncMap {
|
||||
funcMap := NewDefaultTemplateFuncMap(randSource)
|
||||
func NewDefaultBodyTemplateFuncMap(
|
||||
randSource rand.Source,
|
||||
data *BodyTemplateFuncMapData,
|
||||
fileCache *FileCache,
|
||||
) template.FuncMap {
|
||||
funcMap := NewDefaultTemplateFuncMap(randSource, fileCache)
|
||||
|
||||
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
|
||||
writer := multipart.NewWriter(&multipartData)
|
||||
data.formDataContenType = writer.FormDataContentType()
|
||||
|
||||
for k, v := range kv {
|
||||
_ = writer.WriteField(k, v)
|
||||
for i := 0; i < len(pairs); i += 2 {
|
||||
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()
|
||||
return multipartData.String()
|
||||
if err := writer.Close(); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return multipartData.String(), nil
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user