88 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
5bb644d55f Merge pull request #116 from aykhans/feat/value-generator
Add value generators
2025-06-01 23:53:11 +04:00
9152eefdc5 Add 'body_FormData' generator to template func maps 2025-06-01 23:22:55 +04:00
a8cd253c63 Add string functions to templates func map 2025-06-01 20:52:27 +04:00
9aaf2db74d 📚 Update docs 2025-06-01 20:52:06 +04:00
5c3e254e1e 📚 Update docs 2025-05-30 21:51:11 +04:00
e5c681a22b ⬆️ bump version to 0.7.0 2025-05-30 21:44:10 +04:00
79668e4ece Add value generators 2025-05-30 21:41:38 +04:00
f248c2af96 Value generator initial commit 2025-05-30 10:40:20 +04:00
924bd819ee Merge pull request #115 from aykhans/core/refactor
🔧 Refactor 'RandomValueCycle' and 'getKeyValueGeneratorFunc' functions
2025-05-29 22:16:01 +04:00
e567155eb1 🔧 Refactor 'RandomValueCycle' and 'getKeyValueGeneratorFunc' functions 2025-05-29 22:10:41 +04:00
23c74bdbb1 Merge pull request #114 from aykhans/core/refactor
General refactor
2025-05-29 02:42:57 +04:00
addf92df91 🔧 Add 'skip-verify' parameter to skip SSL/TLS cert verification 2025-05-29 02:38:07 +04:00
6aeda3706b 💄 general formatting 2025-05-29 00:38:48 +04:00
dc1cd05714 Merge pull request #112 from aykhans/core/refactor-clients
🔧 Update client configs to skip connection verification if it is not secure
2025-05-25 19:18:03 +04:00
2b9d0520b0 🔧 Update client configs to skip connection verification if it is not secure 2025-05-25 19:16:30 +04:00
bea2e7c040 Merge pull request #111 from aykhans/dependabot/go_modules/github.com/valyala/fasthttp-1.62.0
Bump github.com/valyala/fasthttp from 1.61.0 to 1.62.0
2025-05-08 15:11:32 +04:00
dependabot[bot]
b52b336a52 Bump github.com/valyala/fasthttp from 1.61.0 to 1.62.0
Bumps [github.com/valyala/fasthttp](https://github.com/valyala/fasthttp) from 1.61.0 to 1.62.0.
- [Release notes](https://github.com/valyala/fasthttp/releases)
- [Commits](https://github.com/valyala/fasthttp/compare/v1.61.0...v1.62.0)

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

Signed-off-by: dependabot[bot] <support@github.com>
2025-05-08 00:48:52 +00:00
c927e31c49 Merge pull request #110 from aykhans/core/update-go
🔧 bump go version to 1.24.2
2025-04-26 16:38:03 +04:00
d8e6f532a8 🔧 bump go version to 1.24.2 2025-04-26 16:36:27 +04:00
cf5cd23d97 Merge pull request #109 from aykhans/dependabot/go_modules/github.com/valyala/fasthttp-1.61.0
All checks were successful
golangci-lint / lint (push) Successful in 2m31s
Bump github.com/valyala/fasthttp from 1.60.0 to 1.61.0
2025-04-23 15:10:41 +04:00
dependabot[bot]
350ff4d66d Bump github.com/valyala/fasthttp from 1.60.0 to 1.61.0
Bumps [github.com/valyala/fasthttp](https://github.com/valyala/fasthttp) from 1.60.0 to 1.61.0.
- [Release notes](https://github.com/valyala/fasthttp/releases)
- [Commits](https://github.com/valyala/fasthttp/compare/v1.60.0...v1.61.0)

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

Signed-off-by: dependabot[bot] <support@github.com>
2025-04-23 00:25:31 +00:00
cb8898d20e Merge pull request #108 from aykhans/core/refactor-duration-logic
All checks were successful
golangci-lint / lint (push) Successful in 3m4s
🔨 Replace 'time.AfterFunc' with 'context.WithTimeout'
2025-04-18 15:42:21 +04:00
a552d1c9f9 🔨 Replace 'time.AfterFunc' with 'context.WithTimeout' 2025-04-18 15:39:17 +04:00
35263f1dd6 Merge pull request #106 from aykhans/bump/version
All checks were successful
golangci-lint / lint (push) Successful in 3m0s
⬆️ bump version to 0.6.3
2025-04-03 05:25:06 +04:00
930e173a6a ⬆️ bump version to 0.6.3 2025-04-03 05:24:11 +04:00
bea2a81afa Merge pull request #104 from aykhans/chore/refactor-ci
🔧 Refactor .golangci and Taskfile
2025-04-03 05:05:50 +04:00
53ed486b23 Merge pull request #105 from aykhans/dependabot/go_modules/github.com/valyala/fasthttp-1.60.0
Bump github.com/valyala/fasthttp from 1.59.0 to 1.60.0
2025-04-03 05:05:02 +04:00
0b9c32a09d Merge branch 'main' into chore/refactor-ci 2025-04-03 05:04:12 +04:00
42d5617e3f 🎨 Format files 2025-04-03 05:01:22 +04:00
e80ae9ab24 🔧 bump golangci version 2025-04-03 04:24:23 +04:00
dependabot[bot]
86a6f7814b Bump github.com/valyala/fasthttp from 1.59.0 to 1.60.0
Bumps [github.com/valyala/fasthttp](https://github.com/valyala/fasthttp) from 1.59.0 to 1.60.0.
- [Release notes](https://github.com/valyala/fasthttp/releases)
- [Commits](https://github.com/valyala/fasthttp/compare/v1.59.0...v1.60.0)

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

Signed-off-by: dependabot[bot] <support@github.com>
2025-04-03 00:20:47 +00:00
09034b5f9e 🔧 bump golangci/golangci-lint-action version 2025-04-03 04:18:53 +04:00
f1ca2041c3 🔧 bump golangci version 2025-04-03 04:15:48 +04:00
f5a29a2657 Merge pull request #103 from aykhans/chore/general-refactoring
General refactoring
2025-04-03 04:11:38 +04:00
439f66eb87 🔧 Refactor .golangci and Taskfile 2025-04-03 03:07:38 +04:00
415d0130ce 🔨 Move duration cancel logic to requests package 2025-04-01 21:10:02 +04:00
abaa8e90b2 🔧 Add 'run' and 'fmt' commands to Taskfile 2025-04-01 21:09:17 +04:00
046ce74cd9 Merge pull request #102 from aykhans/chore/replace-makefile-with-taskfile
All checks were successful
golangci-lint / lint (push) Successful in 2m36s
🔧 Replace Makefile with Taskfile and remove 'build.sh'
2025-04-01 20:01:35 +04:00
681cafc213 🔧 Replace Makefile with Taskfile and remove 'build.sh' 2025-03-27 23:32:41 +04:00
7e05cf4f6b Merge pull request #101 from aykhans/feat/add-duration
All checks were successful
golangci-lint / lint (push) Successful in 23s
 Add duration
2025-03-24 18:12:10 +04:00
934cd0ad33 📚 Update docs 2025-03-24 17:02:58 +04:00
69c4841a05 📚 Update docs 2025-03-24 17:02:06 +04:00
3cc165cbf4 ⬆️ bump version to 0.6.2 2025-03-24 16:55:23 +04:00
59f40ad825 Add duration 2025-03-24 16:54:09 +04:00
a170588574 Merge pull request #100 from aykhans/refactor/config
All checks were successful
golangci-lint / lint (push) Successful in 25s
🔨 Add 'User-Agent' in 'SetDefaults' function
2025-03-23 21:19:31 +04:00
2a0ac390d8 🔨 Add 'User-Agent'in 'SetDefaults' function 2025-03-22 22:25:54 +04:00
11bb8b3fb0 Merge pull request #99 from aykhans/docs/update-readme
All checks were successful
golangci-lint / lint (push) Successful in 21s
📚 Update README.md
2025-03-20 19:23:41 +04:00
1aadc3419a 📚 Update README.md 2025-03-20 19:23:14 +04:00
b3af3f6ad5 Merge pull request #98 from aykhans/feat/config-yaml
All checks were successful
golangci-lint / lint (push) Successful in 1m15s
 Add yaml file reader to config
2025-03-20 16:38:13 +04:00
ed52fff363 ⬆️ bump version to 0.6.1 2025-03-20 16:31:39 +04:00
985fc6200d 🐳 Update Dockerfile 2025-03-20 16:11:43 +04:00
1808865358 📚 Update docs 2025-03-20 16:01:53 +04:00
56342e49c6 📚 Update docs 2025-03-20 15:59:46 +04:00
ec80569d5d 🐳 Remove default config file path from 'ENTRYPOINT' 2025-03-20 15:59:09 +04:00
459f7ee0dc Add yaml file reader to config 2025-03-20 03:52:25 +04:00
3cd72855e5 Merge pull request #97 from aykhans/fix/nil-url
All checks were successful
golangci-lint / lint (push) Successful in 58s
🐛 Fix nil URL referance
2025-03-20 03:20:33 +04:00
b8011ce651 🐛 Fix nil URL referance 2025-03-20 03:19:28 +04:00
63 changed files with 4721 additions and 2326 deletions

View File

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

View File

@@ -19,7 +19,7 @@ jobs:
with:
go-version: stable
- name: golangci-lint
uses: golangci/golangci-lint-action@v6
uses: golangci/golangci-lint-action@v7
with:
version: v1.64
version: v2.4.0
args: --timeout=10m --config=.golangci.yml

View File

@@ -1,16 +1,16 @@
version: "2"
run:
go: "1.24"
go: "1.25"
concurrency: 8
timeout: 10m
linters:
disable-all: true
default: none
enable:
- asasalint
- asciicheck
- errcheck
- gofmt
- goimports
- gomodguard
- goprintffuncname
- govet
@@ -21,7 +21,115 @@ linters:
- prealloc
- reassign
- staticcheck
- typecheck
- 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:
checks:
- "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
@@ -6,14 +6,12 @@ COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN go build -ldflags "-s -w" -o dodo
RUN echo "{}" > config.json
RUN CGO_ENABLED=0 go build -ldflags "-s -w" -o dodo
FROM gcr.io/distroless/static-debian12:latest
WORKDIR /
COPY --from=builder /src/dodo /dodo
COPY --from=builder /src/config.json /config.json
ENTRYPOINT ["./dodo", "-f", "/config.json"]
ENTRYPOINT ["./dodo"]

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.

View File

@@ -1,9 +0,0 @@
lint:
golangci-lint run
build:
go build -ldflags "-s -w" -o "./dodo"
build-all:
rm -rf ./binaries
./build.sh

186
README.md
View File

@@ -1,186 +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>
## Installation
### Using Docker (Recommended)
Pull the Dodo image from Docker Hub:
```sh
docker pull aykhans/dodo:latest
```
When using Dodo with Docker and a local config file, you must provide the config.json file as a volume to the Docker run command (not as the "-f config.json" argument):
```sh
docker run -v /path/to/config.json:/config.json aykhans/dodo
```
If you're using Dodo with Docker and providing a config file via URL, you don't need to set a volume:
```sh
docker run aykhans/dodo -f https://raw.githubusercontent.com/aykhans/dodo/main/config.json
```
### Using Binary Files
You can download pre-built binaries from the [releases](https://github.com/aykhans/dodo/releases) section.
### Building from Source
To build Dodo from source, you need to have [Go 1.24+](https://golang.org/dl/) installed.
Follow these steps:
1. **Clone the repository:**
```sh
git clone https://github.com/aykhans/dodo.git
```
2. **Navigate to the project directory:**
```sh
cd dodo
```
3. **Build the project:**
```sh
go build -ldflags "-s -w" -o dodo
```
This will generate an executable named `dodo` in the project directory.
## Usage
You can use Dodo with CLI arguments, a JSON config file, or both. When using both, CLI arguments will override JSON config values if there's a conflict.
### 1. CLI
Send 1000 GET requests to https://example.com with 10 parallel dodos (threads) and a timeout of 2 seconds:
```sh
dodo -u https://example.com -m GET -d 10 -r 1000 -t 2s
```
With Docker:
```sh
docker run --rm -i aykhans/dodo -u https://example.com -m GET -d 10 -r 1000 -t 2s
```
### 2. JSON Config File
Send 1000 GET requests to https://example.com with 10 parallel dodos (threads) and a timeout of 800 milliseconds:
```jsonc
{
"method": "GET",
"url": "https://example.com",
"yes": false,
"timeout": "800ms",
"dodos": 10,
"requests": 1000,
"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. Combined (CLI & JSON)
Override the config file arguments with CLI arguments:
```sh
dodo -f /path/to/config.json -u https://example.com -m GET -d 10 -r 1000 -t 5s
```
With Docker:
```sh
docker run --rm -i -v /path/to/config.json:/config.json aykhans/dodo -u https://example.com -m GET -d 10 -r 1000 -t 5s
```
## CLI and JSON Config Parameters
If `Headers`, `Params`, `Cookies`, `Body`, or `Proxy` fields have multiple values, each request will choose a random value from the list.
| Parameter | JSON 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 |
| Requests | requests | -requests | -r | UnsignedInteger | Total number of requests to send | 1000 |
| Dodos (Threads) | dodos | -dodos | -d | UnsignedInteger | Number of dodos (threads) to send requests in parallel | 1 |
| Timeout | timeout | -timeout | -t | Duration | 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 | - |

20
Taskfile.yaml Normal file
View File

@@ -0,0 +1,20 @@
# https://taskfile.dev
version: "3"
tasks:
run: go run cmd/cli/main.go {{.CLI_ARGS}}
ftl:
cmds:
- task: fmt
- task: tidy
- task: lint
fmt: golangci-lint fmt
tidy: go mod tidy
lint: golangci-lint run
test: go test ./...

View File

@@ -1,32 +0,0 @@
#!/bin/bash
platforms=(
"darwin,amd64"
"darwin,arm64"
"freebsd,386"
"freebsd,amd64"
"freebsd,arm"
"linux,386"
"linux,amd64"
"linux,arm"
"linux,arm64"
"netbsd,386"
"netbsd,amd64"
"netbsd,arm"
"openbsd,386"
"openbsd,amd64"
"openbsd,arm"
"openbsd,arm64"
"windows,386"
"windows,amd64"
"windows,arm64"
)
for platform in "${platforms[@]}"; do
IFS=',' read -r build_os build_arch <<< "$platform"
ext=""
if [ "$build_os" == "windows" ]; then
ext=".exe"
fi
GOOS="$build_os" GOARCH="$build_arch" go build -ldflags "-s -w" -o "./binaries/dodo-$build_os-$build_arch$ext"
done

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,35 +0,0 @@
{
"method": "GET",
"url": "https://example.com",
"yes": false,
"timeout": "5s",
"dodos": 8,
"requests": 1000,
"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,39 +0,0 @@
# YAML/YML config file option is not implemented yet.
# This file is a example for future implementation.
method: "GET"
url: "https://example.com"
yes: false
timeout: "5s"
dodos: 10
requests: 1000
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
body:
- "body-text1"
- "body-text2"
- "body-text3"
# proxy: "http://example.com:8080"
# OR
proxy:
- "http://example.com:8080"
- "http://username:password@example.com:8080"
- "socks5://example.com:8080"
- "socks5h://example.com:8080"

View File

@@ -1,175 +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 only with URL:
dodo -u https://example.com
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 -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" \
-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 (default %d)
-t, -timeout Duration 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")`
func (config *Config) ReadCLI() (types.ConfigFile, error) {
flag.Usage = func() {
fmt.Printf(
cliUsageText+"\n",
DefaultYes,
DefaultDodosCount,
DefaultRequestCount,
DefaultTimeout,
DefaultMethod,
)
}
var (
version = false
configFile = ""
yes = false
method = ""
url types.RequestURL
dodosCount = uint(0)
requestCount = uint(0)
timeout 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.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(&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 "timeout", "t":
config.Timeout = &types.Timeout{Duration: timeout}
case "yes", "y":
config.Yes = utils.ToPtr(yes)
}
})
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,234 +0,0 @@
package config
import (
"errors"
"fmt"
"net/url"
"os"
"slices"
"strings"
"time"
"github.com/aykhans/dodo/types"
"github.com/aykhans/dodo/utils"
"github.com/jedib0t/go-pretty/v6/table"
)
const (
VERSION string = "0.6.0"
DefaultUserAgent string = "Dodo/" + VERSION
DefaultMethod string = "GET"
DefaultTimeout time.Duration = time.Second * 10
DefaultDodosCount uint = 1
DefaultRequestCount uint = 1
DefaultYes bool = false
)
var SupportedProxySchemes []string = []string{"http", "socks5", "socks5h"}
type RequestConfig struct {
Method string `json:"method"`
URL url.URL `json:"url"`
Timeout time.Duration `json:"timeout"`
DodosCount uint `json:"dodos"`
RequestCount uint `json:"requests"`
Yes bool `json:"yes"`
Params types.Params `json:"params"`
Headers types.Headers `json:"headers"`
Cookies types.Cookies `json:"cookies"`
Body types.Body `json:"body"`
Proxies types.Proxies `json:"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,
Yes: *conf.Yes,
Params: conf.Params,
Headers: conf.Headers,
Cookies: conf.Cookies,
Body: conf.Body,
Proxies: conf.Proxies,
}
}
func (rc *RequestConfig) GetValidDodosCountForRequests() uint {
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()
t.AppendRow(table.Row{"Requests", rc.RequestCount})
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.Render()
}
type Config struct {
Method *string `json:"method"`
URL *types.RequestURL `json:"url"`
Timeout *types.Timeout `json:"timeout"`
DodosCount *uint `json:"dodos"`
RequestCount *uint `json:"requests"`
Yes *bool `json:"yes"`
Params types.Params `json:"params"`
Headers types.Headers `json:"headers"`
Cookies types.Cookies `json:"cookies"`
Body types.Body `json:"body"`
Proxies types.Proxies `json:"proxy"`
}
func NewConfig() *Config {
return &Config{}
}
func (c *Config) Validate() []error {
var errs []error
if utils.IsNilOrZero(c.URL) {
errs = append(errs, errors.New("request URL is required"))
}
if c.URL.Scheme == "" {
c.URL.Scheme = "http"
}
if c.URL.Scheme != "http" && c.URL.Scheme != "https" {
errs = append(errs, errors.New("request URL scheme must be http or https"))
}
urlParams := types.Params{}
for key, values := range c.URL.Query() {
for _, value := range values {
urlParams = append(urlParams, types.KeyValue[string, []string]{
Key: key,
Value: []string{value},
})
}
}
c.Params = append(urlParams, c.Params...)
c.URL.RawQuery = ""
if utils.IsNilOrZero(c.Method) {
errs = append(errs, errors.New("request method is required"))
}
if utils.IsNilOrZero(c.Timeout) {
errs = append(errs, errors.New("request timeout must be greater than 0"))
}
if utils.IsNilOrZero(c.DodosCount) {
errs = append(errs, errors.New("dodos count must be greater than 0"))
}
if utils.IsNilOrZero(c.RequestCount) {
errs = append(errs, errors.New("request count must be greater than 0"))
}
for i, proxy := range c.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, ", "),
),
)
}
}
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.Yes != nil {
config.Yes = newConfig.Yes
}
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.Yes == nil {
config.Yes = utils.ToPtr(DefaultYes)
}
}

View File

@@ -1,60 +0,0 @@
package config
import (
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"os"
"time"
"github.com/aykhans/dodo/types"
)
func (config *Config) ReadFile(filePath types.ConfigFile) error {
var (
data []byte
err error
)
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 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())
}
}
return parseJSONConfig(data, config)
}
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
}

14
go.mod
View File

@@ -1,20 +1,18 @@
module github.com/aykhans/dodo
go 1.24.0
go 1.25
require (
github.com/jedib0t/go-pretty/v6 v6.6.7
github.com/valyala/fasthttp v1.59.0
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.17.11 // 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.36.0 // indirect
golang.org/x/sys v0.30.0 // indirect
golang.org/x/term v0.29.0 // indirect
golang.org/x/text v0.22.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)

20
go.sum
View File

@@ -1,11 +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/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.17.11 h1:In6xLpyWOi1+C7tXUUWv2ot1QvBjxevKAaI6IXrJmUc=
github.com/klauspost/compress v1.17.11/go.mod h1:pMDklpSncoRMuLFrf1W9Ss9KT+0rH90U12bZKk7uwG0=
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=
@@ -15,19 +11,11 @@ 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.59.0 h1:Qu0qYHfXvPk1mSLNqcFtEk6DpxgA26hy6bmydotDpRI=
github.com/valyala/fasthttp v1.59.0/go.mod h1:GTxNb9Bc6r2a9D0TWNSPwDz78UxnTGBViY3xZNEqyYU=
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.36.0 h1:vWF2fRbw4qslQsQzgFqZff+BItCvGFQqKzKIzx1rmoA=
golang.org/x/net v0.36.0/go.mod h1:bFmbeoIPfrw4sMHNhb4J9f6+tPziuGjq7Jk/38fxi1I=
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/term v0.29.0 h1:L6pJp37ocefwRRtYPKSWOWzOtWSxVajvz2ldH/xi3iU=
golang.org/x/term v0.29.0/go.mod h1:6bl4lRlvVuDgSf3179VpIxBF0o10JUpXWOnI7nErv7s=
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=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

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,98 +0,0 @@
package requests
import (
"context"
"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(
ctx context.Context,
timeout time.Duration,
proxies []url.URL,
maxConns uint,
URL url.URL,
) []*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,
Addr: addr,
Dial: dialFunc,
MaxIdleConnDuration: timeout,
MaxConnDuration: timeout,
WriteTimeout: timeout,
ReadTimeout: timeout,
},
)
}
return clients
}
client := &fasthttp.HostClient{
MaxConns: int(maxConns),
IsTLS: isTLS,
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
if proxy.Scheme == "socks5" || proxy.Scheme == "socks5h" {
dialer = fasthttpproxy.FasthttpSocksDialerDualStack(proxy.String())
} else if proxy.Scheme == "http" {
dialer = fasthttpproxy.FasthttpHTTPDialerDualStackTimeout(proxy.String(), timeout)
} else {
return nil, errors.New("unsupported proxy scheme")
}
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,51 +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 int64,
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)
go pw.Render()
dodosTracker := progress.Tracker{
Message: message,
Total: total,
}
pw.AppendTracker(&dodosTracker)
for {
select {
case <-ctx.Done():
if ctx.Err() != context.Canceled {
dodosTracker.MarkAsErrored()
}
fmt.Printf("\r")
time.Sleep(time.Millisecond * 500)
pw.Stop()
return
case value := <-increase:
dodosTracker.Increment(value)
}
}
}

View File

@@ -1,250 +0,0 @@
package requests
import (
"context"
"math/rand"
"net/url"
"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
}
// 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 {
bodiesLen := len(bodies)
getBody := func() string { return "" }
if bodiesLen == 1 {
getBody = func() string { return bodies[0] }
} else if bodiesLen > 1 {
getBody = utils.RandomValueCycle(bodies, localRand)
}
getParams := getKeyValueGeneratorFunc(params, localRand)
getHeaders := getKeyValueGeneratorFunc(headers, localRand)
getCookies := getKeyValueGeneratorFunc(cookies, localRand)
return func() *fasthttp.Request {
return newFasthttpRequest(
URL,
getParams(),
getHeaders(),
getCookies(),
method,
getBody(),
)
}
}
// 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)
}
if req.Header.UserAgent() == nil {
req.Header.SetUserAgent(config.DefaultUserAgent)
}
}
// 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 {
getKeyValueSlice := []map[string]func() string{}
isRandom := false
for _, kv := range keyValueSlice {
valuesLen := len(kv.Value)
getValueFunc := func() string { return "" }
if valuesLen == 1 {
getValueFunc = func() string { return kv.Value[0] }
} else if valuesLen > 1 {
getValueFunc = utils.RandomValueCycle(kv.Value, localRand)
isRandom = true
}
getKeyValueSlice = append(
getKeyValueSlice,
map[string]func() string{kv.Key: getValueFunc},
)
}
if isRandom {
return func() T {
keyValues := make(T, len(getKeyValueSlice))
for i, keyValue := range getKeyValueSlice {
for key, value := range keyValue {
keyValues[i] = types.KeyValue[string, string]{
Key: key,
Value: value(),
}
}
}
return keyValues
}
} else {
keyValues := make(T, len(getKeyValueSlice))
for i, keyValue := range getKeyValueSlice {
for key, value := range keyValue {
keyValues[i] = types.KeyValue[string, string]{
Key: key,
Value: value(),
}
}
}
return func() T { return keyValues }
}
}

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,144 +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) {
clients := getClients(
ctx,
requestConfig.Timeout,
requestConfig.Proxies,
requestConfig.GetMaxConns(fasthttp.DefaultMaxConnsPerHost),
requestConfig.URL,
)
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 uint = requestConfig.GetValidDodosCountForRequests()
dodosCountInt int = int(dodosCount)
responses = make([][]*Response, dodosCount)
increase = make(chan int64, requestConfig.RequestCount)
)
wg.Add(dodosCountInt)
streamWG.Add(1)
streamCtx, streamCtxCancel := context.WithCancel(context.Background())
go streamProgress(streamCtx, &streamWG, int64(requestConfig.RequestCount), "Dodos Working🔥", increase)
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 sendRequest(
ctx,
newRequest(*requestConfig, clients, int64(i)),
requestConfig.Timeout,
requestCountPerDodo,
&responses[i],
increase,
&wg,
)
}
wg.Wait()
streamCtxCancel()
streamWG.Wait()
return utils.Flatten(responses)
}
// sendRequest 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 sendRequest(
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
}()
}
}

View File

@@ -1,72 +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 string(buffer.Bytes())
}
if len(body) == 1 {
buffer.WriteString(body[0])
return string(buffer.Bytes())
}
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 string(buffer.Bytes())
}
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) Set(value string) error {
*body = append(*body, value)
return nil
}

View File

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

View File

@@ -1,114 +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 string(buffer.Bytes())
}
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 string(buffer.Bytes())
}
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) 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
}
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
}

View File

@@ -1,36 +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.Duration.String())
}

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,114 +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 string(buffer.Bytes())
}
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 string(buffer.Bytes())
}
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) 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) 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
}

View File

@@ -1,114 +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 string(buffer.Bytes())
}
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 string(buffer.Bytes())
}
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) 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
}
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
}

View File

@@ -1,86 +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 string(buffer.Bytes())
}
if len(proxies) == 1 {
buffer.WriteString(proxies[0].String())
return string(buffer.Bytes())
}
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 string(buffer.Bytes())
}
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) Set(value string) error {
parsedURL, err := url.Parse(value)
if err != nil {
return err
}
*proxies = append(*proxies, *parsedURL)
return nil
}

View File

@@ -1,44 +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) 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,14 +0,0 @@
package utils
func IsNilOrZero[T comparable](value *T) bool {
if value == nil {
return true
}
var zero T
if *value == zero {
return true
}
return false
}

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,41 +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 slice of values
// in a random order. Each call to the returned function will yield a value from the slice.
// The order of values is determined by the provided random number generator.
//
// The returned function will cycle through the values in a random order until all values
// have been returned at least once. After all values have been returned, the function will
// reset and start cycling through the values in a random order again.
// The returned function isn't thread-safe and should be used in a single-threaded context.
func RandomValueCycle[Value any](values []Value, localRand *rand.Rand) func() Value {
var (
clientsCount int = len(values)
currentIndex int = localRand.Intn(clientsCount)
stopIndex int = currentIndex
)
return func() Value {
client := values[currentIndex]
currentIndex++
if currentIndex == clientsCount {
currentIndex = 0
}
if currentIndex == stopIndex {
currentIndex = localRand.Intn(clientsCount)
stopIndex = currentIndex
}
return client
}
}

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
}