diff --git a/docs/templating.md b/docs/templating.md index cd7c6da..dad180f 100644 --- a/docs/templating.md +++ b/docs/templating.md @@ -12,6 +12,7 @@ Sarin supports Go templates in URL paths, methods, bodies, headers, params, cook - [General Functions](#general-functions) - [String Functions](#string-functions) - [Collection Functions](#collection-functions) + - [JSON Functions](#json-functions) - [Time Functions](#time-functions) - [Crypto Functions](#crypto-functions) - [Body Functions](#body-functions) @@ -117,6 +118,33 @@ sarin -U http://example.com/users \ | `slice_Int(values ...int)` | Create int slice | `{{ slice_Int 1 2 3 }}` | | `slice_Uint(values ...uint)` | Create uint slice | `{{ slice_Uint 1 2 3 }}` | +### JSON Functions + +Build JSON payloads programmatically without manual quoting or escaping. `json_Object` is the ergonomic shortcut for flat objects; `json_Encode` marshals any value (slice, map, etc.) to a JSON string. + +| Function | Description | Example | +| --------------------------- | ------------------------------------------------------------------------------------------------------ | ----------------------------------------------------- | +| `json_Object(pairs ...any)` | Build an object from interleaved key-value pairs and return it as a JSON string. Keys must be strings. | `{{ json_Object "name" "Alice" "age" 30 }}` | +| `json_Encode(v any)` | Marshal any value (slice, map, etc.) to a JSON string. | `{{ json_Encode (slice_Str "a" "b") }}` → `["a","b"]` | + +**Examples:** + +```yaml +# Flat object with fake data +body: '{{ json_Object "name" (fakeit_FirstName) "email" (fakeit_Email) }}' + +# Embed a solved captcha token +body: '{{ json_Object "g-recaptcha-response" (twocaptcha_RecaptchaV2 "API_KEY" "SITE_KEY" "https://example.com") }}' + +# Encode a slice as a JSON array +body: '{{ json_Encode (slice_Str "a" "b" "c") }}' + +# Encode a string dictionary (map[string]string) +body: '{{ json_Encode (dict_Str "key1" "value1" "key2" "value2") }}' +``` + +> **Note:** Object keys are serialized in alphabetical order (Go's `encoding/json` default), not insertion order. For API payloads this is almost always fine because JSON key order is semantically irrelevant. + ### Time Functions | Function | Description | Example | diff --git a/internal/sarin/template.go b/internal/sarin/template.go index c74ca8b..df8d50a 100644 --- a/internal/sarin/template.go +++ b/internal/sarin/template.go @@ -7,6 +7,7 @@ import ( "crypto/sha256" "encoding/base64" "encoding/hex" + "encoding/json" "math/rand/v2" "mime/multipart" "strings" @@ -85,6 +86,38 @@ func NewDefaultTemplateFuncMap(randSource rand.Source, fileCache *FileCache) tem "slice_Uint": func(values ...uint) []uint { return values }, "slice_Join": strings.Join, + // JSON + // json_Encode marshals any value to a JSON string. + // Usage: {{ json_Encode (dict_Str "key" "value") }} + "json_Encode": func(v any) (string, error) { + data, err := json.Marshal(v) + if err != nil { + return "", types.NewJSONEncodeError(err) + } + return string(data), nil + }, + // json_Object builds a JSON object from interleaved key-value pairs and returns it + // as a JSON string. Keys must be strings; values may be any JSON-encodable type. + // Usage: {{ json_Object "name" "Alice" "age" 30 }} + "json_Object": func(pairs ...any) (string, error) { + if len(pairs)%2 != 0 { + return "", types.ErrJSONObjectOddArgs + } + obj := make(map[string]any, len(pairs)/2) + for i := 0; i < len(pairs); i += 2 { + key, ok := pairs[i].(string) + if !ok { + return "", types.NewJSONObjectKeyError(i, pairs[i]) + } + obj[key] = pairs[i+1] + } + data, err := json.Marshal(obj) + if err != nil { + return "", types.NewJSONEncodeError(err) + } + return string(data), nil + }, + // Time "time_NowUnix": func() int64 { return time.Now().Unix() }, "time_NowUnixMilli": func() int64 { return time.Now().UnixMilli() }, diff --git a/internal/types/errors.go b/internal/types/errors.go index 98903f2..6be8b55 100644 --- a/internal/types/errors.go +++ b/internal/types/errors.go @@ -208,8 +208,41 @@ func (e URLParseError) Unwrap() error { var ( ErrFileCacheNotInitialized = errors.New("file cache is not initialized") ErrFormDataOddArgs = errors.New("body_FormData requires an even number of arguments (key-value pairs)") + ErrJSONObjectOddArgs = errors.New("json_Object requires an even number of arguments (key-value pairs)") ) +type JSONObjectKeyError struct { + Index int + Value any +} + +func NewJSONObjectKeyError(index int, value any) JSONObjectKeyError { + return JSONObjectKeyError{Index: index, Value: value} +} + +func (e JSONObjectKeyError) Error() string { + return fmt.Sprintf("json_Object key at index %d must be a string, got %T", e.Index, e.Value) +} + +type JSONEncodeError struct { + Err error +} + +func NewJSONEncodeError(err error) JSONEncodeError { + if err == nil { + err = errNoError + } + return JSONEncodeError{Err: err} +} + +func (e JSONEncodeError) Error() string { + return "json_Encode failed: " + e.Err.Error() +} + +func (e JSONEncodeError) Unwrap() error { + return e.Err +} + type TemplateParseError struct { Err error }