156 Commits

Author SHA1 Message Date
a512f3605d Merge pull request #163 from aykhans/dependabot/go_modules/golang.org/x/net-0.49.0
Bump golang.org/x/net from 0.48.0 to 0.49.0
2026-01-13 11:24:54 +04:00
dependabot[bot]
635c33008b Bump golang.org/x/net from 0.48.0 to 0.49.0
Bumps [golang.org/x/net](https://github.com/golang/net) from 0.48.0 to 0.49.0.
- [Commits](https://github.com/golang/net/compare/v0.48.0...v0.49.0)

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

Signed-off-by: dependabot[bot] <support@github.com>
2026-01-13 04:22:03 +00:00
3f2147ec6c Merge pull request #162 from aykhans/feat/url-path-templating
Add URL path templating support with validation and documentation
2026-01-11 21:42:37 +04:00
92d0c5e003 Revise feature support table in README
Updated the supported and not supported features table for clarity.
2026-01-11 21:40:38 +04:00
27bc8f2e96 Add URL path templating support with validation and documentation 2026-01-11 19:05:58 +04:00
46c6fa9912 Merge pull request #161 from aykhans/fix/docker-terminal-colors
Add docker-build task and fix terminal colors in container
2026-01-10 18:16:05 +04:00
a3d311009f Add docker-build task and fix terminal colors in container 2026-01-10 18:14:33 +04:00
710f4c6cb5 Merge pull request #157 from aykhans/v1.0.0
v1.0.0: here we go again
2026-01-10 17:23:40 +04:00
2d7ba34cb8 v1.0.0: here we go again 2026-01-10 17:06:25 +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
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
🔨 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
⬆️ 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
🔧 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
 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
🔨 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
📚 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
 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
🐛 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
💄 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
 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
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
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
a9021bd1a4 Merge pull request #80 from aykhans/refactor/color-utils
🔨 Replace color utils with 'github.com/fatih/color'
2025-03-02 20:22:43 +04:00
48c2dc7935 🔖 bump version to 0.5.7 2025-03-02 20:20:06 +04:00
4cb0540824 🔨 Replace color utils with 'github.com/fatih/color' 2025-03-02 20:18:11 +04:00
a01bf19986 Merge pull request #79 from aykhans/bump/go-version
🔖 Bump go version to 1.24
2025-03-01 17:20:56 +04:00
74dcecc8b1 🔖 Bump go version to 1.24 2025-03-01 17:18:59 +04:00
00afca7139 Merge pull request #77 from aykhans/feat/add-makefile
🔧 Added Makefile
2025-02-28 03:43:33 +04:00
1589fefeb8 🔧 Added Makefile 2025-02-28 03:41:33 +04:00
bbc43bbaac Merge pull request #75 from aykhans/ci/add-golangci-lint
Added golangci lint to CI
2025-02-28 03:27:14 +04:00
89fcf5f174 Merge branch 'ci/add-golangci-lint' of https://github.com/aykhans/dodo into ci/add-golangci-lint 2025-02-28 03:25:29 +04:00
a58f734a55 Merge branch 'main' into ci/add-golangci-lint 2025-02-28 03:23:06 +04:00
efd5176ab9 Merge pull request #76 from aykhans/refactor/general-refactoring
🔨 Remove unnecessary conversions
2025-02-28 03:21:26 +04:00
f0adcaf328 👷 Update golangci-lint.yml 2025-02-28 03:18:50 +04:00
6314cf7724 🐳 '.golangci.yml' added to .dockerignore 2025-02-28 03:16:03 +04:00
140e570b85 👷 Added golangci-lint 2025-02-28 03:15:33 +04:00
83c5788e54 🔨 Remove unnecessary conversions 2025-02-28 02:16:04 +04:00
ca74092615 Merge pull request #74 from aykhans/dependabot/go_modules/github.com/valyala/fasthttp-1.59.0
Bump github.com/valyala/fasthttp from 1.58.0 to 1.59.0
2025-02-20 20:41:41 +04:00
dependabot[bot]
004b10ea3c Bump github.com/valyala/fasthttp from 1.58.0 to 1.59.0
Bumps [github.com/valyala/fasthttp](https://github.com/valyala/fasthttp) from 1.58.0 to 1.59.0.
- [Release notes](https://github.com/valyala/fasthttp/releases)
- [Commits](https://github.com/valyala/fasthttp/compare/v1.58.0...v1.59.0)

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

Signed-off-by: dependabot[bot] <support@github.com>
2025-02-20 00:53:09 +00:00
4c9ceb1c4b Merge pull request #73 from aykhans/dependabot/go_modules/github.com/go-playground/validator/v10-10.25.0
Bump github.com/go-playground/validator/v10 from 10.24.0 to 10.25.0
2025-02-17 15:17:27 +04:00
dependabot[bot]
6f3df7c45b Bump github.com/go-playground/validator/v10 from 10.24.0 to 10.25.0
Bumps [github.com/go-playground/validator/v10](https://github.com/go-playground/validator) from 10.24.0 to 10.25.0.
- [Release notes](https://github.com/go-playground/validator/releases)
- [Commits](https://github.com/go-playground/validator/compare/v10.24.0...v10.25.0)

---
updated-dependencies:
- dependency-name: github.com/go-playground/validator/v10
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-02-17 00:34:54 +00:00
8cd495055d Merge pull request #71 from aykhans/dependabot/go_modules/github.com/jedib0t/go-pretty/v6-6.6.6
Bump github.com/jedib0t/go-pretty/v6 from 6.6.5 to 6.6.6
2025-02-11 19:45:45 +04:00
dependabot[bot]
391a5bc6ec Bump github.com/jedib0t/go-pretty/v6 from 6.6.5 to 6.6.6
Bumps [github.com/jedib0t/go-pretty/v6](https://github.com/jedib0t/go-pretty) from 6.6.5 to 6.6.6.
- [Release notes](https://github.com/jedib0t/go-pretty/releases)
- [Commits](https://github.com/jedib0t/go-pretty/compare/v6.6.5...v6.6.6)

---
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-02-11 15:44:47 +00:00
a69e248f8c Merge pull request #72 from aykhans/dependabot/go_modules/golang.org/x/net-0.35.0
Bump golang.org/x/net from 0.34.0 to 0.35.0
2025-02-11 19:43:31 +04:00
dependabot[bot]
2634ca110c Bump golang.org/x/net from 0.34.0 to 0.35.0
Bumps [golang.org/x/net](https://github.com/golang/net) from 0.34.0 to 0.35.0.
- [Commits](https://github.com/golang/net/compare/v0.34.0...v0.35.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-02-11 00:48:51 +00:00
b1612598c4 Merge pull request #70 from aykhans/docs/update
Docs/update
2025-01-21 20:53:30 +04:00
ba79304b04 📚 Update README.md 2025-01-21 20:52:25 +04:00
015cb15053 📚 Update docs 2025-01-21 20:50:10 +04:00
3dc002188e 📚 Update docs 2025-01-21 20:47:01 +04:00
769c04685a Merge pull request #69 from aykhans/fix/connections-bottleneck
🐛 Remove 'CloseIdleConnections' from 'Send' function
2025-01-15 21:44:02 +04:00
e43378a9a4 🔖 bump version to 0.5.6 2025-01-15 21:43:46 +04:00
e29e4f1bc6 🐛 Remove 'CloseIdleConnections' from 'Send' function 2025-01-14 20:43:45 +04:00
7d2168a014 Merge pull request #67 from aykhans/dependabot/go_modules/github.com/go-playground/validator/v10-10.24.0
Bump github.com/go-playground/validator/v10 from 10.23.0 to 10.24.0
2025-01-14 16:29:25 +04:00
dependabot[bot]
ba53e6b7f7 Bump github.com/go-playground/validator/v10 from 10.23.0 to 10.24.0
Bumps [github.com/go-playground/validator/v10](https://github.com/go-playground/validator) from 10.23.0 to 10.24.0.
- [Release notes](https://github.com/go-playground/validator/releases)
- [Commits](https://github.com/go-playground/validator/compare/v10.23.0...v10.24.0)

---
updated-dependencies:
- dependency-name: github.com/go-playground/validator/v10
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-01-14 00:26:46 +00:00
58964e1098 Merge pull request #66 from aykhans/dependabot/go_modules/golang.org/x/net-0.34.0
Bump golang.org/x/net from 0.33.0 to 0.34.0
2025-01-08 15:27:52 +04:00
dependabot[bot]
d4bf7358ff Bump golang.org/x/net from 0.33.0 to 0.34.0
Bumps [golang.org/x/net](https://github.com/golang/net) from 0.33.0 to 0.34.0.
- [Commits](https://github.com/golang/net/compare/v0.33.0...v0.34.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-01-07 00:38:29 +00:00
779d5e9b18 Merge pull request #65 from aykhans/refactor/rename-cli-flags
🔨 Rename CLI flags
2025-01-03 13:27:24 +04:00
6a79d0b1d7 🔖 bump version to 0.5.502 2025-01-03 13:26:34 +04:00
1e53b8a7fb 📚 Update docs 2025-01-03 13:26:00 +04:00
0a8dbec739 🔨 Renama JSON parameters 2025-01-03 13:17:20 +04:00
3762890914 🔨 Rename CLI flags 2025-01-03 13:14:25 +04:00
ca6b3d4eb2 Merge pull request #64 from aykhans/bump/version
🔖 Bump version to 0.5.501
2024-12-25 02:08:59 +04:00
1ee06aacc3 🔖 Bump version to 0.5.501 2024-12-25 02:08:47 +04:00
3d5834a6a6 Merge pull request #63 from aykhans/fix/empty-slice
🐛 Return empty brackets instead of null when slice length is 0
2024-12-25 01:24:28 +04:00
f1521cbb74 💄 Update config table 2024-12-23 18:58:22 +04:00
40f8a1cc37 🐛 Return empty brackets instead of null when slice length is 0 2024-12-22 23:15:10 +04:00
57 changed files with 6975 additions and 2410 deletions

View File

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

2
.github/FUNDING.yml vendored Normal file
View File

@@ -0,0 +1,2 @@
buy_me_a_coffee: aykhan
custom: https://commerce.coinbase.com/checkout/0f33d2fb-54a6-44f5-8783-006ebf70d1a0

23
.github/workflows/lint.yaml vendored Normal file
View File

@@ -0,0 +1,23 @@
name: golangci-lint
on:
push:
branches:
- main
pull_request:
permissions:
contents: read
jobs:
golangci:
name: lint
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- uses: actions/setup-go@v6
with:
go-version: 1.25.5
- name: golangci-lint
uses: golangci/golangci-lint-action@v9
with:
version: v2.7.2

View File

@@ -1,86 +0,0 @@
name: publish-docker-image
on:
push:
tags:
# Match stable and pre versions, such as 'v1.0.0', 'v0.23.0-a', 'v0.23.0-a.2', 'v0.23.0-b', 'v0.23.0-b.3'
- "v*.*.*"
- "v*.*.*-a"
- "v*.*.*-a.*"
- "v*.*.*-b"
- "v*.*.*-b.*"
jobs:
build-and-push-stable-image:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
- uses: actions/checkout@v4
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Extract build args
# Extract version number and check if it's an pre version
run: |
if [[ "${GITHUB_REF_NAME}" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
echo "PRE_RELEASE=false" >> $GITHUB_ENV
else
echo "PRE_RELEASE=true" >> $GITHUB_ENV
fi
echo "VERSION=${GITHUB_REF_NAME#v}" >> $GITHUB_ENV
- name: Login to Docker Hub
uses: docker/login-action@v3
with:
username: aykhans
password: ${{ secrets.DOCKER_TOKEN }}
- name: Set up Docker Buildx
id: buildx
uses: docker/setup-buildx-action@v3
with:
install: true
version: v0.9.1
# Metadata for stable versions
- name: Docker meta for stable
id: meta-stable
if: env.PRE_RELEASE == 'false'
uses: docker/metadata-action@v5
with:
images: |
aykhans/dodo
tags: |
type=semver,pattern={{version}},value=${{ env.VERSION }}
type=raw,value=stable
flavor: |
latest=true
labels: |
org.opencontainers.image.version=${{ env.VERSION }}
# Metadata for pre versions
- name: Docker meta for pre
id: meta-pre
if: env.PRE_RELEASE == 'true'
uses: docker/metadata-action@v5
with:
images: |
aykhans/dodo
tags: |
type=raw,value=${{ env.VERSION }}
labels: |
org.opencontainers.image.version=${{ env.VERSION }}
- name: Build and Push
id: docker_build
uses: docker/build-push-action@v6
with:
context: ./
file: ./Dockerfile
platforms: linux/amd64,linux/arm64
push: true
tags: ${{ steps.meta-stable.outputs.tags || steps.meta-pre.outputs.tags }}
labels: ${{ steps.meta-stable.outputs.labels || steps.meta-pre.outputs.labels }}

98
.github/workflows/release.yaml vendored Normal file
View File

@@ -0,0 +1,98 @@
name: Build and Release
on:
release:
types: [created]
workflow_dispatch:
inputs:
tag:
description: "Release tag (e.g., v1.0.0)"
required: true
build_binaries:
description: "Build and upload binaries"
type: boolean
default: true
build_docker:
description: "Build and push Docker image"
type: boolean
default: true
permissions:
contents: write
jobs:
build:
name: Build binaries
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v6
with:
ref: ${{ inputs.tag || github.ref }}
- name: Set build metadata
run: |
echo "VERSION=$(git describe --tags --always)" >> $GITHUB_ENV
echo "GIT_COMMIT=$(git rev-parse HEAD)" >> $GITHUB_ENV
echo "GO_VERSION=1.25.5" >> $GITHUB_ENV
- name: Set up Go
if: github.event_name == 'release' || inputs.build_binaries
uses: actions/setup-go@v6
with:
go-version: ${{ env.GO_VERSION }}
cache: true
- name: Build binaries
if: github.event_name == 'release' || inputs.build_binaries
run: |
LDFLAGS="-X 'go.aykhans.me/sarin/internal/version.Version=${{ env.VERSION }}' \
-X 'go.aykhans.me/sarin/internal/version.GitCommit=${{ env.GIT_COMMIT }}' \
-X 'go.aykhans.me/sarin/internal/version.BuildDate=$(date -u +%Y-%m-%dT%H:%M:%SZ)' \
-X 'go.aykhans.me/sarin/internal/version.GoVersion=$(go version)' \
-s -w"
CGO_ENABLED=0 GOEXPERIMENT=greenteagc GOOS=linux GOARCH=amd64 go build -ldflags "$LDFLAGS" -o ./sarin-linux-amd64 ./cmd/cli/main.go
CGO_ENABLED=0 GOEXPERIMENT=greenteagc GOOS=linux GOARCH=arm64 go build -ldflags "$LDFLAGS" -o ./sarin-linux-arm64 ./cmd/cli/main.go
CGO_ENABLED=0 GOEXPERIMENT=greenteagc GOOS=darwin GOARCH=amd64 go build -ldflags "$LDFLAGS" -o ./sarin-darwin-amd64 ./cmd/cli/main.go
CGO_ENABLED=0 GOEXPERIMENT=greenteagc GOOS=darwin GOARCH=arm64 go build -ldflags "$LDFLAGS" -o ./sarin-darwin-arm64 ./cmd/cli/main.go
CGO_ENABLED=0 GOEXPERIMENT=greenteagc GOOS=windows GOARCH=amd64 go build -ldflags "$LDFLAGS" -o ./sarin-windows-amd64.exe ./cmd/cli/main.go
CGO_ENABLED=0 GOEXPERIMENT=greenteagc GOOS=windows GOARCH=arm64 go build -ldflags "$LDFLAGS" -o ./sarin-windows-arm64.exe ./cmd/cli/main.go
- name: Upload Release Assets
if: github.event_name == 'release' || inputs.build_binaries
uses: softprops/action-gh-release@v2
with:
tag_name: ${{ inputs.tag || github.ref_name }}
files: ./sarin-*
- name: Set up QEMU
if: github.event_name == 'release' || inputs.build_docker
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
if: github.event_name == 'release' || inputs.build_docker
uses: docker/setup-buildx-action@v3
- name: Login to Docker Hub
if: github.event_name == 'release' || inputs.build_docker
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Build and push Docker image
if: github.event_name == 'release' || inputs.build_docker
uses: docker/build-push-action@v6
with:
context: .
platforms: linux/amd64,linux/arm64
push: true
build-args: |
VERSION=${{ env.VERSION }}
GIT_COMMIT=${{ env.GIT_COMMIT }}
GO_VERSION=${{ env.GO_VERSION }}
tags: |
${{ secrets.DOCKERHUB_USERNAME }}/sarin:${{ env.VERSION }}
${{ secrets.DOCKERHUB_USERNAME }}/sarin:latest

3
.gitignore vendored
View File

@@ -1,2 +1 @@
dodo
binaries/
bin/*

101
.golangci.yaml Normal file
View File

@@ -0,0 +1,101 @@
version: "2"
run:
go: "1.25"
concurrency: 12
linters:
default: none
enable:
- asciicheck
- errcheck
- govet
- ineffassign
- misspell
- nakedret
- nolintlint
- prealloc
- reassign
- staticcheck
- unconvert
- unused
- whitespace
- bidichk
- bodyclose
- containedctx
- contextcheck
- copyloopvar
- embeddedstructfieldcheck
- errorlint
- exptostd
- fatcontext
- forcetypeassert
- funcorder
- gocheckcompilerdirectives
- gocritic
- gomoddirectives
- gosec
- gosmopolitan
- grouper
- importas
- inamedparam
- intrange
- loggercheck
- mirror
- musttag
- perfsprint
- predeclared
- tagalign
- tagliatelle
- testifylint
- thelper
- tparallel
- unparam
- usestdlibvars
- usetesting
- wastedassign
settings:
staticcheck:
checks:
- "all"
- "-S1002"
- "-ST1000"
varnamelen:
ignore-decls:
- w http.ResponseWriter
- wg sync.WaitGroup
- wg *sync.WaitGroup
exclusions:
rules:
- path: _test\.go$
linters:
- errorlint
- forcetypeassert
- perfsprint
- errcheck
- gosec
- path: _test\.go$
linters:
- staticcheck
text: "SA5011"
formatters:
enable:
- gofmt
settings:
gofmt:
# Simplify code: gofmt with `-s` option.
# Default: true
simplify: false
# Apply the rewrite rules to the source before reformatting.
# https://pkg.go.dev/cmd/gofmt
# Default: []
rewrite-rules:
- pattern: "interface{}"
replacement: "any"
- pattern: "a[b:len(a)]"
replacement: "a[b:]"

View File

@@ -1,19 +1,32 @@
FROM golang:1.23.2-alpine AS builder
ARG GO_VERSION=1.25.5
WORKDIR /dodo
FROM docker.io/library/golang:${GO_VERSION}-alpine AS builder
COPY go.mod go.sum ./
RUN go mod download
COPY . .
ARG VERSION=unknown
ARG GIT_COMMIT=unknown
RUN go build -ldflags "-s -w" -o dodo
RUN echo "{}" > config.json
WORKDIR /src
RUN --mount=type=bind,source=./go.mod,target=./go.mod \
--mount=type=bind,source=./go.sum,target=./go.sum \
go mod download
RUN --mount=type=bind,source=./,target=./ \
CGO_ENABLED=0 GOEXPERIMENT=greenteagc go build \
-ldflags "-X 'go.aykhans.me/sarin/internal/version.Version=${VERSION}' \
-X 'go.aykhans.me/sarin/internal/version.GitCommit=${GIT_COMMIT}' \
-X 'go.aykhans.me/sarin/internal/version.BuildDate=$(date -u +%Y-%m-%dT%H:%M:%SZ)' \
-X 'go.aykhans.me/sarin/internal/version.GoVersion=$(go version)' \
-s -w" \
-o /sarin ./cmd/cli/main.go
FROM gcr.io/distroless/static-debian12:latest
WORKDIR /dodo
ENV TERM=xterm-256color
ENV COLORTERM=truecolor
COPY --from=builder /dodo/dodo /dodo/dodo
COPY --from=builder /dodo/config.json /dodo/config.json
WORKDIR /
ENTRYPOINT ["./dodo", "-c", "/dodo/config.json"]
COPY --from=builder /sarin /sarin
ENTRYPOINT ["./sarin"]

169
README.md
View File

@@ -1,119 +1,118 @@
<h1 align="center">Dodo is a simple and easy-to-use HTTP benchmarking tool.</h1>
<div align="center">
## Sarin is a high-performance HTTP load testing tool built with Go and fasthttp.
</div>
![Demo](docs/static/demo.gif)
<p align="center">
<img width="30%" height="30%" src="https://ftp.aykhans.me/web/client/pubshares/hB6VSdCnBCr8gFPeiMuCji/browse?path=%2Fdodo.png">
<a href="#installation">Install</a> •
<a href="#quick-start">Quick Start</a> •
<a href="docs/examples.md">Examples</a> •
<a href="docs/configuration.md">Configuration</a> •
<a href="docs/templating.md">Templating</a>
</p>
## Overview
Sarin is designed for efficient HTTP load testing with minimal resource consumption. It prioritizes simplicity—features like templating add zero overhead when unused.
| ✅ Supported | ❌ Not Supported |
| ---------------------------------------------------------- | --------------------------------- |
| High-performance with low memory footprint | Detailed response body analysis |
| Long-running duration/count based tests | Extensive response statistics |
| Dynamic requests via 320+ template functions | Web UI or complex TUI |
| Multiple proxy protocols<br>(HTTP, HTTPS, SOCKS5, SOCKS5H) | Scripting or multi-step scenarios |
| Flexible config (CLI, ENV, YAML) | HTTP/2, HTTP/3, WebSocket, gRPC |
## 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:**
### Docker (Recommended)
```sh
git clone https://github.com/aykhans/dodo.git
docker pull aykhans/sarin:latest
```
2. **Navigate to the project directory:**
With a local config file:
```sh
cd dodo
docker run --rm -it -v /path/to/config.yaml:/config.yaml aykhans/sarin -f /config.yaml
```
3. **Build the project:**
With a remote config file:
```sh
go build -ldflags "-s -w" -o dodo
docker run --rm -it aykhans/sarin -f https://example.com/config.yaml
```
This will generate an executable named `dodo` in the project directory.
### Pre-built Binaries
## 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.
Download the latest binaries from the [releases](https://github.com/aykhans/sarin/releases) page.
### 1. CLI
Send 1000 GET requests to https://example.com with 10 parallel dodos (threads) and a timeout of 2000 milliseconds:
### Building from Source
Requires [Go 1.25+](https://golang.org/dl/).
```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
git clone https://github.com/aykhans/sarin.git && cd sarin
CGO_ENABLED=0 GOEXPERIMENT=greenteagc go build \
-ldflags "-X 'go.aykhans.me/sarin/internal/version.Version=dev' \
-X 'go.aykhans.me/sarin/internal/version.GitCommit=$(git rev-parse HEAD)' \
-X 'go.aykhans.me/sarin/internal/version.BuildDate=$(date -u +%Y-%m-%dT%H:%M:%SZ)' \
-X 'go.aykhans.me/sarin/internal/version.GoVersion=$(go version)' \
-s -w" \
-o sarin ./cmd/cli/main.go
```
### 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:
```json
{
"method": "GET",
"url": "https://example.com",
"no_proxy_check": false,
"timeout": 2000,
"dodos_count": 10,
"request_count": 1000,
"params": {},
"headers": {},
"cookies": {},
"body": [],
"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:
## Quick Start
Send 10,000 GET requests with 50 concurrent connections and a random User-Agent for each request:
```sh
dodo -c /path/config.json
```
With Docker:
```sh
docker run --rm -i -v ./path/config.json:/dodo/config.json aykhans/dodo
sarin -U http://example.com -r 10_000 -c 50 -H "User-Agent: {{ fakeit_UserAgent }}"
```
### 3. Both (CLI & JSON)
Override the config file arguments with CLI arguments:
Run a 5-minute duration-based test:
```sh
dodo -c /path/config.json -u https://example.com -m GET -d 10 -r 1000 -t 2000
sarin -U http://example.com -d 5m -c 100
```
With Docker:
Use a YAML config file:
```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
sarin -f config.yaml
```
## 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.
For more usage examples, see the **[Examples Guide](docs/examples.md)**.
| 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 |
| Request count | request_count | --request-count | -r | Integer | Total number of requests to send | 1000 |
| Dodos count (Threads) | dodos_count | --dodos-count | -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) | - |
## Configuration
Sarin supports environment variables, CLI flags, and YAML files. When the same option is specified in multiple sources, the following priority order applies:
```
YAML (Highest) > CLI Flags > Environment Variables (Lowest)
```
For detailed documentation on all configuration options (URL, method, timeout, concurrency, headers, cookies, proxy, etc.), see the **[Configuration Guide](docs/configuration.md)**.
## Templating
Sarin supports Go templates in URL paths, methods, bodies, headers, params, cookies, and values. Use the 320+ built-in functions to generate dynamic data for each request.
**Example:**
```sh
sarin -U "http://example.com/users/{{ fakeit_UUID }}" -r 1000 -c 10 \
-V "RequestID={{ fakeit_UUID }}" \
-H "X-Request-ID: {{ .Values.RequestID }}" \
-B '{"request_id": "{{ .Values.RequestID }}"}'
```
For the complete templating guide and functions reference, see the **[Templating Guide](docs/templating.md)**.
## License
This project is licensed under the MIT License. See the [LICENSE](LICENSE) file for details.

91
Taskfile.yaml Normal file
View File

@@ -0,0 +1,91 @@
# https://taskfile.dev
version: "3"
vars:
BIN_DIR: ./bin
GOLANGCI_LINT_VERSION: v2.7.2
GOLANGCI: "{{.BIN_DIR}}/golangci-lint-{{.GOLANGCI_LINT_VERSION}}"
tasks:
ftl:
desc: Run fmt, tidy, and lint.
cmds:
- task: fmt
- task: tidy
- task: lint
fmt:
desc: Run linters
deps:
- install-golangci-lint
cmds:
- "{{.GOLANGCI}} fmt"
tidy:
desc: Run go mod tidy.
cmds:
- go mod tidy {{.CLI_ARGS}}
lint:
desc: Run linters
deps:
- install-golangci-lint
cmds:
- "{{.GOLANGCI}} run"
test:
desc: Run Go tests.
cmds:
- go test ./... {{.CLI_ARGS}}
create-bin-dir:
desc: Create bin directory.
cmds:
- mkdir -p {{.BIN_DIR}}
build:
desc: Build the application.
deps:
- create-bin-dir
vars:
OUTPUT: '{{.OUTPUT | default (printf "%s/sarin" .BIN_DIR)}}'
cmds:
- rm -f {{.OUTPUT}}
- >-
CGO_ENABLED=0 GOEXPERIMENT=greenteagc go build
-ldflags "-X 'go.aykhans.me/sarin/internal/version.Version=$(git describe --tags --always)'
-X 'go.aykhans.me/sarin/internal/version.GitCommit=$(git rev-parse HEAD)'
-X 'go.aykhans.me/sarin/internal/version.BuildDate=$(date -u +%Y-%m-%dT%H:%M:%SZ)'
-X 'go.aykhans.me/sarin/internal/version.GoVersion=$(go version)'
-s -w"
-o {{.OUTPUT}} ./cmd/cli/main.go
install-golangci-lint:
desc: Install golangci-lint
deps:
- create-bin-dir
status:
- test -f {{.GOLANGCI}}
cmds:
- rm -f {{.GOLANGCI}}
- curl -sSfL https://raw.githubusercontent.com/golangci/golangci-lint/master/install.sh | sh -s -- -b {{.BIN_DIR}} {{.GOLANGCI_LINT_VERSION}}
- mv {{.BIN_DIR}}/golangci-lint {{.GOLANGCI}}
docker-build:
desc: Build the Docker image.
vars:
IMAGE_NAME: '{{.IMAGE_NAME | default "sarin"}}'
TAG: '{{.TAG | default "latest"}}'
GO_VERSION: '{{.GO_VERSION | default ""}}'
VERSION:
sh: git describe --tags --always
GIT_COMMIT:
sh: git rev-parse HEAD
cmds:
- >-
docker build
{{if .GO_VERSION}}--build-arg GO_VERSION={{.GO_VERSION}}{{end}}
--build-arg VERSION={{.VERSION}}
--build-arg GIT_COMMIT={{.GIT_COMMIT}}
-t {{.IMAGE_NAME}}:{{.TAG}}
.

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

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

@@ -0,0 +1,84 @@
package main
import (
"context"
"fmt"
"os"
"os/signal"
"syscall"
"go.aykhans.me/sarin/internal/config"
"go.aykhans.me/sarin/internal/sarin"
"go.aykhans.me/sarin/internal/types"
utilsErr "go.aykhans.me/utils/errors"
)
func main() {
ctx, cancel := context.WithCancel(context.Background())
go listenForTermination(func() { cancel() })
combinedConfig := config.ReadAllConfigs()
combinedConfig.SetDefaults()
if *combinedConfig.ShowConfig {
if !combinedConfig.Print() {
return
}
}
_ = utilsErr.MustHandle(combinedConfig.Validate(),
utilsErr.OnType(func(err types.FieldValidationErrors) error {
for _, fieldErr := range err.Errors {
if fieldErr.Value == "" {
fmt.Fprintln(os.Stderr,
config.StyleYellow.Render(fmt.Sprintf("[VALIDATION] Field '%s': ", fieldErr.Field))+fieldErr.Err.Error(),
)
} else {
fmt.Fprintln(os.Stderr,
config.StyleYellow.Render(fmt.Sprintf("[VALIDATION] Field '%s' (%s): ", fieldErr.Field, fieldErr.Value))+fieldErr.Err.Error(),
)
}
}
os.Exit(1)
return nil
}),
)
srn, err := sarin.NewSarin(
ctx,
combinedConfig.Methods, combinedConfig.URL, *combinedConfig.Timeout,
*combinedConfig.Concurrency, combinedConfig.Requests, combinedConfig.Duration,
*combinedConfig.Quiet, *combinedConfig.Insecure, combinedConfig.Params, combinedConfig.Headers,
combinedConfig.Cookies, combinedConfig.Bodies, combinedConfig.Proxies, combinedConfig.Values,
*combinedConfig.Output != config.ConfigOutputTypeNone,
*combinedConfig.DryRun,
)
_ = utilsErr.MustHandle(err,
utilsErr.OnType(func(err types.ProxyDialError) error {
fmt.Fprintln(os.Stderr, config.StyleRed.Render("[PROXY] ")+err.Error())
os.Exit(1)
return nil
}),
)
srn.Start(ctx)
switch *combinedConfig.Output {
case config.ConfigOutputTypeNone:
return
case config.ConfigOutputTypeJSON:
srn.GetResponses().PrintJSON()
case config.ConfigOutputTypeYAML:
srn.GetResponses().PrintYAML()
default:
srn.GetResponses().PrintTable()
}
}
func listenForTermination(do func()) {
sigChan := make(chan os.Signal, 1)
signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM)
<-sigChan
do()
}

View File

@@ -1,22 +0,0 @@
{
"method": "GET",
"url": "https://example.com",
"no_proxy_check": false,
"timeout": 10000,
"dodos_count": 1,
"request_count": 1,
"params": {},
"headers": {},
"cookies": {},
"body": [],
"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.5"
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 Count", 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, uint(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_count" validate:"gte=1"`
RequestCount uint `json:"request_count" 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"
}

347
docs/configuration.md Normal file
View File

@@ -0,0 +1,347 @@
# Configuration
Sarin supports environment variables, CLI flags, and YAML files. However, they are not exactly equivalent—YAML files have the most configuration options, followed by CLI flags, and then environment variables.
When the same option is specified in multiple sources, the following priority order applies:
```
YAML (Highest) > CLI Flags > Environment Variables (Lowest)
```
Use `-s` or `--show-config` to see the final merged configuration before sending requests.
## Properties
> **Note:** For CLI flags with `string / []string` type, the flag can be used once with a single value or multiple times to provide multiple values.
| Name | YAML | CLI | ENV | Default | Description |
| --------------------------- | ----------------------------------- | --------------------------------------------- | -------------------------------- | ------- | ---------------------------- |
| [Help](#help) | - | `-help` / `-h` | - | - | Show help message |
| [Version](#version) | - | `-version` / `-v` | - | - | Show version and build info |
| [Show Config](#show-config) | `showConfig`<br>(boolean) | `-show-config` / `-s`<br>(boolean) | `SARIN_SHOW_CONFIG`<br>(boolean) | `false` | Show merged configuration |
| [Config File](#config-file) | `configFile`<br>(string / []string) | `-config-file` / `-f`<br>(string / []string) | `SARIN_CONFIG_FILE`<br>(string) | - | Path to config file(s) |
| [URL](#url) | `url`<br>(string) | `-url` / `-U`<br>(string) | `SARIN_URL`<br>(string) | - | Target URL (HTTP/HTTPS) |
| [Method](#method) | `method`<br>(string / []string) | `-method` / `-M`<br>(string / []string) | `SARIN_METHOD`<br>(string) | `GET` | HTTP method(s) |
| [Timeout](#timeout) | `timeout`<br>(duration) | `-timeout` / `-T`<br>(duration) | `SARIN_TIMEOUT`<br>(duration) | `10s` | Request timeout |
| [Concurrency](#concurrency) | `concurrency`<br>(number) | `-concurrency` / `-c`<br>(number) | `SARIN_CONCURRENCY`<br>(number) | `1` | Number of concurrent workers |
| [Requests](#requests) | `requests`<br>(number) | `-requests` / `-r`<br>(number) | `SARIN_REQUESTS`<br>(number) | - | Total requests to send |
| [Duration](#duration) | `duration`<br>(duration) | `-duration` / `-d`<br>(duration) | `SARIN_DURATION`<br>(duration) | - | Test duration |
| [Quiet](#quiet) | `quiet`<br>(boolean) | `-quiet` / `-q`<br>(boolean) | `SARIN_QUIET`<br>(boolean) | `false` | Hide progress bar and logs |
| [Output](#output) | `output`<br>(string) | `-output` / `-o`<br>(string) | `SARIN_OUTPUT`<br>(string) | `table` | Output format for stats |
| [Dry Run](#dry-run) | `dryRun`<br>(boolean) | `-dry-run` / `-z`<br>(boolean) | `SARIN_DRY_RUN`<br>(boolean) | `false` | Generate without sending |
| [Insecure](#insecure) | `insecure`<br>(boolean) | `-insecure` / `-I`<br>(boolean) | `SARIN_INSECURE`<br>(boolean) | `false` | Skip TLS verification |
| [Body](#body) | `body`<br>(string / []string) | `-body` / `-B`<br>(string / []string) | `SARIN_BODY`<br>(string) | - | Request body |
| [Params](#params) | `params`<br>(object) | `-param` / `-P`<br>(string / []string) | `SARIN_PARAM`<br>(string) | - | URL query parameters |
| [Headers](#headers) | `headers`<br>(object) | `-header` / `-H`<br>(string / []string) | `SARIN_HEADER`<br>(string) | - | HTTP headers |
| [Cookies](#cookies) | `cookies`<br>(object) | `-cookie` / `-C`<br>(string / []string) | `SARIN_COOKIE`<br>(string) | - | HTTP cookies |
| [Proxy](#proxy) | `proxy`<br>(string / []string) | `-proxy` / `-X`<br>(string / []string) | `SARIN_PROXY`<br>(string) | - | Proxy URL(s) |
| [Values](#values) | `values`<br>(string / []string) | `-values` / `-V`<br>(string / []string) | `SARIN_VALUES`<br>(string) | - | Template values (key=value) |
---
## Help
Show help message.
## Version
Show version and build information.
## Show Config
Show the final merged configuration before sending requests.
## Config File
Path to configuration file(s). Supports local paths and remote URLs.
If multiple config files are specified, they are merged in order. Later files override earlier ones.
**Example:**
```yaml
# config2.yaml
configFile: /config4.yaml
```
```sh
SARIN_CONFIG_FILE=/config1.yaml sarin -f /config2.yaml -f https://example.com/config3.yaml
```
In this example, all 4 config files are read and merged with the following priority:
```
config3.yaml > config2.yaml > config4.yaml > config1.yaml
```
## URL
Target URL. Must be HTTP or HTTPS. The URL path supports [templating](templating.md), allowing dynamic path generation per request.
> **Note:** Templating is only supported in the URL path. Host and scheme must be static.
**Example with dynamic path:**
```yaml
url: http://example.com/users/{{ fakeit_UUID }}/profile
```
**CLI example with dynamic path:**
```sh
sarin -U "http://example.com/users/{{ fakeit_UUID }}" -r 1000 -c 10
```
## Method
HTTP method(s). If multiple values are provided, Sarin cycles through them randomly for each request. Supports [templating](templating.md).
**YAML example:**
```yaml
method: GET
# OR
method:
- GET
- POST
- PUT
```
**CLI example:**
```sh
-method GET -method POST -method PUT
```
**ENV example:**
```sh
SARIN_METHOD=GET
```
## Timeout
Request timeout. Must be greater than 0.
Valid time units: `ns`, `us` (or `µs`), `ms`, `s`, `m`, `h`
**Examples:** `5s`, `300ms`, `1m20s`
## Concurrency
Number of concurrent workers. Must be between 1 and 100,000,000.
## Requests
Total number of requests to send. At least one of `requests` or `duration` must be specified. If both are provided, the test stops when either limit is reached first.
## Duration
Test duration. At least one of `requests` or `duration` must be specified. If both are provided, the test stops when either limit is reached first.
Valid time units: `ns`, `us` (or `µs`), `ms`, `s`, `m`, `h`
**Examples:** `1m30s`, `25s`, `1h`
## Quiet
Hide the progress bar and runtime logs.
## Output
Output format for response statistics.
Valid formats: `table`, `json`, `yaml`, `none`
Using `none` disables output and reduces memory usage since response statistics are not stored.
## Dry Run
Generate requests without sending them. Useful for testing templates.
## Insecure
Skip TLS certificate verification.
## Body
Request body. If multiple values are provided, Sarin cycles through them randomly for each request. Supports [templating](templating.md).
**YAML example:**
```yaml
body: '{"product": "car"}'
# OR
body:
- '{"product": "car"}'
- '{"product": "phone"}'
- '{"product": "watch"}'
```
**CLI example:**
```sh
-body '{"product": "car"}' -body '{"product": "phone"}' -body '{"product": "watch"}'
```
**ENV example:**
```sh
SARIN_BODY='{"product": "car"}'
```
## Params
URL query parameters. If multiple values are provided for a key, Sarin cycles through them randomly for each request. Supports [templating](templating.md).
**YAML example:**
```yaml
params:
key1: value1
key2: [value2, value3]
# OR
params:
- key1: value1
- key2: [value2, value3]
```
**CLI example:**
```sh
-param "key1=value1" -param "key2=value2" -param "key2=value3"
```
**ENV example:**
```sh
SARIN_PARAM="key1=value1"
```
## Headers
HTTP headers. If multiple values are provided for a key, Sarin cycles through them randomly for each request. Supports [templating](templating.md).
**YAML example:**
```yaml
headers:
key1: value1
key2: [value2, value3]
# OR
headers:
- key1: value1
- key2: [value2, value3]
```
**CLI example:**
```sh
-header "key1: value1" -header "key2: value2" -header "key2: value3"
```
**ENV example:**
```sh
SARIN_HEADER="key1: value1"
```
## Cookies
HTTP cookies. If multiple values are provided for a key, Sarin cycles through them randomly for each request. Supports [templating](templating.md).
**YAML example:**
```yaml
cookies:
key1: value1
key2: [value2, value3]
# OR
cookies:
- key1: value1
- key2: [value2, value3]
```
**CLI example:**
```sh
-cookie "key1=value1" -cookie "key2=value2" -cookie "key2=value3"
```
**ENV example:**
```sh
SARIN_COOKIE="key1=value1"
```
## Proxy
Proxy URL(s). If multiple values are provided, Sarin cycles through them randomly for each request.
Supported protocols: `http`, `https`, `socks5`, `socks5h`
**YAML example:**
```yaml
proxy: http://proxy1.com
# OR
proxy:
- http://proxy1.com
- socks5://proxy2.com
- socks5h://proxy3.com
```
**CLI example:**
```sh
-proxy http://proxy1.com -proxy socks5://proxy2.com -proxy socks5h://proxy3.com
```
**ENV example:**
```sh
SARIN_PROXY="http://proxy1.com"
```
## Values
Template values in key=value format. Supports [templating](templating.md). Multiple values can be specified and all are rendered for each request.
See the [Templating Guide](templating.md) for more details on using values and available template functions.
**YAML example:**
```yaml
values: "key=value"
# OR
values: |
key1=value1
key2=value2
key3=value3
```
**CLI example:**
```sh
-values "key1=value1" -values "key2=value2" -values "key3=value3"
```
**ENV example:**
```sh
SARIN_VALUES="key1=value1"
```

748
docs/examples.md Normal file
View File

@@ -0,0 +1,748 @@
# Examples
This guide provides practical examples for common Sarin use cases.
## Table of Contents
- [Basic Usage](#basic-usage)
- [Request-Based vs Duration-Based Tests](#request-based-vs-duration-based-tests)
- [Headers, Cookies, and Parameters](#headers-cookies-and-parameters)
- [Dynamic Requests with Templating](#dynamic-requests-with-templating)
- [Request Bodies](#request-bodies)
- [Using Proxies](#using-proxies)
- [Output Formats](#output-formats)
- [Docker Usage](#docker-usage)
- [Dry Run Mode](#dry-run-mode)
- [Show Configuration](#show-configuration)
---
## Basic Usage
Send 1000 GET requests with 10 concurrent workers:
```sh
sarin -U http://example.com -r 1000 -c 10
```
<details>
<summary>YAML equivalent</summary>
```yaml
url: http://example.com
requests: 1000
concurrency: 10
```
</details>
Send requests with a custom timeout:
```sh
sarin -U http://example.com -r 1000 -c 10 -T 5s
```
<details>
<summary>YAML equivalent</summary>
```yaml
url: http://example.com
requests: 1000
concurrency: 10
timeout: 5s
```
</details>
## Request-Based vs Duration-Based Tests
**Request-based:** Stop after sending a specific number of requests:
```sh
sarin -U http://example.com -r 10000 -c 50
```
<details>
<summary>YAML equivalent</summary>
```yaml
url: http://example.com
requests: 10000
concurrency: 50
```
</details>
**Duration-based:** Run for a specific amount of time:
```sh
sarin -U http://example.com -d 5m -c 50
```
<details>
<summary>YAML equivalent</summary>
```yaml
url: http://example.com
duration: 5m
concurrency: 50
```
</details>
**Combined:** Stop when either limit is reached first:
```sh
sarin -U http://example.com -r 100000 -d 2m -c 100
```
<details>
<summary>YAML equivalent</summary>
```yaml
url: http://example.com
requests: 100000
duration: 2m
concurrency: 100
```
</details>
## Headers, Cookies, and Parameters
**Custom headers:**
```sh
sarin -U http://example.com -r 1000 -c 10 \
-H "Authorization: Bearer token123" \
-H "X-Custom-Header: value"
```
<details>
<summary>YAML equivalent</summary>
```yaml
url: http://example.com
requests: 1000
concurrency: 10
headers:
Authorization: Bearer token123
X-Custom-Header: value
```
</details>
**Random headers from multiple values:**
> **Note:** When multiple values are provided for the same header, Sarin starts at a random index and cycles through all values in order. Once the cycle completes, it picks a new random starting point. This ensures all values are used while maintaining some randomness.
```sh
sarin -U http://example.com -r 1000 -c 10 \
-H "X-Region: us-east" \
-H "X-Region: us-west" \
-H "X-Region: eu-central"
```
<details>
<summary>YAML equivalent</summary>
```yaml
url: http://example.com
requests: 1000
concurrency: 10
headers:
X-Region:
- us-east
- us-west
- eu-central
```
</details>
**Query parameters:**
```sh
sarin -U http://example.com/search -r 1000 -c 10 \
-P "query=test" \
-P "limit=10"
```
<details>
<summary>YAML equivalent</summary>
```yaml
url: http://example.com/search
requests: 1000
concurrency: 10
params:
query: test
limit: "10"
```
</details>
**Dynamic query parameters:**
```sh
sarin -U http://example.com/users -r 1000 -c 10 \
-P "id={{ fakeit_IntRange 1 1000 }}" \
-P "fields=name,email"
```
<details>
<summary>YAML equivalent</summary>
```yaml
url: http://example.com/users
requests: 1000
concurrency: 10
params:
id: "{{ fakeit_IntRange 1 1000 }}"
fields: name,email
```
</details>
**Cookies:**
```sh
sarin -U http://example.com -r 1000 -c 10 \
-C "session_id=abc123" \
-C "user_id={{ fakeit_UUID }}"
```
<details>
<summary>YAML equivalent</summary>
```yaml
url: http://example.com
requests: 1000
concurrency: 10
cookies:
session_id: abc123
user_id: "{{ fakeit_UUID }}"
```
</details>
## Dynamic Requests with Templating
**Dynamic URL paths:**
Test different resource endpoints with random IDs:
```sh
sarin -U "http://example.com/users/{{ fakeit_UUID }}/profile" -r 1000 -c 10
```
<details>
<summary>YAML equivalent</summary>
```yaml
url: http://example.com/users/{{ fakeit_UUID }}/profile
requests: 1000
concurrency: 10
```
</details>
Test with random numeric IDs:
```sh
sarin -U "http://example.com/products/{{ fakeit_Number 1 10000 }}" -r 1000 -c 10
```
<details>
<summary>YAML equivalent</summary>
```yaml
url: http://example.com/products/{{ fakeit_Number 1 10000 }}
requests: 1000
concurrency: 10
```
</details>
**Generate a random User-Agent for each request:**
```sh
sarin -U http://example.com -r 1000 -c 10 \
-H "User-Agent: {{ fakeit_UserAgent }}"
```
<details>
<summary>YAML equivalent</summary>
```yaml
url: http://example.com
requests: 1000
concurrency: 10
headers:
User-Agent: "{{ fakeit_UserAgent }}"
```
</details>
Send requests with random user data:
```sh
sarin -U http://example.com/api/users -r 1000 -c 10 \
-M POST \
-H "Content-Type: application/json" \
-B '{"name": "{{ fakeit_Name }}", "email": "{{ fakeit_Email }}"}'
```
<details>
<summary>YAML equivalent</summary>
```yaml
url: http://example.com/api/users
requests: 1000
concurrency: 10
method: POST
headers:
Content-Type: application/json
body: '{"name": "{{ fakeit_Name }}", "email": "{{ fakeit_Email }}"}'
```
</details>
Use values to share generated data across headers and body:
```sh
sarin -U http://example.com/api/users -r 1000 -c 10 \
-M POST \
-V "ID={{ fakeit_UUID }}" \
-H "X-Request-ID: {{ .Values.ID }}" \
-B '{"id": "{{ .Values.ID }}", "name": "{{ fakeit_Name }}"}'
```
<details>
<summary>YAML equivalent</summary>
```yaml
url: http://example.com/api/users
requests: 1000
concurrency: 10
method: POST
values: "ID={{ fakeit_UUID }}"
headers:
X-Request-ID: "{{ .Values.ID }}"
body: '{"id": "{{ .Values.ID }}", "name": "{{ fakeit_Name }}"}'
```
</details>
Generate random IPs and timestamps:
```sh
sarin -U http://example.com/api/logs -r 500 -c 20 \
-M POST \
-H "Content-Type: application/json" \
-B '{"ip": "{{ fakeit_IPv4Address }}", "timestamp": "{{ fakeit_Date }}", "action": "{{ fakeit_HackerVerb }}"}'
```
<details>
<summary>YAML equivalent</summary>
```yaml
url: http://example.com/api/logs
requests: 500
concurrency: 20
method: POST
headers:
Content-Type: application/json
body: '{"ip": "{{ fakeit_IPv4Address }}", "timestamp": "{{ fakeit_Date }}", "action": "{{ fakeit_HackerVerb }}"}'
```
</details>
> For the complete list of 320+ template functions, see the **[Templating Guide](templating.md)**.
## Request Bodies
**Simple JSON body:**
```sh
sarin -U http://example.com/api/data -r 1000 -c 10 \
-M POST \
-H "Content-Type: application/json" \
-B '{"key": "value"}'
```
<details>
<summary>YAML equivalent</summary>
```yaml
url: http://example.com/api/data
requests: 1000
concurrency: 10
method: POST
headers:
Content-Type: application/json
body: '{"key": "value"}'
```
</details>
**Multiple bodies (randomly cycled):**
```sh
sarin -U http://example.com/api/products -r 1000 -c 10 \
-M POST \
-H "Content-Type: application/json" \
-B '{"product": "laptop", "price": 999}' \
-B '{"product": "phone", "price": 599}' \
-B '{"product": "tablet", "price": 399}'
```
<details>
<summary>YAML equivalent</summary>
```yaml
url: http://example.com/api/products
requests: 1000
concurrency: 10
method: POST
headers:
Content-Type: application/json
body:
- '{"product": "laptop", "price": 999}'
- '{"product": "phone", "price": 599}'
- '{"product": "tablet", "price": 399}'
```
</details>
**Dynamic body with fake data:**
```sh
sarin -U http://example.com/api/orders -r 1000 -c 10 \
-M POST \
-H "Content-Type: application/json" \
-B '{
"order_id": "{{ fakeit_UUID }}",
"customer": "{{ fakeit_Name }}",
"email": "{{ fakeit_Email }}",
"amount": {{ fakeit_Price 10 500 }},
"currency": "{{ fakeit_CurrencyShort }}"
}'
```
<details>
<summary>YAML equivalent</summary>
```yaml
url: http://example.com/api/orders
requests: 1000
concurrency: 10
method: POST
headers:
Content-Type: application/json
body: |
{
"order_id": "{{ fakeit_UUID }}",
"customer": "{{ fakeit_Name }}",
"email": "{{ fakeit_Email }}",
"amount": {{ fakeit_Price 10 500 }},
"currency": "{{ fakeit_CurrencyShort }}"
}
```
</details>
**Multipart form data:**
```sh
sarin -U http://example.com/api/upload -r 1000 -c 10 \
-M POST \
-B '{{ body_FormData (dict_Str "username" "john" "email" "john@example.com") }}'
```
<details>
<summary>YAML equivalent</summary>
```yaml
url: http://example.com/api/upload
requests: 1000
concurrency: 10
method: POST
body: '{{ body_FormData (dict_Str "username" "john" "email" "john@example.com") }}'
```
</details>
**Multipart form data with dynamic values:**
```sh
sarin -U http://example.com/api/users -r 1000 -c 10 \
-M POST \
-B '{{ body_FormData (dict_Str "name" (fakeit_Name) "email" (fakeit_Email) "phone" (fakeit_Phone)) }}'
```
<details>
<summary>YAML equivalent</summary>
```yaml
url: http://example.com/api/users
requests: 1000
concurrency: 10
method: POST
body: '{{ body_FormData (dict_Str "name" (fakeit_Name) "email" (fakeit_Email) "phone" (fakeit_Phone)) }}'
```
</details>
> **Note:** `body_FormData` automatically sets the `Content-Type` header to `multipart/form-data` with the appropriate boundary.
## Using Proxies
**Single HTTP proxy:**
```sh
sarin -U http://example.com -r 1000 -c 10 \
-X http://proxy.example.com:8080
```
<details>
<summary>YAML equivalent</summary>
```yaml
url: http://example.com
requests: 1000
concurrency: 10
proxy: http://proxy.example.com:8080
```
</details>
**SOCKS5 proxy:**
```sh
sarin -U http://example.com -r 1000 -c 10 \
-X socks5://proxy.example.com:1080
```
<details>
<summary>YAML equivalent</summary>
```yaml
url: http://example.com
requests: 1000
concurrency: 10
proxy: socks5://proxy.example.com:1080
```
</details>
**Multiple proxies (load balanced):**
```sh
sarin -U http://example.com -r 1000 -c 10 \
-X http://proxy1.example.com:8080 \
-X http://proxy2.example.com:8080 \
-X socks5://proxy3.example.com:1080
```
<details>
<summary>YAML equivalent</summary>
```yaml
url: http://example.com
requests: 1000
concurrency: 10
proxy:
- http://proxy1.example.com:8080
- http://proxy2.example.com:8080
- socks5://proxy3.example.com:1080
```
</details>
**Proxy with authentication:**
```sh
sarin -U http://example.com -r 1000 -c 10 \
-X http://user:password@proxy.example.com:8080
```
<details>
<summary>YAML equivalent</summary>
```yaml
url: http://example.com
requests: 1000
concurrency: 10
proxy: http://user:password@proxy.example.com:8080
```
</details>
## Output Formats
**Table output (default):**
```sh
sarin -U http://example.com -r 1000 -c 10 -o table
```
<details>
<summary>YAML equivalent</summary>
```yaml
url: http://example.com
requests: 1000
concurrency: 10
output: table
```
</details>
**JSON output (useful for parsing):**
```sh
sarin -U http://example.com -r 1000 -c 10 -o json
```
<details>
<summary>YAML equivalent</summary>
```yaml
url: http://example.com
requests: 1000
concurrency: 10
output: json
```
</details>
**YAML output:**
```sh
sarin -U http://example.com -r 1000 -c 10 -o yaml
```
<details>
<summary>YAML equivalent</summary>
```yaml
url: http://example.com
requests: 1000
concurrency: 10
output: yaml
```
</details>
**No output (minimal memory usage):**
```sh
sarin -U http://example.com -r 1000 -c 10 -o none
```
<details>
<summary>YAML equivalent</summary>
```yaml
url: http://example.com
requests: 1000
concurrency: 10
output: none
```
</details>
**Quiet mode (hide progress bar):**
```sh
sarin -U http://example.com -r 1000 -c 10 -q
```
<details>
<summary>YAML equivalent</summary>
```yaml
url: http://example.com
requests: 1000
concurrency: 10
quiet: true
```
</details>
## Docker Usage
**Basic Docker usage:**
```sh
docker run --rm aykhans/sarin -U http://example.com -r 1000 -c 10
```
**With local config file:**
```sh
docker run --rm -v $(pwd)/config.yaml:/config.yaml aykhans/sarin -f /config.yaml
```
**With remote config file:**
```sh
docker run --rm aykhans/sarin -f https://example.com/config.yaml
```
**Interactive mode with TTY:**
```sh
docker run --rm -it aykhans/sarin -U http://example.com -r 1000 -c 10
```
## Dry Run Mode
Test your configuration without sending actual requests:
```sh
sarin -U http://example.com -r 10 -c 1 -z \
-H "X-Request-ID: {{ fakeit_UUID }}" \
-B '{"user": "{{ fakeit_Name }}"}'
```
<details>
<summary>YAML equivalent</summary>
```yaml
url: http://example.com
requests: 10
concurrency: 1
dryRun: true
headers:
X-Request-ID: "{{ fakeit_UUID }}"
body: '{"user": "{{ fakeit_Name }}"}'
```
</details>
This validates templates.
## Show Configuration
Preview the merged configuration before running:
```sh
sarin -U http://example.com -r 1000 -c 10 \
-H "Authorization: Bearer token" \
-s
```
<details>
<summary>YAML equivalent</summary>
```yaml
url: http://example.com
requests: 1000
concurrency: 10
showConfig: true
headers:
Authorization: Bearer token
```
</details>

BIN
docs/static/demo.gif vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 96 KiB

612
docs/templating.md Normal file
View File

@@ -0,0 +1,612 @@
# Templating
Sarin supports Go templates in URL paths, methods, bodies, headers, params, cookies, and values.
> **Note:** Templating in URL host and scheme is not supported. Only the path portion of the URL can contain templates.
## Table of Contents
- [Using Values](#using-values)
- [General Functions](#general-functions)
- [String Functions](#string-functions)
- [Collection Functions](#collection-functions)
- [Body Functions](#body-functions)
- [Fake Data Functions](#fake-data-functions)
- [File](#file)
- [ID](#id)
- [Product](#product)
- [Person](#person)
- [Generate](#generate)
- [Auth](#auth)
- [Address](#address)
- [Game](#game)
- [Beer](#beer)
- [Car](#car)
- [Words](#words)
- [Text](#text)
- [Foods](#foods)
- [Misc](#misc)
- [Color](#color)
- [Image](#image)
- [Internet](#internet)
- [HTML](#html)
- [Date/Time](#datetime)
- [Payment](#payment)
- [Finance](#finance)
- [Company](#company)
- [Hacker](#hacker)
- [Hipster](#hipster)
- [App](#app)
- [Animal](#animal)
- [Emoji](#emoji)
- [Language](#language)
- [Number](#number)
- [String](#string)
- [Celebrity](#celebrity)
- [Minecraft](#minecraft)
- [Book](#book)
- [Movie](#movie)
- [Error](#error)
- [School](#school)
- [Song](#song)
## Using Values
Values are generated once per request and can be referenced in multiple fields using `{{ .Values.KEY }}` syntax. This is useful when you need to use the same generated value (e.g., a UUID) in both headers and body within the same request.
**Example:**
```yaml
values: |
REQUEST_ID={{ fakeit_UUID }}
USER_ID={{ fakeit_UUID }}
headers:
X-Request-ID: "{{ .Values.REQUEST_ID }}"
body: |
{
"requestId": "{{ .Values.REQUEST_ID }}",
"userId": "{{ .Values.USER_ID }}"
}
```
In this example, `REQUEST_ID` is generated once and the same value is used in both the header and body. Each new request generates a new `REQUEST_ID`.
**CLI example:**
```sh
sarin -U http://example.com/users \
-V "ID={{ fakeit_UUID }}" \
-H "X-Request-ID: {{ .Values.ID }}" \
-B '{"id": "{{ .Values.ID }}"}'
```
## General Functions
### String Functions
| Function | Description | Example |
| ---------------------------------------------------------- | ------------------------------------------------------------------- | --------------------------------------------------------- |
| `strings_ToUpper` | Convert string to uppercase | `{{ strings_ToUpper "hello" }}``HELLO` |
| `strings_ToLower` | Convert string to lowercase | `{{ strings_ToLower "HELLO" }}``hello` |
| `strings_RemoveSpaces` | Remove all spaces from string | `{{ strings_RemoveSpaces "hello world" }}``helloworld` |
| `strings_Replace(s string, old string, new string, n int)` | Replace first `n` occurrences of `old` with `new`. Use `-1` for all | `{{ strings_Replace "hello" "l" "L" -1 }}``heLLo` |
| `strings_ToDate(date string)` | Parse date string (YYYY-MM-DD format) | `{{ strings_ToDate "2024-01-15" }}` |
| `strings_First(s string, n int)` | Get first `n` characters | `{{ strings_First "hello" 2 }}``he` |
| `strings_Last(s string, n int)` | Get last `n` characters | `{{ strings_Last "hello" 2 }}``lo` |
| `strings_Truncate(s string, n int)` | Truncate to `n` characters with ellipsis | `{{ strings_Truncate "hello world" 5 }}``hello...` |
| `strings_TrimPrefix(s string, prefix string)` | Remove prefix from string | `{{ strings_TrimPrefix "hello" "he" }}``llo` |
| `strings_TrimSuffix(s string, suffix string)` | Remove suffix from string | `{{ strings_TrimSuffix "hello" "lo" }}``hel` |
| `strings_Join(sep string, values ...string)` | Join strings with separator | `{{ strings_Join "-" "a" "b" "c" }}``a-b-c` |
### Collection Functions
| Function | Description | Example |
| ----------------------------- | --------------------------------------------- | -------------------------------------------- |
| `dict_Str(pairs ...string)` | Create string dictionary from key-value pairs | `{{ dict_Str "key1" "val1" "key2" "val2" }}` |
| `slice_Str(values ...string)` | Create string slice | `{{ slice_Str "a" "b" "c" }}` |
| `slice_Int(values ...int)` | Create int slice | `{{ slice_Int 1 2 3 }}` |
| `slice_Uint(values ...uint)` | Create uint slice | `{{ slice_Uint 1 2 3 }}` |
### Body Functions
| Function | Description | Example |
| ----------------------------------------- | ------------------------------------------------------------------------ | -------------------------------------------------- |
| `body_FormData(fields map[string]string)` | Create multipart form data. Automatically sets the `Content-Type` header | `{{ body_FormData (dict_Str "field1" "value1") }}` |
## Fake Data Functions
These functions are powered by [gofakeit](https://github.com/brianvoe/gofakeit) library.
### File
| Function | Description | Example Output |
| ---------------------- | -------------- | -------------------- |
| `fakeit_FileExtension` | File extension | `"nes"` |
| `fakeit_FileMimeType` | MIME type | `"application/json"` |
### ID
| Function | Description | Example Output |
| ------------- | --------------------------------- | ---------------------------------------- |
| `fakeit_ID` | Generate random unique identifier | `"pfsfktb87rcmj6bqha2fz9"` |
| `fakeit_UUID` | Generate UUID v4 | `"b4ddf623-4ea6-48e5-9292-541f028d1fdb"` |
### Product
| Function | Description | Example Output |
| --------------------------- | ------------------- | --------------------------------- |
| `fakeit_ProductName` | Product name | `"olive copper monitor"` |
| `fakeit_ProductDescription` | Product description | `"Backwards caused quarterly..."` |
| `fakeit_ProductCategory` | Product category | `"clothing"` |
| `fakeit_ProductFeature` | Product feature | `"ultra-lightweight"` |
| `fakeit_ProductMaterial` | Product material | `"brass"` |
| `fakeit_ProductUPC` | UPC code | `"012780949980"` |
| `fakeit_ProductAudience` | Target audience | `["adults"]` |
| `fakeit_ProductDimension` | Product dimension | `"medium"` |
| `fakeit_ProductUseCase` | Use case | `"home"` |
| `fakeit_ProductBenefit` | Product benefit | `"comfort"` |
| `fakeit_ProductSuffix` | Product suffix | `"pro"` |
| `fakeit_ProductISBN` | ISBN number | `"978-1-4028-9462-6"` |
### Person
| Function | Description | Example Output |
| ----------------------- | ---------------------- | ------------------------ |
| `fakeit_Name` | Full name | `"Markus Moen"` |
| `fakeit_NamePrefix` | Name prefix | `"Mr."` |
| `fakeit_NameSuffix` | Name suffix | `"Jr."` |
| `fakeit_FirstName` | First name | `"Markus"` |
| `fakeit_MiddleName` | Middle name | `"Belinda"` |
| `fakeit_LastName` | Last name | `"Daniel"` |
| `fakeit_Gender` | Gender | `"male"` |
| `fakeit_Age` | Age | `40` |
| `fakeit_Ethnicity` | Ethnicity | `"German"` |
| `fakeit_SSN` | Social Security Number | `"296446360"` |
| `fakeit_EIN` | Employer ID Number | `"12-3456789"` |
| `fakeit_Hobby` | Hobby | `"Swimming"` |
| `fakeit_Email` | Email address | `"markusmoen@pagac.net"` |
| `fakeit_Phone` | Phone number | `"6136459948"` |
| `fakeit_PhoneFormatted` | Formatted phone | `"136-459-9489"` |
### Generate
| Function | Description | Example |
| ------------------------------ | -------------------------------------- | ------------------------------------------------------ |
| `fakeit_Regex(pattern string)` | Generate string matching regex pattern | `{{ fakeit_Regex "[a-z]{5}[0-9]{3}" }}``"abcde123"` |
### Auth
| Function | Description | Example |
| --------------------------------------------------------------------------------------------- | ----------------------------------------------------------- | ----------------------------------------------------- |
| `fakeit_Username` | Username | `"Daniel1364"` |
| `fakeit_Password(upper bool, lower bool, numeric bool, special bool, space bool, length int)` | Generate password with specified character types and length | `{{ fakeit_Password true true true false false 16 }}` |
### Address
| Function | Description | Example Output |
| --------------------------------------------------- | ---------------------------- | --------------------------------------------------- |
| `fakeit_City` | City name | `"Marcelside"` |
| `fakeit_Country` | Country name | `"United States of America"` |
| `fakeit_CountryAbr` | Country abbreviation | `"US"` |
| `fakeit_State` | State name | `"Illinois"` |
| `fakeit_StateAbr` | State abbreviation | `"IL"` |
| `fakeit_Street` | Full street | `"364 East Rapidsborough"` |
| `fakeit_StreetName` | Street name | `"View"` |
| `fakeit_StreetNumber` | Street number | `"13645"` |
| `fakeit_StreetPrefix` | Street prefix | `"East"` |
| `fakeit_StreetSuffix` | Street suffix | `"Ave"` |
| `fakeit_Unit` | Unit | `"Apt 123"` |
| `fakeit_Zip` | ZIP code | `"13645"` |
| `fakeit_Latitude` | Random latitude | `-73.534056` |
| `fakeit_Longitude` | Random longitude | `-147.068112` |
| `fakeit_LatitudeInRange(min float64, max float64)` | Latitude in specified range | `{{ fakeit_LatitudeInRange 0 90 }}``22.921026` |
| `fakeit_LongitudeInRange(min float64, max float64)` | Longitude in specified range | `{{ fakeit_LongitudeInRange 0 180 }}``-8.170450` |
### Game
| Function | Description | Example Output |
| ----------------- | ----------- | ------------------- |
| `fakeit_Gamertag` | Gamer tag | `"footinterpret63"` |
### Beer
| Function | Description | Example Output |
| -------------------- | --------------- | ----------------------------- |
| `fakeit_BeerAlcohol` | Alcohol content | `"2.7%"` |
| `fakeit_BeerBlg` | Blg | `"6.4°Blg"` |
| `fakeit_BeerHop` | Hop | `"Glacier"` |
| `fakeit_BeerIbu` | IBU | `"29 IBU"` |
| `fakeit_BeerMalt` | Malt | `"Munich"` |
| `fakeit_BeerName` | Beer name | `"Duvel"` |
| `fakeit_BeerStyle` | Beer style | `"European Amber Lager"` |
| `fakeit_BeerYeast` | Yeast | `"1388 - Belgian Strong Ale"` |
### Car
| Function | Description | Example Output |
| ---------------------------- | ------------ | ---------------------- |
| `fakeit_CarMaker` | Car maker | `"Nissan"` |
| `fakeit_CarModel` | Car model | `"Aveo"` |
| `fakeit_CarType` | Car type | `"Passenger car mini"` |
| `fakeit_CarFuelType` | Fuel type | `"CNG"` |
| `fakeit_CarTransmissionType` | Transmission | `"Manual"` |
### Words
| Function | Description | Example Output |
| ---------------------------------- | --------------------------- | ---------------- |
| `fakeit_Word` | Random word | `"example"` |
| `fakeit_Noun` | Random noun | `"computer"` |
| `fakeit_NounCommon` | Common noun | `"table"` |
| `fakeit_NounConcrete` | Concrete noun | `"chair"` |
| `fakeit_NounAbstract` | Abstract noun | `"freedom"` |
| `fakeit_NounCollectivePeople` | Collective noun (people) | `"team"` |
| `fakeit_NounCollectiveAnimal` | Collective noun (animal) | `"herd"` |
| `fakeit_NounCollectiveThing` | Collective noun (thing) | `"bunch"` |
| `fakeit_NounCountable` | Countable noun | `"book"` |
| `fakeit_NounUncountable` | Uncountable noun | `"water"` |
| `fakeit_Verb` | Random verb | `"run"` |
| `fakeit_VerbAction` | Action verb | `"jump"` |
| `fakeit_VerbLinking` | Linking verb | `"is"` |
| `fakeit_VerbHelping` | Helping verb | `"can"` |
| `fakeit_Adverb` | Random adverb | `"quickly"` |
| `fakeit_AdverbManner` | Manner adverb | `"carefully"` |
| `fakeit_AdverbDegree` | Degree adverb | `"very"` |
| `fakeit_AdverbPlace` | Place adverb | `"here"` |
| `fakeit_AdverbTimeDefinite` | Definite time adverb | `"yesterday"` |
| `fakeit_AdverbTimeIndefinite` | Indefinite time adverb | `"soon"` |
| `fakeit_AdverbFrequencyDefinite` | Definite frequency adverb | `"daily"` |
| `fakeit_AdverbFrequencyIndefinite` | Indefinite frequency adverb | `"often"` |
| `fakeit_Preposition` | Random preposition | `"on"` |
| `fakeit_PrepositionSimple` | Simple preposition | `"in"` |
| `fakeit_PrepositionDouble` | Double preposition | `"out of"` |
| `fakeit_PrepositionCompound` | Compound preposition | `"according to"` |
| `fakeit_Adjective` | Random adjective | `"beautiful"` |
| `fakeit_AdjectiveDescriptive` | Descriptive adjective | `"large"` |
| `fakeit_AdjectiveQuantitative` | Quantitative adjective | `"many"` |
| `fakeit_AdjectiveProper` | Proper adjective | `"American"` |
| `fakeit_AdjectiveDemonstrative` | Demonstrative adjective | `"this"` |
| `fakeit_AdjectivePossessive` | Possessive adjective | `"my"` |
| `fakeit_AdjectiveInterrogative` | Interrogative adjective | `"which"` |
| `fakeit_AdjectiveIndefinite` | Indefinite adjective | `"some"` |
| `fakeit_Pronoun` | Random pronoun | `"he"` |
| `fakeit_PronounPersonal` | Personal pronoun | `"I"` |
| `fakeit_PronounObject` | Object pronoun | `"him"` |
| `fakeit_PronounPossessive` | Possessive pronoun | `"mine"` |
| `fakeit_PronounReflective` | Reflective pronoun | `"myself"` |
| `fakeit_PronounDemonstrative` | Demonstrative pronoun | `"that"` |
| `fakeit_PronounInterrogative` | Interrogative pronoun | `"who"` |
| `fakeit_PronounRelative` | Relative pronoun | `"which"` |
| `fakeit_Connective` | Random connective | `"however"` |
| `fakeit_ConnectiveTime` | Time connective | `"then"` |
| `fakeit_ConnectiveComparative` | Comparative connective | `"similarly"` |
| `fakeit_ConnectiveComplaint` | Complaint connective | `"although"` |
| `fakeit_ConnectiveListing` | Listing connective | `"firstly"` |
| `fakeit_ConnectiveCasual` | Casual connective | `"because"` |
| `fakeit_ConnectiveExamplify` | Examplify connective | `"for example"` |
### Text
| Function | Description | Example |
| ---------------------------------------------------------------------------------------- | ----------------------------------------------- | --------------------------------------------- |
| `fakeit_Sentence` | Random sentence | `{{ fakeit_Sentence }}` |
| `fakeit_Paragraph` | Random paragraph | `{{ fakeit_Paragraph }}` |
| `fakeit_LoremIpsumWord` | Lorem ipsum word | `"lorem"` |
| `fakeit_LoremIpsumSentence(wordCount int)` | Lorem ipsum sentence with specified word count | `{{ fakeit_LoremIpsumSentence 5 }}` |
| `fakeit_LoremIpsumParagraph(paragraphs int, sentences int, words int, separator string)` | Lorem ipsum paragraphs with specified structure | `{{ fakeit_LoremIpsumParagraph 1 3 5 "\n" }}` |
| `fakeit_Question` | Random question | `"What is your name?"` |
| `fakeit_Quote` | Random quote | `"Life is what happens..."` |
| `fakeit_Phrase` | Random phrase | `"a piece of cake"` |
### Foods
| Function | Description | Example Output |
| ------------------ | -------------- | ---------------------------------------- |
| `fakeit_Fruit` | Fruit | `"Peach"` |
| `fakeit_Vegetable` | Vegetable | `"Amaranth Leaves"` |
| `fakeit_Breakfast` | Breakfast food | `"Blueberry banana happy face pancakes"` |
| `fakeit_Lunch` | Lunch food | `"No bake hersheys bar pie"` |
| `fakeit_Dinner` | Dinner food | `"Wild addicting dip"` |
| `fakeit_Snack` | Snack | `"Trail mix"` |
| `fakeit_Dessert` | Dessert | `"French napoleons"` |
### Misc
| Function | Description | Example Output |
| ------------------ | -------------- | -------------- |
| `fakeit_Bool` | Random boolean | `true` |
| `fakeit_FlipACoin` | Flip a coin | `"Heads"` |
### Color
| Function | Description | Example Output |
| ------------------- | ------------------ | --------------------------------------------------------- |
| `fakeit_Color` | Color name | `"MediumOrchid"` |
| `fakeit_HexColor` | Hex color | `"#a99fb4"` |
| `fakeit_RGBColor` | RGB color | `[85, 224, 195]` |
| `fakeit_SafeColor` | Safe color | `"black"` |
| `fakeit_NiceColors` | Nice color palette | `["#cfffdd", "#b4dec1", "#5c5863", "#a85163", "#ff1f4c"]` |
### Image
| Function | Description | Example |
| ----------------------------------------- | ------------------------- | -------------------------------- |
| `fakeit_ImageJpeg(width int, height int)` | Generate JPEG image bytes | `{{ fakeit_ImageJpeg 100 100 }}` |
| `fakeit_ImagePng(width int, height int)` | Generate PNG image bytes | `{{ fakeit_ImagePng 100 100 }}` |
### Internet
| Function | Description | Example Output |
| --------------------------------- | ------------------------------------------ | ----------------------------------------------------- |
| `fakeit_URL` | Random URL | `"http://www.principalproductize.biz/target"` |
| `fakeit_UrlSlug(words int)` | URL slug with specified word count | `{{ fakeit_UrlSlug 3 }}``"bathe-regularly-quiver"` |
| `fakeit_DomainName` | Domain name | `"centraltarget.biz"` |
| `fakeit_DomainSuffix` | Domain suffix | `"org"` |
| `fakeit_IPv4Address` | IPv4 address | `"222.83.191.222"` |
| `fakeit_IPv6Address` | IPv6 address | `"2001:cafe:8898:ee17:bc35:9064:5866:d019"` |
| `fakeit_MacAddress` | MAC address | `"cb:ce:06:94:22:e9"` |
| `fakeit_HTTPStatusCode` | HTTP status code | `200` |
| `fakeit_HTTPStatusCodeSimple` | Simple status code | `404` |
| `fakeit_LogLevel(logType string)` | Log level (types: general, syslog, apache) | `{{ fakeit_LogLevel "general" }}``"error"` |
| `fakeit_HTTPMethod` | HTTP method | `"HEAD"` |
| `fakeit_HTTPVersion` | HTTP version | `"HTTP/1.1"` |
| `fakeit_UserAgent` | Random User-Agent | `"Mozilla/5.0..."` |
| `fakeit_ChromeUserAgent` | Chrome User-Agent | `"Mozilla/5.0 (X11; Linux i686)..."` |
| `fakeit_FirefoxUserAgent` | Firefox User-Agent | `"Mozilla/5.0 (Macintosh; U;..."` |
| `fakeit_OperaUserAgent` | Opera User-Agent | `"Opera/8.39..."` |
| `fakeit_SafariUserAgent` | Safari User-Agent | `"Mozilla/5.0 (iPad;..."` |
| `fakeit_APIUserAgent` | API User-Agent | `"curl/8.2.5"` |
### HTML
| Function | Description | Example Output |
| ------------------ | --------------- | ------------------ |
| `fakeit_InputName` | HTML input name | `"email"` |
| `fakeit_Svg` | SVG image | `"<svg>...</svg>"` |
### Date/Time
| Function | Description | Example |
| -------------------------------------------------- | --------------------------------- | ------------------------------------------------------------------------------------ |
| `fakeit_Date` | Random date | `2023-06-15 14:30:00` |
| `fakeit_PastDate` | Past date | `2022-03-10 09:15:00` |
| `fakeit_FutureDate` | Future date | `2025-12-20 18:45:00` |
| `fakeit_DateRange(start time.Time, end time.Time)` | Random date between start and end | `{{ fakeit_DateRange (strings_ToDate "2020-01-01") (strings_ToDate "2025-12-31") }}` |
| `fakeit_NanoSecond` | Nanosecond | `123456789` |
| `fakeit_Second` | Second (0-59) | `45` |
| `fakeit_Minute` | Minute (0-59) | `30` |
| `fakeit_Hour` | Hour (0-23) | `14` |
| `fakeit_Month` | Month (1-12) | `6` |
| `fakeit_MonthString` | Month name | `"June"` |
| `fakeit_Day` | Day (1-31) | `15` |
| `fakeit_WeekDay` | Weekday | `"Monday"` |
| `fakeit_Year` | Year | `2024` |
| `fakeit_TimeZone` | Timezone | `"America/New_York"` |
| `fakeit_TimeZoneAbv` | Timezone abbreviation | `"EST"` |
| `fakeit_TimeZoneFull` | Full timezone | `"Eastern Standard Time"` |
| `fakeit_TimeZoneOffset` | Timezone offset | `-5` |
| `fakeit_TimeZoneRegion` | Timezone region | `"America"` |
### Payment
| Function | Description | Example |
| ---------------------------------------- | ----------------------------------------------------- | -------------------------------------------------------------- |
| `fakeit_Price(min float64, max float64)` | Random price in range | `{{ fakeit_Price 1 100 }}``92.26` |
| `fakeit_CreditCardCvv` | CVV | `"513"` |
| `fakeit_CreditCardExp` | Expiration date | `"01/27"` |
| `fakeit_CreditCardNumber(gaps bool)` | Credit card number. `gaps`: add spaces between groups | `{{ fakeit_CreditCardNumber true }}``"4111 1111 1111 1111"` |
| `fakeit_CreditCardType` | Card type | `"Visa"` |
| `fakeit_CurrencyLong` | Currency name | `"United States Dollar"` |
| `fakeit_CurrencyShort` | Currency code | `"USD"` |
| `fakeit_AchRouting` | ACH routing number | `"513715684"` |
| `fakeit_AchAccount` | ACH account number | `"491527954328"` |
| `fakeit_BitcoinAddress` | Bitcoin address | `"1BoatSLRHtKNngkdXEeobR76b53LETtpyT"` |
| `fakeit_BitcoinPrivateKey` | Bitcoin private key | `"5HueCGU8rMjxEXxiPuD5BDuG6o5xjA7QkbPp"` |
| `fakeit_BankName` | Bank name | `"Wells Fargo"` |
| `fakeit_BankType` | Bank type | `"Investment Bank"` |
### Finance
| Function | Description | Example Output |
| -------------- | ---------------- | ---------------- |
| `fakeit_Cusip` | CUSIP identifier | `"38259P508"` |
| `fakeit_Isin` | ISIN identifier | `"US38259P5089"` |
### Company
| Function | Description | Example Output |
| ---------------------- | -------------- | ------------------------------------------ |
| `fakeit_BS` | Business speak | `"front-end"` |
| `fakeit_Blurb` | Company blurb | `"word"` |
| `fakeit_BuzzWord` | Buzzword | `"disintermediate"` |
| `fakeit_Company` | Company name | `"Moen, Pagac and Wuckert"` |
| `fakeit_CompanySuffix` | Company suffix | `"Inc"` |
| `fakeit_JobDescriptor` | Job descriptor | `"Central"` |
| `fakeit_JobLevel` | Job level | `"Assurance"` |
| `fakeit_JobTitle` | Job title | `"Director"` |
| `fakeit_Slogan` | Company slogan | `"Universal seamless Focus, interactive."` |
### Hacker
| Function | Description | Example Output |
| --------------------------- | ------------------- | --------------------------------------------------------------------------------------------- |
| `fakeit_HackerAbbreviation` | Hacker abbreviation | `"ADP"` |
| `fakeit_HackerAdjective` | Hacker adjective | `"wireless"` |
| `fakeit_HackeringVerb` | Hackering verb | `"connecting"` |
| `fakeit_HackerNoun` | Hacker noun | `"driver"` |
| `fakeit_HackerPhrase` | Hacker phrase | `"If we calculate the program, we can get to the AI pixel through the redundant XSS matrix!"` |
| `fakeit_HackerVerb` | Hacker verb | `"synthesize"` |
### Hipster
| Function | Description | Example |
| ------------------------- | ----------------- | ------------------------------------------------------------------- |
| `fakeit_HipsterWord` | Hipster word | `"microdosing"` |
| `fakeit_HipsterSentence` | Hipster sentence | `"Soul loops with you probably haven't heard of them undertones."` |
| `fakeit_HipsterParagraph` | Hipster paragraph | `"Single-origin austin, double why. Tag it Yuccie, keep it any..."` |
### App
| Function | Description | Example Output |
| ------------------- | ----------- | --------------------- |
| `fakeit_AppName` | App name | `"Parkrespond"` |
| `fakeit_AppVersion` | App version | `"1.12.14"` |
| `fakeit_AppAuthor` | App author | `"Qado Energy, Inc."` |
### Animal
| Function | Description | Example Output |
| ------------------- | ----------- | ------------------- |
| `fakeit_PetName` | Pet name | `"Ozzy Pawsborne"` |
| `fakeit_Animal` | Animal | `"elk"` |
| `fakeit_AnimalType` | Animal type | `"amphibians"` |
| `fakeit_FarmAnimal` | Farm animal | `"Chicken"` |
| `fakeit_Cat` | Cat breed | `"Chausie"` |
| `fakeit_Dog` | Dog breed | `"Norwich Terrier"` |
| `fakeit_Bird` | Bird | `"goose"` |
### Emoji
| Function | Description | Example Output |
| ------------------------- | ---------------------------------------------- | ------------------------------------------------------ |
| `fakeit_Emoji` | Random emoji | `"🤣"` |
| `fakeit_EmojiCategory` | Emoji category | `"Smileys & Emotion"` |
| `fakeit_EmojiAlias` | Emoji alias | `"smile"` |
| `fakeit_EmojiTag` | Emoji tag | `"happy"` |
| `fakeit_EmojiFlag` | Flag emoji | `"🇺🇸"` |
| `fakeit_EmojiAnimal` | Animal emoji | `"🐱"` |
| `fakeit_EmojiFood` | Food emoji | `"🍕"` |
| `fakeit_EmojiPlant` | Plant emoji | `"🌸"` |
| `fakeit_EmojiMusic` | Music emoji | `"🎵"` |
| `fakeit_EmojiVehicle` | Vehicle emoji | `"🚗"` |
| `fakeit_EmojiSport` | Sport emoji | `"⚽"` |
| `fakeit_EmojiFace` | Face emoji | `"😊"` |
| `fakeit_EmojiHand` | Hand emoji | `"👋"` |
| `fakeit_EmojiClothing` | Clothing emoji | `"👕"` |
| `fakeit_EmojiLandmark` | Landmark emoji | `"🗽"` |
| `fakeit_EmojiElectronics` | Electronics emoji | `"📱"` |
| `fakeit_EmojiGame` | Game emoji | `"🎮"` |
| `fakeit_EmojiTools` | Tools emoji | `"🔧"` |
| `fakeit_EmojiWeather` | Weather emoji | `"☀️"` |
| `fakeit_EmojiJob` | Job emoji | `"👨‍💻"` |
| `fakeit_EmojiPerson` | Person emoji | `"👤"` |
| `fakeit_EmojiGesture` | Gesture emoji | `"🙌"` |
| `fakeit_EmojiCostume` | Costume emoji | `"🎃"` |
| `fakeit_EmojiSentence` | Emoji sentence with random emojis interspersed | `"Weekends reserve time for 🖼️ Disc 🏨 golf and day."` |
### Language
| Function | Description | Example Output |
| ----------------------------- | --------------------- | -------------- |
| `fakeit_Language` | Language | `"English"` |
| `fakeit_LanguageAbbreviation` | Language abbreviation | `"en"` |
| `fakeit_ProgrammingLanguage` | Programming language | `"Go"` |
### Number
| Function | Description | Example |
| ----------------------------------------------- | ----------------------------------- | ------------------------------------------ |
| `fakeit_Number(min int, max int)` | Random number in range | `{{ fakeit_Number 1 100 }}``42` |
| `fakeit_Int` | Random int | `{{ fakeit_Int }}` |
| `fakeit_IntN(n int)` | Random int from 0 to n | `{{ fakeit_IntN 100 }}` |
| `fakeit_Int8` | Random int8 | `{{ fakeit_Int8 }}` |
| `fakeit_Int16` | Random int16 | `{{ fakeit_Int16 }}` |
| `fakeit_Int32` | Random int32 | `{{ fakeit_Int32 }}` |
| `fakeit_Int64` | Random int64 | `{{ fakeit_Int64 }}` |
| `fakeit_Uint` | Random uint | `{{ fakeit_Uint }}` |
| `fakeit_UintN(n uint)` | Random uint from 0 to n | `{{ fakeit_UintN 100 }}` |
| `fakeit_Uint8` | Random uint8 | `{{ fakeit_Uint8 }}` |
| `fakeit_Uint16` | Random uint16 | `{{ fakeit_Uint16 }}` |
| `fakeit_Uint32` | Random uint32 | `{{ fakeit_Uint32 }}` |
| `fakeit_Uint64` | Random uint64 | `{{ fakeit_Uint64 }}` |
| `fakeit_Float32` | Random float32 | `{{ fakeit_Float32 }}` |
| `fakeit_Float32Range(min float32, max float32)` | Random float32 in range | `{{ fakeit_Float32Range 0 100 }}` |
| `fakeit_Float64` | Random float64 | `{{ fakeit_Float64 }}` |
| `fakeit_Float64Range(min float64, max float64)` | Random float64 in range | `{{ fakeit_Float64Range 0 100 }}` |
| `fakeit_RandomInt(slice []int)` | Random int from slice | `{{ fakeit_RandomInt (slice_Int 1 2 3) }}` |
| `fakeit_HexUint(bits int)` | Random hex uint with specified bits | `{{ fakeit_HexUint 8 }}``"0xff"` |
### String
| Function | Description | Example |
| ------------------------------------- | ------------------------------- | --------------------------------------------------------------- |
| `fakeit_Digit` | Single random digit | `"0"` |
| `fakeit_DigitN(n uint)` | Generate `n` random digits | `{{ fakeit_DigitN 5 }}``"0136459948"` |
| `fakeit_Letter` | Single random letter | `"g"` |
| `fakeit_LetterN(n uint)` | Generate `n` random letters | `{{ fakeit_LetterN 10 }}``"gbRMaRxHki"` |
| `fakeit_Lexify(pattern string)` | Replace `?` with random letters | `{{ fakeit_Lexify "?????@??????.com" }}``"billy@mister.com"` |
| `fakeit_Numerify(pattern string)` | Replace `#` with random digits | `{{ fakeit_Numerify "(###)###-####" }}``"(555)867-5309"` |
| `fakeit_RandomString(slice []string)` | Random string from slice | `{{ fakeit_RandomString (slice_Str "a" "b" "c") }}` |
### Celebrity
| Function | Description | Example Output |
| -------------------------- | ------------------ | ------------------ |
| `fakeit_CelebrityActor` | Celebrity actor | `"Brad Pitt"` |
| `fakeit_CelebrityBusiness` | Celebrity business | `"Elon Musk"` |
| `fakeit_CelebritySport` | Celebrity sport | `"Michael Phelps"` |
### Minecraft
| Function | Description | Example Output |
| --------------------------------- | ----------------- | ---------------- |
| `fakeit_MinecraftOre` | Minecraft ore | `"coal"` |
| `fakeit_MinecraftWood` | Minecraft wood | `"oak"` |
| `fakeit_MinecraftArmorTier` | Armor tier | `"iron"` |
| `fakeit_MinecraftArmorPart` | Armor part | `"helmet"` |
| `fakeit_MinecraftWeapon` | Minecraft weapon | `"bow"` |
| `fakeit_MinecraftTool` | Minecraft tool | `"shovel"` |
| `fakeit_MinecraftDye` | Minecraft dye | `"white"` |
| `fakeit_MinecraftFood` | Minecraft food | `"apple"` |
| `fakeit_MinecraftAnimal` | Minecraft animal | `"chicken"` |
| `fakeit_MinecraftVillagerJob` | Villager job | `"farmer"` |
| `fakeit_MinecraftVillagerStation` | Villager station | `"furnace"` |
| `fakeit_MinecraftVillagerLevel` | Villager level | `"master"` |
| `fakeit_MinecraftMobPassive` | Passive mob | `"cow"` |
| `fakeit_MinecraftMobNeutral` | Neutral mob | `"bee"` |
| `fakeit_MinecraftMobHostile` | Hostile mob | `"spider"` |
| `fakeit_MinecraftMobBoss` | Boss mob | `"ender dragon"` |
| `fakeit_MinecraftBiome` | Minecraft biome | `"forest"` |
| `fakeit_MinecraftWeather` | Minecraft weather | `"rain"` |
### Book
| Function | Description | Example Output |
| ------------------- | ----------- | -------------- |
| `fakeit_BookTitle` | Book title | `"Hamlet"` |
| `fakeit_BookAuthor` | Book author | `"Mark Twain"` |
| `fakeit_BookGenre` | Book genre | `"Adventure"` |
### Movie
| Function | Description | Example Output |
| ------------------- | ----------- | -------------- |
| `fakeit_MovieName` | Movie name | `"Inception"` |
| `fakeit_MovieGenre` | Movie genre | `"Sci-Fi"` |
### Error
| Function | Description | Example Output |
| ------------------------ | ----------------- | ---------------------------------- |
| `fakeit_Error` | Random error | `"connection refused"` |
| `fakeit_ErrorDatabase` | Database error | `"database connection failed"` |
| `fakeit_ErrorGRPC` | gRPC error | `"rpc error: code = Unavailable"` |
| `fakeit_ErrorHTTP` | HTTP error | `"HTTP 500 Internal Server Error"` |
| `fakeit_ErrorHTTPClient` | HTTP client error | `"HTTP 404 Not Found"` |
| `fakeit_ErrorHTTPServer` | HTTP server error | `"HTTP 503 Service Unavailable"` |
| `fakeit_ErrorRuntime` | Runtime error | `"panic: runtime error"` |
### School
| Function | Description | Example Output |
| --------------- | ----------- | ---------------------- |
| `fakeit_School` | School name | `"Harvard University"` |
### Song
| Function | Description | Example Output |
| ------------------- | ----------- | --------------------- |
| `fakeit_SongName` | Song name | `"Bohemian Rhapsody"` |
| `fakeit_SongArtist` | Song artist | `"Queen"` |
| `fakeit_SongGenre` | Song genre | `"Rock"` |

63
go.mod
View File

@@ -1,26 +1,55 @@
module github.com/aykhans/dodo
module go.aykhans.me/sarin
go 1.23.2
go 1.25.5
require (
github.com/go-playground/validator/v10 v10.23.0
github.com/jedib0t/go-pretty/v6 v6.6.5
github.com/valyala/fasthttp v1.58.0
golang.org/x/net v0.33.0
github.com/brianvoe/gofakeit/v7 v7.14.0
github.com/charmbracelet/bubbles v0.21.0
github.com/charmbracelet/bubbletea v1.3.10
github.com/charmbracelet/glamour v0.10.0
github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834
github.com/charmbracelet/x/term v0.2.2
github.com/joho/godotenv v1.5.1
github.com/valyala/fasthttp v1.69.0
go.aykhans.me/utils v1.0.7
go.yaml.in/yaml/v4 v4.0.0-rc.3
golang.org/x/net v0.49.0
)
require (
github.com/andybalholm/brotli v1.1.1 // indirect
github.com/gabriel-vasile/mimetype v1.4.4 // 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/mattn/go-runewidth v0.0.15 // indirect
github.com/alecthomas/chroma/v2 v2.21.1 // indirect
github.com/andybalholm/brotli v1.2.0 // indirect
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
github.com/aymerick/douceur v0.2.0 // indirect
github.com/charmbracelet/colorprofile v0.4.1 // indirect
github.com/charmbracelet/harmonica v0.2.0 // indirect
github.com/charmbracelet/x/ansi v0.11.3 // indirect
github.com/charmbracelet/x/cellbuf v0.0.14 // indirect
github.com/charmbracelet/x/exp/slice v0.0.0-20260109001716-2fbdffcb221f // indirect
github.com/clipperhouse/displaywidth v0.6.2 // indirect
github.com/clipperhouse/stringish v0.1.1 // indirect
github.com/clipperhouse/uax29/v2 v2.3.0 // indirect
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
github.com/dlclark/regexp2 v1.11.5 // indirect
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
github.com/gorilla/css v1.0.1 // indirect
github.com/klauspost/compress v1.18.2 // indirect
github.com/lucasb-eyer/go-colorful v1.3.0 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mattn/go-localereader v0.0.1 // indirect
github.com/mattn/go-runewidth v0.0.19 // indirect
github.com/microcosm-cc/bluemonday v1.0.27 // indirect
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
github.com/muesli/cancelreader v0.2.2 // indirect
github.com/muesli/reflow v0.3.0 // indirect
github.com/muesli/termenv v0.16.0 // indirect
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
github.com/rivo/uniseg v0.4.7 // indirect
github.com/valyala/bytebufferpool v1.0.0 // indirect
golang.org/x/crypto v0.31.0 // indirect
golang.org/x/sys v0.28.0 // indirect
golang.org/x/term v0.27.0 // indirect
golang.org/x/text v0.21.0 // indirect
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
github.com/yuin/goldmark v1.7.16 // indirect
github.com/yuin/goldmark-emoji v1.0.6 // indirect
golang.org/x/sys v0.40.0 // indirect
golang.org/x/term v0.39.0 // indirect
golang.org/x/text v0.33.0 // indirect
)

144
go.sum
View File

@@ -1,47 +1,115 @@
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/gabriel-vasile/mimetype v1.4.4 h1:QjV6pZ7/XZ7ryI2KuyeEDE8wnh7fHP9YnQy+R0LnH8I=
github.com/gabriel-vasile/mimetype v1.4.4/go.mod h1:JwLei5XPtWdGiMFB5Pjle1oEeoSeEuJfJE+TtfvdB/s=
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.23.0 h1:/PwmTwZhS0dPkav3cdK9kV1FsAmrL8sThn8IHr/sO+o=
github.com/go-playground/validator/v10 v10.23.0/go.mod h1:dbuPbCMFw/DrkbEynArYaCwl3amGuJotoKCe95atGMM=
github.com/jedib0t/go-pretty/v6 v6.6.5 h1:9PgMJOVBedpgYLI56jQRJYqngxYAAzfEUua+3NgSqAo=
github.com/jedib0t/go-pretty/v6 v6.6.5/go.mod h1:Uq/HrbhuFty5WSVNfjpQQe47x16RwVGXIveNGEyGtHs=
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-runewidth v0.0.15 h1:UNAjwbU9l54TA3KzvqLGxwWjHmMgBUVhBiTjelZgg3U=
github.com/mattn/go-runewidth v0.0.15/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/alecthomas/assert/v2 v2.11.0 h1:2Q9r3ki8+JYXvGsDyBXwH3LcJ+WK5D0gc5E8vS6K3D0=
github.com/alecthomas/assert/v2 v2.11.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k=
github.com/alecthomas/chroma/v2 v2.21.1 h1:FaSDrp6N+3pphkNKU6HPCiYLgm8dbe5UXIXcoBhZSWA=
github.com/alecthomas/chroma/v2 v2.21.1/go.mod h1:NqVhfBR0lte5Ouh3DcthuUCTUpDC9cxBOfyMbMQPs3o=
github.com/alecthomas/repr v0.5.2 h1:SU73FTI9D1P5UNtvseffFSGmdNci/O6RsqzeXJtP0Qs=
github.com/alecthomas/repr v0.5.2/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4=
github.com/andybalholm/brotli v1.2.0 h1:ukwgCxwYrmACq68yiUqwIWnGY0cTPox/M94sVwToPjQ=
github.com/andybalholm/brotli v1.2.0/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY=
github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
github.com/aymanbagabas/go-udiff v0.2.0 h1:TK0fH4MteXUDspT88n8CKzvK0X9O2xu9yQjWpi6yML8=
github.com/aymanbagabas/go-udiff v0.2.0/go.mod h1:RE4Ex0qsGkTAJoQdQQCA0uG+nAzJO/pI/QwceO5fgrA=
github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk=
github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4=
github.com/brianvoe/gofakeit/v7 v7.14.0 h1:R8tmT/rTDJmD2ngpqBL9rAKydiL7Qr2u3CXPqRt59pk=
github.com/brianvoe/gofakeit/v7 v7.14.0/go.mod h1:QXuPeBw164PJCzCUZVmgpgHJ3Llj49jSLVkKPMtxtxA=
github.com/charmbracelet/bubbles v0.21.0 h1:9TdC97SdRVg/1aaXNVWfFH3nnLAwOXr8Fn6u6mfQdFs=
github.com/charmbracelet/bubbles v0.21.0/go.mod h1:HF+v6QUR4HkEpz62dx7ym2xc71/KBHg+zKwJtMw+qtg=
github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw=
github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4=
github.com/charmbracelet/colorprofile v0.4.1 h1:a1lO03qTrSIRaK8c3JRxJDZOvhvIeSco3ej+ngLk1kk=
github.com/charmbracelet/colorprofile v0.4.1/go.mod h1:U1d9Dljmdf9DLegaJ0nGZNJvoXAhayhmidOdcBwAvKk=
github.com/charmbracelet/glamour v0.10.0 h1:MtZvfwsYCx8jEPFJm3rIBFIMZUfUJ765oX8V6kXldcY=
github.com/charmbracelet/glamour v0.10.0/go.mod h1:f+uf+I/ChNmqo087elLnVdCiVgjSKWuXa/l6NU2ndYk=
github.com/charmbracelet/harmonica v0.2.0 h1:8NxJWRWg/bzKqqEaaeFNipOu77YR5t8aSwG4pgaUBiQ=
github.com/charmbracelet/harmonica v0.2.0/go.mod h1:KSri/1RMQOZLbw7AHqgcBycp8pgJnQMYYT8QZRqZ1Ao=
github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834 h1:ZR7e0ro+SZZiIZD7msJyA+NjkCNNavuiPBLgerbOziE=
github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834/go.mod h1:aKC/t2arECF6rNOnaKaVU6y4t4ZeHQzqfxedE/VkVhA=
github.com/charmbracelet/x/ansi v0.11.3 h1:6DcVaqWI82BBVM/atTyq6yBoRLZFBsnoDoX9GCu2YOI=
github.com/charmbracelet/x/ansi v0.11.3/go.mod h1:yI7Zslym9tCJcedxz5+WBq+eUGMJT0bM06Fqy1/Y4dI=
github.com/charmbracelet/x/cellbuf v0.0.14 h1:iUEMryGyFTelKW3THW4+FfPgi4fkmKnnaLOXuc+/Kj4=
github.com/charmbracelet/x/cellbuf v0.0.14/go.mod h1:P447lJl49ywBbil/KjCk2HexGh4tEY9LH0/1QrZZ9rA=
github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91 h1:payRxjMjKgx2PaCWLZ4p3ro9y97+TVLZNaRZgJwSVDQ=
github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U=
github.com/charmbracelet/x/exp/slice v0.0.0-20260109001716-2fbdffcb221f h1:kvAY8ffwhFuxWqtVI6+9E5vmgTApG96hswFLXJfsxHI=
github.com/charmbracelet/x/exp/slice v0.0.0-20260109001716-2fbdffcb221f/go.mod h1:vqEfX6xzqW1pKKZUUiFOKg0OQ7bCh54Q2vR/tserrRA=
github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk=
github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI=
github.com/clipperhouse/displaywidth v0.6.2 h1:ZDpTkFfpHOKte4RG5O/BOyf3ysnvFswpyYrV7z2uAKo=
github.com/clipperhouse/displaywidth v0.6.2/go.mod h1:R+kHuzaYWFkTm7xoMmK1lFydbci4X2CicfbGstSGg0o=
github.com/clipperhouse/stringish v0.1.1 h1:+NSqMOr3GR6k1FdRhhnXrLfztGzuG+VuFDfatpWHKCs=
github.com/clipperhouse/stringish v0.1.1/go.mod h1:v/WhFtE1q0ovMta2+m+UbpZ+2/HEXNWYXQgCt4hdOzA=
github.com/clipperhouse/uax29/v2 v2.3.0 h1:SNdx9DVUqMoBuBoW3iLOj4FQv3dN5mDtuqwuhIGpJy4=
github.com/clipperhouse/uax29/v2 v2.3.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dlclark/regexp2 v1.11.5 h1:Q/sSnsKerHeCkc/jSTNq1oCm7KiVgUMZRDUoRu0JQZQ=
github.com/dlclark/regexp2 v1.11.5/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4=
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM=
github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8=
github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0=
github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM=
github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg=
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
github.com/klauspost/compress v1.18.2 h1:iiPHWW0YrcFgpBYhsA6D1+fqHssJscY/Tm/y2Uqnapk=
github.com/klauspost/compress v1.18.2/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4=
github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag=
github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4=
github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88=
github.com/mattn/go-runewidth v0.0.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk=
github.com/mattn/go-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw=
github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwXFM08ygZfk=
github.com/microcosm-cc/bluemonday v1.0.27/go.mod h1:jFi9vgW+H7c3V0lb6nR74Ib/DIB5OBs92Dimizgw2cA=
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI=
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo=
github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
github.com/muesli/reflow v0.3.0 h1:IFsN6K9NfGtjeggFP+68I4chLZV2yIKsXJFNZ+eWh6s=
github.com/muesli/reflow v0.3.0/go.mod h1:pbwTDkVPibjO2kyvBQRBxTWEEGDGq0FlB1BIKtnHY/8=
github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc=
github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk=
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
github.com/valyala/fasthttp v1.58.0 h1:GGB2dWxSbEprU9j0iMJHgdKYJVDyjrOwF9RE59PbRuE=
github.com/valyala/fasthttp v1.58.0/go.mod h1:SYXvHHaFp7QZHGKSHmoMipInhrI5StHrhDTYVEjK/Kw=
github.com/valyala/fasthttp v1.69.0 h1:fNLLESD2SooWeh2cidsuFtOcrEi4uB4m1mPrkJMZyVI=
github.com/valyala/fasthttp v1.69.0/go.mod h1:4wA4PfAraPlAsJ5jMSqCE2ug5tqUPwKXxVj8oNECGcw=
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU=
github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E=
golang.org/x/crypto v0.31.0 h1:ihbySMvVjLAeSH1IbfcRTkD/iNscyz8rGzjF/E5hV6U=
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
golang.org/x/net v0.33.0 h1:74SYHlV8BIgHIFC/LrYkOGIwL19eTYXQ5wc6TBuO36I=
golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4=
golang.org/x/sys v0.28.0 h1:Fksou7UEQUWlKvIdsqzJmUmCX3cZuD2+P3XyyzwMhlA=
golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/term v0.27.0 h1:WP60Sv1nlK1T6SupCHbXzSaN0b9wUmsPoRS9b61A23Q=
golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM=
golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo=
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
github.com/yuin/goldmark v1.7.16 h1:n+CJdUxaFMiDUNnWC3dMWCIQJSkxH4uz3ZwQBkAlVNE=
github.com/yuin/goldmark v1.7.16/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg=
github.com/yuin/goldmark-emoji v1.0.6 h1:QWfF2FYaXwL74tfGOW5izeiZepUDroDJfWubQI9HTHs=
github.com/yuin/goldmark-emoji v1.0.6/go.mod h1:ukxJDKFpdFb5x0a5HqbdlcKtebh086iJpI31LTKmWuA=
go.aykhans.me/utils v1.0.7 h1:ClHXHlWmkjfFlD7+w5BQY29lKCEztxY/yCf543x4hZw=
go.aykhans.me/utils v1.0.7/go.mod h1:0Jz8GlZLN35cCHLOLx39sazWwEe33bF6SYlSeqzEXoI=
go.yaml.in/yaml/v4 v4.0.0-rc.3 h1:3h1fjsh1CTAPjW7q/EMe+C8shx5d8ctzZTrLcs/j8Go=
go.yaml.in/yaml/v4 v4.0.0-rc.3/go.mod h1:aZqd9kCMsGL7AuUv/m/PvWLdg5sjJsZ4oHDEnfPPfY0=
golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI=
golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo=
golang.org/x/net v0.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o=
golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8=
golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ=
golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/term v0.39.0 h1:RclSuaJf32jOqZz74CkPA9qFuVTX7vhLlpfj/IGWlqY=
golang.org/x/term v0.39.0/go.mod h1:yxzUCTP/U+FzoxfdKmLaA0RV1WgE0VY7hXBwKtY/4ww=
golang.org/x/text v0.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE=
golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

285
internal/config/cli.go Normal file
View File

@@ -0,0 +1,285 @@
package config
import (
"flag"
"fmt"
"net/url"
"os"
"strings"
"time"
"go.aykhans.me/sarin/internal/types"
versionpkg "go.aykhans.me/sarin/internal/version"
"go.aykhans.me/utils/common"
)
const cliUsageText = `Usage:
sarin [flags]
Simple usage:
sarin -U https://example.com -d 1m
Usage with all flags:
sarin -s -q -z -o json -f ./config.yaml -c 50 -r 100_000 -d 2m30s \
-U https://example.com \
-M POST \
-V "sharedUUID={{ fakeit_UUID }}" \
-B '{"product": "car"}' \
-P "id={{ .Values.sharedUUID }}" \
-H "User-Agent: {{ fakeit_UserAgent }}" -H "Accept: */*" \
-C "token={{ .Values.sharedUUID }}" \
-X "http://proxy.example.com" \
-T 3s \
-I
Flags:
General Config:
-h, -help Help for sarin
-v, -version Version for sarin
-s, -show-config bool Show the final config after parsing all sources (default %v)
-f, -config-file string Path to the config file (local file / http URL)
-c, -concurrency uint Number of concurrent requests (default %d)
-r, -requests uint Number of total requests
-d, -duration time Maximum duration for the test (e.g. 30s, 1m, 5h)
-q, -quiet bool Hide the progress bar and runtime logs (default %v)
-o, -output string Output format (possible values: table, json, yaml, none) (default '%v')
-z, -dry-run bool Run without sending requests (default %v)
Request Config:
-U, -url string Target URL for the request
-M, -method []string HTTP method for the request (default %s)
-B, -body []string Body for the request (e.g. "body text")
-P, -param []string URL parameter for the request (e.g. "key1=value1")
-H, -header []string Header for the request (e.g. "key1: value1")
-C, -cookie []string Cookie for the request (e.g. "key1=value1")
-X, -proxy []string Proxy for the request (e.g. "http://proxy.example.com:8080")
-V, -values []string List of values for templating (e.g. "key1=value1")
-T, -timeout time Timeout for the request (e.g. 400ms, 3s, 1m10s) (default %v)
-I, -insecure bool Skip SSL/TLS certificate verification (default %v)`
var _ IParser = ConfigCLIParser{}
type ConfigCLIParser struct {
args []string
}
func NewConfigCLIParser(args []string) *ConfigCLIParser {
if args == nil {
args = []string{}
}
return &ConfigCLIParser{args: args}
}
type stringSliceArg []string
func (arg *stringSliceArg) String() string {
return strings.Join(*arg, ",")
}
func (arg *stringSliceArg) Set(value string) error {
*arg = append(*arg, value)
return nil
}
// Parse parses command-line arguments into a Config object.
// It can return the following errors:
// - types.ErrCLINoArgs
// - types.CLIUnexpectedArgsError
// - types.FieldParseErrors
func (parser ConfigCLIParser) Parse() (*Config, error) {
flagSet := flag.NewFlagSet("sarin", flag.ExitOnError)
flagSet.Usage = func() { parser.PrintHelp() }
var (
config = &Config{}
// General config
version bool
showConfig bool
configFiles = stringSliceArg{}
concurrency uint
requestCount uint64
duration time.Duration
quiet bool
output string
dryRun bool
// Request config
urlInput string
methods = stringSliceArg{}
bodies = stringSliceArg{}
params = stringSliceArg{}
headers = stringSliceArg{}
cookies = stringSliceArg{}
proxies = stringSliceArg{}
values = stringSliceArg{}
timeout time.Duration
insecure bool
)
{
// General config
flagSet.BoolVar(&version, "version", false, "Version for sarin")
flagSet.BoolVar(&version, "v", false, "Version for sarin")
flagSet.BoolVar(&showConfig, "show-config", false, "Show the final config after parsing all sources")
flagSet.BoolVar(&showConfig, "s", false, "Show the final config after parsing all sources")
flagSet.Var(&configFiles, "config-file", "Path to the config file")
flagSet.Var(&configFiles, "f", "Path to the config file")
flagSet.UintVar(&concurrency, "concurrency", 0, "Number of concurrent requests")
flagSet.UintVar(&concurrency, "c", 0, "Number of concurrent requests")
flagSet.Uint64Var(&requestCount, "requests", 0, "Number of total requests")
flagSet.Uint64Var(&requestCount, "r", 0, "Number of total requests")
flagSet.DurationVar(&duration, "duration", 0, "Maximum duration for the test")
flagSet.DurationVar(&duration, "d", 0, "Maximum duration for the test")
flagSet.BoolVar(&quiet, "quiet", false, "Hide the progress bar and runtime logs")
flagSet.BoolVar(&quiet, "q", false, "Hide the progress bar and runtime logs")
flagSet.StringVar(&output, "output", "", "Output format (possible values: table, json, yaml, none)")
flagSet.StringVar(&output, "o", "", "Output format (possible values: table, json, yaml, none)")
flagSet.BoolVar(&dryRun, "dry-run", false, "Run without sending requests")
flagSet.BoolVar(&dryRun, "z", false, "Run without sending requests")
// Request config
flagSet.StringVar(&urlInput, "url", "", "Target URL for the request")
flagSet.StringVar(&urlInput, "U", "", "Target URL for the request")
flagSet.Var(&methods, "method", "HTTP method for the request")
flagSet.Var(&methods, "M", "HTTP method for the request")
flagSet.Var(&bodies, "body", "Body for the request")
flagSet.Var(&bodies, "B", "Body for the request")
flagSet.Var(&params, "param", "URL parameter for the request")
flagSet.Var(&params, "P", "URL parameter for the request")
flagSet.Var(&headers, "header", "Header for the request")
flagSet.Var(&headers, "H", "Header for the request")
flagSet.Var(&cookies, "cookie", "Cookie for the request")
flagSet.Var(&cookies, "C", "Cookie for the request")
flagSet.Var(&proxies, "proxy", "Proxy for the request")
flagSet.Var(&proxies, "X", "Proxy for the request")
flagSet.Var(&values, "values", "List of values for templating")
flagSet.Var(&values, "V", "List of values for templating")
flagSet.DurationVar(&timeout, "timeout", 0, "Timeout for the request (e.g. 400ms, 15s, 1m10s)")
flagSet.DurationVar(&timeout, "T", 0, "Timeout for the request (e.g. 400ms, 15s, 1m10s)")
flagSet.BoolVar(&insecure, "insecure", false, "Skip SSL/TLS certificate verification")
flagSet.BoolVar(&insecure, "I", false, "Skip SSL/TLS certificate verification")
}
// Parse the specific arguments provided to the parser, skipping the program name.
if err := flagSet.Parse(parser.args[1:]); err != nil {
panic(err)
}
// Check if no flags were set and no non-flag arguments were provided.
// This covers cases where `sarin` is run without any meaningful arguments.
if flagSet.NFlag() == 0 && len(flagSet.Args()) == 0 {
return nil, types.ErrCLINoArgs
}
// Check for any unexpected non-flag arguments remaining after parsing.
if args := flagSet.Args(); len(args) > 0 {
return nil, types.NewCLIUnexpectedArgsError(args)
}
if version {
fmt.Printf("Version: %s\nGit Commit: %s\nBuild Date: %s\nGo Version: %s\n",
versionpkg.Version, versionpkg.GitCommit, versionpkg.BuildDate, versionpkg.GoVersion)
os.Exit(0)
}
var fieldParseErrors []types.FieldParseError
// Iterate over flags that were explicitly set on the command line.
flagSet.Visit(func(flagVar *flag.Flag) {
switch flagVar.Name {
// General config
case "show-config", "s":
config.ShowConfig = common.ToPtr(showConfig)
case "config-file", "f":
for _, configFile := range configFiles {
config.Files = append(config.Files, *types.ParseConfigFile(configFile))
}
case "concurrency", "c":
config.Concurrency = common.ToPtr(concurrency)
case "requests", "r":
config.Requests = common.ToPtr(requestCount)
case "duration", "d":
config.Duration = common.ToPtr(duration)
case "quiet", "q":
config.Quiet = common.ToPtr(quiet)
case "output", "o":
config.Output = common.ToPtr(ConfigOutputType(output))
case "dry-run", "z":
config.DryRun = common.ToPtr(dryRun)
// Request config
case "url", "U":
urlParsed, err := url.Parse(urlInput)
if err != nil {
fieldParseErrors = append(fieldParseErrors, types.NewFieldParseError("url", urlInput, err))
} else {
config.URL = urlParsed
}
case "method", "M":
config.Methods = append(config.Methods, methods...)
case "body", "B":
config.Bodies = append(config.Bodies, bodies...)
case "param", "P":
config.Params.Parse(params...)
case "header", "H":
config.Headers.Parse(headers...)
case "cookie", "C":
config.Cookies.Parse(cookies...)
case "proxy", "X":
for i, proxy := range proxies {
err := config.Proxies.Parse(proxy)
if err != nil {
fieldParseErrors = append(
fieldParseErrors,
types.NewFieldParseError(fmt.Sprintf("proxy[%d]", i), proxy, err),
)
}
}
case "values", "V":
config.Values = append(config.Values, values...)
case "timeout", "T":
config.Timeout = common.ToPtr(timeout)
case "insecure", "I":
config.Insecure = common.ToPtr(insecure)
}
})
if len(fieldParseErrors) > 0 {
return nil, types.NewFieldParseErrors(fieldParseErrors)
}
return config, nil
}
func (parser ConfigCLIParser) PrintHelp() {
fmt.Printf(
cliUsageText+"\n",
Defaults.ShowConfig,
Defaults.Concurrency,
Defaults.Quiet,
Defaults.Output,
Defaults.DryRun,
Defaults.Method,
Defaults.RequestTimeout,
Defaults.Insecure,
)
}

757
internal/config/config.go Normal file
View File

@@ -0,0 +1,757 @@
package config
import (
"errors"
"fmt"
"net/url"
"os"
"slices"
"strconv"
"strings"
"time"
"github.com/charmbracelet/bubbles/viewport"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/glamour"
"github.com/charmbracelet/glamour/styles"
"github.com/charmbracelet/lipgloss"
"github.com/charmbracelet/x/term"
"go.aykhans.me/sarin/internal/types"
"go.aykhans.me/sarin/internal/version"
"go.aykhans.me/utils/common"
utilsErr "go.aykhans.me/utils/errors"
"go.yaml.in/yaml/v4"
)
var Defaults = struct {
UserAgent string
Method string
RequestTimeout time.Duration
Concurrency uint
ShowConfig bool
Quiet bool
Insecure bool
Output ConfigOutputType
DryRun bool
}{
UserAgent: "Sarin/" + version.Version,
Method: "GET",
RequestTimeout: time.Second * 10,
Concurrency: 1,
ShowConfig: false,
Quiet: false,
Insecure: false,
Output: ConfigOutputTypeTable,
DryRun: false,
}
var (
ValidProxySchemes = []string{"http", "https", "socks5", "socks5h"}
ValidRequestURLSchemes = []string{"http", "https"}
)
var (
StyleYellow = lipgloss.NewStyle().Foreground(lipgloss.Color("220"))
StyleRed = lipgloss.NewStyle().Foreground(lipgloss.Color("196"))
)
type IParser interface {
Parse() (*Config, error)
}
type ConfigOutputType string
var (
ConfigOutputTypeTable ConfigOutputType = "table"
ConfigOutputTypeJSON ConfigOutputType = "json"
ConfigOutputTypeYAML ConfigOutputType = "yaml"
ConfigOutputTypeNone ConfigOutputType = "none"
)
type Config struct {
ShowConfig *bool `yaml:"showConfig,omitempty"`
Files []types.ConfigFile `yaml:"files,omitempty"`
Methods []string `yaml:"methods,omitempty"`
URL *url.URL `yaml:"url,omitempty"`
Timeout *time.Duration `yaml:"timeout,omitempty"`
Concurrency *uint `yaml:"concurrency,omitempty"`
Requests *uint64 `yaml:"requests,omitempty"`
Duration *time.Duration `yaml:"duration,omitempty"`
Quiet *bool `yaml:"quiet,omitempty"`
Output *ConfigOutputType `yaml:"output,omitempty"`
Insecure *bool `yaml:"insecure,omitempty"`
DryRun *bool `yaml:"dryRun,omitempty"`
Params types.Params `yaml:"params,omitempty"`
Headers types.Headers `yaml:"headers,omitempty"`
Cookies types.Cookies `yaml:"cookies,omitempty"`
Bodies []string `yaml:"bodies,omitempty"`
Proxies types.Proxies `yaml:"proxies,omitempty"`
Values []string `yaml:"values,omitempty"`
}
func NewConfig() *Config {
return &Config{}
}
func (config Config) MarshalYAML() (any, error) {
const randomValueComment = "Cycles through all values, with a new random start each round"
toNode := func(v any) *yaml.Node {
node := &yaml.Node{}
_ = node.Encode(v)
return node
}
addField := func(content *[]*yaml.Node, key string, value *yaml.Node, comment string) {
if value.Kind == 0 || (value.Kind == yaml.ScalarNode && value.Value == "") ||
(value.Kind == yaml.SequenceNode && len(value.Content) == 0) {
return
}
keyNode := &yaml.Node{Kind: yaml.ScalarNode, Value: key, LineComment: comment}
*content = append(*content, keyNode, value)
}
addStringSlice := func(content *[]*yaml.Node, key string, items []string, withComment bool) {
comment := ""
if withComment && len(items) > 1 {
comment = randomValueComment
}
switch len(items) {
case 1:
addField(content, key, toNode(items[0]), "")
default:
addField(content, key, toNode(items), comment)
}
}
marshalKeyValues := func(items []types.KeyValue[string, []string]) *yaml.Node {
seqNode := &yaml.Node{Kind: yaml.SequenceNode}
for _, item := range items {
keyNode := &yaml.Node{Kind: yaml.ScalarNode, Value: item.Key}
var valueNode *yaml.Node
switch len(item.Value) {
case 1:
valueNode = &yaml.Node{Kind: yaml.ScalarNode, Value: item.Value[0]}
default:
valueNode = &yaml.Node{Kind: yaml.SequenceNode}
for _, v := range item.Value {
valueNode.Content = append(valueNode.Content, &yaml.Node{Kind: yaml.ScalarNode, Value: v})
}
if len(item.Value) > 1 {
keyNode.LineComment = randomValueComment
}
}
mapNode := &yaml.Node{Kind: yaml.MappingNode, Content: []*yaml.Node{keyNode, valueNode}}
seqNode.Content = append(seqNode.Content, mapNode)
}
return seqNode
}
root := &yaml.Node{Kind: yaml.MappingNode}
content := &root.Content
if config.ShowConfig != nil {
addField(content, "showConfig", toNode(*config.ShowConfig), "")
}
addStringSlice(content, "method", config.Methods, true)
if config.URL != nil {
addField(content, "url", toNode(config.URL.String()), "")
}
if config.Timeout != nil {
addField(content, "timeout", toNode(*config.Timeout), "")
}
if config.Concurrency != nil {
addField(content, "concurrency", toNode(*config.Concurrency), "")
}
if config.Requests != nil {
addField(content, "requests", toNode(*config.Requests), "")
}
if config.Duration != nil {
addField(content, "duration", toNode(*config.Duration), "")
}
if config.Quiet != nil {
addField(content, "quiet", toNode(*config.Quiet), "")
}
if config.Output != nil {
addField(content, "output", toNode(string(*config.Output)), "")
}
if config.Insecure != nil {
addField(content, "insecure", toNode(*config.Insecure), "")
}
if config.DryRun != nil {
addField(content, "dryRun", toNode(*config.DryRun), "")
}
if len(config.Params) > 0 {
items := make([]types.KeyValue[string, []string], len(config.Params))
for i, p := range config.Params {
items[i] = types.KeyValue[string, []string](p)
}
addField(content, "params", marshalKeyValues(items), "")
}
if len(config.Headers) > 0 {
items := make([]types.KeyValue[string, []string], len(config.Headers))
for i, h := range config.Headers {
items[i] = types.KeyValue[string, []string](h)
}
addField(content, "headers", marshalKeyValues(items), "")
}
if len(config.Cookies) > 0 {
items := make([]types.KeyValue[string, []string], len(config.Cookies))
for i, c := range config.Cookies {
items[i] = types.KeyValue[string, []string](c)
}
addField(content, "cookies", marshalKeyValues(items), "")
}
addStringSlice(content, "body", config.Bodies, true)
if len(config.Proxies) > 0 {
proxyStrings := make([]string, len(config.Proxies))
for i, p := range config.Proxies {
proxyStrings[i] = p.String()
}
addStringSlice(content, "proxy", proxyStrings, true)
}
addStringSlice(content, "values", config.Values, false)
return root, nil
}
func (config Config) Print() bool {
configYAML, err := yaml.Marshal(config)
if err != nil {
fmt.Fprintln(os.Stderr, StyleRed.Render("Error marshaling config to yaml: "+err.Error()))
os.Exit(1)
}
// Pipe mode: output raw content directly
if !term.IsTerminal(os.Stdout.Fd()) {
fmt.Println(string(configYAML))
os.Exit(0)
}
style := styles.TokyoNightStyleConfig
style.Document.Margin = common.ToPtr[uint](0)
style.CodeBlock.Margin = common.ToPtr[uint](0)
renderer, err := glamour.NewTermRenderer(
glamour.WithStyles(style),
glamour.WithWordWrap(0),
)
if err != nil {
fmt.Fprintln(os.Stderr, StyleRed.Render(err.Error()))
os.Exit(1)
}
content, err := renderer.Render("```yaml\n" + string(configYAML) + "```")
if err != nil {
fmt.Fprintln(os.Stderr, StyleRed.Render(err.Error()))
os.Exit(1)
}
p := tea.NewProgram(
printConfigModel{content: strings.Trim(content, "\n"), rawContent: configYAML},
tea.WithAltScreen(),
tea.WithMouseCellMotion(),
)
m, err := p.Run()
if err != nil {
fmt.Fprintln(os.Stderr, StyleRed.Render(err.Error()))
os.Exit(1)
}
return m.(printConfigModel).start //nolint:forcetypeassert // m is guaranteed to be of type printConfigModel as it was the only model passed to tea.NewProgram
}
func (config *Config) Merge(newConfig *Config) {
config.Files = append(config.Files, newConfig.Files...)
if len(newConfig.Methods) > 0 {
config.Methods = append(config.Methods, newConfig.Methods...)
}
if newConfig.URL != nil {
config.URL = newConfig.URL
}
if newConfig.Timeout != nil {
config.Timeout = newConfig.Timeout
}
if newConfig.Concurrency != nil {
config.Concurrency = newConfig.Concurrency
}
if newConfig.Requests != nil {
config.Requests = newConfig.Requests
}
if newConfig.Duration != nil {
config.Duration = newConfig.Duration
}
if newConfig.ShowConfig != nil {
config.ShowConfig = newConfig.ShowConfig
}
if newConfig.Quiet != nil {
config.Quiet = newConfig.Quiet
}
if newConfig.Output != nil {
config.Output = newConfig.Output
}
if newConfig.Insecure != nil {
config.Insecure = newConfig.Insecure
}
if newConfig.DryRun != nil {
config.DryRun = newConfig.DryRun
}
if len(newConfig.Params) != 0 {
config.Params = append(config.Params, newConfig.Params...)
}
if len(newConfig.Headers) != 0 {
config.Headers = append(config.Headers, newConfig.Headers...)
}
if len(newConfig.Cookies) != 0 {
config.Cookies = append(config.Cookies, newConfig.Cookies...)
}
if len(newConfig.Bodies) != 0 {
config.Bodies = append(config.Bodies, newConfig.Bodies...)
}
if len(newConfig.Proxies) != 0 {
config.Proxies.Append(newConfig.Proxies...)
}
if len(newConfig.Values) != 0 {
config.Values = append(config.Values, newConfig.Values...)
}
}
func (config *Config) SetDefaults() {
if config.URL != nil && len(config.URL.Query()) > 0 {
urlParams := types.Params{}
for key, values := range config.URL.Query() {
for _, value := range values {
urlParams = append(urlParams, types.Param{
Key: key,
Value: []string{value},
})
}
}
config.Params = append(urlParams, config.Params...)
config.URL.RawQuery = ""
}
if len(config.Methods) == 0 {
config.Methods = []string{Defaults.Method}
}
if config.Timeout == nil {
config.Timeout = &Defaults.RequestTimeout
}
if config.Concurrency == nil {
config.Concurrency = common.ToPtr(Defaults.Concurrency)
}
if config.ShowConfig == nil {
config.ShowConfig = common.ToPtr(Defaults.ShowConfig)
}
if config.Quiet == nil {
config.Quiet = common.ToPtr(Defaults.Quiet)
}
if config.Insecure == nil {
config.Insecure = common.ToPtr(Defaults.Insecure)
}
if config.DryRun == nil {
config.DryRun = common.ToPtr(Defaults.DryRun)
}
if !config.Headers.Has("User-Agent") {
config.Headers = append(config.Headers, types.Header{Key: "User-Agent", Value: []string{Defaults.UserAgent}})
}
if config.Output == nil {
config.Output = common.ToPtr(Defaults.Output)
}
}
// Validate validates the config fields.
// It can return the following errors:
// - types.FieldValidationErrors
func (config Config) Validate() error {
validationErrors := make([]types.FieldValidationError, 0)
if len(config.Methods) == 0 {
validationErrors = append(validationErrors, types.NewFieldValidationError("Method", "", errors.New("method is required")))
}
switch {
case config.URL == nil:
validationErrors = append(validationErrors, types.NewFieldValidationError("URL", "", errors.New("URL is required")))
case !slices.Contains(ValidRequestURLSchemes, config.URL.Scheme):
validationErrors = append(validationErrors, types.NewFieldValidationError("URL", config.URL.String(), fmt.Errorf("URL scheme must be one of: %s", strings.Join(ValidRequestURLSchemes, ", "))))
case config.URL.Host == "":
validationErrors = append(validationErrors, types.NewFieldValidationError("URL", config.URL.String(), errors.New("URL must have a host")))
}
switch {
case config.Concurrency == nil:
validationErrors = append(validationErrors, types.NewFieldValidationError("Concurrency", "", errors.New("concurrency count is required")))
case *config.Concurrency == 0:
validationErrors = append(validationErrors, types.NewFieldValidationError("Concurrency", "0", errors.New("concurrency must be greater than 0")))
case *config.Concurrency > 100_000_000:
validationErrors = append(validationErrors, types.NewFieldValidationError("Concurrency", strconv.FormatUint(uint64(*config.Concurrency), 10), errors.New("concurrency must not exceed 100,000,000")))
}
switch {
case config.Requests == nil && config.Duration == nil:
validationErrors = append(validationErrors, types.NewFieldValidationError("Requests / Duration", "", errors.New("either request count or duration must be specified")))
case (config.Requests != nil && config.Duration != nil) && (*config.Requests == 0 && *config.Duration == 0):
validationErrors = append(validationErrors, types.NewFieldValidationError("Requests / Duration", "0", errors.New("both request count and duration cannot be zero")))
case config.Requests != nil && config.Duration == nil && *config.Requests == 0:
validationErrors = append(validationErrors, types.NewFieldValidationError("Requests", "0", errors.New("request count must be greater than 0")))
case config.Requests == nil && config.Duration != nil && *config.Duration == 0:
validationErrors = append(validationErrors, types.NewFieldValidationError("Duration", "0", errors.New("duration must be greater than 0")))
}
if *config.Timeout < 1 {
validationErrors = append(validationErrors, types.NewFieldValidationError("Timeout", "0", errors.New("timeout must be greater than 0")))
}
if config.ShowConfig == nil {
validationErrors = append(validationErrors, types.NewFieldValidationError("ShowConfig", "", errors.New("showConfig field is required")))
}
if config.Quiet == nil {
validationErrors = append(validationErrors, types.NewFieldValidationError("Quiet", "", errors.New("quiet field is required")))
}
if config.Output == nil {
validationErrors = append(validationErrors, types.NewFieldValidationError("Output", "", errors.New("output field is required")))
} else {
switch *config.Output {
case "":
validationErrors = append(validationErrors, types.NewFieldValidationError("Output", "", errors.New("output field is required")))
case ConfigOutputTypeTable, ConfigOutputTypeJSON, ConfigOutputTypeYAML, ConfigOutputTypeNone:
default:
validOutputs := []string{string(ConfigOutputTypeTable), string(ConfigOutputTypeJSON), string(ConfigOutputTypeYAML), string(ConfigOutputTypeNone)}
validationErrors = append(validationErrors,
types.NewFieldValidationError(
"Output",
string(*config.Output),
fmt.Errorf(
"output type must be one of: %s",
strings.Join(validOutputs, ", "),
),
),
)
}
}
if config.Insecure == nil {
validationErrors = append(validationErrors, types.NewFieldValidationError("Insecure", "", errors.New("insecure field is required")))
}
if config.DryRun == nil {
validationErrors = append(validationErrors, types.NewFieldValidationError("DryRun", "", errors.New("dryRun field is required")))
}
for i, proxy := range config.Proxies {
if !slices.Contains(ValidProxySchemes, proxy.Scheme) {
validationErrors = append(
validationErrors,
types.NewFieldValidationError(
fmt.Sprintf("Proxy[%d]", i),
proxy.String(),
fmt.Errorf("proxy scheme must be one of: %v", ValidProxySchemes),
),
)
}
}
templateErrors := ValidateTemplates(&config)
validationErrors = append(validationErrors, templateErrors...)
if len(validationErrors) > 0 {
return types.NewFieldValidationErrors(validationErrors)
}
return nil
}
func ReadAllConfigs() *Config {
envParser := NewConfigENVParser("SARIN")
envConfig, err := envParser.Parse()
_ = utilsErr.MustHandle(err,
utilsErr.OnType(func(err types.FieldParseErrors) error {
printParseErrors("ENV", err.Errors...)
fmt.Println()
os.Exit(1)
return nil
}),
)
cliParser := NewConfigCLIParser(os.Args)
cliConf, err := cliParser.Parse()
_ = utilsErr.MustHandle(err,
utilsErr.OnSentinel(types.ErrCLINoArgs, func(err error) error {
cliParser.PrintHelp()
fmt.Fprintln(os.Stderr, StyleYellow.Render("\nNo arguments provided."))
os.Exit(1)
return nil
}),
utilsErr.OnType(func(err types.CLIUnexpectedArgsError) error {
cliParser.PrintHelp()
fmt.Fprintln(os.Stderr,
StyleYellow.Render(
"\nUnexpected CLI arguments provided: ",
)+strings.Join(err.Args, ", "),
)
os.Exit(1)
return nil
}),
utilsErr.OnType(func(err types.FieldParseErrors) error {
cliParser.PrintHelp()
fmt.Println()
printParseErrors("CLI", err.Errors...)
os.Exit(1)
return nil
}),
)
for _, configFile := range append(envConfig.Files, cliConf.Files...) {
fileConfig, err := parseConfigFile(configFile, 10)
_ = utilsErr.MustHandle(err,
utilsErr.OnType(func(err types.ConfigFileReadError) error {
cliParser.PrintHelp()
fmt.Fprintln(os.Stderr,
StyleYellow.Render(
fmt.Sprintf("\nFailed to read config file (%s): ", configFile.Path())+err.Error(),
),
)
os.Exit(1)
return nil
}),
utilsErr.OnType(func(err types.UnmarshalError) error {
fmt.Fprintln(os.Stderr,
StyleYellow.Render(
fmt.Sprintf("\nFailed to parse config file (%s): ", configFile.Path())+err.Error(),
),
)
os.Exit(1)
return nil
}),
utilsErr.OnType(func(err types.FieldParseErrors) error {
printParseErrors(fmt.Sprintf("CONFIG FILE '%s'", configFile.Path()), err.Errors...)
os.Exit(1)
return nil
}),
)
envConfig.Merge(fileConfig)
}
envConfig.Merge(cliConf)
return envConfig
}
// parseConfigFile recursively parses a config file and its nested files up to maxDepth levels.
// Returns the merged configuration or an error if parsing fails.
// It can return the following errors:
// - types.ConfigFileReadError
// - types.UnmarshalError
// - types.FieldParseErrors
func parseConfigFile(configFile types.ConfigFile, maxDepth int) (*Config, error) {
configFileParser := NewConfigFileParser(configFile)
fileConfig, err := configFileParser.Parse()
if err != nil {
return nil, err
}
if maxDepth <= 0 {
return fileConfig, nil
}
for _, c := range fileConfig.Files {
innerFileConfig, err := parseConfigFile(c, maxDepth-1)
if err != nil {
return nil, err
}
innerFileConfig.Merge(fileConfig)
fileConfig = innerFileConfig
}
return fileConfig, nil
}
func printParseErrors(parserName string, errors ...types.FieldParseError) {
for _, fieldErr := range errors {
if fieldErr.Value == "" {
fmt.Fprintln(os.Stderr,
StyleYellow.Render(fmt.Sprintf("[%s] Field '%s': ", parserName, fieldErr.Field))+fieldErr.Err.Error(),
)
} else {
fmt.Fprintln(os.Stderr,
StyleYellow.Render(fmt.Sprintf("[%s] Field '%s' (%s): ", parserName, fieldErr.Field, fieldErr.Value))+fieldErr.Err.Error(),
)
}
}
}
const (
scrollbarWidth = 1
scrollbarBottomSpace = 1
statusDisplayTime = 3 * time.Second
)
var (
printConfigBorderStyle = func() lipgloss.Border {
b := lipgloss.RoundedBorder()
return b
}()
printConfigHelpStyle = lipgloss.NewStyle().BorderStyle(printConfigBorderStyle).Padding(0, 1)
printConfigSuccessStatusStyle = lipgloss.NewStyle().BorderStyle(printConfigBorderStyle).Padding(0, 1).Foreground(lipgloss.Color("10"))
printConfigErrorStatusStyle = lipgloss.NewStyle().BorderStyle(printConfigBorderStyle).Padding(0, 1).Foreground(lipgloss.Color("9"))
printConfigKeyStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("12")).Bold(true)
printConfigDescStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("252"))
)
type printConfigClearStatusMsg struct{}
type printConfigModel struct {
viewport viewport.Model
content string
rawContent []byte
statusMsg string
ready bool
start bool
}
func (m printConfigModel) Init() tea.Cmd { return nil }
func (m printConfigModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
var cmd tea.Cmd
switch msg := msg.(type) {
case tea.KeyMsg:
switch msg.String() {
case "ctrl+c", "esc":
return m, tea.Quit
case "ctrl+s":
return m.saveContent()
case "enter":
m.start = true
return m, tea.Quit
}
case printConfigClearStatusMsg:
m.statusMsg = ""
return m, nil
case tea.WindowSizeMsg:
m.handleResize(msg)
}
m.viewport, cmd = m.viewport.Update(msg)
return m, cmd
}
func (m printConfigModel) View() string {
if !m.ready {
return "\n Initializing..."
}
content := lipgloss.JoinHorizontal(lipgloss.Top, m.viewport.View(), m.scrollbar())
return fmt.Sprintf("%s\n%s\n%s", m.headerView(), content, m.footerView())
}
func (m *printConfigModel) saveContent() (printConfigModel, tea.Cmd) {
filename := fmt.Sprintf("sarin_config_%s.yaml", time.Now().Format("2006-01-02_15-04-05"))
if err := os.WriteFile(filename, m.rawContent, 0600); err != nil {
m.statusMsg = printConfigErrorStatusStyle.Render("✗ Error saving file: " + err.Error())
} else {
m.statusMsg = printConfigSuccessStatusStyle.Render("✓ Saved to " + filename)
}
return *m, tea.Tick(statusDisplayTime, func(time.Time) tea.Msg { return printConfigClearStatusMsg{} })
}
func (m *printConfigModel) handleResize(msg tea.WindowSizeMsg) {
headerHeight := lipgloss.Height(m.headerView())
footerHeight := lipgloss.Height(m.footerView())
height := msg.Height - headerHeight - footerHeight
width := msg.Width - scrollbarWidth
if !m.ready {
m.viewport = viewport.New(width, height)
m.viewport.SetContent(m.contentWithLineNumbers())
m.ready = true
} else {
m.viewport.Width = width
m.viewport.Height = height
}
}
func (m printConfigModel) headerView() string {
var title string
if m.statusMsg != "" {
title = ("" + m.statusMsg)
} else {
sep := printConfigDescStyle.Render(" / ")
help := printConfigKeyStyle.Render("ENTER") + printConfigDescStyle.Render(" start") + sep +
printConfigKeyStyle.Render("CTRL+S") + printConfigDescStyle.Render(" save") + sep +
printConfigKeyStyle.Render("ESC") + printConfigDescStyle.Render(" exit")
title = printConfigHelpStyle.Render(help)
}
line := strings.Repeat("─", max(0, m.viewport.Width+scrollbarWidth-lipgloss.Width(title)))
return lipgloss.JoinHorizontal(lipgloss.Center, title, line)
}
func (m printConfigModel) footerView() string {
return strings.Repeat("─", m.viewport.Width+scrollbarWidth)
}
func (m printConfigModel) contentWithLineNumbers() string {
lines := strings.Split(m.content, "\n")
width := len(strconv.Itoa(len(lines)))
lineNumStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("246"))
var sb strings.Builder
for i, line := range lines {
lineNum := lineNumStyle.Render(fmt.Sprintf("%*d", width, i+1))
sb.WriteString(lineNum)
sb.WriteString(" ")
sb.WriteString(line)
if i < len(lines)-1 {
sb.WriteByte('\n')
}
}
return sb.String()
}
func (m printConfigModel) scrollbar() string {
height := m.viewport.Height
trackHeight := height - scrollbarBottomSpace
totalLines := m.viewport.TotalLineCount()
if totalLines <= height {
return strings.Repeat(" \n", trackHeight) + " "
}
thumbSize := max(1, (height*trackHeight)/totalLines)
thumbPos := int(m.viewport.ScrollPercent() * float64(trackHeight-thumbSize))
var sb strings.Builder
for i := range trackHeight {
if i >= thumbPos && i < thumbPos+thumbSize {
sb.WriteByte('\xe2') // █ (U+2588)
sb.WriteByte('\x96')
sb.WriteByte('\x88')
} else {
sb.WriteByte('\xe2') // ░ (U+2591)
sb.WriteByte('\x96')
sb.WriteByte('\x91')
}
sb.WriteByte('\n')
}
sb.WriteByte(' ')
return sb.String()
}

235
internal/config/env.go Normal file
View File

@@ -0,0 +1,235 @@
package config
import (
"errors"
"net/url"
"os"
"time"
"go.aykhans.me/sarin/internal/types"
"go.aykhans.me/utils/common"
utilsParse "go.aykhans.me/utils/parser"
)
var _ IParser = ConfigENVParser{}
type ConfigENVParser struct {
envPrefix string
}
func NewConfigENVParser(envPrefix string) *ConfigENVParser {
return &ConfigENVParser{envPrefix}
}
// Parse parses env arguments into a Config object.
// It can return the following errors:
// - types.FieldParseErrors
func (parser ConfigENVParser) Parse() (*Config, error) {
var (
config = &Config{}
fieldParseErrors []types.FieldParseError
)
if showConfig := parser.getEnv("SHOW_CONFIG"); showConfig != "" {
showConfigParsed, err := utilsParse.ParseString[bool](showConfig)
if err != nil {
fieldParseErrors = append(
fieldParseErrors,
types.NewFieldParseError(
parser.getFullEnvName("SHOW_CONFIG"),
showConfig,
errors.New("invalid value for boolean, expected 'true' or 'false'"),
),
)
} else {
config.ShowConfig = &showConfigParsed
}
}
if configFile := parser.getEnv("CONFIG_FILE"); configFile != "" {
config.Files = append(config.Files, *types.ParseConfigFile(configFile))
}
if quiet := parser.getEnv("QUIET"); quiet != "" {
quietParsed, err := utilsParse.ParseString[bool](quiet)
if err != nil {
fieldParseErrors = append(
fieldParseErrors,
types.NewFieldParseError(
parser.getFullEnvName("QUIET"),
quiet,
errors.New("invalid value for boolean, expected 'true' or 'false'"),
),
)
} else {
config.Quiet = &quietParsed
}
}
if output := parser.getEnv("OUTPUT"); output != "" {
config.Output = common.ToPtr(ConfigOutputType(output))
}
if insecure := parser.getEnv("INSECURE"); insecure != "" {
insecureParsed, err := utilsParse.ParseString[bool](insecure)
if err != nil {
fieldParseErrors = append(
fieldParseErrors,
types.NewFieldParseError(
parser.getFullEnvName("INSECURE"),
insecure,
errors.New("invalid value for boolean, expected 'true' or 'false'"),
),
)
} else {
config.Insecure = &insecureParsed
}
}
if dryRun := parser.getEnv("DRY_RUN"); dryRun != "" {
dryRunParsed, err := utilsParse.ParseString[bool](dryRun)
if err != nil {
fieldParseErrors = append(
fieldParseErrors,
types.NewFieldParseError(
parser.getFullEnvName("DRY_RUN"),
dryRun,
errors.New("invalid value for boolean, expected 'true' or 'false'"),
),
)
} else {
config.DryRun = &dryRunParsed
}
}
if method := parser.getEnv("METHOD"); method != "" {
config.Methods = []string{method}
}
if urlEnv := parser.getEnv("URL"); urlEnv != "" {
urlEnvParsed, err := url.Parse(urlEnv)
if err != nil {
fieldParseErrors = append(
fieldParseErrors,
types.NewFieldParseError(parser.getFullEnvName("URL"), urlEnv, err),
)
} else {
config.URL = urlEnvParsed
}
}
if concurrency := parser.getEnv("CONCURRENCY"); concurrency != "" {
concurrencyParsed, err := utilsParse.ParseString[uint](concurrency)
if err != nil {
fieldParseErrors = append(
fieldParseErrors,
types.NewFieldParseError(
parser.getFullEnvName("CONCURRENCY"),
concurrency,
errors.New("invalid value for unsigned integer"),
),
)
} else {
config.Concurrency = &concurrencyParsed
}
}
if requests := parser.getEnv("REQUESTS"); requests != "" {
requestsParsed, err := utilsParse.ParseString[uint64](requests)
if err != nil {
fieldParseErrors = append(
fieldParseErrors,
types.NewFieldParseError(
parser.getFullEnvName("REQUESTS"),
requests,
errors.New("invalid value for unsigned integer"),
),
)
} else {
config.Requests = &requestsParsed
}
}
if duration := parser.getEnv("DURATION"); duration != "" {
durationParsed, err := utilsParse.ParseString[time.Duration](duration)
if err != nil {
fieldParseErrors = append(
fieldParseErrors,
types.NewFieldParseError(
parser.getFullEnvName("DURATION"),
duration,
errors.New("invalid value duration, expected a duration string (e.g., '10s', '1h30m')"),
),
)
} else {
config.Duration = &durationParsed
}
}
if timeout := parser.getEnv("TIMEOUT"); timeout != "" {
timeoutParsed, err := utilsParse.ParseString[time.Duration](timeout)
if err != nil {
fieldParseErrors = append(
fieldParseErrors,
types.NewFieldParseError(
parser.getFullEnvName("TIMEOUT"),
timeout,
errors.New("invalid value duration, expected a duration string (e.g., '10s', '1h30m')"),
),
)
} else {
config.Timeout = &timeoutParsed
}
}
if param := parser.getEnv("PARAM"); param != "" {
config.Params.Parse(param)
}
if header := parser.getEnv("HEADER"); header != "" {
config.Headers.Parse(header)
}
if cookie := parser.getEnv("COOKIE"); cookie != "" {
config.Cookies.Parse(cookie)
}
if body := parser.getEnv("BODY"); body != "" {
config.Bodies = []string{body}
}
if proxy := parser.getEnv("PROXY"); proxy != "" {
err := config.Proxies.Parse(proxy)
if err != nil {
fieldParseErrors = append(
fieldParseErrors,
types.NewFieldParseError(
parser.getFullEnvName("PROXY"),
proxy,
err,
),
)
}
}
if values := parser.getEnv("VALUES"); values != "" {
config.Values = []string{values}
}
if len(fieldParseErrors) > 0 {
return nil, types.NewFieldParseErrors(fieldParseErrors)
}
return config, nil
}
func (parser ConfigENVParser) getFullEnvName(envName string) string {
if parser.envPrefix == "" {
return envName
}
return parser.envPrefix + "_" + envName
}
func (parser ConfigENVParser) getEnv(envName string) string {
return os.Getenv(parser.getFullEnvName(envName))
}

280
internal/config/file.go Normal file
View File

@@ -0,0 +1,280 @@
package config
import (
"context"
"fmt"
"io"
"net/http"
"net/url"
"os"
"path/filepath"
"strings"
"time"
"go.aykhans.me/sarin/internal/types"
"go.aykhans.me/utils/common"
"go.yaml.in/yaml/v4"
)
var _ IParser = ConfigFileParser{}
type ConfigFileParser struct {
configFile types.ConfigFile
}
func NewConfigFileParser(configFile types.ConfigFile) *ConfigFileParser {
return &ConfigFileParser{configFile}
}
// Parse parses config file arguments into a Config object.
// It can return the following errors:
// - types.ConfigFileReadError
// - types.UnmarshalError
// - types.FieldParseErrors
func (parser ConfigFileParser) Parse() (*Config, error) {
ctx, cancel := context.WithTimeout(context.Background(), time.Second*30)
defer cancel()
configFileData, err := fetchFile(ctx, parser.configFile.Path())
if err != nil {
return nil, types.NewConfigFileReadError(err)
}
switch parser.configFile.Type() {
case types.ConfigFileTypeYAML, types.ConfigFileTypeUnknown:
return parser.ParseYAML(configFileData)
default:
panic("unhandled config file type")
}
}
// fetchFile retrieves file contents from a local path or HTTP/HTTPS URL.
func fetchFile(ctx context.Context, src string) ([]byte, error) {
if strings.HasPrefix(src, "http://") || strings.HasPrefix(src, "https://") {
return fetchHTTP(ctx, src)
}
return fetchLocal(src)
}
// fetchHTTP downloads file contents from an HTTP/HTTPS URL.
func fetchHTTP(ctx context.Context, url string) ([]byte, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return nil, fmt.Errorf("failed to create request: %w", err)
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
return nil, fmt.Errorf("failed to fetch file: %w", err)
}
defer resp.Body.Close() //nolint:errcheck
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("failed to fetch file: HTTP %d %s", resp.StatusCode, resp.Status)
}
data, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("failed to read response body: %w", err)
}
return data, nil
}
// fetchLocal reads file contents from the local filesystem.
// It resolves relative paths from the current working directory.
func fetchLocal(src string) ([]byte, error) {
path := src
if !filepath.IsAbs(src) {
pwd, err := os.Getwd()
if err != nil {
return nil, fmt.Errorf("failed to get working directory: %w", err)
}
path = filepath.Join(pwd, src)
}
data, err := os.ReadFile(path) //nolint:gosec
if err != nil {
return nil, fmt.Errorf("failed to read file: %w", err)
}
return data, nil
}
type stringOrSliceField []string
func (ss *stringOrSliceField) UnmarshalYAML(node *yaml.Node) error {
switch node.Kind {
case yaml.ScalarNode:
// Handle single string value
*ss = []string{node.Value}
return nil
case yaml.SequenceNode:
// Handle array of strings
var slice []string
if err := node.Decode(&slice); err != nil {
return err //nolint:wrapcheck
}
*ss = slice
return nil
default:
return fmt.Errorf("expected a string or a sequence of strings, but got %v", node.Kind)
}
}
// keyValuesField handles flexible YAML formats for key-value pairs.
// Supported formats:
// - Sequence of maps: [{key1: value1}, {key2: [value2, value3]}]
// - Single map: {key1: value1, key2: [value2, value3]}
//
// Values can be either a single string or an array of strings.
type keyValuesField []types.KeyValue[string, []string]
func (kv *keyValuesField) UnmarshalYAML(node *yaml.Node) error {
switch node.Kind {
case yaml.MappingNode:
// Handle single map: {key1: value1, key2: [value2]}
return kv.unmarshalMapping(node)
case yaml.SequenceNode:
// Handle sequence of maps: [{key1: value1}, {key2: value2}]
for _, item := range node.Content {
if item.Kind != yaml.MappingNode {
return fmt.Errorf("expected a mapping in sequence, but got %v", item.Kind)
}
if err := kv.unmarshalMapping(item); err != nil {
return err
}
}
return nil
default:
return fmt.Errorf("expected a mapping or sequence of mappings, but got %v", node.Kind)
}
}
func (kv *keyValuesField) unmarshalMapping(node *yaml.Node) error {
// MappingNode content is [key1, value1, key2, value2, ...]
for i := 0; i < len(node.Content); i += 2 {
keyNode := node.Content[i]
valueNode := node.Content[i+1]
if keyNode.Kind != yaml.ScalarNode {
return fmt.Errorf("expected a string key, but got %v", keyNode.Kind)
}
key := keyNode.Value
var values []string
switch valueNode.Kind {
case yaml.ScalarNode:
values = []string{valueNode.Value}
case yaml.SequenceNode:
for _, v := range valueNode.Content {
if v.Kind != yaml.ScalarNode {
return fmt.Errorf("expected string values in array for key %q, but got %v", key, v.Kind)
}
values = append(values, v.Value)
}
default:
return fmt.Errorf("expected a string or array of strings for key %q, but got %v", key, valueNode.Kind)
}
*kv = append(*kv, types.KeyValue[string, []string]{Key: key, Value: values})
}
return nil
}
type configYAML struct {
ConfigFiles stringOrSliceField `yaml:"configFile"`
Method stringOrSliceField `yaml:"method"`
URL *string `yaml:"url"`
Timeout *time.Duration `yaml:"timeout"`
Concurrency *uint `yaml:"concurrency"`
RequestCount *uint64 `yaml:"requests"`
Duration *time.Duration `yaml:"duration"`
Quiet *bool `yaml:"quiet"`
Output *string `yaml:"output"`
Insecure *bool `yaml:"insecure"`
ShowConfig *bool `yaml:"showConfig"`
DryRun *bool `yaml:"dryRun"`
Params keyValuesField `yaml:"params"`
Headers keyValuesField `yaml:"headers"`
Cookies keyValuesField `yaml:"cookies"`
Bodies stringOrSliceField `yaml:"body"`
Proxies stringOrSliceField `yaml:"proxy"`
Values stringOrSliceField `yaml:"values"`
}
// ParseYAML parses YAML config file arguments into a Config object.
// It can return the following errors:
// - types.UnmarshalError
// - types.FieldParseErrors
func (parser ConfigFileParser) ParseYAML(data []byte) (*Config, error) {
var (
config = &Config{}
parsedData = &configYAML{}
)
err := yaml.Unmarshal(data, &parsedData)
if err != nil {
return nil, types.NewUnmarshalError(err)
}
var fieldParseErrors []types.FieldParseError
config.Methods = append(config.Methods, parsedData.Method...)
config.Timeout = parsedData.Timeout
config.Concurrency = parsedData.Concurrency
config.Requests = parsedData.RequestCount
config.Duration = parsedData.Duration
config.ShowConfig = parsedData.ShowConfig
config.Quiet = parsedData.Quiet
if parsedData.Output != nil {
config.Output = common.ToPtr(ConfigOutputType(*parsedData.Output))
}
config.Insecure = parsedData.Insecure
config.DryRun = parsedData.DryRun
for _, kv := range parsedData.Params {
config.Params = append(config.Params, types.Param(kv))
}
for _, kv := range parsedData.Headers {
config.Headers = append(config.Headers, types.Header(kv))
}
for _, kv := range parsedData.Cookies {
config.Cookies = append(config.Cookies, types.Cookie(kv))
}
config.Bodies = append(config.Bodies, parsedData.Bodies...)
config.Values = append(config.Values, parsedData.Values...)
if len(parsedData.ConfigFiles) > 0 {
for _, configFile := range parsedData.ConfigFiles {
config.Files = append(config.Files, *types.ParseConfigFile(configFile))
}
}
if parsedData.URL != nil {
urlParsed, err := url.Parse(*parsedData.URL)
if err != nil {
fieldParseErrors = append(fieldParseErrors, types.NewFieldParseError("url", *parsedData.URL, err))
} else {
config.URL = urlParsed
}
}
for i, proxy := range parsedData.Proxies {
err := config.Proxies.Parse(proxy)
if err != nil {
fieldParseErrors = append(
fieldParseErrors,
types.NewFieldParseError(fmt.Sprintf("proxy[%d]", i), proxy, err),
)
}
}
if len(fieldParseErrors) > 0 {
return nil, types.NewFieldParseErrors(fieldParseErrors)
}
return config, nil
}

View File

@@ -0,0 +1,226 @@
package config
import (
"fmt"
"text/template"
"go.aykhans.me/sarin/internal/sarin"
"go.aykhans.me/sarin/internal/types"
)
func validateTemplateString(value string, funcMap template.FuncMap) error {
if value == "" {
return nil
}
_, err := template.New("").Funcs(funcMap).Parse(value)
if err != nil {
return fmt.Errorf("template parse error: %w", err)
}
return nil
}
func validateTemplateMethods(methods []string, funcMap template.FuncMap) []types.FieldValidationError {
var validationErrors []types.FieldValidationError
for i, method := range methods {
if err := validateTemplateString(method, funcMap); err != nil {
validationErrors = append(
validationErrors,
types.NewFieldValidationError(
fmt.Sprintf("Method[%d]", i),
method,
err,
),
)
}
}
return validationErrors
}
func validateTemplateParams(params types.Params, funcMap template.FuncMap) []types.FieldValidationError {
var validationErrors []types.FieldValidationError
for paramIndex, param := range params {
// Validate param key
if err := validateTemplateString(param.Key, funcMap); err != nil {
validationErrors = append(
validationErrors,
types.NewFieldValidationError(
fmt.Sprintf("Param[%d].Key", paramIndex),
param.Key,
err,
),
)
}
// Validate param values
for valueIndex, value := range param.Value {
if err := validateTemplateString(value, funcMap); err != nil {
validationErrors = append(
validationErrors,
types.NewFieldValidationError(
fmt.Sprintf("Param[%d].Value[%d]", paramIndex, valueIndex),
value,
err,
),
)
}
}
}
return validationErrors
}
func validateTemplateHeaders(headers types.Headers, funcMap template.FuncMap) []types.FieldValidationError {
var validationErrors []types.FieldValidationError
for headerIndex, header := range headers {
// Validate header key
if err := validateTemplateString(header.Key, funcMap); err != nil {
validationErrors = append(
validationErrors,
types.NewFieldValidationError(
fmt.Sprintf("Header[%d].Key", headerIndex),
header.Key,
err,
),
)
}
// Validate header values
for valueIndex, value := range header.Value {
if err := validateTemplateString(value, funcMap); err != nil {
validationErrors = append(
validationErrors,
types.NewFieldValidationError(
fmt.Sprintf("Header[%d].Value[%d]", headerIndex, valueIndex),
value,
err,
),
)
}
}
}
return validationErrors
}
func validateTemplateCookies(cookies types.Cookies, funcMap template.FuncMap) []types.FieldValidationError {
var validationErrors []types.FieldValidationError
for cookieIndex, cookie := range cookies {
// Validate cookie key
if err := validateTemplateString(cookie.Key, funcMap); err != nil {
validationErrors = append(
validationErrors,
types.NewFieldValidationError(
fmt.Sprintf("Cookie[%d].Key", cookieIndex),
cookie.Key,
err,
),
)
}
// Validate cookie values
for valueIndex, value := range cookie.Value {
if err := validateTemplateString(value, funcMap); err != nil {
validationErrors = append(
validationErrors,
types.NewFieldValidationError(
fmt.Sprintf("Cookie[%d].Value[%d]", cookieIndex, valueIndex),
value,
err,
),
)
}
}
}
return validationErrors
}
func validateTemplateBodies(bodies []string, funcMap template.FuncMap) []types.FieldValidationError {
var validationErrors []types.FieldValidationError
for i, body := range bodies {
if err := validateTemplateString(body, funcMap); err != nil {
validationErrors = append(
validationErrors,
types.NewFieldValidationError(
fmt.Sprintf("Body[%d]", i),
body,
err,
),
)
}
}
return validationErrors
}
func validateTemplateValues(values []string, funcMap template.FuncMap) []types.FieldValidationError {
var validationErrors []types.FieldValidationError
for i, value := range values {
if err := validateTemplateString(value, funcMap); err != nil {
validationErrors = append(
validationErrors,
types.NewFieldValidationError(
fmt.Sprintf("Values[%d]", i),
value,
err,
),
)
}
}
return validationErrors
}
func validateTemplateURLPath(urlPath string, funcMap template.FuncMap) []types.FieldValidationError {
if err := validateTemplateString(urlPath, funcMap); err != nil {
return []types.FieldValidationError{
types.NewFieldValidationError("URL.Path", urlPath, err),
}
}
return nil
}
func ValidateTemplates(config *Config) []types.FieldValidationError {
// Create template function map using the same functions as sarin package
randSource := sarin.NewDefaultRandSource()
funcMap := sarin.NewDefaultTemplateFuncMap(randSource)
bodyFuncMapData := &sarin.BodyTemplateFuncMapData{}
bodyFuncMap := sarin.NewDefaultBodyTemplateFuncMap(randSource, bodyFuncMapData)
var allErrors []types.FieldValidationError
// Validate URL path
if config.URL != nil {
allErrors = append(allErrors, validateTemplateURLPath(config.URL.Path, funcMap)...)
}
// Validate methods
allErrors = append(allErrors, validateTemplateMethods(config.Methods, funcMap)...)
// Validate params
allErrors = append(allErrors, validateTemplateParams(config.Params, funcMap)...)
// Validate headers
allErrors = append(allErrors, validateTemplateHeaders(config.Headers, funcMap)...)
// Validate cookies
allErrors = append(allErrors, validateTemplateCookies(config.Cookies, funcMap)...)
// Validate bodies
allErrors = append(allErrors, validateTemplateBodies(config.Bodies, bodyFuncMap)...)
// Validate values
allErrors = append(allErrors, validateTemplateValues(config.Values, funcMap)...)
return allErrors
}

310
internal/sarin/client.go Normal file
View File

@@ -0,0 +1,310 @@
package sarin
import (
"bufio"
"context"
"crypto/tls"
"encoding/base64"
"errors"
"math"
"net"
"net/http"
"net/url"
"time"
"github.com/valyala/fasthttp"
"github.com/valyala/fasthttp/fasthttpproxy"
"go.aykhans.me/sarin/internal/types"
utilsSlice "go.aykhans.me/utils/slice"
"golang.org/x/net/proxy"
)
type HostClientGenerator func() *fasthttp.HostClient
func safeUintToInt(u uint) int {
if u > math.MaxInt {
return math.MaxInt
}
return int(u)
}
// NewHostClients creates a list of fasthttp.HostClient instances for the given proxies.
// If no proxies are provided, a single client without a proxy is returned.
// It can return the following errors:
// - types.ProxyDialError
func NewHostClients(
ctx context.Context,
timeout time.Duration,
proxies []url.URL,
maxConns uint,
requestURL *url.URL,
skipVerify bool,
) ([]*fasthttp.HostClient, error) {
isTLS := requestURL.Scheme == "https"
if proxiesLen := len(proxies); proxiesLen > 0 {
clients := make([]*fasthttp.HostClient, 0, proxiesLen)
addr := requestURL.Host
if isTLS && requestURL.Port() == "" {
addr += ":443"
}
for _, proxy := range proxies {
dialFunc, err := NewProxyDialFunc(ctx, &proxy, timeout)
if err != nil {
return nil, types.NewProxyDialError(proxy.String(), err)
}
clients = append(clients, &fasthttp.HostClient{
MaxConns: safeUintToInt(maxConns),
IsTLS: isTLS,
TLSConfig: &tls.Config{
InsecureSkipVerify: skipVerify, //nolint:gosec
},
Addr: addr,
Dial: dialFunc,
MaxIdleConnDuration: timeout,
MaxConnDuration: timeout,
WriteTimeout: timeout,
ReadTimeout: timeout,
DisableHeaderNamesNormalizing: true,
DisablePathNormalizing: true,
NoDefaultUserAgentHeader: true,
},
)
}
return clients, nil
}
client := &fasthttp.HostClient{
MaxConns: safeUintToInt(maxConns),
IsTLS: isTLS,
TLSConfig: &tls.Config{
InsecureSkipVerify: skipVerify, //nolint:gosec
},
Addr: requestURL.Host,
MaxIdleConnDuration: timeout,
MaxConnDuration: timeout,
WriteTimeout: timeout,
ReadTimeout: timeout,
DisableHeaderNamesNormalizing: true,
DisablePathNormalizing: true,
NoDefaultUserAgentHeader: true,
}
return []*fasthttp.HostClient{client}, nil
}
func NewProxyDialFunc(ctx context.Context, proxyURL *url.URL, timeout time.Duration) (fasthttp.DialFunc, error) {
var (
dialer fasthttp.DialFunc
err error
)
switch proxyURL.Scheme {
case "socks5":
dialer, err = fasthttpSocksDialerDualStackTimeout(ctx, proxyURL, timeout, true)
if err != nil {
return nil, err
}
case "socks5h":
dialer, err = fasthttpSocksDialerDualStackTimeout(ctx, proxyURL, timeout, false)
if err != nil {
return nil, err
}
case "http":
dialer = fasthttpproxy.FasthttpHTTPDialerDualStackTimeout(proxyURL.String(), timeout)
case "https":
dialer = fasthttpHTTPSDialerDualStackTimeout(proxyURL, timeout)
default:
return nil, errors.New("unsupported proxy scheme")
}
if dialer == nil {
return nil, errors.New("internal error: proxy dialer is nil")
}
return dialer, nil
}
func fasthttpSocksDialerDualStackTimeout(ctx context.Context, proxyURL *url.URL, timeout time.Duration, resolveLocally bool) (fasthttp.DialFunc, error) {
netDialer := &net.Dialer{}
// Parse auth from proxy URL if present
var auth *proxy.Auth
if proxyURL.User != nil {
auth = &proxy.Auth{
User: proxyURL.User.Username(),
}
if password, ok := proxyURL.User.Password(); ok {
auth.Password = password
}
}
// Create SOCKS5 dialer with net.Dialer as forward dialer
socksDialer, err := proxy.SOCKS5("tcp", proxyURL.Host, auth, netDialer)
if err != nil {
return nil, err
}
// Assert to ContextDialer for timeout support
contextDialer, ok := socksDialer.(proxy.ContextDialer)
if !ok {
// Fallback without timeout (should not happen with net.Dialer)
return func(addr string) (net.Conn, error) {
return socksDialer.Dial("tcp", addr)
}, nil
}
// Return dial function that uses context with timeout
return func(addr string) (net.Conn, error) {
deadline := time.Now().Add(timeout)
if resolveLocally {
host, port, err := net.SplitHostPort(addr)
if err != nil {
return nil, err
}
// Cap DNS resolution to half the timeout to reserve time for dial
dnsCtx, dnsCancel := context.WithTimeout(ctx, timeout)
ips, err := net.DefaultResolver.LookupIP(dnsCtx, "ip", host)
dnsCancel()
if err != nil {
return nil, err
}
if len(ips) == 0 {
return nil, errors.New("no IP addresses found for host: " + host)
}
// Use the first resolved IP
addr = net.JoinHostPort(ips[0].String(), port)
}
// Use remaining time for dial
remaining := time.Until(deadline)
if remaining <= 0 {
return nil, context.DeadlineExceeded
}
dialCtx, dialCancel := context.WithTimeout(ctx, remaining)
defer dialCancel()
return contextDialer.DialContext(dialCtx, "tcp", addr)
}, nil
}
func fasthttpHTTPSDialerDualStackTimeout(proxyURL *url.URL, timeout time.Duration) fasthttp.DialFunc {
proxyAddr := proxyURL.Host
if proxyURL.Port() == "" {
proxyAddr = net.JoinHostPort(proxyURL.Hostname(), "443")
}
// Build Proxy-Authorization header if auth is present
var proxyAuth string
if proxyURL.User != nil {
username := proxyURL.User.Username()
password, _ := proxyURL.User.Password()
credentials := username + ":" + password
proxyAuth = "Basic " + base64.StdEncoding.EncodeToString([]byte(credentials))
}
return func(addr string) (net.Conn, error) {
// Establish TCP connection to proxy with timeout
start := time.Now()
conn, err := fasthttp.DialDualStackTimeout(proxyAddr, timeout)
if err != nil {
return nil, err
}
remaining := timeout - time.Since(start)
if remaining <= 0 {
conn.Close() //nolint:errcheck,gosec
return nil, context.DeadlineExceeded
}
// Set deadline for the TLS handshake and CONNECT request
if err := conn.SetDeadline(time.Now().Add(remaining)); err != nil {
conn.Close() //nolint:errcheck,gosec
return nil, err
}
// Upgrade to TLS
tlsConn := tls.Client(conn, &tls.Config{ //nolint:gosec
ServerName: proxyURL.Hostname(),
})
if err := tlsConn.Handshake(); err != nil {
tlsConn.Close() //nolint:errcheck,gosec
return nil, err
}
// Build and send CONNECT request
connectReq := &http.Request{
Method: http.MethodConnect,
URL: &url.URL{Opaque: addr},
Host: addr,
Header: make(http.Header),
}
if proxyAuth != "" {
connectReq.Header.Set("Proxy-Authorization", proxyAuth)
}
if err := connectReq.Write(tlsConn); err != nil {
tlsConn.Close() //nolint:errcheck,gosec
return nil, err
}
// Read response using buffered reader, but return wrapped connection
// to preserve any buffered data
bufReader := bufio.NewReader(tlsConn)
resp, err := http.ReadResponse(bufReader, connectReq)
if err != nil {
tlsConn.Close() //nolint:errcheck,gosec
return nil, err
}
resp.Body.Close() //nolint:errcheck,gosec
if resp.StatusCode != http.StatusOK {
tlsConn.Close() //nolint:errcheck,gosec
return nil, errors.New("proxy CONNECT failed: " + resp.Status)
}
// Clear deadline for the tunneled connection
if err := tlsConn.SetDeadline(time.Time{}); err != nil {
tlsConn.Close() //nolint:errcheck,gosec
return nil, err
}
// Return wrapped connection that uses the buffered reader
// to avoid losing any data that was read ahead
return &bufferedConn{Conn: tlsConn, reader: bufReader}, nil
}
}
// bufferedConn wraps a net.Conn with a buffered reader to preserve
// any data that was read during HTTP response parsing.
type bufferedConn struct {
net.Conn
reader *bufio.Reader
}
func (c *bufferedConn) Read(b []byte) (int, error) {
return c.reader.Read(b)
}
func NewHostClientGenerator(clients ...*fasthttp.HostClient) HostClientGenerator {
switch len(clients) {
case 0:
hostClient := &fasthttp.HostClient{}
return func() *fasthttp.HostClient {
return hostClient
}
case 1:
return func() *fasthttp.HostClient {
return clients[0]
}
default:
return utilsSlice.RandomCycle(nil, clients...)
}
}

14
internal/sarin/helpers.go Normal file
View File

@@ -0,0 +1,14 @@
package sarin
import (
"math/rand/v2"
"time"
)
func NewDefaultRandSource() rand.Source {
now := time.Now().UnixNano()
return rand.NewPCG(
uint64(now), //nolint:gosec // G115: Safe conversion; UnixNano timestamp used as random seed, bit pattern is intentional
uint64(now>>32), //nolint:gosec // G115: Safe conversion; right-shifted timestamp for seed entropy, overflow is acceptable
)
}

348
internal/sarin/request.go Normal file
View File

@@ -0,0 +1,348 @@
package sarin
import (
"bytes"
"fmt"
"maps"
"math/rand/v2"
"net/url"
"strings"
"text/template"
"github.com/joho/godotenv"
"github.com/valyala/fasthttp"
"go.aykhans.me/sarin/internal/types"
utilsSlice "go.aykhans.me/utils/slice"
)
type RequestGenerator func(*fasthttp.Request) error
type RequestGeneratorWithData func(*fasthttp.Request, any) error
type valuesData struct {
Values map[string]string
}
// NewRequestGenerator creates a new RequestGenerator function that generates HTTP requests
// with the specified configuration. The returned RequestGenerator is NOT safe for concurrent
// use by multiple goroutines.
func NewRequestGenerator(
methods []string,
requestURL *url.URL,
params types.Params,
headers types.Headers,
cookies types.Cookies,
bodies []string,
values []string,
) (RequestGenerator, bool) {
randSource := NewDefaultRandSource()
//nolint:gosec // G404: Using non-cryptographic rand for load testing, not security
localRand := rand.New(randSource)
templateFuncMap := NewDefaultTemplateFuncMap(randSource)
pathGenerator, isPathGeneratorDynamic := createTemplateFunc(requestURL.Path, templateFuncMap)
methodGenerator, isMethodGeneratorDynamic := NewMethodGeneratorFunc(localRand, methods, templateFuncMap)
paramsGenerator, isParamsGeneratorDynamic := NewParamsGeneratorFunc(localRand, params, templateFuncMap)
headersGenerator, isHeadersGeneratorDynamic := NewHeadersGeneratorFunc(localRand, headers, templateFuncMap)
cookiesGenerator, isCookiesGeneratorDynamic := NewCookiesGeneratorFunc(localRand, cookies, templateFuncMap)
bodyTemplateFuncMapData := &BodyTemplateFuncMapData{}
bodyTemplateFuncMap := NewDefaultBodyTemplateFuncMap(randSource, bodyTemplateFuncMapData)
bodyGenerator, isBodyGeneratorDynamic := NewBodyGeneratorFunc(localRand, bodies, bodyTemplateFuncMap)
valuesGenerator := NewValuesGeneratorFunc(values, templateFuncMap)
var (
data valuesData
path string
err error
)
return func(req *fasthttp.Request) error {
req.Header.SetHost(requestURL.Host)
data, err = valuesGenerator()
if err != nil {
return err
}
path, err = pathGenerator(data)
if err != nil {
return err
}
req.SetRequestURI(path)
if err = methodGenerator(req, data); err != nil {
return err
}
bodyTemplateFuncMapData.ClearFormDataContenType()
if err = bodyGenerator(req, data); err != nil {
return err
}
if err = headersGenerator(req, data); err != nil {
return err
}
if bodyTemplateFuncMapData.GetFormDataContenType() != "" {
req.Header.Add("Content-Type", bodyTemplateFuncMapData.GetFormDataContenType())
}
if err = paramsGenerator(req, data); err != nil {
return err
}
if err = cookiesGenerator(req, data); err != nil {
return err
}
if requestURL.Scheme == "https" {
req.URI().SetScheme("https")
}
return nil
}, isPathGeneratorDynamic ||
isMethodGeneratorDynamic ||
isParamsGeneratorDynamic ||
isHeadersGeneratorDynamic ||
isCookiesGeneratorDynamic ||
isBodyGeneratorDynamic
}
func NewMethodGeneratorFunc(localRand *rand.Rand, methods []string, templateFunctions template.FuncMap) (RequestGeneratorWithData, bool) {
methodGenerator, isDynamic := buildStringSliceGenerator(localRand, methods, templateFunctions)
var (
method string
err error
)
return func(req *fasthttp.Request, data any) error {
method, err = methodGenerator()(data)
if err != nil {
return err
}
req.Header.SetMethod(method)
return nil
}, isDynamic
}
func NewBodyGeneratorFunc(localRand *rand.Rand, bodies []string, templateFunctions template.FuncMap) (RequestGeneratorWithData, bool) {
bodyGenerator, isDynamic := buildStringSliceGenerator(localRand, bodies, templateFunctions)
var (
body string
err error
)
return func(req *fasthttp.Request, data any) error {
body, err = bodyGenerator()(data)
if err != nil {
return err
}
req.SetBody([]byte(body))
return nil
}, isDynamic
}
func NewParamsGeneratorFunc(localRand *rand.Rand, params types.Params, templateFunctions template.FuncMap) (RequestGeneratorWithData, bool) {
generators, isDynamic := buildKeyValueGenerators(localRand, params, templateFunctions)
var (
key, value string
err error
)
return func(req *fasthttp.Request, data any) error {
for _, gen := range generators {
key, err = gen.Key(data)
if err != nil {
return err
}
value, err = gen.Value()(data)
if err != nil {
return err
}
req.URI().QueryArgs().Add(key, value)
}
return nil
}, isDynamic
}
func NewHeadersGeneratorFunc(localRand *rand.Rand, headers types.Headers, templateFunctions template.FuncMap) (RequestGeneratorWithData, bool) {
generators, isDynamic := buildKeyValueGenerators(localRand, headers, templateFunctions)
var (
key, value string
err error
)
return func(req *fasthttp.Request, data any) error {
for _, gen := range generators {
key, err = gen.Key(data)
if err != nil {
return err
}
value, err = gen.Value()(data)
if err != nil {
return err
}
req.Header.Add(key, value)
}
return nil
}, isDynamic
}
func NewCookiesGeneratorFunc(localRand *rand.Rand, cookies types.Cookies, templateFunctions template.FuncMap) (RequestGeneratorWithData, bool) {
generators, isDynamic := buildKeyValueGenerators(localRand, cookies, templateFunctions)
var (
key, value string
err error
)
if len(generators) > 0 {
return func(req *fasthttp.Request, data any) error {
cookieStrings := make([]string, 0, len(generators))
for _, gen := range generators {
key, err = gen.Key(data)
if err != nil {
return err
}
value, err = gen.Value()(data)
if err != nil {
return err
}
cookieStrings = append(cookieStrings, key+"="+value)
}
req.Header.Add("Cookie", strings.Join(cookieStrings, "; "))
return nil
}, isDynamic
}
return func(req *fasthttp.Request, data any) error {
return nil
}, isDynamic
}
func NewValuesGeneratorFunc(values []string, templateFunctions template.FuncMap) func() (valuesData, error) {
generators := make([]func(_ any) (string, error), len(values))
for i, v := range values {
generators[i], _ = createTemplateFunc(v, templateFunctions)
}
var (
rendered string
data map[string]string
err error
)
return func() (valuesData, error) {
result := make(map[string]string)
for _, generator := range generators {
rendered, err = generator(nil)
if err != nil {
return valuesData{}, fmt.Errorf("values rendering: %w", err)
}
data, err = godotenv.Unmarshal(rendered)
if err != nil {
return valuesData{}, fmt.Errorf("values rendering: %w", err)
}
maps.Copy(result, data)
}
return valuesData{Values: result}, nil
}
}
func createTemplateFunc(value string, templateFunctions template.FuncMap) (func(data any) (string, error), bool) {
tmpl, err := template.New("").Funcs(templateFunctions).Parse(value)
if err == nil && hasTemplateActions(tmpl) {
var err error
return func(data any) (string, error) {
var buf bytes.Buffer
if err = tmpl.Execute(&buf, data); err != nil {
return "", fmt.Errorf("template rendering: %w", err)
}
return buf.String(), nil
}, true
}
return func(_ any) (string, error) { return value, nil }, false
}
type keyValueGenerator struct {
Key func(data any) (string, error)
Value func() func(data any) (string, error)
}
type keyValueItem interface {
types.Param | types.Header | types.Cookie
}
func buildKeyValueGenerators[T keyValueItem](
localRand *rand.Rand,
items []T,
templateFunctions template.FuncMap,
) ([]keyValueGenerator, bool) {
isDynamic := false
generators := make([]keyValueGenerator, len(items))
for generatorIndex, item := range items {
// Convert to KeyValue to access fields
keyValue := types.KeyValue[string, []string](item)
// Generate key function
keyFunc, keyIsDynamic := createTemplateFunc(keyValue.Key, templateFunctions)
if keyIsDynamic {
isDynamic = true
}
// Generate value functions
valueFuncs := make([]func(data any) (string, error), len(keyValue.Value))
for j, v := range keyValue.Value {
valueFunc, valueIsDynamic := createTemplateFunc(v, templateFunctions)
if valueIsDynamic {
isDynamic = true
}
valueFuncs[j] = valueFunc
}
generators[generatorIndex] = keyValueGenerator{
Key: keyFunc,
Value: utilsSlice.RandomCycle(localRand, valueFuncs...),
}
if len(keyValue.Value) > 1 {
isDynamic = true
}
}
return generators, isDynamic
}
func buildStringSliceGenerator(
localRand *rand.Rand,
values []string,
templateFunctions template.FuncMap,
) (func() func(data any) (string, error), bool) {
// Return a function that returns an empty string generator if values is empty
if len(values) == 0 {
emptyFunc := func(_ any) (string, error) { return "", nil }
return func() func(_ any) (string, error) { return emptyFunc }, false
}
isDynamic := len(values) > 1
valueFuncs := make([]func(data any) (string, error), len(values))
for i, value := range values {
valueFunc, valueIsDynamic := createTemplateFunc(value, templateFunctions)
if valueIsDynamic {
isDynamic = true
}
valueFuncs[i] = valueFunc
}
return utilsSlice.RandomCycle(localRand, valueFuncs...), isDynamic
}

348
internal/sarin/response.go Normal file
View File

@@ -0,0 +1,348 @@
package sarin
import (
"encoding/json"
"fmt"
"math/big"
"os"
"slices"
"strings"
"sync"
"time"
"github.com/charmbracelet/lipgloss"
"github.com/charmbracelet/lipgloss/table"
"go.yaml.in/yaml/v4"
)
const DefaultResponseDurationAccuracy uint32 = 1
const DefaultResponseColumnMaxWidth = 50
// Duration wraps time.Duration to provide consistent JSON/YAML marshaling as human-readable strings.
type Duration time.Duration
func (d Duration) MarshalJSON() ([]byte, error) {
//nolint:wrapcheck
return json.Marshal(time.Duration(d).String())
}
func (d Duration) MarshalYAML() (any, error) {
return time.Duration(d).String(), nil
}
func (d Duration) String() string {
dur := time.Duration(d)
switch {
case dur >= time.Second:
return dur.Round(time.Millisecond).String()
case dur >= time.Millisecond:
return dur.Round(time.Microsecond).String()
default:
return dur.String()
}
}
// BigInt wraps big.Int to provide consistent JSON/YAML marshaling as numbers.
type BigInt struct {
*big.Int
}
func (b BigInt) MarshalJSON() ([]byte, error) {
return []byte(b.Int.String()), nil
}
func (b BigInt) MarshalYAML() (any, error) {
return &yaml.Node{
Kind: yaml.ScalarNode,
Tag: "!!int",
Value: b.Int.String(),
}, nil
}
func (b BigInt) String() string {
return b.Int.String()
}
type Response struct {
durations map[time.Duration]uint64
}
type SarinResponseData struct {
sync.Mutex
Responses map[string]*Response
// accuracy is the time bucket size in nanoseconds for storing response durations.
// Larger values (e.g., 1000) save memory but reduce accuracy by grouping more durations together.
// Smaller values (e.g., 10) improve accuracy but increase memory usage.
// Minimum value is 1 (most accurate, highest memory usage).
// Default value is 1.
accuracy time.Duration
}
func NewSarinResponseData(accuracy uint32) *SarinResponseData {
if accuracy == 0 {
accuracy = DefaultResponseDurationAccuracy
}
return &SarinResponseData{
Responses: make(map[string]*Response),
accuracy: time.Duration(accuracy),
}
}
func (data *SarinResponseData) Add(responseKey string, responseTime time.Duration) {
data.Lock()
defer data.Unlock()
response, ok := data.Responses[responseKey]
if !ok {
data.Responses[responseKey] = &Response{
durations: map[time.Duration]uint64{
responseTime / data.accuracy: 1,
},
}
} else {
response.durations[responseTime/data.accuracy]++
}
}
func (data *SarinResponseData) PrintTable() {
data.Lock()
defer data.Unlock()
output := data.prepareOutputData()
headerStyle := lipgloss.NewStyle().
Bold(true).
Foreground(lipgloss.Color("246")).
Padding(0, 1)
cellStyle := lipgloss.NewStyle().
Padding(0, 1)
rows := make([][]string, 0, len(output.Responses)+1)
for key, stats := range output.Responses {
rows = append(rows, []string{
wrapText(key, DefaultResponseColumnMaxWidth),
stats.Count.String(),
stats.Min.String(),
stats.Max.String(),
stats.Average.String(),
stats.P90.String(),
stats.P95.String(),
stats.P99.String(),
})
}
rows = append(rows, []string{
"Total",
output.Total.Count.String(),
output.Total.Min.String(),
output.Total.Max.String(),
output.Total.Average.String(),
output.Total.P90.String(),
output.Total.P95.String(),
output.Total.P99.String(),
})
tbl := table.New().
Border(lipgloss.NormalBorder()).
BorderStyle(lipgloss.NewStyle().Foreground(lipgloss.Color("240"))).
BorderRow(true).
Headers("Response", "Count", "Min", "Max", "Average", "P90", "P95", "P99").
Rows(rows...).
StyleFunc(func(row, col int) lipgloss.Style {
if row == table.HeaderRow {
return headerStyle
}
return cellStyle
})
fmt.Println(tbl)
}
func (data *SarinResponseData) PrintJSON() {
data.Lock()
defer data.Unlock()
output := data.prepareOutputData()
encoder := json.NewEncoder(os.Stdout)
encoder.SetIndent("", " ")
if err := encoder.Encode(output); err != nil {
panic(err)
}
}
func (data *SarinResponseData) PrintYAML() {
data.Lock()
defer data.Unlock()
output := data.prepareOutputData()
encoder := yaml.NewEncoder(os.Stdout)
encoder.SetIndent(2)
if err := encoder.Encode(output); err != nil {
panic(err)
}
}
type responseStat struct {
Count BigInt `json:"count" yaml:"count"`
Min Duration `json:"min" yaml:"min"`
Max Duration `json:"max" yaml:"max"`
Average Duration `json:"average" yaml:"average"`
P90 Duration `json:"p90" yaml:"p90"`
P95 Duration `json:"p95" yaml:"p95"`
P99 Duration `json:"p99" yaml:"p99"`
}
type responseStats map[string]responseStat
type outputData struct {
Responses map[string]responseStat `json:"responses" yaml:"responses"`
Total responseStat `json:"total" yaml:"total"`
}
func (data *SarinResponseData) prepareOutputData() outputData {
switch len(data.Responses) {
case 0:
return outputData{
Responses: make(map[string]responseStat),
Total: responseStat{},
}
case 1:
var (
responseKey string
stats responseStat
)
for key, response := range data.Responses {
stats = calculateStats(response.durations, data.accuracy)
responseKey = key
}
return outputData{
Responses: responseStats{
responseKey: stats,
},
Total: stats,
}
default:
// Calculate stats for each response
allStats := make(responseStats)
var totalDurations = make(map[time.Duration]uint64)
for key, response := range data.Responses {
stats := calculateStats(response.durations, data.accuracy)
allStats[key] = stats
// Aggregate for total row
for duration, count := range response.durations {
totalDurations[duration] += count
}
}
return outputData{
Responses: allStats,
Total: calculateStats(totalDurations, data.accuracy),
}
}
}
func calculateStats(durations map[time.Duration]uint64, accuracy time.Duration) responseStat {
if len(durations) == 0 {
return responseStat{}
}
// Extract and sort unique durations
sortedDurations := make([]time.Duration, 0, len(durations))
for duration := range durations {
sortedDurations = append(sortedDurations, duration)
}
slices.Sort(sortedDurations)
sum := new(big.Int)
totalCount := new(big.Int)
minDuration := sortedDurations[0] * accuracy
maxDuration := sortedDurations[len(sortedDurations)-1] * accuracy
for _, duration := range sortedDurations {
actualDuration := duration * accuracy
count := durations[duration]
totalCount.Add(
totalCount,
new(big.Int).SetUint64(count),
)
sum.Add(
sum,
new(big.Int).Mul(
new(big.Int).SetInt64(int64(actualDuration)),
new(big.Int).SetUint64(count),
),
)
}
// Calculate percentiles
p90 := calculatePercentile(sortedDurations, durations, totalCount, 90, accuracy)
p95 := calculatePercentile(sortedDurations, durations, totalCount, 95, accuracy)
p99 := calculatePercentile(sortedDurations, durations, totalCount, 99, accuracy)
return responseStat{
Count: BigInt{totalCount},
Min: Duration(minDuration),
Max: Duration(maxDuration),
Average: Duration(div(sum, totalCount).Int64()),
P90: p90,
P95: p95,
P99: p99,
}
}
func calculatePercentile(sortedDurations []time.Duration, durations map[time.Duration]uint64, totalCount *big.Int, percentile int, accuracy time.Duration) Duration {
// Calculate the target position for the percentile
// Using ceiling method: position = ceil(totalCount * percentile / 100)
target := new(big.Int).Mul(totalCount, big.NewInt(int64(percentile)))
target.Add(target, big.NewInt(99)) // Add 99 to achieve ceiling division by 100
target.Div(target, big.NewInt(100))
// Accumulate counts until we reach the target position
cumulative := new(big.Int)
for _, duration := range sortedDurations {
count := durations[duration]
cumulative.Add(cumulative, new(big.Int).SetUint64(count))
if cumulative.Cmp(target) >= 0 {
return Duration(duration * accuracy)
}
}
// Fallback to the last duration (shouldn't happen with valid data)
return Duration(sortedDurations[len(sortedDurations)-1] * accuracy)
}
// div performs division with rounding to the nearest integer.
func div(x, y *big.Int) *big.Int {
quotient, remainder := new(big.Int).DivMod(x, y, new(big.Int))
if remainder.Mul(remainder, big.NewInt(2)).Cmp(y) >= 0 {
quotient.Add(quotient, big.NewInt(1))
}
return quotient
}
// wrapText wraps a string to multiple lines if it exceeds maxWidth.
func wrapText(s string, maxWidth int) string {
if len(s) <= maxWidth {
return s
}
var lines []string
for len(s) > maxWidth {
lines = append(lines, s[:maxWidth])
s = s[maxWidth:]
}
if len(s) > 0 {
lines = append(lines, s)
}
return strings.Join(lines, "\n")
}

776
internal/sarin/sarin.go Normal file
View File

@@ -0,0 +1,776 @@
package sarin
import (
"context"
"net/url"
"strconv"
"strings"
"sync"
"sync/atomic"
"time"
"github.com/charmbracelet/bubbles/progress"
"github.com/charmbracelet/bubbles/spinner"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
"github.com/valyala/fasthttp"
"go.aykhans.me/sarin/internal/types"
)
type runtimeMessageLevel uint8
const (
runtimeMessageLevelWarning runtimeMessageLevel = iota
runtimeMessageLevelError
)
type runtimeMessage struct {
timestamp time.Time
level runtimeMessageLevel
text string
}
type messageSender func(level runtimeMessageLevel, text string)
type sarin struct {
workers uint
requestURL *url.URL
methods []string
params types.Params
headers types.Headers
cookies types.Cookies
bodies []string
totalRequests *uint64
totalDuration *time.Duration
timeout time.Duration
quiet bool
skipCertVerify bool
values []string
collectStats bool
dryRun bool
hostClients []*fasthttp.HostClient
responses *SarinResponseData
}
// NewSarin creates a new sarin instance for load testing.
// It can return the following errors:
// - types.ProxyDialError
func NewSarin(
ctx context.Context,
methods []string,
requestURL *url.URL,
timeout time.Duration,
workers uint,
totalRequests *uint64,
totalDuration *time.Duration,
quiet bool,
skipCertVerify bool,
params types.Params,
headers types.Headers,
cookies types.Cookies,
bodies []string,
proxies types.Proxies,
values []string,
collectStats bool,
dryRun bool,
) (*sarin, error) {
if workers == 0 {
workers = 1
}
hostClients, err := newHostClients(ctx, timeout, proxies, workers, requestURL, skipCertVerify)
if err != nil {
return nil, err
}
srn := &sarin{
workers: workers,
requestURL: requestURL,
methods: methods,
params: params,
headers: headers,
cookies: cookies,
bodies: bodies,
totalRequests: totalRequests,
totalDuration: totalDuration,
timeout: timeout,
quiet: quiet,
skipCertVerify: skipCertVerify,
values: values,
collectStats: collectStats,
dryRun: dryRun,
hostClients: hostClients,
}
if collectStats {
srn.responses = NewSarinResponseData(uint32(100))
}
return srn, nil
}
func (q sarin) GetResponses() *SarinResponseData {
return q.responses
}
func (q sarin) Start(ctx context.Context) {
jobsCtx, jobsCancel := context.WithCancel(ctx)
var workersWG sync.WaitGroup
jobsCh := make(chan struct{}, max(q.workers, 1))
var counter atomic.Uint64
totalRequests := uint64(0)
if q.totalRequests != nil {
totalRequests = *q.totalRequests
}
var streamCtx context.Context
var streamCancel context.CancelFunc
var streamCh chan struct{}
var messageChannel chan runtimeMessage
var sendMessage messageSender
if q.quiet {
sendMessage = func(level runtimeMessageLevel, text string) {}
} else {
streamCtx, streamCancel = context.WithCancel(context.Background())
defer streamCancel()
streamCh = make(chan struct{})
messageChannel = make(chan runtimeMessage, max(q.workers, 1))
sendMessage = func(level runtimeMessageLevel, text string) {
messageChannel <- runtimeMessage{
timestamp: time.Now(),
level: level,
text: text,
}
}
}
// Start workers
q.startWorkers(&workersWG, jobsCh, q.hostClients, &counter, sendMessage)
if !q.quiet {
// Start streaming to terminal
//nolint:contextcheck // streamCtx must remain active until all workers complete to ensure all collected data is streamed
go q.streamProgress(streamCtx, jobsCancel, streamCh, totalRequests, &counter, messageChannel)
}
// Setup duration-based cancellation
q.setupDurationTimeout(ctx, jobsCancel)
// Distribute jobs to workers.
// This blocks until all jobs are sent or the context is canceled.
q.sendJobs(jobsCtx, jobsCh)
// Close the jobs channel so workers stop after completing their current job
close(jobsCh)
// Wait until all workers stopped
workersWG.Wait()
if messageChannel != nil {
close(messageChannel)
}
if !q.quiet {
// Stop the progress streaming
streamCancel()
// Wait until progress streaming has completely stopped
<-streamCh
}
}
func (q sarin) Worker(
jobs <-chan struct{},
hostClientGenerator HostClientGenerator,
counter *atomic.Uint64,
sendMessage messageSender,
) {
req := fasthttp.AcquireRequest()
resp := fasthttp.AcquireResponse()
defer fasthttp.ReleaseRequest(req)
defer fasthttp.ReleaseResponse(resp)
requestGenerator, isDynamic := NewRequestGenerator(q.methods, q.requestURL, q.params, q.headers, q.cookies, q.bodies, q.values)
if q.dryRun {
switch {
case q.collectStats && isDynamic:
q.workerDryRunStatsWithDynamic(jobs, req, requestGenerator, counter, sendMessage)
case q.collectStats && !isDynamic:
q.workerDryRunStatsWithStatic(jobs, req, requestGenerator, counter, sendMessage)
case !q.collectStats && isDynamic:
q.workerDryRunNoStatsWithDynamic(jobs, req, requestGenerator, counter, sendMessage)
default:
q.workerDryRunNoStatsWithStatic(jobs, req, requestGenerator, counter, sendMessage)
}
} else {
switch {
case q.collectStats && isDynamic:
q.workerStatsWithDynamic(jobs, req, resp, requestGenerator, hostClientGenerator, counter, sendMessage)
case q.collectStats && !isDynamic:
q.workerStatsWithStatic(jobs, req, resp, requestGenerator, hostClientGenerator, counter, sendMessage)
case !q.collectStats && isDynamic:
q.workerNoStatsWithDynamic(jobs, req, resp, requestGenerator, hostClientGenerator, counter, sendMessage)
default:
q.workerNoStatsWithStatic(jobs, req, resp, requestGenerator, hostClientGenerator, counter, sendMessage)
}
}
}
func (q sarin) workerStatsWithDynamic(
jobs <-chan struct{},
req *fasthttp.Request,
resp *fasthttp.Response,
requestGenerator RequestGenerator,
hostClientGenerator HostClientGenerator,
counter *atomic.Uint64,
sendMessage messageSender,
) {
for range jobs {
req.Reset()
resp.Reset()
if err := requestGenerator(req); err != nil {
q.responses.Add(err.Error(), 0)
sendMessage(runtimeMessageLevelError, err.Error())
counter.Add(1)
continue
}
startTime := time.Now()
err := hostClientGenerator().DoTimeout(req, resp, q.timeout)
if err != nil {
q.responses.Add(err.Error(), time.Since(startTime))
} else {
q.responses.Add(statusCodeToString(resp.StatusCode()), time.Since(startTime))
}
counter.Add(1)
}
}
func (q sarin) workerStatsWithStatic(
jobs <-chan struct{},
req *fasthttp.Request,
resp *fasthttp.Response,
requestGenerator RequestGenerator,
hostClientGenerator HostClientGenerator,
counter *atomic.Uint64,
sendMessage messageSender,
) {
if err := requestGenerator(req); err != nil {
// Static request generation failed - record all jobs as errors
for range jobs {
q.responses.Add(err.Error(), 0)
sendMessage(runtimeMessageLevelError, err.Error())
counter.Add(1)
}
return
}
for range jobs {
resp.Reset()
startTime := time.Now()
err := hostClientGenerator().DoTimeout(req, resp, q.timeout)
if err != nil {
q.responses.Add(err.Error(), time.Since(startTime))
} else {
q.responses.Add(statusCodeToString(resp.StatusCode()), time.Since(startTime))
}
counter.Add(1)
}
}
func (q sarin) workerNoStatsWithDynamic(
jobs <-chan struct{},
req *fasthttp.Request,
resp *fasthttp.Response,
requestGenerator RequestGenerator,
hostClientGenerator HostClientGenerator,
counter *atomic.Uint64,
sendMessage messageSender,
) {
for range jobs {
req.Reset()
resp.Reset()
if err := requestGenerator(req); err != nil {
sendMessage(runtimeMessageLevelError, err.Error())
counter.Add(1)
continue
}
_ = hostClientGenerator().DoTimeout(req, resp, q.timeout)
counter.Add(1)
}
}
func (q sarin) workerNoStatsWithStatic(
jobs <-chan struct{},
req *fasthttp.Request,
resp *fasthttp.Response,
requestGenerator RequestGenerator,
hostClientGenerator HostClientGenerator,
counter *atomic.Uint64,
sendMessage messageSender,
) {
if err := requestGenerator(req); err != nil {
sendMessage(runtimeMessageLevelError, err.Error())
// Static request generation failed - just count the jobs without sending
for range jobs {
counter.Add(1)
}
return
}
for range jobs {
resp.Reset()
_ = hostClientGenerator().DoTimeout(req, resp, q.timeout)
counter.Add(1)
}
}
const dryRunResponseKey = "dry-run"
// statusCodeStrings contains pre-computed string representations for HTTP status codes 100-599.
var statusCodeStrings = func() map[int]string {
m := make(map[int]string, 500)
for i := 100; i < 600; i++ {
m[i] = strconv.Itoa(i)
}
return m
}()
// statusCodeToString returns a string representation of the HTTP status code.
// Uses a pre-computed map for codes 100-599, falls back to strconv.Itoa for others.
func statusCodeToString(code int) string {
if s, ok := statusCodeStrings[code]; ok {
return s
}
return strconv.Itoa(code)
}
func (q sarin) workerDryRunStatsWithDynamic(
jobs <-chan struct{},
req *fasthttp.Request,
requestGenerator RequestGenerator,
counter *atomic.Uint64,
sendMessage messageSender,
) {
for range jobs {
req.Reset()
startTime := time.Now()
if err := requestGenerator(req); err != nil {
q.responses.Add(err.Error(), time.Since(startTime))
sendMessage(runtimeMessageLevelError, err.Error())
counter.Add(1)
continue
}
q.responses.Add(dryRunResponseKey, time.Since(startTime))
counter.Add(1)
}
}
func (q sarin) workerDryRunStatsWithStatic(
jobs <-chan struct{},
req *fasthttp.Request,
requestGenerator RequestGenerator,
counter *atomic.Uint64,
sendMessage messageSender,
) {
if err := requestGenerator(req); err != nil {
// Static request generation failed - record all jobs as errors
for range jobs {
q.responses.Add(err.Error(), 0)
sendMessage(runtimeMessageLevelError, err.Error())
counter.Add(1)
}
return
}
for range jobs {
q.responses.Add(dryRunResponseKey, 0)
counter.Add(1)
}
}
func (q sarin) workerDryRunNoStatsWithDynamic(
jobs <-chan struct{},
req *fasthttp.Request,
requestGenerator RequestGenerator,
counter *atomic.Uint64,
sendMessage messageSender,
) {
for range jobs {
req.Reset()
if err := requestGenerator(req); err != nil {
sendMessage(runtimeMessageLevelError, err.Error())
}
counter.Add(1)
}
}
func (q sarin) workerDryRunNoStatsWithStatic(
jobs <-chan struct{},
req *fasthttp.Request,
requestGenerator RequestGenerator,
counter *atomic.Uint64,
sendMessage messageSender,
) {
if err := requestGenerator(req); err != nil {
sendMessage(runtimeMessageLevelError, err.Error())
}
for range jobs {
counter.Add(1)
}
}
// newHostClients initializes HTTP clients for the given configuration.
// It can return the following errors:
// - types.ProxyDialError
func newHostClients(
ctx context.Context,
timeout time.Duration,
proxies types.Proxies,
workers uint,
requestURL *url.URL,
skipCertVerify bool,
) ([]*fasthttp.HostClient, error) {
proxiesRaw := make([]url.URL, len(proxies))
for i, proxy := range proxies {
proxiesRaw[i] = url.URL(proxy)
}
maxConns := max(fasthttp.DefaultMaxConnsPerHost, workers)
maxConns = ((maxConns * 50 / 100) + maxConns)
return NewHostClients(
ctx,
timeout,
proxiesRaw,
maxConns,
requestURL,
skipCertVerify,
)
}
func (q sarin) startWorkers(wg *sync.WaitGroup, jobs <-chan struct{}, hostClients []*fasthttp.HostClient, counter *atomic.Uint64, sendMessage messageSender) {
for range max(q.workers, 1) {
wg.Go(func() {
q.Worker(jobs, NewHostClientGenerator(hostClients...), counter, sendMessage)
})
}
}
func (q sarin) setupDurationTimeout(ctx context.Context, cancel context.CancelFunc) {
if q.totalDuration != nil {
go func() {
timer := time.NewTimer(*q.totalDuration)
defer timer.Stop()
select {
case <-timer.C:
cancel()
case <-ctx.Done():
// Context cancelled, cleanup
}
}()
}
}
func (q sarin) sendJobs(ctx context.Context, jobs chan<- struct{}) {
if q.totalRequests != nil && *q.totalRequests > 0 {
for range *q.totalRequests {
if ctx.Err() != nil {
break
}
jobs <- struct{}{}
}
} else {
for ctx.Err() == nil {
jobs <- struct{}{}
}
}
}
type tickMsg time.Time
var (
helpStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#d1d1d1"))
errorStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#FC5B5B")).Bold(true)
warningStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#FFD93D")).Bold(true)
messageChannelStyle = lipgloss.NewStyle().
Border(lipgloss.ThickBorder(), false, false, false, true).
BorderForeground(lipgloss.Color("#757575")).
PaddingLeft(1).
Margin(1, 0, 0, 0).
Foreground(lipgloss.Color("#888888"))
)
type progressModel struct {
progress progress.Model
startTime time.Time
messages []string
counter *atomic.Uint64
current uint64
maxValue uint64
ctx context.Context //nolint:containedctx
cancel context.CancelFunc
cancelling bool
}
func (m progressModel) Init() tea.Cmd {
return tea.Batch(progressTickCmd())
}
func (m progressModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.KeyMsg:
if msg.Type == tea.KeyCtrlC {
m.cancelling = true
m.cancel()
}
return m, nil
case tea.WindowSizeMsg:
m.progress.Width = max(10, msg.Width-1)
if m.ctx.Err() != nil {
return m, tea.Quit
}
return m, nil
case runtimeMessage:
var msgBuilder strings.Builder
msgBuilder.WriteString("[")
msgBuilder.WriteString(msg.timestamp.Format("15:04:05"))
msgBuilder.WriteString("] ")
switch msg.level {
case runtimeMessageLevelError:
msgBuilder.WriteString(errorStyle.Render("ERROR: "))
case runtimeMessageLevelWarning:
msgBuilder.WriteString(warningStyle.Render("WARNING: "))
}
msgBuilder.WriteString(msg.text)
m.messages = append(m.messages[1:], msgBuilder.String())
if m.ctx.Err() != nil {
return m, tea.Quit
}
return m, nil
case tickMsg:
if m.ctx.Err() != nil {
return m, tea.Quit
}
return m, progressTickCmd()
default:
if m.ctx.Err() != nil {
return m, tea.Quit
}
return m, nil
}
}
func (m progressModel) View() string {
var messagesBuilder strings.Builder
for i, msg := range m.messages {
if len(msg) > 0 {
messagesBuilder.WriteString(msg)
if i < len(m.messages)-1 {
messagesBuilder.WriteString("\n")
}
}
}
var finalBuilder strings.Builder
if messagesBuilder.Len() > 0 {
finalBuilder.WriteString(messageChannelStyle.Render(messagesBuilder.String()))
finalBuilder.WriteString("\n")
}
m.current = m.counter.Load()
finalBuilder.WriteString("\n ")
finalBuilder.WriteString(strconv.FormatUint(m.current, 10))
finalBuilder.WriteString("/")
finalBuilder.WriteString(strconv.FormatUint(m.maxValue, 10))
finalBuilder.WriteString(" - ")
finalBuilder.WriteString(time.Since(m.startTime).Round(time.Second / 10).String())
finalBuilder.WriteString("\n ")
finalBuilder.WriteString(m.progress.ViewAs(float64(m.current) / float64(m.maxValue)))
finalBuilder.WriteString("\n\n ")
if m.cancelling {
finalBuilder.WriteString(helpStyle.Render("Stopping..."))
} else {
finalBuilder.WriteString(helpStyle.Render("Press Ctrl+C to quit"))
}
return finalBuilder.String()
}
func progressTickCmd() tea.Cmd {
return tea.Tick(time.Millisecond*250, func(t time.Time) tea.Msg {
return tickMsg(t)
})
}
var infiniteProgressStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#00D4FF"))
type infiniteProgressModel struct {
spinner spinner.Model
startTime time.Time
counter *atomic.Uint64
messages []string
ctx context.Context //nolint:containedctx
quit bool
cancel context.CancelFunc
cancelling bool
}
func (m infiniteProgressModel) Init() tea.Cmd {
return m.spinner.Tick
}
func (m infiniteProgressModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.KeyMsg:
if msg.Type == tea.KeyCtrlC {
m.cancelling = true
m.cancel()
}
return m, nil
case runtimeMessage:
var msgBuilder strings.Builder
msgBuilder.WriteString("[")
msgBuilder.WriteString(msg.timestamp.Format("15:04:05"))
msgBuilder.WriteString("] ")
switch msg.level {
case runtimeMessageLevelError:
msgBuilder.WriteString(errorStyle.Render("ERROR: "))
case runtimeMessageLevelWarning:
msgBuilder.WriteString(warningStyle.Render("WARNING: "))
}
msgBuilder.WriteString(msg.text)
m.messages = append(m.messages[1:], msgBuilder.String())
if m.ctx.Err() != nil {
m.quit = true
return m, tea.Quit
}
return m, nil
default:
if m.ctx.Err() != nil {
m.quit = true
return m, tea.Quit
}
var cmd tea.Cmd
m.spinner, cmd = m.spinner.Update(msg)
return m, cmd
}
}
func (m infiniteProgressModel) View() string {
var messagesBuilder strings.Builder
for i, msg := range m.messages {
if len(msg) > 0 {
messagesBuilder.WriteString(msg)
if i < len(m.messages)-1 {
messagesBuilder.WriteString("\n")
}
}
}
var finalBuilder strings.Builder
if messagesBuilder.Len() > 0 {
finalBuilder.WriteString(messageChannelStyle.Render(messagesBuilder.String()))
finalBuilder.WriteString("\n")
}
if m.quit {
finalBuilder.WriteString("\n ")
finalBuilder.WriteString(strconv.FormatUint(m.counter.Load(), 10))
finalBuilder.WriteString(" ")
finalBuilder.WriteString(infiniteProgressStyle.Render("∙∙∙∙∙"))
finalBuilder.WriteString(" ")
finalBuilder.WriteString(time.Since(m.startTime).Round(time.Second / 10).String())
finalBuilder.WriteString("\n\n")
} else {
finalBuilder.WriteString("\n ")
finalBuilder.WriteString(strconv.FormatUint(m.counter.Load(), 10))
finalBuilder.WriteString(" ")
finalBuilder.WriteString(m.spinner.View())
finalBuilder.WriteString(" ")
finalBuilder.WriteString(time.Since(m.startTime).Round(time.Second / 10).String())
finalBuilder.WriteString("\n\n ")
if m.cancelling {
finalBuilder.WriteString(helpStyle.Render("Stopping..."))
} else {
finalBuilder.WriteString(helpStyle.Render("Press Ctrl+C to quit"))
}
}
return finalBuilder.String()
}
func (q sarin) streamProgress(
ctx context.Context,
cancel context.CancelFunc,
done chan<- struct{},
total uint64,
counter *atomic.Uint64,
messageChannel <-chan runtimeMessage,
) {
var program *tea.Program
if total > 0 {
model := progressModel{
progress: progress.New(progress.WithGradient("#151594", "#00D4FF")),
startTime: time.Now(),
messages: make([]string, 8),
counter: counter,
current: 0,
maxValue: total,
ctx: ctx,
cancel: cancel,
}
program = tea.NewProgram(model)
} else {
model := infiniteProgressModel{
spinner: spinner.New(
spinner.WithSpinner(
spinner.Spinner{
Frames: []string{
"●∙∙∙∙",
"∙●∙∙∙",
"∙∙●∙∙",
"∙∙∙●∙",
"∙∙∙∙●",
"∙∙∙●∙",
"∙∙●∙∙",
"∙●∙∙∙",
},
FPS: time.Second / 8, //nolint:mnd
},
),
spinner.WithStyle(infiniteProgressStyle),
),
startTime: time.Now(),
counter: counter,
messages: make([]string, 8),
ctx: ctx,
cancel: cancel,
quit: false,
}
program = tea.NewProgram(model)
}
go func() {
for msg := range messageChannel {
program.Send(msg)
}
}()
if _, err := program.Run(); err != nil {
panic(err)
}
done <- struct{}{}
}

579
internal/sarin/template.go Normal file
View File

@@ -0,0 +1,579 @@
package sarin
import (
"bytes"
"math/rand/v2"
"mime/multipart"
"strings"
"text/template"
"text/template/parse"
"time"
"github.com/brianvoe/gofakeit/v7"
)
func NewDefaultTemplateFuncMap(randSource rand.Source) template.FuncMap {
fakeit := gofakeit.NewFaker(randSource, false)
return template.FuncMap{
// Strings
"strings_ToUpper": strings.ToUpper,
"strings_ToLower": strings.ToLower,
"strings_RemoveSpaces": func(s string) string { return strings.ReplaceAll(s, " ", "") },
"strings_Replace": strings.Replace,
"strings_ToDate": func(dateString string) time.Time {
date, err := time.Parse("2006-01-02", dateString)
if err != nil {
return time.Now()
}
return date
},
"strings_First": func(s string, n int) string {
runes := []rune(s)
if n <= 0 {
return ""
}
if n >= len(runes) {
return s
}
return string(runes[:n])
},
"strings_Last": func(s string, n int) string {
runes := []rune(s)
if n <= 0 {
return ""
}
if n >= len(runes) {
return s
}
return string(runes[len(runes)-n:])
},
"strings_Truncate": func(s string, n int) string {
runes := []rune(s)
if n <= 0 {
return "..."
}
if n >= len(runes) {
return s
}
return string(runes[:n]) + "..."
},
"strings_TrimPrefix": strings.TrimPrefix,
"strings_TrimSuffix": strings.TrimSuffix,
"strings_Join": func(sep string, values ...string) string {
return strings.Join(values, sep)
},
// Dict
"dict_Str": func(values ...string) map[string]string {
dict := make(map[string]string)
for i := 0; i < len(values); i += 2 {
if i+1 < len(values) {
key := values[i]
value := values[i+1]
dict[key] = value
}
}
return dict
},
// Slice
"slice_Str": func(values ...string) []string { return values },
"slice_Int": func(values ...int) []int { return values },
"slice_Uint": func(values ...uint) []uint { return values },
// Fakeit / File
// "fakeit_CSV": fakeit.CSV(nil),
// "fakeit_JSON": fakeit.JSON(nil),
// "fakeit_XML": fakeit.XML(nil),
"fakeit_FileExtension": fakeit.FileExtension,
"fakeit_FileMimeType": fakeit.FileMimeType,
// Fakeit / ID
"fakeit_ID": fakeit.ID,
"fakeit_UUID": fakeit.UUID,
// Fakeit / Template
// "fakeit_Template": fakeit.Template(nil) (string, error),
// "fakeit_Markdown": fakeit.Markdown(nil) (string, error),
// "fakeit_EmailText": fakeit.EmailText(nil) (string, error),
// "fakeit_FixedWidth": fakeit.FixedWidth(nil) (string, error),
// Fakeit / Product
// "fakeit_Product": fakeit.Product() *ProductInfo,
"fakeit_ProductName": fakeit.ProductName,
"fakeit_ProductDescription": fakeit.ProductDescription,
"fakeit_ProductCategory": fakeit.ProductCategory,
"fakeit_ProductFeature": fakeit.ProductFeature,
"fakeit_ProductMaterial": fakeit.ProductMaterial,
"fakeit_ProductUPC": fakeit.ProductUPC,
"fakeit_ProductAudience": fakeit.ProductAudience,
"fakeit_ProductDimension": fakeit.ProductDimension,
"fakeit_ProductUseCase": fakeit.ProductUseCase,
"fakeit_ProductBenefit": fakeit.ProductBenefit,
"fakeit_ProductSuffix": fakeit.ProductSuffix,
"fakeit_ProductISBN": func() string { return fakeit.ProductISBN(nil) },
// Fakeit / Person
// "fakeit_Person": fakeit.Person() *PersonInfo,
"fakeit_Name": fakeit.Name,
"fakeit_NamePrefix": fakeit.NamePrefix,
"fakeit_NameSuffix": fakeit.NameSuffix,
"fakeit_FirstName": fakeit.FirstName,
"fakeit_MiddleName": fakeit.MiddleName,
"fakeit_LastName": fakeit.LastName,
"fakeit_Gender": fakeit.Gender,
"fakeit_Age": fakeit.Age,
"fakeit_Ethnicity": fakeit.Ethnicity,
"fakeit_SSN": fakeit.SSN,
"fakeit_EIN": fakeit.EIN,
"fakeit_Hobby": fakeit.Hobby,
// "fakeit_Contact": fakeit.Contact() *ContactInfo,
"fakeit_Email": fakeit.Email,
"fakeit_Phone": fakeit.Phone,
"fakeit_PhoneFormatted": fakeit.PhoneFormatted,
// "fakeit_Teams": fakeit.Teams(peopleArray []string, teamsArray []string) map[string][]string,
// Fakeit / Generate
// "fakeit_Struct": fakeit.Struct(v any),
// "fakeit_Slice": fakeit.Slice(v any),
// "fakeit_Map": fakeit.Map() map[string]any,
// "fakeit_Generate": fakeit.Generate(value string) string,
"fakeit_Regex": fakeit.Regex,
// Fakeit / Auth
"fakeit_Username": fakeit.Username,
"fakeit_Password": fakeit.Password,
// Fakeit / Address
// "fakeit_Address": fakeit.Address() *AddressInfo,
"fakeit_City": fakeit.City,
"fakeit_Country": fakeit.Country,
"fakeit_CountryAbr": fakeit.CountryAbr,
"fakeit_State": fakeit.State,
"fakeit_StateAbr": fakeit.StateAbr,
"fakeit_Street": fakeit.Street,
"fakeit_StreetName": fakeit.StreetName,
"fakeit_StreetNumber": fakeit.StreetNumber,
"fakeit_StreetPrefix": fakeit.StreetPrefix,
"fakeit_StreetSuffix": fakeit.StreetSuffix,
"fakeit_Unit": fakeit.Unit,
"fakeit_Zip": fakeit.Zip,
"fakeit_Latitude": fakeit.Latitude,
"fakeit_LatitudeInRange": func(minLatitude, maxLatitude float64) float64 {
value, err := fakeit.LatitudeInRange(minLatitude, maxLatitude)
if err != nil {
var zero float64
return zero
}
return value
},
"fakeit_Longitude": fakeit.Longitude,
"fakeit_LongitudeInRange": func(minLongitude, maxLongitude float64) float64 {
value, err := fakeit.LongitudeInRange(minLongitude, maxLongitude)
if err != nil {
var zero float64
return zero
}
return value
},
// Fakeit / Game
"fakeit_Gamertag": fakeit.Gamertag,
// "fakeit_Dice": fakeit.Dice(numDice uint, sides []uint) []uint,
// Fakeit / Beer
"fakeit_BeerAlcohol": fakeit.BeerAlcohol,
"fakeit_BeerBlg": fakeit.BeerBlg,
"fakeit_BeerHop": fakeit.BeerHop,
"fakeit_BeerIbu": fakeit.BeerIbu,
"fakeit_BeerMalt": fakeit.BeerMalt,
"fakeit_BeerName": fakeit.BeerName,
"fakeit_BeerStyle": fakeit.BeerStyle,
"fakeit_BeerYeast": fakeit.BeerYeast,
// Fakeit / Car
// "fakeit_Car": fakeit.Car() *CarInfo,
"fakeit_CarMaker": fakeit.CarMaker,
"fakeit_CarModel": fakeit.CarModel,
"fakeit_CarType": fakeit.CarType,
"fakeit_CarFuelType": fakeit.CarFuelType,
"fakeit_CarTransmissionType": fakeit.CarTransmissionType,
// Fakeit / Words
// Nouns
"fakeit_Noun": fakeit.Noun,
"fakeit_NounCommon": fakeit.NounCommon,
"fakeit_NounConcrete": fakeit.NounConcrete,
"fakeit_NounAbstract": fakeit.NounAbstract,
"fakeit_NounCollectivePeople": fakeit.NounCollectivePeople,
"fakeit_NounCollectiveAnimal": fakeit.NounCollectiveAnimal,
"fakeit_NounCollectiveThing": fakeit.NounCollectiveThing,
"fakeit_NounCountable": fakeit.NounCountable,
"fakeit_NounUncountable": fakeit.NounUncountable,
// Verbs
"fakeit_Verb": fakeit.Verb,
"fakeit_VerbAction": fakeit.VerbAction,
"fakeit_VerbLinking": fakeit.VerbLinking,
"fakeit_VerbHelping": fakeit.VerbHelping,
// Adverbs
"fakeit_Adverb": fakeit.Adverb,
"fakeit_AdverbManner": fakeit.AdverbManner,
"fakeit_AdverbDegree": fakeit.AdverbDegree,
"fakeit_AdverbPlace": fakeit.AdverbPlace,
"fakeit_AdverbTimeDefinite": fakeit.AdverbTimeDefinite,
"fakeit_AdverbTimeIndefinite": fakeit.AdverbTimeIndefinite,
"fakeit_AdverbFrequencyDefinite": fakeit.AdverbFrequencyDefinite,
"fakeit_AdverbFrequencyIndefinite": fakeit.AdverbFrequencyIndefinite,
// Propositions
"fakeit_Preposition": fakeit.Preposition,
"fakeit_PrepositionSimple": fakeit.PrepositionSimple,
"fakeit_PrepositionDouble": fakeit.PrepositionDouble,
"fakeit_PrepositionCompound": fakeit.PrepositionCompound,
// Adjectives
"fakeit_Adjective": fakeit.Adjective,
"fakeit_AdjectiveDescriptive": fakeit.AdjectiveDescriptive,
"fakeit_AdjectiveQuantitative": fakeit.AdjectiveQuantitative,
"fakeit_AdjectiveProper": fakeit.AdjectiveProper,
"fakeit_AdjectiveDemonstrative": fakeit.AdjectiveDemonstrative,
"fakeit_AdjectivePossessive": fakeit.AdjectivePossessive,
"fakeit_AdjectiveInterrogative": fakeit.AdjectiveInterrogative,
"fakeit_AdjectiveIndefinite": fakeit.AdjectiveIndefinite,
// Pronouns
"fakeit_Pronoun": fakeit.Pronoun,
"fakeit_PronounPersonal": fakeit.PronounPersonal,
"fakeit_PronounObject": fakeit.PronounObject,
"fakeit_PronounPossessive": fakeit.PronounPossessive,
"fakeit_PronounReflective": fakeit.PronounReflective,
"fakeit_PronounDemonstrative": fakeit.PronounDemonstrative,
"fakeit_PronounInterrogative": fakeit.PronounInterrogative,
"fakeit_PronounRelative": fakeit.PronounRelative,
// Connectives
"fakeit_Connective": fakeit.Connective,
"fakeit_ConnectiveTime": fakeit.ConnectiveTime,
"fakeit_ConnectiveComparative": fakeit.ConnectiveComparative,
"fakeit_ConnectiveComplaint": fakeit.ConnectiveComplaint,
"fakeit_ConnectiveListing": fakeit.ConnectiveListing,
"fakeit_ConnectiveCasual": fakeit.ConnectiveCasual,
"fakeit_ConnectiveExamplify": fakeit.ConnectiveExamplify,
// Words
"fakeit_Word": fakeit.Word,
// Text
"fakeit_Sentence": fakeit.Sentence,
"fakeit_Paragraph": fakeit.Paragraph,
"fakeit_LoremIpsumWord": fakeit.LoremIpsumWord,
"fakeit_LoremIpsumSentence": fakeit.LoremIpsumSentence,
"fakeit_LoremIpsumParagraph": fakeit.LoremIpsumParagraph,
"fakeit_Question": fakeit.Question,
"fakeit_Quote": fakeit.Quote,
"fakeit_Phrase": fakeit.Phrase,
// Fakeit / Foods
"fakeit_Fruit": fakeit.Fruit,
"fakeit_Vegetable": fakeit.Vegetable,
"fakeit_Breakfast": fakeit.Breakfast,
"fakeit_Lunch": fakeit.Lunch,
"fakeit_Dinner": fakeit.Dinner,
"fakeit_Snack": fakeit.Snack,
"fakeit_Dessert": fakeit.Dessert,
// Fakeit / Misc
"fakeit_Bool": fakeit.Bool,
// "fakeit_Weighted": fakeit.Weighted(options []any, weights []float32) (any, error),
"fakeit_FlipACoin": fakeit.FlipACoin,
// "fakeit_RandomMapKey": fakeit.RandomMapKey(mapI any) any,
// "fakeit_ShuffleAnySlice": fakeit.ShuffleAnySlice(v any),
// Fakeit / Colors
"fakeit_Color": fakeit.Color,
"fakeit_HexColor": fakeit.HexColor,
"fakeit_RGBColor": fakeit.RGBColor,
"fakeit_SafeColor": fakeit.SafeColor,
"fakeit_NiceColors": fakeit.NiceColors,
// Fakeit / Images
// "fakeit_Image": fakeit.Image(width int, height int) *img.RGBA,
"fakeit_ImageJpeg": fakeit.ImageJpeg,
"fakeit_ImagePng": fakeit.ImagePng,
// Fakeit / Internet
"fakeit_URL": fakeit.URL,
"fakeit_UrlSlug": fakeit.UrlSlug,
"fakeit_DomainName": fakeit.DomainName,
"fakeit_DomainSuffix": fakeit.DomainSuffix,
"fakeit_IPv4Address": fakeit.IPv4Address,
"fakeit_IPv6Address": fakeit.IPv6Address,
"fakeit_MacAddress": fakeit.MacAddress,
"fakeit_HTTPStatusCode": fakeit.HTTPStatusCode,
"fakeit_HTTPStatusCodeSimple": fakeit.HTTPStatusCodeSimple,
"fakeit_LogLevel": fakeit.LogLevel,
"fakeit_HTTPMethod": fakeit.HTTPMethod,
"fakeit_HTTPVersion": fakeit.HTTPVersion,
"fakeit_UserAgent": fakeit.UserAgent,
"fakeit_ChromeUserAgent": fakeit.ChromeUserAgent,
"fakeit_FirefoxUserAgent": fakeit.FirefoxUserAgent,
"fakeit_OperaUserAgent": fakeit.OperaUserAgent,
"fakeit_SafariUserAgent": fakeit.SafariUserAgent,
"fakeit_APIUserAgent": fakeit.APIUserAgent,
// Fakeit / HTML
"fakeit_InputName": fakeit.InputName,
"fakeit_Svg": func() string { return fakeit.Svg(nil) },
// Fakeit / Date/Time
"fakeit_Date": fakeit.Date,
"fakeit_PastDate": fakeit.PastDate,
"fakeit_FutureDate": fakeit.FutureDate,
"fakeit_DateRange": fakeit.DateRange,
"fakeit_NanoSecond": fakeit.NanoSecond,
"fakeit_Second": fakeit.Second,
"fakeit_Minute": fakeit.Minute,
"fakeit_Hour": fakeit.Hour,
"fakeit_Month": fakeit.Month,
"fakeit_MonthString": fakeit.MonthString,
"fakeit_Day": fakeit.Day,
"fakeit_WeekDay": fakeit.WeekDay,
"fakeit_Year": fakeit.Year,
"fakeit_TimeZone": fakeit.TimeZone,
"fakeit_TimeZoneAbv": fakeit.TimeZoneAbv,
"fakeit_TimeZoneFull": fakeit.TimeZoneFull,
"fakeit_TimeZoneOffset": fakeit.TimeZoneOffset,
"fakeit_TimeZoneRegion": fakeit.TimeZoneRegion,
// Fakeit / Payment
"fakeit_Price": fakeit.Price,
// "fakeit_CreditCard": fakeit.CreditCard() *CreditCardInfo,
"fakeit_CreditCardCvv": fakeit.CreditCardCvv,
"fakeit_CreditCardExp": fakeit.CreditCardExp,
"fakeit_CreditCardNumber": func(gaps bool) string {
return fakeit.CreditCardNumber(&gofakeit.CreditCardOptions{Gaps: gaps})
},
"fakeit_CreditCardType": fakeit.CreditCardType,
// "fakeit_Currency": fakeit.Currency() *CurrencyInfo,
"fakeit_CurrencyLong": fakeit.CurrencyLong,
"fakeit_CurrencyShort": fakeit.CurrencyShort,
"fakeit_AchRouting": fakeit.AchRouting,
"fakeit_AchAccount": fakeit.AchAccount,
"fakeit_BitcoinAddress": fakeit.BitcoinAddress,
"fakeit_BitcoinPrivateKey": fakeit.BitcoinPrivateKey,
"fakeit_BankName": fakeit.BankName,
"fakeit_BankType": fakeit.BankType,
// Fakeit / Finance
"fakeit_Cusip": fakeit.Cusip,
"fakeit_Isin": fakeit.Isin,
// Fakeit / Company
"fakeit_BS": fakeit.BS,
"fakeit_Blurb": fakeit.Blurb,
"fakeit_BuzzWord": fakeit.BuzzWord,
"fakeit_Company": fakeit.Company,
"fakeit_CompanySuffix": fakeit.CompanySuffix,
// "fakeit_Job": fakeit.Job() *JobInfo,
"fakeit_JobDescriptor": fakeit.JobDescriptor,
"fakeit_JobLevel": fakeit.JobLevel,
"fakeit_JobTitle": fakeit.JobTitle,
"fakeit_Slogan": fakeit.Slogan,
// Fakeit / Hacker
"fakeit_HackerAbbreviation": fakeit.HackerAbbreviation,
"fakeit_HackerAdjective": fakeit.HackerAdjective,
"fakeit_HackeringVerb": fakeit.HackeringVerb,
"fakeit_HackerNoun": fakeit.HackerNoun,
"fakeit_HackerPhrase": fakeit.HackerPhrase,
"fakeit_HackerVerb": fakeit.HackerVerb,
// Fakeit / Hipster
"fakeit_HipsterWord": fakeit.HipsterWord,
"fakeit_HipsterSentence": fakeit.HipsterSentence,
"fakeit_HipsterParagraph": fakeit.HipsterParagraph,
// Fakeit / App
"fakeit_AppName": fakeit.AppName,
"fakeit_AppVersion": fakeit.AppVersion,
"fakeit_AppAuthor": fakeit.AppAuthor,
// Fakeit / Animal
"fakeit_PetName": fakeit.PetName,
"fakeit_Animal": fakeit.Animal,
"fakeit_AnimalType": fakeit.AnimalType,
"fakeit_FarmAnimal": fakeit.FarmAnimal,
"fakeit_Cat": fakeit.Cat,
"fakeit_Dog": fakeit.Dog,
"fakeit_Bird": fakeit.Bird,
// Fakeit / Emoji
"fakeit_Emoji": fakeit.Emoji,
"fakeit_EmojiCategory": fakeit.EmojiCategory,
"fakeit_EmojiAlias": fakeit.EmojiAlias,
"fakeit_EmojiTag": fakeit.EmojiTag,
"fakeit_EmojiFlag": fakeit.EmojiFlag,
"fakeit_EmojiAnimal": fakeit.EmojiAnimal,
"fakeit_EmojiFood": fakeit.EmojiFood,
"fakeit_EmojiPlant": fakeit.EmojiPlant,
"fakeit_EmojiMusic": fakeit.EmojiMusic,
"fakeit_EmojiVehicle": fakeit.EmojiVehicle,
"fakeit_EmojiSport": fakeit.EmojiSport,
"fakeit_EmojiFace": fakeit.EmojiFace,
"fakeit_EmojiHand": fakeit.EmojiHand,
"fakeit_EmojiClothing": fakeit.EmojiClothing,
"fakeit_EmojiLandmark": fakeit.EmojiLandmark,
"fakeit_EmojiElectronics": fakeit.EmojiElectronics,
"fakeit_EmojiGame": fakeit.EmojiGame,
"fakeit_EmojiTools": fakeit.EmojiTools,
"fakeit_EmojiWeather": fakeit.EmojiWeather,
"fakeit_EmojiJob": fakeit.EmojiJob,
"fakeit_EmojiPerson": fakeit.EmojiPerson,
"fakeit_EmojiGesture": fakeit.EmojiGesture,
"fakeit_EmojiCostume": fakeit.EmojiCostume,
"fakeit_EmojiSentence": fakeit.EmojiSentence,
// Fakeit / Language
"fakeit_Language": fakeit.Language,
"fakeit_LanguageAbbreviation": fakeit.LanguageAbbreviation,
"fakeit_ProgrammingLanguage": fakeit.ProgrammingLanguage,
// Fakeit / Number
"fakeit_Number": fakeit.Number,
"fakeit_Int": fakeit.Int,
"fakeit_IntN": fakeit.IntN,
"fakeit_Int8": fakeit.Int8,
"fakeit_Int16": fakeit.Int16,
"fakeit_Int32": fakeit.Int32,
"fakeit_Int64": fakeit.Int64,
"fakeit_Uint": fakeit.Uint,
"fakeit_UintN": fakeit.UintN,
"fakeit_Uint8": fakeit.Uint8,
"fakeit_Uint16": fakeit.Uint16,
"fakeit_Uint32": fakeit.Uint32,
"fakeit_Uint64": fakeit.Uint64,
"fakeit_Float32": fakeit.Float32,
"fakeit_Float32Range": fakeit.Float32Range,
"fakeit_Float64": fakeit.Float64,
"fakeit_Float64Range": fakeit.Float64Range,
// "fakeit_ShuffleInts": fakeit.ShuffleInts,
"fakeit_RandomInt": fakeit.RandomInt,
"fakeit_HexUint": fakeit.HexUint,
// Fakeit / String
"fakeit_Digit": fakeit.Digit,
"fakeit_DigitN": fakeit.DigitN,
"fakeit_Letter": fakeit.Letter,
"fakeit_LetterN": fakeit.LetterN,
"fakeit_Lexify": fakeit.Lexify,
"fakeit_Numerify": fakeit.Numerify,
// "fakeit_ShuffleStrings": fakeit.ShuffleStrings,
"fakeit_RandomString": fakeit.RandomString,
// Fakeit / Celebrity
"fakeit_CelebrityActor": fakeit.CelebrityActor,
"fakeit_CelebrityBusiness": fakeit.CelebrityBusiness,
"fakeit_CelebritySport": fakeit.CelebritySport,
// Fakeit / Minecraft
"fakeit_MinecraftOre": fakeit.MinecraftOre,
"fakeit_MinecraftWood": fakeit.MinecraftWood,
"fakeit_MinecraftArmorTier": fakeit.MinecraftArmorTier,
"fakeit_MinecraftArmorPart": fakeit.MinecraftArmorPart,
"fakeit_MinecraftWeapon": fakeit.MinecraftWeapon,
"fakeit_MinecraftTool": fakeit.MinecraftTool,
"fakeit_MinecraftDye": fakeit.MinecraftDye,
"fakeit_MinecraftFood": fakeit.MinecraftFood,
"fakeit_MinecraftAnimal": fakeit.MinecraftAnimal,
"fakeit_MinecraftVillagerJob": fakeit.MinecraftVillagerJob,
"fakeit_MinecraftVillagerStation": fakeit.MinecraftVillagerStation,
"fakeit_MinecraftVillagerLevel": fakeit.MinecraftVillagerLevel,
"fakeit_MinecraftMobPassive": fakeit.MinecraftMobPassive,
"fakeit_MinecraftMobNeutral": fakeit.MinecraftMobNeutral,
"fakeit_MinecraftMobHostile": fakeit.MinecraftMobHostile,
"fakeit_MinecraftMobBoss": fakeit.MinecraftMobBoss,
"fakeit_MinecraftBiome": fakeit.MinecraftBiome,
"fakeit_MinecraftWeather": fakeit.MinecraftWeather,
// Fakeit / Book
// "fakeit_Book": fakeit.Book() *BookInfo,
"fakeit_BookTitle": fakeit.BookTitle,
"fakeit_BookAuthor": fakeit.BookAuthor,
"fakeit_BookGenre": fakeit.BookGenre,
// Fakeit / Movie
// "fakeit_Movie": fakeit.Movie() *MovieInfo,
"fakeit_MovieName": fakeit.MovieName,
"fakeit_MovieGenre": fakeit.MovieGenre,
// Fakeit / Error
"fakeit_Error": func() string { return fakeit.Error().Error() },
"fakeit_ErrorDatabase": func() string { return fakeit.ErrorDatabase().Error() },
"fakeit_ErrorGRPC": func() string { return fakeit.ErrorGRPC().Error() },
"fakeit_ErrorHTTP": func() string { return fakeit.ErrorHTTP().Error() },
"fakeit_ErrorHTTPClient": func() string { return fakeit.ErrorHTTPClient().Error() },
"fakeit_ErrorHTTPServer": func() string { return fakeit.ErrorHTTPServer().Error() },
// "fakeit_ErrorInput": func() string { return fakeit.ErrorInput().Error() },
"fakeit_ErrorRuntime": func() string { return fakeit.ErrorRuntime().Error() },
// Fakeit / School
"fakeit_School": fakeit.School,
// Fakeit / Song
// "fakeit_Song": fakeit.Song() *SongInfo,
"fakeit_SongName": fakeit.SongName,
"fakeit_SongArtist": fakeit.SongArtist,
"fakeit_SongGenre": fakeit.SongGenre,
}
}
type BodyTemplateFuncMapData struct {
formDataContenType string
}
func (data BodyTemplateFuncMapData) GetFormDataContenType() string {
return data.formDataContenType
}
func (data *BodyTemplateFuncMapData) ClearFormDataContenType() {
data.formDataContenType = ""
}
func NewDefaultBodyTemplateFuncMap(randSource rand.Source, data *BodyTemplateFuncMapData) template.FuncMap {
funcMap := NewDefaultTemplateFuncMap(randSource)
if data != nil {
funcMap["body_FormData"] = func(kv map[string]string) string {
var multipartData bytes.Buffer
writer := multipart.NewWriter(&multipartData)
data.formDataContenType = writer.FormDataContentType()
for k, v := range kv {
_ = writer.WriteField(k, v)
}
_ = writer.Close()
return multipartData.String()
}
}
return funcMap
}
func hasTemplateActions(tmpl *template.Template) bool {
if tmpl.Tree == nil || tmpl.Root == nil {
return false
}
for _, node := range tmpl.Root.Nodes {
switch node.Type() {
case parse.NodeAction, parse.NodeIf, parse.NodeRange,
parse.NodeWith, parse.NodeTemplate:
return true
}
}
return false
}

View File

@@ -0,0 +1,46 @@
package types
import (
"path/filepath"
"strings"
)
type ConfigFileType string
const (
ConfigFileTypeUnknown ConfigFileType = "unknown"
ConfigFileTypeYAML ConfigFileType = "yaml/yml"
)
type ConfigFile struct {
path string
_type ConfigFileType
}
func (configFile ConfigFile) Path() string {
return configFile.path
}
func (configFile ConfigFile) Type() ConfigFileType {
return configFile._type
}
func ParseConfigFile(configFileRaw string) *ConfigFile {
// TODO: Improve file type detection
// (e.g., use magic bytes or content inspection instead of relying solely on file extension)
configFileParsed := &ConfigFile{
path: configFileRaw,
}
configFileExtension, _ := strings.CutPrefix(filepath.Ext(configFileRaw), ".")
switch strings.ToLower(configFileExtension) {
case "yml", "yaml":
configFileParsed._type = ConfigFileTypeYAML
default:
configFileParsed._type = ConfigFileTypeUnknown
}
return configFileParsed
}

40
internal/types/cookie.go Normal file
View File

@@ -0,0 +1,40 @@
package types
import "strings"
type Cookie KeyValue[string, []string]
type Cookies []Cookie
func (cookies Cookies) GetValue(key string) *[]string {
for i := range cookies {
if cookies[i].Key == key {
return &cookies[i].Value
}
}
return nil
}
func (cookies *Cookies) Append(cookie ...Cookie) {
for _, c := range cookie {
if item := cookies.GetValue(c.Key); item != nil {
*item = append(*item, c.Value...)
} else {
*cookies = append(*cookies, c)
}
}
}
func (cookies *Cookies) Parse(rawValues ...string) {
for _, rawValue := range rawValues {
cookies.Append(*ParseCookie(rawValue))
}
}
func ParseCookie(rawValue string) *Cookie {
parts := strings.SplitN(rawValue, "=", 2)
if len(parts) == 1 {
return &Cookie{Key: parts[0], Value: []string{""}}
}
return &Cookie{Key: parts[0], Value: []string{parts[1]}}
}

189
internal/types/errors.go Normal file
View File

@@ -0,0 +1,189 @@
package types
import (
"errors"
"fmt"
"strings"
)
var (
// General
ErrNoError = errors.New("no error (internal)")
// CLI
ErrCLINoArgs = errors.New("CLI expects arguments but received none")
)
// ======================================== General ========================================
type FieldParseError struct {
Field string
Value string
Err error
}
func NewFieldParseError(field string, value string, err error) FieldParseError {
if err == nil {
err = ErrNoError
}
return FieldParseError{field, value, err}
}
func (e FieldParseError) Error() string {
return fmt.Sprintf("Field '%s' parse failed: %v", e.Field, e.Err)
}
func (e FieldParseError) Unwrap() error {
return e.Err
}
type FieldParseErrors struct {
Errors []FieldParseError
}
func NewFieldParseErrors(fieldParseErrors []FieldParseError) FieldParseErrors {
return FieldParseErrors{fieldParseErrors}
}
func (e FieldParseErrors) Error() string {
if len(e.Errors) == 0 {
return "No field parse errors"
}
if len(e.Errors) == 1 {
return e.Errors[0].Error()
}
var builder strings.Builder
for i, err := range e.Errors {
if i > 0 {
builder.WriteString("\n")
}
builder.WriteString(err.Error())
}
return builder.String()
}
type FieldValidationError struct {
Field string
Value string
Err error
}
func NewFieldValidationError(field string, value string, err error) FieldValidationError {
if err == nil {
err = ErrNoError
}
return FieldValidationError{field, value, err}
}
func (e FieldValidationError) Error() string {
return fmt.Sprintf("Field '%s' validation failed: %v", e.Field, e.Err)
}
func (e FieldValidationError) Unwrap() error {
return e.Err
}
type FieldValidationErrors struct {
Errors []FieldValidationError
}
func NewFieldValidationErrors(fieldValidationErrors []FieldValidationError) FieldValidationErrors {
return FieldValidationErrors{fieldValidationErrors}
}
func (e FieldValidationErrors) Error() string {
if len(e.Errors) == 0 {
return "No field validation errors"
}
if len(e.Errors) == 1 {
return e.Errors[0].Error()
}
var builder strings.Builder
for i, err := range e.Errors {
if i > 0 {
builder.WriteString("\n")
}
builder.WriteString(err.Error())
}
return builder.String()
}
type UnmarshalError struct {
error error
}
func NewUnmarshalError(err error) UnmarshalError {
if err == nil {
err = ErrNoError
}
return UnmarshalError{err}
}
func (e UnmarshalError) Error() string {
return "Unmarshal error: " + e.error.Error()
}
func (e UnmarshalError) Unwrap() error {
return e.error
}
// ======================================== CLI ========================================
type CLIUnexpectedArgsError struct {
Args []string
}
func NewCLIUnexpectedArgsError(args []string) CLIUnexpectedArgsError {
return CLIUnexpectedArgsError{args}
}
func (e CLIUnexpectedArgsError) Error() string {
return fmt.Sprintf("CLI received unexpected arguments: %v", strings.Join(e.Args, ","))
}
// ======================================== Config File ========================================
type ConfigFileReadError struct {
error error
}
func NewConfigFileReadError(err error) ConfigFileReadError {
if err == nil {
err = ErrNoError
}
return ConfigFileReadError{err}
}
func (e ConfigFileReadError) Error() string {
return "Config file read error: " + e.error.Error()
}
func (e ConfigFileReadError) Unwrap() error {
return e.error
}
// ======================================== Proxy ========================================
type ProxyDialError struct {
Proxy string
Err error
}
func NewProxyDialError(proxy string, err error) ProxyDialError {
if err == nil {
err = ErrNoError
}
return ProxyDialError{proxy, err}
}
func (e ProxyDialError) Error() string {
return "proxy \"" + e.Proxy + "\": " + e.Err.Error()
}
func (e ProxyDialError) Unwrap() error {
return e.Err
}

49
internal/types/header.go Normal file
View File

@@ -0,0 +1,49 @@
package types
import "strings"
type Header KeyValue[string, []string]
type Headers []Header
func (headers Headers) Has(key string) bool {
for i := range headers {
if headers[i].Key == key {
return true
}
}
return false
}
func (headers Headers) GetValue(key string) *[]string {
for i := range headers {
if headers[i].Key == key {
return &headers[i].Value
}
}
return nil
}
func (headers *Headers) Append(header ...Header) {
for _, h := range header {
if item := headers.GetValue(h.Key); item != nil {
*item = append(*item, h.Value...)
} else {
*headers = append(*headers, h)
}
}
}
func (headers *Headers) Parse(rawValues ...string) {
for _, rawValue := range rawValues {
headers.Append(*ParseHeader(rawValue))
}
}
func ParseHeader(rawValue string) *Header {
parts := strings.SplitN(rawValue, ": ", 2)
if len(parts) == 1 {
return &Header{Key: parts[0], Value: []string{""}}
}
return &Header{Key: parts[0], Value: []string{parts[1]}}
}

View File

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

40
internal/types/param.go Normal file
View File

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

38
internal/types/proxy.go Normal file
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
}

View File

@@ -0,0 +1,8 @@
package version
var (
Version = "unknown" // Set via ldflags
GitCommit = "unknown" // Set via ldflags
BuildDate = "unknown" // Set via ldflags
GoVersion = "unknown" // Set via ldflags
)

122
main.go
View File

@@ -1,122 +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"
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) {
utils.PrintlnC(utils.Colors.Yellow, err.Error())
return
} else if customerrors.Is(err, customerrors.ErrNoInternet) {
utils.PrintAndExit("No internet connection")
return
}
panic(err)
}
responses.Print()
}

View File

@@ -1,155 +0,0 @@
package readers
import (
"flag"
"fmt"
"strings"
"github.com/aykhans/dodo/config"
. "github.com/aykhans/dodo/types"
"github.com/aykhans/dodo/utils"
)
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-count uint Number of dodos(threads) (default %d)
-m, --method string HTTP Method (default %s)
-r, --request-count 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-count", 0, "Number of dodos(threads)")
flag.UintVar(&dodosCount, "d", 0, "Number of dodos(threads)")
flag.UintVar(&requestsCount, "requests-count", 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-count", "d":
cliConfig.DodosCount = dodosCount
case "requests-count", "r":
cliConfig.RequestCount = requestsCount
case "timeout", "t":
var maxUint32 uint = 4294967295
if timeout > maxUint32 {
utils.PrintfC(utils.Colors.Yellow, "timeout value is too large, setting to %d\n", 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,324 +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/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 = utils.Colored(
utils.Colors.Yellow,
"No active proxies found. Do you want to continue?",
)
} else {
yesOrNoMessage = utils.Colored(
utils.Colors.Yellow,
fmt.Sprintf(
"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,249 +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 client.CloseIdleConnections()
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 = 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,82 +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)
}
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,58 +0,0 @@
package utils
import (
"fmt"
"os"
)
var Colors = struct {
reset string
Red string
Green string
Yellow string
Orange string
Blue string
Magenta string
Cyan string
Gray string
White string
}{
reset: "\033[0m",
Red: "\033[31m",
Green: "\033[32m",
Yellow: "\033[33m",
Orange: "\033[38;5;208m",
Blue: "\033[34m",
Magenta: "\033[35m",
Cyan: "\033[36m",
Gray: "\033[37m",
White: "\033[97m",
}
func Colored(color string, a ...any) string {
return color + fmt.Sprint(a...) + Colors.reset
}
func PrintfC(color string, format string, a ...any) {
fmt.Printf(Colored(color, format), a...)
}
func PrintlnC(color string, a ...any) {
fmt.Println(Colored(color, a...))
}
func PrintErr(err error) {
PrintlnC(Colors.Red, 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
}