From 2d7ba34cb8a741b9b93ce48b667d0c11043a3f3c Mon Sep 17 00:00:00 2001 From: Aykhan Shahsuvarov Date: Sat, 10 Jan 2026 17:06:25 +0400 Subject: [PATCH] v1.0.0: here we go again --- .dockerignore | 11 - .github/FUNDING.yml | 2 + .../{golangci-lint.yml => lint.yaml} | 12 +- .github/workflows/publish-docker-image.yml | 86 -- .github/workflows/release.yaml | 98 ++ .gitignore | 3 +- .golangci.yaml | 101 ++ .golangci.yml | 33 - Dockerfile | 26 +- EXAMPLES.md | 934 ------------------ README.md | 378 ++----- Taskfile.yaml | 99 +- cmd/cli/main.go | 84 ++ config.json | 37 - config.yaml | 40 - config/cli.go | 188 ---- config/config.go | 364 ------- config/file.go | 84 -- docs/configuration.md | 333 +++++++ docs/examples.md | 712 +++++++++++++ docs/static/demo.gif | Bin 0 -> 97766 bytes docs/templating.md | 610 ++++++++++++ go.mod | 57 +- go.sum | 130 ++- internal/config/cli.go | 285 ++++++ internal/config/config.go | 757 ++++++++++++++ internal/config/env.go | 235 +++++ internal/config/file.go | 280 ++++++ internal/config/template_validator.go | 212 ++++ internal/sarin/client.go | 310 ++++++ internal/sarin/helpers.go | 14 + internal/sarin/request.go | 336 +++++++ internal/sarin/response.go | 348 +++++++ internal/sarin/sarin.go | 776 +++++++++++++++ internal/sarin/template.go | 579 +++++++++++ internal/types/config_file.go | 46 + internal/types/cookie.go | 40 + internal/types/errors.go | 189 ++++ internal/types/header.go | 49 + internal/types/key_value.go | 6 + internal/types/param.go | 40 + internal/types/proxy.go | 38 + internal/version/version.go | 8 + main.go | 69 -- requests/client.go | 112 --- requests/helper.go | 56 -- requests/request.go | 341 ------- requests/response.go | 94 -- requests/run.go | 211 ---- types/body.go | 94 -- types/config_file.go | 32 - types/cookies.go | 139 --- types/duration.go | 57 -- types/durations.go | 40 - types/errors.go | 10 - types/headers.go | 156 --- types/key_value.go | 6 - types/params.go | 139 --- types/proxies.go | 116 --- types/request_url.go | 59 -- types/timeout.go | 57 -- utils/compare.go | 10 - utils/convert.go | 5 - utils/int.go | 21 - utils/print.go | 24 - utils/slice.go | 42 - utils/templates.go | 479 --------- utils/time.go | 14 - 68 files changed, 6805 insertions(+), 4548 deletions(-) delete mode 100644 .dockerignore create mode 100644 .github/FUNDING.yml rename .github/workflows/{golangci-lint.yml => lint.yaml} (51%) delete mode 100644 .github/workflows/publish-docker-image.yml create mode 100644 .github/workflows/release.yaml create mode 100644 .golangci.yaml delete mode 100644 .golangci.yml delete mode 100644 EXAMPLES.md create mode 100644 cmd/cli/main.go delete mode 100644 config.json delete mode 100644 config.yaml delete mode 100644 config/cli.go delete mode 100644 config/config.go delete mode 100644 config/file.go create mode 100644 docs/configuration.md create mode 100644 docs/examples.md create mode 100644 docs/static/demo.gif create mode 100644 docs/templating.md create mode 100644 internal/config/cli.go create mode 100644 internal/config/config.go create mode 100644 internal/config/env.go create mode 100644 internal/config/file.go create mode 100644 internal/config/template_validator.go create mode 100644 internal/sarin/client.go create mode 100644 internal/sarin/helpers.go create mode 100644 internal/sarin/request.go create mode 100644 internal/sarin/response.go create mode 100644 internal/sarin/sarin.go create mode 100644 internal/sarin/template.go create mode 100644 internal/types/config_file.go create mode 100644 internal/types/cookie.go create mode 100644 internal/types/errors.go create mode 100644 internal/types/header.go create mode 100644 internal/types/key_value.go create mode 100644 internal/types/param.go create mode 100644 internal/types/proxy.go create mode 100644 internal/version/version.go delete mode 100644 main.go delete mode 100644 requests/client.go delete mode 100644 requests/helper.go delete mode 100644 requests/request.go delete mode 100644 requests/response.go delete mode 100644 requests/run.go delete mode 100644 types/body.go delete mode 100644 types/config_file.go delete mode 100644 types/cookies.go delete mode 100644 types/duration.go delete mode 100644 types/durations.go delete mode 100644 types/errors.go delete mode 100644 types/headers.go delete mode 100644 types/key_value.go delete mode 100644 types/params.go delete mode 100644 types/proxies.go delete mode 100644 types/request_url.go delete mode 100644 types/timeout.go delete mode 100644 utils/compare.go delete mode 100644 utils/convert.go delete mode 100644 utils/int.go delete mode 100644 utils/print.go delete mode 100644 utils/slice.go delete mode 100644 utils/templates.go delete mode 100644 utils/time.go diff --git a/.dockerignore b/.dockerignore deleted file mode 100644 index 4da4366..0000000 --- a/.dockerignore +++ /dev/null @@ -1,11 +0,0 @@ -.github -assets -binaries -dodo -.git -.gitignore -.golangci.yml -README.md -LICENSE -config.json -build.sh \ No newline at end of file diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml new file mode 100644 index 0000000..bbc2a1d --- /dev/null +++ b/.github/FUNDING.yml @@ -0,0 +1,2 @@ +buy_me_a_coffee: aykhan +custom: https://commerce.coinbase.com/checkout/0f33d2fb-54a6-44f5-8783-006ebf70d1a0 diff --git a/.github/workflows/golangci-lint.yml b/.github/workflows/lint.yaml similarity index 51% rename from .github/workflows/golangci-lint.yml rename to .github/workflows/lint.yaml index 45c5f38..23c937c 100644 --- a/.github/workflows/golangci-lint.yml +++ b/.github/workflows/lint.yaml @@ -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 diff --git a/.github/workflows/publish-docker-image.yml b/.github/workflows/publish-docker-image.yml deleted file mode 100644 index 92b43b8..0000000 --- a/.github/workflows/publish-docker-image.yml +++ /dev/null @@ -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 }} diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml new file mode 100644 index 0000000..0fca66f --- /dev/null +++ b/.github/workflows/release.yaml @@ -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 diff --git a/.gitignore b/.gitignore index c46c198..36f971e 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1 @@ -dodo -binaries/ \ No newline at end of file +bin/* diff --git a/.golangci.yaml b/.golangci.yaml new file mode 100644 index 0000000..030740a --- /dev/null +++ b/.golangci.yaml @@ -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:]" diff --git a/.golangci.yml b/.golangci.yml deleted file mode 100644 index 5bc019a..0000000 --- a/.golangci.yml +++ /dev/null @@ -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" diff --git a/Dockerfile b/Dockerfile index 55e67f7..066851c 100644 --- a/Dockerfile +++ b/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"] \ No newline at end of file +ENTRYPOINT ["./sarin"] diff --git a/EXAMPLES.md b/EXAMPLES.md deleted file mode 100644 index d021603..0000000 --- a/EXAMPLES.md +++ /dev/null @@ -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`. diff --git a/README.md b/README.md index b431888..40be526 100644 --- a/README.md +++ b/README.md @@ -1,340 +1,130 @@ -

Dodo - A Fast and Easy-to-Use HTTP Benchmarking Tool

- -![Usage](https://ftp.aykhans.me/web/client/pubshares/VzPtSHS7yPQT7ngoZzZSNU/browse?path=/dodo_demonstrate.gif) -
-

- - Examples - - | - - Install - - | - - Docker - -

-
- - Buy Me A Coffee - + +## Sarin is a high-performance HTTP load testing tool built with Go and fasthttp. +
-## Table of Contents +![Demo](docs/static/demo.gif) -- [Installation](#installation) - - [Using Docker (Recommended)](#using-docker-recommended) - - [Using Pre-built Binaries](#using-pre-built-binaries) - - [Building from Source](#building-from-source) -- [Usage](#usage) - - [1. CLI Usage](#1-cli-usage) - - [2. Config File Usage](#2-config-file-usage) - - [2.1 YAML/YML Example](#21-yamlyml-example) - - [2.2 JSON Example](#22-json-example) - - [3. CLI & Config File Combination](#3-cli--config-file-combination) -- [Config Parameters Reference](#config-parameters-reference) -- [Template Functions](#template-functions) +

+ Install • + Quick Start • + Examples • + Configuration • + Templating +

+ +## 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. diff --git a/Taskfile.yaml b/Taskfile.yaml index c1730c4..66204b7 100644 --- a/Taskfile.yaml +++ b/Taskfile.yaml @@ -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}} diff --git a/cmd/cli/main.go b/cmd/cli/main.go new file mode 100644 index 0000000..b5703f7 --- /dev/null +++ b/cmd/cli/main.go @@ -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() +} diff --git a/config.json b/config.json deleted file mode 100644 index 66eb6d1..0000000 --- a/config.json +++ /dev/null @@ -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" - ] -} diff --git a/config.yaml b/config.yaml deleted file mode 100644 index 78b929d..0000000 --- a/config.yaml +++ /dev/null @@ -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" diff --git a/config/cli.go b/config/cli.go deleted file mode 100644 index 997829a..0000000 --- a/config/cli.go +++ /dev/null @@ -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" -} diff --git a/config/config.go b/config/config.go deleted file mode 100644 index f333966..0000000 --- a/config/config.go +++ /dev/null @@ -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) -} diff --git a/config/file.go b/config/file.go deleted file mode 100644 index abf5e21..0000000 --- a/config/file.go +++ /dev/null @@ -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 -} diff --git a/docs/configuration.md b/docs/configuration.md new file mode 100644 index 0000000..ab0c7f2 --- /dev/null +++ b/docs/configuration.md @@ -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`
(boolean) | `-show-config` / `-s`
(boolean) | `SARIN_SHOW_CONFIG`
(boolean) | `false` | Show merged configuration | +| [Config File](#config-file) | `configFile`
(string / []string) | `-config-file` / `-f`
(string / []string) | `SARIN_CONFIG_FILE`
(string) | - | Path to config file(s) | +| [URL](#url) | `url`
(string) | `-url` / `-U`
(string) | `SARIN_URL`
(string) | - | Target URL (HTTP/HTTPS) | +| [Method](#method) | `method`
(string / []string) | `-method` / `-M`
(string / []string) | `SARIN_METHOD`
(string) | `GET` | HTTP method(s) | +| [Timeout](#timeout) | `timeout`
(duration) | `-timeout` / `-T`
(duration) | `SARIN_TIMEOUT`
(duration) | `10s` | Request timeout | +| [Concurrency](#concurrency) | `concurrency`
(number) | `-concurrency` / `-c`
(number) | `SARIN_CONCURRENCY`
(number) | `1` | Number of concurrent workers | +| [Requests](#requests) | `requests`
(number) | `-requests` / `-r`
(number) | `SARIN_REQUESTS`
(number) | - | Total requests to send | +| [Duration](#duration) | `duration`
(duration) | `-duration` / `-d`
(duration) | `SARIN_DURATION`
(duration) | - | Test duration | +| [Quiet](#quiet) | `quiet`
(boolean) | `-quiet` / `-q`
(boolean) | `SARIN_QUIET`
(boolean) | `false` | Hide progress bar and logs | +| [Output](#output) | `output`
(string) | `-output` / `-o`
(string) | `SARIN_OUTPUT`
(string) | `table` | Output format for stats | +| [Dry Run](#dry-run) | `dryRun`
(boolean) | `-dry-run` / `-z`
(boolean) | `SARIN_DRY_RUN`
(boolean) | `false` | Generate without sending | +| [Insecure](#insecure) | `insecure`
(boolean) | `-insecure` / `-I`
(boolean) | `SARIN_INSECURE`
(boolean) | `false` | Skip TLS verification | +| [Body](#body) | `body`
(string / []string) | `-body` / `-B`
(string / []string) | `SARIN_BODY`
(string) | - | Request body | +| [Params](#params) | `params`
(object) | `-param` / `-P`
(string / []string) | `SARIN_PARAM`
(string) | - | URL query parameters | +| [Headers](#headers) | `headers`
(object) | `-header` / `-H`
(string / []string) | `SARIN_HEADER`
(string) | - | HTTP headers | +| [Cookies](#cookies) | `cookies`
(object) | `-cookie` / `-C`
(string / []string) | `SARIN_COOKIE`
(string) | - | HTTP cookies | +| [Proxy](#proxy) | `proxy`
(string / []string) | `-proxy` / `-X`
(string / []string) | `SARIN_PROXY`
(string) | - | Proxy URL(s) | +| [Values](#values) | `values`
(string / []string) | `-values` / `-V`
(string / []string) | `SARIN_VALUES`
(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" +``` diff --git a/docs/examples.md b/docs/examples.md new file mode 100644 index 0000000..e5d245f --- /dev/null +++ b/docs/examples.md @@ -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 +``` + +
+YAML equivalent + +```yaml +url: http://example.com +requests: 1000 +concurrency: 10 +``` + +
+ +Send requests with a custom timeout: + +```sh +sarin -U http://example.com -r 1000 -c 10 -T 5s +``` + +
+YAML equivalent + +```yaml +url: http://example.com +requests: 1000 +concurrency: 10 +timeout: 5s +``` + +
+ +## 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 +``` + +
+YAML equivalent + +```yaml +url: http://example.com +requests: 10000 +concurrency: 50 +``` + +
+ +**Duration-based:** Run for a specific amount of time: + +```sh +sarin -U http://example.com -d 5m -c 50 +``` + +
+YAML equivalent + +```yaml +url: http://example.com +duration: 5m +concurrency: 50 +``` + +
+ +**Combined:** Stop when either limit is reached first: + +```sh +sarin -U http://example.com -r 100000 -d 2m -c 100 +``` + +
+YAML equivalent + +```yaml +url: http://example.com +requests: 100000 +duration: 2m +concurrency: 100 +``` + +
+ +## 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 }}" +``` + +
+YAML equivalent + +```yaml +url: http://example.com +requests: 1000 +concurrency: 10 +headers: + User-Agent: "{{ fakeit_UserAgent }}" +``` + +
+ +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 }}"}' +``` + +
+YAML equivalent + +```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 }}"}' +``` + +
+ +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 }}"}' +``` + +
+YAML equivalent + +```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 }}"}' +``` + +
+ +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 }}"}' +``` + +
+YAML equivalent + +```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 }}"}' +``` + +
+ +> 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" +``` + +
+YAML equivalent + +```yaml +url: http://example.com +requests: 1000 +concurrency: 10 +headers: + Authorization: Bearer token123 + X-Custom-Header: value +``` + +
+ +**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" +``` + +
+YAML equivalent + +```yaml +url: http://example.com +requests: 1000 +concurrency: 10 +headers: + X-Region: + - us-east + - us-west + - eu-central +``` + +
+ +**Query parameters:** + +```sh +sarin -U http://example.com/search -r 1000 -c 10 \ + -P "query=test" \ + -P "limit=10" +``` + +
+YAML equivalent + +```yaml +url: http://example.com/search +requests: 1000 +concurrency: 10 +params: + query: test + limit: "10" +``` + +
+ +**Dynamic query parameters:** + +```sh +sarin -U http://example.com/users -r 1000 -c 10 \ + -P "id={{ fakeit_IntRange 1 1000 }}" \ + -P "fields=name,email" +``` + +
+YAML equivalent + +```yaml +url: http://example.com/users +requests: 1000 +concurrency: 10 +params: + id: "{{ fakeit_IntRange 1 1000 }}" + fields: name,email +``` + +
+ +**Cookies:** + +```sh +sarin -U http://example.com -r 1000 -c 10 \ + -C "session_id=abc123" \ + -C "user_id={{ fakeit_UUID }}" +``` + +
+YAML equivalent + +```yaml +url: http://example.com +requests: 1000 +concurrency: 10 +cookies: + session_id: abc123 + user_id: "{{ fakeit_UUID }}" +``` + +
+ +## 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"}' +``` + +
+YAML equivalent + +```yaml +url: http://example.com/api/data +requests: 1000 +concurrency: 10 +method: POST +headers: + Content-Type: application/json +body: '{"key": "value"}' +``` + +
+ +**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}' +``` + +
+YAML equivalent + +```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}' +``` + +
+ +**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 }}" + }' +``` + +
+YAML equivalent + +```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 }}" + } +``` + +
+ +**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") }}' +``` + +
+YAML equivalent + +```yaml +url: http://example.com/api/upload +requests: 1000 +concurrency: 10 +method: POST +body: '{{ body_FormData (dict_Str "username" "john" "email" "john@example.com") }}' +``` + +
+ +**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)) }}' +``` + +
+YAML equivalent + +```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)) }}' +``` + +
+ +> **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 +``` + +
+YAML equivalent + +```yaml +url: http://example.com +requests: 1000 +concurrency: 10 +proxy: http://proxy.example.com:8080 +``` + +
+ +**SOCKS5 proxy:** + +```sh +sarin -U http://example.com -r 1000 -c 10 \ + -X socks5://proxy.example.com:1080 +``` + +
+YAML equivalent + +```yaml +url: http://example.com +requests: 1000 +concurrency: 10 +proxy: socks5://proxy.example.com:1080 +``` + +
+ +**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 +``` + +
+YAML equivalent + +```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 +``` + +
+ +**Proxy with authentication:** + +```sh +sarin -U http://example.com -r 1000 -c 10 \ + -X http://user:password@proxy.example.com:8080 +``` + +
+YAML equivalent + +```yaml +url: http://example.com +requests: 1000 +concurrency: 10 +proxy: http://user:password@proxy.example.com:8080 +``` + +
+ +## Output Formats + +**Table output (default):** + +```sh +sarin -U http://example.com -r 1000 -c 10 -o table +``` + +
+YAML equivalent + +```yaml +url: http://example.com +requests: 1000 +concurrency: 10 +output: table +``` + +
+ +**JSON output (useful for parsing):** + +```sh +sarin -U http://example.com -r 1000 -c 10 -o json +``` + +
+YAML equivalent + +```yaml +url: http://example.com +requests: 1000 +concurrency: 10 +output: json +``` + +
+ +**YAML output:** + +```sh +sarin -U http://example.com -r 1000 -c 10 -o yaml +``` + +
+YAML equivalent + +```yaml +url: http://example.com +requests: 1000 +concurrency: 10 +output: yaml +``` + +
+ +**No output (minimal memory usage):** + +```sh +sarin -U http://example.com -r 1000 -c 10 -o none +``` + +
+YAML equivalent + +```yaml +url: http://example.com +requests: 1000 +concurrency: 10 +output: none +``` + +
+ +**Quiet mode (hide progress bar):** + +```sh +sarin -U http://example.com -r 1000 -c 10 -q +``` + +
+YAML equivalent + +```yaml +url: http://example.com +requests: 1000 +concurrency: 10 +quiet: true +``` + +
+ +## 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 }}"}' +``` + +
+YAML equivalent + +```yaml +url: http://example.com +requests: 10 +concurrency: 1 +dryRun: true +headers: + X-Request-ID: "{{ fakeit_UUID }}" +body: '{"user": "{{ fakeit_Name }}"}' +``` + +
+ +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 +``` + +
+YAML equivalent + +```yaml +url: http://example.com +requests: 1000 +concurrency: 10 +showConfig: true +headers: + Authorization: Bearer token +``` + +
diff --git a/docs/static/demo.gif b/docs/static/demo.gif new file mode 100644 index 0000000000000000000000000000000000000000..5f07ab5128ce16844deb698e881b69b7c1ca2557 GIT binary patch literal 97766 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 SOK-jQ-ivR(`tI9nKma>sNG#C+ literal 0 HcmV?d00001 diff --git a/docs/templating.md b/docs/templating.md new file mode 100644 index 0000000..5a4bf62 --- /dev/null +++ b/docs/templating.md @@ -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 | `"..."` | + +### 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"` | diff --git a/go.mod b/go.mod index 58e414e..6c79e39 100644 --- a/go.mod +++ b/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 ) diff --git a/go.sum b/go.sum index 3668d1f..da1314c 100644 --- a/go.sum +++ b/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= diff --git a/internal/config/cli.go b/internal/config/cli.go new file mode 100644 index 0000000..5e0ed60 --- /dev/null +++ b/internal/config/cli.go @@ -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, + ) +} diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..cbaa374 --- /dev/null +++ b/internal/config/config.go @@ -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() +} diff --git a/internal/config/env.go b/internal/config/env.go new file mode 100644 index 0000000..fbdd0e9 --- /dev/null +++ b/internal/config/env.go @@ -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)) +} diff --git a/internal/config/file.go b/internal/config/file.go new file mode 100644 index 0000000..5df450d --- /dev/null +++ b/internal/config/file.go @@ -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 +} diff --git a/internal/config/template_validator.go b/internal/config/template_validator.go new file mode 100644 index 0000000..72de180 --- /dev/null +++ b/internal/config/template_validator.go @@ -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 +} diff --git a/internal/sarin/client.go b/internal/sarin/client.go new file mode 100644 index 0000000..bd32cff --- /dev/null +++ b/internal/sarin/client.go @@ -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...) + } +} diff --git a/internal/sarin/helpers.go b/internal/sarin/helpers.go new file mode 100644 index 0000000..d5cc22e --- /dev/null +++ b/internal/sarin/helpers.go @@ -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 + ) +} diff --git a/internal/sarin/request.go b/internal/sarin/request.go new file mode 100644 index 0000000..96768c9 --- /dev/null +++ b/internal/sarin/request.go @@ -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 +} diff --git a/internal/sarin/response.go b/internal/sarin/response.go new file mode 100644 index 0000000..3c62a8b --- /dev/null +++ b/internal/sarin/response.go @@ -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") +} diff --git a/internal/sarin/sarin.go b/internal/sarin/sarin.go new file mode 100644 index 0000000..6c3a9bb --- /dev/null +++ b/internal/sarin/sarin.go @@ -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{}{} +} diff --git a/internal/sarin/template.go b/internal/sarin/template.go new file mode 100644 index 0000000..1e42b8d --- /dev/null +++ b/internal/sarin/template.go @@ -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 +} diff --git a/internal/types/config_file.go b/internal/types/config_file.go new file mode 100644 index 0000000..898bb2f --- /dev/null +++ b/internal/types/config_file.go @@ -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 +} diff --git a/internal/types/cookie.go b/internal/types/cookie.go new file mode 100644 index 0000000..927f900 --- /dev/null +++ b/internal/types/cookie.go @@ -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]}} +} diff --git a/internal/types/errors.go b/internal/types/errors.go new file mode 100644 index 0000000..ab73acd --- /dev/null +++ b/internal/types/errors.go @@ -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 +} diff --git a/internal/types/header.go b/internal/types/header.go new file mode 100644 index 0000000..9215218 --- /dev/null +++ b/internal/types/header.go @@ -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]}} +} diff --git a/internal/types/key_value.go b/internal/types/key_value.go new file mode 100644 index 0000000..855346d --- /dev/null +++ b/internal/types/key_value.go @@ -0,0 +1,6 @@ +package types + +type KeyValue[K, V any] struct { + Key K + Value V +} diff --git a/internal/types/param.go b/internal/types/param.go new file mode 100644 index 0000000..d043396 --- /dev/null +++ b/internal/types/param.go @@ -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]}} +} diff --git a/internal/types/proxy.go b/internal/types/proxy.go new file mode 100644 index 0000000..365cd13 --- /dev/null +++ b/internal/types/proxy.go @@ -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 +} diff --git a/internal/version/version.go b/internal/version/version.go new file mode 100644 index 0000000..a8034f7 --- /dev/null +++ b/internal/version/version.go @@ -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 +) diff --git a/main.go b/main.go deleted file mode 100644 index 5e99a86..0000000 --- a/main.go +++ /dev/null @@ -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() -} diff --git a/requests/client.go b/requests/client.go deleted file mode 100644 index 4336a0c..0000000 --- a/requests/client.go +++ /dev/null @@ -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 - } -} diff --git a/requests/helper.go b/requests/helper.go deleted file mode 100644 index 47d4bfd..0000000 --- a/requests/helper.go +++ /dev/null @@ -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) - } - } -} diff --git a/requests/request.go b/requests/request.go deleted file mode 100644 index acd1a6a..0000000 --- a/requests/request.go +++ /dev/null @@ -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() - } - } -} diff --git a/requests/response.go b/requests/response.go deleted file mode 100644 index be3ec76..0000000 --- a/requests/response.go +++ /dev/null @@ -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() -} diff --git a/requests/run.go b/requests/run.go deleted file mode 100644 index edd59d6..0000000 --- a/requests/run.go +++ /dev/null @@ -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 - }() - } -} diff --git a/types/body.go b/types/body.go deleted file mode 100644 index 4329b87..0000000 --- a/types/body.go +++ /dev/null @@ -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 -} diff --git a/types/config_file.go b/types/config_file.go deleted file mode 100644 index f5f2a89..0000000 --- a/types/config_file.go +++ /dev/null @@ -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:] -} diff --git a/types/cookies.go b/types/cookies.go deleted file mode 100644 index c7d182f..0000000 --- a/types/cookies.go +++ /dev/null @@ -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 -} diff --git a/types/duration.go b/types/duration.go deleted file mode 100644 index 5b79612..0000000 --- a/types/duration.go +++ /dev/null @@ -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)") - } -} diff --git a/types/durations.go b/types/durations.go deleted file mode 100644 index ee4970d..0000000 --- a/types/durations.go +++ /dev/null @@ -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)) -} diff --git a/types/errors.go b/types/errors.go deleted file mode 100644 index aeb7172..0000000 --- a/types/errors.go +++ /dev/null @@ -1,10 +0,0 @@ -package types - -import ( - "errors" -) - -var ( - ErrInterrupt = errors.New("interrupted") - ErrTimeout = errors.New("timeout") -) diff --git a/types/headers.go b/types/headers.go deleted file mode 100644 index dad080f..0000000 --- a/types/headers.go +++ /dev/null @@ -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 -} diff --git a/types/key_value.go b/types/key_value.go deleted file mode 100644 index 1361c35..0000000 --- a/types/key_value.go +++ /dev/null @@ -1,6 +0,0 @@ -package types - -type KeyValue[K comparable, V any] struct { - Key K - Value V -} diff --git a/types/params.go b/types/params.go deleted file mode 100644 index 598876d..0000000 --- a/types/params.go +++ /dev/null @@ -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 -} diff --git a/types/proxies.go b/types/proxies.go deleted file mode 100644 index 7d8323d..0000000 --- a/types/proxies.go +++ /dev/null @@ -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 -} diff --git a/types/request_url.go b/types/request_url.go deleted file mode 100644 index 4553ec4..0000000 --- a/types/request_url.go +++ /dev/null @@ -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 -} diff --git a/types/timeout.go b/types/timeout.go deleted file mode 100644 index 7d9e24a..0000000 --- a/types/timeout.go +++ /dev/null @@ -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)") - } -} diff --git a/utils/compare.go b/utils/compare.go deleted file mode 100644 index e49389f..0000000 --- a/utils/compare.go +++ /dev/null @@ -1,10 +0,0 @@ -package utils - -func IsNilOrZero[T comparable](value *T) bool { - if value == nil { - return true - } - - var zero T - return *value == zero -} diff --git a/utils/convert.go b/utils/convert.go deleted file mode 100644 index 14d369b..0000000 --- a/utils/convert.go +++ /dev/null @@ -1,5 +0,0 @@ -package utils - -func ToPtr[T any](value T) *T { - return &value -} diff --git a/utils/int.go b/utils/int.go deleted file mode 100644 index 6faa348..0000000 --- a/utils/int.go +++ /dev/null @@ -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 -} diff --git a/utils/print.go b/utils/print.go deleted file mode 100644 index 489688a..0000000 --- a/utils/print.go +++ /dev/null @@ -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) -} diff --git a/utils/slice.go b/utils/slice.go deleted file mode 100644 index 3c6d636..0000000 --- a/utils/slice.go +++ /dev/null @@ -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 - } - } -} diff --git a/utils/templates.go b/utils/templates.go deleted file mode 100644 index ab54122..0000000 --- a/utils/templates.go +++ /dev/null @@ -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, - } -} diff --git a/utils/time.go b/utils/time.go deleted file mode 100644 index 9aff2fa..0000000 --- a/utils/time.go +++ /dev/null @@ -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 -}