31 Commits

Author SHA1 Message Date
29b85d5b83 Add config file support to CLI parser
Add -f/--config-file flag for loading YAML configs from local or remote sources. Fix error handling to return unmatched errors.
2025-08-28 23:57:00 +04:00
42335c1178 Here we go again... 2025-08-28 21:25:10 +04:00
25d4762a3c Merge pull request #130 from aykhans/refactor/response
Refactor 'Responses' type and its methods
2025-08-17 19:51:50 +04:00
361d423651 ⬆️ bump version to 0.7.3 2025-08-17 19:31:36 +04:00
ffa724fae7 🔨 Refactor 'Responses' type and its methods 2025-08-17 19:30:26 +04:00
7930be490d Merge pull request #129 from aykhans/bump/go-version
🔖 Bump go version to 1.25
2025-08-17 15:48:35 +04:00
e6c54e9cb2 🔖 Bump golangci-lint version to v2.4.0 2025-08-17 15:47:04 +04:00
b32f567de7 🔖 Bump go version to 1.25 2025-08-17 15:45:14 +04:00
b6e85d9443 Merge pull request #128 from aykhans/docs/update
📚 Update README.md
2025-08-17 15:31:14 +04:00
827e3535cd Merge pull request #127 from aykhans/dependabot/go_modules/github.com/valyala/fasthttp-1.65.0
Bump github.com/valyala/fasthttp from 1.64.0 to 1.65.0
2025-08-17 15:31:03 +04:00
7ecf534d87 📚 Update README.md 2025-08-17 15:30:19 +04:00
dependabot[bot]
17ad5fadb9 Bump github.com/valyala/fasthttp from 1.64.0 to 1.65.0
Bumps [github.com/valyala/fasthttp](https://github.com/valyala/fasthttp) from 1.64.0 to 1.65.0.
- [Release notes](https://github.com/valyala/fasthttp/releases)
- [Commits](https://github.com/valyala/fasthttp/compare/v1.64.0...v1.65.0)

---
updated-dependencies:
- dependency-name: github.com/valyala/fasthttp
  dependency-version: 1.65.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-08-15 00:35:43 +00:00
7fb59a7989 Merge pull request #126 from aykhans/bump/version
⬆️ bump version to 0.7.2
2025-07-30 11:52:54 +04:00
527909c882 ⬆️ bump version to 0.7.2 2025-07-30 11:52:44 +04:00
4459675efa Merge pull request #125 from aykhans/dependabot/go_modules/github.com/jedib0t/go-pretty/v6-6.6.8
Bump github.com/jedib0t/go-pretty/v6 from 6.6.7 to 6.6.8
2025-07-29 10:33:41 +04:00
dependabot[bot]
604af355e6 Bump github.com/jedib0t/go-pretty/v6 from 6.6.7 to 6.6.8
Bumps [github.com/jedib0t/go-pretty/v6](https://github.com/jedib0t/go-pretty) from 6.6.7 to 6.6.8.
- [Release notes](https://github.com/jedib0t/go-pretty/releases)
- [Commits](https://github.com/jedib0t/go-pretty/compare/v6.6.7...v6.6.8)

---
updated-dependencies:
- dependency-name: github.com/jedib0t/go-pretty/v6
  dependency-version: 6.6.8
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-07-29 04:16:35 +00:00
7d4267c4c2 Merge pull request #124 from aykhans/dependabot/go_modules/github.com/valyala/fasthttp-1.64.0
Bump github.com/valyala/fasthttp from 1.63.0 to 1.64.0
2025-07-16 10:56:35 +04:00
dependabot[bot]
845ab7296c Bump github.com/valyala/fasthttp from 1.63.0 to 1.64.0
---
updated-dependencies:
- dependency-name: github.com/valyala/fasthttp
  dependency-version: 1.64.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-07-16 00:55:32 +00:00
49d004ff06 Merge pull request #123 from aykhans/docs/refactor
💄 Remove logo
2025-07-09 21:37:11 +04:00
045deb6120 💄 Remove logo 2025-07-09 21:36:53 +04:00
075ef26203 Merge pull request #122 from aykhans/dependabot/go_modules/github.com/valyala/fasthttp-1.63.0
Bump github.com/valyala/fasthttp from 1.62.0 to 1.63.0
2025-07-02 12:04:07 +04:00
dependabot[bot]
946afbb2c3 Bump github.com/valyala/fasthttp from 1.62.0 to 1.63.0
Bumps [github.com/valyala/fasthttp](https://github.com/valyala/fasthttp) from 1.62.0 to 1.63.0.
- [Release notes](https://github.com/valyala/fasthttp/releases)
- [Commits](https://github.com/valyala/fasthttp/compare/v1.62.0...v1.63.0)

---
updated-dependencies:
- dependency-name: github.com/valyala/fasthttp
  dependency-version: 1.63.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-07-02 00:49:09 +00:00
aacb33cfa5 Merge pull request #121 from aykhans/feat/config
🔨 Remove default 'http' schema from request URL
2025-06-28 23:45:11 +04:00
4a7db48351 🔨 Remove default 'http' schema from request URL 2025-06-28 23:44:04 +04:00
b73087dce5 Merge pull request #120 from aykhans/dependabot/go_modules/github.com/brianvoe/gofakeit/v7-7.3.0
Bump github.com/brianvoe/gofakeit/v7 from 7.2.1 to 7.3.0
2025-06-27 18:04:40 +04:00
dependabot[bot]
20a46feab8 Bump github.com/brianvoe/gofakeit/v7 from 7.2.1 to 7.3.0
Bumps [github.com/brianvoe/gofakeit/v7](https://github.com/brianvoe/gofakeit) from 7.2.1 to 7.3.0.
- [Release notes](https://github.com/brianvoe/gofakeit/releases)
- [Commits](https://github.com/brianvoe/gofakeit/compare/v7.2.1...v7.3.0)

---
updated-dependencies:
- dependency-name: github.com/brianvoe/gofakeit/v7
  dependency-version: 7.3.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-06-27 00:56:38 +00:00
0adde6e04e Merge pull request #118 from aykhans/core/refactor
General Refactoring
2025-06-08 23:39:02 +04:00
ca50de4e2f ⬆️ bump version to 0.7.1 2025-06-08 20:57:01 +04:00
c99e7c66d9 📚 Update docs 2025-06-08 20:56:00 +04:00
280e5f5c4e 📚 Add EXAMPLES.md 2025-06-08 20:55:45 +04:00
47dfad6046 🔧 Add more template functions 2025-06-08 20:55:02 +04:00
63 changed files with 4695 additions and 3509 deletions

View File

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

View File

@@ -21,5 +21,5 @@ jobs:
- name: golangci-lint
uses: golangci/golangci-lint-action@v7
with:
version: v2.0.2
version: v2.4.0
args: --timeout=10m --config=.golangci.yml

View File

@@ -1,7 +1,7 @@
version: "2"
run:
go: "1.24"
go: "1.25"
concurrency: 8
timeout: 10m
@@ -24,6 +24,72 @@ linters:
- unconvert
- unused
- whitespace
- bidichk
- bodyclose
- containedctx
- contextcheck
- copyloopvar
- decorder
- dogsled
- dupword
- durationcheck
- embeddedstructfieldcheck
- errchkjson
- errorlint
- exhaustive
- exptostd
- fatcontext
- forcetypeassert
- funcorder
- ginkgolinter
- gocheckcompilerdirectives
- gochecknoinits
- gochecksumtype
- goconst
- gocritic
- gocyclo
- godox
- goheader
- gomoddirectives
- gosec
- gosmopolitan
- grouper
- iface
- importas
- inamedparam
- interfacebloat
- intrange
- ireturn
- loggercheck
- makezero
- mirror
- musttag
- nilerr
- nilnesserr
- nilnil
- noctx
- nonamedreturns
- nosprintfhostport
- perfsprint
- predeclared
- promlinter
- protogetter
- sloglint
- spancheck
- sqlclosecheck
- tagalign
- tagliatelle
- testableexamples
- testifylint
- thelper
- tparallel
- unparam
- usestdlibvars
- usetesting
- varnamelen
- wastedassign
- wrapcheck
- zerologlint
settings:
staticcheck:
@@ -31,3 +97,39 @@ linters:
- "all"
- "-S1002"
- "-ST1000"
varnamelen:
ignore-decls:
- i int
exclusions:
rules:
- path: _test\.go$
linters:
- errorlint
- forcetypeassert
- perfsprint
- errcheck
- gosec
- path: _test\.go$
linters:
- staticcheck
text: "SA5011"
formatters:
enable:
- gofmt
settings:
gofmt:
# Simplify code: gofmt with `-s` option.
# Default: true
simplify: false
# Apply the rewrite rules to the source before reformatting.
# https://pkg.go.dev/cmd/gofmt
# Default: []
rewrite-rules:
- pattern: "interface{}"
replacement: "any"
- pattern: "a[b:len(a)]"
replacement: "a[b:]"

View File

@@ -1,4 +1,4 @@
FROM golang:1.24-alpine AS builder
FROM golang:1.25-alpine AS builder
WORKDIR /src

21
LICENSE
View File

@@ -1,21 +0,0 @@
MIT License
Copyright (c) 2024 Aykhan Shahsuvarov
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

319
README.md
View File

@@ -1,319 +0,0 @@
<h1 align="center">Dodo - A Fast and Easy-to-Use HTTP Benchmarking Tool</h1>
<p align="center">
<img width="30%" height="30%" src="https://ftp.aykhans.me/web/client/pubshares/VzPtSHS7yPQT7ngoZzZSNU/browse?path=%2Fdodo.png">
</p>
## Table of Contents
- [Installation](#installation)
- [Using Docker (Recommended)](#using-docker-recommended)
- [Using Pre-built Binaries](#using-pre-built-binaries)
- [Building from Source](#building-from-source)
- [Usage](#usage)
- [1. CLI Usage](#1-cli-usage)
- [2. Config File Usage](#2-config-file-usage)
- [2.1 YAML/YML Example](#21-yamlyml-example)
- [2.2 JSON Example](#22-json-example)
- [3. CLI & Config File Combination](#3-cli--config-file-combination)
- [Config Parameters Reference](#config-parameters-reference)
- [Template Functions](#template-functions)
## Installation
### Using Docker (Recommended)
Pull the latest Dodo image from Docker Hub:
```sh
docker pull aykhans/dodo:latest
```
To use Dodo with Docker and a local config file, mount the config file as a volume and pass it as an argument:
```sh
docker run -v /path/to/config.json:/config.json aykhans/dodo -f /config.json
```
If you're using a remote config file via URL, you don't need to mount a volume:
```sh
docker run aykhans/dodo -f https://raw.githubusercontent.com/aykhans/dodo/main/config.yaml
```
### Using Pre-built Binaries
Download the latest binaries from the [releases](https://github.com/aykhans/dodo/releases) section.
### Building from Source
To build Dodo from source, ensure you have [Go 1.24+](https://golang.org/dl/) installed.
```sh
go install -ldflags "-s -w" github.com/aykhans/dodo@latest
```
## Usage
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:
```sh
dodo -u https://example.com -m GET -d 10 -r 1000 -o 1m -t 2s
```
With Docker:
```sh
docker run --rm -i aykhans/dodo -u https://example.com -m GET -d 10 -r 1000 -o 1m -t 2s
```
### 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"
```
```sh
dodo -f /path/config.yaml
# OR
dodo -f https://example.com/config.yaml
```
With Docker:
```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
```
#### 2.2 JSON Example
```jsonc
{
"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",
],
}
```
```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
```
## 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`.

View File

@@ -2,23 +2,8 @@
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]
tasks:
run: go run main.go
run: go run cmd/cli/main.go {{.CLI_ARGS}}
ftl:
cmds:
@@ -26,28 +11,10 @@ tasks:
- task: tidy
- task: lint
fmt: gofmt -w -d .
fmt: golangci-lint fmt
tidy: go mod tidy
lint: golangci-lint run
build: CGO_ENABLED=0 go build -ldflags "-s -w" -o "dodo"
build-all:
silent: true
cmds:
- rm -rf binaries
- |
{{ $ext := "" }}
{{- range $platform := .PLATFORMS }}
{{- if eq $platform.os "windows" }}
{{ $ext = ".exe" }}
{{- end }}
{{- 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"
test: go test ./...

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

@@ -0,0 +1,45 @@
package main
import (
"fmt"
"os"
"github.com/aykhans/dodo/pkg/config"
"github.com/aykhans/dodo/pkg/types"
"github.com/aykhans/dodo/pkg/utils"
"github.com/jedib0t/go-pretty/v6/text"
)
func main() {
cliParser := config.NewConfigCLIParser(os.Args)
cfg, err := cliParser.Parse()
_ = utils.HandleErrorOrDie(err,
utils.OnSentinelError(types.ErrCLINoArgs, func(err error) error {
cliParser.PrintHelp()
utils.PrintErrAndExit(text.FgRed, 1, "\nNo arguments provided.")
return nil
}),
utils.OnCustomError(func(err types.CLIUnexpectedArgsError) error {
cliParser.PrintHelp()
utils.PrintErrAndExit(text.FgRed, 1, "\nUnexpected CLI arguments provided: %v", err.Args)
return nil
}),
utils.OnCustomError(func(err types.FieldParseErrors) error {
cliParser.PrintHelp()
fmt.Println()
printValidationErrors("CLI", err.Errors...)
fmt.Println()
os.Exit(1)
return nil
}),
)
fmt.Println(cfg)
}
func printValidationErrors(parserName string, errors ...types.FieldParseError) {
for _, fieldErr := range errors {
utils.PrintErr(text.FgRed, "[%s] Field '%s': %v", parserName, fieldErr.Field, fieldErr.Err)
}
}

View File

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

View File

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

View File

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

View File

@@ -1,367 +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.0"
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 == "" {
config.URL.Scheme = "http"
}
if config.URL.Scheme != "http" && config.URL.Scheme != "https" {
errs = append(errs, errors.New("request URL scheme must be http or https"))
}
urlParams := types.Params{}
for key, values := range config.URL.Query() {
for _, value := range values {
urlParams = append(urlParams, types.KeyValue[string, []string]{
Key: key,
Value: []string{value},
})
}
}
config.Params = append(urlParams, config.Params...)
config.URL.RawQuery = ""
}
if utils.IsNilOrZero(config.Method) {
errs = append(errs, errors.New("request method is required"))
}
if utils.IsNilOrZero(config.Timeout) {
errs = append(errs, errors.New("request timeout must be greater than 0"))
}
if utils.IsNilOrZero(config.DodosCount) {
errs = append(errs, errors.New("dodos count must be greater than 0"))
}
if utils.IsNilOrZero(config.Duration) && utils.IsNilOrZero(config.RequestCount) {
errs = append(errs, errors.New("you should provide at least one of duration or request count"))
}
for i, proxy := range config.Proxies {
if proxy.String() == "" {
errs = append(errs, fmt.Errorf("proxies[%d]: proxy cannot be empty", i))
} else if schema := proxy.Scheme; !slices.Contains(SupportedProxySchemes, schema) {
errs = append(errs,
fmt.Errorf("proxies[%d]: proxy has unsupported scheme \"%s\" (supported schemes: %s)",
i, proxy.String(), strings.Join(SupportedProxySchemes, ", "),
),
)
}
}
funcMap := *utils.NewFuncMapGenerator(
rand.New(
rand.NewSource(
time.Now().UnixNano(),
),
),
).GetFuncMap()
for _, header := range config.Headers {
t, err := template.New("default").Funcs(funcMap).Parse(header.Key)
if err != nil {
errs = append(errs, fmt.Errorf("header key (%s) parse error: %v", header.Key, err))
} else {
var buf bytes.Buffer
if err = t.Execute(&buf, nil); err != nil {
errs = append(errs, fmt.Errorf("header key (%s) parse error: %v", header.Key, err))
}
}
for _, value := range header.Value {
t, err := template.New("default").Funcs(funcMap).Parse(value)
if err != nil {
errs = append(errs, fmt.Errorf("header value (%s) parse error: %v", value, err))
} else {
var buf bytes.Buffer
if err = t.Execute(&buf, nil); err != nil {
errs = append(errs, fmt.Errorf("header value (%s) parse error: %v", value, err))
}
}
}
}
for _, cookie := range config.Cookies {
t, err := template.New("default").Funcs(funcMap).Parse(cookie.Key)
if err != nil {
errs = append(errs, fmt.Errorf("cookie key (%s) parse error: %v", cookie.Key, err))
} else {
var buf bytes.Buffer
if err = t.Execute(&buf, nil); err != nil {
errs = append(errs, fmt.Errorf("cookie key (%s) parse error: %v", cookie.Key, err))
}
}
for _, value := range cookie.Value {
t, err := template.New("default").Funcs(funcMap).Parse(value)
if err != nil {
errs = append(errs, fmt.Errorf("cookie value (%s) parse error: %v", value, err))
} else {
var buf bytes.Buffer
if err = t.Execute(&buf, nil); err != nil {
errs = append(errs, fmt.Errorf("cookie value (%s) parse error: %v", value, err))
}
}
}
}
for _, param := range config.Params {
t, err := template.New("default").Funcs(funcMap).Parse(param.Key)
if err != nil {
errs = append(errs, fmt.Errorf("param key (%s) parse error: %v", param.Key, err))
} else {
var buf bytes.Buffer
if err = t.Execute(&buf, nil); err != nil {
errs = append(errs, fmt.Errorf("param key (%s) parse error: %v", param.Key, err))
}
}
for _, value := range param.Value {
t, err := template.New("default").Funcs(funcMap).Parse(value)
if err != nil {
errs = append(errs, fmt.Errorf("param value (%s) parse error: %v", value, err))
} else {
var buf bytes.Buffer
if err = t.Execute(&buf, nil); err != nil {
errs = append(errs, fmt.Errorf("param value (%s) parse error: %v", value, err))
}
}
}
}
for _, body := range config.Body {
t, err := template.New("default").Funcs(funcMap).Parse(body)
if err != nil {
errs = append(errs, fmt.Errorf("body (%s) parse error: %v", body, err))
} else {
var buf bytes.Buffer
if err = t.Execute(&buf, nil); err != nil {
errs = append(errs, fmt.Errorf("body (%s) parse error: %v", body, err))
}
}
}
return errs
}
func (config *Config) MergeConfig(newConfig *Config) {
if newConfig.Method != nil {
config.Method = newConfig.Method
}
if newConfig.URL != nil {
config.URL = newConfig.URL
}
if newConfig.Timeout != nil {
config.Timeout = newConfig.Timeout
}
if newConfig.DodosCount != nil {
config.DodosCount = newConfig.DodosCount
}
if newConfig.RequestCount != nil {
config.RequestCount = newConfig.RequestCount
}
if newConfig.Duration != nil {
config.Duration = newConfig.Duration
}
if newConfig.Yes != nil {
config.Yes = newConfig.Yes
}
if newConfig.SkipVerify != nil {
config.SkipVerify = newConfig.SkipVerify
}
if len(newConfig.Params) != 0 {
config.Params = newConfig.Params
}
if len(newConfig.Headers) != 0 {
config.Headers = newConfig.Headers
}
if len(newConfig.Cookies) != 0 {
config.Cookies = newConfig.Cookies
}
if len(newConfig.Body) != 0 {
config.Body = newConfig.Body
}
if len(newConfig.Proxies) != 0 {
config.Proxies = newConfig.Proxies
}
}
func (config *Config) SetDefaults() {
if config.Method == nil {
config.Method = utils.ToPtr(DefaultMethod)
}
if config.Timeout == nil {
config.Timeout = &types.Timeout{Duration: DefaultTimeout}
}
if config.DodosCount == nil {
config.DodosCount = utils.ToPtr(DefaultDodosCount)
}
if config.RequestCount == nil {
config.RequestCount = utils.ToPtr(DefaultRequestCount)
}
if config.Duration == nil {
config.Duration = &types.Duration{Duration: DefaultDuration}
}
if config.Yes == nil {
config.Yes = utils.ToPtr(DefaultYes)
}
if config.SkipVerify == nil {
config.SkipVerify = utils.ToPtr(DefaultSkipVerify)
}
config.Headers.SetIfNotExists("User-Agent", DefaultUserAgent)
}

View File

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

20
go.mod
View File

@@ -1,22 +1,18 @@
module github.com/aykhans/dodo
go 1.24.2
go 1.25
require (
github.com/brianvoe/gofakeit/v7 v7.2.1
github.com/jedib0t/go-pretty/v6 v6.6.7
github.com/valyala/fasthttp v1.62.0
gopkg.in/yaml.v3 v3.0.1
github.com/jedib0t/go-pretty/v6 v6.6.8
github.com/stretchr/testify v1.10.0
)
require (
github.com/andybalholm/brotli v1.1.1 // indirect
github.com/klauspost/compress v1.18.0 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/mattn/go-runewidth v0.0.16 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/rivo/uniseg v0.4.7 // indirect
github.com/valyala/bytebufferpool v1.0.0 // indirect
golang.org/x/net v0.40.0 // indirect
golang.org/x/sys v0.33.0 // indirect
golang.org/x/term v0.32.0 // indirect
golang.org/x/text v0.25.0 // indirect
golang.org/x/sys v0.30.0 // indirect
golang.org/x/text v0.22.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)

28
go.sum
View File

@@ -1,13 +1,7 @@
github.com/andybalholm/brotli v1.1.1 h1:PR2pgnyFznKEugtsUo0xLdDop5SKXd5Qf5ysW+7XdTA=
github.com/andybalholm/brotli v1.1.1/go.mod h1:05ib4cKhjx3OQYUY22hTVd34Bc8upXjOLL2rKwwZBoA=
github.com/brianvoe/gofakeit/v7 v7.2.1 h1:AGojgaaCdgq4Adzrd2uWdbGNDyX6MWNhHdQBraNfOHI=
github.com/brianvoe/gofakeit/v7 v7.2.1/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.7 h1:m+LbHpm0aIAPLzLbMfn8dc3Ht8MW7lsSO4MPItz/Uuo=
github.com/jedib0t/go-pretty/v6 v6.6.7/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/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/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=
@@ -17,20 +11,10 @@ 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/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.62.0 h1:8dKRBX/y2rCzyc6903Zu1+3qN0H/d2MsxPPmVNamiH0=
github.com/valyala/fasthttp v1.62.0/go.mod h1:FCINgr4GKdKqV8Q0xv8b+UxPV+H/O5nNFo3D+r54Htg=
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.40.0 h1:79Xs7wF06Gbdcg4kdCCIQArK11Z1hr5POQ6+fIYHNuY=
golang.org/x/net v0.40.0/go.mod h1:y0hY0exeL2Pku80/zKK7tpntoX23cqL3Oa6njdgRtds=
golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw=
golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
golang.org/x/term v0.32.0 h1:DR4lr0TjUs3epypdhTOkMmuF5CDFJ/8pOnbzMZPQ7bg=
golang.org/x/term v0.32.0/go.mod h1:uZG1FhGx848Sqfsq4/DlJr3xGGsYMu/L5GW4abiaEPQ=
golang.org/x/text v0.25.0 h1:qVyWApTSYLk/drJRO5mDlNYskwQznZmkpV2c8q9zls4=
golang.org/x/text v0.25.0/go.mod h1:WEdwpYrmk1qmdHvhkSTNPm3app7v4rsT8F2UD6+VHIA=
golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc=
golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/text v0.22.0 h1:bofq7m3/HAFvbF51jz3Q9wLg3jkvSPuiZu/pD1XwgtM=
golang.org/x/text v0.22.0/go.mod h1:YRoo4H8PVmsu+E3Ou7cqLVH8oXWIHVoX0jqUWALQhfY=
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=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=

69
main.go
View File

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

283
pkg/config/cli.go Normal file
View File

@@ -0,0 +1,283 @@
package config
import (
"errors"
"flag"
"fmt"
"net/url"
"strings"
"time"
"github.com/aykhans/dodo/pkg/types"
"github.com/aykhans/dodo/pkg/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)`
type ConfigCLIParser struct {
args []string
configFile *types.ConfigFile
}
func NewConfigCLIParser(args []string) *ConfigCLIParser {
if args == nil {
args = []string{}
}
return &ConfigCLIParser{args: args}
}
func (parser ConfigCLIParser) GetConfigFile() *types.ConfigFile {
return parser.configFile
}
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("dodo", flag.ExitOnError)
flagSet.Usage = func() { parser.PrintHelp() }
var (
config = &Config{}
configFile string
yes bool
skipVerify bool
method string
urlInput string
dodosCount uint
requestCount uint
duration time.Duration
timeout time.Duration
params = stringSliceArg{}
headers = stringSliceArg{}
cookies = stringSliceArg{}
bodies = stringSliceArg{}
proxies = stringSliceArg{}
)
{
flagSet.StringVar(&configFile, "config-file", "", "Config file")
flagSet.StringVar(&configFile, "f", "", "Config file")
flagSet.BoolVar(&yes, "yes", false, "Answer yes to all questions")
flagSet.BoolVar(&yes, "y", false, "Answer yes to all questions")
flagSet.BoolVar(&skipVerify, "skip-verify", false, "Skip SSL/TLS certificate verification")
flagSet.StringVar(&method, "method", "", "HTTP Method")
flagSet.StringVar(&method, "m", "", "HTTP Method")
flagSet.StringVar(&urlInput, "url", "", "URL to send the request")
flagSet.StringVar(&urlInput, "u", "", "URL to send the request")
flagSet.UintVar(&dodosCount, "dodos", 0, "Number of dodos(threads)")
flagSet.UintVar(&dodosCount, "d", 0, "Number of dodos(threads)")
flagSet.UintVar(&requestCount, "requests", 0, "Number of total requests")
flagSet.UintVar(&requestCount, "r", 0, "Number of total requests")
flagSet.DurationVar(&duration, "duration", 0, "Maximum duration of the test")
flagSet.DurationVar(&duration, "o", 0, "Maximum duration of the test")
flagSet.DurationVar(&timeout, "timeout", 0, "Timeout for each request (e.g. 400ms, 15s, 1m10s)")
flagSet.DurationVar(&timeout, "t", 0, "Timeout for each request (e.g. 400ms, 15s, 1m10s)")
flagSet.Var(&params, "param", "URL parameter to send with the request")
flagSet.Var(&params, "p", "URL parameter to send with the request")
flagSet.Var(&headers, "header", "Header to send with the request")
flagSet.Var(&headers, "H", "Header to send with the request")
flagSet.Var(&cookies, "cookie", "Cookie to send with the request")
flagSet.Var(&cookies, "c", "Cookie to send with the request")
flagSet.Var(&bodies, "body", "Body to send with the request")
flagSet.Var(&bodies, "b", "Body to send with the request")
flagSet.Var(&proxies, "proxy", "Proxy to use for the request")
flagSet.Var(&proxies, "x", "Proxy to use for the request")
}
// 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 `dodo` 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)
}
var fieldParseErrors []types.FieldParseError
// Iterate over flags that were explicitly set on the command line.
flagSet.Visit(func(flagVar *flag.Flag) {
switch flagVar.Name {
case "config-file", "f":
var err error
parser.configFile, err = types.ParseConfigFile(configFile)
_ = utils.HandleErrorOrDie(err,
utils.OnSentinelError(types.ErrConfigFileExtensionNotFound, func(err error) error {
fieldParseErrors = append(fieldParseErrors, *types.NewFieldParseError("config-file", errors.New("file extension not found")))
return nil
}),
utils.OnCustomError(func(err types.RemoteConfigFileParseError) error {
fieldParseErrors = append(fieldParseErrors, *types.NewFieldParseError("config-file", fmt.Errorf("parse error: %w", err)))
return nil
}),
utils.OnCustomError(func(err types.UnknownConfigFileTypeError) error {
fieldParseErrors = append(
fieldParseErrors,
*types.NewFieldParseError(
"config-file",
fmt.Errorf("file type '%s' not supported (supported types: %s)", err.Type, types.ConfigFileTypeYAML.String()),
),
)
return nil
}),
)
case "yes", "y":
config.Yes = utils.ToPtr(yes)
case "skip-verify":
config.SkipVerify = utils.ToPtr(skipVerify)
case "method", "m":
config.Method = utils.ToPtr(method)
case "url", "u":
urlParsed, err := url.Parse(urlInput)
if err != nil {
fieldParseErrors = append(fieldParseErrors, *types.NewFieldParseError("url", err))
} else {
config.URL = urlParsed
}
case "dodos", "d":
config.DodosCount = utils.ToPtr(dodosCount)
case "requests", "r":
config.RequestCount = utils.ToPtr(requestCount)
case "duration", "o":
config.Duration = utils.ToPtr(duration)
case "timeout", "t":
config.Timeout = utils.ToPtr(timeout)
case "param", "p":
config.Params.Parse(params...)
case "header", "H":
config.Headers.Parse(headers...)
case "cookie", "c":
config.Cookies.Parse(cookies...)
case "body", "b":
config.Bodies.Parse(bodies...)
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), err),
)
}
}
}
})
if len(fieldParseErrors) > 0 {
return nil, types.NewFieldParseErrors(fieldParseErrors)
}
return config, nil
}
func (parser *ConfigCLIParser) PrintHelp() {
fmt.Printf(
cliUsageText+"\n",
Defaults.Yes,
Defaults.DodosCount,
Defaults.RequestTimeout,
Defaults.Method,
Defaults.SkipVerify,
)
}
// 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 `def` parameter.
func CLIYesOrNoReader(message string, def bool) bool {
var answer string
defaultMessage := "Y/n"
if !def {
defaultMessage = "y/N"
}
fmt.Printf("%s [%s]: ", message, defaultMessage)
if _, err := fmt.Scanln(&answer); err != nil {
if err.Error() == "unexpected newline" {
return def
}
return false
}
if answer == "" {
return def
}
return answer == "y" || answer == "Y"
}

752
pkg/config/cli_test.go Normal file
View File

@@ -0,0 +1,752 @@
package config
import (
"bytes"
"io"
"net/url"
"os"
"testing"
"time"
"github.com/aykhans/dodo/pkg/types"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestNewConfigCLIParser(t *testing.T) {
t.Run("NewConfigCLIParser with valid args", func(t *testing.T) {
args := []string{"dodo", "-u", "https://example.com"}
parser := NewConfigCLIParser(args)
require.NotNil(t, parser)
assert.Equal(t, args, parser.args)
assert.Nil(t, parser.configFile)
})
t.Run("NewConfigCLIParser with nil args", func(t *testing.T) {
parser := NewConfigCLIParser(nil)
require.NotNil(t, parser)
assert.Equal(t, []string{}, parser.args)
assert.Nil(t, parser.configFile)
})
t.Run("NewConfigCLIParser with empty args", func(t *testing.T) {
args := []string{}
parser := NewConfigCLIParser(args)
require.NotNil(t, parser)
assert.Equal(t, args, parser.args)
assert.Nil(t, parser.configFile)
})
}
func TestConfigCLIParser_GetConfigFile(t *testing.T) {
t.Run("GetConfigFile returns nil when no config file is set", func(t *testing.T) {
parser := NewConfigCLIParser([]string{"dodo"})
assert.Nil(t, parser.GetConfigFile())
})
t.Run("GetConfigFile returns config file when set", func(t *testing.T) {
parser := NewConfigCLIParser([]string{"dodo"})
expectedConfigFile := &types.ConfigFile{}
parser.configFile = expectedConfigFile
assert.Equal(t, expectedConfigFile, parser.GetConfigFile())
})
}
func TestStringSliceArg(t *testing.T) {
t.Run("stringSliceArg String method", func(t *testing.T) {
arg := stringSliceArg{"value1", "value2", "value3"}
assert.Equal(t, "value1,value2,value3", arg.String())
})
t.Run("stringSliceArg String with empty slice", func(t *testing.T) {
arg := stringSliceArg{}
assert.Empty(t, arg.String())
})
t.Run("stringSliceArg String with single value", func(t *testing.T) {
arg := stringSliceArg{"single"}
assert.Equal(t, "single", arg.String())
})
t.Run("stringSliceArg Set method", func(t *testing.T) {
arg := &stringSliceArg{}
err := arg.Set("first")
require.NoError(t, err)
assert.Equal(t, stringSliceArg{"first"}, *arg)
err = arg.Set("second")
require.NoError(t, err)
assert.Equal(t, stringSliceArg{"first", "second"}, *arg)
})
t.Run("stringSliceArg Set with empty string", func(t *testing.T) {
arg := &stringSliceArg{}
err := arg.Set("")
require.NoError(t, err)
assert.Equal(t, stringSliceArg{""}, *arg)
})
}
func TestConfigCLIParser_Parse(t *testing.T) {
t.Run("Parse with no arguments returns ErrCLINoArgs", func(t *testing.T) {
parser := NewConfigCLIParser([]string{"dodo"})
config, err := parser.Parse()
assert.Nil(t, config)
require.ErrorIs(t, err, types.ErrCLINoArgs)
})
t.Run("Parse with unexpected arguments returns CLIUnexpectedArgsError", func(t *testing.T) {
parser := NewConfigCLIParser([]string{"dodo", "unexpected", "args"})
config, err := parser.Parse()
assert.Nil(t, config)
var cliErr types.CLIUnexpectedArgsError
require.ErrorAs(t, err, &cliErr)
assert.Equal(t, []string{"unexpected", "args"}, cliErr.Args)
})
t.Run("Parse with valid URL", func(t *testing.T) {
parser := NewConfigCLIParser([]string{"dodo", "-u", "https://example.com"})
config, err := parser.Parse()
require.NoError(t, err)
require.NotNil(t, config)
require.NotNil(t, config.URL)
assert.Equal(t, "https://example.com", config.URL.String())
})
t.Run("Parse with invalid URL returns FieldParseErrors", func(t *testing.T) {
parser := NewConfigCLIParser([]string{"dodo", "-u", "://invalid-url"})
config, err := parser.Parse()
assert.Nil(t, config)
var fieldErr types.FieldParseErrors
require.ErrorAs(t, err, &fieldErr)
assert.Len(t, fieldErr.Errors, 1)
assert.Equal(t, "url", fieldErr.Errors[0].Field)
})
t.Run("Parse with method flag", func(t *testing.T) {
parser := NewConfigCLIParser([]string{"dodo", "-m", "POST"})
config, err := parser.Parse()
require.NoError(t, err)
require.NotNil(t, config)
require.NotNil(t, config.Method)
assert.Equal(t, "POST", *config.Method)
})
t.Run("Parse with yes flag", func(t *testing.T) {
parser := NewConfigCLIParser([]string{"dodo", "-y"})
config, err := parser.Parse()
require.NoError(t, err)
require.NotNil(t, config)
require.NotNil(t, config.Yes)
assert.True(t, *config.Yes)
})
t.Run("Parse with skip-verify flag", func(t *testing.T) {
parser := NewConfigCLIParser([]string{"dodo", "-skip-verify"})
config, err := parser.Parse()
require.NoError(t, err)
require.NotNil(t, config)
require.NotNil(t, config.SkipVerify)
assert.True(t, *config.SkipVerify)
})
t.Run("Parse with dodos count", func(t *testing.T) {
parser := NewConfigCLIParser([]string{"dodo", "-d", "5"})
config, err := parser.Parse()
require.NoError(t, err)
require.NotNil(t, config)
require.NotNil(t, config.DodosCount)
assert.Equal(t, uint(5), *config.DodosCount)
})
t.Run("Parse with request count", func(t *testing.T) {
parser := NewConfigCLIParser([]string{"dodo", "-r", "1000"})
config, err := parser.Parse()
require.NoError(t, err)
require.NotNil(t, config)
require.NotNil(t, config.RequestCount)
assert.Equal(t, uint(1000), *config.RequestCount)
})
t.Run("Parse with duration", func(t *testing.T) {
parser := NewConfigCLIParser([]string{"dodo", "-o", "5m"})
config, err := parser.Parse()
require.NoError(t, err)
require.NotNil(t, config)
require.NotNil(t, config.Duration)
assert.Equal(t, 5*time.Minute, *config.Duration)
})
t.Run("Parse with timeout", func(t *testing.T) {
parser := NewConfigCLIParser([]string{"dodo", "-t", "30s"})
config, err := parser.Parse()
require.NoError(t, err)
require.NotNil(t, config)
require.NotNil(t, config.Timeout)
assert.Equal(t, 30*time.Second, *config.Timeout)
})
t.Run("Parse with parameters", func(t *testing.T) {
parser := NewConfigCLIParser([]string{"dodo", "-p", "key1=value1", "-p", "key2=value2"})
config, err := parser.Parse()
require.NoError(t, err)
require.NotNil(t, config)
assert.Len(t, config.Params, 2)
assert.Equal(t, "key1", config.Params[0].Key)
assert.Equal(t, []string{"value1"}, config.Params[0].Value)
assert.Equal(t, "key2", config.Params[1].Key)
assert.Equal(t, []string{"value2"}, config.Params[1].Value)
})
t.Run("Parse with headers", func(t *testing.T) {
parser := NewConfigCLIParser([]string{"dodo", "-H", "Content-Type: application/json", "-H", "Authorization: Bearer token"})
config, err := parser.Parse()
require.NoError(t, err)
require.NotNil(t, config)
assert.Len(t, config.Headers, 2)
assert.Equal(t, "Content-Type", config.Headers[0].Key)
assert.Equal(t, []string{"application/json"}, config.Headers[0].Value)
assert.Equal(t, "Authorization", config.Headers[1].Key)
assert.Equal(t, []string{"Bearer token"}, config.Headers[1].Value)
})
t.Run("Parse with cookies", func(t *testing.T) {
parser := NewConfigCLIParser([]string{"dodo", "-c", "session=abc123", "-c", "user=john"})
config, err := parser.Parse()
require.NoError(t, err)
require.NotNil(t, config)
assert.Len(t, config.Cookies, 2)
assert.Equal(t, "session", config.Cookies[0].Key)
assert.Equal(t, []string{"abc123"}, config.Cookies[0].Value)
assert.Equal(t, "user", config.Cookies[1].Key)
assert.Equal(t, []string{"john"}, config.Cookies[1].Value)
})
t.Run("Parse with bodies", func(t *testing.T) {
parser := NewConfigCLIParser([]string{"dodo", "-b", "body1", "-b", "body2"})
config, err := parser.Parse()
require.NoError(t, err)
require.NotNil(t, config)
assert.Len(t, config.Bodies, 2)
assert.Equal(t, types.Body("body1"), config.Bodies[0])
assert.Equal(t, types.Body("body2"), config.Bodies[1])
})
t.Run("Parse with valid proxies", func(t *testing.T) {
parser := NewConfigCLIParser([]string{"dodo", "-x", "http://proxy1.example.com:8080", "-x", "socks5://proxy2.example.com:1080"})
config, err := parser.Parse()
require.NoError(t, err)
require.NotNil(t, config)
assert.Len(t, config.Proxies, 2)
assert.Equal(t, "http://proxy1.example.com:8080", config.Proxies[0].String())
assert.Equal(t, "socks5://proxy2.example.com:1080", config.Proxies[1].String())
})
t.Run("Parse with invalid proxy returns FieldParseErrors", func(t *testing.T) {
parser := NewConfigCLIParser([]string{"dodo", "-x", "://invalid-proxy"})
config, err := parser.Parse()
assert.Nil(t, config)
var fieldErr types.FieldParseErrors
require.ErrorAs(t, err, &fieldErr)
assert.Len(t, fieldErr.Errors, 1)
assert.Equal(t, "proxy[0]", fieldErr.Errors[0].Field)
})
t.Run("Parse with mixed valid and invalid proxies", func(t *testing.T) {
parser := NewConfigCLIParser([]string{"dodo", "-x", "http://valid.example.com:8080", "-x", "://invalid"})
config, err := parser.Parse()
assert.Nil(t, config)
var fieldErr types.FieldParseErrors
require.ErrorAs(t, err, &fieldErr)
assert.Len(t, fieldErr.Errors, 1)
assert.Equal(t, "proxy[1]", fieldErr.Errors[0].Field)
})
t.Run("Parse with long flag names", func(t *testing.T) {
parser := NewConfigCLIParser([]string{
"dodo",
"--url", "https://example.com",
"--method", "POST",
"--yes",
"--skip-verify",
"--dodos", "3",
"--requests", "500",
"--duration", "1m",
"--timeout", "10s",
})
config, err := parser.Parse()
require.NoError(t, err)
require.NotNil(t, config)
assert.Equal(t, "https://example.com", config.URL.String())
assert.Equal(t, "POST", *config.Method)
assert.True(t, *config.Yes)
assert.True(t, *config.SkipVerify)
assert.Equal(t, uint(3), *config.DodosCount)
assert.Equal(t, uint(500), *config.RequestCount)
assert.Equal(t, time.Minute, *config.Duration)
assert.Equal(t, 10*time.Second, *config.Timeout)
})
t.Run("Parse with config-file flag valid YAML", func(t *testing.T) {
parser := NewConfigCLIParser([]string{"dodo", "-f", "/path/to/config.yaml"})
config, err := parser.Parse()
require.NoError(t, err)
require.NotNil(t, config)
assert.NotNil(t, parser.GetConfigFile())
})
t.Run("Parse with config-file flag using long form", func(t *testing.T) {
parser := NewConfigCLIParser([]string{"dodo", "--config-file", "/path/to/config.yml"})
config, err := parser.Parse()
require.NoError(t, err)
require.NotNil(t, config)
assert.NotNil(t, parser.GetConfigFile())
})
t.Run("Parse with config-file flag invalid extension", func(t *testing.T) {
parser := NewConfigCLIParser([]string{"dodo", "-f", "/path/to/config"})
config, err := parser.Parse()
assert.Nil(t, config)
var fieldErr types.FieldParseErrors
require.ErrorAs(t, err, &fieldErr)
assert.Len(t, fieldErr.Errors, 1)
assert.Equal(t, "config-file", fieldErr.Errors[0].Field)
assert.Contains(t, fieldErr.Errors[0].Err.Error(), "file extension not found")
})
t.Run("Parse with config-file flag unsupported file type", func(t *testing.T) {
parser := NewConfigCLIParser([]string{"dodo", "-f", "/path/to/config.json"})
config, err := parser.Parse()
assert.Nil(t, config)
var fieldErr types.FieldParseErrors
require.ErrorAs(t, err, &fieldErr)
assert.Len(t, fieldErr.Errors, 1)
assert.Equal(t, "config-file", fieldErr.Errors[0].Field)
assert.Contains(t, fieldErr.Errors[0].Err.Error(), "file type")
assert.Contains(t, fieldErr.Errors[0].Err.Error(), "not supported")
})
t.Run("Parse with config-file flag remote URL", func(t *testing.T) {
parser := NewConfigCLIParser([]string{"dodo", "-f", "https://example.com/config.yaml"})
config, err := parser.Parse()
require.NoError(t, err)
require.NotNil(t, config)
assert.NotNil(t, parser.GetConfigFile())
})
t.Run("Parse with all flags combined", func(t *testing.T) {
parser := NewConfigCLIParser([]string{
"dodo",
"-u", "https://api.example.com/test",
"-m", "PUT",
"-y",
"-skip-verify",
"-d", "10",
"-r", "2000",
"-o", "30m",
"-t", "5s",
"-p", "apikey=123",
"-H", "Content-Type: application/json",
"-c", "session=token123",
"-b", `{"data": "test"}`,
"-x", "http://proxy.example.com:3128",
"-f", "/path/to/config.yaml",
})
config, err := parser.Parse()
require.NoError(t, err)
require.NotNil(t, config)
// Verify all fields are set correctly
assert.Equal(t, "https://api.example.com/test", config.URL.String())
assert.Equal(t, "PUT", *config.Method)
assert.True(t, *config.Yes)
assert.True(t, *config.SkipVerify)
assert.Equal(t, uint(10), *config.DodosCount)
assert.Equal(t, uint(2000), *config.RequestCount)
assert.Equal(t, 30*time.Minute, *config.Duration)
assert.Equal(t, 5*time.Second, *config.Timeout)
assert.Len(t, config.Params, 1)
assert.Equal(t, "apikey", config.Params[0].Key)
assert.Len(t, config.Headers, 1)
assert.Equal(t, "Content-Type", config.Headers[0].Key)
assert.Len(t, config.Cookies, 1)
assert.Equal(t, "session", config.Cookies[0].Key)
assert.Len(t, config.Bodies, 1)
assert.Equal(t, types.Body(`{"data": "test"}`), config.Bodies[0]) //nolint:testifylint
assert.Len(t, config.Proxies, 1)
assert.Equal(t, "http://proxy.example.com:3128", config.Proxies[0].String())
assert.NotNil(t, parser.GetConfigFile())
})
t.Run("Parse with multiple field parse errors", func(t *testing.T) {
parser := NewConfigCLIParser([]string{
"dodo",
"-u", "://invalid-url",
"-x", "://invalid-proxy1",
"-x", "://invalid-proxy2",
})
config, err := parser.Parse()
assert.Nil(t, config)
var fieldErr types.FieldParseErrors
require.ErrorAs(t, err, &fieldErr)
assert.Len(t, fieldErr.Errors, 3)
// Check error fields
fields := make(map[string]bool)
for _, parseErr := range fieldErr.Errors {
fields[parseErr.Field] = true
}
assert.True(t, fields["url"])
assert.True(t, fields["proxy[0]"])
assert.True(t, fields["proxy[1]"])
})
}
func TestConfigCLIParser_PrintHelp(t *testing.T) {
t.Run("PrintHelp outputs expected content", func(t *testing.T) {
// Capture stdout
oldStdout := os.Stdout
reader, writer, _ := os.Pipe()
os.Stdout = writer
parser := NewConfigCLIParser([]string{"dodo"})
parser.PrintHelp()
// Restore stdout and read output
writer.Close()
os.Stdout = oldStdout
var buf bytes.Buffer
io.Copy(&buf, reader)
output := buf.String()
// Verify help text contains expected elements
assert.Contains(t, output, "Usage:")
assert.Contains(t, output, "dodo [flags]")
assert.Contains(t, output, "Examples:")
assert.Contains(t, output, "Flags:")
assert.Contains(t, output, "-h, -help")
assert.Contains(t, output, "-v, -version")
assert.Contains(t, output, "-u, -url")
assert.Contains(t, output, "-m, -method")
assert.Contains(t, output, "-d, -dodos")
assert.Contains(t, output, "-r, -requests")
assert.Contains(t, output, "-t, -timeout")
assert.Contains(t, output, "-b, -body")
assert.Contains(t, output, "-H, -header")
assert.Contains(t, output, "-p, -param")
assert.Contains(t, output, "-c, -cookie")
assert.Contains(t, output, "-x, -proxy")
assert.Contains(t, output, "-skip-verify")
assert.Contains(t, output, "-y, -yes")
assert.Contains(t, output, "-f, -config-file")
// Verify default values are included
assert.Contains(t, output, Defaults.Method)
assert.Contains(t, output, "1") // DodosCount default
assert.Contains(t, output, "10s") // RequestTimeout default
assert.Contains(t, output, "false") // Yes default
assert.Contains(t, output, "false") // SkipVerify default
})
}
func TestCLIYesOrNoReader(t *testing.T) {
t.Run("CLIYesOrNoReader with 'y' input returns true", func(t *testing.T) {
// Redirect stdin
oldStdin := os.Stdin
reader, writer, _ := os.Pipe()
os.Stdin = reader
// Write input and close writer
writer.WriteString("y\n")
writer.Close()
result := CLIYesOrNoReader("Test question", false)
// Restore stdin
os.Stdin = oldStdin
assert.True(t, result)
})
t.Run("CLIYesOrNoReader with 'Y' input returns true", func(t *testing.T) {
// Redirect stdin
oldStdin := os.Stdin
reader, writer, _ := os.Pipe()
os.Stdin = reader
// Write input and close writer
writer.WriteString("Y\n")
writer.Close()
result := CLIYesOrNoReader("Test question", false)
// Restore stdin
os.Stdin = oldStdin
assert.True(t, result)
})
t.Run("CLIYesOrNoReader with 'n' input returns false", func(t *testing.T) {
// Redirect stdin
oldStdin := os.Stdin
reader, writer, _ := os.Pipe()
os.Stdin = reader
// Write input and close writer
writer.WriteString("n\n")
writer.Close()
result := CLIYesOrNoReader("Test question", true)
// Restore stdin
os.Stdin = oldStdin
assert.False(t, result)
})
t.Run("CLIYesOrNoReader with empty input returns default", func(t *testing.T) {
// Redirect stdin
oldStdin := os.Stdin
reader, writer, _ := os.Pipe()
os.Stdin = reader
// Write just newline and close writer
writer.WriteString("\n")
writer.Close()
// Test with default true
result := CLIYesOrNoReader("Test question", true)
os.Stdin = oldStdin
assert.True(t, result)
})
t.Run("CLIYesOrNoReader with empty input returns default false", func(t *testing.T) {
// Redirect stdin
oldStdin := os.Stdin
reader, writer, _ := os.Pipe()
os.Stdin = reader
// Write just newline and close writer
writer.WriteString("\n")
writer.Close()
// Test with default false
result := CLIYesOrNoReader("Test question", false)
os.Stdin = oldStdin
assert.False(t, result)
})
t.Run("CLIYesOrNoReader with other input returns false", func(t *testing.T) {
// Redirect stdin
oldStdin := os.Stdin
reader, writer, _ := os.Pipe()
os.Stdin = reader
// Write other input and close writer
writer.WriteString("maybe\n")
writer.Close()
result := CLIYesOrNoReader("Test question", true)
// Restore stdin
os.Stdin = oldStdin
assert.False(t, result)
})
t.Run("CLIYesOrNoReader message format with default true", func(t *testing.T) {
// Capture stdout to verify message format
oldStdout := os.Stdout
stdoutReader, stdoutWriter, _ := os.Pipe()
os.Stdout = stdoutWriter
// Redirect stdin
oldStdin := os.Stdin
stdinReader, stdinWriter, _ := os.Pipe()
os.Stdin = stdinReader
// Write input and close writer
stdinWriter.WriteString("y\n")
stdinWriter.Close()
CLIYesOrNoReader("Continue?", true)
// Restore stdin and stdout
os.Stdin = oldStdin
stdoutWriter.Close()
os.Stdout = oldStdout
// Read output
var buf bytes.Buffer
io.Copy(&buf, stdoutReader)
output := buf.String()
assert.Contains(t, output, "Continue? [Y/n]:")
})
t.Run("CLIYesOrNoReader message format with default false", func(t *testing.T) {
// Capture stdout to verify message format
oldStdout := os.Stdout
stdoutReader, stdoutWriter, _ := os.Pipe()
os.Stdout = stdoutWriter
// Redirect stdin
oldStdin := os.Stdin
stdinReader, stdinWriter, _ := os.Pipe()
os.Stdin = stdinReader
// Write input and close writer
stdinWriter.WriteString("n\n")
stdinWriter.Close()
CLIYesOrNoReader("Delete files?", false)
// Restore stdin and stdout
os.Stdin = oldStdin
stdoutWriter.Close()
os.Stdout = oldStdout
// Read output
var buf bytes.Buffer
io.Copy(&buf, stdoutReader)
output := buf.String()
assert.Contains(t, output, "Delete files? [y/N]:")
})
}
func TestConfigCLIParser_EdgeCases(t *testing.T) {
t.Run("Parse with zero duration", func(t *testing.T) {
parser := NewConfigCLIParser([]string{"dodo", "-o", "0s"})
config, err := parser.Parse()
require.NoError(t, err)
require.NotNil(t, config)
require.NotNil(t, config.Duration)
assert.Equal(t, time.Duration(0), *config.Duration)
})
t.Run("Parse with zero timeout", func(t *testing.T) {
parser := NewConfigCLIParser([]string{"dodo", "-t", "0s"})
config, err := parser.Parse()
require.NoError(t, err)
require.NotNil(t, config)
require.NotNil(t, config.Timeout)
assert.Equal(t, time.Duration(0), *config.Timeout)
})
t.Run("Parse with zero dodos count", func(t *testing.T) {
parser := NewConfigCLIParser([]string{"dodo", "-d", "0"})
config, err := parser.Parse()
require.NoError(t, err)
require.NotNil(t, config)
require.NotNil(t, config.DodosCount)
assert.Equal(t, uint(0), *config.DodosCount)
})
t.Run("Parse with zero request count", func(t *testing.T) {
parser := NewConfigCLIParser([]string{"dodo", "-r", "0"})
config, err := parser.Parse()
require.NoError(t, err)
require.NotNil(t, config)
require.NotNil(t, config.RequestCount)
assert.Equal(t, uint(0), *config.RequestCount)
})
t.Run("Parse with empty string values", func(t *testing.T) {
parser := NewConfigCLIParser([]string{
"dodo",
"-m", "",
"-p", "",
"-H", "",
"-c", "",
"-b", "",
})
config, err := parser.Parse()
require.NoError(t, err)
require.NotNil(t, config)
assert.Empty(t, *config.Method)
assert.Len(t, config.Params, 1)
assert.Empty(t, config.Params[0].Key)
assert.Len(t, config.Headers, 1)
assert.Empty(t, config.Headers[0].Key)
assert.Len(t, config.Cookies, 1)
assert.Empty(t, config.Cookies[0].Key)
assert.Len(t, config.Bodies, 1)
assert.Equal(t, types.Body(""), config.Bodies[0])
})
t.Run("Parse with complex URL", func(t *testing.T) {
complexURL := "https://user:pass@api.example.com:8080/v1/endpoint?param=value&other=test#fragment"
parser := NewConfigCLIParser([]string{"dodo", "-u", complexURL})
config, err := parser.Parse()
require.NoError(t, err)
require.NotNil(t, config)
require.NotNil(t, config.URL)
parsedURL, parseErr := url.Parse(complexURL)
require.NoError(t, parseErr)
assert.Equal(t, parsedURL, config.URL)
})
t.Run("Parse with repeated same flags overrides previous values", func(t *testing.T) {
parser := NewConfigCLIParser([]string{
"dodo",
"-m", "GET",
"-m", "POST", // This should override the previous
"-d", "1",
"-d", "5", // This should override the previous
})
config, err := parser.Parse()
require.NoError(t, err)
require.NotNil(t, config)
assert.Equal(t, "POST", *config.Method)
assert.Equal(t, uint(5), *config.DodosCount)
})
}

112
pkg/config/config.go Normal file
View File

@@ -0,0 +1,112 @@
package config
import (
"net/url"
"time"
"github.com/aykhans/dodo/pkg/types"
"github.com/aykhans/dodo/pkg/utils"
)
const VERSION string = "1.0.0"
var Defaults = struct {
UserAgent string
Method string
RequestTimeout time.Duration
DodosCount uint
Yes bool
SkipVerify bool
}{
UserAgent: "dodo/" + VERSION,
Method: "GET",
RequestTimeout: time.Second * 10,
DodosCount: 1,
Yes: false,
SkipVerify: false,
}
var SupportedProxySchemes []string = []string{"http", "socks5", "socks5h"}
type Config 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
Bodies types.Bodies
Proxies types.Proxies
}
func NewConfig() *Config {
return &Config{}
}
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.Bodies) != 0 {
config.Bodies = newConfig.Bodies
}
if len(newConfig.Proxies) != 0 {
config.Proxies = newConfig.Proxies
}
}
func (config *Config) SetDefaults() {
if config.Method == nil {
config.Method = utils.ToPtr(Defaults.Method)
}
if config.Timeout == nil {
config.Timeout = &Defaults.RequestTimeout
}
if config.DodosCount == nil {
config.DodosCount = utils.ToPtr(Defaults.DodosCount)
}
if config.Yes == nil {
config.Yes = utils.ToPtr(Defaults.Yes)
}
if config.SkipVerify == nil {
config.SkipVerify = utils.ToPtr(Defaults.SkipVerify)
}
if !config.Headers.Has("User-Agent") {
config.Headers = append(config.Headers, types.Header{Key: "User-Agent", Value: []string{Defaults.UserAgent}})
}
}

318
pkg/config/config_test.go Normal file
View File

@@ -0,0 +1,318 @@
package config
import (
"net/url"
"testing"
"time"
"github.com/aykhans/dodo/pkg/types"
"github.com/aykhans/dodo/pkg/utils"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestMergeConfig(t *testing.T) {
t.Run("MergeConfig with all fields from new config", func(t *testing.T) {
originalURL, _ := url.Parse("https://original.example.com")
newURL, _ := url.Parse("https://new.example.com")
originalTimeout := 5 * time.Second
newTimeout := 10 * time.Second
originalDuration := 1 * time.Minute
newDuration := 2 * time.Minute
config := &Config{
Method: utils.ToPtr("GET"),
URL: originalURL,
Timeout: &originalTimeout,
DodosCount: utils.ToPtr(uint(1)),
RequestCount: utils.ToPtr(uint(10)),
Duration: &originalDuration,
Yes: utils.ToPtr(false),
SkipVerify: utils.ToPtr(false),
Params: types.Params{{Key: "old", Value: []string{"value"}}},
Headers: types.Headers{{Key: "Old-Header", Value: []string{"old"}}},
Cookies: types.Cookies{{Key: "oldCookie", Value: []string{"oldValue"}}},
Bodies: types.Bodies{types.Body("old body")},
Proxies: types.Proxies{},
}
newConfig := &Config{
Method: utils.ToPtr("POST"),
URL: newURL,
Timeout: &newTimeout,
DodosCount: utils.ToPtr(uint(5)),
RequestCount: utils.ToPtr(uint(20)),
Duration: &newDuration,
Yes: utils.ToPtr(true),
SkipVerify: utils.ToPtr(true),
Params: types.Params{{Key: "new", Value: []string{"value"}}},
Headers: types.Headers{{Key: "New-Header", Value: []string{"new"}}},
Cookies: types.Cookies{{Key: "newCookie", Value: []string{"newValue"}}},
Bodies: types.Bodies{types.Body("new body")},
Proxies: types.Proxies{},
}
config.MergeConfig(newConfig)
assert.Equal(t, "POST", *config.Method)
assert.Equal(t, newURL, config.URL)
assert.Equal(t, newTimeout, *config.Timeout)
assert.Equal(t, uint(5), *config.DodosCount)
assert.Equal(t, uint(20), *config.RequestCount)
assert.Equal(t, newDuration, *config.Duration)
assert.True(t, *config.Yes)
assert.True(t, *config.SkipVerify)
assert.Equal(t, types.Params{{Key: "new", Value: []string{"value"}}}, config.Params)
assert.Equal(t, types.Headers{{Key: "New-Header", Value: []string{"new"}}}, config.Headers)
assert.Equal(t, types.Cookies{{Key: "newCookie", Value: []string{"newValue"}}}, config.Cookies)
assert.Equal(t, types.Bodies{types.Body("new body")}, config.Bodies)
assert.Empty(t, config.Proxies)
})
t.Run("MergeConfig with partial fields from new config", func(t *testing.T) {
originalURL, _ := url.Parse("https://original.example.com")
originalTimeout := 5 * time.Second
config := &Config{
Method: utils.ToPtr("GET"),
URL: originalURL,
Timeout: &originalTimeout,
DodosCount: utils.ToPtr(uint(1)),
Headers: types.Headers{{Key: "Original-Header", Value: []string{"original"}}},
}
newURL, _ := url.Parse("https://new.example.com")
newConfig := &Config{
URL: newURL,
DodosCount: utils.ToPtr(uint(10)),
}
config.MergeConfig(newConfig)
assert.Equal(t, "GET", *config.Method, "Method should remain unchanged")
assert.Equal(t, newURL, config.URL, "URL should be updated")
assert.Equal(t, originalTimeout, *config.Timeout, "Timeout should remain unchanged")
assert.Equal(t, uint(10), *config.DodosCount, "DodosCount should be updated")
assert.Equal(t, types.Headers{{Key: "Original-Header", Value: []string{"original"}}}, config.Headers, "Headers should remain unchanged")
})
t.Run("MergeConfig with nil new config fields", func(t *testing.T) {
originalURL, _ := url.Parse("https://original.example.com")
originalTimeout := 5 * time.Second
config := &Config{
Method: utils.ToPtr("GET"),
URL: originalURL,
Timeout: &originalTimeout,
DodosCount: utils.ToPtr(uint(1)),
Yes: utils.ToPtr(false),
SkipVerify: utils.ToPtr(false),
}
newConfig := &Config{
Method: nil,
URL: nil,
Timeout: nil,
DodosCount: nil,
Yes: nil,
SkipVerify: nil,
}
originalConfigCopy := *config
config.MergeConfig(newConfig)
assert.Equal(t, originalConfigCopy.Method, config.Method)
assert.Equal(t, originalConfigCopy.URL, config.URL)
assert.Equal(t, originalConfigCopy.Timeout, config.Timeout)
assert.Equal(t, originalConfigCopy.DodosCount, config.DodosCount)
assert.Equal(t, originalConfigCopy.Yes, config.Yes)
assert.Equal(t, originalConfigCopy.SkipVerify, config.SkipVerify)
})
t.Run("MergeConfig with empty slices", func(t *testing.T) {
config := &Config{
Params: types.Params{{Key: "original", Value: []string{"value"}}},
Headers: types.Headers{{Key: "Original-Header", Value: []string{"original"}}},
Cookies: types.Cookies{{Key: "originalCookie", Value: []string{"originalValue"}}},
Bodies: types.Bodies{types.Body("original body")},
Proxies: types.Proxies{},
}
newConfig := &Config{
Params: types.Params{},
Headers: types.Headers{},
Cookies: types.Cookies{},
Bodies: types.Bodies{},
Proxies: types.Proxies{},
}
config.MergeConfig(newConfig)
assert.Equal(t, types.Params{{Key: "original", Value: []string{"value"}}}, config.Params, "Empty Params should not override")
assert.Equal(t, types.Headers{{Key: "Original-Header", Value: []string{"original"}}}, config.Headers, "Empty Headers should not override")
assert.Equal(t, types.Cookies{{Key: "originalCookie", Value: []string{"originalValue"}}}, config.Cookies, "Empty Cookies should not override")
assert.Equal(t, types.Bodies{types.Body("original body")}, config.Bodies, "Empty Bodies should not override")
assert.Equal(t, types.Proxies{}, config.Proxies, "Empty Proxies should not override")
})
t.Run("MergeConfig on empty original config", func(t *testing.T) {
config := &Config{}
newURL, _ := url.Parse("https://new.example.com")
newTimeout := 10 * time.Second
newDuration := 2 * time.Minute
newConfig := &Config{
Method: utils.ToPtr("POST"),
URL: newURL,
Timeout: &newTimeout,
DodosCount: utils.ToPtr(uint(5)),
RequestCount: utils.ToPtr(uint(20)),
Duration: &newDuration,
Yes: utils.ToPtr(true),
SkipVerify: utils.ToPtr(true),
Params: types.Params{{Key: "new", Value: []string{"value"}}},
Headers: types.Headers{{Key: "New-Header", Value: []string{"new"}}},
Cookies: types.Cookies{{Key: "newCookie", Value: []string{"newValue"}}},
Bodies: types.Bodies{types.Body("new body")},
Proxies: types.Proxies{},
}
config.MergeConfig(newConfig)
assert.Equal(t, "POST", *config.Method)
assert.Equal(t, newURL, config.URL)
assert.Equal(t, newTimeout, *config.Timeout)
assert.Equal(t, uint(5), *config.DodosCount)
assert.Equal(t, uint(20), *config.RequestCount)
assert.Equal(t, newDuration, *config.Duration)
assert.True(t, *config.Yes)
assert.True(t, *config.SkipVerify)
assert.Equal(t, types.Params{{Key: "new", Value: []string{"value"}}}, config.Params)
assert.Equal(t, types.Headers{{Key: "New-Header", Value: []string{"new"}}}, config.Headers)
assert.Equal(t, types.Cookies{{Key: "newCookie", Value: []string{"newValue"}}}, config.Cookies)
assert.Equal(t, types.Bodies{types.Body("new body")}, config.Bodies)
assert.Empty(t, config.Proxies)
})
}
func TestSetDefaults(t *testing.T) {
t.Run("SetDefaults on empty config", func(t *testing.T) {
config := &Config{}
config.SetDefaults()
require.NotNil(t, config.Method)
assert.Equal(t, Defaults.Method, *config.Method)
require.NotNil(t, config.Timeout)
assert.Equal(t, Defaults.RequestTimeout, *config.Timeout)
require.NotNil(t, config.DodosCount)
assert.Equal(t, Defaults.DodosCount, *config.DodosCount)
require.NotNil(t, config.Yes)
assert.Equal(t, Defaults.Yes, *config.Yes)
require.NotNil(t, config.SkipVerify)
assert.Equal(t, Defaults.SkipVerify, *config.SkipVerify)
assert.True(t, config.Headers.Has("User-Agent"))
assert.Equal(t, Defaults.UserAgent, config.Headers[0].Value[0])
})
t.Run("SetDefaults preserves existing values", func(t *testing.T) {
customTimeout := 30 * time.Second
config := &Config{
Method: utils.ToPtr("POST"),
Timeout: &customTimeout,
DodosCount: utils.ToPtr(uint(10)),
Yes: utils.ToPtr(true),
SkipVerify: utils.ToPtr(true),
Headers: types.Headers{{Key: "User-Agent", Value: []string{"custom-agent"}}},
}
config.SetDefaults()
assert.Equal(t, "POST", *config.Method, "Method should not be overridden")
assert.Equal(t, customTimeout, *config.Timeout, "Timeout should not be overridden")
assert.Equal(t, uint(10), *config.DodosCount, "DodosCount should not be overridden")
assert.True(t, *config.Yes, "Yes should not be overridden")
assert.True(t, *config.SkipVerify, "SkipVerify should not be overridden")
assert.Equal(t, "custom-agent", config.Headers[0].Value[0], "User-Agent should not be overridden")
assert.Len(t, config.Headers, 1, "Should not add duplicate User-Agent")
})
t.Run("SetDefaults adds User-Agent when missing", func(t *testing.T) {
config := &Config{
Headers: types.Headers{{Key: "Content-Type", Value: []string{"application/json"}}},
}
config.SetDefaults()
assert.Len(t, config.Headers, 2)
assert.True(t, config.Headers.Has("User-Agent"))
assert.True(t, config.Headers.Has("Content-Type"))
var userAgentFound bool
for _, h := range config.Headers {
if h.Key == "User-Agent" {
userAgentFound = true
assert.Equal(t, Defaults.UserAgent, h.Value[0])
break
}
}
assert.True(t, userAgentFound, "User-Agent header should be added")
})
t.Run("SetDefaults with partial config", func(t *testing.T) {
config := &Config{
Method: utils.ToPtr("PUT"),
Yes: utils.ToPtr(true),
}
config.SetDefaults()
assert.Equal(t, "PUT", *config.Method, "Existing Method should be preserved")
assert.True(t, *config.Yes, "Existing Yes should be preserved")
require.NotNil(t, config.Timeout)
assert.Equal(t, Defaults.RequestTimeout, *config.Timeout, "Timeout should be set to default")
require.NotNil(t, config.DodosCount)
assert.Equal(t, Defaults.DodosCount, *config.DodosCount, "DodosCount should be set to default")
require.NotNil(t, config.SkipVerify)
assert.Equal(t, Defaults.SkipVerify, *config.SkipVerify, "SkipVerify should be set to default")
assert.True(t, config.Headers.Has("User-Agent"))
})
t.Run("SetDefaults idempotent", func(t *testing.T) {
config := &Config{}
config.SetDefaults()
firstCallHeaders := len(config.Headers)
firstCallMethod := *config.Method
firstCallTimeout := *config.Timeout
config.SetDefaults()
assert.Len(t, config.Headers, firstCallHeaders, "Headers count should not change on second call")
assert.Equal(t, firstCallMethod, *config.Method, "Method should not change on second call")
assert.Equal(t, firstCallTimeout, *config.Timeout, "Timeout should not change on second call")
})
t.Run("SetDefaults with empty Headers initializes correctly", func(t *testing.T) {
config := &Config{
Headers: types.Headers{},
}
config.SetDefaults()
assert.Len(t, config.Headers, 1)
assert.Equal(t, "User-Agent", config.Headers[0].Key)
assert.Equal(t, Defaults.UserAgent, config.Headers[0].Value[0])
})
}

23
pkg/types/body.go Normal file
View File

@@ -0,0 +1,23 @@
package types
type Body string
func (body Body) String() string {
return string(body)
}
type Bodies []Body
func (bodies *Bodies) Append(body Body) {
*bodies = append(*bodies, body)
}
func (bodies *Bodies) Parse(rawValues ...string) {
for _, rawValue := range rawValues {
bodies.Append(ParseBody(rawValue))
}
}
func ParseBody(rawValue string) Body {
return Body(rawValue)
}

160
pkg/types/body_test.go Normal file
View File

@@ -0,0 +1,160 @@
package types
import (
"testing"
"github.com/stretchr/testify/assert"
)
func TestBody_String(t *testing.T) {
t.Run("Body String returns correct value", func(t *testing.T) {
body := Body("test body content")
assert.Equal(t, "test body content", body.String())
})
t.Run("Body String with empty body", func(t *testing.T) {
body := Body("")
assert.Empty(t, body.String())
})
t.Run("Body String with JSON", func(t *testing.T) {
body := Body(`{"key": "value", "number": 42}`)
assert.JSONEq(t, `{"key": "value", "number": 42}`, body.String())
})
t.Run("Body String with special characters", func(t *testing.T) {
body := Body("special: !@#$%^&*()\nnewline\ttab")
assert.Equal(t, "special: !@#$%^&*()\nnewline\ttab", body.String())
})
}
func TestBodies_Append(t *testing.T) {
t.Run("Append single body", func(t *testing.T) {
bodies := &Bodies{}
bodies.Append(Body("first body"))
assert.Len(t, *bodies, 1)
assert.Equal(t, Body("first body"), (*bodies)[0])
})
t.Run("Append multiple bodies", func(t *testing.T) {
bodies := &Bodies{}
bodies.Append(Body("first"))
bodies.Append(Body("second"))
bodies.Append(Body("third"))
assert.Len(t, *bodies, 3)
assert.Equal(t, Body("first"), (*bodies)[0])
assert.Equal(t, Body("second"), (*bodies)[1])
assert.Equal(t, Body("third"), (*bodies)[2])
})
t.Run("Append to existing bodies", func(t *testing.T) {
bodies := &Bodies{Body("existing")}
bodies.Append(Body("new"))
assert.Len(t, *bodies, 2)
assert.Equal(t, Body("existing"), (*bodies)[0])
assert.Equal(t, Body("new"), (*bodies)[1])
})
t.Run("Append empty body", func(t *testing.T) {
bodies := &Bodies{}
bodies.Append(Body(""))
assert.Len(t, *bodies, 1)
assert.Empty(t, (*bodies)[0].String())
})
}
func TestBodies_Parse(t *testing.T) {
t.Run("Parse single value", func(t *testing.T) {
bodies := &Bodies{}
bodies.Parse("test body")
assert.Len(t, *bodies, 1)
assert.Equal(t, Body("test body"), (*bodies)[0])
})
t.Run("Parse multiple values", func(t *testing.T) {
bodies := &Bodies{}
bodies.Parse("body1", "body2", "body3")
assert.Len(t, *bodies, 3)
assert.Equal(t, Body("body1"), (*bodies)[0])
assert.Equal(t, Body("body2"), (*bodies)[1])
assert.Equal(t, Body("body3"), (*bodies)[2])
})
t.Run("Parse with existing bodies", func(t *testing.T) {
bodies := &Bodies{Body("existing")}
bodies.Parse("new1", "new2")
assert.Len(t, *bodies, 3)
assert.Equal(t, Body("existing"), (*bodies)[0])
assert.Equal(t, Body("new1"), (*bodies)[1])
assert.Equal(t, Body("new2"), (*bodies)[2])
})
t.Run("Parse empty values", func(t *testing.T) {
bodies := &Bodies{}
bodies.Parse("", "", "")
assert.Len(t, *bodies, 3)
for _, body := range *bodies {
assert.Empty(t, body.String())
}
})
t.Run("Parse no arguments", func(t *testing.T) {
bodies := &Bodies{}
bodies.Parse()
assert.Empty(t, *bodies)
})
t.Run("Parse JSON strings", func(t *testing.T) {
bodies := &Bodies{}
bodies.Parse(`{"key": "value"}`, `{"array": [1, 2, 3]}`)
assert.Len(t, *bodies, 2)
assert.JSONEq(t, `{"key": "value"}`, (*bodies)[0].String())
assert.JSONEq(t, `{"array": [1, 2, 3]}`, (*bodies)[1].String())
})
}
func TestParseBody(t *testing.T) {
t.Run("ParseBody with regular string", func(t *testing.T) {
body := ParseBody("test content")
assert.Equal(t, Body("test content"), body)
})
t.Run("ParseBody with empty string", func(t *testing.T) {
body := ParseBody("")
assert.Equal(t, Body(""), body)
})
t.Run("ParseBody with multiline string", func(t *testing.T) {
input := "line1\nline2\nline3"
body := ParseBody(input)
assert.Equal(t, Body(input), body)
})
t.Run("ParseBody with special characters", func(t *testing.T) {
input := "!@#$%^&*()_+-=[]{}|;':\",./<>?"
body := ParseBody(input)
assert.Equal(t, Body(input), body)
})
t.Run("ParseBody with unicode", func(t *testing.T) {
input := "Hello World 🌍"
body := ParseBody(input)
assert.Equal(t, Body(input), body)
})
t.Run("ParseBody preserves whitespace", func(t *testing.T) {
input := " leading and trailing spaces "
body := ParseBody(input)
assert.Equal(t, Body(input), body)
})
}

99
pkg/types/config_file.go Normal file
View File

@@ -0,0 +1,99 @@
package types
import (
"net/url"
"path/filepath"
"strings"
)
type ConfigFileType int
const (
ConfigFileTypeYAML ConfigFileType = iota
)
func (t ConfigFileType) String() string {
switch t {
case ConfigFileTypeYAML:
return "yaml/yml"
default:
return "unknown"
}
}
type ConfigFileLocationType int
const (
ConfigFileLocationLocal ConfigFileLocationType = iota
ConfigFileLocationRemote
)
func (l ConfigFileLocationType) String() string {
switch l {
case ConfigFileLocationLocal:
return "local"
case ConfigFileLocationRemote:
return "remote"
default:
return "unknown"
}
}
type ConfigFile struct {
path string
_type ConfigFileType
locationType ConfigFileLocationType
}
func (configFile ConfigFile) String() string {
return configFile.path
}
func (configFile ConfigFile) Type() ConfigFileType {
return configFile._type
}
func (configFile ConfigFile) LocationType() ConfigFileLocationType {
return configFile.locationType
}
// ParseConfigFile parses a raw string representing a configuration file
// path or URL and returns a ConfigFile struct.
// It determines the file's type and location (local or remote) based on the string.
// It can return the following errors:
// - ErrConfigFileExtensionNotFound
// - RemoteConfigFileParseError
// - UnknownConfigFileTypeError
func ParseConfigFile(configFileRaw string) (*ConfigFile, error) {
configFileParsed := &ConfigFile{
path: configFileRaw,
locationType: ConfigFileLocationLocal,
}
if strings.HasPrefix(configFileRaw, "http://") || strings.HasPrefix(configFileRaw, "https://") {
configFileParsed.locationType = ConfigFileLocationRemote
}
configFilePath := configFileRaw
if configFileParsed.locationType == ConfigFileLocationRemote {
remoteConfigFileParsed, err := url.Parse(configFileRaw)
if err != nil {
return nil, NewRemoteConfigFileParseError(err)
}
configFilePath = remoteConfigFileParsed.Path
}
configFileExtension, _ := strings.CutPrefix(filepath.Ext(configFilePath), ".")
if configFileExtension == "" {
return nil, ErrConfigFileExtensionNotFound
}
switch strings.ToLower(configFileExtension) {
case "yml", "yaml":
configFileParsed._type = ConfigFileTypeYAML
default:
return nil, NewUnknownConfigFileTypeError(configFileExtension)
}
return configFileParsed, nil
}

View File

@@ -0,0 +1,216 @@
package types
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestConfigFileType_String(t *testing.T) {
t.Run("ConfigFileTypeYAML returns yaml", func(t *testing.T) {
configType := ConfigFileTypeYAML
assert.Equal(t, "yaml/yml", configType.String())
})
t.Run("Unknown config file type returns unknown", func(t *testing.T) {
configType := ConfigFileType(999)
assert.Equal(t, "unknown", configType.String())
})
}
func TestConfigFileLocationType_String(t *testing.T) {
t.Run("ConfigFileLocationLocal returns local", func(t *testing.T) {
locationType := ConfigFileLocationLocal
assert.Equal(t, "local", locationType.String())
})
t.Run("ConfigFileLocationRemote returns remote", func(t *testing.T) {
locationType := ConfigFileLocationRemote
assert.Equal(t, "remote", locationType.String())
})
t.Run("Unknown location type returns unknown", func(t *testing.T) {
locationType := ConfigFileLocationType(999)
assert.Equal(t, "unknown", locationType.String())
})
}
func TestConfigFile_String(t *testing.T) {
t.Run("String returns the file path", func(t *testing.T) {
configFile := ConfigFile{path: "/path/to/config.yaml"}
assert.Equal(t, "/path/to/config.yaml", configFile.String())
})
t.Run("String returns empty path", func(t *testing.T) {
configFile := ConfigFile{path: ""}
assert.Empty(t, configFile.String())
})
}
func TestConfigFile_Type(t *testing.T) {
t.Run("Type returns the config file type", func(t *testing.T) {
configFile := ConfigFile{_type: ConfigFileTypeYAML}
assert.Equal(t, ConfigFileTypeYAML, configFile.Type())
})
}
func TestConfigFile_LocationType(t *testing.T) {
t.Run("LocationType returns local", func(t *testing.T) {
configFile := ConfigFile{locationType: ConfigFileLocationLocal}
assert.Equal(t, ConfigFileLocationLocal, configFile.LocationType())
})
t.Run("LocationType returns remote", func(t *testing.T) {
configFile := ConfigFile{locationType: ConfigFileLocationRemote}
assert.Equal(t, ConfigFileLocationRemote, configFile.LocationType())
})
}
func TestParseConfigFile(t *testing.T) {
t.Run("Parse local YAML file with yml extension", func(t *testing.T) {
configFile, err := ParseConfigFile("config.yml")
require.NoError(t, err)
require.NotNil(t, configFile)
assert.Equal(t, "config.yml", configFile.String())
assert.Equal(t, ConfigFileTypeYAML, configFile.Type())
assert.Equal(t, ConfigFileLocationLocal, configFile.LocationType())
})
t.Run("Parse local YAML file with yaml extension", func(t *testing.T) {
configFile, err := ParseConfigFile("config.yaml")
require.NoError(t, err)
require.NotNil(t, configFile)
assert.Equal(t, "config.yaml", configFile.String())
assert.Equal(t, ConfigFileTypeYAML, configFile.Type())
assert.Equal(t, ConfigFileLocationLocal, configFile.LocationType())
})
t.Run("Parse local YAML file with uppercase extensions", func(t *testing.T) {
testCases := []string{"config.YML", "config.YAML", "config.Yml", "config.Yaml"}
for _, testCase := range testCases {
t.Run("Extension: "+testCase, func(t *testing.T) {
configFile, err := ParseConfigFile(testCase)
require.NoError(t, err)
require.NotNil(t, configFile)
assert.Equal(t, testCase, configFile.String())
assert.Equal(t, ConfigFileTypeYAML, configFile.Type())
assert.Equal(t, ConfigFileLocationLocal, configFile.LocationType())
})
}
})
t.Run("Parse remote HTTP YAML file", func(t *testing.T) {
configFile, err := ParseConfigFile("http://example.com/config.yaml")
require.NoError(t, err)
require.NotNil(t, configFile)
assert.Equal(t, "http://example.com/config.yaml", configFile.String())
assert.Equal(t, ConfigFileTypeYAML, configFile.Type())
assert.Equal(t, ConfigFileLocationRemote, configFile.LocationType())
})
t.Run("Parse remote HTTPS YAML file", func(t *testing.T) {
configFile, err := ParseConfigFile("https://example.com/path/config.yml")
require.NoError(t, err)
require.NotNil(t, configFile)
assert.Equal(t, "https://example.com/path/config.yml", configFile.String())
assert.Equal(t, ConfigFileTypeYAML, configFile.Type())
assert.Equal(t, ConfigFileLocationRemote, configFile.LocationType())
})
t.Run("Parse file with path separators", func(t *testing.T) {
configFile, err := ParseConfigFile("/path/to/config.yaml")
require.NoError(t, err)
require.NotNil(t, configFile)
assert.Equal(t, "/path/to/config.yaml", configFile.String())
assert.Equal(t, ConfigFileTypeYAML, configFile.Type())
assert.Equal(t, ConfigFileLocationLocal, configFile.LocationType())
})
t.Run("Parse file without extension returns error", func(t *testing.T) {
configFile, err := ParseConfigFile("config")
require.Error(t, err)
assert.Equal(t, ErrConfigFileExtensionNotFound, err)
assert.Nil(t, configFile)
})
t.Run("Parse file with unsupported extension returns error", func(t *testing.T) {
configFile, err := ParseConfigFile("config.json")
require.Error(t, err)
assert.IsType(t, UnknownConfigFileTypeError{}, err)
assert.Contains(t, err.Error(), "json")
assert.Nil(t, configFile)
})
t.Run("Parse remote file with invalid URL returns error", func(t *testing.T) {
configFile, err := ParseConfigFile("http://192.168.1.%30/config.yaml")
require.Error(t, err)
assert.IsType(t, RemoteConfigFileParseError{}, err)
assert.Nil(t, configFile)
})
t.Run("Parse remote file without extension returns error", func(t *testing.T) {
configFile, err := ParseConfigFile("https://example.com/config")
require.Error(t, err)
assert.Equal(t, ErrConfigFileExtensionNotFound, err)
assert.Nil(t, configFile)
})
t.Run("Parse remote file with unsupported extension returns error", func(t *testing.T) {
configFile, err := ParseConfigFile("https://example.com/config.txt")
require.Error(t, err)
assert.IsType(t, UnknownConfigFileTypeError{}, err)
assert.Contains(t, err.Error(), "txt")
assert.Nil(t, configFile)
})
t.Run("Parse empty string returns error", func(t *testing.T) {
configFile, err := ParseConfigFile("")
require.Error(t, err)
assert.Equal(t, ErrConfigFileExtensionNotFound, err)
assert.Nil(t, configFile)
})
t.Run("Parse file with multiple dots in name", func(t *testing.T) {
configFile, err := ParseConfigFile("config.test.yaml")
require.NoError(t, err)
require.NotNil(t, configFile)
assert.Equal(t, "config.test.yaml", configFile.String())
assert.Equal(t, ConfigFileTypeYAML, configFile.Type())
assert.Equal(t, ConfigFileLocationLocal, configFile.LocationType())
})
t.Run("Parse remote URL with query parameters and fragment", func(t *testing.T) {
configFile, err := ParseConfigFile("https://example.com/path/config.yaml?version=1&format=yaml#section")
require.NoError(t, err)
require.NotNil(t, configFile)
assert.Equal(t, "https://example.com/path/config.yaml?version=1&format=yaml#section", configFile.String())
assert.Equal(t, ConfigFileTypeYAML, configFile.Type())
assert.Equal(t, ConfigFileLocationRemote, configFile.LocationType())
})
t.Run("Parse remote URL with port", func(t *testing.T) {
configFile, err := ParseConfigFile("https://example.com:8080/config.yml")
require.NoError(t, err)
require.NotNil(t, configFile)
assert.Equal(t, "https://example.com:8080/config.yml", configFile.String())
assert.Equal(t, ConfigFileTypeYAML, configFile.Type())
assert.Equal(t, ConfigFileLocationRemote, configFile.LocationType())
})
}

42
pkg/types/cookie.go Normal file
View File

@@ -0,0 +1,42 @@
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) {
if item := cookies.GetValue(cookie.Key); item != nil {
*item = append(*item, cookie.Value...)
} else {
*cookies = append(*cookies, cookie)
}
}
func (cookies *Cookies) Parse(rawValues ...string) {
for _, rawValue := range rawValues {
cookies.Append(*ParseCookie(rawValue))
}
}
func ParseCookie(rawValue string) *Cookie {
parts := strings.SplitN(rawValue, "=", 2)
switch len(parts) {
case 1:
return &Cookie{Key: parts[0], Value: []string{""}}
case 2:
return &Cookie{Key: parts[0], Value: []string{parts[1]}}
default:
return &Cookie{Key: "", Value: []string{""}}
}
}

240
pkg/types/cookie_test.go Normal file
View File

@@ -0,0 +1,240 @@
package types
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestCookies_GetValue(t *testing.T) {
t.Run("GetValue returns existing cookie value", func(t *testing.T) {
cookies := Cookies{
{Key: "session", Value: []string{"abc123"}},
{Key: "user", Value: []string{"john"}},
}
value := cookies.GetValue("session")
require.NotNil(t, value)
assert.Equal(t, []string{"abc123"}, *value)
})
t.Run("GetValue returns nil for non-existent cookie", func(t *testing.T) {
cookies := Cookies{
{Key: "session", Value: []string{"abc123"}},
}
value := cookies.GetValue("nonexistent")
assert.Nil(t, value)
})
t.Run("GetValue with empty cookies", func(t *testing.T) {
cookies := Cookies{}
value := cookies.GetValue("any")
assert.Nil(t, value)
})
t.Run("GetValue with multiple values", func(t *testing.T) {
cookies := Cookies{
{Key: "multi", Value: []string{"val1", "val2", "val3"}},
}
value := cookies.GetValue("multi")
require.NotNil(t, value)
assert.Equal(t, []string{"val1", "val2", "val3"}, *value)
})
t.Run("GetValue case sensitive", func(t *testing.T) {
cookies := Cookies{
{Key: "Cookie", Value: []string{"value"}},
}
value1 := cookies.GetValue("Cookie")
require.NotNil(t, value1)
assert.Equal(t, []string{"value"}, *value1)
value2 := cookies.GetValue("cookie")
assert.Nil(t, value2)
})
}
func TestCookies_Append(t *testing.T) {
t.Run("Append new cookie", func(t *testing.T) {
cookies := &Cookies{}
cookies.Append(Cookie{Key: "session", Value: []string{"abc123"}})
assert.Len(t, *cookies, 1)
assert.Equal(t, "session", (*cookies)[0].Key)
assert.Equal(t, []string{"abc123"}, (*cookies)[0].Value)
})
t.Run("Append to existing cookie key", func(t *testing.T) {
cookies := &Cookies{
{Key: "session", Value: []string{"abc123"}},
}
cookies.Append(Cookie{Key: "session", Value: []string{"def456"}})
assert.Len(t, *cookies, 1)
assert.Equal(t, []string{"abc123", "def456"}, (*cookies)[0].Value)
})
t.Run("Append different cookies", func(t *testing.T) {
cookies := &Cookies{}
cookies.Append(Cookie{Key: "session", Value: []string{"abc"}})
cookies.Append(Cookie{Key: "user", Value: []string{"john"}})
cookies.Append(Cookie{Key: "token", Value: []string{"xyz"}})
assert.Len(t, *cookies, 3)
})
t.Run("Append multiple values at once", func(t *testing.T) {
cookies := &Cookies{
{Key: "tags", Value: []string{"tag1"}},
}
cookies.Append(Cookie{Key: "tags", Value: []string{"tag2", "tag3"}})
assert.Len(t, *cookies, 1)
assert.Equal(t, []string{"tag1", "tag2", "tag3"}, (*cookies)[0].Value)
})
t.Run("Append empty value", func(t *testing.T) {
cookies := &Cookies{}
cookies.Append(Cookie{Key: "empty", Value: []string{""}})
assert.Len(t, *cookies, 1)
assert.Equal(t, []string{""}, (*cookies)[0].Value)
})
}
func TestCookies_Parse(t *testing.T) {
t.Run("Parse single cookie", func(t *testing.T) {
cookies := &Cookies{}
cookies.Parse("session=abc123")
assert.Len(t, *cookies, 1)
assert.Equal(t, "session", (*cookies)[0].Key)
assert.Equal(t, []string{"abc123"}, (*cookies)[0].Value)
})
t.Run("Parse multiple cookies", func(t *testing.T) {
cookies := &Cookies{}
cookies.Parse("session=abc123", "user=john", "token=xyz789")
assert.Len(t, *cookies, 3)
assert.Equal(t, "session", (*cookies)[0].Key)
assert.Equal(t, "user", (*cookies)[1].Key)
assert.Equal(t, "token", (*cookies)[2].Key)
})
t.Run("Parse cookies with same key", func(t *testing.T) {
cookies := &Cookies{}
cookies.Parse("pref=dark", "pref=large", "pref=en")
assert.Len(t, *cookies, 1)
assert.Equal(t, []string{"dark", "large", "en"}, (*cookies)[0].Value)
})
t.Run("Parse cookie without value", func(t *testing.T) {
cookies := &Cookies{}
cookies.Parse("sessionid")
assert.Len(t, *cookies, 1)
assert.Equal(t, "sessionid", (*cookies)[0].Key)
assert.Equal(t, []string{""}, (*cookies)[0].Value)
})
t.Run("Parse cookie with empty value", func(t *testing.T) {
cookies := &Cookies{}
cookies.Parse("empty=")
assert.Len(t, *cookies, 1)
assert.Equal(t, "empty", (*cookies)[0].Key)
assert.Equal(t, []string{""}, (*cookies)[0].Value)
})
t.Run("Parse cookie with multiple equals", func(t *testing.T) {
cookies := &Cookies{}
cookies.Parse("data=key=value=test")
assert.Len(t, *cookies, 1)
assert.Equal(t, "data", (*cookies)[0].Key)
assert.Equal(t, []string{"key=value=test"}, (*cookies)[0].Value)
})
t.Run("Parse no arguments", func(t *testing.T) {
cookies := &Cookies{}
cookies.Parse()
assert.Empty(t, *cookies)
})
t.Run("Parse with existing cookies", func(t *testing.T) {
cookies := &Cookies{
{Key: "existing", Value: []string{"value"}},
}
cookies.Parse("new=cookie")
assert.Len(t, *cookies, 2)
assert.Equal(t, "existing", (*cookies)[0].Key)
assert.Equal(t, "new", (*cookies)[1].Key)
})
}
func TestParseCookie(t *testing.T) {
t.Run("ParseCookie with key and value", func(t *testing.T) {
cookie := ParseCookie("session=abc123")
require.NotNil(t, cookie)
assert.Equal(t, "session", cookie.Key)
assert.Equal(t, []string{"abc123"}, cookie.Value)
})
t.Run("ParseCookie with only key", func(t *testing.T) {
cookie := ParseCookie("sessionid")
require.NotNil(t, cookie)
assert.Equal(t, "sessionid", cookie.Key)
assert.Equal(t, []string{""}, cookie.Value)
})
t.Run("ParseCookie with empty value", func(t *testing.T) {
cookie := ParseCookie("key=")
require.NotNil(t, cookie)
assert.Equal(t, "key", cookie.Key)
assert.Equal(t, []string{""}, cookie.Value)
})
t.Run("ParseCookie with multiple equals", func(t *testing.T) {
cookie := ParseCookie("data=base64=encoded=value")
require.NotNil(t, cookie)
assert.Equal(t, "data", cookie.Key)
assert.Equal(t, []string{"base64=encoded=value"}, cookie.Value)
})
t.Run("ParseCookie with empty string", func(t *testing.T) {
cookie := ParseCookie("")
require.NotNil(t, cookie)
assert.Empty(t, cookie.Key)
assert.Equal(t, []string{""}, cookie.Value)
})
t.Run("ParseCookie with spaces", func(t *testing.T) {
cookie := ParseCookie("key with spaces=value with spaces")
require.NotNil(t, cookie)
assert.Equal(t, "key with spaces", cookie.Key)
assert.Equal(t, []string{"value with spaces"}, cookie.Value)
})
t.Run("ParseCookie with special characters", func(t *testing.T) {
cookie := ParseCookie("key-._~=val!@#$%^&*()")
require.NotNil(t, cookie)
assert.Equal(t, "key-._~", cookie.Key)
assert.Equal(t, []string{"val!@#$%^&*()"}, cookie.Value)
})
t.Run("ParseCookie with URL encoded value", func(t *testing.T) {
cookie := ParseCookie("data=hello%20world%3D%26")
require.NotNil(t, cookie)
assert.Equal(t, "data", cookie.Key)
assert.Equal(t, []string{"hello%20world%3D%26"}, cookie.Value)
})
}

113
pkg/types/errors.go Normal file
View File

@@ -0,0 +1,113 @@
package types
import (
"errors"
"fmt"
"strings"
)
var (
// General
ErrNoError = errors.New("no error (internal)")
// CLI
ErrCLINoArgs = errors.New("CLI expects arguments but received none")
ErrCLIUnexpectedArgs = errors.New("CLI received unexpected arguments")
// Config File
ErrConfigFileExtensionNotFound = errors.New("config file extension not found")
)
// ======================================== General ========================================
type FieldParseError struct {
Field string
Err error
}
func NewFieldParseError(field string, err error) *FieldParseError {
if err == nil {
err = ErrNoError
}
return &FieldParseError{field, 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()
}
errorString := ""
for _, err := range e.Errors {
errorString += err.Error() + "\n"
}
errorString, _ = strings.CutSuffix(errorString, "\n")
return errorString
}
// ======================================== 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 RemoteConfigFileParseError struct {
error error
}
func NewRemoteConfigFileParseError(err error) RemoteConfigFileParseError {
if err == nil {
err = ErrNoError
}
return RemoteConfigFileParseError{err}
}
func (e RemoteConfigFileParseError) Error() string {
return "Remote config file parse error: " + e.error.Error()
}
func (e RemoteConfigFileParseError) Unwrap() error {
return e.error
}
type UnknownConfigFileTypeError struct {
Type string
}
func NewUnknownConfigFileTypeError(_type string) UnknownConfigFileTypeError {
return UnknownConfigFileTypeError{_type}
}
func (e UnknownConfigFileTypeError) Error() string {
return "Unknown config file type: " + e.Type
}

284
pkg/types/errors_test.go Normal file
View File

@@ -0,0 +1,284 @@
package types
import (
"errors"
"testing"
"github.com/stretchr/testify/assert"
)
func TestFieldParseError_Error(t *testing.T) {
t.Run("Error returns formatted message", func(t *testing.T) {
originalErr := errors.New("invalid value")
fieldErr := NewFieldParseError("username", originalErr)
expected := "Field 'username' parse failed: invalid value"
assert.Equal(t, expected, fieldErr.Error())
})
t.Run("Error with empty field name", func(t *testing.T) {
originalErr := errors.New("test error")
fieldErr := NewFieldParseError("", originalErr)
expected := "Field '' parse failed: test error"
assert.Equal(t, expected, fieldErr.Error())
})
t.Run("Error with nil underlying error", func(t *testing.T) {
fieldErr := NewFieldParseError("field", nil)
expected := "Field 'field' parse failed: no error (internal)"
assert.Equal(t, expected, fieldErr.Error())
})
}
func TestFieldParseError_Unwrap(t *testing.T) {
t.Run("Unwrap returns original error", func(t *testing.T) {
originalErr := errors.New("original error")
fieldErr := NewFieldParseError("field", originalErr)
assert.Equal(t, originalErr, fieldErr.Unwrap())
})
t.Run("Unwrap with nil error", func(t *testing.T) {
fieldErr := NewFieldParseError("field", nil)
assert.Equal(t, ErrNoError, fieldErr.Unwrap())
})
}
func TestNewFieldParseError(t *testing.T) {
t.Run("Creates FieldParseError with correct values", func(t *testing.T) {
originalErr := errors.New("test error")
fieldErr := NewFieldParseError("testField", originalErr)
assert.Equal(t, "testField", fieldErr.Field)
assert.Equal(t, originalErr, fieldErr.Err)
})
t.Run("Creates FieldParseError with ErrNoError when nil passed", func(t *testing.T) {
fieldErr := NewFieldParseError("testField", nil)
assert.Equal(t, "testField", fieldErr.Field)
assert.Equal(t, ErrNoError, fieldErr.Err)
})
}
func TestFieldParseErrors_Error(t *testing.T) {
t.Run("Error with no errors returns default message", func(t *testing.T) {
fieldErrors := NewFieldParseErrors([]FieldParseError{})
assert.Equal(t, "No field parse errors", fieldErrors.Error())
})
t.Run("Error with single error returns single error message", func(t *testing.T) {
fieldErr := *NewFieldParseError("field1", errors.New("error1"))
fieldErrors := NewFieldParseErrors([]FieldParseError{fieldErr})
expected := "Field 'field1' parse failed: error1"
assert.Equal(t, expected, fieldErrors.Error())
})
t.Run("Error with multiple errors returns concatenated messages", func(t *testing.T) {
fieldErr1 := *NewFieldParseError("field1", errors.New("error1"))
fieldErr2 := *NewFieldParseError("field2", errors.New("error2"))
fieldErr3 := *NewFieldParseError("field3", errors.New("error3"))
fieldErrors := NewFieldParseErrors([]FieldParseError{fieldErr1, fieldErr2, fieldErr3})
expected := "Field 'field1' parse failed: error1\nField 'field2' parse failed: error2\nField 'field3' parse failed: error3"
assert.Equal(t, expected, fieldErrors.Error())
})
t.Run("Error with two errors", func(t *testing.T) {
fieldErr1 := *NewFieldParseError("username", errors.New("too short"))
fieldErr2 := *NewFieldParseError("email", errors.New("invalid format"))
fieldErrors := NewFieldParseErrors([]FieldParseError{fieldErr1, fieldErr2})
expected := "Field 'username' parse failed: too short\nField 'email' parse failed: invalid format"
assert.Equal(t, expected, fieldErrors.Error())
})
}
func TestNewFieldParseErrors(t *testing.T) {
t.Run("Creates FieldParseErrors with correct values", func(t *testing.T) {
fieldErr1 := *NewFieldParseError("field1", errors.New("error1"))
fieldErr2 := *NewFieldParseError("field2", errors.New("error2"))
fieldErrors := NewFieldParseErrors([]FieldParseError{fieldErr1, fieldErr2})
assert.Len(t, fieldErrors.Errors, 2)
assert.Equal(t, fieldErr1, fieldErrors.Errors[0])
assert.Equal(t, fieldErr2, fieldErrors.Errors[1])
})
t.Run("Creates FieldParseErrors with empty slice", func(t *testing.T) {
fieldErrors := NewFieldParseErrors([]FieldParseError{})
assert.Empty(t, fieldErrors.Errors)
})
}
func TestCLIUnexpectedArgsError_Error(t *testing.T) {
t.Run("Error with single argument", func(t *testing.T) {
err := NewCLIUnexpectedArgsError([]string{"arg1"})
expected := "CLI received unexpected arguments: arg1"
assert.Equal(t, expected, err.Error())
})
t.Run("Error with multiple arguments", func(t *testing.T) {
err := NewCLIUnexpectedArgsError([]string{"arg1", "arg2", "arg3"})
expected := "CLI received unexpected arguments: arg1,arg2,arg3"
assert.Equal(t, expected, err.Error())
})
t.Run("Error with empty arguments", func(t *testing.T) {
err := NewCLIUnexpectedArgsError([]string{})
expected := "CLI received unexpected arguments: "
assert.Equal(t, expected, err.Error())
})
t.Run("Error with arguments containing special characters", func(t *testing.T) {
err := NewCLIUnexpectedArgsError([]string{"--flag", "value with spaces", "-x"})
expected := "CLI received unexpected arguments: --flag,value with spaces,-x"
assert.Equal(t, expected, err.Error())
})
}
func TestNewCLIUnexpectedArgsError(t *testing.T) {
t.Run("Creates CLIUnexpectedArgsError with correct values", func(t *testing.T) {
args := []string{"arg1", "arg2"}
err := NewCLIUnexpectedArgsError(args)
assert.Equal(t, args, err.Args)
})
}
func TestRemoteConfigFileParseError_Error(t *testing.T) {
t.Run("Error returns formatted message", func(t *testing.T) {
originalErr := errors.New("invalid URL")
err := NewRemoteConfigFileParseError(originalErr)
expected := "Remote config file parse error: invalid URL"
assert.Equal(t, expected, err.Error())
})
t.Run("Error with nil underlying error", func(t *testing.T) {
err := NewRemoteConfigFileParseError(nil)
expected := "Remote config file parse error: no error (internal)"
assert.Equal(t, expected, err.Error())
})
}
func TestRemoteConfigFileParseError_Unwrap(t *testing.T) {
t.Run("Unwrap returns original error", func(t *testing.T) {
originalErr := errors.New("original error")
err := NewRemoteConfigFileParseError(originalErr)
assert.Equal(t, originalErr, err.Unwrap())
})
t.Run("Unwrap with nil error", func(t *testing.T) {
err := NewRemoteConfigFileParseError(nil)
assert.Equal(t, ErrNoError, err.Unwrap())
})
}
func TestNewRemoteConfigFileParseError(t *testing.T) {
t.Run("Creates RemoteConfigFileParseError with correct values", func(t *testing.T) {
originalErr := errors.New("test error")
err := NewRemoteConfigFileParseError(originalErr)
assert.Equal(t, originalErr, err.error)
})
t.Run("Creates RemoteConfigFileParseError with ErrNoError when nil passed", func(t *testing.T) {
err := NewRemoteConfigFileParseError(nil)
assert.Equal(t, ErrNoError, err.error)
})
}
func TestUnknownConfigFileTypeError_Error(t *testing.T) {
t.Run("Error returns formatted message", func(t *testing.T) {
err := NewUnknownConfigFileTypeError("json")
expected := "Unknown config file type: json"
assert.Equal(t, expected, err.Error())
})
t.Run("Error with empty type", func(t *testing.T) {
err := NewUnknownConfigFileTypeError("")
expected := "Unknown config file type: "
assert.Equal(t, expected, err.Error())
})
t.Run("Error with special characters in type", func(t *testing.T) {
err := NewUnknownConfigFileTypeError("type.with.dots")
expected := "Unknown config file type: type.with.dots"
assert.Equal(t, expected, err.Error())
})
}
func TestNewUnknownConfigFileTypeError(t *testing.T) {
t.Run("Creates UnknownConfigFileTypeError with correct values", func(t *testing.T) {
err := NewUnknownConfigFileTypeError("xml")
assert.Equal(t, "xml", err.Type)
})
}
func TestErrorConstants(t *testing.T) {
t.Run("ErrNoError has correct message", func(t *testing.T) {
expected := "no error (internal)"
assert.Equal(t, expected, ErrNoError.Error())
})
t.Run("ErrCLINoArgs has correct message", func(t *testing.T) {
expected := "CLI expects arguments but received none"
assert.Equal(t, expected, ErrCLINoArgs.Error())
})
t.Run("ErrCLIUnexpectedArgs has correct message", func(t *testing.T) {
expected := "CLI received unexpected arguments"
assert.Equal(t, expected, ErrCLIUnexpectedArgs.Error())
})
t.Run("ErrConfigFileExtensionNotFound has correct message", func(t *testing.T) {
expected := "config file extension not found"
assert.Equal(t, expected, ErrConfigFileExtensionNotFound.Error())
})
}
func TestErrorImplementsErrorInterface(t *testing.T) {
t.Run("FieldParseError implements error interface", func(t *testing.T) {
var err error = NewFieldParseError("field", errors.New("test"))
assert.Error(t, err)
})
t.Run("FieldParseErrors implements error interface", func(t *testing.T) {
var err error = NewFieldParseErrors([]FieldParseError{})
assert.Error(t, err)
})
t.Run("CLIUnexpectedArgsError implements error interface", func(t *testing.T) {
var err error = NewCLIUnexpectedArgsError([]string{})
assert.Error(t, err)
})
t.Run("RemoteConfigFileParseError implements error interface", func(t *testing.T) {
var err error = NewRemoteConfigFileParseError(errors.New("test"))
assert.Error(t, err)
})
t.Run("UnknownConfigFileTypeError implements error interface", func(t *testing.T) {
var err error = NewUnknownConfigFileTypeError("test")
assert.Error(t, err)
})
}

52
pkg/types/header.go Normal file
View File

@@ -0,0 +1,52 @@
package types
import "strings"
type Header KeyValue[string, []string]
type Headers []Header
// Has checks if a header with the given key exists.
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) {
if item := headers.GetValue(header.Key); item != nil {
*item = append(*item, header.Value...)
} else {
*headers = append(*headers, header)
}
}
func (headers *Headers) Parse(rawValues ...string) {
for _, rawValue := range rawValues {
headers.Append(*ParseHeader(rawValue))
}
}
func ParseHeader(rawValue string) *Header {
parts := strings.SplitN(rawValue, ": ", 2)
switch len(parts) {
case 1:
return &Header{Key: parts[0], Value: []string{""}}
case 2:
return &Header{Key: parts[0], Value: []string{parts[1]}}
default:
return &Header{Key: "", Value: []string{""}}
}
}

277
pkg/types/header_test.go Normal file
View File

@@ -0,0 +1,277 @@
package types
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestHeaders_Has(t *testing.T) {
t.Run("Has returns true for existing header", func(t *testing.T) {
headers := Headers{
{Key: "Content-Type", Value: []string{"application/json"}},
{Key: "Authorization", Value: []string{"Bearer token"}},
}
assert.True(t, headers.Has("Content-Type"))
assert.True(t, headers.Has("Authorization"))
})
t.Run("Has returns false for non-existent header", func(t *testing.T) {
headers := Headers{
{Key: "Content-Type", Value: []string{"application/json"}},
}
assert.False(t, headers.Has("Authorization"))
assert.False(t, headers.Has("X-Custom-Header"))
})
t.Run("Has with empty headers", func(t *testing.T) {
headers := Headers{}
assert.False(t, headers.Has("Any-Header"))
})
t.Run("Has is case sensitive", func(t *testing.T) {
headers := Headers{
{Key: "Content-Type", Value: []string{"text/html"}},
}
assert.True(t, headers.Has("Content-Type"))
assert.False(t, headers.Has("content-type"))
assert.False(t, headers.Has("CONTENT-TYPE"))
})
}
func TestHeaders_GetValue(t *testing.T) {
t.Run("GetValue returns existing header value", func(t *testing.T) {
headers := Headers{
{Key: "Content-Type", Value: []string{"application/json"}},
{Key: "Accept", Value: []string{"text/html"}},
}
value := headers.GetValue("Content-Type")
require.NotNil(t, value)
assert.Equal(t, []string{"application/json"}, *value)
})
t.Run("GetValue returns nil for non-existent header", func(t *testing.T) {
headers := Headers{
{Key: "Content-Type", Value: []string{"application/json"}},
}
value := headers.GetValue("Authorization")
assert.Nil(t, value)
})
t.Run("GetValue with empty headers", func(t *testing.T) {
headers := Headers{}
value := headers.GetValue("Any-Header")
assert.Nil(t, value)
})
t.Run("GetValue with multiple values", func(t *testing.T) {
headers := Headers{
{Key: "Accept", Value: []string{"text/html", "application/xml", "application/json"}},
}
value := headers.GetValue("Accept")
require.NotNil(t, value)
assert.Equal(t, []string{"text/html", "application/xml", "application/json"}, *value)
})
t.Run("GetValue is case sensitive", func(t *testing.T) {
headers := Headers{
{Key: "X-Custom-Header", Value: []string{"value"}},
}
value1 := headers.GetValue("X-Custom-Header")
require.NotNil(t, value1)
assert.Equal(t, []string{"value"}, *value1)
value2 := headers.GetValue("x-custom-header")
assert.Nil(t, value2)
})
}
func TestHeaders_Append(t *testing.T) {
t.Run("Append new header", func(t *testing.T) {
headers := &Headers{}
headers.Append(Header{Key: "Content-Type", Value: []string{"application/json"}})
assert.Len(t, *headers, 1)
assert.Equal(t, "Content-Type", (*headers)[0].Key)
assert.Equal(t, []string{"application/json"}, (*headers)[0].Value)
})
t.Run("Append to existing header key", func(t *testing.T) {
headers := &Headers{
{Key: "Accept", Value: []string{"text/html"}},
}
headers.Append(Header{Key: "Accept", Value: []string{"application/json"}})
assert.Len(t, *headers, 1)
assert.Equal(t, []string{"text/html", "application/json"}, (*headers)[0].Value)
})
t.Run("Append different headers", func(t *testing.T) {
headers := &Headers{}
headers.Append(Header{Key: "Content-Type", Value: []string{"application/json"}})
headers.Append(Header{Key: "Authorization", Value: []string{"Bearer token"}})
headers.Append(Header{Key: "Accept", Value: []string{"*/*"}})
assert.Len(t, *headers, 3)
})
t.Run("Append multiple values at once", func(t *testing.T) {
headers := &Headers{
{Key: "Accept-Language", Value: []string{"en"}},
}
headers.Append(Header{Key: "Accept-Language", Value: []string{"fr", "de"}})
assert.Len(t, *headers, 1)
assert.Equal(t, []string{"en", "fr", "de"}, (*headers)[0].Value)
})
t.Run("Append empty value", func(t *testing.T) {
headers := &Headers{}
headers.Append(Header{Key: "Empty-Header", Value: []string{""}})
assert.Len(t, *headers, 1)
assert.Equal(t, []string{""}, (*headers)[0].Value)
})
}
func TestHeaders_Parse(t *testing.T) {
t.Run("Parse single header", func(t *testing.T) {
headers := &Headers{}
headers.Parse("Content-Type: application/json")
assert.Len(t, *headers, 1)
assert.Equal(t, "Content-Type", (*headers)[0].Key)
assert.Equal(t, []string{"application/json"}, (*headers)[0].Value)
})
t.Run("Parse multiple headers", func(t *testing.T) {
headers := &Headers{}
headers.Parse("Content-Type: application/json", "Authorization: Bearer token", "Accept: */*")
assert.Len(t, *headers, 3)
assert.Equal(t, "Content-Type", (*headers)[0].Key)
assert.Equal(t, "Authorization", (*headers)[1].Key)
assert.Equal(t, "Accept", (*headers)[2].Key)
})
t.Run("Parse headers with same key", func(t *testing.T) {
headers := &Headers{}
headers.Parse("Accept: text/html", "Accept: application/json", "Accept: application/xml")
assert.Len(t, *headers, 1)
assert.Equal(t, []string{"text/html", "application/json", "application/xml"}, (*headers)[0].Value)
})
t.Run("Parse header without value", func(t *testing.T) {
headers := &Headers{}
headers.Parse("X-Empty-Header")
assert.Len(t, *headers, 1)
assert.Equal(t, "X-Empty-Header", (*headers)[0].Key)
assert.Equal(t, []string{""}, (*headers)[0].Value)
})
t.Run("Parse header with empty value", func(t *testing.T) {
headers := &Headers{}
headers.Parse("X-Empty: ")
assert.Len(t, *headers, 1)
assert.Equal(t, "X-Empty", (*headers)[0].Key)
assert.Equal(t, []string{""}, (*headers)[0].Value)
})
t.Run("Parse header with multiple colons", func(t *testing.T) {
headers := &Headers{}
headers.Parse("X-Time: 12:34:56")
assert.Len(t, *headers, 1)
assert.Equal(t, "X-Time", (*headers)[0].Key)
assert.Equal(t, []string{"12:34:56"}, (*headers)[0].Value)
})
t.Run("Parse no arguments", func(t *testing.T) {
headers := &Headers{}
headers.Parse()
assert.Empty(t, *headers)
})
t.Run("Parse with existing headers", func(t *testing.T) {
headers := &Headers{
{Key: "Existing", Value: []string{"value"}},
}
headers.Parse("New: header")
assert.Len(t, *headers, 2)
assert.Equal(t, "Existing", (*headers)[0].Key)
assert.Equal(t, "New", (*headers)[1].Key)
})
}
func TestParseHeader(t *testing.T) {
t.Run("ParseHeader with key and value", func(t *testing.T) {
header := ParseHeader("Content-Type: application/json")
require.NotNil(t, header)
assert.Equal(t, "Content-Type", header.Key)
assert.Equal(t, []string{"application/json"}, header.Value)
})
t.Run("ParseHeader with only key", func(t *testing.T) {
header := ParseHeader("X-Header")
require.NotNil(t, header)
assert.Equal(t, "X-Header", header.Key)
assert.Equal(t, []string{""}, header.Value)
})
t.Run("ParseHeader with empty value", func(t *testing.T) {
header := ParseHeader("Key: ")
require.NotNil(t, header)
assert.Equal(t, "Key", header.Key)
assert.Equal(t, []string{""}, header.Value)
})
t.Run("ParseHeader with multiple colons", func(t *testing.T) {
header := ParseHeader("X-URL: https://example.com:8080/path")
require.NotNil(t, header)
assert.Equal(t, "X-URL", header.Key)
assert.Equal(t, []string{"https://example.com:8080/path"}, header.Value)
})
t.Run("ParseHeader with empty string", func(t *testing.T) {
header := ParseHeader("")
require.NotNil(t, header)
assert.Empty(t, header.Key)
assert.Equal(t, []string{""}, header.Value)
})
t.Run("ParseHeader with spaces in value", func(t *testing.T) {
header := ParseHeader("User-Agent: Mozilla/5.0 (Windows NT 10.0)")
require.NotNil(t, header)
assert.Equal(t, "User-Agent", header.Key)
assert.Equal(t, []string{"Mozilla/5.0 (Windows NT 10.0)"}, header.Value)
})
t.Run("ParseHeader without colon-space separator", func(t *testing.T) {
header := ParseHeader("Content-Type:application/json")
require.NotNil(t, header)
assert.Equal(t, "Content-Type:application/json", header.Key)
assert.Equal(t, []string{""}, header.Value)
})
t.Run("ParseHeader with trailing spaces", func(t *testing.T) {
header := ParseHeader("Header: value with spaces ")
require.NotNil(t, header)
assert.Equal(t, "Header", header.Key)
assert.Equal(t, []string{"value with spaces "}, header.Value)
})
}

42
pkg/types/param.go Normal file
View File

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

281
pkg/types/param_test.go Normal file
View File

@@ -0,0 +1,281 @@
package types
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestParams_GetValue(t *testing.T) {
t.Run("GetValue returns existing parameter value", func(t *testing.T) {
params := Params{
{Key: "name", Value: []string{"john"}},
{Key: "age", Value: []string{"25"}},
}
value := params.GetValue("name")
require.NotNil(t, value)
assert.Equal(t, []string{"john"}, *value)
})
t.Run("GetValue returns nil for non-existent parameter", func(t *testing.T) {
params := Params{
{Key: "name", Value: []string{"john"}},
}
value := params.GetValue("nonexistent")
assert.Nil(t, value)
})
t.Run("GetValue with empty params", func(t *testing.T) {
params := Params{}
value := params.GetValue("any")
assert.Nil(t, value)
})
t.Run("GetValue with multiple values", func(t *testing.T) {
params := Params{
{Key: "tags", Value: []string{"go", "test", "api"}},
}
value := params.GetValue("tags")
require.NotNil(t, value)
assert.Equal(t, []string{"go", "test", "api"}, *value)
})
t.Run("GetValue case sensitive", func(t *testing.T) {
params := Params{
{Key: "Name", Value: []string{"value"}},
}
value1 := params.GetValue("Name")
require.NotNil(t, value1)
assert.Equal(t, []string{"value"}, *value1)
value2 := params.GetValue("name")
assert.Nil(t, value2)
})
}
func TestParams_Append(t *testing.T) {
t.Run("Append new parameter", func(t *testing.T) {
params := &Params{}
params.Append(Param{Key: "name", Value: []string{"john"}})
assert.Len(t, *params, 1)
assert.Equal(t, "name", (*params)[0].Key)
assert.Equal(t, []string{"john"}, (*params)[0].Value)
})
t.Run("Append to existing parameter key", func(t *testing.T) {
params := &Params{
{Key: "tags", Value: []string{"go"}},
}
params.Append(Param{Key: "tags", Value: []string{"test"}})
assert.Len(t, *params, 1)
assert.Equal(t, []string{"go", "test"}, (*params)[0].Value)
})
t.Run("Append different parameters", func(t *testing.T) {
params := &Params{}
params.Append(Param{Key: "name", Value: []string{"john"}})
params.Append(Param{Key: "age", Value: []string{"25"}})
params.Append(Param{Key: "city", Value: []string{"NYC"}})
assert.Len(t, *params, 3)
})
t.Run("Append multiple values at once", func(t *testing.T) {
params := &Params{
{Key: "colors", Value: []string{"red"}},
}
params.Append(Param{Key: "colors", Value: []string{"blue", "green"}})
assert.Len(t, *params, 1)
assert.Equal(t, []string{"red", "blue", "green"}, (*params)[0].Value)
})
t.Run("Append empty value", func(t *testing.T) {
params := &Params{}
params.Append(Param{Key: "empty", Value: []string{""}})
assert.Len(t, *params, 1)
assert.Equal(t, []string{""}, (*params)[0].Value)
})
}
func TestParams_Parse(t *testing.T) {
t.Run("Parse single parameter", func(t *testing.T) {
params := &Params{}
params.Parse("name=john")
assert.Len(t, *params, 1)
assert.Equal(t, "name", (*params)[0].Key)
assert.Equal(t, []string{"john"}, (*params)[0].Value)
})
t.Run("Parse multiple parameters", func(t *testing.T) {
params := &Params{}
params.Parse("name=john", "age=25", "city=NYC")
assert.Len(t, *params, 3)
assert.Equal(t, "name", (*params)[0].Key)
assert.Equal(t, "age", (*params)[1].Key)
assert.Equal(t, "city", (*params)[2].Key)
})
t.Run("Parse parameters with same key", func(t *testing.T) {
params := &Params{}
params.Parse("filter=name", "filter=age", "filter=city")
assert.Len(t, *params, 1)
assert.Equal(t, []string{"name", "age", "city"}, (*params)[0].Value)
})
t.Run("Parse parameter without value", func(t *testing.T) {
params := &Params{}
params.Parse("debug")
assert.Len(t, *params, 1)
assert.Equal(t, "debug", (*params)[0].Key)
assert.Equal(t, []string{""}, (*params)[0].Value)
})
t.Run("Parse parameter with empty value", func(t *testing.T) {
params := &Params{}
params.Parse("empty=")
assert.Len(t, *params, 1)
assert.Equal(t, "empty", (*params)[0].Key)
assert.Equal(t, []string{""}, (*params)[0].Value)
})
t.Run("Parse parameter with multiple equals", func(t *testing.T) {
params := &Params{}
params.Parse("equation=x=y+z")
assert.Len(t, *params, 1)
assert.Equal(t, "equation", (*params)[0].Key)
assert.Equal(t, []string{"x=y+z"}, (*params)[0].Value)
})
t.Run("Parse no arguments", func(t *testing.T) {
params := &Params{}
params.Parse()
assert.Empty(t, *params)
})
t.Run("Parse with existing parameters", func(t *testing.T) {
params := &Params{
{Key: "existing", Value: []string{"value"}},
}
params.Parse("new=param")
assert.Len(t, *params, 2)
assert.Equal(t, "existing", (*params)[0].Key)
assert.Equal(t, "new", (*params)[1].Key)
})
t.Run("Parse URL-encoded values", func(t *testing.T) {
params := &Params{}
params.Parse("query=hello%20world", "special=%21%40%23")
assert.Len(t, *params, 2)
assert.Equal(t, []string{"hello%20world"}, (*params)[0].Value)
assert.Equal(t, []string{"%21%40%23"}, (*params)[1].Value)
})
}
func TestParseParam(t *testing.T) {
t.Run("ParseParam with key and value", func(t *testing.T) {
param := ParseParam("name=john")
require.NotNil(t, param)
assert.Equal(t, "name", param.Key)
assert.Equal(t, []string{"john"}, param.Value)
})
t.Run("ParseParam with only key", func(t *testing.T) {
param := ParseParam("debug")
require.NotNil(t, param)
assert.Equal(t, "debug", param.Key)
assert.Equal(t, []string{""}, param.Value)
})
t.Run("ParseParam with empty value", func(t *testing.T) {
param := ParseParam("key=")
require.NotNil(t, param)
assert.Equal(t, "key", param.Key)
assert.Equal(t, []string{""}, param.Value)
})
t.Run("ParseParam with multiple equals", func(t *testing.T) {
param := ParseParam("data=key=value=test")
require.NotNil(t, param)
assert.Equal(t, "data", param.Key)
assert.Equal(t, []string{"key=value=test"}, param.Value)
})
t.Run("ParseParam with empty string", func(t *testing.T) {
param := ParseParam("")
require.NotNil(t, param)
assert.Empty(t, param.Key)
assert.Equal(t, []string{""}, param.Value)
})
t.Run("ParseParam with spaces", func(t *testing.T) {
param := ParseParam("key with spaces=value with spaces")
require.NotNil(t, param)
assert.Equal(t, "key with spaces", param.Key)
assert.Equal(t, []string{"value with spaces"}, param.Value)
})
t.Run("ParseParam with special characters", func(t *testing.T) {
param := ParseParam("key-._~=val!@#$%^&*()")
require.NotNil(t, param)
assert.Equal(t, "key-._~", param.Key)
assert.Equal(t, []string{"val!@#$%^&*()"}, param.Value)
})
t.Run("ParseParam with numeric values", func(t *testing.T) {
param := ParseParam("count=42")
require.NotNil(t, param)
assert.Equal(t, "count", param.Key)
assert.Equal(t, []string{"42"}, param.Value)
})
t.Run("ParseParam with boolean-like values", func(t *testing.T) {
testCases := []struct {
input string
expected string
}{
{"active=true", "true"},
{"enabled=false", "false"},
{"visible=1", "1"},
{"hidden=0", "0"},
}
for _, testCase := range testCases {
param := ParseParam(testCase.input)
require.NotNil(t, param)
assert.Equal(t, []string{testCase.expected}, param.Value)
}
})
t.Run("ParseParam with URL-encoded value", func(t *testing.T) {
param := ParseParam("message=hello%20world")
require.NotNil(t, param)
assert.Equal(t, "message", param.Key)
assert.Equal(t, []string{"hello%20world"}, param.Value)
})
t.Run("ParseParam with JSON-like value", func(t *testing.T) {
param := ParseParam(`data={"key":"value"}`)
require.NotNil(t, param)
assert.Equal(t, "data", param.Key)
assert.Equal(t, []string{`{"key":"value"}`}, param.Value)
})
}

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

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

285
pkg/types/proxy_test.go Normal file
View File

@@ -0,0 +1,285 @@
package types
import (
"net/url"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestProxy_String(t *testing.T) {
t.Run("Proxy String returns correct URL", func(t *testing.T) {
u, err := url.Parse("http://proxy.example.com:8080")
require.NoError(t, err)
proxy := Proxy(*u)
assert.Equal(t, "http://proxy.example.com:8080", proxy.String())
})
t.Run("Proxy String with HTTPS", func(t *testing.T) {
u, err := url.Parse("https://secure-proxy.example.com:443")
require.NoError(t, err)
proxy := Proxy(*u)
assert.Equal(t, "https://secure-proxy.example.com:443", proxy.String())
})
t.Run("Proxy String with authentication", func(t *testing.T) {
u, err := url.Parse("http://user:pass@proxy.example.com:8080")
require.NoError(t, err)
proxy := Proxy(*u)
assert.Equal(t, "http://user:pass@proxy.example.com:8080", proxy.String())
})
t.Run("Proxy String with path", func(t *testing.T) {
u, err := url.Parse("http://proxy.example.com:8080/proxy/path")
require.NoError(t, err)
proxy := Proxy(*u)
assert.Equal(t, "http://proxy.example.com:8080/proxy/path", proxy.String())
})
t.Run("Proxy String with query params", func(t *testing.T) {
u, err := url.Parse("http://proxy.example.com:8080/?timeout=30&retry=3")
require.NoError(t, err)
proxy := Proxy(*u)
assert.Equal(t, "http://proxy.example.com:8080/?timeout=30&retry=3", proxy.String())
})
}
func TestProxies_Append(t *testing.T) {
t.Run("Append single proxy", func(t *testing.T) {
proxies := &Proxies{}
u, err := url.Parse("http://proxy1.example.com:8080")
require.NoError(t, err)
proxy := Proxy(*u)
proxies.Append(proxy)
assert.Len(t, *proxies, 1)
assert.Equal(t, "http://proxy1.example.com:8080", (*proxies)[0].String())
})
t.Run("Append multiple proxies", func(t *testing.T) {
proxies := &Proxies{}
url1, err := url.Parse("http://proxy1.example.com:8080")
require.NoError(t, err)
url2, err := url.Parse("http://proxy2.example.com:8081")
require.NoError(t, err)
url3, err := url.Parse("https://proxy3.example.com:443")
require.NoError(t, err)
proxies.Append(Proxy(*url1))
proxies.Append(Proxy(*url2))
proxies.Append(Proxy(*url3))
assert.Len(t, *proxies, 3)
assert.Equal(t, "http://proxy1.example.com:8080", (*proxies)[0].String())
assert.Equal(t, "http://proxy2.example.com:8081", (*proxies)[1].String())
assert.Equal(t, "https://proxy3.example.com:443", (*proxies)[2].String())
})
t.Run("Append to existing proxies", func(t *testing.T) {
existingURL, err := url.Parse("http://existing.example.com:8080")
require.NoError(t, err)
proxies := &Proxies{Proxy(*existingURL)}
newURL, err := url.Parse("http://new.example.com:8081")
require.NoError(t, err)
proxies.Append(Proxy(*newURL))
assert.Len(t, *proxies, 2)
assert.Equal(t, "http://existing.example.com:8080", (*proxies)[0].String())
assert.Equal(t, "http://new.example.com:8081", (*proxies)[1].String())
})
}
func TestProxies_Parse(t *testing.T) {
t.Run("Parse valid proxy URL", func(t *testing.T) {
proxies := &Proxies{}
err := proxies.Parse("http://proxy.example.com:8080")
require.NoError(t, err)
assert.Len(t, *proxies, 1)
assert.Equal(t, "http://proxy.example.com:8080", (*proxies)[0].String())
})
t.Run("Parse HTTPS proxy URL", func(t *testing.T) {
proxies := &Proxies{}
err := proxies.Parse("https://secure-proxy.example.com:443")
require.NoError(t, err)
assert.Len(t, *proxies, 1)
assert.Equal(t, "https://secure-proxy.example.com:443", (*proxies)[0].String())
})
t.Run("Parse proxy URL with authentication", func(t *testing.T) {
proxies := &Proxies{}
err := proxies.Parse("http://user:pass@proxy.example.com:8080")
require.NoError(t, err)
assert.Len(t, *proxies, 1)
assert.Equal(t, "http://user:pass@proxy.example.com:8080", (*proxies)[0].String())
})
t.Run("Parse invalid proxy URL", func(t *testing.T) {
proxies := &Proxies{}
err := proxies.Parse("://invalid-url")
require.Error(t, err)
assert.Contains(t, err.Error(), "failed to parse proxy URL")
assert.Empty(t, *proxies)
})
t.Run("Parse empty string", func(t *testing.T) {
proxies := &Proxies{}
err := proxies.Parse("")
require.NoError(t, err)
assert.Len(t, *proxies, 1)
assert.Empty(t, (*proxies)[0].String())
})
t.Run("Parse to existing proxies", func(t *testing.T) {
existingURL, err := url.Parse("http://existing.example.com:8080")
require.NoError(t, err)
proxies := &Proxies{Proxy(*existingURL)}
err = proxies.Parse("http://new.example.com:8081")
require.NoError(t, err)
assert.Len(t, *proxies, 2)
assert.Equal(t, "http://existing.example.com:8080", (*proxies)[0].String())
assert.Equal(t, "http://new.example.com:8081", (*proxies)[1].String())
})
t.Run("Parse proxy with special characters", func(t *testing.T) {
proxies := &Proxies{}
err := proxies.Parse("http://proxy.example.com:8080/path?param=value&other=test")
require.NoError(t, err)
assert.Len(t, *proxies, 1)
assert.Equal(t, "http://proxy.example.com:8080/path?param=value&other=test", (*proxies)[0].String())
})
}
func TestParseProxy(t *testing.T) {
t.Run("ParseProxy with valid HTTP URL", func(t *testing.T) {
proxy, err := ParseProxy("http://proxy.example.com:8080")
require.NoError(t, err)
require.NotNil(t, proxy)
assert.Equal(t, "http://proxy.example.com:8080", proxy.String())
})
t.Run("ParseProxy with valid HTTPS URL", func(t *testing.T) {
proxy, err := ParseProxy("https://secure-proxy.example.com:443")
require.NoError(t, err)
require.NotNil(t, proxy)
assert.Equal(t, "https://secure-proxy.example.com:443", proxy.String())
})
t.Run("ParseProxy with authentication", func(t *testing.T) {
proxy, err := ParseProxy("http://user:password@proxy.example.com:8080")
require.NoError(t, err)
require.NotNil(t, proxy)
assert.Equal(t, "http://user:password@proxy.example.com:8080", proxy.String())
})
t.Run("ParseProxy with path", func(t *testing.T) {
proxy, err := ParseProxy("http://proxy.example.com:8080/proxy/endpoint")
require.NoError(t, err)
require.NotNil(t, proxy)
assert.Equal(t, "http://proxy.example.com:8080/proxy/endpoint", proxy.String())
})
t.Run("ParseProxy with query parameters", func(t *testing.T) {
proxy, err := ParseProxy("http://proxy.example.com:8080/?timeout=30&retry=3")
require.NoError(t, err)
require.NotNil(t, proxy)
assert.Equal(t, "http://proxy.example.com:8080/?timeout=30&retry=3", proxy.String())
})
t.Run("ParseProxy with malformed URL", func(t *testing.T) {
proxy, err := ParseProxy("://malformed-url")
require.Error(t, err)
assert.Nil(t, proxy)
assert.Contains(t, err.Error(), "failed to parse proxy URL")
})
t.Run("ParseProxy with empty string", func(t *testing.T) {
proxy, err := ParseProxy("")
require.NoError(t, err)
require.NotNil(t, proxy)
assert.Empty(t, proxy.String())
})
t.Run("ParseProxy with localhost", func(t *testing.T) {
proxy, err := ParseProxy("http://localhost:3128")
require.NoError(t, err)
require.NotNil(t, proxy)
assert.Equal(t, "http://localhost:3128", proxy.String())
})
t.Run("ParseProxy with IP address", func(t *testing.T) {
proxy, err := ParseProxy("http://192.168.1.100:8080")
require.NoError(t, err)
require.NotNil(t, proxy)
assert.Equal(t, "http://192.168.1.100:8080", proxy.String())
})
t.Run("ParseProxy without scheme", func(t *testing.T) {
proxy, err := ParseProxy("proxy.example.com:8080")
require.NoError(t, err)
require.NotNil(t, proxy)
assert.Equal(t, "proxy.example.com:8080", proxy.String())
})
t.Run("ParseProxy with SOCKS protocol", func(t *testing.T) {
proxy, err := ParseProxy("socks5://proxy.example.com:1080")
require.NoError(t, err)
require.NotNil(t, proxy)
assert.Equal(t, "socks5://proxy.example.com:1080", proxy.String())
})
t.Run("ParseProxy preserves URL components", func(t *testing.T) {
rawURL := "http://user:pass@proxy.example.com:8080/path?param=value#fragment"
proxy, err := ParseProxy(rawURL)
require.NoError(t, err)
require.NotNil(t, proxy)
assert.Equal(t, rawURL, proxy.String())
})
t.Run("ParseProxy with percent encoding", func(t *testing.T) {
proxy, err := ParseProxy("http://proxy.example.com:8080/path%20with%20spaces")
require.NoError(t, err)
require.NotNil(t, proxy)
assert.Equal(t, "http://proxy.example.com:8080/path%20with%20spaces", proxy.String())
})
t.Run("ParseProxy error message format", func(t *testing.T) {
_, err := ParseProxy("http://[invalid-ipv6")
require.Error(t, err)
assert.Contains(t, err.Error(), "failed to parse proxy URL:")
assert.Contains(t, err.Error(), "missing ']' in host")
})
}

155
pkg/utils/convert_test.go Normal file
View File

@@ -0,0 +1,155 @@
package utils
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestToPtr(t *testing.T) {
t.Run("ToPtr with int", func(t *testing.T) {
value := 42
ptr := ToPtr(value)
require.NotNil(t, ptr)
assert.Equal(t, value, *ptr)
assert.NotSame(t, &value, ptr, "Should return a new pointer")
})
t.Run("ToPtr with string", func(t *testing.T) {
value := "test string"
ptr := ToPtr(value)
require.NotNil(t, ptr)
assert.Equal(t, value, *ptr)
})
t.Run("ToPtr with bool", func(t *testing.T) {
value := true
ptr := ToPtr(value)
require.NotNil(t, ptr)
assert.Equal(t, value, *ptr)
})
t.Run("ToPtr with float64", func(t *testing.T) {
value := 3.14159
ptr := ToPtr(value)
require.NotNil(t, ptr)
assert.InEpsilon(t, value, *ptr, 0.0001)
})
t.Run("ToPtr with struct", func(t *testing.T) {
type TestStruct struct {
Field1 string
Field2 int
}
value := TestStruct{Field1: "test", Field2: 123}
ptr := ToPtr(value)
require.NotNil(t, ptr)
assert.Equal(t, value, *ptr)
assert.Equal(t, "test", ptr.Field1)
assert.Equal(t, 123, ptr.Field2)
})
t.Run("ToPtr with slice", func(t *testing.T) {
value := []int{1, 2, 3}
ptr := ToPtr(value)
require.NotNil(t, ptr)
assert.Equal(t, value, *ptr)
assert.Len(t, *ptr, 3)
})
t.Run("ToPtr with map", func(t *testing.T) {
value := map[string]int{"one": 1, "two": 2}
ptr := ToPtr(value)
require.NotNil(t, ptr)
assert.Equal(t, value, *ptr)
assert.Len(t, *ptr, 2)
})
t.Run("ToPtr with nil interface", func(t *testing.T) {
var value any = nil
ptr := ToPtr(value)
require.NotNil(t, ptr)
assert.Nil(t, *ptr)
})
t.Run("ToPtr with pointer", func(t *testing.T) {
originalValue := 42
originalPtr := &originalValue
ptr := ToPtr(originalPtr)
require.NotNil(t, ptr)
assert.Equal(t, originalPtr, *ptr)
assert.NotSame(t, originalPtr, ptr, "Should return a pointer to pointer")
})
t.Run("ToPtr with uint", func(t *testing.T) {
value := uint(100)
ptr := ToPtr(value)
require.NotNil(t, ptr)
assert.Equal(t, value, *ptr)
})
t.Run("ToPtr modification safety", func(t *testing.T) {
value := 10
ptr := ToPtr(value)
*ptr = 20
assert.Equal(t, 10, value, "Original value should not be modified")
assert.Equal(t, 20, *ptr, "Pointer value should be modified")
})
t.Run("ToPtr with byte array", func(t *testing.T) {
value := [3]byte{1, 2, 3}
ptr := ToPtr(value)
require.NotNil(t, ptr)
assert.Equal(t, value, *ptr)
})
t.Run("ToPtr with rune", func(t *testing.T) {
value := 'A'
ptr := ToPtr(value)
require.NotNil(t, ptr)
assert.Equal(t, value, *ptr)
assert.Equal(t, int32(65), *ptr)
})
t.Run("ToPtr with empty string", func(t *testing.T) {
value := ""
ptr := ToPtr(value)
require.NotNil(t, ptr)
assert.Equal(t, value, *ptr)
assert.Empty(t, *ptr)
})
t.Run("ToPtr with zero values", func(t *testing.T) {
// Test with various zero values
intZero := 0
intPtr := ToPtr(intZero)
require.NotNil(t, intPtr)
assert.Equal(t, 0, *intPtr)
boolZero := false
boolPtr := ToPtr(boolZero)
require.NotNil(t, boolPtr)
assert.False(t, *boolPtr)
floatZero := 0.0
floatPtr := ToPtr(floatZero)
require.NotNil(t, floatPtr)
assert.Equal(t, 0.0, *floatPtr) //nolint:testifylint
})
}

105
pkg/utils/error.go Normal file
View File

@@ -0,0 +1,105 @@
package utils
import (
"errors"
"fmt"
"reflect"
)
// ErrorHandler represents a function that handles a specific error type
type ErrorHandler func(error) error
// ErrorMatcher holds the error type/value and its handler
type ErrorMatcher struct {
ErrorType any // Can be error value (sentinel) or error type
Handler ErrorHandler
IsSentinel bool // true for sentinel errors, false for custom types
}
// HandleError processes an error against a list of matchers and executes the appropriate handler.
// It returns (true, handlerResult) if a matching handler is found and executed,
// or (false, nil) if no matcher matches the error.
// If err is nil, returns (true, nil).
//
// Example:
//
// handled, result := HandleError(err,
// OnSentinelError(io.EOF, func(e error) error {
// return nil // EOF is expected, ignore it
// }),
// OnCustomError(func(e *CustomError) error {
// return fmt.Errorf("custom error: %w", e)
// }),
// )
func HandleError(err error, matchers ...ErrorMatcher) (bool, error) {
if err == nil {
return true, nil
}
for _, matcher := range matchers {
if matcher.IsSentinel {
// Handle sentinel errors with errors.Is
if sentinelErr, ok := matcher.ErrorType.(error); ok {
if errors.Is(err, sentinelErr) {
return true, matcher.Handler(err)
}
}
} else {
// Handle custom error types with errors.As
errorType := reflect.TypeOf(matcher.ErrorType)
errorValue := reflect.New(errorType).Interface()
if errors.As(err, errorValue) {
return true, matcher.Handler(err)
}
}
}
return false, err // No matcher found
}
// HandleErrorOrDie processes an error against a list of matchers and executes the appropriate handler.
// If a matching handler is found, it returns the handler's result.
// If no matcher matches the error, it panics with a descriptive message.
// This function is useful when all expected error types must be handled explicitly.
//
// Example:
//
// result := HandleErrorOrDie(err,
// OnSentinelError(context.Canceled, func(e error) error {
// return fmt.Errorf("operation canceled")
// }),
// OnCustomError(func(e *ValidationError) error {
// return fmt.Errorf("validation failed: %w", e)
// }),
// ) // Panics if err doesn't match any handler
func HandleErrorOrDie(err error, matchers ...ErrorMatcher) error {
ok, err := HandleError(err, matchers...)
if !ok {
panic(fmt.Sprintf("Unhandled error of type %T: %v", err, err))
}
return err
}
func OnSentinelError(sentinelErr error, handler ErrorHandler) ErrorMatcher {
return ErrorMatcher{
ErrorType: sentinelErr,
Handler: handler,
IsSentinel: true,
}
}
func OnCustomError[T error](handler func(T) error) ErrorMatcher {
var zero T
return ErrorMatcher{
ErrorType: zero,
Handler: func(err error) error {
var typedErr T
if errors.As(err, &typedErr) {
return handler(typedErr)
}
return nil
},
IsSentinel: false,
}
}

384
pkg/utils/error_test.go Normal file
View File

@@ -0,0 +1,384 @@
package utils
import (
"context"
"errors"
"fmt"
"io"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// Custom error types for testing
type CustomError struct {
Code int
Message string
}
func (e CustomError) Error() string {
return fmt.Sprintf("custom error %d: %s", e.Code, e.Message)
}
type ValidationError struct {
Field string
Value string
}
func (e *ValidationError) Error() string {
return fmt.Sprintf("validation failed for field %s with value %s", e.Field, e.Value)
}
// Sentinel errors for testing
var (
ErrSentinel1 = errors.New("sentinel error 1")
ErrSentinel2 = errors.New("sentinel error 2")
)
func TestHandleError(t *testing.T) {
t.Run("HandleError with nil error", func(t *testing.T) {
handled, result := HandleError(nil)
assert.True(t, handled)
assert.NoError(t, result)
})
t.Run("HandleError with sentinel error match", func(t *testing.T) {
err := io.EOF
handled, result := HandleError(err,
OnSentinelError(io.EOF, func(e error) error {
return errors.New("handled EOF")
}),
)
assert.True(t, handled)
assert.EqualError(t, result, "handled EOF")
})
t.Run("HandleError with wrapped sentinel error", func(t *testing.T) {
wrappedErr := fmt.Errorf("wrapped: %w", io.EOF)
handled, result := HandleError(wrappedErr,
OnSentinelError(io.EOF, func(e error) error {
return errors.New("handled wrapped EOF")
}),
)
assert.True(t, handled)
assert.EqualError(t, result, "handled wrapped EOF")
})
t.Run("HandleError with custom error type match", func(t *testing.T) {
err := &CustomError{Code: 404, Message: "not found"}
handled, result := HandleError(err,
OnCustomError(func(e *CustomError) error {
return fmt.Errorf("handled custom error with code %d", e.Code)
}),
)
assert.True(t, handled)
assert.EqualError(t, result, "handled custom error with code 404")
})
t.Run("HandleError with wrapped custom error", func(t *testing.T) {
customErr := &CustomError{Code: 500, Message: "internal error"}
wrappedErr := fmt.Errorf("wrapped: %w", customErr)
handled, result := HandleError(wrappedErr,
OnCustomError(func(e *CustomError) error {
return fmt.Errorf("handled wrapped custom error: %s", e.Message)
}),
)
assert.True(t, handled)
assert.EqualError(t, result, "handled wrapped custom error: internal error")
})
t.Run("HandleError with no matching handler", func(t *testing.T) {
err := errors.New("unhandled error")
handled, _ := HandleError(err,
OnSentinelError(io.EOF, func(e error) error {
return nil
}),
OnCustomError(func(e CustomError) error {
return nil
}),
)
assert.False(t, handled)
})
t.Run("HandleError with multiple matchers first match wins", func(t *testing.T) {
err := io.EOF
handled, result := HandleError(err,
OnSentinelError(io.EOF, func(e error) error {
return errors.New("first handler")
}),
OnSentinelError(io.EOF, func(e error) error {
return errors.New("second handler")
}),
)
assert.True(t, handled)
assert.EqualError(t, result, "first handler")
})
t.Run("HandleError with handler returning nil", func(t *testing.T) {
err := io.EOF
handled, result := HandleError(err,
OnSentinelError(io.EOF, func(e error) error {
return nil
}),
)
assert.True(t, handled)
assert.NoError(t, result)
})
t.Run("HandleError with multiple error types", func(t *testing.T) {
customErr := &CustomError{Code: 400, Message: "bad request"}
validationErr := &ValidationError{Field: "email", Value: "invalid"}
// Test CustomError handling
handled1, result1 := HandleError(customErr,
OnCustomError(func(e *CustomError) error {
return fmt.Errorf("custom: %d", e.Code)
}),
OnCustomError(func(e *ValidationError) error {
return fmt.Errorf("validation: %s", e.Field)
}),
)
assert.True(t, handled1)
require.EqualError(t, result1, "custom: 400")
// Test ValidationError handling
handled2, result2 := HandleError(validationErr,
OnCustomError(func(e *CustomError) error {
return fmt.Errorf("custom: %d", e.Code)
}),
OnCustomError(func(e *ValidationError) error {
return fmt.Errorf("validation: %s", e.Field)
}),
)
assert.True(t, handled2)
assert.EqualError(t, result2, "validation: email")
})
t.Run("HandleError with context errors", func(t *testing.T) {
// Test context.Canceled
handled1, result1 := HandleError(context.Canceled,
OnSentinelError(context.Canceled, func(e error) error {
return errors.New("operation canceled")
}),
)
assert.True(t, handled1)
require.EqualError(t, result1, "operation canceled")
// Test context.DeadlineExceeded
handled2, result2 := HandleError(context.DeadlineExceeded,
OnSentinelError(context.DeadlineExceeded, func(e error) error {
return errors.New("deadline exceeded")
}),
)
assert.True(t, handled2)
assert.EqualError(t, result2, "deadline exceeded")
})
t.Run("HandleError preserves original error in handler", func(t *testing.T) {
originalErr := &CustomError{Code: 403, Message: "forbidden"}
var capturedErr error
handled, _ := HandleError(originalErr,
OnCustomError(func(e *CustomError) error {
capturedErr = e
return nil
}),
)
assert.True(t, handled)
assert.Equal(t, originalErr, capturedErr)
})
}
func TestHandleErrorOrDie(t *testing.T) {
t.Run("HandleErrorOrDie with nil error", func(t *testing.T) {
result := HandleErrorOrDie(nil)
assert.NoError(t, result)
})
t.Run("HandleErrorOrDie with matched error", func(t *testing.T) {
err := io.EOF
result := HandleErrorOrDie(err,
OnSentinelError(io.EOF, func(e error) error {
return errors.New("handled EOF in die")
}),
)
assert.EqualError(t, result, "handled EOF in die")
})
t.Run("HandleErrorOrDie panics on unmatched error", func(t *testing.T) {
err := errors.New("unmatched error")
assert.Panics(t, func() {
HandleErrorOrDie(err,
OnSentinelError(io.EOF, func(e error) error {
return nil
}),
)
})
})
t.Run("HandleErrorOrDie with custom error panic", func(t *testing.T) {
customErr := &CustomError{Code: 500, Message: "server error"}
assert.Panics(t, func() {
HandleErrorOrDie(customErr,
OnCustomError(func(e *ValidationError) error {
return nil
}),
)
})
})
t.Run("HandleErrorOrDie with multiple matchers", func(t *testing.T) {
validationErr := &ValidationError{Field: "username", Value: ""}
result := HandleErrorOrDie(validationErr,
OnSentinelError(io.EOF, func(e error) error {
return errors.New("EOF handler")
}),
OnCustomError(func(e *CustomError) error {
return errors.New("custom handler")
}),
OnCustomError(func(e *ValidationError) error {
return fmt.Errorf("validation handler: field=%s", e.Field)
}),
)
assert.EqualError(t, result, "validation handler: field=username")
})
}
func TestOnSentinelError(t *testing.T) {
t.Run("OnSentinelError creates proper matcher", func(t *testing.T) {
handler := func(e error) error { return e }
matcher := OnSentinelError(io.EOF, handler)
assert.Equal(t, io.EOF, matcher.ErrorType)
assert.True(t, matcher.IsSentinel)
assert.NotNil(t, matcher.Handler)
})
t.Run("OnSentinelError with custom sentinel", func(t *testing.T) {
customSentinel := errors.New("custom sentinel")
callCount := 0
matcher := OnSentinelError(customSentinel, func(e error) error {
callCount++
return errors.New("handled custom sentinel")
})
// Test that it matches the sentinel
handled, result := HandleError(customSentinel, matcher)
assert.True(t, handled)
require.EqualError(t, result, "handled custom sentinel")
assert.Equal(t, 1, callCount)
// Test that it matches wrapped sentinel
wrappedErr := fmt.Errorf("wrapped: %w", customSentinel)
handled, result = HandleError(wrappedErr, matcher)
assert.True(t, handled)
require.EqualError(t, result, "handled custom sentinel")
assert.Equal(t, 2, callCount)
})
}
func TestOnCustomError(t *testing.T) {
t.Run("OnCustomError creates proper matcher", func(t *testing.T) {
matcher := OnCustomError(func(e *CustomError) error {
return fmt.Errorf("handled: %d", e.Code)
})
assert.False(t, matcher.IsSentinel)
assert.NotNil(t, matcher.Handler)
// Test the handler works
err := &CustomError{Code: 200, Message: "ok"}
result := matcher.Handler(err)
assert.EqualError(t, result, "handled: 200")
})
t.Run("OnCustomError with different error types", func(t *testing.T) {
// Create matchers for different types
customMatcher := OnCustomError(func(e *CustomError) error {
return fmt.Errorf("custom error: code=%d", e.Code)
})
validationMatcher := OnCustomError(func(e *ValidationError) error {
return fmt.Errorf("validation error: field=%s", e.Field)
})
// Test with CustomError
customErr := &CustomError{Code: 404, Message: "not found"}
handled, result := HandleError(customErr, customMatcher, validationMatcher)
assert.True(t, handled)
require.EqualError(t, result, "custom error: code=404")
// Test with ValidationError
validationErr := &ValidationError{Field: "age", Value: "-1"}
handled, result = HandleError(validationErr, customMatcher, validationMatcher)
assert.True(t, handled)
assert.EqualError(t, result, "validation error: field=age")
})
t.Run("OnCustomError handler receives correct type", func(t *testing.T) {
var receivedErr *CustomError
matcher := OnCustomError(func(e *CustomError) error {
receivedErr = e
return nil
})
originalErr := &CustomError{Code: 301, Message: "redirect"}
handled, _ := HandleError(originalErr, matcher)
assert.True(t, handled)
require.NotNil(t, receivedErr)
assert.Equal(t, 301, receivedErr.Code)
assert.Equal(t, "redirect", receivedErr.Message)
})
}
func TestErrorMatcherEdgeCases(t *testing.T) {
t.Run("Invalid sentinel error type in matcher", func(t *testing.T) {
// Create a matcher with invalid ErrorType for sentinel
matcher := ErrorMatcher{
ErrorType: "not an error", // Invalid type
Handler: func(e error) error { return e },
IsSentinel: true,
}
err := errors.New("test error")
handled, _ := HandleError(err, matcher)
assert.False(t, handled)
})
t.Run("Handler that panics", func(t *testing.T) {
matcher := OnSentinelError(io.EOF, func(e error) error {
panic("handler panic")
})
assert.Panics(t, func() {
HandleError(io.EOF, matcher)
})
})
t.Run("Complex error chain", func(t *testing.T) {
// Create a complex error chain
baseErr := &CustomError{Code: 500, Message: "base"}
wrapped1 := fmt.Errorf("layer1: %w", baseErr)
wrapped2 := fmt.Errorf("layer2: %w", wrapped1)
wrapped3 := fmt.Errorf("layer3: %w", wrapped2)
handled, result := HandleError(wrapped3,
OnCustomError(func(e *CustomError) error {
return fmt.Errorf("found custom error at code %d", e.Code)
}),
)
assert.True(t, handled)
assert.EqualError(t, result, "found custom error at code 500")
})
}

17
pkg/utils/print.go Normal file
View File

@@ -0,0 +1,17 @@
package utils
import (
"fmt"
"os"
"github.com/jedib0t/go-pretty/v6/text"
)
func PrintErr(color text.Color, format string, a ...any) {
fmt.Fprintln(os.Stderr, color.Sprintf(format, a...))
}
func PrintErrAndExit(color text.Color, exitCode int, format string, a ...any) {
PrintErr(color, format, a...)
os.Exit(exitCode)
}

250
pkg/utils/print_test.go Normal file
View File

@@ -0,0 +1,250 @@
package utils
import (
"bytes"
"context"
"io"
"os"
"os/exec"
"strings"
"testing"
"github.com/jedib0t/go-pretty/v6/text"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestPrintErr(t *testing.T) {
t.Run("PrintErr writes to stderr with color", func(t *testing.T) {
// Capture stderr
oldStderr := os.Stderr
reader, writer, _ := os.Pipe()
os.Stderr = writer
// Call PrintErr
PrintErr(text.FgRed, "Error: %s", "test error")
// Restore stderr and read output
writer.Close()
os.Stderr = oldStderr
var buf bytes.Buffer
io.Copy(&buf, reader)
output := buf.String()
// The output should contain the message (color codes are included)
assert.Contains(t, output, "test error")
assert.Contains(t, output, "Error:")
assert.True(t, strings.HasSuffix(output, "\n"))
})
t.Run("PrintErr with multiple format arguments", func(t *testing.T) {
oldStderr := os.Stderr
reader, writer, _ := os.Pipe()
os.Stderr = writer
PrintErr(text.FgYellow, "Warning: %s at line %d", "issue", 42)
writer.Close()
os.Stderr = oldStderr
var buf bytes.Buffer
io.Copy(&buf, reader)
output := buf.String()
assert.Contains(t, output, "Warning: issue at line 42")
})
t.Run("PrintErr with no format arguments", func(t *testing.T) {
oldStderr := os.Stderr
reader, writer, _ := os.Pipe()
os.Stderr = writer
PrintErr(text.FgGreen, "Simple message")
writer.Close()
os.Stderr = oldStderr
var buf bytes.Buffer
io.Copy(&buf, reader)
output := buf.String()
assert.Contains(t, output, "Simple message")
assert.True(t, strings.HasSuffix(output, "\n"))
})
t.Run("PrintErr with different colors", func(t *testing.T) {
colors := []text.Color{
text.FgRed,
text.FgGreen,
text.FgYellow,
text.FgBlue,
text.FgMagenta,
text.FgCyan,
}
for _, color := range colors {
oldStderr := os.Stderr
reader, writer, _ := os.Pipe()
os.Stderr = writer
PrintErr(color, "Message with color")
writer.Close()
os.Stderr = oldStderr
var buf bytes.Buffer
io.Copy(&buf, reader)
output := buf.String()
assert.Contains(t, output, "Message with color")
}
})
t.Run("PrintErr with empty string", func(t *testing.T) {
oldStderr := os.Stderr
reader, writer, _ := os.Pipe()
os.Stderr = writer
PrintErr(text.FgRed, "")
writer.Close()
os.Stderr = oldStderr
var buf bytes.Buffer
io.Copy(&buf, reader)
output := buf.String()
assert.Equal(t, "\n", strings.TrimPrefix(output, "\x1b[31m\x1b[0m")) // Just newline after color codes
})
t.Run("PrintErr with special characters", func(t *testing.T) {
oldStderr := os.Stderr
reader, writer, _ := os.Pipe()
os.Stderr = writer
PrintErr(text.FgRed, "Special chars: %s", "!@#$%^&*()")
writer.Close()
os.Stderr = oldStderr
var buf bytes.Buffer
io.Copy(&buf, reader)
output := buf.String()
assert.Contains(t, output, "Special chars: !@#$%^&*()")
})
t.Run("PrintErr with percent sign in message", func(t *testing.T) {
oldStderr := os.Stderr
reader, writer, _ := os.Pipe()
os.Stderr = writer
PrintErr(text.FgRed, "Progress: 100%% complete")
writer.Close()
os.Stderr = oldStderr
var buf bytes.Buffer
io.Copy(&buf, reader)
output := buf.String()
assert.Contains(t, output, "Progress: 100% complete")
})
}
func TestPrintErrAndExit(t *testing.T) {
if os.Getenv("BE_CRASHER") == "1" {
// This is the subprocess that will actually call PrintErrAndExit
exitCode := 1
if code := os.Getenv("EXIT_CODE"); code != "" {
switch code {
case "0":
exitCode = 0
case "1":
exitCode = 1
case "2":
exitCode = 2
}
}
PrintErrAndExit(text.FgRed, exitCode, "Error: %s", "fatal error")
return
}
t.Run("PrintErrAndExit calls os.Exit with correct code", func(t *testing.T) {
testCases := []struct {
name string
exitCode int
}{
{"Exit with code 0", 0},
{"Exit with code 1", 1},
{"Exit with code 2", 2},
}
for _, testCase := range testCases {
t.Run(testCase.name, func(t *testing.T) {
ctx := context.Background()
cmd := exec.CommandContext(ctx, os.Args[0], "-test.run=TestPrintErrAndExit")
cmd.Env = append(os.Environ(),
"BE_CRASHER=1",
"EXIT_CODE="+string(rune('0'+testCase.exitCode)))
var stderr bytes.Buffer
cmd.Stderr = &stderr
err := cmd.Run()
if testCase.exitCode == 0 {
require.NoError(t, err)
} else {
require.Error(t, err)
if exitErr, ok := err.(*exec.ExitError); ok {
assert.Equal(t, testCase.exitCode, exitErr.ExitCode())
}
}
// Check that error message was printed to stderr
assert.Contains(t, stderr.String(), "Error: fatal error")
})
}
})
t.Run("PrintErrAndExit prints before exiting", func(t *testing.T) {
ctx := context.Background()
cmd := exec.CommandContext(ctx, os.Args[0], "-test.run=TestPrintErrAndExit")
cmd.Env = append(os.Environ(), "BE_CRASHER=1", "EXIT_CODE=1")
var stderr bytes.Buffer
cmd.Stderr = &stderr
cmd.Run() // Ignore error since we expect non-zero exit
output := stderr.String()
assert.Contains(t, output, "Error: fatal error")
assert.True(t, strings.HasSuffix(output, "\n"))
})
}
// Benchmarks for performance testing
func BenchmarkPrintErr(b *testing.B) {
// Redirect stderr to /dev/null for benchmarking
oldStderr := os.Stderr
devNull, _ := os.Open(os.DevNull)
os.Stderr = devNull
defer func() {
os.Stderr = oldStderr
devNull.Close()
}()
b.Run("Simple message", func(b *testing.B) {
for range b.N {
PrintErr(text.FgRed, "Error message")
}
})
b.Run("Formatted message", func(b *testing.B) {
for range b.N {
PrintErr(text.FgRed, "Error: %s at line %d", "issue", 42)
}
})
b.Run("Different colors", func(b *testing.B) {
colors := []text.Color{text.FgRed, text.FgGreen, text.FgYellow}
for idx := range b.N {
PrintErr(colors[idx%len(colors)], "Message %d", idx)
}
})
}

View File

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

View File

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

View File

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

View File

@@ -1,108 +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() {
total := struct {
Count int
Min time.Duration
Max time.Duration
Sum time.Duration
P90 time.Duration
P95 time.Duration
P99 time.Duration
}{
Count: len(responses),
Min: responses[0].Time,
Max: responses[0].Time,
}
mergedResponses := make(map[string]types.Durations)
var allDurations types.Durations
for _, response := range responses {
if response.Time < total.Min {
total.Min = response.Time
}
if response.Time > total.Max {
total.Max = response.Time
}
total.Sum += response.Time
mergedResponses[response.Response] = append(
mergedResponses[response.Response],
response.Time,
)
allDurations = append(allDurations, response.Time)
}
allDurations.Sort()
allDurationsLenAsFloat := float64(len(allDurations) - 1)
total.P90 = allDurations[int(0.90*allDurationsLenAsFloat)]
total.P95 = allDurations[int(0.95*allDurationsLenAsFloat)]
total.P99 = allDurations[int(0.99*allDurationsLenAsFloat)]
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 {
t.AppendRow(table.Row{
"Total",
total.Count,
utils.DurationRoundBy(total.Min, roundPrecision),
utils.DurationRoundBy(total.Max, roundPrecision),
utils.DurationRoundBy(total.Sum/time.Duration(total.Count), roundPrecision), // Average
utils.DurationRoundBy(total.P90, roundPrecision),
utils.DurationRoundBy(total.P95, roundPrecision),
utils.DurationRoundBy(total.P99, roundPrecision),
})
}
t.Render()
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,41 +0,0 @@
package types
import (
"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
sort.Slice(d, func(i, j int) bool {
return d[i] < d[j]
})
}
}
func (d Durations) First() *time.Duration {
return &d[0]
}
func (d Durations) Last() *time.Duration {
return &d[len(d)-1]
}
func (d Durations) Sum() time.Duration {
sum := time.Duration(0)
for _, duration := range d {
sum += duration
}
return sum
}
func (d Durations) Avg() time.Duration {
return d.Sum() / time.Duration(len(d))
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,461 +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,
// Dict
"dict_Str": func(values ...any) map[string]string {
dict := make(map[string]string)
for i := 0; i < len(values); i += 2 {
if i+1 < len(values) {
key := values[i].(string)
value := values[i+1].(string)
dict[key] = value
}
}
return dict
},
// 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_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_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_Lexify": g.localFaker.Lexify,
"fakeit_Numerify": g.localFaker.Numerify,
// FakeIt / Celebrity
"fakeit_CelebrityActor": g.localFaker.CelebrityActor,
"fakeit_CelebrityBusiness": g.localFaker.CelebrityBusiness,
"fakeit_CelebritySport": g.localFaker.CelebritySport,
// FakeIt / Minecraft
"fakeit_MinecraftOre": g.localFaker.MinecraftOre,
"fakeit_MinecraftWood": g.localFaker.MinecraftWood,
"fakeit_MinecraftArmorTier": g.localFaker.MinecraftArmorTier,
"fakeit_MinecraftArmorPart": g.localFaker.MinecraftArmorPart,
"fakeit_MinecraftWeapon": g.localFaker.MinecraftWeapon,
"fakeit_MinecraftTool": g.localFaker.MinecraftTool,
"fakeit_MinecraftDye": g.localFaker.MinecraftDye,
"fakeit_MinecraftFood": g.localFaker.MinecraftFood,
"fakeit_MinecraftAnimal": g.localFaker.MinecraftAnimal,
"fakeit_MinecraftVillagerJob": g.localFaker.MinecraftVillagerJob,
"fakeit_MinecraftVillagerStation": g.localFaker.MinecraftVillagerStation,
"fakeit_MinecraftVillagerLevel": g.localFaker.MinecraftVillagerLevel,
"fakeit_MinecraftMobPassive": g.localFaker.MinecraftMobPassive,
"fakeit_MinecraftMobNeutral": g.localFaker.MinecraftMobNeutral,
"fakeit_MinecraftMobHostile": g.localFaker.MinecraftMobHostile,
"fakeit_MinecraftMobBoss": g.localFaker.MinecraftMobBoss,
"fakeit_MinecraftBiome": g.localFaker.MinecraftBiome,
"fakeit_MinecraftWeather": g.localFaker.MinecraftWeather,
// FakeIt / Book
"fakeit_BookTitle": g.localFaker.BookTitle,
"fakeit_BookAuthor": g.localFaker.BookAuthor,
"fakeit_BookGenre": g.localFaker.BookGenre,
// FakeIt / Movie
"fakeit_MovieName": g.localFaker.MovieName,
"fakeit_MovieGenre": g.localFaker.MovieGenre,
// FakeIt / Error
"fakeit_Error": g.localFaker.Error,
"fakeit_ErrorDatabase": g.localFaker.ErrorDatabase,
"fakeit_ErrorGRPC": g.localFaker.ErrorGRPC,
"fakeit_ErrorHTTP": g.localFaker.ErrorHTTP,
"fakeit_ErrorHTTPClient": g.localFaker.ErrorHTTPClient,
"fakeit_ErrorHTTPServer": g.localFaker.ErrorHTTPServer,
"fakeit_ErrorRuntime": g.localFaker.ErrorRuntime,
// FakeIt / School
"fakeit_School": g.localFaker.School,
// FakeIt / Song
"fakeit_SongName": g.localFaker.SongName,
"fakeit_SongArtist": g.localFaker.SongArtist,
"fakeit_SongGenre": g.localFaker.SongGenre,
}
}

View File

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