v1.0.0: here we go again

This commit is contained in:
2026-01-10 17:06:25 +04:00
parent 25d4762a3c
commit 2d7ba34cb8
68 changed files with 6805 additions and 4548 deletions

View File

@@ -1,11 +0,0 @@
.github
assets
binaries
dodo
.git
.gitignore
.golangci.yml
README.md
LICENSE
config.json
build.sh

2
.github/FUNDING.yml vendored Normal file
View File

@@ -0,0 +1,2 @@
buy_me_a_coffee: aykhan
custom: https://commerce.coinbase.com/checkout/0f33d2fb-54a6-44f5-8783-006ebf70d1a0

View File

@@ -1,5 +1,4 @@
name: golangci-lint
on:
push:
branches:
@@ -14,12 +13,11 @@ jobs:
name: lint
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
- uses: actions/checkout@v5
- uses: actions/setup-go@v6
with:
go-version: stable
go-version: 1.25.5
- name: golangci-lint
uses: golangci/golangci-lint-action@v7
uses: golangci/golangci-lint-action@v9
with:
version: v2.4.0
args: --timeout=10m --config=.golangci.yml
version: v2.7.2

View File

@@ -1,86 +0,0 @@
name: publish-docker-image
on:
push:
tags:
# Match stable and pre versions, such as 'v1.0.0', 'v0.23.0-a', 'v0.23.0-a.2', 'v0.23.0-b', 'v0.23.0-b.3'
- "v*.*.*"
- "v*.*.*-a"
- "v*.*.*-a.*"
- "v*.*.*-b"
- "v*.*.*-b.*"
jobs:
build-and-push-stable-image:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
- uses: actions/checkout@v4
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Extract build args
# Extract version number and check if it's an pre version
run: |
if [[ "${GITHUB_REF_NAME}" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
echo "PRE_RELEASE=false" >> $GITHUB_ENV
else
echo "PRE_RELEASE=true" >> $GITHUB_ENV
fi
echo "VERSION=${GITHUB_REF_NAME#v}" >> $GITHUB_ENV
- name: Login to Docker Hub
uses: docker/login-action@v3
with:
username: aykhans
password: ${{ secrets.DOCKER_TOKEN }}
- name: Set up Docker Buildx
id: buildx
uses: docker/setup-buildx-action@v3
with:
install: true
version: v0.9.1
# Metadata for stable versions
- name: Docker meta for stable
id: meta-stable
if: env.PRE_RELEASE == 'false'
uses: docker/metadata-action@v5
with:
images: |
aykhans/dodo
tags: |
type=semver,pattern={{version}},value=${{ env.VERSION }}
type=raw,value=stable
flavor: |
latest=true
labels: |
org.opencontainers.image.version=${{ env.VERSION }}
# Metadata for pre versions
- name: Docker meta for pre
id: meta-pre
if: env.PRE_RELEASE == 'true'
uses: docker/metadata-action@v5
with:
images: |
aykhans/dodo
tags: |
type=raw,value=${{ env.VERSION }}
labels: |
org.opencontainers.image.version=${{ env.VERSION }}
- name: Build and Push
id: docker_build
uses: docker/build-push-action@v6
with:
context: ./
file: ./Dockerfile
platforms: linux/amd64,linux/arm64
push: true
tags: ${{ steps.meta-stable.outputs.tags || steps.meta-pre.outputs.tags }}
labels: ${{ steps.meta-stable.outputs.labels || steps.meta-pre.outputs.labels }}

98
.github/workflows/release.yaml vendored Normal file
View File

@@ -0,0 +1,98 @@
name: Build and Release
on:
release:
types: [created]
workflow_dispatch:
inputs:
tag:
description: "Release tag (e.g., v1.0.0)"
required: true
build_binaries:
description: "Build and upload binaries"
type: boolean
default: true
build_docker:
description: "Build and push Docker image"
type: boolean
default: true
permissions:
contents: write
jobs:
build:
name: Build binaries
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v6
with:
ref: ${{ inputs.tag || github.ref }}
- name: Set build metadata
run: |
echo "VERSION=$(git describe --tags --always)" >> $GITHUB_ENV
echo "GIT_COMMIT=$(git rev-parse HEAD)" >> $GITHUB_ENV
echo "GO_VERSION=1.25.5" >> $GITHUB_ENV
- name: Set up Go
if: github.event_name == 'release' || inputs.build_binaries
uses: actions/setup-go@v6
with:
go-version: ${{ env.GO_VERSION }}
cache: true
- name: Build binaries
if: github.event_name == 'release' || inputs.build_binaries
run: |
LDFLAGS="-X 'go.aykhans.me/sarin/internal/version.Version=${{ env.VERSION }}' \
-X 'go.aykhans.me/sarin/internal/version.GitCommit=${{ env.GIT_COMMIT }}' \
-X 'go.aykhans.me/sarin/internal/version.BuildDate=$(date -u +%Y-%m-%dT%H:%M:%SZ)' \
-X 'go.aykhans.me/sarin/internal/version.GoVersion=$(go version)' \
-s -w"
CGO_ENABLED=0 GOEXPERIMENT=greenteagc GOOS=linux GOARCH=amd64 go build -ldflags "$LDFLAGS" -o ./sarin-linux-amd64 ./cmd/cli/main.go
CGO_ENABLED=0 GOEXPERIMENT=greenteagc GOOS=linux GOARCH=arm64 go build -ldflags "$LDFLAGS" -o ./sarin-linux-arm64 ./cmd/cli/main.go
CGO_ENABLED=0 GOEXPERIMENT=greenteagc GOOS=darwin GOARCH=amd64 go build -ldflags "$LDFLAGS" -o ./sarin-darwin-amd64 ./cmd/cli/main.go
CGO_ENABLED=0 GOEXPERIMENT=greenteagc GOOS=darwin GOARCH=arm64 go build -ldflags "$LDFLAGS" -o ./sarin-darwin-arm64 ./cmd/cli/main.go
CGO_ENABLED=0 GOEXPERIMENT=greenteagc GOOS=windows GOARCH=amd64 go build -ldflags "$LDFLAGS" -o ./sarin-windows-amd64.exe ./cmd/cli/main.go
CGO_ENABLED=0 GOEXPERIMENT=greenteagc GOOS=windows GOARCH=arm64 go build -ldflags "$LDFLAGS" -o ./sarin-windows-arm64.exe ./cmd/cli/main.go
- name: Upload Release Assets
if: github.event_name == 'release' || inputs.build_binaries
uses: softprops/action-gh-release@v2
with:
tag_name: ${{ inputs.tag || github.ref_name }}
files: ./sarin-*
- name: Set up QEMU
if: github.event_name == 'release' || inputs.build_docker
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
if: github.event_name == 'release' || inputs.build_docker
uses: docker/setup-buildx-action@v3
- name: Login to Docker Hub
if: github.event_name == 'release' || inputs.build_docker
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Build and push Docker image
if: github.event_name == 'release' || inputs.build_docker
uses: docker/build-push-action@v6
with:
context: .
platforms: linux/amd64,linux/arm64
push: true
build-args: |
VERSION=${{ env.VERSION }}
GIT_COMMIT=${{ env.GIT_COMMIT }}
GO_VERSION=${{ env.GO_VERSION }}
tags: |
${{ secrets.DOCKERHUB_USERNAME }}/sarin:${{ env.VERSION }}
${{ secrets.DOCKERHUB_USERNAME }}/sarin:latest

3
.gitignore vendored
View File

@@ -1,2 +1 @@
dodo
binaries/
bin/*

101
.golangci.yaml Normal file
View File

@@ -0,0 +1,101 @@
version: "2"
run:
go: "1.25"
concurrency: 12
linters:
default: none
enable:
- asciicheck
- errcheck
- govet
- ineffassign
- misspell
- nakedret
- nolintlint
- prealloc
- reassign
- staticcheck
- unconvert
- unused
- whitespace
- bidichk
- bodyclose
- containedctx
- contextcheck
- copyloopvar
- embeddedstructfieldcheck
- errorlint
- exptostd
- fatcontext
- forcetypeassert
- funcorder
- gocheckcompilerdirectives
- gocritic
- gomoddirectives
- gosec
- gosmopolitan
- grouper
- importas
- inamedparam
- intrange
- loggercheck
- mirror
- musttag
- perfsprint
- predeclared
- tagalign
- tagliatelle
- testifylint
- thelper
- tparallel
- unparam
- usestdlibvars
- usetesting
- wastedassign
settings:
staticcheck:
checks:
- "all"
- "-S1002"
- "-ST1000"
varnamelen:
ignore-decls:
- w http.ResponseWriter
- wg sync.WaitGroup
- wg *sync.WaitGroup
exclusions:
rules:
- path: _test\.go$
linters:
- errorlint
- forcetypeassert
- perfsprint
- errcheck
- gosec
- path: _test\.go$
linters:
- staticcheck
text: "SA5011"
formatters:
enable:
- gofmt
settings:
gofmt:
# Simplify code: gofmt with `-s` option.
# Default: true
simplify: false
# Apply the rewrite rules to the source before reformatting.
# https://pkg.go.dev/cmd/gofmt
# Default: []
rewrite-rules:
- pattern: "interface{}"
replacement: "any"
- pattern: "a[b:len(a)]"
replacement: "a[b:]"

View File

@@ -1,33 +0,0 @@
version: "2"
run:
go: "1.25"
concurrency: 8
timeout: 10m
linters:
default: none
enable:
- asasalint
- asciicheck
- errcheck
- gomodguard
- goprintffuncname
- govet
- ineffassign
- misspell
- nakedret
- nolintlint
- prealloc
- reassign
- staticcheck
- unconvert
- unused
- whitespace
settings:
staticcheck:
checks:
- "all"
- "-S1002"
- "-ST1000"

View File

@@ -1,17 +1,29 @@
FROM golang:1.25-alpine AS builder
ARG GO_VERSION=required
FROM docker.io/library/golang:${GO_VERSION}-alpine AS builder
ARG VERSION=unknown
ARG GIT_COMMIT=unknown
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN --mount=type=bind,source=./go.mod,target=./go.mod \
--mount=type=bind,source=./go.sum,target=./go.sum \
go mod download
RUN CGO_ENABLED=0 go build -ldflags "-s -w" -o dodo
RUN --mount=type=bind,source=./,target=./ \
CGO_ENABLED=0 GOEXPERIMENT=greenteagc go build \
-ldflags "-X 'go.aykhans.me/sarin/internal/version.Version=${VERSION}' \
-X 'go.aykhans.me/sarin/internal/version.GitCommit=${GIT_COMMIT}' \
-X 'go.aykhans.me/sarin/internal/version.BuildDate=$(date -u +%Y-%m-%dT%H:%M:%SZ)' \
-X 'go.aykhans.me/sarin/internal/version.GoVersion=$(go version)' \
-s -w" \
-o /sarin ./cmd/cli/main.go
FROM gcr.io/distroless/static-debian12:latest
WORKDIR /
COPY --from=builder /src/dodo /dodo
COPY --from=builder /sarin /sarin
ENTRYPOINT ["./dodo"]
ENTRYPOINT ["./sarin"]

View File

@@ -1,934 +0,0 @@
# Dodo Usage Examples
This document provides comprehensive examples of using Dodo with various configuration combinations. Each example includes three methods: CLI usage, YAML configuration, and JSON configuration.
## Table of Contents
1. [Basic HTTP Stress Testing](#1-basic-http-stress-testing)
2. [POST Request with Form Data](#2-post-request-with-form-data)
3. [API Testing with Authentication](#3-api-testing-with-authentication)
4. [Testing with Custom Headers and Cookies](#4-testing-with-custom-headers-and-cookies)
5. [Load Testing with Proxy Rotation](#5-load-testing-with-proxy-rotation)
6. [JSON API Testing with Dynamic Data](#6-json-api-testing-with-dynamic-data)
7. [File Upload Testing](#7-file-upload-testing)
8. [E-commerce Cart Testing](#8-e-commerce-cart-testing)
9. [GraphQL API Testing](#9-graphql-api-testing)
10. [WebSocket-style HTTP Testing](#10-websocket-style-http-testing)
11. [Multi-tenant Application Testing](#11-multi-tenant-application-testing)
12. [Rate Limiting Testing](#12-rate-limiting-testing)
---
## 1. Basic HTTP Stress Testing
Test a simple website with basic GET requests to measure performance under load.
### CLI Usage
```bash
dodo -u https://httpbin.org/get \
-m GET \
-d 5 \
-r 100 \
-t 5s \
-o 30s \
--skip-verify=false \
-y
```
### YAML Configuration
```yaml
method: "GET"
url: "https://httpbin.org/get"
yes: true
timeout: "5s"
dodos: 5
requests: 100
duration: "30s"
skip_verify: false
```
### JSON Configuration
```json
{
"method": "GET",
"url": "https://httpbin.org/get",
"yes": true,
"timeout": "5s",
"dodos": 5,
"requests": 100,
"duration": "30s",
"skip_verify": false
}
```
---
## 2. POST Request with Form Data
Test form submission endpoints with randomized form data.
### CLI Usage
```bash
dodo -u https://httpbin.org/post \
-m POST \
-d 3 \
-r 50 \
-t 10s \
--skip-verify=false \
-H "Content-Type:application/x-www-form-urlencoded" \
-b "username={{ fakeit_Username }}&password={{ fakeit_Password true true true true true 12 }}&email={{ fakeit_Email }}" \
-b "username={{ fakeit_Username }}&password={{ fakeit_Password true true true true true 8 }}&email={{ fakeit_Email }}" \
-y
```
### YAML Configuration
```yaml
method: "POST"
url: "https://httpbin.org/post"
yes: true
timeout: "10s"
dodos: 3
requests: 50
skip_verify: false
headers:
- Content-Type: "application/x-www-form-urlencoded"
body:
- "username={{ fakeit_Username }}&password={{ fakeit_Password true true true true true 12 }}&email={{ fakeit_Email }}"
- "username={{ fakeit_Username }}&password={{ fakeit_Password true true true true true 8 }}&email={{ fakeit_Email }}"
```
### JSON Configuration
```json
{
"method": "POST",
"url": "https://httpbin.org/post",
"yes": true,
"timeout": "10s",
"dodos": 3,
"requests": 50,
"skip_verify": false,
"headers": [{ "Content-Type": "application/x-www-form-urlencoded" }],
"body": [
"username={{ fakeit_Username }}&password={{ fakeit_Password true true true true true 12 }}&email={{ fakeit_Email }}",
"username={{ fakeit_Username }}&password={{ fakeit_Password true true true true true 8 }}&email={{ fakeit_Email }}"
]
}
```
---
## 3. API Testing with Authentication
Test protected API endpoints with various authentication methods.
### CLI Usage
```bash
dodo -u https://httpbin.org/bearer \
-m GET \
-d 4 \
-r 200 \
-t 8s \
--skip-verify=false \
-H "Authorization:Bearer {{ fakeit_LetterN 32 }}" \
-H "User-Agent:{{ fakeit_UserAgent }}" \
-H "X-Request-ID:{{ fakeit_Int }}" \
-H "Accept:application/json" \
-p "api_version=v1" \
-p "format=json" \
-p "client_id=mobile" -p "client_id=web" -p "client_id=desktop" \
-y
```
### YAML Configuration
```yaml
method: "GET"
url: "https://httpbin.org/bearer"
yes: true
timeout: "8s"
dodos: 4
requests: 200
skip_verify: false
params:
- api_version: "v1"
- format: "json"
- client_id: ["mobile", "web", "desktop"]
headers:
- Authorization: "Bearer {{ fakeit_LetterN 32 }}"
- User-Agent: "{{ fakeit_UserAgent }}"
- X-Request-ID: "{{ fakeit_Int }}"
- Accept: "application/json"
```
### JSON Configuration
```json
{
"method": "GET",
"url": "https://httpbin.org/bearer",
"yes": true,
"timeout": "8s",
"dodos": 4,
"requests": 200,
"skip_verify": false,
"params": [
{ "api_version": "v1" },
{ "format": "json" },
{ "client_id": ["mobile", "web", "desktop"] }
],
"headers": [
{ "Authorization": "Bearer {{ fakeit_LetterN 32 }}" },
{ "User-Agent": "{{ fakeit_UserAgent }}" },
{ "X-Request-ID": "{{ fakeit_Int }}" },
{ "Accept": "application/json" }
]
}
```
---
## 4. Testing with Custom Headers and Cookies
Test applications that require specific headers and session cookies.
### CLI Usage
```bash
dodo -u https://httpbin.org/cookies \
-m GET \
-d 6 \
-r 75 \
-t 5s \
--skip-verify=false \
-H 'Accept-Language:{{ strings_Join "," (fakeit_LanguageAbbreviation) (fakeit_LanguageAbbreviation) (fakeit_LanguageAbbreviation) }}' \
-H "X-Forwarded-For:{{ fakeit_IPv4Address }}" \
-H "X-Real-IP:{{ fakeit_IPv4Address }}" \
-H "Accept-Encoding:gzip" -H "Accept-Encoding:deflate" -H "Accept-Encoding:br" \
-c "session_id={{ fakeit_UUID }}" \
-c 'user_pref={{ fakeit_RandomString "a1" "b2" "c3" }}' \
-c "theme=dark" -c "theme=light" -c "theme=auto" \
-c "lang=en" -c "lang=es" -c "lang=fr" -c "lang=de" \
-y
```
### YAML Configuration
```yaml
method: "GET"
url: "https://httpbin.org/cookies"
yes: true
timeout: "5s"
dodos: 6
requests: 75
skip_verify: false
headers:
- Accept-Language: '{{ strings_Join "," (fakeit_LanguageAbbreviation) (fakeit_LanguageAbbreviation) (fakeit_LanguageAbbreviation) }}'
- X-Forwarded-For: "{{ fakeit_IPv4Address }}"
- X-Real-IP: "{{ fakeit_IPv4Address }}"
- Accept-Encoding: ["gzip", "deflate", "br"]
cookies:
- session_id: "{{ fakeit_UUID }}"
- user_pref: '{{ fakeit_RandomString "a1" "b2" "c3" }}'
- theme: ["dark", "light", "auto"]
- lang: ["en", "es", "fr", "de"]
```
### JSON Configuration
```json
{
"method": "GET",
"url": "https://httpbin.org/cookies",
"yes": true,
"timeout": "5s",
"dodos": 6,
"requests": 75,
"skip_verify": false,
"headers": [
{
"Accept-Language": "{{ strings_Join \",\" (fakeit_LanguageAbbreviation) (fakeit_LanguageAbbreviation) (fakeit_LanguageAbbreviation) }}"
},
{ "X-Forwarded-For": "{{ fakeit_IPv4Address }}" },
{ "X-Real-IP": "{{ fakeit_IPv4Address }}" },
{ "Accept-Encoding": ["gzip", "deflate", "br"] }
],
"cookies": [
{ "session_id": "{{ fakeit_UUID }}" },
{ "user_pref": "{{ fakeit_RandomString \"a1\" \"b2\" \"c3\" }}" },
{ "theme": ["dark", "light", "auto"] },
{ "lang": ["en", "es", "fr", "de"] }
]
}
```
---
## 5. Load Testing with Proxy Rotation
Test through multiple proxies for distributed load testing.
### CLI Usage
```bash
dodo -u https://httpbin.org/ip \
-m GET \
-d 8 \
-r 300 \
-t 15s \
--skip-verify=false \
-x "http://proxy1.example.com:8080" \
-x "http://proxy2.example.com:8080" \
-x "socks5://proxy3.example.com:1080" \
-x "http://username:password@proxy4.example.com:8080" \
-H "User-Agent:{{ fakeit_UserAgent }}" \
-H "Accept:application/json" \
-y
```
### YAML Configuration
```yaml
method: "GET"
url: "https://httpbin.org/ip"
yes: true
timeout: "15s"
dodos: 8
requests: 300
skip_verify: false
proxy:
- "http://proxy1.example.com:8080"
- "http://proxy2.example.com:8080"
- "socks5://proxy3.example.com:1080"
- "http://username:password@proxy4.example.com:8080"
headers:
- User-Agent: "{{ fakeit_UserAgent }}"
- Accept: "application/json"
```
### JSON Configuration
```json
{
"method": "GET",
"url": "https://httpbin.org/ip",
"yes": true,
"timeout": "15s",
"dodos": 8,
"requests": 300,
"skip_verify": false,
"proxy": [
"http://proxy1.example.com:8080",
"http://proxy2.example.com:8080",
"socks5://proxy3.example.com:1080",
"http://username:password@proxy4.example.com:8080"
],
"headers": [
{ "User-Agent": "{{ fakeit_UserAgent }}" },
{ "Accept": "application/json" }
]
}
```
---
## 6. JSON API Testing with Dynamic Data
Test REST APIs with realistic JSON payloads.
### CLI Usage
```bash
dodo -u https://httpbin.org/post \
-m POST \
-d 5 \
-r 150 \
-t 12s \
--skip-verify=false \
-H "Content-Type:application/json" \
-H "Accept:application/json" \
-H "X-API-Version:2023-10-01" \
-b '{"user_id":{{ fakeit_Uint }},"name":"{{ fakeit_Name }}","email":"{{ fakeit_Email }}","created_at":"{{ fakeit_Date }}"}' \
-b '{"product_id":{{ fakeit_Uint }},"name":"{{ fakeit_ProductName }}","price":{{ fakeit_Price 10 1000 }},"category":"{{ fakeit_ProductCategory }}"}' \
-b '{"order_id":"{{ fakeit_UUID }}","items":[{"id":{{ fakeit_Uint }},"quantity":{{ fakeit_IntRange 1 10 }}}],"total":{{ fakeit_Price 50 500 }}}' \
-y
```
### YAML Configuration
```yaml
method: "POST"
url: "https://httpbin.org/post"
yes: true
timeout: "12s"
dodos: 5
requests: 150
skip_verify: false
headers:
- Content-Type: "application/json"
- Accept: "application/json"
- X-API-Version: "2023-10-01"
body:
- '{"user_id":{{ fakeit_Uint }},"name":"{{ fakeit_Name }}","email":"{{ fakeit_Email }}","created_at":"{{ fakeit_Date }}"}'
- '{"product_id":{{ fakeit_Uint }},"name":"{{ fakeit_ProductName }}","price":{{ fakeit_Price 10 1000 }},"category":"{{ fakeit_ProductCategory }}"}'
- '{"order_id":"{{ fakeit_UUID }}","items":[{"id":{{ fakeit_Uint }},"quantity":{{ fakeit_IntRange 1 10 }}}],"total":{{ fakeit_Price 50 500 }}}'
```
### JSON Configuration
```json
{
"method": "POST",
"url": "https://httpbin.org/post",
"yes": true,
"timeout": "12s",
"dodos": 5,
"requests": 150,
"skip_verify": false,
"headers": [
{ "Content-Type": "application/json" },
{ "Accept": "application/json" },
{ "X-API-Version": "2023-10-01" }
],
"body": [
"{\"user_id\":{{ fakeit_Uint }},\"name\":\"{{ fakeit_Name }}\",\"email\":\"{{ fakeit_Email }}\",\"created_at\":\"{{ fakeit_Date }}\"}",
"{\"product_id\":{{ fakeit_Uint }},\"name\":\"{{ fakeit_ProductName }}\",\"price\":{{ fakeit_Price 10 1000 }},\"category\":\"{{ fakeit_ProductCategory }}\"}",
"{\"order_id\":\"{{ fakeit_UUID }}\",\"items\":[{\"id\":{{ fakeit_Uint }},\"quantity\":{{ fakeit_IntRange 1 10 }}}],\"total\":{{ fakeit_Price 50 500 }}}"
]
}
```
---
## 7. File Upload Testing
Test file upload endpoints with multipart form data.
### CLI Usage
```bash
dodo -u https://httpbin.org/post \
-m POST \
-d 3 \
-r 25 \
-t 30s \
--skip-verify=false \
-H "X-Upload-Source:dodo-test" \
-H "User-Agent:{{ fakeit_UserAgent }}" \
-b '{{ body_FormData (dict_Str "filename" (fakeit_UUID) "content" (fakeit_Paragraph 3 5 10 " ")) }}' \
-b '{{ body_FormData (dict_Str "file" (fakeit_UUID) "description" (fakeit_Sentence 10) "category" "image") }}' \
-y
```
### YAML Configuration
```yaml
method: "POST"
url: "https://httpbin.org/post"
yes: true
timeout: "30s"
dodos: 3
requests: 25
skip_verify: false
headers:
- X-Upload-Source: "dodo-test"
- User-Agent: "{{ fakeit_UserAgent }}"
body:
- '{{ body_FormData (dict_Str "filename" (fakeit_UUID) "content" (fakeit_Paragraph 3 5 10 " ")) }}'
- '{{ body_FormData (dict_Str "file" (fakeit_UUID) "description" (fakeit_Sentence 10) "category" "image") }}'
```
### JSON Configuration
```json
{
"method": "POST",
"url": "https://httpbin.org/post",
"yes": true,
"timeout": "30s",
"dodos": 3,
"requests": 25,
"skip_verify": false,
"headers": [
{ "X-Upload-Source": "dodo-test" },
{ "User-Agent": "{{ fakeit_UserAgent }}" }
],
"body": [
"{{ body_FormData (dict_Str \"filename\" (fakeit_UUID) \"content\" (fakeit_Paragraph 3 5 10 \" \")) }}",
"{{ body_FormData (dict_Str \"file\" (fakeit_UUID) \"description\" (fakeit_Sentence 10) \"category\" \"image\") }}"
]
}
```
---
## 8. E-commerce Cart Testing
Test shopping cart operations with realistic product data.
### CLI Usage
```bash
dodo -u https://api.example-shop.com/cart \
-m POST \
-d 10 \
-r 500 \
-t 8s \
--skip-verify=false \
-H "Content-Type:application/json" \
-H "Authorization:Bearer {{ fakeit_LetterN 32 }}" \
-H "X-Client-Version:1.2.3" \
-H "User-Agent:{{ fakeit_UserAgent }}" \
-c "cart_session={{ fakeit_UUID }}" \
-c "user_pref=guest" -c "user_pref=member" -c "user_pref=premium" \
-c "region=US" -c "region=EU" -c "region=ASIA" \
-p "currency=USD" -p "currency=EUR" -p "currency=GBP" \
-p "locale=en-US" -p "locale=en-GB" -p "locale=de-DE" -p "locale=fr-FR" \
-b '{"action":"add","product_id":"{{ fakeit_UUID }}","quantity":{{ fakeit_IntRange 1 5 }},"user_id":"{{ fakeit_UUID }}"}' \
-b '{"action":"remove","product_id":"{{ fakeit_UUID }}","user_id":"{{ fakeit_UUID }}"}' \
-b '{"action":"update","product_id":"{{ fakeit_UUID }}","quantity":{{ fakeit_IntRange 1 10 }},"user_id":"{{ fakeit_UUID }}"}' \
-y
```
### YAML Configuration
```yaml
method: "POST"
url: "https://api.example-shop.com/cart"
yes: true
timeout: "8s"
dodos: 10
requests: 500
skip_verify: false
headers:
- Content-Type: "application/json"
- Authorization: "Bearer {{ fakeit_LetterN 32 }}"
- X-Client-Version: "1.2.3"
- User-Agent: "{{ fakeit_UserAgent }}"
cookies:
- cart_session: "{{ fakeit_UUID }}"
- user_pref: ["guest", "member", "premium"]
- region: ["US", "EU", "ASIA"]
params:
- currency: ["USD", "EUR", "GBP"]
- locale: ["en-US", "en-GB", "de-DE", "fr-FR"]
body:
- '{"action":"add","product_id":"{{ fakeit_UUID }}","quantity":{{ fakeit_IntRange 1 5 }},"user_id":"{{ fakeit_UUID }}"}'
- '{"action":"remove","product_id":"{{ fakeit_UUID }}","user_id":"{{ fakeit_UUID }}"}'
- '{"action":"update","product_id":"{{ fakeit_UUID }}","quantity":{{ fakeit_IntRange 1 10 }},"user_id":"{{ fakeit_UUID }}"}'
```
### JSON Configuration
```json
{
"method": "POST",
"url": "https://api.example-shop.com/cart",
"yes": true,
"timeout": "8s",
"dodos": 10,
"requests": 500,
"skip_verify": false,
"headers": [
{ "Content-Type": "application/json" },
{ "Authorization": "Bearer {{ fakeit_LetterN 32 }}" },
{ "X-Client-Version": "1.2.3" },
{ "User-Agent": "{{ fakeit_UserAgent }}" }
],
"cookies": [
{ "cart_session": "{{ fakeit_UUID }}" },
{ "user_pref": ["guest", "member", "premium"] },
{ "region": ["US", "EU", "ASIA"] }
],
"params": [
{ "currency": ["USD", "EUR", "GBP"] },
{ "locale": ["en-US", "en-GB", "de-DE", "fr-FR"] }
],
"body": [
"{\"action\":\"add\",\"product_id\":\"{{ fakeit_UUID }}\",\"quantity\":{{ fakeit_IntRange 1 5 }},\"user_id\":\"{{ fakeit_UUID }}\"}",
"{\"action\":\"remove\",\"product_id\":\"{{ fakeit_UUID }}\",\"user_id\":\"{{ fakeit_UUID }}\"}",
"{\"action\":\"update\",\"product_id\":\"{{ fakeit_UUID }}\",\"quantity\":{{ fakeit_IntRange 1 10 }},\"user_id\":\"{{ fakeit_UUID }}\"}"
]
}
```
---
## 9. GraphQL API Testing
Test GraphQL endpoints with various queries and mutations.
### CLI Usage
```bash
dodo -u https://api.example.com/graphql \
-m POST \
-d 4 \
-r 100 \
-t 10s \
--skip-verify=false \
-H "Content-Type:application/json" \
-H "Authorization:Bearer {{ fakeit_UUID }}" \
-H "X-GraphQL-Client:dodo-test" \
-b '{"query":"query GetUser($id: ID!) { user(id: $id) { id name email } }","variables":{"id":"{{ fakeit_UUID }}"}}' \
-b '{"query":"query GetPosts($limit: Int) { posts(limit: $limit) { id title content } }","variables":{"limit":{{ fakeit_IntRange 5 20 }}}}' \
-b '{"query":"mutation CreatePost($input: PostInput!) { createPost(input: $input) { id title } }","variables":{"input":{"title":"{{ fakeit_Sentence 5 }}","content":"{{ fakeit_Paragraph 2 3 5 " "}}","authorId":"{{ fakeit_UUID }}"}}}' \
-y
```
### YAML Configuration
```yaml
method: "POST"
url: "https://api.example.com/graphql"
yes: true
timeout: "10s"
dodos: 4
requests: 100
skip_verify: false
headers:
- Content-Type: "application/json"
- Authorization: "Bearer {{ fakeit_UUID }}"
- X-GraphQL-Client: "dodo-test"
body:
- '{"query":"query GetUser($id: ID!) { user(id: $id) { id name email } }","variables":{"id":"{{ fakeit_UUID }}"}}'
- '{"query":"query GetPosts($limit: Int) { posts(limit: $limit) { id title content } }","variables":{"limit":{{ fakeit_IntRange 5 20 }}}}'
- '{"query":"mutation CreatePost($input: PostInput!) { createPost(input: $input) { id title } }","variables":{"input":{"title":"{{ fakeit_Sentence 5 }}","content":"{{ fakeit_Paragraph 2 3 5 " "}}","authorId":"{{ fakeit_UUID }}"}}}'
```
### JSON Configuration
```json
{
"method": "POST",
"url": "https://api.example.com/graphql",
"yes": true,
"timeout": "10s",
"dodos": 4,
"requests": 100,
"skip_verify": false,
"headers": [
{ "Content-Type": "application/json" },
{ "Authorization": "Bearer {{ fakeit_UUID }}" },
{ "X-GraphQL-Client": "dodo-test" }
],
"body": [
"{\"query\":\"query GetUser($id: ID!) { user(id: $id) { id name email } }\",\"variables\":{\"id\":\"{{ fakeit_UUID }}\"}}",
"{\"query\":\"query GetPosts($limit: Int) { posts(limit: $limit) { id title content } }\",\"variables\":{\"limit\":{{ fakeit_IntRange 5 20 }}}}",
"{\"query\":\"mutation CreatePost($input: PostInput!) { createPost(input: $input) { id title } }\",\"variables\":{\"input\":{\"title\":\"{{ fakeit_Sentence 5 }}\",\"content\":\"{{ fakeit_Paragraph 2 3 5 \\\" \\\"}}\",\"authorId\":\"{{ fakeit_UUID }}\"}}}"
]
}
```
---
## 10. WebSocket-style HTTP Testing
Test real-time applications with WebSocket-like HTTP endpoints.
### CLI Usage
```bash
dodo -u https://api.realtime-app.com/events \
-m POST \
-d 15 \
-r 1000 \
-t 5s \
-o 60s \
--skip-verify=false \
-H "Content-Type:application/json" \
-H "X-Event-Type:{{ fakeit_LetterNN 4 12 }}" \
-H "Connection:keep-alive" \
-H "Cache-Control:no-cache" \
-c "connection_id={{ fakeit_UUID }}" \
-c "session_token={{ fakeit_UUID }}" \
-p "channel=general" -p "channel=notifications" -p "channel=alerts" -p "channel=updates" \
-p "version=v1" -p "version=v2" \
-b '{"event":"{{ fakeit_Word }}","data":{"timestamp":"{{ fakeit_Date }}","user_id":"{{ fakeit_UUID }}","message":"{{ fakeit_Sentence 8 }}"}}' \
-b '{"event":"ping","data":{"timestamp":"{{ fakeit_Date }}","client_id":"{{ fakeit_UUID }}"}}' \
-b '{"event":"status_update","data":{"status":"{{ fakeit_Word }}","user_id":"{{ fakeit_UUID }}","timestamp":"{{ fakeit_Date }}"}}' \
-y
```
### YAML Configuration
```yaml
method: "POST"
url: "https://api.realtime-app.com/events"
yes: true
timeout: "5s"
dodos: 15
requests: 1000
duration: "60s"
skip_verify: false
headers:
- Content-Type: "application/json"
- X-Event-Type: "{{ fakeit_LetterNN 4 12 }}"
- Connection: "keep-alive"
- Cache-Control: "no-cache"
cookies:
- connection_id: "{{ fakeit_UUID }}"
- session_token: "{{ fakeit_UUID }}"
params:
- channel: ["general", "notifications", "alerts", "updates"]
- version: ["v1", "v2"]
body:
- '{"event":"{{ fakeit_Word }}","data":{"timestamp":"{{ fakeit_Date }}","user_id":"{{ fakeit_UUID }}","message":"{{ fakeit_Sentence 8 }}"}}'
- '{"event":"ping","data":{"timestamp":"{{ fakeit_Date }}","client_id":"{{ fakeit_UUID }}"}}'
- '{"event":"status_update","data":{"status":"{{ fakeit_Word }}","user_id":"{{ fakeit_UUID }}","timestamp":"{{ fakeit_Date }}"}}'
```
### JSON Configuration
```json
{
"method": "POST",
"url": "https://api.realtime-app.com/events",
"yes": true,
"timeout": "5s",
"dodos": 15,
"requests": 1000,
"duration": "60s",
"skip_verify": false,
"headers": [
{ "Content-Type": "application/json" },
{ "X-Event-Type": "{{ fakeit_LetterNN 4 12 }}" },
{ "Connection": "keep-alive" },
{ "Cache-Control": "no-cache" }
],
"cookies": [
{ "connection_id": "{{ fakeit_UUID }}" },
{ "session_token": "{{ fakeit_UUID }}" }
],
"params": [
{ "channel": ["general", "notifications", "alerts", "updates"] },
{ "version": ["v1", "v2"] }
],
"body": [
"{\"event\":\"{{ fakeit_Word }}\",\"data\":{\"timestamp\":\"{{ fakeit_Date }}\",\"user_id\":\"{{ fakeit_UUID }}\",\"message\":\"{{ fakeit_Sentence 8 }}\"}}",
"{\"event\":\"ping\",\"data\":{\"timestamp\":\"{{ fakeit_Date }}\",\"client_id\":\"{{ fakeit_UUID }}\"}}",
"{\"event\":\"status_update\",\"data\":{\"status\":\"{{ fakeit_Word }}\",\"user_id\":\"{{ fakeit_UUID }}\",\"timestamp\":\"{{ fakeit_Date }}\"}}"
]
}
```
---
## 11. Multi-tenant Application Testing
Test SaaS applications with tenant-specific configurations.
### CLI Usage
```bash
dodo -u https://app.saas-platform.com/api/data \
-m GET \
-d 12 \
-r 600 \
-t 15s \
--skip-verify=false \
-H "X-Tenant-ID:{{ fakeit_UUID }}" \
-H "Authorization:Bearer {{ fakeit_LetterN 64 }}" \
-H "X-Client-Type:web" -H "X-Client-Type:mobile" -H "X-Client-Type:api" \
-H "Accept:application/json" \
-c "tenant_session={{ fakeit_UUID }}" \
-c "user_role=admin" -c "user_role=user" -c "user_role=viewer" \
-c "subscription_tier=free" -c "subscription_tier=pro" -c "subscription_tier=enterprise" \
-p "page={{ fakeit_IntRange 1 10 }}" \
-p "limit={{ fakeit_IntRange 10 100 }}" \
-p "sort=created_at" -p "sort=updated_at" -p "sort=name" \
-p "order=asc" -p "order=desc" \
-p "filter_by=active" -p "filter_by=inactive" -p "filter_by=pending" \
-y
```
### YAML Configuration
```yaml
method: "GET"
url: "https://app.saas-platform.com/api/data"
yes: true
timeout: "15s"
dodos: 12
requests: 600
skip_verify: false
headers:
- X-Tenant-ID: "{{ fakeit_UUID }}"
- Authorization: "Bearer {{ fakeit_LetterN 64 }}"
- X-Client-Type: ["web", "mobile", "api"]
- Accept: "application/json"
cookies:
- tenant_session: "{{ fakeit_UUID }}"
- user_role: ["admin", "user", "viewer"]
- subscription_tier: ["free", "pro", "enterprise"]
params:
- page: "{{ fakeit_IntRange 1 10 }}"
- limit: "{{ fakeit_IntRange 10 100 }}"
- sort: ["created_at", "updated_at", "name"]
- order: ["asc", "desc"]
- filter_by: ["active", "inactive", "pending"]
```
### JSON Configuration
```json
{
"method": "GET",
"url": "https://app.saas-platform.com/api/data",
"yes": true,
"timeout": "15s",
"dodos": 12,
"requests": 600,
"skip_verify": false,
"headers": [
{ "X-Tenant-ID": "{{ fakeit_UUID }}" },
{ "Authorization": "Bearer {{ fakeit_LetterN 64 }}" },
{ "X-Client-Type": ["web", "mobile", "api"] },
{ "Accept": "application/json" }
],
"cookies": [
{ "tenant_session": "{{ fakeit_UUID }}" },
{ "user_role": ["admin", "user", "viewer"] },
{ "subscription_tier": ["free", "pro", "enterprise"] }
],
"params": [
{ "page": "{{ fakeit_IntRange 1 10 }}" },
{ "limit": "{{ fakeit_IntRange 10 100 }}" },
{ "sort": ["created_at", "updated_at", "name"] },
{ "order": ["asc", "desc"] },
{ "filter_by": ["active", "inactive", "pending"] }
]
}
```
---
## 12. Rate Limiting Testing
Test API rate limits and throttling mechanisms.
### CLI Usage
```bash
dodo -u https://api.rate-limited.com/endpoint \
-m GET \
-d 20 \
-r 2000 \
-t 3s \
-o 120s \
--skip-verify=false \
-H "X-API-Key:{{ fakeit_UUID }}" \
-H "X-Client-ID:{{ fakeit_UUID }}" \
-H "X-Rate-Limit-Test:true" \
-H "User-Agent:{{ fakeit_UserAgent }}" \
-c "rate_limit_bucket={{ fakeit_UUID }}" \
-c "client_tier=tier1" -c "client_tier=tier2" -c "client_tier=tier3" \
-p "burst_test=true" \
-p "client_type=premium" -p "client_type=standard" -p "client_type=free" \
-p "request_id={{ fakeit_UUID }}" \
-y
```
### YAML Configuration
```yaml
method: "GET"
url: "https://api.rate-limited.com/endpoint"
yes: true
timeout: "3s"
dodos: 20
requests: 2000
duration: "120s"
skip_verify: false
headers:
- X-API-Key: "{{ fakeit_UUID }}"
- X-Client-ID: "{{ fakeit_UUID }}"
- X-Rate-Limit-Test: "true"
- User-Agent: "{{ fakeit_UserAgent }}"
params:
- burst_test: "true"
- client_type: ["premium", "standard", "free"]
- request_id: "{{ fakeit_UUID }}"
cookies:
- rate_limit_bucket: "{{ fakeit_UUID }}"
- client_tier: ["tier1", "tier2", "tier3"]
```
### JSON Configuration
```json
{
"method": "GET",
"url": "https://api.rate-limited.com/endpoint",
"yes": true,
"timeout": "3s",
"dodos": 20,
"requests": 2000,
"duration": "120s",
"skip_verify": false,
"headers": [
{ "X-API-Key": "{{ fakeit_UUID }}" },
{ "X-Client-ID": "{{ fakeit_UUID }}" },
{ "X-Rate-Limit-Test": "true" },
{ "User-Agent": "{{ fakeit_UserAgent }}" }
],
"params": [
{ "burst_test": "true" },
{ "client_type": ["premium", "standard", "free"] },
{ "request_id": "{{ fakeit_UUID }}" }
],
"cookies": [
{ "rate_limit_bucket": "{{ fakeit_UUID }}" },
{ "client_tier": ["tier1", "tier2", "tier3"] }
]
}
```
---
## Notes
- All examples use template functions for dynamic data generation
- Adjust `dodos`, `requests`, `duration`, and `timeout` values based on your testing requirements
- Use `skip_verify: true` for testing with self-signed certificates
- Set `yes: true` to skip confirmation prompts in automated testing
- Template functions like `{{ fakeit_* }}` generate random realistic data for each request
- Multiple values in arrays (e.g., `["value1", "value2"]`) will be randomly selected per request
- Use the `body_FormData` function for multipart form uploads
- Proxy configurations support HTTP, SOCKS5, and SOCKS5H protocols
For more template functions and advanced configuration options, refer to the main documentation and `utils/templates.go`.

378
README.md
View File

@@ -1,340 +1,130 @@
<h1 align="center">Dodo - A Fast and Easy-to-Use HTTP Benchmarking Tool</h1>
![Usage](https://ftp.aykhans.me/web/client/pubshares/VzPtSHS7yPQT7ngoZzZSNU/browse?path=/dodo_demonstrate.gif)
<div align="center">
<h4>
<a href="./EXAMPLES.md">
Examples
</a>
<span> | </span>
<a href="#installation">
Install
</a>
<span> | </span>
<a href="https://hub.docker.com/r/aykhans/dodo">
Docker
</a>
</h4>
<br>
<a href="https://coff.ee/aykhan">
<img src="https://cdn.buymeacoffee.com/buttons/v2/default-yellow.png" alt="Buy Me A Coffee" style="height: 40px !important;width: 150px !important;">
</a>
## Sarin is a high-performance HTTP load testing tool built with Go and fasthttp.
</div>
## Table of Contents
![Demo](docs/static/demo.gif)
- [Installation](#installation)
- [Using Docker (Recommended)](#using-docker-recommended)
- [Using Pre-built Binaries](#using-pre-built-binaries)
- [Building from Source](#building-from-source)
- [Usage](#usage)
- [1. CLI Usage](#1-cli-usage)
- [2. Config File Usage](#2-config-file-usage)
- [2.1 YAML/YML Example](#21-yamlyml-example)
- [2.2 JSON Example](#22-json-example)
- [3. CLI & Config File Combination](#3-cli--config-file-combination)
- [Config Parameters Reference](#config-parameters-reference)
- [Template Functions](#template-functions)
<p align="center">
<a href="#installation">Install</a> •
<a href="#quick-start">Quick Start</a> •
<a href="docs/examples.md">Examples</a> •
<a href="docs/configuration.md">Configuration</a> •
<a href="docs/templating.md">Templating</a>
</p>
## Overview
Sarin is designed for efficient HTTP load testing with minimal resource consumption. It prioritizes simplicity—features like templating add zero overhead when unused.
| ✅ Supported | ❌ Not Supported |
| ---------------------------------------------------- | --------------------------------- |
| High-performance with low memory footprint | Detailed response body analysis |
| Long-running duration/count based tests | Extensive response statistics |
| Dynamic requests via 320+ template functions | Web UI or complex TUI |
| Multiple proxy protocols (HTTP/HTTPS/SOCKS5/SOCKS5H) | Scripting or multi-step scenarios |
| Flexible config (CLI, ENV, YAML) | HTTP/2, HTTP/3, WebSocket, gRPC |
## Installation
### Using Docker (Recommended)
Pull the latest Dodo image from Docker Hub:
### Docker (Recommended)
```sh
docker pull aykhans/dodo:latest
docker pull aykhans/sarin:latest
```
To use Dodo with Docker and a local config file, mount the config file as a volume and pass it as an argument:
With a local config file:
```sh
docker run -v /path/to/config.json:/config.json aykhans/dodo -f /config.json
docker run --rm -it -v /path/to/config.yaml:/config.yaml aykhans/sarin -f /config.yaml
```
If you're using a remote config file via URL, you don't need to mount a volume:
With a remote config file:
```sh
docker run aykhans/dodo -f https://raw.githubusercontent.com/aykhans/dodo/main/config.yaml
docker run --rm -it aykhans/sarin -f https://example.com/config.yaml
```
### Using Pre-built Binaries
### Pre-built Binaries
Download the latest binaries from the [releases](https://github.com/aykhans/dodo/releases) section.
Download the latest binaries from the [releases](https://github.com/aykhans/sarin/releases) page.
### Building from Source
To build Dodo from source, ensure you have [Go 1.24+](https://golang.org/dl/) installed.
Requires [Go 1.25+](https://golang.org/dl/).
```sh
go install -ldflags "-s -w" github.com/aykhans/dodo@latest
git clone https://github.com/aykhans/sarin.git && cd sarin
CGO_ENABLED=0 GOEXPERIMENT=greenteagc go build \
-ldflags "-X 'go.aykhans.me/sarin/internal/version.Version=dev' \
-X 'go.aykhans.me/sarin/internal/version.GitCommit=$(git rev-parse HEAD)' \
-X 'go.aykhans.me/sarin/internal/version.BuildDate=$(date -u +%Y-%m-%dT%H:%M:%SZ)' \
-X 'go.aykhans.me/sarin/internal/version.GoVersion=$(go version)' \
-s -w" \
-o sarin ./cmd/cli/main.go
```
## Usage
## Quick Start
Dodo supports CLI arguments, configuration files (JSON/YAML), or a combination of both. If both are used, CLI arguments take precedence.
### 1. CLI Usage
Send 1000 GET requests to https://example.com with 10 parallel dodos (threads), each with a timeout of 2 seconds, within a maximum duration of 1 minute:
Send 10,000 GET requests with 50 concurrent connections and a random User-Agent for each request:
```sh
dodo -u https://example.com -m GET -d 10 -r 1000 -o 1m -t 2s
sarin -U http://example.com -r 10_000 -c 50 -H "User-Agent: {{ fakeit_UserAgent }}"
```
With Docker:
Example output:
```
┌──────────┬───────┬──────────┬───────────┬─────────┬───────────┬──────────┬───────────┐
│ Response │ Count │ Min │ Max │ Average │ P90 │ P95 │ P99 │
├──────────┼───────┼──────────┼───────────┼─────────┼───────────┼──────────┼───────────┤
│ 200 │ 10000 │ 78.038ms │ 288.153ms │ 94.71ms │ 103.078ms │ 131.08ms │ 269.218ms │
├──────────┼───────┼──────────┼───────────┼─────────┼───────────┼──────────┼───────────┤
│ Total │ 10000 │ 78.038ms │ 288.153ms │ 94.71ms │ 103.078ms │ 131.08ms │ 269.218ms │
└──────────┴───────┴──────────┴───────────┴─────────┴───────────┴──────────┴───────────┘
```
Run a 5-minute duration-based test:
```sh
docker run --rm -i aykhans/dodo -u https://example.com -m GET -d 10 -r 1000 -o 1m -t 2s
sarin -U http://example.com -d 5m -c 100
```
### 2. Config File Usage
Send 1000 GET requests to https://example.com with 10 parallel dodos (threads), each with a timeout of 800 milliseconds, within a maximum duration of 250 seconds:
#### 2.1 YAML/YML Example
```yaml
method: "GET"
url: "https://example.com"
yes: false
timeout: "800ms"
dodos: 10
requests: 1000
duration: "250s"
skip_verify: false
params:
# A random value will be selected from the list for first "key1" param on each request
# And always "value" for second "key1" param on each request
# e.g. "?key1=value2&key1=value"
- key1: ["value1", "value2", "value3", "value4"]
- key1: "value"
# A random value will be selected from the list for param "key2" on each request
# e.g. "?key2=value2"
- key2: ["value1", "value2"]
headers:
# A random value will be selected from the list for first "key1" header on each request
# And always "value" for second "key1" header on each request
# e.g. "key1: value3", "key1: value"
- key1: ["value1", "value2", "value3", "value4"]
- key1: "value"
# A random value will be selected from the list for header "key2" on each request
# e.g. "key2: value2"
- key2: ["value1", "value2"]
cookies:
# A random value will be selected from the list for first "key1" cookie on each request
# And always "value" for second "key1" cookie on each request
# e.g. "key1=value4; key1=value"
- key1: ["value1", "value2", "value3", "value4"]
- key1: "value"
# A random value will be selected from the list for cookie "key2" on each request
# e.g. "key2=value1"
- key2: ["value1", "value2"]
body: "body-text"
# OR
# A random body value will be selected from the list for each request
body:
- "body-text1"
- "body-text2"
- "body-text3"
proxy: "http://example.com:8080"
# OR
# A random proxy will be selected from the list for each request
proxy:
- "http://example.com:8080"
- "http://username:password@example.com:8080"
- "socks5://example.com:8080"
- "socks5h://example.com:8080"
```
Use a YAML config file:
```sh
dodo -f /path/config.yaml
# OR
dodo -f https://example.com/config.yaml
sarin -f config.yaml
```
With Docker:
For more usage examples, see the **[Examples Guide](docs/examples.md)**.
## Configuration
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)
```
For detailed documentation on all configuration options (URL, method, timeout, concurrency, headers, cookies, proxy, etc.), see the **[Configuration Guide](docs/configuration.md)**.
## Templating
Sarin supports Go templates in methods, bodies, headers, params, cookies, and values. Use the 320+ built-in functions to generate dynamic data for each request.
**Example:**
```sh
docker run --rm -i -v /path/to/config.yaml:/config.yaml aykhans/dodo -f /config.yaml
# OR
docker run --rm -i aykhans/dodo -f https://example.com/config.yaml
sarin -U http://example.com/users \
-V "ID={{ fakeit_UUID }}" \
-H "X-Request-ID: {{ .Values.ID }}" \
-B '{"id": "{{ .Values.ID }}"}'
```
#### 2.2 JSON Example
For the complete templating guide and functions reference, see the **[Templating Guide](docs/templating.md)**.
```jsonc
{
"method": "GET",
"url": "https://example.com",
"yes": false,
"timeout": "800ms",
"dodos": 10,
"requests": 1000,
"duration": "250s",
"skip_verify": false,
## License
"params": [
// A random value will be selected from the list for first "key1" param on each request
// And always "value" for second "key1" param on each request
// e.g. "?key1=value2&key1=value"
{ "key1": ["value1", "value2", "value3", "value4"] },
{ "key1": "value" },
// A random value will be selected from the list for param "key2" on each request
// e.g. "?key2=value2"
{ "key2": ["value1", "value2"] },
],
"headers": [
// A random value will be selected from the list for first "key1" header on each request
// And always "value" for second "key1" header on each request
// e.g. "key1: value3", "key1: value"
{ "key1": ["value1", "value2", "value3", "value4"] },
{ "key1": "value" },
// A random value will be selected from the list for header "key2" on each request
// e.g. "key2: value2"
{ "key2": ["value1", "value2"] },
],
"cookies": [
// A random value will be selected from the list for first "key1" cookie on each request
// And always "value" for second "key1" cookie on each request
// e.g. "key1=value4; key1=value"
{ "key1": ["value1", "value2", "value3", "value4"] },
{ "key1": "value" },
// A random value will be selected from the list for cookie "key2" on each request
// e.g. "key2=value1"
{ "key2": ["value1", "value2"] },
],
"body": "body-text",
// OR
// A random body value will be selected from the list for each request
"body": ["body-text1", "body-text2", "body-text3"],
"proxy": "http://example.com:8080",
// OR
// A random proxy will be selected from the list for each request
"proxy": [
"http://example.com:8080",
"http://username:password@example.com:8080",
"socks5://example.com:8080",
"socks5h://example.com:8080",
],
}
```
```sh
dodo -f /path/config.json
# OR
dodo -f https://example.com/config.json
```
With Docker:
```sh
docker run --rm -i -v /path/to/config.json:/config.json aykhans/dodo
# OR
docker run --rm -i aykhans/dodo -f https://example.com/config.json
```
### 3. CLI & Config File Combination
CLI arguments override config file values:
```sh
dodo -f /path/to/config.yaml -u https://example.com -m GET -d 10 -r 1000 -o 1m -t 5s
```
With Docker:
```sh
docker run --rm -i -v /path/to/config.json:/config.json aykhans/dodo -f /config.json -u https://example.com -m GET -d 10 -r 1000 -o 1m -t 5s
```
You can find more usage examples in the [EXAMPLES.md](./EXAMPLES.md) file.
## Config Parameters Reference
If `Headers`, `Params`, `Cookies`, `Body`, or `Proxy` fields have multiple values, each request will choose a random value from the list.
| Parameter | config file | CLI Flag | CLI Short Flag | Type | Description | Default |
| --------------- | ----------- | ------------ | -------------- | ------------------------------ | ----------------------------------------------------------- | ------- |
| Config file | | -config-file | -f | String | Path to local config file or http(s) URL of the config file | - |
| Yes | yes | -yes | -y | Boolean | Answer yes to all questions | false |
| URL | url | -url | -u | String | URL to send the request to | - |
| Method | method | -method | -m | String | HTTP method | GET |
| Dodos (Threads) | dodos | -dodos | -d | UnsignedInteger | Number of dodos (threads) to send requests in parallel | 1 |
| Requests | requests | -requests | -r | UnsignedInteger | Total number of requests to send | - |
| Duration | duration | -duration | -o | Time | Maximum duration for the test | - |
| Timeout | timeout | -timeout | -t | Time | Timeout for canceling each request | 10s |
| Params | params | -param | -p | [{String: String OR [String]}] | Request parameters | - |
| Headers | headers | -header | -H | [{String: String OR [String]}] | Request headers | - |
| Cookies | cookies | -cookie | -c | [{String: String OR [String]}] | Request cookies | - |
| Body | body | -body | -b | String OR [String] | Request body or list of request bodies | - |
| Proxy | proxies | -proxy | -x | String OR [String] | Proxy URL or list of proxy URLs | - |
| Skip Verify | skip_verify | -skip-verify | | Boolean | Skip SSL/TLS certificate verification | false |
## Template Functions
Dodo supports template functions in `Headers`, `Params`, `Cookies`, and `Body` fields. These functions allow you to generate dynamic values for each request.
You can use Go template syntax to include dynamic values in your requests. Here's how to use template functions:
In CLI config:
```sh
dodo -u https://example.com -r 1 \
-header "User-Agent:{{ fakeit_UserAgent }}" \ # e.g. "User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko)"
-param "username={{ strings_ToUpper fakeit_Username }}" \ # e.g. "username=JOHN BOB"
-cookie "token={{ fakeit_Password true true true true true 10 }}" \ # e.g. token=1234567890abcdef1234567890abcdef
-body '{"email":"{{ fakeit_Email }}", "password":"{{ fakeit_Password true true true true true 10 }}"}' # e.g. {"email":"john.doe@example.com", "password":"12rw4d-78d"}
```
In YAML/YML config:
```yaml
headers:
- User-Agent: "{{ fakeit_UserAgent }}" # e.g. "User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko)"
- "Random-Header-{{fakeit_FirstName}}": "static_value" # e.g. "Random-Header-John: static_value"
cookies:
- token: "Bearer {{ fakeit_UUID }}" # e.g. "token=Bearer 1234567890abcdef1234567890abcdef"
params:
- id: "{{ fakeit_Uint }}" # e.g. "id=1234567890"
- username: "{{ fakeit_Username }}" # e.g. "username=John Doe"
body:
- '{ "username": "{{ fakeit_Username }}", "password": "{{ fakeit_Password }}" }' # e.g. { "username": "john.doe", "password": "password123" }
- '{ "email": "{{ fakeit_Email }}", "phone": "{{ fakeit_Phone }}" }' # e.g. { "email": "john.doe@example.com", "phone": "1234567890" }
- '{{ body_FormData (dict_Str "username" fakeit_Username "password" "secret123") }}' # Creates multipart form data for form submissions, automatically sets the appropriate Content-Type header.
```
In JSON config:
```jsonc
{
"headers": [
{ "User-Agent": "{{ fakeit_UserAgent }}" }, // e.g. "User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko)"
],
"body": [
"{ \"username\": \"{{ strings_RemoveSpaces fakeit_Username }}\", \"password\": \"{{ fakeit_Password }}\" }", // e.g. { "username": "johndoe", "password": "password123" }
"{{ body_FormData (dict_Str \"username\" fakeit_Username \"password\" \"12345\") }}", // Creates multipart form data for form submissions, automatically sets the appropriate Content-Type header.
],
}
```
For the full list of template functions over 200 functions, refer to the `NewFuncMap` function in `utils/templates.go`.
This project is licensed under the MIT License. See the [LICENSE](LICENSE) file for details.

View File

@@ -1,53 +1,72 @@
# https://taskfile.dev
version: "3"
vars:
PLATFORMS:
- os: darwin
archs: [amd64, arm64]
- os: freebsd
archs: [386, amd64, arm]
- os: linux
archs: [386, amd64, arm, arm64]
- os: netbsd
archs: [386, amd64, arm]
- os: openbsd
archs: [386, amd64, arm, arm64]
- os: windows
archs: [386, amd64, arm64]
BIN_DIR: ./bin
GOLANGCI_LINT_VERSION: v2.7.2
GOLANGCI: "{{.BIN_DIR}}/golangci-lint-{{.GOLANGCI_LINT_VERSION}}"
tasks:
run: go run main.go
ftl:
desc: Run fmt, tidy, and lint.
cmds:
- task: fmt
- task: tidy
- task: lint
ftl:
cmds:
- task: fmt
- task: tidy
- task: lint
fmt:
desc: Run linters
deps:
- install-golangci-lint
cmds:
- "{{.GOLANGCI}} fmt"
fmt: gofmt -w -d .
tidy:
desc: Run go mod tidy.
cmds:
- go mod tidy {{.CLI_ARGS}}
tidy: go mod tidy
lint:
desc: Run linters
deps:
- install-golangci-lint
cmds:
- "{{.GOLANGCI}} run"
lint: golangci-lint run
test:
desc: Run Go tests.
cmds:
- go test ./... {{.CLI_ARGS}}
build: CGO_ENABLED=0 go build -ldflags "-s -w" -o "dodo"
create-bin-dir:
desc: Create bin directory.
cmds:
- mkdir -p {{.BIN_DIR}}
build-all:
silent: true
cmds:
- rm -rf binaries
- |
{{ $ext := "" }}
{{- range $platform := .PLATFORMS }}
{{- if eq $platform.os "windows" }}
{{ $ext = ".exe" }}
{{- end }}
build:
desc: Build the application.
deps:
- create-bin-dir
vars:
OUTPUT: '{{.OUTPUT | default (printf "%s/sarin" .BIN_DIR)}}'
cmds:
- rm -f {{.OUTPUT}}
- >-
CGO_ENABLED=0 GOEXPERIMENT=greenteagc go build
-ldflags "-X 'go.aykhans.me/sarin/internal/version.Version=$(git describe --tags --always)'
-X 'go.aykhans.me/sarin/internal/version.GitCommit=$(git rev-parse HEAD)'
-X 'go.aykhans.me/sarin/internal/version.BuildDate=$(date -u +%Y-%m-%dT%H:%M:%SZ)'
-X 'go.aykhans.me/sarin/internal/version.GoVersion=$(go version)'
-s -w"
-o {{.OUTPUT}} ./cmd/cli/main.go
{{- range $arch := $platform.archs }}
echo "Building for {{$platform.os}}/{{$arch}}"
GOOS={{$platform.os}} GOARCH={{$arch}} go build -ldflags "-s -w" -o "./binaries/dodo-{{$platform.os}}-{{$arch}}{{$ext}}"
{{- end }}
{{- end }}
- echo -e "\033[32m*** Build completed ***\033[0m"
install-golangci-lint:
desc: Install golangci-lint
deps:
- create-bin-dir
status:
- test -f {{.GOLANGCI}}
cmds:
- rm -f {{.GOLANGCI}}
- curl -sSfL https://raw.githubusercontent.com/golangci/golangci-lint/master/install.sh | sh -s -- -b {{.BIN_DIR}} {{.GOLANGCI_LINT_VERSION}}
- mv {{.BIN_DIR}}/golangci-lint {{.GOLANGCI}}

84
cmd/cli/main.go Normal file
View File

@@ -0,0 +1,84 @@
package main
import (
"context"
"fmt"
"os"
"os/signal"
"syscall"
"go.aykhans.me/sarin/internal/config"
"go.aykhans.me/sarin/internal/sarin"
"go.aykhans.me/sarin/internal/types"
utilsErr "go.aykhans.me/utils/errors"
)
func main() {
ctx, cancel := context.WithCancel(context.Background())
go listenForTermination(func() { cancel() })
combinedConfig := config.ReadAllConfigs()
combinedConfig.SetDefaults()
if *combinedConfig.ShowConfig {
if !combinedConfig.Print() {
return
}
}
_ = utilsErr.MustHandle(combinedConfig.Validate(),
utilsErr.OnType(func(err types.FieldValidationErrors) error {
for _, fieldErr := range err.Errors {
if fieldErr.Value == "" {
fmt.Fprintln(os.Stderr,
config.StyleYellow.Render(fmt.Sprintf("[VALIDATION] Field '%s': ", fieldErr.Field))+fieldErr.Err.Error(),
)
} else {
fmt.Fprintln(os.Stderr,
config.StyleYellow.Render(fmt.Sprintf("[VALIDATION] Field '%s' (%s): ", fieldErr.Field, fieldErr.Value))+fieldErr.Err.Error(),
)
}
}
os.Exit(1)
return nil
}),
)
srn, err := sarin.NewSarin(
ctx,
combinedConfig.Methods, combinedConfig.URL, *combinedConfig.Timeout,
*combinedConfig.Concurrency, combinedConfig.Requests, combinedConfig.Duration,
*combinedConfig.Quiet, *combinedConfig.Insecure, combinedConfig.Params, combinedConfig.Headers,
combinedConfig.Cookies, combinedConfig.Bodies, combinedConfig.Proxies, combinedConfig.Values,
*combinedConfig.Output != config.ConfigOutputTypeNone,
*combinedConfig.DryRun,
)
_ = utilsErr.MustHandle(err,
utilsErr.OnType(func(err types.ProxyDialError) error {
fmt.Fprintln(os.Stderr, config.StyleRed.Render("[PROXY] ")+err.Error())
os.Exit(1)
return nil
}),
)
srn.Start(ctx)
switch *combinedConfig.Output {
case config.ConfigOutputTypeNone:
return
case config.ConfigOutputTypeJSON:
srn.GetResponses().PrintJSON()
case config.ConfigOutputTypeYAML:
srn.GetResponses().PrintYAML()
default:
srn.GetResponses().PrintTable()
}
}
func listenForTermination(do func()) {
sigChan := make(chan os.Signal, 1)
signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM)
<-sigChan
do()
}

View File

@@ -1,37 +0,0 @@
{
"method": "GET",
"url": "https://example.com",
"yes": false,
"timeout": "5s",
"dodos": 8,
"requests": 1000,
"duration": "10s",
"skip_verify": false,
"params": [
{ "key1": ["value1", "value2", "value3", "value4"] },
{ "key1": "value" },
{ "key2": ["value1", "value2"] }
],
"headers": [
{ "key1": ["value1", "value2", "value3", "value4"] },
{ "key1": "value" },
{ "key2": ["value1", "value2"] }
],
"cookies": [
{ "key1": ["value1", "value2", "value3", "value4"] },
{ "key1": "value" },
{ "key2": ["value1", "value2"] }
],
"body": ["body-text1", "body-text2", "body-text3"],
"proxy": [
"http://example.com:8080",
"http://username:password@example.com:8080",
"socks5://example.com:8080",
"socks5h://example.com:8080"
]
}

View File

@@ -1,40 +0,0 @@
method: "GET"
url: "https://example.com"
yes: false
timeout: "5s"
dodos: 8
requests: 1000
duration: "10s"
skip_verify: false
params:
- key1: ["value1", "value2", "value3", "value4"]
- key1: "value"
- key2: ["value1", "value2"]
headers:
- key1: ["value1", "value2", "value3", "value4"]
- key1: "value"
- key2: ["value1", "value2"]
cookies:
- key1: ["value1", "value2", "value3", "value4"]
- key1: "value"
- key2: ["value1", "value2"]
# body: "body-text"
# OR
# A random body value will be selected from the list for each request
body:
- "body-text1"
- "body-text2"
- "body-text3"
# proxy: "http://example.com:8080"
# OR
# A random proxy will be selected from the list for each request
proxy:
- "http://example.com:8080"
- "http://username:password@example.com:8080"
- "socks5://example.com:8080"
- "socks5h://example.com:8080"

View File

@@ -1,188 +0,0 @@
package config
import (
"flag"
"fmt"
"os"
"strings"
"time"
"github.com/aykhans/dodo/types"
"github.com/aykhans/dodo/utils"
)
const cliUsageText = `Usage:
dodo [flags]
Examples:
Simple usage:
dodo -u https://example.com -o 1m
Usage with config file:
dodo -f /path/to/config/file/config.json
Usage with all flags:
dodo -f /path/to/config/file/config.json \
-u https://example.com -m POST \
-d 10 -r 1000 -o 3m -t 3s \
-b "body1" -body "body2" \
-H "header1:value1" -header "header2:value2" \
-p "param1=value1" -param "param2=value2" \
-c "cookie1=value1" -cookie "cookie2=value2" \
-x "http://proxy.example.com:8080" -proxy "socks5://proxy2.example.com:8080" \
-skip-verify -y
Flags:
-h, -help help for dodo
-v, -version version for dodo
-y, -yes bool Answer yes to all questions (default %v)
-f, -config-file string Path to the local config file or http(s) URL of the config file
-d, -dodos uint Number of dodos(threads) (default %d)
-r, -requests uint Number of total requests
-o, -duration Time Maximum duration for the test (e.g. 30s, 1m, 5h)
-t, -timeout Time Timeout for each request (e.g. 400ms, 15s, 1m10s) (default %v)
-u, -url string URL for stress testing
-m, -method string HTTP Method for the request (default %s)
-b, -body [string] Body for the request (e.g. "body text")
-p, -param [string] Parameter for the request (e.g. "key1=value1")
-H, -header [string] Header for the request (e.g. "key1:value1")
-c, -cookie [string] Cookie for the request (e.g. "key1=value1")
-x, -proxy [string] Proxy for the request (e.g. "http://proxy.example.com:8080")
-skip-verify bool Skip SSL/TLS certificate verification (default %v)`
func (config *Config) ReadCLI() (types.ConfigFile, error) {
flag.Usage = func() {
fmt.Printf(
cliUsageText+"\n",
DefaultYes,
DefaultDodosCount,
DefaultTimeout,
DefaultMethod,
DefaultSkipVerify,
)
}
var (
version = false
configFile = ""
yes = false
skipVerify = false
method = ""
url types.RequestURL
dodosCount = uint(0)
requestCount = uint(0)
timeout time.Duration
duration time.Duration
)
{
flag.BoolVar(&version, "version", false, "Prints the version of the program")
flag.BoolVar(&version, "v", false, "Prints the version of the program")
flag.StringVar(&configFile, "config-file", "", "Path to the configuration file")
flag.StringVar(&configFile, "f", "", "Path to the configuration file")
flag.BoolVar(&yes, "yes", false, "Answer yes to all questions")
flag.BoolVar(&yes, "y", false, "Answer yes to all questions")
flag.BoolVar(&skipVerify, "skip-verify", false, "Skip SSL/TLS certificate verification")
flag.StringVar(&method, "method", "", "HTTP Method")
flag.StringVar(&method, "m", "", "HTTP Method")
flag.Var(&url, "url", "URL to send the request")
flag.Var(&url, "u", "URL to send the request")
flag.UintVar(&dodosCount, "dodos", 0, "Number of dodos(threads)")
flag.UintVar(&dodosCount, "d", 0, "Number of dodos(threads)")
flag.UintVar(&requestCount, "requests", 0, "Number of total requests")
flag.UintVar(&requestCount, "r", 0, "Number of total requests")
flag.DurationVar(&duration, "duration", 0, "Maximum duration of the test")
flag.DurationVar(&duration, "o", 0, "Maximum duration of the test")
flag.DurationVar(&timeout, "timeout", 0, "Timeout for each request (e.g. 400ms, 15s, 1m10s)")
flag.DurationVar(&timeout, "t", 0, "Timeout for each request (e.g. 400ms, 15s, 1m10s)")
flag.Var(&config.Params, "param", "URL parameter to send with the request")
flag.Var(&config.Params, "p", "URL parameter to send with the request")
flag.Var(&config.Headers, "header", "Header to send with the request")
flag.Var(&config.Headers, "H", "Header to send with the request")
flag.Var(&config.Cookies, "cookie", "Cookie to send with the request")
flag.Var(&config.Cookies, "c", "Cookie to send with the request")
flag.Var(&config.Body, "body", "Body to send with the request")
flag.Var(&config.Body, "b", "Body to send with the request")
flag.Var(&config.Proxies, "proxy", "Proxy to use for the request")
flag.Var(&config.Proxies, "x", "Proxy to use for the request")
}
flag.Parse()
if len(os.Args) <= 1 {
flag.CommandLine.Usage()
os.Exit(0)
}
if args := flag.Args(); len(args) > 0 {
return types.ConfigFile(configFile), fmt.Errorf("unexpected arguments: %v", strings.Join(args, ", "))
}
if version {
fmt.Printf("dodo version %s\n", VERSION)
os.Exit(0)
}
flag.Visit(func(f *flag.Flag) {
switch f.Name {
case "method", "m":
config.Method = utils.ToPtr(method)
case "url", "u":
config.URL = utils.ToPtr(url)
case "dodos", "d":
config.DodosCount = utils.ToPtr(dodosCount)
case "requests", "r":
config.RequestCount = utils.ToPtr(requestCount)
case "duration", "o":
config.Duration = &types.Duration{Duration: duration}
case "timeout", "t":
config.Timeout = &types.Timeout{Duration: timeout}
case "yes", "y":
config.Yes = utils.ToPtr(yes)
case "skip-verify":
config.SkipVerify = utils.ToPtr(skipVerify)
}
})
return types.ConfigFile(configFile), nil
}
// CLIYesOrNoReader reads a yes or no answer from the command line.
// It prompts the user with the given message and default value,
// and returns true if the user answers "y" or "Y", and false otherwise.
// If there is an error while reading the input, it returns false.
// If the user simply presses enter without providing any input,
// it returns the default value specified by the `dft` parameter.
func CLIYesOrNoReader(message string, dft bool) bool {
var answer string
defaultMessage := "Y/n"
if !dft {
defaultMessage = "y/N"
}
fmt.Printf("%s [%s]: ", message, defaultMessage)
if _, err := fmt.Scanln(&answer); err != nil {
if err.Error() == "unexpected newline" {
return dft
}
return false
}
if answer == "" {
return dft
}
return answer == "y" || answer == "Y"
}

View File

@@ -1,364 +0,0 @@
package config
import (
"bytes"
"errors"
"fmt"
"math/rand"
"net/url"
"os"
"slices"
"strings"
"text/template"
"time"
"github.com/aykhans/dodo/types"
"github.com/aykhans/dodo/utils"
"github.com/jedib0t/go-pretty/v6/table"
)
const (
VERSION string = "0.7.3"
DefaultUserAgent string = "Dodo/" + VERSION
DefaultMethod string = "GET"
DefaultTimeout time.Duration = time.Second * 10
DefaultDodosCount uint = 1
DefaultRequestCount uint = 0
DefaultDuration time.Duration = 0
DefaultYes bool = false
DefaultSkipVerify bool = false
)
var SupportedProxySchemes []string = []string{"http", "socks5", "socks5h"}
type RequestConfig struct {
Method string
URL url.URL
Timeout time.Duration
DodosCount uint
RequestCount uint
Duration time.Duration
Yes bool
SkipVerify bool
Params types.Params
Headers types.Headers
Cookies types.Cookies
Body types.Body
Proxies types.Proxies
}
func NewRequestConfig(conf *Config) *RequestConfig {
return &RequestConfig{
Method: *conf.Method,
URL: conf.URL.URL,
Timeout: conf.Timeout.Duration,
DodosCount: *conf.DodosCount,
RequestCount: *conf.RequestCount,
Duration: conf.Duration.Duration,
Yes: *conf.Yes,
SkipVerify: *conf.SkipVerify,
Params: conf.Params,
Headers: conf.Headers,
Cookies: conf.Cookies,
Body: conf.Body,
Proxies: conf.Proxies,
}
}
func (rc *RequestConfig) GetValidDodosCountForRequests() uint {
if rc.RequestCount == 0 {
return rc.DodosCount
}
return min(rc.DodosCount, rc.RequestCount)
}
func (rc *RequestConfig) GetMaxConns(minConns uint) uint {
maxConns := max(
minConns, rc.GetValidDodosCountForRequests(),
)
return ((maxConns * 50 / 100) + maxConns)
}
func (rc *RequestConfig) Print() {
t := table.NewWriter()
t.SetOutputMirror(os.Stdout)
t.SetStyle(table.StyleLight)
t.SetColumnConfigs([]table.ColumnConfig{
{
Number: 2,
WidthMaxEnforcer: func(col string, maxLen int) string {
lines := strings.Split(col, "\n")
for i, line := range lines {
if len(line) > maxLen {
lines[i] = line[:maxLen-3] + "..."
}
}
return strings.Join(lines, "\n")
},
WidthMax: 50},
})
t.AppendHeader(table.Row{"Request Configuration"})
t.AppendRow(table.Row{"URL", rc.URL.String()})
t.AppendSeparator()
t.AppendRow(table.Row{"Method", rc.Method})
t.AppendSeparator()
t.AppendRow(table.Row{"Timeout", rc.Timeout})
t.AppendSeparator()
t.AppendRow(table.Row{"Dodos", rc.DodosCount})
t.AppendSeparator()
if rc.RequestCount > 0 {
t.AppendRow(table.Row{"Requests", rc.RequestCount})
} else {
t.AppendRow(table.Row{"Requests"})
}
t.AppendSeparator()
if rc.Duration > 0 {
t.AppendRow(table.Row{"Duration", rc.Duration})
} else {
t.AppendRow(table.Row{"Duration"})
}
t.AppendSeparator()
t.AppendRow(table.Row{"Params", rc.Params.String()})
t.AppendSeparator()
t.AppendRow(table.Row{"Headers", rc.Headers.String()})
t.AppendSeparator()
t.AppendRow(table.Row{"Cookies", rc.Cookies.String()})
t.AppendSeparator()
t.AppendRow(table.Row{"Proxy", rc.Proxies.String()})
t.AppendSeparator()
t.AppendRow(table.Row{"Body", rc.Body.String()})
t.AppendSeparator()
t.AppendRow(table.Row{"Skip Verify", rc.SkipVerify})
t.Render()
}
type Config struct {
Method *string `json:"method" yaml:"method"`
URL *types.RequestURL `json:"url" yaml:"url"`
Timeout *types.Timeout `json:"timeout" yaml:"timeout"`
DodosCount *uint `json:"dodos" yaml:"dodos"`
RequestCount *uint `json:"requests" yaml:"requests"`
Duration *types.Duration `json:"duration" yaml:"duration"`
Yes *bool `json:"yes" yaml:"yes"`
SkipVerify *bool `json:"skip_verify" yaml:"skip_verify"`
Params types.Params `json:"params" yaml:"params"`
Headers types.Headers `json:"headers" yaml:"headers"`
Cookies types.Cookies `json:"cookies" yaml:"cookies"`
Body types.Body `json:"body" yaml:"body"`
Proxies types.Proxies `json:"proxy" yaml:"proxy"`
}
func NewConfig() *Config {
return &Config{}
}
func (config *Config) Validate() []error {
var errs []error
if utils.IsNilOrZero(config.URL) {
errs = append(errs, errors.New("request URL is required"))
} else {
if config.URL.Scheme != "http" && config.URL.Scheme != "https" {
errs = append(errs, errors.New("request URL scheme must be http or https"))
}
urlParams := types.Params{}
for key, values := range config.URL.Query() {
for _, value := range values {
urlParams = append(urlParams, types.KeyValue[string, []string]{
Key: key,
Value: []string{value},
})
}
}
config.Params = append(urlParams, config.Params...)
config.URL.RawQuery = ""
}
if utils.IsNilOrZero(config.Method) {
errs = append(errs, errors.New("request method is required"))
}
if utils.IsNilOrZero(config.Timeout) {
errs = append(errs, errors.New("request timeout must be greater than 0"))
}
if utils.IsNilOrZero(config.DodosCount) {
errs = append(errs, errors.New("dodos count must be greater than 0"))
}
if utils.IsNilOrZero(config.Duration) && utils.IsNilOrZero(config.RequestCount) {
errs = append(errs, errors.New("you should provide at least one of duration or request count"))
}
for i, proxy := range config.Proxies {
if proxy.String() == "" {
errs = append(errs, fmt.Errorf("proxies[%d]: proxy cannot be empty", i))
} else if schema := proxy.Scheme; !slices.Contains(SupportedProxySchemes, schema) {
errs = append(errs,
fmt.Errorf("proxies[%d]: proxy has unsupported scheme \"%s\" (supported schemes: %s)",
i, proxy.String(), strings.Join(SupportedProxySchemes, ", "),
),
)
}
}
funcMap := *utils.NewFuncMapGenerator(
rand.New(
rand.NewSource(
time.Now().UnixNano(),
),
),
).GetFuncMap()
for _, header := range config.Headers {
t, err := template.New("default").Funcs(funcMap).Parse(header.Key)
if err != nil {
errs = append(errs, fmt.Errorf("header key (%s) parse error: %v", header.Key, err))
} else {
var buf bytes.Buffer
if err = t.Execute(&buf, nil); err != nil {
errs = append(errs, fmt.Errorf("header key (%s) parse error: %v", header.Key, err))
}
}
for _, value := range header.Value {
t, err := template.New("default").Funcs(funcMap).Parse(value)
if err != nil {
errs = append(errs, fmt.Errorf("header value (%s) parse error: %v", value, err))
} else {
var buf bytes.Buffer
if err = t.Execute(&buf, nil); err != nil {
errs = append(errs, fmt.Errorf("header value (%s) parse error: %v", value, err))
}
}
}
}
for _, cookie := range config.Cookies {
t, err := template.New("default").Funcs(funcMap).Parse(cookie.Key)
if err != nil {
errs = append(errs, fmt.Errorf("cookie key (%s) parse error: %v", cookie.Key, err))
} else {
var buf bytes.Buffer
if err = t.Execute(&buf, nil); err != nil {
errs = append(errs, fmt.Errorf("cookie key (%s) parse error: %v", cookie.Key, err))
}
}
for _, value := range cookie.Value {
t, err := template.New("default").Funcs(funcMap).Parse(value)
if err != nil {
errs = append(errs, fmt.Errorf("cookie value (%s) parse error: %v", value, err))
} else {
var buf bytes.Buffer
if err = t.Execute(&buf, nil); err != nil {
errs = append(errs, fmt.Errorf("cookie value (%s) parse error: %v", value, err))
}
}
}
}
for _, param := range config.Params {
t, err := template.New("default").Funcs(funcMap).Parse(param.Key)
if err != nil {
errs = append(errs, fmt.Errorf("param key (%s) parse error: %v", param.Key, err))
} else {
var buf bytes.Buffer
if err = t.Execute(&buf, nil); err != nil {
errs = append(errs, fmt.Errorf("param key (%s) parse error: %v", param.Key, err))
}
}
for _, value := range param.Value {
t, err := template.New("default").Funcs(funcMap).Parse(value)
if err != nil {
errs = append(errs, fmt.Errorf("param value (%s) parse error: %v", value, err))
} else {
var buf bytes.Buffer
if err = t.Execute(&buf, nil); err != nil {
errs = append(errs, fmt.Errorf("param value (%s) parse error: %v", value, err))
}
}
}
}
for _, body := range config.Body {
t, err := template.New("default").Funcs(funcMap).Parse(body)
if err != nil {
errs = append(errs, fmt.Errorf("body (%s) parse error: %v", body, err))
} else {
var buf bytes.Buffer
if err = t.Execute(&buf, nil); err != nil {
errs = append(errs, fmt.Errorf("body (%s) parse error: %v", body, err))
}
}
}
return errs
}
func (config *Config) MergeConfig(newConfig *Config) {
if newConfig.Method != nil {
config.Method = newConfig.Method
}
if newConfig.URL != nil {
config.URL = newConfig.URL
}
if newConfig.Timeout != nil {
config.Timeout = newConfig.Timeout
}
if newConfig.DodosCount != nil {
config.DodosCount = newConfig.DodosCount
}
if newConfig.RequestCount != nil {
config.RequestCount = newConfig.RequestCount
}
if newConfig.Duration != nil {
config.Duration = newConfig.Duration
}
if newConfig.Yes != nil {
config.Yes = newConfig.Yes
}
if newConfig.SkipVerify != nil {
config.SkipVerify = newConfig.SkipVerify
}
if len(newConfig.Params) != 0 {
config.Params = newConfig.Params
}
if len(newConfig.Headers) != 0 {
config.Headers = newConfig.Headers
}
if len(newConfig.Cookies) != 0 {
config.Cookies = newConfig.Cookies
}
if len(newConfig.Body) != 0 {
config.Body = newConfig.Body
}
if len(newConfig.Proxies) != 0 {
config.Proxies = newConfig.Proxies
}
}
func (config *Config) SetDefaults() {
if config.Method == nil {
config.Method = utils.ToPtr(DefaultMethod)
}
if config.Timeout == nil {
config.Timeout = &types.Timeout{Duration: DefaultTimeout}
}
if config.DodosCount == nil {
config.DodosCount = utils.ToPtr(DefaultDodosCount)
}
if config.RequestCount == nil {
config.RequestCount = utils.ToPtr(DefaultRequestCount)
}
if config.Duration == nil {
config.Duration = &types.Duration{Duration: DefaultDuration}
}
if config.Yes == nil {
config.Yes = utils.ToPtr(DefaultYes)
}
if config.SkipVerify == nil {
config.SkipVerify = utils.ToPtr(DefaultSkipVerify)
}
config.Headers.SetIfNotExists("User-Agent", DefaultUserAgent)
}

View File

@@ -1,84 +0,0 @@
package config
import (
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"os"
"slices"
"strings"
"time"
"github.com/aykhans/dodo/types"
"gopkg.in/yaml.v3"
)
var supportedFileTypes = []string{"json", "yaml", "yml"}
func (config *Config) ReadFile(filePath types.ConfigFile) error {
var (
data []byte
err error
)
fileExt := filePath.Extension()
if slices.Contains(supportedFileTypes, fileExt) {
if filePath.LocationType() == types.FileLocationTypeRemoteHTTP {
client := &http.Client{
Timeout: 10 * time.Second,
}
resp, err := client.Get(filePath.String())
if err != nil {
return fmt.Errorf("failed to fetch config file from %s", filePath)
}
defer func() { _ = resp.Body.Close() }()
data, err = io.ReadAll(io.Reader(resp.Body))
if err != nil {
return fmt.Errorf("failed to read config file from %s", filePath)
}
} else {
data, err = os.ReadFile(filePath.String())
if err != nil {
return errors.New("failed to read config file from " + filePath.String())
}
}
switch fileExt {
case "json":
return parseJSONConfig(data, config)
case "yml", "yaml":
return parseYAMLConfig(data, config)
}
}
return fmt.Errorf("unsupported config file type (supported types: %v)", strings.Join(supportedFileTypes, ", "))
}
func parseJSONConfig(data []byte, config *Config) error {
err := json.Unmarshal(data, &config)
if err != nil {
switch parsedErr := err.(type) {
case *json.SyntaxError:
return fmt.Errorf("JSON Config file: invalid syntax at byte offset %d", parsedErr.Offset)
case *json.UnmarshalTypeError:
return fmt.Errorf("JSON Config file: invalid type %v for field %s, expected %v", parsedErr.Value, parsedErr.Field, parsedErr.Type)
default:
return fmt.Errorf("JSON Config file: %s", err.Error())
}
}
return nil
}
func parseYAMLConfig(data []byte, config *Config) error {
err := yaml.Unmarshal(data, &config)
if err != nil {
return fmt.Errorf("YAML Config file: %s", err.Error())
}
return nil
}

333
docs/configuration.md Normal file
View File

@@ -0,0 +1,333 @@
# Configuration
Sarin supports environment variables, CLI flags, and YAML files. However, they are not exactly equivalent—YAML files have the most configuration options, followed by CLI flags, and then environment variables.
When the same option is specified in multiple sources, the following priority order applies:
```
YAML (Highest) > CLI Flags > Environment Variables (Lowest)
```
Use `-s` or `--show-config` to see the final merged configuration before sending requests.
## Properties
> **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 |
| --------------------------- | ----------------------------------- | --------------------------------------------- | -------------------------------- | ------- | ---------------------------- |
| [Help](#help) | - | `-help` / `-h` | - | - | Show help message |
| [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 |
| [Config File](#config-file) | `configFile`<br>(string / []string) | `-config-file` / `-f`<br>(string / []string) | `SARIN_CONFIG_FILE`<br>(string) | - | Path to config file(s) |
| [URL](#url) | `url`<br>(string) | `-url` / `-U`<br>(string) | `SARIN_URL`<br>(string) | - | Target URL (HTTP/HTTPS) |
| [Method](#method) | `method`<br>(string / []string) | `-method` / `-M`<br>(string / []string) | `SARIN_METHOD`<br>(string) | `GET` | HTTP method(s) |
| [Timeout](#timeout) | `timeout`<br>(duration) | `-timeout` / `-T`<br>(duration) | `SARIN_TIMEOUT`<br>(duration) | `10s` | Request timeout |
| [Concurrency](#concurrency) | `concurrency`<br>(number) | `-concurrency` / `-c`<br>(number) | `SARIN_CONCURRENCY`<br>(number) | `1` | Number of concurrent workers |
| [Requests](#requests) | `requests`<br>(number) | `-requests` / `-r`<br>(number) | `SARIN_REQUESTS`<br>(number) | - | Total requests to send |
| [Duration](#duration) | `duration`<br>(duration) | `-duration` / `-d`<br>(duration) | `SARIN_DURATION`<br>(duration) | - | Test duration |
| [Quiet](#quiet) | `quiet`<br>(boolean) | `-quiet` / `-q`<br>(boolean) | `SARIN_QUIET`<br>(boolean) | `false` | Hide progress bar and logs |
| [Output](#output) | `output`<br>(string) | `-output` / `-o`<br>(string) | `SARIN_OUTPUT`<br>(string) | `table` | Output format for stats |
| [Dry Run](#dry-run) | `dryRun`<br>(boolean) | `-dry-run` / `-z`<br>(boolean) | `SARIN_DRY_RUN`<br>(boolean) | `false` | Generate without sending |
| [Insecure](#insecure) | `insecure`<br>(boolean) | `-insecure` / `-I`<br>(boolean) | `SARIN_INSECURE`<br>(boolean) | `false` | Skip TLS verification |
| [Body](#body) | `body`<br>(string / []string) | `-body` / `-B`<br>(string / []string) | `SARIN_BODY`<br>(string) | - | Request body |
| [Params](#params) | `params`<br>(object) | `-param` / `-P`<br>(string / []string) | `SARIN_PARAM`<br>(string) | - | URL query parameters |
| [Headers](#headers) | `headers`<br>(object) | `-header` / `-H`<br>(string / []string) | `SARIN_HEADER`<br>(string) | - | HTTP headers |
| [Cookies](#cookies) | `cookies`<br>(object) | `-cookie` / `-C`<br>(string / []string) | `SARIN_COOKIE`<br>(string) | - | HTTP cookies |
| [Proxy](#proxy) | `proxy`<br>(string / []string) | `-proxy` / `-X`<br>(string / []string) | `SARIN_PROXY`<br>(string) | - | Proxy URL(s) |
| [Values](#values) | `values`<br>(string / []string) | `-values` / `-V`<br>(string / []string) | `SARIN_VALUES`<br>(string) | - | Template values (key=value) |
---
## Help
Show help message.
## Version
Show version and build information.
## Show Config
Show the final merged configuration before sending requests.
## Config File
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.
**Example:**
```yaml
# config2.yaml
configFile: /config4.yaml
```
```sh
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:
```
config3.yaml > config2.yaml > config4.yaml > config1.yaml
```
## URL
Target URL. Must be HTTP or HTTPS.
## Method
HTTP method(s). If multiple values are provided, Sarin cycles through them randomly for each request. Supports [templating](templating.md).
**YAML example:**
```yaml
method: GET
# OR
method:
- GET
- POST
- PUT
```
**CLI example:**
```sh
-method GET -method POST -method PUT
```
**ENV example:**
```sh
SARIN_METHOD=GET
```
## Timeout
Request timeout. Must be greater than 0.
Valid time units: `ns`, `us` (or `µs`), `ms`, `s`, `m`, `h`
**Examples:** `5s`, `300ms`, `1m20s`
## Concurrency
Number of concurrent workers. Must be between 1 and 100,000,000.
## Requests
Total number of requests to send. At least one of `requests` or `duration` must be specified. If both are provided, the test stops when either limit is reached first.
## Duration
Test duration. At least one of `requests` or `duration` must be specified. If both are provided, the test stops when either limit is reached first.
Valid time units: `ns`, `us` (or `µs`), `ms`, `s`, `m`, `h`
**Examples:** `1m30s`, `25s`, `1h`
## Quiet
Hide the progress bar and runtime logs.
## Output
Output format for response statistics.
Valid formats: `table`, `json`, `yaml`, `none`
Using `none` disables output and reduces memory usage since response statistics are not stored.
## Dry Run
Generate requests without sending them. Useful for testing templates.
## Insecure
Skip TLS certificate verification.
## Body
Request body. If multiple values are provided, Sarin cycles through them randomly for each request. Supports [templating](templating.md).
**YAML example:**
```yaml
body: '{"product": "car"}'
# OR
body:
- '{"product": "car"}'
- '{"product": "phone"}'
- '{"product": "watch"}'
```
**CLI example:**
```sh
-body '{"product": "car"}' -body '{"product": "phone"}' -body '{"product": "watch"}'
```
**ENV example:**
```sh
SARIN_BODY='{"product": "car"}'
```
## Params
URL query parameters. If multiple values are provided for a key, Sarin cycles through them randomly for each request. Supports [templating](templating.md).
**YAML example:**
```yaml
params:
key1: value1
key2: [value2, value3]
# OR
params:
- key1: value1
- key2: [value2, value3]
```
**CLI example:**
```sh
-param "key1=value1" -param "key2=value2" -param "key2=value3"
```
**ENV example:**
```sh
SARIN_PARAM="key1=value1"
```
## Headers
HTTP headers. If multiple values are provided for a key, Sarin cycles through them randomly for each request. Supports [templating](templating.md).
**YAML example:**
```yaml
headers:
key1: value1
key2: [value2, value3]
# OR
headers:
- key1: value1
- key2: [value2, value3]
```
**CLI example:**
```sh
-header "key1: value1" -header "key2: value2" -header "key2: value3"
```
**ENV example:**
```sh
SARIN_HEADER="key1: value1"
```
## Cookies
HTTP cookies. If multiple values are provided for a key, Sarin cycles through them randomly for each request. Supports [templating](templating.md).
**YAML example:**
```yaml
cookies:
key1: value1
key2: [value2, value3]
# OR
cookies:
- key1: value1
- key2: [value2, value3]
```
**CLI example:**
```sh
-cookie "key1=value1" -cookie "key2=value2" -cookie "key2=value3"
```
**ENV example:**
```sh
SARIN_COOKIE="key1=value1"
```
## Proxy
Proxy URL(s). If multiple values are provided, Sarin cycles through them randomly for each request.
Supported protocols: `http`, `https`, `socks5`, `socks5h`
**YAML example:**
```yaml
proxy: http://proxy1.com
# OR
proxy:
- http://proxy1.com
- socks5://proxy2.com
- socks5h://proxy3.com
```
**CLI example:**
```sh
-proxy http://proxy1.com -proxy socks5://proxy2.com -proxy socks5h://proxy3.com
```
**ENV example:**
```sh
SARIN_PROXY="http://proxy1.com"
```
## Values
Template values in key=value format. Supports [templating](templating.md). Multiple values can be specified and all are rendered for each request.
See the [Templating Guide](templating.md) for more details on using values and available template functions.
**YAML example:**
```yaml
values: "key=value"
# OR
values: |
key1=value1
key2=value2
key3=value3
```
**CLI example:**
```sh
-values "key1=value1" -values "key2=value2" -values "key3=value3"
```
**ENV example:**
```sh
SARIN_VALUES="key1=value1"
```

712
docs/examples.md Normal file
View File

@@ -0,0 +1,712 @@
# Examples
This guide provides practical examples for common Sarin use cases.
## Table of Contents
- [Basic Usage](#basic-usage)
- [Request-Based vs Duration-Based Tests](#request-based-vs-duration-based-tests)
- [Dynamic Requests with Templating](#dynamic-requests-with-templating)
- [Headers, Cookies, and Parameters](#headers-cookies-and-parameters)
- [Request Bodies](#request-bodies)
- [Using Proxies](#using-proxies)
- [Output Formats](#output-formats)
- [Docker Usage](#docker-usage)
- [Dry Run Mode](#dry-run-mode)
- [Show Configuration](#show-configuration)
---
## Basic Usage
Send 1000 GET requests with 10 concurrent workers:
```sh
sarin -U http://example.com -r 1000 -c 10
```
<details>
<summary>YAML equivalent</summary>
```yaml
url: http://example.com
requests: 1000
concurrency: 10
```
</details>
Send requests with a custom timeout:
```sh
sarin -U http://example.com -r 1000 -c 10 -T 5s
```
<details>
<summary>YAML equivalent</summary>
```yaml
url: http://example.com
requests: 1000
concurrency: 10
timeout: 5s
```
</details>
## Request-Based vs Duration-Based Tests
**Request-based:** Stop after sending a specific number of requests:
```sh
sarin -U http://example.com -r 10000 -c 50
```
<details>
<summary>YAML equivalent</summary>
```yaml
url: http://example.com
requests: 10000
concurrency: 50
```
</details>
**Duration-based:** Run for a specific amount of time:
```sh
sarin -U http://example.com -d 5m -c 50
```
<details>
<summary>YAML equivalent</summary>
```yaml
url: http://example.com
duration: 5m
concurrency: 50
```
</details>
**Combined:** Stop when either limit is reached first:
```sh
sarin -U http://example.com -r 100000 -d 2m -c 100
```
<details>
<summary>YAML equivalent</summary>
```yaml
url: http://example.com
requests: 100000
duration: 2m
concurrency: 100
```
</details>
## Dynamic Requests with Templating
Generate a random User-Agent for each request:
```sh
sarin -U http://example.com -r 1000 -c 10 \
-H "User-Agent: {{ fakeit_UserAgent }}"
```
<details>
<summary>YAML equivalent</summary>
```yaml
url: http://example.com
requests: 1000
concurrency: 10
headers:
User-Agent: "{{ fakeit_UserAgent }}"
```
</details>
Send requests with random user data:
```sh
sarin -U http://example.com/api/users -r 1000 -c 10 \
-M POST \
-H "Content-Type: application/json" \
-B '{"name": "{{ fakeit_Name }}", "email": "{{ fakeit_Email }}"}'
```
<details>
<summary>YAML equivalent</summary>
```yaml
url: http://example.com/api/users
requests: 1000
concurrency: 10
method: POST
headers:
Content-Type: application/json
body: '{"name": "{{ fakeit_Name }}", "email": "{{ fakeit_Email }}"}'
```
</details>
Use values to share generated data across headers and body:
```sh
sarin -U http://example.com/api/users -r 1000 -c 10 \
-M POST \
-V "ID={{ fakeit_UUID }}" \
-H "X-Request-ID: {{ .Values.ID }}" \
-B '{"id": "{{ .Values.ID }}", "name": "{{ fakeit_Name }}"}'
```
<details>
<summary>YAML equivalent</summary>
```yaml
url: http://example.com/api/users
requests: 1000
concurrency: 10
method: POST
values: "ID={{ fakeit_UUID }}"
headers:
X-Request-ID: "{{ .Values.ID }}"
body: '{"id": "{{ .Values.ID }}", "name": "{{ fakeit_Name }}"}'
```
</details>
Generate random IPs and timestamps:
```sh
sarin -U http://example.com/api/logs -r 500 -c 20 \
-M POST \
-H "Content-Type: application/json" \
-B '{"ip": "{{ fakeit_IPv4Address }}", "timestamp": "{{ fakeit_Date }}", "action": "{{ fakeit_HackerVerb }}"}'
```
<details>
<summary>YAML equivalent</summary>
```yaml
url: http://example.com/api/logs
requests: 500
concurrency: 20
method: POST
headers:
Content-Type: application/json
body: '{"ip": "{{ fakeit_IPv4Address }}", "timestamp": "{{ fakeit_Date }}", "action": "{{ fakeit_HackerVerb }}"}'
```
</details>
> For the complete list of 320+ template functions, see the **[Templating Guide](templating.md)**.
## Headers, Cookies, and Parameters
**Custom headers:**
```sh
sarin -U http://example.com -r 1000 -c 10 \
-H "Authorization: Bearer token123" \
-H "X-Custom-Header: value"
```
<details>
<summary>YAML equivalent</summary>
```yaml
url: http://example.com
requests: 1000
concurrency: 10
headers:
Authorization: Bearer token123
X-Custom-Header: value
```
</details>
**Random headers from multiple values:**
> **Note:** When multiple values are provided for the same header, Sarin starts at a random index and cycles through all values in order. Once the cycle completes, it picks a new random starting point. This ensures all values are used while maintaining some randomness.
```sh
sarin -U http://example.com -r 1000 -c 10 \
-H "X-Region: us-east" \
-H "X-Region: us-west" \
-H "X-Region: eu-central"
```
<details>
<summary>YAML equivalent</summary>
```yaml
url: http://example.com
requests: 1000
concurrency: 10
headers:
X-Region:
- us-east
- us-west
- eu-central
```
</details>
**Query parameters:**
```sh
sarin -U http://example.com/search -r 1000 -c 10 \
-P "query=test" \
-P "limit=10"
```
<details>
<summary>YAML equivalent</summary>
```yaml
url: http://example.com/search
requests: 1000
concurrency: 10
params:
query: test
limit: "10"
```
</details>
**Dynamic query parameters:**
```sh
sarin -U http://example.com/users -r 1000 -c 10 \
-P "id={{ fakeit_IntRange 1 1000 }}" \
-P "fields=name,email"
```
<details>
<summary>YAML equivalent</summary>
```yaml
url: http://example.com/users
requests: 1000
concurrency: 10
params:
id: "{{ fakeit_IntRange 1 1000 }}"
fields: name,email
```
</details>
**Cookies:**
```sh
sarin -U http://example.com -r 1000 -c 10 \
-C "session_id=abc123" \
-C "user_id={{ fakeit_UUID }}"
```
<details>
<summary>YAML equivalent</summary>
```yaml
url: http://example.com
requests: 1000
concurrency: 10
cookies:
session_id: abc123
user_id: "{{ fakeit_UUID }}"
```
</details>
## Request Bodies
**Simple JSON body:**
```sh
sarin -U http://example.com/api/data -r 1000 -c 10 \
-M POST \
-H "Content-Type: application/json" \
-B '{"key": "value"}'
```
<details>
<summary>YAML equivalent</summary>
```yaml
url: http://example.com/api/data
requests: 1000
concurrency: 10
method: POST
headers:
Content-Type: application/json
body: '{"key": "value"}'
```
</details>
**Multiple bodies (randomly cycled):**
```sh
sarin -U http://example.com/api/products -r 1000 -c 10 \
-M POST \
-H "Content-Type: application/json" \
-B '{"product": "laptop", "price": 999}' \
-B '{"product": "phone", "price": 599}' \
-B '{"product": "tablet", "price": 399}'
```
<details>
<summary>YAML equivalent</summary>
```yaml
url: http://example.com/api/products
requests: 1000
concurrency: 10
method: POST
headers:
Content-Type: application/json
body:
- '{"product": "laptop", "price": 999}'
- '{"product": "phone", "price": 599}'
- '{"product": "tablet", "price": 399}'
```
</details>
**Dynamic body with fake data:**
```sh
sarin -U http://example.com/api/orders -r 1000 -c 10 \
-M POST \
-H "Content-Type: application/json" \
-B '{
"order_id": "{{ fakeit_UUID }}",
"customer": "{{ fakeit_Name }}",
"email": "{{ fakeit_Email }}",
"amount": {{ fakeit_Price 10 500 }},
"currency": "{{ fakeit_CurrencyShort }}"
}'
```
<details>
<summary>YAML equivalent</summary>
```yaml
url: http://example.com/api/orders
requests: 1000
concurrency: 10
method: POST
headers:
Content-Type: application/json
body: |
{
"order_id": "{{ fakeit_UUID }}",
"customer": "{{ fakeit_Name }}",
"email": "{{ fakeit_Email }}",
"amount": {{ fakeit_Price 10 500 }},
"currency": "{{ fakeit_CurrencyShort }}"
}
```
</details>
**Multipart form data:**
```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") }}'
```
<details>
<summary>YAML equivalent</summary>
```yaml
url: http://example.com/api/upload
requests: 1000
concurrency: 10
method: POST
body: '{{ body_FormData (dict_Str "username" "john" "email" "john@example.com") }}'
```
</details>
**Multipart form data with dynamic values:**
```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)) }}'
```
<details>
<summary>YAML equivalent</summary>
```yaml
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)) }}'
```
</details>
> **Note:** `body_FormData` automatically sets the `Content-Type` header to `multipart/form-data` with the appropriate boundary.
## Using Proxies
**Single HTTP proxy:**
```sh
sarin -U http://example.com -r 1000 -c 10 \
-X http://proxy.example.com:8080
```
<details>
<summary>YAML equivalent</summary>
```yaml
url: http://example.com
requests: 1000
concurrency: 10
proxy: http://proxy.example.com:8080
```
</details>
**SOCKS5 proxy:**
```sh
sarin -U http://example.com -r 1000 -c 10 \
-X socks5://proxy.example.com:1080
```
<details>
<summary>YAML equivalent</summary>
```yaml
url: http://example.com
requests: 1000
concurrency: 10
proxy: socks5://proxy.example.com:1080
```
</details>
**Multiple proxies (load balanced):**
```sh
sarin -U http://example.com -r 1000 -c 10 \
-X http://proxy1.example.com:8080 \
-X http://proxy2.example.com:8080 \
-X socks5://proxy3.example.com:1080
```
<details>
<summary>YAML equivalent</summary>
```yaml
url: http://example.com
requests: 1000
concurrency: 10
proxy:
- http://proxy1.example.com:8080
- http://proxy2.example.com:8080
- socks5://proxy3.example.com:1080
```
</details>
**Proxy with authentication:**
```sh
sarin -U http://example.com -r 1000 -c 10 \
-X http://user:password@proxy.example.com:8080
```
<details>
<summary>YAML equivalent</summary>
```yaml
url: http://example.com
requests: 1000
concurrency: 10
proxy: http://user:password@proxy.example.com:8080
```
</details>
## Output Formats
**Table output (default):**
```sh
sarin -U http://example.com -r 1000 -c 10 -o table
```
<details>
<summary>YAML equivalent</summary>
```yaml
url: http://example.com
requests: 1000
concurrency: 10
output: table
```
</details>
**JSON output (useful for parsing):**
```sh
sarin -U http://example.com -r 1000 -c 10 -o json
```
<details>
<summary>YAML equivalent</summary>
```yaml
url: http://example.com
requests: 1000
concurrency: 10
output: json
```
</details>
**YAML output:**
```sh
sarin -U http://example.com -r 1000 -c 10 -o yaml
```
<details>
<summary>YAML equivalent</summary>
```yaml
url: http://example.com
requests: 1000
concurrency: 10
output: yaml
```
</details>
**No output (minimal memory usage):**
```sh
sarin -U http://example.com -r 1000 -c 10 -o none
```
<details>
<summary>YAML equivalent</summary>
```yaml
url: http://example.com
requests: 1000
concurrency: 10
output: none
```
</details>
**Quiet mode (hide progress bar):**
```sh
sarin -U http://example.com -r 1000 -c 10 -q
```
<details>
<summary>YAML equivalent</summary>
```yaml
url: http://example.com
requests: 1000
concurrency: 10
quiet: true
```
</details>
## Docker Usage
**Basic Docker usage:**
```sh
docker run --rm aykhans/sarin -U http://example.com -r 1000 -c 10
```
**With local config file:**
```sh
docker run --rm -v $(pwd)/config.yaml:/config.yaml aykhans/sarin -f /config.yaml
```
**With remote config file:**
```sh
docker run --rm aykhans/sarin -f https://example.com/config.yaml
```
**Interactive mode with TTY:**
```sh
docker run --rm -it aykhans/sarin -U http://example.com -r 1000 -c 10
```
## Dry Run Mode
Test your configuration without sending actual requests:
```sh
sarin -U http://example.com -r 10 -c 1 -z \
-H "X-Request-ID: {{ fakeit_UUID }}" \
-B '{"user": "{{ fakeit_Name }}"}'
```
<details>
<summary>YAML equivalent</summary>
```yaml
url: http://example.com
requests: 10
concurrency: 1
dryRun: true
headers:
X-Request-ID: "{{ fakeit_UUID }}"
body: '{"user": "{{ fakeit_Name }}"}'
```
</details>
This validates templates.
## Show Configuration
Preview the merged configuration before running:
```sh
sarin -U http://example.com -r 1000 -c 10 \
-H "Authorization: Bearer token" \
-s
```
<details>
<summary>YAML equivalent</summary>
```yaml
url: http://example.com
requests: 1000
concurrency: 10
showConfig: true
headers:
Authorization: Bearer token
```
</details>

BIN
docs/static/demo.gif vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 96 KiB

610
docs/templating.md Normal file
View File

@@ -0,0 +1,610 @@
# Templating
Sarin supports Go templates in methods, bodies, headers, params, cookies, and values.
## Table of Contents
- [Using Values](#using-values)
- [General Functions](#general-functions)
- [String Functions](#string-functions)
- [Collection Functions](#collection-functions)
- [Body Functions](#body-functions)
- [Fake Data Functions](#fake-data-functions)
- [File](#file)
- [ID](#id)
- [Product](#product)
- [Person](#person)
- [Generate](#generate)
- [Auth](#auth)
- [Address](#address)
- [Game](#game)
- [Beer](#beer)
- [Car](#car)
- [Words](#words)
- [Text](#text)
- [Foods](#foods)
- [Misc](#misc)
- [Color](#color)
- [Image](#image)
- [Internet](#internet)
- [HTML](#html)
- [Date/Time](#datetime)
- [Payment](#payment)
- [Finance](#finance)
- [Company](#company)
- [Hacker](#hacker)
- [Hipster](#hipster)
- [App](#app)
- [Animal](#animal)
- [Emoji](#emoji)
- [Language](#language)
- [Number](#number)
- [String](#string)
- [Celebrity](#celebrity)
- [Minecraft](#minecraft)
- [Book](#book)
- [Movie](#movie)
- [Error](#error)
- [School](#school)
- [Song](#song)
## Using Values
Values are generated once per request and can be referenced in multiple fields using `{{ .Values.KEY }}` syntax. This is useful when you need to use the same generated value (e.g., a UUID) in both headers and body within the same request.
**Example:**
```yaml
values: |
REQUEST_ID={{ fakeit_UUID }}
USER_ID={{ fakeit_UUID }}
headers:
X-Request-ID: "{{ .Values.REQUEST_ID }}"
body: |
{
"requestId": "{{ .Values.REQUEST_ID }}",
"userId": "{{ .Values.USER_ID }}"
}
```
In this example, `REQUEST_ID` is generated once and the same value is used in both the header and body. Each new request generates a new `REQUEST_ID`.
**CLI example:**
```sh
sarin -U http://example.com/users \
-V "ID={{ fakeit_UUID }}" \
-H "X-Request-ID: {{ .Values.ID }}" \
-B '{"id": "{{ .Values.ID }}"}'
```
## General Functions
### String Functions
| Function | Description | Example |
| ---------------------------------------------------------- | ------------------------------------------------------------------- | --------------------------------------------------------- |
| `strings_ToUpper` | Convert string to uppercase | `{{ strings_ToUpper "hello" }}``HELLO` |
| `strings_ToLower` | Convert string to lowercase | `{{ strings_ToLower "HELLO" }}``hello` |
| `strings_RemoveSpaces` | Remove all spaces from string | `{{ strings_RemoveSpaces "hello world" }}``helloworld` |
| `strings_Replace(s string, old string, new string, n int)` | Replace first `n` occurrences of `old` with `new`. Use `-1` for all | `{{ strings_Replace "hello" "l" "L" -1 }}``heLLo` |
| `strings_ToDate(date string)` | Parse date string (YYYY-MM-DD format) | `{{ strings_ToDate "2024-01-15" }}` |
| `strings_First(s string, n int)` | Get first `n` characters | `{{ strings_First "hello" 2 }}``he` |
| `strings_Last(s string, n int)` | Get last `n` characters | `{{ strings_Last "hello" 2 }}``lo` |
| `strings_Truncate(s string, n int)` | Truncate to `n` characters with ellipsis | `{{ strings_Truncate "hello world" 5 }}``hello...` |
| `strings_TrimPrefix(s string, prefix string)` | Remove prefix from string | `{{ strings_TrimPrefix "hello" "he" }}``llo` |
| `strings_TrimSuffix(s string, suffix string)` | Remove suffix from string | `{{ strings_TrimSuffix "hello" "lo" }}``hel` |
| `strings_Join(sep string, values ...string)` | Join strings with separator | `{{ strings_Join "-" "a" "b" "c" }}``a-b-c` |
### Collection Functions
| Function | Description | Example |
| ----------------------------- | --------------------------------------------- | -------------------------------------------- |
| `dict_Str(pairs ...string)` | Create string dictionary from key-value pairs | `{{ dict_Str "key1" "val1" "key2" "val2" }}` |
| `slice_Str(values ...string)` | Create string slice | `{{ slice_Str "a" "b" "c" }}` |
| `slice_Int(values ...int)` | Create int slice | `{{ slice_Int 1 2 3 }}` |
| `slice_Uint(values ...uint)` | Create uint slice | `{{ slice_Uint 1 2 3 }}` |
### 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") }}` |
## Fake Data Functions
These functions are powered by [gofakeit](https://github.com/brianvoe/gofakeit) library.
### File
| Function | Description | Example Output |
| ---------------------- | -------------- | -------------------- |
| `fakeit_FileExtension` | File extension | `"nes"` |
| `fakeit_FileMimeType` | MIME type | `"application/json"` |
### ID
| Function | Description | Example Output |
| ------------- | --------------------------------- | ---------------------------------------- |
| `fakeit_ID` | Generate random unique identifier | `"pfsfktb87rcmj6bqha2fz9"` |
| `fakeit_UUID` | Generate UUID v4 | `"b4ddf623-4ea6-48e5-9292-541f028d1fdb"` |
### Product
| Function | Description | Example Output |
| --------------------------- | ------------------- | --------------------------------- |
| `fakeit_ProductName` | Product name | `"olive copper monitor"` |
| `fakeit_ProductDescription` | Product description | `"Backwards caused quarterly..."` |
| `fakeit_ProductCategory` | Product category | `"clothing"` |
| `fakeit_ProductFeature` | Product feature | `"ultra-lightweight"` |
| `fakeit_ProductMaterial` | Product material | `"brass"` |
| `fakeit_ProductUPC` | UPC code | `"012780949980"` |
| `fakeit_ProductAudience` | Target audience | `["adults"]` |
| `fakeit_ProductDimension` | Product dimension | `"medium"` |
| `fakeit_ProductUseCase` | Use case | `"home"` |
| `fakeit_ProductBenefit` | Product benefit | `"comfort"` |
| `fakeit_ProductSuffix` | Product suffix | `"pro"` |
| `fakeit_ProductISBN` | ISBN number | `"978-1-4028-9462-6"` |
### Person
| Function | Description | Example Output |
| ----------------------- | ---------------------- | ------------------------ |
| `fakeit_Name` | Full name | `"Markus Moen"` |
| `fakeit_NamePrefix` | Name prefix | `"Mr."` |
| `fakeit_NameSuffix` | Name suffix | `"Jr."` |
| `fakeit_FirstName` | First name | `"Markus"` |
| `fakeit_MiddleName` | Middle name | `"Belinda"` |
| `fakeit_LastName` | Last name | `"Daniel"` |
| `fakeit_Gender` | Gender | `"male"` |
| `fakeit_Age` | Age | `40` |
| `fakeit_Ethnicity` | Ethnicity | `"German"` |
| `fakeit_SSN` | Social Security Number | `"296446360"` |
| `fakeit_EIN` | Employer ID Number | `"12-3456789"` |
| `fakeit_Hobby` | Hobby | `"Swimming"` |
| `fakeit_Email` | Email address | `"markusmoen@pagac.net"` |
| `fakeit_Phone` | Phone number | `"6136459948"` |
| `fakeit_PhoneFormatted` | Formatted phone | `"136-459-9489"` |
### Generate
| Function | Description | Example |
| ------------------------------ | -------------------------------------- | ------------------------------------------------------ |
| `fakeit_Regex(pattern string)` | Generate string matching regex pattern | `{{ fakeit_Regex "[a-z]{5}[0-9]{3}" }}``"abcde123"` |
### Auth
| Function | Description | Example |
| --------------------------------------------------------------------------------------------- | ----------------------------------------------------------- | ----------------------------------------------------- |
| `fakeit_Username` | Username | `"Daniel1364"` |
| `fakeit_Password(upper bool, lower bool, numeric bool, special bool, space bool, length int)` | Generate password with specified character types and length | `{{ fakeit_Password true true true false false 16 }}` |
### Address
| Function | Description | Example Output |
| --------------------------------------------------- | ---------------------------- | --------------------------------------------------- |
| `fakeit_City` | City name | `"Marcelside"` |
| `fakeit_Country` | Country name | `"United States of America"` |
| `fakeit_CountryAbr` | Country abbreviation | `"US"` |
| `fakeit_State` | State name | `"Illinois"` |
| `fakeit_StateAbr` | State abbreviation | `"IL"` |
| `fakeit_Street` | Full street | `"364 East Rapidsborough"` |
| `fakeit_StreetName` | Street name | `"View"` |
| `fakeit_StreetNumber` | Street number | `"13645"` |
| `fakeit_StreetPrefix` | Street prefix | `"East"` |
| `fakeit_StreetSuffix` | Street suffix | `"Ave"` |
| `fakeit_Unit` | Unit | `"Apt 123"` |
| `fakeit_Zip` | ZIP code | `"13645"` |
| `fakeit_Latitude` | Random latitude | `-73.534056` |
| `fakeit_Longitude` | Random longitude | `-147.068112` |
| `fakeit_LatitudeInRange(min float64, max float64)` | Latitude in specified range | `{{ fakeit_LatitudeInRange 0 90 }}``22.921026` |
| `fakeit_LongitudeInRange(min float64, max float64)` | Longitude in specified range | `{{ fakeit_LongitudeInRange 0 180 }}``-8.170450` |
### Game
| Function | Description | Example Output |
| ----------------- | ----------- | ------------------- |
| `fakeit_Gamertag` | Gamer tag | `"footinterpret63"` |
### Beer
| Function | Description | Example Output |
| -------------------- | --------------- | ----------------------------- |
| `fakeit_BeerAlcohol` | Alcohol content | `"2.7%"` |
| `fakeit_BeerBlg` | Blg | `"6.4°Blg"` |
| `fakeit_BeerHop` | Hop | `"Glacier"` |
| `fakeit_BeerIbu` | IBU | `"29 IBU"` |
| `fakeit_BeerMalt` | Malt | `"Munich"` |
| `fakeit_BeerName` | Beer name | `"Duvel"` |
| `fakeit_BeerStyle` | Beer style | `"European Amber Lager"` |
| `fakeit_BeerYeast` | Yeast | `"1388 - Belgian Strong Ale"` |
### Car
| Function | Description | Example Output |
| ---------------------------- | ------------ | ---------------------- |
| `fakeit_CarMaker` | Car maker | `"Nissan"` |
| `fakeit_CarModel` | Car model | `"Aveo"` |
| `fakeit_CarType` | Car type | `"Passenger car mini"` |
| `fakeit_CarFuelType` | Fuel type | `"CNG"` |
| `fakeit_CarTransmissionType` | Transmission | `"Manual"` |
### Words
| Function | Description | Example Output |
| ---------------------------------- | --------------------------- | ---------------- |
| `fakeit_Word` | Random word | `"example"` |
| `fakeit_Noun` | Random noun | `"computer"` |
| `fakeit_NounCommon` | Common noun | `"table"` |
| `fakeit_NounConcrete` | Concrete noun | `"chair"` |
| `fakeit_NounAbstract` | Abstract noun | `"freedom"` |
| `fakeit_NounCollectivePeople` | Collective noun (people) | `"team"` |
| `fakeit_NounCollectiveAnimal` | Collective noun (animal) | `"herd"` |
| `fakeit_NounCollectiveThing` | Collective noun (thing) | `"bunch"` |
| `fakeit_NounCountable` | Countable noun | `"book"` |
| `fakeit_NounUncountable` | Uncountable noun | `"water"` |
| `fakeit_Verb` | Random verb | `"run"` |
| `fakeit_VerbAction` | Action verb | `"jump"` |
| `fakeit_VerbLinking` | Linking verb | `"is"` |
| `fakeit_VerbHelping` | Helping verb | `"can"` |
| `fakeit_Adverb` | Random adverb | `"quickly"` |
| `fakeit_AdverbManner` | Manner adverb | `"carefully"` |
| `fakeit_AdverbDegree` | Degree adverb | `"very"` |
| `fakeit_AdverbPlace` | Place adverb | `"here"` |
| `fakeit_AdverbTimeDefinite` | Definite time adverb | `"yesterday"` |
| `fakeit_AdverbTimeIndefinite` | Indefinite time adverb | `"soon"` |
| `fakeit_AdverbFrequencyDefinite` | Definite frequency adverb | `"daily"` |
| `fakeit_AdverbFrequencyIndefinite` | Indefinite frequency adverb | `"often"` |
| `fakeit_Preposition` | Random preposition | `"on"` |
| `fakeit_PrepositionSimple` | Simple preposition | `"in"` |
| `fakeit_PrepositionDouble` | Double preposition | `"out of"` |
| `fakeit_PrepositionCompound` | Compound preposition | `"according to"` |
| `fakeit_Adjective` | Random adjective | `"beautiful"` |
| `fakeit_AdjectiveDescriptive` | Descriptive adjective | `"large"` |
| `fakeit_AdjectiveQuantitative` | Quantitative adjective | `"many"` |
| `fakeit_AdjectiveProper` | Proper adjective | `"American"` |
| `fakeit_AdjectiveDemonstrative` | Demonstrative adjective | `"this"` |
| `fakeit_AdjectivePossessive` | Possessive adjective | `"my"` |
| `fakeit_AdjectiveInterrogative` | Interrogative adjective | `"which"` |
| `fakeit_AdjectiveIndefinite` | Indefinite adjective | `"some"` |
| `fakeit_Pronoun` | Random pronoun | `"he"` |
| `fakeit_PronounPersonal` | Personal pronoun | `"I"` |
| `fakeit_PronounObject` | Object pronoun | `"him"` |
| `fakeit_PronounPossessive` | Possessive pronoun | `"mine"` |
| `fakeit_PronounReflective` | Reflective pronoun | `"myself"` |
| `fakeit_PronounDemonstrative` | Demonstrative pronoun | `"that"` |
| `fakeit_PronounInterrogative` | Interrogative pronoun | `"who"` |
| `fakeit_PronounRelative` | Relative pronoun | `"which"` |
| `fakeit_Connective` | Random connective | `"however"` |
| `fakeit_ConnectiveTime` | Time connective | `"then"` |
| `fakeit_ConnectiveComparative` | Comparative connective | `"similarly"` |
| `fakeit_ConnectiveComplaint` | Complaint connective | `"although"` |
| `fakeit_ConnectiveListing` | Listing connective | `"firstly"` |
| `fakeit_ConnectiveCasual` | Casual connective | `"because"` |
| `fakeit_ConnectiveExamplify` | Examplify connective | `"for example"` |
### Text
| Function | Description | Example |
| ---------------------------------------------------------------------------------------- | ----------------------------------------------- | --------------------------------------------- |
| `fakeit_Sentence` | Random sentence | `{{ fakeit_Sentence }}` |
| `fakeit_Paragraph` | Random paragraph | `{{ fakeit_Paragraph }}` |
| `fakeit_LoremIpsumWord` | Lorem ipsum word | `"lorem"` |
| `fakeit_LoremIpsumSentence(wordCount int)` | Lorem ipsum sentence with specified word count | `{{ fakeit_LoremIpsumSentence 5 }}` |
| `fakeit_LoremIpsumParagraph(paragraphs int, sentences int, words int, separator string)` | Lorem ipsum paragraphs with specified structure | `{{ fakeit_LoremIpsumParagraph 1 3 5 "\n" }}` |
| `fakeit_Question` | Random question | `"What is your name?"` |
| `fakeit_Quote` | Random quote | `"Life is what happens..."` |
| `fakeit_Phrase` | Random phrase | `"a piece of cake"` |
### Foods
| Function | Description | Example Output |
| ------------------ | -------------- | ---------------------------------------- |
| `fakeit_Fruit` | Fruit | `"Peach"` |
| `fakeit_Vegetable` | Vegetable | `"Amaranth Leaves"` |
| `fakeit_Breakfast` | Breakfast food | `"Blueberry banana happy face pancakes"` |
| `fakeit_Lunch` | Lunch food | `"No bake hersheys bar pie"` |
| `fakeit_Dinner` | Dinner food | `"Wild addicting dip"` |
| `fakeit_Snack` | Snack | `"Trail mix"` |
| `fakeit_Dessert` | Dessert | `"French napoleons"` |
### Misc
| Function | Description | Example Output |
| ------------------ | -------------- | -------------- |
| `fakeit_Bool` | Random boolean | `true` |
| `fakeit_FlipACoin` | Flip a coin | `"Heads"` |
### Color
| Function | Description | Example Output |
| ------------------- | ------------------ | --------------------------------------------------------- |
| `fakeit_Color` | Color name | `"MediumOrchid"` |
| `fakeit_HexColor` | Hex color | `"#a99fb4"` |
| `fakeit_RGBColor` | RGB color | `[85, 224, 195]` |
| `fakeit_SafeColor` | Safe color | `"black"` |
| `fakeit_NiceColors` | Nice color palette | `["#cfffdd", "#b4dec1", "#5c5863", "#a85163", "#ff1f4c"]` |
### Image
| Function | Description | Example |
| ----------------------------------------- | ------------------------- | -------------------------------- |
| `fakeit_ImageJpeg(width int, height int)` | Generate JPEG image bytes | `{{ fakeit_ImageJpeg 100 100 }}` |
| `fakeit_ImagePng(width int, height int)` | Generate PNG image bytes | `{{ fakeit_ImagePng 100 100 }}` |
### Internet
| Function | Description | Example Output |
| --------------------------------- | ------------------------------------------ | ----------------------------------------------------- |
| `fakeit_URL` | Random URL | `"http://www.principalproductize.biz/target"` |
| `fakeit_UrlSlug(words int)` | URL slug with specified word count | `{{ fakeit_UrlSlug 3 }}``"bathe-regularly-quiver"` |
| `fakeit_DomainName` | Domain name | `"centraltarget.biz"` |
| `fakeit_DomainSuffix` | Domain suffix | `"org"` |
| `fakeit_IPv4Address` | IPv4 address | `"222.83.191.222"` |
| `fakeit_IPv6Address` | IPv6 address | `"2001:cafe:8898:ee17:bc35:9064:5866:d019"` |
| `fakeit_MacAddress` | MAC address | `"cb:ce:06:94:22:e9"` |
| `fakeit_HTTPStatusCode` | HTTP status code | `200` |
| `fakeit_HTTPStatusCodeSimple` | Simple status code | `404` |
| `fakeit_LogLevel(logType string)` | Log level (types: general, syslog, apache) | `{{ fakeit_LogLevel "general" }}``"error"` |
| `fakeit_HTTPMethod` | HTTP method | `"HEAD"` |
| `fakeit_HTTPVersion` | HTTP version | `"HTTP/1.1"` |
| `fakeit_UserAgent` | Random User-Agent | `"Mozilla/5.0..."` |
| `fakeit_ChromeUserAgent` | Chrome User-Agent | `"Mozilla/5.0 (X11; Linux i686)..."` |
| `fakeit_FirefoxUserAgent` | Firefox User-Agent | `"Mozilla/5.0 (Macintosh; U;..."` |
| `fakeit_OperaUserAgent` | Opera User-Agent | `"Opera/8.39..."` |
| `fakeit_SafariUserAgent` | Safari User-Agent | `"Mozilla/5.0 (iPad;..."` |
| `fakeit_APIUserAgent` | API User-Agent | `"curl/8.2.5"` |
### HTML
| Function | Description | Example Output |
| ------------------ | --------------- | ------------------ |
| `fakeit_InputName` | HTML input name | `"email"` |
| `fakeit_Svg` | SVG image | `"<svg>...</svg>"` |
### Date/Time
| Function | Description | Example |
| -------------------------------------------------- | --------------------------------- | ------------------------------------------------------------------------------------ |
| `fakeit_Date` | Random date | `2023-06-15 14:30:00` |
| `fakeit_PastDate` | Past date | `2022-03-10 09:15:00` |
| `fakeit_FutureDate` | Future date | `2025-12-20 18:45:00` |
| `fakeit_DateRange(start time.Time, end time.Time)` | Random date between start and end | `{{ fakeit_DateRange (strings_ToDate "2020-01-01") (strings_ToDate "2025-12-31") }}` |
| `fakeit_NanoSecond` | Nanosecond | `123456789` |
| `fakeit_Second` | Second (0-59) | `45` |
| `fakeit_Minute` | Minute (0-59) | `30` |
| `fakeit_Hour` | Hour (0-23) | `14` |
| `fakeit_Month` | Month (1-12) | `6` |
| `fakeit_MonthString` | Month name | `"June"` |
| `fakeit_Day` | Day (1-31) | `15` |
| `fakeit_WeekDay` | Weekday | `"Monday"` |
| `fakeit_Year` | Year | `2024` |
| `fakeit_TimeZone` | Timezone | `"America/New_York"` |
| `fakeit_TimeZoneAbv` | Timezone abbreviation | `"EST"` |
| `fakeit_TimeZoneFull` | Full timezone | `"Eastern Standard Time"` |
| `fakeit_TimeZoneOffset` | Timezone offset | `-5` |
| `fakeit_TimeZoneRegion` | Timezone region | `"America"` |
### Payment
| Function | Description | Example |
| ---------------------------------------- | ----------------------------------------------------- | -------------------------------------------------------------- |
| `fakeit_Price(min float64, max float64)` | Random price in range | `{{ fakeit_Price 1 100 }}``92.26` |
| `fakeit_CreditCardCvv` | CVV | `"513"` |
| `fakeit_CreditCardExp` | Expiration date | `"01/27"` |
| `fakeit_CreditCardNumber(gaps bool)` | Credit card number. `gaps`: add spaces between groups | `{{ fakeit_CreditCardNumber true }}``"4111 1111 1111 1111"` |
| `fakeit_CreditCardType` | Card type | `"Visa"` |
| `fakeit_CurrencyLong` | Currency name | `"United States Dollar"` |
| `fakeit_CurrencyShort` | Currency code | `"USD"` |
| `fakeit_AchRouting` | ACH routing number | `"513715684"` |
| `fakeit_AchAccount` | ACH account number | `"491527954328"` |
| `fakeit_BitcoinAddress` | Bitcoin address | `"1BoatSLRHtKNngkdXEeobR76b53LETtpyT"` |
| `fakeit_BitcoinPrivateKey` | Bitcoin private key | `"5HueCGU8rMjxEXxiPuD5BDuG6o5xjA7QkbPp"` |
| `fakeit_BankName` | Bank name | `"Wells Fargo"` |
| `fakeit_BankType` | Bank type | `"Investment Bank"` |
### Finance
| Function | Description | Example Output |
| -------------- | ---------------- | ---------------- |
| `fakeit_Cusip` | CUSIP identifier | `"38259P508"` |
| `fakeit_Isin` | ISIN identifier | `"US38259P5089"` |
### Company
| Function | Description | Example Output |
| ---------------------- | -------------- | ------------------------------------------ |
| `fakeit_BS` | Business speak | `"front-end"` |
| `fakeit_Blurb` | Company blurb | `"word"` |
| `fakeit_BuzzWord` | Buzzword | `"disintermediate"` |
| `fakeit_Company` | Company name | `"Moen, Pagac and Wuckert"` |
| `fakeit_CompanySuffix` | Company suffix | `"Inc"` |
| `fakeit_JobDescriptor` | Job descriptor | `"Central"` |
| `fakeit_JobLevel` | Job level | `"Assurance"` |
| `fakeit_JobTitle` | Job title | `"Director"` |
| `fakeit_Slogan` | Company slogan | `"Universal seamless Focus, interactive."` |
### Hacker
| Function | Description | Example Output |
| --------------------------- | ------------------- | --------------------------------------------------------------------------------------------- |
| `fakeit_HackerAbbreviation` | Hacker abbreviation | `"ADP"` |
| `fakeit_HackerAdjective` | Hacker adjective | `"wireless"` |
| `fakeit_HackeringVerb` | Hackering verb | `"connecting"` |
| `fakeit_HackerNoun` | Hacker noun | `"driver"` |
| `fakeit_HackerPhrase` | Hacker phrase | `"If we calculate the program, we can get to the AI pixel through the redundant XSS matrix!"` |
| `fakeit_HackerVerb` | Hacker verb | `"synthesize"` |
### Hipster
| Function | Description | Example |
| ------------------------- | ----------------- | ------------------------------------------------------------------- |
| `fakeit_HipsterWord` | Hipster word | `"microdosing"` |
| `fakeit_HipsterSentence` | Hipster sentence | `"Soul loops with you probably haven't heard of them undertones."` |
| `fakeit_HipsterParagraph` | Hipster paragraph | `"Single-origin austin, double why. Tag it Yuccie, keep it any..."` |
### App
| Function | Description | Example Output |
| ------------------- | ----------- | --------------------- |
| `fakeit_AppName` | App name | `"Parkrespond"` |
| `fakeit_AppVersion` | App version | `"1.12.14"` |
| `fakeit_AppAuthor` | App author | `"Qado Energy, Inc."` |
### Animal
| Function | Description | Example Output |
| ------------------- | ----------- | ------------------- |
| `fakeit_PetName` | Pet name | `"Ozzy Pawsborne"` |
| `fakeit_Animal` | Animal | `"elk"` |
| `fakeit_AnimalType` | Animal type | `"amphibians"` |
| `fakeit_FarmAnimal` | Farm animal | `"Chicken"` |
| `fakeit_Cat` | Cat breed | `"Chausie"` |
| `fakeit_Dog` | Dog breed | `"Norwich Terrier"` |
| `fakeit_Bird` | Bird | `"goose"` |
### Emoji
| Function | Description | Example Output |
| ------------------------- | ---------------------------------------------- | ------------------------------------------------------ |
| `fakeit_Emoji` | Random emoji | `"🤣"` |
| `fakeit_EmojiCategory` | Emoji category | `"Smileys & Emotion"` |
| `fakeit_EmojiAlias` | Emoji alias | `"smile"` |
| `fakeit_EmojiTag` | Emoji tag | `"happy"` |
| `fakeit_EmojiFlag` | Flag emoji | `"🇺🇸"` |
| `fakeit_EmojiAnimal` | Animal emoji | `"🐱"` |
| `fakeit_EmojiFood` | Food emoji | `"🍕"` |
| `fakeit_EmojiPlant` | Plant emoji | `"🌸"` |
| `fakeit_EmojiMusic` | Music emoji | `"🎵"` |
| `fakeit_EmojiVehicle` | Vehicle emoji | `"🚗"` |
| `fakeit_EmojiSport` | Sport emoji | `"⚽"` |
| `fakeit_EmojiFace` | Face emoji | `"😊"` |
| `fakeit_EmojiHand` | Hand emoji | `"👋"` |
| `fakeit_EmojiClothing` | Clothing emoji | `"👕"` |
| `fakeit_EmojiLandmark` | Landmark emoji | `"🗽"` |
| `fakeit_EmojiElectronics` | Electronics emoji | `"📱"` |
| `fakeit_EmojiGame` | Game emoji | `"🎮"` |
| `fakeit_EmojiTools` | Tools emoji | `"🔧"` |
| `fakeit_EmojiWeather` | Weather emoji | `"☀️"` |
| `fakeit_EmojiJob` | Job emoji | `"👨‍💻"` |
| `fakeit_EmojiPerson` | Person emoji | `"👤"` |
| `fakeit_EmojiGesture` | Gesture emoji | `"🙌"` |
| `fakeit_EmojiCostume` | Costume emoji | `"🎃"` |
| `fakeit_EmojiSentence` | Emoji sentence with random emojis interspersed | `"Weekends reserve time for 🖼️ Disc 🏨 golf and day."` |
### Language
| Function | Description | Example Output |
| ----------------------------- | --------------------- | -------------- |
| `fakeit_Language` | Language | `"English"` |
| `fakeit_LanguageAbbreviation` | Language abbreviation | `"en"` |
| `fakeit_ProgrammingLanguage` | Programming language | `"Go"` |
### Number
| Function | Description | Example |
| ----------------------------------------------- | ----------------------------------- | ------------------------------------------ |
| `fakeit_Number(min int, max int)` | Random number in range | `{{ fakeit_Number 1 100 }}``42` |
| `fakeit_Int` | Random int | `{{ fakeit_Int }}` |
| `fakeit_IntN(n int)` | Random int from 0 to n | `{{ fakeit_IntN 100 }}` |
| `fakeit_Int8` | Random int8 | `{{ fakeit_Int8 }}` |
| `fakeit_Int16` | Random int16 | `{{ fakeit_Int16 }}` |
| `fakeit_Int32` | Random int32 | `{{ fakeit_Int32 }}` |
| `fakeit_Int64` | Random int64 | `{{ fakeit_Int64 }}` |
| `fakeit_Uint` | Random uint | `{{ fakeit_Uint }}` |
| `fakeit_UintN(n uint)` | Random uint from 0 to n | `{{ fakeit_UintN 100 }}` |
| `fakeit_Uint8` | Random uint8 | `{{ fakeit_Uint8 }}` |
| `fakeit_Uint16` | Random uint16 | `{{ fakeit_Uint16 }}` |
| `fakeit_Uint32` | Random uint32 | `{{ fakeit_Uint32 }}` |
| `fakeit_Uint64` | Random uint64 | `{{ fakeit_Uint64 }}` |
| `fakeit_Float32` | Random float32 | `{{ fakeit_Float32 }}` |
| `fakeit_Float32Range(min float32, max float32)` | Random float32 in range | `{{ fakeit_Float32Range 0 100 }}` |
| `fakeit_Float64` | Random float64 | `{{ fakeit_Float64 }}` |
| `fakeit_Float64Range(min float64, max float64)` | Random float64 in range | `{{ fakeit_Float64Range 0 100 }}` |
| `fakeit_RandomInt(slice []int)` | Random int from slice | `{{ fakeit_RandomInt (slice_Int 1 2 3) }}` |
| `fakeit_HexUint(bits int)` | Random hex uint with specified bits | `{{ fakeit_HexUint 8 }}``"0xff"` |
### String
| Function | Description | Example |
| ------------------------------------- | ------------------------------- | --------------------------------------------------------------- |
| `fakeit_Digit` | Single random digit | `"0"` |
| `fakeit_DigitN(n uint)` | Generate `n` random digits | `{{ fakeit_DigitN 5 }}``"0136459948"` |
| `fakeit_Letter` | Single random letter | `"g"` |
| `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_Numerify(pattern string)` | Replace `#` with random digits | `{{ fakeit_Numerify "(###)###-####" }}``"(555)867-5309"` |
| `fakeit_RandomString(slice []string)` | Random string from slice | `{{ fakeit_RandomString (slice_Str "a" "b" "c") }}` |
### Celebrity
| Function | Description | Example Output |
| -------------------------- | ------------------ | ------------------ |
| `fakeit_CelebrityActor` | Celebrity actor | `"Brad Pitt"` |
| `fakeit_CelebrityBusiness` | Celebrity business | `"Elon Musk"` |
| `fakeit_CelebritySport` | Celebrity sport | `"Michael Phelps"` |
### Minecraft
| Function | Description | Example Output |
| --------------------------------- | ----------------- | ---------------- |
| `fakeit_MinecraftOre` | Minecraft ore | `"coal"` |
| `fakeit_MinecraftWood` | Minecraft wood | `"oak"` |
| `fakeit_MinecraftArmorTier` | Armor tier | `"iron"` |
| `fakeit_MinecraftArmorPart` | Armor part | `"helmet"` |
| `fakeit_MinecraftWeapon` | Minecraft weapon | `"bow"` |
| `fakeit_MinecraftTool` | Minecraft tool | `"shovel"` |
| `fakeit_MinecraftDye` | Minecraft dye | `"white"` |
| `fakeit_MinecraftFood` | Minecraft food | `"apple"` |
| `fakeit_MinecraftAnimal` | Minecraft animal | `"chicken"` |
| `fakeit_MinecraftVillagerJob` | Villager job | `"farmer"` |
| `fakeit_MinecraftVillagerStation` | Villager station | `"furnace"` |
| `fakeit_MinecraftVillagerLevel` | Villager level | `"master"` |
| `fakeit_MinecraftMobPassive` | Passive mob | `"cow"` |
| `fakeit_MinecraftMobNeutral` | Neutral mob | `"bee"` |
| `fakeit_MinecraftMobHostile` | Hostile mob | `"spider"` |
| `fakeit_MinecraftMobBoss` | Boss mob | `"ender dragon"` |
| `fakeit_MinecraftBiome` | Minecraft biome | `"forest"` |
| `fakeit_MinecraftWeather` | Minecraft weather | `"rain"` |
### Book
| Function | Description | Example Output |
| ------------------- | ----------- | -------------- |
| `fakeit_BookTitle` | Book title | `"Hamlet"` |
| `fakeit_BookAuthor` | Book author | `"Mark Twain"` |
| `fakeit_BookGenre` | Book genre | `"Adventure"` |
### Movie
| Function | Description | Example Output |
| ------------------- | ----------- | -------------- |
| `fakeit_MovieName` | Movie name | `"Inception"` |
| `fakeit_MovieGenre` | Movie genre | `"Sci-Fi"` |
### Error
| Function | Description | Example Output |
| ------------------------ | ----------------- | ---------------------------------- |
| `fakeit_Error` | Random error | `"connection refused"` |
| `fakeit_ErrorDatabase` | Database error | `"database connection failed"` |
| `fakeit_ErrorGRPC` | gRPC error | `"rpc error: code = Unavailable"` |
| `fakeit_ErrorHTTP` | HTTP error | `"HTTP 500 Internal Server Error"` |
| `fakeit_ErrorHTTPClient` | HTTP client error | `"HTTP 404 Not Found"` |
| `fakeit_ErrorHTTPServer` | HTTP server error | `"HTTP 503 Service Unavailable"` |
| `fakeit_ErrorRuntime` | Runtime error | `"panic: runtime error"` |
### School
| Function | Description | Example Output |
| --------------- | ----------- | ---------------------- |
| `fakeit_School` | School name | `"Harvard University"` |
### Song
| Function | Description | Example Output |
| ------------------- | ----------- | --------------------- |
| `fakeit_SongName` | Song name | `"Bohemian Rhapsody"` |
| `fakeit_SongArtist` | Song artist | `"Queen"` |
| `fakeit_SongGenre` | Song genre | `"Rock"` |

57
go.mod
View File

@@ -1,22 +1,55 @@
module github.com/aykhans/dodo
module go.aykhans.me/sarin
go 1.25
go 1.25.5
require (
github.com/brianvoe/gofakeit/v7 v7.3.0
github.com/jedib0t/go-pretty/v6 v6.6.8
github.com/valyala/fasthttp v1.65.0
gopkg.in/yaml.v3 v3.0.1
github.com/brianvoe/gofakeit/v7 v7.14.0
github.com/charmbracelet/bubbles v0.21.0
github.com/charmbracelet/bubbletea v1.3.10
github.com/charmbracelet/glamour v0.10.0
github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834
github.com/charmbracelet/x/term v0.2.2
github.com/joho/godotenv v1.5.1
github.com/valyala/fasthttp v1.69.0
go.aykhans.me/utils v1.0.7
go.yaml.in/yaml/v4 v4.0.0-rc.3
golang.org/x/net v0.48.0
)
require (
github.com/alecthomas/chroma/v2 v2.21.1 // indirect
github.com/andybalholm/brotli v1.2.0 // indirect
github.com/klauspost/compress v1.18.0 // indirect
github.com/mattn/go-runewidth v0.0.16 // indirect
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
github.com/aymerick/douceur v0.2.0 // indirect
github.com/charmbracelet/colorprofile v0.4.1 // indirect
github.com/charmbracelet/harmonica v0.2.0 // indirect
github.com/charmbracelet/x/ansi v0.11.3 // indirect
github.com/charmbracelet/x/cellbuf v0.0.14 // indirect
github.com/charmbracelet/x/exp/slice v0.0.0-20260109001716-2fbdffcb221f // indirect
github.com/clipperhouse/displaywidth v0.6.2 // indirect
github.com/clipperhouse/stringish v0.1.1 // indirect
github.com/clipperhouse/uax29/v2 v2.3.0 // indirect
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
github.com/dlclark/regexp2 v1.11.5 // indirect
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
github.com/gorilla/css v1.0.1 // indirect
github.com/klauspost/compress v1.18.2 // indirect
github.com/lucasb-eyer/go-colorful v1.3.0 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mattn/go-localereader v0.0.1 // indirect
github.com/mattn/go-runewidth v0.0.19 // indirect
github.com/microcosm-cc/bluemonday v1.0.27 // indirect
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
github.com/muesli/cancelreader v0.2.2 // indirect
github.com/muesli/reflow v0.3.0 // indirect
github.com/muesli/termenv v0.16.0 // indirect
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
github.com/rivo/uniseg v0.4.7 // indirect
github.com/valyala/bytebufferpool v1.0.0 // indirect
golang.org/x/net v0.43.0 // indirect
golang.org/x/sys v0.35.0 // indirect
golang.org/x/term v0.34.0 // indirect
golang.org/x/text v0.28.0 // indirect
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
github.com/yuin/goldmark v1.7.16 // indirect
github.com/yuin/goldmark-emoji v1.0.6 // indirect
golang.org/x/sys v0.40.0 // indirect
golang.org/x/term v0.38.0 // indirect
golang.org/x/text v0.32.0 // indirect
)

130
go.sum
View File

@@ -1,37 +1,115 @@
github.com/alecthomas/assert/v2 v2.11.0 h1:2Q9r3ki8+JYXvGsDyBXwH3LcJ+WK5D0gc5E8vS6K3D0=
github.com/alecthomas/assert/v2 v2.11.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k=
github.com/alecthomas/chroma/v2 v2.21.1 h1:FaSDrp6N+3pphkNKU6HPCiYLgm8dbe5UXIXcoBhZSWA=
github.com/alecthomas/chroma/v2 v2.21.1/go.mod h1:NqVhfBR0lte5Ouh3DcthuUCTUpDC9cxBOfyMbMQPs3o=
github.com/alecthomas/repr v0.5.2 h1:SU73FTI9D1P5UNtvseffFSGmdNci/O6RsqzeXJtP0Qs=
github.com/alecthomas/repr v0.5.2/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4=
github.com/andybalholm/brotli v1.2.0 h1:ukwgCxwYrmACq68yiUqwIWnGY0cTPox/M94sVwToPjQ=
github.com/andybalholm/brotli v1.2.0/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY=
github.com/brianvoe/gofakeit/v7 v7.3.0 h1:TWStf7/lLpAjKw+bqwzeORo9jvrxToWEwp9b1J2vApQ=
github.com/brianvoe/gofakeit/v7 v7.3.0/go.mod h1:QXuPeBw164PJCzCUZVmgpgHJ3Llj49jSLVkKPMtxtxA=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/jedib0t/go-pretty/v6 v6.6.8 h1:JnnzQeRz2bACBobIaa/r+nqjvws4yEhcmaZ4n1QzsEc=
github.com/jedib0t/go-pretty/v6 v6.6.8/go.mod h1:YwC5CE4fJ1HFUDeivSV1r//AmANFHyqczZk+U6BDALU=
github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo=
github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ=
github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc=
github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
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-udiff v0.2.0 h1:TK0fH4MteXUDspT88n8CKzvK0X9O2xu9yQjWpi6yML8=
github.com/aymanbagabas/go-udiff v0.2.0/go.mod h1:RE4Ex0qsGkTAJoQdQQCA0uG+nAzJO/pI/QwceO5fgrA=
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/brianvoe/gofakeit/v7 v7.14.0 h1:R8tmT/rTDJmD2ngpqBL9rAKydiL7Qr2u3CXPqRt59pk=
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.0/go.mod h1:HF+v6QUR4HkEpz62dx7ym2xc71/KBHg+zKwJtMw+qtg=
github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw=
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/go.mod h1:U1d9Dljmdf9DLegaJ0nGZNJvoXAhayhmidOdcBwAvKk=
github.com/charmbracelet/glamour v0.10.0 h1:MtZvfwsYCx8jEPFJm3rIBFIMZUfUJ765oX8V6kXldcY=
github.com/charmbracelet/glamour v0.10.0/go.mod h1:f+uf+I/ChNmqo087elLnVdCiVgjSKWuXa/l6NU2ndYk=
github.com/charmbracelet/harmonica v0.2.0 h1:8NxJWRWg/bzKqqEaaeFNipOu77YR5t8aSwG4pgaUBiQ=
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/go.mod h1:aKC/t2arECF6rNOnaKaVU6y4t4ZeHQzqfxedE/VkVhA=
github.com/charmbracelet/x/ansi v0.11.3 h1:6DcVaqWI82BBVM/atTyq6yBoRLZFBsnoDoX9GCu2YOI=
github.com/charmbracelet/x/ansi v0.11.3/go.mod h1:yI7Zslym9tCJcedxz5+WBq+eUGMJT0bM06Fqy1/Y4dI=
github.com/charmbracelet/x/cellbuf v0.0.14 h1:iUEMryGyFTelKW3THW4+FfPgi4fkmKnnaLOXuc+/Kj4=
github.com/charmbracelet/x/cellbuf v0.0.14/go.mod h1:P447lJl49ywBbil/KjCk2HexGh4tEY9LH0/1QrZZ9rA=
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/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/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk=
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.6.2/go.mod h1:R+kHuzaYWFkTm7xoMmK1lFydbci4X2CicfbGstSGg0o=
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/uax29/v2 v2.3.0 h1:SNdx9DVUqMoBuBoW3iLOj4FQv3dN5mDtuqwuhIGpJy4=
github.com/clipperhouse/uax29/v2 v2.3.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/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/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4=
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM=
github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8=
github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0=
github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM=
github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg=
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
github.com/klauspost/compress v1.18.2 h1:iiPHWW0YrcFgpBYhsA6D1+fqHssJscY/Tm/y2Uqnapk=
github.com/klauspost/compress v1.18.2/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4=
github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag=
github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4=
github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88=
github.com/mattn/go-runewidth v0.0.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk=
github.com/mattn/go-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw=
github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwXFM08ygZfk=
github.com/microcosm-cc/bluemonday v1.0.27/go.mod h1:jFi9vgW+H7c3V0lb6nR74Ib/DIB5OBs92Dimizgw2cA=
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI=
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo=
github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
github.com/muesli/reflow v0.3.0 h1:IFsN6K9NfGtjeggFP+68I4chLZV2yIKsXJFNZ+eWh6s=
github.com/muesli/reflow v0.3.0/go.mod h1:pbwTDkVPibjO2kyvBQRBxTWEEGDGq0FlB1BIKtnHY/8=
github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc=
github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
github.com/valyala/fasthttp v1.65.0 h1:j/u3uzFEGFfRxw79iYzJN+TteTJwbYkru9uDp3d0Yf8=
github.com/valyala/fasthttp v1.65.0/go.mod h1:P/93/YkKPMsKSnATEeELUCkG8a7Y+k99uxNHVbKINr4=
github.com/valyala/fasthttp v1.69.0 h1:fNLLESD2SooWeh2cidsuFtOcrEi4uB4m1mPrkJMZyVI=
github.com/valyala/fasthttp v1.69.0/go.mod h1:4wA4PfAraPlAsJ5jMSqCE2ug5tqUPwKXxVj8oNECGcw=
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU=
github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E=
golang.org/x/net v0.43.0 h1:lat02VYK2j4aLzMzecihNvTlJNQUq316m2Mr9rnM6YE=
golang.org/x/net v0.43.0/go.mod h1:vhO1fvI4dGsIjh73sWfUVjj3N7CA9WkKJNQm2svM6Jg=
golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI=
golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
golang.org/x/term v0.34.0 h1:O/2T7POpk0ZZ7MAzMeWFSg6S5IpWd/RXDlM9hgM3DR4=
golang.org/x/term v0.34.0/go.mod h1:5jC53AEywhIVebHgPVeg0mj8OD3VO9OzclacVrqpaAw=
golang.org/x/text v0.28.0 h1:rhazDwis8INMIwQ4tpjLDzUhx6RlXqZNPEM0huQojng=
golang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
github.com/yuin/goldmark v1.7.16 h1:n+CJdUxaFMiDUNnWC3dMWCIQJSkxH4uz3ZwQBkAlVNE=
github.com/yuin/goldmark v1.7.16/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg=
github.com/yuin/goldmark-emoji v1.0.6 h1:QWfF2FYaXwL74tfGOW5izeiZepUDroDJfWubQI9HTHs=
github.com/yuin/goldmark-emoji v1.0.6/go.mod h1:ukxJDKFpdFb5x0a5HqbdlcKtebh086iJpI31LTKmWuA=
go.aykhans.me/utils v1.0.7 h1:ClHXHlWmkjfFlD7+w5BQY29lKCEztxY/yCf543x4hZw=
go.aykhans.me/utils v1.0.7/go.mod h1:0Jz8GlZLN35cCHLOLx39sazWwEe33bF6SYlSeqzEXoI=
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=
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/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU=
golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY=
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.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ=
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.38.0/go.mod h1:bSEAKrOT1W+VSu9TSCMtoGEOUcKxOKgl3LE5QEF/xVg=
golang.org/x/text v0.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU=
golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

285
internal/config/cli.go Normal file
View File

@@ -0,0 +1,285 @@
package config
import (
"flag"
"fmt"
"net/url"
"os"
"strings"
"time"
"go.aykhans.me/sarin/internal/types"
versionpkg "go.aykhans.me/sarin/internal/version"
"go.aykhans.me/utils/common"
)
const cliUsageText = `Usage:
sarin [flags]
Simple usage:
sarin -U https://example.com -d 1m
Usage with all flags:
sarin -s -q -z -o json -f ./config.yaml -c 50 -r 100_000 -d 2m30s \
-U https://example.com \
-M POST \
-V "sharedUUID={{ fakeit_UUID }}" \
-B '{"product": "car"}' \
-P "id={{ .Values.sharedUUID }}" \
-H "User-Agent: {{ fakeit_UserAgent }}" -H "Accept: */*" \
-C "token={{ .Values.sharedUUID }}" \
-X "http://proxy.example.com" \
-T 3s \
-I
Flags:
General Config:
-h, -help Help for sarin
-v, -version Version for sarin
-s, -show-config bool Show the final config after parsing all sources (default %v)
-f, -config-file string Path to the config file (local file / http URL)
-c, -concurrency uint Number of concurrent requests (default %d)
-r, -requests uint Number of total requests
-d, -duration time Maximum duration for the test (e.g. 30s, 1m, 5h)
-q, -quiet bool Hide the progress bar and runtime logs (default %v)
-o, -output string Output format (possible values: table, json, yaml, none) (default '%v')
-z, -dry-run bool Run without sending requests (default %v)
Request Config:
-U, -url string Target URL for the request
-M, -method []string HTTP method for the request (default %s)
-B, -body []string Body for the request (e.g. "body text")
-P, -param []string URL parameter for the request (e.g. "key1=value1")
-H, -header []string Header for the request (e.g. "key1: value1")
-C, -cookie []string Cookie for the request (e.g. "key1=value1")
-X, -proxy []string Proxy for the request (e.g. "http://proxy.example.com:8080")
-V, -values []string List of values for templating (e.g. "key1=value1")
-T, -timeout time Timeout for the request (e.g. 400ms, 3s, 1m10s) (default %v)
-I, -insecure bool Skip SSL/TLS certificate verification (default %v)`
var _ IParser = ConfigCLIParser{}
type ConfigCLIParser struct {
args []string
}
func NewConfigCLIParser(args []string) *ConfigCLIParser {
if args == nil {
args = []string{}
}
return &ConfigCLIParser{args: args}
}
type stringSliceArg []string
func (arg *stringSliceArg) String() string {
return strings.Join(*arg, ",")
}
func (arg *stringSliceArg) Set(value string) error {
*arg = append(*arg, value)
return nil
}
// Parse parses command-line arguments into a Config object.
// It can return the following errors:
// - types.ErrCLINoArgs
// - types.CLIUnexpectedArgsError
// - types.FieldParseErrors
func (parser ConfigCLIParser) Parse() (*Config, error) {
flagSet := flag.NewFlagSet("sarin", flag.ExitOnError)
flagSet.Usage = func() { parser.PrintHelp() }
var (
config = &Config{}
// General config
version bool
showConfig bool
configFiles = stringSliceArg{}
concurrency uint
requestCount uint64
duration time.Duration
quiet bool
output string
dryRun bool
// Request config
urlInput string
methods = stringSliceArg{}
bodies = stringSliceArg{}
params = stringSliceArg{}
headers = stringSliceArg{}
cookies = stringSliceArg{}
proxies = stringSliceArg{}
values = stringSliceArg{}
timeout time.Duration
insecure bool
)
{
// General config
flagSet.BoolVar(&version, "version", false, "Version for sarin")
flagSet.BoolVar(&version, "v", false, "Version for sarin")
flagSet.BoolVar(&showConfig, "show-config", false, "Show the final config after parsing all sources")
flagSet.BoolVar(&showConfig, "s", false, "Show the final config after parsing all sources")
flagSet.Var(&configFiles, "config-file", "Path to the config file")
flagSet.Var(&configFiles, "f", "Path to the config file")
flagSet.UintVar(&concurrency, "concurrency", 0, "Number of concurrent requests")
flagSet.UintVar(&concurrency, "c", 0, "Number of concurrent requests")
flagSet.Uint64Var(&requestCount, "requests", 0, "Number of total requests")
flagSet.Uint64Var(&requestCount, "r", 0, "Number of total requests")
flagSet.DurationVar(&duration, "duration", 0, "Maximum duration for the test")
flagSet.DurationVar(&duration, "d", 0, "Maximum duration for the test")
flagSet.BoolVar(&quiet, "quiet", false, "Hide the progress bar and runtime logs")
flagSet.BoolVar(&quiet, "q", false, "Hide the progress bar and runtime logs")
flagSet.StringVar(&output, "output", "", "Output format (possible values: table, json, yaml, none)")
flagSet.StringVar(&output, "o", "", "Output format (possible values: table, json, yaml, none)")
flagSet.BoolVar(&dryRun, "dry-run", false, "Run without sending requests")
flagSet.BoolVar(&dryRun, "z", false, "Run without sending requests")
// Request config
flagSet.StringVar(&urlInput, "url", "", "Target URL for the request")
flagSet.StringVar(&urlInput, "U", "", "Target URL for the request")
flagSet.Var(&methods, "method", "HTTP method for the request")
flagSet.Var(&methods, "M", "HTTP method for the request")
flagSet.Var(&bodies, "body", "Body for the request")
flagSet.Var(&bodies, "B", "Body for the request")
flagSet.Var(&params, "param", "URL parameter for the request")
flagSet.Var(&params, "P", "URL parameter for the request")
flagSet.Var(&headers, "header", "Header for the request")
flagSet.Var(&headers, "H", "Header for the request")
flagSet.Var(&cookies, "cookie", "Cookie for the request")
flagSet.Var(&cookies, "C", "Cookie for the request")
flagSet.Var(&proxies, "proxy", "Proxy for the request")
flagSet.Var(&proxies, "X", "Proxy for the request")
flagSet.Var(&values, "values", "List of values for templating")
flagSet.Var(&values, "V", "List of values for templating")
flagSet.DurationVar(&timeout, "timeout", 0, "Timeout for the request (e.g. 400ms, 15s, 1m10s)")
flagSet.DurationVar(&timeout, "T", 0, "Timeout for the request (e.g. 400ms, 15s, 1m10s)")
flagSet.BoolVar(&insecure, "insecure", false, "Skip SSL/TLS certificate verification")
flagSet.BoolVar(&insecure, "I", false, "Skip SSL/TLS certificate verification")
}
// Parse the specific arguments provided to the parser, skipping the program name.
if err := flagSet.Parse(parser.args[1:]); err != nil {
panic(err)
}
// Check if no flags were set and no non-flag arguments were provided.
// This covers cases where `sarin` is run without any meaningful arguments.
if flagSet.NFlag() == 0 && len(flagSet.Args()) == 0 {
return nil, types.ErrCLINoArgs
}
// Check for any unexpected non-flag arguments remaining after parsing.
if args := flagSet.Args(); len(args) > 0 {
return nil, types.NewCLIUnexpectedArgsError(args)
}
if version {
fmt.Printf("Version: %s\nGit Commit: %s\nBuild Date: %s\nGo Version: %s\n",
versionpkg.Version, versionpkg.GitCommit, versionpkg.BuildDate, versionpkg.GoVersion)
os.Exit(0)
}
var fieldParseErrors []types.FieldParseError
// Iterate over flags that were explicitly set on the command line.
flagSet.Visit(func(flagVar *flag.Flag) {
switch flagVar.Name {
// General config
case "show-config", "s":
config.ShowConfig = common.ToPtr(showConfig)
case "config-file", "f":
for _, configFile := range configFiles {
config.Files = append(config.Files, *types.ParseConfigFile(configFile))
}
case "concurrency", "c":
config.Concurrency = common.ToPtr(concurrency)
case "requests", "r":
config.Requests = common.ToPtr(requestCount)
case "duration", "d":
config.Duration = common.ToPtr(duration)
case "quiet", "q":
config.Quiet = common.ToPtr(quiet)
case "output", "o":
config.Output = common.ToPtr(ConfigOutputType(output))
case "dry-run", "z":
config.DryRun = common.ToPtr(dryRun)
// Request config
case "url", "U":
urlParsed, err := url.Parse(urlInput)
if err != nil {
fieldParseErrors = append(fieldParseErrors, types.NewFieldParseError("url", urlInput, err))
} else {
config.URL = urlParsed
}
case "method", "M":
config.Methods = append(config.Methods, methods...)
case "body", "B":
config.Bodies = append(config.Bodies, bodies...)
case "param", "P":
config.Params.Parse(params...)
case "header", "H":
config.Headers.Parse(headers...)
case "cookie", "C":
config.Cookies.Parse(cookies...)
case "proxy", "X":
for i, proxy := range proxies {
err := config.Proxies.Parse(proxy)
if err != nil {
fieldParseErrors = append(
fieldParseErrors,
types.NewFieldParseError(fmt.Sprintf("proxy[%d]", i), proxy, err),
)
}
}
case "values", "V":
config.Values = append(config.Values, values...)
case "timeout", "T":
config.Timeout = common.ToPtr(timeout)
case "insecure", "I":
config.Insecure = common.ToPtr(insecure)
}
})
if len(fieldParseErrors) > 0 {
return nil, types.NewFieldParseErrors(fieldParseErrors)
}
return config, nil
}
func (parser ConfigCLIParser) PrintHelp() {
fmt.Printf(
cliUsageText+"\n",
Defaults.ShowConfig,
Defaults.Concurrency,
Defaults.Quiet,
Defaults.Output,
Defaults.DryRun,
Defaults.Method,
Defaults.RequestTimeout,
Defaults.Insecure,
)
}

757
internal/config/config.go Normal file
View File

@@ -0,0 +1,757 @@
package config
import (
"errors"
"fmt"
"net/url"
"os"
"slices"
"strconv"
"strings"
"time"
"github.com/charmbracelet/bubbles/viewport"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/glamour"
"github.com/charmbracelet/glamour/styles"
"github.com/charmbracelet/lipgloss"
"github.com/charmbracelet/x/term"
"go.aykhans.me/sarin/internal/types"
"go.aykhans.me/sarin/internal/version"
"go.aykhans.me/utils/common"
utilsErr "go.aykhans.me/utils/errors"
"go.yaml.in/yaml/v4"
)
var Defaults = struct {
UserAgent string
Method string
RequestTimeout time.Duration
Concurrency uint
ShowConfig bool
Quiet bool
Insecure bool
Output ConfigOutputType
DryRun bool
}{
UserAgent: "Sarin/" + version.Version,
Method: "GET",
RequestTimeout: time.Second * 10,
Concurrency: 1,
ShowConfig: false,
Quiet: false,
Insecure: false,
Output: ConfigOutputTypeTable,
DryRun: false,
}
var (
ValidProxySchemes = []string{"http", "https", "socks5", "socks5h"}
ValidRequestURLSchemes = []string{"http", "https"}
)
var (
StyleYellow = lipgloss.NewStyle().Foreground(lipgloss.Color("220"))
StyleRed = lipgloss.NewStyle().Foreground(lipgloss.Color("196"))
)
type IParser interface {
Parse() (*Config, error)
}
type ConfigOutputType string
var (
ConfigOutputTypeTable ConfigOutputType = "table"
ConfigOutputTypeJSON ConfigOutputType = "json"
ConfigOutputTypeYAML ConfigOutputType = "yaml"
ConfigOutputTypeNone ConfigOutputType = "none"
)
type Config struct {
ShowConfig *bool `yaml:"showConfig,omitempty"`
Files []types.ConfigFile `yaml:"files,omitempty"`
Methods []string `yaml:"methods,omitempty"`
URL *url.URL `yaml:"url,omitempty"`
Timeout *time.Duration `yaml:"timeout,omitempty"`
Concurrency *uint `yaml:"concurrency,omitempty"`
Requests *uint64 `yaml:"requests,omitempty"`
Duration *time.Duration `yaml:"duration,omitempty"`
Quiet *bool `yaml:"quiet,omitempty"`
Output *ConfigOutputType `yaml:"output,omitempty"`
Insecure *bool `yaml:"insecure,omitempty"`
DryRun *bool `yaml:"dryRun,omitempty"`
Params types.Params `yaml:"params,omitempty"`
Headers types.Headers `yaml:"headers,omitempty"`
Cookies types.Cookies `yaml:"cookies,omitempty"`
Bodies []string `yaml:"bodies,omitempty"`
Proxies types.Proxies `yaml:"proxies,omitempty"`
Values []string `yaml:"values,omitempty"`
}
func NewConfig() *Config {
return &Config{}
}
func (config Config) MarshalYAML() (any, error) {
const randomValueComment = "Cycles through all values, with a new random start each round"
toNode := func(v any) *yaml.Node {
node := &yaml.Node{}
_ = node.Encode(v)
return node
}
addField := func(content *[]*yaml.Node, key string, value *yaml.Node, comment string) {
if value.Kind == 0 || (value.Kind == yaml.ScalarNode && value.Value == "") ||
(value.Kind == yaml.SequenceNode && len(value.Content) == 0) {
return
}
keyNode := &yaml.Node{Kind: yaml.ScalarNode, Value: key, LineComment: comment}
*content = append(*content, keyNode, value)
}
addStringSlice := func(content *[]*yaml.Node, key string, items []string, withComment bool) {
comment := ""
if withComment && len(items) > 1 {
comment = randomValueComment
}
switch len(items) {
case 1:
addField(content, key, toNode(items[0]), "")
default:
addField(content, key, toNode(items), comment)
}
}
marshalKeyValues := func(items []types.KeyValue[string, []string]) *yaml.Node {
seqNode := &yaml.Node{Kind: yaml.SequenceNode}
for _, item := range items {
keyNode := &yaml.Node{Kind: yaml.ScalarNode, Value: item.Key}
var valueNode *yaml.Node
switch len(item.Value) {
case 1:
valueNode = &yaml.Node{Kind: yaml.ScalarNode, Value: item.Value[0]}
default:
valueNode = &yaml.Node{Kind: yaml.SequenceNode}
for _, v := range item.Value {
valueNode.Content = append(valueNode.Content, &yaml.Node{Kind: yaml.ScalarNode, Value: v})
}
if len(item.Value) > 1 {
keyNode.LineComment = randomValueComment
}
}
mapNode := &yaml.Node{Kind: yaml.MappingNode, Content: []*yaml.Node{keyNode, valueNode}}
seqNode.Content = append(seqNode.Content, mapNode)
}
return seqNode
}
root := &yaml.Node{Kind: yaml.MappingNode}
content := &root.Content
if config.ShowConfig != nil {
addField(content, "showConfig", toNode(*config.ShowConfig), "")
}
addStringSlice(content, "method", config.Methods, true)
if config.URL != nil {
addField(content, "url", toNode(config.URL.String()), "")
}
if config.Timeout != nil {
addField(content, "timeout", toNode(*config.Timeout), "")
}
if config.Concurrency != nil {
addField(content, "concurrency", toNode(*config.Concurrency), "")
}
if config.Requests != nil {
addField(content, "requests", toNode(*config.Requests), "")
}
if config.Duration != nil {
addField(content, "duration", toNode(*config.Duration), "")
}
if config.Quiet != nil {
addField(content, "quiet", toNode(*config.Quiet), "")
}
if config.Output != nil {
addField(content, "output", toNode(string(*config.Output)), "")
}
if config.Insecure != nil {
addField(content, "insecure", toNode(*config.Insecure), "")
}
if config.DryRun != nil {
addField(content, "dryRun", toNode(*config.DryRun), "")
}
if len(config.Params) > 0 {
items := make([]types.KeyValue[string, []string], len(config.Params))
for i, p := range config.Params {
items[i] = types.KeyValue[string, []string](p)
}
addField(content, "params", marshalKeyValues(items), "")
}
if len(config.Headers) > 0 {
items := make([]types.KeyValue[string, []string], len(config.Headers))
for i, h := range config.Headers {
items[i] = types.KeyValue[string, []string](h)
}
addField(content, "headers", marshalKeyValues(items), "")
}
if len(config.Cookies) > 0 {
items := make([]types.KeyValue[string, []string], len(config.Cookies))
for i, c := range config.Cookies {
items[i] = types.KeyValue[string, []string](c)
}
addField(content, "cookies", marshalKeyValues(items), "")
}
addStringSlice(content, "body", config.Bodies, true)
if len(config.Proxies) > 0 {
proxyStrings := make([]string, len(config.Proxies))
for i, p := range config.Proxies {
proxyStrings[i] = p.String()
}
addStringSlice(content, "proxy", proxyStrings, true)
}
addStringSlice(content, "values", config.Values, false)
return root, nil
}
func (config Config) Print() bool {
configYAML, err := yaml.Marshal(config)
if err != nil {
fmt.Fprintln(os.Stderr, StyleRed.Render("Error marshaling config to yaml: "+err.Error()))
os.Exit(1)
}
// Pipe mode: output raw content directly
if !term.IsTerminal(os.Stdout.Fd()) {
fmt.Println(string(configYAML))
os.Exit(0)
}
style := styles.TokyoNightStyleConfig
style.Document.Margin = common.ToPtr[uint](0)
style.CodeBlock.Margin = common.ToPtr[uint](0)
renderer, err := glamour.NewTermRenderer(
glamour.WithStyles(style),
glamour.WithWordWrap(0),
)
if err != nil {
fmt.Fprintln(os.Stderr, StyleRed.Render(err.Error()))
os.Exit(1)
}
content, err := renderer.Render("```yaml\n" + string(configYAML) + "```")
if err != nil {
fmt.Fprintln(os.Stderr, StyleRed.Render(err.Error()))
os.Exit(1)
}
p := tea.NewProgram(
printConfigModel{content: strings.Trim(content, "\n"), rawContent: configYAML},
tea.WithAltScreen(),
tea.WithMouseCellMotion(),
)
m, err := p.Run()
if err != nil {
fmt.Fprintln(os.Stderr, StyleRed.Render(err.Error()))
os.Exit(1)
}
return m.(printConfigModel).start //nolint:forcetypeassert // m is guaranteed to be of type printConfigModel as it was the only model passed to tea.NewProgram
}
func (config *Config) Merge(newConfig *Config) {
config.Files = append(config.Files, newConfig.Files...)
if len(newConfig.Methods) > 0 {
config.Methods = append(config.Methods, newConfig.Methods...)
}
if newConfig.URL != nil {
config.URL = newConfig.URL
}
if newConfig.Timeout != nil {
config.Timeout = newConfig.Timeout
}
if newConfig.Concurrency != nil {
config.Concurrency = newConfig.Concurrency
}
if newConfig.Requests != nil {
config.Requests = newConfig.Requests
}
if newConfig.Duration != nil {
config.Duration = newConfig.Duration
}
if newConfig.ShowConfig != nil {
config.ShowConfig = newConfig.ShowConfig
}
if newConfig.Quiet != nil {
config.Quiet = newConfig.Quiet
}
if newConfig.Output != nil {
config.Output = newConfig.Output
}
if newConfig.Insecure != nil {
config.Insecure = newConfig.Insecure
}
if newConfig.DryRun != nil {
config.DryRun = newConfig.DryRun
}
if len(newConfig.Params) != 0 {
config.Params = append(config.Params, newConfig.Params...)
}
if len(newConfig.Headers) != 0 {
config.Headers = append(config.Headers, newConfig.Headers...)
}
if len(newConfig.Cookies) != 0 {
config.Cookies = append(config.Cookies, newConfig.Cookies...)
}
if len(newConfig.Bodies) != 0 {
config.Bodies = append(config.Bodies, newConfig.Bodies...)
}
if len(newConfig.Proxies) != 0 {
config.Proxies.Append(newConfig.Proxies...)
}
if len(newConfig.Values) != 0 {
config.Values = append(config.Values, newConfig.Values...)
}
}
func (config *Config) SetDefaults() {
if config.URL != nil && len(config.URL.Query()) > 0 {
urlParams := types.Params{}
for key, values := range config.URL.Query() {
for _, value := range values {
urlParams = append(urlParams, types.Param{
Key: key,
Value: []string{value},
})
}
}
config.Params = append(urlParams, config.Params...)
config.URL.RawQuery = ""
}
if len(config.Methods) == 0 {
config.Methods = []string{Defaults.Method}
}
if config.Timeout == nil {
config.Timeout = &Defaults.RequestTimeout
}
if config.Concurrency == nil {
config.Concurrency = common.ToPtr(Defaults.Concurrency)
}
if config.ShowConfig == nil {
config.ShowConfig = common.ToPtr(Defaults.ShowConfig)
}
if config.Quiet == nil {
config.Quiet = common.ToPtr(Defaults.Quiet)
}
if config.Insecure == nil {
config.Insecure = common.ToPtr(Defaults.Insecure)
}
if config.DryRun == nil {
config.DryRun = common.ToPtr(Defaults.DryRun)
}
if !config.Headers.Has("User-Agent") {
config.Headers = append(config.Headers, types.Header{Key: "User-Agent", Value: []string{Defaults.UserAgent}})
}
if config.Output == nil {
config.Output = common.ToPtr(Defaults.Output)
}
}
// Validate validates the config fields.
// It can return the following errors:
// - types.FieldValidationErrors
func (config Config) Validate() error {
validationErrors := make([]types.FieldValidationError, 0)
if len(config.Methods) == 0 {
validationErrors = append(validationErrors, types.NewFieldValidationError("Method", "", errors.New("method is required")))
}
switch {
case config.URL == nil:
validationErrors = append(validationErrors, types.NewFieldValidationError("URL", "", errors.New("URL is required")))
case !slices.Contains(ValidRequestURLSchemes, config.URL.Scheme):
validationErrors = append(validationErrors, types.NewFieldValidationError("URL", config.URL.String(), fmt.Errorf("URL scheme must be one of: %s", strings.Join(ValidRequestURLSchemes, ", "))))
case config.URL.Host == "":
validationErrors = append(validationErrors, types.NewFieldValidationError("URL", config.URL.String(), errors.New("URL must have a host")))
}
switch {
case config.Concurrency == nil:
validationErrors = append(validationErrors, types.NewFieldValidationError("Concurrency", "", errors.New("concurrency count is required")))
case *config.Concurrency == 0:
validationErrors = append(validationErrors, types.NewFieldValidationError("Concurrency", "0", errors.New("concurrency must be greater than 0")))
case *config.Concurrency > 100_000_000:
validationErrors = append(validationErrors, types.NewFieldValidationError("Concurrency", strconv.FormatUint(uint64(*config.Concurrency), 10), errors.New("concurrency must not exceed 100,000,000")))
}
switch {
case config.Requests == nil && config.Duration == nil:
validationErrors = append(validationErrors, types.NewFieldValidationError("Requests / Duration", "", errors.New("either request count or duration must be specified")))
case (config.Requests != nil && config.Duration != nil) && (*config.Requests == 0 && *config.Duration == 0):
validationErrors = append(validationErrors, types.NewFieldValidationError("Requests / Duration", "0", errors.New("both request count and duration cannot be zero")))
case config.Requests != nil && config.Duration == nil && *config.Requests == 0:
validationErrors = append(validationErrors, types.NewFieldValidationError("Requests", "0", errors.New("request count must be greater than 0")))
case config.Requests == nil && config.Duration != nil && *config.Duration == 0:
validationErrors = append(validationErrors, types.NewFieldValidationError("Duration", "0", errors.New("duration must be greater than 0")))
}
if *config.Timeout < 1 {
validationErrors = append(validationErrors, types.NewFieldValidationError("Timeout", "0", errors.New("timeout must be greater than 0")))
}
if config.ShowConfig == nil {
validationErrors = append(validationErrors, types.NewFieldValidationError("ShowConfig", "", errors.New("showConfig field is required")))
}
if config.Quiet == nil {
validationErrors = append(validationErrors, types.NewFieldValidationError("Quiet", "", errors.New("quiet field is required")))
}
if config.Output == nil {
validationErrors = append(validationErrors, types.NewFieldValidationError("Output", "", errors.New("output field is required")))
} else {
switch *config.Output {
case "":
validationErrors = append(validationErrors, types.NewFieldValidationError("Output", "", errors.New("output field is required")))
case ConfigOutputTypeTable, ConfigOutputTypeJSON, ConfigOutputTypeYAML, ConfigOutputTypeNone:
default:
validOutputs := []string{string(ConfigOutputTypeTable), string(ConfigOutputTypeJSON), string(ConfigOutputTypeYAML), string(ConfigOutputTypeNone)}
validationErrors = append(validationErrors,
types.NewFieldValidationError(
"Output",
string(*config.Output),
fmt.Errorf(
"output type must be one of: %s",
strings.Join(validOutputs, ", "),
),
),
)
}
}
if config.Insecure == nil {
validationErrors = append(validationErrors, types.NewFieldValidationError("Insecure", "", errors.New("insecure field is required")))
}
if config.DryRun == nil {
validationErrors = append(validationErrors, types.NewFieldValidationError("DryRun", "", errors.New("dryRun field is required")))
}
for i, proxy := range config.Proxies {
if !slices.Contains(ValidProxySchemes, proxy.Scheme) {
validationErrors = append(
validationErrors,
types.NewFieldValidationError(
fmt.Sprintf("Proxy[%d]", i),
proxy.String(),
fmt.Errorf("proxy scheme must be one of: %v", ValidProxySchemes),
),
)
}
}
templateErrors := ValidateTemplates(&config)
validationErrors = append(validationErrors, templateErrors...)
if len(validationErrors) > 0 {
return types.NewFieldValidationErrors(validationErrors)
}
return nil
}
func ReadAllConfigs() *Config {
envParser := NewConfigENVParser("SARIN")
envConfig, err := envParser.Parse()
_ = utilsErr.MustHandle(err,
utilsErr.OnType(func(err types.FieldParseErrors) error {
printParseErrors("ENV", err.Errors...)
fmt.Println()
os.Exit(1)
return nil
}),
)
cliParser := NewConfigCLIParser(os.Args)
cliConf, err := cliParser.Parse()
_ = utilsErr.MustHandle(err,
utilsErr.OnSentinel(types.ErrCLINoArgs, func(err error) error {
cliParser.PrintHelp()
fmt.Fprintln(os.Stderr, StyleYellow.Render("\nNo arguments provided."))
os.Exit(1)
return nil
}),
utilsErr.OnType(func(err types.CLIUnexpectedArgsError) error {
cliParser.PrintHelp()
fmt.Fprintln(os.Stderr,
StyleYellow.Render(
"\nUnexpected CLI arguments provided: ",
)+strings.Join(err.Args, ", "),
)
os.Exit(1)
return nil
}),
utilsErr.OnType(func(err types.FieldParseErrors) error {
cliParser.PrintHelp()
fmt.Println()
printParseErrors("CLI", err.Errors...)
os.Exit(1)
return nil
}),
)
for _, configFile := range append(envConfig.Files, cliConf.Files...) {
fileConfig, err := parseConfigFile(configFile, 10)
_ = utilsErr.MustHandle(err,
utilsErr.OnType(func(err types.ConfigFileReadError) error {
cliParser.PrintHelp()
fmt.Fprintln(os.Stderr,
StyleYellow.Render(
fmt.Sprintf("\nFailed to read config file (%s): ", configFile.Path())+err.Error(),
),
)
os.Exit(1)
return nil
}),
utilsErr.OnType(func(err types.UnmarshalError) error {
fmt.Fprintln(os.Stderr,
StyleYellow.Render(
fmt.Sprintf("\nFailed to parse config file (%s): ", configFile.Path())+err.Error(),
),
)
os.Exit(1)
return nil
}),
utilsErr.OnType(func(err types.FieldParseErrors) error {
printParseErrors(fmt.Sprintf("CONFIG FILE '%s'", configFile.Path()), err.Errors...)
os.Exit(1)
return nil
}),
)
envConfig.Merge(fileConfig)
}
envConfig.Merge(cliConf)
return envConfig
}
// parseConfigFile recursively parses a config file and its nested files up to maxDepth levels.
// Returns the merged configuration or an error if parsing fails.
// It can return the following errors:
// - types.ConfigFileReadError
// - types.UnmarshalError
// - types.FieldParseErrors
func parseConfigFile(configFile types.ConfigFile, maxDepth int) (*Config, error) {
configFileParser := NewConfigFileParser(configFile)
fileConfig, err := configFileParser.Parse()
if err != nil {
return nil, err
}
if maxDepth <= 0 {
return fileConfig, nil
}
for _, c := range fileConfig.Files {
innerFileConfig, err := parseConfigFile(c, maxDepth-1)
if err != nil {
return nil, err
}
innerFileConfig.Merge(fileConfig)
fileConfig = innerFileConfig
}
return fileConfig, nil
}
func printParseErrors(parserName string, errors ...types.FieldParseError) {
for _, fieldErr := range errors {
if fieldErr.Value == "" {
fmt.Fprintln(os.Stderr,
StyleYellow.Render(fmt.Sprintf("[%s] Field '%s': ", parserName, fieldErr.Field))+fieldErr.Err.Error(),
)
} else {
fmt.Fprintln(os.Stderr,
StyleYellow.Render(fmt.Sprintf("[%s] Field '%s' (%s): ", parserName, fieldErr.Field, fieldErr.Value))+fieldErr.Err.Error(),
)
}
}
}
const (
scrollbarWidth = 1
scrollbarBottomSpace = 1
statusDisplayTime = 3 * time.Second
)
var (
printConfigBorderStyle = func() lipgloss.Border {
b := lipgloss.RoundedBorder()
return b
}()
printConfigHelpStyle = lipgloss.NewStyle().BorderStyle(printConfigBorderStyle).Padding(0, 1)
printConfigSuccessStatusStyle = lipgloss.NewStyle().BorderStyle(printConfigBorderStyle).Padding(0, 1).Foreground(lipgloss.Color("10"))
printConfigErrorStatusStyle = lipgloss.NewStyle().BorderStyle(printConfigBorderStyle).Padding(0, 1).Foreground(lipgloss.Color("9"))
printConfigKeyStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("12")).Bold(true)
printConfigDescStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("252"))
)
type printConfigClearStatusMsg struct{}
type printConfigModel struct {
viewport viewport.Model
content string
rawContent []byte
statusMsg string
ready bool
start bool
}
func (m printConfigModel) Init() tea.Cmd { return nil }
func (m printConfigModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
var cmd tea.Cmd
switch msg := msg.(type) {
case tea.KeyMsg:
switch msg.String() {
case "ctrl+c", "esc":
return m, tea.Quit
case "ctrl+s":
return m.saveContent()
case "enter":
m.start = true
return m, tea.Quit
}
case printConfigClearStatusMsg:
m.statusMsg = ""
return m, nil
case tea.WindowSizeMsg:
m.handleResize(msg)
}
m.viewport, cmd = m.viewport.Update(msg)
return m, cmd
}
func (m printConfigModel) View() string {
if !m.ready {
return "\n Initializing..."
}
content := lipgloss.JoinHorizontal(lipgloss.Top, m.viewport.View(), m.scrollbar())
return fmt.Sprintf("%s\n%s\n%s", m.headerView(), content, m.footerView())
}
func (m *printConfigModel) saveContent() (printConfigModel, tea.Cmd) {
filename := fmt.Sprintf("sarin_config_%s.yaml", time.Now().Format("2006-01-02_15-04-05"))
if err := os.WriteFile(filename, m.rawContent, 0600); err != nil {
m.statusMsg = printConfigErrorStatusStyle.Render("✗ Error saving file: " + err.Error())
} else {
m.statusMsg = printConfigSuccessStatusStyle.Render("✓ Saved to " + filename)
}
return *m, tea.Tick(statusDisplayTime, func(time.Time) tea.Msg { return printConfigClearStatusMsg{} })
}
func (m *printConfigModel) handleResize(msg tea.WindowSizeMsg) {
headerHeight := lipgloss.Height(m.headerView())
footerHeight := lipgloss.Height(m.footerView())
height := msg.Height - headerHeight - footerHeight
width := msg.Width - scrollbarWidth
if !m.ready {
m.viewport = viewport.New(width, height)
m.viewport.SetContent(m.contentWithLineNumbers())
m.ready = true
} else {
m.viewport.Width = width
m.viewport.Height = height
}
}
func (m printConfigModel) headerView() string {
var title string
if m.statusMsg != "" {
title = ("" + m.statusMsg)
} else {
sep := printConfigDescStyle.Render(" / ")
help := printConfigKeyStyle.Render("ENTER") + printConfigDescStyle.Render(" start") + sep +
printConfigKeyStyle.Render("CTRL+S") + printConfigDescStyle.Render(" save") + sep +
printConfigKeyStyle.Render("ESC") + printConfigDescStyle.Render(" exit")
title = printConfigHelpStyle.Render(help)
}
line := strings.Repeat("─", max(0, m.viewport.Width+scrollbarWidth-lipgloss.Width(title)))
return lipgloss.JoinHorizontal(lipgloss.Center, title, line)
}
func (m printConfigModel) footerView() string {
return strings.Repeat("─", m.viewport.Width+scrollbarWidth)
}
func (m printConfigModel) contentWithLineNumbers() string {
lines := strings.Split(m.content, "\n")
width := len(strconv.Itoa(len(lines)))
lineNumStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("246"))
var sb strings.Builder
for i, line := range lines {
lineNum := lineNumStyle.Render(fmt.Sprintf("%*d", width, i+1))
sb.WriteString(lineNum)
sb.WriteString(" ")
sb.WriteString(line)
if i < len(lines)-1 {
sb.WriteByte('\n')
}
}
return sb.String()
}
func (m printConfigModel) scrollbar() string {
height := m.viewport.Height
trackHeight := height - scrollbarBottomSpace
totalLines := m.viewport.TotalLineCount()
if totalLines <= height {
return strings.Repeat(" \n", trackHeight) + " "
}
thumbSize := max(1, (height*trackHeight)/totalLines)
thumbPos := int(m.viewport.ScrollPercent() * float64(trackHeight-thumbSize))
var sb strings.Builder
for i := range trackHeight {
if i >= thumbPos && i < thumbPos+thumbSize {
sb.WriteByte('\xe2') // █ (U+2588)
sb.WriteByte('\x96')
sb.WriteByte('\x88')
} else {
sb.WriteByte('\xe2') // ░ (U+2591)
sb.WriteByte('\x96')
sb.WriteByte('\x91')
}
sb.WriteByte('\n')
}
sb.WriteByte(' ')
return sb.String()
}

235
internal/config/env.go Normal file
View File

@@ -0,0 +1,235 @@
package config
import (
"errors"
"net/url"
"os"
"time"
"go.aykhans.me/sarin/internal/types"
"go.aykhans.me/utils/common"
utilsParse "go.aykhans.me/utils/parser"
)
var _ IParser = ConfigENVParser{}
type ConfigENVParser struct {
envPrefix string
}
func NewConfigENVParser(envPrefix string) *ConfigENVParser {
return &ConfigENVParser{envPrefix}
}
// Parse parses env arguments into a Config object.
// It can return the following errors:
// - types.FieldParseErrors
func (parser ConfigENVParser) Parse() (*Config, error) {
var (
config = &Config{}
fieldParseErrors []types.FieldParseError
)
if showConfig := parser.getEnv("SHOW_CONFIG"); showConfig != "" {
showConfigParsed, err := utilsParse.ParseString[bool](showConfig)
if err != nil {
fieldParseErrors = append(
fieldParseErrors,
types.NewFieldParseError(
parser.getFullEnvName("SHOW_CONFIG"),
showConfig,
errors.New("invalid value for boolean, expected 'true' or 'false'"),
),
)
} else {
config.ShowConfig = &showConfigParsed
}
}
if configFile := parser.getEnv("CONFIG_FILE"); configFile != "" {
config.Files = append(config.Files, *types.ParseConfigFile(configFile))
}
if quiet := parser.getEnv("QUIET"); quiet != "" {
quietParsed, err := utilsParse.ParseString[bool](quiet)
if err != nil {
fieldParseErrors = append(
fieldParseErrors,
types.NewFieldParseError(
parser.getFullEnvName("QUIET"),
quiet,
errors.New("invalid value for boolean, expected 'true' or 'false'"),
),
)
} else {
config.Quiet = &quietParsed
}
}
if output := parser.getEnv("OUTPUT"); output != "" {
config.Output = common.ToPtr(ConfigOutputType(output))
}
if insecure := parser.getEnv("INSECURE"); insecure != "" {
insecureParsed, err := utilsParse.ParseString[bool](insecure)
if err != nil {
fieldParseErrors = append(
fieldParseErrors,
types.NewFieldParseError(
parser.getFullEnvName("INSECURE"),
insecure,
errors.New("invalid value for boolean, expected 'true' or 'false'"),
),
)
} else {
config.Insecure = &insecureParsed
}
}
if dryRun := parser.getEnv("DRY_RUN"); dryRun != "" {
dryRunParsed, err := utilsParse.ParseString[bool](dryRun)
if err != nil {
fieldParseErrors = append(
fieldParseErrors,
types.NewFieldParseError(
parser.getFullEnvName("DRY_RUN"),
dryRun,
errors.New("invalid value for boolean, expected 'true' or 'false'"),
),
)
} else {
config.DryRun = &dryRunParsed
}
}
if method := parser.getEnv("METHOD"); method != "" {
config.Methods = []string{method}
}
if urlEnv := parser.getEnv("URL"); urlEnv != "" {
urlEnvParsed, err := url.Parse(urlEnv)
if err != nil {
fieldParseErrors = append(
fieldParseErrors,
types.NewFieldParseError(parser.getFullEnvName("URL"), urlEnv, err),
)
} else {
config.URL = urlEnvParsed
}
}
if concurrency := parser.getEnv("CONCURRENCY"); concurrency != "" {
concurrencyParsed, err := utilsParse.ParseString[uint](concurrency)
if err != nil {
fieldParseErrors = append(
fieldParseErrors,
types.NewFieldParseError(
parser.getFullEnvName("CONCURRENCY"),
concurrency,
errors.New("invalid value for unsigned integer"),
),
)
} else {
config.Concurrency = &concurrencyParsed
}
}
if requests := parser.getEnv("REQUESTS"); requests != "" {
requestsParsed, err := utilsParse.ParseString[uint64](requests)
if err != nil {
fieldParseErrors = append(
fieldParseErrors,
types.NewFieldParseError(
parser.getFullEnvName("REQUESTS"),
requests,
errors.New("invalid value for unsigned integer"),
),
)
} else {
config.Requests = &requestsParsed
}
}
if duration := parser.getEnv("DURATION"); duration != "" {
durationParsed, err := utilsParse.ParseString[time.Duration](duration)
if err != nil {
fieldParseErrors = append(
fieldParseErrors,
types.NewFieldParseError(
parser.getFullEnvName("DURATION"),
duration,
errors.New("invalid value duration, expected a duration string (e.g., '10s', '1h30m')"),
),
)
} else {
config.Duration = &durationParsed
}
}
if timeout := parser.getEnv("TIMEOUT"); timeout != "" {
timeoutParsed, err := utilsParse.ParseString[time.Duration](timeout)
if err != nil {
fieldParseErrors = append(
fieldParseErrors,
types.NewFieldParseError(
parser.getFullEnvName("TIMEOUT"),
timeout,
errors.New("invalid value duration, expected a duration string (e.g., '10s', '1h30m')"),
),
)
} else {
config.Timeout = &timeoutParsed
}
}
if param := parser.getEnv("PARAM"); param != "" {
config.Params.Parse(param)
}
if header := parser.getEnv("HEADER"); header != "" {
config.Headers.Parse(header)
}
if cookie := parser.getEnv("COOKIE"); cookie != "" {
config.Cookies.Parse(cookie)
}
if body := parser.getEnv("BODY"); body != "" {
config.Bodies = []string{body}
}
if proxy := parser.getEnv("PROXY"); proxy != "" {
err := config.Proxies.Parse(proxy)
if err != nil {
fieldParseErrors = append(
fieldParseErrors,
types.NewFieldParseError(
parser.getFullEnvName("PROXY"),
proxy,
err,
),
)
}
}
if values := parser.getEnv("VALUES"); values != "" {
config.Values = []string{values}
}
if len(fieldParseErrors) > 0 {
return nil, types.NewFieldParseErrors(fieldParseErrors)
}
return config, nil
}
func (parser ConfigENVParser) getFullEnvName(envName string) string {
if parser.envPrefix == "" {
return envName
}
return parser.envPrefix + "_" + envName
}
func (parser ConfigENVParser) getEnv(envName string) string {
return os.Getenv(parser.getFullEnvName(envName))
}

280
internal/config/file.go Normal file
View File

@@ -0,0 +1,280 @@
package config
import (
"context"
"fmt"
"io"
"net/http"
"net/url"
"os"
"path/filepath"
"strings"
"time"
"go.aykhans.me/sarin/internal/types"
"go.aykhans.me/utils/common"
"go.yaml.in/yaml/v4"
)
var _ IParser = ConfigFileParser{}
type ConfigFileParser struct {
configFile types.ConfigFile
}
func NewConfigFileParser(configFile types.ConfigFile) *ConfigFileParser {
return &ConfigFileParser{configFile}
}
// Parse parses config file arguments into a Config object.
// It can return the following errors:
// - types.ConfigFileReadError
// - types.UnmarshalError
// - types.FieldParseErrors
func (parser ConfigFileParser) Parse() (*Config, error) {
ctx, cancel := context.WithTimeout(context.Background(), time.Second*30)
defer cancel()
configFileData, err := fetchFile(ctx, parser.configFile.Path())
if err != nil {
return nil, types.NewConfigFileReadError(err)
}
switch parser.configFile.Type() {
case types.ConfigFileTypeYAML, types.ConfigFileTypeUnknown:
return parser.ParseYAML(configFileData)
default:
panic("unhandled config file type")
}
}
// fetchFile retrieves file contents from a local path or HTTP/HTTPS URL.
func fetchFile(ctx context.Context, src string) ([]byte, error) {
if strings.HasPrefix(src, "http://") || strings.HasPrefix(src, "https://") {
return fetchHTTP(ctx, src)
}
return fetchLocal(src)
}
// fetchHTTP downloads file contents from an HTTP/HTTPS URL.
func fetchHTTP(ctx context.Context, url string) ([]byte, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return nil, fmt.Errorf("failed to create request: %w", err)
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
return nil, fmt.Errorf("failed to fetch file: %w", err)
}
defer resp.Body.Close() //nolint:errcheck
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("failed to fetch file: HTTP %d %s", resp.StatusCode, resp.Status)
}
data, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("failed to read response body: %w", err)
}
return data, nil
}
// fetchLocal reads file contents from the local filesystem.
// It resolves relative paths from the current working directory.
func fetchLocal(src string) ([]byte, error) {
path := src
if !filepath.IsAbs(src) {
pwd, err := os.Getwd()
if err != nil {
return nil, fmt.Errorf("failed to get working directory: %w", err)
}
path = filepath.Join(pwd, src)
}
data, err := os.ReadFile(path) //nolint:gosec
if err != nil {
return nil, fmt.Errorf("failed to read file: %w", err)
}
return data, nil
}
type stringOrSliceField []string
func (ss *stringOrSliceField) UnmarshalYAML(node *yaml.Node) error {
switch node.Kind {
case yaml.ScalarNode:
// Handle single string value
*ss = []string{node.Value}
return nil
case yaml.SequenceNode:
// Handle array of strings
var slice []string
if err := node.Decode(&slice); err != nil {
return err //nolint:wrapcheck
}
*ss = slice
return nil
default:
return fmt.Errorf("expected a string or a sequence of strings, but got %v", node.Kind)
}
}
// keyValuesField handles flexible YAML formats for key-value pairs.
// Supported formats:
// - Sequence of maps: [{key1: value1}, {key2: [value2, value3]}]
// - Single map: {key1: value1, key2: [value2, value3]}
//
// Values can be either a single string or an array of strings.
type keyValuesField []types.KeyValue[string, []string]
func (kv *keyValuesField) UnmarshalYAML(node *yaml.Node) error {
switch node.Kind {
case yaml.MappingNode:
// Handle single map: {key1: value1, key2: [value2]}
return kv.unmarshalMapping(node)
case yaml.SequenceNode:
// Handle sequence of maps: [{key1: value1}, {key2: value2}]
for _, item := range node.Content {
if item.Kind != yaml.MappingNode {
return fmt.Errorf("expected a mapping in sequence, but got %v", item.Kind)
}
if err := kv.unmarshalMapping(item); err != nil {
return err
}
}
return nil
default:
return fmt.Errorf("expected a mapping or sequence of mappings, but got %v", node.Kind)
}
}
func (kv *keyValuesField) unmarshalMapping(node *yaml.Node) error {
// MappingNode content is [key1, value1, key2, value2, ...]
for i := 0; i < len(node.Content); i += 2 {
keyNode := node.Content[i]
valueNode := node.Content[i+1]
if keyNode.Kind != yaml.ScalarNode {
return fmt.Errorf("expected a string key, but got %v", keyNode.Kind)
}
key := keyNode.Value
var values []string
switch valueNode.Kind {
case yaml.ScalarNode:
values = []string{valueNode.Value}
case yaml.SequenceNode:
for _, v := range valueNode.Content {
if v.Kind != yaml.ScalarNode {
return fmt.Errorf("expected string values in array for key %q, but got %v", key, v.Kind)
}
values = append(values, v.Value)
}
default:
return fmt.Errorf("expected a string or array of strings for key %q, but got %v", key, valueNode.Kind)
}
*kv = append(*kv, types.KeyValue[string, []string]{Key: key, Value: values})
}
return nil
}
type configYAML struct {
ConfigFiles stringOrSliceField `yaml:"configFile"`
Method stringOrSliceField `yaml:"method"`
URL *string `yaml:"url"`
Timeout *time.Duration `yaml:"timeout"`
Concurrency *uint `yaml:"concurrency"`
RequestCount *uint64 `yaml:"requests"`
Duration *time.Duration `yaml:"duration"`
Quiet *bool `yaml:"quiet"`
Output *string `yaml:"output"`
Insecure *bool `yaml:"insecure"`
ShowConfig *bool `yaml:"showConfig"`
DryRun *bool `yaml:"dryRun"`
Params keyValuesField `yaml:"params"`
Headers keyValuesField `yaml:"headers"`
Cookies keyValuesField `yaml:"cookies"`
Bodies stringOrSliceField `yaml:"body"`
Proxies stringOrSliceField `yaml:"proxy"`
Values stringOrSliceField `yaml:"values"`
}
// ParseYAML parses YAML config file arguments into a Config object.
// It can return the following errors:
// - types.UnmarshalError
// - types.FieldParseErrors
func (parser ConfigFileParser) ParseYAML(data []byte) (*Config, error) {
var (
config = &Config{}
parsedData = &configYAML{}
)
err := yaml.Unmarshal(data, &parsedData)
if err != nil {
return nil, types.NewUnmarshalError(err)
}
var fieldParseErrors []types.FieldParseError
config.Methods = append(config.Methods, parsedData.Method...)
config.Timeout = parsedData.Timeout
config.Concurrency = parsedData.Concurrency
config.Requests = parsedData.RequestCount
config.Duration = parsedData.Duration
config.ShowConfig = parsedData.ShowConfig
config.Quiet = parsedData.Quiet
if parsedData.Output != nil {
config.Output = common.ToPtr(ConfigOutputType(*parsedData.Output))
}
config.Insecure = parsedData.Insecure
config.DryRun = parsedData.DryRun
for _, kv := range parsedData.Params {
config.Params = append(config.Params, types.Param(kv))
}
for _, kv := range parsedData.Headers {
config.Headers = append(config.Headers, types.Header(kv))
}
for _, kv := range parsedData.Cookies {
config.Cookies = append(config.Cookies, types.Cookie(kv))
}
config.Bodies = append(config.Bodies, parsedData.Bodies...)
config.Values = append(config.Values, parsedData.Values...)
if len(parsedData.ConfigFiles) > 0 {
for _, configFile := range parsedData.ConfigFiles {
config.Files = append(config.Files, *types.ParseConfigFile(configFile))
}
}
if parsedData.URL != nil {
urlParsed, err := url.Parse(*parsedData.URL)
if err != nil {
fieldParseErrors = append(fieldParseErrors, types.NewFieldParseError("url", *parsedData.URL, err))
} else {
config.URL = urlParsed
}
}
for i, proxy := range parsedData.Proxies {
err := config.Proxies.Parse(proxy)
if err != nil {
fieldParseErrors = append(
fieldParseErrors,
types.NewFieldParseError(fmt.Sprintf("proxy[%d]", i), proxy, err),
)
}
}
if len(fieldParseErrors) > 0 {
return nil, types.NewFieldParseErrors(fieldParseErrors)
}
return config, nil
}

View File

@@ -0,0 +1,212 @@
package config
import (
"fmt"
"text/template"
"go.aykhans.me/sarin/internal/sarin"
"go.aykhans.me/sarin/internal/types"
)
func validateTemplateString(value string, funcMap template.FuncMap) error {
if value == "" {
return nil
}
_, err := template.New("").Funcs(funcMap).Parse(value)
if err != nil {
return fmt.Errorf("template parse error: %w", err)
}
return nil
}
func validateTemplateMethods(methods []string, funcMap template.FuncMap) []types.FieldValidationError {
var validationErrors []types.FieldValidationError
for i, method := range methods {
if err := validateTemplateString(method, funcMap); err != nil {
validationErrors = append(
validationErrors,
types.NewFieldValidationError(
fmt.Sprintf("Method[%d]", i),
method,
err,
),
)
}
}
return validationErrors
}
func validateTemplateParams(params types.Params, funcMap template.FuncMap) []types.FieldValidationError {
var validationErrors []types.FieldValidationError
for paramIndex, param := range params {
// Validate param key
if err := validateTemplateString(param.Key, funcMap); err != nil {
validationErrors = append(
validationErrors,
types.NewFieldValidationError(
fmt.Sprintf("Param[%d].Key", paramIndex),
param.Key,
err,
),
)
}
// Validate param values
for valueIndex, value := range param.Value {
if err := validateTemplateString(value, funcMap); err != nil {
validationErrors = append(
validationErrors,
types.NewFieldValidationError(
fmt.Sprintf("Param[%d].Value[%d]", paramIndex, valueIndex),
value,
err,
),
)
}
}
}
return validationErrors
}
func validateTemplateHeaders(headers types.Headers, funcMap template.FuncMap) []types.FieldValidationError {
var validationErrors []types.FieldValidationError
for headerIndex, header := range headers {
// Validate header key
if err := validateTemplateString(header.Key, funcMap); err != nil {
validationErrors = append(
validationErrors,
types.NewFieldValidationError(
fmt.Sprintf("Header[%d].Key", headerIndex),
header.Key,
err,
),
)
}
// Validate header values
for valueIndex, value := range header.Value {
if err := validateTemplateString(value, funcMap); err != nil {
validationErrors = append(
validationErrors,
types.NewFieldValidationError(
fmt.Sprintf("Header[%d].Value[%d]", headerIndex, valueIndex),
value,
err,
),
)
}
}
}
return validationErrors
}
func validateTemplateCookies(cookies types.Cookies, funcMap template.FuncMap) []types.FieldValidationError {
var validationErrors []types.FieldValidationError
for cookieIndex, cookie := range cookies {
// Validate cookie key
if err := validateTemplateString(cookie.Key, funcMap); err != nil {
validationErrors = append(
validationErrors,
types.NewFieldValidationError(
fmt.Sprintf("Cookie[%d].Key", cookieIndex),
cookie.Key,
err,
),
)
}
// Validate cookie values
for valueIndex, value := range cookie.Value {
if err := validateTemplateString(value, funcMap); err != nil {
validationErrors = append(
validationErrors,
types.NewFieldValidationError(
fmt.Sprintf("Cookie[%d].Value[%d]", cookieIndex, valueIndex),
value,
err,
),
)
}
}
}
return validationErrors
}
func validateTemplateBodies(bodies []string, funcMap template.FuncMap) []types.FieldValidationError {
var validationErrors []types.FieldValidationError
for i, body := range bodies {
if err := validateTemplateString(body, funcMap); err != nil {
validationErrors = append(
validationErrors,
types.NewFieldValidationError(
fmt.Sprintf("Body[%d]", i),
body,
err,
),
)
}
}
return validationErrors
}
func validateTemplateValues(values []string, funcMap template.FuncMap) []types.FieldValidationError {
var validationErrors []types.FieldValidationError
for i, value := range values {
if err := validateTemplateString(value, funcMap); err != nil {
validationErrors = append(
validationErrors,
types.NewFieldValidationError(
fmt.Sprintf("Values[%d]", i),
value,
err,
),
)
}
}
return validationErrors
}
func ValidateTemplates(config *Config) []types.FieldValidationError {
// Create template function map using the same functions as sarin package
randSource := sarin.NewDefaultRandSource()
funcMap := sarin.NewDefaultTemplateFuncMap(randSource)
bodyFuncMapData := &sarin.BodyTemplateFuncMapData{}
bodyFuncMap := sarin.NewDefaultBodyTemplateFuncMap(randSource, bodyFuncMapData)
var allErrors []types.FieldValidationError
// Validate methods
allErrors = append(allErrors, validateTemplateMethods(config.Methods, funcMap)...)
// Validate params
allErrors = append(allErrors, validateTemplateParams(config.Params, funcMap)...)
// Validate headers
allErrors = append(allErrors, validateTemplateHeaders(config.Headers, funcMap)...)
// Validate cookies
allErrors = append(allErrors, validateTemplateCookies(config.Cookies, funcMap)...)
// Validate bodies
allErrors = append(allErrors, validateTemplateBodies(config.Bodies, bodyFuncMap)...)
// Validate values
allErrors = append(allErrors, validateTemplateValues(config.Values, funcMap)...)
return allErrors
}

310
internal/sarin/client.go Normal file
View File

@@ -0,0 +1,310 @@
package sarin
import (
"bufio"
"context"
"crypto/tls"
"encoding/base64"
"errors"
"math"
"net"
"net/http"
"net/url"
"time"
"github.com/valyala/fasthttp"
"github.com/valyala/fasthttp/fasthttpproxy"
"go.aykhans.me/sarin/internal/types"
utilsSlice "go.aykhans.me/utils/slice"
"golang.org/x/net/proxy"
)
type HostClientGenerator func() *fasthttp.HostClient
func safeUintToInt(u uint) int {
if u > math.MaxInt {
return math.MaxInt
}
return int(u)
}
// NewHostClients creates a list of fasthttp.HostClient instances for the given proxies.
// If no proxies are provided, a single client without a proxy is returned.
// It can return the following errors:
// - types.ProxyDialError
func NewHostClients(
ctx context.Context,
timeout time.Duration,
proxies []url.URL,
maxConns uint,
requestURL *url.URL,
skipVerify bool,
) ([]*fasthttp.HostClient, error) {
isTLS := requestURL.Scheme == "https"
if proxiesLen := len(proxies); proxiesLen > 0 {
clients := make([]*fasthttp.HostClient, 0, proxiesLen)
addr := requestURL.Host
if isTLS && requestURL.Port() == "" {
addr += ":443"
}
for _, proxy := range proxies {
dialFunc, err := NewProxyDialFunc(ctx, &proxy, timeout)
if err != nil {
return nil, types.NewProxyDialError(proxy.String(), err)
}
clients = append(clients, &fasthttp.HostClient{
MaxConns: safeUintToInt(maxConns),
IsTLS: isTLS,
TLSConfig: &tls.Config{
InsecureSkipVerify: skipVerify, //nolint:gosec
},
Addr: addr,
Dial: dialFunc,
MaxIdleConnDuration: timeout,
MaxConnDuration: timeout,
WriteTimeout: timeout,
ReadTimeout: timeout,
DisableHeaderNamesNormalizing: true,
DisablePathNormalizing: true,
NoDefaultUserAgentHeader: true,
},
)
}
return clients, nil
}
client := &fasthttp.HostClient{
MaxConns: safeUintToInt(maxConns),
IsTLS: isTLS,
TLSConfig: &tls.Config{
InsecureSkipVerify: skipVerify, //nolint:gosec
},
Addr: requestURL.Host,
MaxIdleConnDuration: timeout,
MaxConnDuration: timeout,
WriteTimeout: timeout,
ReadTimeout: timeout,
DisableHeaderNamesNormalizing: true,
DisablePathNormalizing: true,
NoDefaultUserAgentHeader: true,
}
return []*fasthttp.HostClient{client}, nil
}
func NewProxyDialFunc(ctx context.Context, proxyURL *url.URL, timeout time.Duration) (fasthttp.DialFunc, error) {
var (
dialer fasthttp.DialFunc
err error
)
switch proxyURL.Scheme {
case "socks5":
dialer, err = fasthttpSocksDialerDualStackTimeout(ctx, proxyURL, timeout, true)
if err != nil {
return nil, err
}
case "socks5h":
dialer, err = fasthttpSocksDialerDualStackTimeout(ctx, proxyURL, timeout, false)
if err != nil {
return nil, err
}
case "http":
dialer = fasthttpproxy.FasthttpHTTPDialerDualStackTimeout(proxyURL.String(), timeout)
case "https":
dialer = fasthttpHTTPSDialerDualStackTimeout(proxyURL, timeout)
default:
return nil, errors.New("unsupported proxy scheme")
}
if dialer == nil {
return nil, errors.New("internal error: proxy dialer is nil")
}
return dialer, nil
}
func fasthttpSocksDialerDualStackTimeout(ctx context.Context, proxyURL *url.URL, timeout time.Duration, resolveLocally bool) (fasthttp.DialFunc, error) {
netDialer := &net.Dialer{}
// Parse auth from proxy URL if present
var auth *proxy.Auth
if proxyURL.User != nil {
auth = &proxy.Auth{
User: proxyURL.User.Username(),
}
if password, ok := proxyURL.User.Password(); ok {
auth.Password = password
}
}
// Create SOCKS5 dialer with net.Dialer as forward dialer
socksDialer, err := proxy.SOCKS5("tcp", proxyURL.Host, auth, netDialer)
if err != nil {
return nil, err
}
// Assert to ContextDialer for timeout support
contextDialer, ok := socksDialer.(proxy.ContextDialer)
if !ok {
// Fallback without timeout (should not happen with net.Dialer)
return func(addr string) (net.Conn, error) {
return socksDialer.Dial("tcp", addr)
}, nil
}
// Return dial function that uses context with timeout
return func(addr string) (net.Conn, error) {
deadline := time.Now().Add(timeout)
if resolveLocally {
host, port, err := net.SplitHostPort(addr)
if err != nil {
return nil, err
}
// Cap DNS resolution to half the timeout to reserve time for dial
dnsCtx, dnsCancel := context.WithTimeout(ctx, timeout)
ips, err := net.DefaultResolver.LookupIP(dnsCtx, "ip", host)
dnsCancel()
if err != nil {
return nil, err
}
if len(ips) == 0 {
return nil, errors.New("no IP addresses found for host: " + host)
}
// Use the first resolved IP
addr = net.JoinHostPort(ips[0].String(), port)
}
// Use remaining time for dial
remaining := time.Until(deadline)
if remaining <= 0 {
return nil, context.DeadlineExceeded
}
dialCtx, dialCancel := context.WithTimeout(ctx, remaining)
defer dialCancel()
return contextDialer.DialContext(dialCtx, "tcp", addr)
}, nil
}
func fasthttpHTTPSDialerDualStackTimeout(proxyURL *url.URL, timeout time.Duration) fasthttp.DialFunc {
proxyAddr := proxyURL.Host
if proxyURL.Port() == "" {
proxyAddr = net.JoinHostPort(proxyURL.Hostname(), "443")
}
// Build Proxy-Authorization header if auth is present
var proxyAuth string
if proxyURL.User != nil {
username := proxyURL.User.Username()
password, _ := proxyURL.User.Password()
credentials := username + ":" + password
proxyAuth = "Basic " + base64.StdEncoding.EncodeToString([]byte(credentials))
}
return func(addr string) (net.Conn, error) {
// Establish TCP connection to proxy with timeout
start := time.Now()
conn, err := fasthttp.DialDualStackTimeout(proxyAddr, timeout)
if err != nil {
return nil, err
}
remaining := timeout - time.Since(start)
if remaining <= 0 {
conn.Close() //nolint:errcheck,gosec
return nil, context.DeadlineExceeded
}
// Set deadline for the TLS handshake and CONNECT request
if err := conn.SetDeadline(time.Now().Add(remaining)); err != nil {
conn.Close() //nolint:errcheck,gosec
return nil, err
}
// Upgrade to TLS
tlsConn := tls.Client(conn, &tls.Config{ //nolint:gosec
ServerName: proxyURL.Hostname(),
})
if err := tlsConn.Handshake(); err != nil {
tlsConn.Close() //nolint:errcheck,gosec
return nil, err
}
// Build and send CONNECT request
connectReq := &http.Request{
Method: http.MethodConnect,
URL: &url.URL{Opaque: addr},
Host: addr,
Header: make(http.Header),
}
if proxyAuth != "" {
connectReq.Header.Set("Proxy-Authorization", proxyAuth)
}
if err := connectReq.Write(tlsConn); err != nil {
tlsConn.Close() //nolint:errcheck,gosec
return nil, err
}
// Read response using buffered reader, but return wrapped connection
// to preserve any buffered data
bufReader := bufio.NewReader(tlsConn)
resp, err := http.ReadResponse(bufReader, connectReq)
if err != nil {
tlsConn.Close() //nolint:errcheck,gosec
return nil, err
}
resp.Body.Close() //nolint:errcheck,gosec
if resp.StatusCode != http.StatusOK {
tlsConn.Close() //nolint:errcheck,gosec
return nil, errors.New("proxy CONNECT failed: " + resp.Status)
}
// Clear deadline for the tunneled connection
if err := tlsConn.SetDeadline(time.Time{}); err != nil {
tlsConn.Close() //nolint:errcheck,gosec
return nil, err
}
// Return wrapped connection that uses the buffered reader
// to avoid losing any data that was read ahead
return &bufferedConn{Conn: tlsConn, reader: bufReader}, nil
}
}
// bufferedConn wraps a net.Conn with a buffered reader to preserve
// any data that was read during HTTP response parsing.
type bufferedConn struct {
net.Conn
reader *bufio.Reader
}
func (c *bufferedConn) Read(b []byte) (int, error) {
return c.reader.Read(b)
}
func NewHostClientGenerator(clients ...*fasthttp.HostClient) HostClientGenerator {
switch len(clients) {
case 0:
hostClient := &fasthttp.HostClient{}
return func() *fasthttp.HostClient {
return hostClient
}
case 1:
return func() *fasthttp.HostClient {
return clients[0]
}
default:
return utilsSlice.RandomCycle(nil, clients...)
}
}

14
internal/sarin/helpers.go Normal file
View File

@@ -0,0 +1,14 @@
package sarin
import (
"math/rand/v2"
"time"
)
func NewDefaultRandSource() rand.Source {
now := time.Now().UnixNano()
return rand.NewPCG(
uint64(now), //nolint:gosec // G115: Safe conversion; UnixNano timestamp used as random seed, bit pattern is intentional
uint64(now>>32), //nolint:gosec // G115: Safe conversion; right-shifted timestamp for seed entropy, overflow is acceptable
)
}

336
internal/sarin/request.go Normal file
View File

@@ -0,0 +1,336 @@
package sarin
import (
"bytes"
"fmt"
"maps"
"math/rand/v2"
"net/url"
"strings"
"text/template"
"github.com/joho/godotenv"
"github.com/valyala/fasthttp"
"go.aykhans.me/sarin/internal/types"
utilsSlice "go.aykhans.me/utils/slice"
)
type RequestGenerator func(*fasthttp.Request) error
type RequestGeneratorWithData func(*fasthttp.Request, any) error
type valuesData struct {
Values map[string]string
}
// NewRequestGenerator creates a new RequestGenerator function that generates HTTP requests
// with the specified configuration. The returned RequestGenerator is NOT safe for concurrent
// use by multiple goroutines.
func NewRequestGenerator(
methods []string,
requestURL *url.URL,
params types.Params,
headers types.Headers,
cookies types.Cookies,
bodies []string,
values []string,
) (RequestGenerator, bool) {
randSource := NewDefaultRandSource()
//nolint:gosec // G404: Using non-cryptographic rand for load testing, not security
localRand := rand.New(randSource)
templateFuncMap := NewDefaultTemplateFuncMap(randSource)
methodGenerator, isMethodGeneratorDynamic := NewMethodGeneratorFunc(localRand, methods, templateFuncMap)
paramsGenerator, isParamsGeneratorDynamic := NewParamsGeneratorFunc(localRand, params, templateFuncMap)
headersGenerator, isHeadersGeneratorDynamic := NewHeadersGeneratorFunc(localRand, headers, templateFuncMap)
cookiesGenerator, isCookiesGeneratorDynamic := NewCookiesGeneratorFunc(localRand, cookies, templateFuncMap)
bodyTemplateFuncMapData := &BodyTemplateFuncMapData{}
bodyTemplateFuncMap := NewDefaultBodyTemplateFuncMap(randSource, bodyTemplateFuncMapData)
bodyGenerator, isBodyGeneratorDynamic := NewBodyGeneratorFunc(localRand, bodies, bodyTemplateFuncMap)
valuesGenerator := NewValuesGeneratorFunc(values, templateFuncMap)
return func(req *fasthttp.Request) error {
req.SetRequestURI(requestURL.Path)
req.Header.SetHost(requestURL.Host)
data, err := valuesGenerator()
if err != nil {
return err
}
if err := methodGenerator(req, data); err != nil {
return err
}
bodyTemplateFuncMapData.ClearFormDataContenType()
if err := bodyGenerator(req, data); err != nil {
return err
}
if err := headersGenerator(req, data); err != nil {
return err
}
if bodyTemplateFuncMapData.GetFormDataContenType() != "" {
req.Header.Add("Content-Type", bodyTemplateFuncMapData.GetFormDataContenType())
}
if err := paramsGenerator(req, data); err != nil {
return err
}
if err := cookiesGenerator(req, data); err != nil {
return err
}
if requestURL.Scheme == "https" {
req.URI().SetScheme("https")
}
return nil
}, isMethodGeneratorDynamic ||
isParamsGeneratorDynamic ||
isHeadersGeneratorDynamic ||
isCookiesGeneratorDynamic ||
isBodyGeneratorDynamic
}
func NewMethodGeneratorFunc(localRand *rand.Rand, methods []string, templateFunctions template.FuncMap) (RequestGeneratorWithData, bool) {
methodGenerator, isDynamic := buildStringSliceGenerator(localRand, methods, templateFunctions)
var (
method string
err error
)
return func(req *fasthttp.Request, data any) error {
method, err = methodGenerator()(data)
if err != nil {
return err
}
req.Header.SetMethod(method)
return nil
}, isDynamic
}
func NewBodyGeneratorFunc(localRand *rand.Rand, bodies []string, templateFunctions template.FuncMap) (RequestGeneratorWithData, bool) {
bodyGenerator, isDynamic := buildStringSliceGenerator(localRand, bodies, templateFunctions)
var (
body string
err error
)
return func(req *fasthttp.Request, data any) error {
body, err = bodyGenerator()(data)
if err != nil {
return err
}
req.SetBody([]byte(body))
return nil
}, isDynamic
}
func NewParamsGeneratorFunc(localRand *rand.Rand, params types.Params, templateFunctions template.FuncMap) (RequestGeneratorWithData, bool) {
generators, isDynamic := buildKeyValueGenerators(localRand, params, templateFunctions)
var (
key, value string
err error
)
return func(req *fasthttp.Request, data any) error {
for _, gen := range generators {
key, err = gen.Key(data)
if err != nil {
return err
}
value, err = gen.Value()(data)
if err != nil {
return err
}
req.URI().QueryArgs().Add(key, value)
}
return nil
}, isDynamic
}
func NewHeadersGeneratorFunc(localRand *rand.Rand, headers types.Headers, templateFunctions template.FuncMap) (RequestGeneratorWithData, bool) {
generators, isDynamic := buildKeyValueGenerators(localRand, headers, templateFunctions)
var (
key, value string
err error
)
return func(req *fasthttp.Request, data any) error {
for _, gen := range generators {
key, err = gen.Key(data)
if err != nil {
return err
}
value, err = gen.Value()(data)
if err != nil {
return err
}
req.Header.Add(key, value)
}
return nil
}, isDynamic
}
func NewCookiesGeneratorFunc(localRand *rand.Rand, cookies types.Cookies, templateFunctions template.FuncMap) (RequestGeneratorWithData, bool) {
generators, isDynamic := buildKeyValueGenerators(localRand, cookies, templateFunctions)
var (
key, value string
err error
)
if len(generators) > 0 {
return func(req *fasthttp.Request, data any) error {
cookieStrings := make([]string, 0, len(generators))
for _, gen := range generators {
key, err = gen.Key(data)
if err != nil {
return err
}
value, err = gen.Value()(data)
if err != nil {
return err
}
cookieStrings = append(cookieStrings, key+"="+value)
}
req.Header.Add("Cookie", strings.Join(cookieStrings, "; "))
return nil
}, isDynamic
}
return func(req *fasthttp.Request, data any) error {
return nil
}, isDynamic
}
func NewValuesGeneratorFunc(values []string, templateFunctions template.FuncMap) func() (valuesData, error) {
generators := make([]func(_ any) (string, error), len(values))
for i, v := range values {
generators[i], _ = createTemplateFunc(v, templateFunctions)
}
var (
rendered string
data map[string]string
err error
)
return func() (valuesData, error) {
result := make(map[string]string)
for _, generator := range generators {
rendered, err = generator(nil)
if err != nil {
return valuesData{}, fmt.Errorf("values rendering: %w", err)
}
data, err = godotenv.Unmarshal(rendered)
if err != nil {
return valuesData{}, fmt.Errorf("values rendering: %w", err)
}
maps.Copy(result, data)
}
return valuesData{Values: result}, nil
}
}
func createTemplateFunc(value string, templateFunctions template.FuncMap) (func(data any) (string, error), bool) {
tmpl, err := template.New("").Funcs(templateFunctions).Parse(value)
if err == nil && hasTemplateActions(tmpl) {
var err error
return func(data any) (string, error) {
var buf bytes.Buffer
if err = tmpl.Execute(&buf, data); err != nil {
return "", fmt.Errorf("template rendering: %w", err)
}
return buf.String(), nil
}, true
}
return func(_ any) (string, error) { return value, nil }, false
}
type keyValueGenerator struct {
Key func(data any) (string, error)
Value func() func(data any) (string, error)
}
type keyValueItem interface {
types.Param | types.Header | types.Cookie
}
func buildKeyValueGenerators[T keyValueItem](
localRand *rand.Rand,
items []T,
templateFunctions template.FuncMap,
) ([]keyValueGenerator, bool) {
isDynamic := false
generators := make([]keyValueGenerator, len(items))
for generatorIndex, item := range items {
// Convert to KeyValue to access fields
keyValue := types.KeyValue[string, []string](item)
// Generate key function
keyFunc, keyIsDynamic := createTemplateFunc(keyValue.Key, templateFunctions)
if keyIsDynamic {
isDynamic = true
}
// Generate value functions
valueFuncs := make([]func(data any) (string, error), len(keyValue.Value))
for j, v := range keyValue.Value {
valueFunc, valueIsDynamic := createTemplateFunc(v, templateFunctions)
if valueIsDynamic {
isDynamic = true
}
valueFuncs[j] = valueFunc
}
generators[generatorIndex] = keyValueGenerator{
Key: keyFunc,
Value: utilsSlice.RandomCycle(localRand, valueFuncs...),
}
if len(keyValue.Value) > 1 {
isDynamic = true
}
}
return generators, isDynamic
}
func buildStringSliceGenerator(
localRand *rand.Rand,
values []string,
templateFunctions template.FuncMap,
) (func() func(data any) (string, error), bool) {
// Return a function that returns an empty string generator if values is empty
if len(values) == 0 {
emptyFunc := func(_ any) (string, error) { return "", nil }
return func() func(_ any) (string, error) { return emptyFunc }, false
}
isDynamic := len(values) > 1
valueFuncs := make([]func(data any) (string, error), len(values))
for i, value := range values {
valueFunc, valueIsDynamic := createTemplateFunc(value, templateFunctions)
if valueIsDynamic {
isDynamic = true
}
valueFuncs[i] = valueFunc
}
return utilsSlice.RandomCycle(localRand, valueFuncs...), isDynamic
}

348
internal/sarin/response.go Normal file
View File

@@ -0,0 +1,348 @@
package sarin
import (
"encoding/json"
"fmt"
"math/big"
"os"
"slices"
"strings"
"sync"
"time"
"github.com/charmbracelet/lipgloss"
"github.com/charmbracelet/lipgloss/table"
"go.yaml.in/yaml/v4"
)
const DefaultResponseDurationAccuracy uint32 = 1
const DefaultResponseColumnMaxWidth = 50
// Duration wraps time.Duration to provide consistent JSON/YAML marshaling as human-readable strings.
type Duration time.Duration
func (d Duration) MarshalJSON() ([]byte, error) {
//nolint:wrapcheck
return json.Marshal(time.Duration(d).String())
}
func (d Duration) MarshalYAML() (any, error) {
return time.Duration(d).String(), nil
}
func (d Duration) String() string {
dur := time.Duration(d)
switch {
case dur >= time.Second:
return dur.Round(time.Millisecond).String()
case dur >= time.Millisecond:
return dur.Round(time.Microsecond).String()
default:
return dur.String()
}
}
// BigInt wraps big.Int to provide consistent JSON/YAML marshaling as numbers.
type BigInt struct {
*big.Int
}
func (b BigInt) MarshalJSON() ([]byte, error) {
return []byte(b.Int.String()), nil
}
func (b BigInt) MarshalYAML() (any, error) {
return &yaml.Node{
Kind: yaml.ScalarNode,
Tag: "!!int",
Value: b.Int.String(),
}, nil
}
func (b BigInt) String() string {
return b.Int.String()
}
type Response struct {
durations map[time.Duration]uint64
}
type SarinResponseData struct {
sync.Mutex
Responses map[string]*Response
// accuracy is the time bucket size in nanoseconds for storing response durations.
// Larger values (e.g., 1000) save memory but reduce accuracy by grouping more durations together.
// Smaller values (e.g., 10) improve accuracy but increase memory usage.
// Minimum value is 1 (most accurate, highest memory usage).
// Default value is 1.
accuracy time.Duration
}
func NewSarinResponseData(accuracy uint32) *SarinResponseData {
if accuracy == 0 {
accuracy = DefaultResponseDurationAccuracy
}
return &SarinResponseData{
Responses: make(map[string]*Response),
accuracy: time.Duration(accuracy),
}
}
func (data *SarinResponseData) Add(responseKey string, responseTime time.Duration) {
data.Lock()
defer data.Unlock()
response, ok := data.Responses[responseKey]
if !ok {
data.Responses[responseKey] = &Response{
durations: map[time.Duration]uint64{
responseTime / data.accuracy: 1,
},
}
} else {
response.durations[responseTime/data.accuracy]++
}
}
func (data *SarinResponseData) PrintTable() {
data.Lock()
defer data.Unlock()
output := data.prepareOutputData()
headerStyle := lipgloss.NewStyle().
Bold(true).
Foreground(lipgloss.Color("246")).
Padding(0, 1)
cellStyle := lipgloss.NewStyle().
Padding(0, 1)
rows := make([][]string, 0, len(output.Responses)+1)
for key, stats := range output.Responses {
rows = append(rows, []string{
wrapText(key, DefaultResponseColumnMaxWidth),
stats.Count.String(),
stats.Min.String(),
stats.Max.String(),
stats.Average.String(),
stats.P90.String(),
stats.P95.String(),
stats.P99.String(),
})
}
rows = append(rows, []string{
"Total",
output.Total.Count.String(),
output.Total.Min.String(),
output.Total.Max.String(),
output.Total.Average.String(),
output.Total.P90.String(),
output.Total.P95.String(),
output.Total.P99.String(),
})
tbl := table.New().
Border(lipgloss.NormalBorder()).
BorderStyle(lipgloss.NewStyle().Foreground(lipgloss.Color("240"))).
BorderRow(true).
Headers("Response", "Count", "Min", "Max", "Average", "P90", "P95", "P99").
Rows(rows...).
StyleFunc(func(row, col int) lipgloss.Style {
if row == table.HeaderRow {
return headerStyle
}
return cellStyle
})
fmt.Println(tbl)
}
func (data *SarinResponseData) PrintJSON() {
data.Lock()
defer data.Unlock()
output := data.prepareOutputData()
encoder := json.NewEncoder(os.Stdout)
encoder.SetIndent("", " ")
if err := encoder.Encode(output); err != nil {
panic(err)
}
}
func (data *SarinResponseData) PrintYAML() {
data.Lock()
defer data.Unlock()
output := data.prepareOutputData()
encoder := yaml.NewEncoder(os.Stdout)
encoder.SetIndent(2)
if err := encoder.Encode(output); err != nil {
panic(err)
}
}
type responseStat struct {
Count BigInt `json:"count" yaml:"count"`
Min Duration `json:"min" yaml:"min"`
Max Duration `json:"max" yaml:"max"`
Average Duration `json:"average" yaml:"average"`
P90 Duration `json:"p90" yaml:"p90"`
P95 Duration `json:"p95" yaml:"p95"`
P99 Duration `json:"p99" yaml:"p99"`
}
type responseStats map[string]responseStat
type outputData struct {
Responses map[string]responseStat `json:"responses" yaml:"responses"`
Total responseStat `json:"total" yaml:"total"`
}
func (data *SarinResponseData) prepareOutputData() outputData {
switch len(data.Responses) {
case 0:
return outputData{
Responses: make(map[string]responseStat),
Total: responseStat{},
}
case 1:
var (
responseKey string
stats responseStat
)
for key, response := range data.Responses {
stats = calculateStats(response.durations, data.accuracy)
responseKey = key
}
return outputData{
Responses: responseStats{
responseKey: stats,
},
Total: stats,
}
default:
// Calculate stats for each response
allStats := make(responseStats)
var totalDurations = make(map[time.Duration]uint64)
for key, response := range data.Responses {
stats := calculateStats(response.durations, data.accuracy)
allStats[key] = stats
// Aggregate for total row
for duration, count := range response.durations {
totalDurations[duration] += count
}
}
return outputData{
Responses: allStats,
Total: calculateStats(totalDurations, data.accuracy),
}
}
}
func calculateStats(durations map[time.Duration]uint64, accuracy time.Duration) responseStat {
if len(durations) == 0 {
return responseStat{}
}
// Extract and sort unique durations
sortedDurations := make([]time.Duration, 0, len(durations))
for duration := range durations {
sortedDurations = append(sortedDurations, duration)
}
slices.Sort(sortedDurations)
sum := new(big.Int)
totalCount := new(big.Int)
minDuration := sortedDurations[0] * accuracy
maxDuration := sortedDurations[len(sortedDurations)-1] * accuracy
for _, duration := range sortedDurations {
actualDuration := duration * accuracy
count := durations[duration]
totalCount.Add(
totalCount,
new(big.Int).SetUint64(count),
)
sum.Add(
sum,
new(big.Int).Mul(
new(big.Int).SetInt64(int64(actualDuration)),
new(big.Int).SetUint64(count),
),
)
}
// Calculate percentiles
p90 := calculatePercentile(sortedDurations, durations, totalCount, 90, accuracy)
p95 := calculatePercentile(sortedDurations, durations, totalCount, 95, accuracy)
p99 := calculatePercentile(sortedDurations, durations, totalCount, 99, accuracy)
return responseStat{
Count: BigInt{totalCount},
Min: Duration(minDuration),
Max: Duration(maxDuration),
Average: Duration(div(sum, totalCount).Int64()),
P90: p90,
P95: p95,
P99: p99,
}
}
func calculatePercentile(sortedDurations []time.Duration, durations map[time.Duration]uint64, totalCount *big.Int, percentile int, accuracy time.Duration) Duration {
// Calculate the target position for the percentile
// Using ceiling method: position = ceil(totalCount * percentile / 100)
target := new(big.Int).Mul(totalCount, big.NewInt(int64(percentile)))
target.Add(target, big.NewInt(99)) // Add 99 to achieve ceiling division by 100
target.Div(target, big.NewInt(100))
// Accumulate counts until we reach the target position
cumulative := new(big.Int)
for _, duration := range sortedDurations {
count := durations[duration]
cumulative.Add(cumulative, new(big.Int).SetUint64(count))
if cumulative.Cmp(target) >= 0 {
return Duration(duration * accuracy)
}
}
// Fallback to the last duration (shouldn't happen with valid data)
return Duration(sortedDurations[len(sortedDurations)-1] * accuracy)
}
// div performs division with rounding to the nearest integer.
func div(x, y *big.Int) *big.Int {
quotient, remainder := new(big.Int).DivMod(x, y, new(big.Int))
if remainder.Mul(remainder, big.NewInt(2)).Cmp(y) >= 0 {
quotient.Add(quotient, big.NewInt(1))
}
return quotient
}
// wrapText wraps a string to multiple lines if it exceeds maxWidth.
func wrapText(s string, maxWidth int) string {
if len(s) <= maxWidth {
return s
}
var lines []string
for len(s) > maxWidth {
lines = append(lines, s[:maxWidth])
s = s[maxWidth:]
}
if len(s) > 0 {
lines = append(lines, s)
}
return strings.Join(lines, "\n")
}

776
internal/sarin/sarin.go Normal file
View File

@@ -0,0 +1,776 @@
package sarin
import (
"context"
"net/url"
"strconv"
"strings"
"sync"
"sync/atomic"
"time"
"github.com/charmbracelet/bubbles/progress"
"github.com/charmbracelet/bubbles/spinner"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
"github.com/valyala/fasthttp"
"go.aykhans.me/sarin/internal/types"
)
type runtimeMessageLevel uint8
const (
runtimeMessageLevelWarning runtimeMessageLevel = iota
runtimeMessageLevelError
)
type runtimeMessage struct {
timestamp time.Time
level runtimeMessageLevel
text string
}
type messageSender func(level runtimeMessageLevel, text string)
type sarin struct {
workers uint
requestURL *url.URL
methods []string
params types.Params
headers types.Headers
cookies types.Cookies
bodies []string
totalRequests *uint64
totalDuration *time.Duration
timeout time.Duration
quiet bool
skipCertVerify bool
values []string
collectStats bool
dryRun bool
hostClients []*fasthttp.HostClient
responses *SarinResponseData
}
// NewSarin creates a new sarin instance for load testing.
// It can return the following errors:
// - types.ProxyDialError
func NewSarin(
ctx context.Context,
methods []string,
requestURL *url.URL,
timeout time.Duration,
workers uint,
totalRequests *uint64,
totalDuration *time.Duration,
quiet bool,
skipCertVerify bool,
params types.Params,
headers types.Headers,
cookies types.Cookies,
bodies []string,
proxies types.Proxies,
values []string,
collectStats bool,
dryRun bool,
) (*sarin, error) {
if workers == 0 {
workers = 1
}
hostClients, err := newHostClients(ctx, timeout, proxies, workers, requestURL, skipCertVerify)
if err != nil {
return nil, err
}
srn := &sarin{
workers: workers,
requestURL: requestURL,
methods: methods,
params: params,
headers: headers,
cookies: cookies,
bodies: bodies,
totalRequests: totalRequests,
totalDuration: totalDuration,
timeout: timeout,
quiet: quiet,
skipCertVerify: skipCertVerify,
values: values,
collectStats: collectStats,
dryRun: dryRun,
hostClients: hostClients,
}
if collectStats {
srn.responses = NewSarinResponseData(uint32(100))
}
return srn, nil
}
func (q sarin) GetResponses() *SarinResponseData {
return q.responses
}
func (q sarin) Start(ctx context.Context) {
jobsCtx, jobsCancel := context.WithCancel(ctx)
var workersWG sync.WaitGroup
jobsCh := make(chan struct{}, max(q.workers, 1))
var counter atomic.Uint64
totalRequests := uint64(0)
if q.totalRequests != nil {
totalRequests = *q.totalRequests
}
var streamCtx context.Context
var streamCancel context.CancelFunc
var streamCh chan struct{}
var messageChannel chan runtimeMessage
var sendMessage messageSender
if q.quiet {
sendMessage = func(level runtimeMessageLevel, text string) {}
} else {
streamCtx, streamCancel = context.WithCancel(context.Background())
defer streamCancel()
streamCh = make(chan struct{})
messageChannel = make(chan runtimeMessage, max(q.workers, 1))
sendMessage = func(level runtimeMessageLevel, text string) {
messageChannel <- runtimeMessage{
timestamp: time.Now(),
level: level,
text: text,
}
}
}
// Start workers
q.startWorkers(&workersWG, jobsCh, q.hostClients, &counter, sendMessage)
if !q.quiet {
// Start streaming to terminal
//nolint:contextcheck // streamCtx must remain active until all workers complete to ensure all collected data is streamed
go q.streamProgress(streamCtx, jobsCancel, streamCh, totalRequests, &counter, messageChannel)
}
// Setup duration-based cancellation
q.setupDurationTimeout(ctx, jobsCancel)
// Distribute jobs to workers.
// This blocks until all jobs are sent or the context is canceled.
q.sendJobs(jobsCtx, jobsCh)
// Close the jobs channel so workers stop after completing their current job
close(jobsCh)
// Wait until all workers stopped
workersWG.Wait()
if messageChannel != nil {
close(messageChannel)
}
if !q.quiet {
// Stop the progress streaming
streamCancel()
// Wait until progress streaming has completely stopped
<-streamCh
}
}
func (q sarin) Worker(
jobs <-chan struct{},
hostClientGenerator HostClientGenerator,
counter *atomic.Uint64,
sendMessage messageSender,
) {
req := fasthttp.AcquireRequest()
resp := fasthttp.AcquireResponse()
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)
if q.dryRun {
switch {
case q.collectStats && isDynamic:
q.workerDryRunStatsWithDynamic(jobs, req, requestGenerator, counter, sendMessage)
case q.collectStats && !isDynamic:
q.workerDryRunStatsWithStatic(jobs, req, requestGenerator, counter, sendMessage)
case !q.collectStats && isDynamic:
q.workerDryRunNoStatsWithDynamic(jobs, req, requestGenerator, counter, sendMessage)
default:
q.workerDryRunNoStatsWithStatic(jobs, req, requestGenerator, counter, sendMessage)
}
} else {
switch {
case q.collectStats && isDynamic:
q.workerStatsWithDynamic(jobs, req, resp, requestGenerator, hostClientGenerator, counter, sendMessage)
case q.collectStats && !isDynamic:
q.workerStatsWithStatic(jobs, req, resp, requestGenerator, hostClientGenerator, counter, sendMessage)
case !q.collectStats && isDynamic:
q.workerNoStatsWithDynamic(jobs, req, resp, requestGenerator, hostClientGenerator, counter, sendMessage)
default:
q.workerNoStatsWithStatic(jobs, req, resp, requestGenerator, hostClientGenerator, counter, sendMessage)
}
}
}
func (q sarin) workerStatsWithDynamic(
jobs <-chan struct{},
req *fasthttp.Request,
resp *fasthttp.Response,
requestGenerator RequestGenerator,
hostClientGenerator HostClientGenerator,
counter *atomic.Uint64,
sendMessage messageSender,
) {
for range jobs {
req.Reset()
resp.Reset()
if err := requestGenerator(req); err != nil {
q.responses.Add(err.Error(), 0)
sendMessage(runtimeMessageLevelError, err.Error())
counter.Add(1)
continue
}
startTime := time.Now()
err := hostClientGenerator().DoTimeout(req, resp, q.timeout)
if err != nil {
q.responses.Add(err.Error(), time.Since(startTime))
} else {
q.responses.Add(statusCodeToString(resp.StatusCode()), time.Since(startTime))
}
counter.Add(1)
}
}
func (q sarin) workerStatsWithStatic(
jobs <-chan struct{},
req *fasthttp.Request,
resp *fasthttp.Response,
requestGenerator RequestGenerator,
hostClientGenerator HostClientGenerator,
counter *atomic.Uint64,
sendMessage messageSender,
) {
if err := requestGenerator(req); err != nil {
// Static request generation failed - record all jobs as errors
for range jobs {
q.responses.Add(err.Error(), 0)
sendMessage(runtimeMessageLevelError, err.Error())
counter.Add(1)
}
return
}
for range jobs {
resp.Reset()
startTime := time.Now()
err := hostClientGenerator().DoTimeout(req, resp, q.timeout)
if err != nil {
q.responses.Add(err.Error(), time.Since(startTime))
} else {
q.responses.Add(statusCodeToString(resp.StatusCode()), time.Since(startTime))
}
counter.Add(1)
}
}
func (q sarin) workerNoStatsWithDynamic(
jobs <-chan struct{},
req *fasthttp.Request,
resp *fasthttp.Response,
requestGenerator RequestGenerator,
hostClientGenerator HostClientGenerator,
counter *atomic.Uint64,
sendMessage messageSender,
) {
for range jobs {
req.Reset()
resp.Reset()
if err := requestGenerator(req); err != nil {
sendMessage(runtimeMessageLevelError, err.Error())
counter.Add(1)
continue
}
_ = hostClientGenerator().DoTimeout(req, resp, q.timeout)
counter.Add(1)
}
}
func (q sarin) workerNoStatsWithStatic(
jobs <-chan struct{},
req *fasthttp.Request,
resp *fasthttp.Response,
requestGenerator RequestGenerator,
hostClientGenerator HostClientGenerator,
counter *atomic.Uint64,
sendMessage messageSender,
) {
if err := requestGenerator(req); err != nil {
sendMessage(runtimeMessageLevelError, err.Error())
// Static request generation failed - just count the jobs without sending
for range jobs {
counter.Add(1)
}
return
}
for range jobs {
resp.Reset()
_ = hostClientGenerator().DoTimeout(req, resp, q.timeout)
counter.Add(1)
}
}
const dryRunResponseKey = "dry-run"
// statusCodeStrings contains pre-computed string representations for HTTP status codes 100-599.
var statusCodeStrings = func() map[int]string {
m := make(map[int]string, 500)
for i := 100; i < 600; i++ {
m[i] = strconv.Itoa(i)
}
return m
}()
// statusCodeToString returns a string representation of the HTTP status code.
// Uses a pre-computed map for codes 100-599, falls back to strconv.Itoa for others.
func statusCodeToString(code int) string {
if s, ok := statusCodeStrings[code]; ok {
return s
}
return strconv.Itoa(code)
}
func (q sarin) workerDryRunStatsWithDynamic(
jobs <-chan struct{},
req *fasthttp.Request,
requestGenerator RequestGenerator,
counter *atomic.Uint64,
sendMessage messageSender,
) {
for range jobs {
req.Reset()
startTime := time.Now()
if err := requestGenerator(req); err != nil {
q.responses.Add(err.Error(), time.Since(startTime))
sendMessage(runtimeMessageLevelError, err.Error())
counter.Add(1)
continue
}
q.responses.Add(dryRunResponseKey, time.Since(startTime))
counter.Add(1)
}
}
func (q sarin) workerDryRunStatsWithStatic(
jobs <-chan struct{},
req *fasthttp.Request,
requestGenerator RequestGenerator,
counter *atomic.Uint64,
sendMessage messageSender,
) {
if err := requestGenerator(req); err != nil {
// Static request generation failed - record all jobs as errors
for range jobs {
q.responses.Add(err.Error(), 0)
sendMessage(runtimeMessageLevelError, err.Error())
counter.Add(1)
}
return
}
for range jobs {
q.responses.Add(dryRunResponseKey, 0)
counter.Add(1)
}
}
func (q sarin) workerDryRunNoStatsWithDynamic(
jobs <-chan struct{},
req *fasthttp.Request,
requestGenerator RequestGenerator,
counter *atomic.Uint64,
sendMessage messageSender,
) {
for range jobs {
req.Reset()
if err := requestGenerator(req); err != nil {
sendMessage(runtimeMessageLevelError, err.Error())
}
counter.Add(1)
}
}
func (q sarin) workerDryRunNoStatsWithStatic(
jobs <-chan struct{},
req *fasthttp.Request,
requestGenerator RequestGenerator,
counter *atomic.Uint64,
sendMessage messageSender,
) {
if err := requestGenerator(req); err != nil {
sendMessage(runtimeMessageLevelError, err.Error())
}
for range jobs {
counter.Add(1)
}
}
// newHostClients initializes HTTP clients for the given configuration.
// It can return the following errors:
// - types.ProxyDialError
func newHostClients(
ctx context.Context,
timeout time.Duration,
proxies types.Proxies,
workers uint,
requestURL *url.URL,
skipCertVerify bool,
) ([]*fasthttp.HostClient, error) {
proxiesRaw := make([]url.URL, len(proxies))
for i, proxy := range proxies {
proxiesRaw[i] = url.URL(proxy)
}
maxConns := max(fasthttp.DefaultMaxConnsPerHost, workers)
maxConns = ((maxConns * 50 / 100) + maxConns)
return NewHostClients(
ctx,
timeout,
proxiesRaw,
maxConns,
requestURL,
skipCertVerify,
)
}
func (q sarin) startWorkers(wg *sync.WaitGroup, jobs <-chan struct{}, hostClients []*fasthttp.HostClient, counter *atomic.Uint64, sendMessage messageSender) {
for range max(q.workers, 1) {
wg.Go(func() {
q.Worker(jobs, NewHostClientGenerator(hostClients...), counter, sendMessage)
})
}
}
func (q sarin) setupDurationTimeout(ctx context.Context, cancel context.CancelFunc) {
if q.totalDuration != nil {
go func() {
timer := time.NewTimer(*q.totalDuration)
defer timer.Stop()
select {
case <-timer.C:
cancel()
case <-ctx.Done():
// Context cancelled, cleanup
}
}()
}
}
func (q sarin) sendJobs(ctx context.Context, jobs chan<- struct{}) {
if q.totalRequests != nil && *q.totalRequests > 0 {
for range *q.totalRequests {
if ctx.Err() != nil {
break
}
jobs <- struct{}{}
}
} else {
for ctx.Err() == nil {
jobs <- struct{}{}
}
}
}
type tickMsg time.Time
var (
helpStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#d1d1d1"))
errorStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#FC5B5B")).Bold(true)
warningStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#FFD93D")).Bold(true)
messageChannelStyle = lipgloss.NewStyle().
Border(lipgloss.ThickBorder(), false, false, false, true).
BorderForeground(lipgloss.Color("#757575")).
PaddingLeft(1).
Margin(1, 0, 0, 0).
Foreground(lipgloss.Color("#888888"))
)
type progressModel struct {
progress progress.Model
startTime time.Time
messages []string
counter *atomic.Uint64
current uint64
maxValue uint64
ctx context.Context //nolint:containedctx
cancel context.CancelFunc
cancelling bool
}
func (m progressModel) Init() tea.Cmd {
return tea.Batch(progressTickCmd())
}
func (m progressModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.KeyMsg:
if msg.Type == tea.KeyCtrlC {
m.cancelling = true
m.cancel()
}
return m, nil
case tea.WindowSizeMsg:
m.progress.Width = max(10, msg.Width-1)
if m.ctx.Err() != nil {
return m, tea.Quit
}
return m, nil
case runtimeMessage:
var msgBuilder strings.Builder
msgBuilder.WriteString("[")
msgBuilder.WriteString(msg.timestamp.Format("15:04:05"))
msgBuilder.WriteString("] ")
switch msg.level {
case runtimeMessageLevelError:
msgBuilder.WriteString(errorStyle.Render("ERROR: "))
case runtimeMessageLevelWarning:
msgBuilder.WriteString(warningStyle.Render("WARNING: "))
}
msgBuilder.WriteString(msg.text)
m.messages = append(m.messages[1:], msgBuilder.String())
if m.ctx.Err() != nil {
return m, tea.Quit
}
return m, nil
case tickMsg:
if m.ctx.Err() != nil {
return m, tea.Quit
}
return m, progressTickCmd()
default:
if m.ctx.Err() != nil {
return m, tea.Quit
}
return m, nil
}
}
func (m progressModel) View() string {
var messagesBuilder strings.Builder
for i, msg := range m.messages {
if len(msg) > 0 {
messagesBuilder.WriteString(msg)
if i < len(m.messages)-1 {
messagesBuilder.WriteString("\n")
}
}
}
var finalBuilder strings.Builder
if messagesBuilder.Len() > 0 {
finalBuilder.WriteString(messageChannelStyle.Render(messagesBuilder.String()))
finalBuilder.WriteString("\n")
}
m.current = m.counter.Load()
finalBuilder.WriteString("\n ")
finalBuilder.WriteString(strconv.FormatUint(m.current, 10))
finalBuilder.WriteString("/")
finalBuilder.WriteString(strconv.FormatUint(m.maxValue, 10))
finalBuilder.WriteString(" - ")
finalBuilder.WriteString(time.Since(m.startTime).Round(time.Second / 10).String())
finalBuilder.WriteString("\n ")
finalBuilder.WriteString(m.progress.ViewAs(float64(m.current) / float64(m.maxValue)))
finalBuilder.WriteString("\n\n ")
if m.cancelling {
finalBuilder.WriteString(helpStyle.Render("Stopping..."))
} else {
finalBuilder.WriteString(helpStyle.Render("Press Ctrl+C to quit"))
}
return finalBuilder.String()
}
func progressTickCmd() tea.Cmd {
return tea.Tick(time.Millisecond*250, func(t time.Time) tea.Msg {
return tickMsg(t)
})
}
var infiniteProgressStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#00D4FF"))
type infiniteProgressModel struct {
spinner spinner.Model
startTime time.Time
counter *atomic.Uint64
messages []string
ctx context.Context //nolint:containedctx
quit bool
cancel context.CancelFunc
cancelling bool
}
func (m infiniteProgressModel) Init() tea.Cmd {
return m.spinner.Tick
}
func (m infiniteProgressModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.KeyMsg:
if msg.Type == tea.KeyCtrlC {
m.cancelling = true
m.cancel()
}
return m, nil
case runtimeMessage:
var msgBuilder strings.Builder
msgBuilder.WriteString("[")
msgBuilder.WriteString(msg.timestamp.Format("15:04:05"))
msgBuilder.WriteString("] ")
switch msg.level {
case runtimeMessageLevelError:
msgBuilder.WriteString(errorStyle.Render("ERROR: "))
case runtimeMessageLevelWarning:
msgBuilder.WriteString(warningStyle.Render("WARNING: "))
}
msgBuilder.WriteString(msg.text)
m.messages = append(m.messages[1:], msgBuilder.String())
if m.ctx.Err() != nil {
m.quit = true
return m, tea.Quit
}
return m, nil
default:
if m.ctx.Err() != nil {
m.quit = true
return m, tea.Quit
}
var cmd tea.Cmd
m.spinner, cmd = m.spinner.Update(msg)
return m, cmd
}
}
func (m infiniteProgressModel) View() string {
var messagesBuilder strings.Builder
for i, msg := range m.messages {
if len(msg) > 0 {
messagesBuilder.WriteString(msg)
if i < len(m.messages)-1 {
messagesBuilder.WriteString("\n")
}
}
}
var finalBuilder strings.Builder
if messagesBuilder.Len() > 0 {
finalBuilder.WriteString(messageChannelStyle.Render(messagesBuilder.String()))
finalBuilder.WriteString("\n")
}
if m.quit {
finalBuilder.WriteString("\n ")
finalBuilder.WriteString(strconv.FormatUint(m.counter.Load(), 10))
finalBuilder.WriteString(" ")
finalBuilder.WriteString(infiniteProgressStyle.Render("∙∙∙∙∙"))
finalBuilder.WriteString(" ")
finalBuilder.WriteString(time.Since(m.startTime).Round(time.Second / 10).String())
finalBuilder.WriteString("\n\n")
} else {
finalBuilder.WriteString("\n ")
finalBuilder.WriteString(strconv.FormatUint(m.counter.Load(), 10))
finalBuilder.WriteString(" ")
finalBuilder.WriteString(m.spinner.View())
finalBuilder.WriteString(" ")
finalBuilder.WriteString(time.Since(m.startTime).Round(time.Second / 10).String())
finalBuilder.WriteString("\n\n ")
if m.cancelling {
finalBuilder.WriteString(helpStyle.Render("Stopping..."))
} else {
finalBuilder.WriteString(helpStyle.Render("Press Ctrl+C to quit"))
}
}
return finalBuilder.String()
}
func (q sarin) streamProgress(
ctx context.Context,
cancel context.CancelFunc,
done chan<- struct{},
total uint64,
counter *atomic.Uint64,
messageChannel <-chan runtimeMessage,
) {
var program *tea.Program
if total > 0 {
model := progressModel{
progress: progress.New(progress.WithGradient("#151594", "#00D4FF")),
startTime: time.Now(),
messages: make([]string, 8),
counter: counter,
current: 0,
maxValue: total,
ctx: ctx,
cancel: cancel,
}
program = tea.NewProgram(model)
} else {
model := infiniteProgressModel{
spinner: spinner.New(
spinner.WithSpinner(
spinner.Spinner{
Frames: []string{
"●∙∙∙∙",
"∙●∙∙∙",
"∙∙●∙∙",
"∙∙∙●∙",
"∙∙∙∙●",
"∙∙∙●∙",
"∙∙●∙∙",
"∙●∙∙∙",
},
FPS: time.Second / 8, //nolint:mnd
},
),
spinner.WithStyle(infiniteProgressStyle),
),
startTime: time.Now(),
counter: counter,
messages: make([]string, 8),
ctx: ctx,
cancel: cancel,
quit: false,
}
program = tea.NewProgram(model)
}
go func() {
for msg := range messageChannel {
program.Send(msg)
}
}()
if _, err := program.Run(); err != nil {
panic(err)
}
done <- struct{}{}
}

579
internal/sarin/template.go Normal file
View File

@@ -0,0 +1,579 @@
package sarin
import (
"bytes"
"math/rand/v2"
"mime/multipart"
"strings"
"text/template"
"text/template/parse"
"time"
"github.com/brianvoe/gofakeit/v7"
)
func NewDefaultTemplateFuncMap(randSource rand.Source) template.FuncMap {
fakeit := gofakeit.NewFaker(randSource, false)
return template.FuncMap{
// Strings
"strings_ToUpper": strings.ToUpper,
"strings_ToLower": strings.ToLower,
"strings_RemoveSpaces": func(s string) string { return strings.ReplaceAll(s, " ", "") },
"strings_Replace": strings.Replace,
"strings_ToDate": func(dateString string) time.Time {
date, err := time.Parse("2006-01-02", dateString)
if err != nil {
return time.Now()
}
return date
},
"strings_First": func(s string, n int) string {
runes := []rune(s)
if n <= 0 {
return ""
}
if n >= len(runes) {
return s
}
return string(runes[:n])
},
"strings_Last": func(s string, n int) string {
runes := []rune(s)
if n <= 0 {
return ""
}
if n >= len(runes) {
return s
}
return string(runes[len(runes)-n:])
},
"strings_Truncate": func(s string, n int) string {
runes := []rune(s)
if n <= 0 {
return "..."
}
if n >= len(runes) {
return s
}
return string(runes[:n]) + "..."
},
"strings_TrimPrefix": strings.TrimPrefix,
"strings_TrimSuffix": strings.TrimSuffix,
"strings_Join": func(sep string, values ...string) string {
return strings.Join(values, sep)
},
// Dict
"dict_Str": func(values ...string) map[string]string {
dict := make(map[string]string)
for i := 0; i < len(values); i += 2 {
if i+1 < len(values) {
key := values[i]
value := values[i+1]
dict[key] = value
}
}
return dict
},
// Slice
"slice_Str": func(values ...string) []string { return values },
"slice_Int": func(values ...int) []int { return values },
"slice_Uint": func(values ...uint) []uint { return values },
// Fakeit / File
// "fakeit_CSV": fakeit.CSV(nil),
// "fakeit_JSON": fakeit.JSON(nil),
// "fakeit_XML": fakeit.XML(nil),
"fakeit_FileExtension": fakeit.FileExtension,
"fakeit_FileMimeType": fakeit.FileMimeType,
// Fakeit / ID
"fakeit_ID": fakeit.ID,
"fakeit_UUID": fakeit.UUID,
// Fakeit / Template
// "fakeit_Template": fakeit.Template(nil) (string, error),
// "fakeit_Markdown": fakeit.Markdown(nil) (string, error),
// "fakeit_EmailText": fakeit.EmailText(nil) (string, error),
// "fakeit_FixedWidth": fakeit.FixedWidth(nil) (string, error),
// Fakeit / Product
// "fakeit_Product": fakeit.Product() *ProductInfo,
"fakeit_ProductName": fakeit.ProductName,
"fakeit_ProductDescription": fakeit.ProductDescription,
"fakeit_ProductCategory": fakeit.ProductCategory,
"fakeit_ProductFeature": fakeit.ProductFeature,
"fakeit_ProductMaterial": fakeit.ProductMaterial,
"fakeit_ProductUPC": fakeit.ProductUPC,
"fakeit_ProductAudience": fakeit.ProductAudience,
"fakeit_ProductDimension": fakeit.ProductDimension,
"fakeit_ProductUseCase": fakeit.ProductUseCase,
"fakeit_ProductBenefit": fakeit.ProductBenefit,
"fakeit_ProductSuffix": fakeit.ProductSuffix,
"fakeit_ProductISBN": func() string { return fakeit.ProductISBN(nil) },
// Fakeit / Person
// "fakeit_Person": fakeit.Person() *PersonInfo,
"fakeit_Name": fakeit.Name,
"fakeit_NamePrefix": fakeit.NamePrefix,
"fakeit_NameSuffix": fakeit.NameSuffix,
"fakeit_FirstName": fakeit.FirstName,
"fakeit_MiddleName": fakeit.MiddleName,
"fakeit_LastName": fakeit.LastName,
"fakeit_Gender": fakeit.Gender,
"fakeit_Age": fakeit.Age,
"fakeit_Ethnicity": fakeit.Ethnicity,
"fakeit_SSN": fakeit.SSN,
"fakeit_EIN": fakeit.EIN,
"fakeit_Hobby": fakeit.Hobby,
// "fakeit_Contact": fakeit.Contact() *ContactInfo,
"fakeit_Email": fakeit.Email,
"fakeit_Phone": fakeit.Phone,
"fakeit_PhoneFormatted": fakeit.PhoneFormatted,
// "fakeit_Teams": fakeit.Teams(peopleArray []string, teamsArray []string) map[string][]string,
// Fakeit / Generate
// "fakeit_Struct": fakeit.Struct(v any),
// "fakeit_Slice": fakeit.Slice(v any),
// "fakeit_Map": fakeit.Map() map[string]any,
// "fakeit_Generate": fakeit.Generate(value string) string,
"fakeit_Regex": fakeit.Regex,
// Fakeit / Auth
"fakeit_Username": fakeit.Username,
"fakeit_Password": fakeit.Password,
// Fakeit / Address
// "fakeit_Address": fakeit.Address() *AddressInfo,
"fakeit_City": fakeit.City,
"fakeit_Country": fakeit.Country,
"fakeit_CountryAbr": fakeit.CountryAbr,
"fakeit_State": fakeit.State,
"fakeit_StateAbr": fakeit.StateAbr,
"fakeit_Street": fakeit.Street,
"fakeit_StreetName": fakeit.StreetName,
"fakeit_StreetNumber": fakeit.StreetNumber,
"fakeit_StreetPrefix": fakeit.StreetPrefix,
"fakeit_StreetSuffix": fakeit.StreetSuffix,
"fakeit_Unit": fakeit.Unit,
"fakeit_Zip": fakeit.Zip,
"fakeit_Latitude": fakeit.Latitude,
"fakeit_LatitudeInRange": func(minLatitude, maxLatitude float64) float64 {
value, err := fakeit.LatitudeInRange(minLatitude, maxLatitude)
if err != nil {
var zero float64
return zero
}
return value
},
"fakeit_Longitude": fakeit.Longitude,
"fakeit_LongitudeInRange": func(minLongitude, maxLongitude float64) float64 {
value, err := fakeit.LongitudeInRange(minLongitude, maxLongitude)
if err != nil {
var zero float64
return zero
}
return value
},
// Fakeit / Game
"fakeit_Gamertag": fakeit.Gamertag,
// "fakeit_Dice": fakeit.Dice(numDice uint, sides []uint) []uint,
// Fakeit / Beer
"fakeit_BeerAlcohol": fakeit.BeerAlcohol,
"fakeit_BeerBlg": fakeit.BeerBlg,
"fakeit_BeerHop": fakeit.BeerHop,
"fakeit_BeerIbu": fakeit.BeerIbu,
"fakeit_BeerMalt": fakeit.BeerMalt,
"fakeit_BeerName": fakeit.BeerName,
"fakeit_BeerStyle": fakeit.BeerStyle,
"fakeit_BeerYeast": fakeit.BeerYeast,
// Fakeit / Car
// "fakeit_Car": fakeit.Car() *CarInfo,
"fakeit_CarMaker": fakeit.CarMaker,
"fakeit_CarModel": fakeit.CarModel,
"fakeit_CarType": fakeit.CarType,
"fakeit_CarFuelType": fakeit.CarFuelType,
"fakeit_CarTransmissionType": fakeit.CarTransmissionType,
// Fakeit / Words
// Nouns
"fakeit_Noun": fakeit.Noun,
"fakeit_NounCommon": fakeit.NounCommon,
"fakeit_NounConcrete": fakeit.NounConcrete,
"fakeit_NounAbstract": fakeit.NounAbstract,
"fakeit_NounCollectivePeople": fakeit.NounCollectivePeople,
"fakeit_NounCollectiveAnimal": fakeit.NounCollectiveAnimal,
"fakeit_NounCollectiveThing": fakeit.NounCollectiveThing,
"fakeit_NounCountable": fakeit.NounCountable,
"fakeit_NounUncountable": fakeit.NounUncountable,
// Verbs
"fakeit_Verb": fakeit.Verb,
"fakeit_VerbAction": fakeit.VerbAction,
"fakeit_VerbLinking": fakeit.VerbLinking,
"fakeit_VerbHelping": fakeit.VerbHelping,
// Adverbs
"fakeit_Adverb": fakeit.Adverb,
"fakeit_AdverbManner": fakeit.AdverbManner,
"fakeit_AdverbDegree": fakeit.AdverbDegree,
"fakeit_AdverbPlace": fakeit.AdverbPlace,
"fakeit_AdverbTimeDefinite": fakeit.AdverbTimeDefinite,
"fakeit_AdverbTimeIndefinite": fakeit.AdverbTimeIndefinite,
"fakeit_AdverbFrequencyDefinite": fakeit.AdverbFrequencyDefinite,
"fakeit_AdverbFrequencyIndefinite": fakeit.AdverbFrequencyIndefinite,
// Propositions
"fakeit_Preposition": fakeit.Preposition,
"fakeit_PrepositionSimple": fakeit.PrepositionSimple,
"fakeit_PrepositionDouble": fakeit.PrepositionDouble,
"fakeit_PrepositionCompound": fakeit.PrepositionCompound,
// Adjectives
"fakeit_Adjective": fakeit.Adjective,
"fakeit_AdjectiveDescriptive": fakeit.AdjectiveDescriptive,
"fakeit_AdjectiveQuantitative": fakeit.AdjectiveQuantitative,
"fakeit_AdjectiveProper": fakeit.AdjectiveProper,
"fakeit_AdjectiveDemonstrative": fakeit.AdjectiveDemonstrative,
"fakeit_AdjectivePossessive": fakeit.AdjectivePossessive,
"fakeit_AdjectiveInterrogative": fakeit.AdjectiveInterrogative,
"fakeit_AdjectiveIndefinite": fakeit.AdjectiveIndefinite,
// Pronouns
"fakeit_Pronoun": fakeit.Pronoun,
"fakeit_PronounPersonal": fakeit.PronounPersonal,
"fakeit_PronounObject": fakeit.PronounObject,
"fakeit_PronounPossessive": fakeit.PronounPossessive,
"fakeit_PronounReflective": fakeit.PronounReflective,
"fakeit_PronounDemonstrative": fakeit.PronounDemonstrative,
"fakeit_PronounInterrogative": fakeit.PronounInterrogative,
"fakeit_PronounRelative": fakeit.PronounRelative,
// Connectives
"fakeit_Connective": fakeit.Connective,
"fakeit_ConnectiveTime": fakeit.ConnectiveTime,
"fakeit_ConnectiveComparative": fakeit.ConnectiveComparative,
"fakeit_ConnectiveComplaint": fakeit.ConnectiveComplaint,
"fakeit_ConnectiveListing": fakeit.ConnectiveListing,
"fakeit_ConnectiveCasual": fakeit.ConnectiveCasual,
"fakeit_ConnectiveExamplify": fakeit.ConnectiveExamplify,
// Words
"fakeit_Word": fakeit.Word,
// Text
"fakeit_Sentence": fakeit.Sentence,
"fakeit_Paragraph": fakeit.Paragraph,
"fakeit_LoremIpsumWord": fakeit.LoremIpsumWord,
"fakeit_LoremIpsumSentence": fakeit.LoremIpsumSentence,
"fakeit_LoremIpsumParagraph": fakeit.LoremIpsumParagraph,
"fakeit_Question": fakeit.Question,
"fakeit_Quote": fakeit.Quote,
"fakeit_Phrase": fakeit.Phrase,
// Fakeit / Foods
"fakeit_Fruit": fakeit.Fruit,
"fakeit_Vegetable": fakeit.Vegetable,
"fakeit_Breakfast": fakeit.Breakfast,
"fakeit_Lunch": fakeit.Lunch,
"fakeit_Dinner": fakeit.Dinner,
"fakeit_Snack": fakeit.Snack,
"fakeit_Dessert": fakeit.Dessert,
// Fakeit / Misc
"fakeit_Bool": fakeit.Bool,
// "fakeit_Weighted": fakeit.Weighted(options []any, weights []float32) (any, error),
"fakeit_FlipACoin": fakeit.FlipACoin,
// "fakeit_RandomMapKey": fakeit.RandomMapKey(mapI any) any,
// "fakeit_ShuffleAnySlice": fakeit.ShuffleAnySlice(v any),
// Fakeit / Colors
"fakeit_Color": fakeit.Color,
"fakeit_HexColor": fakeit.HexColor,
"fakeit_RGBColor": fakeit.RGBColor,
"fakeit_SafeColor": fakeit.SafeColor,
"fakeit_NiceColors": fakeit.NiceColors,
// Fakeit / Images
// "fakeit_Image": fakeit.Image(width int, height int) *img.RGBA,
"fakeit_ImageJpeg": fakeit.ImageJpeg,
"fakeit_ImagePng": fakeit.ImagePng,
// Fakeit / Internet
"fakeit_URL": fakeit.URL,
"fakeit_UrlSlug": fakeit.UrlSlug,
"fakeit_DomainName": fakeit.DomainName,
"fakeit_DomainSuffix": fakeit.DomainSuffix,
"fakeit_IPv4Address": fakeit.IPv4Address,
"fakeit_IPv6Address": fakeit.IPv6Address,
"fakeit_MacAddress": fakeit.MacAddress,
"fakeit_HTTPStatusCode": fakeit.HTTPStatusCode,
"fakeit_HTTPStatusCodeSimple": fakeit.HTTPStatusCodeSimple,
"fakeit_LogLevel": fakeit.LogLevel,
"fakeit_HTTPMethod": fakeit.HTTPMethod,
"fakeit_HTTPVersion": fakeit.HTTPVersion,
"fakeit_UserAgent": fakeit.UserAgent,
"fakeit_ChromeUserAgent": fakeit.ChromeUserAgent,
"fakeit_FirefoxUserAgent": fakeit.FirefoxUserAgent,
"fakeit_OperaUserAgent": fakeit.OperaUserAgent,
"fakeit_SafariUserAgent": fakeit.SafariUserAgent,
"fakeit_APIUserAgent": fakeit.APIUserAgent,
// Fakeit / HTML
"fakeit_InputName": fakeit.InputName,
"fakeit_Svg": func() string { return fakeit.Svg(nil) },
// Fakeit / Date/Time
"fakeit_Date": fakeit.Date,
"fakeit_PastDate": fakeit.PastDate,
"fakeit_FutureDate": fakeit.FutureDate,
"fakeit_DateRange": fakeit.DateRange,
"fakeit_NanoSecond": fakeit.NanoSecond,
"fakeit_Second": fakeit.Second,
"fakeit_Minute": fakeit.Minute,
"fakeit_Hour": fakeit.Hour,
"fakeit_Month": fakeit.Month,
"fakeit_MonthString": fakeit.MonthString,
"fakeit_Day": fakeit.Day,
"fakeit_WeekDay": fakeit.WeekDay,
"fakeit_Year": fakeit.Year,
"fakeit_TimeZone": fakeit.TimeZone,
"fakeit_TimeZoneAbv": fakeit.TimeZoneAbv,
"fakeit_TimeZoneFull": fakeit.TimeZoneFull,
"fakeit_TimeZoneOffset": fakeit.TimeZoneOffset,
"fakeit_TimeZoneRegion": fakeit.TimeZoneRegion,
// Fakeit / Payment
"fakeit_Price": fakeit.Price,
// "fakeit_CreditCard": fakeit.CreditCard() *CreditCardInfo,
"fakeit_CreditCardCvv": fakeit.CreditCardCvv,
"fakeit_CreditCardExp": fakeit.CreditCardExp,
"fakeit_CreditCardNumber": func(gaps bool) string {
return fakeit.CreditCardNumber(&gofakeit.CreditCardOptions{Gaps: gaps})
},
"fakeit_CreditCardType": fakeit.CreditCardType,
// "fakeit_Currency": fakeit.Currency() *CurrencyInfo,
"fakeit_CurrencyLong": fakeit.CurrencyLong,
"fakeit_CurrencyShort": fakeit.CurrencyShort,
"fakeit_AchRouting": fakeit.AchRouting,
"fakeit_AchAccount": fakeit.AchAccount,
"fakeit_BitcoinAddress": fakeit.BitcoinAddress,
"fakeit_BitcoinPrivateKey": fakeit.BitcoinPrivateKey,
"fakeit_BankName": fakeit.BankName,
"fakeit_BankType": fakeit.BankType,
// Fakeit / Finance
"fakeit_Cusip": fakeit.Cusip,
"fakeit_Isin": fakeit.Isin,
// Fakeit / Company
"fakeit_BS": fakeit.BS,
"fakeit_Blurb": fakeit.Blurb,
"fakeit_BuzzWord": fakeit.BuzzWord,
"fakeit_Company": fakeit.Company,
"fakeit_CompanySuffix": fakeit.CompanySuffix,
// "fakeit_Job": fakeit.Job() *JobInfo,
"fakeit_JobDescriptor": fakeit.JobDescriptor,
"fakeit_JobLevel": fakeit.JobLevel,
"fakeit_JobTitle": fakeit.JobTitle,
"fakeit_Slogan": fakeit.Slogan,
// Fakeit / Hacker
"fakeit_HackerAbbreviation": fakeit.HackerAbbreviation,
"fakeit_HackerAdjective": fakeit.HackerAdjective,
"fakeit_HackeringVerb": fakeit.HackeringVerb,
"fakeit_HackerNoun": fakeit.HackerNoun,
"fakeit_HackerPhrase": fakeit.HackerPhrase,
"fakeit_HackerVerb": fakeit.HackerVerb,
// Fakeit / Hipster
"fakeit_HipsterWord": fakeit.HipsterWord,
"fakeit_HipsterSentence": fakeit.HipsterSentence,
"fakeit_HipsterParagraph": fakeit.HipsterParagraph,
// Fakeit / App
"fakeit_AppName": fakeit.AppName,
"fakeit_AppVersion": fakeit.AppVersion,
"fakeit_AppAuthor": fakeit.AppAuthor,
// Fakeit / Animal
"fakeit_PetName": fakeit.PetName,
"fakeit_Animal": fakeit.Animal,
"fakeit_AnimalType": fakeit.AnimalType,
"fakeit_FarmAnimal": fakeit.FarmAnimal,
"fakeit_Cat": fakeit.Cat,
"fakeit_Dog": fakeit.Dog,
"fakeit_Bird": fakeit.Bird,
// Fakeit / Emoji
"fakeit_Emoji": fakeit.Emoji,
"fakeit_EmojiCategory": fakeit.EmojiCategory,
"fakeit_EmojiAlias": fakeit.EmojiAlias,
"fakeit_EmojiTag": fakeit.EmojiTag,
"fakeit_EmojiFlag": fakeit.EmojiFlag,
"fakeit_EmojiAnimal": fakeit.EmojiAnimal,
"fakeit_EmojiFood": fakeit.EmojiFood,
"fakeit_EmojiPlant": fakeit.EmojiPlant,
"fakeit_EmojiMusic": fakeit.EmojiMusic,
"fakeit_EmojiVehicle": fakeit.EmojiVehicle,
"fakeit_EmojiSport": fakeit.EmojiSport,
"fakeit_EmojiFace": fakeit.EmojiFace,
"fakeit_EmojiHand": fakeit.EmojiHand,
"fakeit_EmojiClothing": fakeit.EmojiClothing,
"fakeit_EmojiLandmark": fakeit.EmojiLandmark,
"fakeit_EmojiElectronics": fakeit.EmojiElectronics,
"fakeit_EmojiGame": fakeit.EmojiGame,
"fakeit_EmojiTools": fakeit.EmojiTools,
"fakeit_EmojiWeather": fakeit.EmojiWeather,
"fakeit_EmojiJob": fakeit.EmojiJob,
"fakeit_EmojiPerson": fakeit.EmojiPerson,
"fakeit_EmojiGesture": fakeit.EmojiGesture,
"fakeit_EmojiCostume": fakeit.EmojiCostume,
"fakeit_EmojiSentence": fakeit.EmojiSentence,
// Fakeit / Language
"fakeit_Language": fakeit.Language,
"fakeit_LanguageAbbreviation": fakeit.LanguageAbbreviation,
"fakeit_ProgrammingLanguage": fakeit.ProgrammingLanguage,
// Fakeit / Number
"fakeit_Number": fakeit.Number,
"fakeit_Int": fakeit.Int,
"fakeit_IntN": fakeit.IntN,
"fakeit_Int8": fakeit.Int8,
"fakeit_Int16": fakeit.Int16,
"fakeit_Int32": fakeit.Int32,
"fakeit_Int64": fakeit.Int64,
"fakeit_Uint": fakeit.Uint,
"fakeit_UintN": fakeit.UintN,
"fakeit_Uint8": fakeit.Uint8,
"fakeit_Uint16": fakeit.Uint16,
"fakeit_Uint32": fakeit.Uint32,
"fakeit_Uint64": fakeit.Uint64,
"fakeit_Float32": fakeit.Float32,
"fakeit_Float32Range": fakeit.Float32Range,
"fakeit_Float64": fakeit.Float64,
"fakeit_Float64Range": fakeit.Float64Range,
// "fakeit_ShuffleInts": fakeit.ShuffleInts,
"fakeit_RandomInt": fakeit.RandomInt,
"fakeit_HexUint": fakeit.HexUint,
// Fakeit / String
"fakeit_Digit": fakeit.Digit,
"fakeit_DigitN": fakeit.DigitN,
"fakeit_Letter": fakeit.Letter,
"fakeit_LetterN": fakeit.LetterN,
"fakeit_Lexify": fakeit.Lexify,
"fakeit_Numerify": fakeit.Numerify,
// "fakeit_ShuffleStrings": fakeit.ShuffleStrings,
"fakeit_RandomString": fakeit.RandomString,
// Fakeit / Celebrity
"fakeit_CelebrityActor": fakeit.CelebrityActor,
"fakeit_CelebrityBusiness": fakeit.CelebrityBusiness,
"fakeit_CelebritySport": fakeit.CelebritySport,
// Fakeit / Minecraft
"fakeit_MinecraftOre": fakeit.MinecraftOre,
"fakeit_MinecraftWood": fakeit.MinecraftWood,
"fakeit_MinecraftArmorTier": fakeit.MinecraftArmorTier,
"fakeit_MinecraftArmorPart": fakeit.MinecraftArmorPart,
"fakeit_MinecraftWeapon": fakeit.MinecraftWeapon,
"fakeit_MinecraftTool": fakeit.MinecraftTool,
"fakeit_MinecraftDye": fakeit.MinecraftDye,
"fakeit_MinecraftFood": fakeit.MinecraftFood,
"fakeit_MinecraftAnimal": fakeit.MinecraftAnimal,
"fakeit_MinecraftVillagerJob": fakeit.MinecraftVillagerJob,
"fakeit_MinecraftVillagerStation": fakeit.MinecraftVillagerStation,
"fakeit_MinecraftVillagerLevel": fakeit.MinecraftVillagerLevel,
"fakeit_MinecraftMobPassive": fakeit.MinecraftMobPassive,
"fakeit_MinecraftMobNeutral": fakeit.MinecraftMobNeutral,
"fakeit_MinecraftMobHostile": fakeit.MinecraftMobHostile,
"fakeit_MinecraftMobBoss": fakeit.MinecraftMobBoss,
"fakeit_MinecraftBiome": fakeit.MinecraftBiome,
"fakeit_MinecraftWeather": fakeit.MinecraftWeather,
// Fakeit / Book
// "fakeit_Book": fakeit.Book() *BookInfo,
"fakeit_BookTitle": fakeit.BookTitle,
"fakeit_BookAuthor": fakeit.BookAuthor,
"fakeit_BookGenre": fakeit.BookGenre,
// Fakeit / Movie
// "fakeit_Movie": fakeit.Movie() *MovieInfo,
"fakeit_MovieName": fakeit.MovieName,
"fakeit_MovieGenre": fakeit.MovieGenre,
// Fakeit / Error
"fakeit_Error": func() string { return fakeit.Error().Error() },
"fakeit_ErrorDatabase": func() string { return fakeit.ErrorDatabase().Error() },
"fakeit_ErrorGRPC": func() string { return fakeit.ErrorGRPC().Error() },
"fakeit_ErrorHTTP": func() string { return fakeit.ErrorHTTP().Error() },
"fakeit_ErrorHTTPClient": func() string { return fakeit.ErrorHTTPClient().Error() },
"fakeit_ErrorHTTPServer": func() string { return fakeit.ErrorHTTPServer().Error() },
// "fakeit_ErrorInput": func() string { return fakeit.ErrorInput().Error() },
"fakeit_ErrorRuntime": func() string { return fakeit.ErrorRuntime().Error() },
// Fakeit / School
"fakeit_School": fakeit.School,
// Fakeit / Song
// "fakeit_Song": fakeit.Song() *SongInfo,
"fakeit_SongName": fakeit.SongName,
"fakeit_SongArtist": fakeit.SongArtist,
"fakeit_SongGenre": fakeit.SongGenre,
}
}
type BodyTemplateFuncMapData struct {
formDataContenType string
}
func (data BodyTemplateFuncMapData) GetFormDataContenType() string {
return data.formDataContenType
}
func (data *BodyTemplateFuncMapData) ClearFormDataContenType() {
data.formDataContenType = ""
}
func NewDefaultBodyTemplateFuncMap(randSource rand.Source, data *BodyTemplateFuncMapData) template.FuncMap {
funcMap := NewDefaultTemplateFuncMap(randSource)
if data != nil {
funcMap["body_FormData"] = func(kv map[string]string) string {
var multipartData bytes.Buffer
writer := multipart.NewWriter(&multipartData)
data.formDataContenType = writer.FormDataContentType()
for k, v := range kv {
_ = writer.WriteField(k, v)
}
_ = writer.Close()
return multipartData.String()
}
}
return funcMap
}
func hasTemplateActions(tmpl *template.Template) bool {
if tmpl.Tree == nil || tmpl.Root == nil {
return false
}
for _, node := range tmpl.Root.Nodes {
switch node.Type() {
case parse.NodeAction, parse.NodeIf, parse.NodeRange,
parse.NodeWith, parse.NodeTemplate:
return true
}
}
return false
}

View File

@@ -0,0 +1,46 @@
package types
import (
"path/filepath"
"strings"
)
type ConfigFileType string
const (
ConfigFileTypeUnknown ConfigFileType = "unknown"
ConfigFileTypeYAML ConfigFileType = "yaml/yml"
)
type ConfigFile struct {
path string
_type ConfigFileType
}
func (configFile ConfigFile) Path() string {
return configFile.path
}
func (configFile ConfigFile) Type() ConfigFileType {
return configFile._type
}
func ParseConfigFile(configFileRaw string) *ConfigFile {
// TODO: Improve file type detection
// (e.g., use magic bytes or content inspection instead of relying solely on file extension)
configFileParsed := &ConfigFile{
path: configFileRaw,
}
configFileExtension, _ := strings.CutPrefix(filepath.Ext(configFileRaw), ".")
switch strings.ToLower(configFileExtension) {
case "yml", "yaml":
configFileParsed._type = ConfigFileTypeYAML
default:
configFileParsed._type = ConfigFileTypeUnknown
}
return configFileParsed
}

40
internal/types/cookie.go Normal file
View File

@@ -0,0 +1,40 @@
package types
import "strings"
type Cookie KeyValue[string, []string]
type Cookies []Cookie
func (cookies Cookies) GetValue(key string) *[]string {
for i := range cookies {
if cookies[i].Key == key {
return &cookies[i].Value
}
}
return nil
}
func (cookies *Cookies) Append(cookie ...Cookie) {
for _, c := range cookie {
if item := cookies.GetValue(c.Key); item != nil {
*item = append(*item, c.Value...)
} else {
*cookies = append(*cookies, c)
}
}
}
func (cookies *Cookies) Parse(rawValues ...string) {
for _, rawValue := range rawValues {
cookies.Append(*ParseCookie(rawValue))
}
}
func ParseCookie(rawValue string) *Cookie {
parts := strings.SplitN(rawValue, "=", 2)
if len(parts) == 1 {
return &Cookie{Key: parts[0], Value: []string{""}}
}
return &Cookie{Key: parts[0], Value: []string{parts[1]}}
}

189
internal/types/errors.go Normal file
View File

@@ -0,0 +1,189 @@
package types
import (
"errors"
"fmt"
"strings"
)
var (
// General
ErrNoError = errors.New("no error (internal)")
// CLI
ErrCLINoArgs = errors.New("CLI expects arguments but received none")
)
// ======================================== General ========================================
type FieldParseError struct {
Field string
Value string
Err error
}
func NewFieldParseError(field string, value string, err error) FieldParseError {
if err == nil {
err = ErrNoError
}
return FieldParseError{field, value, err}
}
func (e FieldParseError) Error() string {
return fmt.Sprintf("Field '%s' parse failed: %v", e.Field, e.Err)
}
func (e FieldParseError) Unwrap() error {
return e.Err
}
type FieldParseErrors struct {
Errors []FieldParseError
}
func NewFieldParseErrors(fieldParseErrors []FieldParseError) FieldParseErrors {
return FieldParseErrors{fieldParseErrors}
}
func (e FieldParseErrors) Error() string {
if len(e.Errors) == 0 {
return "No field parse errors"
}
if len(e.Errors) == 1 {
return e.Errors[0].Error()
}
var builder strings.Builder
for i, err := range e.Errors {
if i > 0 {
builder.WriteString("\n")
}
builder.WriteString(err.Error())
}
return builder.String()
}
type FieldValidationError struct {
Field string
Value string
Err error
}
func NewFieldValidationError(field string, value string, err error) FieldValidationError {
if err == nil {
err = ErrNoError
}
return FieldValidationError{field, value, err}
}
func (e FieldValidationError) Error() string {
return fmt.Sprintf("Field '%s' validation failed: %v", e.Field, e.Err)
}
func (e FieldValidationError) Unwrap() error {
return e.Err
}
type FieldValidationErrors struct {
Errors []FieldValidationError
}
func NewFieldValidationErrors(fieldValidationErrors []FieldValidationError) FieldValidationErrors {
return FieldValidationErrors{fieldValidationErrors}
}
func (e FieldValidationErrors) Error() string {
if len(e.Errors) == 0 {
return "No field validation errors"
}
if len(e.Errors) == 1 {
return e.Errors[0].Error()
}
var builder strings.Builder
for i, err := range e.Errors {
if i > 0 {
builder.WriteString("\n")
}
builder.WriteString(err.Error())
}
return builder.String()
}
type UnmarshalError struct {
error error
}
func NewUnmarshalError(err error) UnmarshalError {
if err == nil {
err = ErrNoError
}
return UnmarshalError{err}
}
func (e UnmarshalError) Error() string {
return "Unmarshal error: " + e.error.Error()
}
func (e UnmarshalError) Unwrap() error {
return e.error
}
// ======================================== CLI ========================================
type CLIUnexpectedArgsError struct {
Args []string
}
func NewCLIUnexpectedArgsError(args []string) CLIUnexpectedArgsError {
return CLIUnexpectedArgsError{args}
}
func (e CLIUnexpectedArgsError) Error() string {
return fmt.Sprintf("CLI received unexpected arguments: %v", strings.Join(e.Args, ","))
}
// ======================================== Config File ========================================
type ConfigFileReadError struct {
error error
}
func NewConfigFileReadError(err error) ConfigFileReadError {
if err == nil {
err = ErrNoError
}
return ConfigFileReadError{err}
}
func (e ConfigFileReadError) Error() string {
return "Config file read error: " + e.error.Error()
}
func (e ConfigFileReadError) Unwrap() error {
return e.error
}
// ======================================== Proxy ========================================
type ProxyDialError struct {
Proxy string
Err error
}
func NewProxyDialError(proxy string, err error) ProxyDialError {
if err == nil {
err = ErrNoError
}
return ProxyDialError{proxy, err}
}
func (e ProxyDialError) Error() string {
return "proxy \"" + e.Proxy + "\": " + e.Err.Error()
}
func (e ProxyDialError) Unwrap() error {
return e.Err
}

49
internal/types/header.go Normal file
View File

@@ -0,0 +1,49 @@
package types
import "strings"
type Header KeyValue[string, []string]
type Headers []Header
func (headers Headers) Has(key string) bool {
for i := range headers {
if headers[i].Key == key {
return true
}
}
return false
}
func (headers Headers) GetValue(key string) *[]string {
for i := range headers {
if headers[i].Key == key {
return &headers[i].Value
}
}
return nil
}
func (headers *Headers) Append(header ...Header) {
for _, h := range header {
if item := headers.GetValue(h.Key); item != nil {
*item = append(*item, h.Value...)
} else {
*headers = append(*headers, h)
}
}
}
func (headers *Headers) Parse(rawValues ...string) {
for _, rawValue := range rawValues {
headers.Append(*ParseHeader(rawValue))
}
}
func ParseHeader(rawValue string) *Header {
parts := strings.SplitN(rawValue, ": ", 2)
if len(parts) == 1 {
return &Header{Key: parts[0], Value: []string{""}}
}
return &Header{Key: parts[0], Value: []string{parts[1]}}
}

View File

@@ -0,0 +1,6 @@
package types
type KeyValue[K, V any] struct {
Key K
Value V
}

40
internal/types/param.go Normal file
View File

@@ -0,0 +1,40 @@
package types
import "strings"
type Param KeyValue[string, []string]
type Params []Param
func (params Params) GetValue(key string) *[]string {
for i := range params {
if params[i].Key == key {
return &params[i].Value
}
}
return nil
}
func (params *Params) Append(param ...Param) {
for _, p := range param {
if item := params.GetValue(p.Key); item != nil {
*item = append(*item, p.Value...)
} else {
*params = append(*params, p)
}
}
}
func (params *Params) Parse(rawValues ...string) {
for _, rawValue := range rawValues {
params.Append(*ParseParam(rawValue))
}
}
func ParseParam(rawValue string) *Param {
parts := strings.SplitN(rawValue, "=", 2)
if len(parts) == 1 {
return &Param{Key: parts[0], Value: []string{""}}
}
return &Param{Key: parts[0], Value: []string{parts[1]}}
}

38
internal/types/proxy.go Normal file
View File

@@ -0,0 +1,38 @@
package types
import (
"fmt"
"net/url"
)
type Proxy url.URL
func (proxy Proxy) String() string {
return (*url.URL)(&proxy).String()
}
type Proxies []Proxy
func (proxies *Proxies) Append(proxy ...Proxy) {
*proxies = append(*proxies, proxy...)
}
func (proxies *Proxies) Parse(rawValue string) error {
parsedProxy, err := ParseProxy(rawValue)
if err != nil {
return err
}
proxies.Append(*parsedProxy)
return nil
}
func ParseProxy(rawValue string) (*Proxy, error) {
urlParsed, err := url.Parse(rawValue)
if err != nil {
return nil, fmt.Errorf("failed to parse proxy URL: %w", err)
}
proxyParsed := Proxy(*urlParsed)
return &proxyParsed, nil
}

View File

@@ -0,0 +1,8 @@
package version
var (
Version = "unknown" // Set via ldflags
GitCommit = "unknown" // Set via ldflags
BuildDate = "unknown" // Set via ldflags
GoVersion = "unknown" // Set via ldflags
)

69
main.go
View File

@@ -1,69 +0,0 @@
package main
import (
"context"
"errors"
"fmt"
"os"
"os/signal"
"syscall"
"github.com/aykhans/dodo/config"
"github.com/aykhans/dodo/requests"
"github.com/aykhans/dodo/types"
"github.com/aykhans/dodo/utils"
"github.com/jedib0t/go-pretty/v6/text"
)
func main() {
conf := config.NewConfig()
configFile, err := conf.ReadCLI()
if err != nil {
utils.PrintErrAndExit(err)
}
if configFile.String() != "" {
tempConf := config.NewConfig()
if err := tempConf.ReadFile(configFile); err != nil {
utils.PrintErrAndExit(err)
}
tempConf.MergeConfig(conf)
conf = tempConf
}
conf.SetDefaults()
if errs := conf.Validate(); len(errs) > 0 {
utils.PrintErrAndExit(errors.Join(errs...))
}
requestConf := config.NewRequestConfig(conf)
requestConf.Print()
if !requestConf.Yes {
response := config.CLIYesOrNoReader("Do you want to continue?", false)
if !response {
utils.PrintAndExit("Exiting...\n")
}
}
ctx, cancel := context.WithCancel(context.Background())
go listenForTermination(func() { cancel() })
responses, err := requests.Run(ctx, requestConf)
if err != nil {
if err == types.ErrInterrupt {
fmt.Println(text.FgYellow.Sprint(err.Error()))
return
}
utils.PrintErrAndExit(err)
}
responses.Print()
}
func listenForTermination(do func()) {
sigChan := make(chan os.Signal, 1)
signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM)
<-sigChan
do()
}

View File

@@ -1,112 +0,0 @@
package requests
import (
"context"
"crypto/tls"
"errors"
"math/rand"
"net/url"
"time"
"github.com/aykhans/dodo/utils"
"github.com/valyala/fasthttp"
"github.com/valyala/fasthttp/fasthttpproxy"
)
type ClientGeneratorFunc func() *fasthttp.HostClient
// getClients initializes and returns a slice of fasthttp.HostClient based on the provided parameters.
// It can either return clients with proxies or a single client without proxies.
func getClients(
_ context.Context,
timeout time.Duration,
proxies []url.URL,
maxConns uint,
URL url.URL,
skipVerify bool,
) []*fasthttp.HostClient {
isTLS := URL.Scheme == "https"
if proxiesLen := len(proxies); proxiesLen > 0 {
clients := make([]*fasthttp.HostClient, 0, proxiesLen)
addr := URL.Host
if isTLS && URL.Port() == "" {
addr += ":443"
}
for _, proxy := range proxies {
dialFunc, err := getDialFunc(&proxy, timeout)
if err != nil {
continue
}
clients = append(clients, &fasthttp.HostClient{
MaxConns: int(maxConns),
IsTLS: isTLS,
TLSConfig: &tls.Config{
InsecureSkipVerify: skipVerify,
},
Addr: addr,
Dial: dialFunc,
MaxIdleConnDuration: timeout,
MaxConnDuration: timeout,
WriteTimeout: timeout,
ReadTimeout: timeout,
},
)
}
return clients
}
client := &fasthttp.HostClient{
MaxConns: int(maxConns),
IsTLS: isTLS,
TLSConfig: &tls.Config{
InsecureSkipVerify: skipVerify,
},
Addr: URL.Host,
MaxIdleConnDuration: timeout,
MaxConnDuration: timeout,
WriteTimeout: timeout,
ReadTimeout: timeout,
}
return []*fasthttp.HostClient{client}
}
// getDialFunc returns the appropriate fasthttp.DialFunc based on the provided proxy URL scheme.
// It supports SOCKS5 ('socks5' or 'socks5h') and HTTP ('http') proxy schemes.
// For HTTP proxies, the timeout parameter determines connection timeouts.
// Returns an error if the proxy scheme is unsupported.
func getDialFunc(proxy *url.URL, timeout time.Duration) (fasthttp.DialFunc, error) {
var dialer fasthttp.DialFunc
switch proxy.Scheme {
case "socks5", "socks5h":
dialer = fasthttpproxy.FasthttpSocksDialerDualStack(proxy.String())
case "http":
dialer = fasthttpproxy.FasthttpHTTPDialerDualStackTimeout(proxy.String(), timeout)
default:
return nil, errors.New("unsupported proxy scheme")
}
if dialer == nil {
return nil, errors.New("internal error: proxy dialer is nil")
}
return dialer, nil
}
// getSharedClientFuncMultiple returns a ClientGeneratorFunc that cycles through a list of fasthttp.HostClient instances.
// The function uses a local random number generator to determine the starting index and stop index for cycling through the clients.
// The returned function isn't thread-safe and should be used in a single-threaded context.
func getSharedClientFuncMultiple(clients []*fasthttp.HostClient, localRand *rand.Rand) ClientGeneratorFunc {
return utils.RandomValueCycle(clients, localRand)
}
// getSharedClientFuncSingle returns a ClientGeneratorFunc that always returns the provided fasthttp.HostClient instance.
// This can be useful for sharing a single client instance across multiple requests.
func getSharedClientFuncSingle(client *fasthttp.HostClient) ClientGeneratorFunc {
return func() *fasthttp.HostClient {
return client
}
}

View File

@@ -1,56 +0,0 @@
package requests
import (
"context"
"fmt"
"sync"
"time"
"github.com/jedib0t/go-pretty/v6/progress"
)
// streamProgress streams the progress of a task to the console using a progress bar.
// It listens for increments on the provided channel and updates the progress bar accordingly.
//
// The function will stop and mark the progress as errored if the context is cancelled.
// It will also stop and mark the progress as done when the total number of increments is reached.
func streamProgress(
ctx context.Context,
wg *sync.WaitGroup,
total uint,
message string,
increase <-chan int64,
) {
defer wg.Done()
pw := progress.NewWriter()
pw.SetTrackerPosition(progress.PositionRight)
pw.SetStyle(progress.StyleBlocks)
pw.SetTrackerLength(40)
pw.SetUpdateFrequency(time.Millisecond * 250)
if total == 0 {
pw.Style().Visibility.Percentage = false
}
go pw.Render()
dodosTracker := progress.Tracker{
Message: message,
Total: int64(total),
}
pw.AppendTracker(&dodosTracker)
for {
select {
case <-ctx.Done():
if err := ctx.Err(); err == context.Canceled || err == context.DeadlineExceeded {
dodosTracker.MarkAsDone()
} else {
dodosTracker.MarkAsErrored()
}
time.Sleep(time.Millisecond * 300)
fmt.Printf("\r")
return
case value := <-increase:
dodosTracker.Increment(value)
}
}
}

View File

@@ -1,341 +0,0 @@
package requests
import (
"bytes"
"context"
"math/rand"
"net/url"
"text/template"
"time"
"github.com/aykhans/dodo/config"
"github.com/aykhans/dodo/types"
"github.com/aykhans/dodo/utils"
"github.com/valyala/fasthttp"
)
type RequestGeneratorFunc func() *fasthttp.Request
// Request represents an HTTP request to be sent using the fasthttp client.
// It isn't thread-safe and should be used by a single goroutine.
type Request struct {
getClient ClientGeneratorFunc
getRequest RequestGeneratorFunc
}
type keyValueGenerator struct {
key func() string
value func() string
}
// Send sends the HTTP request using the fasthttp client with a specified timeout.
// It returns the HTTP response or an error if the request fails or times out.
func (r *Request) Send(ctx context.Context, timeout time.Duration) (*fasthttp.Response, error) {
client := r.getClient()
request := r.getRequest()
defer fasthttp.ReleaseRequest(request)
response := fasthttp.AcquireResponse()
ch := make(chan error)
go func() {
err := client.DoTimeout(request, response, timeout)
ch <- err
}()
select {
case err := <-ch:
if err != nil {
fasthttp.ReleaseResponse(response)
return nil, err
}
return response, nil
case <-time.After(timeout):
fasthttp.ReleaseResponse(response)
return nil, types.ErrTimeout
case <-ctx.Done():
return nil, types.ErrInterrupt
}
}
// newRequest creates a new Request instance based on the provided configuration and clients.
// It initializes a random number generator using the current time and a unique identifier (uid).
// Depending on the number of clients provided, it sets up a function to select the appropriate client.
// It also sets up a function to generate the request based on the provided configuration.
func newRequest(
requestConfig config.RequestConfig,
clients []*fasthttp.HostClient,
uid int64,
) *Request {
localRand := rand.New(rand.NewSource(time.Now().UnixNano() + uid))
clientsCount := len(clients)
if clientsCount < 1 {
panic("no clients")
}
getClient := ClientGeneratorFunc(nil)
if clientsCount == 1 {
getClient = getSharedClientFuncSingle(clients[0])
} else {
getClient = getSharedClientFuncMultiple(clients, localRand)
}
getRequest := getRequestGeneratorFunc(
requestConfig.URL,
requestConfig.Params,
requestConfig.Headers,
requestConfig.Cookies,
requestConfig.Method,
requestConfig.Body,
localRand,
)
requests := &Request{
getClient: getClient,
getRequest: getRequest,
}
return requests
}
// getRequestGeneratorFunc returns a RequestGeneratorFunc which generates HTTP requests with the specified parameters.
// The function uses a local random number generator to select bodies, headers, cookies, and parameters if multiple options are provided.
func getRequestGeneratorFunc(
URL url.URL,
params types.Params,
headers types.Headers,
cookies types.Cookies,
method string,
bodies []string,
localRand *rand.Rand,
) RequestGeneratorFunc {
getParams := getKeyValueGeneratorFunc(params, localRand)
getHeaders := getKeyValueGeneratorFunc(headers, localRand)
getCookies := getKeyValueGeneratorFunc(cookies, localRand)
getBody := getBodyValueFunc(bodies, utils.NewFuncMapGenerator(localRand), localRand)
return func() *fasthttp.Request {
body, contentType := getBody()
headers := getHeaders()
if contentType != "" {
headers = append(headers, types.KeyValue[string, string]{
Key: "Content-Type",
Value: contentType,
})
}
return newFasthttpRequest(
URL,
getParams(),
headers,
getCookies(),
method,
body,
)
}
}
// newFasthttpRequest creates a new fasthttp.Request object with the provided parameters.
// It sets the request URI, host header, headers, cookies, params, method, and body.
func newFasthttpRequest(
URL url.URL,
params []types.KeyValue[string, string],
headers []types.KeyValue[string, string],
cookies []types.KeyValue[string, string],
method string,
body string,
) *fasthttp.Request {
request := fasthttp.AcquireRequest()
request.SetRequestURI(URL.Path)
// Set the host of the request to the host header
// If the host header is not set, the request will fail
// If there is host header in the headers, it will be overwritten
request.Header.SetHost(URL.Host)
setRequestParams(request, params)
setRequestHeaders(request, headers)
setRequestCookies(request, cookies)
setRequestMethod(request, method)
setRequestBody(request, body)
if URL.Scheme == "https" {
request.URI().SetScheme("https")
}
return request
}
// setRequestParams adds the query parameters of the given request based on the provided key-value pairs.
func setRequestParams(req *fasthttp.Request, params []types.KeyValue[string, string]) {
for _, param := range params {
req.URI().QueryArgs().Add(param.Key, param.Value)
}
}
// setRequestHeaders adds the headers of the given request with the provided key-value pairs.
func setRequestHeaders(req *fasthttp.Request, headers []types.KeyValue[string, string]) {
for _, header := range headers {
req.Header.Add(header.Key, header.Value)
}
}
// setRequestCookies adds the cookies of the given request with the provided key-value pairs.
func setRequestCookies(req *fasthttp.Request, cookies []types.KeyValue[string, string]) {
for _, cookie := range cookies {
req.Header.Add("Cookie", cookie.Key+"="+cookie.Value)
}
}
// setRequestMethod sets the HTTP request method for the given request.
func setRequestMethod(req *fasthttp.Request, method string) {
req.Header.SetMethod(method)
}
// setRequestBody sets the request body of the given fasthttp.Request object.
// The body parameter is a string that will be converted to a byte slice and set as the request body.
func setRequestBody(req *fasthttp.Request, body string) {
req.SetBody([]byte(body))
}
// getKeyValueGeneratorFunc creates a function that generates key-value pairs for HTTP requests.
// It takes a slice of key-value pairs where each key maps to a slice of possible values,
// and a random number generator.
//
// If any key has multiple possible values, the function will randomly select one value for each
// call (using the provided random number generator). If all keys have at most one value, the
// function will always return the same set of key-value pairs for efficiency.
func getKeyValueGeneratorFunc[
T []types.KeyValue[string, string],
](
keyValueSlice []types.KeyValue[string, []string],
localRand *rand.Rand,
) func() T {
keyValueGenerators := make([]keyValueGenerator, len(keyValueSlice))
funcMap := *utils.NewFuncMapGenerator(localRand).GetFuncMap()
for i, kv := range keyValueSlice {
keyValueGenerators[i] = keyValueGenerator{
key: getKeyFunc(kv.Key, funcMap),
value: getValueFunc(kv.Value, funcMap, localRand),
}
}
return func() T {
keyValues := make(T, len(keyValueGenerators))
for i, keyValue := range keyValueGenerators {
keyValues[i] = types.KeyValue[string, string]{
Key: keyValue.key(),
Value: keyValue.value(),
}
}
return keyValues
}
}
// getKeyFunc creates a function that processes a key string through Go's template engine.
// It takes a key string and a template.FuncMap containing the available template functions.
//
// The returned function, when called, will execute the template with the given key and return
// the processed string result. If template parsing fails, the returned function will always
// return an empty string.
//
// This enables dynamic generation of keys that can include template directives and functions.
func getKeyFunc(key string, funcMap template.FuncMap) func() string {
t, err := template.New("default").Funcs(funcMap).Parse(key)
if err != nil {
return func() string { return "" }
}
return func() string {
var buf bytes.Buffer
_ = t.Execute(&buf, nil)
return buf.String()
}
}
// getValueFunc creates a function that randomly selects and processes a value from a slice of strings
// through Go's template engine.
//
// Parameters:
// - values: A slice of string templates that can contain template directives
// - funcMap: A template.FuncMap containing all available template functions
// - localRand: A random number generator for consistent randomization
//
// The returned function, when called, will:
// 1. Select a random template from the values slice
// 2. Execute the selected template
// 3. Return the processed string result
//
// If a selected template is nil (due to earlier parsing failure), the function will return an empty string.
// This enables dynamic generation of values with randomized selection from multiple templates.
func getValueFunc(
values []string,
funcMap template.FuncMap,
localRand *rand.Rand,
) func() string {
templates := make([]*template.Template, len(values))
for i, value := range values {
t, err := template.New("default").Funcs(funcMap).Parse(value)
if err != nil {
templates[i] = nil
}
templates[i] = t
}
randomTemplateFunc := utils.RandomValueCycle(templates, localRand)
return func() string {
if tmpl := randomTemplateFunc(); tmpl == nil {
return ""
} else {
var buf bytes.Buffer
_ = tmpl.Execute(&buf, nil)
return buf.String()
}
}
}
// getBodyValueFunc creates a function that randomly selects and processes a request body from a slice of templates.
// It returns a closure that generates both the body content and the appropriate Content-Type header value.
//
// Parameters:
// - values: A slice of string templates that can contain template directives for request bodies
// - funcMapGenerator: Provides template functions and content type information
// - localRand: A random number generator for consistent randomization
//
// The returned function, when called, will:
// 1. Select a random body template from the values slice
// 2. Execute the selected template with available template functions
// 3. Return both the processed body string and the appropriate Content-Type header value
//
// If the selected template is nil (due to earlier parsing failure), the function will return
// empty strings for both the body and Content-Type.
//
// This enables dynamic generation of request bodies with proper content type headers.
func getBodyValueFunc(
values []string,
funcMapGenerator *utils.FuncMapGenerator,
localRand *rand.Rand,
) func() (string, string) {
templates := make([]*template.Template, len(values))
for i, value := range values {
t, err := template.New("default").Funcs(*funcMapGenerator.GetFuncMap()).Parse(value)
if err != nil {
templates[i] = nil
}
templates[i] = t
}
randomTemplateFunc := utils.RandomValueCycle(templates, localRand)
return func() (string, string) {
if tmpl := randomTemplateFunc(); tmpl == nil {
return "", ""
} else {
var buf bytes.Buffer
_ = tmpl.Execute(&buf, nil)
return buf.String(), funcMapGenerator.GetBodyDataHeader()
}
}
}

View File

@@ -1,94 +0,0 @@
package requests
import (
"os"
"time"
"github.com/aykhans/dodo/types"
"github.com/aykhans/dodo/utils"
"github.com/jedib0t/go-pretty/v6/table"
)
type Response struct {
Response string
Time time.Duration
}
type Responses []Response
// Print prints the responses in a tabular format, including information such as
// response count, minimum time, maximum time, average time, and latency percentiles.
func (responses Responses) Print() {
if len(responses) == 0 {
return
}
mergedResponses := make(map[string]types.Durations)
totalDurations := make(types.Durations, len(responses))
var totalSum time.Duration
totalCount := len(responses)
for i, response := range responses {
totalSum += response.Time
totalDurations[i] = response.Time
mergedResponses[response.Response] = append(
mergedResponses[response.Response],
response.Time,
)
}
t := table.NewWriter()
t.SetOutputMirror(os.Stdout)
t.SetStyle(table.StyleLight)
t.SetColumnConfigs([]table.ColumnConfig{
{Number: 1, WidthMax: 40},
})
t.AppendHeader(table.Row{
"Response",
"Count",
"Min",
"Max",
"Average",
"P90",
"P95",
"P99",
})
var roundPrecision int64 = 4
for key, durations := range mergedResponses {
durations.Sort()
durationsLen := len(durations)
durationsLenAsFloat := float64(durationsLen - 1)
t.AppendRow(table.Row{
key,
durationsLen,
utils.DurationRoundBy(*durations.First(), roundPrecision),
utils.DurationRoundBy(*durations.Last(), roundPrecision),
utils.DurationRoundBy(durations.Avg(), roundPrecision),
utils.DurationRoundBy(durations[int(0.90*durationsLenAsFloat)], roundPrecision),
utils.DurationRoundBy(durations[int(0.95*durationsLenAsFloat)], roundPrecision),
utils.DurationRoundBy(durations[int(0.99*durationsLenAsFloat)], roundPrecision),
})
t.AppendSeparator()
}
if len(mergedResponses) > 1 {
totalDurations.Sort()
allDurationsLenAsFloat := float64(len(totalDurations) - 1)
t.AppendRow(table.Row{
"Total",
totalCount,
utils.DurationRoundBy(totalDurations[0], roundPrecision),
utils.DurationRoundBy(totalDurations[len(totalDurations)-1], roundPrecision),
utils.DurationRoundBy(totalSum/time.Duration(totalCount), roundPrecision), // Average
utils.DurationRoundBy(totalDurations[int(0.90*allDurationsLenAsFloat)], roundPrecision),
utils.DurationRoundBy(totalDurations[int(0.95*allDurationsLenAsFloat)], roundPrecision),
utils.DurationRoundBy(totalDurations[int(0.99*allDurationsLenAsFloat)], roundPrecision),
})
}
t.Render()
}

View File

@@ -1,211 +0,0 @@
package requests
import (
"context"
"strconv"
"sync"
"time"
"github.com/aykhans/dodo/config"
"github.com/aykhans/dodo/types"
"github.com/aykhans/dodo/utils"
"github.com/valyala/fasthttp"
)
// Run executes the main logic for processing requests based on the provided configuration.
// It initializes clients based on the request configuration and releases the dodos.
// If the context is canceled and no responses are collected, it returns an interrupt error.
//
// Parameters:
// - ctx: The context for managing request lifecycle and cancellation.
// - requestConfig: The configuration for the request, including timeout, proxies, and other settings.
func Run(ctx context.Context, requestConfig *config.RequestConfig) (Responses, error) {
if requestConfig.Duration > 0 {
var cancel context.CancelFunc
ctx, cancel = context.WithTimeout(ctx, requestConfig.Duration)
defer cancel()
}
clients := getClients(
ctx,
requestConfig.Timeout,
requestConfig.Proxies,
requestConfig.GetMaxConns(fasthttp.DefaultMaxConnsPerHost),
requestConfig.URL,
requestConfig.SkipVerify,
)
if clients == nil {
return nil, types.ErrInterrupt
}
responses := releaseDodos(ctx, requestConfig, clients)
if ctx.Err() != nil && len(responses) == 0 {
return nil, types.ErrInterrupt
}
return responses, nil
}
// releaseDodos sends requests concurrently using multiple dodos (goroutines) and returns the aggregated responses.
//
// The function performs the following steps:
// 1. Initializes wait groups and other necessary variables.
// 2. Starts a goroutine to stream progress updates.
// 3. Distributes the total request count among the dodos.
// 4. Starts a goroutine for each dodo to send requests concurrently.
// 5. Waits for all dodos to complete their requests.
// 6. Cancels the progress streaming context and waits for the progress goroutine to finish.
// 7. Flattens and returns the aggregated responses.
func releaseDodos(
ctx context.Context,
requestConfig *config.RequestConfig,
clients []*fasthttp.HostClient,
) Responses {
var (
wg sync.WaitGroup
streamWG sync.WaitGroup
requestCountPerDodo uint
dodosCount = requestConfig.GetValidDodosCountForRequests()
responses = make([][]Response, dodosCount)
increase = make(chan int64, requestConfig.RequestCount)
)
wg.Add(int(dodosCount))
streamWG.Add(1)
streamCtx, streamCtxCancel := context.WithCancel(ctx)
go streamProgress(streamCtx, &streamWG, requestConfig.RequestCount, "Dodos Working🔥", increase)
if requestConfig.RequestCount == 0 {
for i := range dodosCount {
go sendRequest(
ctx,
newRequest(*requestConfig, clients, int64(i)),
requestConfig.Timeout,
&responses[i],
increase,
&wg,
)
}
} else {
for i := range dodosCount {
if i+1 == dodosCount {
requestCountPerDodo = requestConfig.RequestCount - (i * requestConfig.RequestCount / dodosCount)
} else {
requestCountPerDodo = ((i + 1) * requestConfig.RequestCount / dodosCount) -
(i * requestConfig.RequestCount / dodosCount)
}
go sendRequestByCount(
ctx,
newRequest(*requestConfig, clients, int64(i)),
requestConfig.Timeout,
requestCountPerDodo,
&responses[i],
increase,
&wg,
)
}
}
wg.Wait()
streamCtxCancel()
streamWG.Wait()
return utils.Flatten(responses)
}
// sendRequestByCount sends a specified number of HTTP requests concurrently with a given timeout.
// It appends the responses to the provided responseData slice and sends the count of completed requests
// to the increase channel. The function terminates early if the context is canceled or if a custom
// interrupt error is encountered.
func sendRequestByCount(
ctx context.Context,
request *Request,
timeout time.Duration,
requestCount uint,
responseData *[]Response,
increase chan<- int64,
wg *sync.WaitGroup,
) {
defer wg.Done()
for range requestCount {
if ctx.Err() != nil {
return
}
func() {
startTime := time.Now()
response, err := request.Send(ctx, timeout)
completedTime := time.Since(startTime)
if response != nil {
defer fasthttp.ReleaseResponse(response)
}
if err != nil {
if err == types.ErrInterrupt {
return
}
*responseData = append(*responseData, Response{
Response: err.Error(),
Time: completedTime,
})
increase <- 1
return
}
*responseData = append(*responseData, Response{
Response: strconv.Itoa(response.StatusCode()),
Time: completedTime,
})
increase <- 1
}()
}
}
// sendRequest continuously sends HTTP requests until the context is canceled.
// It records the response status code or error message along with the response time,
// and signals each completed request through the increase channel.
func sendRequest(
ctx context.Context,
request *Request,
timeout time.Duration,
responseData *[]Response,
increase chan<- int64,
wg *sync.WaitGroup,
) {
defer wg.Done()
for {
if ctx.Err() != nil {
return
}
func() {
startTime := time.Now()
response, err := request.Send(ctx, timeout)
completedTime := time.Since(startTime)
if response != nil {
defer fasthttp.ReleaseResponse(response)
}
if err != nil {
if err == types.ErrInterrupt {
return
}
*responseData = append(*responseData, Response{
Response: err.Error(),
Time: completedTime,
})
increase <- 1
return
}
*responseData = append(*responseData, Response{
Response: strconv.Itoa(response.StatusCode()),
Time: completedTime,
})
increase <- 1
}()
}
}

View File

@@ -1,94 +0,0 @@
package types
import (
"bytes"
"encoding/json"
"fmt"
"github.com/jedib0t/go-pretty/v6/text"
)
type Body []string
func (body Body) String() string {
var buffer bytes.Buffer
if len(body) == 0 {
return buffer.String()
}
if len(body) == 1 {
buffer.WriteString(body[0])
return buffer.String()
}
buffer.WriteString(text.FgBlue.Sprint("Random") + "[\n")
indent := " "
displayLimit := 5
for i, item := range body[:min(len(body), displayLimit)] {
if i > 0 {
buffer.WriteString(",\n")
}
buffer.WriteString(indent + item)
}
// Add remaining count if there are more items
if remainingValues := len(body) - displayLimit; remainingValues > 0 {
buffer.WriteString(",\n" + indent + text.FgGreen.Sprintf("+%d bodies", remainingValues))
}
buffer.WriteString("\n]")
return buffer.String()
}
func (body *Body) UnmarshalJSON(b []byte) error {
var data any
if err := json.Unmarshal(b, &data); err != nil {
return err
}
switch v := data.(type) {
case string:
*body = []string{v}
case []any:
var slice []string
for _, item := range v {
slice = append(slice, fmt.Sprintf("%v", item))
}
*body = slice
default:
return fmt.Errorf("invalid type for Body: %T (should be string or []string)", v)
}
return nil
}
func (body *Body) UnmarshalYAML(unmarshal func(any) error) error {
var data any
if err := unmarshal(&data); err != nil {
return err
}
switch v := data.(type) {
case string:
*body = []string{v}
case []any:
var slice []string
for _, item := range v {
slice = append(slice, fmt.Sprintf("%v", item))
}
*body = slice
default:
return fmt.Errorf("invalid type for Body: %T (should be string or []string)", v)
}
return nil
}
func (body *Body) Set(value string) error {
*body = append(*body, value)
return nil
}

View File

@@ -1,32 +0,0 @@
package types
import "strings"
type FileLocationType int
const (
FileLocationTypeLocal FileLocationType = iota
FileLocationTypeRemoteHTTP
)
type ConfigFile string
func (configFile ConfigFile) String() string {
return string(configFile)
}
func (configFile ConfigFile) LocationType() FileLocationType {
if strings.HasPrefix(string(configFile), "http://") || strings.HasPrefix(string(configFile), "https://") {
return FileLocationTypeRemoteHTTP
}
return FileLocationTypeLocal
}
func (configFile ConfigFile) Extension() string {
i := strings.LastIndex(configFile.String(), ".")
if i == -1 {
return ""
}
return configFile.String()[i+1:]
}

View File

@@ -1,139 +0,0 @@
package types
import (
"bytes"
"encoding/json"
"fmt"
"strings"
"github.com/jedib0t/go-pretty/v6/text"
)
type Cookies []KeyValue[string, []string]
func (cookies Cookies) String() string {
var buffer bytes.Buffer
if len(cookies) == 0 {
return buffer.String()
}
indent := " "
displayLimit := 3
for i, item := range cookies[:min(len(cookies), displayLimit)] {
if i > 0 {
buffer.WriteString(",\n")
}
if len(item.Value) == 1 {
buffer.WriteString(item.Key + ": " + item.Value[0])
continue
}
buffer.WriteString(item.Key + ": " + text.FgBlue.Sprint("Random") + "[\n")
for ii, v := range item.Value[:min(len(item.Value), displayLimit)] {
if ii == len(item.Value)-1 {
buffer.WriteString(indent + v + "\n")
} else {
buffer.WriteString(indent + v + ",\n")
}
}
// Add remaining values count if needed
if remainingValues := len(item.Value) - displayLimit; remainingValues > 0 {
buffer.WriteString(indent + text.FgGreen.Sprintf("+%d values", remainingValues) + "\n")
}
buffer.WriteString("]")
}
// Add remaining key-value pairs count if needed
if remainingPairs := len(cookies) - displayLimit; remainingPairs > 0 {
buffer.WriteString(",\n" + text.FgGreen.Sprintf("+%d cookies", remainingPairs))
}
return buffer.String()
}
func (cookies *Cookies) AppendByKey(key, value string) {
if item := cookies.GetValue(key); item != nil {
*item = append(*item, value)
} else {
*cookies = append(*cookies, KeyValue[string, []string]{Key: key, Value: []string{value}})
}
}
func (cookies Cookies) GetValue(key string) *[]string {
for i := range cookies {
if cookies[i].Key == key {
return &cookies[i].Value
}
}
return nil
}
func (cookies *Cookies) UnmarshalJSON(b []byte) error {
var data []map[string]any
if err := json.Unmarshal(b, &data); err != nil {
return err
}
for _, item := range data {
for key, value := range item {
switch parsedValue := value.(type) {
case string:
*cookies = append(*cookies, KeyValue[string, []string]{Key: key, Value: []string{parsedValue}})
case []any:
parsedStr := make([]string, len(parsedValue))
for i, item := range parsedValue {
parsedStr[i] = fmt.Sprintf("%v", item)
}
*cookies = append(*cookies, KeyValue[string, []string]{Key: key, Value: parsedStr})
default:
return fmt.Errorf("unsupported type for cookies expected string or []string, got %T", parsedValue)
}
}
}
return nil
}
func (cookies *Cookies) UnmarshalYAML(unmarshal func(any) error) error {
var raw []map[string]any
if err := unmarshal(&raw); err != nil {
return err
}
for _, param := range raw {
for key, value := range param {
switch parsed := value.(type) {
case string:
*cookies = append(*cookies, KeyValue[string, []string]{Key: key, Value: []string{parsed}})
case []any:
var values []string
for _, v := range parsed {
if str, ok := v.(string); ok {
values = append(values, str)
}
}
*cookies = append(*cookies, KeyValue[string, []string]{Key: key, Value: values})
}
}
}
return nil
}
func (cookies *Cookies) Set(value string) error {
parts := strings.SplitN(value, "=", 2)
switch len(parts) {
case 0:
cookies.AppendByKey("", "")
case 1:
cookies.AppendByKey(parts[0], "")
case 2:
cookies.AppendByKey(parts[0], parts[1])
}
return nil
}

View File

@@ -1,57 +0,0 @@
package types
import (
"encoding/json"
"errors"
"time"
)
type Duration struct {
time.Duration
}
func (duration *Duration) UnmarshalJSON(b []byte) error {
var v any
if err := json.Unmarshal(b, &v); err != nil {
return err
}
switch value := v.(type) {
case float64:
duration.Duration = time.Duration(value)
return nil
case string:
var err error
duration.Duration, err = time.ParseDuration(value)
if err != nil {
return errors.New("Duration is invalid (e.g. 400ms, 1s, 5m, 1h)")
}
return nil
default:
return errors.New("Duration is invalid (e.g. 400ms, 1s, 5m, 1h)")
}
}
func (duration Duration) MarshalJSON() ([]byte, error) {
return json.Marshal(duration.String())
}
func (duration *Duration) UnmarshalYAML(unmarshal func(any) error) error {
var v any
if err := unmarshal(&v); err != nil {
return err
}
switch value := v.(type) {
case float64:
duration.Duration = time.Duration(value)
return nil
case string:
var err error
duration.Duration, err = time.ParseDuration(value)
if err != nil {
return errors.New("Duration is invalid (e.g. 400ms, 1s, 5m, 1h)")
}
return nil
default:
return errors.New("Duration is invalid (e.g. 400ms, 1s, 5m, 1h)")
}
}

View File

@@ -1,40 +0,0 @@
package types
import (
"slices"
"sort"
"time"
)
type Durations []time.Duration
func (d Durations) Sort(ascending ...bool) {
// If ascending is provided and is false, sort in descending order
if len(ascending) > 0 && ascending[0] == false {
sort.Slice(d, func(i, j int) bool {
return d[i] > d[j]
})
} else { // Otherwise, sort in ascending order
slices.Sort(d)
}
}
func (d Durations) First() *time.Duration {
return &d[0]
}
func (d Durations) Last() *time.Duration {
return &d[len(d)-1]
}
func (d Durations) Sum() time.Duration {
sum := time.Duration(0)
for _, duration := range d {
sum += duration
}
return sum
}
func (d Durations) Avg() time.Duration {
return d.Sum() / time.Duration(len(d))
}

View File

@@ -1,10 +0,0 @@
package types
import (
"errors"
)
var (
ErrInterrupt = errors.New("interrupted")
ErrTimeout = errors.New("timeout")
)

View File

@@ -1,156 +0,0 @@
package types
import (
"bytes"
"encoding/json"
"fmt"
"strings"
"github.com/jedib0t/go-pretty/v6/text"
)
type Headers []KeyValue[string, []string]
func (headers Headers) String() string {
var buffer bytes.Buffer
if len(headers) == 0 {
return buffer.String()
}
indent := " "
displayLimit := 3
for i, item := range headers[:min(len(headers), displayLimit)] {
if i > 0 {
buffer.WriteString(",\n")
}
if len(item.Value) == 1 {
buffer.WriteString(item.Key + ": " + item.Value[0])
continue
}
buffer.WriteString(item.Key + ": " + text.FgBlue.Sprint("Random") + "[\n")
for ii, v := range item.Value[:min(len(item.Value), displayLimit)] {
if ii == len(item.Value)-1 {
buffer.WriteString(indent + v + "\n")
} else {
buffer.WriteString(indent + v + ",\n")
}
}
// Add remaining values count if needed
if remainingValues := len(item.Value) - displayLimit; remainingValues > 0 {
buffer.WriteString(indent + text.FgGreen.Sprintf("+%d values", remainingValues) + "\n")
}
buffer.WriteString("]")
}
// Add remaining key-value pairs count if needed
if remainingPairs := len(headers) - displayLimit; remainingPairs > 0 {
buffer.WriteString(",\n" + text.FgGreen.Sprintf("+%d headers", remainingPairs))
}
return buffer.String()
}
func (headers *Headers) AppendByKey(key, value string) {
if item := headers.GetValue(key); item != nil {
*item = append(*item, value)
} else {
*headers = append(*headers, KeyValue[string, []string]{Key: key, Value: []string{value}})
}
}
func (headers Headers) GetValue(key string) *[]string {
for i := range headers {
if headers[i].Key == key {
return &headers[i].Value
}
}
return nil
}
func (headers Headers) Has(key string) bool {
for i := range headers {
if headers[i].Key == key {
return true
}
}
return false
}
func (headers *Headers) UnmarshalJSON(b []byte) error {
var data []map[string]any
if err := json.Unmarshal(b, &data); err != nil {
return err
}
for _, item := range data {
for key, value := range item {
switch parsedValue := value.(type) {
case string:
*headers = append(*headers, KeyValue[string, []string]{Key: key, Value: []string{parsedValue}})
case []any:
parsedStr := make([]string, len(parsedValue))
for i, item := range parsedValue {
parsedStr[i] = fmt.Sprintf("%v", item)
}
*headers = append(*headers, KeyValue[string, []string]{Key: key, Value: parsedStr})
default:
return fmt.Errorf("unsupported type for headers expected string or []string, got %T", parsedValue)
}
}
}
return nil
}
func (headers *Headers) UnmarshalYAML(unmarshal func(any) error) error {
var raw []map[string]any
if err := unmarshal(&raw); err != nil {
return err
}
for _, param := range raw {
for key, value := range param {
switch parsed := value.(type) {
case string:
*headers = append(*headers, KeyValue[string, []string]{Key: key, Value: []string{parsed}})
case []any:
var values []string
for _, v := range parsed {
if str, ok := v.(string); ok {
values = append(values, str)
}
}
*headers = append(*headers, KeyValue[string, []string]{Key: key, Value: values})
}
}
}
return nil
}
func (headers *Headers) Set(value string) error {
parts := strings.SplitN(value, ":", 2)
switch len(parts) {
case 0:
headers.AppendByKey("", "")
case 1:
headers.AppendByKey(parts[0], "")
case 2:
headers.AppendByKey(parts[0], parts[1])
}
return nil
}
func (headers *Headers) SetIfNotExists(key string, value string) bool {
if headers.Has(key) {
return false
}
*headers = append(*headers, KeyValue[string, []string]{Key: key, Value: []string{value}})
return true
}

View File

@@ -1,6 +0,0 @@
package types
type KeyValue[K comparable, V any] struct {
Key K
Value V
}

View File

@@ -1,139 +0,0 @@
package types
import (
"bytes"
"encoding/json"
"fmt"
"strings"
"github.com/jedib0t/go-pretty/v6/text"
)
type Params []KeyValue[string, []string]
func (params Params) String() string {
var buffer bytes.Buffer
if len(params) == 0 {
return buffer.String()
}
indent := " "
displayLimit := 3
for i, item := range params[:min(len(params), displayLimit)] {
if i > 0 {
buffer.WriteString(",\n")
}
if len(item.Value) == 1 {
buffer.WriteString(item.Key + ": " + item.Value[0])
continue
}
buffer.WriteString(item.Key + ": " + text.FgBlue.Sprint("Random") + "[\n")
for ii, v := range item.Value[:min(len(item.Value), displayLimit)] {
if ii == len(item.Value)-1 {
buffer.WriteString(indent + v + "\n")
} else {
buffer.WriteString(indent + v + ",\n")
}
}
// Add remaining values count if needed
if remainingValues := len(item.Value) - displayLimit; remainingValues > 0 {
buffer.WriteString(indent + text.FgGreen.Sprintf("+%d values", remainingValues) + "\n")
}
buffer.WriteString("]")
}
// Add remaining key-value pairs count if needed
if remainingPairs := len(params) - displayLimit; remainingPairs > 0 {
buffer.WriteString(",\n" + text.FgGreen.Sprintf("+%d params", remainingPairs))
}
return buffer.String()
}
func (params *Params) AppendByKey(key, value string) {
if item := params.GetValue(key); item != nil {
*item = append(*item, value)
} else {
*params = append(*params, KeyValue[string, []string]{Key: key, Value: []string{value}})
}
}
func (params Params) GetValue(key string) *[]string {
for i := range params {
if params[i].Key == key {
return &params[i].Value
}
}
return nil
}
func (params *Params) UnmarshalJSON(b []byte) error {
var data []map[string]any
if err := json.Unmarshal(b, &data); err != nil {
return err
}
for _, item := range data {
for key, value := range item {
switch parsedValue := value.(type) {
case string:
*params = append(*params, KeyValue[string, []string]{Key: key, Value: []string{parsedValue}})
case []any:
parsedStr := make([]string, len(parsedValue))
for i, item := range parsedValue {
parsedStr[i] = fmt.Sprintf("%v", item)
}
*params = append(*params, KeyValue[string, []string]{Key: key, Value: parsedStr})
default:
return fmt.Errorf("unsupported type for params expected string or []string, got %T", parsedValue)
}
}
}
return nil
}
func (params *Params) UnmarshalYAML(unmarshal func(any) error) error {
var raw []map[string]any
if err := unmarshal(&raw); err != nil {
return err
}
for _, param := range raw {
for key, value := range param {
switch parsed := value.(type) {
case string:
*params = append(*params, KeyValue[string, []string]{Key: key, Value: []string{parsed}})
case []any:
var values []string
for _, v := range parsed {
if str, ok := v.(string); ok {
values = append(values, str)
}
}
*params = append(*params, KeyValue[string, []string]{Key: key, Value: values})
}
}
}
return nil
}
func (params *Params) Set(value string) error {
parts := strings.SplitN(value, "=", 2)
switch len(parts) {
case 0:
params.AppendByKey("", "")
case 1:
params.AppendByKey(parts[0], "")
case 2:
params.AppendByKey(parts[0], parts[1])
}
return nil
}

View File

@@ -1,116 +0,0 @@
package types
import (
"bytes"
"encoding/json"
"fmt"
"net/url"
"github.com/jedib0t/go-pretty/v6/text"
)
type Proxies []url.URL
func (proxies Proxies) String() string {
var buffer bytes.Buffer
if len(proxies) == 0 {
return buffer.String()
}
if len(proxies) == 1 {
buffer.WriteString(proxies[0].String())
return buffer.String()
}
buffer.WriteString(text.FgBlue.Sprint("Random") + "[\n")
indent := " "
displayLimit := 5
for i, item := range proxies[:min(len(proxies), displayLimit)] {
if i > 0 {
buffer.WriteString(",\n")
}
buffer.WriteString(indent + item.String())
}
// Add remaining count if there are more items
if remainingValues := len(proxies) - displayLimit; remainingValues > 0 {
buffer.WriteString(",\n" + indent + text.FgGreen.Sprintf("+%d proxies", remainingValues))
}
buffer.WriteString("\n]")
return buffer.String()
}
func (proxies *Proxies) UnmarshalJSON(b []byte) error {
var data any
if err := json.Unmarshal(b, &data); err != nil {
return err
}
switch v := data.(type) {
case string:
parsed, err := url.Parse(v)
if err != nil {
return err
}
*proxies = []url.URL{*parsed}
case []any:
var urls []url.URL
for _, item := range v {
url, err := url.Parse(item.(string))
if err != nil {
return err
}
urls = append(urls, *url)
}
*proxies = urls
default:
return fmt.Errorf("invalid type for Body: %T (should be URL or []URL)", v)
}
return nil
}
func (proxies *Proxies) UnmarshalYAML(unmarshal func(any) error) error {
var data any
if err := unmarshal(&data); err != nil {
return err
}
switch v := data.(type) {
case string:
parsed, err := url.Parse(v)
if err != nil {
return err
}
*proxies = []url.URL{*parsed}
case []any:
var urls []url.URL
for _, item := range v {
url, err := url.Parse(item.(string))
if err != nil {
return err
}
urls = append(urls, *url)
}
*proxies = urls
default:
return fmt.Errorf("invalid type for Body: %T (should be URL or []URL)", v)
}
return nil
}
func (proxies *Proxies) Set(value string) error {
parsedURL, err := url.Parse(value)
if err != nil {
return err
}
*proxies = append(*proxies, *parsedURL)
return nil
}

View File

@@ -1,59 +0,0 @@
package types
import (
"encoding/json"
"errors"
"net/url"
)
type RequestURL struct {
url.URL
}
func (requestURL *RequestURL) UnmarshalJSON(data []byte) error {
var urlStr string
if err := json.Unmarshal(data, &urlStr); err != nil {
return err
}
parsedURL, err := url.Parse(urlStr)
if err != nil {
return errors.New("request URL is invalid")
}
requestURL.URL = *parsedURL
return nil
}
func (requestURL *RequestURL) UnmarshalYAML(unmarshal func(any) error) error {
var urlStr string
if err := unmarshal(&urlStr); err != nil {
return err
}
parsedURL, err := url.Parse(urlStr)
if err != nil {
return errors.New("request URL is invalid")
}
requestURL.URL = *parsedURL
return nil
}
func (requestURL RequestURL) MarshalJSON() ([]byte, error) {
return json.Marshal(requestURL.URL.String())
}
func (requestURL RequestURL) String() string {
return requestURL.URL.String()
}
func (requestURL *RequestURL) Set(value string) error {
parsedURL, err := url.Parse(value)
if err != nil {
return err
}
requestURL.URL = *parsedURL
return nil
}

View File

@@ -1,57 +0,0 @@
package types
import (
"encoding/json"
"errors"
"time"
)
type Timeout struct {
time.Duration
}
func (timeout *Timeout) UnmarshalJSON(b []byte) error {
var v any
if err := json.Unmarshal(b, &v); err != nil {
return err
}
switch value := v.(type) {
case float64:
timeout.Duration = time.Duration(value)
return nil
case string:
var err error
timeout.Duration, err = time.ParseDuration(value)
if err != nil {
return errors.New("Timeout is invalid (e.g. 400ms, 1s, 5m, 1h)")
}
return nil
default:
return errors.New("Timeout is invalid (e.g. 400ms, 1s, 5m, 1h)")
}
}
func (timeout Timeout) MarshalJSON() ([]byte, error) {
return json.Marshal(timeout.String())
}
func (timeout *Timeout) UnmarshalYAML(unmarshal func(any) error) error {
var v any
if err := unmarshal(&v); err != nil {
return err
}
switch value := v.(type) {
case float64:
timeout.Duration = time.Duration(value)
return nil
case string:
var err error
timeout.Duration, err = time.ParseDuration(value)
if err != nil {
return errors.New("Timeout is invalid (e.g. 400ms, 1s, 5m, 1h)")
}
return nil
default:
return errors.New("Timeout is invalid (e.g. 400ms, 1s, 5m, 1h)")
}
}

View File

@@ -1,10 +0,0 @@
package utils
func IsNilOrZero[T comparable](value *T) bool {
if value == nil {
return true
}
var zero T
return *value == zero
}

View File

@@ -1,5 +0,0 @@
package utils
func ToPtr[T any](value T) *T {
return &value
}

View File

@@ -1,21 +0,0 @@
package utils
type Number interface {
int | int8 | int16 | int32 | int64
}
func NumLen[T Number](n T) T {
if n < 0 {
n = -n
}
if n == 0 {
return 1
}
var count T = 0
for n > 0 {
n /= 10
count++
}
return count
}

View File

@@ -1,24 +0,0 @@
package utils
import (
"fmt"
"os"
"github.com/jedib0t/go-pretty/v6/text"
)
func PrintErr(err error) {
fmt.Fprintln(os.Stderr, text.FgRed.Sprint(err.Error()))
}
func PrintErrAndExit(err error) {
if err != nil {
PrintErr(err)
os.Exit(1)
}
}
func PrintAndExit(message string) {
fmt.Println(message)
os.Exit(0)
}

View File

@@ -1,42 +0,0 @@
package utils
import "math/rand"
func Flatten[T any](nested [][]T) []T {
flattened := make([]T, 0)
for _, n := range nested {
flattened = append(flattened, n...)
}
return flattened
}
// RandomValueCycle returns a function that cycles through the provided values in a pseudo-random order.
// Each value in the input slice will be returned before any value is repeated.
// If the input slice is empty, the returned function will always return the zero value of type T.
// If the input slice contains only one element, that element is always returned.
// This function is not thread-safe and should not be called concurrently.
func RandomValueCycle[T any](values []T, localRand *rand.Rand) func() T {
switch valuesLen := len(values); valuesLen {
case 0:
var zero T
return func() T { return zero }
case 1:
return func() T { return values[0] }
default:
currentIndex := localRand.Intn(valuesLen)
stopIndex := currentIndex
return func() T {
value := values[currentIndex]
currentIndex++
if currentIndex == valuesLen {
currentIndex = 0
}
if currentIndex == stopIndex {
currentIndex = localRand.Intn(valuesLen)
stopIndex = currentIndex
}
return value
}
}
}

View File

@@ -1,479 +0,0 @@
package utils
import (
"bytes"
"math/rand"
"mime/multipart"
"strings"
"text/template"
"time"
"github.com/brianvoe/gofakeit/v7"
)
type FuncMapGenerator struct {
bodyDataHeader string
localFaker *gofakeit.Faker
funcMap *template.FuncMap
}
func NewFuncMapGenerator(localRand *rand.Rand) *FuncMapGenerator {
f := &FuncMapGenerator{
localFaker: gofakeit.NewFaker(localRand, false),
}
f.funcMap = f.newFuncMap()
return f
}
func (g *FuncMapGenerator) GetBodyDataHeader() string {
tempHeader := g.bodyDataHeader
g.bodyDataHeader = ""
return tempHeader
}
func (g *FuncMapGenerator) GetFuncMap() *template.FuncMap {
return g.funcMap
}
// NewFuncMap creates a template.FuncMap populated with string manipulation functions
// and data generation functions from gofakeit.
//
// It takes a random number generator that is used to initialize a localized faker
// instance, ensuring that random data generation is deterministic within a request context.
//
// All functions are prefixed to avoid naming conflicts:
// - String functions: "strings_*"
// - Dict functions: "dict_*"
// - Body functions: "body_*"
// - Data generation functions: "fakeit_*"
func (g *FuncMapGenerator) newFuncMap() *template.FuncMap {
return &template.FuncMap{
// Strings
"strings_ToUpper": strings.ToUpper,
"strings_ToLower": strings.ToLower,
"strings_RemoveSpaces": func(s string) string { return strings.ReplaceAll(s, " ", "") },
"strings_Replace": strings.Replace,
"strings_ToDate": func(dateString string) time.Time {
date, err := time.Parse("2006-01-02", dateString)
if err != nil {
return time.Now()
}
return date
},
"strings_First": func(s string, n int) string {
if n >= len(s) {
return s
}
return s[:n]
},
"strings_Last": func(s string, n int) string {
if n >= len(s) {
return s
}
return s[len(s)-n:]
},
"strings_Truncate": func(s string, n int) string {
if n >= len(s) {
return s
}
return s[:n] + "..."
},
"strings_TrimPrefix": strings.TrimPrefix,
"strings_TrimSuffix": strings.TrimSuffix,
"strings_Join": func(sep string, values ...string) string {
return strings.Join(values, sep)
},
// Dict
"dict_Str": func(values ...string) map[string]string {
dict := make(map[string]string)
for i := 0; i < len(values); i += 2 {
if i+1 < len(values) {
key := values[i]
value := values[i+1]
dict[key] = value
}
}
return dict
},
// Slice
"slice_Str": func(values ...string) []string { return values },
"slice_Int": func(values ...int) []int { return values },
"slice_Uint": func(values ...uint) []uint { return values },
// Body
"body_FormData": func(kv map[string]string) string {
var data bytes.Buffer
writer := multipart.NewWriter(&data)
for k, v := range kv {
_ = writer.WriteField(k, v)
}
_ = writer.Close()
g.bodyDataHeader = writer.FormDataContentType()
return data.String()
},
// FakeIt / Product
"fakeit_ProductName": g.localFaker.ProductName,
"fakeit_ProductDescription": g.localFaker.ProductDescription,
"fakeit_ProductCategory": g.localFaker.ProductCategory,
"fakeit_ProductFeature": g.localFaker.ProductFeature,
"fakeit_ProductMaterial": g.localFaker.ProductMaterial,
"fakeit_ProductUPC": g.localFaker.ProductUPC,
"fakeit_ProductAudience": g.localFaker.ProductAudience,
"fakeit_ProductDimension": g.localFaker.ProductDimension,
"fakeit_ProductUseCase": g.localFaker.ProductUseCase,
"fakeit_ProductBenefit": g.localFaker.ProductBenefit,
"fakeit_ProductSuffix": g.localFaker.ProductSuffix,
// FakeIt / Person
"fakeit_Name": g.localFaker.Name,
"fakeit_NamePrefix": g.localFaker.NamePrefix,
"fakeit_NameSuffix": g.localFaker.NameSuffix,
"fakeit_FirstName": g.localFaker.FirstName,
"fakeit_MiddleName": g.localFaker.MiddleName,
"fakeit_LastName": g.localFaker.LastName,
"fakeit_Gender": g.localFaker.Gender,
"fakeit_SSN": g.localFaker.SSN,
"fakeit_Hobby": g.localFaker.Hobby,
"fakeit_Email": g.localFaker.Email,
"fakeit_Phone": g.localFaker.Phone,
"fakeit_PhoneFormatted": g.localFaker.PhoneFormatted,
// FakeIt / Auth
"fakeit_Username": g.localFaker.Username,
"fakeit_Password": g.localFaker.Password,
// FakeIt / Address
"fakeit_City": g.localFaker.City,
"fakeit_Country": g.localFaker.Country,
"fakeit_CountryAbr": g.localFaker.CountryAbr,
"fakeit_State": g.localFaker.State,
"fakeit_StateAbr": g.localFaker.StateAbr,
"fakeit_Street": g.localFaker.Street,
"fakeit_StreetName": g.localFaker.StreetName,
"fakeit_StreetNumber": g.localFaker.StreetNumber,
"fakeit_StreetPrefix": g.localFaker.StreetPrefix,
"fakeit_StreetSuffix": g.localFaker.StreetSuffix,
"fakeit_Zip": g.localFaker.Zip,
"fakeit_Latitude": g.localFaker.Latitude,
"fakeit_LatitudeInRange": func(min, max float64) float64 {
value, err := g.localFaker.LatitudeInRange(min, max)
if err != nil {
var zero float64
return zero
}
return value
},
"fakeit_Longitude": g.localFaker.Longitude,
"fakeit_LongitudeInRange": func(min, max float64) float64 {
value, err := g.localFaker.LongitudeInRange(min, max)
if err != nil {
var zero float64
return zero
}
return value
},
// FakeIt / Game
"fakeit_Gamertag": g.localFaker.Gamertag,
// FakeIt / Beer
"fakeit_BeerAlcohol": g.localFaker.BeerAlcohol,
"fakeit_BeerBlg": g.localFaker.BeerBlg,
"fakeit_BeerHop": g.localFaker.BeerHop,
"fakeit_BeerIbu": g.localFaker.BeerIbu,
"fakeit_BeerMalt": g.localFaker.BeerMalt,
"fakeit_BeerName": g.localFaker.BeerName,
"fakeit_BeerStyle": g.localFaker.BeerStyle,
"fakeit_BeerYeast": g.localFaker.BeerYeast,
// FakeIt / Car
"fakeit_CarMaker": g.localFaker.CarMaker,
"fakeit_CarModel": g.localFaker.CarModel,
"fakeit_CarType": g.localFaker.CarType,
"fakeit_CarFuelType": g.localFaker.CarFuelType,
"fakeit_CarTransmissionType": g.localFaker.CarTransmissionType,
// FakeIt / Words
"fakeit_Noun": g.localFaker.Noun,
"fakeit_NounCommon": g.localFaker.NounCommon,
"fakeit_NounConcrete": g.localFaker.NounConcrete,
"fakeit_NounAbstract": g.localFaker.NounAbstract,
"fakeit_NounCollectivePeople": g.localFaker.NounCollectivePeople,
"fakeit_NounCollectiveAnimal": g.localFaker.NounCollectiveAnimal,
"fakeit_NounCollectiveThing": g.localFaker.NounCollectiveThing,
"fakeit_NounCountable": g.localFaker.NounCountable,
"fakeit_NounUncountable": g.localFaker.NounUncountable,
"fakeit_Verb": g.localFaker.Verb,
"fakeit_VerbAction": g.localFaker.VerbAction,
"fakeit_VerbLinking": g.localFaker.VerbLinking,
"fakeit_VerbHelping": g.localFaker.VerbHelping,
"fakeit_Adverb": g.localFaker.Adverb,
"fakeit_AdverbManner": g.localFaker.AdverbManner,
"fakeit_AdverbDegree": g.localFaker.AdverbDegree,
"fakeit_AdverbPlace": g.localFaker.AdverbPlace,
"fakeit_AdverbTimeDefinite": g.localFaker.AdverbTimeDefinite,
"fakeit_AdverbTimeIndefinite": g.localFaker.AdverbTimeIndefinite,
"fakeit_AdverbFrequencyDefinite": g.localFaker.AdverbFrequencyDefinite,
"fakeit_AdverbFrequencyIndefinite": g.localFaker.AdverbFrequencyIndefinite,
"fakeit_Preposition": g.localFaker.Preposition,
"fakeit_PrepositionSimple": g.localFaker.PrepositionSimple,
"fakeit_PrepositionDouble": g.localFaker.PrepositionDouble,
"fakeit_PrepositionCompound": g.localFaker.PrepositionCompound,
"fakeit_Adjective": g.localFaker.Adjective,
"fakeit_AdjectiveDescriptive": g.localFaker.AdjectiveDescriptive,
"fakeit_AdjectiveQuantitative": g.localFaker.AdjectiveQuantitative,
"fakeit_AdjectiveProper": g.localFaker.AdjectiveProper,
"fakeit_AdjectiveDemonstrative": g.localFaker.AdjectiveDemonstrative,
"fakeit_AdjectivePossessive": g.localFaker.AdjectivePossessive,
"fakeit_AdjectiveInterrogative": g.localFaker.AdjectiveInterrogative,
"fakeit_AdjectiveIndefinite": g.localFaker.AdjectiveIndefinite,
"fakeit_Pronoun": g.localFaker.Pronoun,
"fakeit_PronounPersonal": g.localFaker.PronounPersonal,
"fakeit_PronounObject": g.localFaker.PronounObject,
"fakeit_PronounPossessive": g.localFaker.PronounPossessive,
"fakeit_PronounReflective": g.localFaker.PronounReflective,
"fakeit_PronounDemonstrative": g.localFaker.PronounDemonstrative,
"fakeit_PronounInterrogative": g.localFaker.PronounInterrogative,
"fakeit_PronounRelative": g.localFaker.PronounRelative,
"fakeit_Connective": g.localFaker.Connective,
"fakeit_ConnectiveTime": g.localFaker.ConnectiveTime,
"fakeit_ConnectiveComparative": g.localFaker.ConnectiveComparative,
"fakeit_ConnectiveComplaint": g.localFaker.ConnectiveComplaint,
"fakeit_ConnectiveListing": g.localFaker.ConnectiveListing,
"fakeit_ConnectiveCasual": g.localFaker.ConnectiveCasual,
"fakeit_ConnectiveExamplify": g.localFaker.ConnectiveExamplify,
"fakeit_Word": g.localFaker.Word,
"fakeit_Sentence": g.localFaker.Sentence,
"fakeit_Paragraph": g.localFaker.Paragraph,
"fakeit_LoremIpsumWord": g.localFaker.LoremIpsumWord,
"fakeit_LoremIpsumSentence": g.localFaker.LoremIpsumSentence,
"fakeit_LoremIpsumParagraph": g.localFaker.LoremIpsumParagraph,
"fakeit_Question": g.localFaker.Question,
"fakeit_Quote": g.localFaker.Quote,
"fakeit_Phrase": g.localFaker.Phrase,
// FakeIt / Foods
"fakeit_Fruit": g.localFaker.Fruit,
"fakeit_Vegetable": g.localFaker.Vegetable,
"fakeit_Breakfast": g.localFaker.Breakfast,
"fakeit_Lunch": g.localFaker.Lunch,
"fakeit_Dinner": g.localFaker.Dinner,
"fakeit_Snack": g.localFaker.Snack,
"fakeit_Dessert": g.localFaker.Dessert,
// FakeIt / Misc
"fakeit_Bool": g.localFaker.Bool,
"fakeit_UUID": g.localFaker.UUID,
"fakeit_FlipACoin": g.localFaker.FlipACoin,
// FakeIt / Colors
"fakeit_Color": g.localFaker.Color,
"fakeit_HexColor": g.localFaker.HexColor,
"fakeit_RGBColor": g.localFaker.RGBColor,
"fakeit_SafeColor": g.localFaker.SafeColor,
"fakeit_NiceColors": g.localFaker.NiceColors,
// FakeIt / Internet
"fakeit_URL": g.localFaker.URL,
"fakeit_DomainName": g.localFaker.DomainName,
"fakeit_DomainSuffix": g.localFaker.DomainSuffix,
"fakeit_IPv4Address": g.localFaker.IPv4Address,
"fakeit_IPv6Address": g.localFaker.IPv6Address,
"fakeit_MacAddress": g.localFaker.MacAddress,
"fakeit_HTTPStatusCode": g.localFaker.HTTPStatusCode,
"fakeit_HTTPStatusCodeSimple": g.localFaker.HTTPStatusCodeSimple,
"fakeit_LogLevel": g.localFaker.LogLevel,
"fakeit_HTTPMethod": g.localFaker.HTTPMethod,
"fakeit_HTTPVersion": g.localFaker.HTTPVersion,
"fakeit_UserAgent": g.localFaker.UserAgent,
"fakeit_ChromeUserAgent": g.localFaker.ChromeUserAgent,
"fakeit_FirefoxUserAgent": g.localFaker.FirefoxUserAgent,
"fakeit_OperaUserAgent": g.localFaker.OperaUserAgent,
"fakeit_SafariUserAgent": g.localFaker.SafariUserAgent,
// FakeIt / HTML
"fakeit_InputName": g.localFaker.InputName,
// FakeIt / Date/Time
"fakeit_Date": g.localFaker.Date,
"fakeit_PastDate": g.localFaker.PastDate,
"fakeit_FutureDate": g.localFaker.FutureDate,
"fakeit_DateRange": g.localFaker.DateRange,
"fakeit_NanoSecond": g.localFaker.NanoSecond,
"fakeit_Second": g.localFaker.Second,
"fakeit_Minute": g.localFaker.Minute,
"fakeit_Hour": g.localFaker.Hour,
"fakeit_Month": g.localFaker.Month,
"fakeit_MonthString": g.localFaker.MonthString,
"fakeit_Day": g.localFaker.Day,
"fakeit_WeekDay": g.localFaker.WeekDay,
"fakeit_Year": g.localFaker.Year,
"fakeit_TimeZone": g.localFaker.TimeZone,
"fakeit_TimeZoneAbv": g.localFaker.TimeZoneAbv,
"fakeit_TimeZoneFull": g.localFaker.TimeZoneFull,
"fakeit_TimeZoneOffset": g.localFaker.TimeZoneOffset,
"fakeit_TimeZoneRegion": g.localFaker.TimeZoneRegion,
// FakeIt / Payment
"fakeit_Price": g.localFaker.Price,
"fakeit_CreditCardCvv": g.localFaker.CreditCardCvv,
"fakeit_CreditCardExp": g.localFaker.CreditCardExp,
"fakeit_CreditCardNumber": g.localFaker.CreditCardNumber,
"fakeit_CreditCardType": g.localFaker.CreditCardType,
"fakeit_CurrencyLong": g.localFaker.CurrencyLong,
"fakeit_CurrencyShort": g.localFaker.CurrencyShort,
"fakeit_AchRouting": g.localFaker.AchRouting,
"fakeit_AchAccount": g.localFaker.AchAccount,
"fakeit_BitcoinAddress": g.localFaker.BitcoinAddress,
"fakeit_BitcoinPrivateKey": g.localFaker.BitcoinPrivateKey,
// FakeIt / Finance
"fakeit_Cusip": g.localFaker.Cusip,
"fakeit_Isin": g.localFaker.Isin,
// FakeIt / Company
"fakeit_BS": g.localFaker.BS,
"fakeit_Blurb": g.localFaker.Blurb,
"fakeit_BuzzWord": g.localFaker.BuzzWord,
"fakeit_Company": g.localFaker.Company,
"fakeit_CompanySuffix": g.localFaker.CompanySuffix,
"fakeit_JobDescriptor": g.localFaker.JobDescriptor,
"fakeit_JobLevel": g.localFaker.JobLevel,
"fakeit_JobTitle": g.localFaker.JobTitle,
"fakeit_Slogan": g.localFaker.Slogan,
// FakeIt / Hacker
"fakeit_HackerAbbreviation": g.localFaker.HackerAbbreviation,
"fakeit_HackerAdjective": g.localFaker.HackerAdjective,
"fakeit_HackerNoun": g.localFaker.HackerNoun,
"fakeit_HackerPhrase": g.localFaker.HackerPhrase,
"fakeit_HackerVerb": g.localFaker.HackerVerb,
// FakeIt / Hipster
"fakeit_HipsterWord": g.localFaker.HipsterWord,
"fakeit_HipsterSentence": g.localFaker.HipsterSentence,
"fakeit_HipsterParagraph": g.localFaker.HipsterParagraph,
// FakeIt / App
"fakeit_AppName": g.localFaker.AppName,
"fakeit_AppVersion": g.localFaker.AppVersion,
"fakeit_AppAuthor": g.localFaker.AppAuthor,
// FakeIt / Animal
"fakeit_PetName": g.localFaker.PetName,
"fakeit_Animal": g.localFaker.Animal,
"fakeit_AnimalType": g.localFaker.AnimalType,
"fakeit_FarmAnimal": g.localFaker.FarmAnimal,
"fakeit_Cat": g.localFaker.Cat,
"fakeit_Dog": g.localFaker.Dog,
"fakeit_Bird": g.localFaker.Bird,
// FakeIt / Emoji
"fakeit_Emoji": g.localFaker.Emoji,
"fakeit_EmojiDescription": g.localFaker.EmojiDescription,
"fakeit_EmojiCategory": g.localFaker.EmojiCategory,
"fakeit_EmojiAlias": g.localFaker.EmojiAlias,
"fakeit_EmojiTag": g.localFaker.EmojiTag,
// FakeIt / Language
"fakeit_Language": g.localFaker.Language,
"fakeit_LanguageAbbreviation": g.localFaker.LanguageAbbreviation,
"fakeit_ProgrammingLanguage": g.localFaker.ProgrammingLanguage,
// FakeIt / Number
"fakeit_Number": g.localFaker.Number,
"fakeit_Int": g.localFaker.Int,
"fakeit_IntN": g.localFaker.IntN,
"fakeit_IntRange": g.localFaker.IntRange,
"fakeit_RandomInt": g.localFaker.RandomInt,
"fakeit_Int8": g.localFaker.Int8,
"fakeit_Int16": g.localFaker.Int16,
"fakeit_Int32": g.localFaker.Int32,
"fakeit_Int64": g.localFaker.Int64,
"fakeit_Uint": g.localFaker.Uint,
"fakeit_UintN": g.localFaker.UintN,
"fakeit_UintRange": g.localFaker.UintRange,
"fakeit_RandomUint": g.localFaker.RandomUint,
"fakeit_Uint8": g.localFaker.Uint8,
"fakeit_Uint16": g.localFaker.Uint16,
"fakeit_Uint32": g.localFaker.Uint32,
"fakeit_Uint64": g.localFaker.Uint64,
"fakeit_Float32": g.localFaker.Float32,
"fakeit_Float32Range": g.localFaker.Float32Range,
"fakeit_Float64": g.localFaker.Float64,
"fakeit_Float64Range": g.localFaker.Float64Range,
"fakeit_HexUint": g.localFaker.HexUint,
// FakeIt / String
"fakeit_Digit": g.localFaker.Digit,
"fakeit_DigitN": g.localFaker.DigitN,
"fakeit_Letter": g.localFaker.Letter,
"fakeit_LetterN": g.localFaker.LetterN,
"fakeit_LetterNN": func(min, max uint) string {
return g.localFaker.LetterN(g.localFaker.UintRange(min, max))
},
"fakeit_Lexify": g.localFaker.Lexify,
"fakeit_Numerify": g.localFaker.Numerify,
"fakeit_RandomString": func(values ...string) string {
return g.localFaker.RandomString(values)
},
// FakeIt / Celebrity
"fakeit_CelebrityActor": g.localFaker.CelebrityActor,
"fakeit_CelebrityBusiness": g.localFaker.CelebrityBusiness,
"fakeit_CelebritySport": g.localFaker.CelebritySport,
// FakeIt / Minecraft
"fakeit_MinecraftOre": g.localFaker.MinecraftOre,
"fakeit_MinecraftWood": g.localFaker.MinecraftWood,
"fakeit_MinecraftArmorTier": g.localFaker.MinecraftArmorTier,
"fakeit_MinecraftArmorPart": g.localFaker.MinecraftArmorPart,
"fakeit_MinecraftWeapon": g.localFaker.MinecraftWeapon,
"fakeit_MinecraftTool": g.localFaker.MinecraftTool,
"fakeit_MinecraftDye": g.localFaker.MinecraftDye,
"fakeit_MinecraftFood": g.localFaker.MinecraftFood,
"fakeit_MinecraftAnimal": g.localFaker.MinecraftAnimal,
"fakeit_MinecraftVillagerJob": g.localFaker.MinecraftVillagerJob,
"fakeit_MinecraftVillagerStation": g.localFaker.MinecraftVillagerStation,
"fakeit_MinecraftVillagerLevel": g.localFaker.MinecraftVillagerLevel,
"fakeit_MinecraftMobPassive": g.localFaker.MinecraftMobPassive,
"fakeit_MinecraftMobNeutral": g.localFaker.MinecraftMobNeutral,
"fakeit_MinecraftMobHostile": g.localFaker.MinecraftMobHostile,
"fakeit_MinecraftMobBoss": g.localFaker.MinecraftMobBoss,
"fakeit_MinecraftBiome": g.localFaker.MinecraftBiome,
"fakeit_MinecraftWeather": g.localFaker.MinecraftWeather,
// FakeIt / Book
"fakeit_BookTitle": g.localFaker.BookTitle,
"fakeit_BookAuthor": g.localFaker.BookAuthor,
"fakeit_BookGenre": g.localFaker.BookGenre,
// FakeIt / Movie
"fakeit_MovieName": g.localFaker.MovieName,
"fakeit_MovieGenre": g.localFaker.MovieGenre,
// FakeIt / Error
"fakeit_Error": g.localFaker.Error,
"fakeit_ErrorDatabase": g.localFaker.ErrorDatabase,
"fakeit_ErrorGRPC": g.localFaker.ErrorGRPC,
"fakeit_ErrorHTTP": g.localFaker.ErrorHTTP,
"fakeit_ErrorHTTPClient": g.localFaker.ErrorHTTPClient,
"fakeit_ErrorHTTPServer": g.localFaker.ErrorHTTPServer,
"fakeit_ErrorRuntime": g.localFaker.ErrorRuntime,
// FakeIt / School
"fakeit_School": g.localFaker.School,
// FakeIt / Song
"fakeit_SongName": g.localFaker.SongName,
"fakeit_SongArtist": g.localFaker.SongArtist,
"fakeit_SongGenre": g.localFaker.SongGenre,
}
}

View File

@@ -1,14 +0,0 @@
package utils
import "time"
func DurationRoundBy(duration time.Duration, n int64) time.Duration {
if durationLen := NumLen(duration.Nanoseconds()); durationLen > n {
roundNum := 1
for range durationLen - n {
roundNum *= 10
}
return duration.Round(time.Duration(roundNum))
}
return duration
}