diff --git a/.golangci.yaml b/.golangci.yaml
index 030740a..aafe3e0 100644
--- a/.golangci.yaml
+++ b/.golangci.yaml
@@ -29,7 +29,6 @@ linters:
- errorlint
- exptostd
- fatcontext
- - forcetypeassert
- funcorder
- gocheckcompilerdirectives
- gocritic
diff --git a/docs/examples.md b/docs/examples.md
index 717ad5a..060adf5 100644
--- a/docs/examples.md
+++ b/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" }}'
```
@@ -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" }}'
```
@@ -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) }}'
```
@@ -488,13 +489,160 @@ 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) }}'
```
> **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" }}'
+```
+
+
+YAML equivalent
+
+```yaml
+url: http://example.com/api/upload
+requests: 100
+concurrency: 10
+method: POST
+body: '{{ body_FormData "title" "My Document" "document" "@/path/to/file.pdf" }}'
+```
+
+
+
+**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" }}'
+```
+
+
+YAML equivalent
+
+```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"
+ }}
+```
+
+
+
+**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" }}'
+```
+
+
+YAML equivalent
+
+```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"
+ }}
+```
+
+
+
+**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" }}'
+```
+
+
+YAML equivalent
+
+```yaml
+url: http://example.com/api/upload
+requests: 100
+concurrency: 10
+method: POST
+body: '{{ body_FormData "image" "@https://example.com/photo.jpg" }}'
+```
+
+
+
+> **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"}'
+```
+
+
+YAML equivalent
+
+```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"}'
+```
+
+
+
+**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"}'
+```
+
+
+YAML equivalent
+
+```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"}'
+```
+
+
+
## Using Proxies
**Single HTTP proxy:**
diff --git a/docs/templating.md b/docs/templating.md
index 6a35faf..4ff3e49 100644
--- a/docs/templating.md
+++ b/docs/templating.md
@@ -11,6 +11,7 @@ Sarin supports Go templates in URL paths, methods, bodies, headers, params, cook
- [String Functions](#string-functions)
- [Collection Functions](#collection-functions)
- [Body Functions](#body-functions)
+ - [File Functions](#file-functions)
- [Fake Data Functions](#fake-data-functions)
- [File](#file)
- [ID](#id)
@@ -110,9 +111,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
diff --git a/internal/config/template_validator.go b/internal/config/template_validator.go
index 6618209..054ba29 100644
--- a/internal/config/template_validator.go
+++ b/internal/config/template_validator.go
@@ -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
diff --git a/internal/sarin/filecache.go b/internal/sarin/filecache.go
new file mode 100644
index 0000000..b3a24fd
--- /dev/null
+++ b/internal/sarin/filecache.go
@@ -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
+}
diff --git a/internal/sarin/request.go b/internal/sarin/request.go
index 9b4604e..aeddcb4 100644
--- a/internal/sarin/request.go
+++ b/internal/sarin/request.go
@@ -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)
diff --git a/internal/sarin/sarin.go b/internal/sarin/sarin.go
index 6c3a9bb..664e9b7 100644
--- a/internal/sarin/sarin.go
+++ b/internal/sarin/sarin.go
@@ -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 {
diff --git a/internal/sarin/template.go b/internal/sarin/template.go
index 1e42b8d..bd4f611 100644
--- a/internal/sarin/template.go
+++ b/internal/sarin/template.go
@@ -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
}
}