diff --git a/.dockerignore b/.dockerignore deleted file mode 100644 index 4da4366..0000000 --- a/.dockerignore +++ /dev/null @@ -1,11 +0,0 @@ -.github -assets -binaries -dodo -.git -.gitignore -.golangci.yml -README.md -LICENSE -config.json -build.sh \ No newline at end of file diff --git a/.golangci.yml b/.golangci.yml index 5bc019a..347957d 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -24,6 +24,72 @@ linters: - unconvert - unused - whitespace + - bidichk + - bodyclose + - containedctx + - contextcheck + - copyloopvar + - decorder + - dogsled + - dupword + - durationcheck + - embeddedstructfieldcheck + - errchkjson + - errorlint + - exhaustive + - exptostd + - fatcontext + - forcetypeassert + - funcorder + - ginkgolinter + - gocheckcompilerdirectives + - gochecknoinits + - gochecksumtype + - goconst + - gocritic + - gocyclo + - godox + - goheader + - gomoddirectives + - gosec + - gosmopolitan + - grouper + - iface + - importas + - inamedparam + - interfacebloat + - intrange + - ireturn + - loggercheck + - makezero + - mirror + - musttag + - nilerr + - nilnesserr + - nilnil + - noctx + - nonamedreturns + - nosprintfhostport + - perfsprint + - predeclared + - promlinter + - protogetter + - sloglint + - spancheck + - sqlclosecheck + - tagalign + - tagliatelle + - testableexamples + - testifylint + - thelper + - tparallel + - unparam + - usestdlibvars + - usetesting + - varnamelen + - wastedassign + - wrapcheck + - zerologlint settings: staticcheck: @@ -31,3 +97,39 @@ linters: - "all" - "-S1002" - "-ST1000" + varnamelen: + ignore-decls: + - i int + + exclusions: + rules: + - path: _test\.go$ + linters: + - errorlint + - forcetypeassert + - perfsprint + - errcheck + - gosec + + - path: _test\.go$ + linters: + - staticcheck + text: "SA5011" + +formatters: + enable: + - gofmt + + settings: + gofmt: + # Simplify code: gofmt with `-s` option. + # Default: true + simplify: false + # Apply the rewrite rules to the source before reformatting. + # https://pkg.go.dev/cmd/gofmt + # Default: [] + rewrite-rules: + - pattern: "interface{}" + replacement: "any" + - pattern: "a[b:len(a)]" + replacement: "a[b:]" diff --git a/EXAMPLES.md b/EXAMPLES.md deleted file mode 100644 index d021603..0000000 --- a/EXAMPLES.md +++ /dev/null @@ -1,934 +0,0 @@ -# Dodo Usage Examples - -This document provides comprehensive examples of using Dodo with various configuration combinations. Each example includes three methods: CLI usage, YAML configuration, and JSON configuration. - -## Table of Contents - -1. [Basic HTTP Stress Testing](#1-basic-http-stress-testing) -2. [POST Request with Form Data](#2-post-request-with-form-data) -3. [API Testing with Authentication](#3-api-testing-with-authentication) -4. [Testing with Custom Headers and Cookies](#4-testing-with-custom-headers-and-cookies) -5. [Load Testing with Proxy Rotation](#5-load-testing-with-proxy-rotation) -6. [JSON API Testing with Dynamic Data](#6-json-api-testing-with-dynamic-data) -7. [File Upload Testing](#7-file-upload-testing) -8. [E-commerce Cart Testing](#8-e-commerce-cart-testing) -9. [GraphQL API Testing](#9-graphql-api-testing) -10. [WebSocket-style HTTP Testing](#10-websocket-style-http-testing) -11. [Multi-tenant Application Testing](#11-multi-tenant-application-testing) -12. [Rate Limiting Testing](#12-rate-limiting-testing) - ---- - -## 1. Basic HTTP Stress Testing - -Test a simple website with basic GET requests to measure performance under load. - -### CLI Usage - -```bash -dodo -u https://httpbin.org/get \ - -m GET \ - -d 5 \ - -r 100 \ - -t 5s \ - -o 30s \ - --skip-verify=false \ - -y -``` - -### YAML Configuration - -```yaml -method: "GET" -url: "https://httpbin.org/get" -yes: true -timeout: "5s" -dodos: 5 -requests: 100 -duration: "30s" -skip_verify: false -``` - -### JSON Configuration - -```json -{ - "method": "GET", - "url": "https://httpbin.org/get", - "yes": true, - "timeout": "5s", - "dodos": 5, - "requests": 100, - "duration": "30s", - "skip_verify": false -} -``` - ---- - -## 2. POST Request with Form Data - -Test form submission endpoints with randomized form data. - -### CLI Usage - -```bash -dodo -u https://httpbin.org/post \ - -m POST \ - -d 3 \ - -r 50 \ - -t 10s \ - --skip-verify=false \ - -H "Content-Type:application/x-www-form-urlencoded" \ - -b "username={{ fakeit_Username }}&password={{ fakeit_Password true true true true true 12 }}&email={{ fakeit_Email }}" \ - -b "username={{ fakeit_Username }}&password={{ fakeit_Password true true true true true 8 }}&email={{ fakeit_Email }}" \ - -y -``` - -### YAML Configuration - -```yaml -method: "POST" -url: "https://httpbin.org/post" -yes: true -timeout: "10s" -dodos: 3 -requests: 50 -skip_verify: false - -headers: - - Content-Type: "application/x-www-form-urlencoded" - -body: - - "username={{ fakeit_Username }}&password={{ fakeit_Password true true true true true 12 }}&email={{ fakeit_Email }}" - - "username={{ fakeit_Username }}&password={{ fakeit_Password true true true true true 8 }}&email={{ fakeit_Email }}" -``` - -### JSON Configuration - -```json -{ - "method": "POST", - "url": "https://httpbin.org/post", - "yes": true, - "timeout": "10s", - "dodos": 3, - "requests": 50, - "skip_verify": false, - "headers": [{ "Content-Type": "application/x-www-form-urlencoded" }], - "body": [ - "username={{ fakeit_Username }}&password={{ fakeit_Password true true true true true 12 }}&email={{ fakeit_Email }}", - "username={{ fakeit_Username }}&password={{ fakeit_Password true true true true true 8 }}&email={{ fakeit_Email }}" - ] -} -``` - ---- - -## 3. API Testing with Authentication - -Test protected API endpoints with various authentication methods. - -### CLI Usage - -```bash -dodo -u https://httpbin.org/bearer \ - -m GET \ - -d 4 \ - -r 200 \ - -t 8s \ - --skip-verify=false \ - -H "Authorization:Bearer {{ fakeit_LetterN 32 }}" \ - -H "User-Agent:{{ fakeit_UserAgent }}" \ - -H "X-Request-ID:{{ fakeit_Int }}" \ - -H "Accept:application/json" \ - -p "api_version=v1" \ - -p "format=json" \ - -p "client_id=mobile" -p "client_id=web" -p "client_id=desktop" \ - -y -``` - -### YAML Configuration - -```yaml -method: "GET" -url: "https://httpbin.org/bearer" -yes: true -timeout: "8s" -dodos: 4 -requests: 200 -skip_verify: false - -params: - - api_version: "v1" - - format: "json" - - client_id: ["mobile", "web", "desktop"] - -headers: - - Authorization: "Bearer {{ fakeit_LetterN 32 }}" - - User-Agent: "{{ fakeit_UserAgent }}" - - X-Request-ID: "{{ fakeit_Int }}" - - Accept: "application/json" -``` - -### JSON Configuration - -```json -{ - "method": "GET", - "url": "https://httpbin.org/bearer", - "yes": true, - "timeout": "8s", - "dodos": 4, - "requests": 200, - "skip_verify": false, - "params": [ - { "api_version": "v1" }, - { "format": "json" }, - { "client_id": ["mobile", "web", "desktop"] } - ], - "headers": [ - { "Authorization": "Bearer {{ fakeit_LetterN 32 }}" }, - { "User-Agent": "{{ fakeit_UserAgent }}" }, - { "X-Request-ID": "{{ fakeit_Int }}" }, - { "Accept": "application/json" } - ] -} -``` - ---- - -## 4. Testing with Custom Headers and Cookies - -Test applications that require specific headers and session cookies. - -### CLI Usage - -```bash -dodo -u https://httpbin.org/cookies \ - -m GET \ - -d 6 \ - -r 75 \ - -t 5s \ - --skip-verify=false \ - -H 'Accept-Language:{{ strings_Join "," (fakeit_LanguageAbbreviation) (fakeit_LanguageAbbreviation) (fakeit_LanguageAbbreviation) }}' \ - -H "X-Forwarded-For:{{ fakeit_IPv4Address }}" \ - -H "X-Real-IP:{{ fakeit_IPv4Address }}" \ - -H "Accept-Encoding:gzip" -H "Accept-Encoding:deflate" -H "Accept-Encoding:br" \ - -c "session_id={{ fakeit_UUID }}" \ - -c 'user_pref={{ fakeit_RandomString "a1" "b2" "c3" }}' \ - -c "theme=dark" -c "theme=light" -c "theme=auto" \ - -c "lang=en" -c "lang=es" -c "lang=fr" -c "lang=de" \ - -y -``` - -### YAML Configuration - -```yaml -method: "GET" -url: "https://httpbin.org/cookies" -yes: true -timeout: "5s" -dodos: 6 -requests: 75 -skip_verify: false - -headers: - - Accept-Language: '{{ strings_Join "," (fakeit_LanguageAbbreviation) (fakeit_LanguageAbbreviation) (fakeit_LanguageAbbreviation) }}' - - X-Forwarded-For: "{{ fakeit_IPv4Address }}" - - X-Real-IP: "{{ fakeit_IPv4Address }}" - - Accept-Encoding: ["gzip", "deflate", "br"] - -cookies: - - session_id: "{{ fakeit_UUID }}" - - user_pref: '{{ fakeit_RandomString "a1" "b2" "c3" }}' - - theme: ["dark", "light", "auto"] - - lang: ["en", "es", "fr", "de"] -``` - -### JSON Configuration - -```json -{ - "method": "GET", - "url": "https://httpbin.org/cookies", - "yes": true, - "timeout": "5s", - "dodos": 6, - "requests": 75, - "skip_verify": false, - "headers": [ - { - "Accept-Language": "{{ strings_Join \",\" (fakeit_LanguageAbbreviation) (fakeit_LanguageAbbreviation) (fakeit_LanguageAbbreviation) }}" - }, - { "X-Forwarded-For": "{{ fakeit_IPv4Address }}" }, - { "X-Real-IP": "{{ fakeit_IPv4Address }}" }, - { "Accept-Encoding": ["gzip", "deflate", "br"] } - ], - "cookies": [ - { "session_id": "{{ fakeit_UUID }}" }, - { "user_pref": "{{ fakeit_RandomString \"a1\" \"b2\" \"c3\" }}" }, - { "theme": ["dark", "light", "auto"] }, - { "lang": ["en", "es", "fr", "de"] } - ] -} -``` - ---- - -## 5. Load Testing with Proxy Rotation - -Test through multiple proxies for distributed load testing. - -### CLI Usage - -```bash -dodo -u https://httpbin.org/ip \ - -m GET \ - -d 8 \ - -r 300 \ - -t 15s \ - --skip-verify=false \ - -x "http://proxy1.example.com:8080" \ - -x "http://proxy2.example.com:8080" \ - -x "socks5://proxy3.example.com:1080" \ - -x "http://username:password@proxy4.example.com:8080" \ - -H "User-Agent:{{ fakeit_UserAgent }}" \ - -H "Accept:application/json" \ - -y -``` - -### YAML Configuration - -```yaml -method: "GET" -url: "https://httpbin.org/ip" -yes: true -timeout: "15s" -dodos: 8 -requests: 300 -skip_verify: false - -proxy: - - "http://proxy1.example.com:8080" - - "http://proxy2.example.com:8080" - - "socks5://proxy3.example.com:1080" - - "http://username:password@proxy4.example.com:8080" - -headers: - - User-Agent: "{{ fakeit_UserAgent }}" - - Accept: "application/json" -``` - -### JSON Configuration - -```json -{ - "method": "GET", - "url": "https://httpbin.org/ip", - "yes": true, - "timeout": "15s", - "dodos": 8, - "requests": 300, - "skip_verify": false, - "proxy": [ - "http://proxy1.example.com:8080", - "http://proxy2.example.com:8080", - "socks5://proxy3.example.com:1080", - "http://username:password@proxy4.example.com:8080" - ], - "headers": [ - { "User-Agent": "{{ fakeit_UserAgent }}" }, - { "Accept": "application/json" } - ] -} -``` - ---- - -## 6. JSON API Testing with Dynamic Data - -Test REST APIs with realistic JSON payloads. - -### CLI Usage - -```bash -dodo -u https://httpbin.org/post \ - -m POST \ - -d 5 \ - -r 150 \ - -t 12s \ - --skip-verify=false \ - -H "Content-Type:application/json" \ - -H "Accept:application/json" \ - -H "X-API-Version:2023-10-01" \ - -b '{"user_id":{{ fakeit_Uint }},"name":"{{ fakeit_Name }}","email":"{{ fakeit_Email }}","created_at":"{{ fakeit_Date }}"}' \ - -b '{"product_id":{{ fakeit_Uint }},"name":"{{ fakeit_ProductName }}","price":{{ fakeit_Price 10 1000 }},"category":"{{ fakeit_ProductCategory }}"}' \ - -b '{"order_id":"{{ fakeit_UUID }}","items":[{"id":{{ fakeit_Uint }},"quantity":{{ fakeit_IntRange 1 10 }}}],"total":{{ fakeit_Price 50 500 }}}' \ - -y -``` - -### YAML Configuration - -```yaml -method: "POST" -url: "https://httpbin.org/post" -yes: true -timeout: "12s" -dodos: 5 -requests: 150 -skip_verify: false - -headers: - - Content-Type: "application/json" - - Accept: "application/json" - - X-API-Version: "2023-10-01" - -body: - - '{"user_id":{{ fakeit_Uint }},"name":"{{ fakeit_Name }}","email":"{{ fakeit_Email }}","created_at":"{{ fakeit_Date }}"}' - - '{"product_id":{{ fakeit_Uint }},"name":"{{ fakeit_ProductName }}","price":{{ fakeit_Price 10 1000 }},"category":"{{ fakeit_ProductCategory }}"}' - - '{"order_id":"{{ fakeit_UUID }}","items":[{"id":{{ fakeit_Uint }},"quantity":{{ fakeit_IntRange 1 10 }}}],"total":{{ fakeit_Price 50 500 }}}' -``` - -### JSON Configuration - -```json -{ - "method": "POST", - "url": "https://httpbin.org/post", - "yes": true, - "timeout": "12s", - "dodos": 5, - "requests": 150, - "skip_verify": false, - "headers": [ - { "Content-Type": "application/json" }, - { "Accept": "application/json" }, - { "X-API-Version": "2023-10-01" } - ], - "body": [ - "{\"user_id\":{{ fakeit_Uint }},\"name\":\"{{ fakeit_Name }}\",\"email\":\"{{ fakeit_Email }}\",\"created_at\":\"{{ fakeit_Date }}\"}", - "{\"product_id\":{{ fakeit_Uint }},\"name\":\"{{ fakeit_ProductName }}\",\"price\":{{ fakeit_Price 10 1000 }},\"category\":\"{{ fakeit_ProductCategory }}\"}", - "{\"order_id\":\"{{ fakeit_UUID }}\",\"items\":[{\"id\":{{ fakeit_Uint }},\"quantity\":{{ fakeit_IntRange 1 10 }}}],\"total\":{{ fakeit_Price 50 500 }}}" - ] -} -``` - ---- - -## 7. File Upload Testing - -Test file upload endpoints with multipart form data. - -### CLI Usage - -```bash -dodo -u https://httpbin.org/post \ - -m POST \ - -d 3 \ - -r 25 \ - -t 30s \ - --skip-verify=false \ - -H "X-Upload-Source:dodo-test" \ - -H "User-Agent:{{ fakeit_UserAgent }}" \ - -b '{{ body_FormData (dict_Str "filename" (fakeit_UUID) "content" (fakeit_Paragraph 3 5 10 " ")) }}' \ - -b '{{ body_FormData (dict_Str "file" (fakeit_UUID) "description" (fakeit_Sentence 10) "category" "image") }}' \ - -y -``` - -### YAML Configuration - -```yaml -method: "POST" -url: "https://httpbin.org/post" -yes: true -timeout: "30s" -dodos: 3 -requests: 25 -skip_verify: false - -headers: - - X-Upload-Source: "dodo-test" - - User-Agent: "{{ fakeit_UserAgent }}" - -body: - - '{{ body_FormData (dict_Str "filename" (fakeit_UUID) "content" (fakeit_Paragraph 3 5 10 " ")) }}' - - '{{ body_FormData (dict_Str "file" (fakeit_UUID) "description" (fakeit_Sentence 10) "category" "image") }}' -``` - -### JSON Configuration - -```json -{ - "method": "POST", - "url": "https://httpbin.org/post", - "yes": true, - "timeout": "30s", - "dodos": 3, - "requests": 25, - "skip_verify": false, - "headers": [ - { "X-Upload-Source": "dodo-test" }, - { "User-Agent": "{{ fakeit_UserAgent }}" } - ], - "body": [ - "{{ body_FormData (dict_Str \"filename\" (fakeit_UUID) \"content\" (fakeit_Paragraph 3 5 10 \" \")) }}", - "{{ body_FormData (dict_Str \"file\" (fakeit_UUID) \"description\" (fakeit_Sentence 10) \"category\" \"image\") }}" - ] -} -``` - ---- - -## 8. E-commerce Cart Testing - -Test shopping cart operations with realistic product data. - -### CLI Usage - -```bash -dodo -u https://api.example-shop.com/cart \ - -m POST \ - -d 10 \ - -r 500 \ - -t 8s \ - --skip-verify=false \ - -H "Content-Type:application/json" \ - -H "Authorization:Bearer {{ fakeit_LetterN 32 }}" \ - -H "X-Client-Version:1.2.3" \ - -H "User-Agent:{{ fakeit_UserAgent }}" \ - -c "cart_session={{ fakeit_UUID }}" \ - -c "user_pref=guest" -c "user_pref=member" -c "user_pref=premium" \ - -c "region=US" -c "region=EU" -c "region=ASIA" \ - -p "currency=USD" -p "currency=EUR" -p "currency=GBP" \ - -p "locale=en-US" -p "locale=en-GB" -p "locale=de-DE" -p "locale=fr-FR" \ - -b '{"action":"add","product_id":"{{ fakeit_UUID }}","quantity":{{ fakeit_IntRange 1 5 }},"user_id":"{{ fakeit_UUID }}"}' \ - -b '{"action":"remove","product_id":"{{ fakeit_UUID }}","user_id":"{{ fakeit_UUID }}"}' \ - -b '{"action":"update","product_id":"{{ fakeit_UUID }}","quantity":{{ fakeit_IntRange 1 10 }},"user_id":"{{ fakeit_UUID }}"}' \ - -y -``` - -### YAML Configuration - -```yaml -method: "POST" -url: "https://api.example-shop.com/cart" -yes: true -timeout: "8s" -dodos: 10 -requests: 500 -skip_verify: false - -headers: - - Content-Type: "application/json" - - Authorization: "Bearer {{ fakeit_LetterN 32 }}" - - X-Client-Version: "1.2.3" - - User-Agent: "{{ fakeit_UserAgent }}" - -cookies: - - cart_session: "{{ fakeit_UUID }}" - - user_pref: ["guest", "member", "premium"] - - region: ["US", "EU", "ASIA"] - -params: - - currency: ["USD", "EUR", "GBP"] - - locale: ["en-US", "en-GB", "de-DE", "fr-FR"] - -body: - - '{"action":"add","product_id":"{{ fakeit_UUID }}","quantity":{{ fakeit_IntRange 1 5 }},"user_id":"{{ fakeit_UUID }}"}' - - '{"action":"remove","product_id":"{{ fakeit_UUID }}","user_id":"{{ fakeit_UUID }}"}' - - '{"action":"update","product_id":"{{ fakeit_UUID }}","quantity":{{ fakeit_IntRange 1 10 }},"user_id":"{{ fakeit_UUID }}"}' -``` - -### JSON Configuration - -```json -{ - "method": "POST", - "url": "https://api.example-shop.com/cart", - "yes": true, - "timeout": "8s", - "dodos": 10, - "requests": 500, - "skip_verify": false, - "headers": [ - { "Content-Type": "application/json" }, - { "Authorization": "Bearer {{ fakeit_LetterN 32 }}" }, - { "X-Client-Version": "1.2.3" }, - { "User-Agent": "{{ fakeit_UserAgent }}" } - ], - "cookies": [ - { "cart_session": "{{ fakeit_UUID }}" }, - { "user_pref": ["guest", "member", "premium"] }, - { "region": ["US", "EU", "ASIA"] } - ], - "params": [ - { "currency": ["USD", "EUR", "GBP"] }, - { "locale": ["en-US", "en-GB", "de-DE", "fr-FR"] } - ], - "body": [ - "{\"action\":\"add\",\"product_id\":\"{{ fakeit_UUID }}\",\"quantity\":{{ fakeit_IntRange 1 5 }},\"user_id\":\"{{ fakeit_UUID }}\"}", - "{\"action\":\"remove\",\"product_id\":\"{{ fakeit_UUID }}\",\"user_id\":\"{{ fakeit_UUID }}\"}", - "{\"action\":\"update\",\"product_id\":\"{{ fakeit_UUID }}\",\"quantity\":{{ fakeit_IntRange 1 10 }},\"user_id\":\"{{ fakeit_UUID }}\"}" - ] -} -``` - ---- - -## 9. GraphQL API Testing - -Test GraphQL endpoints with various queries and mutations. - -### CLI Usage - -```bash -dodo -u https://api.example.com/graphql \ - -m POST \ - -d 4 \ - -r 100 \ - -t 10s \ - --skip-verify=false \ - -H "Content-Type:application/json" \ - -H "Authorization:Bearer {{ fakeit_UUID }}" \ - -H "X-GraphQL-Client:dodo-test" \ - -b '{"query":"query GetUser($id: ID!) { user(id: $id) { id name email } }","variables":{"id":"{{ fakeit_UUID }}"}}' \ - -b '{"query":"query GetPosts($limit: Int) { posts(limit: $limit) { id title content } }","variables":{"limit":{{ fakeit_IntRange 5 20 }}}}' \ - -b '{"query":"mutation CreatePost($input: PostInput!) { createPost(input: $input) { id title } }","variables":{"input":{"title":"{{ fakeit_Sentence 5 }}","content":"{{ fakeit_Paragraph 2 3 5 " "}}","authorId":"{{ fakeit_UUID }}"}}}' \ - -y -``` - -### YAML Configuration - -```yaml -method: "POST" -url: "https://api.example.com/graphql" -yes: true -timeout: "10s" -dodos: 4 -requests: 100 -skip_verify: false - -headers: - - Content-Type: "application/json" - - Authorization: "Bearer {{ fakeit_UUID }}" - - X-GraphQL-Client: "dodo-test" - -body: - - '{"query":"query GetUser($id: ID!) { user(id: $id) { id name email } }","variables":{"id":"{{ fakeit_UUID }}"}}' - - '{"query":"query GetPosts($limit: Int) { posts(limit: $limit) { id title content } }","variables":{"limit":{{ fakeit_IntRange 5 20 }}}}' - - '{"query":"mutation CreatePost($input: PostInput!) { createPost(input: $input) { id title } }","variables":{"input":{"title":"{{ fakeit_Sentence 5 }}","content":"{{ fakeit_Paragraph 2 3 5 " "}}","authorId":"{{ fakeit_UUID }}"}}}' -``` - -### JSON Configuration - -```json -{ - "method": "POST", - "url": "https://api.example.com/graphql", - "yes": true, - "timeout": "10s", - "dodos": 4, - "requests": 100, - "skip_verify": false, - "headers": [ - { "Content-Type": "application/json" }, - { "Authorization": "Bearer {{ fakeit_UUID }}" }, - { "X-GraphQL-Client": "dodo-test" } - ], - "body": [ - "{\"query\":\"query GetUser($id: ID!) { user(id: $id) { id name email } }\",\"variables\":{\"id\":\"{{ fakeit_UUID }}\"}}", - "{\"query\":\"query GetPosts($limit: Int) { posts(limit: $limit) { id title content } }\",\"variables\":{\"limit\":{{ fakeit_IntRange 5 20 }}}}", - "{\"query\":\"mutation CreatePost($input: PostInput!) { createPost(input: $input) { id title } }\",\"variables\":{\"input\":{\"title\":\"{{ fakeit_Sentence 5 }}\",\"content\":\"{{ fakeit_Paragraph 2 3 5 \\\" \\\"}}\",\"authorId\":\"{{ fakeit_UUID }}\"}}}" - ] -} -``` - ---- - -## 10. WebSocket-style HTTP Testing - -Test real-time applications with WebSocket-like HTTP endpoints. - -### CLI Usage - -```bash -dodo -u https://api.realtime-app.com/events \ - -m POST \ - -d 15 \ - -r 1000 \ - -t 5s \ - -o 60s \ - --skip-verify=false \ - -H "Content-Type:application/json" \ - -H "X-Event-Type:{{ fakeit_LetterNN 4 12 }}" \ - -H "Connection:keep-alive" \ - -H "Cache-Control:no-cache" \ - -c "connection_id={{ fakeit_UUID }}" \ - -c "session_token={{ fakeit_UUID }}" \ - -p "channel=general" -p "channel=notifications" -p "channel=alerts" -p "channel=updates" \ - -p "version=v1" -p "version=v2" \ - -b '{"event":"{{ fakeit_Word }}","data":{"timestamp":"{{ fakeit_Date }}","user_id":"{{ fakeit_UUID }}","message":"{{ fakeit_Sentence 8 }}"}}' \ - -b '{"event":"ping","data":{"timestamp":"{{ fakeit_Date }}","client_id":"{{ fakeit_UUID }}"}}' \ - -b '{"event":"status_update","data":{"status":"{{ fakeit_Word }}","user_id":"{{ fakeit_UUID }}","timestamp":"{{ fakeit_Date }}"}}' \ - -y -``` - -### YAML Configuration - -```yaml -method: "POST" -url: "https://api.realtime-app.com/events" -yes: true -timeout: "5s" -dodos: 15 -requests: 1000 -duration: "60s" -skip_verify: false - -headers: - - Content-Type: "application/json" - - X-Event-Type: "{{ fakeit_LetterNN 4 12 }}" - - Connection: "keep-alive" - - Cache-Control: "no-cache" - -cookies: - - connection_id: "{{ fakeit_UUID }}" - - session_token: "{{ fakeit_UUID }}" - -params: - - channel: ["general", "notifications", "alerts", "updates"] - - version: ["v1", "v2"] - -body: - - '{"event":"{{ fakeit_Word }}","data":{"timestamp":"{{ fakeit_Date }}","user_id":"{{ fakeit_UUID }}","message":"{{ fakeit_Sentence 8 }}"}}' - - '{"event":"ping","data":{"timestamp":"{{ fakeit_Date }}","client_id":"{{ fakeit_UUID }}"}}' - - '{"event":"status_update","data":{"status":"{{ fakeit_Word }}","user_id":"{{ fakeit_UUID }}","timestamp":"{{ fakeit_Date }}"}}' -``` - -### JSON Configuration - -```json -{ - "method": "POST", - "url": "https://api.realtime-app.com/events", - "yes": true, - "timeout": "5s", - "dodos": 15, - "requests": 1000, - "duration": "60s", - "skip_verify": false, - "headers": [ - { "Content-Type": "application/json" }, - { "X-Event-Type": "{{ fakeit_LetterNN 4 12 }}" }, - { "Connection": "keep-alive" }, - { "Cache-Control": "no-cache" } - ], - "cookies": [ - { "connection_id": "{{ fakeit_UUID }}" }, - { "session_token": "{{ fakeit_UUID }}" } - ], - "params": [ - { "channel": ["general", "notifications", "alerts", "updates"] }, - { "version": ["v1", "v2"] } - ], - "body": [ - "{\"event\":\"{{ fakeit_Word }}\",\"data\":{\"timestamp\":\"{{ fakeit_Date }}\",\"user_id\":\"{{ fakeit_UUID }}\",\"message\":\"{{ fakeit_Sentence 8 }}\"}}", - "{\"event\":\"ping\",\"data\":{\"timestamp\":\"{{ fakeit_Date }}\",\"client_id\":\"{{ fakeit_UUID }}\"}}", - "{\"event\":\"status_update\",\"data\":{\"status\":\"{{ fakeit_Word }}\",\"user_id\":\"{{ fakeit_UUID }}\",\"timestamp\":\"{{ fakeit_Date }}\"}}" - ] -} -``` - ---- - -## 11. Multi-tenant Application Testing - -Test SaaS applications with tenant-specific configurations. - -### CLI Usage - -```bash -dodo -u https://app.saas-platform.com/api/data \ - -m GET \ - -d 12 \ - -r 600 \ - -t 15s \ - --skip-verify=false \ - -H "X-Tenant-ID:{{ fakeit_UUID }}" \ - -H "Authorization:Bearer {{ fakeit_LetterN 64 }}" \ - -H "X-Client-Type:web" -H "X-Client-Type:mobile" -H "X-Client-Type:api" \ - -H "Accept:application/json" \ - -c "tenant_session={{ fakeit_UUID }}" \ - -c "user_role=admin" -c "user_role=user" -c "user_role=viewer" \ - -c "subscription_tier=free" -c "subscription_tier=pro" -c "subscription_tier=enterprise" \ - -p "page={{ fakeit_IntRange 1 10 }}" \ - -p "limit={{ fakeit_IntRange 10 100 }}" \ - -p "sort=created_at" -p "sort=updated_at" -p "sort=name" \ - -p "order=asc" -p "order=desc" \ - -p "filter_by=active" -p "filter_by=inactive" -p "filter_by=pending" \ - -y -``` - -### YAML Configuration - -```yaml -method: "GET" -url: "https://app.saas-platform.com/api/data" -yes: true -timeout: "15s" -dodos: 12 -requests: 600 -skip_verify: false - -headers: - - X-Tenant-ID: "{{ fakeit_UUID }}" - - Authorization: "Bearer {{ fakeit_LetterN 64 }}" - - X-Client-Type: ["web", "mobile", "api"] - - Accept: "application/json" - -cookies: - - tenant_session: "{{ fakeit_UUID }}" - - user_role: ["admin", "user", "viewer"] - - subscription_tier: ["free", "pro", "enterprise"] - -params: - - page: "{{ fakeit_IntRange 1 10 }}" - - limit: "{{ fakeit_IntRange 10 100 }}" - - sort: ["created_at", "updated_at", "name"] - - order: ["asc", "desc"] - - filter_by: ["active", "inactive", "pending"] -``` - -### JSON Configuration - -```json -{ - "method": "GET", - "url": "https://app.saas-platform.com/api/data", - "yes": true, - "timeout": "15s", - "dodos": 12, - "requests": 600, - "skip_verify": false, - "headers": [ - { "X-Tenant-ID": "{{ fakeit_UUID }}" }, - { "Authorization": "Bearer {{ fakeit_LetterN 64 }}" }, - { "X-Client-Type": ["web", "mobile", "api"] }, - { "Accept": "application/json" } - ], - "cookies": [ - { "tenant_session": "{{ fakeit_UUID }}" }, - { "user_role": ["admin", "user", "viewer"] }, - { "subscription_tier": ["free", "pro", "enterprise"] } - ], - "params": [ - { "page": "{{ fakeit_IntRange 1 10 }}" }, - { "limit": "{{ fakeit_IntRange 10 100 }}" }, - { "sort": ["created_at", "updated_at", "name"] }, - { "order": ["asc", "desc"] }, - { "filter_by": ["active", "inactive", "pending"] } - ] -} -``` - ---- - -## 12. Rate Limiting Testing - -Test API rate limits and throttling mechanisms. - -### CLI Usage - -```bash -dodo -u https://api.rate-limited.com/endpoint \ - -m GET \ - -d 20 \ - -r 2000 \ - -t 3s \ - -o 120s \ - --skip-verify=false \ - -H "X-API-Key:{{ fakeit_UUID }}" \ - -H "X-Client-ID:{{ fakeit_UUID }}" \ - -H "X-Rate-Limit-Test:true" \ - -H "User-Agent:{{ fakeit_UserAgent }}" \ - -c "rate_limit_bucket={{ fakeit_UUID }}" \ - -c "client_tier=tier1" -c "client_tier=tier2" -c "client_tier=tier3" \ - -p "burst_test=true" \ - -p "client_type=premium" -p "client_type=standard" -p "client_type=free" \ - -p "request_id={{ fakeit_UUID }}" \ - -y -``` - -### YAML Configuration - -```yaml -method: "GET" -url: "https://api.rate-limited.com/endpoint" -yes: true -timeout: "3s" -dodos: 20 -requests: 2000 -duration: "120s" -skip_verify: false - -headers: - - X-API-Key: "{{ fakeit_UUID }}" - - X-Client-ID: "{{ fakeit_UUID }}" - - X-Rate-Limit-Test: "true" - - User-Agent: "{{ fakeit_UserAgent }}" - -params: - - burst_test: "true" - - client_type: ["premium", "standard", "free"] - - request_id: "{{ fakeit_UUID }}" - -cookies: - - rate_limit_bucket: "{{ fakeit_UUID }}" - - client_tier: ["tier1", "tier2", "tier3"] -``` - -### JSON Configuration - -```json -{ - "method": "GET", - "url": "https://api.rate-limited.com/endpoint", - "yes": true, - "timeout": "3s", - "dodos": 20, - "requests": 2000, - "duration": "120s", - "skip_verify": false, - "headers": [ - { "X-API-Key": "{{ fakeit_UUID }}" }, - { "X-Client-ID": "{{ fakeit_UUID }}" }, - { "X-Rate-Limit-Test": "true" }, - { "User-Agent": "{{ fakeit_UserAgent }}" } - ], - "params": [ - { "burst_test": "true" }, - { "client_type": ["premium", "standard", "free"] }, - { "request_id": "{{ fakeit_UUID }}" } - ], - "cookies": [ - { "rate_limit_bucket": "{{ fakeit_UUID }}" }, - { "client_tier": ["tier1", "tier2", "tier3"] } - ] -} -``` - ---- - -## Notes - -- All examples use template functions for dynamic data generation -- Adjust `dodos`, `requests`, `duration`, and `timeout` values based on your testing requirements -- Use `skip_verify: true` for testing with self-signed certificates -- Set `yes: true` to skip confirmation prompts in automated testing -- Template functions like `{{ fakeit_* }}` generate random realistic data for each request -- Multiple values in arrays (e.g., `["value1", "value2"]`) will be randomly selected per request -- Use the `body_FormData` function for multipart form uploads -- Proxy configurations support HTTP, SOCKS5, and SOCKS5H protocols - -For more template functions and advanced configuration options, refer to the main documentation and `utils/templates.go`. diff --git a/LICENSE b/LICENSE deleted file mode 100644 index 8131b39..0000000 --- a/LICENSE +++ /dev/null @@ -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. diff --git a/README.md b/README.md deleted file mode 100644 index b431888..0000000 --- a/README.md +++ /dev/null @@ -1,340 +0,0 @@ -

Dodo - A Fast and Easy-to-Use HTTP Benchmarking Tool

- -![Usage](https://ftp.aykhans.me/web/client/pubshares/VzPtSHS7yPQT7ngoZzZSNU/browse?path=/dodo_demonstrate.gif) - -
-

- - Examples - - | - - Install - - | - - Docker - -

-
- - Buy Me A Coffee - -
- -## Table of Contents - -- [Installation](#installation) - - [Using Docker (Recommended)](#using-docker-recommended) - - [Using Pre-built Binaries](#using-pre-built-binaries) - - [Building from Source](#building-from-source) -- [Usage](#usage) - - [1. CLI Usage](#1-cli-usage) - - [2. Config File Usage](#2-config-file-usage) - - [2.1 YAML/YML Example](#21-yamlyml-example) - - [2.2 JSON Example](#22-json-example) - - [3. CLI & Config File Combination](#3-cli--config-file-combination) -- [Config Parameters Reference](#config-parameters-reference) -- [Template Functions](#template-functions) - -## Installation - -### Using Docker (Recommended) - -Pull the latest Dodo image from Docker Hub: - -```sh -docker pull aykhans/dodo:latest -``` - -To use Dodo with Docker and a local config file, mount the config file as a volume and pass it as an argument: - -```sh -docker run -v /path/to/config.json:/config.json aykhans/dodo -f /config.json -``` - -If you're using a remote config file via URL, you don't need to mount a volume: - -```sh -docker run aykhans/dodo -f https://raw.githubusercontent.com/aykhans/dodo/main/config.yaml -``` - -### Using Pre-built Binaries - -Download the latest binaries from the [releases](https://github.com/aykhans/dodo/releases) section. - -### Building from Source - -To build Dodo from source, ensure you have [Go 1.24+](https://golang.org/dl/) installed. - -```sh -go install -ldflags "-s -w" github.com/aykhans/dodo@latest -``` - -## Usage - -Dodo supports CLI arguments, configuration files (JSON/YAML), or a combination of both. If both are used, CLI arguments take precedence. - -### 1. CLI Usage - -Send 1000 GET requests to https://example.com with 10 parallel dodos (threads), each with a timeout of 2 seconds, within a maximum duration of 1 minute: - -```sh -dodo -u https://example.com -m GET -d 10 -r 1000 -o 1m -t 2s -``` - -With Docker: - -```sh -docker run --rm -i aykhans/dodo -u https://example.com -m GET -d 10 -r 1000 -o 1m -t 2s -``` - -### 2. Config File Usage - -Send 1000 GET requests to https://example.com with 10 parallel dodos (threads), each with a timeout of 800 milliseconds, within a maximum duration of 250 seconds: - -#### 2.1 YAML/YML Example - -```yaml -method: "GET" -url: "https://example.com" -yes: false -timeout: "800ms" -dodos: 10 -requests: 1000 -duration: "250s" -skip_verify: false - -params: - # A random value will be selected from the list for first "key1" param on each request - # And always "value" for second "key1" param on each request - # e.g. "?key1=value2&key1=value" - - key1: ["value1", "value2", "value3", "value4"] - - key1: "value" - - # A random value will be selected from the list for param "key2" on each request - # e.g. "?key2=value2" - - key2: ["value1", "value2"] - -headers: - # A random value will be selected from the list for first "key1" header on each request - # And always "value" for second "key1" header on each request - # e.g. "key1: value3", "key1: value" - - key1: ["value1", "value2", "value3", "value4"] - - key1: "value" - - # A random value will be selected from the list for header "key2" on each request - # e.g. "key2: value2" - - key2: ["value1", "value2"] - -cookies: - # A random value will be selected from the list for first "key1" cookie on each request - # And always "value" for second "key1" cookie on each request - # e.g. "key1=value4; key1=value" - - key1: ["value1", "value2", "value3", "value4"] - - key1: "value" - - # A random value will be selected from the list for cookie "key2" on each request - # e.g. "key2=value1" - - key2: ["value1", "value2"] - -body: "body-text" -# OR -# A random body value will be selected from the list for each request -body: - - "body-text1" - - "body-text2" - - "body-text3" - -proxy: "http://example.com:8080" -# OR -# A random proxy will be selected from the list for each request -proxy: - - "http://example.com:8080" - - "http://username:password@example.com:8080" - - "socks5://example.com:8080" - - "socks5h://example.com:8080" -``` - -```sh -dodo -f /path/config.yaml -# OR -dodo -f https://example.com/config.yaml -``` - -With Docker: - -```sh -docker run --rm -i -v /path/to/config.yaml:/config.yaml aykhans/dodo -f /config.yaml -# OR -docker run --rm -i aykhans/dodo -f https://example.com/config.yaml -``` - -#### 2.2 JSON Example - -```jsonc -{ - "method": "GET", - "url": "https://example.com", - "yes": false, - "timeout": "800ms", - "dodos": 10, - "requests": 1000, - "duration": "250s", - "skip_verify": false, - - "params": [ - // A random value will be selected from the list for first "key1" param on each request - // And always "value" for second "key1" param on each request - // e.g. "?key1=value2&key1=value" - { "key1": ["value1", "value2", "value3", "value4"] }, - { "key1": "value" }, - - // A random value will be selected from the list for param "key2" on each request - // e.g. "?key2=value2" - { "key2": ["value1", "value2"] }, - ], - - "headers": [ - // A random value will be selected from the list for first "key1" header on each request - // And always "value" for second "key1" header on each request - // e.g. "key1: value3", "key1: value" - { "key1": ["value1", "value2", "value3", "value4"] }, - { "key1": "value" }, - - // A random value will be selected from the list for header "key2" on each request - // e.g. "key2: value2" - { "key2": ["value1", "value2"] }, - ], - - "cookies": [ - // A random value will be selected from the list for first "key1" cookie on each request - // And always "value" for second "key1" cookie on each request - // e.g. "key1=value4; key1=value" - { "key1": ["value1", "value2", "value3", "value4"] }, - { "key1": "value" }, - - // A random value will be selected from the list for cookie "key2" on each request - // e.g. "key2=value1" - { "key2": ["value1", "value2"] }, - ], - - "body": "body-text", - // OR - // A random body value will be selected from the list for each request - "body": ["body-text1", "body-text2", "body-text3"], - - "proxy": "http://example.com:8080", - // OR - // A random proxy will be selected from the list for each request - "proxy": [ - "http://example.com:8080", - "http://username:password@example.com:8080", - "socks5://example.com:8080", - "socks5h://example.com:8080", - ], -} -``` - -```sh -dodo -f /path/config.json -# OR -dodo -f https://example.com/config.json -``` - -With Docker: - -```sh -docker run --rm -i -v /path/to/config.json:/config.json aykhans/dodo -# OR -docker run --rm -i aykhans/dodo -f https://example.com/config.json -``` - -### 3. CLI & Config File Combination - -CLI arguments override config file values: - -```sh -dodo -f /path/to/config.yaml -u https://example.com -m GET -d 10 -r 1000 -o 1m -t 5s -``` - -With Docker: - -```sh -docker run --rm -i -v /path/to/config.json:/config.json aykhans/dodo -f /config.json -u https://example.com -m GET -d 10 -r 1000 -o 1m -t 5s -``` - -You can find more usage examples in the [EXAMPLES.md](./EXAMPLES.md) file. - -## Config Parameters Reference - -If `Headers`, `Params`, `Cookies`, `Body`, or `Proxy` fields have multiple values, each request will choose a random value from the list. - -| Parameter | config file | CLI Flag | CLI Short Flag | Type | Description | Default | -| --------------- | ----------- | ------------ | -------------- | ------------------------------ | ----------------------------------------------------------- | ------- | -| Config file | | -config-file | -f | String | Path to local config file or http(s) URL of the config file | - | -| Yes | yes | -yes | -y | Boolean | Answer yes to all questions | false | -| URL | url | -url | -u | String | URL to send the request to | - | -| Method | method | -method | -m | String | HTTP method | GET | -| Dodos (Threads) | dodos | -dodos | -d | UnsignedInteger | Number of dodos (threads) to send requests in parallel | 1 | -| Requests | requests | -requests | -r | UnsignedInteger | Total number of requests to send | - | -| Duration | duration | -duration | -o | Time | Maximum duration for the test | - | -| Timeout | timeout | -timeout | -t | Time | Timeout for canceling each request | 10s | -| Params | params | -param | -p | [{String: String OR [String]}] | Request parameters | - | -| Headers | headers | -header | -H | [{String: String OR [String]}] | Request headers | - | -| Cookies | cookies | -cookie | -c | [{String: String OR [String]}] | Request cookies | - | -| Body | body | -body | -b | String OR [String] | Request body or list of request bodies | - | -| Proxy | proxies | -proxy | -x | String OR [String] | Proxy URL or list of proxy URLs | - | -| Skip Verify | skip_verify | -skip-verify | | Boolean | Skip SSL/TLS certificate verification | false | - -## Template Functions - -Dodo supports template functions in `Headers`, `Params`, `Cookies`, and `Body` fields. These functions allow you to generate dynamic values for each request. - -You can use Go template syntax to include dynamic values in your requests. Here's how to use template functions: - -In CLI config: - -```sh -dodo -u https://example.com -r 1 \ - -header "User-Agent:{{ fakeit_UserAgent }}" \ # e.g. "User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko)" - -param "username={{ strings_ToUpper fakeit_Username }}" \ # e.g. "username=JOHN BOB" - -cookie "token={{ fakeit_Password true true true true true 10 }}" \ # e.g. token=1234567890abcdef1234567890abcdef - -body '{"email":"{{ fakeit_Email }}", "password":"{{ fakeit_Password true true true true true 10 }}"}' # e.g. {"email":"john.doe@example.com", "password":"12rw4d-78d"} -``` - -In YAML/YML config: - -```yaml -headers: - - User-Agent: "{{ fakeit_UserAgent }}" # e.g. "User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko)" - - "Random-Header-{{fakeit_FirstName}}": "static_value" # e.g. "Random-Header-John: static_value" - -cookies: - - token: "Bearer {{ fakeit_UUID }}" # e.g. "token=Bearer 1234567890abcdef1234567890abcdef" - -params: - - id: "{{ fakeit_Uint }}" # e.g. "id=1234567890" - - username: "{{ fakeit_Username }}" # e.g. "username=John Doe" - -body: - - '{ "username": "{{ fakeit_Username }}", "password": "{{ fakeit_Password }}" }' # e.g. { "username": "john.doe", "password": "password123" } - - '{ "email": "{{ fakeit_Email }}", "phone": "{{ fakeit_Phone }}" }' # e.g. { "email": "john.doe@example.com", "phone": "1234567890" } - - '{{ body_FormData (dict_Str "username" fakeit_Username "password" "secret123") }}' # Creates multipart form data for form submissions, automatically sets the appropriate Content-Type header. -``` - -In JSON config: - -```jsonc -{ - "headers": [ - { "User-Agent": "{{ fakeit_UserAgent }}" }, // e.g. "User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko)" - ], - "body": [ - "{ \"username\": \"{{ strings_RemoveSpaces fakeit_Username }}\", \"password\": \"{{ fakeit_Password }}\" }", // e.g. { "username": "johndoe", "password": "password123" } - "{{ body_FormData (dict_Str \"username\" fakeit_Username \"password\" \"12345\") }}", // Creates multipart form data for form submissions, automatically sets the appropriate Content-Type header. - ], -} -``` - -For the full list of template functions over 200 functions, refer to the `NewFuncMap` function in `utils/templates.go`. diff --git a/Taskfile.yaml b/Taskfile.yaml index c1730c4..eb40384 100644 --- a/Taskfile.yaml +++ b/Taskfile.yaml @@ -2,23 +2,8 @@ version: "3" -vars: - PLATFORMS: - - os: darwin - archs: [amd64, arm64] - - os: freebsd - archs: [386, amd64, arm] - - os: linux - archs: [386, amd64, arm, arm64] - - os: netbsd - archs: [386, amd64, arm] - - os: openbsd - archs: [386, amd64, arm, arm64] - - os: windows - archs: [386, amd64, arm64] - tasks: - run: go run main.go + run: go run cmd/cli/main.go {{.CLI_ARGS}} ftl: cmds: @@ -26,28 +11,10 @@ tasks: - task: tidy - task: lint - fmt: gofmt -w -d . + fmt: golangci-lint fmt tidy: go mod tidy lint: golangci-lint run - build: CGO_ENABLED=0 go build -ldflags "-s -w" -o "dodo" - - build-all: - silent: true - cmds: - - rm -rf binaries - - | - {{ $ext := "" }} - {{- range $platform := .PLATFORMS }} - {{- if eq $platform.os "windows" }} - {{ $ext = ".exe" }} - {{- end }} - - {{- range $arch := $platform.archs }} - echo "Building for {{$platform.os}}/{{$arch}}" - GOOS={{$platform.os}} GOARCH={{$arch}} go build -ldflags "-s -w" -o "./binaries/dodo-{{$platform.os}}-{{$arch}}{{$ext}}" - {{- end }} - {{- end }} - - echo -e "\033[32m*** Build completed ***\033[0m" + test: go test ./... diff --git a/cmd/cli/main.go b/cmd/cli/main.go new file mode 100644 index 0000000..963d5a3 --- /dev/null +++ b/cmd/cli/main.go @@ -0,0 +1,43 @@ +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) + _, 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 + }), + ) +} + +func printValidationErrors(parserName string, errors ...types.FieldParseError) { + for _, fieldErr := range errors { + utils.PrintErr(text.FgRed, "[%s] Field '%s': %v", parserName, fieldErr.Field, fieldErr.Err) + } +} diff --git a/config.json b/config.json deleted file mode 100644 index 66eb6d1..0000000 --- a/config.json +++ /dev/null @@ -1,37 +0,0 @@ -{ - "method": "GET", - "url": "https://example.com", - "yes": false, - "timeout": "5s", - "dodos": 8, - "requests": 1000, - "duration": "10s", - "skip_verify": false, - - "params": [ - { "key1": ["value1", "value2", "value3", "value4"] }, - { "key1": "value" }, - { "key2": ["value1", "value2"] } - ], - - "headers": [ - { "key1": ["value1", "value2", "value3", "value4"] }, - { "key1": "value" }, - { "key2": ["value1", "value2"] } - ], - - "cookies": [ - { "key1": ["value1", "value2", "value3", "value4"] }, - { "key1": "value" }, - { "key2": ["value1", "value2"] } - ], - - "body": ["body-text1", "body-text2", "body-text3"], - - "proxy": [ - "http://example.com:8080", - "http://username:password@example.com:8080", - "socks5://example.com:8080", - "socks5h://example.com:8080" - ] -} diff --git a/config.yaml b/config.yaml deleted file mode 100644 index 78b929d..0000000 --- a/config.yaml +++ /dev/null @@ -1,40 +0,0 @@ -method: "GET" -url: "https://example.com" -yes: false -timeout: "5s" -dodos: 8 -requests: 1000 -duration: "10s" -skip_verify: false - -params: - - key1: ["value1", "value2", "value3", "value4"] - - key1: "value" - - key2: ["value1", "value2"] - -headers: - - key1: ["value1", "value2", "value3", "value4"] - - key1: "value" - - key2: ["value1", "value2"] - -cookies: - - key1: ["value1", "value2", "value3", "value4"] - - key1: "value" - - key2: ["value1", "value2"] - -# body: "body-text" -# OR -# A random body value will be selected from the list for each request -body: - - "body-text1" - - "body-text2" - - "body-text3" - -# proxy: "http://example.com:8080" -# OR -# A random proxy will be selected from the list for each request -proxy: - - "http://example.com:8080" - - "http://username:password@example.com:8080" - - "socks5://example.com:8080" - - "socks5h://example.com:8080" diff --git a/config/cli.go b/config/cli.go deleted file mode 100644 index 997829a..0000000 --- a/config/cli.go +++ /dev/null @@ -1,188 +0,0 @@ -package config - -import ( - "flag" - "fmt" - "os" - "strings" - "time" - - "github.com/aykhans/dodo/types" - "github.com/aykhans/dodo/utils" -) - -const cliUsageText = `Usage: - dodo [flags] - -Examples: - -Simple usage: - dodo -u https://example.com -o 1m - -Usage with config file: - dodo -f /path/to/config/file/config.json - -Usage with all flags: - dodo -f /path/to/config/file/config.json \ - -u https://example.com -m POST \ - -d 10 -r 1000 -o 3m -t 3s \ - -b "body1" -body "body2" \ - -H "header1:value1" -header "header2:value2" \ - -p "param1=value1" -param "param2=value2" \ - -c "cookie1=value1" -cookie "cookie2=value2" \ - -x "http://proxy.example.com:8080" -proxy "socks5://proxy2.example.com:8080" \ - -skip-verify -y - -Flags: - -h, -help help for dodo - -v, -version version for dodo - -y, -yes bool Answer yes to all questions (default %v) - -f, -config-file string Path to the local config file or http(s) URL of the config file - -d, -dodos uint Number of dodos(threads) (default %d) - -r, -requests uint Number of total requests - -o, -duration Time Maximum duration for the test (e.g. 30s, 1m, 5h) - -t, -timeout Time Timeout for each request (e.g. 400ms, 15s, 1m10s) (default %v) - -u, -url string URL for stress testing - -m, -method string HTTP Method for the request (default %s) - -b, -body [string] Body for the request (e.g. "body text") - -p, -param [string] Parameter for the request (e.g. "key1=value1") - -H, -header [string] Header for the request (e.g. "key1:value1") - -c, -cookie [string] Cookie for the request (e.g. "key1=value1") - -x, -proxy [string] Proxy for the request (e.g. "http://proxy.example.com:8080") - -skip-verify bool Skip SSL/TLS certificate verification (default %v)` - -func (config *Config) ReadCLI() (types.ConfigFile, error) { - flag.Usage = func() { - fmt.Printf( - cliUsageText+"\n", - DefaultYes, - DefaultDodosCount, - DefaultTimeout, - DefaultMethod, - DefaultSkipVerify, - ) - } - - var ( - version = false - configFile = "" - yes = false - skipVerify = false - method = "" - url types.RequestURL - dodosCount = uint(0) - requestCount = uint(0) - timeout time.Duration - duration time.Duration - ) - - { - flag.BoolVar(&version, "version", false, "Prints the version of the program") - flag.BoolVar(&version, "v", false, "Prints the version of the program") - - flag.StringVar(&configFile, "config-file", "", "Path to the configuration file") - flag.StringVar(&configFile, "f", "", "Path to the configuration file") - - flag.BoolVar(&yes, "yes", false, "Answer yes to all questions") - flag.BoolVar(&yes, "y", false, "Answer yes to all questions") - - flag.BoolVar(&skipVerify, "skip-verify", false, "Skip SSL/TLS certificate verification") - - flag.StringVar(&method, "method", "", "HTTP Method") - flag.StringVar(&method, "m", "", "HTTP Method") - - flag.Var(&url, "url", "URL to send the request") - flag.Var(&url, "u", "URL to send the request") - - flag.UintVar(&dodosCount, "dodos", 0, "Number of dodos(threads)") - flag.UintVar(&dodosCount, "d", 0, "Number of dodos(threads)") - - flag.UintVar(&requestCount, "requests", 0, "Number of total requests") - flag.UintVar(&requestCount, "r", 0, "Number of total requests") - - flag.DurationVar(&duration, "duration", 0, "Maximum duration of the test") - flag.DurationVar(&duration, "o", 0, "Maximum duration of the test") - - flag.DurationVar(&timeout, "timeout", 0, "Timeout for each request (e.g. 400ms, 15s, 1m10s)") - flag.DurationVar(&timeout, "t", 0, "Timeout for each request (e.g. 400ms, 15s, 1m10s)") - - flag.Var(&config.Params, "param", "URL parameter to send with the request") - flag.Var(&config.Params, "p", "URL parameter to send with the request") - - flag.Var(&config.Headers, "header", "Header to send with the request") - flag.Var(&config.Headers, "H", "Header to send with the request") - - flag.Var(&config.Cookies, "cookie", "Cookie to send with the request") - flag.Var(&config.Cookies, "c", "Cookie to send with the request") - - flag.Var(&config.Body, "body", "Body to send with the request") - flag.Var(&config.Body, "b", "Body to send with the request") - - flag.Var(&config.Proxies, "proxy", "Proxy to use for the request") - flag.Var(&config.Proxies, "x", "Proxy to use for the request") - } - - flag.Parse() - - if len(os.Args) <= 1 { - flag.CommandLine.Usage() - os.Exit(0) - } - - if args := flag.Args(); len(args) > 0 { - return types.ConfigFile(configFile), fmt.Errorf("unexpected arguments: %v", strings.Join(args, ", ")) - } - - if version { - fmt.Printf("dodo version %s\n", VERSION) - os.Exit(0) - } - - flag.Visit(func(f *flag.Flag) { - switch f.Name { - case "method", "m": - config.Method = utils.ToPtr(method) - case "url", "u": - config.URL = utils.ToPtr(url) - case "dodos", "d": - config.DodosCount = utils.ToPtr(dodosCount) - case "requests", "r": - config.RequestCount = utils.ToPtr(requestCount) - case "duration", "o": - config.Duration = &types.Duration{Duration: duration} - case "timeout", "t": - config.Timeout = &types.Timeout{Duration: timeout} - case "yes", "y": - config.Yes = utils.ToPtr(yes) - case "skip-verify": - config.SkipVerify = utils.ToPtr(skipVerify) - } - }) - - return types.ConfigFile(configFile), nil -} - -// CLIYesOrNoReader reads a yes or no answer from the command line. -// It prompts the user with the given message and default value, -// and returns true if the user answers "y" or "Y", and false otherwise. -// If there is an error while reading the input, it returns false. -// If the user simply presses enter without providing any input, -// it returns the default value specified by the `dft` parameter. -func CLIYesOrNoReader(message string, dft bool) bool { - var answer string - defaultMessage := "Y/n" - if !dft { - defaultMessage = "y/N" - } - fmt.Printf("%s [%s]: ", message, defaultMessage) - if _, err := fmt.Scanln(&answer); err != nil { - if err.Error() == "unexpected newline" { - return dft - } - return false - } - if answer == "" { - return dft - } - return answer == "y" || answer == "Y" -} diff --git a/config/config.go b/config/config.go deleted file mode 100644 index f333966..0000000 --- a/config/config.go +++ /dev/null @@ -1,364 +0,0 @@ -package config - -import ( - "bytes" - "errors" - "fmt" - "math/rand" - "net/url" - "os" - "slices" - "strings" - "text/template" - "time" - - "github.com/aykhans/dodo/types" - "github.com/aykhans/dodo/utils" - "github.com/jedib0t/go-pretty/v6/table" -) - -const ( - VERSION string = "0.7.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 - DefaultSkipVerify bool = false -) - -var SupportedProxySchemes []string = []string{"http", "socks5", "socks5h"} - -type RequestConfig struct { - Method string - URL url.URL - Timeout time.Duration - DodosCount uint - RequestCount uint - Duration time.Duration - Yes bool - SkipVerify bool - Params types.Params - Headers types.Headers - Cookies types.Cookies - Body types.Body - Proxies types.Proxies -} - -func NewRequestConfig(conf *Config) *RequestConfig { - return &RequestConfig{ - Method: *conf.Method, - URL: conf.URL.URL, - Timeout: conf.Timeout.Duration, - DodosCount: *conf.DodosCount, - RequestCount: *conf.RequestCount, - Duration: conf.Duration.Duration, - Yes: *conf.Yes, - SkipVerify: *conf.SkipVerify, - Params: conf.Params, - Headers: conf.Headers, - Cookies: conf.Cookies, - Body: conf.Body, - Proxies: conf.Proxies, - } -} - -func (rc *RequestConfig) GetValidDodosCountForRequests() uint { - if rc.RequestCount == 0 { - return rc.DodosCount - } - return min(rc.DodosCount, rc.RequestCount) -} - -func (rc *RequestConfig) GetMaxConns(minConns uint) uint { - maxConns := max( - minConns, rc.GetValidDodosCountForRequests(), - ) - return ((maxConns * 50 / 100) + maxConns) -} - -func (rc *RequestConfig) Print() { - t := table.NewWriter() - t.SetOutputMirror(os.Stdout) - t.SetStyle(table.StyleLight) - t.SetColumnConfigs([]table.ColumnConfig{ - { - Number: 2, - WidthMaxEnforcer: func(col string, maxLen int) string { - lines := strings.Split(col, "\n") - for i, line := range lines { - if len(line) > maxLen { - lines[i] = line[:maxLen-3] + "..." - } - } - return strings.Join(lines, "\n") - }, - WidthMax: 50}, - }) - - t.AppendHeader(table.Row{"Request Configuration"}) - t.AppendRow(table.Row{"URL", rc.URL.String()}) - t.AppendSeparator() - t.AppendRow(table.Row{"Method", rc.Method}) - t.AppendSeparator() - t.AppendRow(table.Row{"Timeout", rc.Timeout}) - t.AppendSeparator() - t.AppendRow(table.Row{"Dodos", rc.DodosCount}) - t.AppendSeparator() - if rc.RequestCount > 0 { - t.AppendRow(table.Row{"Requests", rc.RequestCount}) - } else { - t.AppendRow(table.Row{"Requests"}) - } - t.AppendSeparator() - if rc.Duration > 0 { - t.AppendRow(table.Row{"Duration", rc.Duration}) - } else { - t.AppendRow(table.Row{"Duration"}) - } - t.AppendSeparator() - t.AppendRow(table.Row{"Params", rc.Params.String()}) - t.AppendSeparator() - t.AppendRow(table.Row{"Headers", rc.Headers.String()}) - t.AppendSeparator() - t.AppendRow(table.Row{"Cookies", rc.Cookies.String()}) - t.AppendSeparator() - t.AppendRow(table.Row{"Proxy", rc.Proxies.String()}) - t.AppendSeparator() - t.AppendRow(table.Row{"Body", rc.Body.String()}) - t.AppendSeparator() - t.AppendRow(table.Row{"Skip Verify", rc.SkipVerify}) - - t.Render() -} - -type Config struct { - Method *string `json:"method" yaml:"method"` - URL *types.RequestURL `json:"url" yaml:"url"` - Timeout *types.Timeout `json:"timeout" yaml:"timeout"` - DodosCount *uint `json:"dodos" yaml:"dodos"` - RequestCount *uint `json:"requests" yaml:"requests"` - Duration *types.Duration `json:"duration" yaml:"duration"` - Yes *bool `json:"yes" yaml:"yes"` - SkipVerify *bool `json:"skip_verify" yaml:"skip_verify"` - Params types.Params `json:"params" yaml:"params"` - Headers types.Headers `json:"headers" yaml:"headers"` - Cookies types.Cookies `json:"cookies" yaml:"cookies"` - Body types.Body `json:"body" yaml:"body"` - Proxies types.Proxies `json:"proxy" yaml:"proxy"` -} - -func NewConfig() *Config { - return &Config{} -} - -func (config *Config) Validate() []error { - var errs []error - if utils.IsNilOrZero(config.URL) { - errs = append(errs, errors.New("request URL is required")) - } else { - if config.URL.Scheme != "http" && config.URL.Scheme != "https" { - errs = append(errs, errors.New("request URL scheme must be http or https")) - } - - urlParams := types.Params{} - for key, values := range config.URL.Query() { - for _, value := range values { - urlParams = append(urlParams, types.KeyValue[string, []string]{ - Key: key, - Value: []string{value}, - }) - } - } - config.Params = append(urlParams, config.Params...) - config.URL.RawQuery = "" - } - - if utils.IsNilOrZero(config.Method) { - errs = append(errs, errors.New("request method is required")) - } - if utils.IsNilOrZero(config.Timeout) { - errs = append(errs, errors.New("request timeout must be greater than 0")) - } - if utils.IsNilOrZero(config.DodosCount) { - errs = append(errs, errors.New("dodos count must be greater than 0")) - } - if utils.IsNilOrZero(config.Duration) && utils.IsNilOrZero(config.RequestCount) { - errs = append(errs, errors.New("you should provide at least one of duration or request count")) - } - - for i, proxy := range config.Proxies { - if proxy.String() == "" { - errs = append(errs, fmt.Errorf("proxies[%d]: proxy cannot be empty", i)) - } else if schema := proxy.Scheme; !slices.Contains(SupportedProxySchemes, schema) { - errs = append(errs, - fmt.Errorf("proxies[%d]: proxy has unsupported scheme \"%s\" (supported schemes: %s)", - i, proxy.String(), strings.Join(SupportedProxySchemes, ", "), - ), - ) - } - } - - funcMap := *utils.NewFuncMapGenerator( - rand.New( - rand.NewSource( - time.Now().UnixNano(), - ), - ), - ).GetFuncMap() - - for _, header := range config.Headers { - t, err := template.New("default").Funcs(funcMap).Parse(header.Key) - if err != nil { - errs = append(errs, fmt.Errorf("header key (%s) parse error: %v", header.Key, err)) - } else { - var buf bytes.Buffer - if err = t.Execute(&buf, nil); err != nil { - errs = append(errs, fmt.Errorf("header key (%s) parse error: %v", header.Key, err)) - } - } - - for _, value := range header.Value { - t, err := template.New("default").Funcs(funcMap).Parse(value) - if err != nil { - errs = append(errs, fmt.Errorf("header value (%s) parse error: %v", value, err)) - } else { - var buf bytes.Buffer - if err = t.Execute(&buf, nil); err != nil { - errs = append(errs, fmt.Errorf("header value (%s) parse error: %v", value, err)) - } - } - } - } - - for _, cookie := range config.Cookies { - t, err := template.New("default").Funcs(funcMap).Parse(cookie.Key) - if err != nil { - errs = append(errs, fmt.Errorf("cookie key (%s) parse error: %v", cookie.Key, err)) - } else { - var buf bytes.Buffer - if err = t.Execute(&buf, nil); err != nil { - errs = append(errs, fmt.Errorf("cookie key (%s) parse error: %v", cookie.Key, err)) - } - } - - for _, value := range cookie.Value { - t, err := template.New("default").Funcs(funcMap).Parse(value) - if err != nil { - errs = append(errs, fmt.Errorf("cookie value (%s) parse error: %v", value, err)) - } else { - var buf bytes.Buffer - if err = t.Execute(&buf, nil); err != nil { - errs = append(errs, fmt.Errorf("cookie value (%s) parse error: %v", value, err)) - } - } - } - } - - for _, param := range config.Params { - t, err := template.New("default").Funcs(funcMap).Parse(param.Key) - if err != nil { - errs = append(errs, fmt.Errorf("param key (%s) parse error: %v", param.Key, err)) - } else { - var buf bytes.Buffer - if err = t.Execute(&buf, nil); err != nil { - errs = append(errs, fmt.Errorf("param key (%s) parse error: %v", param.Key, err)) - } - } - - for _, value := range param.Value { - t, err := template.New("default").Funcs(funcMap).Parse(value) - if err != nil { - errs = append(errs, fmt.Errorf("param value (%s) parse error: %v", value, err)) - } else { - var buf bytes.Buffer - if err = t.Execute(&buf, nil); err != nil { - errs = append(errs, fmt.Errorf("param value (%s) parse error: %v", value, err)) - } - } - } - } - - for _, body := range config.Body { - t, err := template.New("default").Funcs(funcMap).Parse(body) - if err != nil { - errs = append(errs, fmt.Errorf("body (%s) parse error: %v", body, err)) - } else { - var buf bytes.Buffer - if err = t.Execute(&buf, nil); err != nil { - errs = append(errs, fmt.Errorf("body (%s) parse error: %v", body, err)) - } - } - } - - return errs -} - -func (config *Config) MergeConfig(newConfig *Config) { - if newConfig.Method != nil { - config.Method = newConfig.Method - } - if newConfig.URL != nil { - config.URL = newConfig.URL - } - if newConfig.Timeout != nil { - config.Timeout = newConfig.Timeout - } - if newConfig.DodosCount != nil { - config.DodosCount = newConfig.DodosCount - } - if newConfig.RequestCount != nil { - config.RequestCount = newConfig.RequestCount - } - if newConfig.Duration != nil { - config.Duration = newConfig.Duration - } - if newConfig.Yes != nil { - config.Yes = newConfig.Yes - } - if newConfig.SkipVerify != nil { - config.SkipVerify = newConfig.SkipVerify - } - if len(newConfig.Params) != 0 { - config.Params = newConfig.Params - } - if len(newConfig.Headers) != 0 { - config.Headers = newConfig.Headers - } - if len(newConfig.Cookies) != 0 { - config.Cookies = newConfig.Cookies - } - if len(newConfig.Body) != 0 { - config.Body = newConfig.Body - } - if len(newConfig.Proxies) != 0 { - config.Proxies = newConfig.Proxies - } -} - -func (config *Config) SetDefaults() { - if config.Method == nil { - config.Method = utils.ToPtr(DefaultMethod) - } - if config.Timeout == nil { - config.Timeout = &types.Timeout{Duration: DefaultTimeout} - } - if config.DodosCount == nil { - config.DodosCount = utils.ToPtr(DefaultDodosCount) - } - if config.RequestCount == nil { - config.RequestCount = utils.ToPtr(DefaultRequestCount) - } - if config.Duration == nil { - config.Duration = &types.Duration{Duration: DefaultDuration} - } - if config.Yes == nil { - config.Yes = utils.ToPtr(DefaultYes) - } - if config.SkipVerify == nil { - config.SkipVerify = utils.ToPtr(DefaultSkipVerify) - } - config.Headers.SetIfNotExists("User-Agent", DefaultUserAgent) -} diff --git a/config/file.go b/config/file.go deleted file mode 100644 index abf5e21..0000000 --- a/config/file.go +++ /dev/null @@ -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 -} diff --git a/go.mod b/go.mod index 58e414e..71398e3 100644 --- a/go.mod +++ b/go.mod @@ -2,21 +2,18 @@ module github.com/aykhans/dodo go 1.25 +require github.com/stretchr/testify v1.10.0 + require ( - github.com/brianvoe/gofakeit/v7 v7.3.0 - github.com/jedib0t/go-pretty/v6 v6.6.8 - github.com/valyala/fasthttp v1.65.0 - gopkg.in/yaml.v3 v3.0.1 + github.com/mattn/go-runewidth v0.0.16 // indirect + github.com/rivo/uniseg v0.4.7 // indirect + golang.org/x/sys v0.30.0 // indirect + golang.org/x/text v0.22.0 // indirect ) require ( - github.com/andybalholm/brotli v1.2.0 // indirect - github.com/klauspost/compress v1.18.0 // indirect - github.com/mattn/go-runewidth v0.0.16 // indirect - github.com/rivo/uniseg v0.4.7 // indirect - github.com/valyala/bytebufferpool v1.0.0 // indirect - golang.org/x/net v0.43.0 // indirect - golang.org/x/sys v0.35.0 // indirect - golang.org/x/term v0.34.0 // indirect - golang.org/x/text v0.28.0 // indirect + github.com/davecgh/go-spew v1.1.1 // indirect + github.com/jedib0t/go-pretty/v6 v6.6.8 + github.com/pmezard/go-difflib v1.0.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/go.sum b/go.sum index 3668d1f..42dbb42 100644 --- a/go.sum +++ b/go.sum @@ -1,13 +1,7 @@ -github.com/andybalholm/brotli v1.2.0 h1:ukwgCxwYrmACq68yiUqwIWnGY0cTPox/M94sVwToPjQ= -github.com/andybalholm/brotli v1.2.0/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY= -github.com/brianvoe/gofakeit/v7 v7.3.0 h1:TWStf7/lLpAjKw+bqwzeORo9jvrxToWEwp9b1J2vApQ= -github.com/brianvoe/gofakeit/v7 v7.3.0/go.mod h1:QXuPeBw164PJCzCUZVmgpgHJ3Llj49jSLVkKPMtxtxA= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/jedib0t/go-pretty/v6 v6.6.8 h1:JnnzQeRz2bACBobIaa/r+nqjvws4yEhcmaZ4n1QzsEc= 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/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= @@ -17,20 +11,10 @@ github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= -github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw= -github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc= -github.com/valyala/fasthttp v1.65.0 h1:j/u3uzFEGFfRxw79iYzJN+TteTJwbYkru9uDp3d0Yf8= -github.com/valyala/fasthttp v1.65.0/go.mod h1:P/93/YkKPMsKSnATEeELUCkG8a7Y+k99uxNHVbKINr4= -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.43.0 h1:lat02VYK2j4aLzMzecihNvTlJNQUq316m2Mr9rnM6YE= -golang.org/x/net v0.43.0/go.mod h1:vhO1fvI4dGsIjh73sWfUVjj3N7CA9WkKJNQm2svM6Jg= -golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI= -golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= -golang.org/x/term v0.34.0 h1:O/2T7POpk0ZZ7MAzMeWFSg6S5IpWd/RXDlM9hgM3DR4= -golang.org/x/term v0.34.0/go.mod h1:5jC53AEywhIVebHgPVeg0mj8OD3VO9OzclacVrqpaAw= -golang.org/x/text v0.28.0 h1:rhazDwis8INMIwQ4tpjLDzUhx6RlXqZNPEM0huQojng= -golang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU= +golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc= +golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/text v0.22.0 h1:bofq7m3/HAFvbF51jz3Q9wLg3jkvSPuiZu/pD1XwgtM= +golang.org/x/text v0.22.0/go.mod h1:YRoo4H8PVmsu+E3Ou7cqLVH8oXWIHVoX0jqUWALQhfY= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= diff --git a/main.go b/main.go deleted file mode 100644 index 5e99a86..0000000 --- a/main.go +++ /dev/null @@ -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() -} diff --git a/pkg/config/cli.go b/pkg/config/cli.go new file mode 100644 index 0000000..d741a4f --- /dev/null +++ b/pkg/config/cli.go @@ -0,0 +1,250 @@ +package config + +import ( + "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} +} + +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{} + 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.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 "yes", "y": + config.Yes = utils.ToPtr(yes) + case "skip-verify": + config.SkipVerify = utils.ToPtr(skipVerify) + case "method", "m": + config.Method = utils.ToPtr(method) + case "url", "u": + urlParsed, err := url.Parse(urlInput) + if err != nil { + fieldParseErrors = append(fieldParseErrors, *types.NewFieldParseError("url", err)) + } else { + config.URL = urlParsed + } + case "dodos", "d": + config.DodosCount = utils.ToPtr(dodosCount) + case "requests", "r": + config.RequestCount = utils.ToPtr(requestCount) + case "duration", "o": + config.Duration = utils.ToPtr(duration) + case "timeout", "t": + config.Timeout = utils.ToPtr(timeout) + case "param", "p": + config.Params.Parse(params...) + case "header", "H": + config.Headers.Parse(headers...) + case "cookie", "c": + config.Cookies.Parse(cookies...) + case "body", "b": + config.Bodies.Parse(bodies...) + case "proxy", "x": + for i, proxy := range proxies { + err := config.Proxies.Parse(proxy) + if err != nil { + fieldParseErrors = append( + fieldParseErrors, + *types.NewFieldParseError(fmt.Sprintf("proxy[%d]", i), err), + ) + } + } + } + }) + + if len(fieldParseErrors) > 0 { + return nil, types.NewFieldParseErrors(fieldParseErrors) + } + + return config, nil +} + +func (parser *ConfigCLIParser) PrintHelp() { + fmt.Printf( + cliUsageText+"\n", + Defaults.Yes, + Defaults.DodosCount, + Defaults.RequestTimeout, + Defaults.Method, + Defaults.SkipVerify, + ) +} + +// CLIYesOrNoReader reads a yes or no answer from the command line. +// It prompts the user with the given message and default value, +// and returns true if the user answers "y" or "Y", and false otherwise. +// If there is an error while reading the input, it returns false. +// If the user simply presses enter without providing any input, +// it returns the default value specified by the `def` parameter. +func CLIYesOrNoReader(message string, def bool) bool { + var answer string + defaultMessage := "Y/n" + + if !def { + defaultMessage = "y/N" + } + + fmt.Printf("%s [%s]: ", message, defaultMessage) + if _, err := fmt.Scanln(&answer); err != nil { + if err.Error() == "unexpected newline" { + return def + } + return false + } + + if answer == "" { + return def + } + + return answer == "y" || answer == "Y" +} diff --git a/pkg/config/cli_test.go b/pkg/config/cli_test.go new file mode 100644 index 0000000..e165c4e --- /dev/null +++ b/pkg/config/cli_test.go @@ -0,0 +1,679 @@ +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) + }) + + t.Run("Parse with method flag", func(t *testing.T) { + parser := NewConfigCLIParser([]string{"dodo", "-m", "POST"}) + config, err := parser.Parse() + + require.NoError(t, err) + require.NotNil(t, config) + require.NotNil(t, config.Method) + assert.Equal(t, "POST", *config.Method) + }) + + t.Run("Parse with yes flag", func(t *testing.T) { + parser := NewConfigCLIParser([]string{"dodo", "-y"}) + config, err := parser.Parse() + + require.NoError(t, err) + require.NotNil(t, config) + require.NotNil(t, config.Yes) + assert.True(t, *config.Yes) + }) + + t.Run("Parse with skip-verify flag", func(t *testing.T) { + parser := NewConfigCLIParser([]string{"dodo", "-skip-verify"}) + config, err := parser.Parse() + + require.NoError(t, err) + require.NotNil(t, config) + require.NotNil(t, config.SkipVerify) + assert.True(t, *config.SkipVerify) + }) + + t.Run("Parse with dodos count", func(t *testing.T) { + parser := NewConfigCLIParser([]string{"dodo", "-d", "5"}) + config, err := parser.Parse() + + require.NoError(t, err) + require.NotNil(t, config) + require.NotNil(t, config.DodosCount) + assert.Equal(t, uint(5), *config.DodosCount) + }) + + t.Run("Parse with request count", func(t *testing.T) { + parser := NewConfigCLIParser([]string{"dodo", "-r", "1000"}) + config, err := parser.Parse() + + require.NoError(t, err) + require.NotNil(t, config) + require.NotNil(t, config.RequestCount) + assert.Equal(t, uint(1000), *config.RequestCount) + }) + + t.Run("Parse with duration", func(t *testing.T) { + parser := NewConfigCLIParser([]string{"dodo", "-o", "5m"}) + config, err := parser.Parse() + + require.NoError(t, err) + require.NotNil(t, config) + require.NotNil(t, config.Duration) + assert.Equal(t, 5*time.Minute, *config.Duration) + }) + + t.Run("Parse with timeout", func(t *testing.T) { + parser := NewConfigCLIParser([]string{"dodo", "-t", "30s"}) + config, err := parser.Parse() + + require.NoError(t, err) + require.NotNil(t, config) + require.NotNil(t, config.Timeout) + assert.Equal(t, 30*time.Second, *config.Timeout) + }) + + t.Run("Parse with parameters", func(t *testing.T) { + parser := NewConfigCLIParser([]string{"dodo", "-p", "key1=value1", "-p", "key2=value2"}) + config, err := parser.Parse() + + require.NoError(t, err) + require.NotNil(t, config) + assert.Len(t, config.Params, 2) + assert.Equal(t, "key1", config.Params[0].Key) + assert.Equal(t, []string{"value1"}, config.Params[0].Value) + assert.Equal(t, "key2", config.Params[1].Key) + assert.Equal(t, []string{"value2"}, config.Params[1].Value) + }) + + t.Run("Parse with headers", func(t *testing.T) { + parser := NewConfigCLIParser([]string{"dodo", "-H", "Content-Type: application/json", "-H", "Authorization: Bearer token"}) + config, err := parser.Parse() + + require.NoError(t, err) + require.NotNil(t, config) + assert.Len(t, config.Headers, 2) + assert.Equal(t, "Content-Type", config.Headers[0].Key) + assert.Equal(t, []string{"application/json"}, config.Headers[0].Value) + assert.Equal(t, "Authorization", config.Headers[1].Key) + assert.Equal(t, []string{"Bearer token"}, config.Headers[1].Value) + }) + + t.Run("Parse with cookies", func(t *testing.T) { + parser := NewConfigCLIParser([]string{"dodo", "-c", "session=abc123", "-c", "user=john"}) + config, err := parser.Parse() + + require.NoError(t, err) + require.NotNil(t, config) + assert.Len(t, config.Cookies, 2) + assert.Equal(t, "session", config.Cookies[0].Key) + assert.Equal(t, []string{"abc123"}, config.Cookies[0].Value) + assert.Equal(t, "user", config.Cookies[1].Key) + assert.Equal(t, []string{"john"}, config.Cookies[1].Value) + }) + + t.Run("Parse with bodies", func(t *testing.T) { + parser := NewConfigCLIParser([]string{"dodo", "-b", "body1", "-b", "body2"}) + config, err := parser.Parse() + + require.NoError(t, err) + require.NotNil(t, config) + assert.Len(t, config.Bodies, 2) + assert.Equal(t, types.Body("body1"), config.Bodies[0]) + assert.Equal(t, types.Body("body2"), config.Bodies[1]) + }) + + t.Run("Parse with valid proxies", func(t *testing.T) { + parser := NewConfigCLIParser([]string{"dodo", "-x", "http://proxy1.example.com:8080", "-x", "socks5://proxy2.example.com:1080"}) + config, err := parser.Parse() + + require.NoError(t, err) + require.NotNil(t, config) + assert.Len(t, config.Proxies, 2) + assert.Equal(t, "http://proxy1.example.com:8080", config.Proxies[0].String()) + assert.Equal(t, "socks5://proxy2.example.com:1080", config.Proxies[1].String()) + }) + + t.Run("Parse with invalid proxy returns FieldParseErrors", func(t *testing.T) { + parser := NewConfigCLIParser([]string{"dodo", "-x", "://invalid-proxy"}) + config, err := parser.Parse() + + assert.Nil(t, config) + var fieldErr types.FieldParseErrors + require.ErrorAs(t, err, &fieldErr) + assert.Len(t, fieldErr.Errors, 1) + assert.Equal(t, "proxy[0]", fieldErr.Errors[0].Field) + }) + + t.Run("Parse with mixed valid and invalid proxies", func(t *testing.T) { + parser := NewConfigCLIParser([]string{"dodo", "-x", "http://valid.example.com:8080", "-x", "://invalid"}) + config, err := parser.Parse() + + assert.Nil(t, config) + var fieldErr types.FieldParseErrors + require.ErrorAs(t, err, &fieldErr) + assert.Len(t, fieldErr.Errors, 1) + assert.Equal(t, "proxy[1]", fieldErr.Errors[0].Field) + }) + + t.Run("Parse with long flag names", func(t *testing.T) { + parser := NewConfigCLIParser([]string{ + "dodo", + "--url", "https://example.com", + "--method", "POST", + "--yes", + "--skip-verify", + "--dodos", "3", + "--requests", "500", + "--duration", "1m", + "--timeout", "10s", + }) + config, err := parser.Parse() + + require.NoError(t, err) + require.NotNil(t, config) + + assert.Equal(t, "https://example.com", config.URL.String()) + assert.Equal(t, "POST", *config.Method) + assert.True(t, *config.Yes) + assert.True(t, *config.SkipVerify) + assert.Equal(t, uint(3), *config.DodosCount) + assert.Equal(t, uint(500), *config.RequestCount) + assert.Equal(t, time.Minute, *config.Duration) + assert.Equal(t, 10*time.Second, *config.Timeout) + }) + + t.Run("Parse with 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", + }) + 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()) + }) + + t.Run("Parse with multiple field parse errors", func(t *testing.T) { + parser := NewConfigCLIParser([]string{ + "dodo", + "-u", "://invalid-url", + "-x", "://invalid-proxy1", + "-x", "://invalid-proxy2", + }) + config, err := parser.Parse() + + assert.Nil(t, config) + var fieldErr types.FieldParseErrors + require.ErrorAs(t, err, &fieldErr) + assert.Len(t, fieldErr.Errors, 3) + + // Check error fields + fields := make(map[string]bool) + for _, parseErr := range fieldErr.Errors { + fields[parseErr.Field] = true + } + assert.True(t, fields["url"]) + assert.True(t, fields["proxy[0]"]) + assert.True(t, fields["proxy[1]"]) + }) +} + +func TestConfigCLIParser_PrintHelp(t *testing.T) { + t.Run("PrintHelp outputs expected content", func(t *testing.T) { + // Capture stdout + oldStdout := os.Stdout + reader, writer, _ := os.Pipe() + os.Stdout = writer + + parser := NewConfigCLIParser([]string{"dodo"}) + parser.PrintHelp() + + // Restore stdout and read output + writer.Close() + os.Stdout = oldStdout + var buf bytes.Buffer + io.Copy(&buf, reader) + output := buf.String() + + // Verify help text contains expected elements + assert.Contains(t, output, "Usage:") + assert.Contains(t, output, "dodo [flags]") + assert.Contains(t, output, "Examples:") + assert.Contains(t, output, "Flags:") + assert.Contains(t, output, "-h, -help") + assert.Contains(t, output, "-v, -version") + assert.Contains(t, output, "-u, -url") + assert.Contains(t, output, "-m, -method") + assert.Contains(t, output, "-d, -dodos") + assert.Contains(t, output, "-r, -requests") + assert.Contains(t, output, "-t, -timeout") + assert.Contains(t, output, "-b, -body") + assert.Contains(t, output, "-H, -header") + assert.Contains(t, output, "-p, -param") + assert.Contains(t, output, "-c, -cookie") + assert.Contains(t, output, "-x, -proxy") + assert.Contains(t, output, "-skip-verify") + assert.Contains(t, output, "-y, -yes") + + // 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) + }) +} diff --git a/pkg/config/config.go b/pkg/config/config.go new file mode 100644 index 0000000..86f2b18 --- /dev/null +++ b/pkg/config/config.go @@ -0,0 +1,112 @@ +package config + +import ( + "net/url" + "time" + + "github.com/aykhans/dodo/pkg/types" + "github.com/aykhans/dodo/pkg/utils" +) + +const VERSION string = "1.0.0" + +var Defaults = struct { + UserAgent string + Method string + RequestTimeout time.Duration + DodosCount uint + Yes bool + SkipVerify bool +}{ + UserAgent: "dodo/" + VERSION, + Method: "GET", + RequestTimeout: time.Second * 10, + DodosCount: 1, + Yes: false, + SkipVerify: false, +} + +var SupportedProxySchemes []string = []string{"http", "socks5", "socks5h"} + +type Config struct { + Method *string + URL *url.URL + Timeout *time.Duration + DodosCount *uint + RequestCount *uint + Duration *time.Duration + Yes *bool + SkipVerify *bool + Params types.Params + Headers types.Headers + Cookies types.Cookies + Bodies types.Bodies + Proxies types.Proxies +} + +func NewConfig() *Config { + return &Config{} +} + +func (config *Config) MergeConfig(newConfig *Config) { + if newConfig.Method != nil { + config.Method = newConfig.Method + } + if newConfig.URL != nil { + config.URL = newConfig.URL + } + if newConfig.Timeout != nil { + config.Timeout = newConfig.Timeout + } + if newConfig.DodosCount != nil { + config.DodosCount = newConfig.DodosCount + } + if newConfig.RequestCount != nil { + config.RequestCount = newConfig.RequestCount + } + if newConfig.Duration != nil { + config.Duration = newConfig.Duration + } + if newConfig.Yes != nil { + config.Yes = newConfig.Yes + } + if newConfig.SkipVerify != nil { + config.SkipVerify = newConfig.SkipVerify + } + if len(newConfig.Params) != 0 { + config.Params = newConfig.Params + } + if len(newConfig.Headers) != 0 { + config.Headers = newConfig.Headers + } + if len(newConfig.Cookies) != 0 { + config.Cookies = newConfig.Cookies + } + if len(newConfig.Bodies) != 0 { + config.Bodies = newConfig.Bodies + } + if len(newConfig.Proxies) != 0 { + config.Proxies = newConfig.Proxies + } +} + +func (config *Config) SetDefaults() { + if config.Method == nil { + config.Method = utils.ToPtr(Defaults.Method) + } + if config.Timeout == nil { + config.Timeout = &Defaults.RequestTimeout + } + if config.DodosCount == nil { + config.DodosCount = utils.ToPtr(Defaults.DodosCount) + } + if config.Yes == nil { + config.Yes = utils.ToPtr(Defaults.Yes) + } + if config.SkipVerify == nil { + config.SkipVerify = utils.ToPtr(Defaults.SkipVerify) + } + if !config.Headers.Has("User-Agent") { + config.Headers = append(config.Headers, types.Header{Key: "User-Agent", Value: []string{Defaults.UserAgent}}) + } +} diff --git a/pkg/config/config_test.go b/pkg/config/config_test.go new file mode 100644 index 0000000..e7c4f20 --- /dev/null +++ b/pkg/config/config_test.go @@ -0,0 +1,318 @@ +package config + +import ( + "net/url" + "testing" + "time" + + "github.com/aykhans/dodo/pkg/types" + "github.com/aykhans/dodo/pkg/utils" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestMergeConfig(t *testing.T) { + t.Run("MergeConfig with all fields from new config", func(t *testing.T) { + originalURL, _ := url.Parse("https://original.example.com") + newURL, _ := url.Parse("https://new.example.com") + + originalTimeout := 5 * time.Second + newTimeout := 10 * time.Second + + originalDuration := 1 * time.Minute + newDuration := 2 * time.Minute + + config := &Config{ + Method: utils.ToPtr("GET"), + URL: originalURL, + Timeout: &originalTimeout, + DodosCount: utils.ToPtr(uint(1)), + RequestCount: utils.ToPtr(uint(10)), + Duration: &originalDuration, + Yes: utils.ToPtr(false), + SkipVerify: utils.ToPtr(false), + Params: types.Params{{Key: "old", Value: []string{"value"}}}, + Headers: types.Headers{{Key: "Old-Header", Value: []string{"old"}}}, + Cookies: types.Cookies{{Key: "oldCookie", Value: []string{"oldValue"}}}, + Bodies: types.Bodies{types.Body("old body")}, + Proxies: types.Proxies{}, + } + + newConfig := &Config{ + Method: utils.ToPtr("POST"), + URL: newURL, + Timeout: &newTimeout, + DodosCount: utils.ToPtr(uint(5)), + RequestCount: utils.ToPtr(uint(20)), + Duration: &newDuration, + Yes: utils.ToPtr(true), + SkipVerify: utils.ToPtr(true), + Params: types.Params{{Key: "new", Value: []string{"value"}}}, + Headers: types.Headers{{Key: "New-Header", Value: []string{"new"}}}, + Cookies: types.Cookies{{Key: "newCookie", Value: []string{"newValue"}}}, + Bodies: types.Bodies{types.Body("new body")}, + Proxies: types.Proxies{}, + } + + config.MergeConfig(newConfig) + + assert.Equal(t, "POST", *config.Method) + assert.Equal(t, newURL, config.URL) + assert.Equal(t, newTimeout, *config.Timeout) + assert.Equal(t, uint(5), *config.DodosCount) + assert.Equal(t, uint(20), *config.RequestCount) + assert.Equal(t, newDuration, *config.Duration) + assert.True(t, *config.Yes) + assert.True(t, *config.SkipVerify) + assert.Equal(t, types.Params{{Key: "new", Value: []string{"value"}}}, config.Params) + assert.Equal(t, types.Headers{{Key: "New-Header", Value: []string{"new"}}}, config.Headers) + assert.Equal(t, types.Cookies{{Key: "newCookie", Value: []string{"newValue"}}}, config.Cookies) + assert.Equal(t, types.Bodies{types.Body("new body")}, config.Bodies) + assert.Empty(t, config.Proxies) + }) + + t.Run("MergeConfig with partial fields from new config", func(t *testing.T) { + originalURL, _ := url.Parse("https://original.example.com") + originalTimeout := 5 * time.Second + + config := &Config{ + Method: utils.ToPtr("GET"), + URL: originalURL, + Timeout: &originalTimeout, + DodosCount: utils.ToPtr(uint(1)), + Headers: types.Headers{{Key: "Original-Header", Value: []string{"original"}}}, + } + + newURL, _ := url.Parse("https://new.example.com") + newConfig := &Config{ + URL: newURL, + DodosCount: utils.ToPtr(uint(10)), + } + + config.MergeConfig(newConfig) + + assert.Equal(t, "GET", *config.Method, "Method should remain unchanged") + assert.Equal(t, newURL, config.URL, "URL should be updated") + assert.Equal(t, originalTimeout, *config.Timeout, "Timeout should remain unchanged") + assert.Equal(t, uint(10), *config.DodosCount, "DodosCount should be updated") + assert.Equal(t, types.Headers{{Key: "Original-Header", Value: []string{"original"}}}, config.Headers, "Headers should remain unchanged") + }) + + t.Run("MergeConfig with nil new config fields", func(t *testing.T) { + originalURL, _ := url.Parse("https://original.example.com") + originalTimeout := 5 * time.Second + + config := &Config{ + Method: utils.ToPtr("GET"), + URL: originalURL, + Timeout: &originalTimeout, + DodosCount: utils.ToPtr(uint(1)), + Yes: utils.ToPtr(false), + SkipVerify: utils.ToPtr(false), + } + + newConfig := &Config{ + Method: nil, + URL: nil, + Timeout: nil, + DodosCount: nil, + Yes: nil, + SkipVerify: nil, + } + + originalConfigCopy := *config + config.MergeConfig(newConfig) + + assert.Equal(t, originalConfigCopy.Method, config.Method) + assert.Equal(t, originalConfigCopy.URL, config.URL) + assert.Equal(t, originalConfigCopy.Timeout, config.Timeout) + assert.Equal(t, originalConfigCopy.DodosCount, config.DodosCount) + assert.Equal(t, originalConfigCopy.Yes, config.Yes) + assert.Equal(t, originalConfigCopy.SkipVerify, config.SkipVerify) + }) + + t.Run("MergeConfig with empty slices", func(t *testing.T) { + config := &Config{ + Params: types.Params{{Key: "original", Value: []string{"value"}}}, + Headers: types.Headers{{Key: "Original-Header", Value: []string{"original"}}}, + Cookies: types.Cookies{{Key: "originalCookie", Value: []string{"originalValue"}}}, + Bodies: types.Bodies{types.Body("original body")}, + Proxies: types.Proxies{}, + } + + newConfig := &Config{ + Params: types.Params{}, + Headers: types.Headers{}, + Cookies: types.Cookies{}, + Bodies: types.Bodies{}, + Proxies: types.Proxies{}, + } + + config.MergeConfig(newConfig) + + assert.Equal(t, types.Params{{Key: "original", Value: []string{"value"}}}, config.Params, "Empty Params should not override") + assert.Equal(t, types.Headers{{Key: "Original-Header", Value: []string{"original"}}}, config.Headers, "Empty Headers should not override") + assert.Equal(t, types.Cookies{{Key: "originalCookie", Value: []string{"originalValue"}}}, config.Cookies, "Empty Cookies should not override") + assert.Equal(t, types.Bodies{types.Body("original body")}, config.Bodies, "Empty Bodies should not override") + assert.Equal(t, types.Proxies{}, config.Proxies, "Empty Proxies should not override") + }) + + t.Run("MergeConfig on empty original config", func(t *testing.T) { + config := &Config{} + + newURL, _ := url.Parse("https://new.example.com") + newTimeout := 10 * time.Second + newDuration := 2 * time.Minute + + newConfig := &Config{ + Method: utils.ToPtr("POST"), + URL: newURL, + Timeout: &newTimeout, + DodosCount: utils.ToPtr(uint(5)), + RequestCount: utils.ToPtr(uint(20)), + Duration: &newDuration, + Yes: utils.ToPtr(true), + SkipVerify: utils.ToPtr(true), + Params: types.Params{{Key: "new", Value: []string{"value"}}}, + Headers: types.Headers{{Key: "New-Header", Value: []string{"new"}}}, + Cookies: types.Cookies{{Key: "newCookie", Value: []string{"newValue"}}}, + Bodies: types.Bodies{types.Body("new body")}, + Proxies: types.Proxies{}, + } + + config.MergeConfig(newConfig) + + assert.Equal(t, "POST", *config.Method) + assert.Equal(t, newURL, config.URL) + assert.Equal(t, newTimeout, *config.Timeout) + assert.Equal(t, uint(5), *config.DodosCount) + assert.Equal(t, uint(20), *config.RequestCount) + assert.Equal(t, newDuration, *config.Duration) + assert.True(t, *config.Yes) + assert.True(t, *config.SkipVerify) + assert.Equal(t, types.Params{{Key: "new", Value: []string{"value"}}}, config.Params) + assert.Equal(t, types.Headers{{Key: "New-Header", Value: []string{"new"}}}, config.Headers) + assert.Equal(t, types.Cookies{{Key: "newCookie", Value: []string{"newValue"}}}, config.Cookies) + assert.Equal(t, types.Bodies{types.Body("new body")}, config.Bodies) + assert.Empty(t, config.Proxies) + }) +} + +func TestSetDefaults(t *testing.T) { + t.Run("SetDefaults on empty config", func(t *testing.T) { + config := &Config{} + config.SetDefaults() + + require.NotNil(t, config.Method) + assert.Equal(t, Defaults.Method, *config.Method) + + require.NotNil(t, config.Timeout) + assert.Equal(t, Defaults.RequestTimeout, *config.Timeout) + + require.NotNil(t, config.DodosCount) + assert.Equal(t, Defaults.DodosCount, *config.DodosCount) + + require.NotNil(t, config.Yes) + assert.Equal(t, Defaults.Yes, *config.Yes) + + require.NotNil(t, config.SkipVerify) + assert.Equal(t, Defaults.SkipVerify, *config.SkipVerify) + + assert.True(t, config.Headers.Has("User-Agent")) + assert.Equal(t, Defaults.UserAgent, config.Headers[0].Value[0]) + }) + + t.Run("SetDefaults preserves existing values", func(t *testing.T) { + customTimeout := 30 * time.Second + config := &Config{ + Method: utils.ToPtr("POST"), + Timeout: &customTimeout, + DodosCount: utils.ToPtr(uint(10)), + Yes: utils.ToPtr(true), + SkipVerify: utils.ToPtr(true), + Headers: types.Headers{{Key: "User-Agent", Value: []string{"custom-agent"}}}, + } + + config.SetDefaults() + + assert.Equal(t, "POST", *config.Method, "Method should not be overridden") + assert.Equal(t, customTimeout, *config.Timeout, "Timeout should not be overridden") + assert.Equal(t, uint(10), *config.DodosCount, "DodosCount should not be overridden") + assert.True(t, *config.Yes, "Yes should not be overridden") + assert.True(t, *config.SkipVerify, "SkipVerify should not be overridden") + assert.Equal(t, "custom-agent", config.Headers[0].Value[0], "User-Agent should not be overridden") + assert.Len(t, config.Headers, 1, "Should not add duplicate User-Agent") + }) + + t.Run("SetDefaults adds User-Agent when missing", func(t *testing.T) { + config := &Config{ + Headers: types.Headers{{Key: "Content-Type", Value: []string{"application/json"}}}, + } + + config.SetDefaults() + + assert.Len(t, config.Headers, 2) + assert.True(t, config.Headers.Has("User-Agent")) + assert.True(t, config.Headers.Has("Content-Type")) + + var userAgentFound bool + for _, h := range config.Headers { + if h.Key == "User-Agent" { + userAgentFound = true + assert.Equal(t, Defaults.UserAgent, h.Value[0]) + break + } + } + assert.True(t, userAgentFound, "User-Agent header should be added") + }) + + t.Run("SetDefaults with partial config", func(t *testing.T) { + config := &Config{ + Method: utils.ToPtr("PUT"), + Yes: utils.ToPtr(true), + } + + config.SetDefaults() + + assert.Equal(t, "PUT", *config.Method, "Existing Method should be preserved") + assert.True(t, *config.Yes, "Existing Yes should be preserved") + + require.NotNil(t, config.Timeout) + assert.Equal(t, Defaults.RequestTimeout, *config.Timeout, "Timeout should be set to default") + + require.NotNil(t, config.DodosCount) + assert.Equal(t, Defaults.DodosCount, *config.DodosCount, "DodosCount should be set to default") + + require.NotNil(t, config.SkipVerify) + assert.Equal(t, Defaults.SkipVerify, *config.SkipVerify, "SkipVerify should be set to default") + + assert.True(t, config.Headers.Has("User-Agent")) + }) + + t.Run("SetDefaults idempotent", func(t *testing.T) { + config := &Config{} + + config.SetDefaults() + firstCallHeaders := len(config.Headers) + firstCallMethod := *config.Method + firstCallTimeout := *config.Timeout + + config.SetDefaults() + + assert.Len(t, config.Headers, firstCallHeaders, "Headers count should not change on second call") + assert.Equal(t, firstCallMethod, *config.Method, "Method should not change on second call") + assert.Equal(t, firstCallTimeout, *config.Timeout, "Timeout should not change on second call") + }) + + t.Run("SetDefaults with empty Headers initializes correctly", func(t *testing.T) { + config := &Config{ + Headers: types.Headers{}, + } + + config.SetDefaults() + + assert.Len(t, config.Headers, 1) + assert.Equal(t, "User-Agent", config.Headers[0].Key) + assert.Equal(t, Defaults.UserAgent, config.Headers[0].Value[0]) + }) +} diff --git a/pkg/types/body.go b/pkg/types/body.go new file mode 100644 index 0000000..7e4ae08 --- /dev/null +++ b/pkg/types/body.go @@ -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) +} diff --git a/pkg/types/body_test.go b/pkg/types/body_test.go new file mode 100644 index 0000000..deb7a06 --- /dev/null +++ b/pkg/types/body_test.go @@ -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) + }) +} diff --git a/pkg/types/config_file.go b/pkg/types/config_file.go new file mode 100644 index 0000000..d4026a4 --- /dev/null +++ b/pkg/types/config_file.go @@ -0,0 +1,92 @@ +package types + +import ( + "net/url" + "path/filepath" + "strings" +) + +type ConfigFileType int + +const ( + ConfigFileTypeYAML ConfigFileType = iota +) + +func (t ConfigFileType) String() string { + switch t { + case ConfigFileTypeYAML: + return "yaml" + default: + return "unknown" + } +} + +type ConfigFileLocationType int + +const ( + ConfigFileLocationLocal ConfigFileLocationType = iota + ConfigFileLocationRemote +) + +func (l ConfigFileLocationType) String() string { + switch l { + case ConfigFileLocationLocal: + return "local" + case ConfigFileLocationRemote: + return "remote" + default: + return "unknown" + } +} + +type ConfigFile struct { + path string + _type ConfigFileType + locationType ConfigFileLocationType +} + +func (configFile ConfigFile) String() string { + return configFile.path +} + +func (configFile ConfigFile) Type() ConfigFileType { + return configFile._type +} + +func (configFile ConfigFile) LocationType() ConfigFileLocationType { + return configFile.locationType +} + +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 +} diff --git a/pkg/types/config_file_test.go b/pkg/types/config_file_test.go new file mode 100644 index 0000000..fe5f1c7 --- /dev/null +++ b/pkg/types/config_file_test.go @@ -0,0 +1,216 @@ +package types + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestConfigFileType_String(t *testing.T) { + t.Run("ConfigFileTypeYAML returns yaml", func(t *testing.T) { + configType := ConfigFileTypeYAML + assert.Equal(t, "yaml", configType.String()) + }) + + t.Run("Unknown config file type returns unknown", func(t *testing.T) { + configType := ConfigFileType(999) + assert.Equal(t, "unknown", configType.String()) + }) +} + +func TestConfigFileLocationType_String(t *testing.T) { + t.Run("ConfigFileLocationLocal returns local", func(t *testing.T) { + locationType := ConfigFileLocationLocal + assert.Equal(t, "local", locationType.String()) + }) + + t.Run("ConfigFileLocationRemote returns remote", func(t *testing.T) { + locationType := ConfigFileLocationRemote + assert.Equal(t, "remote", locationType.String()) + }) + + t.Run("Unknown location type returns unknown", func(t *testing.T) { + locationType := ConfigFileLocationType(999) + assert.Equal(t, "unknown", locationType.String()) + }) +} + +func TestConfigFile_String(t *testing.T) { + t.Run("String returns the file path", func(t *testing.T) { + configFile := ConfigFile{path: "/path/to/config.yaml"} + assert.Equal(t, "/path/to/config.yaml", configFile.String()) + }) + + t.Run("String returns empty path", func(t *testing.T) { + configFile := ConfigFile{path: ""} + assert.Empty(t, configFile.String()) + }) +} + +func TestConfigFile_Type(t *testing.T) { + t.Run("Type returns the config file type", func(t *testing.T) { + configFile := ConfigFile{_type: ConfigFileTypeYAML} + assert.Equal(t, ConfigFileTypeYAML, configFile.Type()) + }) +} + +func TestConfigFile_LocationType(t *testing.T) { + t.Run("LocationType returns local", func(t *testing.T) { + configFile := ConfigFile{locationType: ConfigFileLocationLocal} + assert.Equal(t, ConfigFileLocationLocal, configFile.LocationType()) + }) + + t.Run("LocationType returns remote", func(t *testing.T) { + configFile := ConfigFile{locationType: ConfigFileLocationRemote} + assert.Equal(t, ConfigFileLocationRemote, configFile.LocationType()) + }) +} + +func TestParseConfigFile(t *testing.T) { + t.Run("Parse local YAML file with yml extension", func(t *testing.T) { + configFile, err := ParseConfigFile("config.yml") + + require.NoError(t, err) + require.NotNil(t, configFile) + assert.Equal(t, "config.yml", configFile.String()) + assert.Equal(t, ConfigFileTypeYAML, configFile.Type()) + assert.Equal(t, ConfigFileLocationLocal, configFile.LocationType()) + }) + + t.Run("Parse local YAML file with yaml extension", func(t *testing.T) { + configFile, err := ParseConfigFile("config.yaml") + + require.NoError(t, err) + require.NotNil(t, configFile) + assert.Equal(t, "config.yaml", configFile.String()) + assert.Equal(t, ConfigFileTypeYAML, configFile.Type()) + assert.Equal(t, ConfigFileLocationLocal, configFile.LocationType()) + }) + + t.Run("Parse local YAML file with uppercase extensions", func(t *testing.T) { + testCases := []string{"config.YML", "config.YAML", "config.Yml", "config.Yaml"} + + for _, testCase := range testCases { + t.Run("Extension: "+testCase, func(t *testing.T) { + configFile, err := ParseConfigFile(testCase) + + require.NoError(t, err) + require.NotNil(t, configFile) + assert.Equal(t, testCase, configFile.String()) + assert.Equal(t, ConfigFileTypeYAML, configFile.Type()) + assert.Equal(t, ConfigFileLocationLocal, configFile.LocationType()) + }) + } + }) + + t.Run("Parse remote HTTP YAML file", func(t *testing.T) { + configFile, err := ParseConfigFile("http://example.com/config.yaml") + + require.NoError(t, err) + require.NotNil(t, configFile) + assert.Equal(t, "http://example.com/config.yaml", configFile.String()) + assert.Equal(t, ConfigFileTypeYAML, configFile.Type()) + assert.Equal(t, ConfigFileLocationRemote, configFile.LocationType()) + }) + + t.Run("Parse remote HTTPS YAML file", func(t *testing.T) { + configFile, err := ParseConfigFile("https://example.com/path/config.yml") + + require.NoError(t, err) + require.NotNil(t, configFile) + assert.Equal(t, "https://example.com/path/config.yml", configFile.String()) + assert.Equal(t, ConfigFileTypeYAML, configFile.Type()) + assert.Equal(t, ConfigFileLocationRemote, configFile.LocationType()) + }) + + t.Run("Parse file with path separators", func(t *testing.T) { + configFile, err := ParseConfigFile("/path/to/config.yaml") + + require.NoError(t, err) + require.NotNil(t, configFile) + assert.Equal(t, "/path/to/config.yaml", configFile.String()) + assert.Equal(t, ConfigFileTypeYAML, configFile.Type()) + assert.Equal(t, ConfigFileLocationLocal, configFile.LocationType()) + }) + + t.Run("Parse file without extension returns error", func(t *testing.T) { + configFile, err := ParseConfigFile("config") + + require.Error(t, err) + assert.Equal(t, ErrConfigFileExtensionNotFound, err) + assert.Nil(t, configFile) + }) + + t.Run("Parse file with unsupported extension returns error", func(t *testing.T) { + configFile, err := ParseConfigFile("config.json") + + require.Error(t, err) + assert.IsType(t, &UnknownConfigFileTypeError{}, err) + assert.Contains(t, err.Error(), "json") + assert.Nil(t, configFile) + }) + + t.Run("Parse remote file with invalid URL returns error", func(t *testing.T) { + configFile, err := ParseConfigFile("http://192.168.1.%30/config.yaml") + + require.Error(t, err) + assert.IsType(t, &RemoteConfigFileParseError{}, err) + assert.Nil(t, configFile) + }) + + t.Run("Parse remote file without extension returns error", func(t *testing.T) { + configFile, err := ParseConfigFile("https://example.com/config") + + require.Error(t, err) + assert.Equal(t, ErrConfigFileExtensionNotFound, err) + assert.Nil(t, configFile) + }) + + t.Run("Parse remote file with unsupported extension returns error", func(t *testing.T) { + configFile, err := ParseConfigFile("https://example.com/config.txt") + + require.Error(t, err) + assert.IsType(t, &UnknownConfigFileTypeError{}, err) + assert.Contains(t, err.Error(), "txt") + assert.Nil(t, configFile) + }) + + t.Run("Parse empty string returns error", func(t *testing.T) { + configFile, err := ParseConfigFile("") + + require.Error(t, err) + assert.Equal(t, ErrConfigFileExtensionNotFound, err) + assert.Nil(t, configFile) + }) + + t.Run("Parse file with multiple dots in name", func(t *testing.T) { + configFile, err := ParseConfigFile("config.test.yaml") + + require.NoError(t, err) + require.NotNil(t, configFile) + assert.Equal(t, "config.test.yaml", configFile.String()) + assert.Equal(t, ConfigFileTypeYAML, configFile.Type()) + assert.Equal(t, ConfigFileLocationLocal, configFile.LocationType()) + }) + + t.Run("Parse remote URL with query parameters and fragment", func(t *testing.T) { + configFile, err := ParseConfigFile("https://example.com/path/config.yaml?version=1&format=yaml#section") + + require.NoError(t, err) + require.NotNil(t, configFile) + assert.Equal(t, "https://example.com/path/config.yaml?version=1&format=yaml#section", configFile.String()) + assert.Equal(t, ConfigFileTypeYAML, configFile.Type()) + assert.Equal(t, ConfigFileLocationRemote, configFile.LocationType()) + }) + + t.Run("Parse remote URL with port", func(t *testing.T) { + configFile, err := ParseConfigFile("https://example.com:8080/config.yml") + + require.NoError(t, err) + require.NotNil(t, configFile) + assert.Equal(t, "https://example.com:8080/config.yml", configFile.String()) + assert.Equal(t, ConfigFileTypeYAML, configFile.Type()) + assert.Equal(t, ConfigFileLocationRemote, configFile.LocationType()) + }) +} diff --git a/pkg/types/cookie.go b/pkg/types/cookie.go new file mode 100644 index 0000000..c2d97a8 --- /dev/null +++ b/pkg/types/cookie.go @@ -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{""}} + } +} diff --git a/pkg/types/cookie_test.go b/pkg/types/cookie_test.go new file mode 100644 index 0000000..a457d95 --- /dev/null +++ b/pkg/types/cookie_test.go @@ -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) + }) +} diff --git a/pkg/types/errors.go b/pkg/types/errors.go new file mode 100644 index 0000000..963180b --- /dev/null +++ b/pkg/types/errors.go @@ -0,0 +1,113 @@ +package types + +import ( + "errors" + "fmt" + "strings" +) + +var ( + // General + ErrNoError = errors.New("no error (internal)") + + // CLI + ErrCLINoArgs = errors.New("CLI expects arguments but received none") + ErrCLIUnexpectedArgs = errors.New("CLI received unexpected arguments") + + // Config File + ErrConfigFileExtensionNotFound = errors.New("config file extension not found") +) + +// ======================================== General ======================================== + +type FieldParseError struct { + Field string + Err error +} + +func NewFieldParseError(field string, err error) *FieldParseError { + if err == nil { + err = ErrNoError + } + return &FieldParseError{field, err} +} + +func (e FieldParseError) Error() string { + return fmt.Sprintf("Field '%s' parse failed: %v", e.Field, e.Err) +} + +func (e FieldParseError) Unwrap() error { + return e.Err +} + +type FieldParseErrors struct { + Errors []FieldParseError +} + +func NewFieldParseErrors(fieldParseErrors []FieldParseError) FieldParseErrors { + return FieldParseErrors{fieldParseErrors} +} + +func (e FieldParseErrors) Error() string { + if len(e.Errors) == 0 { + return "No field parse errors" + } + if len(e.Errors) == 1 { + return e.Errors[0].Error() + } + + errorString := "" + for _, err := range e.Errors { + errorString += err.Error() + "\n" + } + errorString, _ = strings.CutSuffix(errorString, "\n") + + return errorString +} + +// ======================================== CLI ======================================== + +type CLIUnexpectedArgsError struct { + Args []string +} + +func NewCLIUnexpectedArgsError(args []string) CLIUnexpectedArgsError { + return CLIUnexpectedArgsError{args} +} + +func (e CLIUnexpectedArgsError) Error() string { + return fmt.Sprintf("CLI received unexpected arguments: %v", strings.Join(e.Args, ",")) +} + +// ======================================== Config File ======================================== + +type RemoteConfigFileParseError struct { + error error +} + +func NewRemoteConfigFileParseError(err error) *RemoteConfigFileParseError { + if err == nil { + err = ErrNoError + } + return &RemoteConfigFileParseError{err} +} + +func (e RemoteConfigFileParseError) Error() string { + return "Remote config file parse error: " + e.error.Error() +} + +func (e RemoteConfigFileParseError) Unwrap() error { + return e.error +} + +type UnknownConfigFileTypeError struct { + Type string +} + +func NewUnknownConfigFileTypeError(_type string) *UnknownConfigFileTypeError { + return &UnknownConfigFileTypeError{_type} +} + +func (e UnknownConfigFileTypeError) Error() string { + return "Unknown config file type: " + e.Type +} diff --git a/pkg/types/errors_test.go b/pkg/types/errors_test.go new file mode 100644 index 0000000..3788eaa --- /dev/null +++ b/pkg/types/errors_test.go @@ -0,0 +1,284 @@ +package types + +import ( + "errors" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestFieldParseError_Error(t *testing.T) { + t.Run("Error returns formatted message", func(t *testing.T) { + originalErr := errors.New("invalid value") + fieldErr := NewFieldParseError("username", originalErr) + + expected := "Field 'username' parse failed: invalid value" + assert.Equal(t, expected, fieldErr.Error()) + }) + + t.Run("Error with empty field name", func(t *testing.T) { + originalErr := errors.New("test error") + fieldErr := NewFieldParseError("", originalErr) + + expected := "Field '' parse failed: test error" + assert.Equal(t, expected, fieldErr.Error()) + }) + + t.Run("Error with nil underlying error", func(t *testing.T) { + fieldErr := NewFieldParseError("field", nil) + + expected := "Field 'field' parse failed: no error (internal)" + assert.Equal(t, expected, fieldErr.Error()) + }) +} + +func TestFieldParseError_Unwrap(t *testing.T) { + t.Run("Unwrap returns original error", func(t *testing.T) { + originalErr := errors.New("original error") + fieldErr := NewFieldParseError("field", originalErr) + + assert.Equal(t, originalErr, fieldErr.Unwrap()) + }) + + t.Run("Unwrap with nil error", func(t *testing.T) { + fieldErr := NewFieldParseError("field", nil) + + assert.Equal(t, ErrNoError, fieldErr.Unwrap()) + }) +} + +func TestNewFieldParseError(t *testing.T) { + t.Run("Creates FieldParseError with correct values", func(t *testing.T) { + originalErr := errors.New("test error") + fieldErr := NewFieldParseError("testField", originalErr) + + assert.Equal(t, "testField", fieldErr.Field) + assert.Equal(t, originalErr, fieldErr.Err) + }) + + t.Run("Creates FieldParseError with ErrNoError when nil passed", func(t *testing.T) { + fieldErr := NewFieldParseError("testField", nil) + + assert.Equal(t, "testField", fieldErr.Field) + assert.Equal(t, ErrNoError, fieldErr.Err) + }) +} + +func TestFieldParseErrors_Error(t *testing.T) { + t.Run("Error with no errors returns default message", func(t *testing.T) { + fieldErrors := NewFieldParseErrors([]FieldParseError{}) + + assert.Equal(t, "No field parse errors", fieldErrors.Error()) + }) + + t.Run("Error with single error returns single error message", func(t *testing.T) { + fieldErr := *NewFieldParseError("field1", errors.New("error1")) + fieldErrors := NewFieldParseErrors([]FieldParseError{fieldErr}) + + expected := "Field 'field1' parse failed: error1" + assert.Equal(t, expected, fieldErrors.Error()) + }) + + t.Run("Error with multiple errors returns concatenated messages", func(t *testing.T) { + fieldErr1 := *NewFieldParseError("field1", errors.New("error1")) + fieldErr2 := *NewFieldParseError("field2", errors.New("error2")) + fieldErr3 := *NewFieldParseError("field3", errors.New("error3")) + fieldErrors := NewFieldParseErrors([]FieldParseError{fieldErr1, fieldErr2, fieldErr3}) + + expected := "Field 'field1' parse failed: error1\nField 'field2' parse failed: error2\nField 'field3' parse failed: error3" + assert.Equal(t, expected, fieldErrors.Error()) + }) + + t.Run("Error with two errors", func(t *testing.T) { + fieldErr1 := *NewFieldParseError("username", errors.New("too short")) + fieldErr2 := *NewFieldParseError("email", errors.New("invalid format")) + fieldErrors := NewFieldParseErrors([]FieldParseError{fieldErr1, fieldErr2}) + + expected := "Field 'username' parse failed: too short\nField 'email' parse failed: invalid format" + assert.Equal(t, expected, fieldErrors.Error()) + }) +} + +func TestNewFieldParseErrors(t *testing.T) { + t.Run("Creates FieldParseErrors with correct values", func(t *testing.T) { + fieldErr1 := *NewFieldParseError("field1", errors.New("error1")) + fieldErr2 := *NewFieldParseError("field2", errors.New("error2")) + fieldErrors := NewFieldParseErrors([]FieldParseError{fieldErr1, fieldErr2}) + + assert.Len(t, fieldErrors.Errors, 2) + assert.Equal(t, fieldErr1, fieldErrors.Errors[0]) + assert.Equal(t, fieldErr2, fieldErrors.Errors[1]) + }) + + t.Run("Creates FieldParseErrors with empty slice", func(t *testing.T) { + fieldErrors := NewFieldParseErrors([]FieldParseError{}) + + assert.Empty(t, fieldErrors.Errors) + }) +} + +func TestCLIUnexpectedArgsError_Error(t *testing.T) { + t.Run("Error with single argument", func(t *testing.T) { + err := NewCLIUnexpectedArgsError([]string{"arg1"}) + + expected := "CLI received unexpected arguments: arg1" + assert.Equal(t, expected, err.Error()) + }) + + t.Run("Error with multiple arguments", func(t *testing.T) { + err := NewCLIUnexpectedArgsError([]string{"arg1", "arg2", "arg3"}) + + expected := "CLI received unexpected arguments: arg1,arg2,arg3" + assert.Equal(t, expected, err.Error()) + }) + + t.Run("Error with empty arguments", func(t *testing.T) { + err := NewCLIUnexpectedArgsError([]string{}) + + expected := "CLI received unexpected arguments: " + assert.Equal(t, expected, err.Error()) + }) + + t.Run("Error with arguments containing special characters", func(t *testing.T) { + err := NewCLIUnexpectedArgsError([]string{"--flag", "value with spaces", "-x"}) + + expected := "CLI received unexpected arguments: --flag,value with spaces,-x" + assert.Equal(t, expected, err.Error()) + }) +} + +func TestNewCLIUnexpectedArgsError(t *testing.T) { + t.Run("Creates CLIUnexpectedArgsError with correct values", func(t *testing.T) { + args := []string{"arg1", "arg2"} + err := NewCLIUnexpectedArgsError(args) + + assert.Equal(t, args, err.Args) + }) +} + +func TestRemoteConfigFileParseError_Error(t *testing.T) { + t.Run("Error returns formatted message", func(t *testing.T) { + originalErr := errors.New("invalid URL") + err := NewRemoteConfigFileParseError(originalErr) + + expected := "Remote config file parse error: invalid URL" + assert.Equal(t, expected, err.Error()) + }) + + t.Run("Error with nil underlying error", func(t *testing.T) { + err := NewRemoteConfigFileParseError(nil) + + expected := "Remote config file parse error: no error (internal)" + assert.Equal(t, expected, err.Error()) + }) +} + +func TestRemoteConfigFileParseError_Unwrap(t *testing.T) { + t.Run("Unwrap returns original error", func(t *testing.T) { + originalErr := errors.New("original error") + err := NewRemoteConfigFileParseError(originalErr) + + assert.Equal(t, originalErr, err.Unwrap()) + }) + + t.Run("Unwrap with nil error", func(t *testing.T) { + err := NewRemoteConfigFileParseError(nil) + + assert.Equal(t, ErrNoError, err.Unwrap()) + }) +} + +func TestNewRemoteConfigFileParseError(t *testing.T) { + t.Run("Creates RemoteConfigFileParseError with correct values", func(t *testing.T) { + originalErr := errors.New("test error") + err := NewRemoteConfigFileParseError(originalErr) + + assert.Equal(t, originalErr, err.error) + }) + + t.Run("Creates RemoteConfigFileParseError with ErrNoError when nil passed", func(t *testing.T) { + err := NewRemoteConfigFileParseError(nil) + + assert.Equal(t, ErrNoError, err.error) + }) +} + +func TestUnknownConfigFileTypeError_Error(t *testing.T) { + t.Run("Error returns formatted message", func(t *testing.T) { + err := NewUnknownConfigFileTypeError("json") + + expected := "Unknown config file type: json" + assert.Equal(t, expected, err.Error()) + }) + + t.Run("Error with empty type", func(t *testing.T) { + err := NewUnknownConfigFileTypeError("") + + expected := "Unknown config file type: " + assert.Equal(t, expected, err.Error()) + }) + + t.Run("Error with special characters in type", func(t *testing.T) { + err := NewUnknownConfigFileTypeError("type.with.dots") + + expected := "Unknown config file type: type.with.dots" + assert.Equal(t, expected, err.Error()) + }) +} + +func TestNewUnknownConfigFileTypeError(t *testing.T) { + t.Run("Creates UnknownConfigFileTypeError with correct values", func(t *testing.T) { + err := NewUnknownConfigFileTypeError("xml") + + assert.Equal(t, "xml", err.Type) + }) +} + +func TestErrorConstants(t *testing.T) { + t.Run("ErrNoError has correct message", func(t *testing.T) { + expected := "no error (internal)" + assert.Equal(t, expected, ErrNoError.Error()) + }) + + t.Run("ErrCLINoArgs has correct message", func(t *testing.T) { + expected := "CLI expects arguments but received none" + assert.Equal(t, expected, ErrCLINoArgs.Error()) + }) + + t.Run("ErrCLIUnexpectedArgs has correct message", func(t *testing.T) { + expected := "CLI received unexpected arguments" + assert.Equal(t, expected, ErrCLIUnexpectedArgs.Error()) + }) + + t.Run("ErrConfigFileExtensionNotFound has correct message", func(t *testing.T) { + expected := "config file extension not found" + assert.Equal(t, expected, ErrConfigFileExtensionNotFound.Error()) + }) +} + +func TestErrorImplementsErrorInterface(t *testing.T) { + t.Run("FieldParseError implements error interface", func(t *testing.T) { + var err error = NewFieldParseError("field", errors.New("test")) + assert.Error(t, err) + }) + + t.Run("FieldParseErrors implements error interface", func(t *testing.T) { + var err error = NewFieldParseErrors([]FieldParseError{}) + assert.Error(t, err) + }) + + t.Run("CLIUnexpectedArgsError implements error interface", func(t *testing.T) { + var err error = NewCLIUnexpectedArgsError([]string{}) + assert.Error(t, err) + }) + + t.Run("RemoteConfigFileParseError implements error interface", func(t *testing.T) { + var err error = NewRemoteConfigFileParseError(errors.New("test")) + assert.Error(t, err) + }) + + t.Run("UnknownConfigFileTypeError implements error interface", func(t *testing.T) { + var err error = NewUnknownConfigFileTypeError("test") + assert.Error(t, err) + }) +} diff --git a/pkg/types/header.go b/pkg/types/header.go new file mode 100644 index 0000000..147a68d --- /dev/null +++ b/pkg/types/header.go @@ -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{""}} + } +} diff --git a/pkg/types/header_test.go b/pkg/types/header_test.go new file mode 100644 index 0000000..b5a9b23 --- /dev/null +++ b/pkg/types/header_test.go @@ -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) + }) +} diff --git a/types/key_value.go b/pkg/types/key_value.go similarity index 100% rename from types/key_value.go rename to pkg/types/key_value.go diff --git a/pkg/types/param.go b/pkg/types/param.go new file mode 100644 index 0000000..645d484 --- /dev/null +++ b/pkg/types/param.go @@ -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{""}} + } +} diff --git a/pkg/types/param_test.go b/pkg/types/param_test.go new file mode 100644 index 0000000..93b2d89 --- /dev/null +++ b/pkg/types/param_test.go @@ -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) + }) +} diff --git a/pkg/types/proxy.go b/pkg/types/proxy.go new file mode 100644 index 0000000..f5be5ab --- /dev/null +++ b/pkg/types/proxy.go @@ -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 +} diff --git a/pkg/types/proxy_test.go b/pkg/types/proxy_test.go new file mode 100644 index 0000000..3bcab02 --- /dev/null +++ b/pkg/types/proxy_test.go @@ -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") + }) +} diff --git a/utils/convert.go b/pkg/utils/convert.go similarity index 100% rename from utils/convert.go rename to pkg/utils/convert.go diff --git a/pkg/utils/convert_test.go b/pkg/utils/convert_test.go new file mode 100644 index 0000000..f0c4f9f --- /dev/null +++ b/pkg/utils/convert_test.go @@ -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 + }) +} diff --git a/pkg/utils/error.go b/pkg/utils/error.go new file mode 100644 index 0000000..ebbf33c --- /dev/null +++ b/pkg/utils/error.go @@ -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, nil // 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, + } +} diff --git a/pkg/utils/error_test.go b/pkg/utils/error_test.go new file mode 100644 index 0000000..832774e --- /dev/null +++ b/pkg/utils/error_test.go @@ -0,0 +1,386 @@ +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, result := HandleError(err, + OnSentinelError(io.EOF, func(e error) error { + return nil + }), + OnCustomError(func(e *CustomError) error { + return nil + }), + ) + assert.False(t, handled) + assert.NoError(t, result) + }) + + 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, result := HandleError(err, matcher) + assert.False(t, handled) + assert.NoError(t, result) + }) + + 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") + }) +} diff --git a/pkg/utils/print.go b/pkg/utils/print.go new file mode 100644 index 0000000..0ab7480 --- /dev/null +++ b/pkg/utils/print.go @@ -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) +} diff --git a/pkg/utils/print_test.go b/pkg/utils/print_test.go new file mode 100644 index 0000000..e5657a6 --- /dev/null +++ b/pkg/utils/print_test.go @@ -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) + } + }) +} diff --git a/requests/client.go b/requests/client.go deleted file mode 100644 index 4336a0c..0000000 --- a/requests/client.go +++ /dev/null @@ -1,112 +0,0 @@ -package requests - -import ( - "context" - "crypto/tls" - "errors" - "math/rand" - "net/url" - "time" - - "github.com/aykhans/dodo/utils" - "github.com/valyala/fasthttp" - "github.com/valyala/fasthttp/fasthttpproxy" -) - -type ClientGeneratorFunc func() *fasthttp.HostClient - -// getClients initializes and returns a slice of fasthttp.HostClient based on the provided parameters. -// It can either return clients with proxies or a single client without proxies. -func getClients( - _ context.Context, - timeout time.Duration, - proxies []url.URL, - maxConns uint, - URL url.URL, - skipVerify bool, -) []*fasthttp.HostClient { - isTLS := URL.Scheme == "https" - - if proxiesLen := len(proxies); proxiesLen > 0 { - clients := make([]*fasthttp.HostClient, 0, proxiesLen) - addr := URL.Host - if isTLS && URL.Port() == "" { - addr += ":443" - } - - for _, proxy := range proxies { - dialFunc, err := getDialFunc(&proxy, timeout) - if err != nil { - continue - } - - clients = append(clients, &fasthttp.HostClient{ - MaxConns: int(maxConns), - IsTLS: isTLS, - TLSConfig: &tls.Config{ - InsecureSkipVerify: skipVerify, - }, - Addr: addr, - Dial: dialFunc, - MaxIdleConnDuration: timeout, - MaxConnDuration: timeout, - WriteTimeout: timeout, - ReadTimeout: timeout, - }, - ) - } - return clients - } - - client := &fasthttp.HostClient{ - MaxConns: int(maxConns), - IsTLS: isTLS, - TLSConfig: &tls.Config{ - InsecureSkipVerify: skipVerify, - }, - Addr: URL.Host, - MaxIdleConnDuration: timeout, - MaxConnDuration: timeout, - WriteTimeout: timeout, - ReadTimeout: timeout, - } - return []*fasthttp.HostClient{client} -} - -// getDialFunc returns the appropriate fasthttp.DialFunc based on the provided proxy URL scheme. -// It supports SOCKS5 ('socks5' or 'socks5h') and HTTP ('http') proxy schemes. -// For HTTP proxies, the timeout parameter determines connection timeouts. -// Returns an error if the proxy scheme is unsupported. -func getDialFunc(proxy *url.URL, timeout time.Duration) (fasthttp.DialFunc, error) { - var dialer fasthttp.DialFunc - - switch proxy.Scheme { - case "socks5", "socks5h": - dialer = fasthttpproxy.FasthttpSocksDialerDualStack(proxy.String()) - case "http": - dialer = fasthttpproxy.FasthttpHTTPDialerDualStackTimeout(proxy.String(), timeout) - default: - return nil, errors.New("unsupported proxy scheme") - } - - if dialer == nil { - return nil, errors.New("internal error: proxy dialer is nil") - } - - return dialer, nil -} - -// getSharedClientFuncMultiple returns a ClientGeneratorFunc that cycles through a list of fasthttp.HostClient instances. -// The function uses a local random number generator to determine the starting index and stop index for cycling through the clients. -// The returned function isn't thread-safe and should be used in a single-threaded context. -func getSharedClientFuncMultiple(clients []*fasthttp.HostClient, localRand *rand.Rand) ClientGeneratorFunc { - return utils.RandomValueCycle(clients, localRand) -} - -// getSharedClientFuncSingle returns a ClientGeneratorFunc that always returns the provided fasthttp.HostClient instance. -// This can be useful for sharing a single client instance across multiple requests. -func getSharedClientFuncSingle(client *fasthttp.HostClient) ClientGeneratorFunc { - return func() *fasthttp.HostClient { - return client - } -} diff --git a/requests/helper.go b/requests/helper.go deleted file mode 100644 index 47d4bfd..0000000 --- a/requests/helper.go +++ /dev/null @@ -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) - } - } -} diff --git a/requests/request.go b/requests/request.go deleted file mode 100644 index acd1a6a..0000000 --- a/requests/request.go +++ /dev/null @@ -1,341 +0,0 @@ -package requests - -import ( - "bytes" - "context" - "math/rand" - "net/url" - "text/template" - "time" - - "github.com/aykhans/dodo/config" - "github.com/aykhans/dodo/types" - "github.com/aykhans/dodo/utils" - "github.com/valyala/fasthttp" -) - -type RequestGeneratorFunc func() *fasthttp.Request - -// Request represents an HTTP request to be sent using the fasthttp client. -// It isn't thread-safe and should be used by a single goroutine. -type Request struct { - getClient ClientGeneratorFunc - getRequest RequestGeneratorFunc -} - -type keyValueGenerator struct { - key func() string - value func() string -} - -// Send sends the HTTP request using the fasthttp client with a specified timeout. -// It returns the HTTP response or an error if the request fails or times out. -func (r *Request) Send(ctx context.Context, timeout time.Duration) (*fasthttp.Response, error) { - client := r.getClient() - request := r.getRequest() - defer fasthttp.ReleaseRequest(request) - - response := fasthttp.AcquireResponse() - ch := make(chan error) - go func() { - err := client.DoTimeout(request, response, timeout) - ch <- err - }() - select { - case err := <-ch: - if err != nil { - fasthttp.ReleaseResponse(response) - return nil, err - } - return response, nil - case <-time.After(timeout): - fasthttp.ReleaseResponse(response) - return nil, types.ErrTimeout - case <-ctx.Done(): - return nil, types.ErrInterrupt - } -} - -// newRequest creates a new Request instance based on the provided configuration and clients. -// It initializes a random number generator using the current time and a unique identifier (uid). -// Depending on the number of clients provided, it sets up a function to select the appropriate client. -// It also sets up a function to generate the request based on the provided configuration. -func newRequest( - requestConfig config.RequestConfig, - clients []*fasthttp.HostClient, - uid int64, -) *Request { - localRand := rand.New(rand.NewSource(time.Now().UnixNano() + uid)) - - clientsCount := len(clients) - if clientsCount < 1 { - panic("no clients") - } - - getClient := ClientGeneratorFunc(nil) - if clientsCount == 1 { - getClient = getSharedClientFuncSingle(clients[0]) - } else { - getClient = getSharedClientFuncMultiple(clients, localRand) - } - - getRequest := getRequestGeneratorFunc( - requestConfig.URL, - requestConfig.Params, - requestConfig.Headers, - requestConfig.Cookies, - requestConfig.Method, - requestConfig.Body, - localRand, - ) - - requests := &Request{ - getClient: getClient, - getRequest: getRequest, - } - - return requests -} - -// getRequestGeneratorFunc returns a RequestGeneratorFunc which generates HTTP requests with the specified parameters. -// The function uses a local random number generator to select bodies, headers, cookies, and parameters if multiple options are provided. -func getRequestGeneratorFunc( - URL url.URL, - params types.Params, - headers types.Headers, - cookies types.Cookies, - method string, - bodies []string, - localRand *rand.Rand, -) RequestGeneratorFunc { - getParams := getKeyValueGeneratorFunc(params, localRand) - getHeaders := getKeyValueGeneratorFunc(headers, localRand) - getCookies := getKeyValueGeneratorFunc(cookies, localRand) - getBody := getBodyValueFunc(bodies, utils.NewFuncMapGenerator(localRand), localRand) - - return func() *fasthttp.Request { - body, contentType := getBody() - headers := getHeaders() - if contentType != "" { - headers = append(headers, types.KeyValue[string, string]{ - Key: "Content-Type", - Value: contentType, - }) - } - - return newFasthttpRequest( - URL, - getParams(), - headers, - getCookies(), - method, - body, - ) - } -} - -// newFasthttpRequest creates a new fasthttp.Request object with the provided parameters. -// It sets the request URI, host header, headers, cookies, params, method, and body. -func newFasthttpRequest( - URL url.URL, - params []types.KeyValue[string, string], - headers []types.KeyValue[string, string], - cookies []types.KeyValue[string, string], - method string, - body string, -) *fasthttp.Request { - request := fasthttp.AcquireRequest() - request.SetRequestURI(URL.Path) - - // Set the host of the request to the host header - // If the host header is not set, the request will fail - // If there is host header in the headers, it will be overwritten - request.Header.SetHost(URL.Host) - setRequestParams(request, params) - setRequestHeaders(request, headers) - setRequestCookies(request, cookies) - setRequestMethod(request, method) - setRequestBody(request, body) - if URL.Scheme == "https" { - request.URI().SetScheme("https") - } - - return request -} - -// setRequestParams adds the query parameters of the given request based on the provided key-value pairs. -func setRequestParams(req *fasthttp.Request, params []types.KeyValue[string, string]) { - for _, param := range params { - req.URI().QueryArgs().Add(param.Key, param.Value) - } -} - -// setRequestHeaders adds the headers of the given request with the provided key-value pairs. -func setRequestHeaders(req *fasthttp.Request, headers []types.KeyValue[string, string]) { - for _, header := range headers { - req.Header.Add(header.Key, header.Value) - } -} - -// setRequestCookies adds the cookies of the given request with the provided key-value pairs. -func setRequestCookies(req *fasthttp.Request, cookies []types.KeyValue[string, string]) { - for _, cookie := range cookies { - req.Header.Add("Cookie", cookie.Key+"="+cookie.Value) - } -} - -// setRequestMethod sets the HTTP request method for the given request. -func setRequestMethod(req *fasthttp.Request, method string) { - req.Header.SetMethod(method) -} - -// setRequestBody sets the request body of the given fasthttp.Request object. -// The body parameter is a string that will be converted to a byte slice and set as the request body. -func setRequestBody(req *fasthttp.Request, body string) { - req.SetBody([]byte(body)) -} - -// getKeyValueGeneratorFunc creates a function that generates key-value pairs for HTTP requests. -// It takes a slice of key-value pairs where each key maps to a slice of possible values, -// and a random number generator. -// -// If any key has multiple possible values, the function will randomly select one value for each -// call (using the provided random number generator). If all keys have at most one value, the -// function will always return the same set of key-value pairs for efficiency. -func getKeyValueGeneratorFunc[ - T []types.KeyValue[string, string], -]( - keyValueSlice []types.KeyValue[string, []string], - localRand *rand.Rand, -) func() T { - keyValueGenerators := make([]keyValueGenerator, len(keyValueSlice)) - - funcMap := *utils.NewFuncMapGenerator(localRand).GetFuncMap() - - for i, kv := range keyValueSlice { - keyValueGenerators[i] = keyValueGenerator{ - key: getKeyFunc(kv.Key, funcMap), - value: getValueFunc(kv.Value, funcMap, localRand), - } - } - - return func() T { - keyValues := make(T, len(keyValueGenerators)) - for i, keyValue := range keyValueGenerators { - keyValues[i] = types.KeyValue[string, string]{ - Key: keyValue.key(), - Value: keyValue.value(), - } - } - return keyValues - } -} - -// getKeyFunc creates a function that processes a key string through Go's template engine. -// It takes a key string and a template.FuncMap containing the available template functions. -// -// The returned function, when called, will execute the template with the given key and return -// the processed string result. If template parsing fails, the returned function will always -// return an empty string. -// -// This enables dynamic generation of keys that can include template directives and functions. -func getKeyFunc(key string, funcMap template.FuncMap) func() string { - t, err := template.New("default").Funcs(funcMap).Parse(key) - if err != nil { - return func() string { return "" } - } - - return func() string { - var buf bytes.Buffer - _ = t.Execute(&buf, nil) - return buf.String() - } -} - -// getValueFunc creates a function that randomly selects and processes a value from a slice of strings -// through Go's template engine. -// -// Parameters: -// - values: A slice of string templates that can contain template directives -// - funcMap: A template.FuncMap containing all available template functions -// - localRand: A random number generator for consistent randomization -// -// The returned function, when called, will: -// 1. Select a random template from the values slice -// 2. Execute the selected template -// 3. Return the processed string result -// -// If a selected template is nil (due to earlier parsing failure), the function will return an empty string. -// This enables dynamic generation of values with randomized selection from multiple templates. -func getValueFunc( - values []string, - funcMap template.FuncMap, - localRand *rand.Rand, -) func() string { - templates := make([]*template.Template, len(values)) - - for i, value := range values { - t, err := template.New("default").Funcs(funcMap).Parse(value) - if err != nil { - templates[i] = nil - } - templates[i] = t - } - - randomTemplateFunc := utils.RandomValueCycle(templates, localRand) - - return func() string { - if tmpl := randomTemplateFunc(); tmpl == nil { - return "" - } else { - var buf bytes.Buffer - _ = tmpl.Execute(&buf, nil) - return buf.String() - } - } -} - -// getBodyValueFunc creates a function that randomly selects and processes a request body from a slice of templates. -// It returns a closure that generates both the body content and the appropriate Content-Type header value. -// -// Parameters: -// - values: A slice of string templates that can contain template directives for request bodies -// - funcMapGenerator: Provides template functions and content type information -// - localRand: A random number generator for consistent randomization -// -// The returned function, when called, will: -// 1. Select a random body template from the values slice -// 2. Execute the selected template with available template functions -// 3. Return both the processed body string and the appropriate Content-Type header value -// -// If the selected template is nil (due to earlier parsing failure), the function will return -// empty strings for both the body and Content-Type. -// -// This enables dynamic generation of request bodies with proper content type headers. -func getBodyValueFunc( - values []string, - funcMapGenerator *utils.FuncMapGenerator, - localRand *rand.Rand, -) func() (string, string) { - templates := make([]*template.Template, len(values)) - - for i, value := range values { - t, err := template.New("default").Funcs(*funcMapGenerator.GetFuncMap()).Parse(value) - if err != nil { - templates[i] = nil - } - templates[i] = t - } - - randomTemplateFunc := utils.RandomValueCycle(templates, localRand) - - return func() (string, string) { - if tmpl := randomTemplateFunc(); tmpl == nil { - return "", "" - } else { - var buf bytes.Buffer - _ = tmpl.Execute(&buf, nil) - return buf.String(), funcMapGenerator.GetBodyDataHeader() - } - } -} diff --git a/requests/response.go b/requests/response.go deleted file mode 100644 index be3ec76..0000000 --- a/requests/response.go +++ /dev/null @@ -1,94 +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() { - if len(responses) == 0 { - return - } - - mergedResponses := make(map[string]types.Durations) - - totalDurations := make(types.Durations, len(responses)) - var totalSum time.Duration - totalCount := len(responses) - - for i, response := range responses { - totalSum += response.Time - totalDurations[i] = response.Time - - mergedResponses[response.Response] = append( - mergedResponses[response.Response], - response.Time, - ) - } - - 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 { - totalDurations.Sort() - allDurationsLenAsFloat := float64(len(totalDurations) - 1) - - t.AppendRow(table.Row{ - "Total", - totalCount, - utils.DurationRoundBy(totalDurations[0], roundPrecision), - utils.DurationRoundBy(totalDurations[len(totalDurations)-1], roundPrecision), - utils.DurationRoundBy(totalSum/time.Duration(totalCount), roundPrecision), // Average - utils.DurationRoundBy(totalDurations[int(0.90*allDurationsLenAsFloat)], roundPrecision), - utils.DurationRoundBy(totalDurations[int(0.95*allDurationsLenAsFloat)], roundPrecision), - utils.DurationRoundBy(totalDurations[int(0.99*allDurationsLenAsFloat)], roundPrecision), - }) - } - t.Render() -} diff --git a/requests/run.go b/requests/run.go deleted file mode 100644 index edd59d6..0000000 --- a/requests/run.go +++ /dev/null @@ -1,211 +0,0 @@ -package requests - -import ( - "context" - "strconv" - "sync" - "time" - - "github.com/aykhans/dodo/config" - "github.com/aykhans/dodo/types" - "github.com/aykhans/dodo/utils" - "github.com/valyala/fasthttp" -) - -// Run executes the main logic for processing requests based on the provided configuration. -// It initializes clients based on the request configuration and releases the dodos. -// If the context is canceled and no responses are collected, it returns an interrupt error. -// -// Parameters: -// - ctx: The context for managing request lifecycle and cancellation. -// - requestConfig: The configuration for the request, including timeout, proxies, and other settings. -func Run(ctx context.Context, requestConfig *config.RequestConfig) (Responses, error) { - if requestConfig.Duration > 0 { - var cancel context.CancelFunc - ctx, cancel = context.WithTimeout(ctx, requestConfig.Duration) - defer cancel() - } - - clients := getClients( - ctx, - requestConfig.Timeout, - requestConfig.Proxies, - requestConfig.GetMaxConns(fasthttp.DefaultMaxConnsPerHost), - requestConfig.URL, - requestConfig.SkipVerify, - ) - if clients == nil { - return nil, types.ErrInterrupt - } - - responses := releaseDodos(ctx, requestConfig, clients) - if ctx.Err() != nil && len(responses) == 0 { - return nil, types.ErrInterrupt - } - - return responses, nil -} - -// releaseDodos sends requests concurrently using multiple dodos (goroutines) and returns the aggregated responses. -// -// The function performs the following steps: -// 1. Initializes wait groups and other necessary variables. -// 2. Starts a goroutine to stream progress updates. -// 3. Distributes the total request count among the dodos. -// 4. Starts a goroutine for each dodo to send requests concurrently. -// 5. Waits for all dodos to complete their requests. -// 6. Cancels the progress streaming context and waits for the progress goroutine to finish. -// 7. Flattens and returns the aggregated responses. -func releaseDodos( - ctx context.Context, - requestConfig *config.RequestConfig, - clients []*fasthttp.HostClient, -) Responses { - var ( - wg sync.WaitGroup - streamWG sync.WaitGroup - requestCountPerDodo uint - dodosCount = requestConfig.GetValidDodosCountForRequests() - responses = make([][]Response, dodosCount) - increase = make(chan int64, requestConfig.RequestCount) - ) - - wg.Add(int(dodosCount)) - streamWG.Add(1) - streamCtx, streamCtxCancel := context.WithCancel(ctx) - - go streamProgress(streamCtx, &streamWG, requestConfig.RequestCount, "Dodos Working🔥", increase) - - if requestConfig.RequestCount == 0 { - for i := range dodosCount { - go sendRequest( - ctx, - newRequest(*requestConfig, clients, int64(i)), - requestConfig.Timeout, - &responses[i], - increase, - &wg, - ) - } - } else { - for i := range dodosCount { - if i+1 == dodosCount { - requestCountPerDodo = requestConfig.RequestCount - (i * requestConfig.RequestCount / dodosCount) - } else { - requestCountPerDodo = ((i + 1) * requestConfig.RequestCount / dodosCount) - - (i * requestConfig.RequestCount / dodosCount) - } - - go sendRequestByCount( - ctx, - newRequest(*requestConfig, clients, int64(i)), - requestConfig.Timeout, - requestCountPerDodo, - &responses[i], - increase, - &wg, - ) - } - } - - wg.Wait() - streamCtxCancel() - streamWG.Wait() - return utils.Flatten(responses) -} - -// sendRequestByCount sends a specified number of HTTP requests concurrently with a given timeout. -// It appends the responses to the provided responseData slice and sends the count of completed requests -// to the increase channel. The function terminates early if the context is canceled or if a custom -// interrupt error is encountered. -func sendRequestByCount( - ctx context.Context, - request *Request, - timeout time.Duration, - requestCount uint, - responseData *[]Response, - increase chan<- int64, - wg *sync.WaitGroup, -) { - defer wg.Done() - - for range requestCount { - if ctx.Err() != nil { - return - } - - func() { - startTime := time.Now() - response, err := request.Send(ctx, timeout) - completedTime := time.Since(startTime) - if response != nil { - defer fasthttp.ReleaseResponse(response) - } - - if err != nil { - if err == types.ErrInterrupt { - return - } - *responseData = append(*responseData, Response{ - Response: err.Error(), - Time: completedTime, - }) - increase <- 1 - return - } - - *responseData = append(*responseData, Response{ - Response: strconv.Itoa(response.StatusCode()), - Time: completedTime, - }) - increase <- 1 - }() - } -} - -// sendRequest continuously sends HTTP requests until the context is canceled. -// It records the response status code or error message along with the response time, -// and signals each completed request through the increase channel. -func sendRequest( - ctx context.Context, - request *Request, - timeout time.Duration, - responseData *[]Response, - increase chan<- int64, - wg *sync.WaitGroup, -) { - defer wg.Done() - - for { - if ctx.Err() != nil { - return - } - - func() { - startTime := time.Now() - response, err := request.Send(ctx, timeout) - completedTime := time.Since(startTime) - if response != nil { - defer fasthttp.ReleaseResponse(response) - } - - if err != nil { - if err == types.ErrInterrupt { - return - } - *responseData = append(*responseData, Response{ - Response: err.Error(), - Time: completedTime, - }) - increase <- 1 - return - } - - *responseData = append(*responseData, Response{ - Response: strconv.Itoa(response.StatusCode()), - Time: completedTime, - }) - increase <- 1 - }() - } -} diff --git a/types/body.go b/types/body.go deleted file mode 100644 index 4329b87..0000000 --- a/types/body.go +++ /dev/null @@ -1,94 +0,0 @@ -package types - -import ( - "bytes" - "encoding/json" - "fmt" - - "github.com/jedib0t/go-pretty/v6/text" -) - -type Body []string - -func (body Body) String() string { - var buffer bytes.Buffer - if len(body) == 0 { - return buffer.String() - } - - if len(body) == 1 { - buffer.WriteString(body[0]) - return buffer.String() - } - - buffer.WriteString(text.FgBlue.Sprint("Random") + "[\n") - - indent := " " - - displayLimit := 5 - - for i, item := range body[:min(len(body), displayLimit)] { - if i > 0 { - buffer.WriteString(",\n") - } - - buffer.WriteString(indent + item) - } - - // Add remaining count if there are more items - if remainingValues := len(body) - displayLimit; remainingValues > 0 { - buffer.WriteString(",\n" + indent + text.FgGreen.Sprintf("+%d bodies", remainingValues)) - } - - buffer.WriteString("\n]") - return buffer.String() -} - -func (body *Body) UnmarshalJSON(b []byte) error { - var data any - if err := json.Unmarshal(b, &data); err != nil { - return err - } - - switch v := data.(type) { - case string: - *body = []string{v} - case []any: - var slice []string - for _, item := range v { - slice = append(slice, fmt.Sprintf("%v", item)) - } - *body = slice - default: - return fmt.Errorf("invalid type for Body: %T (should be string or []string)", v) - } - - return nil -} - -func (body *Body) UnmarshalYAML(unmarshal func(any) error) error { - var data any - if err := unmarshal(&data); err != nil { - return err - } - - switch v := data.(type) { - case string: - *body = []string{v} - case []any: - var slice []string - for _, item := range v { - slice = append(slice, fmt.Sprintf("%v", item)) - } - *body = slice - default: - return fmt.Errorf("invalid type for Body: %T (should be string or []string)", v) - } - - return nil -} - -func (body *Body) Set(value string) error { - *body = append(*body, value) - return nil -} diff --git a/types/config_file.go b/types/config_file.go deleted file mode 100644 index f5f2a89..0000000 --- a/types/config_file.go +++ /dev/null @@ -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:] -} diff --git a/types/cookies.go b/types/cookies.go deleted file mode 100644 index c7d182f..0000000 --- a/types/cookies.go +++ /dev/null @@ -1,139 +0,0 @@ -package types - -import ( - "bytes" - "encoding/json" - "fmt" - "strings" - - "github.com/jedib0t/go-pretty/v6/text" -) - -type Cookies []KeyValue[string, []string] - -func (cookies Cookies) String() string { - var buffer bytes.Buffer - if len(cookies) == 0 { - return buffer.String() - } - - indent := " " - - displayLimit := 3 - - for i, item := range cookies[:min(len(cookies), displayLimit)] { - if i > 0 { - buffer.WriteString(",\n") - } - - if len(item.Value) == 1 { - buffer.WriteString(item.Key + ": " + item.Value[0]) - continue - } - buffer.WriteString(item.Key + ": " + text.FgBlue.Sprint("Random") + "[\n") - - for ii, v := range item.Value[:min(len(item.Value), displayLimit)] { - if ii == len(item.Value)-1 { - buffer.WriteString(indent + v + "\n") - } else { - buffer.WriteString(indent + v + ",\n") - } - } - - // Add remaining values count if needed - if remainingValues := len(item.Value) - displayLimit; remainingValues > 0 { - buffer.WriteString(indent + text.FgGreen.Sprintf("+%d values", remainingValues) + "\n") - } - - buffer.WriteString("]") - } - - // Add remaining key-value pairs count if needed - if remainingPairs := len(cookies) - displayLimit; remainingPairs > 0 { - buffer.WriteString(",\n" + text.FgGreen.Sprintf("+%d cookies", remainingPairs)) - } - - return buffer.String() -} - -func (cookies *Cookies) AppendByKey(key, value string) { - if item := cookies.GetValue(key); item != nil { - *item = append(*item, value) - } else { - *cookies = append(*cookies, KeyValue[string, []string]{Key: key, Value: []string{value}}) - } -} - -func (cookies Cookies) GetValue(key string) *[]string { - for i := range cookies { - if cookies[i].Key == key { - return &cookies[i].Value - } - } - return nil -} - -func (cookies *Cookies) UnmarshalJSON(b []byte) error { - var data []map[string]any - if err := json.Unmarshal(b, &data); err != nil { - return err - } - - for _, item := range data { - for key, value := range item { - switch parsedValue := value.(type) { - case string: - *cookies = append(*cookies, KeyValue[string, []string]{Key: key, Value: []string{parsedValue}}) - case []any: - parsedStr := make([]string, len(parsedValue)) - for i, item := range parsedValue { - parsedStr[i] = fmt.Sprintf("%v", item) - } - *cookies = append(*cookies, KeyValue[string, []string]{Key: key, Value: parsedStr}) - default: - return fmt.Errorf("unsupported type for cookies expected string or []string, got %T", parsedValue) - } - } - } - - return nil -} - -func (cookies *Cookies) UnmarshalYAML(unmarshal func(any) error) error { - var raw []map[string]any - if err := unmarshal(&raw); err != nil { - return err - } - - for _, param := range raw { - for key, value := range param { - switch parsed := value.(type) { - case string: - *cookies = append(*cookies, KeyValue[string, []string]{Key: key, Value: []string{parsed}}) - case []any: - var values []string - for _, v := range parsed { - if str, ok := v.(string); ok { - values = append(values, str) - } - } - *cookies = append(*cookies, KeyValue[string, []string]{Key: key, Value: values}) - } - } - } - return nil -} - -func (cookies *Cookies) Set(value string) error { - parts := strings.SplitN(value, "=", 2) - switch len(parts) { - case 0: - cookies.AppendByKey("", "") - case 1: - cookies.AppendByKey(parts[0], "") - case 2: - cookies.AppendByKey(parts[0], parts[1]) - } - - return nil -} diff --git a/types/duration.go b/types/duration.go deleted file mode 100644 index 5b79612..0000000 --- a/types/duration.go +++ /dev/null @@ -1,57 +0,0 @@ -package types - -import ( - "encoding/json" - "errors" - "time" -) - -type Duration struct { - time.Duration -} - -func (duration *Duration) UnmarshalJSON(b []byte) error { - var v any - if err := json.Unmarshal(b, &v); err != nil { - return err - } - switch value := v.(type) { - case float64: - duration.Duration = time.Duration(value) - return nil - case string: - var err error - duration.Duration, err = time.ParseDuration(value) - if err != nil { - return errors.New("Duration is invalid (e.g. 400ms, 1s, 5m, 1h)") - } - return nil - default: - return errors.New("Duration is invalid (e.g. 400ms, 1s, 5m, 1h)") - } -} - -func (duration Duration) MarshalJSON() ([]byte, error) { - return json.Marshal(duration.String()) -} - -func (duration *Duration) UnmarshalYAML(unmarshal func(any) error) error { - var v any - if err := unmarshal(&v); err != nil { - return err - } - switch value := v.(type) { - case float64: - duration.Duration = time.Duration(value) - return nil - case string: - var err error - duration.Duration, err = time.ParseDuration(value) - if err != nil { - return errors.New("Duration is invalid (e.g. 400ms, 1s, 5m, 1h)") - } - return nil - default: - return errors.New("Duration is invalid (e.g. 400ms, 1s, 5m, 1h)") - } -} diff --git a/types/durations.go b/types/durations.go deleted file mode 100644 index ee4970d..0000000 --- a/types/durations.go +++ /dev/null @@ -1,40 +0,0 @@ -package types - -import ( - "slices" - "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 - slices.Sort(d) - } -} - -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)) -} diff --git a/types/errors.go b/types/errors.go deleted file mode 100644 index aeb7172..0000000 --- a/types/errors.go +++ /dev/null @@ -1,10 +0,0 @@ -package types - -import ( - "errors" -) - -var ( - ErrInterrupt = errors.New("interrupted") - ErrTimeout = errors.New("timeout") -) diff --git a/types/headers.go b/types/headers.go deleted file mode 100644 index dad080f..0000000 --- a/types/headers.go +++ /dev/null @@ -1,156 +0,0 @@ -package types - -import ( - "bytes" - "encoding/json" - "fmt" - "strings" - - "github.com/jedib0t/go-pretty/v6/text" -) - -type Headers []KeyValue[string, []string] - -func (headers Headers) String() string { - var buffer bytes.Buffer - if len(headers) == 0 { - return buffer.String() - } - - indent := " " - - displayLimit := 3 - - for i, item := range headers[:min(len(headers), displayLimit)] { - if i > 0 { - buffer.WriteString(",\n") - } - - if len(item.Value) == 1 { - buffer.WriteString(item.Key + ": " + item.Value[0]) - continue - } - buffer.WriteString(item.Key + ": " + text.FgBlue.Sprint("Random") + "[\n") - - for ii, v := range item.Value[:min(len(item.Value), displayLimit)] { - if ii == len(item.Value)-1 { - buffer.WriteString(indent + v + "\n") - } else { - buffer.WriteString(indent + v + ",\n") - } - } - - // Add remaining values count if needed - if remainingValues := len(item.Value) - displayLimit; remainingValues > 0 { - buffer.WriteString(indent + text.FgGreen.Sprintf("+%d values", remainingValues) + "\n") - } - - buffer.WriteString("]") - } - - // Add remaining key-value pairs count if needed - if remainingPairs := len(headers) - displayLimit; remainingPairs > 0 { - buffer.WriteString(",\n" + text.FgGreen.Sprintf("+%d headers", remainingPairs)) - } - - return buffer.String() -} - -func (headers *Headers) AppendByKey(key, value string) { - if item := headers.GetValue(key); item != nil { - *item = append(*item, value) - } else { - *headers = append(*headers, KeyValue[string, []string]{Key: key, Value: []string{value}}) - } -} - -func (headers Headers) GetValue(key string) *[]string { - for i := range headers { - if headers[i].Key == key { - return &headers[i].Value - } - } - return nil -} - -func (headers Headers) Has(key string) bool { - for i := range headers { - if headers[i].Key == key { - return true - } - } - return false -} - -func (headers *Headers) UnmarshalJSON(b []byte) error { - var data []map[string]any - if err := json.Unmarshal(b, &data); err != nil { - return err - } - - for _, item := range data { - for key, value := range item { - switch parsedValue := value.(type) { - case string: - *headers = append(*headers, KeyValue[string, []string]{Key: key, Value: []string{parsedValue}}) - case []any: - parsedStr := make([]string, len(parsedValue)) - for i, item := range parsedValue { - parsedStr[i] = fmt.Sprintf("%v", item) - } - *headers = append(*headers, KeyValue[string, []string]{Key: key, Value: parsedStr}) - default: - return fmt.Errorf("unsupported type for headers expected string or []string, got %T", parsedValue) - } - } - } - - return nil -} - -func (headers *Headers) UnmarshalYAML(unmarshal func(any) error) error { - var raw []map[string]any - if err := unmarshal(&raw); err != nil { - return err - } - - for _, param := range raw { - for key, value := range param { - switch parsed := value.(type) { - case string: - *headers = append(*headers, KeyValue[string, []string]{Key: key, Value: []string{parsed}}) - case []any: - var values []string - for _, v := range parsed { - if str, ok := v.(string); ok { - values = append(values, str) - } - } - *headers = append(*headers, KeyValue[string, []string]{Key: key, Value: values}) - } - } - } - return nil -} - -func (headers *Headers) Set(value string) error { - parts := strings.SplitN(value, ":", 2) - switch len(parts) { - case 0: - headers.AppendByKey("", "") - case 1: - headers.AppendByKey(parts[0], "") - case 2: - headers.AppendByKey(parts[0], parts[1]) - } - - return nil -} - -func (headers *Headers) SetIfNotExists(key string, value string) bool { - if headers.Has(key) { - return false - } - *headers = append(*headers, KeyValue[string, []string]{Key: key, Value: []string{value}}) - return true -} diff --git a/types/params.go b/types/params.go deleted file mode 100644 index 598876d..0000000 --- a/types/params.go +++ /dev/null @@ -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(any) error) error { - var raw []map[string]any - if err := unmarshal(&raw); err != nil { - return err - } - - for _, param := range raw { - for key, value := range param { - switch parsed := value.(type) { - case string: - *params = append(*params, KeyValue[string, []string]{Key: key, Value: []string{parsed}}) - case []any: - var values []string - for _, v := range parsed { - if str, ok := v.(string); ok { - values = append(values, str) - } - } - *params = append(*params, KeyValue[string, []string]{Key: key, Value: values}) - } - } - } - return nil -} - -func (params *Params) Set(value string) error { - parts := strings.SplitN(value, "=", 2) - switch len(parts) { - case 0: - params.AppendByKey("", "") - case 1: - params.AppendByKey(parts[0], "") - case 2: - params.AppendByKey(parts[0], parts[1]) - } - - return nil -} diff --git a/types/proxies.go b/types/proxies.go deleted file mode 100644 index 7d8323d..0000000 --- a/types/proxies.go +++ /dev/null @@ -1,116 +0,0 @@ -package types - -import ( - "bytes" - "encoding/json" - "fmt" - "net/url" - - "github.com/jedib0t/go-pretty/v6/text" -) - -type Proxies []url.URL - -func (proxies Proxies) String() string { - var buffer bytes.Buffer - if len(proxies) == 0 { - return buffer.String() - } - - if len(proxies) == 1 { - buffer.WriteString(proxies[0].String()) - return buffer.String() - } - - buffer.WriteString(text.FgBlue.Sprint("Random") + "[\n") - - indent := " " - - displayLimit := 5 - - for i, item := range proxies[:min(len(proxies), displayLimit)] { - if i > 0 { - buffer.WriteString(",\n") - } - - buffer.WriteString(indent + item.String()) - } - - // Add remaining count if there are more items - if remainingValues := len(proxies) - displayLimit; remainingValues > 0 { - buffer.WriteString(",\n" + indent + text.FgGreen.Sprintf("+%d proxies", remainingValues)) - } - - buffer.WriteString("\n]") - return buffer.String() -} - -func (proxies *Proxies) UnmarshalJSON(b []byte) error { - var data any - if err := json.Unmarshal(b, &data); err != nil { - return err - } - - switch v := data.(type) { - case string: - parsed, err := url.Parse(v) - if err != nil { - return err - } - *proxies = []url.URL{*parsed} - case []any: - var urls []url.URL - for _, item := range v { - url, err := url.Parse(item.(string)) - if err != nil { - return err - } - urls = append(urls, *url) - } - *proxies = urls - default: - return fmt.Errorf("invalid type for Body: %T (should be URL or []URL)", v) - } - - return nil -} - -func (proxies *Proxies) UnmarshalYAML(unmarshal func(any) error) error { - var data any - if err := unmarshal(&data); err != nil { - return err - } - - switch v := data.(type) { - case string: - parsed, err := url.Parse(v) - if err != nil { - return err - } - *proxies = []url.URL{*parsed} - case []any: - var urls []url.URL - for _, item := range v { - url, err := url.Parse(item.(string)) - if err != nil { - return err - } - urls = append(urls, *url) - } - *proxies = urls - default: - return fmt.Errorf("invalid type for Body: %T (should be URL or []URL)", v) - } - - return nil -} - -func (proxies *Proxies) Set(value string) error { - parsedURL, err := url.Parse(value) - if err != nil { - return err - } - - *proxies = append(*proxies, *parsedURL) - return nil -} diff --git a/types/request_url.go b/types/request_url.go deleted file mode 100644 index 4553ec4..0000000 --- a/types/request_url.go +++ /dev/null @@ -1,59 +0,0 @@ -package types - -import ( - "encoding/json" - "errors" - "net/url" -) - -type RequestURL struct { - url.URL -} - -func (requestURL *RequestURL) UnmarshalJSON(data []byte) error { - var urlStr string - if err := json.Unmarshal(data, &urlStr); err != nil { - return err - } - - parsedURL, err := url.Parse(urlStr) - if err != nil { - return errors.New("request URL is invalid") - } - - requestURL.URL = *parsedURL - return nil -} - -func (requestURL *RequestURL) UnmarshalYAML(unmarshal func(any) error) error { - var urlStr string - if err := unmarshal(&urlStr); err != nil { - return err - } - - parsedURL, err := url.Parse(urlStr) - if err != nil { - return errors.New("request URL is invalid") - } - - requestURL.URL = *parsedURL - return nil -} - -func (requestURL RequestURL) MarshalJSON() ([]byte, error) { - return json.Marshal(requestURL.URL.String()) -} - -func (requestURL RequestURL) String() string { - return requestURL.URL.String() -} - -func (requestURL *RequestURL) Set(value string) error { - parsedURL, err := url.Parse(value) - if err != nil { - return err - } - - requestURL.URL = *parsedURL - return nil -} diff --git a/types/timeout.go b/types/timeout.go deleted file mode 100644 index 7d9e24a..0000000 --- a/types/timeout.go +++ /dev/null @@ -1,57 +0,0 @@ -package types - -import ( - "encoding/json" - "errors" - "time" -) - -type Timeout struct { - time.Duration -} - -func (timeout *Timeout) UnmarshalJSON(b []byte) error { - var v any - if err := json.Unmarshal(b, &v); err != nil { - return err - } - switch value := v.(type) { - case float64: - timeout.Duration = time.Duration(value) - return nil - case string: - var err error - timeout.Duration, err = time.ParseDuration(value) - if err != nil { - return errors.New("Timeout is invalid (e.g. 400ms, 1s, 5m, 1h)") - } - return nil - default: - return errors.New("Timeout is invalid (e.g. 400ms, 1s, 5m, 1h)") - } -} - -func (timeout Timeout) MarshalJSON() ([]byte, error) { - return json.Marshal(timeout.String()) -} - -func (timeout *Timeout) UnmarshalYAML(unmarshal func(any) error) error { - var v any - if err := unmarshal(&v); err != nil { - return err - } - switch value := v.(type) { - case float64: - timeout.Duration = time.Duration(value) - return nil - case string: - var err error - timeout.Duration, err = time.ParseDuration(value) - if err != nil { - return errors.New("Timeout is invalid (e.g. 400ms, 1s, 5m, 1h)") - } - return nil - default: - return errors.New("Timeout is invalid (e.g. 400ms, 1s, 5m, 1h)") - } -} diff --git a/utils/compare.go b/utils/compare.go deleted file mode 100644 index e49389f..0000000 --- a/utils/compare.go +++ /dev/null @@ -1,10 +0,0 @@ -package utils - -func IsNilOrZero[T comparable](value *T) bool { - if value == nil { - return true - } - - var zero T - return *value == zero -} diff --git a/utils/int.go b/utils/int.go deleted file mode 100644 index 6faa348..0000000 --- a/utils/int.go +++ /dev/null @@ -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 -} diff --git a/utils/print.go b/utils/print.go deleted file mode 100644 index 489688a..0000000 --- a/utils/print.go +++ /dev/null @@ -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) -} diff --git a/utils/slice.go b/utils/slice.go deleted file mode 100644 index 3c6d636..0000000 --- a/utils/slice.go +++ /dev/null @@ -1,42 +0,0 @@ -package utils - -import "math/rand" - -func Flatten[T any](nested [][]T) []T { - flattened := make([]T, 0) - for _, n := range nested { - flattened = append(flattened, n...) - } - return flattened -} - -// RandomValueCycle returns a function that cycles through the provided values in a pseudo-random order. -// Each value in the input slice will be returned before any value is repeated. -// If the input slice is empty, the returned function will always return the zero value of type T. -// If the input slice contains only one element, that element is always returned. -// This function is not thread-safe and should not be called concurrently. -func RandomValueCycle[T any](values []T, localRand *rand.Rand) func() T { - switch valuesLen := len(values); valuesLen { - case 0: - var zero T - return func() T { return zero } - case 1: - return func() T { return values[0] } - default: - currentIndex := localRand.Intn(valuesLen) - stopIndex := currentIndex - return func() T { - value := values[currentIndex] - currentIndex++ - if currentIndex == valuesLen { - currentIndex = 0 - } - if currentIndex == stopIndex { - currentIndex = localRand.Intn(valuesLen) - stopIndex = currentIndex - } - - return value - } - } -} diff --git a/utils/templates.go b/utils/templates.go deleted file mode 100644 index ab54122..0000000 --- a/utils/templates.go +++ /dev/null @@ -1,479 +0,0 @@ -package utils - -import ( - "bytes" - "math/rand" - "mime/multipart" - "strings" - "text/template" - "time" - - "github.com/brianvoe/gofakeit/v7" -) - -type FuncMapGenerator struct { - bodyDataHeader string - localFaker *gofakeit.Faker - funcMap *template.FuncMap -} - -func NewFuncMapGenerator(localRand *rand.Rand) *FuncMapGenerator { - f := &FuncMapGenerator{ - localFaker: gofakeit.NewFaker(localRand, false), - } - f.funcMap = f.newFuncMap() - - return f -} - -func (g *FuncMapGenerator) GetBodyDataHeader() string { - tempHeader := g.bodyDataHeader - g.bodyDataHeader = "" - return tempHeader -} - -func (g *FuncMapGenerator) GetFuncMap() *template.FuncMap { - return g.funcMap -} - -// NewFuncMap creates a template.FuncMap populated with string manipulation functions -// and data generation functions from gofakeit. -// -// It takes a random number generator that is used to initialize a localized faker -// instance, ensuring that random data generation is deterministic within a request context. -// -// All functions are prefixed to avoid naming conflicts: -// - String functions: "strings_*" -// - Dict functions: "dict_*" -// - Body functions: "body_*" -// - Data generation functions: "fakeit_*" -func (g *FuncMapGenerator) newFuncMap() *template.FuncMap { - return &template.FuncMap{ - // Strings - "strings_ToUpper": strings.ToUpper, - "strings_ToLower": strings.ToLower, - "strings_RemoveSpaces": func(s string) string { return strings.ReplaceAll(s, " ", "") }, - "strings_Replace": strings.Replace, - "strings_ToDate": func(dateString string) time.Time { - date, err := time.Parse("2006-01-02", dateString) - if err != nil { - return time.Now() - } - return date - }, - "strings_First": func(s string, n int) string { - if n >= len(s) { - return s - } - return s[:n] - }, - "strings_Last": func(s string, n int) string { - if n >= len(s) { - return s - } - return s[len(s)-n:] - }, - "strings_Truncate": func(s string, n int) string { - if n >= len(s) { - return s - } - return s[:n] + "..." - }, - "strings_TrimPrefix": strings.TrimPrefix, - "strings_TrimSuffix": strings.TrimSuffix, - "strings_Join": func(sep string, values ...string) string { - return strings.Join(values, sep) - }, - - // Dict - "dict_Str": func(values ...string) map[string]string { - dict := make(map[string]string) - for i := 0; i < len(values); i += 2 { - if i+1 < len(values) { - key := values[i] - value := values[i+1] - dict[key] = value - } - } - return dict - }, - - // Slice - "slice_Str": func(values ...string) []string { return values }, - "slice_Int": func(values ...int) []int { return values }, - "slice_Uint": func(values ...uint) []uint { return values }, - - // Body - "body_FormData": func(kv map[string]string) string { - var data bytes.Buffer - writer := multipart.NewWriter(&data) - - for k, v := range kv { - _ = writer.WriteField(k, v) - } - - _ = writer.Close() - g.bodyDataHeader = writer.FormDataContentType() - - return data.String() - }, - - // FakeIt / Product - "fakeit_ProductName": g.localFaker.ProductName, - "fakeit_ProductDescription": g.localFaker.ProductDescription, - "fakeit_ProductCategory": g.localFaker.ProductCategory, - "fakeit_ProductFeature": g.localFaker.ProductFeature, - "fakeit_ProductMaterial": g.localFaker.ProductMaterial, - "fakeit_ProductUPC": g.localFaker.ProductUPC, - "fakeit_ProductAudience": g.localFaker.ProductAudience, - "fakeit_ProductDimension": g.localFaker.ProductDimension, - "fakeit_ProductUseCase": g.localFaker.ProductUseCase, - "fakeit_ProductBenefit": g.localFaker.ProductBenefit, - "fakeit_ProductSuffix": g.localFaker.ProductSuffix, - - // FakeIt / Person - "fakeit_Name": g.localFaker.Name, - "fakeit_NamePrefix": g.localFaker.NamePrefix, - "fakeit_NameSuffix": g.localFaker.NameSuffix, - "fakeit_FirstName": g.localFaker.FirstName, - "fakeit_MiddleName": g.localFaker.MiddleName, - "fakeit_LastName": g.localFaker.LastName, - "fakeit_Gender": g.localFaker.Gender, - "fakeit_SSN": g.localFaker.SSN, - "fakeit_Hobby": g.localFaker.Hobby, - "fakeit_Email": g.localFaker.Email, - "fakeit_Phone": g.localFaker.Phone, - "fakeit_PhoneFormatted": g.localFaker.PhoneFormatted, - - // FakeIt / Auth - "fakeit_Username": g.localFaker.Username, - "fakeit_Password": g.localFaker.Password, - - // FakeIt / Address - "fakeit_City": g.localFaker.City, - "fakeit_Country": g.localFaker.Country, - "fakeit_CountryAbr": g.localFaker.CountryAbr, - "fakeit_State": g.localFaker.State, - "fakeit_StateAbr": g.localFaker.StateAbr, - "fakeit_Street": g.localFaker.Street, - "fakeit_StreetName": g.localFaker.StreetName, - "fakeit_StreetNumber": g.localFaker.StreetNumber, - "fakeit_StreetPrefix": g.localFaker.StreetPrefix, - "fakeit_StreetSuffix": g.localFaker.StreetSuffix, - "fakeit_Zip": g.localFaker.Zip, - "fakeit_Latitude": g.localFaker.Latitude, - "fakeit_LatitudeInRange": func(min, max float64) float64 { - value, err := g.localFaker.LatitudeInRange(min, max) - if err != nil { - var zero float64 - return zero - } - return value - }, - "fakeit_Longitude": g.localFaker.Longitude, - "fakeit_LongitudeInRange": func(min, max float64) float64 { - value, err := g.localFaker.LongitudeInRange(min, max) - if err != nil { - var zero float64 - return zero - } - return value - }, - - // FakeIt / Game - "fakeit_Gamertag": g.localFaker.Gamertag, - - // FakeIt / Beer - "fakeit_BeerAlcohol": g.localFaker.BeerAlcohol, - "fakeit_BeerBlg": g.localFaker.BeerBlg, - "fakeit_BeerHop": g.localFaker.BeerHop, - "fakeit_BeerIbu": g.localFaker.BeerIbu, - "fakeit_BeerMalt": g.localFaker.BeerMalt, - "fakeit_BeerName": g.localFaker.BeerName, - "fakeit_BeerStyle": g.localFaker.BeerStyle, - "fakeit_BeerYeast": g.localFaker.BeerYeast, - - // FakeIt / Car - "fakeit_CarMaker": g.localFaker.CarMaker, - "fakeit_CarModel": g.localFaker.CarModel, - "fakeit_CarType": g.localFaker.CarType, - "fakeit_CarFuelType": g.localFaker.CarFuelType, - "fakeit_CarTransmissionType": g.localFaker.CarTransmissionType, - - // FakeIt / Words - "fakeit_Noun": g.localFaker.Noun, - "fakeit_NounCommon": g.localFaker.NounCommon, - "fakeit_NounConcrete": g.localFaker.NounConcrete, - "fakeit_NounAbstract": g.localFaker.NounAbstract, - "fakeit_NounCollectivePeople": g.localFaker.NounCollectivePeople, - "fakeit_NounCollectiveAnimal": g.localFaker.NounCollectiveAnimal, - "fakeit_NounCollectiveThing": g.localFaker.NounCollectiveThing, - "fakeit_NounCountable": g.localFaker.NounCountable, - "fakeit_NounUncountable": g.localFaker.NounUncountable, - "fakeit_Verb": g.localFaker.Verb, - "fakeit_VerbAction": g.localFaker.VerbAction, - "fakeit_VerbLinking": g.localFaker.VerbLinking, - "fakeit_VerbHelping": g.localFaker.VerbHelping, - "fakeit_Adverb": g.localFaker.Adverb, - "fakeit_AdverbManner": g.localFaker.AdverbManner, - "fakeit_AdverbDegree": g.localFaker.AdverbDegree, - "fakeit_AdverbPlace": g.localFaker.AdverbPlace, - "fakeit_AdverbTimeDefinite": g.localFaker.AdverbTimeDefinite, - "fakeit_AdverbTimeIndefinite": g.localFaker.AdverbTimeIndefinite, - "fakeit_AdverbFrequencyDefinite": g.localFaker.AdverbFrequencyDefinite, - "fakeit_AdverbFrequencyIndefinite": g.localFaker.AdverbFrequencyIndefinite, - "fakeit_Preposition": g.localFaker.Preposition, - "fakeit_PrepositionSimple": g.localFaker.PrepositionSimple, - "fakeit_PrepositionDouble": g.localFaker.PrepositionDouble, - "fakeit_PrepositionCompound": g.localFaker.PrepositionCompound, - "fakeit_Adjective": g.localFaker.Adjective, - "fakeit_AdjectiveDescriptive": g.localFaker.AdjectiveDescriptive, - "fakeit_AdjectiveQuantitative": g.localFaker.AdjectiveQuantitative, - "fakeit_AdjectiveProper": g.localFaker.AdjectiveProper, - "fakeit_AdjectiveDemonstrative": g.localFaker.AdjectiveDemonstrative, - "fakeit_AdjectivePossessive": g.localFaker.AdjectivePossessive, - "fakeit_AdjectiveInterrogative": g.localFaker.AdjectiveInterrogative, - "fakeit_AdjectiveIndefinite": g.localFaker.AdjectiveIndefinite, - "fakeit_Pronoun": g.localFaker.Pronoun, - "fakeit_PronounPersonal": g.localFaker.PronounPersonal, - "fakeit_PronounObject": g.localFaker.PronounObject, - "fakeit_PronounPossessive": g.localFaker.PronounPossessive, - "fakeit_PronounReflective": g.localFaker.PronounReflective, - "fakeit_PronounDemonstrative": g.localFaker.PronounDemonstrative, - "fakeit_PronounInterrogative": g.localFaker.PronounInterrogative, - "fakeit_PronounRelative": g.localFaker.PronounRelative, - "fakeit_Connective": g.localFaker.Connective, - "fakeit_ConnectiveTime": g.localFaker.ConnectiveTime, - "fakeit_ConnectiveComparative": g.localFaker.ConnectiveComparative, - "fakeit_ConnectiveComplaint": g.localFaker.ConnectiveComplaint, - "fakeit_ConnectiveListing": g.localFaker.ConnectiveListing, - "fakeit_ConnectiveCasual": g.localFaker.ConnectiveCasual, - "fakeit_ConnectiveExamplify": g.localFaker.ConnectiveExamplify, - "fakeit_Word": g.localFaker.Word, - "fakeit_Sentence": g.localFaker.Sentence, - "fakeit_Paragraph": g.localFaker.Paragraph, - "fakeit_LoremIpsumWord": g.localFaker.LoremIpsumWord, - "fakeit_LoremIpsumSentence": g.localFaker.LoremIpsumSentence, - "fakeit_LoremIpsumParagraph": g.localFaker.LoremIpsumParagraph, - "fakeit_Question": g.localFaker.Question, - "fakeit_Quote": g.localFaker.Quote, - "fakeit_Phrase": g.localFaker.Phrase, - - // FakeIt / Foods - "fakeit_Fruit": g.localFaker.Fruit, - "fakeit_Vegetable": g.localFaker.Vegetable, - "fakeit_Breakfast": g.localFaker.Breakfast, - "fakeit_Lunch": g.localFaker.Lunch, - "fakeit_Dinner": g.localFaker.Dinner, - "fakeit_Snack": g.localFaker.Snack, - "fakeit_Dessert": g.localFaker.Dessert, - - // FakeIt / Misc - "fakeit_Bool": g.localFaker.Bool, - "fakeit_UUID": g.localFaker.UUID, - "fakeit_FlipACoin": g.localFaker.FlipACoin, - - // FakeIt / Colors - "fakeit_Color": g.localFaker.Color, - "fakeit_HexColor": g.localFaker.HexColor, - "fakeit_RGBColor": g.localFaker.RGBColor, - "fakeit_SafeColor": g.localFaker.SafeColor, - "fakeit_NiceColors": g.localFaker.NiceColors, - - // FakeIt / Internet - "fakeit_URL": g.localFaker.URL, - "fakeit_DomainName": g.localFaker.DomainName, - "fakeit_DomainSuffix": g.localFaker.DomainSuffix, - "fakeit_IPv4Address": g.localFaker.IPv4Address, - "fakeit_IPv6Address": g.localFaker.IPv6Address, - "fakeit_MacAddress": g.localFaker.MacAddress, - "fakeit_HTTPStatusCode": g.localFaker.HTTPStatusCode, - "fakeit_HTTPStatusCodeSimple": g.localFaker.HTTPStatusCodeSimple, - "fakeit_LogLevel": g.localFaker.LogLevel, - "fakeit_HTTPMethod": g.localFaker.HTTPMethod, - "fakeit_HTTPVersion": g.localFaker.HTTPVersion, - "fakeit_UserAgent": g.localFaker.UserAgent, - "fakeit_ChromeUserAgent": g.localFaker.ChromeUserAgent, - "fakeit_FirefoxUserAgent": g.localFaker.FirefoxUserAgent, - "fakeit_OperaUserAgent": g.localFaker.OperaUserAgent, - "fakeit_SafariUserAgent": g.localFaker.SafariUserAgent, - - // FakeIt / HTML - "fakeit_InputName": g.localFaker.InputName, - - // FakeIt / Date/Time - "fakeit_Date": g.localFaker.Date, - "fakeit_PastDate": g.localFaker.PastDate, - "fakeit_FutureDate": g.localFaker.FutureDate, - "fakeit_DateRange": g.localFaker.DateRange, - "fakeit_NanoSecond": g.localFaker.NanoSecond, - "fakeit_Second": g.localFaker.Second, - "fakeit_Minute": g.localFaker.Minute, - "fakeit_Hour": g.localFaker.Hour, - "fakeit_Month": g.localFaker.Month, - "fakeit_MonthString": g.localFaker.MonthString, - "fakeit_Day": g.localFaker.Day, - "fakeit_WeekDay": g.localFaker.WeekDay, - "fakeit_Year": g.localFaker.Year, - "fakeit_TimeZone": g.localFaker.TimeZone, - "fakeit_TimeZoneAbv": g.localFaker.TimeZoneAbv, - "fakeit_TimeZoneFull": g.localFaker.TimeZoneFull, - "fakeit_TimeZoneOffset": g.localFaker.TimeZoneOffset, - "fakeit_TimeZoneRegion": g.localFaker.TimeZoneRegion, - - // FakeIt / Payment - "fakeit_Price": g.localFaker.Price, - "fakeit_CreditCardCvv": g.localFaker.CreditCardCvv, - "fakeit_CreditCardExp": g.localFaker.CreditCardExp, - "fakeit_CreditCardNumber": g.localFaker.CreditCardNumber, - "fakeit_CreditCardType": g.localFaker.CreditCardType, - "fakeit_CurrencyLong": g.localFaker.CurrencyLong, - "fakeit_CurrencyShort": g.localFaker.CurrencyShort, - "fakeit_AchRouting": g.localFaker.AchRouting, - "fakeit_AchAccount": g.localFaker.AchAccount, - "fakeit_BitcoinAddress": g.localFaker.BitcoinAddress, - "fakeit_BitcoinPrivateKey": g.localFaker.BitcoinPrivateKey, - - // FakeIt / Finance - "fakeit_Cusip": g.localFaker.Cusip, - "fakeit_Isin": g.localFaker.Isin, - - // FakeIt / Company - "fakeit_BS": g.localFaker.BS, - "fakeit_Blurb": g.localFaker.Blurb, - "fakeit_BuzzWord": g.localFaker.BuzzWord, - "fakeit_Company": g.localFaker.Company, - "fakeit_CompanySuffix": g.localFaker.CompanySuffix, - "fakeit_JobDescriptor": g.localFaker.JobDescriptor, - "fakeit_JobLevel": g.localFaker.JobLevel, - "fakeit_JobTitle": g.localFaker.JobTitle, - "fakeit_Slogan": g.localFaker.Slogan, - - // FakeIt / Hacker - "fakeit_HackerAbbreviation": g.localFaker.HackerAbbreviation, - "fakeit_HackerAdjective": g.localFaker.HackerAdjective, - "fakeit_HackerNoun": g.localFaker.HackerNoun, - "fakeit_HackerPhrase": g.localFaker.HackerPhrase, - "fakeit_HackerVerb": g.localFaker.HackerVerb, - - // FakeIt / Hipster - "fakeit_HipsterWord": g.localFaker.HipsterWord, - "fakeit_HipsterSentence": g.localFaker.HipsterSentence, - "fakeit_HipsterParagraph": g.localFaker.HipsterParagraph, - - // FakeIt / App - "fakeit_AppName": g.localFaker.AppName, - "fakeit_AppVersion": g.localFaker.AppVersion, - "fakeit_AppAuthor": g.localFaker.AppAuthor, - - // FakeIt / Animal - "fakeit_PetName": g.localFaker.PetName, - "fakeit_Animal": g.localFaker.Animal, - "fakeit_AnimalType": g.localFaker.AnimalType, - "fakeit_FarmAnimal": g.localFaker.FarmAnimal, - "fakeit_Cat": g.localFaker.Cat, - "fakeit_Dog": g.localFaker.Dog, - "fakeit_Bird": g.localFaker.Bird, - - // FakeIt / Emoji - "fakeit_Emoji": g.localFaker.Emoji, - "fakeit_EmojiDescription": g.localFaker.EmojiDescription, - "fakeit_EmojiCategory": g.localFaker.EmojiCategory, - "fakeit_EmojiAlias": g.localFaker.EmojiAlias, - "fakeit_EmojiTag": g.localFaker.EmojiTag, - - // FakeIt / Language - "fakeit_Language": g.localFaker.Language, - "fakeit_LanguageAbbreviation": g.localFaker.LanguageAbbreviation, - "fakeit_ProgrammingLanguage": g.localFaker.ProgrammingLanguage, - - // FakeIt / Number - "fakeit_Number": g.localFaker.Number, - "fakeit_Int": g.localFaker.Int, - "fakeit_IntN": g.localFaker.IntN, - "fakeit_IntRange": g.localFaker.IntRange, - "fakeit_RandomInt": g.localFaker.RandomInt, - "fakeit_Int8": g.localFaker.Int8, - "fakeit_Int16": g.localFaker.Int16, - "fakeit_Int32": g.localFaker.Int32, - "fakeit_Int64": g.localFaker.Int64, - "fakeit_Uint": g.localFaker.Uint, - "fakeit_UintN": g.localFaker.UintN, - "fakeit_UintRange": g.localFaker.UintRange, - "fakeit_RandomUint": g.localFaker.RandomUint, - "fakeit_Uint8": g.localFaker.Uint8, - "fakeit_Uint16": g.localFaker.Uint16, - "fakeit_Uint32": g.localFaker.Uint32, - "fakeit_Uint64": g.localFaker.Uint64, - "fakeit_Float32": g.localFaker.Float32, - "fakeit_Float32Range": g.localFaker.Float32Range, - "fakeit_Float64": g.localFaker.Float64, - "fakeit_Float64Range": g.localFaker.Float64Range, - "fakeit_HexUint": g.localFaker.HexUint, - - // FakeIt / String - "fakeit_Digit": g.localFaker.Digit, - "fakeit_DigitN": g.localFaker.DigitN, - "fakeit_Letter": g.localFaker.Letter, - "fakeit_LetterN": g.localFaker.LetterN, - "fakeit_LetterNN": func(min, max uint) string { - return g.localFaker.LetterN(g.localFaker.UintRange(min, max)) - }, - "fakeit_Lexify": g.localFaker.Lexify, - "fakeit_Numerify": g.localFaker.Numerify, - "fakeit_RandomString": func(values ...string) string { - return g.localFaker.RandomString(values) - }, - - // FakeIt / Celebrity - "fakeit_CelebrityActor": g.localFaker.CelebrityActor, - "fakeit_CelebrityBusiness": g.localFaker.CelebrityBusiness, - "fakeit_CelebritySport": g.localFaker.CelebritySport, - - // FakeIt / Minecraft - "fakeit_MinecraftOre": g.localFaker.MinecraftOre, - "fakeit_MinecraftWood": g.localFaker.MinecraftWood, - "fakeit_MinecraftArmorTier": g.localFaker.MinecraftArmorTier, - "fakeit_MinecraftArmorPart": g.localFaker.MinecraftArmorPart, - "fakeit_MinecraftWeapon": g.localFaker.MinecraftWeapon, - "fakeit_MinecraftTool": g.localFaker.MinecraftTool, - "fakeit_MinecraftDye": g.localFaker.MinecraftDye, - "fakeit_MinecraftFood": g.localFaker.MinecraftFood, - "fakeit_MinecraftAnimal": g.localFaker.MinecraftAnimal, - "fakeit_MinecraftVillagerJob": g.localFaker.MinecraftVillagerJob, - "fakeit_MinecraftVillagerStation": g.localFaker.MinecraftVillagerStation, - "fakeit_MinecraftVillagerLevel": g.localFaker.MinecraftVillagerLevel, - "fakeit_MinecraftMobPassive": g.localFaker.MinecraftMobPassive, - "fakeit_MinecraftMobNeutral": g.localFaker.MinecraftMobNeutral, - "fakeit_MinecraftMobHostile": g.localFaker.MinecraftMobHostile, - "fakeit_MinecraftMobBoss": g.localFaker.MinecraftMobBoss, - "fakeit_MinecraftBiome": g.localFaker.MinecraftBiome, - "fakeit_MinecraftWeather": g.localFaker.MinecraftWeather, - - // FakeIt / Book - "fakeit_BookTitle": g.localFaker.BookTitle, - "fakeit_BookAuthor": g.localFaker.BookAuthor, - "fakeit_BookGenre": g.localFaker.BookGenre, - - // FakeIt / Movie - "fakeit_MovieName": g.localFaker.MovieName, - "fakeit_MovieGenre": g.localFaker.MovieGenre, - - // FakeIt / Error - "fakeit_Error": g.localFaker.Error, - "fakeit_ErrorDatabase": g.localFaker.ErrorDatabase, - "fakeit_ErrorGRPC": g.localFaker.ErrorGRPC, - "fakeit_ErrorHTTP": g.localFaker.ErrorHTTP, - "fakeit_ErrorHTTPClient": g.localFaker.ErrorHTTPClient, - "fakeit_ErrorHTTPServer": g.localFaker.ErrorHTTPServer, - "fakeit_ErrorRuntime": g.localFaker.ErrorRuntime, - - // FakeIt / School - "fakeit_School": g.localFaker.School, - - // FakeIt / Song - "fakeit_SongName": g.localFaker.SongName, - "fakeit_SongArtist": g.localFaker.SongArtist, - "fakeit_SongGenre": g.localFaker.SongGenre, - } -} diff --git a/utils/time.go b/utils/time.go deleted file mode 100644 index 9aff2fa..0000000 --- a/utils/time.go +++ /dev/null @@ -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 -}