mirror of
https://github.com/aykhans/sarin.git
synced 2026-01-13 20:11:21 +00:00
v1.0.0: here we go again
This commit is contained in:
@@ -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
2
.github/FUNDING.yml
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
buy_me_a_coffee: aykhan
|
||||
custom: https://commerce.coinbase.com/checkout/0f33d2fb-54a6-44f5-8783-006ebf70d1a0
|
||||
@@ -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
|
||||
86
.github/workflows/publish-docker-image.yml
vendored
86
.github/workflows/publish-docker-image.yml
vendored
@@ -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
98
.github/workflows/release.yaml
vendored
Normal 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
3
.gitignore
vendored
@@ -1,2 +1 @@
|
||||
dodo
|
||||
binaries/
|
||||
bin/*
|
||||
|
||||
101
.golangci.yaml
Normal file
101
.golangci.yaml
Normal 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:]"
|
||||
@@ -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"
|
||||
26
Dockerfile
26
Dockerfile
@@ -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"]
|
||||
|
||||
934
EXAMPLES.md
934
EXAMPLES.md
@@ -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
378
README.md
@@ -1,340 +1,130 @@
|
||||
<h1 align="center">Dodo - A Fast and Easy-to-Use HTTP Benchmarking Tool</h1>
|
||||
|
||||

|
||||
|
||||
<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
|
||||

|
||||
|
||||
- [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.
|
||||
|
||||
@@ -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
84
cmd/cli/main.go
Normal 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()
|
||||
}
|
||||
37
config.json
37
config.json
@@ -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"
|
||||
]
|
||||
}
|
||||
40
config.yaml
40
config.yaml
@@ -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"
|
||||
188
config/cli.go
188
config/cli.go
@@ -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"
|
||||
}
|
||||
364
config/config.go
364
config/config.go
@@ -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)
|
||||
}
|
||||
@@ -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
333
docs/configuration.md
Normal 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
712
docs/examples.md
Normal 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
BIN
docs/static/demo.gif
vendored
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 96 KiB |
610
docs/templating.md
Normal file
610
docs/templating.md
Normal 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
57
go.mod
@@ -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
130
go.sum
@@ -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
285
internal/config/cli.go
Normal 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(¶ms, "param", "URL parameter for the request")
|
||||
flagSet.Var(¶ms, "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
757
internal/config/config.go
Normal 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
235
internal/config/env.go
Normal 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
280
internal/config/file.go
Normal 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
|
||||
}
|
||||
212
internal/config/template_validator.go
Normal file
212
internal/config/template_validator.go
Normal 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
310
internal/sarin/client.go
Normal 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
14
internal/sarin/helpers.go
Normal 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
336
internal/sarin/request.go
Normal 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
348
internal/sarin/response.go
Normal 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
776
internal/sarin/sarin.go
Normal 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
579
internal/sarin/template.go
Normal 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
|
||||
}
|
||||
46
internal/types/config_file.go
Normal file
46
internal/types/config_file.go
Normal 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
40
internal/types/cookie.go
Normal 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
189
internal/types/errors.go
Normal 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
49
internal/types/header.go
Normal 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]}}
|
||||
}
|
||||
6
internal/types/key_value.go
Normal file
6
internal/types/key_value.go
Normal file
@@ -0,0 +1,6 @@
|
||||
package types
|
||||
|
||||
type KeyValue[K, V any] struct {
|
||||
Key K
|
||||
Value V
|
||||
}
|
||||
40
internal/types/param.go
Normal file
40
internal/types/param.go
Normal 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 ¶ms[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
38
internal/types/proxy.go
Normal 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
|
||||
}
|
||||
8
internal/version/version.go
Normal file
8
internal/version/version.go
Normal 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
69
main.go
@@ -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()
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
211
requests/run.go
211
requests/run.go
@@ -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
|
||||
}()
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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:]
|
||||
}
|
||||
139
types/cookies.go
139
types/cookies.go
@@ -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
|
||||
}
|
||||
@@ -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)")
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
package types
|
||||
|
||||
import (
|
||||
"errors"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrInterrupt = errors.New("interrupted")
|
||||
ErrTimeout = errors.New("timeout")
|
||||
)
|
||||
156
types/headers.go
156
types/headers.go
@@ -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
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
package types
|
||||
|
||||
type KeyValue[K comparable, V any] struct {
|
||||
Key K
|
||||
Value V
|
||||
}
|
||||
139
types/params.go
139
types/params.go
@@ -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 ¶ms[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
|
||||
}
|
||||
116
types/proxies.go
116
types/proxies.go
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)")
|
||||
}
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
package utils
|
||||
|
||||
func IsNilOrZero[T comparable](value *T) bool {
|
||||
if value == nil {
|
||||
return true
|
||||
}
|
||||
|
||||
var zero T
|
||||
return *value == zero
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
package utils
|
||||
|
||||
func ToPtr[T any](value T) *T {
|
||||
return &value
|
||||
}
|
||||
21
utils/int.go
21
utils/int.go
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user