mirror of
https://github.com/aykhans/dodo.git
synced 2025-09-03 18:03:34 +00:00
Compare commits
56 Commits
v0.6.3
...
fd7c4c6454
Author | SHA1 | Date | |
---|---|---|---|
fd7c4c6454 | |||
438e655311 | |||
29b85d5b83 | |||
42335c1178 | |||
25d4762a3c | |||
361d423651 | |||
ffa724fae7 | |||
7930be490d | |||
e6c54e9cb2 | |||
b32f567de7 | |||
b6e85d9443 | |||
827e3535cd | |||
7ecf534d87 | |||
![]() |
17ad5fadb9 | ||
7fb59a7989 | |||
527909c882 | |||
4459675efa | |||
![]() |
604af355e6 | ||
7d4267c4c2 | |||
![]() |
845ab7296c | ||
49d004ff06 | |||
045deb6120 | |||
075ef26203 | |||
![]() |
946afbb2c3 | ||
aacb33cfa5 | |||
4a7db48351 | |||
b73087dce5 | |||
![]() |
20a46feab8 | ||
0adde6e04e | |||
ca50de4e2f | |||
c99e7c66d9 | |||
280e5f5c4e | |||
47dfad6046 | |||
5bb644d55f | |||
9152eefdc5 | |||
a8cd253c63 | |||
9aaf2db74d | |||
5c3e254e1e | |||
e5c681a22b | |||
79668e4ece | |||
f248c2af96 | |||
924bd819ee | |||
e567155eb1 | |||
23c74bdbb1 | |||
addf92df91 | |||
6aeda3706b | |||
dc1cd05714 | |||
2b9d0520b0 | |||
bea2e7c040 | |||
![]() |
b52b336a52 | ||
c927e31c49 | |||
d8e6f532a8 | |||
cf5cd23d97 | |||
![]() |
350ff4d66d | ||
cb8898d20e | |||
a552d1c9f9 |
@@ -1,11 +0,0 @@
|
|||||||
.github
|
|
||||||
assets
|
|
||||||
binaries
|
|
||||||
dodo
|
|
||||||
.git
|
|
||||||
.gitignore
|
|
||||||
.golangci.yml
|
|
||||||
README.md
|
|
||||||
LICENSE
|
|
||||||
config.json
|
|
||||||
build.sh
|
|
2
.github/workflows/golangci-lint.yml
vendored
2
.github/workflows/golangci-lint.yml
vendored
@@ -21,5 +21,5 @@ jobs:
|
|||||||
- name: golangci-lint
|
- name: golangci-lint
|
||||||
uses: golangci/golangci-lint-action@v7
|
uses: golangci/golangci-lint-action@v7
|
||||||
with:
|
with:
|
||||||
version: v2.0.2
|
version: v2.4.0
|
||||||
args: --timeout=10m --config=.golangci.yml
|
args: --timeout=10m --config=.golangci.yml
|
||||||
|
104
.golangci.yml
104
.golangci.yml
@@ -1,7 +1,7 @@
|
|||||||
version: "2"
|
version: "2"
|
||||||
|
|
||||||
run:
|
run:
|
||||||
go: "1.24"
|
go: "1.25"
|
||||||
concurrency: 8
|
concurrency: 8
|
||||||
timeout: 10m
|
timeout: 10m
|
||||||
|
|
||||||
@@ -24,6 +24,71 @@ linters:
|
|||||||
- unconvert
|
- unconvert
|
||||||
- unused
|
- unused
|
||||||
- whitespace
|
- whitespace
|
||||||
|
- bidichk
|
||||||
|
- bodyclose
|
||||||
|
- containedctx
|
||||||
|
- contextcheck
|
||||||
|
- copyloopvar
|
||||||
|
- decorder
|
||||||
|
- dogsled
|
||||||
|
- dupword
|
||||||
|
- durationcheck
|
||||||
|
- embeddedstructfieldcheck
|
||||||
|
- errchkjson
|
||||||
|
- errorlint
|
||||||
|
- exhaustive
|
||||||
|
- exptostd
|
||||||
|
- fatcontext
|
||||||
|
- forcetypeassert
|
||||||
|
- funcorder
|
||||||
|
- ginkgolinter
|
||||||
|
- gocheckcompilerdirectives
|
||||||
|
- gochecknoinits
|
||||||
|
- gochecksumtype
|
||||||
|
- goconst
|
||||||
|
- gocritic
|
||||||
|
- gocyclo
|
||||||
|
- godox
|
||||||
|
- goheader
|
||||||
|
- gomoddirectives
|
||||||
|
- gosec
|
||||||
|
- gosmopolitan
|
||||||
|
- grouper
|
||||||
|
- iface
|
||||||
|
- importas
|
||||||
|
- inamedparam
|
||||||
|
- interfacebloat
|
||||||
|
- intrange
|
||||||
|
- loggercheck
|
||||||
|
- makezero
|
||||||
|
- mirror
|
||||||
|
- musttag
|
||||||
|
- nilerr
|
||||||
|
- nilnesserr
|
||||||
|
- nilnil
|
||||||
|
- noctx
|
||||||
|
- nonamedreturns
|
||||||
|
- nosprintfhostport
|
||||||
|
- perfsprint
|
||||||
|
- predeclared
|
||||||
|
- promlinter
|
||||||
|
- protogetter
|
||||||
|
- sloglint
|
||||||
|
- spancheck
|
||||||
|
- sqlclosecheck
|
||||||
|
- tagalign
|
||||||
|
- tagliatelle
|
||||||
|
- testableexamples
|
||||||
|
- testifylint
|
||||||
|
- thelper
|
||||||
|
- tparallel
|
||||||
|
- unparam
|
||||||
|
- usestdlibvars
|
||||||
|
- usetesting
|
||||||
|
- varnamelen
|
||||||
|
- wastedassign
|
||||||
|
- wrapcheck
|
||||||
|
- zerologlint
|
||||||
|
|
||||||
settings:
|
settings:
|
||||||
staticcheck:
|
staticcheck:
|
||||||
@@ -31,3 +96,40 @@ linters:
|
|||||||
- "all"
|
- "all"
|
||||||
- "-S1002"
|
- "-S1002"
|
||||||
- "-ST1000"
|
- "-ST1000"
|
||||||
|
varnamelen:
|
||||||
|
ignore-decls:
|
||||||
|
- i int
|
||||||
|
|
||||||
|
exclusions:
|
||||||
|
rules:
|
||||||
|
- path: _test\.go$
|
||||||
|
linters:
|
||||||
|
- errorlint
|
||||||
|
- forcetypeassert
|
||||||
|
- perfsprint
|
||||||
|
- errcheck
|
||||||
|
- gosec
|
||||||
|
- gocyclo
|
||||||
|
|
||||||
|
- path: _test\.go$
|
||||||
|
linters:
|
||||||
|
- staticcheck
|
||||||
|
text: "SA5011"
|
||||||
|
|
||||||
|
formatters:
|
||||||
|
enable:
|
||||||
|
- gofmt
|
||||||
|
|
||||||
|
settings:
|
||||||
|
gofmt:
|
||||||
|
# Simplify code: gofmt with `-s` option.
|
||||||
|
# Default: true
|
||||||
|
simplify: false
|
||||||
|
# Apply the rewrite rules to the source before reformatting.
|
||||||
|
# https://pkg.go.dev/cmd/gofmt
|
||||||
|
# Default: []
|
||||||
|
rewrite-rules:
|
||||||
|
- pattern: "interface{}"
|
||||||
|
replacement: "any"
|
||||||
|
- pattern: "a[b:len(a)]"
|
||||||
|
replacement: "a[b:]"
|
||||||
|
@@ -1,4 +1,4 @@
|
|||||||
FROM golang:1.24-alpine AS builder
|
FROM golang:1.25-alpine AS builder
|
||||||
|
|
||||||
WORKDIR /src
|
WORKDIR /src
|
||||||
|
|
||||||
@@ -6,7 +6,7 @@ COPY go.mod go.sum ./
|
|||||||
RUN go mod download
|
RUN go mod download
|
||||||
COPY . .
|
COPY . .
|
||||||
|
|
||||||
RUN go build -ldflags "-s -w" -o dodo
|
RUN CGO_ENABLED=0 go build -ldflags "-s -w" -o dodo
|
||||||
|
|
||||||
FROM gcr.io/distroless/static-debian12:latest
|
FROM gcr.io/distroless/static-debian12:latest
|
||||||
|
|
||||||
|
21
LICENSE
21
LICENSE
@@ -1,21 +0,0 @@
|
|||||||
MIT License
|
|
||||||
|
|
||||||
Copyright (c) 2024 Aykhan Shahsuvarov
|
|
||||||
|
|
||||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
||||||
of this software and associated documentation files (the "Software"), to deal
|
|
||||||
in the Software without restriction, including without limitation the rights
|
|
||||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
||||||
copies of the Software, and to permit persons to whom the Software is
|
|
||||||
furnished to do so, subject to the following conditions:
|
|
||||||
|
|
||||||
The above copyright notice and this permission notice shall be included in all
|
|
||||||
copies or substantial portions of the Software.
|
|
||||||
|
|
||||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
||||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
||||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
||||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
||||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
||||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
||||||
SOFTWARE.
|
|
263
README.md
263
README.md
@@ -1,263 +0,0 @@
|
|||||||
<h1 align="center">Dodo - A Fast and Easy-to-Use HTTP Benchmarking Tool</h1>
|
|
||||||
<p align="center">
|
|
||||||
<img width="30%" height="30%" src="https://ftp.aykhans.me/web/client/pubshares/VzPtSHS7yPQT7ngoZzZSNU/browse?path=%2Fdodo.png">
|
|
||||||
</p>
|
|
||||||
|
|
||||||
## Table of Contents
|
|
||||||
|
|
||||||
- [Installation](#installation)
|
|
||||||
- [Using Docker (Recommended)](#using-docker-recommended)
|
|
||||||
- [Using Pre-built Binaries](#using-pre-built-binaries)
|
|
||||||
- [Building from Source](#building-from-source)
|
|
||||||
- [Usage](#usage)
|
|
||||||
- [1. CLI Usage](#1-cli-usage)
|
|
||||||
- [2. Config File Usage](#2-config-file-usage)
|
|
||||||
- [2.1 JSON Example](#21-json-example)
|
|
||||||
- [2.2 YAML/YML Example](#22-yamlyml-example)
|
|
||||||
- [3. CLI & Config File Combination](#3-cli--config-file-combination)
|
|
||||||
- [Config Parameters Reference](#config-parameters-reference)
|
|
||||||
|
|
||||||
## Installation
|
|
||||||
|
|
||||||
### Using Docker (Recommended)
|
|
||||||
|
|
||||||
Pull the latest Dodo image from Docker Hub:
|
|
||||||
|
|
||||||
```sh
|
|
||||||
docker pull aykhans/dodo:latest
|
|
||||||
```
|
|
||||||
|
|
||||||
To use Dodo with Docker and a local config file, mount the config file as a volume and pass it as an argument:
|
|
||||||
|
|
||||||
```sh
|
|
||||||
docker run -v /path/to/config.json:/config.json aykhans/dodo -f /config.json
|
|
||||||
```
|
|
||||||
|
|
||||||
If you're using a remote config file via URL, you don't need to mount a volume:
|
|
||||||
|
|
||||||
```sh
|
|
||||||
docker run aykhans/dodo -f https://raw.githubusercontent.com/aykhans/dodo/main/config.yaml
|
|
||||||
```
|
|
||||||
|
|
||||||
### Using Pre-built Binaries
|
|
||||||
|
|
||||||
Download the latest binaries from the [releases](https://github.com/aykhans/dodo/releases) section.
|
|
||||||
|
|
||||||
### Building from Source
|
|
||||||
|
|
||||||
To build Dodo from source, ensure you have [Go 1.24+](https://golang.org/dl/) installed.
|
|
||||||
|
|
||||||
```sh
|
|
||||||
go install -ldflags "-s -w" github.com/aykhans/dodo@latest
|
|
||||||
```
|
|
||||||
|
|
||||||
## Usage
|
|
||||||
|
|
||||||
Dodo supports CLI arguments, configuration files (JSON/YAML), or a combination of both. If both are used, CLI arguments take precedence.
|
|
||||||
|
|
||||||
### 1. CLI Usage
|
|
||||||
|
|
||||||
Send 1000 GET requests to https://example.com with 10 parallel dodos (threads), each with a timeout of 2 seconds, within a maximum duration of 1 minute:
|
|
||||||
|
|
||||||
```sh
|
|
||||||
dodo -u https://example.com -m GET -d 10 -r 1000 -o 1m -t 2s
|
|
||||||
```
|
|
||||||
|
|
||||||
With Docker:
|
|
||||||
|
|
||||||
```sh
|
|
||||||
docker run --rm -i aykhans/dodo -u https://example.com -m GET -d 10 -r 1000 -o 1m -t 2s
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2. Config File Usage
|
|
||||||
|
|
||||||
Send 1000 GET requests to https://example.com with 10 parallel dodos (threads), each with a timeout of 800 milliseconds, within a maximum duration of 250 seconds:
|
|
||||||
|
|
||||||
#### 2.1 JSON Example
|
|
||||||
|
|
||||||
```jsonc
|
|
||||||
{
|
|
||||||
"method": "GET",
|
|
||||||
"url": "https://example.com",
|
|
||||||
"yes": false,
|
|
||||||
"timeout": "800ms",
|
|
||||||
"dodos": 10,
|
|
||||||
"requests": 1000,
|
|
||||||
"duration": "250s",
|
|
||||||
|
|
||||||
"params": [
|
|
||||||
// A random value will be selected from the list for first "key1" param on each request
|
|
||||||
// And always "value" for second "key1" param on each request
|
|
||||||
// e.g. "?key1=value2&key1=value"
|
|
||||||
{ "key1": ["value1", "value2", "value3", "value4"] },
|
|
||||||
{ "key1": "value" },
|
|
||||||
|
|
||||||
// A random value will be selected from the list for param "key2" on each request
|
|
||||||
// e.g. "?key2=value2"
|
|
||||||
{ "key2": ["value1", "value2"] },
|
|
||||||
],
|
|
||||||
|
|
||||||
"headers": [
|
|
||||||
// A random value will be selected from the list for first "key1" header on each request
|
|
||||||
// And always "value" for second "key1" header on each request
|
|
||||||
// e.g. "key1: value3", "key1: value"
|
|
||||||
{ "key1": ["value1", "value2", "value3", "value4"] },
|
|
||||||
{ "key1": "value" },
|
|
||||||
|
|
||||||
// A random value will be selected from the list for header "key2" on each request
|
|
||||||
// e.g. "key2: value2"
|
|
||||||
{ "key2": ["value1", "value2"] },
|
|
||||||
],
|
|
||||||
|
|
||||||
"cookies": [
|
|
||||||
// A random value will be selected from the list for first "key1" cookie on each request
|
|
||||||
// And always "value" for second "key1" cookie on each request
|
|
||||||
// e.g. "key1=value4; key1=value"
|
|
||||||
{ "key1": ["value1", "value2", "value3", "value4"] },
|
|
||||||
{ "key1": "value" },
|
|
||||||
|
|
||||||
// A random value will be selected from the list for cookie "key2" on each request
|
|
||||||
// e.g. "key2=value1"
|
|
||||||
{ "key2": ["value1", "value2"] },
|
|
||||||
],
|
|
||||||
|
|
||||||
"body": "body-text",
|
|
||||||
// OR
|
|
||||||
// A random body value will be selected from the list for each request
|
|
||||||
"body": ["body-text1", "body-text2", "body-text3"],
|
|
||||||
|
|
||||||
"proxy": "http://example.com:8080",
|
|
||||||
// OR
|
|
||||||
// A random proxy will be selected from the list for each request
|
|
||||||
"proxy": [
|
|
||||||
"http://example.com:8080",
|
|
||||||
"http://username:password@example.com:8080",
|
|
||||||
"socks5://example.com:8080",
|
|
||||||
"socks5h://example.com:8080",
|
|
||||||
],
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
```sh
|
|
||||||
dodo -f /path/config.json
|
|
||||||
# OR
|
|
||||||
dodo -f https://example.com/config.json
|
|
||||||
```
|
|
||||||
|
|
||||||
With Docker:
|
|
||||||
|
|
||||||
```sh
|
|
||||||
docker run --rm -i -v /path/to/config.json:/config.json aykhans/dodo
|
|
||||||
# OR
|
|
||||||
docker run --rm -i aykhans/dodo -f https://example.com/config.json
|
|
||||||
```
|
|
||||||
|
|
||||||
#### 2.2 YAML/YML Example
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
method: "GET"
|
|
||||||
url: "https://example.com"
|
|
||||||
yes: false
|
|
||||||
timeout: "800ms"
|
|
||||||
dodos: 10
|
|
||||||
requests: 1000
|
|
||||||
duration: "250s"
|
|
||||||
|
|
||||||
params:
|
|
||||||
# A random value will be selected from the list for first "key1" param on each request
|
|
||||||
# And always "value" for second "key1" param on each request
|
|
||||||
# e.g. "?key1=value2&key1=value"
|
|
||||||
- key1: ["value1", "value2", "value3", "value4"]
|
|
||||||
- key1: "value"
|
|
||||||
|
|
||||||
# A random value will be selected from the list for param "key2" on each request
|
|
||||||
# e.g. "?key2=value2"
|
|
||||||
- key2: ["value1", "value2"]
|
|
||||||
|
|
||||||
headers:
|
|
||||||
# A random value will be selected from the list for first "key1" header on each request
|
|
||||||
# And always "value" for second "key1" header on each request
|
|
||||||
# e.g. "key1: value3", "key1: value"
|
|
||||||
- key1: ["value1", "value2", "value3", "value4"]
|
|
||||||
- key1: "value"
|
|
||||||
|
|
||||||
# A random value will be selected from the list for header "key2" on each request
|
|
||||||
# e.g. "key2: value2"
|
|
||||||
- key2: ["value1", "value2"]
|
|
||||||
|
|
||||||
cookies:
|
|
||||||
# A random value will be selected from the list for first "key1" cookie on each request
|
|
||||||
# And always "value" for second "key1" cookie on each request
|
|
||||||
# e.g. "key1=value4; key1=value"
|
|
||||||
- key1: ["value1", "value2", "value3", "value4"]
|
|
||||||
- key1: "value"
|
|
||||||
|
|
||||||
# A random value will be selected from the list for cookie "key2" on each request
|
|
||||||
# e.g. "key2=value1"
|
|
||||||
- key2: ["value1", "value2"]
|
|
||||||
|
|
||||||
body: "body-text"
|
|
||||||
# OR
|
|
||||||
# A random body value will be selected from the list for each request
|
|
||||||
body:
|
|
||||||
- "body-text1"
|
|
||||||
- "body-text2"
|
|
||||||
- "body-text3"
|
|
||||||
|
|
||||||
proxy: "http://example.com:8080"
|
|
||||||
# OR
|
|
||||||
# A random proxy will be selected from the list for each request
|
|
||||||
proxy:
|
|
||||||
- "http://example.com:8080"
|
|
||||||
- "http://username:password@example.com:8080"
|
|
||||||
- "socks5://example.com:8080"
|
|
||||||
- "socks5h://example.com:8080"
|
|
||||||
```
|
|
||||||
|
|
||||||
```sh
|
|
||||||
dodo -f /path/config.yaml
|
|
||||||
# OR
|
|
||||||
dodo -f https://example.com/config.yaml
|
|
||||||
```
|
|
||||||
|
|
||||||
With Docker:
|
|
||||||
|
|
||||||
```sh
|
|
||||||
docker run --rm -i -v /path/to/config.yaml:/config.yaml aykhans/dodo -f /config.yaml
|
|
||||||
# OR
|
|
||||||
docker run --rm -i aykhans/dodo -f https://example.com/config.yaml
|
|
||||||
```
|
|
||||||
|
|
||||||
### 3. CLI & Config File Combination
|
|
||||||
|
|
||||||
CLI arguments override config file values:
|
|
||||||
|
|
||||||
```sh
|
|
||||||
dodo -f /path/to/config.yaml -u https://example.com -m GET -d 10 -r 1000 -o 1m -t 5s
|
|
||||||
```
|
|
||||||
|
|
||||||
With Docker:
|
|
||||||
|
|
||||||
```sh
|
|
||||||
docker run --rm -i -v /path/to/config.json:/config.json aykhans/dodo -f /config.json -u https://example.com -m GET -d 10 -r 1000 -o 1m -t 5s
|
|
||||||
```
|
|
||||||
|
|
||||||
## Config Parameters Reference
|
|
||||||
|
|
||||||
If `Headers`, `Params`, `Cookies`, `Body`, or `Proxy` fields have multiple values, each request will choose a random value from the list.
|
|
||||||
|
|
||||||
| Parameter | config file | CLI Flag | CLI Short Flag | Type | Description | Default |
|
|
||||||
| --------------- | ----------- | ------------ | -------------- | ------------------------------ | ----------------------------------------------------------- | ------- |
|
|
||||||
| Config file | - | -config-file | -f | String | Path to local config file or http(s) URL of the config file | - |
|
|
||||||
| Yes | yes | -yes | -y | Boolean | Answer yes to all questions | false |
|
|
||||||
| URL | url | -url | -u | String | URL to send the request to | - |
|
|
||||||
| Method | method | -method | -m | String | HTTP method | GET |
|
|
||||||
| Dodos (Threads) | dodos | -dodos | -d | UnsignedInteger | Number of dodos (threads) to send requests in parallel | 1 |
|
|
||||||
| Requests | requests | -requests | -r | UnsignedInteger | Total number of requests to send | - |
|
|
||||||
| Duration | duration | -duration | -o | Time | Maximum duration for the test | - |
|
|
||||||
| Timeout | timeout | -timeout | -t | Time | Timeout for canceling each request | 10s |
|
|
||||||
| Params | params | -param | -p | [{String: String OR [String]}] | Request parameters | - |
|
|
||||||
| Headers | headers | -header | -H | [{String: String OR [String]}] | Request headers | - |
|
|
||||||
| Cookies | cookies | -cookie | -c | [{String: String OR [String]}] | Request cookies | - |
|
|
||||||
| Body | body | -body | -b | String OR [String] | Request body or list of request bodies | - |
|
|
||||||
| Proxy | proxies | -proxy | -x | String OR [String] | Proxy URL or list of proxy URLs | - |
|
|
@@ -2,44 +2,19 @@
|
|||||||
|
|
||||||
version: "3"
|
version: "3"
|
||||||
|
|
||||||
vars:
|
|
||||||
PLATFORMS:
|
|
||||||
- os: darwin
|
|
||||||
archs: [amd64, arm64]
|
|
||||||
- os: freebsd
|
|
||||||
archs: [386, amd64, arm]
|
|
||||||
- os: linux
|
|
||||||
archs: [386, amd64, arm, arm64]
|
|
||||||
- os: netbsd
|
|
||||||
archs: [386, amd64, arm]
|
|
||||||
- os: openbsd
|
|
||||||
archs: [386, amd64, arm, arm64]
|
|
||||||
- os: windows
|
|
||||||
archs: [386, amd64, arm64]
|
|
||||||
|
|
||||||
tasks:
|
tasks:
|
||||||
run: go run main.go
|
run: go run cmd/cli/main.go {{.CLI_ARGS}}
|
||||||
|
|
||||||
fmt: gofmt -w -d .
|
ftl:
|
||||||
|
cmds:
|
||||||
|
- task: fmt
|
||||||
|
- task: tidy
|
||||||
|
- task: lint
|
||||||
|
|
||||||
|
fmt: golangci-lint fmt
|
||||||
|
|
||||||
|
tidy: go mod tidy
|
||||||
|
|
||||||
lint: golangci-lint run
|
lint: golangci-lint run
|
||||||
|
|
||||||
build: go build -ldflags "-s -w" -o "dodo"
|
test: go test ./...
|
||||||
|
|
||||||
build-all:
|
|
||||||
silent: true
|
|
||||||
cmds:
|
|
||||||
- rm -rf binaries
|
|
||||||
- |
|
|
||||||
{{ $ext := "" }}
|
|
||||||
{{- range $platform := .PLATFORMS }}
|
|
||||||
{{- if eq $platform.os "windows" }}
|
|
||||||
{{ $ext = ".exe" }}
|
|
||||||
{{- end }}
|
|
||||||
|
|
||||||
{{- range $arch := $platform.archs }}
|
|
||||||
echo "Building for {{$platform.os}}/{{$arch}}"
|
|
||||||
GOOS={{$platform.os}} GOARCH={{$arch}} go build -ldflags "-s -w" -o "./binaries/dodo-{{$platform.os}}-{{$arch}}{{$ext}}"
|
|
||||||
{{- end }}
|
|
||||||
{{- end }}
|
|
||||||
- echo -e "\033[32m*** Build completed ***\033[0m"
|
|
||||||
|
48
cmd/cli/main.go
Normal file
48
cmd/cli/main.go
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
|
||||||
|
"github.com/aykhans/dodo/pkg/config"
|
||||||
|
"github.com/aykhans/dodo/pkg/types"
|
||||||
|
"github.com/aykhans/dodo/pkg/utils"
|
||||||
|
"github.com/jedib0t/go-pretty/v6/text"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
cliParser := config.NewConfigCLIParser(os.Args)
|
||||||
|
cfg, err := cliParser.Parse()
|
||||||
|
|
||||||
|
_ = utils.HandleErrorOrDie(err,
|
||||||
|
utils.OnSentinelError(types.ErrCLINoArgs, func(err error) error {
|
||||||
|
cliParser.PrintHelp()
|
||||||
|
utils.PrintErrAndExit(text.FgRed, 1, "\nNo arguments provided.")
|
||||||
|
return nil
|
||||||
|
}),
|
||||||
|
utils.OnCustomError(func(err types.CLIUnexpectedArgsError) error {
|
||||||
|
cliParser.PrintHelp()
|
||||||
|
utils.PrintErrAndExit(text.FgRed, 1, "\nUnexpected CLI arguments provided: %v", err.Args)
|
||||||
|
return nil
|
||||||
|
}),
|
||||||
|
utils.OnCustomError(func(err types.FieldParseErrors) error {
|
||||||
|
cliParser.PrintHelp()
|
||||||
|
fmt.Println()
|
||||||
|
printValidationErrors("CLI", err.Errors...)
|
||||||
|
fmt.Println()
|
||||||
|
os.Exit(1)
|
||||||
|
return nil
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
fmt.Println(cfg)
|
||||||
|
}
|
||||||
|
|
||||||
|
func printValidationErrors(parserName string, errors ...types.FieldParseError) {
|
||||||
|
for _, fieldErr := range errors {
|
||||||
|
if fieldErr.Value == "" {
|
||||||
|
utils.PrintErr(text.FgYellow, "[%s] Field '%s': %v", parserName, fieldErr.Field, fieldErr.Err)
|
||||||
|
}
|
||||||
|
utils.PrintErr(text.FgYellow, "[%s] Field '%s' (%s): %v", parserName, fieldErr.Field, fieldErr.Value, fieldErr.Err)
|
||||||
|
}
|
||||||
|
}
|
36
config.json
36
config.json
@@ -1,36 +0,0 @@
|
|||||||
{
|
|
||||||
"method": "GET",
|
|
||||||
"url": "https://example.com",
|
|
||||||
"yes": false,
|
|
||||||
"timeout": "5s",
|
|
||||||
"dodos": 8,
|
|
||||||
"requests": 1000,
|
|
||||||
"duration": "10s",
|
|
||||||
|
|
||||||
"params": [
|
|
||||||
{ "key1": ["value1", "value2", "value3", "value4"] },
|
|
||||||
{ "key1": "value" },
|
|
||||||
{ "key2": ["value1", "value2"] }
|
|
||||||
],
|
|
||||||
|
|
||||||
"headers": [
|
|
||||||
{ "key1": ["value1", "value2", "value3", "value4"] },
|
|
||||||
{ "key1": "value" },
|
|
||||||
{ "key2": ["value1", "value2"] }
|
|
||||||
],
|
|
||||||
|
|
||||||
"cookies": [
|
|
||||||
{ "key1": ["value1", "value2", "value3", "value4"] },
|
|
||||||
{ "key1": "value" },
|
|
||||||
{ "key2": ["value1", "value2"] }
|
|
||||||
],
|
|
||||||
|
|
||||||
"body": ["body-text1", "body-text2", "body-text3"],
|
|
||||||
|
|
||||||
"proxy": [
|
|
||||||
"http://example.com:8080",
|
|
||||||
"http://username:password@example.com:8080",
|
|
||||||
"socks5://example.com:8080",
|
|
||||||
"socks5h://example.com:8080"
|
|
||||||
]
|
|
||||||
}
|
|
39
config.yaml
39
config.yaml
@@ -1,39 +0,0 @@
|
|||||||
method: "GET"
|
|
||||||
url: "https://example.com"
|
|
||||||
yes: false
|
|
||||||
timeout: "5s"
|
|
||||||
dodos: 8
|
|
||||||
requests: 1000
|
|
||||||
duration: "10s"
|
|
||||||
|
|
||||||
params:
|
|
||||||
- key1: ["value1", "value2", "value3", "value4"]
|
|
||||||
- key1: "value"
|
|
||||||
- key2: ["value1", "value2"]
|
|
||||||
|
|
||||||
headers:
|
|
||||||
- key1: ["value1", "value2", "value3", "value4"]
|
|
||||||
- key1: "value"
|
|
||||||
- key2: ["value1", "value2"]
|
|
||||||
|
|
||||||
cookies:
|
|
||||||
- key1: ["value1", "value2", "value3", "value4"]
|
|
||||||
- key1: "value"
|
|
||||||
- key2: ["value1", "value2"]
|
|
||||||
|
|
||||||
# body: "body-text"
|
|
||||||
# OR
|
|
||||||
# A random body value will be selected from the list for each request
|
|
||||||
body:
|
|
||||||
- "body-text1"
|
|
||||||
- "body-text2"
|
|
||||||
- "body-text3"
|
|
||||||
|
|
||||||
# proxy: "http://example.com:8080"
|
|
||||||
# OR
|
|
||||||
# A random proxy will be selected from the list for each request
|
|
||||||
proxy:
|
|
||||||
- "http://example.com:8080"
|
|
||||||
- "http://username:password@example.com:8080"
|
|
||||||
- "socks5://example.com:8080"
|
|
||||||
- "socks5h://example.com:8080"
|
|
181
config/cli.go
181
config/cli.go
@@ -1,181 +0,0 @@
|
|||||||
package config
|
|
||||||
|
|
||||||
import (
|
|
||||||
"flag"
|
|
||||||
"fmt"
|
|
||||||
"os"
|
|
||||||
"strings"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/aykhans/dodo/types"
|
|
||||||
"github.com/aykhans/dodo/utils"
|
|
||||||
)
|
|
||||||
|
|
||||||
const cliUsageText = `Usage:
|
|
||||||
dodo [flags]
|
|
||||||
|
|
||||||
Examples:
|
|
||||||
|
|
||||||
Simple usage:
|
|
||||||
dodo -u https://example.com -o 1m
|
|
||||||
|
|
||||||
Usage with config file:
|
|
||||||
dodo -f /path/to/config/file/config.json
|
|
||||||
|
|
||||||
Usage with all flags:
|
|
||||||
dodo -f /path/to/config/file/config.json \
|
|
||||||
-u https://example.com -m POST \
|
|
||||||
-d 10 -r 1000 -o 3m -t 3s \
|
|
||||||
-b "body1" -body "body2" \
|
|
||||||
-H "header1:value1" -header "header2:value2" \
|
|
||||||
-p "param1=value1" -param "param2=value2" \
|
|
||||||
-c "cookie1=value1" -cookie "cookie2=value2" \
|
|
||||||
-x "http://proxy.example.com:8080" -proxy "socks5://proxy2.example.com:8080" \
|
|
||||||
-y
|
|
||||||
|
|
||||||
Flags:
|
|
||||||
-h, -help help for dodo
|
|
||||||
-v, -version version for dodo
|
|
||||||
-y, -yes bool Answer yes to all questions (default %v)
|
|
||||||
-f, -config-file string Path to the local config file or http(s) URL of the config file
|
|
||||||
-d, -dodos uint Number of dodos(threads) (default %d)
|
|
||||||
-r, -requests uint Number of total requests
|
|
||||||
-o, -duration Time Maximum duration for the test (e.g. 30s, 1m, 5h)
|
|
||||||
-t, -timeout Time Timeout for each request (e.g. 400ms, 15s, 1m10s) (default %v)
|
|
||||||
-u, -url string URL for stress testing
|
|
||||||
-m, -method string HTTP Method for the request (default %s)
|
|
||||||
-b, -body [string] Body for the request (e.g. "body text")
|
|
||||||
-p, -param [string] Parameter for the request (e.g. "key1=value1")
|
|
||||||
-H, -header [string] Header for the request (e.g. "key1:value1")
|
|
||||||
-c, -cookie [string] Cookie for the request (e.g. "key1=value1")
|
|
||||||
-x, -proxy [string] Proxy for the request (e.g. "http://proxy.example.com:8080")`
|
|
||||||
|
|
||||||
func (config *Config) ReadCLI() (types.ConfigFile, error) {
|
|
||||||
flag.Usage = func() {
|
|
||||||
fmt.Printf(
|
|
||||||
cliUsageText+"\n",
|
|
||||||
DefaultYes,
|
|
||||||
DefaultDodosCount,
|
|
||||||
DefaultTimeout,
|
|
||||||
DefaultMethod,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
var (
|
|
||||||
version = false
|
|
||||||
configFile = ""
|
|
||||||
yes = false
|
|
||||||
method = ""
|
|
||||||
url types.RequestURL
|
|
||||||
dodosCount = uint(0)
|
|
||||||
requestCount = uint(0)
|
|
||||||
timeout time.Duration
|
|
||||||
duration time.Duration
|
|
||||||
)
|
|
||||||
|
|
||||||
{
|
|
||||||
flag.BoolVar(&version, "version", false, "Prints the version of the program")
|
|
||||||
flag.BoolVar(&version, "v", false, "Prints the version of the program")
|
|
||||||
|
|
||||||
flag.StringVar(&configFile, "config-file", "", "Path to the configuration file")
|
|
||||||
flag.StringVar(&configFile, "f", "", "Path to the configuration file")
|
|
||||||
|
|
||||||
flag.BoolVar(&yes, "yes", false, "Answer yes to all questions")
|
|
||||||
flag.BoolVar(&yes, "y", false, "Answer yes to all questions")
|
|
||||||
|
|
||||||
flag.StringVar(&method, "method", "", "HTTP Method")
|
|
||||||
flag.StringVar(&method, "m", "", "HTTP Method")
|
|
||||||
|
|
||||||
flag.Var(&url, "url", "URL to send the request")
|
|
||||||
flag.Var(&url, "u", "URL to send the request")
|
|
||||||
|
|
||||||
flag.UintVar(&dodosCount, "dodos", 0, "Number of dodos(threads)")
|
|
||||||
flag.UintVar(&dodosCount, "d", 0, "Number of dodos(threads)")
|
|
||||||
|
|
||||||
flag.UintVar(&requestCount, "requests", 0, "Number of total requests")
|
|
||||||
flag.UintVar(&requestCount, "r", 0, "Number of total requests")
|
|
||||||
|
|
||||||
flag.DurationVar(&duration, "duration", 0, "Maximum duration of the test")
|
|
||||||
flag.DurationVar(&duration, "o", 0, "Maximum duration of the test")
|
|
||||||
|
|
||||||
flag.DurationVar(&timeout, "timeout", 0, "Timeout for each request (e.g. 400ms, 15s, 1m10s)")
|
|
||||||
flag.DurationVar(&timeout, "t", 0, "Timeout for each request (e.g. 400ms, 15s, 1m10s)")
|
|
||||||
|
|
||||||
flag.Var(&config.Params, "param", "URL parameter to send with the request")
|
|
||||||
flag.Var(&config.Params, "p", "URL parameter to send with the request")
|
|
||||||
|
|
||||||
flag.Var(&config.Headers, "header", "Header to send with the request")
|
|
||||||
flag.Var(&config.Headers, "H", "Header to send with the request")
|
|
||||||
|
|
||||||
flag.Var(&config.Cookies, "cookie", "Cookie to send with the request")
|
|
||||||
flag.Var(&config.Cookies, "c", "Cookie to send with the request")
|
|
||||||
|
|
||||||
flag.Var(&config.Body, "body", "Body to send with the request")
|
|
||||||
flag.Var(&config.Body, "b", "Body to send with the request")
|
|
||||||
|
|
||||||
flag.Var(&config.Proxies, "proxy", "Proxy to use for the request")
|
|
||||||
flag.Var(&config.Proxies, "x", "Proxy to use for the request")
|
|
||||||
}
|
|
||||||
|
|
||||||
flag.Parse()
|
|
||||||
|
|
||||||
if len(os.Args) <= 1 {
|
|
||||||
flag.CommandLine.Usage()
|
|
||||||
os.Exit(0)
|
|
||||||
}
|
|
||||||
|
|
||||||
if args := flag.Args(); len(args) > 0 {
|
|
||||||
return types.ConfigFile(configFile), fmt.Errorf("unexpected arguments: %v", strings.Join(args, ", "))
|
|
||||||
}
|
|
||||||
|
|
||||||
if version {
|
|
||||||
fmt.Printf("dodo version %s\n", VERSION)
|
|
||||||
os.Exit(0)
|
|
||||||
}
|
|
||||||
|
|
||||||
flag.Visit(func(f *flag.Flag) {
|
|
||||||
switch f.Name {
|
|
||||||
case "method", "m":
|
|
||||||
config.Method = utils.ToPtr(method)
|
|
||||||
case "url", "u":
|
|
||||||
config.URL = utils.ToPtr(url)
|
|
||||||
case "dodos", "d":
|
|
||||||
config.DodosCount = utils.ToPtr(dodosCount)
|
|
||||||
case "requests", "r":
|
|
||||||
config.RequestCount = utils.ToPtr(requestCount)
|
|
||||||
case "duration", "o":
|
|
||||||
config.Duration = &types.Duration{Duration: duration}
|
|
||||||
case "timeout", "t":
|
|
||||||
config.Timeout = &types.Timeout{Duration: timeout}
|
|
||||||
case "yes", "y":
|
|
||||||
config.Yes = utils.ToPtr(yes)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
return types.ConfigFile(configFile), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// CLIYesOrNoReader reads a yes or no answer from the command line.
|
|
||||||
// It prompts the user with the given message and default value,
|
|
||||||
// and returns true if the user answers "y" or "Y", and false otherwise.
|
|
||||||
// If there is an error while reading the input, it returns false.
|
|
||||||
// If the user simply presses enter without providing any input,
|
|
||||||
// it returns the default value specified by the `dft` parameter.
|
|
||||||
func CLIYesOrNoReader(message string, dft bool) bool {
|
|
||||||
var answer string
|
|
||||||
defaultMessage := "Y/n"
|
|
||||||
if !dft {
|
|
||||||
defaultMessage = "y/N"
|
|
||||||
}
|
|
||||||
fmt.Printf("%s [%s]: ", message, defaultMessage)
|
|
||||||
if _, err := fmt.Scanln(&answer); err != nil {
|
|
||||||
if err.Error() == "unexpected newline" {
|
|
||||||
return dft
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
if answer == "" {
|
|
||||||
return dft
|
|
||||||
}
|
|
||||||
return answer == "y" || answer == "Y"
|
|
||||||
}
|
|
260
config/config.go
260
config/config.go
@@ -1,260 +0,0 @@
|
|||||||
package config
|
|
||||||
|
|
||||||
import (
|
|
||||||
"errors"
|
|
||||||
"fmt"
|
|
||||||
"net/url"
|
|
||||||
"os"
|
|
||||||
"slices"
|
|
||||||
"strings"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/aykhans/dodo/types"
|
|
||||||
"github.com/aykhans/dodo/utils"
|
|
||||||
"github.com/jedib0t/go-pretty/v6/table"
|
|
||||||
)
|
|
||||||
|
|
||||||
const (
|
|
||||||
VERSION string = "0.6.3"
|
|
||||||
DefaultUserAgent string = "Dodo/" + VERSION
|
|
||||||
DefaultMethod string = "GET"
|
|
||||||
DefaultTimeout time.Duration = time.Second * 10
|
|
||||||
DefaultDodosCount uint = 1
|
|
||||||
DefaultRequestCount uint = 0
|
|
||||||
DefaultDuration time.Duration = 0
|
|
||||||
DefaultYes bool = false
|
|
||||||
)
|
|
||||||
|
|
||||||
var SupportedProxySchemes []string = []string{"http", "socks5", "socks5h"}
|
|
||||||
|
|
||||||
type RequestConfig struct {
|
|
||||||
Method string
|
|
||||||
URL url.URL
|
|
||||||
Timeout time.Duration
|
|
||||||
DodosCount uint
|
|
||||||
RequestCount uint
|
|
||||||
Duration time.Duration
|
|
||||||
Yes bool
|
|
||||||
Params types.Params
|
|
||||||
Headers types.Headers
|
|
||||||
Cookies types.Cookies
|
|
||||||
Body types.Body
|
|
||||||
Proxies types.Proxies
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewRequestConfig(conf *Config) *RequestConfig {
|
|
||||||
return &RequestConfig{
|
|
||||||
Method: *conf.Method,
|
|
||||||
URL: conf.URL.URL,
|
|
||||||
Timeout: conf.Timeout.Duration,
|
|
||||||
DodosCount: *conf.DodosCount,
|
|
||||||
RequestCount: *conf.RequestCount,
|
|
||||||
Duration: conf.Duration.Duration,
|
|
||||||
Yes: *conf.Yes,
|
|
||||||
Params: conf.Params,
|
|
||||||
Headers: conf.Headers,
|
|
||||||
Cookies: conf.Cookies,
|
|
||||||
Body: conf.Body,
|
|
||||||
Proxies: conf.Proxies,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (rc *RequestConfig) GetValidDodosCountForRequests() uint {
|
|
||||||
if rc.RequestCount == 0 {
|
|
||||||
return rc.DodosCount
|
|
||||||
}
|
|
||||||
return min(rc.DodosCount, rc.RequestCount)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (rc *RequestConfig) GetMaxConns(minConns uint) uint {
|
|
||||||
maxConns := max(
|
|
||||||
minConns, rc.GetValidDodosCountForRequests(),
|
|
||||||
)
|
|
||||||
return ((maxConns * 50 / 100) + maxConns)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (rc *RequestConfig) Print() {
|
|
||||||
t := table.NewWriter()
|
|
||||||
t.SetOutputMirror(os.Stdout)
|
|
||||||
t.SetStyle(table.StyleLight)
|
|
||||||
t.SetColumnConfigs([]table.ColumnConfig{
|
|
||||||
{
|
|
||||||
Number: 2,
|
|
||||||
WidthMaxEnforcer: func(col string, maxLen int) string {
|
|
||||||
lines := strings.Split(col, "\n")
|
|
||||||
for i, line := range lines {
|
|
||||||
if len(line) > maxLen {
|
|
||||||
lines[i] = line[:maxLen-3] + "..."
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return strings.Join(lines, "\n")
|
|
||||||
},
|
|
||||||
WidthMax: 50},
|
|
||||||
})
|
|
||||||
|
|
||||||
t.AppendHeader(table.Row{"Request Configuration"})
|
|
||||||
t.AppendRow(table.Row{"URL", rc.URL.String()})
|
|
||||||
t.AppendSeparator()
|
|
||||||
t.AppendRow(table.Row{"Method", rc.Method})
|
|
||||||
t.AppendSeparator()
|
|
||||||
t.AppendRow(table.Row{"Timeout", rc.Timeout})
|
|
||||||
t.AppendSeparator()
|
|
||||||
t.AppendRow(table.Row{"Dodos", rc.DodosCount})
|
|
||||||
t.AppendSeparator()
|
|
||||||
if rc.RequestCount > 0 {
|
|
||||||
t.AppendRow(table.Row{"Requests", rc.RequestCount})
|
|
||||||
} else {
|
|
||||||
t.AppendRow(table.Row{"Requests"})
|
|
||||||
}
|
|
||||||
t.AppendSeparator()
|
|
||||||
if rc.Duration > 0 {
|
|
||||||
t.AppendRow(table.Row{"Duration", rc.Duration})
|
|
||||||
} else {
|
|
||||||
t.AppendRow(table.Row{"Duration"})
|
|
||||||
}
|
|
||||||
t.AppendSeparator()
|
|
||||||
t.AppendRow(table.Row{"Params", rc.Params.String()})
|
|
||||||
t.AppendSeparator()
|
|
||||||
t.AppendRow(table.Row{"Headers", rc.Headers.String()})
|
|
||||||
t.AppendSeparator()
|
|
||||||
t.AppendRow(table.Row{"Cookies", rc.Cookies.String()})
|
|
||||||
t.AppendSeparator()
|
|
||||||
t.AppendRow(table.Row{"Proxy", rc.Proxies.String()})
|
|
||||||
t.AppendSeparator()
|
|
||||||
t.AppendRow(table.Row{"Body", rc.Body.String()})
|
|
||||||
|
|
||||||
t.Render()
|
|
||||||
}
|
|
||||||
|
|
||||||
type Config struct {
|
|
||||||
Method *string `json:"method" yaml:"method"`
|
|
||||||
URL *types.RequestURL `json:"url" yaml:"url"`
|
|
||||||
Timeout *types.Timeout `json:"timeout" yaml:"timeout"`
|
|
||||||
DodosCount *uint `json:"dodos" yaml:"dodos"`
|
|
||||||
RequestCount *uint `json:"requests" yaml:"requests"`
|
|
||||||
Duration *types.Duration `json:"duration" yaml:"duration"`
|
|
||||||
Yes *bool `json:"yes" yaml:"yes"`
|
|
||||||
Params types.Params `json:"params" yaml:"params"`
|
|
||||||
Headers types.Headers `json:"headers" yaml:"headers"`
|
|
||||||
Cookies types.Cookies `json:"cookies" yaml:"cookies"`
|
|
||||||
Body types.Body `json:"body" yaml:"body"`
|
|
||||||
Proxies types.Proxies `json:"proxy" yaml:"proxy"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewConfig() *Config {
|
|
||||||
return &Config{}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (config *Config) Validate() []error {
|
|
||||||
var errs []error
|
|
||||||
if utils.IsNilOrZero(config.URL) {
|
|
||||||
errs = append(errs, errors.New("request URL is required"))
|
|
||||||
} else {
|
|
||||||
if config.URL.Scheme == "" {
|
|
||||||
config.URL.Scheme = "http"
|
|
||||||
}
|
|
||||||
if config.URL.Scheme != "http" && config.URL.Scheme != "https" {
|
|
||||||
errs = append(errs, errors.New("request URL scheme must be http or https"))
|
|
||||||
}
|
|
||||||
|
|
||||||
urlParams := types.Params{}
|
|
||||||
for key, values := range config.URL.Query() {
|
|
||||||
for _, value := range values {
|
|
||||||
urlParams = append(urlParams, types.KeyValue[string, []string]{
|
|
||||||
Key: key,
|
|
||||||
Value: []string{value},
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
config.Params = append(urlParams, config.Params...)
|
|
||||||
config.URL.RawQuery = ""
|
|
||||||
}
|
|
||||||
|
|
||||||
if utils.IsNilOrZero(config.Method) {
|
|
||||||
errs = append(errs, errors.New("request method is required"))
|
|
||||||
}
|
|
||||||
if utils.IsNilOrZero(config.Timeout) {
|
|
||||||
errs = append(errs, errors.New("request timeout must be greater than 0"))
|
|
||||||
}
|
|
||||||
if utils.IsNilOrZero(config.DodosCount) {
|
|
||||||
errs = append(errs, errors.New("dodos count must be greater than 0"))
|
|
||||||
}
|
|
||||||
if utils.IsNilOrZero(config.Duration) && utils.IsNilOrZero(config.RequestCount) {
|
|
||||||
errs = append(errs, errors.New("you should provide at least one of duration or request count"))
|
|
||||||
}
|
|
||||||
|
|
||||||
for i, proxy := range config.Proxies {
|
|
||||||
if proxy.String() == "" {
|
|
||||||
errs = append(errs, fmt.Errorf("proxies[%d]: proxy cannot be empty", i))
|
|
||||||
} else if schema := proxy.Scheme; !slices.Contains(SupportedProxySchemes, schema) {
|
|
||||||
errs = append(errs,
|
|
||||||
fmt.Errorf("proxies[%d]: proxy has unsupported scheme \"%s\" (supported schemes: %s)",
|
|
||||||
i, proxy.String(), strings.Join(SupportedProxySchemes, ", "),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return errs
|
|
||||||
}
|
|
||||||
|
|
||||||
func (config *Config) MergeConfig(newConfig *Config) {
|
|
||||||
if newConfig.Method != nil {
|
|
||||||
config.Method = newConfig.Method
|
|
||||||
}
|
|
||||||
if newConfig.URL != nil {
|
|
||||||
config.URL = newConfig.URL
|
|
||||||
}
|
|
||||||
if newConfig.Timeout != nil {
|
|
||||||
config.Timeout = newConfig.Timeout
|
|
||||||
}
|
|
||||||
if newConfig.DodosCount != nil {
|
|
||||||
config.DodosCount = newConfig.DodosCount
|
|
||||||
}
|
|
||||||
if newConfig.RequestCount != nil {
|
|
||||||
config.RequestCount = newConfig.RequestCount
|
|
||||||
}
|
|
||||||
if newConfig.Duration != nil {
|
|
||||||
config.Duration = newConfig.Duration
|
|
||||||
}
|
|
||||||
if newConfig.Yes != nil {
|
|
||||||
config.Yes = newConfig.Yes
|
|
||||||
}
|
|
||||||
if len(newConfig.Params) != 0 {
|
|
||||||
config.Params = newConfig.Params
|
|
||||||
}
|
|
||||||
if len(newConfig.Headers) != 0 {
|
|
||||||
config.Headers = newConfig.Headers
|
|
||||||
}
|
|
||||||
if len(newConfig.Cookies) != 0 {
|
|
||||||
config.Cookies = newConfig.Cookies
|
|
||||||
}
|
|
||||||
if len(newConfig.Body) != 0 {
|
|
||||||
config.Body = newConfig.Body
|
|
||||||
}
|
|
||||||
if len(newConfig.Proxies) != 0 {
|
|
||||||
config.Proxies = newConfig.Proxies
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (config *Config) SetDefaults() {
|
|
||||||
if config.Method == nil {
|
|
||||||
config.Method = utils.ToPtr(DefaultMethod)
|
|
||||||
}
|
|
||||||
if config.Timeout == nil {
|
|
||||||
config.Timeout = &types.Timeout{Duration: DefaultTimeout}
|
|
||||||
}
|
|
||||||
if config.DodosCount == nil {
|
|
||||||
config.DodosCount = utils.ToPtr(DefaultDodosCount)
|
|
||||||
}
|
|
||||||
if config.RequestCount == nil {
|
|
||||||
config.RequestCount = utils.ToPtr(DefaultRequestCount)
|
|
||||||
}
|
|
||||||
if config.Duration == nil {
|
|
||||||
config.Duration = &types.Duration{Duration: DefaultDuration}
|
|
||||||
}
|
|
||||||
if config.Yes == nil {
|
|
||||||
config.Yes = utils.ToPtr(DefaultYes)
|
|
||||||
}
|
|
||||||
config.Headers.SetIfNotExists("User-Agent", DefaultUserAgent)
|
|
||||||
}
|
|
@@ -1,84 +0,0 @@
|
|||||||
package config
|
|
||||||
|
|
||||||
import (
|
|
||||||
"encoding/json"
|
|
||||||
"errors"
|
|
||||||
"fmt"
|
|
||||||
"io"
|
|
||||||
"net/http"
|
|
||||||
"os"
|
|
||||||
"slices"
|
|
||||||
"strings"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/aykhans/dodo/types"
|
|
||||||
"gopkg.in/yaml.v3"
|
|
||||||
)
|
|
||||||
|
|
||||||
var supportedFileTypes = []string{"json", "yaml", "yml"}
|
|
||||||
|
|
||||||
func (config *Config) ReadFile(filePath types.ConfigFile) error {
|
|
||||||
var (
|
|
||||||
data []byte
|
|
||||||
err error
|
|
||||||
)
|
|
||||||
|
|
||||||
fileExt := filePath.Extension()
|
|
||||||
if slices.Contains(supportedFileTypes, fileExt) {
|
|
||||||
if filePath.LocationType() == types.FileLocationTypeRemoteHTTP {
|
|
||||||
client := &http.Client{
|
|
||||||
Timeout: 10 * time.Second,
|
|
||||||
}
|
|
||||||
|
|
||||||
resp, err := client.Get(filePath.String())
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to fetch config file from %s", filePath)
|
|
||||||
}
|
|
||||||
defer func() { _ = resp.Body.Close() }()
|
|
||||||
|
|
||||||
data, err = io.ReadAll(io.Reader(resp.Body))
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to read config file from %s", filePath)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
data, err = os.ReadFile(filePath.String())
|
|
||||||
if err != nil {
|
|
||||||
return errors.New("failed to read config file from " + filePath.String())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
switch fileExt {
|
|
||||||
case "json":
|
|
||||||
return parseJSONConfig(data, config)
|
|
||||||
case "yml", "yaml":
|
|
||||||
return parseYAMLConfig(data, config)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return fmt.Errorf("unsupported config file type (supported types: %v)", strings.Join(supportedFileTypes, ", "))
|
|
||||||
}
|
|
||||||
|
|
||||||
func parseJSONConfig(data []byte, config *Config) error {
|
|
||||||
err := json.Unmarshal(data, &config)
|
|
||||||
if err != nil {
|
|
||||||
switch parsedErr := err.(type) {
|
|
||||||
case *json.SyntaxError:
|
|
||||||
return fmt.Errorf("JSON Config file: invalid syntax at byte offset %d", parsedErr.Offset)
|
|
||||||
case *json.UnmarshalTypeError:
|
|
||||||
return fmt.Errorf("JSON Config file: invalid type %v for field %s, expected %v", parsedErr.Value, parsedErr.Field, parsedErr.Type)
|
|
||||||
default:
|
|
||||||
return fmt.Errorf("JSON Config file: %s", err.Error())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func parseYAMLConfig(data []byte, config *Config) error {
|
|
||||||
err := yaml.Unmarshal(data, &config)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("YAML Config file: %s", err.Error())
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
19
go.mod
19
go.mod
@@ -1,21 +1,18 @@
|
|||||||
module github.com/aykhans/dodo
|
module github.com/aykhans/dodo
|
||||||
|
|
||||||
go 1.24.0
|
go 1.25
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/jedib0t/go-pretty/v6 v6.6.7
|
github.com/jedib0t/go-pretty/v6 v6.6.8
|
||||||
github.com/valyala/fasthttp v1.60.0
|
github.com/stretchr/testify v1.10.0
|
||||||
gopkg.in/yaml.v3 v3.0.1
|
|
||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/andybalholm/brotli v1.1.1 // indirect
|
github.com/davecgh/go-spew v1.1.1 // indirect
|
||||||
github.com/klauspost/compress v1.18.0 // indirect
|
|
||||||
github.com/mattn/go-runewidth v0.0.16 // indirect
|
github.com/mattn/go-runewidth v0.0.16 // indirect
|
||||||
|
github.com/pmezard/go-difflib v1.0.0 // indirect
|
||||||
github.com/rivo/uniseg v0.4.7 // indirect
|
github.com/rivo/uniseg v0.4.7 // indirect
|
||||||
github.com/valyala/bytebufferpool v1.0.0 // indirect
|
golang.org/x/sys v0.30.0 // indirect
|
||||||
golang.org/x/net v0.38.0 // indirect
|
golang.org/x/text v0.22.0 // indirect
|
||||||
golang.org/x/sys v0.31.0 // indirect
|
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||||
golang.org/x/term v0.30.0 // indirect
|
|
||||||
golang.org/x/text v0.23.0 // indirect
|
|
||||||
)
|
)
|
||||||
|
26
go.sum
26
go.sum
@@ -1,11 +1,7 @@
|
|||||||
github.com/andybalholm/brotli v1.1.1 h1:PR2pgnyFznKEugtsUo0xLdDop5SKXd5Qf5ysW+7XdTA=
|
|
||||||
github.com/andybalholm/brotli v1.1.1/go.mod h1:05ib4cKhjx3OQYUY22hTVd34Bc8upXjOLL2rKwwZBoA=
|
|
||||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
github.com/jedib0t/go-pretty/v6 v6.6.7 h1:m+LbHpm0aIAPLzLbMfn8dc3Ht8MW7lsSO4MPItz/Uuo=
|
github.com/jedib0t/go-pretty/v6 v6.6.8 h1:JnnzQeRz2bACBobIaa/r+nqjvws4yEhcmaZ4n1QzsEc=
|
||||||
github.com/jedib0t/go-pretty/v6 v6.6.7/go.mod h1:YwC5CE4fJ1HFUDeivSV1r//AmANFHyqczZk+U6BDALU=
|
github.com/jedib0t/go-pretty/v6 v6.6.8/go.mod h1:YwC5CE4fJ1HFUDeivSV1r//AmANFHyqczZk+U6BDALU=
|
||||||
github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo=
|
|
||||||
github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ=
|
|
||||||
github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc=
|
github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc=
|
||||||
github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
|
github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
|
||||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||||
@@ -15,20 +11,10 @@ github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
|
|||||||
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
|
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
|
||||||
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
|
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
|
||||||
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||||
github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
|
golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc=
|
||||||
github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
|
golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||||
github.com/valyala/fasthttp v1.60.0 h1:kBRYS0lOhVJ6V+bYN8PqAHELKHtXqwq9zNMLKx1MBsw=
|
golang.org/x/text v0.22.0 h1:bofq7m3/HAFvbF51jz3Q9wLg3jkvSPuiZu/pD1XwgtM=
|
||||||
github.com/valyala/fasthttp v1.60.0/go.mod h1:iY4kDgV3Gc6EqhRZ8icqcmlG6bqhcDXfuHgTO4FXCvc=
|
golang.org/x/text v0.22.0/go.mod h1:YRoo4H8PVmsu+E3Ou7cqLVH8oXWIHVoX0jqUWALQhfY=
|
||||||
github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU=
|
|
||||||
github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E=
|
|
||||||
golang.org/x/net v0.38.0 h1:vRMAPTMaeGqVhG5QyLJHqNDwecKTomGeqbnfZyKlBI8=
|
|
||||||
golang.org/x/net v0.38.0/go.mod h1:ivrbrMbzFq5J41QOQh0siUuly180yBYtLp+CKbEaFx8=
|
|
||||||
golang.org/x/sys v0.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik=
|
|
||||||
golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
|
|
||||||
golang.org/x/term v0.30.0 h1:PQ39fJZ+mfadBm0y5WlL4vlM7Sx1Hgf13sMIY2+QS9Y=
|
|
||||||
golang.org/x/term v0.30.0/go.mod h1:NYYFdzHoI5wRh/h5tDMdMqCqPJZEuNqVR5xJLd/n67g=
|
|
||||||
golang.org/x/text v0.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY=
|
|
||||||
golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4=
|
|
||||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
|
||||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
|
69
main.go
69
main.go
@@ -1,69 +0,0 @@
|
|||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"errors"
|
|
||||||
"fmt"
|
|
||||||
"os"
|
|
||||||
"os/signal"
|
|
||||||
"syscall"
|
|
||||||
|
|
||||||
"github.com/aykhans/dodo/config"
|
|
||||||
"github.com/aykhans/dodo/requests"
|
|
||||||
"github.com/aykhans/dodo/types"
|
|
||||||
"github.com/aykhans/dodo/utils"
|
|
||||||
"github.com/jedib0t/go-pretty/v6/text"
|
|
||||||
)
|
|
||||||
|
|
||||||
func main() {
|
|
||||||
conf := config.NewConfig()
|
|
||||||
configFile, err := conf.ReadCLI()
|
|
||||||
if err != nil {
|
|
||||||
utils.PrintErrAndExit(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if configFile.String() != "" {
|
|
||||||
tempConf := config.NewConfig()
|
|
||||||
if err := tempConf.ReadFile(configFile); err != nil {
|
|
||||||
utils.PrintErrAndExit(err)
|
|
||||||
}
|
|
||||||
tempConf.MergeConfig(conf)
|
|
||||||
conf = tempConf
|
|
||||||
}
|
|
||||||
conf.SetDefaults()
|
|
||||||
|
|
||||||
if errs := conf.Validate(); len(errs) > 0 {
|
|
||||||
utils.PrintErrAndExit(errors.Join(errs...))
|
|
||||||
}
|
|
||||||
|
|
||||||
requestConf := config.NewRequestConfig(conf)
|
|
||||||
requestConf.Print()
|
|
||||||
|
|
||||||
if !requestConf.Yes {
|
|
||||||
response := config.CLIYesOrNoReader("Do you want to continue?", false)
|
|
||||||
if !response {
|
|
||||||
utils.PrintAndExit("Exiting...\n")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
ctx, cancel := context.WithCancel(context.Background())
|
|
||||||
go listenForTermination(func() { cancel() })
|
|
||||||
|
|
||||||
responses, err := requests.Run(ctx, requestConf)
|
|
||||||
if err != nil {
|
|
||||||
if err == types.ErrInterrupt {
|
|
||||||
fmt.Println(text.FgYellow.Sprint(err.Error()))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
utils.PrintErrAndExit(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
responses.Print()
|
|
||||||
}
|
|
||||||
|
|
||||||
func listenForTermination(do func()) {
|
|
||||||
sigChan := make(chan os.Signal, 1)
|
|
||||||
signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM)
|
|
||||||
<-sigChan
|
|
||||||
do()
|
|
||||||
}
|
|
300
pkg/config/cli.go
Normal file
300
pkg/config/cli.go
Normal file
@@ -0,0 +1,300 @@
|
|||||||
|
package config
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"flag"
|
||||||
|
"fmt"
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/aykhans/dodo/pkg/types"
|
||||||
|
"github.com/aykhans/dodo/pkg/utils"
|
||||||
|
)
|
||||||
|
|
||||||
|
const cliUsageText = `Usage:
|
||||||
|
dodo [flags]
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
|
||||||
|
Simple usage:
|
||||||
|
dodo -u https://example.com -o 1m
|
||||||
|
|
||||||
|
Usage with config file:
|
||||||
|
dodo -f /path/to/config/file/config.json
|
||||||
|
|
||||||
|
Usage with all flags:
|
||||||
|
dodo -f /path/to/config/file/config.json \
|
||||||
|
-u https://example.com -m POST \
|
||||||
|
-d 10 -r 1000 -o 3m -t 3s \
|
||||||
|
-b "body1" -body "body2" \
|
||||||
|
-H "header1:value1" -header "header2:value2" \
|
||||||
|
-p "param1=value1" -param "param2=value2" \
|
||||||
|
-c "cookie1=value1" -cookie "cookie2=value2" \
|
||||||
|
-x "http://proxy.example.com:8080" -proxy "socks5://proxy2.example.com:8080" \
|
||||||
|
-skip-verify -y
|
||||||
|
|
||||||
|
Flags:
|
||||||
|
-h, -help help for dodo
|
||||||
|
-v, -version version for dodo
|
||||||
|
-y, -yes bool Answer yes to all questions (default %v)
|
||||||
|
-f, -config-file string Path to the local config file or http(s) URL of the config file
|
||||||
|
-d, -dodos uint Number of dodos(threads) (default %d)
|
||||||
|
-r, -requests uint Number of total requests
|
||||||
|
-o, -duration Time Maximum duration for the test (e.g. 30s, 1m, 5h)
|
||||||
|
-t, -timeout Time Timeout for each request (e.g. 400ms, 15s, 1m10s) (default %v)
|
||||||
|
-u, -url string URL for stress testing
|
||||||
|
-m, -method string HTTP Method for the request (default %s)
|
||||||
|
-b, -body [string] Body for the request (e.g. "body text")
|
||||||
|
-p, -param [string] Parameter for the request (e.g. "key1=value1")
|
||||||
|
-H, -header [string] Header for the request (e.g. "key1: value1")
|
||||||
|
-c, -cookie [string] Cookie for the request (e.g. "key1=value1")
|
||||||
|
-x, -proxy [string] Proxy for the request (e.g. "http://proxy.example.com:8080")
|
||||||
|
-skip-verify bool Skip SSL/TLS certificate verification (default %v)`
|
||||||
|
|
||||||
|
type ConfigCLIParser struct {
|
||||||
|
args []string
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewConfigCLIParser(args []string) *ConfigCLIParser {
|
||||||
|
if args == nil {
|
||||||
|
args = []string{}
|
||||||
|
}
|
||||||
|
return &ConfigCLIParser{args: args}
|
||||||
|
}
|
||||||
|
|
||||||
|
type stringSliceArg []string
|
||||||
|
|
||||||
|
func (arg *stringSliceArg) String() string {
|
||||||
|
return strings.Join(*arg, ",")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (arg *stringSliceArg) Set(value string) error {
|
||||||
|
*arg = append(*arg, value)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse parses command-line arguments into a Config object.
|
||||||
|
// It can return the following errors:
|
||||||
|
// - types.ErrCLINoArgs
|
||||||
|
// - types.CLIUnexpectedArgsError
|
||||||
|
// - types.FieldParseErrors
|
||||||
|
func (parser *ConfigCLIParser) Parse() (*Config, error) {
|
||||||
|
flagSet := flag.NewFlagSet("dodo", flag.ExitOnError)
|
||||||
|
|
||||||
|
flagSet.Usage = func() { parser.PrintHelp() }
|
||||||
|
|
||||||
|
var (
|
||||||
|
config = &Config{}
|
||||||
|
configFiles = stringSliceArg{}
|
||||||
|
yes bool
|
||||||
|
skipVerify bool
|
||||||
|
method string
|
||||||
|
urlInput string
|
||||||
|
dodosCount uint
|
||||||
|
requestCount uint
|
||||||
|
duration time.Duration
|
||||||
|
timeout time.Duration
|
||||||
|
params = stringSliceArg{}
|
||||||
|
headers = stringSliceArg{}
|
||||||
|
cookies = stringSliceArg{}
|
||||||
|
bodies = stringSliceArg{}
|
||||||
|
proxies = stringSliceArg{}
|
||||||
|
)
|
||||||
|
|
||||||
|
{
|
||||||
|
flagSet.Var(&configFiles, "config-file", "Config file")
|
||||||
|
flagSet.Var(&configFiles, "f", "Config file")
|
||||||
|
|
||||||
|
flagSet.BoolVar(&yes, "yes", false, "Answer yes to all questions")
|
||||||
|
flagSet.BoolVar(&yes, "y", false, "Answer yes to all questions")
|
||||||
|
|
||||||
|
flagSet.BoolVar(&skipVerify, "skip-verify", false, "Skip SSL/TLS certificate verification")
|
||||||
|
|
||||||
|
flagSet.StringVar(&method, "method", "", "HTTP Method")
|
||||||
|
flagSet.StringVar(&method, "m", "", "HTTP Method")
|
||||||
|
|
||||||
|
flagSet.StringVar(&urlInput, "url", "", "URL to send the request")
|
||||||
|
flagSet.StringVar(&urlInput, "u", "", "URL to send the request")
|
||||||
|
|
||||||
|
flagSet.UintVar(&dodosCount, "dodos", 0, "Number of dodos(threads)")
|
||||||
|
flagSet.UintVar(&dodosCount, "d", 0, "Number of dodos(threads)")
|
||||||
|
|
||||||
|
flagSet.UintVar(&requestCount, "requests", 0, "Number of total requests")
|
||||||
|
flagSet.UintVar(&requestCount, "r", 0, "Number of total requests")
|
||||||
|
|
||||||
|
flagSet.DurationVar(&duration, "duration", 0, "Maximum duration of the test")
|
||||||
|
flagSet.DurationVar(&duration, "o", 0, "Maximum duration of the test")
|
||||||
|
|
||||||
|
flagSet.DurationVar(&timeout, "timeout", 0, "Timeout for each request (e.g. 400ms, 15s, 1m10s)")
|
||||||
|
flagSet.DurationVar(&timeout, "t", 0, "Timeout for each request (e.g. 400ms, 15s, 1m10s)")
|
||||||
|
|
||||||
|
flagSet.Var(¶ms, "param", "URL parameter to send with the request")
|
||||||
|
flagSet.Var(¶ms, "p", "URL parameter to send with the request")
|
||||||
|
|
||||||
|
flagSet.Var(&headers, "header", "Header to send with the request")
|
||||||
|
flagSet.Var(&headers, "H", "Header to send with the request")
|
||||||
|
|
||||||
|
flagSet.Var(&cookies, "cookie", "Cookie to send with the request")
|
||||||
|
flagSet.Var(&cookies, "c", "Cookie to send with the request")
|
||||||
|
|
||||||
|
flagSet.Var(&bodies, "body", "Body to send with the request")
|
||||||
|
flagSet.Var(&bodies, "b", "Body to send with the request")
|
||||||
|
|
||||||
|
flagSet.Var(&proxies, "proxy", "Proxy to use for the request")
|
||||||
|
flagSet.Var(&proxies, "x", "Proxy to use for the request")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse the specific arguments provided to the parser, skipping the program name.
|
||||||
|
if err := flagSet.Parse(parser.args[1:]); err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if no flags were set and no non-flag arguments were provided.
|
||||||
|
// This covers cases where `dodo` is run without any meaningful arguments.
|
||||||
|
if flagSet.NFlag() == 0 && len(flagSet.Args()) == 0 {
|
||||||
|
return nil, types.ErrCLINoArgs
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for any unexpected non-flag arguments remaining after parsing.
|
||||||
|
if args := flagSet.Args(); len(args) > 0 {
|
||||||
|
return nil, types.NewCLIUnexpectedArgsError(args)
|
||||||
|
}
|
||||||
|
|
||||||
|
var fieldParseErrors []types.FieldParseError
|
||||||
|
// Iterate over flags that were explicitly set on the command line.
|
||||||
|
flagSet.Visit(func(flagVar *flag.Flag) {
|
||||||
|
switch flagVar.Name {
|
||||||
|
case "config-file", "f":
|
||||||
|
for i, configFile := range configFiles {
|
||||||
|
configFileParsed, err := types.ParseConfigFile(configFile)
|
||||||
|
|
||||||
|
_ = utils.HandleErrorOrDie(err,
|
||||||
|
utils.OnSentinelError(types.ErrConfigFileExtensionNotFound, func(err error) error {
|
||||||
|
fieldParseErrors = append(
|
||||||
|
fieldParseErrors,
|
||||||
|
*types.NewFieldParseError(
|
||||||
|
fmt.Sprintf("config-file[%d]", i),
|
||||||
|
configFile,
|
||||||
|
errors.New("file extension not found"),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
return nil
|
||||||
|
}),
|
||||||
|
utils.OnCustomError(func(err types.RemoteConfigFileParseError) error {
|
||||||
|
fieldParseErrors = append(
|
||||||
|
fieldParseErrors,
|
||||||
|
*types.NewFieldParseError(
|
||||||
|
fmt.Sprintf("config-file[%d]", i),
|
||||||
|
configFile,
|
||||||
|
fmt.Errorf("parse error: %w", err),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
return nil
|
||||||
|
}),
|
||||||
|
utils.OnCustomError(func(err types.UnknownConfigFileTypeError) error {
|
||||||
|
fieldParseErrors = append(
|
||||||
|
fieldParseErrors,
|
||||||
|
*types.NewFieldParseError(
|
||||||
|
fmt.Sprintf("config-file[%d]", i),
|
||||||
|
configFile,
|
||||||
|
fmt.Errorf("file type '%s' not supported (supported types: %s)", err.Type, types.ConfigFileTypeYAML),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
return nil
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
if err == nil {
|
||||||
|
config.Files = append(config.Files, *configFileParsed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
case "yes", "y":
|
||||||
|
config.Yes = utils.ToPtr(yes)
|
||||||
|
case "skip-verify":
|
||||||
|
config.SkipVerify = utils.ToPtr(skipVerify)
|
||||||
|
case "method", "m":
|
||||||
|
config.Method = utils.ToPtr(method)
|
||||||
|
case "url", "u":
|
||||||
|
urlParsed, err := url.Parse(urlInput)
|
||||||
|
if err != nil {
|
||||||
|
fieldParseErrors = append(fieldParseErrors, *types.NewFieldParseError("url", urlInput, err))
|
||||||
|
} else {
|
||||||
|
config.URL = urlParsed
|
||||||
|
}
|
||||||
|
case "dodos", "d":
|
||||||
|
config.DodosCount = utils.ToPtr(dodosCount)
|
||||||
|
case "requests", "r":
|
||||||
|
config.RequestCount = utils.ToPtr(requestCount)
|
||||||
|
case "duration", "o":
|
||||||
|
config.Duration = utils.ToPtr(duration)
|
||||||
|
case "timeout", "t":
|
||||||
|
config.Timeout = utils.ToPtr(timeout)
|
||||||
|
case "param", "p":
|
||||||
|
config.Params.Parse(params...)
|
||||||
|
case "header", "H":
|
||||||
|
config.Headers.Parse(headers...)
|
||||||
|
case "cookie", "c":
|
||||||
|
config.Cookies.Parse(cookies...)
|
||||||
|
case "body", "b":
|
||||||
|
config.Bodies.Parse(bodies...)
|
||||||
|
case "proxy", "x":
|
||||||
|
for i, proxy := range proxies {
|
||||||
|
err := config.Proxies.Parse(proxy)
|
||||||
|
if err != nil {
|
||||||
|
fieldParseErrors = append(
|
||||||
|
fieldParseErrors,
|
||||||
|
*types.NewFieldParseError(fmt.Sprintf("proxy[%d]", i), proxy, err),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
if len(fieldParseErrors) > 0 {
|
||||||
|
return nil, types.NewFieldParseErrors(fieldParseErrors)
|
||||||
|
}
|
||||||
|
|
||||||
|
return config, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (parser *ConfigCLIParser) PrintHelp() {
|
||||||
|
fmt.Printf(
|
||||||
|
cliUsageText+"\n",
|
||||||
|
Defaults.Yes,
|
||||||
|
Defaults.DodosCount,
|
||||||
|
Defaults.RequestTimeout,
|
||||||
|
Defaults.Method,
|
||||||
|
Defaults.SkipVerify,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// CLIYesOrNoReader reads a yes or no answer from the command line.
|
||||||
|
// It prompts the user with the given message and default value,
|
||||||
|
// and returns true if the user answers "y" or "Y", and false otherwise.
|
||||||
|
// If there is an error while reading the input, it returns false.
|
||||||
|
// If the user simply presses enter without providing any input,
|
||||||
|
// it returns the default value specified by the `def` parameter.
|
||||||
|
func CLIYesOrNoReader(message string, def bool) bool {
|
||||||
|
var answer string
|
||||||
|
defaultMessage := "Y/n"
|
||||||
|
|
||||||
|
if !def {
|
||||||
|
defaultMessage = "y/N"
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf("%s [%s]: ", message, defaultMessage)
|
||||||
|
if _, err := fmt.Scanln(&answer); err != nil {
|
||||||
|
if err.Error() == "unexpected newline" {
|
||||||
|
return def
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
if answer == "" {
|
||||||
|
return def
|
||||||
|
}
|
||||||
|
|
||||||
|
return answer == "y" || answer == "Y"
|
||||||
|
}
|
758
pkg/config/cli_test.go
Normal file
758
pkg/config/cli_test.go
Normal file
@@ -0,0 +1,758 @@
|
|||||||
|
package config
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"io"
|
||||||
|
"net/url"
|
||||||
|
"os"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/aykhans/dodo/pkg/types"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestNewConfigCLIParser(t *testing.T) {
|
||||||
|
t.Run("NewConfigCLIParser with valid args", func(t *testing.T) {
|
||||||
|
args := []string{"dodo", "-u", "https://example.com"}
|
||||||
|
parser := NewConfigCLIParser(args)
|
||||||
|
|
||||||
|
require.NotNil(t, parser)
|
||||||
|
assert.Equal(t, args, parser.args)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("NewConfigCLIParser with nil args", func(t *testing.T) {
|
||||||
|
parser := NewConfigCLIParser(nil)
|
||||||
|
|
||||||
|
require.NotNil(t, parser)
|
||||||
|
assert.Equal(t, []string{}, parser.args)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("NewConfigCLIParser with empty args", func(t *testing.T) {
|
||||||
|
args := []string{}
|
||||||
|
parser := NewConfigCLIParser(args)
|
||||||
|
|
||||||
|
require.NotNil(t, parser)
|
||||||
|
assert.Equal(t, args, parser.args)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStringSliceArg(t *testing.T) {
|
||||||
|
t.Run("stringSliceArg String method", func(t *testing.T) {
|
||||||
|
arg := stringSliceArg{"value1", "value2", "value3"}
|
||||||
|
assert.Equal(t, "value1,value2,value3", arg.String())
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("stringSliceArg String with empty slice", func(t *testing.T) {
|
||||||
|
arg := stringSliceArg{}
|
||||||
|
assert.Empty(t, arg.String())
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("stringSliceArg String with single value", func(t *testing.T) {
|
||||||
|
arg := stringSliceArg{"single"}
|
||||||
|
assert.Equal(t, "single", arg.String())
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("stringSliceArg Set method", func(t *testing.T) {
|
||||||
|
arg := &stringSliceArg{}
|
||||||
|
|
||||||
|
err := arg.Set("first")
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, stringSliceArg{"first"}, *arg)
|
||||||
|
|
||||||
|
err = arg.Set("second")
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, stringSliceArg{"first", "second"}, *arg)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("stringSliceArg Set with empty string", func(t *testing.T) {
|
||||||
|
arg := &stringSliceArg{}
|
||||||
|
|
||||||
|
err := arg.Set("")
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, stringSliceArg{""}, *arg)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConfigCLIParser_Parse(t *testing.T) {
|
||||||
|
t.Run("Parse with no arguments returns ErrCLINoArgs", func(t *testing.T) {
|
||||||
|
parser := NewConfigCLIParser([]string{"dodo"})
|
||||||
|
config, err := parser.Parse()
|
||||||
|
|
||||||
|
assert.Nil(t, config)
|
||||||
|
require.ErrorIs(t, err, types.ErrCLINoArgs)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("Parse with unexpected arguments returns CLIUnexpectedArgsError", func(t *testing.T) {
|
||||||
|
parser := NewConfigCLIParser([]string{"dodo", "unexpected", "args"})
|
||||||
|
config, err := parser.Parse()
|
||||||
|
|
||||||
|
assert.Nil(t, config)
|
||||||
|
var cliErr types.CLIUnexpectedArgsError
|
||||||
|
require.ErrorAs(t, err, &cliErr)
|
||||||
|
assert.Equal(t, []string{"unexpected", "args"}, cliErr.Args)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("Parse with valid URL", func(t *testing.T) {
|
||||||
|
parser := NewConfigCLIParser([]string{"dodo", "-u", "https://example.com"})
|
||||||
|
config, err := parser.Parse()
|
||||||
|
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NotNil(t, config)
|
||||||
|
require.NotNil(t, config.URL)
|
||||||
|
assert.Equal(t, "https://example.com", config.URL.String())
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("Parse with invalid URL returns FieldParseErrors", func(t *testing.T) {
|
||||||
|
parser := NewConfigCLIParser([]string{"dodo", "-u", "://invalid-url"})
|
||||||
|
config, err := parser.Parse()
|
||||||
|
|
||||||
|
assert.Nil(t, config)
|
||||||
|
var fieldErr types.FieldParseErrors
|
||||||
|
require.ErrorAs(t, err, &fieldErr)
|
||||||
|
assert.Len(t, fieldErr.Errors, 1)
|
||||||
|
assert.Equal(t, "url", fieldErr.Errors[0].Field)
|
||||||
|
assert.Equal(t, "://invalid-url", fieldErr.Errors[0].Value)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("Parse with method flag", func(t *testing.T) {
|
||||||
|
parser := NewConfigCLIParser([]string{"dodo", "-m", "POST"})
|
||||||
|
config, err := parser.Parse()
|
||||||
|
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NotNil(t, config)
|
||||||
|
require.NotNil(t, config.Method)
|
||||||
|
assert.Equal(t, "POST", *config.Method)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("Parse with yes flag", func(t *testing.T) {
|
||||||
|
parser := NewConfigCLIParser([]string{"dodo", "-y"})
|
||||||
|
config, err := parser.Parse()
|
||||||
|
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NotNil(t, config)
|
||||||
|
require.NotNil(t, config.Yes)
|
||||||
|
assert.True(t, *config.Yes)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("Parse with skip-verify flag", func(t *testing.T) {
|
||||||
|
parser := NewConfigCLIParser([]string{"dodo", "-skip-verify"})
|
||||||
|
config, err := parser.Parse()
|
||||||
|
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NotNil(t, config)
|
||||||
|
require.NotNil(t, config.SkipVerify)
|
||||||
|
assert.True(t, *config.SkipVerify)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("Parse with dodos count", func(t *testing.T) {
|
||||||
|
parser := NewConfigCLIParser([]string{"dodo", "-d", "5"})
|
||||||
|
config, err := parser.Parse()
|
||||||
|
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NotNil(t, config)
|
||||||
|
require.NotNil(t, config.DodosCount)
|
||||||
|
assert.Equal(t, uint(5), *config.DodosCount)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("Parse with request count", func(t *testing.T) {
|
||||||
|
parser := NewConfigCLIParser([]string{"dodo", "-r", "1000"})
|
||||||
|
config, err := parser.Parse()
|
||||||
|
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NotNil(t, config)
|
||||||
|
require.NotNil(t, config.RequestCount)
|
||||||
|
assert.Equal(t, uint(1000), *config.RequestCount)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("Parse with duration", func(t *testing.T) {
|
||||||
|
parser := NewConfigCLIParser([]string{"dodo", "-o", "5m"})
|
||||||
|
config, err := parser.Parse()
|
||||||
|
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NotNil(t, config)
|
||||||
|
require.NotNil(t, config.Duration)
|
||||||
|
assert.Equal(t, 5*time.Minute, *config.Duration)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("Parse with timeout", func(t *testing.T) {
|
||||||
|
parser := NewConfigCLIParser([]string{"dodo", "-t", "30s"})
|
||||||
|
config, err := parser.Parse()
|
||||||
|
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NotNil(t, config)
|
||||||
|
require.NotNil(t, config.Timeout)
|
||||||
|
assert.Equal(t, 30*time.Second, *config.Timeout)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("Parse with parameters", func(t *testing.T) {
|
||||||
|
parser := NewConfigCLIParser([]string{"dodo", "-p", "key1=value1", "-p", "key2=value2"})
|
||||||
|
config, err := parser.Parse()
|
||||||
|
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NotNil(t, config)
|
||||||
|
assert.Len(t, config.Params, 2)
|
||||||
|
assert.Equal(t, "key1", config.Params[0].Key)
|
||||||
|
assert.Equal(t, []string{"value1"}, config.Params[0].Value)
|
||||||
|
assert.Equal(t, "key2", config.Params[1].Key)
|
||||||
|
assert.Equal(t, []string{"value2"}, config.Params[1].Value)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("Parse with headers", func(t *testing.T) {
|
||||||
|
parser := NewConfigCLIParser([]string{"dodo", "-H", "Content-Type: application/json", "-H", "Authorization: Bearer token"})
|
||||||
|
config, err := parser.Parse()
|
||||||
|
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NotNil(t, config)
|
||||||
|
assert.Len(t, config.Headers, 2)
|
||||||
|
assert.Equal(t, "Content-Type", config.Headers[0].Key)
|
||||||
|
assert.Equal(t, []string{"application/json"}, config.Headers[0].Value)
|
||||||
|
assert.Equal(t, "Authorization", config.Headers[1].Key)
|
||||||
|
assert.Equal(t, []string{"Bearer token"}, config.Headers[1].Value)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("Parse with cookies", func(t *testing.T) {
|
||||||
|
parser := NewConfigCLIParser([]string{"dodo", "-c", "session=abc123", "-c", "user=john"})
|
||||||
|
config, err := parser.Parse()
|
||||||
|
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NotNil(t, config)
|
||||||
|
assert.Len(t, config.Cookies, 2)
|
||||||
|
assert.Equal(t, "session", config.Cookies[0].Key)
|
||||||
|
assert.Equal(t, []string{"abc123"}, config.Cookies[0].Value)
|
||||||
|
assert.Equal(t, "user", config.Cookies[1].Key)
|
||||||
|
assert.Equal(t, []string{"john"}, config.Cookies[1].Value)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("Parse with bodies", func(t *testing.T) {
|
||||||
|
parser := NewConfigCLIParser([]string{"dodo", "-b", "body1", "-b", "body2"})
|
||||||
|
config, err := parser.Parse()
|
||||||
|
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NotNil(t, config)
|
||||||
|
assert.Len(t, config.Bodies, 2)
|
||||||
|
assert.Equal(t, types.Body("body1"), config.Bodies[0])
|
||||||
|
assert.Equal(t, types.Body("body2"), config.Bodies[1])
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("Parse with valid proxies", func(t *testing.T) {
|
||||||
|
parser := NewConfigCLIParser([]string{"dodo", "-x", "http://proxy1.example.com:8080", "-x", "socks5://proxy2.example.com:1080"})
|
||||||
|
config, err := parser.Parse()
|
||||||
|
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NotNil(t, config)
|
||||||
|
assert.Len(t, config.Proxies, 2)
|
||||||
|
assert.Equal(t, "http://proxy1.example.com:8080", config.Proxies[0].String())
|
||||||
|
assert.Equal(t, "socks5://proxy2.example.com:1080", config.Proxies[1].String())
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("Parse with invalid proxy returns FieldParseErrors", func(t *testing.T) {
|
||||||
|
parser := NewConfigCLIParser([]string{"dodo", "-x", "://invalid-proxy"})
|
||||||
|
config, err := parser.Parse()
|
||||||
|
|
||||||
|
assert.Nil(t, config)
|
||||||
|
var fieldErr types.FieldParseErrors
|
||||||
|
require.ErrorAs(t, err, &fieldErr)
|
||||||
|
assert.Len(t, fieldErr.Errors, 1)
|
||||||
|
assert.Equal(t, "proxy[0]", fieldErr.Errors[0].Field)
|
||||||
|
assert.Equal(t, "://invalid-proxy", fieldErr.Errors[0].Value)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("Parse with mixed valid and invalid proxies", func(t *testing.T) {
|
||||||
|
parser := NewConfigCLIParser([]string{"dodo", "-x", "http://valid.example.com:8080", "-x", "://invalid"})
|
||||||
|
config, err := parser.Parse()
|
||||||
|
|
||||||
|
assert.Nil(t, config)
|
||||||
|
var fieldErr types.FieldParseErrors
|
||||||
|
require.ErrorAs(t, err, &fieldErr)
|
||||||
|
assert.Len(t, fieldErr.Errors, 1)
|
||||||
|
assert.Equal(t, "proxy[1]", fieldErr.Errors[0].Field)
|
||||||
|
assert.Equal(t, "://invalid", fieldErr.Errors[0].Value)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("Parse with long flag names", func(t *testing.T) {
|
||||||
|
parser := NewConfigCLIParser([]string{
|
||||||
|
"dodo",
|
||||||
|
"--url", "https://example.com",
|
||||||
|
"--method", "POST",
|
||||||
|
"--yes",
|
||||||
|
"--skip-verify",
|
||||||
|
"--dodos", "3",
|
||||||
|
"--requests", "500",
|
||||||
|
"--duration", "1m",
|
||||||
|
"--timeout", "10s",
|
||||||
|
})
|
||||||
|
config, err := parser.Parse()
|
||||||
|
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NotNil(t, config)
|
||||||
|
|
||||||
|
assert.Equal(t, "https://example.com", config.URL.String())
|
||||||
|
assert.Equal(t, "POST", *config.Method)
|
||||||
|
assert.True(t, *config.Yes)
|
||||||
|
assert.True(t, *config.SkipVerify)
|
||||||
|
assert.Equal(t, uint(3), *config.DodosCount)
|
||||||
|
assert.Equal(t, uint(500), *config.RequestCount)
|
||||||
|
assert.Equal(t, time.Minute, *config.Duration)
|
||||||
|
assert.Equal(t, 10*time.Second, *config.Timeout)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("Parse with config-file flag valid YAML", func(t *testing.T) {
|
||||||
|
parser := NewConfigCLIParser([]string{"dodo", "-f", "/path/to/config.yaml"})
|
||||||
|
config, err := parser.Parse()
|
||||||
|
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NotNil(t, config)
|
||||||
|
assert.Len(t, config.Files, 1)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("Parse with config-file flag using long form", func(t *testing.T) {
|
||||||
|
parser := NewConfigCLIParser([]string{"dodo", "--config-file", "/path/to/config.yml"})
|
||||||
|
config, err := parser.Parse()
|
||||||
|
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NotNil(t, config)
|
||||||
|
assert.Len(t, config.Files, 1)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("Parse with config-file flag invalid extension", func(t *testing.T) {
|
||||||
|
parser := NewConfigCLIParser([]string{"dodo", "-f", "/path/to/config"})
|
||||||
|
config, err := parser.Parse()
|
||||||
|
|
||||||
|
assert.Nil(t, config)
|
||||||
|
var fieldErr types.FieldParseErrors
|
||||||
|
require.ErrorAs(t, err, &fieldErr)
|
||||||
|
assert.Len(t, fieldErr.Errors, 1)
|
||||||
|
assert.Equal(t, "config-file[0]", fieldErr.Errors[0].Field)
|
||||||
|
assert.Equal(t, "/path/to/config", fieldErr.Errors[0].Value)
|
||||||
|
assert.Contains(t, fieldErr.Errors[0].Err.Error(), "file extension not found")
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("Parse with config-file flag unsupported file type", func(t *testing.T) {
|
||||||
|
parser := NewConfigCLIParser([]string{"dodo", "-f", "/path/to/config.json"})
|
||||||
|
config, err := parser.Parse()
|
||||||
|
|
||||||
|
assert.Nil(t, config)
|
||||||
|
var fieldErr types.FieldParseErrors
|
||||||
|
require.ErrorAs(t, err, &fieldErr)
|
||||||
|
assert.Len(t, fieldErr.Errors, 1)
|
||||||
|
assert.Equal(t, "config-file[0]", fieldErr.Errors[0].Field)
|
||||||
|
assert.Equal(t, "/path/to/config.json", fieldErr.Errors[0].Value)
|
||||||
|
assert.Contains(t, fieldErr.Errors[0].Err.Error(), "file type")
|
||||||
|
assert.Contains(t, fieldErr.Errors[0].Err.Error(), "not supported")
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("Parse with config-file flag remote URL", func(t *testing.T) {
|
||||||
|
parser := NewConfigCLIParser([]string{"dodo", "-f", "https://example.com/config.yaml"})
|
||||||
|
config, err := parser.Parse()
|
||||||
|
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NotNil(t, config)
|
||||||
|
assert.Len(t, config.Files, 1)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("Parse with multiple config files", func(t *testing.T) {
|
||||||
|
parser := NewConfigCLIParser([]string{"dodo", "-f", "/path/config1.yaml", "-f", "/path/config2.yml"})
|
||||||
|
config, err := parser.Parse()
|
||||||
|
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NotNil(t, config)
|
||||||
|
assert.Len(t, config.Files, 2)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("Parse with all flags combined", func(t *testing.T) {
|
||||||
|
parser := NewConfigCLIParser([]string{
|
||||||
|
"dodo",
|
||||||
|
"-u", "https://api.example.com/test",
|
||||||
|
"-m", "PUT",
|
||||||
|
"-y",
|
||||||
|
"-skip-verify",
|
||||||
|
"-d", "10",
|
||||||
|
"-r", "2000",
|
||||||
|
"-o", "30m",
|
||||||
|
"-t", "5s",
|
||||||
|
"-p", "apikey=123",
|
||||||
|
"-H", "Content-Type: application/json",
|
||||||
|
"-c", "session=token123",
|
||||||
|
"-b", `{"data": "test"}`,
|
||||||
|
"-x", "http://proxy.example.com:3128",
|
||||||
|
"-f", "/path/to/config.yaml",
|
||||||
|
})
|
||||||
|
config, err := parser.Parse()
|
||||||
|
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NotNil(t, config)
|
||||||
|
|
||||||
|
// Verify all fields are set correctly
|
||||||
|
assert.Equal(t, "https://api.example.com/test", config.URL.String())
|
||||||
|
assert.Equal(t, "PUT", *config.Method)
|
||||||
|
assert.True(t, *config.Yes)
|
||||||
|
assert.True(t, *config.SkipVerify)
|
||||||
|
assert.Equal(t, uint(10), *config.DodosCount)
|
||||||
|
assert.Equal(t, uint(2000), *config.RequestCount)
|
||||||
|
assert.Equal(t, 30*time.Minute, *config.Duration)
|
||||||
|
assert.Equal(t, 5*time.Second, *config.Timeout)
|
||||||
|
|
||||||
|
assert.Len(t, config.Params, 1)
|
||||||
|
assert.Equal(t, "apikey", config.Params[0].Key)
|
||||||
|
|
||||||
|
assert.Len(t, config.Headers, 1)
|
||||||
|
assert.Equal(t, "Content-Type", config.Headers[0].Key)
|
||||||
|
|
||||||
|
assert.Len(t, config.Cookies, 1)
|
||||||
|
assert.Equal(t, "session", config.Cookies[0].Key)
|
||||||
|
|
||||||
|
assert.Len(t, config.Bodies, 1)
|
||||||
|
assert.Equal(t, types.Body(`{"data": "test"}`), config.Bodies[0]) //nolint:testifylint
|
||||||
|
|
||||||
|
assert.Len(t, config.Proxies, 1)
|
||||||
|
assert.Equal(t, "http://proxy.example.com:3128", config.Proxies[0].String())
|
||||||
|
|
||||||
|
assert.Len(t, config.Files, 1)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("Parse with multiple field parse errors", func(t *testing.T) {
|
||||||
|
parser := NewConfigCLIParser([]string{
|
||||||
|
"dodo",
|
||||||
|
"-u", "://invalid-url",
|
||||||
|
"-x", "://invalid-proxy1",
|
||||||
|
"-x", "://invalid-proxy2",
|
||||||
|
})
|
||||||
|
config, err := parser.Parse()
|
||||||
|
|
||||||
|
assert.Nil(t, config)
|
||||||
|
var fieldErr types.FieldParseErrors
|
||||||
|
require.ErrorAs(t, err, &fieldErr)
|
||||||
|
assert.Len(t, fieldErr.Errors, 3)
|
||||||
|
|
||||||
|
// Check error fields
|
||||||
|
fields := make(map[string]bool)
|
||||||
|
for _, parseErr := range fieldErr.Errors {
|
||||||
|
fields[parseErr.Field] = true
|
||||||
|
}
|
||||||
|
assert.True(t, fields["url"])
|
||||||
|
assert.True(t, fields["proxy[0]"])
|
||||||
|
assert.True(t, fields["proxy[1]"])
|
||||||
|
|
||||||
|
// Check error values
|
||||||
|
values := make(map[string]string)
|
||||||
|
for _, parseErr := range fieldErr.Errors {
|
||||||
|
values[parseErr.Field] = parseErr.Value
|
||||||
|
}
|
||||||
|
assert.Equal(t, "://invalid-url", values["url"])
|
||||||
|
assert.Equal(t, "://invalid-proxy1", values["proxy[0]"])
|
||||||
|
assert.Equal(t, "://invalid-proxy2", values["proxy[1]"])
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConfigCLIParser_PrintHelp(t *testing.T) {
|
||||||
|
t.Run("PrintHelp outputs expected content", func(t *testing.T) {
|
||||||
|
// Capture stdout
|
||||||
|
oldStdout := os.Stdout
|
||||||
|
reader, writer, _ := os.Pipe()
|
||||||
|
os.Stdout = writer
|
||||||
|
|
||||||
|
parser := NewConfigCLIParser([]string{"dodo"})
|
||||||
|
parser.PrintHelp()
|
||||||
|
|
||||||
|
// Restore stdout and read output
|
||||||
|
writer.Close()
|
||||||
|
os.Stdout = oldStdout
|
||||||
|
var buf bytes.Buffer
|
||||||
|
io.Copy(&buf, reader)
|
||||||
|
output := buf.String()
|
||||||
|
|
||||||
|
// Verify help text contains expected elements
|
||||||
|
assert.Contains(t, output, "Usage:")
|
||||||
|
assert.Contains(t, output, "dodo [flags]")
|
||||||
|
assert.Contains(t, output, "Examples:")
|
||||||
|
assert.Contains(t, output, "Flags:")
|
||||||
|
assert.Contains(t, output, "-h, -help")
|
||||||
|
assert.Contains(t, output, "-v, -version")
|
||||||
|
assert.Contains(t, output, "-u, -url")
|
||||||
|
assert.Contains(t, output, "-m, -method")
|
||||||
|
assert.Contains(t, output, "-d, -dodos")
|
||||||
|
assert.Contains(t, output, "-r, -requests")
|
||||||
|
assert.Contains(t, output, "-t, -timeout")
|
||||||
|
assert.Contains(t, output, "-b, -body")
|
||||||
|
assert.Contains(t, output, "-H, -header")
|
||||||
|
assert.Contains(t, output, "-p, -param")
|
||||||
|
assert.Contains(t, output, "-c, -cookie")
|
||||||
|
assert.Contains(t, output, "-x, -proxy")
|
||||||
|
assert.Contains(t, output, "-skip-verify")
|
||||||
|
assert.Contains(t, output, "-y, -yes")
|
||||||
|
assert.Contains(t, output, "-f, -config-file")
|
||||||
|
|
||||||
|
// Verify default values are included
|
||||||
|
assert.Contains(t, output, Defaults.Method)
|
||||||
|
assert.Contains(t, output, "1") // DodosCount default
|
||||||
|
assert.Contains(t, output, "10s") // RequestTimeout default
|
||||||
|
assert.Contains(t, output, "false") // Yes default
|
||||||
|
assert.Contains(t, output, "false") // SkipVerify default
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCLIYesOrNoReader(t *testing.T) {
|
||||||
|
t.Run("CLIYesOrNoReader with 'y' input returns true", func(t *testing.T) {
|
||||||
|
// Redirect stdin
|
||||||
|
oldStdin := os.Stdin
|
||||||
|
reader, writer, _ := os.Pipe()
|
||||||
|
os.Stdin = reader
|
||||||
|
|
||||||
|
// Write input and close writer
|
||||||
|
writer.WriteString("y\n")
|
||||||
|
writer.Close()
|
||||||
|
|
||||||
|
result := CLIYesOrNoReader("Test question", false)
|
||||||
|
|
||||||
|
// Restore stdin
|
||||||
|
os.Stdin = oldStdin
|
||||||
|
|
||||||
|
assert.True(t, result)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("CLIYesOrNoReader with 'Y' input returns true", func(t *testing.T) {
|
||||||
|
// Redirect stdin
|
||||||
|
oldStdin := os.Stdin
|
||||||
|
reader, writer, _ := os.Pipe()
|
||||||
|
os.Stdin = reader
|
||||||
|
|
||||||
|
// Write input and close writer
|
||||||
|
writer.WriteString("Y\n")
|
||||||
|
writer.Close()
|
||||||
|
|
||||||
|
result := CLIYesOrNoReader("Test question", false)
|
||||||
|
|
||||||
|
// Restore stdin
|
||||||
|
os.Stdin = oldStdin
|
||||||
|
|
||||||
|
assert.True(t, result)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("CLIYesOrNoReader with 'n' input returns false", func(t *testing.T) {
|
||||||
|
// Redirect stdin
|
||||||
|
oldStdin := os.Stdin
|
||||||
|
reader, writer, _ := os.Pipe()
|
||||||
|
os.Stdin = reader
|
||||||
|
|
||||||
|
// Write input and close writer
|
||||||
|
writer.WriteString("n\n")
|
||||||
|
writer.Close()
|
||||||
|
|
||||||
|
result := CLIYesOrNoReader("Test question", true)
|
||||||
|
|
||||||
|
// Restore stdin
|
||||||
|
os.Stdin = oldStdin
|
||||||
|
|
||||||
|
assert.False(t, result)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("CLIYesOrNoReader with empty input returns default", func(t *testing.T) {
|
||||||
|
// Redirect stdin
|
||||||
|
oldStdin := os.Stdin
|
||||||
|
reader, writer, _ := os.Pipe()
|
||||||
|
os.Stdin = reader
|
||||||
|
|
||||||
|
// Write just newline and close writer
|
||||||
|
writer.WriteString("\n")
|
||||||
|
writer.Close()
|
||||||
|
|
||||||
|
// Test with default true
|
||||||
|
result := CLIYesOrNoReader("Test question", true)
|
||||||
|
os.Stdin = oldStdin
|
||||||
|
assert.True(t, result)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("CLIYesOrNoReader with empty input returns default false", func(t *testing.T) {
|
||||||
|
// Redirect stdin
|
||||||
|
oldStdin := os.Stdin
|
||||||
|
reader, writer, _ := os.Pipe()
|
||||||
|
os.Stdin = reader
|
||||||
|
|
||||||
|
// Write just newline and close writer
|
||||||
|
writer.WriteString("\n")
|
||||||
|
writer.Close()
|
||||||
|
|
||||||
|
// Test with default false
|
||||||
|
result := CLIYesOrNoReader("Test question", false)
|
||||||
|
os.Stdin = oldStdin
|
||||||
|
assert.False(t, result)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("CLIYesOrNoReader with other input returns false", func(t *testing.T) {
|
||||||
|
// Redirect stdin
|
||||||
|
oldStdin := os.Stdin
|
||||||
|
reader, writer, _ := os.Pipe()
|
||||||
|
os.Stdin = reader
|
||||||
|
|
||||||
|
// Write other input and close writer
|
||||||
|
writer.WriteString("maybe\n")
|
||||||
|
writer.Close()
|
||||||
|
|
||||||
|
result := CLIYesOrNoReader("Test question", true)
|
||||||
|
|
||||||
|
// Restore stdin
|
||||||
|
os.Stdin = oldStdin
|
||||||
|
|
||||||
|
assert.False(t, result)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("CLIYesOrNoReader message format with default true", func(t *testing.T) {
|
||||||
|
// Capture stdout to verify message format
|
||||||
|
oldStdout := os.Stdout
|
||||||
|
stdoutReader, stdoutWriter, _ := os.Pipe()
|
||||||
|
os.Stdout = stdoutWriter
|
||||||
|
|
||||||
|
// Redirect stdin
|
||||||
|
oldStdin := os.Stdin
|
||||||
|
stdinReader, stdinWriter, _ := os.Pipe()
|
||||||
|
os.Stdin = stdinReader
|
||||||
|
|
||||||
|
// Write input and close writer
|
||||||
|
stdinWriter.WriteString("y\n")
|
||||||
|
stdinWriter.Close()
|
||||||
|
|
||||||
|
CLIYesOrNoReader("Continue?", true)
|
||||||
|
|
||||||
|
// Restore stdin and stdout
|
||||||
|
os.Stdin = oldStdin
|
||||||
|
stdoutWriter.Close()
|
||||||
|
os.Stdout = oldStdout
|
||||||
|
|
||||||
|
// Read output
|
||||||
|
var buf bytes.Buffer
|
||||||
|
io.Copy(&buf, stdoutReader)
|
||||||
|
output := buf.String()
|
||||||
|
|
||||||
|
assert.Contains(t, output, "Continue? [Y/n]:")
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("CLIYesOrNoReader message format with default false", func(t *testing.T) {
|
||||||
|
// Capture stdout to verify message format
|
||||||
|
oldStdout := os.Stdout
|
||||||
|
stdoutReader, stdoutWriter, _ := os.Pipe()
|
||||||
|
os.Stdout = stdoutWriter
|
||||||
|
|
||||||
|
// Redirect stdin
|
||||||
|
oldStdin := os.Stdin
|
||||||
|
stdinReader, stdinWriter, _ := os.Pipe()
|
||||||
|
os.Stdin = stdinReader
|
||||||
|
|
||||||
|
// Write input and close writer
|
||||||
|
stdinWriter.WriteString("n\n")
|
||||||
|
stdinWriter.Close()
|
||||||
|
|
||||||
|
CLIYesOrNoReader("Delete files?", false)
|
||||||
|
|
||||||
|
// Restore stdin and stdout
|
||||||
|
os.Stdin = oldStdin
|
||||||
|
stdoutWriter.Close()
|
||||||
|
os.Stdout = oldStdout
|
||||||
|
|
||||||
|
// Read output
|
||||||
|
var buf bytes.Buffer
|
||||||
|
io.Copy(&buf, stdoutReader)
|
||||||
|
output := buf.String()
|
||||||
|
|
||||||
|
assert.Contains(t, output, "Delete files? [y/N]:")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConfigCLIParser_EdgeCases(t *testing.T) {
|
||||||
|
t.Run("Parse with zero duration", func(t *testing.T) {
|
||||||
|
parser := NewConfigCLIParser([]string{"dodo", "-o", "0s"})
|
||||||
|
config, err := parser.Parse()
|
||||||
|
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NotNil(t, config)
|
||||||
|
require.NotNil(t, config.Duration)
|
||||||
|
assert.Equal(t, time.Duration(0), *config.Duration)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("Parse with zero timeout", func(t *testing.T) {
|
||||||
|
parser := NewConfigCLIParser([]string{"dodo", "-t", "0s"})
|
||||||
|
config, err := parser.Parse()
|
||||||
|
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NotNil(t, config)
|
||||||
|
require.NotNil(t, config.Timeout)
|
||||||
|
assert.Equal(t, time.Duration(0), *config.Timeout)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("Parse with zero dodos count", func(t *testing.T) {
|
||||||
|
parser := NewConfigCLIParser([]string{"dodo", "-d", "0"})
|
||||||
|
config, err := parser.Parse()
|
||||||
|
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NotNil(t, config)
|
||||||
|
require.NotNil(t, config.DodosCount)
|
||||||
|
assert.Equal(t, uint(0), *config.DodosCount)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("Parse with zero request count", func(t *testing.T) {
|
||||||
|
parser := NewConfigCLIParser([]string{"dodo", "-r", "0"})
|
||||||
|
config, err := parser.Parse()
|
||||||
|
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NotNil(t, config)
|
||||||
|
require.NotNil(t, config.RequestCount)
|
||||||
|
assert.Equal(t, uint(0), *config.RequestCount)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("Parse with empty string values", func(t *testing.T) {
|
||||||
|
parser := NewConfigCLIParser([]string{
|
||||||
|
"dodo",
|
||||||
|
"-m", "",
|
||||||
|
"-p", "",
|
||||||
|
"-H", "",
|
||||||
|
"-c", "",
|
||||||
|
"-b", "",
|
||||||
|
})
|
||||||
|
config, err := parser.Parse()
|
||||||
|
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NotNil(t, config)
|
||||||
|
|
||||||
|
assert.Empty(t, *config.Method)
|
||||||
|
assert.Len(t, config.Params, 1)
|
||||||
|
assert.Empty(t, config.Params[0].Key)
|
||||||
|
assert.Len(t, config.Headers, 1)
|
||||||
|
assert.Empty(t, config.Headers[0].Key)
|
||||||
|
assert.Len(t, config.Cookies, 1)
|
||||||
|
assert.Empty(t, config.Cookies[0].Key)
|
||||||
|
assert.Len(t, config.Bodies, 1)
|
||||||
|
assert.Equal(t, types.Body(""), config.Bodies[0])
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("Parse with complex URL", func(t *testing.T) {
|
||||||
|
complexURL := "https://user:pass@api.example.com:8080/v1/endpoint?param=value&other=test#fragment"
|
||||||
|
parser := NewConfigCLIParser([]string{"dodo", "-u", complexURL})
|
||||||
|
config, err := parser.Parse()
|
||||||
|
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NotNil(t, config)
|
||||||
|
require.NotNil(t, config.URL)
|
||||||
|
|
||||||
|
parsedURL, parseErr := url.Parse(complexURL)
|
||||||
|
require.NoError(t, parseErr)
|
||||||
|
assert.Equal(t, parsedURL, config.URL)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("Parse with repeated same flags overrides previous values", func(t *testing.T) {
|
||||||
|
parser := NewConfigCLIParser([]string{
|
||||||
|
"dodo",
|
||||||
|
"-m", "GET",
|
||||||
|
"-m", "POST", // This should override the previous
|
||||||
|
"-d", "1",
|
||||||
|
"-d", "5", // This should override the previous
|
||||||
|
})
|
||||||
|
config, err := parser.Parse()
|
||||||
|
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NotNil(t, config)
|
||||||
|
|
||||||
|
assert.Equal(t, "POST", *config.Method)
|
||||||
|
assert.Equal(t, uint(5), *config.DodosCount)
|
||||||
|
})
|
||||||
|
}
|
114
pkg/config/config.go
Normal file
114
pkg/config/config.go
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
package config
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/url"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/aykhans/dodo/pkg/types"
|
||||||
|
"github.com/aykhans/dodo/pkg/utils"
|
||||||
|
)
|
||||||
|
|
||||||
|
const VERSION string = "1.0.0"
|
||||||
|
|
||||||
|
var Defaults = struct {
|
||||||
|
UserAgent string
|
||||||
|
Method string
|
||||||
|
RequestTimeout time.Duration
|
||||||
|
DodosCount uint
|
||||||
|
Yes bool
|
||||||
|
SkipVerify bool
|
||||||
|
}{
|
||||||
|
UserAgent: "dodo/" + VERSION,
|
||||||
|
Method: "GET",
|
||||||
|
RequestTimeout: time.Second * 10,
|
||||||
|
DodosCount: 1,
|
||||||
|
Yes: false,
|
||||||
|
SkipVerify: false,
|
||||||
|
}
|
||||||
|
|
||||||
|
var SupportedProxySchemes []string = []string{"http", "socks5", "socks5h"}
|
||||||
|
|
||||||
|
type Config struct {
|
||||||
|
Files []types.ConfigFile
|
||||||
|
Method *string
|
||||||
|
URL *url.URL
|
||||||
|
Timeout *time.Duration
|
||||||
|
DodosCount *uint
|
||||||
|
RequestCount *uint
|
||||||
|
Duration *time.Duration
|
||||||
|
Yes *bool
|
||||||
|
SkipVerify *bool
|
||||||
|
Params types.Params
|
||||||
|
Headers types.Headers
|
||||||
|
Cookies types.Cookies
|
||||||
|
Bodies types.Bodies
|
||||||
|
Proxies types.Proxies
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewConfig() *Config {
|
||||||
|
return &Config{}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (config *Config) MergeConfig(newConfig *Config) {
|
||||||
|
config.Files = append(config.Files, newConfig.Files...)
|
||||||
|
if newConfig.Method != nil {
|
||||||
|
config.Method = newConfig.Method
|
||||||
|
}
|
||||||
|
if newConfig.URL != nil {
|
||||||
|
config.URL = newConfig.URL
|
||||||
|
}
|
||||||
|
if newConfig.Timeout != nil {
|
||||||
|
config.Timeout = newConfig.Timeout
|
||||||
|
}
|
||||||
|
if newConfig.DodosCount != nil {
|
||||||
|
config.DodosCount = newConfig.DodosCount
|
||||||
|
}
|
||||||
|
if newConfig.RequestCount != nil {
|
||||||
|
config.RequestCount = newConfig.RequestCount
|
||||||
|
}
|
||||||
|
if newConfig.Duration != nil {
|
||||||
|
config.Duration = newConfig.Duration
|
||||||
|
}
|
||||||
|
if newConfig.Yes != nil {
|
||||||
|
config.Yes = newConfig.Yes
|
||||||
|
}
|
||||||
|
if newConfig.SkipVerify != nil {
|
||||||
|
config.SkipVerify = newConfig.SkipVerify
|
||||||
|
}
|
||||||
|
if len(newConfig.Params) != 0 {
|
||||||
|
config.Params = newConfig.Params
|
||||||
|
}
|
||||||
|
if len(newConfig.Headers) != 0 {
|
||||||
|
config.Headers = newConfig.Headers
|
||||||
|
}
|
||||||
|
if len(newConfig.Cookies) != 0 {
|
||||||
|
config.Cookies = newConfig.Cookies
|
||||||
|
}
|
||||||
|
if len(newConfig.Bodies) != 0 {
|
||||||
|
config.Bodies = newConfig.Bodies
|
||||||
|
}
|
||||||
|
if len(newConfig.Proxies) != 0 {
|
||||||
|
config.Proxies = newConfig.Proxies
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (config *Config) SetDefaults() {
|
||||||
|
if config.Method == nil {
|
||||||
|
config.Method = utils.ToPtr(Defaults.Method)
|
||||||
|
}
|
||||||
|
if config.Timeout == nil {
|
||||||
|
config.Timeout = &Defaults.RequestTimeout
|
||||||
|
}
|
||||||
|
if config.DodosCount == nil {
|
||||||
|
config.DodosCount = utils.ToPtr(Defaults.DodosCount)
|
||||||
|
}
|
||||||
|
if config.Yes == nil {
|
||||||
|
config.Yes = utils.ToPtr(Defaults.Yes)
|
||||||
|
}
|
||||||
|
if config.SkipVerify == nil {
|
||||||
|
config.SkipVerify = utils.ToPtr(Defaults.SkipVerify)
|
||||||
|
}
|
||||||
|
if !config.Headers.Has("User-Agent") {
|
||||||
|
config.Headers = append(config.Headers, types.Header{Key: "User-Agent", Value: []string{Defaults.UserAgent}})
|
||||||
|
}
|
||||||
|
}
|
354
pkg/config/config_test.go
Normal file
354
pkg/config/config_test.go
Normal file
@@ -0,0 +1,354 @@
|
|||||||
|
package config
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/url"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/aykhans/dodo/pkg/types"
|
||||||
|
"github.com/aykhans/dodo/pkg/utils"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestMergeConfig(t *testing.T) {
|
||||||
|
t.Run("MergeConfig with all fields from new config", func(t *testing.T) {
|
||||||
|
originalURL, _ := url.Parse("https://original.example.com")
|
||||||
|
newURL, _ := url.Parse("https://new.example.com")
|
||||||
|
|
||||||
|
originalTimeout := 5 * time.Second
|
||||||
|
newTimeout := 10 * time.Second
|
||||||
|
|
||||||
|
originalDuration := 1 * time.Minute
|
||||||
|
newDuration := 2 * time.Minute
|
||||||
|
|
||||||
|
config := &Config{
|
||||||
|
Files: []types.ConfigFile{},
|
||||||
|
Method: utils.ToPtr("GET"),
|
||||||
|
URL: originalURL,
|
||||||
|
Timeout: &originalTimeout,
|
||||||
|
DodosCount: utils.ToPtr(uint(1)),
|
||||||
|
RequestCount: utils.ToPtr(uint(10)),
|
||||||
|
Duration: &originalDuration,
|
||||||
|
Yes: utils.ToPtr(false),
|
||||||
|
SkipVerify: utils.ToPtr(false),
|
||||||
|
Params: types.Params{{Key: "old", Value: []string{"value"}}},
|
||||||
|
Headers: types.Headers{{Key: "Old-Header", Value: []string{"old"}}},
|
||||||
|
Cookies: types.Cookies{{Key: "oldCookie", Value: []string{"oldValue"}}},
|
||||||
|
Bodies: types.Bodies{types.Body("old body")},
|
||||||
|
Proxies: types.Proxies{},
|
||||||
|
}
|
||||||
|
|
||||||
|
newConfig := &Config{
|
||||||
|
Files: []types.ConfigFile{},
|
||||||
|
Method: utils.ToPtr("POST"),
|
||||||
|
URL: newURL,
|
||||||
|
Timeout: &newTimeout,
|
||||||
|
DodosCount: utils.ToPtr(uint(5)),
|
||||||
|
RequestCount: utils.ToPtr(uint(20)),
|
||||||
|
Duration: &newDuration,
|
||||||
|
Yes: utils.ToPtr(true),
|
||||||
|
SkipVerify: utils.ToPtr(true),
|
||||||
|
Params: types.Params{{Key: "new", Value: []string{"value"}}},
|
||||||
|
Headers: types.Headers{{Key: "New-Header", Value: []string{"new"}}},
|
||||||
|
Cookies: types.Cookies{{Key: "newCookie", Value: []string{"newValue"}}},
|
||||||
|
Bodies: types.Bodies{types.Body("new body")},
|
||||||
|
Proxies: types.Proxies{},
|
||||||
|
}
|
||||||
|
|
||||||
|
config.MergeConfig(newConfig)
|
||||||
|
|
||||||
|
assert.Equal(t, "POST", *config.Method)
|
||||||
|
assert.Equal(t, newURL, config.URL)
|
||||||
|
assert.Equal(t, newTimeout, *config.Timeout)
|
||||||
|
assert.Equal(t, uint(5), *config.DodosCount)
|
||||||
|
assert.Equal(t, uint(20), *config.RequestCount)
|
||||||
|
assert.Equal(t, newDuration, *config.Duration)
|
||||||
|
assert.True(t, *config.Yes)
|
||||||
|
assert.True(t, *config.SkipVerify)
|
||||||
|
assert.Equal(t, types.Params{{Key: "new", Value: []string{"value"}}}, config.Params)
|
||||||
|
assert.Equal(t, types.Headers{{Key: "New-Header", Value: []string{"new"}}}, config.Headers)
|
||||||
|
assert.Equal(t, types.Cookies{{Key: "newCookie", Value: []string{"newValue"}}}, config.Cookies)
|
||||||
|
assert.Equal(t, types.Bodies{types.Body("new body")}, config.Bodies)
|
||||||
|
assert.Empty(t, config.Proxies)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("MergeConfig with partial fields from new config", func(t *testing.T) {
|
||||||
|
originalURL, _ := url.Parse("https://original.example.com")
|
||||||
|
originalTimeout := 5 * time.Second
|
||||||
|
|
||||||
|
config := &Config{
|
||||||
|
Files: []types.ConfigFile{},
|
||||||
|
Method: utils.ToPtr("GET"),
|
||||||
|
URL: originalURL,
|
||||||
|
Timeout: &originalTimeout,
|
||||||
|
DodosCount: utils.ToPtr(uint(1)),
|
||||||
|
Headers: types.Headers{{Key: "Original-Header", Value: []string{"original"}}},
|
||||||
|
}
|
||||||
|
|
||||||
|
newURL, _ := url.Parse("https://new.example.com")
|
||||||
|
newConfig := &Config{
|
||||||
|
Files: []types.ConfigFile{},
|
||||||
|
URL: newURL,
|
||||||
|
DodosCount: utils.ToPtr(uint(10)),
|
||||||
|
}
|
||||||
|
|
||||||
|
config.MergeConfig(newConfig)
|
||||||
|
|
||||||
|
assert.Equal(t, "GET", *config.Method, "Method should remain unchanged")
|
||||||
|
assert.Equal(t, newURL, config.URL, "URL should be updated")
|
||||||
|
assert.Equal(t, originalTimeout, *config.Timeout, "Timeout should remain unchanged")
|
||||||
|
assert.Equal(t, uint(10), *config.DodosCount, "DodosCount should be updated")
|
||||||
|
assert.Equal(t, types.Headers{{Key: "Original-Header", Value: []string{"original"}}}, config.Headers, "Headers should remain unchanged")
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("MergeConfig with nil new config fields", func(t *testing.T) {
|
||||||
|
originalURL, _ := url.Parse("https://original.example.com")
|
||||||
|
originalTimeout := 5 * time.Second
|
||||||
|
|
||||||
|
config := &Config{
|
||||||
|
Files: []types.ConfigFile{},
|
||||||
|
Method: utils.ToPtr("GET"),
|
||||||
|
URL: originalURL,
|
||||||
|
Timeout: &originalTimeout,
|
||||||
|
DodosCount: utils.ToPtr(uint(1)),
|
||||||
|
Yes: utils.ToPtr(false),
|
||||||
|
SkipVerify: utils.ToPtr(false),
|
||||||
|
}
|
||||||
|
|
||||||
|
newConfig := &Config{
|
||||||
|
Files: []types.ConfigFile{},
|
||||||
|
Method: nil,
|
||||||
|
URL: nil,
|
||||||
|
Timeout: nil,
|
||||||
|
DodosCount: nil,
|
||||||
|
Yes: nil,
|
||||||
|
SkipVerify: nil,
|
||||||
|
}
|
||||||
|
|
||||||
|
originalConfigCopy := *config
|
||||||
|
config.MergeConfig(newConfig)
|
||||||
|
|
||||||
|
assert.Equal(t, originalConfigCopy.Method, config.Method)
|
||||||
|
assert.Equal(t, originalConfigCopy.URL, config.URL)
|
||||||
|
assert.Equal(t, originalConfigCopy.Timeout, config.Timeout)
|
||||||
|
assert.Equal(t, originalConfigCopy.DodosCount, config.DodosCount)
|
||||||
|
assert.Equal(t, originalConfigCopy.Yes, config.Yes)
|
||||||
|
assert.Equal(t, originalConfigCopy.SkipVerify, config.SkipVerify)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("MergeConfig with empty slices", func(t *testing.T) {
|
||||||
|
configFile, _ := types.ParseConfigFile("original.yml")
|
||||||
|
config := &Config{
|
||||||
|
Files: []types.ConfigFile{*configFile},
|
||||||
|
Params: types.Params{{Key: "original", Value: []string{"value"}}},
|
||||||
|
Headers: types.Headers{{Key: "Original-Header", Value: []string{"original"}}},
|
||||||
|
Cookies: types.Cookies{{Key: "originalCookie", Value: []string{"originalValue"}}},
|
||||||
|
Bodies: types.Bodies{types.Body("original body")},
|
||||||
|
Proxies: types.Proxies{},
|
||||||
|
}
|
||||||
|
|
||||||
|
newConfig := &Config{
|
||||||
|
Files: []types.ConfigFile{},
|
||||||
|
Params: types.Params{},
|
||||||
|
Headers: types.Headers{},
|
||||||
|
Cookies: types.Cookies{},
|
||||||
|
Bodies: types.Bodies{},
|
||||||
|
Proxies: types.Proxies{},
|
||||||
|
}
|
||||||
|
|
||||||
|
config.MergeConfig(newConfig)
|
||||||
|
|
||||||
|
assert.Equal(t, []types.ConfigFile{*configFile}, config.Files, "Empty Files should not override")
|
||||||
|
assert.Equal(t, types.Params{{Key: "original", Value: []string{"value"}}}, config.Params, "Empty Params should not override")
|
||||||
|
assert.Equal(t, types.Headers{{Key: "Original-Header", Value: []string{"original"}}}, config.Headers, "Empty Headers should not override")
|
||||||
|
assert.Equal(t, types.Cookies{{Key: "originalCookie", Value: []string{"originalValue"}}}, config.Cookies, "Empty Cookies should not override")
|
||||||
|
assert.Equal(t, types.Bodies{types.Body("original body")}, config.Bodies, "Empty Bodies should not override")
|
||||||
|
assert.Equal(t, types.Proxies{}, config.Proxies, "Empty Proxies should not override")
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("MergeConfig with Files field", func(t *testing.T) {
|
||||||
|
configFile1, _ := types.ParseConfigFile("config1.yml")
|
||||||
|
configFile2, _ := types.ParseConfigFile("config2.yaml")
|
||||||
|
|
||||||
|
config := &Config{
|
||||||
|
Files: []types.ConfigFile{*configFile1},
|
||||||
|
Method: utils.ToPtr("GET"),
|
||||||
|
Headers: types.Headers{{Key: "Original-Header", Value: []string{"original"}}},
|
||||||
|
}
|
||||||
|
|
||||||
|
newConfig := &Config{
|
||||||
|
Files: []types.ConfigFile{*configFile2},
|
||||||
|
Method: utils.ToPtr("POST"),
|
||||||
|
}
|
||||||
|
|
||||||
|
config.MergeConfig(newConfig)
|
||||||
|
|
||||||
|
assert.Equal(t, "POST", *config.Method, "Method should be updated")
|
||||||
|
assert.Equal(t, []types.ConfigFile{*configFile1, *configFile2}, config.Files, "Files should be appended")
|
||||||
|
assert.Equal(t, types.Headers{{Key: "Original-Header", Value: []string{"original"}}}, config.Headers, "Headers should remain unchanged")
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("MergeConfig on empty original config", func(t *testing.T) {
|
||||||
|
config := &Config{}
|
||||||
|
|
||||||
|
newURL, _ := url.Parse("https://new.example.com")
|
||||||
|
newTimeout := 10 * time.Second
|
||||||
|
newDuration := 2 * time.Minute
|
||||||
|
|
||||||
|
newConfig := &Config{
|
||||||
|
Files: []types.ConfigFile{},
|
||||||
|
Method: utils.ToPtr("POST"),
|
||||||
|
URL: newURL,
|
||||||
|
Timeout: &newTimeout,
|
||||||
|
DodosCount: utils.ToPtr(uint(5)),
|
||||||
|
RequestCount: utils.ToPtr(uint(20)),
|
||||||
|
Duration: &newDuration,
|
||||||
|
Yes: utils.ToPtr(true),
|
||||||
|
SkipVerify: utils.ToPtr(true),
|
||||||
|
Params: types.Params{{Key: "new", Value: []string{"value"}}},
|
||||||
|
Headers: types.Headers{{Key: "New-Header", Value: []string{"new"}}},
|
||||||
|
Cookies: types.Cookies{{Key: "newCookie", Value: []string{"newValue"}}},
|
||||||
|
Bodies: types.Bodies{types.Body("new body")},
|
||||||
|
Proxies: types.Proxies{},
|
||||||
|
}
|
||||||
|
|
||||||
|
config.MergeConfig(newConfig)
|
||||||
|
|
||||||
|
assert.Equal(t, "POST", *config.Method)
|
||||||
|
assert.Equal(t, newURL, config.URL)
|
||||||
|
assert.Equal(t, newTimeout, *config.Timeout)
|
||||||
|
assert.Equal(t, uint(5), *config.DodosCount)
|
||||||
|
assert.Equal(t, uint(20), *config.RequestCount)
|
||||||
|
assert.Equal(t, newDuration, *config.Duration)
|
||||||
|
assert.True(t, *config.Yes)
|
||||||
|
assert.True(t, *config.SkipVerify)
|
||||||
|
assert.Equal(t, types.Params{{Key: "new", Value: []string{"value"}}}, config.Params)
|
||||||
|
assert.Equal(t, types.Headers{{Key: "New-Header", Value: []string{"new"}}}, config.Headers)
|
||||||
|
assert.Equal(t, types.Cookies{{Key: "newCookie", Value: []string{"newValue"}}}, config.Cookies)
|
||||||
|
assert.Equal(t, types.Bodies{types.Body("new body")}, config.Bodies)
|
||||||
|
assert.Empty(t, config.Proxies)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSetDefaults(t *testing.T) {
|
||||||
|
t.Run("SetDefaults on empty config", func(t *testing.T) {
|
||||||
|
config := &Config{}
|
||||||
|
config.SetDefaults()
|
||||||
|
|
||||||
|
require.NotNil(t, config.Method)
|
||||||
|
assert.Equal(t, Defaults.Method, *config.Method)
|
||||||
|
|
||||||
|
require.NotNil(t, config.Timeout)
|
||||||
|
assert.Equal(t, Defaults.RequestTimeout, *config.Timeout)
|
||||||
|
|
||||||
|
require.NotNil(t, config.DodosCount)
|
||||||
|
assert.Equal(t, Defaults.DodosCount, *config.DodosCount)
|
||||||
|
|
||||||
|
require.NotNil(t, config.Yes)
|
||||||
|
assert.Equal(t, Defaults.Yes, *config.Yes)
|
||||||
|
|
||||||
|
require.NotNil(t, config.SkipVerify)
|
||||||
|
assert.Equal(t, Defaults.SkipVerify, *config.SkipVerify)
|
||||||
|
|
||||||
|
assert.True(t, config.Headers.Has("User-Agent"))
|
||||||
|
assert.Equal(t, Defaults.UserAgent, config.Headers[0].Value[0])
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("SetDefaults preserves existing values", func(t *testing.T) {
|
||||||
|
customTimeout := 30 * time.Second
|
||||||
|
config := &Config{
|
||||||
|
Method: utils.ToPtr("POST"),
|
||||||
|
Timeout: &customTimeout,
|
||||||
|
DodosCount: utils.ToPtr(uint(10)),
|
||||||
|
Yes: utils.ToPtr(true),
|
||||||
|
SkipVerify: utils.ToPtr(true),
|
||||||
|
Headers: types.Headers{{Key: "User-Agent", Value: []string{"custom-agent"}}},
|
||||||
|
}
|
||||||
|
|
||||||
|
config.SetDefaults()
|
||||||
|
|
||||||
|
assert.Equal(t, "POST", *config.Method, "Method should not be overridden")
|
||||||
|
assert.Equal(t, customTimeout, *config.Timeout, "Timeout should not be overridden")
|
||||||
|
assert.Equal(t, uint(10), *config.DodosCount, "DodosCount should not be overridden")
|
||||||
|
assert.True(t, *config.Yes, "Yes should not be overridden")
|
||||||
|
assert.True(t, *config.SkipVerify, "SkipVerify should not be overridden")
|
||||||
|
assert.Equal(t, "custom-agent", config.Headers[0].Value[0], "User-Agent should not be overridden")
|
||||||
|
assert.Len(t, config.Headers, 1, "Should not add duplicate User-Agent")
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("SetDefaults adds User-Agent when missing", func(t *testing.T) {
|
||||||
|
config := &Config{
|
||||||
|
Files: []types.ConfigFile{},
|
||||||
|
Headers: types.Headers{{Key: "Content-Type", Value: []string{"application/json"}}},
|
||||||
|
}
|
||||||
|
|
||||||
|
config.SetDefaults()
|
||||||
|
|
||||||
|
assert.Len(t, config.Headers, 2)
|
||||||
|
assert.True(t, config.Headers.Has("User-Agent"))
|
||||||
|
assert.True(t, config.Headers.Has("Content-Type"))
|
||||||
|
|
||||||
|
var userAgentFound bool
|
||||||
|
for _, h := range config.Headers {
|
||||||
|
if h.Key == "User-Agent" {
|
||||||
|
userAgentFound = true
|
||||||
|
assert.Equal(t, Defaults.UserAgent, h.Value[0])
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assert.True(t, userAgentFound, "User-Agent header should be added")
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("SetDefaults with partial config", func(t *testing.T) {
|
||||||
|
config := &Config{
|
||||||
|
Files: []types.ConfigFile{},
|
||||||
|
Method: utils.ToPtr("PUT"),
|
||||||
|
Yes: utils.ToPtr(true),
|
||||||
|
}
|
||||||
|
|
||||||
|
config.SetDefaults()
|
||||||
|
|
||||||
|
assert.Equal(t, "PUT", *config.Method, "Existing Method should be preserved")
|
||||||
|
assert.True(t, *config.Yes, "Existing Yes should be preserved")
|
||||||
|
|
||||||
|
require.NotNil(t, config.Timeout)
|
||||||
|
assert.Equal(t, Defaults.RequestTimeout, *config.Timeout, "Timeout should be set to default")
|
||||||
|
|
||||||
|
require.NotNil(t, config.DodosCount)
|
||||||
|
assert.Equal(t, Defaults.DodosCount, *config.DodosCount, "DodosCount should be set to default")
|
||||||
|
|
||||||
|
require.NotNil(t, config.SkipVerify)
|
||||||
|
assert.Equal(t, Defaults.SkipVerify, *config.SkipVerify, "SkipVerify should be set to default")
|
||||||
|
|
||||||
|
assert.True(t, config.Headers.Has("User-Agent"))
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("SetDefaults idempotent", func(t *testing.T) {
|
||||||
|
config := &Config{}
|
||||||
|
|
||||||
|
config.SetDefaults()
|
||||||
|
firstCallHeaders := len(config.Headers)
|
||||||
|
firstCallMethod := *config.Method
|
||||||
|
firstCallTimeout := *config.Timeout
|
||||||
|
|
||||||
|
config.SetDefaults()
|
||||||
|
|
||||||
|
assert.Len(t, config.Headers, firstCallHeaders, "Headers count should not change on second call")
|
||||||
|
assert.Equal(t, firstCallMethod, *config.Method, "Method should not change on second call")
|
||||||
|
assert.Equal(t, firstCallTimeout, *config.Timeout, "Timeout should not change on second call")
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("SetDefaults with empty Headers initializes correctly", func(t *testing.T) {
|
||||||
|
config := &Config{
|
||||||
|
Files: []types.ConfigFile{},
|
||||||
|
Headers: types.Headers{},
|
||||||
|
}
|
||||||
|
|
||||||
|
config.SetDefaults()
|
||||||
|
|
||||||
|
assert.Len(t, config.Headers, 1)
|
||||||
|
assert.Equal(t, "User-Agent", config.Headers[0].Key)
|
||||||
|
assert.Equal(t, Defaults.UserAgent, config.Headers[0].Value[0])
|
||||||
|
})
|
||||||
|
}
|
23
pkg/types/body.go
Normal file
23
pkg/types/body.go
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
package types
|
||||||
|
|
||||||
|
type Body string
|
||||||
|
|
||||||
|
func (body Body) String() string {
|
||||||
|
return string(body)
|
||||||
|
}
|
||||||
|
|
||||||
|
type Bodies []Body
|
||||||
|
|
||||||
|
func (bodies *Bodies) Append(body Body) {
|
||||||
|
*bodies = append(*bodies, body)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (bodies *Bodies) Parse(rawValues ...string) {
|
||||||
|
for _, rawValue := range rawValues {
|
||||||
|
bodies.Append(ParseBody(rawValue))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func ParseBody(rawValue string) Body {
|
||||||
|
return Body(rawValue)
|
||||||
|
}
|
160
pkg/types/body_test.go
Normal file
160
pkg/types/body_test.go
Normal file
@@ -0,0 +1,160 @@
|
|||||||
|
package types
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestBody_String(t *testing.T) {
|
||||||
|
t.Run("Body String returns correct value", func(t *testing.T) {
|
||||||
|
body := Body("test body content")
|
||||||
|
assert.Equal(t, "test body content", body.String())
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("Body String with empty body", func(t *testing.T) {
|
||||||
|
body := Body("")
|
||||||
|
assert.Empty(t, body.String())
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("Body String with JSON", func(t *testing.T) {
|
||||||
|
body := Body(`{"key": "value", "number": 42}`)
|
||||||
|
assert.JSONEq(t, `{"key": "value", "number": 42}`, body.String())
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("Body String with special characters", func(t *testing.T) {
|
||||||
|
body := Body("special: !@#$%^&*()\nnewline\ttab")
|
||||||
|
assert.Equal(t, "special: !@#$%^&*()\nnewline\ttab", body.String())
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBodies_Append(t *testing.T) {
|
||||||
|
t.Run("Append single body", func(t *testing.T) {
|
||||||
|
bodies := &Bodies{}
|
||||||
|
bodies.Append(Body("first body"))
|
||||||
|
|
||||||
|
assert.Len(t, *bodies, 1)
|
||||||
|
assert.Equal(t, Body("first body"), (*bodies)[0])
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("Append multiple bodies", func(t *testing.T) {
|
||||||
|
bodies := &Bodies{}
|
||||||
|
bodies.Append(Body("first"))
|
||||||
|
bodies.Append(Body("second"))
|
||||||
|
bodies.Append(Body("third"))
|
||||||
|
|
||||||
|
assert.Len(t, *bodies, 3)
|
||||||
|
assert.Equal(t, Body("first"), (*bodies)[0])
|
||||||
|
assert.Equal(t, Body("second"), (*bodies)[1])
|
||||||
|
assert.Equal(t, Body("third"), (*bodies)[2])
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("Append to existing bodies", func(t *testing.T) {
|
||||||
|
bodies := &Bodies{Body("existing")}
|
||||||
|
bodies.Append(Body("new"))
|
||||||
|
|
||||||
|
assert.Len(t, *bodies, 2)
|
||||||
|
assert.Equal(t, Body("existing"), (*bodies)[0])
|
||||||
|
assert.Equal(t, Body("new"), (*bodies)[1])
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("Append empty body", func(t *testing.T) {
|
||||||
|
bodies := &Bodies{}
|
||||||
|
bodies.Append(Body(""))
|
||||||
|
|
||||||
|
assert.Len(t, *bodies, 1)
|
||||||
|
assert.Empty(t, (*bodies)[0].String())
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBodies_Parse(t *testing.T) {
|
||||||
|
t.Run("Parse single value", func(t *testing.T) {
|
||||||
|
bodies := &Bodies{}
|
||||||
|
bodies.Parse("test body")
|
||||||
|
|
||||||
|
assert.Len(t, *bodies, 1)
|
||||||
|
assert.Equal(t, Body("test body"), (*bodies)[0])
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("Parse multiple values", func(t *testing.T) {
|
||||||
|
bodies := &Bodies{}
|
||||||
|
bodies.Parse("body1", "body2", "body3")
|
||||||
|
|
||||||
|
assert.Len(t, *bodies, 3)
|
||||||
|
assert.Equal(t, Body("body1"), (*bodies)[0])
|
||||||
|
assert.Equal(t, Body("body2"), (*bodies)[1])
|
||||||
|
assert.Equal(t, Body("body3"), (*bodies)[2])
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("Parse with existing bodies", func(t *testing.T) {
|
||||||
|
bodies := &Bodies{Body("existing")}
|
||||||
|
bodies.Parse("new1", "new2")
|
||||||
|
|
||||||
|
assert.Len(t, *bodies, 3)
|
||||||
|
assert.Equal(t, Body("existing"), (*bodies)[0])
|
||||||
|
assert.Equal(t, Body("new1"), (*bodies)[1])
|
||||||
|
assert.Equal(t, Body("new2"), (*bodies)[2])
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("Parse empty values", func(t *testing.T) {
|
||||||
|
bodies := &Bodies{}
|
||||||
|
bodies.Parse("", "", "")
|
||||||
|
|
||||||
|
assert.Len(t, *bodies, 3)
|
||||||
|
for _, body := range *bodies {
|
||||||
|
assert.Empty(t, body.String())
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("Parse no arguments", func(t *testing.T) {
|
||||||
|
bodies := &Bodies{}
|
||||||
|
bodies.Parse()
|
||||||
|
|
||||||
|
assert.Empty(t, *bodies)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("Parse JSON strings", func(t *testing.T) {
|
||||||
|
bodies := &Bodies{}
|
||||||
|
bodies.Parse(`{"key": "value"}`, `{"array": [1, 2, 3]}`)
|
||||||
|
|
||||||
|
assert.Len(t, *bodies, 2)
|
||||||
|
assert.JSONEq(t, `{"key": "value"}`, (*bodies)[0].String())
|
||||||
|
assert.JSONEq(t, `{"array": [1, 2, 3]}`, (*bodies)[1].String())
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseBody(t *testing.T) {
|
||||||
|
t.Run("ParseBody with regular string", func(t *testing.T) {
|
||||||
|
body := ParseBody("test content")
|
||||||
|
assert.Equal(t, Body("test content"), body)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("ParseBody with empty string", func(t *testing.T) {
|
||||||
|
body := ParseBody("")
|
||||||
|
assert.Equal(t, Body(""), body)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("ParseBody with multiline string", func(t *testing.T) {
|
||||||
|
input := "line1\nline2\nline3"
|
||||||
|
body := ParseBody(input)
|
||||||
|
assert.Equal(t, Body(input), body)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("ParseBody with special characters", func(t *testing.T) {
|
||||||
|
input := "!@#$%^&*()_+-=[]{}|;':\",./<>?"
|
||||||
|
body := ParseBody(input)
|
||||||
|
assert.Equal(t, Body(input), body)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("ParseBody with unicode", func(t *testing.T) {
|
||||||
|
input := "Hello World 🌍"
|
||||||
|
body := ParseBody(input)
|
||||||
|
assert.Equal(t, Body(input), body)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("ParseBody preserves whitespace", func(t *testing.T) {
|
||||||
|
input := " leading and trailing spaces "
|
||||||
|
body := ParseBody(input)
|
||||||
|
assert.Equal(t, Body(input), body)
|
||||||
|
})
|
||||||
|
}
|
79
pkg/types/config_file.go
Normal file
79
pkg/types/config_file.go
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
package types
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/url"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
type ConfigFileType string
|
||||||
|
|
||||||
|
const (
|
||||||
|
ConfigFileTypeYAML ConfigFileType = "yaml/yml"
|
||||||
|
)
|
||||||
|
|
||||||
|
type ConfigFileLocationType string
|
||||||
|
|
||||||
|
const (
|
||||||
|
ConfigFileLocationLocal ConfigFileLocationType = "local"
|
||||||
|
ConfigFileLocationRemote ConfigFileLocationType = "remote"
|
||||||
|
)
|
||||||
|
|
||||||
|
type ConfigFile struct {
|
||||||
|
path string
|
||||||
|
_type ConfigFileType
|
||||||
|
locationType ConfigFileLocationType
|
||||||
|
}
|
||||||
|
|
||||||
|
func (configFile ConfigFile) String() string {
|
||||||
|
return configFile.path
|
||||||
|
}
|
||||||
|
|
||||||
|
func (configFile ConfigFile) Type() ConfigFileType {
|
||||||
|
return configFile._type
|
||||||
|
}
|
||||||
|
|
||||||
|
func (configFile ConfigFile) LocationType() ConfigFileLocationType {
|
||||||
|
return configFile.locationType
|
||||||
|
}
|
||||||
|
|
||||||
|
// ParseConfigFile parses a raw string representing a configuration file
|
||||||
|
// path or URL and returns a ConfigFile struct.
|
||||||
|
// It determines the file's type and location (local or remote) based on the string.
|
||||||
|
// It can return the following errors:
|
||||||
|
// - ErrConfigFileExtensionNotFound
|
||||||
|
// - RemoteConfigFileParseError
|
||||||
|
// - UnknownConfigFileTypeError
|
||||||
|
func ParseConfigFile(configFileRaw string) (*ConfigFile, error) {
|
||||||
|
configFileParsed := &ConfigFile{
|
||||||
|
path: configFileRaw,
|
||||||
|
locationType: ConfigFileLocationLocal,
|
||||||
|
}
|
||||||
|
|
||||||
|
if strings.HasPrefix(configFileRaw, "http://") || strings.HasPrefix(configFileRaw, "https://") {
|
||||||
|
configFileParsed.locationType = ConfigFileLocationRemote
|
||||||
|
}
|
||||||
|
|
||||||
|
configFilePath := configFileRaw
|
||||||
|
if configFileParsed.locationType == ConfigFileLocationRemote {
|
||||||
|
remoteConfigFileParsed, err := url.Parse(configFileRaw)
|
||||||
|
if err != nil {
|
||||||
|
return nil, NewRemoteConfigFileParseError(err)
|
||||||
|
}
|
||||||
|
configFilePath = remoteConfigFileParsed.Path
|
||||||
|
}
|
||||||
|
|
||||||
|
configFileExtension, _ := strings.CutPrefix(filepath.Ext(configFilePath), ".")
|
||||||
|
if configFileExtension == "" {
|
||||||
|
return nil, ErrConfigFileExtensionNotFound
|
||||||
|
}
|
||||||
|
|
||||||
|
switch strings.ToLower(configFileExtension) {
|
||||||
|
case "yml", "yaml":
|
||||||
|
configFileParsed._type = ConfigFileTypeYAML
|
||||||
|
default:
|
||||||
|
return nil, NewUnknownConfigFileTypeError(configFileExtension)
|
||||||
|
}
|
||||||
|
|
||||||
|
return configFileParsed, nil
|
||||||
|
}
|
187
pkg/types/config_file_test.go
Normal file
187
pkg/types/config_file_test.go
Normal file
@@ -0,0 +1,187 @@
|
|||||||
|
package types
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestConfigFile_String(t *testing.T) {
|
||||||
|
t.Run("String returns the file path", func(t *testing.T) {
|
||||||
|
configFile := ConfigFile{path: "/path/to/config.yaml"}
|
||||||
|
assert.Equal(t, "/path/to/config.yaml", configFile.String())
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("String returns empty path", func(t *testing.T) {
|
||||||
|
configFile := ConfigFile{path: ""}
|
||||||
|
assert.Empty(t, configFile.String())
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConfigFile_Type(t *testing.T) {
|
||||||
|
t.Run("Type returns the config file type", func(t *testing.T) {
|
||||||
|
configFile := ConfigFile{_type: ConfigFileTypeYAML}
|
||||||
|
assert.Equal(t, ConfigFileTypeYAML, configFile.Type())
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConfigFile_LocationType(t *testing.T) {
|
||||||
|
t.Run("LocationType returns local", func(t *testing.T) {
|
||||||
|
configFile := ConfigFile{locationType: ConfigFileLocationLocal}
|
||||||
|
assert.Equal(t, ConfigFileLocationLocal, configFile.LocationType())
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("LocationType returns remote", func(t *testing.T) {
|
||||||
|
configFile := ConfigFile{locationType: ConfigFileLocationRemote}
|
||||||
|
assert.Equal(t, ConfigFileLocationRemote, configFile.LocationType())
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseConfigFile(t *testing.T) {
|
||||||
|
t.Run("Parse local YAML file with yml extension", func(t *testing.T) {
|
||||||
|
configFile, err := ParseConfigFile("config.yml")
|
||||||
|
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NotNil(t, configFile)
|
||||||
|
assert.Equal(t, "config.yml", configFile.String())
|
||||||
|
assert.Equal(t, ConfigFileTypeYAML, configFile.Type())
|
||||||
|
assert.Equal(t, ConfigFileLocationLocal, configFile.LocationType())
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("Parse local YAML file with yaml extension", func(t *testing.T) {
|
||||||
|
configFile, err := ParseConfigFile("config.yaml")
|
||||||
|
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NotNil(t, configFile)
|
||||||
|
assert.Equal(t, "config.yaml", configFile.String())
|
||||||
|
assert.Equal(t, ConfigFileTypeYAML, configFile.Type())
|
||||||
|
assert.Equal(t, ConfigFileLocationLocal, configFile.LocationType())
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("Parse local YAML file with uppercase extensions", func(t *testing.T) {
|
||||||
|
testCases := []string{"config.YML", "config.YAML", "config.Yml", "config.Yaml"}
|
||||||
|
|
||||||
|
for _, testCase := range testCases {
|
||||||
|
t.Run("Extension: "+testCase, func(t *testing.T) {
|
||||||
|
configFile, err := ParseConfigFile(testCase)
|
||||||
|
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NotNil(t, configFile)
|
||||||
|
assert.Equal(t, testCase, configFile.String())
|
||||||
|
assert.Equal(t, ConfigFileTypeYAML, configFile.Type())
|
||||||
|
assert.Equal(t, ConfigFileLocationLocal, configFile.LocationType())
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("Parse remote HTTP YAML file", func(t *testing.T) {
|
||||||
|
configFile, err := ParseConfigFile("http://example.com/config.yaml")
|
||||||
|
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NotNil(t, configFile)
|
||||||
|
assert.Equal(t, "http://example.com/config.yaml", configFile.String())
|
||||||
|
assert.Equal(t, ConfigFileTypeYAML, configFile.Type())
|
||||||
|
assert.Equal(t, ConfigFileLocationRemote, configFile.LocationType())
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("Parse remote HTTPS YAML file", func(t *testing.T) {
|
||||||
|
configFile, err := ParseConfigFile("https://example.com/path/config.yml")
|
||||||
|
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NotNil(t, configFile)
|
||||||
|
assert.Equal(t, "https://example.com/path/config.yml", configFile.String())
|
||||||
|
assert.Equal(t, ConfigFileTypeYAML, configFile.Type())
|
||||||
|
assert.Equal(t, ConfigFileLocationRemote, configFile.LocationType())
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("Parse file with path separators", func(t *testing.T) {
|
||||||
|
configFile, err := ParseConfigFile("/path/to/config.yaml")
|
||||||
|
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NotNil(t, configFile)
|
||||||
|
assert.Equal(t, "/path/to/config.yaml", configFile.String())
|
||||||
|
assert.Equal(t, ConfigFileTypeYAML, configFile.Type())
|
||||||
|
assert.Equal(t, ConfigFileLocationLocal, configFile.LocationType())
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("Parse file without extension returns error", func(t *testing.T) {
|
||||||
|
configFile, err := ParseConfigFile("config")
|
||||||
|
|
||||||
|
require.Error(t, err)
|
||||||
|
assert.Equal(t, ErrConfigFileExtensionNotFound, err)
|
||||||
|
assert.Nil(t, configFile)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("Parse file with unsupported extension returns error", func(t *testing.T) {
|
||||||
|
configFile, err := ParseConfigFile("config.json")
|
||||||
|
|
||||||
|
require.Error(t, err)
|
||||||
|
assert.IsType(t, UnknownConfigFileTypeError{}, err)
|
||||||
|
assert.Contains(t, err.Error(), "json")
|
||||||
|
assert.Nil(t, configFile)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("Parse remote file with invalid URL returns error", func(t *testing.T) {
|
||||||
|
configFile, err := ParseConfigFile("http://192.168.1.%30/config.yaml")
|
||||||
|
|
||||||
|
require.Error(t, err)
|
||||||
|
assert.IsType(t, RemoteConfigFileParseError{}, err)
|
||||||
|
assert.Nil(t, configFile)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("Parse remote file without extension returns error", func(t *testing.T) {
|
||||||
|
configFile, err := ParseConfigFile("https://example.com/config")
|
||||||
|
|
||||||
|
require.Error(t, err)
|
||||||
|
assert.Equal(t, ErrConfigFileExtensionNotFound, err)
|
||||||
|
assert.Nil(t, configFile)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("Parse remote file with unsupported extension returns error", func(t *testing.T) {
|
||||||
|
configFile, err := ParseConfigFile("https://example.com/config.txt")
|
||||||
|
|
||||||
|
require.Error(t, err)
|
||||||
|
assert.IsType(t, UnknownConfigFileTypeError{}, err)
|
||||||
|
assert.Contains(t, err.Error(), "txt")
|
||||||
|
assert.Nil(t, configFile)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("Parse empty string returns error", func(t *testing.T) {
|
||||||
|
configFile, err := ParseConfigFile("")
|
||||||
|
|
||||||
|
require.Error(t, err)
|
||||||
|
assert.Equal(t, ErrConfigFileExtensionNotFound, err)
|
||||||
|
assert.Nil(t, configFile)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("Parse file with multiple dots in name", func(t *testing.T) {
|
||||||
|
configFile, err := ParseConfigFile("config.test.yaml")
|
||||||
|
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NotNil(t, configFile)
|
||||||
|
assert.Equal(t, "config.test.yaml", configFile.String())
|
||||||
|
assert.Equal(t, ConfigFileTypeYAML, configFile.Type())
|
||||||
|
assert.Equal(t, ConfigFileLocationLocal, configFile.LocationType())
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("Parse remote URL with query parameters and fragment", func(t *testing.T) {
|
||||||
|
configFile, err := ParseConfigFile("https://example.com/path/config.yaml?version=1&format=yaml#section")
|
||||||
|
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NotNil(t, configFile)
|
||||||
|
assert.Equal(t, "https://example.com/path/config.yaml?version=1&format=yaml#section", configFile.String())
|
||||||
|
assert.Equal(t, ConfigFileTypeYAML, configFile.Type())
|
||||||
|
assert.Equal(t, ConfigFileLocationRemote, configFile.LocationType())
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("Parse remote URL with port", func(t *testing.T) {
|
||||||
|
configFile, err := ParseConfigFile("https://example.com:8080/config.yml")
|
||||||
|
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NotNil(t, configFile)
|
||||||
|
assert.Equal(t, "https://example.com:8080/config.yml", configFile.String())
|
||||||
|
assert.Equal(t, ConfigFileTypeYAML, configFile.Type())
|
||||||
|
assert.Equal(t, ConfigFileLocationRemote, configFile.LocationType())
|
||||||
|
})
|
||||||
|
}
|
42
pkg/types/cookie.go
Normal file
42
pkg/types/cookie.go
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
package types
|
||||||
|
|
||||||
|
import "strings"
|
||||||
|
|
||||||
|
type Cookie KeyValue[string, []string]
|
||||||
|
|
||||||
|
type Cookies []Cookie
|
||||||
|
|
||||||
|
func (cookies Cookies) GetValue(key string) *[]string {
|
||||||
|
for i := range cookies {
|
||||||
|
if cookies[i].Key == key {
|
||||||
|
return &cookies[i].Value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (cookies *Cookies) Append(cookie Cookie) {
|
||||||
|
if item := cookies.GetValue(cookie.Key); item != nil {
|
||||||
|
*item = append(*item, cookie.Value...)
|
||||||
|
} else {
|
||||||
|
*cookies = append(*cookies, cookie)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (cookies *Cookies) Parse(rawValues ...string) {
|
||||||
|
for _, rawValue := range rawValues {
|
||||||
|
cookies.Append(*ParseCookie(rawValue))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func ParseCookie(rawValue string) *Cookie {
|
||||||
|
parts := strings.SplitN(rawValue, "=", 2)
|
||||||
|
switch len(parts) {
|
||||||
|
case 1:
|
||||||
|
return &Cookie{Key: parts[0], Value: []string{""}}
|
||||||
|
case 2:
|
||||||
|
return &Cookie{Key: parts[0], Value: []string{parts[1]}}
|
||||||
|
default:
|
||||||
|
return &Cookie{Key: "", Value: []string{""}}
|
||||||
|
}
|
||||||
|
}
|
240
pkg/types/cookie_test.go
Normal file
240
pkg/types/cookie_test.go
Normal file
@@ -0,0 +1,240 @@
|
|||||||
|
package types
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestCookies_GetValue(t *testing.T) {
|
||||||
|
t.Run("GetValue returns existing cookie value", func(t *testing.T) {
|
||||||
|
cookies := Cookies{
|
||||||
|
{Key: "session", Value: []string{"abc123"}},
|
||||||
|
{Key: "user", Value: []string{"john"}},
|
||||||
|
}
|
||||||
|
|
||||||
|
value := cookies.GetValue("session")
|
||||||
|
require.NotNil(t, value)
|
||||||
|
assert.Equal(t, []string{"abc123"}, *value)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("GetValue returns nil for non-existent cookie", func(t *testing.T) {
|
||||||
|
cookies := Cookies{
|
||||||
|
{Key: "session", Value: []string{"abc123"}},
|
||||||
|
}
|
||||||
|
|
||||||
|
value := cookies.GetValue("nonexistent")
|
||||||
|
assert.Nil(t, value)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("GetValue with empty cookies", func(t *testing.T) {
|
||||||
|
cookies := Cookies{}
|
||||||
|
|
||||||
|
value := cookies.GetValue("any")
|
||||||
|
assert.Nil(t, value)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("GetValue with multiple values", func(t *testing.T) {
|
||||||
|
cookies := Cookies{
|
||||||
|
{Key: "multi", Value: []string{"val1", "val2", "val3"}},
|
||||||
|
}
|
||||||
|
|
||||||
|
value := cookies.GetValue("multi")
|
||||||
|
require.NotNil(t, value)
|
||||||
|
assert.Equal(t, []string{"val1", "val2", "val3"}, *value)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("GetValue case sensitive", func(t *testing.T) {
|
||||||
|
cookies := Cookies{
|
||||||
|
{Key: "Cookie", Value: []string{"value"}},
|
||||||
|
}
|
||||||
|
|
||||||
|
value1 := cookies.GetValue("Cookie")
|
||||||
|
require.NotNil(t, value1)
|
||||||
|
assert.Equal(t, []string{"value"}, *value1)
|
||||||
|
|
||||||
|
value2 := cookies.GetValue("cookie")
|
||||||
|
assert.Nil(t, value2)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCookies_Append(t *testing.T) {
|
||||||
|
t.Run("Append new cookie", func(t *testing.T) {
|
||||||
|
cookies := &Cookies{}
|
||||||
|
cookies.Append(Cookie{Key: "session", Value: []string{"abc123"}})
|
||||||
|
|
||||||
|
assert.Len(t, *cookies, 1)
|
||||||
|
assert.Equal(t, "session", (*cookies)[0].Key)
|
||||||
|
assert.Equal(t, []string{"abc123"}, (*cookies)[0].Value)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("Append to existing cookie key", func(t *testing.T) {
|
||||||
|
cookies := &Cookies{
|
||||||
|
{Key: "session", Value: []string{"abc123"}},
|
||||||
|
}
|
||||||
|
cookies.Append(Cookie{Key: "session", Value: []string{"def456"}})
|
||||||
|
|
||||||
|
assert.Len(t, *cookies, 1)
|
||||||
|
assert.Equal(t, []string{"abc123", "def456"}, (*cookies)[0].Value)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("Append different cookies", func(t *testing.T) {
|
||||||
|
cookies := &Cookies{}
|
||||||
|
cookies.Append(Cookie{Key: "session", Value: []string{"abc"}})
|
||||||
|
cookies.Append(Cookie{Key: "user", Value: []string{"john"}})
|
||||||
|
cookies.Append(Cookie{Key: "token", Value: []string{"xyz"}})
|
||||||
|
|
||||||
|
assert.Len(t, *cookies, 3)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("Append multiple values at once", func(t *testing.T) {
|
||||||
|
cookies := &Cookies{
|
||||||
|
{Key: "tags", Value: []string{"tag1"}},
|
||||||
|
}
|
||||||
|
cookies.Append(Cookie{Key: "tags", Value: []string{"tag2", "tag3"}})
|
||||||
|
|
||||||
|
assert.Len(t, *cookies, 1)
|
||||||
|
assert.Equal(t, []string{"tag1", "tag2", "tag3"}, (*cookies)[0].Value)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("Append empty value", func(t *testing.T) {
|
||||||
|
cookies := &Cookies{}
|
||||||
|
cookies.Append(Cookie{Key: "empty", Value: []string{""}})
|
||||||
|
|
||||||
|
assert.Len(t, *cookies, 1)
|
||||||
|
assert.Equal(t, []string{""}, (*cookies)[0].Value)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCookies_Parse(t *testing.T) {
|
||||||
|
t.Run("Parse single cookie", func(t *testing.T) {
|
||||||
|
cookies := &Cookies{}
|
||||||
|
cookies.Parse("session=abc123")
|
||||||
|
|
||||||
|
assert.Len(t, *cookies, 1)
|
||||||
|
assert.Equal(t, "session", (*cookies)[0].Key)
|
||||||
|
assert.Equal(t, []string{"abc123"}, (*cookies)[0].Value)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("Parse multiple cookies", func(t *testing.T) {
|
||||||
|
cookies := &Cookies{}
|
||||||
|
cookies.Parse("session=abc123", "user=john", "token=xyz789")
|
||||||
|
|
||||||
|
assert.Len(t, *cookies, 3)
|
||||||
|
assert.Equal(t, "session", (*cookies)[0].Key)
|
||||||
|
assert.Equal(t, "user", (*cookies)[1].Key)
|
||||||
|
assert.Equal(t, "token", (*cookies)[2].Key)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("Parse cookies with same key", func(t *testing.T) {
|
||||||
|
cookies := &Cookies{}
|
||||||
|
cookies.Parse("pref=dark", "pref=large", "pref=en")
|
||||||
|
|
||||||
|
assert.Len(t, *cookies, 1)
|
||||||
|
assert.Equal(t, []string{"dark", "large", "en"}, (*cookies)[0].Value)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("Parse cookie without value", func(t *testing.T) {
|
||||||
|
cookies := &Cookies{}
|
||||||
|
cookies.Parse("sessionid")
|
||||||
|
|
||||||
|
assert.Len(t, *cookies, 1)
|
||||||
|
assert.Equal(t, "sessionid", (*cookies)[0].Key)
|
||||||
|
assert.Equal(t, []string{""}, (*cookies)[0].Value)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("Parse cookie with empty value", func(t *testing.T) {
|
||||||
|
cookies := &Cookies{}
|
||||||
|
cookies.Parse("empty=")
|
||||||
|
|
||||||
|
assert.Len(t, *cookies, 1)
|
||||||
|
assert.Equal(t, "empty", (*cookies)[0].Key)
|
||||||
|
assert.Equal(t, []string{""}, (*cookies)[0].Value)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("Parse cookie with multiple equals", func(t *testing.T) {
|
||||||
|
cookies := &Cookies{}
|
||||||
|
cookies.Parse("data=key=value=test")
|
||||||
|
|
||||||
|
assert.Len(t, *cookies, 1)
|
||||||
|
assert.Equal(t, "data", (*cookies)[0].Key)
|
||||||
|
assert.Equal(t, []string{"key=value=test"}, (*cookies)[0].Value)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("Parse no arguments", func(t *testing.T) {
|
||||||
|
cookies := &Cookies{}
|
||||||
|
cookies.Parse()
|
||||||
|
|
||||||
|
assert.Empty(t, *cookies)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("Parse with existing cookies", func(t *testing.T) {
|
||||||
|
cookies := &Cookies{
|
||||||
|
{Key: "existing", Value: []string{"value"}},
|
||||||
|
}
|
||||||
|
cookies.Parse("new=cookie")
|
||||||
|
|
||||||
|
assert.Len(t, *cookies, 2)
|
||||||
|
assert.Equal(t, "existing", (*cookies)[0].Key)
|
||||||
|
assert.Equal(t, "new", (*cookies)[1].Key)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseCookie(t *testing.T) {
|
||||||
|
t.Run("ParseCookie with key and value", func(t *testing.T) {
|
||||||
|
cookie := ParseCookie("session=abc123")
|
||||||
|
require.NotNil(t, cookie)
|
||||||
|
assert.Equal(t, "session", cookie.Key)
|
||||||
|
assert.Equal(t, []string{"abc123"}, cookie.Value)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("ParseCookie with only key", func(t *testing.T) {
|
||||||
|
cookie := ParseCookie("sessionid")
|
||||||
|
require.NotNil(t, cookie)
|
||||||
|
assert.Equal(t, "sessionid", cookie.Key)
|
||||||
|
assert.Equal(t, []string{""}, cookie.Value)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("ParseCookie with empty value", func(t *testing.T) {
|
||||||
|
cookie := ParseCookie("key=")
|
||||||
|
require.NotNil(t, cookie)
|
||||||
|
assert.Equal(t, "key", cookie.Key)
|
||||||
|
assert.Equal(t, []string{""}, cookie.Value)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("ParseCookie with multiple equals", func(t *testing.T) {
|
||||||
|
cookie := ParseCookie("data=base64=encoded=value")
|
||||||
|
require.NotNil(t, cookie)
|
||||||
|
assert.Equal(t, "data", cookie.Key)
|
||||||
|
assert.Equal(t, []string{"base64=encoded=value"}, cookie.Value)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("ParseCookie with empty string", func(t *testing.T) {
|
||||||
|
cookie := ParseCookie("")
|
||||||
|
require.NotNil(t, cookie)
|
||||||
|
assert.Empty(t, cookie.Key)
|
||||||
|
assert.Equal(t, []string{""}, cookie.Value)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("ParseCookie with spaces", func(t *testing.T) {
|
||||||
|
cookie := ParseCookie("key with spaces=value with spaces")
|
||||||
|
require.NotNil(t, cookie)
|
||||||
|
assert.Equal(t, "key with spaces", cookie.Key)
|
||||||
|
assert.Equal(t, []string{"value with spaces"}, cookie.Value)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("ParseCookie with special characters", func(t *testing.T) {
|
||||||
|
cookie := ParseCookie("key-._~=val!@#$%^&*()")
|
||||||
|
require.NotNil(t, cookie)
|
||||||
|
assert.Equal(t, "key-._~", cookie.Key)
|
||||||
|
assert.Equal(t, []string{"val!@#$%^&*()"}, cookie.Value)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("ParseCookie with URL encoded value", func(t *testing.T) {
|
||||||
|
cookie := ParseCookie("data=hello%20world%3D%26")
|
||||||
|
require.NotNil(t, cookie)
|
||||||
|
assert.Equal(t, "data", cookie.Key)
|
||||||
|
assert.Equal(t, []string{"hello%20world%3D%26"}, cookie.Value)
|
||||||
|
})
|
||||||
|
}
|
114
pkg/types/errors.go
Normal file
114
pkg/types/errors.go
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
package types
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
// General
|
||||||
|
ErrNoError = errors.New("no error (internal)")
|
||||||
|
|
||||||
|
// CLI
|
||||||
|
ErrCLINoArgs = errors.New("CLI expects arguments but received none")
|
||||||
|
ErrCLIUnexpectedArgs = errors.New("CLI received unexpected arguments")
|
||||||
|
|
||||||
|
// Config File
|
||||||
|
ErrConfigFileExtensionNotFound = errors.New("config file extension not found")
|
||||||
|
)
|
||||||
|
|
||||||
|
// ======================================== General ========================================
|
||||||
|
|
||||||
|
type FieldParseError struct {
|
||||||
|
Field string
|
||||||
|
Value string
|
||||||
|
Err error
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewFieldParseError(field string, value string, err error) *FieldParseError {
|
||||||
|
if err == nil {
|
||||||
|
err = ErrNoError
|
||||||
|
}
|
||||||
|
return &FieldParseError{field, value, err}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e FieldParseError) Error() string {
|
||||||
|
return fmt.Sprintf("Field '%s' parse failed: %v", e.Field, e.Err)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e FieldParseError) Unwrap() error {
|
||||||
|
return e.Err
|
||||||
|
}
|
||||||
|
|
||||||
|
type FieldParseErrors struct {
|
||||||
|
Errors []FieldParseError
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewFieldParseErrors(fieldParseErrors []FieldParseError) FieldParseErrors {
|
||||||
|
return FieldParseErrors{fieldParseErrors}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e FieldParseErrors) Error() string {
|
||||||
|
if len(e.Errors) == 0 {
|
||||||
|
return "No field parse errors"
|
||||||
|
}
|
||||||
|
if len(e.Errors) == 1 {
|
||||||
|
return e.Errors[0].Error()
|
||||||
|
}
|
||||||
|
|
||||||
|
errorString := ""
|
||||||
|
for _, err := range e.Errors {
|
||||||
|
errorString += err.Error() + "\n"
|
||||||
|
}
|
||||||
|
errorString, _ = strings.CutSuffix(errorString, "\n")
|
||||||
|
|
||||||
|
return errorString
|
||||||
|
}
|
||||||
|
|
||||||
|
// ======================================== CLI ========================================
|
||||||
|
|
||||||
|
type CLIUnexpectedArgsError struct {
|
||||||
|
Args []string
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewCLIUnexpectedArgsError(args []string) CLIUnexpectedArgsError {
|
||||||
|
return CLIUnexpectedArgsError{args}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e CLIUnexpectedArgsError) Error() string {
|
||||||
|
return fmt.Sprintf("CLI received unexpected arguments: %v", strings.Join(e.Args, ","))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ======================================== Config File ========================================
|
||||||
|
|
||||||
|
type RemoteConfigFileParseError struct {
|
||||||
|
error error
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewRemoteConfigFileParseError(err error) RemoteConfigFileParseError {
|
||||||
|
if err == nil {
|
||||||
|
err = ErrNoError
|
||||||
|
}
|
||||||
|
return RemoteConfigFileParseError{err}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e RemoteConfigFileParseError) Error() string {
|
||||||
|
return "Remote config file parse error: " + e.error.Error()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e RemoteConfigFileParseError) Unwrap() error {
|
||||||
|
return e.error
|
||||||
|
}
|
||||||
|
|
||||||
|
type UnknownConfigFileTypeError struct {
|
||||||
|
Type string
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewUnknownConfigFileTypeError(_type string) UnknownConfigFileTypeError {
|
||||||
|
return UnknownConfigFileTypeError{_type}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e UnknownConfigFileTypeError) Error() string {
|
||||||
|
return "Unknown config file type: " + e.Type
|
||||||
|
}
|
286
pkg/types/errors_test.go
Normal file
286
pkg/types/errors_test.go
Normal file
@@ -0,0 +1,286 @@
|
|||||||
|
package types
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestFieldParseError_Error(t *testing.T) {
|
||||||
|
t.Run("Error returns formatted message", func(t *testing.T) {
|
||||||
|
originalErr := errors.New("invalid value")
|
||||||
|
fieldErr := NewFieldParseError("username", "testuser", originalErr)
|
||||||
|
|
||||||
|
expected := "Field 'username' parse failed: invalid value"
|
||||||
|
assert.Equal(t, expected, fieldErr.Error())
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("Error with empty field name", func(t *testing.T) {
|
||||||
|
originalErr := errors.New("test error")
|
||||||
|
fieldErr := NewFieldParseError("", "somevalue", originalErr)
|
||||||
|
|
||||||
|
expected := "Field '' parse failed: test error"
|
||||||
|
assert.Equal(t, expected, fieldErr.Error())
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("Error with nil underlying error", func(t *testing.T) {
|
||||||
|
fieldErr := NewFieldParseError("field", "value123", nil)
|
||||||
|
|
||||||
|
expected := "Field 'field' parse failed: no error (internal)"
|
||||||
|
assert.Equal(t, expected, fieldErr.Error())
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFieldParseError_Unwrap(t *testing.T) {
|
||||||
|
t.Run("Unwrap returns original error", func(t *testing.T) {
|
||||||
|
originalErr := errors.New("original error")
|
||||||
|
fieldErr := NewFieldParseError("field", "value", originalErr)
|
||||||
|
|
||||||
|
assert.Equal(t, originalErr, fieldErr.Unwrap())
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("Unwrap with nil error", func(t *testing.T) {
|
||||||
|
fieldErr := NewFieldParseError("field", "value", nil)
|
||||||
|
|
||||||
|
assert.Equal(t, ErrNoError, fieldErr.Unwrap())
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNewFieldParseError(t *testing.T) {
|
||||||
|
t.Run("Creates FieldParseError with correct values", func(t *testing.T) {
|
||||||
|
originalErr := errors.New("test error")
|
||||||
|
fieldErr := NewFieldParseError("testField", "testValue", originalErr)
|
||||||
|
|
||||||
|
assert.Equal(t, "testField", fieldErr.Field)
|
||||||
|
assert.Equal(t, "testValue", fieldErr.Value)
|
||||||
|
assert.Equal(t, originalErr, fieldErr.Err)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("Creates FieldParseError with ErrNoError when nil passed", func(t *testing.T) {
|
||||||
|
fieldErr := NewFieldParseError("testField", "testValue", nil)
|
||||||
|
|
||||||
|
assert.Equal(t, "testField", fieldErr.Field)
|
||||||
|
assert.Equal(t, "testValue", fieldErr.Value)
|
||||||
|
assert.Equal(t, ErrNoError, fieldErr.Err)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFieldParseErrors_Error(t *testing.T) {
|
||||||
|
t.Run("Error with no errors returns default message", func(t *testing.T) {
|
||||||
|
fieldErrors := NewFieldParseErrors([]FieldParseError{})
|
||||||
|
|
||||||
|
assert.Equal(t, "No field parse errors", fieldErrors.Error())
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("Error with single error returns single error message", func(t *testing.T) {
|
||||||
|
fieldErr := *NewFieldParseError("field1", "value1", errors.New("error1"))
|
||||||
|
fieldErrors := NewFieldParseErrors([]FieldParseError{fieldErr})
|
||||||
|
|
||||||
|
expected := "Field 'field1' parse failed: error1"
|
||||||
|
assert.Equal(t, expected, fieldErrors.Error())
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("Error with multiple errors returns concatenated messages", func(t *testing.T) {
|
||||||
|
fieldErr1 := *NewFieldParseError("field1", "value1", errors.New("error1"))
|
||||||
|
fieldErr2 := *NewFieldParseError("field2", "value2", errors.New("error2"))
|
||||||
|
fieldErr3 := *NewFieldParseError("field3", "value3", errors.New("error3"))
|
||||||
|
fieldErrors := NewFieldParseErrors([]FieldParseError{fieldErr1, fieldErr2, fieldErr3})
|
||||||
|
|
||||||
|
expected := "Field 'field1' parse failed: error1\nField 'field2' parse failed: error2\nField 'field3' parse failed: error3"
|
||||||
|
assert.Equal(t, expected, fieldErrors.Error())
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("Error with two errors", func(t *testing.T) {
|
||||||
|
fieldErr1 := *NewFieldParseError("username", "john", errors.New("too short"))
|
||||||
|
fieldErr2 := *NewFieldParseError("email", "invalid", errors.New("invalid format"))
|
||||||
|
fieldErrors := NewFieldParseErrors([]FieldParseError{fieldErr1, fieldErr2})
|
||||||
|
|
||||||
|
expected := "Field 'username' parse failed: too short\nField 'email' parse failed: invalid format"
|
||||||
|
assert.Equal(t, expected, fieldErrors.Error())
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNewFieldParseErrors(t *testing.T) {
|
||||||
|
t.Run("Creates FieldParseErrors with correct values", func(t *testing.T) {
|
||||||
|
fieldErr1 := *NewFieldParseError("field1", "value1", errors.New("error1"))
|
||||||
|
fieldErr2 := *NewFieldParseError("field2", "value2", errors.New("error2"))
|
||||||
|
fieldErrors := NewFieldParseErrors([]FieldParseError{fieldErr1, fieldErr2})
|
||||||
|
|
||||||
|
assert.Len(t, fieldErrors.Errors, 2)
|
||||||
|
assert.Equal(t, fieldErr1, fieldErrors.Errors[0])
|
||||||
|
assert.Equal(t, fieldErr2, fieldErrors.Errors[1])
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("Creates FieldParseErrors with empty slice", func(t *testing.T) {
|
||||||
|
fieldErrors := NewFieldParseErrors([]FieldParseError{})
|
||||||
|
|
||||||
|
assert.Empty(t, fieldErrors.Errors)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCLIUnexpectedArgsError_Error(t *testing.T) {
|
||||||
|
t.Run("Error with single argument", func(t *testing.T) {
|
||||||
|
err := NewCLIUnexpectedArgsError([]string{"arg1"})
|
||||||
|
|
||||||
|
expected := "CLI received unexpected arguments: arg1"
|
||||||
|
assert.Equal(t, expected, err.Error())
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("Error with multiple arguments", func(t *testing.T) {
|
||||||
|
err := NewCLIUnexpectedArgsError([]string{"arg1", "arg2", "arg3"})
|
||||||
|
|
||||||
|
expected := "CLI received unexpected arguments: arg1,arg2,arg3"
|
||||||
|
assert.Equal(t, expected, err.Error())
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("Error with empty arguments", func(t *testing.T) {
|
||||||
|
err := NewCLIUnexpectedArgsError([]string{})
|
||||||
|
|
||||||
|
expected := "CLI received unexpected arguments: "
|
||||||
|
assert.Equal(t, expected, err.Error())
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("Error with arguments containing special characters", func(t *testing.T) {
|
||||||
|
err := NewCLIUnexpectedArgsError([]string{"--flag", "value with spaces", "-x"})
|
||||||
|
|
||||||
|
expected := "CLI received unexpected arguments: --flag,value with spaces,-x"
|
||||||
|
assert.Equal(t, expected, err.Error())
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNewCLIUnexpectedArgsError(t *testing.T) {
|
||||||
|
t.Run("Creates CLIUnexpectedArgsError with correct values", func(t *testing.T) {
|
||||||
|
args := []string{"arg1", "arg2"}
|
||||||
|
err := NewCLIUnexpectedArgsError(args)
|
||||||
|
|
||||||
|
assert.Equal(t, args, err.Args)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRemoteConfigFileParseError_Error(t *testing.T) {
|
||||||
|
t.Run("Error returns formatted message", func(t *testing.T) {
|
||||||
|
originalErr := errors.New("invalid URL")
|
||||||
|
err := NewRemoteConfigFileParseError(originalErr)
|
||||||
|
|
||||||
|
expected := "Remote config file parse error: invalid URL"
|
||||||
|
assert.Equal(t, expected, err.Error())
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("Error with nil underlying error", func(t *testing.T) {
|
||||||
|
err := NewRemoteConfigFileParseError(nil)
|
||||||
|
|
||||||
|
expected := "Remote config file parse error: no error (internal)"
|
||||||
|
assert.Equal(t, expected, err.Error())
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRemoteConfigFileParseError_Unwrap(t *testing.T) {
|
||||||
|
t.Run("Unwrap returns original error", func(t *testing.T) {
|
||||||
|
originalErr := errors.New("original error")
|
||||||
|
err := NewRemoteConfigFileParseError(originalErr)
|
||||||
|
|
||||||
|
assert.Equal(t, originalErr, err.Unwrap())
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("Unwrap with nil error", func(t *testing.T) {
|
||||||
|
err := NewRemoteConfigFileParseError(nil)
|
||||||
|
|
||||||
|
assert.Equal(t, ErrNoError, err.Unwrap())
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNewRemoteConfigFileParseError(t *testing.T) {
|
||||||
|
t.Run("Creates RemoteConfigFileParseError with correct values", func(t *testing.T) {
|
||||||
|
originalErr := errors.New("test error")
|
||||||
|
err := NewRemoteConfigFileParseError(originalErr)
|
||||||
|
|
||||||
|
assert.Equal(t, originalErr, err.error)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("Creates RemoteConfigFileParseError with ErrNoError when nil passed", func(t *testing.T) {
|
||||||
|
err := NewRemoteConfigFileParseError(nil)
|
||||||
|
|
||||||
|
assert.Equal(t, ErrNoError, err.error)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUnknownConfigFileTypeError_Error(t *testing.T) {
|
||||||
|
t.Run("Error returns formatted message", func(t *testing.T) {
|
||||||
|
err := NewUnknownConfigFileTypeError("json")
|
||||||
|
|
||||||
|
expected := "Unknown config file type: json"
|
||||||
|
assert.Equal(t, expected, err.Error())
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("Error with empty type", func(t *testing.T) {
|
||||||
|
err := NewUnknownConfigFileTypeError("")
|
||||||
|
|
||||||
|
expected := "Unknown config file type: "
|
||||||
|
assert.Equal(t, expected, err.Error())
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("Error with special characters in type", func(t *testing.T) {
|
||||||
|
err := NewUnknownConfigFileTypeError("type.with.dots")
|
||||||
|
|
||||||
|
expected := "Unknown config file type: type.with.dots"
|
||||||
|
assert.Equal(t, expected, err.Error())
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNewUnknownConfigFileTypeError(t *testing.T) {
|
||||||
|
t.Run("Creates UnknownConfigFileTypeError with correct values", func(t *testing.T) {
|
||||||
|
err := NewUnknownConfigFileTypeError("xml")
|
||||||
|
|
||||||
|
assert.Equal(t, "xml", err.Type)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestErrorConstants(t *testing.T) {
|
||||||
|
t.Run("ErrNoError has correct message", func(t *testing.T) {
|
||||||
|
expected := "no error (internal)"
|
||||||
|
assert.Equal(t, expected, ErrNoError.Error())
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("ErrCLINoArgs has correct message", func(t *testing.T) {
|
||||||
|
expected := "CLI expects arguments but received none"
|
||||||
|
assert.Equal(t, expected, ErrCLINoArgs.Error())
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("ErrCLIUnexpectedArgs has correct message", func(t *testing.T) {
|
||||||
|
expected := "CLI received unexpected arguments"
|
||||||
|
assert.Equal(t, expected, ErrCLIUnexpectedArgs.Error())
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("ErrConfigFileExtensionNotFound has correct message", func(t *testing.T) {
|
||||||
|
expected := "config file extension not found"
|
||||||
|
assert.Equal(t, expected, ErrConfigFileExtensionNotFound.Error())
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestErrorImplementsErrorInterface(t *testing.T) {
|
||||||
|
t.Run("FieldParseError implements error interface", func(t *testing.T) {
|
||||||
|
var err error = NewFieldParseError("field", "value", errors.New("test"))
|
||||||
|
assert.Error(t, err)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("FieldParseErrors implements error interface", func(t *testing.T) {
|
||||||
|
var err error = NewFieldParseErrors([]FieldParseError{})
|
||||||
|
assert.Error(t, err)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("CLIUnexpectedArgsError implements error interface", func(t *testing.T) {
|
||||||
|
var err error = NewCLIUnexpectedArgsError([]string{})
|
||||||
|
assert.Error(t, err)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("RemoteConfigFileParseError implements error interface", func(t *testing.T) {
|
||||||
|
var err error = NewRemoteConfigFileParseError(errors.New("test"))
|
||||||
|
assert.Error(t, err)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("UnknownConfigFileTypeError implements error interface", func(t *testing.T) {
|
||||||
|
var err error = NewUnknownConfigFileTypeError("test")
|
||||||
|
assert.Error(t, err)
|
||||||
|
})
|
||||||
|
}
|
52
pkg/types/header.go
Normal file
52
pkg/types/header.go
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
package types
|
||||||
|
|
||||||
|
import "strings"
|
||||||
|
|
||||||
|
type Header KeyValue[string, []string]
|
||||||
|
|
||||||
|
type Headers []Header
|
||||||
|
|
||||||
|
// Has checks if a header with the given key exists.
|
||||||
|
func (headers Headers) Has(key string) bool {
|
||||||
|
for i := range headers {
|
||||||
|
if headers[i].Key == key {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func (headers Headers) GetValue(key string) *[]string {
|
||||||
|
for i := range headers {
|
||||||
|
if headers[i].Key == key {
|
||||||
|
return &headers[i].Value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (headers *Headers) Append(header Header) {
|
||||||
|
if item := headers.GetValue(header.Key); item != nil {
|
||||||
|
*item = append(*item, header.Value...)
|
||||||
|
} else {
|
||||||
|
*headers = append(*headers, header)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (headers *Headers) Parse(rawValues ...string) {
|
||||||
|
for _, rawValue := range rawValues {
|
||||||
|
headers.Append(*ParseHeader(rawValue))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func ParseHeader(rawValue string) *Header {
|
||||||
|
parts := strings.SplitN(rawValue, ": ", 2)
|
||||||
|
switch len(parts) {
|
||||||
|
case 1:
|
||||||
|
return &Header{Key: parts[0], Value: []string{""}}
|
||||||
|
case 2:
|
||||||
|
return &Header{Key: parts[0], Value: []string{parts[1]}}
|
||||||
|
default:
|
||||||
|
return &Header{Key: "", Value: []string{""}}
|
||||||
|
}
|
||||||
|
}
|
277
pkg/types/header_test.go
Normal file
277
pkg/types/header_test.go
Normal file
@@ -0,0 +1,277 @@
|
|||||||
|
package types
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestHeaders_Has(t *testing.T) {
|
||||||
|
t.Run("Has returns true for existing header", func(t *testing.T) {
|
||||||
|
headers := Headers{
|
||||||
|
{Key: "Content-Type", Value: []string{"application/json"}},
|
||||||
|
{Key: "Authorization", Value: []string{"Bearer token"}},
|
||||||
|
}
|
||||||
|
|
||||||
|
assert.True(t, headers.Has("Content-Type"))
|
||||||
|
assert.True(t, headers.Has("Authorization"))
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("Has returns false for non-existent header", func(t *testing.T) {
|
||||||
|
headers := Headers{
|
||||||
|
{Key: "Content-Type", Value: []string{"application/json"}},
|
||||||
|
}
|
||||||
|
|
||||||
|
assert.False(t, headers.Has("Authorization"))
|
||||||
|
assert.False(t, headers.Has("X-Custom-Header"))
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("Has with empty headers", func(t *testing.T) {
|
||||||
|
headers := Headers{}
|
||||||
|
|
||||||
|
assert.False(t, headers.Has("Any-Header"))
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("Has is case sensitive", func(t *testing.T) {
|
||||||
|
headers := Headers{
|
||||||
|
{Key: "Content-Type", Value: []string{"text/html"}},
|
||||||
|
}
|
||||||
|
|
||||||
|
assert.True(t, headers.Has("Content-Type"))
|
||||||
|
assert.False(t, headers.Has("content-type"))
|
||||||
|
assert.False(t, headers.Has("CONTENT-TYPE"))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHeaders_GetValue(t *testing.T) {
|
||||||
|
t.Run("GetValue returns existing header value", func(t *testing.T) {
|
||||||
|
headers := Headers{
|
||||||
|
{Key: "Content-Type", Value: []string{"application/json"}},
|
||||||
|
{Key: "Accept", Value: []string{"text/html"}},
|
||||||
|
}
|
||||||
|
|
||||||
|
value := headers.GetValue("Content-Type")
|
||||||
|
require.NotNil(t, value)
|
||||||
|
assert.Equal(t, []string{"application/json"}, *value)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("GetValue returns nil for non-existent header", func(t *testing.T) {
|
||||||
|
headers := Headers{
|
||||||
|
{Key: "Content-Type", Value: []string{"application/json"}},
|
||||||
|
}
|
||||||
|
|
||||||
|
value := headers.GetValue("Authorization")
|
||||||
|
assert.Nil(t, value)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("GetValue with empty headers", func(t *testing.T) {
|
||||||
|
headers := Headers{}
|
||||||
|
|
||||||
|
value := headers.GetValue("Any-Header")
|
||||||
|
assert.Nil(t, value)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("GetValue with multiple values", func(t *testing.T) {
|
||||||
|
headers := Headers{
|
||||||
|
{Key: "Accept", Value: []string{"text/html", "application/xml", "application/json"}},
|
||||||
|
}
|
||||||
|
|
||||||
|
value := headers.GetValue("Accept")
|
||||||
|
require.NotNil(t, value)
|
||||||
|
assert.Equal(t, []string{"text/html", "application/xml", "application/json"}, *value)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("GetValue is case sensitive", func(t *testing.T) {
|
||||||
|
headers := Headers{
|
||||||
|
{Key: "X-Custom-Header", Value: []string{"value"}},
|
||||||
|
}
|
||||||
|
|
||||||
|
value1 := headers.GetValue("X-Custom-Header")
|
||||||
|
require.NotNil(t, value1)
|
||||||
|
assert.Equal(t, []string{"value"}, *value1)
|
||||||
|
|
||||||
|
value2 := headers.GetValue("x-custom-header")
|
||||||
|
assert.Nil(t, value2)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHeaders_Append(t *testing.T) {
|
||||||
|
t.Run("Append new header", func(t *testing.T) {
|
||||||
|
headers := &Headers{}
|
||||||
|
headers.Append(Header{Key: "Content-Type", Value: []string{"application/json"}})
|
||||||
|
|
||||||
|
assert.Len(t, *headers, 1)
|
||||||
|
assert.Equal(t, "Content-Type", (*headers)[0].Key)
|
||||||
|
assert.Equal(t, []string{"application/json"}, (*headers)[0].Value)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("Append to existing header key", func(t *testing.T) {
|
||||||
|
headers := &Headers{
|
||||||
|
{Key: "Accept", Value: []string{"text/html"}},
|
||||||
|
}
|
||||||
|
headers.Append(Header{Key: "Accept", Value: []string{"application/json"}})
|
||||||
|
|
||||||
|
assert.Len(t, *headers, 1)
|
||||||
|
assert.Equal(t, []string{"text/html", "application/json"}, (*headers)[0].Value)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("Append different headers", func(t *testing.T) {
|
||||||
|
headers := &Headers{}
|
||||||
|
headers.Append(Header{Key: "Content-Type", Value: []string{"application/json"}})
|
||||||
|
headers.Append(Header{Key: "Authorization", Value: []string{"Bearer token"}})
|
||||||
|
headers.Append(Header{Key: "Accept", Value: []string{"*/*"}})
|
||||||
|
|
||||||
|
assert.Len(t, *headers, 3)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("Append multiple values at once", func(t *testing.T) {
|
||||||
|
headers := &Headers{
|
||||||
|
{Key: "Accept-Language", Value: []string{"en"}},
|
||||||
|
}
|
||||||
|
headers.Append(Header{Key: "Accept-Language", Value: []string{"fr", "de"}})
|
||||||
|
|
||||||
|
assert.Len(t, *headers, 1)
|
||||||
|
assert.Equal(t, []string{"en", "fr", "de"}, (*headers)[0].Value)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("Append empty value", func(t *testing.T) {
|
||||||
|
headers := &Headers{}
|
||||||
|
headers.Append(Header{Key: "Empty-Header", Value: []string{""}})
|
||||||
|
|
||||||
|
assert.Len(t, *headers, 1)
|
||||||
|
assert.Equal(t, []string{""}, (*headers)[0].Value)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHeaders_Parse(t *testing.T) {
|
||||||
|
t.Run("Parse single header", func(t *testing.T) {
|
||||||
|
headers := &Headers{}
|
||||||
|
headers.Parse("Content-Type: application/json")
|
||||||
|
|
||||||
|
assert.Len(t, *headers, 1)
|
||||||
|
assert.Equal(t, "Content-Type", (*headers)[0].Key)
|
||||||
|
assert.Equal(t, []string{"application/json"}, (*headers)[0].Value)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("Parse multiple headers", func(t *testing.T) {
|
||||||
|
headers := &Headers{}
|
||||||
|
headers.Parse("Content-Type: application/json", "Authorization: Bearer token", "Accept: */*")
|
||||||
|
|
||||||
|
assert.Len(t, *headers, 3)
|
||||||
|
assert.Equal(t, "Content-Type", (*headers)[0].Key)
|
||||||
|
assert.Equal(t, "Authorization", (*headers)[1].Key)
|
||||||
|
assert.Equal(t, "Accept", (*headers)[2].Key)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("Parse headers with same key", func(t *testing.T) {
|
||||||
|
headers := &Headers{}
|
||||||
|
headers.Parse("Accept: text/html", "Accept: application/json", "Accept: application/xml")
|
||||||
|
|
||||||
|
assert.Len(t, *headers, 1)
|
||||||
|
assert.Equal(t, []string{"text/html", "application/json", "application/xml"}, (*headers)[0].Value)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("Parse header without value", func(t *testing.T) {
|
||||||
|
headers := &Headers{}
|
||||||
|
headers.Parse("X-Empty-Header")
|
||||||
|
|
||||||
|
assert.Len(t, *headers, 1)
|
||||||
|
assert.Equal(t, "X-Empty-Header", (*headers)[0].Key)
|
||||||
|
assert.Equal(t, []string{""}, (*headers)[0].Value)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("Parse header with empty value", func(t *testing.T) {
|
||||||
|
headers := &Headers{}
|
||||||
|
headers.Parse("X-Empty: ")
|
||||||
|
|
||||||
|
assert.Len(t, *headers, 1)
|
||||||
|
assert.Equal(t, "X-Empty", (*headers)[0].Key)
|
||||||
|
assert.Equal(t, []string{""}, (*headers)[0].Value)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("Parse header with multiple colons", func(t *testing.T) {
|
||||||
|
headers := &Headers{}
|
||||||
|
headers.Parse("X-Time: 12:34:56")
|
||||||
|
|
||||||
|
assert.Len(t, *headers, 1)
|
||||||
|
assert.Equal(t, "X-Time", (*headers)[0].Key)
|
||||||
|
assert.Equal(t, []string{"12:34:56"}, (*headers)[0].Value)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("Parse no arguments", func(t *testing.T) {
|
||||||
|
headers := &Headers{}
|
||||||
|
headers.Parse()
|
||||||
|
|
||||||
|
assert.Empty(t, *headers)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("Parse with existing headers", func(t *testing.T) {
|
||||||
|
headers := &Headers{
|
||||||
|
{Key: "Existing", Value: []string{"value"}},
|
||||||
|
}
|
||||||
|
headers.Parse("New: header")
|
||||||
|
|
||||||
|
assert.Len(t, *headers, 2)
|
||||||
|
assert.Equal(t, "Existing", (*headers)[0].Key)
|
||||||
|
assert.Equal(t, "New", (*headers)[1].Key)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseHeader(t *testing.T) {
|
||||||
|
t.Run("ParseHeader with key and value", func(t *testing.T) {
|
||||||
|
header := ParseHeader("Content-Type: application/json")
|
||||||
|
require.NotNil(t, header)
|
||||||
|
assert.Equal(t, "Content-Type", header.Key)
|
||||||
|
assert.Equal(t, []string{"application/json"}, header.Value)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("ParseHeader with only key", func(t *testing.T) {
|
||||||
|
header := ParseHeader("X-Header")
|
||||||
|
require.NotNil(t, header)
|
||||||
|
assert.Equal(t, "X-Header", header.Key)
|
||||||
|
assert.Equal(t, []string{""}, header.Value)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("ParseHeader with empty value", func(t *testing.T) {
|
||||||
|
header := ParseHeader("Key: ")
|
||||||
|
require.NotNil(t, header)
|
||||||
|
assert.Equal(t, "Key", header.Key)
|
||||||
|
assert.Equal(t, []string{""}, header.Value)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("ParseHeader with multiple colons", func(t *testing.T) {
|
||||||
|
header := ParseHeader("X-URL: https://example.com:8080/path")
|
||||||
|
require.NotNil(t, header)
|
||||||
|
assert.Equal(t, "X-URL", header.Key)
|
||||||
|
assert.Equal(t, []string{"https://example.com:8080/path"}, header.Value)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("ParseHeader with empty string", func(t *testing.T) {
|
||||||
|
header := ParseHeader("")
|
||||||
|
require.NotNil(t, header)
|
||||||
|
assert.Empty(t, header.Key)
|
||||||
|
assert.Equal(t, []string{""}, header.Value)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("ParseHeader with spaces in value", func(t *testing.T) {
|
||||||
|
header := ParseHeader("User-Agent: Mozilla/5.0 (Windows NT 10.0)")
|
||||||
|
require.NotNil(t, header)
|
||||||
|
assert.Equal(t, "User-Agent", header.Key)
|
||||||
|
assert.Equal(t, []string{"Mozilla/5.0 (Windows NT 10.0)"}, header.Value)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("ParseHeader without colon-space separator", func(t *testing.T) {
|
||||||
|
header := ParseHeader("Content-Type:application/json")
|
||||||
|
require.NotNil(t, header)
|
||||||
|
assert.Equal(t, "Content-Type:application/json", header.Key)
|
||||||
|
assert.Equal(t, []string{""}, header.Value)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("ParseHeader with trailing spaces", func(t *testing.T) {
|
||||||
|
header := ParseHeader("Header: value with spaces ")
|
||||||
|
require.NotNil(t, header)
|
||||||
|
assert.Equal(t, "Header", header.Key)
|
||||||
|
assert.Equal(t, []string{"value with spaces "}, header.Value)
|
||||||
|
})
|
||||||
|
}
|
42
pkg/types/param.go
Normal file
42
pkg/types/param.go
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
package types
|
||||||
|
|
||||||
|
import "strings"
|
||||||
|
|
||||||
|
type Param KeyValue[string, []string]
|
||||||
|
|
||||||
|
type Params []Param
|
||||||
|
|
||||||
|
func (params Params) GetValue(key string) *[]string {
|
||||||
|
for i := range params {
|
||||||
|
if params[i].Key == key {
|
||||||
|
return ¶ms[i].Value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (params *Params) Append(param Param) {
|
||||||
|
if item := params.GetValue(param.Key); item != nil {
|
||||||
|
*item = append(*item, param.Value...)
|
||||||
|
} else {
|
||||||
|
*params = append(*params, param)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (params *Params) Parse(rawValues ...string) {
|
||||||
|
for _, rawValue := range rawValues {
|
||||||
|
params.Append(*ParseParam(rawValue))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func ParseParam(rawValue string) *Param {
|
||||||
|
parts := strings.SplitN(rawValue, "=", 2)
|
||||||
|
switch len(parts) {
|
||||||
|
case 1:
|
||||||
|
return &Param{Key: parts[0], Value: []string{""}}
|
||||||
|
case 2:
|
||||||
|
return &Param{Key: parts[0], Value: []string{parts[1]}}
|
||||||
|
default:
|
||||||
|
return &Param{Key: "", Value: []string{""}}
|
||||||
|
}
|
||||||
|
}
|
281
pkg/types/param_test.go
Normal file
281
pkg/types/param_test.go
Normal file
@@ -0,0 +1,281 @@
|
|||||||
|
package types
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestParams_GetValue(t *testing.T) {
|
||||||
|
t.Run("GetValue returns existing parameter value", func(t *testing.T) {
|
||||||
|
params := Params{
|
||||||
|
{Key: "name", Value: []string{"john"}},
|
||||||
|
{Key: "age", Value: []string{"25"}},
|
||||||
|
}
|
||||||
|
|
||||||
|
value := params.GetValue("name")
|
||||||
|
require.NotNil(t, value)
|
||||||
|
assert.Equal(t, []string{"john"}, *value)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("GetValue returns nil for non-existent parameter", func(t *testing.T) {
|
||||||
|
params := Params{
|
||||||
|
{Key: "name", Value: []string{"john"}},
|
||||||
|
}
|
||||||
|
|
||||||
|
value := params.GetValue("nonexistent")
|
||||||
|
assert.Nil(t, value)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("GetValue with empty params", func(t *testing.T) {
|
||||||
|
params := Params{}
|
||||||
|
|
||||||
|
value := params.GetValue("any")
|
||||||
|
assert.Nil(t, value)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("GetValue with multiple values", func(t *testing.T) {
|
||||||
|
params := Params{
|
||||||
|
{Key: "tags", Value: []string{"go", "test", "api"}},
|
||||||
|
}
|
||||||
|
|
||||||
|
value := params.GetValue("tags")
|
||||||
|
require.NotNil(t, value)
|
||||||
|
assert.Equal(t, []string{"go", "test", "api"}, *value)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("GetValue case sensitive", func(t *testing.T) {
|
||||||
|
params := Params{
|
||||||
|
{Key: "Name", Value: []string{"value"}},
|
||||||
|
}
|
||||||
|
|
||||||
|
value1 := params.GetValue("Name")
|
||||||
|
require.NotNil(t, value1)
|
||||||
|
assert.Equal(t, []string{"value"}, *value1)
|
||||||
|
|
||||||
|
value2 := params.GetValue("name")
|
||||||
|
assert.Nil(t, value2)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParams_Append(t *testing.T) {
|
||||||
|
t.Run("Append new parameter", func(t *testing.T) {
|
||||||
|
params := &Params{}
|
||||||
|
params.Append(Param{Key: "name", Value: []string{"john"}})
|
||||||
|
|
||||||
|
assert.Len(t, *params, 1)
|
||||||
|
assert.Equal(t, "name", (*params)[0].Key)
|
||||||
|
assert.Equal(t, []string{"john"}, (*params)[0].Value)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("Append to existing parameter key", func(t *testing.T) {
|
||||||
|
params := &Params{
|
||||||
|
{Key: "tags", Value: []string{"go"}},
|
||||||
|
}
|
||||||
|
params.Append(Param{Key: "tags", Value: []string{"test"}})
|
||||||
|
|
||||||
|
assert.Len(t, *params, 1)
|
||||||
|
assert.Equal(t, []string{"go", "test"}, (*params)[0].Value)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("Append different parameters", func(t *testing.T) {
|
||||||
|
params := &Params{}
|
||||||
|
params.Append(Param{Key: "name", Value: []string{"john"}})
|
||||||
|
params.Append(Param{Key: "age", Value: []string{"25"}})
|
||||||
|
params.Append(Param{Key: "city", Value: []string{"NYC"}})
|
||||||
|
|
||||||
|
assert.Len(t, *params, 3)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("Append multiple values at once", func(t *testing.T) {
|
||||||
|
params := &Params{
|
||||||
|
{Key: "colors", Value: []string{"red"}},
|
||||||
|
}
|
||||||
|
params.Append(Param{Key: "colors", Value: []string{"blue", "green"}})
|
||||||
|
|
||||||
|
assert.Len(t, *params, 1)
|
||||||
|
assert.Equal(t, []string{"red", "blue", "green"}, (*params)[0].Value)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("Append empty value", func(t *testing.T) {
|
||||||
|
params := &Params{}
|
||||||
|
params.Append(Param{Key: "empty", Value: []string{""}})
|
||||||
|
|
||||||
|
assert.Len(t, *params, 1)
|
||||||
|
assert.Equal(t, []string{""}, (*params)[0].Value)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParams_Parse(t *testing.T) {
|
||||||
|
t.Run("Parse single parameter", func(t *testing.T) {
|
||||||
|
params := &Params{}
|
||||||
|
params.Parse("name=john")
|
||||||
|
|
||||||
|
assert.Len(t, *params, 1)
|
||||||
|
assert.Equal(t, "name", (*params)[0].Key)
|
||||||
|
assert.Equal(t, []string{"john"}, (*params)[0].Value)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("Parse multiple parameters", func(t *testing.T) {
|
||||||
|
params := &Params{}
|
||||||
|
params.Parse("name=john", "age=25", "city=NYC")
|
||||||
|
|
||||||
|
assert.Len(t, *params, 3)
|
||||||
|
assert.Equal(t, "name", (*params)[0].Key)
|
||||||
|
assert.Equal(t, "age", (*params)[1].Key)
|
||||||
|
assert.Equal(t, "city", (*params)[2].Key)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("Parse parameters with same key", func(t *testing.T) {
|
||||||
|
params := &Params{}
|
||||||
|
params.Parse("filter=name", "filter=age", "filter=city")
|
||||||
|
|
||||||
|
assert.Len(t, *params, 1)
|
||||||
|
assert.Equal(t, []string{"name", "age", "city"}, (*params)[0].Value)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("Parse parameter without value", func(t *testing.T) {
|
||||||
|
params := &Params{}
|
||||||
|
params.Parse("debug")
|
||||||
|
|
||||||
|
assert.Len(t, *params, 1)
|
||||||
|
assert.Equal(t, "debug", (*params)[0].Key)
|
||||||
|
assert.Equal(t, []string{""}, (*params)[0].Value)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("Parse parameter with empty value", func(t *testing.T) {
|
||||||
|
params := &Params{}
|
||||||
|
params.Parse("empty=")
|
||||||
|
|
||||||
|
assert.Len(t, *params, 1)
|
||||||
|
assert.Equal(t, "empty", (*params)[0].Key)
|
||||||
|
assert.Equal(t, []string{""}, (*params)[0].Value)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("Parse parameter with multiple equals", func(t *testing.T) {
|
||||||
|
params := &Params{}
|
||||||
|
params.Parse("equation=x=y+z")
|
||||||
|
|
||||||
|
assert.Len(t, *params, 1)
|
||||||
|
assert.Equal(t, "equation", (*params)[0].Key)
|
||||||
|
assert.Equal(t, []string{"x=y+z"}, (*params)[0].Value)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("Parse no arguments", func(t *testing.T) {
|
||||||
|
params := &Params{}
|
||||||
|
params.Parse()
|
||||||
|
|
||||||
|
assert.Empty(t, *params)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("Parse with existing parameters", func(t *testing.T) {
|
||||||
|
params := &Params{
|
||||||
|
{Key: "existing", Value: []string{"value"}},
|
||||||
|
}
|
||||||
|
params.Parse("new=param")
|
||||||
|
|
||||||
|
assert.Len(t, *params, 2)
|
||||||
|
assert.Equal(t, "existing", (*params)[0].Key)
|
||||||
|
assert.Equal(t, "new", (*params)[1].Key)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("Parse URL-encoded values", func(t *testing.T) {
|
||||||
|
params := &Params{}
|
||||||
|
params.Parse("query=hello%20world", "special=%21%40%23")
|
||||||
|
|
||||||
|
assert.Len(t, *params, 2)
|
||||||
|
assert.Equal(t, []string{"hello%20world"}, (*params)[0].Value)
|
||||||
|
assert.Equal(t, []string{"%21%40%23"}, (*params)[1].Value)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseParam(t *testing.T) {
|
||||||
|
t.Run("ParseParam with key and value", func(t *testing.T) {
|
||||||
|
param := ParseParam("name=john")
|
||||||
|
require.NotNil(t, param)
|
||||||
|
assert.Equal(t, "name", param.Key)
|
||||||
|
assert.Equal(t, []string{"john"}, param.Value)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("ParseParam with only key", func(t *testing.T) {
|
||||||
|
param := ParseParam("debug")
|
||||||
|
require.NotNil(t, param)
|
||||||
|
assert.Equal(t, "debug", param.Key)
|
||||||
|
assert.Equal(t, []string{""}, param.Value)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("ParseParam with empty value", func(t *testing.T) {
|
||||||
|
param := ParseParam("key=")
|
||||||
|
require.NotNil(t, param)
|
||||||
|
assert.Equal(t, "key", param.Key)
|
||||||
|
assert.Equal(t, []string{""}, param.Value)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("ParseParam with multiple equals", func(t *testing.T) {
|
||||||
|
param := ParseParam("data=key=value=test")
|
||||||
|
require.NotNil(t, param)
|
||||||
|
assert.Equal(t, "data", param.Key)
|
||||||
|
assert.Equal(t, []string{"key=value=test"}, param.Value)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("ParseParam with empty string", func(t *testing.T) {
|
||||||
|
param := ParseParam("")
|
||||||
|
require.NotNil(t, param)
|
||||||
|
assert.Empty(t, param.Key)
|
||||||
|
assert.Equal(t, []string{""}, param.Value)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("ParseParam with spaces", func(t *testing.T) {
|
||||||
|
param := ParseParam("key with spaces=value with spaces")
|
||||||
|
require.NotNil(t, param)
|
||||||
|
assert.Equal(t, "key with spaces", param.Key)
|
||||||
|
assert.Equal(t, []string{"value with spaces"}, param.Value)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("ParseParam with special characters", func(t *testing.T) {
|
||||||
|
param := ParseParam("key-._~=val!@#$%^&*()")
|
||||||
|
require.NotNil(t, param)
|
||||||
|
assert.Equal(t, "key-._~", param.Key)
|
||||||
|
assert.Equal(t, []string{"val!@#$%^&*()"}, param.Value)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("ParseParam with numeric values", func(t *testing.T) {
|
||||||
|
param := ParseParam("count=42")
|
||||||
|
require.NotNil(t, param)
|
||||||
|
assert.Equal(t, "count", param.Key)
|
||||||
|
assert.Equal(t, []string{"42"}, param.Value)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("ParseParam with boolean-like values", func(t *testing.T) {
|
||||||
|
testCases := []struct {
|
||||||
|
input string
|
||||||
|
expected string
|
||||||
|
}{
|
||||||
|
{"active=true", "true"},
|
||||||
|
{"enabled=false", "false"},
|
||||||
|
{"visible=1", "1"},
|
||||||
|
{"hidden=0", "0"},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, testCase := range testCases {
|
||||||
|
param := ParseParam(testCase.input)
|
||||||
|
require.NotNil(t, param)
|
||||||
|
assert.Equal(t, []string{testCase.expected}, param.Value)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("ParseParam with URL-encoded value", func(t *testing.T) {
|
||||||
|
param := ParseParam("message=hello%20world")
|
||||||
|
require.NotNil(t, param)
|
||||||
|
assert.Equal(t, "message", param.Key)
|
||||||
|
assert.Equal(t, []string{"hello%20world"}, param.Value)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("ParseParam with JSON-like value", func(t *testing.T) {
|
||||||
|
param := ParseParam(`data={"key":"value"}`)
|
||||||
|
require.NotNil(t, param)
|
||||||
|
assert.Equal(t, "data", param.Key)
|
||||||
|
assert.Equal(t, []string{`{"key":"value"}`}, param.Value)
|
||||||
|
})
|
||||||
|
}
|
38
pkg/types/proxy.go
Normal file
38
pkg/types/proxy.go
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
package types
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"net/url"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Proxy url.URL
|
||||||
|
|
||||||
|
func (proxy Proxy) String() string {
|
||||||
|
return (*url.URL)(&proxy).String()
|
||||||
|
}
|
||||||
|
|
||||||
|
type Proxies []Proxy
|
||||||
|
|
||||||
|
func (proxies *Proxies) Append(proxy Proxy) {
|
||||||
|
*proxies = append(*proxies, proxy)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (proxies *Proxies) Parse(rawValue string) error {
|
||||||
|
parsedProxy, err := ParseProxy(rawValue)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
proxies.Append(*parsedProxy)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func ParseProxy(rawValue string) (*Proxy, error) {
|
||||||
|
urlParsed, err := url.Parse(rawValue)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to parse proxy URL: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
proxyParsed := Proxy(*urlParsed)
|
||||||
|
return &proxyParsed, nil
|
||||||
|
}
|
285
pkg/types/proxy_test.go
Normal file
285
pkg/types/proxy_test.go
Normal file
@@ -0,0 +1,285 @@
|
|||||||
|
package types
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/url"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestProxy_String(t *testing.T) {
|
||||||
|
t.Run("Proxy String returns correct URL", func(t *testing.T) {
|
||||||
|
u, err := url.Parse("http://proxy.example.com:8080")
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
proxy := Proxy(*u)
|
||||||
|
assert.Equal(t, "http://proxy.example.com:8080", proxy.String())
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("Proxy String with HTTPS", func(t *testing.T) {
|
||||||
|
u, err := url.Parse("https://secure-proxy.example.com:443")
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
proxy := Proxy(*u)
|
||||||
|
assert.Equal(t, "https://secure-proxy.example.com:443", proxy.String())
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("Proxy String with authentication", func(t *testing.T) {
|
||||||
|
u, err := url.Parse("http://user:pass@proxy.example.com:8080")
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
proxy := Proxy(*u)
|
||||||
|
assert.Equal(t, "http://user:pass@proxy.example.com:8080", proxy.String())
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("Proxy String with path", func(t *testing.T) {
|
||||||
|
u, err := url.Parse("http://proxy.example.com:8080/proxy/path")
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
proxy := Proxy(*u)
|
||||||
|
assert.Equal(t, "http://proxy.example.com:8080/proxy/path", proxy.String())
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("Proxy String with query params", func(t *testing.T) {
|
||||||
|
u, err := url.Parse("http://proxy.example.com:8080/?timeout=30&retry=3")
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
proxy := Proxy(*u)
|
||||||
|
assert.Equal(t, "http://proxy.example.com:8080/?timeout=30&retry=3", proxy.String())
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestProxies_Append(t *testing.T) {
|
||||||
|
t.Run("Append single proxy", func(t *testing.T) {
|
||||||
|
proxies := &Proxies{}
|
||||||
|
u, err := url.Parse("http://proxy1.example.com:8080")
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
proxy := Proxy(*u)
|
||||||
|
proxies.Append(proxy)
|
||||||
|
|
||||||
|
assert.Len(t, *proxies, 1)
|
||||||
|
assert.Equal(t, "http://proxy1.example.com:8080", (*proxies)[0].String())
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("Append multiple proxies", func(t *testing.T) {
|
||||||
|
proxies := &Proxies{}
|
||||||
|
|
||||||
|
url1, err := url.Parse("http://proxy1.example.com:8080")
|
||||||
|
require.NoError(t, err)
|
||||||
|
url2, err := url.Parse("http://proxy2.example.com:8081")
|
||||||
|
require.NoError(t, err)
|
||||||
|
url3, err := url.Parse("https://proxy3.example.com:443")
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
proxies.Append(Proxy(*url1))
|
||||||
|
proxies.Append(Proxy(*url2))
|
||||||
|
proxies.Append(Proxy(*url3))
|
||||||
|
|
||||||
|
assert.Len(t, *proxies, 3)
|
||||||
|
assert.Equal(t, "http://proxy1.example.com:8080", (*proxies)[0].String())
|
||||||
|
assert.Equal(t, "http://proxy2.example.com:8081", (*proxies)[1].String())
|
||||||
|
assert.Equal(t, "https://proxy3.example.com:443", (*proxies)[2].String())
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("Append to existing proxies", func(t *testing.T) {
|
||||||
|
existingURL, err := url.Parse("http://existing.example.com:8080")
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
proxies := &Proxies{Proxy(*existingURL)}
|
||||||
|
|
||||||
|
newURL, err := url.Parse("http://new.example.com:8081")
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
proxies.Append(Proxy(*newURL))
|
||||||
|
|
||||||
|
assert.Len(t, *proxies, 2)
|
||||||
|
assert.Equal(t, "http://existing.example.com:8080", (*proxies)[0].String())
|
||||||
|
assert.Equal(t, "http://new.example.com:8081", (*proxies)[1].String())
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestProxies_Parse(t *testing.T) {
|
||||||
|
t.Run("Parse valid proxy URL", func(t *testing.T) {
|
||||||
|
proxies := &Proxies{}
|
||||||
|
err := proxies.Parse("http://proxy.example.com:8080")
|
||||||
|
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Len(t, *proxies, 1)
|
||||||
|
assert.Equal(t, "http://proxy.example.com:8080", (*proxies)[0].String())
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("Parse HTTPS proxy URL", func(t *testing.T) {
|
||||||
|
proxies := &Proxies{}
|
||||||
|
err := proxies.Parse("https://secure-proxy.example.com:443")
|
||||||
|
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Len(t, *proxies, 1)
|
||||||
|
assert.Equal(t, "https://secure-proxy.example.com:443", (*proxies)[0].String())
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("Parse proxy URL with authentication", func(t *testing.T) {
|
||||||
|
proxies := &Proxies{}
|
||||||
|
err := proxies.Parse("http://user:pass@proxy.example.com:8080")
|
||||||
|
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Len(t, *proxies, 1)
|
||||||
|
assert.Equal(t, "http://user:pass@proxy.example.com:8080", (*proxies)[0].String())
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("Parse invalid proxy URL", func(t *testing.T) {
|
||||||
|
proxies := &Proxies{}
|
||||||
|
err := proxies.Parse("://invalid-url")
|
||||||
|
|
||||||
|
require.Error(t, err)
|
||||||
|
assert.Contains(t, err.Error(), "failed to parse proxy URL")
|
||||||
|
assert.Empty(t, *proxies)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("Parse empty string", func(t *testing.T) {
|
||||||
|
proxies := &Proxies{}
|
||||||
|
err := proxies.Parse("")
|
||||||
|
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Len(t, *proxies, 1)
|
||||||
|
assert.Empty(t, (*proxies)[0].String())
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("Parse to existing proxies", func(t *testing.T) {
|
||||||
|
existingURL, err := url.Parse("http://existing.example.com:8080")
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
proxies := &Proxies{Proxy(*existingURL)}
|
||||||
|
err = proxies.Parse("http://new.example.com:8081")
|
||||||
|
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Len(t, *proxies, 2)
|
||||||
|
assert.Equal(t, "http://existing.example.com:8080", (*proxies)[0].String())
|
||||||
|
assert.Equal(t, "http://new.example.com:8081", (*proxies)[1].String())
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("Parse proxy with special characters", func(t *testing.T) {
|
||||||
|
proxies := &Proxies{}
|
||||||
|
err := proxies.Parse("http://proxy.example.com:8080/path?param=value&other=test")
|
||||||
|
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Len(t, *proxies, 1)
|
||||||
|
assert.Equal(t, "http://proxy.example.com:8080/path?param=value&other=test", (*proxies)[0].String())
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseProxy(t *testing.T) {
|
||||||
|
t.Run("ParseProxy with valid HTTP URL", func(t *testing.T) {
|
||||||
|
proxy, err := ParseProxy("http://proxy.example.com:8080")
|
||||||
|
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NotNil(t, proxy)
|
||||||
|
assert.Equal(t, "http://proxy.example.com:8080", proxy.String())
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("ParseProxy with valid HTTPS URL", func(t *testing.T) {
|
||||||
|
proxy, err := ParseProxy("https://secure-proxy.example.com:443")
|
||||||
|
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NotNil(t, proxy)
|
||||||
|
assert.Equal(t, "https://secure-proxy.example.com:443", proxy.String())
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("ParseProxy with authentication", func(t *testing.T) {
|
||||||
|
proxy, err := ParseProxy("http://user:password@proxy.example.com:8080")
|
||||||
|
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NotNil(t, proxy)
|
||||||
|
assert.Equal(t, "http://user:password@proxy.example.com:8080", proxy.String())
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("ParseProxy with path", func(t *testing.T) {
|
||||||
|
proxy, err := ParseProxy("http://proxy.example.com:8080/proxy/endpoint")
|
||||||
|
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NotNil(t, proxy)
|
||||||
|
assert.Equal(t, "http://proxy.example.com:8080/proxy/endpoint", proxy.String())
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("ParseProxy with query parameters", func(t *testing.T) {
|
||||||
|
proxy, err := ParseProxy("http://proxy.example.com:8080/?timeout=30&retry=3")
|
||||||
|
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NotNil(t, proxy)
|
||||||
|
assert.Equal(t, "http://proxy.example.com:8080/?timeout=30&retry=3", proxy.String())
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("ParseProxy with malformed URL", func(t *testing.T) {
|
||||||
|
proxy, err := ParseProxy("://malformed-url")
|
||||||
|
|
||||||
|
require.Error(t, err)
|
||||||
|
assert.Nil(t, proxy)
|
||||||
|
assert.Contains(t, err.Error(), "failed to parse proxy URL")
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("ParseProxy with empty string", func(t *testing.T) {
|
||||||
|
proxy, err := ParseProxy("")
|
||||||
|
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NotNil(t, proxy)
|
||||||
|
assert.Empty(t, proxy.String())
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("ParseProxy with localhost", func(t *testing.T) {
|
||||||
|
proxy, err := ParseProxy("http://localhost:3128")
|
||||||
|
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NotNil(t, proxy)
|
||||||
|
assert.Equal(t, "http://localhost:3128", proxy.String())
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("ParseProxy with IP address", func(t *testing.T) {
|
||||||
|
proxy, err := ParseProxy("http://192.168.1.100:8080")
|
||||||
|
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NotNil(t, proxy)
|
||||||
|
assert.Equal(t, "http://192.168.1.100:8080", proxy.String())
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("ParseProxy without scheme", func(t *testing.T) {
|
||||||
|
proxy, err := ParseProxy("proxy.example.com:8080")
|
||||||
|
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NotNil(t, proxy)
|
||||||
|
assert.Equal(t, "proxy.example.com:8080", proxy.String())
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("ParseProxy with SOCKS protocol", func(t *testing.T) {
|
||||||
|
proxy, err := ParseProxy("socks5://proxy.example.com:1080")
|
||||||
|
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NotNil(t, proxy)
|
||||||
|
assert.Equal(t, "socks5://proxy.example.com:1080", proxy.String())
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("ParseProxy preserves URL components", func(t *testing.T) {
|
||||||
|
rawURL := "http://user:pass@proxy.example.com:8080/path?param=value#fragment"
|
||||||
|
proxy, err := ParseProxy(rawURL)
|
||||||
|
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NotNil(t, proxy)
|
||||||
|
assert.Equal(t, rawURL, proxy.String())
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("ParseProxy with percent encoding", func(t *testing.T) {
|
||||||
|
proxy, err := ParseProxy("http://proxy.example.com:8080/path%20with%20spaces")
|
||||||
|
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NotNil(t, proxy)
|
||||||
|
assert.Equal(t, "http://proxy.example.com:8080/path%20with%20spaces", proxy.String())
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("ParseProxy error message format", func(t *testing.T) {
|
||||||
|
_, err := ParseProxy("http://[invalid-ipv6")
|
||||||
|
|
||||||
|
require.Error(t, err)
|
||||||
|
assert.Contains(t, err.Error(), "failed to parse proxy URL:")
|
||||||
|
assert.Contains(t, err.Error(), "missing ']' in host")
|
||||||
|
})
|
||||||
|
}
|
155
pkg/utils/convert_test.go
Normal file
155
pkg/utils/convert_test.go
Normal file
@@ -0,0 +1,155 @@
|
|||||||
|
package utils
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestToPtr(t *testing.T) {
|
||||||
|
t.Run("ToPtr with int", func(t *testing.T) {
|
||||||
|
value := 42
|
||||||
|
ptr := ToPtr(value)
|
||||||
|
|
||||||
|
require.NotNil(t, ptr)
|
||||||
|
assert.Equal(t, value, *ptr)
|
||||||
|
assert.NotSame(t, &value, ptr, "Should return a new pointer")
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("ToPtr with string", func(t *testing.T) {
|
||||||
|
value := "test string"
|
||||||
|
ptr := ToPtr(value)
|
||||||
|
|
||||||
|
require.NotNil(t, ptr)
|
||||||
|
assert.Equal(t, value, *ptr)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("ToPtr with bool", func(t *testing.T) {
|
||||||
|
value := true
|
||||||
|
ptr := ToPtr(value)
|
||||||
|
|
||||||
|
require.NotNil(t, ptr)
|
||||||
|
assert.Equal(t, value, *ptr)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("ToPtr with float64", func(t *testing.T) {
|
||||||
|
value := 3.14159
|
||||||
|
ptr := ToPtr(value)
|
||||||
|
|
||||||
|
require.NotNil(t, ptr)
|
||||||
|
assert.InEpsilon(t, value, *ptr, 0.0001)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("ToPtr with struct", func(t *testing.T) {
|
||||||
|
type TestStruct struct {
|
||||||
|
Field1 string
|
||||||
|
Field2 int
|
||||||
|
}
|
||||||
|
|
||||||
|
value := TestStruct{Field1: "test", Field2: 123}
|
||||||
|
ptr := ToPtr(value)
|
||||||
|
|
||||||
|
require.NotNil(t, ptr)
|
||||||
|
assert.Equal(t, value, *ptr)
|
||||||
|
assert.Equal(t, "test", ptr.Field1)
|
||||||
|
assert.Equal(t, 123, ptr.Field2)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("ToPtr with slice", func(t *testing.T) {
|
||||||
|
value := []int{1, 2, 3}
|
||||||
|
ptr := ToPtr(value)
|
||||||
|
|
||||||
|
require.NotNil(t, ptr)
|
||||||
|
assert.Equal(t, value, *ptr)
|
||||||
|
assert.Len(t, *ptr, 3)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("ToPtr with map", func(t *testing.T) {
|
||||||
|
value := map[string]int{"one": 1, "two": 2}
|
||||||
|
ptr := ToPtr(value)
|
||||||
|
|
||||||
|
require.NotNil(t, ptr)
|
||||||
|
assert.Equal(t, value, *ptr)
|
||||||
|
assert.Len(t, *ptr, 2)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("ToPtr with nil interface", func(t *testing.T) {
|
||||||
|
var value any = nil
|
||||||
|
ptr := ToPtr(value)
|
||||||
|
|
||||||
|
require.NotNil(t, ptr)
|
||||||
|
assert.Nil(t, *ptr)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("ToPtr with pointer", func(t *testing.T) {
|
||||||
|
originalValue := 42
|
||||||
|
originalPtr := &originalValue
|
||||||
|
ptr := ToPtr(originalPtr)
|
||||||
|
|
||||||
|
require.NotNil(t, ptr)
|
||||||
|
assert.Equal(t, originalPtr, *ptr)
|
||||||
|
assert.NotSame(t, originalPtr, ptr, "Should return a pointer to pointer")
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("ToPtr with uint", func(t *testing.T) {
|
||||||
|
value := uint(100)
|
||||||
|
ptr := ToPtr(value)
|
||||||
|
|
||||||
|
require.NotNil(t, ptr)
|
||||||
|
assert.Equal(t, value, *ptr)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("ToPtr modification safety", func(t *testing.T) {
|
||||||
|
value := 10
|
||||||
|
ptr := ToPtr(value)
|
||||||
|
|
||||||
|
*ptr = 20
|
||||||
|
assert.Equal(t, 10, value, "Original value should not be modified")
|
||||||
|
assert.Equal(t, 20, *ptr, "Pointer value should be modified")
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("ToPtr with byte array", func(t *testing.T) {
|
||||||
|
value := [3]byte{1, 2, 3}
|
||||||
|
ptr := ToPtr(value)
|
||||||
|
|
||||||
|
require.NotNil(t, ptr)
|
||||||
|
assert.Equal(t, value, *ptr)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("ToPtr with rune", func(t *testing.T) {
|
||||||
|
value := 'A'
|
||||||
|
ptr := ToPtr(value)
|
||||||
|
|
||||||
|
require.NotNil(t, ptr)
|
||||||
|
assert.Equal(t, value, *ptr)
|
||||||
|
assert.Equal(t, int32(65), *ptr)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("ToPtr with empty string", func(t *testing.T) {
|
||||||
|
value := ""
|
||||||
|
ptr := ToPtr(value)
|
||||||
|
|
||||||
|
require.NotNil(t, ptr)
|
||||||
|
assert.Equal(t, value, *ptr)
|
||||||
|
assert.Empty(t, *ptr)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("ToPtr with zero values", func(t *testing.T) {
|
||||||
|
// Test with various zero values
|
||||||
|
intZero := 0
|
||||||
|
intPtr := ToPtr(intZero)
|
||||||
|
require.NotNil(t, intPtr)
|
||||||
|
assert.Equal(t, 0, *intPtr)
|
||||||
|
|
||||||
|
boolZero := false
|
||||||
|
boolPtr := ToPtr(boolZero)
|
||||||
|
require.NotNil(t, boolPtr)
|
||||||
|
assert.False(t, *boolPtr)
|
||||||
|
|
||||||
|
floatZero := 0.0
|
||||||
|
floatPtr := ToPtr(floatZero)
|
||||||
|
require.NotNil(t, floatPtr)
|
||||||
|
assert.Equal(t, 0.0, *floatPtr) //nolint:testifylint
|
||||||
|
})
|
||||||
|
}
|
105
pkg/utils/error.go
Normal file
105
pkg/utils/error.go
Normal file
@@ -0,0 +1,105 @@
|
|||||||
|
package utils
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"reflect"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ErrorHandler represents a function that handles a specific error type
|
||||||
|
type ErrorHandler func(error) error
|
||||||
|
|
||||||
|
// ErrorMatcher holds the error type/value and its handler
|
||||||
|
type ErrorMatcher struct {
|
||||||
|
ErrorType any // Can be error value (sentinel) or error type
|
||||||
|
Handler ErrorHandler
|
||||||
|
IsSentinel bool // true for sentinel errors, false for custom types
|
||||||
|
}
|
||||||
|
|
||||||
|
// HandleError processes an error against a list of matchers and executes the appropriate handler.
|
||||||
|
// It returns (true, handlerResult) if a matching handler is found and executed,
|
||||||
|
// or (false, nil) if no matcher matches the error.
|
||||||
|
// If err is nil, returns (true, nil).
|
||||||
|
//
|
||||||
|
// Example:
|
||||||
|
//
|
||||||
|
// handled, result := HandleError(err,
|
||||||
|
// OnSentinelError(io.EOF, func(e error) error {
|
||||||
|
// return nil // EOF is expected, ignore it
|
||||||
|
// }),
|
||||||
|
// OnCustomError(func(e *CustomError) error {
|
||||||
|
// return fmt.Errorf("custom error: %w", e)
|
||||||
|
// }),
|
||||||
|
// )
|
||||||
|
func HandleError(err error, matchers ...ErrorMatcher) (bool, error) {
|
||||||
|
if err == nil {
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, matcher := range matchers {
|
||||||
|
if matcher.IsSentinel {
|
||||||
|
// Handle sentinel errors with errors.Is
|
||||||
|
if sentinelErr, ok := matcher.ErrorType.(error); ok {
|
||||||
|
if errors.Is(err, sentinelErr) {
|
||||||
|
return true, matcher.Handler(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Handle custom error types with errors.As
|
||||||
|
errorType := reflect.TypeOf(matcher.ErrorType)
|
||||||
|
errorValue := reflect.New(errorType).Interface()
|
||||||
|
|
||||||
|
if errors.As(err, errorValue) {
|
||||||
|
return true, matcher.Handler(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false, err // No matcher found
|
||||||
|
}
|
||||||
|
|
||||||
|
// HandleErrorOrDie processes an error against a list of matchers and executes the appropriate handler.
|
||||||
|
// If a matching handler is found, it returns the handler's result.
|
||||||
|
// If no matcher matches the error, it panics with a descriptive message.
|
||||||
|
// This function is useful when all expected error types must be handled explicitly.
|
||||||
|
//
|
||||||
|
// Example:
|
||||||
|
//
|
||||||
|
// result := HandleErrorOrDie(err,
|
||||||
|
// OnSentinelError(context.Canceled, func(e error) error {
|
||||||
|
// return fmt.Errorf("operation canceled")
|
||||||
|
// }),
|
||||||
|
// OnCustomError(func(e *ValidationError) error {
|
||||||
|
// return fmt.Errorf("validation failed: %w", e)
|
||||||
|
// }),
|
||||||
|
// ) // Panics if err doesn't match any handler
|
||||||
|
func HandleErrorOrDie(err error, matchers ...ErrorMatcher) error {
|
||||||
|
ok, err := HandleError(err, matchers...)
|
||||||
|
if !ok {
|
||||||
|
panic(fmt.Sprintf("Unhandled error of type %T: %v", err, err))
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func OnSentinelError(sentinelErr error, handler ErrorHandler) ErrorMatcher {
|
||||||
|
return ErrorMatcher{
|
||||||
|
ErrorType: sentinelErr,
|
||||||
|
Handler: handler,
|
||||||
|
IsSentinel: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func OnCustomError[T error](handler func(T) error) ErrorMatcher {
|
||||||
|
var zero T
|
||||||
|
return ErrorMatcher{
|
||||||
|
ErrorType: zero,
|
||||||
|
Handler: func(err error) error {
|
||||||
|
var typedErr T
|
||||||
|
if errors.As(err, &typedErr) {
|
||||||
|
return handler(typedErr)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
IsSentinel: false,
|
||||||
|
}
|
||||||
|
}
|
384
pkg/utils/error_test.go
Normal file
384
pkg/utils/error_test.go
Normal file
@@ -0,0 +1,384 @@
|
|||||||
|
package utils
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Custom error types for testing
|
||||||
|
type CustomError struct {
|
||||||
|
Code int
|
||||||
|
Message string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e CustomError) Error() string {
|
||||||
|
return fmt.Sprintf("custom error %d: %s", e.Code, e.Message)
|
||||||
|
}
|
||||||
|
|
||||||
|
type ValidationError struct {
|
||||||
|
Field string
|
||||||
|
Value string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *ValidationError) Error() string {
|
||||||
|
return fmt.Sprintf("validation failed for field %s with value %s", e.Field, e.Value)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sentinel errors for testing
|
||||||
|
var (
|
||||||
|
ErrSentinel1 = errors.New("sentinel error 1")
|
||||||
|
ErrSentinel2 = errors.New("sentinel error 2")
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestHandleError(t *testing.T) {
|
||||||
|
t.Run("HandleError with nil error", func(t *testing.T) {
|
||||||
|
handled, result := HandleError(nil)
|
||||||
|
assert.True(t, handled)
|
||||||
|
assert.NoError(t, result)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("HandleError with sentinel error match", func(t *testing.T) {
|
||||||
|
err := io.EOF
|
||||||
|
handled, result := HandleError(err,
|
||||||
|
OnSentinelError(io.EOF, func(e error) error {
|
||||||
|
return errors.New("handled EOF")
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
assert.True(t, handled)
|
||||||
|
assert.EqualError(t, result, "handled EOF")
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("HandleError with wrapped sentinel error", func(t *testing.T) {
|
||||||
|
wrappedErr := fmt.Errorf("wrapped: %w", io.EOF)
|
||||||
|
handled, result := HandleError(wrappedErr,
|
||||||
|
OnSentinelError(io.EOF, func(e error) error {
|
||||||
|
return errors.New("handled wrapped EOF")
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
assert.True(t, handled)
|
||||||
|
assert.EqualError(t, result, "handled wrapped EOF")
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("HandleError with custom error type match", func(t *testing.T) {
|
||||||
|
err := &CustomError{Code: 404, Message: "not found"}
|
||||||
|
handled, result := HandleError(err,
|
||||||
|
OnCustomError(func(e *CustomError) error {
|
||||||
|
return fmt.Errorf("handled custom error with code %d", e.Code)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
assert.True(t, handled)
|
||||||
|
assert.EqualError(t, result, "handled custom error with code 404")
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("HandleError with wrapped custom error", func(t *testing.T) {
|
||||||
|
customErr := &CustomError{Code: 500, Message: "internal error"}
|
||||||
|
wrappedErr := fmt.Errorf("wrapped: %w", customErr)
|
||||||
|
|
||||||
|
handled, result := HandleError(wrappedErr,
|
||||||
|
OnCustomError(func(e *CustomError) error {
|
||||||
|
return fmt.Errorf("handled wrapped custom error: %s", e.Message)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
assert.True(t, handled)
|
||||||
|
assert.EqualError(t, result, "handled wrapped custom error: internal error")
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("HandleError with no matching handler", func(t *testing.T) {
|
||||||
|
err := errors.New("unhandled error")
|
||||||
|
handled, _ := HandleError(err,
|
||||||
|
OnSentinelError(io.EOF, func(e error) error {
|
||||||
|
return nil
|
||||||
|
}),
|
||||||
|
OnCustomError(func(e CustomError) error {
|
||||||
|
return nil
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
assert.False(t, handled)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("HandleError with multiple matchers first match wins", func(t *testing.T) {
|
||||||
|
err := io.EOF
|
||||||
|
handled, result := HandleError(err,
|
||||||
|
OnSentinelError(io.EOF, func(e error) error {
|
||||||
|
return errors.New("first handler")
|
||||||
|
}),
|
||||||
|
OnSentinelError(io.EOF, func(e error) error {
|
||||||
|
return errors.New("second handler")
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
assert.True(t, handled)
|
||||||
|
assert.EqualError(t, result, "first handler")
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("HandleError with handler returning nil", func(t *testing.T) {
|
||||||
|
err := io.EOF
|
||||||
|
handled, result := HandleError(err,
|
||||||
|
OnSentinelError(io.EOF, func(e error) error {
|
||||||
|
return nil
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
assert.True(t, handled)
|
||||||
|
assert.NoError(t, result)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("HandleError with multiple error types", func(t *testing.T) {
|
||||||
|
customErr := &CustomError{Code: 400, Message: "bad request"}
|
||||||
|
validationErr := &ValidationError{Field: "email", Value: "invalid"}
|
||||||
|
|
||||||
|
// Test CustomError handling
|
||||||
|
handled1, result1 := HandleError(customErr,
|
||||||
|
OnCustomError(func(e *CustomError) error {
|
||||||
|
return fmt.Errorf("custom: %d", e.Code)
|
||||||
|
}),
|
||||||
|
OnCustomError(func(e *ValidationError) error {
|
||||||
|
return fmt.Errorf("validation: %s", e.Field)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
assert.True(t, handled1)
|
||||||
|
require.EqualError(t, result1, "custom: 400")
|
||||||
|
|
||||||
|
// Test ValidationError handling
|
||||||
|
handled2, result2 := HandleError(validationErr,
|
||||||
|
OnCustomError(func(e *CustomError) error {
|
||||||
|
return fmt.Errorf("custom: %d", e.Code)
|
||||||
|
}),
|
||||||
|
OnCustomError(func(e *ValidationError) error {
|
||||||
|
return fmt.Errorf("validation: %s", e.Field)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
assert.True(t, handled2)
|
||||||
|
assert.EqualError(t, result2, "validation: email")
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("HandleError with context errors", func(t *testing.T) {
|
||||||
|
// Test context.Canceled
|
||||||
|
handled1, result1 := HandleError(context.Canceled,
|
||||||
|
OnSentinelError(context.Canceled, func(e error) error {
|
||||||
|
return errors.New("operation canceled")
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
assert.True(t, handled1)
|
||||||
|
require.EqualError(t, result1, "operation canceled")
|
||||||
|
|
||||||
|
// Test context.DeadlineExceeded
|
||||||
|
handled2, result2 := HandleError(context.DeadlineExceeded,
|
||||||
|
OnSentinelError(context.DeadlineExceeded, func(e error) error {
|
||||||
|
return errors.New("deadline exceeded")
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
assert.True(t, handled2)
|
||||||
|
assert.EqualError(t, result2, "deadline exceeded")
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("HandleError preserves original error in handler", func(t *testing.T) {
|
||||||
|
originalErr := &CustomError{Code: 403, Message: "forbidden"}
|
||||||
|
var capturedErr error
|
||||||
|
|
||||||
|
handled, _ := HandleError(originalErr,
|
||||||
|
OnCustomError(func(e *CustomError) error {
|
||||||
|
capturedErr = e
|
||||||
|
return nil
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert.True(t, handled)
|
||||||
|
assert.Equal(t, originalErr, capturedErr)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandleErrorOrDie(t *testing.T) {
|
||||||
|
t.Run("HandleErrorOrDie with nil error", func(t *testing.T) {
|
||||||
|
result := HandleErrorOrDie(nil)
|
||||||
|
assert.NoError(t, result)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("HandleErrorOrDie with matched error", func(t *testing.T) {
|
||||||
|
err := io.EOF
|
||||||
|
result := HandleErrorOrDie(err,
|
||||||
|
OnSentinelError(io.EOF, func(e error) error {
|
||||||
|
return errors.New("handled EOF in die")
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
assert.EqualError(t, result, "handled EOF in die")
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("HandleErrorOrDie panics on unmatched error", func(t *testing.T) {
|
||||||
|
err := errors.New("unmatched error")
|
||||||
|
|
||||||
|
assert.Panics(t, func() {
|
||||||
|
HandleErrorOrDie(err,
|
||||||
|
OnSentinelError(io.EOF, func(e error) error {
|
||||||
|
return nil
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("HandleErrorOrDie with custom error panic", func(t *testing.T) {
|
||||||
|
customErr := &CustomError{Code: 500, Message: "server error"}
|
||||||
|
|
||||||
|
assert.Panics(t, func() {
|
||||||
|
HandleErrorOrDie(customErr,
|
||||||
|
OnCustomError(func(e *ValidationError) error {
|
||||||
|
return nil
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("HandleErrorOrDie with multiple matchers", func(t *testing.T) {
|
||||||
|
validationErr := &ValidationError{Field: "username", Value: ""}
|
||||||
|
|
||||||
|
result := HandleErrorOrDie(validationErr,
|
||||||
|
OnSentinelError(io.EOF, func(e error) error {
|
||||||
|
return errors.New("EOF handler")
|
||||||
|
}),
|
||||||
|
OnCustomError(func(e *CustomError) error {
|
||||||
|
return errors.New("custom handler")
|
||||||
|
}),
|
||||||
|
OnCustomError(func(e *ValidationError) error {
|
||||||
|
return fmt.Errorf("validation handler: field=%s", e.Field)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert.EqualError(t, result, "validation handler: field=username")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOnSentinelError(t *testing.T) {
|
||||||
|
t.Run("OnSentinelError creates proper matcher", func(t *testing.T) {
|
||||||
|
handler := func(e error) error { return e }
|
||||||
|
matcher := OnSentinelError(io.EOF, handler)
|
||||||
|
|
||||||
|
assert.Equal(t, io.EOF, matcher.ErrorType)
|
||||||
|
assert.True(t, matcher.IsSentinel)
|
||||||
|
assert.NotNil(t, matcher.Handler)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("OnSentinelError with custom sentinel", func(t *testing.T) {
|
||||||
|
customSentinel := errors.New("custom sentinel")
|
||||||
|
callCount := 0
|
||||||
|
|
||||||
|
matcher := OnSentinelError(customSentinel, func(e error) error {
|
||||||
|
callCount++
|
||||||
|
return errors.New("handled custom sentinel")
|
||||||
|
})
|
||||||
|
|
||||||
|
// Test that it matches the sentinel
|
||||||
|
handled, result := HandleError(customSentinel, matcher)
|
||||||
|
assert.True(t, handled)
|
||||||
|
require.EqualError(t, result, "handled custom sentinel")
|
||||||
|
assert.Equal(t, 1, callCount)
|
||||||
|
|
||||||
|
// Test that it matches wrapped sentinel
|
||||||
|
wrappedErr := fmt.Errorf("wrapped: %w", customSentinel)
|
||||||
|
handled, result = HandleError(wrappedErr, matcher)
|
||||||
|
assert.True(t, handled)
|
||||||
|
require.EqualError(t, result, "handled custom sentinel")
|
||||||
|
assert.Equal(t, 2, callCount)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOnCustomError(t *testing.T) {
|
||||||
|
t.Run("OnCustomError creates proper matcher", func(t *testing.T) {
|
||||||
|
matcher := OnCustomError(func(e *CustomError) error {
|
||||||
|
return fmt.Errorf("handled: %d", e.Code)
|
||||||
|
})
|
||||||
|
|
||||||
|
assert.False(t, matcher.IsSentinel)
|
||||||
|
assert.NotNil(t, matcher.Handler)
|
||||||
|
|
||||||
|
// Test the handler works
|
||||||
|
err := &CustomError{Code: 200, Message: "ok"}
|
||||||
|
result := matcher.Handler(err)
|
||||||
|
assert.EqualError(t, result, "handled: 200")
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("OnCustomError with different error types", func(t *testing.T) {
|
||||||
|
// Create matchers for different types
|
||||||
|
customMatcher := OnCustomError(func(e *CustomError) error {
|
||||||
|
return fmt.Errorf("custom error: code=%d", e.Code)
|
||||||
|
})
|
||||||
|
|
||||||
|
validationMatcher := OnCustomError(func(e *ValidationError) error {
|
||||||
|
return fmt.Errorf("validation error: field=%s", e.Field)
|
||||||
|
})
|
||||||
|
|
||||||
|
// Test with CustomError
|
||||||
|
customErr := &CustomError{Code: 404, Message: "not found"}
|
||||||
|
handled, result := HandleError(customErr, customMatcher, validationMatcher)
|
||||||
|
assert.True(t, handled)
|
||||||
|
require.EqualError(t, result, "custom error: code=404")
|
||||||
|
|
||||||
|
// Test with ValidationError
|
||||||
|
validationErr := &ValidationError{Field: "age", Value: "-1"}
|
||||||
|
handled, result = HandleError(validationErr, customMatcher, validationMatcher)
|
||||||
|
assert.True(t, handled)
|
||||||
|
assert.EqualError(t, result, "validation error: field=age")
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("OnCustomError handler receives correct type", func(t *testing.T) {
|
||||||
|
var receivedErr *CustomError
|
||||||
|
|
||||||
|
matcher := OnCustomError(func(e *CustomError) error {
|
||||||
|
receivedErr = e
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
|
||||||
|
originalErr := &CustomError{Code: 301, Message: "redirect"}
|
||||||
|
handled, _ := HandleError(originalErr, matcher)
|
||||||
|
|
||||||
|
assert.True(t, handled)
|
||||||
|
require.NotNil(t, receivedErr)
|
||||||
|
assert.Equal(t, 301, receivedErr.Code)
|
||||||
|
assert.Equal(t, "redirect", receivedErr.Message)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestErrorMatcherEdgeCases(t *testing.T) {
|
||||||
|
t.Run("Invalid sentinel error type in matcher", func(t *testing.T) {
|
||||||
|
// Create a matcher with invalid ErrorType for sentinel
|
||||||
|
matcher := ErrorMatcher{
|
||||||
|
ErrorType: "not an error", // Invalid type
|
||||||
|
Handler: func(e error) error { return e },
|
||||||
|
IsSentinel: true,
|
||||||
|
}
|
||||||
|
|
||||||
|
err := errors.New("test error")
|
||||||
|
handled, _ := HandleError(err, matcher)
|
||||||
|
assert.False(t, handled)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("Handler that panics", func(t *testing.T) {
|
||||||
|
matcher := OnSentinelError(io.EOF, func(e error) error {
|
||||||
|
panic("handler panic")
|
||||||
|
})
|
||||||
|
|
||||||
|
assert.Panics(t, func() {
|
||||||
|
HandleError(io.EOF, matcher)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("Complex error chain", func(t *testing.T) {
|
||||||
|
// Create a complex error chain
|
||||||
|
baseErr := &CustomError{Code: 500, Message: "base"}
|
||||||
|
wrapped1 := fmt.Errorf("layer1: %w", baseErr)
|
||||||
|
wrapped2 := fmt.Errorf("layer2: %w", wrapped1)
|
||||||
|
wrapped3 := fmt.Errorf("layer3: %w", wrapped2)
|
||||||
|
|
||||||
|
handled, result := HandleError(wrapped3,
|
||||||
|
OnCustomError(func(e *CustomError) error {
|
||||||
|
return fmt.Errorf("found custom error at code %d", e.Code)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert.True(t, handled)
|
||||||
|
assert.EqualError(t, result, "found custom error at code 500")
|
||||||
|
})
|
||||||
|
}
|
111
pkg/utils/parse.go
Normal file
111
pkg/utils/parse.go
Normal file
@@ -0,0 +1,111 @@
|
|||||||
|
package utils
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"net/url"
|
||||||
|
"strconv"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ParseString attempts to parse the input string `s` into a value of the specified type T.
|
||||||
|
// If parsing the string `s` fails for a supported type, it returns the zero value of T
|
||||||
|
// and the parsing error.
|
||||||
|
// /nolint:forcetypeassert,wrapcheck
|
||||||
|
func ParseString[
|
||||||
|
T string | int | int8 | int16 | int32 | int64 | uint | uint8 | uint16 | uint32 | uint64 | float64 | bool | time.Duration | url.URL,
|
||||||
|
](rawValue string) (T, error) {
|
||||||
|
var value T
|
||||||
|
|
||||||
|
switch any(value).(type) {
|
||||||
|
case int:
|
||||||
|
i, err := strconv.Atoi(rawValue)
|
||||||
|
if err != nil {
|
||||||
|
return value, err
|
||||||
|
}
|
||||||
|
value = any(i).(T)
|
||||||
|
case int8:
|
||||||
|
i, err := strconv.ParseInt(rawValue, 10, 8)
|
||||||
|
if err != nil {
|
||||||
|
return value, err
|
||||||
|
}
|
||||||
|
value = any(int8(i)).(T)
|
||||||
|
case int16:
|
||||||
|
i, err := strconv.ParseInt(rawValue, 10, 16)
|
||||||
|
if err != nil {
|
||||||
|
return value, err
|
||||||
|
}
|
||||||
|
value = any(int16(i)).(T)
|
||||||
|
case int32:
|
||||||
|
i, err := strconv.ParseInt(rawValue, 10, 32)
|
||||||
|
if err != nil {
|
||||||
|
return value, err
|
||||||
|
}
|
||||||
|
value = any(int32(i)).(T)
|
||||||
|
case int64:
|
||||||
|
i, err := strconv.ParseInt(rawValue, 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
return value, err
|
||||||
|
}
|
||||||
|
value = any(i).(T)
|
||||||
|
case uint:
|
||||||
|
u, err := strconv.ParseUint(rawValue, 10, 0)
|
||||||
|
if err != nil {
|
||||||
|
return value, err
|
||||||
|
}
|
||||||
|
value = any(uint(u)).(T)
|
||||||
|
case uint8:
|
||||||
|
u, err := strconv.ParseUint(rawValue, 10, 8)
|
||||||
|
if err != nil {
|
||||||
|
return value, err
|
||||||
|
}
|
||||||
|
value = any(uint8(u)).(T)
|
||||||
|
case uint16:
|
||||||
|
u, err := strconv.ParseUint(rawValue, 10, 16)
|
||||||
|
if err != nil {
|
||||||
|
return value, err
|
||||||
|
}
|
||||||
|
value = any(uint16(u)).(T)
|
||||||
|
case uint32:
|
||||||
|
u, err := strconv.ParseUint(rawValue, 10, 32)
|
||||||
|
if err != nil {
|
||||||
|
return value, err
|
||||||
|
}
|
||||||
|
value = any(uint32(u)).(T)
|
||||||
|
case uint64:
|
||||||
|
u, err := strconv.ParseUint(rawValue, 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
return value, err
|
||||||
|
}
|
||||||
|
value = any(u).(T)
|
||||||
|
case float64:
|
||||||
|
f, err := strconv.ParseFloat(rawValue, 64)
|
||||||
|
if err != nil {
|
||||||
|
return value, err
|
||||||
|
}
|
||||||
|
value = any(f).(T)
|
||||||
|
case bool:
|
||||||
|
b, err := strconv.ParseBool(rawValue)
|
||||||
|
if err != nil {
|
||||||
|
return value, err
|
||||||
|
}
|
||||||
|
value = any(b).(T)
|
||||||
|
case string:
|
||||||
|
value = any(rawValue).(T)
|
||||||
|
case time.Duration:
|
||||||
|
d, err := time.ParseDuration(rawValue)
|
||||||
|
if err != nil {
|
||||||
|
return value, err
|
||||||
|
}
|
||||||
|
value = any(d).(T)
|
||||||
|
case url.URL:
|
||||||
|
u, err := url.Parse(rawValue)
|
||||||
|
if err != nil {
|
||||||
|
return value, err
|
||||||
|
}
|
||||||
|
value = any(*u).(T)
|
||||||
|
default:
|
||||||
|
return value, fmt.Errorf("unsupported type: %T", value)
|
||||||
|
}
|
||||||
|
|
||||||
|
return value, nil
|
||||||
|
}
|
528
pkg/utils/parse_test.go
Normal file
528
pkg/utils/parse_test.go
Normal file
@@ -0,0 +1,528 @@
|
|||||||
|
package utils
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/url"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestParseString(t *testing.T) {
|
||||||
|
t.Run("ParseString to string", func(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
input string
|
||||||
|
expected string
|
||||||
|
}{
|
||||||
|
{"empty string", "", ""},
|
||||||
|
{"simple string", "hello", "hello"},
|
||||||
|
{"string with spaces", "hello world", "hello world"},
|
||||||
|
{"numeric string", "123", "123"},
|
||||||
|
{"special characters", "!@#$%^&*()", "!@#$%^&*()"},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, test := range tests {
|
||||||
|
t.Run(test.name, func(t *testing.T) {
|
||||||
|
result, err := ParseString[string](test.input)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, test.expected, result)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("ParseString to int", func(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
input string
|
||||||
|
expected int
|
||||||
|
expectError bool
|
||||||
|
}{
|
||||||
|
{"positive int", "42", 42, false},
|
||||||
|
{"negative int", "-42", -42, false},
|
||||||
|
{"zero", "0", 0, false},
|
||||||
|
{"invalid int", "abc", 0, true},
|
||||||
|
{"float string", "3.14", 0, true},
|
||||||
|
{"empty string", "", 0, true},
|
||||||
|
{"overflow string", "99999999999999999999", 0, true},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, test := range tests {
|
||||||
|
t.Run(test.name, func(t *testing.T) {
|
||||||
|
result, err := ParseString[int](test.input)
|
||||||
|
if test.expectError {
|
||||||
|
assert.Error(t, err)
|
||||||
|
} else {
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, test.expected, result)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("ParseString to int8", func(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
input string
|
||||||
|
expected int8
|
||||||
|
expectError bool
|
||||||
|
}{
|
||||||
|
{"valid int8", "127", 127, false},
|
||||||
|
{"min int8", "-128", -128, false},
|
||||||
|
{"overflow int8", "128", 0, true},
|
||||||
|
{"underflow int8", "-129", 0, true},
|
||||||
|
{"invalid", "abc", 0, true},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, test := range tests {
|
||||||
|
t.Run(test.name, func(t *testing.T) {
|
||||||
|
result, err := ParseString[int8](test.input)
|
||||||
|
if test.expectError {
|
||||||
|
assert.Error(t, err)
|
||||||
|
} else {
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, test.expected, result)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("ParseString to int16", func(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
input string
|
||||||
|
expected int16
|
||||||
|
expectError bool
|
||||||
|
}{
|
||||||
|
{"valid int16", "32767", 32767, false},
|
||||||
|
{"min int16", "-32768", -32768, false},
|
||||||
|
{"overflow int16", "32768", 0, true},
|
||||||
|
{"underflow int16", "-32769", 0, true},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, test := range tests {
|
||||||
|
t.Run(test.name, func(t *testing.T) {
|
||||||
|
result, err := ParseString[int16](test.input)
|
||||||
|
if test.expectError {
|
||||||
|
assert.Error(t, err)
|
||||||
|
} else {
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, test.expected, result)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("ParseString to int32", func(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
input string
|
||||||
|
expected int32
|
||||||
|
expectError bool
|
||||||
|
}{
|
||||||
|
{"valid int32", "2147483647", 2147483647, false},
|
||||||
|
{"min int32", "-2147483648", -2147483648, false},
|
||||||
|
{"overflow int32", "2147483648", 0, true},
|
||||||
|
{"underflow int32", "-2147483649", 0, true},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, test := range tests {
|
||||||
|
t.Run(test.name, func(t *testing.T) {
|
||||||
|
result, err := ParseString[int32](test.input)
|
||||||
|
if test.expectError {
|
||||||
|
assert.Error(t, err)
|
||||||
|
} else {
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, test.expected, result)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("ParseString to int64", func(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
input string
|
||||||
|
expected int64
|
||||||
|
expectError bool
|
||||||
|
}{
|
||||||
|
{"valid int64", "9223372036854775807", 9223372036854775807, false},
|
||||||
|
{"min int64", "-9223372036854775808", -9223372036854775808, false},
|
||||||
|
{"large number", "123456789012345", 123456789012345, false},
|
||||||
|
{"invalid", "not a number", 0, true},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, test := range tests {
|
||||||
|
t.Run(test.name, func(t *testing.T) {
|
||||||
|
result, err := ParseString[int64](test.input)
|
||||||
|
if test.expectError {
|
||||||
|
assert.Error(t, err)
|
||||||
|
} else {
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, test.expected, result)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("ParseString to uint", func(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
input string
|
||||||
|
expected uint
|
||||||
|
expectError bool
|
||||||
|
}{
|
||||||
|
{"valid uint", "42", 42, false},
|
||||||
|
{"zero", "0", 0, false},
|
||||||
|
{"large uint", "4294967295", 4294967295, false},
|
||||||
|
{"negative", "-1", 0, true},
|
||||||
|
{"invalid", "abc", 0, true},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, test := range tests {
|
||||||
|
t.Run(test.name, func(t *testing.T) {
|
||||||
|
result, err := ParseString[uint](test.input)
|
||||||
|
if test.expectError {
|
||||||
|
assert.Error(t, err)
|
||||||
|
} else {
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, test.expected, result)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("ParseString to uint8", func(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
input string
|
||||||
|
expected uint8
|
||||||
|
expectError bool
|
||||||
|
}{
|
||||||
|
{"valid uint8", "255", 255, false},
|
||||||
|
{"min uint8", "0", 0, false},
|
||||||
|
{"overflow uint8", "256", 0, true},
|
||||||
|
{"negative", "-1", 0, true},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, test := range tests {
|
||||||
|
t.Run(test.name, func(t *testing.T) {
|
||||||
|
result, err := ParseString[uint8](test.input)
|
||||||
|
if test.expectError {
|
||||||
|
assert.Error(t, err)
|
||||||
|
} else {
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, test.expected, result)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("ParseString to uint16", func(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
input string
|
||||||
|
expected uint16
|
||||||
|
expectError bool
|
||||||
|
}{
|
||||||
|
{"valid uint16", "65535", 65535, false},
|
||||||
|
{"min uint16", "0", 0, false},
|
||||||
|
{"overflow uint16", "65536", 0, true},
|
||||||
|
{"negative", "-1", 0, true},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, test := range tests {
|
||||||
|
t.Run(test.name, func(t *testing.T) {
|
||||||
|
result, err := ParseString[uint16](test.input)
|
||||||
|
if test.expectError {
|
||||||
|
assert.Error(t, err)
|
||||||
|
} else {
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, test.expected, result)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("ParseString to uint32", func(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
input string
|
||||||
|
expected uint32
|
||||||
|
expectError bool
|
||||||
|
}{
|
||||||
|
{"valid uint32", "4294967295", 4294967295, false},
|
||||||
|
{"min uint32", "0", 0, false},
|
||||||
|
{"overflow uint32", "4294967296", 0, true},
|
||||||
|
{"negative", "-1", 0, true},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, test := range tests {
|
||||||
|
t.Run(test.name, func(t *testing.T) {
|
||||||
|
result, err := ParseString[uint32](test.input)
|
||||||
|
if test.expectError {
|
||||||
|
assert.Error(t, err)
|
||||||
|
} else {
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, test.expected, result)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("ParseString to uint64", func(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
input string
|
||||||
|
expected uint64
|
||||||
|
expectError bool
|
||||||
|
}{
|
||||||
|
{"valid uint64", "18446744073709551615", 18446744073709551615, false},
|
||||||
|
{"min uint64", "0", 0, false},
|
||||||
|
{"large number", "123456789012345", 123456789012345, false},
|
||||||
|
{"negative", "-1", 0, true},
|
||||||
|
{"invalid", "not a number", 0, true},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, test := range tests {
|
||||||
|
t.Run(test.name, func(t *testing.T) {
|
||||||
|
result, err := ParseString[uint64](test.input)
|
||||||
|
if test.expectError {
|
||||||
|
assert.Error(t, err)
|
||||||
|
} else {
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, test.expected, result)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("ParseString to float64", func(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
input string
|
||||||
|
expected float64
|
||||||
|
expectError bool
|
||||||
|
}{
|
||||||
|
{"integer", "42", 42.0, false},
|
||||||
|
{"decimal", "3.14159", 3.14159, false},
|
||||||
|
{"negative", "-2.5", -2.5, false},
|
||||||
|
{"scientific notation", "1.23e10", 1.23e10, false},
|
||||||
|
{"zero", "0", 0.0, false},
|
||||||
|
{"invalid", "not a number", 0, true},
|
||||||
|
{"empty", "", 0, true},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, test := range tests {
|
||||||
|
t.Run(test.name, func(t *testing.T) {
|
||||||
|
result, err := ParseString[float64](test.input)
|
||||||
|
if test.expectError {
|
||||||
|
assert.Error(t, err)
|
||||||
|
} else {
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.InDelta(t, test.expected, result, 0.0001)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("ParseString to bool", func(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
input string
|
||||||
|
expected bool
|
||||||
|
expectError bool
|
||||||
|
}{
|
||||||
|
{"true lowercase", "true", true, false},
|
||||||
|
{"True mixed case", "True", true, false},
|
||||||
|
{"TRUE uppercase", "TRUE", true, false},
|
||||||
|
{"1 as true", "1", true, false},
|
||||||
|
{"false lowercase", "false", false, false},
|
||||||
|
{"False mixed case", "False", false, false},
|
||||||
|
{"FALSE uppercase", "FALSE", false, false},
|
||||||
|
{"0 as false", "0", false, false},
|
||||||
|
{"invalid", "yes", false, true},
|
||||||
|
{"empty", "", false, true},
|
||||||
|
{"numeric non-binary", "2", false, true},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, test := range tests {
|
||||||
|
t.Run(test.name, func(t *testing.T) {
|
||||||
|
result, err := ParseString[bool](test.input)
|
||||||
|
if test.expectError {
|
||||||
|
assert.Error(t, err)
|
||||||
|
} else {
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, test.expected, result)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("ParseString to time.Duration", func(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
input string
|
||||||
|
expected time.Duration
|
||||||
|
expectError bool
|
||||||
|
}{
|
||||||
|
{"seconds", "10s", 10 * time.Second, false},
|
||||||
|
{"minutes", "5m", 5 * time.Minute, false},
|
||||||
|
{"hours", "2h", 2 * time.Hour, false},
|
||||||
|
{"combined", "1h30m45s", time.Hour + 30*time.Minute + 45*time.Second, false},
|
||||||
|
{"milliseconds", "500ms", 500 * time.Millisecond, false},
|
||||||
|
{"microseconds", "100us", 100 * time.Microsecond, false},
|
||||||
|
{"nanoseconds", "50ns", 50 * time.Nanosecond, false},
|
||||||
|
{"negative", "-5s", -5 * time.Second, false},
|
||||||
|
{"invalid", "5x", 0, true},
|
||||||
|
{"empty", "", 0, true},
|
||||||
|
{"no unit", "100", 0, true},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, test := range tests {
|
||||||
|
t.Run(test.name, func(t *testing.T) {
|
||||||
|
result, err := ParseString[time.Duration](test.input)
|
||||||
|
if test.expectError {
|
||||||
|
assert.Error(t, err)
|
||||||
|
} else {
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, test.expected, result)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("ParseString to url.URL", func(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
input string
|
||||||
|
checkFunc func(t *testing.T, u url.URL)
|
||||||
|
expectError bool
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "http URL",
|
||||||
|
input: "http://example.com",
|
||||||
|
checkFunc: func(t *testing.T, u url.URL) {
|
||||||
|
t.Helper()
|
||||||
|
assert.Equal(t, "http", u.Scheme)
|
||||||
|
assert.Equal(t, "example.com", u.Host)
|
||||||
|
},
|
||||||
|
expectError: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "https URL with path",
|
||||||
|
input: "https://example.com/path/to/resource",
|
||||||
|
checkFunc: func(t *testing.T, u url.URL) {
|
||||||
|
t.Helper()
|
||||||
|
assert.Equal(t, "https", u.Scheme)
|
||||||
|
assert.Equal(t, "example.com", u.Host)
|
||||||
|
assert.Equal(t, "/path/to/resource", u.Path)
|
||||||
|
},
|
||||||
|
expectError: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "URL with query parameters",
|
||||||
|
input: "https://example.com/search?q=test&page=1",
|
||||||
|
checkFunc: func(t *testing.T, u url.URL) {
|
||||||
|
t.Helper()
|
||||||
|
assert.Equal(t, "https", u.Scheme)
|
||||||
|
assert.Equal(t, "example.com", u.Host)
|
||||||
|
assert.Equal(t, "/search", u.Path)
|
||||||
|
assert.Equal(t, "q=test&page=1", u.RawQuery)
|
||||||
|
},
|
||||||
|
expectError: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "URL with port",
|
||||||
|
input: "http://localhost:8080/api",
|
||||||
|
checkFunc: func(t *testing.T, u url.URL) {
|
||||||
|
t.Helper()
|
||||||
|
assert.Equal(t, "http", u.Scheme)
|
||||||
|
assert.Equal(t, "localhost:8080", u.Host)
|
||||||
|
assert.Equal(t, "/api", u.Path)
|
||||||
|
},
|
||||||
|
expectError: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "URL with fragment",
|
||||||
|
input: "https://example.com/page#section",
|
||||||
|
checkFunc: func(t *testing.T, u url.URL) {
|
||||||
|
t.Helper()
|
||||||
|
assert.Equal(t, "https", u.Scheme)
|
||||||
|
assert.Equal(t, "example.com", u.Host)
|
||||||
|
assert.Equal(t, "/page", u.Path)
|
||||||
|
assert.Equal(t, "section", u.Fragment)
|
||||||
|
},
|
||||||
|
expectError: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "relative path",
|
||||||
|
input: "/path/to/resource",
|
||||||
|
checkFunc: func(t *testing.T, u url.URL) {
|
||||||
|
t.Helper()
|
||||||
|
assert.Empty(t, u.Scheme)
|
||||||
|
assert.Empty(t, u.Host)
|
||||||
|
assert.Equal(t, "/path/to/resource", u.Path)
|
||||||
|
},
|
||||||
|
expectError: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "empty string",
|
||||||
|
input: "",
|
||||||
|
checkFunc: func(t *testing.T, u url.URL) {
|
||||||
|
t.Helper()
|
||||||
|
assert.Empty(t, u.String())
|
||||||
|
},
|
||||||
|
expectError: false,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, test := range tests {
|
||||||
|
t.Run(test.name, func(t *testing.T) {
|
||||||
|
result, err := ParseString[url.URL](test.input)
|
||||||
|
if test.expectError {
|
||||||
|
assert.Error(t, err)
|
||||||
|
} else {
|
||||||
|
require.NoError(t, err)
|
||||||
|
if test.checkFunc != nil {
|
||||||
|
test.checkFunc(t, result)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("Edge cases", func(t *testing.T) {
|
||||||
|
t.Run("whitespace handling for numeric types", func(t *testing.T) {
|
||||||
|
result, err := ParseString[int](" 42 ")
|
||||||
|
require.Error(t, err)
|
||||||
|
assert.Equal(t, 0, result)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("leading zeros for int", func(t *testing.T) {
|
||||||
|
result, err := ParseString[int]("007")
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, 7, result)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("plus sign for positive numbers", func(t *testing.T) {
|
||||||
|
result, err := ParseString[int]("+42")
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, 42, result)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("case sensitivity for bool", func(t *testing.T) {
|
||||||
|
testCases := []string{"t", "T", "f", "F"}
|
||||||
|
for _, tc := range testCases {
|
||||||
|
result, err := ParseString[bool](tc)
|
||||||
|
require.NoError(t, err)
|
||||||
|
if tc == "t" || tc == "T" {
|
||||||
|
assert.True(t, result)
|
||||||
|
} else {
|
||||||
|
assert.False(t, result)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
17
pkg/utils/print.go
Normal file
17
pkg/utils/print.go
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
package utils
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
|
||||||
|
"github.com/jedib0t/go-pretty/v6/text"
|
||||||
|
)
|
||||||
|
|
||||||
|
func PrintErr(color text.Color, format string, a ...any) {
|
||||||
|
fmt.Fprintln(os.Stderr, color.Sprintf(format, a...))
|
||||||
|
}
|
||||||
|
|
||||||
|
func PrintErrAndExit(color text.Color, exitCode int, format string, a ...any) {
|
||||||
|
PrintErr(color, format, a...)
|
||||||
|
os.Exit(exitCode)
|
||||||
|
}
|
250
pkg/utils/print_test.go
Normal file
250
pkg/utils/print_test.go
Normal file
@@ -0,0 +1,250 @@
|
|||||||
|
package utils
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/jedib0t/go-pretty/v6/text"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestPrintErr(t *testing.T) {
|
||||||
|
t.Run("PrintErr writes to stderr with color", func(t *testing.T) {
|
||||||
|
// Capture stderr
|
||||||
|
oldStderr := os.Stderr
|
||||||
|
reader, writer, _ := os.Pipe()
|
||||||
|
os.Stderr = writer
|
||||||
|
|
||||||
|
// Call PrintErr
|
||||||
|
PrintErr(text.FgRed, "Error: %s", "test error")
|
||||||
|
|
||||||
|
// Restore stderr and read output
|
||||||
|
writer.Close()
|
||||||
|
os.Stderr = oldStderr
|
||||||
|
var buf bytes.Buffer
|
||||||
|
io.Copy(&buf, reader)
|
||||||
|
output := buf.String()
|
||||||
|
|
||||||
|
// The output should contain the message (color codes are included)
|
||||||
|
assert.Contains(t, output, "test error")
|
||||||
|
assert.Contains(t, output, "Error:")
|
||||||
|
assert.True(t, strings.HasSuffix(output, "\n"))
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("PrintErr with multiple format arguments", func(t *testing.T) {
|
||||||
|
oldStderr := os.Stderr
|
||||||
|
reader, writer, _ := os.Pipe()
|
||||||
|
os.Stderr = writer
|
||||||
|
|
||||||
|
PrintErr(text.FgYellow, "Warning: %s at line %d", "issue", 42)
|
||||||
|
|
||||||
|
writer.Close()
|
||||||
|
os.Stderr = oldStderr
|
||||||
|
var buf bytes.Buffer
|
||||||
|
io.Copy(&buf, reader)
|
||||||
|
output := buf.String()
|
||||||
|
|
||||||
|
assert.Contains(t, output, "Warning: issue at line 42")
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("PrintErr with no format arguments", func(t *testing.T) {
|
||||||
|
oldStderr := os.Stderr
|
||||||
|
reader, writer, _ := os.Pipe()
|
||||||
|
os.Stderr = writer
|
||||||
|
|
||||||
|
PrintErr(text.FgGreen, "Simple message")
|
||||||
|
|
||||||
|
writer.Close()
|
||||||
|
os.Stderr = oldStderr
|
||||||
|
var buf bytes.Buffer
|
||||||
|
io.Copy(&buf, reader)
|
||||||
|
output := buf.String()
|
||||||
|
|
||||||
|
assert.Contains(t, output, "Simple message")
|
||||||
|
assert.True(t, strings.HasSuffix(output, "\n"))
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("PrintErr with different colors", func(t *testing.T) {
|
||||||
|
colors := []text.Color{
|
||||||
|
text.FgRed,
|
||||||
|
text.FgGreen,
|
||||||
|
text.FgYellow,
|
||||||
|
text.FgBlue,
|
||||||
|
text.FgMagenta,
|
||||||
|
text.FgCyan,
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, color := range colors {
|
||||||
|
oldStderr := os.Stderr
|
||||||
|
reader, writer, _ := os.Pipe()
|
||||||
|
os.Stderr = writer
|
||||||
|
|
||||||
|
PrintErr(color, "Message with color")
|
||||||
|
|
||||||
|
writer.Close()
|
||||||
|
os.Stderr = oldStderr
|
||||||
|
var buf bytes.Buffer
|
||||||
|
io.Copy(&buf, reader)
|
||||||
|
output := buf.String()
|
||||||
|
|
||||||
|
assert.Contains(t, output, "Message with color")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("PrintErr with empty string", func(t *testing.T) {
|
||||||
|
oldStderr := os.Stderr
|
||||||
|
reader, writer, _ := os.Pipe()
|
||||||
|
os.Stderr = writer
|
||||||
|
|
||||||
|
PrintErr(text.FgRed, "")
|
||||||
|
|
||||||
|
writer.Close()
|
||||||
|
os.Stderr = oldStderr
|
||||||
|
var buf bytes.Buffer
|
||||||
|
io.Copy(&buf, reader)
|
||||||
|
output := buf.String()
|
||||||
|
|
||||||
|
assert.Equal(t, "\n", strings.TrimPrefix(output, "\x1b[31m\x1b[0m")) // Just newline after color codes
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("PrintErr with special characters", func(t *testing.T) {
|
||||||
|
oldStderr := os.Stderr
|
||||||
|
reader, writer, _ := os.Pipe()
|
||||||
|
os.Stderr = writer
|
||||||
|
|
||||||
|
PrintErr(text.FgRed, "Special chars: %s", "!@#$%^&*()")
|
||||||
|
|
||||||
|
writer.Close()
|
||||||
|
os.Stderr = oldStderr
|
||||||
|
var buf bytes.Buffer
|
||||||
|
io.Copy(&buf, reader)
|
||||||
|
output := buf.String()
|
||||||
|
|
||||||
|
assert.Contains(t, output, "Special chars: !@#$%^&*()")
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("PrintErr with percent sign in message", func(t *testing.T) {
|
||||||
|
oldStderr := os.Stderr
|
||||||
|
reader, writer, _ := os.Pipe()
|
||||||
|
os.Stderr = writer
|
||||||
|
|
||||||
|
PrintErr(text.FgRed, "Progress: 100%% complete")
|
||||||
|
|
||||||
|
writer.Close()
|
||||||
|
os.Stderr = oldStderr
|
||||||
|
var buf bytes.Buffer
|
||||||
|
io.Copy(&buf, reader)
|
||||||
|
output := buf.String()
|
||||||
|
|
||||||
|
assert.Contains(t, output, "Progress: 100% complete")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPrintErrAndExit(t *testing.T) {
|
||||||
|
if os.Getenv("BE_CRASHER") == "1" {
|
||||||
|
// This is the subprocess that will actually call PrintErrAndExit
|
||||||
|
exitCode := 1
|
||||||
|
if code := os.Getenv("EXIT_CODE"); code != "" {
|
||||||
|
switch code {
|
||||||
|
case "0":
|
||||||
|
exitCode = 0
|
||||||
|
case "1":
|
||||||
|
exitCode = 1
|
||||||
|
case "2":
|
||||||
|
exitCode = 2
|
||||||
|
}
|
||||||
|
}
|
||||||
|
PrintErrAndExit(text.FgRed, exitCode, "Error: %s", "fatal error")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Run("PrintErrAndExit calls os.Exit with correct code", func(t *testing.T) {
|
||||||
|
testCases := []struct {
|
||||||
|
name string
|
||||||
|
exitCode int
|
||||||
|
}{
|
||||||
|
{"Exit with code 0", 0},
|
||||||
|
{"Exit with code 1", 1},
|
||||||
|
{"Exit with code 2", 2},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, testCase := range testCases {
|
||||||
|
t.Run(testCase.name, func(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
cmd := exec.CommandContext(ctx, os.Args[0], "-test.run=TestPrintErrAndExit")
|
||||||
|
cmd.Env = append(os.Environ(),
|
||||||
|
"BE_CRASHER=1",
|
||||||
|
"EXIT_CODE="+string(rune('0'+testCase.exitCode)))
|
||||||
|
|
||||||
|
var stderr bytes.Buffer
|
||||||
|
cmd.Stderr = &stderr
|
||||||
|
|
||||||
|
err := cmd.Run()
|
||||||
|
|
||||||
|
if testCase.exitCode == 0 {
|
||||||
|
require.NoError(t, err)
|
||||||
|
} else {
|
||||||
|
require.Error(t, err)
|
||||||
|
if exitErr, ok := err.(*exec.ExitError); ok {
|
||||||
|
assert.Equal(t, testCase.exitCode, exitErr.ExitCode())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check that error message was printed to stderr
|
||||||
|
assert.Contains(t, stderr.String(), "Error: fatal error")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("PrintErrAndExit prints before exiting", func(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
cmd := exec.CommandContext(ctx, os.Args[0], "-test.run=TestPrintErrAndExit")
|
||||||
|
cmd.Env = append(os.Environ(), "BE_CRASHER=1", "EXIT_CODE=1")
|
||||||
|
|
||||||
|
var stderr bytes.Buffer
|
||||||
|
cmd.Stderr = &stderr
|
||||||
|
|
||||||
|
cmd.Run() // Ignore error since we expect non-zero exit
|
||||||
|
|
||||||
|
output := stderr.String()
|
||||||
|
assert.Contains(t, output, "Error: fatal error")
|
||||||
|
assert.True(t, strings.HasSuffix(output, "\n"))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Benchmarks for performance testing
|
||||||
|
func BenchmarkPrintErr(b *testing.B) {
|
||||||
|
// Redirect stderr to /dev/null for benchmarking
|
||||||
|
oldStderr := os.Stderr
|
||||||
|
devNull, _ := os.Open(os.DevNull)
|
||||||
|
os.Stderr = devNull
|
||||||
|
defer func() {
|
||||||
|
os.Stderr = oldStderr
|
||||||
|
devNull.Close()
|
||||||
|
}()
|
||||||
|
|
||||||
|
b.Run("Simple message", func(b *testing.B) {
|
||||||
|
for range b.N {
|
||||||
|
PrintErr(text.FgRed, "Error message")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
b.Run("Formatted message", func(b *testing.B) {
|
||||||
|
for range b.N {
|
||||||
|
PrintErr(text.FgRed, "Error: %s at line %d", "issue", 42)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
b.Run("Different colors", func(b *testing.B) {
|
||||||
|
colors := []text.Color{text.FgRed, text.FgGreen, text.FgYellow}
|
||||||
|
for idx := range b.N {
|
||||||
|
PrintErr(colors[idx%len(colors)], "Message %d", idx)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
@@ -1,99 +0,0 @@
|
|||||||
package requests
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"errors"
|
|
||||||
"math/rand"
|
|
||||||
"net/url"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/aykhans/dodo/utils"
|
|
||||||
"github.com/valyala/fasthttp"
|
|
||||||
"github.com/valyala/fasthttp/fasthttpproxy"
|
|
||||||
)
|
|
||||||
|
|
||||||
type ClientGeneratorFunc func() *fasthttp.HostClient
|
|
||||||
|
|
||||||
// getClients initializes and returns a slice of fasthttp.HostClient based on the provided parameters.
|
|
||||||
// It can either return clients with proxies or a single client without proxies.
|
|
||||||
func getClients(
|
|
||||||
ctx context.Context,
|
|
||||||
timeout time.Duration,
|
|
||||||
proxies []url.URL,
|
|
||||||
maxConns uint,
|
|
||||||
URL url.URL,
|
|
||||||
) []*fasthttp.HostClient {
|
|
||||||
isTLS := URL.Scheme == "https"
|
|
||||||
|
|
||||||
if proxiesLen := len(proxies); proxiesLen > 0 {
|
|
||||||
clients := make([]*fasthttp.HostClient, 0, proxiesLen)
|
|
||||||
addr := URL.Host
|
|
||||||
if isTLS && URL.Port() == "" {
|
|
||||||
addr += ":443"
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, proxy := range proxies {
|
|
||||||
dialFunc, err := getDialFunc(&proxy, timeout)
|
|
||||||
if err != nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
clients = append(clients, &fasthttp.HostClient{
|
|
||||||
MaxConns: int(maxConns),
|
|
||||||
IsTLS: isTLS,
|
|
||||||
Addr: addr,
|
|
||||||
Dial: dialFunc,
|
|
||||||
MaxIdleConnDuration: timeout,
|
|
||||||
MaxConnDuration: timeout,
|
|
||||||
WriteTimeout: timeout,
|
|
||||||
ReadTimeout: timeout,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
return clients
|
|
||||||
}
|
|
||||||
|
|
||||||
client := &fasthttp.HostClient{
|
|
||||||
MaxConns: int(maxConns),
|
|
||||||
IsTLS: isTLS,
|
|
||||||
Addr: URL.Host,
|
|
||||||
MaxIdleConnDuration: timeout,
|
|
||||||
MaxConnDuration: timeout,
|
|
||||||
WriteTimeout: timeout,
|
|
||||||
ReadTimeout: timeout,
|
|
||||||
}
|
|
||||||
return []*fasthttp.HostClient{client}
|
|
||||||
}
|
|
||||||
|
|
||||||
// getDialFunc returns the appropriate fasthttp.DialFunc based on the provided proxy URL scheme.
|
|
||||||
// It supports SOCKS5 ('socks5' or 'socks5h') and HTTP ('http') proxy schemes.
|
|
||||||
// For HTTP proxies, the timeout parameter determines connection timeouts.
|
|
||||||
// Returns an error if the proxy scheme is unsupported.
|
|
||||||
func getDialFunc(proxy *url.URL, timeout time.Duration) (fasthttp.DialFunc, error) {
|
|
||||||
var dialer fasthttp.DialFunc
|
|
||||||
|
|
||||||
switch proxy.Scheme {
|
|
||||||
case "socks5", "socks5h":
|
|
||||||
dialer = fasthttpproxy.FasthttpSocksDialerDualStack(proxy.String())
|
|
||||||
case "http":
|
|
||||||
dialer = fasthttpproxy.FasthttpHTTPDialerDualStackTimeout(proxy.String(), timeout)
|
|
||||||
default:
|
|
||||||
return nil, errors.New("unsupported proxy scheme")
|
|
||||||
}
|
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
|
@@ -1,56 +0,0 @@
|
|||||||
package requests
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"fmt"
|
|
||||||
"sync"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/jedib0t/go-pretty/v6/progress"
|
|
||||||
)
|
|
||||||
|
|
||||||
// streamProgress streams the progress of a task to the console using a progress bar.
|
|
||||||
// It listens for increments on the provided channel and updates the progress bar accordingly.
|
|
||||||
//
|
|
||||||
// The function will stop and mark the progress as errored if the context is cancelled.
|
|
||||||
// It will also stop and mark the progress as done when the total number of increments is reached.
|
|
||||||
func streamProgress(
|
|
||||||
ctx context.Context,
|
|
||||||
wg *sync.WaitGroup,
|
|
||||||
total uint,
|
|
||||||
message string,
|
|
||||||
increase <-chan int64,
|
|
||||||
) {
|
|
||||||
defer wg.Done()
|
|
||||||
pw := progress.NewWriter()
|
|
||||||
pw.SetTrackerPosition(progress.PositionRight)
|
|
||||||
pw.SetStyle(progress.StyleBlocks)
|
|
||||||
pw.SetTrackerLength(40)
|
|
||||||
pw.SetUpdateFrequency(time.Millisecond * 250)
|
|
||||||
if total == 0 {
|
|
||||||
pw.Style().Visibility.Percentage = false
|
|
||||||
}
|
|
||||||
go pw.Render()
|
|
||||||
dodosTracker := progress.Tracker{
|
|
||||||
Message: message,
|
|
||||||
Total: int64(total),
|
|
||||||
}
|
|
||||||
pw.AppendTracker(&dodosTracker)
|
|
||||||
|
|
||||||
for {
|
|
||||||
select {
|
|
||||||
case <-ctx.Done():
|
|
||||||
if err := ctx.Err(); err == context.Canceled || err == context.DeadlineExceeded {
|
|
||||||
dodosTracker.MarkAsDone()
|
|
||||||
} else {
|
|
||||||
dodosTracker.MarkAsErrored()
|
|
||||||
}
|
|
||||||
time.Sleep(time.Millisecond * 300)
|
|
||||||
fmt.Printf("\r")
|
|
||||||
return
|
|
||||||
|
|
||||||
case value := <-increase:
|
|
||||||
dodosTracker.Increment(value)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
@@ -1,247 +0,0 @@
|
|||||||
package requests
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"math/rand"
|
|
||||||
"net/url"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/aykhans/dodo/config"
|
|
||||||
"github.com/aykhans/dodo/types"
|
|
||||||
"github.com/aykhans/dodo/utils"
|
|
||||||
"github.com/valyala/fasthttp"
|
|
||||||
)
|
|
||||||
|
|
||||||
type RequestGeneratorFunc func() *fasthttp.Request
|
|
||||||
|
|
||||||
// Request represents an HTTP request to be sent using the fasthttp client.
|
|
||||||
// It isn't thread-safe and should be used by a single goroutine.
|
|
||||||
type Request struct {
|
|
||||||
getClient ClientGeneratorFunc
|
|
||||||
getRequest RequestGeneratorFunc
|
|
||||||
}
|
|
||||||
|
|
||||||
// Send sends the HTTP request using the fasthttp client with a specified timeout.
|
|
||||||
// It returns the HTTP response or an error if the request fails or times out.
|
|
||||||
func (r *Request) Send(ctx context.Context, timeout time.Duration) (*fasthttp.Response, error) {
|
|
||||||
client := r.getClient()
|
|
||||||
request := r.getRequest()
|
|
||||||
defer fasthttp.ReleaseRequest(request)
|
|
||||||
|
|
||||||
response := fasthttp.AcquireResponse()
|
|
||||||
ch := make(chan error)
|
|
||||||
go func() {
|
|
||||||
err := client.DoTimeout(request, response, timeout)
|
|
||||||
ch <- err
|
|
||||||
}()
|
|
||||||
select {
|
|
||||||
case err := <-ch:
|
|
||||||
if err != nil {
|
|
||||||
fasthttp.ReleaseResponse(response)
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return response, nil
|
|
||||||
case <-time.After(timeout):
|
|
||||||
fasthttp.ReleaseResponse(response)
|
|
||||||
return nil, types.ErrTimeout
|
|
||||||
case <-ctx.Done():
|
|
||||||
return nil, types.ErrInterrupt
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// newRequest creates a new Request instance based on the provided configuration and clients.
|
|
||||||
// It initializes a random number generator using the current time and a unique identifier (uid).
|
|
||||||
// Depending on the number of clients provided, it sets up a function to select the appropriate client.
|
|
||||||
// It also sets up a function to generate the request based on the provided configuration.
|
|
||||||
func newRequest(
|
|
||||||
requestConfig config.RequestConfig,
|
|
||||||
clients []*fasthttp.HostClient,
|
|
||||||
uid int64,
|
|
||||||
) *Request {
|
|
||||||
localRand := rand.New(rand.NewSource(time.Now().UnixNano() + uid))
|
|
||||||
|
|
||||||
clientsCount := len(clients)
|
|
||||||
if clientsCount < 1 {
|
|
||||||
panic("no clients")
|
|
||||||
}
|
|
||||||
|
|
||||||
getClient := ClientGeneratorFunc(nil)
|
|
||||||
if clientsCount == 1 {
|
|
||||||
getClient = getSharedClientFuncSingle(clients[0])
|
|
||||||
} else {
|
|
||||||
getClient = getSharedClientFuncMultiple(clients, localRand)
|
|
||||||
}
|
|
||||||
|
|
||||||
getRequest := getRequestGeneratorFunc(
|
|
||||||
requestConfig.URL,
|
|
||||||
requestConfig.Params,
|
|
||||||
requestConfig.Headers,
|
|
||||||
requestConfig.Cookies,
|
|
||||||
requestConfig.Method,
|
|
||||||
requestConfig.Body,
|
|
||||||
localRand,
|
|
||||||
)
|
|
||||||
|
|
||||||
requests := &Request{
|
|
||||||
getClient: getClient,
|
|
||||||
getRequest: getRequest,
|
|
||||||
}
|
|
||||||
|
|
||||||
return requests
|
|
||||||
}
|
|
||||||
|
|
||||||
// getRequestGeneratorFunc returns a RequestGeneratorFunc which generates HTTP requests with the specified parameters.
|
|
||||||
// The function uses a local random number generator to select bodies, headers, cookies, and parameters if multiple options are provided.
|
|
||||||
func getRequestGeneratorFunc(
|
|
||||||
URL url.URL,
|
|
||||||
params types.Params,
|
|
||||||
headers types.Headers,
|
|
||||||
cookies types.Cookies,
|
|
||||||
method string,
|
|
||||||
bodies []string,
|
|
||||||
localRand *rand.Rand,
|
|
||||||
) RequestGeneratorFunc {
|
|
||||||
bodiesLen := len(bodies)
|
|
||||||
getBody := func() string { return "" }
|
|
||||||
if bodiesLen == 1 {
|
|
||||||
getBody = func() string { return bodies[0] }
|
|
||||||
} else if bodiesLen > 1 {
|
|
||||||
getBody = utils.RandomValueCycle(bodies, localRand)
|
|
||||||
}
|
|
||||||
|
|
||||||
getParams := getKeyValueGeneratorFunc(params, localRand)
|
|
||||||
getHeaders := getKeyValueGeneratorFunc(headers, localRand)
|
|
||||||
getCookies := getKeyValueGeneratorFunc(cookies, localRand)
|
|
||||||
|
|
||||||
return func() *fasthttp.Request {
|
|
||||||
return newFasthttpRequest(
|
|
||||||
URL,
|
|
||||||
getParams(),
|
|
||||||
getHeaders(),
|
|
||||||
getCookies(),
|
|
||||||
method,
|
|
||||||
getBody(),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// newFasthttpRequest creates a new fasthttp.Request object with the provided parameters.
|
|
||||||
// It sets the request URI, host header, headers, cookies, params, method, and body.
|
|
||||||
func newFasthttpRequest(
|
|
||||||
URL url.URL,
|
|
||||||
params []types.KeyValue[string, string],
|
|
||||||
headers []types.KeyValue[string, string],
|
|
||||||
cookies []types.KeyValue[string, string],
|
|
||||||
method string,
|
|
||||||
body string,
|
|
||||||
) *fasthttp.Request {
|
|
||||||
request := fasthttp.AcquireRequest()
|
|
||||||
request.SetRequestURI(URL.Path)
|
|
||||||
|
|
||||||
// Set the host of the request to the host header
|
|
||||||
// If the host header is not set, the request will fail
|
|
||||||
// If there is host header in the headers, it will be overwritten
|
|
||||||
request.Header.SetHost(URL.Host)
|
|
||||||
setRequestParams(request, params)
|
|
||||||
setRequestHeaders(request, headers)
|
|
||||||
setRequestCookies(request, cookies)
|
|
||||||
setRequestMethod(request, method)
|
|
||||||
setRequestBody(request, body)
|
|
||||||
if URL.Scheme == "https" {
|
|
||||||
request.URI().SetScheme("https")
|
|
||||||
}
|
|
||||||
|
|
||||||
return request
|
|
||||||
}
|
|
||||||
|
|
||||||
// setRequestParams adds the query parameters of the given request based on the provided key-value pairs.
|
|
||||||
func setRequestParams(req *fasthttp.Request, params []types.KeyValue[string, string]) {
|
|
||||||
for _, param := range params {
|
|
||||||
req.URI().QueryArgs().Add(param.Key, param.Value)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// setRequestHeaders adds the headers of the given request with the provided key-value pairs.
|
|
||||||
func setRequestHeaders(req *fasthttp.Request, headers []types.KeyValue[string, string]) {
|
|
||||||
for _, header := range headers {
|
|
||||||
req.Header.Add(header.Key, header.Value)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// setRequestCookies adds the cookies of the given request with the provided key-value pairs.
|
|
||||||
func setRequestCookies(req *fasthttp.Request, cookies []types.KeyValue[string, string]) {
|
|
||||||
for _, cookie := range cookies {
|
|
||||||
req.Header.Add("Cookie", cookie.Key+"="+cookie.Value)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// setRequestMethod sets the HTTP request method for the given request.
|
|
||||||
func setRequestMethod(req *fasthttp.Request, method string) {
|
|
||||||
req.Header.SetMethod(method)
|
|
||||||
}
|
|
||||||
|
|
||||||
// setRequestBody sets the request body of the given fasthttp.Request object.
|
|
||||||
// The body parameter is a string that will be converted to a byte slice and set as the request body.
|
|
||||||
func setRequestBody(req *fasthttp.Request, body string) {
|
|
||||||
req.SetBody([]byte(body))
|
|
||||||
}
|
|
||||||
|
|
||||||
// getKeyValueGeneratorFunc creates a function that generates key-value pairs for HTTP requests.
|
|
||||||
// It takes a slice of key-value pairs where each key maps to a slice of possible values,
|
|
||||||
// and a random number generator.
|
|
||||||
//
|
|
||||||
// If any key has multiple possible values, the function will randomly select one value for each
|
|
||||||
// call (using the provided random number generator). If all keys have at most one value, the
|
|
||||||
// function will always return the same set of key-value pairs for efficiency.
|
|
||||||
func getKeyValueGeneratorFunc[
|
|
||||||
T []types.KeyValue[string, string],
|
|
||||||
](
|
|
||||||
keyValueSlice []types.KeyValue[string, []string],
|
|
||||||
localRand *rand.Rand,
|
|
||||||
) func() T {
|
|
||||||
getKeyValueSlice := []map[string]func() string{}
|
|
||||||
isRandom := false
|
|
||||||
|
|
||||||
for _, kv := range keyValueSlice {
|
|
||||||
valuesLen := len(kv.Value)
|
|
||||||
|
|
||||||
getValueFunc := func() string { return "" }
|
|
||||||
if valuesLen == 1 {
|
|
||||||
getValueFunc = func() string { return kv.Value[0] }
|
|
||||||
} else if valuesLen > 1 {
|
|
||||||
getValueFunc = utils.RandomValueCycle(kv.Value, localRand)
|
|
||||||
isRandom = true
|
|
||||||
}
|
|
||||||
|
|
||||||
getKeyValueSlice = append(
|
|
||||||
getKeyValueSlice,
|
|
||||||
map[string]func() string{kv.Key: getValueFunc},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
if isRandom {
|
|
||||||
return func() T {
|
|
||||||
keyValues := make(T, len(getKeyValueSlice))
|
|
||||||
for i, keyValue := range getKeyValueSlice {
|
|
||||||
for key, value := range keyValue {
|
|
||||||
keyValues[i] = types.KeyValue[string, string]{
|
|
||||||
Key: key,
|
|
||||||
Value: value(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return keyValues
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
keyValues := make(T, len(getKeyValueSlice))
|
|
||||||
for i, keyValue := range getKeyValueSlice {
|
|
||||||
for key, value := range keyValue {
|
|
||||||
keyValues[i] = types.KeyValue[string, string]{
|
|
||||||
Key: key,
|
|
||||||
Value: value(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return func() T { return keyValues }
|
|
||||||
}
|
|
||||||
}
|
|
@@ -1,108 +0,0 @@
|
|||||||
package requests
|
|
||||||
|
|
||||||
import (
|
|
||||||
"os"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/aykhans/dodo/types"
|
|
||||||
"github.com/aykhans/dodo/utils"
|
|
||||||
"github.com/jedib0t/go-pretty/v6/table"
|
|
||||||
)
|
|
||||||
|
|
||||||
type Response struct {
|
|
||||||
Response string
|
|
||||||
Time time.Duration
|
|
||||||
}
|
|
||||||
|
|
||||||
type Responses []*Response
|
|
||||||
|
|
||||||
// Print prints the responses in a tabular format, including information such as
|
|
||||||
// response count, minimum time, maximum time, average time, and latency percentiles.
|
|
||||||
func (responses Responses) Print() {
|
|
||||||
total := struct {
|
|
||||||
Count int
|
|
||||||
Min time.Duration
|
|
||||||
Max time.Duration
|
|
||||||
Sum time.Duration
|
|
||||||
P90 time.Duration
|
|
||||||
P95 time.Duration
|
|
||||||
P99 time.Duration
|
|
||||||
}{
|
|
||||||
Count: len(responses),
|
|
||||||
Min: responses[0].Time,
|
|
||||||
Max: responses[0].Time,
|
|
||||||
}
|
|
||||||
mergedResponses := make(map[string]types.Durations)
|
|
||||||
var allDurations types.Durations
|
|
||||||
|
|
||||||
for _, response := range responses {
|
|
||||||
if response.Time < total.Min {
|
|
||||||
total.Min = response.Time
|
|
||||||
}
|
|
||||||
if response.Time > total.Max {
|
|
||||||
total.Max = response.Time
|
|
||||||
}
|
|
||||||
total.Sum += response.Time
|
|
||||||
|
|
||||||
mergedResponses[response.Response] = append(
|
|
||||||
mergedResponses[response.Response],
|
|
||||||
response.Time,
|
|
||||||
)
|
|
||||||
allDurations = append(allDurations, response.Time)
|
|
||||||
}
|
|
||||||
allDurations.Sort()
|
|
||||||
allDurationsLenAsFloat := float64(len(allDurations) - 1)
|
|
||||||
total.P90 = allDurations[int(0.90*allDurationsLenAsFloat)]
|
|
||||||
total.P95 = allDurations[int(0.95*allDurationsLenAsFloat)]
|
|
||||||
total.P99 = allDurations[int(0.99*allDurationsLenAsFloat)]
|
|
||||||
|
|
||||||
t := table.NewWriter()
|
|
||||||
t.SetOutputMirror(os.Stdout)
|
|
||||||
t.SetStyle(table.StyleLight)
|
|
||||||
t.SetColumnConfigs([]table.ColumnConfig{
|
|
||||||
{Number: 1, WidthMax: 40},
|
|
||||||
})
|
|
||||||
t.AppendHeader(table.Row{
|
|
||||||
"Response",
|
|
||||||
"Count",
|
|
||||||
"Min",
|
|
||||||
"Max",
|
|
||||||
"Average",
|
|
||||||
"P90",
|
|
||||||
"P95",
|
|
||||||
"P99",
|
|
||||||
})
|
|
||||||
|
|
||||||
var roundPrecision int64 = 4
|
|
||||||
for key, durations := range mergedResponses {
|
|
||||||
durations.Sort()
|
|
||||||
durationsLen := len(durations)
|
|
||||||
durationsLenAsFloat := float64(durationsLen - 1)
|
|
||||||
|
|
||||||
t.AppendRow(table.Row{
|
|
||||||
key,
|
|
||||||
durationsLen,
|
|
||||||
utils.DurationRoundBy(*durations.First(), roundPrecision),
|
|
||||||
utils.DurationRoundBy(*durations.Last(), roundPrecision),
|
|
||||||
utils.DurationRoundBy(durations.Avg(), roundPrecision),
|
|
||||||
utils.DurationRoundBy(durations[int(0.90*durationsLenAsFloat)], roundPrecision),
|
|
||||||
utils.DurationRoundBy(durations[int(0.95*durationsLenAsFloat)], roundPrecision),
|
|
||||||
utils.DurationRoundBy(durations[int(0.99*durationsLenAsFloat)], roundPrecision),
|
|
||||||
})
|
|
||||||
t.AppendSeparator()
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(mergedResponses) > 1 {
|
|
||||||
t.AppendRow(table.Row{
|
|
||||||
"Total",
|
|
||||||
total.Count,
|
|
||||||
utils.DurationRoundBy(total.Min, roundPrecision),
|
|
||||||
utils.DurationRoundBy(total.Max, roundPrecision),
|
|
||||||
utils.DurationRoundBy(total.Sum/time.Duration(total.Count), roundPrecision), // Average
|
|
||||||
utils.DurationRoundBy(total.P90, roundPrecision),
|
|
||||||
utils.DurationRoundBy(total.P95, roundPrecision),
|
|
||||||
utils.DurationRoundBy(total.P99, roundPrecision),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
t.Render()
|
|
||||||
}
|
|
211
requests/run.go
211
requests/run.go
@@ -1,211 +0,0 @@
|
|||||||
package requests
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"strconv"
|
|
||||||
"sync"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/aykhans/dodo/config"
|
|
||||||
"github.com/aykhans/dodo/types"
|
|
||||||
"github.com/aykhans/dodo/utils"
|
|
||||||
"github.com/valyala/fasthttp"
|
|
||||||
)
|
|
||||||
|
|
||||||
// Run executes the main logic for processing requests based on the provided configuration.
|
|
||||||
// It initializes clients based on the request configuration and releases the dodos.
|
|
||||||
// If the context is canceled and no responses are collected, it returns an interrupt error.
|
|
||||||
//
|
|
||||||
// Parameters:
|
|
||||||
// - ctx: The context for managing request lifecycle and cancellation.
|
|
||||||
// - requestConfig: The configuration for the request, including timeout, proxies, and other settings.
|
|
||||||
func Run(ctx context.Context, requestConfig *config.RequestConfig) (Responses, error) {
|
|
||||||
ctx, cancel := context.WithCancel(ctx)
|
|
||||||
defer cancel()
|
|
||||||
|
|
||||||
clients := getClients(
|
|
||||||
ctx,
|
|
||||||
requestConfig.Timeout,
|
|
||||||
requestConfig.Proxies,
|
|
||||||
requestConfig.GetMaxConns(fasthttp.DefaultMaxConnsPerHost),
|
|
||||||
requestConfig.URL,
|
|
||||||
)
|
|
||||||
if clients == nil {
|
|
||||||
return nil, types.ErrInterrupt
|
|
||||||
}
|
|
||||||
|
|
||||||
if requestConfig.Duration > 0 {
|
|
||||||
time.AfterFunc(requestConfig.Duration, func() { cancel() })
|
|
||||||
}
|
|
||||||
|
|
||||||
responses := releaseDodos(ctx, requestConfig, clients)
|
|
||||||
if ctx.Err() != nil && len(responses) == 0 {
|
|
||||||
return nil, types.ErrInterrupt
|
|
||||||
}
|
|
||||||
|
|
||||||
return responses, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// releaseDodos sends requests concurrently using multiple dodos (goroutines) and returns the aggregated responses.
|
|
||||||
//
|
|
||||||
// The function performs the following steps:
|
|
||||||
// 1. Initializes wait groups and other necessary variables.
|
|
||||||
// 2. Starts a goroutine to stream progress updates.
|
|
||||||
// 3. Distributes the total request count among the dodos.
|
|
||||||
// 4. Starts a goroutine for each dodo to send requests concurrently.
|
|
||||||
// 5. Waits for all dodos to complete their requests.
|
|
||||||
// 6. Cancels the progress streaming context and waits for the progress goroutine to finish.
|
|
||||||
// 7. Flattens and returns the aggregated responses.
|
|
||||||
func releaseDodos(
|
|
||||||
ctx context.Context,
|
|
||||||
requestConfig *config.RequestConfig,
|
|
||||||
clients []*fasthttp.HostClient,
|
|
||||||
) Responses {
|
|
||||||
var (
|
|
||||||
wg sync.WaitGroup
|
|
||||||
streamWG sync.WaitGroup
|
|
||||||
requestCountPerDodo uint
|
|
||||||
dodosCount = requestConfig.GetValidDodosCountForRequests()
|
|
||||||
responses = make([][]*Response, dodosCount)
|
|
||||||
increase = make(chan int64, requestConfig.RequestCount)
|
|
||||||
)
|
|
||||||
|
|
||||||
wg.Add(int(dodosCount))
|
|
||||||
streamWG.Add(1)
|
|
||||||
streamCtx, streamCtxCancel := context.WithCancel(context.Background())
|
|
||||||
|
|
||||||
go streamProgress(streamCtx, &streamWG, requestConfig.RequestCount, "Dodos Working🔥", increase)
|
|
||||||
|
|
||||||
if requestConfig.RequestCount == 0 {
|
|
||||||
for i := range dodosCount {
|
|
||||||
go sendRequest(
|
|
||||||
ctx,
|
|
||||||
newRequest(*requestConfig, clients, int64(i)),
|
|
||||||
requestConfig.Timeout,
|
|
||||||
&responses[i],
|
|
||||||
increase,
|
|
||||||
&wg,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
for i := range dodosCount {
|
|
||||||
if i+1 == dodosCount {
|
|
||||||
requestCountPerDodo = requestConfig.RequestCount - (i * requestConfig.RequestCount / dodosCount)
|
|
||||||
} else {
|
|
||||||
requestCountPerDodo = ((i + 1) * requestConfig.RequestCount / dodosCount) -
|
|
||||||
(i * requestConfig.RequestCount / dodosCount)
|
|
||||||
}
|
|
||||||
|
|
||||||
go sendRequestByCount(
|
|
||||||
ctx,
|
|
||||||
newRequest(*requestConfig, clients, int64(i)),
|
|
||||||
requestConfig.Timeout,
|
|
||||||
requestCountPerDodo,
|
|
||||||
&responses[i],
|
|
||||||
increase,
|
|
||||||
&wg,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
wg.Wait()
|
|
||||||
streamCtxCancel()
|
|
||||||
streamWG.Wait()
|
|
||||||
return utils.Flatten(responses)
|
|
||||||
}
|
|
||||||
|
|
||||||
// sendRequestByCount sends a specified number of HTTP requests concurrently with a given timeout.
|
|
||||||
// It appends the responses to the provided responseData slice and sends the count of completed requests
|
|
||||||
// to the increase channel. The function terminates early if the context is canceled or if a custom
|
|
||||||
// interrupt error is encountered.
|
|
||||||
func sendRequestByCount(
|
|
||||||
ctx context.Context,
|
|
||||||
request *Request,
|
|
||||||
timeout time.Duration,
|
|
||||||
requestCount uint,
|
|
||||||
responseData *[]*Response,
|
|
||||||
increase chan<- int64,
|
|
||||||
wg *sync.WaitGroup,
|
|
||||||
) {
|
|
||||||
defer wg.Done()
|
|
||||||
|
|
||||||
for range requestCount {
|
|
||||||
if ctx.Err() != nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
func() {
|
|
||||||
startTime := time.Now()
|
|
||||||
response, err := request.Send(ctx, timeout)
|
|
||||||
completedTime := time.Since(startTime)
|
|
||||||
if response != nil {
|
|
||||||
defer fasthttp.ReleaseResponse(response)
|
|
||||||
}
|
|
||||||
|
|
||||||
if err != nil {
|
|
||||||
if err == types.ErrInterrupt {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
*responseData = append(*responseData, &Response{
|
|
||||||
Response: err.Error(),
|
|
||||||
Time: completedTime,
|
|
||||||
})
|
|
||||||
increase <- 1
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
*responseData = append(*responseData, &Response{
|
|
||||||
Response: strconv.Itoa(response.StatusCode()),
|
|
||||||
Time: completedTime,
|
|
||||||
})
|
|
||||||
increase <- 1
|
|
||||||
}()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// sendRequest continuously sends HTTP requests until the context is canceled.
|
|
||||||
// It records the response status code or error message along with the response time,
|
|
||||||
// and signals each completed request through the increase channel.
|
|
||||||
func sendRequest(
|
|
||||||
ctx context.Context,
|
|
||||||
request *Request,
|
|
||||||
timeout time.Duration,
|
|
||||||
responseData *[]*Response,
|
|
||||||
increase chan<- int64,
|
|
||||||
wg *sync.WaitGroup,
|
|
||||||
) {
|
|
||||||
defer wg.Done()
|
|
||||||
|
|
||||||
for {
|
|
||||||
if ctx.Err() != nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
func() {
|
|
||||||
startTime := time.Now()
|
|
||||||
response, err := request.Send(ctx, timeout)
|
|
||||||
completedTime := time.Since(startTime)
|
|
||||||
if response != nil {
|
|
||||||
defer fasthttp.ReleaseResponse(response)
|
|
||||||
}
|
|
||||||
|
|
||||||
if err != nil {
|
|
||||||
if err == types.ErrInterrupt {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
*responseData = append(*responseData, &Response{
|
|
||||||
Response: err.Error(),
|
|
||||||
Time: completedTime,
|
|
||||||
})
|
|
||||||
increase <- 1
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
*responseData = append(*responseData, &Response{
|
|
||||||
Response: strconv.Itoa(response.StatusCode()),
|
|
||||||
Time: completedTime,
|
|
||||||
})
|
|
||||||
increase <- 1
|
|
||||||
}()
|
|
||||||
}
|
|
||||||
}
|
|
@@ -1,94 +0,0 @@
|
|||||||
package types
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bytes"
|
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
|
||||||
|
|
||||||
"github.com/jedib0t/go-pretty/v6/text"
|
|
||||||
)
|
|
||||||
|
|
||||||
type Body []string
|
|
||||||
|
|
||||||
func (body Body) String() string {
|
|
||||||
var buffer bytes.Buffer
|
|
||||||
if len(body) == 0 {
|
|
||||||
return buffer.String()
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(body) == 1 {
|
|
||||||
buffer.WriteString(body[0])
|
|
||||||
return buffer.String()
|
|
||||||
}
|
|
||||||
|
|
||||||
buffer.WriteString(text.FgBlue.Sprint("Random") + "[\n")
|
|
||||||
|
|
||||||
indent := " "
|
|
||||||
|
|
||||||
displayLimit := 5
|
|
||||||
|
|
||||||
for i, item := range body[:min(len(body), displayLimit)] {
|
|
||||||
if i > 0 {
|
|
||||||
buffer.WriteString(",\n")
|
|
||||||
}
|
|
||||||
|
|
||||||
buffer.WriteString(indent + item)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add remaining count if there are more items
|
|
||||||
if remainingValues := len(body) - displayLimit; remainingValues > 0 {
|
|
||||||
buffer.WriteString(",\n" + indent + text.FgGreen.Sprintf("+%d bodies", remainingValues))
|
|
||||||
}
|
|
||||||
|
|
||||||
buffer.WriteString("\n]")
|
|
||||||
return buffer.String()
|
|
||||||
}
|
|
||||||
|
|
||||||
func (body *Body) UnmarshalJSON(b []byte) error {
|
|
||||||
var data any
|
|
||||||
if err := json.Unmarshal(b, &data); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
switch v := data.(type) {
|
|
||||||
case string:
|
|
||||||
*body = []string{v}
|
|
||||||
case []any:
|
|
||||||
var slice []string
|
|
||||||
for _, item := range v {
|
|
||||||
slice = append(slice, fmt.Sprintf("%v", item))
|
|
||||||
}
|
|
||||||
*body = slice
|
|
||||||
default:
|
|
||||||
return fmt.Errorf("invalid type for Body: %T (should be string or []string)", v)
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (body *Body) UnmarshalYAML(unmarshal func(interface{}) error) error {
|
|
||||||
var data any
|
|
||||||
if err := unmarshal(&data); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
switch v := data.(type) {
|
|
||||||
case string:
|
|
||||||
*body = []string{v}
|
|
||||||
case []any:
|
|
||||||
var slice []string
|
|
||||||
for _, item := range v {
|
|
||||||
slice = append(slice, fmt.Sprintf("%v", item))
|
|
||||||
}
|
|
||||||
*body = slice
|
|
||||||
default:
|
|
||||||
return fmt.Errorf("invalid type for Body: %T (should be string or []string)", v)
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (body *Body) Set(value string) error {
|
|
||||||
*body = append(*body, value)
|
|
||||||
return nil
|
|
||||||
}
|
|
@@ -1,32 +0,0 @@
|
|||||||
package types
|
|
||||||
|
|
||||||
import "strings"
|
|
||||||
|
|
||||||
type FileLocationType int
|
|
||||||
|
|
||||||
const (
|
|
||||||
FileLocationTypeLocal FileLocationType = iota
|
|
||||||
FileLocationTypeRemoteHTTP
|
|
||||||
)
|
|
||||||
|
|
||||||
type ConfigFile string
|
|
||||||
|
|
||||||
func (configFile ConfigFile) String() string {
|
|
||||||
return string(configFile)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (configFile ConfigFile) LocationType() FileLocationType {
|
|
||||||
if strings.HasPrefix(string(configFile), "http://") || strings.HasPrefix(string(configFile), "https://") {
|
|
||||||
return FileLocationTypeRemoteHTTP
|
|
||||||
}
|
|
||||||
return FileLocationTypeLocal
|
|
||||||
}
|
|
||||||
|
|
||||||
func (configFile ConfigFile) Extension() string {
|
|
||||||
i := strings.LastIndex(configFile.String(), ".")
|
|
||||||
if i == -1 {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
|
|
||||||
return configFile.String()[i+1:]
|
|
||||||
}
|
|
139
types/cookies.go
139
types/cookies.go
@@ -1,139 +0,0 @@
|
|||||||
package types
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bytes"
|
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
"github.com/jedib0t/go-pretty/v6/text"
|
|
||||||
)
|
|
||||||
|
|
||||||
type Cookies []KeyValue[string, []string]
|
|
||||||
|
|
||||||
func (cookies Cookies) String() string {
|
|
||||||
var buffer bytes.Buffer
|
|
||||||
if len(cookies) == 0 {
|
|
||||||
return buffer.String()
|
|
||||||
}
|
|
||||||
|
|
||||||
indent := " "
|
|
||||||
|
|
||||||
displayLimit := 3
|
|
||||||
|
|
||||||
for i, item := range cookies[:min(len(cookies), displayLimit)] {
|
|
||||||
if i > 0 {
|
|
||||||
buffer.WriteString(",\n")
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(item.Value) == 1 {
|
|
||||||
buffer.WriteString(item.Key + ": " + item.Value[0])
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
buffer.WriteString(item.Key + ": " + text.FgBlue.Sprint("Random") + "[\n")
|
|
||||||
|
|
||||||
for ii, v := range item.Value[:min(len(item.Value), displayLimit)] {
|
|
||||||
if ii == len(item.Value)-1 {
|
|
||||||
buffer.WriteString(indent + v + "\n")
|
|
||||||
} else {
|
|
||||||
buffer.WriteString(indent + v + ",\n")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add remaining values count if needed
|
|
||||||
if remainingValues := len(item.Value) - displayLimit; remainingValues > 0 {
|
|
||||||
buffer.WriteString(indent + text.FgGreen.Sprintf("+%d values", remainingValues) + "\n")
|
|
||||||
}
|
|
||||||
|
|
||||||
buffer.WriteString("]")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add remaining key-value pairs count if needed
|
|
||||||
if remainingPairs := len(cookies) - displayLimit; remainingPairs > 0 {
|
|
||||||
buffer.WriteString(",\n" + text.FgGreen.Sprintf("+%d cookies", remainingPairs))
|
|
||||||
}
|
|
||||||
|
|
||||||
return buffer.String()
|
|
||||||
}
|
|
||||||
|
|
||||||
func (cookies *Cookies) AppendByKey(key, value string) {
|
|
||||||
if item := cookies.GetValue(key); item != nil {
|
|
||||||
*item = append(*item, value)
|
|
||||||
} else {
|
|
||||||
*cookies = append(*cookies, KeyValue[string, []string]{Key: key, Value: []string{value}})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (cookies Cookies) GetValue(key string) *[]string {
|
|
||||||
for i := range cookies {
|
|
||||||
if cookies[i].Key == key {
|
|
||||||
return &cookies[i].Value
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (cookies *Cookies) UnmarshalJSON(b []byte) error {
|
|
||||||
var data []map[string]any
|
|
||||||
if err := json.Unmarshal(b, &data); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, item := range data {
|
|
||||||
for key, value := range item {
|
|
||||||
switch parsedValue := value.(type) {
|
|
||||||
case string:
|
|
||||||
*cookies = append(*cookies, KeyValue[string, []string]{Key: key, Value: []string{parsedValue}})
|
|
||||||
case []any:
|
|
||||||
parsedStr := make([]string, len(parsedValue))
|
|
||||||
for i, item := range parsedValue {
|
|
||||||
parsedStr[i] = fmt.Sprintf("%v", item)
|
|
||||||
}
|
|
||||||
*cookies = append(*cookies, KeyValue[string, []string]{Key: key, Value: parsedStr})
|
|
||||||
default:
|
|
||||||
return fmt.Errorf("unsupported type for cookies expected string or []string, got %T", parsedValue)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (cookies *Cookies) UnmarshalYAML(unmarshal func(interface{}) error) error {
|
|
||||||
var raw []map[string]any
|
|
||||||
if err := unmarshal(&raw); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, param := range raw {
|
|
||||||
for key, value := range param {
|
|
||||||
switch parsed := value.(type) {
|
|
||||||
case string:
|
|
||||||
*cookies = append(*cookies, KeyValue[string, []string]{Key: key, Value: []string{parsed}})
|
|
||||||
case []any:
|
|
||||||
var values []string
|
|
||||||
for _, v := range parsed {
|
|
||||||
if str, ok := v.(string); ok {
|
|
||||||
values = append(values, str)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
*cookies = append(*cookies, KeyValue[string, []string]{Key: key, Value: values})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (cookies *Cookies) Set(value string) error {
|
|
||||||
parts := strings.SplitN(value, "=", 2)
|
|
||||||
switch len(parts) {
|
|
||||||
case 0:
|
|
||||||
cookies.AppendByKey("", "")
|
|
||||||
case 1:
|
|
||||||
cookies.AppendByKey(parts[0], "")
|
|
||||||
case 2:
|
|
||||||
cookies.AppendByKey(parts[0], parts[1])
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
@@ -1,57 +0,0 @@
|
|||||||
package types
|
|
||||||
|
|
||||||
import (
|
|
||||||
"encoding/json"
|
|
||||||
"errors"
|
|
||||||
"time"
|
|
||||||
)
|
|
||||||
|
|
||||||
type Duration struct {
|
|
||||||
time.Duration
|
|
||||||
}
|
|
||||||
|
|
||||||
func (duration *Duration) UnmarshalJSON(b []byte) error {
|
|
||||||
var v any
|
|
||||||
if err := json.Unmarshal(b, &v); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
switch value := v.(type) {
|
|
||||||
case float64:
|
|
||||||
duration.Duration = time.Duration(value)
|
|
||||||
return nil
|
|
||||||
case string:
|
|
||||||
var err error
|
|
||||||
duration.Duration, err = time.ParseDuration(value)
|
|
||||||
if err != nil {
|
|
||||||
return errors.New("Duration is invalid (e.g. 400ms, 1s, 5m, 1h)")
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
default:
|
|
||||||
return errors.New("Duration is invalid (e.g. 400ms, 1s, 5m, 1h)")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (duration Duration) MarshalJSON() ([]byte, error) {
|
|
||||||
return json.Marshal(duration.String())
|
|
||||||
}
|
|
||||||
|
|
||||||
func (duration *Duration) UnmarshalYAML(unmarshal func(interface{}) error) error {
|
|
||||||
var v any
|
|
||||||
if err := unmarshal(&v); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
switch value := v.(type) {
|
|
||||||
case float64:
|
|
||||||
duration.Duration = time.Duration(value)
|
|
||||||
return nil
|
|
||||||
case string:
|
|
||||||
var err error
|
|
||||||
duration.Duration, err = time.ParseDuration(value)
|
|
||||||
if err != nil {
|
|
||||||
return errors.New("Duration is invalid (e.g. 400ms, 1s, 5m, 1h)")
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
default:
|
|
||||||
return errors.New("Duration is invalid (e.g. 400ms, 1s, 5m, 1h)")
|
|
||||||
}
|
|
||||||
}
|
|
@@ -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))
|
|
||||||
}
|
|
@@ -1,10 +0,0 @@
|
|||||||
package types
|
|
||||||
|
|
||||||
import (
|
|
||||||
"errors"
|
|
||||||
)
|
|
||||||
|
|
||||||
var (
|
|
||||||
ErrInterrupt = errors.New("interrupted")
|
|
||||||
ErrTimeout = errors.New("timeout")
|
|
||||||
)
|
|
156
types/headers.go
156
types/headers.go
@@ -1,156 +0,0 @@
|
|||||||
package types
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bytes"
|
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
"github.com/jedib0t/go-pretty/v6/text"
|
|
||||||
)
|
|
||||||
|
|
||||||
type Headers []KeyValue[string, []string]
|
|
||||||
|
|
||||||
func (headers Headers) String() string {
|
|
||||||
var buffer bytes.Buffer
|
|
||||||
if len(headers) == 0 {
|
|
||||||
return buffer.String()
|
|
||||||
}
|
|
||||||
|
|
||||||
indent := " "
|
|
||||||
|
|
||||||
displayLimit := 3
|
|
||||||
|
|
||||||
for i, item := range headers[:min(len(headers), displayLimit)] {
|
|
||||||
if i > 0 {
|
|
||||||
buffer.WriteString(",\n")
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(item.Value) == 1 {
|
|
||||||
buffer.WriteString(item.Key + ": " + item.Value[0])
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
buffer.WriteString(item.Key + ": " + text.FgBlue.Sprint("Random") + "[\n")
|
|
||||||
|
|
||||||
for ii, v := range item.Value[:min(len(item.Value), displayLimit)] {
|
|
||||||
if ii == len(item.Value)-1 {
|
|
||||||
buffer.WriteString(indent + v + "\n")
|
|
||||||
} else {
|
|
||||||
buffer.WriteString(indent + v + ",\n")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add remaining values count if needed
|
|
||||||
if remainingValues := len(item.Value) - displayLimit; remainingValues > 0 {
|
|
||||||
buffer.WriteString(indent + text.FgGreen.Sprintf("+%d values", remainingValues) + "\n")
|
|
||||||
}
|
|
||||||
|
|
||||||
buffer.WriteString("]")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add remaining key-value pairs count if needed
|
|
||||||
if remainingPairs := len(headers) - displayLimit; remainingPairs > 0 {
|
|
||||||
buffer.WriteString(",\n" + text.FgGreen.Sprintf("+%d headers", remainingPairs))
|
|
||||||
}
|
|
||||||
|
|
||||||
return buffer.String()
|
|
||||||
}
|
|
||||||
|
|
||||||
func (headers *Headers) AppendByKey(key, value string) {
|
|
||||||
if item := headers.GetValue(key); item != nil {
|
|
||||||
*item = append(*item, value)
|
|
||||||
} else {
|
|
||||||
*headers = append(*headers, KeyValue[string, []string]{Key: key, Value: []string{value}})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (headers Headers) GetValue(key string) *[]string {
|
|
||||||
for i := range headers {
|
|
||||||
if headers[i].Key == key {
|
|
||||||
return &headers[i].Value
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (headers Headers) Has(key string) bool {
|
|
||||||
for i := range headers {
|
|
||||||
if headers[i].Key == key {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
func (headers *Headers) UnmarshalJSON(b []byte) error {
|
|
||||||
var data []map[string]any
|
|
||||||
if err := json.Unmarshal(b, &data); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, item := range data {
|
|
||||||
for key, value := range item {
|
|
||||||
switch parsedValue := value.(type) {
|
|
||||||
case string:
|
|
||||||
*headers = append(*headers, KeyValue[string, []string]{Key: key, Value: []string{parsedValue}})
|
|
||||||
case []any:
|
|
||||||
parsedStr := make([]string, len(parsedValue))
|
|
||||||
for i, item := range parsedValue {
|
|
||||||
parsedStr[i] = fmt.Sprintf("%v", item)
|
|
||||||
}
|
|
||||||
*headers = append(*headers, KeyValue[string, []string]{Key: key, Value: parsedStr})
|
|
||||||
default:
|
|
||||||
return fmt.Errorf("unsupported type for headers expected string or []string, got %T", parsedValue)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (headers *Headers) UnmarshalYAML(unmarshal func(interface{}) error) error {
|
|
||||||
var raw []map[string]any
|
|
||||||
if err := unmarshal(&raw); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, param := range raw {
|
|
||||||
for key, value := range param {
|
|
||||||
switch parsed := value.(type) {
|
|
||||||
case string:
|
|
||||||
*headers = append(*headers, KeyValue[string, []string]{Key: key, Value: []string{parsed}})
|
|
||||||
case []any:
|
|
||||||
var values []string
|
|
||||||
for _, v := range parsed {
|
|
||||||
if str, ok := v.(string); ok {
|
|
||||||
values = append(values, str)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
*headers = append(*headers, KeyValue[string, []string]{Key: key, Value: values})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (headers *Headers) Set(value string) error {
|
|
||||||
parts := strings.SplitN(value, ":", 2)
|
|
||||||
switch len(parts) {
|
|
||||||
case 0:
|
|
||||||
headers.AppendByKey("", "")
|
|
||||||
case 1:
|
|
||||||
headers.AppendByKey(parts[0], "")
|
|
||||||
case 2:
|
|
||||||
headers.AppendByKey(parts[0], parts[1])
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (headers *Headers) SetIfNotExists(key string, value string) bool {
|
|
||||||
if headers.Has(key) {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
*headers = append(*headers, KeyValue[string, []string]{Key: key, Value: []string{value}})
|
|
||||||
return true
|
|
||||||
}
|
|
139
types/params.go
139
types/params.go
@@ -1,139 +0,0 @@
|
|||||||
package types
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bytes"
|
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
"github.com/jedib0t/go-pretty/v6/text"
|
|
||||||
)
|
|
||||||
|
|
||||||
type Params []KeyValue[string, []string]
|
|
||||||
|
|
||||||
func (params Params) String() string {
|
|
||||||
var buffer bytes.Buffer
|
|
||||||
if len(params) == 0 {
|
|
||||||
return buffer.String()
|
|
||||||
}
|
|
||||||
|
|
||||||
indent := " "
|
|
||||||
|
|
||||||
displayLimit := 3
|
|
||||||
|
|
||||||
for i, item := range params[:min(len(params), displayLimit)] {
|
|
||||||
if i > 0 {
|
|
||||||
buffer.WriteString(",\n")
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(item.Value) == 1 {
|
|
||||||
buffer.WriteString(item.Key + ": " + item.Value[0])
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
buffer.WriteString(item.Key + ": " + text.FgBlue.Sprint("Random") + "[\n")
|
|
||||||
|
|
||||||
for ii, v := range item.Value[:min(len(item.Value), displayLimit)] {
|
|
||||||
if ii == len(item.Value)-1 {
|
|
||||||
buffer.WriteString(indent + v + "\n")
|
|
||||||
} else {
|
|
||||||
buffer.WriteString(indent + v + ",\n")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add remaining values count if needed
|
|
||||||
if remainingValues := len(item.Value) - displayLimit; remainingValues > 0 {
|
|
||||||
buffer.WriteString(indent + text.FgGreen.Sprintf("+%d values", remainingValues) + "\n")
|
|
||||||
}
|
|
||||||
|
|
||||||
buffer.WriteString("]")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add remaining key-value pairs count if needed
|
|
||||||
if remainingPairs := len(params) - displayLimit; remainingPairs > 0 {
|
|
||||||
buffer.WriteString(",\n" + text.FgGreen.Sprintf("+%d params", remainingPairs))
|
|
||||||
}
|
|
||||||
|
|
||||||
return buffer.String()
|
|
||||||
}
|
|
||||||
|
|
||||||
func (params *Params) AppendByKey(key, value string) {
|
|
||||||
if item := params.GetValue(key); item != nil {
|
|
||||||
*item = append(*item, value)
|
|
||||||
} else {
|
|
||||||
*params = append(*params, KeyValue[string, []string]{Key: key, Value: []string{value}})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (params Params) GetValue(key string) *[]string {
|
|
||||||
for i := range params {
|
|
||||||
if params[i].Key == key {
|
|
||||||
return ¶ms[i].Value
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (params *Params) UnmarshalJSON(b []byte) error {
|
|
||||||
var data []map[string]any
|
|
||||||
if err := json.Unmarshal(b, &data); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, item := range data {
|
|
||||||
for key, value := range item {
|
|
||||||
switch parsedValue := value.(type) {
|
|
||||||
case string:
|
|
||||||
*params = append(*params, KeyValue[string, []string]{Key: key, Value: []string{parsedValue}})
|
|
||||||
case []any:
|
|
||||||
parsedStr := make([]string, len(parsedValue))
|
|
||||||
for i, item := range parsedValue {
|
|
||||||
parsedStr[i] = fmt.Sprintf("%v", item)
|
|
||||||
}
|
|
||||||
*params = append(*params, KeyValue[string, []string]{Key: key, Value: parsedStr})
|
|
||||||
default:
|
|
||||||
return fmt.Errorf("unsupported type for params expected string or []string, got %T", parsedValue)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (params *Params) UnmarshalYAML(unmarshal func(interface{}) error) error {
|
|
||||||
var raw []map[string]any
|
|
||||||
if err := unmarshal(&raw); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, param := range raw {
|
|
||||||
for key, value := range param {
|
|
||||||
switch parsed := value.(type) {
|
|
||||||
case string:
|
|
||||||
*params = append(*params, KeyValue[string, []string]{Key: key, Value: []string{parsed}})
|
|
||||||
case []any:
|
|
||||||
var values []string
|
|
||||||
for _, v := range parsed {
|
|
||||||
if str, ok := v.(string); ok {
|
|
||||||
values = append(values, str)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
*params = append(*params, KeyValue[string, []string]{Key: key, Value: values})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (params *Params) Set(value string) error {
|
|
||||||
parts := strings.SplitN(value, "=", 2)
|
|
||||||
switch len(parts) {
|
|
||||||
case 0:
|
|
||||||
params.AppendByKey("", "")
|
|
||||||
case 1:
|
|
||||||
params.AppendByKey(parts[0], "")
|
|
||||||
case 2:
|
|
||||||
params.AppendByKey(parts[0], parts[1])
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
116
types/proxies.go
116
types/proxies.go
@@ -1,116 +0,0 @@
|
|||||||
package types
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bytes"
|
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
|
||||||
"net/url"
|
|
||||||
|
|
||||||
"github.com/jedib0t/go-pretty/v6/text"
|
|
||||||
)
|
|
||||||
|
|
||||||
type Proxies []url.URL
|
|
||||||
|
|
||||||
func (proxies Proxies) String() string {
|
|
||||||
var buffer bytes.Buffer
|
|
||||||
if len(proxies) == 0 {
|
|
||||||
return buffer.String()
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(proxies) == 1 {
|
|
||||||
buffer.WriteString(proxies[0].String())
|
|
||||||
return buffer.String()
|
|
||||||
}
|
|
||||||
|
|
||||||
buffer.WriteString(text.FgBlue.Sprint("Random") + "[\n")
|
|
||||||
|
|
||||||
indent := " "
|
|
||||||
|
|
||||||
displayLimit := 5
|
|
||||||
|
|
||||||
for i, item := range proxies[:min(len(proxies), displayLimit)] {
|
|
||||||
if i > 0 {
|
|
||||||
buffer.WriteString(",\n")
|
|
||||||
}
|
|
||||||
|
|
||||||
buffer.WriteString(indent + item.String())
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add remaining count if there are more items
|
|
||||||
if remainingValues := len(proxies) - displayLimit; remainingValues > 0 {
|
|
||||||
buffer.WriteString(",\n" + indent + text.FgGreen.Sprintf("+%d proxies", remainingValues))
|
|
||||||
}
|
|
||||||
|
|
||||||
buffer.WriteString("\n]")
|
|
||||||
return buffer.String()
|
|
||||||
}
|
|
||||||
|
|
||||||
func (proxies *Proxies) UnmarshalJSON(b []byte) error {
|
|
||||||
var data any
|
|
||||||
if err := json.Unmarshal(b, &data); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
switch v := data.(type) {
|
|
||||||
case string:
|
|
||||||
parsed, err := url.Parse(v)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
*proxies = []url.URL{*parsed}
|
|
||||||
case []any:
|
|
||||||
var urls []url.URL
|
|
||||||
for _, item := range v {
|
|
||||||
url, err := url.Parse(item.(string))
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
urls = append(urls, *url)
|
|
||||||
}
|
|
||||||
*proxies = urls
|
|
||||||
default:
|
|
||||||
return fmt.Errorf("invalid type for Body: %T (should be URL or []URL)", v)
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (proxies *Proxies) UnmarshalYAML(unmarshal func(interface{}) error) error {
|
|
||||||
var data any
|
|
||||||
if err := unmarshal(&data); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
switch v := data.(type) {
|
|
||||||
case string:
|
|
||||||
parsed, err := url.Parse(v)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
*proxies = []url.URL{*parsed}
|
|
||||||
case []any:
|
|
||||||
var urls []url.URL
|
|
||||||
for _, item := range v {
|
|
||||||
url, err := url.Parse(item.(string))
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
urls = append(urls, *url)
|
|
||||||
}
|
|
||||||
*proxies = urls
|
|
||||||
default:
|
|
||||||
return fmt.Errorf("invalid type for Body: %T (should be URL or []URL)", v)
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (proxies *Proxies) Set(value string) error {
|
|
||||||
parsedURL, err := url.Parse(value)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
*proxies = append(*proxies, *parsedURL)
|
|
||||||
return nil
|
|
||||||
}
|
|
@@ -1,59 +0,0 @@
|
|||||||
package types
|
|
||||||
|
|
||||||
import (
|
|
||||||
"encoding/json"
|
|
||||||
"errors"
|
|
||||||
"net/url"
|
|
||||||
)
|
|
||||||
|
|
||||||
type RequestURL struct {
|
|
||||||
url.URL
|
|
||||||
}
|
|
||||||
|
|
||||||
func (requestURL *RequestURL) UnmarshalJSON(data []byte) error {
|
|
||||||
var urlStr string
|
|
||||||
if err := json.Unmarshal(data, &urlStr); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
parsedURL, err := url.Parse(urlStr)
|
|
||||||
if err != nil {
|
|
||||||
return errors.New("request URL is invalid")
|
|
||||||
}
|
|
||||||
|
|
||||||
requestURL.URL = *parsedURL
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (requestURL *RequestURL) UnmarshalYAML(unmarshal func(interface{}) error) error {
|
|
||||||
var urlStr string
|
|
||||||
if err := unmarshal(&urlStr); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
parsedURL, err := url.Parse(urlStr)
|
|
||||||
if err != nil {
|
|
||||||
return errors.New("request URL is invalid")
|
|
||||||
}
|
|
||||||
|
|
||||||
requestURL.URL = *parsedURL
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (requestURL RequestURL) MarshalJSON() ([]byte, error) {
|
|
||||||
return json.Marshal(requestURL.URL.String())
|
|
||||||
}
|
|
||||||
|
|
||||||
func (requestURL RequestURL) String() string {
|
|
||||||
return requestURL.URL.String()
|
|
||||||
}
|
|
||||||
|
|
||||||
func (requestURL *RequestURL) Set(value string) error {
|
|
||||||
parsedURL, err := url.Parse(value)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
requestURL.URL = *parsedURL
|
|
||||||
return nil
|
|
||||||
}
|
|
@@ -1,57 +0,0 @@
|
|||||||
package types
|
|
||||||
|
|
||||||
import (
|
|
||||||
"encoding/json"
|
|
||||||
"errors"
|
|
||||||
"time"
|
|
||||||
)
|
|
||||||
|
|
||||||
type Timeout struct {
|
|
||||||
time.Duration
|
|
||||||
}
|
|
||||||
|
|
||||||
func (timeout *Timeout) UnmarshalJSON(b []byte) error {
|
|
||||||
var v any
|
|
||||||
if err := json.Unmarshal(b, &v); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
switch value := v.(type) {
|
|
||||||
case float64:
|
|
||||||
timeout.Duration = time.Duration(value)
|
|
||||||
return nil
|
|
||||||
case string:
|
|
||||||
var err error
|
|
||||||
timeout.Duration, err = time.ParseDuration(value)
|
|
||||||
if err != nil {
|
|
||||||
return errors.New("Timeout is invalid (e.g. 400ms, 1s, 5m, 1h)")
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
default:
|
|
||||||
return errors.New("Timeout is invalid (e.g. 400ms, 1s, 5m, 1h)")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (timeout Timeout) MarshalJSON() ([]byte, error) {
|
|
||||||
return json.Marshal(timeout.String())
|
|
||||||
}
|
|
||||||
|
|
||||||
func (timeout *Timeout) UnmarshalYAML(unmarshal func(interface{}) error) error {
|
|
||||||
var v any
|
|
||||||
if err := unmarshal(&v); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
switch value := v.(type) {
|
|
||||||
case float64:
|
|
||||||
timeout.Duration = time.Duration(value)
|
|
||||||
return nil
|
|
||||||
case string:
|
|
||||||
var err error
|
|
||||||
timeout.Duration, err = time.ParseDuration(value)
|
|
||||||
if err != nil {
|
|
||||||
return errors.New("Timeout is invalid (e.g. 400ms, 1s, 5m, 1h)")
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
default:
|
|
||||||
return errors.New("Timeout is invalid (e.g. 400ms, 1s, 5m, 1h)")
|
|
||||||
}
|
|
||||||
}
|
|
@@ -1,10 +0,0 @@
|
|||||||
package utils
|
|
||||||
|
|
||||||
func IsNilOrZero[T comparable](value *T) bool {
|
|
||||||
if value == nil {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
var zero T
|
|
||||||
return *value == zero
|
|
||||||
}
|
|
21
utils/int.go
21
utils/int.go
@@ -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
|
|
||||||
}
|
|
@@ -1,24 +0,0 @@
|
|||||||
package utils
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"os"
|
|
||||||
|
|
||||||
"github.com/jedib0t/go-pretty/v6/text"
|
|
||||||
)
|
|
||||||
|
|
||||||
func PrintErr(err error) {
|
|
||||||
fmt.Fprintln(os.Stderr, text.FgRed.Sprint(err.Error()))
|
|
||||||
}
|
|
||||||
|
|
||||||
func PrintErrAndExit(err error) {
|
|
||||||
if err != nil {
|
|
||||||
PrintErr(err)
|
|
||||||
os.Exit(1)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func PrintAndExit(message string) {
|
|
||||||
fmt.Println(message)
|
|
||||||
os.Exit(0)
|
|
||||||
}
|
|
@@ -1,41 +0,0 @@
|
|||||||
package utils
|
|
||||||
|
|
||||||
import "math/rand"
|
|
||||||
|
|
||||||
func Flatten[T any](nested [][]*T) []*T {
|
|
||||||
flattened := make([]*T, 0)
|
|
||||||
for _, n := range nested {
|
|
||||||
flattened = append(flattened, n...)
|
|
||||||
}
|
|
||||||
return flattened
|
|
||||||
}
|
|
||||||
|
|
||||||
// RandomValueCycle returns a function that cycles through the provided slice of values
|
|
||||||
// in a random order. Each call to the returned function will yield a value from the slice.
|
|
||||||
// The order of values is determined by the provided random number generator.
|
|
||||||
//
|
|
||||||
// The returned function will cycle through the values in a random order until all values
|
|
||||||
// have been returned at least once. After all values have been returned, the function will
|
|
||||||
// reset and start cycling through the values in a random order again.
|
|
||||||
// The returned function isn't thread-safe and should be used in a single-threaded context.
|
|
||||||
func RandomValueCycle[Value any](values []Value, localRand *rand.Rand) func() Value {
|
|
||||||
var (
|
|
||||||
clientsCount = len(values)
|
|
||||||
currentIndex = localRand.Intn(clientsCount)
|
|
||||||
stopIndex = currentIndex
|
|
||||||
)
|
|
||||||
|
|
||||||
return func() Value {
|
|
||||||
client := values[currentIndex]
|
|
||||||
currentIndex++
|
|
||||||
if currentIndex == clientsCount {
|
|
||||||
currentIndex = 0
|
|
||||||
}
|
|
||||||
if currentIndex == stopIndex {
|
|
||||||
currentIndex = localRand.Intn(clientsCount)
|
|
||||||
stopIndex = currentIndex
|
|
||||||
}
|
|
||||||
|
|
||||||
return client
|
|
||||||
}
|
|
||||||
}
|
|
@@ -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
|
|
||||||
}
|
|
Reference in New Issue
Block a user