107 Commits

Author SHA1 Message Date
fd7c4c6454 Add 'Files' field to the 'Config'. Add 'Value' field to the 'FieldParseError' 2025-08-30 01:42:28 +04:00
438e655311 Add 'ParseString' to the utils. Refactor enums of the 'ConfigFile'. 2025-08-30 00:45:10 +04:00
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
0aeeb484e2 Merge pull request #96 from aykhans/refactor/general
All checks were successful
golangci-lint / lint (push) Successful in 3m58s
💄 General refactoring
2025-03-19 05:29:13 +04:00
fc3244dc33 💄 General refactoring 2025-03-19 05:28:14 +04:00
aa6ec450b8 Merge pull request #95 from aykhans/fix/types
🐛 Fix 'AppendByKey' method of the '[]KeyValue[string, []string]' types
2025-03-19 04:06:47 +04:00
e31f5ad204 🐛 Fix 'AppendByKey' method of the '[]KeyValue[string, []string]' types 2025-03-19 04:06:10 +04:00
de9a4bb355 Merge pull request #94 from aykhans/dependabot/go_modules/go_modules-c153b83258
Bump golang.org/x/net from 0.35.0 to 0.36.0 in the go_modules group
2025-03-19 02:31:20 +04:00
dependabot[bot]
234ca01e41 Bump golang.org/x/net from 0.35.0 to 0.36.0 in the go_modules group
Bumps the go_modules group with 1 update: [golang.org/x/net](https://github.com/golang/net).


Updates `golang.org/x/net` from 0.35.0 to 0.36.0
- [Commits](https://github.com/golang/net/compare/v0.35.0...v0.36.0)

---
updated-dependencies:
- dependency-name: golang.org/x/net
  dependency-type: indirect
  dependency-group: go_modules
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-03-18 22:29:02 +00:00
cc490143ea Merge pull request #92 from aykhans/refactor/config
Restructure entire project logic
2025-03-19 02:28:13 +04:00
a8c3efe198 🔨 Refactor 'releaseDodos' function 2025-03-19 01:18:36 +04:00
3c2a0ee1b2 📚 Update README.md 2025-03-18 23:41:20 +04:00
00f0bcb2de 🔨 Restructure entire project logic
- Moved readers to the config package
- Added an option to read remote config files
- Moved the validation package to the config package and removed the validator dependency
- Moved the customerrors package to the config package
- Replaced fatih/color with jedib0t/go-pretty/v6/text
- Removed proxy check functionality
- Added param, header, cookie, body, and proxy flags to the CLI
- Allowed multiple values for the same key in params, headers, and cookies
2025-03-16 21:20:33 +04:00
8f811e1bec 💄 Format '.golangci.yml' file 2025-03-11 00:25:32 +04:00
58ea31683b Merge pull request #85 from aykhans/feat/config-file-http-support
Some checks failed
golangci-lint / lint (push) Failing after 44s
 Added http support to 'JSONConfigReader' function
2025-03-09 17:07:41 +04:00
cc2a6eb367 Added http support to 'JSONConfigReader' function 2025-03-09 05:00:33 +04:00
f721abb583 Merge pull request #82 from aykhans/dependabot/go_modules/golang.org/x/net-0.37.0
Some checks failed
golangci-lint / lint (push) Failing after 36s
Bump golang.org/x/net from 0.35.0 to 0.37.0
2025-03-06 15:14:08 +04:00
dependabot[bot]
4a9fb9fdda Bump golang.org/x/net from 0.35.0 to 0.37.0
Bumps [golang.org/x/net](https://github.com/golang/net) from 0.35.0 to 0.37.0.
- [Commits](https://github.com/golang/net/compare/v0.35.0...v0.37.0)

---
updated-dependencies:
- dependency-name: golang.org/x/net
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-03-06 00:50:05 +00:00
198b6c785a Merge pull request #81 from aykhans/dependabot/go_modules/github.com/jedib0t/go-pretty/v6-6.6.7
Some checks failed
golangci-lint / lint (push) Failing after 37s
Bump github.com/jedib0t/go-pretty/v6 from 6.6.6 to 6.6.7
2025-03-03 16:17:20 +04:00
dependabot[bot]
9dc56709a7 Bump github.com/jedib0t/go-pretty/v6 from 6.6.6 to 6.6.7
Bumps [github.com/jedib0t/go-pretty/v6](https://github.com/jedib0t/go-pretty) from 6.6.6 to 6.6.7.
- [Release notes](https://github.com/jedib0t/go-pretty/releases)
- [Commits](https://github.com/jedib0t/go-pretty/compare/v6.6.6...v6.6.7)

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

Signed-off-by: dependabot[bot] <support@github.com>
2025-03-03 01:00:54 +00:00
59 changed files with 5411 additions and 2364 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,27 +1,135 @@
version: "2"
run:
go: "1.24"
concurrency: 8
timeout: 10m
go: "1.25"
concurrency: 8
timeout: 10m
linters:
disable-all: true
enable:
- asasalint
- asciicheck
- gofmt
- goimports
- gomodguard
- goprintffuncname
- govet
- ineffassign
- misspell
- nakedret
- nolintlint
- prealloc
- prealloc
- reassign
- staticcheck
- typecheck
- unconvert
- unused
- whitespace
default: none
enable:
- asasalint
- asciicheck
- errcheck
- gomodguard
- goprintffuncname
- govet
- ineffassign
- misspell
- nakedret
- nolintlint
- prealloc
- reassign
- staticcheck
- 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
- 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
- gocyclo
- 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,19 +1,17 @@
FROM golang:1.24-alpine AS builder
FROM golang:1.25-alpine AS builder
WORKDIR /dodo
WORKDIR /src
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 /dodo
WORKDIR /
COPY --from=builder /dodo/dodo /dodo/dodo
COPY --from=builder /dodo/config.json /dodo/config.json
COPY --from=builder /src/dodo /dodo
ENTRYPOINT ["./dodo", "-c", "/dodo/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

133
README.md
View File

@@ -1,133 +0,0 @@
<h1 align="center">Dodo is a simple and easy-to-use HTTP benchmarking tool.</h1>
<p align="center">
<img width="30%" height="30%" src="https://ftp.aykhans.me/web/client/pubshares/hB6VSdCnBCr8gFPeiMuCji/browse?path=%2Fdodo.png">
</p>
## Installation
### With Docker (Recommended)
Pull the Dodo image from Docker Hub:
```sh
docker pull aykhans/dodo:latest
```
If you use Dodo with Docker and a config file, you must provide the config.json file as a volume to the Docker run command (not as the "-c config.json" argument), as shown in the examples in the [usage](#usage) section.
### With Binary File
You can grab binaries in the [releases](https://github.com/aykhans/dodo/releases) section.
### Build from Source
To build Dodo from source, you need to have [Go1.22+](https://golang.org/dl/) installed. <br>
Follow the steps below to build dodo:
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. If you use both, CLI arguments will always override JSON config arguments if there is a conflict.
### 1. CLI
Send 1000 GET requests to https://example.com with 10 parallel dodos (threads) and a timeout of 2000 milliseconds:
```sh
dodo -u https://example.com -m GET -d 10 -r 1000 -t 2000
```
With Docker:
```sh
docker run --rm -i aykhans/dodo -u https://example.com -m GET -d 10 -r 1000 -t 2000
```
### 2. JSON config file
You can find an example config structure in the [config.json](https://github.com/aykhans/dodo/blob/main/config.json) file:
```jsonc
{
"method": "GET",
"url": "https://example.com",
"no_proxy_check": false,
"timeout": 10000,
"dodos": 1,
"requests": 1,
"params": {
// Random param value will be selected from the param-key1 and param-key2 list for each request
"param-key1": ["param-value1", "param-value2", "param-value3"],
"param-key2": ["param-value1", "param-value2", "param-value3"]
},
"headers": {
// Random header value will be selected from the header-key1 and header-key2 list for each request
"header-key1": ["header-value1", "header-value2", "header-value3"],
"header-key2": ["header-value2", "header-value2", "header-value3"]
},
"cookies": {
// Random cookie value will be selected from the cookie-key1 and cookie-key2 list for each request
"cookie-key1": ["cookie-value1", "cookie-value2", "cookie-value3"],
"cookie-key2": ["cookie-value2", "cookie-value2", "cookie-value3"]
},
// Random body value will be selected from the body list for each request
"body": ["body1", "body2", "body3"],
// Random proxy will be selected from the proxy list for each request
"proxies": [
{
"url": "http://example.com:8080",
"username": "username",
"password": "password"
},
{
"url": "http://example.com:8080"
}
]
}
```
Send 1000 GET requests to https://example.com with 10 parallel dodos (threads) and a timeout of 2000 milliseconds:
```sh
dodo -c /path/config.json
```
With Docker:
```sh
docker run --rm -i -v ./path/config.json:/dodo/config.json aykhans/dodo
```
### 3. Both (CLI & JSON)
Override the config file arguments with CLI arguments:
```sh
dodo -c /path/config.json -u https://example.com -m GET -d 10 -r 1000 -t 2000
```
With Docker:
```sh
docker run --rm -i -v ./path/config.json:/dodo/config.json aykhans/dodo -u https://example.com -m GET -d 10 -r 1000 -t 2000
```
## CLI and JSON Config Parameters
If the Headers, Params, Cookies and Body 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 | -c | String | Path to the JSON config file | - |
| 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 | Integer | Total number of requests to send | 1000 |
| Dodos (Threads) | dodos | --dodos | -d | Integer | Number of dodos (threads) to send requests in parallel | 1 |
| Timeout | timeout | --timeout | -t | Integer | Timeout for canceling each request (milliseconds) | 10000 |
| No Proxy Check | no_proxy_check | --no-proxy-check| - | Boolean | Disable proxy check | false |
| Params | params | - | - | Key-Value {String: [String]} | Request parameters | - |
| Headers | headers | - | - | Key-Value {String: [String]} | Request headers | - |
| Cookies | cookies | - | - | Key-Value {String: [String]} | Request cookies | - |
| Body | body | - | - | [String] | Request body | - |
| Proxy | proxies | - | - | List[Key-Value {string: string}] | List of proxies (will check active proxies before sending requests) | - |

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

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

@@ -0,0 +1,48 @@
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 {
if fieldErr.Value == "" {
utils.PrintErr(text.FgYellow, "[%s] Field '%s': %v", parserName, fieldErr.Field, fieldErr.Err)
}
utils.PrintErr(text.FgYellow, "[%s] Field '%s' (%s): %v", parserName, fieldErr.Field, fieldErr.Value, fieldErr.Err)
}
}

View File

@@ -1,31 +0,0 @@
{
"method": "GET",
"url": "https://example.com",
"no_proxy_check": false,
"timeout": 10000,
"dodos": 1,
"requests": 1,
"params": {
"param-key1": ["param-value1", "param-value2", "param-value3"],
"param-key2": ["param-value1", "param-value2", "param-value3"]
},
"headers": {
"header-key1": ["header-value1", "header-value2", "header-value3"],
"header-key2": ["header-value2", "header-value2", "header-value3"]
},
"cookies": {
"cookie-key1": ["cookie-value1", "cookie-value2", "cookie-value3"],
"cookie-key2": ["cookie-value2", "cookie-value2", "cookie-value3"]
},
"body": ["body1", "body2", "body3"],
"proxies": [
{
"url": "http://example.com:8080",
"username": "username",
"password": "password"
},
{
"url": "http://example.com:8080"
}
]
}

View File

@@ -1,245 +0,0 @@
package config
import (
"net/url"
"os"
"strings"
"time"
. "github.com/aykhans/dodo/types"
"github.com/aykhans/dodo/utils"
"github.com/jedib0t/go-pretty/v6/table"
)
const (
VERSION string = "0.5.7"
DefaultUserAgent string = "Dodo/" + VERSION
ProxyCheckURL string = "https://www.google.com"
DefaultMethod string = "GET"
DefaultTimeout uint32 = 10000 // Milliseconds (10 seconds)
DefaultDodosCount uint = 1
DefaultRequestCount uint = 1
MaxDodosCountForProxies uint = 20 // Max dodos count for proxy check
)
type RequestConfig struct {
Method string
URL *url.URL
Timeout time.Duration
DodosCount uint
RequestCount uint
Params map[string][]string
Headers map[string][]string
Cookies map[string][]string
Proxies []Proxy
Body []string
Yes bool
NoProxyCheck bool
}
func (config *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},
})
newHeaders := make(map[string][]string)
newHeaders["User-Agent"] = []string{DefaultUserAgent}
for k, v := range config.Headers {
newHeaders[k] = v
}
t.AppendHeader(table.Row{"Request Configuration"})
t.AppendRow(table.Row{"Method", config.Method})
t.AppendSeparator()
t.AppendRow(table.Row{"URL", config.URL})
t.AppendSeparator()
t.AppendRow(table.Row{"Timeout", config.Timeout})
t.AppendSeparator()
t.AppendRow(table.Row{"Dodos", config.DodosCount})
t.AppendSeparator()
t.AppendRow(table.Row{"Requests", config.RequestCount})
t.AppendSeparator()
t.AppendRow(table.Row{"Params", string(utils.PrettyJSONMarshal(config.Params, 3, "", " "))})
t.AppendSeparator()
t.AppendRow(table.Row{"Headers", string(utils.PrettyJSONMarshal(newHeaders, 3, "", " "))})
t.AppendSeparator()
t.AppendRow(table.Row{"Cookies", string(utils.PrettyJSONMarshal(config.Cookies, 3, "", " "))})
t.AppendSeparator()
t.AppendRow(table.Row{"Proxies", string(utils.PrettyJSONMarshal(config.Proxies, 3, "", " "))})
t.AppendSeparator()
t.AppendRow(table.Row{"Proxy Check", !config.NoProxyCheck})
t.AppendSeparator()
t.AppendRow(table.Row{"Body", string(utils.PrettyJSONMarshal(config.Body, 3, "", " "))})
t.Render()
}
func (config *RequestConfig) GetValidDodosCountForRequests() uint {
return min(config.DodosCount, config.RequestCount)
}
func (config *RequestConfig) GetValidDodosCountForProxies() uint {
return min(config.DodosCount, uint(len(config.Proxies)), MaxDodosCountForProxies)
}
func (config *RequestConfig) GetMaxConns(minConns uint) uint {
maxConns := max(
minConns, config.GetValidDodosCountForRequests(),
)
return ((maxConns * 50 / 100) + maxConns)
}
type Config struct {
Method string `json:"method" validate:"http_method"` // custom validations: http_method
URL string `json:"url" validate:"http_url,required"`
Timeout uint32 `json:"timeout" validate:"gte=1,lte=100000"`
DodosCount uint `json:"dodos" validate:"gte=1"`
RequestCount uint `json:"requests" validation_name:"request-count" validate:"gte=1"`
NoProxyCheck Option[bool] `json:"no_proxy_check"`
}
func NewConfig(
method string,
timeout uint32,
dodosCount uint,
requestCount uint,
noProxyCheck Option[bool],
) *Config {
if noProxyCheck == nil {
noProxyCheck = NewNoneOption[bool]()
}
return &Config{
Method: method,
Timeout: timeout,
DodosCount: dodosCount,
RequestCount: requestCount,
NoProxyCheck: noProxyCheck,
}
}
func (config *Config) MergeConfigs(newConfig *Config) {
if newConfig.Method != "" {
config.Method = newConfig.Method
}
if newConfig.URL != "" {
config.URL = newConfig.URL
}
if newConfig.Timeout != 0 {
config.Timeout = newConfig.Timeout
}
if newConfig.DodosCount != 0 {
config.DodosCount = newConfig.DodosCount
}
if newConfig.RequestCount != 0 {
config.RequestCount = newConfig.RequestCount
}
if !newConfig.NoProxyCheck.IsNone() {
config.NoProxyCheck = newConfig.NoProxyCheck
}
}
func (config *Config) SetDefaults() {
if config.Method == "" {
config.Method = DefaultMethod
}
if config.Timeout == 0 {
config.Timeout = DefaultTimeout
}
if config.DodosCount == 0 {
config.DodosCount = DefaultDodosCount
}
if config.RequestCount == 0 {
config.RequestCount = DefaultRequestCount
}
if config.NoProxyCheck.IsNone() {
config.NoProxyCheck = NewOption(false)
}
}
type Proxy struct {
URL string `json:"url" validate:"required,proxy_url"`
Username string `json:"username"`
Password string `json:"password"`
}
type JSONConfig struct {
*Config
Params map[string][]string `json:"params"`
Headers map[string][]string `json:"headers"`
Cookies map[string][]string `json:"cookies"`
Proxies []Proxy `json:"proxies" validate:"dive"`
Body []string `json:"body"`
}
func NewJSONConfig(
config *Config,
params map[string][]string,
headers map[string][]string,
cookies map[string][]string,
proxies []Proxy,
body []string,
) *JSONConfig {
return &JSONConfig{
config, params, headers, cookies, proxies, body,
}
}
func (config *JSONConfig) MergeConfigs(newConfig *JSONConfig) {
config.Config.MergeConfigs(newConfig.Config)
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
}
}
type CLIConfig struct {
*Config
Yes Option[bool] `json:"yes" validate:"omitempty"`
ConfigFile string `validation_name:"config-file" validate:"omitempty,filepath"`
}
func NewCLIConfig(
config *Config,
yes Option[bool],
configFile string,
) *CLIConfig {
return &CLIConfig{
config, yes, configFile,
}
}
func (config *CLIConfig) MergeConfigs(newConfig *CLIConfig) {
config.Config.MergeConfigs(newConfig.Config)
if newConfig.ConfigFile != "" {
config.ConfigFile = newConfig.ConfigFile
}
if !newConfig.Yes.IsNone() {
config.Yes = newConfig.Yes
}
}

View File

@@ -1,117 +0,0 @@
package customerrors
import (
"errors"
"fmt"
"github.com/go-playground/validator/v10"
)
var (
ErrInvalidJSON = errors.New("invalid JSON file")
ErrInvalidFile = errors.New("invalid file")
ErrInterrupt = errors.New("interrupted")
ErrNoInternet = errors.New("no internet connection")
ErrTimeout = errors.New("timeout")
)
func As(err error, target any) bool {
return errors.As(err, target)
}
func Is(err, target error) bool {
return errors.Is(err, target)
}
type Error interface {
Error() string
Unwrap() error
}
type TypeError struct {
Expected string
Received string
Field string
err error
}
func NewTypeError(expected, received, field string, err error) *TypeError {
return &TypeError{
Expected: expected,
Received: received,
Field: field,
err: err,
}
}
func (e *TypeError) Error() string {
return "Expected " + e.Expected + " but received " + e.Received + " in field " + e.Field
}
func (e *TypeError) Unwrap() error {
return e.err
}
type InvalidFileError struct {
FileName string
err error
}
func NewInvalidFileError(fileName string, err error) *InvalidFileError {
return &InvalidFileError{
FileName: fileName,
err: err,
}
}
func (e *InvalidFileError) Error() string {
return "Invalid file: " + e.FileName
}
func (e *InvalidFileError) Unwrap() error {
return e.err
}
type FileNotFoundError struct {
FileName string
err error
}
func NewFileNotFoundError(fileName string, err error) *FileNotFoundError {
return &FileNotFoundError{
FileName: fileName,
err: err,
}
}
func (e *FileNotFoundError) Error() string {
return "File not found: " + e.FileName
}
func (e *FileNotFoundError) Unwrap() error {
return e.err
}
type ValidationErrors struct {
MapErrors map[string]string
errors validator.ValidationErrors
}
func NewValidationErrors(errsMap map[string]string, errs validator.ValidationErrors) *ValidationErrors {
return &ValidationErrors{
MapErrors: errsMap,
errors: errs,
}
}
func (errs *ValidationErrors) Error() string {
var errorsStr string
for k, v := range errs.MapErrors {
errorsStr += fmt.Sprintf("[%s]: %s\n", k, v)
}
return errorsStr
}
func (errs *ValidationErrors) Unwrap() error {
return errs.errors
}

View File

@@ -1,68 +0,0 @@
package customerrors
import (
"fmt"
"net"
"net/url"
"strings"
"github.com/go-playground/validator/v10"
)
func OSErrorFormater(err error) error {
errStr := err.Error()
if strings.Contains(errStr, "no such file or directory") {
fileName1 := strings.Index(errStr, "open")
fileName2 := strings.LastIndex(errStr, ":")
return NewFileNotFoundError(errStr[fileName1+5:fileName2], err)
}
return ErrInvalidFile
}
func shortenNamespace(namespace string) string {
return namespace[strings.Index(namespace, ".")+1:]
}
func ValidationErrorsFormater(errs validator.ValidationErrors) error {
errsStr := make(map[string]string)
for _, err := range errs {
switch err.Tag() {
case "required":
errsStr[shortenNamespace(err.Namespace())] = fmt.Sprintf("Field \"%s\" is required", err.Field())
case "gte":
errsStr[shortenNamespace(err.Namespace())] = fmt.Sprintf("Value of \"%s\" must be greater than or equal to \"%s\"", err.Field(), err.Param())
case "lte":
errsStr[shortenNamespace(err.Namespace())] = fmt.Sprintf("Value of \"%s\" must be less than or equal to \"%s\"", err.Field(), err.Param())
case "filepath":
errsStr[shortenNamespace(err.Namespace())] = fmt.Sprintf("Invalid file path for \"%s\" field: \"%s\"", err.Field(), err.Value())
case "http_url":
errsStr[shortenNamespace(err.Namespace())] =
fmt.Sprintf("Invalid url for \"%s\" field: \"%s\"", err.Field(), err.Value())
// --------------------------------------| Custom validations |--------------------------------------
case "http_method":
errsStr[shortenNamespace(err.Namespace())] = fmt.Sprintf("Invalid HTTP method for \"%s\" field: \"%s\"", err.Field(), err.Value())
case "proxy_url":
errsStr[shortenNamespace(err.Namespace())] = fmt.Sprintf("Invalid proxy url for \"%s\" field: \"%s\" (it must be http, socks5 or socks5h)", err.Field(), err.Value())
case "string_bool":
errsStr[shortenNamespace(err.Namespace())] = fmt.Sprintf("Invalid value for \"%s\" field: \"%s\"", err.Field(), err.Value())
default:
errsStr[shortenNamespace(err.Namespace())] = fmt.Sprintf("Invalid value for \"%s\" field: \"%s\"", err.Field(), err.Value())
}
}
return NewValidationErrors(errsStr, errs)
}
func RequestErrorsFormater(err error) string {
switch e := err.(type) {
case *url.Error:
if netErr, ok := e.Err.(net.Error); ok && netErr.Timeout() {
return "Timeout Error"
}
if strings.Contains(e.Error(), "http: ContentLength=") {
println(e.Error())
return "Empty Body Error"
}
// TODO: Add more cases
}
return "Unknown Error"
}

26
go.mod
View File

@@ -1,32 +1,18 @@
module github.com/aykhans/dodo
go 1.24
go 1.25
require (
github.com/go-playground/validator/v10 v10.25.0
github.com/jedib0t/go-pretty/v6 v6.6.6
github.com/valyala/fasthttp v1.59.0
golang.org/x/net v0.35.0
github.com/jedib0t/go-pretty/v6 v6.6.8
github.com/stretchr/testify v1.10.0
)
require (
github.com/mattn/go-colorable v0.1.13 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
)
require (
github.com/andybalholm/brotli v1.1.1 // indirect
github.com/fatih/color v1.18.0
github.com/gabriel-vasile/mimetype v1.4.8 // indirect
github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/klauspost/compress v1.17.11 // indirect
github.com/leodido/go-urn v1.4.0 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/mattn/go-runewidth v0.0.16 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/rivo/uniseg v0.4.7 // indirect
github.com/valyala/bytebufferpool v1.0.0 // indirect
golang.org/x/crypto v0.33.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
)

43
go.sum
View File

@@ -1,30 +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/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM=
github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU=
github.com/gabriel-vasile/mimetype v1.4.8 h1:FfZ3gj38NjllZIeJAmMhr+qKL8Wu+nOoI3GqacKw1NM=
github.com/gabriel-vasile/mimetype v1.4.8/go.mod h1:ByKUIKGjh1ODkGM1asKUbQZOLGrPjydw3hYPU2YU9t8=
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
github.com/go-playground/validator/v10 v10.25.0 h1:5Dh7cjvzR7BRZadnsVOzPhWsrwUr0nmsZJxEAnFLNO8=
github.com/go-playground/validator/v10 v10.25.0/go.mod h1:GGzBIJMuE98Ic/kJsBXbz1x/7cByt++cQ+YOuDM5wus=
github.com/jedib0t/go-pretty/v6 v6.6.6 h1:LyezkL+1SuqH2z47e5IMQkYUIcs2BD+MnpdPRiRcN0c=
github.com/jedib0t/go-pretty/v6 v6.6.6/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/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/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=
@@ -34,23 +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/crypto v0.33.0 h1:IOBPskki6Lysi0lo9qQvbxiQ+FvsCC/YWOecCHAixus=
golang.org/x/crypto v0.33.0/go.mod h1:bVdXmD7IV/4GdElGPozy6U7lWdRXA4qyRVGJV57uQ5M=
golang.org/x/net v0.35.0 h1:T5GQRQb2y08kTAByq9L4/bz8cipCdA8FbRTXewonqY8=
golang.org/x/net v0.35.0/go.mod h1:EglIi67kWsHKlRzzVMUD93VMSWGFOMSZgxFjparz1Qk=
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
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=

123
main.go
View File

@@ -1,123 +0,0 @@
package main
import (
"context"
"fmt"
"net/url"
"os"
"os/signal"
"strings"
"syscall"
"time"
"github.com/aykhans/dodo/config"
customerrors "github.com/aykhans/dodo/custom_errors"
"github.com/aykhans/dodo/readers"
"github.com/aykhans/dodo/requests"
"github.com/aykhans/dodo/utils"
"github.com/aykhans/dodo/validation"
"github.com/fatih/color"
goValidator "github.com/go-playground/validator/v10"
)
func main() {
validator := validation.NewValidator()
conf := config.NewConfig("", 0, 0, 0, nil)
jsonConf := config.NewJSONConfig(
config.NewConfig("", 0, 0, 0, nil), nil, nil, nil, nil, nil,
)
cliConf, err := readers.CLIConfigReader()
if err != nil {
utils.PrintAndExit(err.Error())
}
if cliConf == nil {
os.Exit(0)
}
if err := validator.StructPartial(cliConf, "ConfigFile"); err != nil {
utils.PrintErrAndExit(
customerrors.ValidationErrorsFormater(
err.(goValidator.ValidationErrors),
),
)
}
if cliConf.ConfigFile != "" {
jsonConfNew, err := readers.JSONConfigReader(cliConf.ConfigFile)
if err != nil {
utils.PrintErrAndExit(err)
}
if err := validator.StructFiltered(
jsonConfNew,
func(ns []byte) bool {
return strings.LastIndex(string(ns), "Proxies") == -1
}); err != nil {
utils.PrintErrAndExit(
customerrors.ValidationErrorsFormater(
err.(goValidator.ValidationErrors),
),
)
}
jsonConf = jsonConfNew
conf.MergeConfigs(jsonConf.Config)
}
conf.MergeConfigs(cliConf.Config)
conf.SetDefaults()
if err := validator.Struct(conf); err != nil {
utils.PrintErrAndExit(
customerrors.ValidationErrorsFormater(
err.(goValidator.ValidationErrors),
),
)
}
parsedURL, err := url.Parse(conf.URL)
if err != nil {
utils.PrintErrAndExit(err)
}
requestConf := &config.RequestConfig{
Method: conf.Method,
URL: parsedURL,
Timeout: time.Duration(conf.Timeout) * time.Millisecond,
DodosCount: conf.DodosCount,
RequestCount: conf.RequestCount,
Params: jsonConf.Params,
Headers: jsonConf.Headers,
Cookies: jsonConf.Cookies,
Proxies: jsonConf.Proxies,
Body: jsonConf.Body,
Yes: cliConf.Yes.ValueOr(false),
NoProxyCheck: conf.NoProxyCheck.ValueOr(false),
}
requestConf.Print()
if !cliConf.Yes.ValueOr(false) {
response := readers.CLIYesOrNoReader("Do you want to continue?", true)
if !response {
utils.PrintAndExit("Exiting...")
}
fmt.Println()
}
ctx, cancel := context.WithCancel(context.Background())
sigChan := make(chan os.Signal, 1)
signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM)
go func() {
<-sigChan
cancel()
}()
responses, err := requests.Run(ctx, requestConf)
if err != nil {
if customerrors.Is(err, customerrors.ErrInterrupt) {
color.Yellow(err.Error())
return
} else if customerrors.Is(err, customerrors.ErrNoInternet) {
utils.PrintAndExit("No internet connection")
return
}
utils.PrintErrAndExit(err)
}
responses.Print()
}

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

@@ -0,0 +1,300 @@
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
}
func NewConfigCLIParser(args []string) *ConfigCLIParser {
if args == nil {
args = []string{}
}
return &ConfigCLIParser{args: args}
}
type stringSliceArg []string
func (arg *stringSliceArg) String() string {
return strings.Join(*arg, ",")
}
func (arg *stringSliceArg) Set(value string) error {
*arg = append(*arg, value)
return nil
}
// Parse parses command-line arguments into a Config object.
// It can return the following errors:
// - types.ErrCLINoArgs
// - types.CLIUnexpectedArgsError
// - types.FieldParseErrors
func (parser *ConfigCLIParser) Parse() (*Config, error) {
flagSet := flag.NewFlagSet("dodo", flag.ExitOnError)
flagSet.Usage = func() { parser.PrintHelp() }
var (
config = &Config{}
configFiles = stringSliceArg{}
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.Var(&configFiles, "config-file", "Config file")
flagSet.Var(&configFiles, "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":
for i, configFile := range configFiles {
configFileParsed, err := types.ParseConfigFile(configFile)
_ = utils.HandleErrorOrDie(err,
utils.OnSentinelError(types.ErrConfigFileExtensionNotFound, func(err error) error {
fieldParseErrors = append(
fieldParseErrors,
*types.NewFieldParseError(
fmt.Sprintf("config-file[%d]", i),
configFile,
errors.New("file extension not found"),
),
)
return nil
}),
utils.OnCustomError(func(err types.RemoteConfigFileParseError) error {
fieldParseErrors = append(
fieldParseErrors,
*types.NewFieldParseError(
fmt.Sprintf("config-file[%d]", i),
configFile,
fmt.Errorf("parse error: %w", err),
),
)
return nil
}),
utils.OnCustomError(func(err types.UnknownConfigFileTypeError) error {
fieldParseErrors = append(
fieldParseErrors,
*types.NewFieldParseError(
fmt.Sprintf("config-file[%d]", i),
configFile,
fmt.Errorf("file type '%s' not supported (supported types: %s)", err.Type, types.ConfigFileTypeYAML),
),
)
return nil
}),
)
if err == nil {
config.Files = append(config.Files, *configFileParsed)
}
}
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", urlInput, 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), proxy, 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"
}

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

@@ -0,0 +1,758 @@
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)
})
t.Run("NewConfigCLIParser with nil args", func(t *testing.T) {
parser := NewConfigCLIParser(nil)
require.NotNil(t, parser)
assert.Equal(t, []string{}, parser.args)
})
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)
})
}
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)
assert.Equal(t, "://invalid-url", fieldErr.Errors[0].Value)
})
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)
assert.Equal(t, "://invalid-proxy", fieldErr.Errors[0].Value)
})
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)
assert.Equal(t, "://invalid", fieldErr.Errors[0].Value)
})
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.Len(t, config.Files, 1)
})
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.Len(t, config.Files, 1)
})
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[0]", fieldErr.Errors[0].Field)
assert.Equal(t, "/path/to/config", fieldErr.Errors[0].Value)
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[0]", fieldErr.Errors[0].Field)
assert.Equal(t, "/path/to/config.json", fieldErr.Errors[0].Value)
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.Len(t, config.Files, 1)
})
t.Run("Parse with multiple config files", func(t *testing.T) {
parser := NewConfigCLIParser([]string{"dodo", "-f", "/path/config1.yaml", "-f", "/path/config2.yml"})
config, err := parser.Parse()
require.NoError(t, err)
require.NotNil(t, config)
assert.Len(t, config.Files, 2)
})
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.Len(t, config.Files, 1)
})
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]"])
// Check error values
values := make(map[string]string)
for _, parseErr := range fieldErr.Errors {
values[parseErr.Field] = parseErr.Value
}
assert.Equal(t, "://invalid-url", values["url"])
assert.Equal(t, "://invalid-proxy1", values["proxy[0]"])
assert.Equal(t, "://invalid-proxy2", values["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)
})
}

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

@@ -0,0 +1,114 @@
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 {
Files []types.ConfigFile
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) {
config.Files = append(config.Files, newConfig.Files...)
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}})
}
}

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

@@ -0,0 +1,354 @@
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{
Files: []types.ConfigFile{},
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{
Files: []types.ConfigFile{},
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{
Files: []types.ConfigFile{},
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{
Files: []types.ConfigFile{},
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{
Files: []types.ConfigFile{},
Method: utils.ToPtr("GET"),
URL: originalURL,
Timeout: &originalTimeout,
DodosCount: utils.ToPtr(uint(1)),
Yes: utils.ToPtr(false),
SkipVerify: utils.ToPtr(false),
}
newConfig := &Config{
Files: []types.ConfigFile{},
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) {
configFile, _ := types.ParseConfigFile("original.yml")
config := &Config{
Files: []types.ConfigFile{*configFile},
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{
Files: []types.ConfigFile{},
Params: types.Params{},
Headers: types.Headers{},
Cookies: types.Cookies{},
Bodies: types.Bodies{},
Proxies: types.Proxies{},
}
config.MergeConfig(newConfig)
assert.Equal(t, []types.ConfigFile{*configFile}, config.Files, "Empty Files should not override")
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 with Files field", func(t *testing.T) {
configFile1, _ := types.ParseConfigFile("config1.yml")
configFile2, _ := types.ParseConfigFile("config2.yaml")
config := &Config{
Files: []types.ConfigFile{*configFile1},
Method: utils.ToPtr("GET"),
Headers: types.Headers{{Key: "Original-Header", Value: []string{"original"}}},
}
newConfig := &Config{
Files: []types.ConfigFile{*configFile2},
Method: utils.ToPtr("POST"),
}
config.MergeConfig(newConfig)
assert.Equal(t, "POST", *config.Method, "Method should be updated")
assert.Equal(t, []types.ConfigFile{*configFile1, *configFile2}, config.Files, "Files should be appended")
assert.Equal(t, types.Headers{{Key: "Original-Header", Value: []string{"original"}}}, config.Headers, "Headers should remain unchanged")
})
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{
Files: []types.ConfigFile{},
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{
Files: []types.ConfigFile{},
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{
Files: []types.ConfigFile{},
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{
Files: []types.ConfigFile{},
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)
})
}

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

@@ -0,0 +1,79 @@
package types
import (
"net/url"
"path/filepath"
"strings"
)
type ConfigFileType string
const (
ConfigFileTypeYAML ConfigFileType = "yaml/yml"
)
type ConfigFileLocationType string
const (
ConfigFileLocationLocal ConfigFileLocationType = "local"
ConfigFileLocationRemote ConfigFileLocationType = "remote"
)
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,187 @@
package types
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
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)
})
}

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

@@ -0,0 +1,114 @@
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
Value string
Err error
}
func NewFieldParseError(field string, value string, err error) *FieldParseError {
if err == nil {
err = ErrNoError
}
return &FieldParseError{field, value, err}
}
func (e FieldParseError) Error() string {
return fmt.Sprintf("Field '%s' parse failed: %v", e.Field, e.Err)
}
func (e FieldParseError) Unwrap() error {
return e.Err
}
type FieldParseErrors struct {
Errors []FieldParseError
}
func NewFieldParseErrors(fieldParseErrors []FieldParseError) FieldParseErrors {
return FieldParseErrors{fieldParseErrors}
}
func (e FieldParseErrors) Error() string {
if len(e.Errors) == 0 {
return "No field parse errors"
}
if len(e.Errors) == 1 {
return e.Errors[0].Error()
}
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
}

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

@@ -0,0 +1,286 @@
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", "testuser", 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("", "somevalue", 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", "value123", 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", "value", originalErr)
assert.Equal(t, originalErr, fieldErr.Unwrap())
})
t.Run("Unwrap with nil error", func(t *testing.T) {
fieldErr := NewFieldParseError("field", "value", 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", "testValue", originalErr)
assert.Equal(t, "testField", fieldErr.Field)
assert.Equal(t, "testValue", fieldErr.Value)
assert.Equal(t, originalErr, fieldErr.Err)
})
t.Run("Creates FieldParseError with ErrNoError when nil passed", func(t *testing.T) {
fieldErr := NewFieldParseError("testField", "testValue", nil)
assert.Equal(t, "testField", fieldErr.Field)
assert.Equal(t, "testValue", fieldErr.Value)
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", "value1", 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", "value1", errors.New("error1"))
fieldErr2 := *NewFieldParseError("field2", "value2", errors.New("error2"))
fieldErr3 := *NewFieldParseError("field3", "value3", 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", "john", errors.New("too short"))
fieldErr2 := *NewFieldParseError("email", "invalid", 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", "value1", errors.New("error1"))
fieldErr2 := *NewFieldParseError("field2", "value2", 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", "value", 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)
})
}

6
pkg/types/key_value.go Normal file
View File

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

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")
})
}

5
pkg/utils/convert.go Normal file
View File

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

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")
})
}

111
pkg/utils/parse.go Normal file
View File

@@ -0,0 +1,111 @@
package utils
import (
"fmt"
"net/url"
"strconv"
"time"
)
// ParseString attempts to parse the input string `s` into a value of the specified type T.
// If parsing the string `s` fails for a supported type, it returns the zero value of T
// and the parsing error.
// /nolint:forcetypeassert,wrapcheck
func ParseString[
T string | int | int8 | int16 | int32 | int64 | uint | uint8 | uint16 | uint32 | uint64 | float64 | bool | time.Duration | url.URL,
](rawValue string) (T, error) {
var value T
switch any(value).(type) {
case int:
i, err := strconv.Atoi(rawValue)
if err != nil {
return value, err
}
value = any(i).(T)
case int8:
i, err := strconv.ParseInt(rawValue, 10, 8)
if err != nil {
return value, err
}
value = any(int8(i)).(T)
case int16:
i, err := strconv.ParseInt(rawValue, 10, 16)
if err != nil {
return value, err
}
value = any(int16(i)).(T)
case int32:
i, err := strconv.ParseInt(rawValue, 10, 32)
if err != nil {
return value, err
}
value = any(int32(i)).(T)
case int64:
i, err := strconv.ParseInt(rawValue, 10, 64)
if err != nil {
return value, err
}
value = any(i).(T)
case uint:
u, err := strconv.ParseUint(rawValue, 10, 0)
if err != nil {
return value, err
}
value = any(uint(u)).(T)
case uint8:
u, err := strconv.ParseUint(rawValue, 10, 8)
if err != nil {
return value, err
}
value = any(uint8(u)).(T)
case uint16:
u, err := strconv.ParseUint(rawValue, 10, 16)
if err != nil {
return value, err
}
value = any(uint16(u)).(T)
case uint32:
u, err := strconv.ParseUint(rawValue, 10, 32)
if err != nil {
return value, err
}
value = any(uint32(u)).(T)
case uint64:
u, err := strconv.ParseUint(rawValue, 10, 64)
if err != nil {
return value, err
}
value = any(u).(T)
case float64:
f, err := strconv.ParseFloat(rawValue, 64)
if err != nil {
return value, err
}
value = any(f).(T)
case bool:
b, err := strconv.ParseBool(rawValue)
if err != nil {
return value, err
}
value = any(b).(T)
case string:
value = any(rawValue).(T)
case time.Duration:
d, err := time.ParseDuration(rawValue)
if err != nil {
return value, err
}
value = any(d).(T)
case url.URL:
u, err := url.Parse(rawValue)
if err != nil {
return value, err
}
value = any(*u).(T)
default:
return value, fmt.Errorf("unsupported type: %T", value)
}
return value, nil
}

528
pkg/utils/parse_test.go Normal file
View File

@@ -0,0 +1,528 @@
package utils
import (
"net/url"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestParseString(t *testing.T) {
t.Run("ParseString to string", func(t *testing.T) {
tests := []struct {
name string
input string
expected string
}{
{"empty string", "", ""},
{"simple string", "hello", "hello"},
{"string with spaces", "hello world", "hello world"},
{"numeric string", "123", "123"},
{"special characters", "!@#$%^&*()", "!@#$%^&*()"},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
result, err := ParseString[string](test.input)
require.NoError(t, err)
assert.Equal(t, test.expected, result)
})
}
})
t.Run("ParseString to int", func(t *testing.T) {
tests := []struct {
name string
input string
expected int
expectError bool
}{
{"positive int", "42", 42, false},
{"negative int", "-42", -42, false},
{"zero", "0", 0, false},
{"invalid int", "abc", 0, true},
{"float string", "3.14", 0, true},
{"empty string", "", 0, true},
{"overflow string", "99999999999999999999", 0, true},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
result, err := ParseString[int](test.input)
if test.expectError {
assert.Error(t, err)
} else {
require.NoError(t, err)
assert.Equal(t, test.expected, result)
}
})
}
})
t.Run("ParseString to int8", func(t *testing.T) {
tests := []struct {
name string
input string
expected int8
expectError bool
}{
{"valid int8", "127", 127, false},
{"min int8", "-128", -128, false},
{"overflow int8", "128", 0, true},
{"underflow int8", "-129", 0, true},
{"invalid", "abc", 0, true},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
result, err := ParseString[int8](test.input)
if test.expectError {
assert.Error(t, err)
} else {
require.NoError(t, err)
assert.Equal(t, test.expected, result)
}
})
}
})
t.Run("ParseString to int16", func(t *testing.T) {
tests := []struct {
name string
input string
expected int16
expectError bool
}{
{"valid int16", "32767", 32767, false},
{"min int16", "-32768", -32768, false},
{"overflow int16", "32768", 0, true},
{"underflow int16", "-32769", 0, true},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
result, err := ParseString[int16](test.input)
if test.expectError {
assert.Error(t, err)
} else {
require.NoError(t, err)
assert.Equal(t, test.expected, result)
}
})
}
})
t.Run("ParseString to int32", func(t *testing.T) {
tests := []struct {
name string
input string
expected int32
expectError bool
}{
{"valid int32", "2147483647", 2147483647, false},
{"min int32", "-2147483648", -2147483648, false},
{"overflow int32", "2147483648", 0, true},
{"underflow int32", "-2147483649", 0, true},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
result, err := ParseString[int32](test.input)
if test.expectError {
assert.Error(t, err)
} else {
require.NoError(t, err)
assert.Equal(t, test.expected, result)
}
})
}
})
t.Run("ParseString to int64", func(t *testing.T) {
tests := []struct {
name string
input string
expected int64
expectError bool
}{
{"valid int64", "9223372036854775807", 9223372036854775807, false},
{"min int64", "-9223372036854775808", -9223372036854775808, false},
{"large number", "123456789012345", 123456789012345, false},
{"invalid", "not a number", 0, true},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
result, err := ParseString[int64](test.input)
if test.expectError {
assert.Error(t, err)
} else {
require.NoError(t, err)
assert.Equal(t, test.expected, result)
}
})
}
})
t.Run("ParseString to uint", func(t *testing.T) {
tests := []struct {
name string
input string
expected uint
expectError bool
}{
{"valid uint", "42", 42, false},
{"zero", "0", 0, false},
{"large uint", "4294967295", 4294967295, false},
{"negative", "-1", 0, true},
{"invalid", "abc", 0, true},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
result, err := ParseString[uint](test.input)
if test.expectError {
assert.Error(t, err)
} else {
require.NoError(t, err)
assert.Equal(t, test.expected, result)
}
})
}
})
t.Run("ParseString to uint8", func(t *testing.T) {
tests := []struct {
name string
input string
expected uint8
expectError bool
}{
{"valid uint8", "255", 255, false},
{"min uint8", "0", 0, false},
{"overflow uint8", "256", 0, true},
{"negative", "-1", 0, true},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
result, err := ParseString[uint8](test.input)
if test.expectError {
assert.Error(t, err)
} else {
require.NoError(t, err)
assert.Equal(t, test.expected, result)
}
})
}
})
t.Run("ParseString to uint16", func(t *testing.T) {
tests := []struct {
name string
input string
expected uint16
expectError bool
}{
{"valid uint16", "65535", 65535, false},
{"min uint16", "0", 0, false},
{"overflow uint16", "65536", 0, true},
{"negative", "-1", 0, true},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
result, err := ParseString[uint16](test.input)
if test.expectError {
assert.Error(t, err)
} else {
require.NoError(t, err)
assert.Equal(t, test.expected, result)
}
})
}
})
t.Run("ParseString to uint32", func(t *testing.T) {
tests := []struct {
name string
input string
expected uint32
expectError bool
}{
{"valid uint32", "4294967295", 4294967295, false},
{"min uint32", "0", 0, false},
{"overflow uint32", "4294967296", 0, true},
{"negative", "-1", 0, true},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
result, err := ParseString[uint32](test.input)
if test.expectError {
assert.Error(t, err)
} else {
require.NoError(t, err)
assert.Equal(t, test.expected, result)
}
})
}
})
t.Run("ParseString to uint64", func(t *testing.T) {
tests := []struct {
name string
input string
expected uint64
expectError bool
}{
{"valid uint64", "18446744073709551615", 18446744073709551615, false},
{"min uint64", "0", 0, false},
{"large number", "123456789012345", 123456789012345, false},
{"negative", "-1", 0, true},
{"invalid", "not a number", 0, true},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
result, err := ParseString[uint64](test.input)
if test.expectError {
assert.Error(t, err)
} else {
require.NoError(t, err)
assert.Equal(t, test.expected, result)
}
})
}
})
t.Run("ParseString to float64", func(t *testing.T) {
tests := []struct {
name string
input string
expected float64
expectError bool
}{
{"integer", "42", 42.0, false},
{"decimal", "3.14159", 3.14159, false},
{"negative", "-2.5", -2.5, false},
{"scientific notation", "1.23e10", 1.23e10, false},
{"zero", "0", 0.0, false},
{"invalid", "not a number", 0, true},
{"empty", "", 0, true},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
result, err := ParseString[float64](test.input)
if test.expectError {
assert.Error(t, err)
} else {
require.NoError(t, err)
assert.InDelta(t, test.expected, result, 0.0001)
}
})
}
})
t.Run("ParseString to bool", func(t *testing.T) {
tests := []struct {
name string
input string
expected bool
expectError bool
}{
{"true lowercase", "true", true, false},
{"True mixed case", "True", true, false},
{"TRUE uppercase", "TRUE", true, false},
{"1 as true", "1", true, false},
{"false lowercase", "false", false, false},
{"False mixed case", "False", false, false},
{"FALSE uppercase", "FALSE", false, false},
{"0 as false", "0", false, false},
{"invalid", "yes", false, true},
{"empty", "", false, true},
{"numeric non-binary", "2", false, true},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
result, err := ParseString[bool](test.input)
if test.expectError {
assert.Error(t, err)
} else {
require.NoError(t, err)
assert.Equal(t, test.expected, result)
}
})
}
})
t.Run("ParseString to time.Duration", func(t *testing.T) {
tests := []struct {
name string
input string
expected time.Duration
expectError bool
}{
{"seconds", "10s", 10 * time.Second, false},
{"minutes", "5m", 5 * time.Minute, false},
{"hours", "2h", 2 * time.Hour, false},
{"combined", "1h30m45s", time.Hour + 30*time.Minute + 45*time.Second, false},
{"milliseconds", "500ms", 500 * time.Millisecond, false},
{"microseconds", "100us", 100 * time.Microsecond, false},
{"nanoseconds", "50ns", 50 * time.Nanosecond, false},
{"negative", "-5s", -5 * time.Second, false},
{"invalid", "5x", 0, true},
{"empty", "", 0, true},
{"no unit", "100", 0, true},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
result, err := ParseString[time.Duration](test.input)
if test.expectError {
assert.Error(t, err)
} else {
require.NoError(t, err)
assert.Equal(t, test.expected, result)
}
})
}
})
t.Run("ParseString to url.URL", func(t *testing.T) {
tests := []struct {
name string
input string
checkFunc func(t *testing.T, u url.URL)
expectError bool
}{
{
name: "http URL",
input: "http://example.com",
checkFunc: func(t *testing.T, u url.URL) {
t.Helper()
assert.Equal(t, "http", u.Scheme)
assert.Equal(t, "example.com", u.Host)
},
expectError: false,
},
{
name: "https URL with path",
input: "https://example.com/path/to/resource",
checkFunc: func(t *testing.T, u url.URL) {
t.Helper()
assert.Equal(t, "https", u.Scheme)
assert.Equal(t, "example.com", u.Host)
assert.Equal(t, "/path/to/resource", u.Path)
},
expectError: false,
},
{
name: "URL with query parameters",
input: "https://example.com/search?q=test&page=1",
checkFunc: func(t *testing.T, u url.URL) {
t.Helper()
assert.Equal(t, "https", u.Scheme)
assert.Equal(t, "example.com", u.Host)
assert.Equal(t, "/search", u.Path)
assert.Equal(t, "q=test&page=1", u.RawQuery)
},
expectError: false,
},
{
name: "URL with port",
input: "http://localhost:8080/api",
checkFunc: func(t *testing.T, u url.URL) {
t.Helper()
assert.Equal(t, "http", u.Scheme)
assert.Equal(t, "localhost:8080", u.Host)
assert.Equal(t, "/api", u.Path)
},
expectError: false,
},
{
name: "URL with fragment",
input: "https://example.com/page#section",
checkFunc: func(t *testing.T, u url.URL) {
t.Helper()
assert.Equal(t, "https", u.Scheme)
assert.Equal(t, "example.com", u.Host)
assert.Equal(t, "/page", u.Path)
assert.Equal(t, "section", u.Fragment)
},
expectError: false,
},
{
name: "relative path",
input: "/path/to/resource",
checkFunc: func(t *testing.T, u url.URL) {
t.Helper()
assert.Empty(t, u.Scheme)
assert.Empty(t, u.Host)
assert.Equal(t, "/path/to/resource", u.Path)
},
expectError: false,
},
{
name: "empty string",
input: "",
checkFunc: func(t *testing.T, u url.URL) {
t.Helper()
assert.Empty(t, u.String())
},
expectError: false,
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
result, err := ParseString[url.URL](test.input)
if test.expectError {
assert.Error(t, err)
} else {
require.NoError(t, err)
if test.checkFunc != nil {
test.checkFunc(t, result)
}
}
})
}
})
t.Run("Edge cases", func(t *testing.T) {
t.Run("whitespace handling for numeric types", func(t *testing.T) {
result, err := ParseString[int](" 42 ")
require.Error(t, err)
assert.Equal(t, 0, result)
})
t.Run("leading zeros for int", func(t *testing.T) {
result, err := ParseString[int]("007")
require.NoError(t, err)
assert.Equal(t, 7, result)
})
t.Run("plus sign for positive numbers", func(t *testing.T) {
result, err := ParseString[int]("+42")
require.NoError(t, err)
assert.Equal(t, 42, result)
})
t.Run("case sensitivity for bool", func(t *testing.T) {
testCases := []string{"t", "T", "f", "F"}
for _, tc := range testCases {
result, err := ParseString[bool](tc)
require.NoError(t, err)
if tc == "t" || tc == "T" {
assert.True(t, result)
} else {
assert.False(t, result)
}
}
})
})
}

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,155 +0,0 @@
package readers
import (
"flag"
"fmt"
"strings"
"github.com/aykhans/dodo/config"
. "github.com/aykhans/dodo/types"
"github.com/fatih/color"
)
const usageText = `Usage:
dodo [flags]
Examples:
Simple usage only with URL:
dodo -u https://example.com
Simple usage with config file:
dodo -c /path/to/config/file/config.json
Usage with all flags:
dodo -c /path/to/config/file/config.json -u https://example.com -m POST -d 10 -r 1000 -t 2000 --no-proxy-check -y
Flags:
-h, --help help for dodo
-v, --version version for dodo
-c, --config-file string Path to the config file
-d, --dodos uint Number of dodos(threads) (default %d)
-m, --method string HTTP Method (default %s)
-r, --request uint Number of total requests (default %d)
-t, --timeout uint32 Timeout for each request in milliseconds (default %d)
-u, --url string URL for stress testing
--no-proxy-check bool Do not check for proxies (default false)
-y, --yes bool Answer yes to all questions (default false)`
func CLIConfigReader() (*config.CLIConfig, error) {
flag.Usage = func() {
fmt.Printf(
usageText+"\n",
config.DefaultDodosCount,
config.DefaultMethod,
config.DefaultRequestCount,
config.DefaultTimeout,
)
}
var (
cliConfig = config.NewCLIConfig(config.NewConfig("", 0, 0, 0, nil), NewOption(false), "")
configFile = ""
yes = false
method = ""
url = ""
dodosCount uint = 0
requestsCount uint = 0
timeout uint = 0
noProxyCheck bool = false
)
{
flag.Bool("version", false, "Prints the version of the program")
flag.Bool("v", false, "Prints the version of the program")
flag.StringVar(&configFile, "config-file", "", "Path to the configuration file")
flag.StringVar(&configFile, "c", "", "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.StringVar(&url, "url", "", "URL to send the request")
flag.StringVar(&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(&requestsCount, "requests", 0, "Number of total requests")
flag.UintVar(&requestsCount, "r", 0, "Number of total requests")
flag.UintVar(&timeout, "timeout", 0, "Timeout for each request in milliseconds")
flag.UintVar(&timeout, "t", 0, "Timeout for each request in milliseconds")
flag.BoolVar(&noProxyCheck, "no-proxy-check", false, "Do not check for active proxies")
}
flag.Parse()
args := flag.Args()
if len(args) > 0 {
return nil, fmt.Errorf("unexpected arguments: %v", strings.Join(args, ", "))
}
returnNil := false
flag.Visit(func(f *flag.Flag) {
switch f.Name {
case "version", "v":
fmt.Printf("dodo version %s\n", config.VERSION)
returnNil = true
case "config-file", "c":
cliConfig.ConfigFile = configFile
case "yes", "y":
cliConfig.Yes.SetValue(yes)
case "method", "m":
cliConfig.Method = method
case "url", "u":
cliConfig.URL = url
case "dodos", "d":
cliConfig.DodosCount = dodosCount
case "requests", "r":
cliConfig.RequestCount = requestsCount
case "timeout", "t":
var maxUint32 uint = 4294967295
if timeout > maxUint32 {
color.Yellow("timeout value is too large, setting to %d", maxUint32)
timeout = maxUint32
}
cliConfig.Timeout = uint32(timeout)
case "no-proxy-check":
cliConfig.NoProxyCheck.SetValue(noProxyCheck)
}
})
if returnNil {
return nil, nil
}
return cliConfig, 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,36 +0,0 @@
package readers
import (
"encoding/json"
"os"
"github.com/aykhans/dodo/config"
customerrors "github.com/aykhans/dodo/custom_errors"
)
func JSONConfigReader(filePath string) (*config.JSONConfig, error) {
data, err := os.ReadFile(filePath)
if err != nil {
return nil, customerrors.OSErrorFormater(err)
}
jsonConf := config.NewJSONConfig(
config.NewConfig("", 0, 0, 0, nil),
nil, nil, nil, nil, nil,
)
err = json.Unmarshal(data, &jsonConf)
if err != nil {
switch err := err.(type) {
case *json.UnmarshalTypeError:
return nil,
customerrors.NewTypeError(
err.Type.String(),
err.Value,
err.Field,
err,
)
}
return nil, customerrors.NewInvalidFileError(filePath, err)
}
return jsonConf, nil
}

View File

@@ -1,316 +0,0 @@
package requests
import (
"context"
"fmt"
"math/rand"
"net/url"
"sync"
"time"
"github.com/aykhans/dodo/config"
"github.com/aykhans/dodo/readers"
"github.com/aykhans/dodo/utils"
"github.com/fatih/color"
"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 []config.Proxy,
dodosCount uint,
maxConns uint,
yes bool,
noProxyCheck bool,
URL *url.URL,
) []*fasthttp.HostClient {
isTLS := URL.Scheme == "https"
if proxiesLen := len(proxies); proxiesLen > 0 {
// If noProxyCheck is true, we will return the clients without checking the proxies.
if noProxyCheck {
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
}
// Else, we will check the proxies and return the active ones.
activeProxyClients := getActiveProxyClients(
ctx, proxies, timeout, dodosCount, maxConns, URL,
)
if ctx.Err() != nil {
return nil
}
activeProxyClientsCount := uint(len(activeProxyClients))
var yesOrNoMessage string
var yesOrNoDefault bool
if activeProxyClientsCount == 0 {
yesOrNoDefault = false
yesOrNoMessage = color.YellowString("No active proxies found. Do you want to continue?")
} else {
yesOrNoMessage = color.YellowString("Found %d active proxies. Do you want to continue?", activeProxyClientsCount)
}
if !yes {
response := readers.CLIYesOrNoReader("\n"+yesOrNoMessage, yesOrNoDefault)
if !response {
utils.PrintAndExit("Exiting...")
}
}
fmt.Println()
if activeProxyClientsCount > 0 {
return activeProxyClients
}
}
client := &fasthttp.HostClient{
MaxConns: int(maxConns),
IsTLS: isTLS,
Addr: URL.Host,
MaxIdleConnDuration: timeout,
MaxConnDuration: timeout,
WriteTimeout: timeout,
ReadTimeout: timeout,
}
return []*fasthttp.HostClient{client}
}
// getActiveProxyClients divides the proxies into slices based on the number of dodos and
// launches goroutines to find active proxy clients for each slice.
// It uses a progress tracker to monitor the progress of the search.
// Once all goroutines have completed, the function waits for them to finish and
// returns a flattened slice of active proxy clients.
func getActiveProxyClients(
ctx context.Context,
proxies []config.Proxy,
timeout time.Duration,
dodosCount uint,
maxConns uint,
URL *url.URL,
) []*fasthttp.HostClient {
activeProxyClientsArray := make([][]*fasthttp.HostClient, dodosCount)
proxiesCount := len(proxies)
dodosCountInt := int(dodosCount)
var (
wg sync.WaitGroup
streamWG sync.WaitGroup
)
wg.Add(dodosCountInt)
streamWG.Add(1)
var proxiesSlice []config.Proxy
increase := make(chan int64, proxiesCount)
streamCtx, streamCtxCancel := context.WithCancel(context.Background())
go streamProgress(streamCtx, &streamWG, int64(proxiesCount), "Searching for active proxies🌐", increase)
for i := range dodosCountInt {
if i+1 == dodosCountInt {
proxiesSlice = proxies[i*proxiesCount/dodosCountInt:]
} else {
proxiesSlice = proxies[i*proxiesCount/dodosCountInt : (i+1)*proxiesCount/dodosCountInt]
}
go findActiveProxyClients(
ctx,
proxiesSlice,
timeout,
&activeProxyClientsArray[i],
increase,
maxConns,
URL,
&wg,
)
}
wg.Wait()
streamCtxCancel()
streamWG.Wait()
return utils.Flatten(activeProxyClientsArray)
}
// findActiveProxyClients checks a list of proxies to determine which ones are active
// and appends the active ones to the provided activeProxyClients slice.
//
// Parameters:
// - ctx: The context to control cancellation and timeout.
// - proxies: A slice of Proxy configurations to be checked.
// - timeout: The duration to wait for each proxy check before timing out.
// - activeProxyClients: A pointer to a slice where active proxy clients will be appended.
// - increase: A channel to signal the increase of checked proxies count.
// - URL: The URL to be used for checking the proxies.
// - wg: A WaitGroup to signal when the function is done.
//
// The function sends a GET request to each proxy using the provided URL. If the proxy
// responds with a status code of 200, it is considered active and added to the activeProxyClients slice.
// The function respects the context's cancellation and timeout settings.
func findActiveProxyClients(
ctx context.Context,
proxies []config.Proxy,
timeout time.Duration,
activeProxyClients *[]*fasthttp.HostClient,
increase chan<- int64,
maxConns uint,
URL *url.URL,
wg *sync.WaitGroup,
) {
defer wg.Done()
request := fasthttp.AcquireRequest()
defer fasthttp.ReleaseRequest(request)
request.SetRequestURI(config.ProxyCheckURL)
request.Header.SetMethod("GET")
for _, proxy := range proxies {
if ctx.Err() != nil {
return
}
func() {
defer func() { increase <- 1 }()
response := fasthttp.AcquireResponse()
defer fasthttp.ReleaseResponse(response)
dialFunc, err := getDialFunc(&proxy, timeout)
if err != nil {
return
}
client := &fasthttp.Client{
Dial: dialFunc,
}
defer client.CloseIdleConnections()
ch := make(chan error)
go func() {
err := client.DoTimeout(request, response, timeout)
ch <- err
}()
select {
case err := <-ch:
if err != nil {
return
}
break
case <-time.After(timeout):
return
case <-ctx.Done():
return
}
isTLS := URL.Scheme == "https"
addr := URL.Host
if isTLS && URL.Port() == "" {
addr += ":443"
}
if response.StatusCode() == 200 {
*activeProxyClients = append(
*activeProxyClients,
&fasthttp.HostClient{
MaxConns: int(maxConns),
IsTLS: isTLS,
Addr: addr,
Dial: dialFunc,
MaxIdleConnDuration: timeout,
MaxConnDuration: timeout,
WriteTimeout: timeout,
ReadTimeout: timeout,
},
)
}
}()
}
}
// getDialFunc returns a fasthttp.DialFunc based on the provided proxy configuration.
// It takes a pointer to a config.Proxy struct as input and returns a fasthttp.DialFunc and an error.
// The function parses the proxy URL, determines the scheme (socks5, socks5h, http, or https),
// and creates a dialer accordingly. If the proxy URL is invalid or the scheme is not supported,
// it returns an error.
func getDialFunc(proxy *config.Proxy, timeout time.Duration) (fasthttp.DialFunc, error) {
parsedProxyURL, err := url.Parse(proxy.URL)
if err != nil {
return nil, err
}
var dialer fasthttp.DialFunc
if parsedProxyURL.Scheme == "socks5" || parsedProxyURL.Scheme == "socks5h" {
if proxy.Username != "" {
dialer = fasthttpproxy.FasthttpSocksDialer(
fmt.Sprintf(
"%s://%s:%s@%s",
parsedProxyURL.Scheme,
proxy.Username,
proxy.Password,
parsedProxyURL.Host,
),
)
} else {
dialer = fasthttpproxy.FasthttpSocksDialer(
fmt.Sprintf(
"%s://%s",
parsedProxyURL.Scheme,
parsedProxyURL.Host,
),
)
}
} else if parsedProxyURL.Scheme == "http" {
if proxy.Username != "" {
dialer = fasthttpproxy.FasthttpHTTPDialerTimeout(
fmt.Sprintf(
"%s:%s@%s",
proxy.Username, proxy.Password, parsedProxyURL.Host,
),
timeout,
)
} else {
dialer = fasthttpproxy.FasthttpHTTPDialerTimeout(
parsedProxyURL.Host,
timeout,
)
}
} else {
return nil, err
}
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,75 +0,0 @@
package requests
import (
"context"
"fmt"
"sync"
"time"
"github.com/jedib0t/go-pretty/v6/progress"
"github.com/valyala/fasthttp"
)
// 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():
fmt.Printf("\r")
dodosTracker.MarkAsErrored()
time.Sleep(time.Millisecond * 300)
pw.Stop()
return
case value := <-increase:
dodosTracker.Increment(value)
}
}
}
// checkConnection checks the internet connection by making requests to different websites.
// It returns true if the connection is successful, otherwise false.
func checkConnection(ctx context.Context) bool {
ch := make(chan bool)
go func() {
_, _, err := fasthttp.Get(nil, "https://www.google.com")
if err != nil {
_, _, err = fasthttp.Get(nil, "https://www.bing.com")
if err != nil {
_, _, err = fasthttp.Get(nil, "https://www.yahoo.com")
ch <- err == nil
}
ch <- true
}
ch <- true
}()
select {
case <-ctx.Done():
return false
case res := <-ch:
return res
}
}

View File

@@ -1,248 +0,0 @@
package requests
import (
"context"
"math/rand"
"net/url"
"time"
"github.com/aykhans/dodo/config"
customerrors "github.com/aykhans/dodo/custom_errors"
"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, customerrors.ErrTimeout
case <-ctx.Done():
return nil, customerrors.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.Headers,
requestConfig.Cookies,
requestConfig.Params,
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,
Headers map[string][]string,
Cookies map[string][]string,
Params map[string][]string,
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)
}
getHeaders := getKeyValueSetFunc(Headers, localRand)
getCookies := getKeyValueSetFunc(Cookies, localRand)
getParams := getKeyValueSetFunc(Params, localRand)
return func() *fasthttp.Request {
return newFasthttpRequest(
URL,
getHeaders(),
getCookies(),
getParams(),
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,
Headers map[string]string,
Cookies map[string]string,
Params map[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.Set("Host", URL.Host)
setRequestHeaders(request, Headers)
setRequestCookies(request, Cookies)
setRequestParams(request, Params)
setRequestMethod(request, Method)
setRequestBody(request, Body)
if URL.Scheme == "https" {
request.URI().SetScheme("https")
}
return request
}
// setRequestHeaders sets the headers of the given request with the provided key-value pairs.
func setRequestHeaders(req *fasthttp.Request, headers map[string]string) {
req.Header.Set("User-Agent", config.DefaultUserAgent)
for key, value := range headers {
req.Header.Set(key, value)
}
}
// setRequestCookies sets the cookies in the given request.
func setRequestCookies(req *fasthttp.Request, cookies map[string]string) {
for key, value := range cookies {
req.Header.SetCookie(key, value)
}
}
// setRequestParams sets the query parameters of the given request based on the provided map of key-value pairs.
func setRequestParams(req *fasthttp.Request, params map[string]string) {
urlParams := url.Values{}
for key, value := range params {
urlParams.Add(key, value)
}
req.URI().SetQueryString(urlParams.Encode())
}
// 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))
}
// getKeyValueSetFunc generates a function that returns a map of key-value pairs based on the provided key-value set.
// The generated function will either return fixed values or random values depending on the input.
//
// Returns:
// - A function that returns a map of key-value pairs. If the input map contains multiple values for a key,
// the returned function will generate random values for that key. If the input map contains a single value
// for a key, the returned function will always return that value. If the input map is empty for a key,
// the returned function will generate an empty string for that key.
func getKeyValueSetFunc[
KeyValueSet map[string][]string,
KeyValue map[string]string,
](keyValueSet KeyValueSet, localRand *rand.Rand) func() KeyValue {
getKeyValueSlice := []map[string]func() string{}
isRandom := false
for key, values := range keyValueSet {
valuesLen := len(values)
// if values is empty, return a function that generates empty string
// if values has only one element, return a function that generates that element
// if values has more than one element, return a function that generates a random element
getKeyValue := func() string { return "" }
if valuesLen == 1 {
getKeyValue = func() string { return values[0] }
} else if valuesLen > 1 {
getKeyValue = utils.RandomValueCycle(values, localRand)
isRandom = true
}
getKeyValueSlice = append(
getKeyValueSlice,
map[string]func() string{key: getKeyValue},
)
}
// if isRandom is true, return a function that generates random values,
// otherwise return a function that generates fixed values to avoid unnecessary random number generation
if isRandom {
return func() KeyValue {
keyValues := make(KeyValue, len(getKeyValueSlice))
for _, keyValue := range getKeyValueSlice {
for key, value := range keyValue {
keyValues[key] = value()
}
}
return keyValues
}
} else {
keyValues := make(KeyValue, len(getKeyValueSlice))
for _, keyValue := range getKeyValueSlice {
for key, value := range keyValue {
keyValues[key] = value()
}
}
return func() KeyValue { 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]Durations)
var allDurations 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,160 +0,0 @@
package requests
import (
"context"
"strconv"
"sync"
"time"
"github.com/aykhans/dodo/config"
customerrors "github.com/aykhans/dodo/custom_errors"
"github.com/aykhans/dodo/utils"
"github.com/valyala/fasthttp"
)
// Run executes the main logic for processing requests based on the provided configuration.
// It first checks for an internet connection with a timeout context. If no connection is found,
// it returns an error. Then, 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.
//
// Returns:
// - Responses: A collection of responses from the executed requests.
// - error: An error if the operation fails, such as no internet connection or an interrupt.
func Run(ctx context.Context, requestConfig *config.RequestConfig) (Responses, error) {
checkConnectionCtx, checkConnectionCtxCancel := context.WithTimeout(ctx, 8*time.Second)
if !checkConnection(checkConnectionCtx) {
checkConnectionCtxCancel()
return nil, customerrors.ErrNoInternet
}
checkConnectionCtxCancel()
clients := getClients(
ctx,
requestConfig.Timeout,
requestConfig.Proxies,
requestConfig.GetValidDodosCountForProxies(),
requestConfig.GetMaxConns(fasthttp.DefaultMaxConnsPerHost),
requestConfig.Yes,
requestConfig.NoProxyCheck,
requestConfig.URL,
)
if clients == nil {
return nil, customerrors.ErrInterrupt
}
responses := releaseDodos(ctx, requestConfig, clients)
if ctx.Err() != nil && len(responses) == 0 {
return nil, customerrors.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)
requestCount uint = requestConfig.RequestCount
responses = make([][]*Response, dodosCount)
increase = make(chan int64, requestCount)
)
wg.Add(dodosCountInt)
streamWG.Add(1)
streamCtx, streamCtxCancel := context.WithCancel(context.Background())
go streamProgress(streamCtx, &streamWG, int64(requestCount), "Dodos Working🔥", increase)
for i := range dodosCount {
if i+1 == dodosCount {
requestCountPerDodo = requestCount - (i * requestCount / dodosCount)
} else {
requestCountPerDodo = ((i + 1) * requestCount / dodosCount) -
(i * 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 == customerrors.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,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,89 +0,0 @@
package types
import (
"encoding/json"
"errors"
)
type NonNilT interface {
~int | ~float64 | ~string | ~bool
}
type Option[T NonNilT] interface {
IsNone() bool
ValueOrErr() (T, error)
ValueOr(def T) T
ValueOrPanic() T
SetValue(value T)
SetNone()
UnmarshalJSON(data []byte) error
}
// Don't call this struct directly, use NewOption[T] or NewNoneOption[T] instead.
type option[T NonNilT] struct {
// value holds the actual value of the Option if it is not None.
value T
// none indicates whether the Option is None (i.e., has no value).
none bool
}
func (o *option[T]) IsNone() bool {
return o.none
}
// If the Option is None, it will return zero value of the type and an error.
func (o *option[T]) ValueOrErr() (T, error) {
if o.IsNone() {
return o.value, errors.New("Option is None")
}
return o.value, nil
}
// If the Option is None, it will return the default value.
func (o *option[T]) ValueOr(def T) T {
if o.IsNone() {
return def
}
return o.value
}
// If the Option is None, it will panic.
func (o *option[T]) ValueOrPanic() T {
if o.IsNone() {
panic("Option is None")
}
return o.value
}
func (o *option[T]) SetValue(value T) {
o.value = value
o.none = false
}
func (o *option[T]) SetNone() {
var zeroValue T
o.value = zeroValue
o.none = true
}
func (o *option[T]) UnmarshalJSON(data []byte) error {
if string(data) == "null" || len(data) == 0 {
o.SetNone()
return nil
}
if err := json.Unmarshal(data, &o.value); err != nil {
o.SetNone()
return err
}
o.none = false
return nil
}
func NewOption[T NonNilT](value T) *option[T] {
return &option[T]{value: value}
}
func NewNoneOption[T NonNilT]() *option[T] {
return &option[T]{none: true}
}

View File

@@ -1,85 +0,0 @@
package utils
import (
"encoding/json"
"fmt"
"reflect"
)
type TruncatedMarshaller struct {
Value interface{}
MaxItems int
}
func (t TruncatedMarshaller) MarshalJSON() ([]byte, error) {
val := reflect.ValueOf(t.Value)
if val.Kind() != reflect.Slice && val.Kind() != reflect.Array {
return json.Marshal(t.Value)
}
if val.Len() == 0 {
return []byte("[]"), nil
}
length := val.Len()
if length <= t.MaxItems {
return json.Marshal(t.Value)
}
truncated := make([]interface{}, t.MaxItems+1)
for i := 0; i < t.MaxItems; i++ {
truncated[i] = val.Index(i).Interface()
}
remaining := length - t.MaxItems
truncated[t.MaxItems] = fmt.Sprintf("+%d", remaining)
return json.Marshal(truncated)
}
func PrettyJSONMarshal(v interface{}, maxItems int, prefix, indent string) []byte {
truncated := processValue(v, maxItems)
d, _ := json.MarshalIndent(truncated, prefix, indent)
return d
}
func processValue(v interface{}, maxItems int) interface{} {
val := reflect.ValueOf(v)
switch val.Kind() {
case reflect.Map:
newMap := make(map[string]interface{})
iter := val.MapRange()
for iter.Next() {
k := iter.Key().String()
newMap[k] = processValue(iter.Value().Interface(), maxItems)
}
return newMap
case reflect.Slice, reflect.Array:
return TruncatedMarshaller{Value: v, MaxItems: maxItems}
case reflect.Struct:
newMap := make(map[string]interface{})
t := val.Type()
for i := 0; i < t.NumField(); i++ {
field := t.Field(i)
if field.IsExported() {
jsonTag := field.Tag.Get("json")
if jsonTag == "-" {
continue
}
fieldName := field.Name
if jsonTag != "" {
fieldName = jsonTag
}
newMap[fieldName] = processValue(val.Field(i).Interface(), maxItems)
}
}
return newMap
default:
return v
}
}

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/fatih/color"
)
func PrintErr(err error) {
color.New(color.FgRed).Fprintln(os.Stderr, 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,50 +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
}
func Contains[T comparable](slice []T, item T) bool {
for _, i := range slice {
if i == item {
return true
}
}
return false
}
// 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
}

View File

@@ -1,59 +0,0 @@
package validation
import (
"reflect"
"strings"
"github.com/go-playground/validator/v10"
"golang.org/x/net/http/httpguts"
)
// net/http/request.go/isNotToken
func isNotToken(r rune) bool {
return !httpguts.IsTokenRune(r)
}
func NewValidator() *validator.Validate {
validation := validator.New()
validation.RegisterTagNameFunc(func(fld reflect.StructField) string {
if fld.Tag.Get("validation_name") != "" {
return fld.Tag.Get("validation_name")
} else {
return fld.Tag.Get("json")
}
})
validation.RegisterValidation(
"http_method",
func(fl validator.FieldLevel) bool {
method := fl.Field().String()
// net/http/request.go/validMethod
return len(method) > 0 && strings.IndexFunc(method, isNotToken) == -1
},
)
validation.RegisterValidation(
"string_bool",
func(fl validator.FieldLevel) bool {
s := fl.Field().String()
return s == "true" || s == "false" || s == ""
},
)
validation.RegisterValidation(
"proxy_url",
func(fl validator.FieldLevel) bool {
url := fl.Field().String()
if url == "" {
return false
}
if err := validation.Var(url, "url"); err != nil {
return false
}
if !(url[:7] == "http://" ||
url[:9] == "socks5://" ||
url[:10] == "socks5h://") {
return false
}
return true
},
)
return validation
}