From 6a2f3c26be11e332d2e38122009436cda4fa58f4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christian=20Gonz=C3=A1lez=20Di=20Antonio?= Date: Sun, 18 Jan 2026 13:37:24 +0100 Subject: [PATCH 1/9] feat: first version --- .github/copilot-instructions.md | 44 + .github/workflows/main.yaml | 84 ++ .github/workflows/pr.yaml | 80 ++ .github/workflows/release.yaml | 75 ++ .golangci.yaml | 73 ++ .vscode/settings.json | 18 + DEVELOPMENT_GUIDELINES.md | 1 + README.md | 1340 ++++++++++++++++++++++- docs.go | 411 +++++++ example_generic_client_test.go | 366 +++++++ example_http_client_test.go | 265 +++++ example_http_retrier_test.go | 223 ++++ example_logger_test.go | 191 ++++ example_request_builder_test.go | 330 ++++++ go.mod | 3 + http_client.go | 387 +++++++ http_client_test.go | 203 ++++ http_generic_client.go | 443 ++++++++ http_generic_client_test.go | 727 ++++++++++++ http_request_builder.go | 475 ++++++++ http_request_builder_test.go | 1120 +++++++++++++++++++ http_request_builder_validation_test.go | 318 ++++++ http_retrier.go | 265 +++++ http_retrier_test.go | 684 ++++++++++++ logger_test.go | 220 ++++ 25 files changed, 8345 insertions(+), 1 deletion(-) create mode 100644 .github/copilot-instructions.md create mode 100644 .github/workflows/main.yaml create mode 100644 .github/workflows/pr.yaml create mode 100644 .github/workflows/release.yaml create mode 100644 .golangci.yaml create mode 100644 .vscode/settings.json create mode 120000 DEVELOPMENT_GUIDELINES.md create mode 100644 docs.go create mode 100644 example_generic_client_test.go create mode 100644 example_http_client_test.go create mode 100644 example_http_retrier_test.go create mode 100644 example_logger_test.go create mode 100644 example_request_builder_test.go create mode 100644 go.mod create mode 100644 http_client.go create mode 100644 http_client_test.go create mode 100644 http_generic_client.go create mode 100644 http_generic_client_test.go create mode 100644 http_request_builder.go create mode 100644 http_request_builder_test.go create mode 100644 http_request_builder_validation_test.go create mode 100644 http_retrier.go create mode 100644 http_retrier_test.go create mode 100644 logger_test.go diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md new file mode 100644 index 0000000..11b689e --- /dev/null +++ b/.github/copilot-instructions.md @@ -0,0 +1,44 @@ +# Development Guidelines + +This document contains the critical information about working with the project codebase. +Follows these guidelines precisely to ensure consistency and maintainability of the code. + +## Stack + +- Language: Go (Go 1.22+) +- Framework: Go standard library +- Testing: Go's built-in testing package +- Dependency Management: Go modules +- Version Control: Git +- Documentation: go doc +- Code Review: Pull requests on GitHub +- CI/CD: GitHub Actions +- Logging: `slog` package from the standard library + +## Project Structure + +Since this is a library build in native go, the files are mostly organized following the standard Go project layout with some additional folders for specific functionalities. + +- Library files are located in the root directory. +- examples/ contains example code demonstrating how to use the library. +- .github/ contains GitHub-specific files such as workflows for CI/CD. +- .gitignore specifies files and directories to be ignored by Git. +- .vscode/ contains Visual Studio Code configuration files. +- LICENSE is the license file for the project. +- README.md provides an overview of the project, installation instructions, usage examples, and other relevant information. +- go.mod and go.sum manage the project's dependencies. +- \*.go files contain the main source code of the library. +- \*\_test.go files contain the test cases for the library. + +## Code Style + +- Follow Go's idiomatic style defined in + - #fetch https://google.github.io/styleguide/go/guide + - #fetch https://google.github.io/styleguide/go/decisions + - #fetch https://google.github.io/styleguide/go/best-practices + - #fetch https://golang.org/doc/effective_go.html +- Use meaningful names for variables, functions, and packages. +- Keep functions small and focused on a single task. +- Use comments to explain complex logic or decisions. +- Use dependency injection for services and repositories to facilitate testing and maintainability. +- don't use `interface{}` instead use `any` for better readability. diff --git a/.github/workflows/main.yaml b/.github/workflows/main.yaml new file mode 100644 index 0000000..90f6aa5 --- /dev/null +++ b/.github/workflows/main.yaml @@ -0,0 +1,84 @@ +name: Main + +on: + push: + branches: + - main + +jobs: + build: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v6 + + - name: Set up Go + uses: actions/setup-go@v6 + with: + go-version-file: ./go.mod + + - name: Summary Information + run: | + echo "# Push Summary" > $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo "**Repository:** ${{ github.repository }}" >> $GITHUB_STEP_SUMMARY + echo "**Push:** ${{ github.event.head_commit.message }}" >> $GITHUB_STEP_SUMMARY + echo "**Author:** ${{ github.event.head_commit.author.name }}" >> $GITHUB_STEP_SUMMARY + echo "**Branch:** ${{ github.ref }}" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + + - name: Tools and versions + run: | + echo "## Tools and versions" >> $GITHUB_STEP_SUMMARY + + ubuntu_version=$(lsb_release -a 2>&1 | grep "Description" | awk '{print $2, $3, $4}') + echo "Ubuntu version: $ubuntu_version" + echo "**Ubuntu Version:** $ubuntu_version" >> $GITHUB_STEP_SUMMARY + + bash_version=$(bash --version | head -n 1 | awk '{print $4}') + echo "Bash version: $bash_version" + echo "**Bash Version:** $bash_version" >> $GITHUB_STEP_SUMMARY + + git_version=$(git --version | awk '{print $3}') + echo "Git version: $git_version" + echo "**Git Version:** $git_version" >> $GITHUB_STEP_SUMMARY + + go_version=$(go version | awk '{print $3}') + echo "Go version: $go_version" + echo "**Go Version:** $go_version" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + + - name: Lines of code + run: | + echo "## Lines of code" >> $GITHUB_STEP_SUMMARY + + go install github.com/boyter/scc/v3@latest + scc --format html-table . | tee -a $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + + - name: test + run: | + echo "### Test report" >> $GITHUB_STEP_SUMMARY + + go test -race -coverprofile=coverage.txt -covermode=atomic -tags=unit ./... | tee -a $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + + - name: test coverage + run: | + echo "## Test Coverage" >> $GITHUB_STEP_SUMMARY + + go install github.com/vladopajic/go-test-coverage/v2@latest + + # execute again to get the summary + echo "" >> $GITHUB_STEP_SUMMARY + echo "### Coverage report" >> $GITHUB_STEP_SUMMARY + go-test-coverage --config=./.testcoverage.yml | sed 's/PASS/PASS ✅/g' | sed 's/FAIL/FAIL ❌/g' | tee -a $GITHUB_STEP_SUMMARY + + - name: Build + run: | + echo "## Build" >> $GITHUB_STEP_SUMMARY + + go build ./... | tee -a $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo "Build completed successfully." >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY diff --git a/.github/workflows/pr.yaml b/.github/workflows/pr.yaml new file mode 100644 index 0000000..46e0f67 --- /dev/null +++ b/.github/workflows/pr.yaml @@ -0,0 +1,80 @@ +name: Pull Request + +on: + pull_request: + branches: + - main + +jobs: + build: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v6 + + - name: Set up Go + uses: actions/setup-go@v6 + with: + go-version-file: ./go.mod + + - name: Summary Information + run: | + echo "# Pull Request Summary" > $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo "**Repository:** ${{ github.repository }}" >> $GITHUB_STEP_SUMMARY + echo "**Pull Request:** ${{ github.event.pull_request.title }}" >> $GITHUB_STEP_SUMMARY + echo "**Author:** ${{ github.event.pull_request.user.login }}" >> $GITHUB_STEP_SUMMARY + echo "**Branch:** ${{ github.event.pull_request.head.ref }}" >> $GITHUB_STEP_SUMMARY + echo "**Base:** ${{ github.event.pull_request.base.ref }}" >> $GITHUB_STEP_SUMMARY + echo "**Commits:** ${{ github.event.pull_request.commits }}" >> $GITHUB_STEP_SUMMARY + echo "**Changed Files:** ${{ github.event.pull_request.changed_files }}" >> $GITHUB_STEP_SUMMARY + echo "**Additions:** ${{ github.event.pull_request.additions }}" >> $GITHUB_STEP_SUMMARY + echo "**Deletions:** ${{ github.event.pull_request.deletions }}" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + + - name: Tools and versions + run: | + echo "## Tools and versions" >> $GITHUB_STEP_SUMMARY + + ubuntu_version=$(lsb_release -a 2>&1 | grep "Description" | awk '{print $2, $3, $4}') + echo "Ubuntu version: $ubuntu_version" + echo "**Ubuntu Version:** $ubuntu_version" >> $GITHUB_STEP_SUMMARY + + bash_version=$(bash --version | head -n 1 | awk '{print $4}') + echo "Bash version: $bash_version" + echo "**Bash Version:** $bash_version" >> $GITHUB_STEP_SUMMARY + + git_version=$(git --version | awk '{print $3}') + echo "Git version: $git_version" + echo "**Git Version:** $git_version" >> $GITHUB_STEP_SUMMARY + + go_version=$(go version | awk '{print $3}') + echo "Go version: $go_version" + echo "**Go Version:** $go_version" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + + - name: Lines of code + run: | + echo "## Lines of code" >> $GITHUB_STEP_SUMMARY + + go install github.com/boyter/scc/v3@latest + scc --format html-table . | tee -a $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + + - name: test + run: | + echo "### Test report" >> $GITHUB_STEP_SUMMARY + + go test -race -coverprofile=coverage.txt -covermode=atomic -tags=unit ./... | tee -a $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + + - name: test coverage + run: | + echo "## Test Coverage" >> $GITHUB_STEP_SUMMARY + + go install github.com/vladopajic/go-test-coverage/v2@latest + + # execute again to get the summary + echo "" >> $GITHUB_STEP_SUMMARY + echo "### Coverage report" >> $GITHUB_STEP_SUMMARY + go-test-coverage --config=./.testcoverage.yml | sed 's/PASS/PASS ✅/g' | sed 's/FAIL/FAIL ❌/g' | tee -a $GITHUB_STEP_SUMMARY diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml new file mode 100644 index 0000000..1aa271a --- /dev/null +++ b/.github/workflows/release.yaml @@ -0,0 +1,75 @@ +name: Release + +# https://help.github.com/es/actions/reference/workflow-syntax-for-github-actions#filter-pattern-cheat-sheet +on: + push: + tags: + - v[0-9].[0-9]+.[0-9]* + +permissions: + id-token: write + security-events: write + actions: write + contents: write + pull-requests: read + packages: write + +jobs: + release: + name: Release + runs-on: ubuntu-latest + steps: + - name: Check out code + uses: actions/checkout@v6 + + - name: Set up Go 1.x + id: go + uses: actions/setup-go@v6 + with: + go-version-file: ./go.mod + + - name: Summary Information + run: | + echo "# Build Summary" > $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo "**Repository:** ${{ github.repository }}" >> $GITHUB_STEP_SUMMARY + echo "**Who merge:** ${{ github.triggering_actor }}" >> $GITHUB_STEP_SUMMARY + echo "**Commit ID:** ${{ github.sha }}" >> $GITHUB_STEP_SUMMARY + echo "**Branch:** ${{ github.ref_name }}" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + + - name: Lines of code + run: | + echo "## Lines of code" >> $GITHUB_STEP_SUMMARY + + go install github.com/boyter/scc/v3@latest + scc --format html-table . | tee -a $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + + - name: test + run: | + echo "### Test report" >> $GITHUB_STEP_SUMMARY + + go test -race -coverprofile=coverage.txt -covermode=atomic -tags=unit ./... | tee -a $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + + - name: test coverage + run: | + echo "## Test Coverage" >> $GITHUB_STEP_SUMMARY + + go install github.com/vladopajic/go-test-coverage/v2@latest + + # execute again to get the summary + echo "" >> $GITHUB_STEP_SUMMARY + echo "### Coverage report" >> $GITHUB_STEP_SUMMARY + go-test-coverage --config=./.testcoverage.yml | sed 's/PASS/PASS ✅/g' | sed 's/FAIL/FAIL ❌/g' | tee -a $GITHUB_STEP_SUMMARY + + - name: Release + uses: softprops/action-gh-release@v2 + with: + tag_name: ${{ github.ref_name }} + name: ${{ github.ref_name }} + draft: false + prerelease: false + generate_release_notes: true + make_latest: true diff --git a/.golangci.yaml b/.golangci.yaml new file mode 100644 index 0000000..720ed32 --- /dev/null +++ b/.golangci.yaml @@ -0,0 +1,73 @@ +version: "2" +linters: + # Enable specific linter + # https://golangci-lint.run/usage/linters/#enabled-by-default + enable: + - errcheck + - ineffassign + - staticcheck + - unused + + # Disable specific linter + # https://golangci-lint.run/usage/linters/#disabled-by-default + disable: + # Enable presets. + # https://golangci-lint.run/usage/linters + # Default: [] + - govet + - godot + - wsl + - testpackage + - whitespace + - tagalign + - nosprintfhostport + - nlreturn + - nestif + - mnd + - misspell + - lll + - godox + - funlen + - gochecknoinits + - depguard + - goconst + - dupword + - cyclop + - gocognit + - maintidx + - gocyclo + - dupl + + settings: + errcheck: + # Report about not checking of errors in type assertions: `a := b.(MyStruct)`. + # Such cases aren't reported by default. + # Default: false + check-type-assertions: false + # report about assignment of errors to blank identifier: `num, _ := strconv.Atoi(numStr)`. + # Such cases aren't reported by default. + # Default: false + check-blank: false + # To disable the errcheck built-in exclude list. + # See `-excludeonly` option in https://github.com/kisielk/errcheck#excluding-functions for details. + # Default: false + disable-default-exclusions: true + # List of functions to exclude from checking, where each entry is a single function to exclude. + # See https://github.com/kisielk/errcheck#excluding-functions for details. + exclude-functions: + - (*os.File).Close + - (io.Closer).Close + - io/ioutil.ReadFile + - io.Copy(*bytes.Buffer) + - io.Copy(os.Stdout) + - (io.Writer).Write + - (*bufio.Writer).Write + - (*encoding/json.Encoder).Encode + - os.Setenv + - os.Unsetenv + - fmt.Printf + - fmt.Print + - fmt.Println + - fmt.Fprint + - fmt.Fprintf + - (*strings.Builder).WriteString diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..4a43fbf --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,18 @@ +{ + "cSpell.words": [ + "Agentic", + "Alives", + "Fprint", + "Fprintf", + "getbody", + "golangci", + "goroutines", + "httpx", + "Retryable", + "slashdevops", + "Strat", + "testkey", + "unmarshals", + "Unsetenv" + ] +} \ No newline at end of file diff --git a/DEVELOPMENT_GUIDELINES.md b/DEVELOPMENT_GUIDELINES.md new file mode 120000 index 0000000..02dd134 --- /dev/null +++ b/DEVELOPMENT_GUIDELINES.md @@ -0,0 +1 @@ +.github/copilot-instructions.md \ No newline at end of file diff --git a/README.md b/README.md index b2f2216..6a05c61 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,1340 @@ # httpx -A comprehensive Go package for building and executing HTTP requests with advanced features + +[![main branch](https://github.com/slashdevops/httpx/actions/workflows/main.yml/badge.svg)](https://github.com/slashdevops/httpx/actions/workflows/main.yml) +![GitHub go.mod Go version](https://img.shields.io/github/go-mod/go-version/slashdevops/httpx?style=plastic) +[![Go Reference](https://pkg.go.dev/badge/github.com/slashdevops/httpx.svg)](https://pkg.go.dev/github.com/slashdevops/httpx) +[![Go Report Card](https://goreportcard.com/badge/github.com/slashdevops/httpx)](https://goreportcard.com/report/github.com/slashdevops/httpx) +[![license](https://img.shields.io/github/license/slashdevops/httpx.svg)](https://github.com/slashdevops/httpx/blob/main/LICENSE) +[![Release](https://github.com/slashdevops/httpx/actions/workflows/release.yml/badge.svg)](https://github.com/slashdevops/httpx/actions/workflows/release.yml) + +A comprehensive Go package for building and executing HTTP requests with advanced features. + +**🚀 Zero Dependencies** - Built entirely using the Go standard library for maximum reliability, security, and minimal maintenance overhead. See [go.mod](go.mod) + +## Key Features + +- 🔨 **Fluent Request Builder** - Chainable API for constructing HTTP requests +- 🔄 **Automatic Retry Logic** - Configurable retry strategies with exponential backoff +- 🎯 **Type-Safe Generic Client** - Go generics for type-safe HTTP responses +- ✅ **Input Validation** - Comprehensive validation with error accumulation +- 🔐 **Authentication Support** - Built-in Basic and Bearer token authentication +- 📝 **Optional Logging** - slog integration for observability (disabled by default) +- 📦 **Zero External Dependencies** - Only Go standard library, no third-party packages + +## Table of Contents + +- [Installation](#installation) +- [Upgrade](#upgrade) +- [Quick Start](#quick-start) +- [Features](#features) + - [Request Builder](#request-builder) + - [Generic HTTP Client](#generic-http-client) + - [Retry Logic](#retry-logic) + - [Client Builder](#client-builder) + - [Logging](#logging) +- [Examples](#examples) +- [API Reference](#api-reference) +- [Best Practices](#best-practices) +- [Contributing](#contributing) + +## Installation + +```bash +go get github.com/slashdevops/httpx +``` + +## Upgrade + +To upgrade to the latest version, run: + +```bash +go get -u github.com/slashdevops/httpx +``` + +## Quick Start + +### Simple GET Request + +```go +import "github.com/slashdevops/httpx" + +// Build and execute a simple GET request +req, err := httpx.NewRequestBuilder("https://api.example.com"). + WithMethodGET(). + WithPath("/users/123"). + WithHeader("Accept", "application/json"). + Build() + +if err != nil { + log.Fatal(err) +} + +// Use with standard http.Client +resp, err := http.DefaultClient.Do(req) +``` + +### Type-Safe Requests with Generic Client + +```go +type User struct { + ID int `json:"id"` + Name string `json:"name"` + Email string `json:"email"` +} + +// Create a typed client with configuration +client := httpx.NewGenericClient[User]( + httpx.WithTimeout[User](10 * time.Second), + httpx.WithMaxRetries[User](3), + httpx.WithRetryStrategy[User](httpx.ExponentialBackoffStrategy), +) + +// Execute typed request +response, err := client.Get("https://api.example.com/users/123") +if err != nil { + log.Fatal(err) +} + +// response.Data is strongly typed as User +fmt.Printf("User: %s (%s)\n", response.Data.Name, response.Data.Email) +``` + +### Request with Retry Logic + +```go +// Create client with retry logic +retryClient := httpx.NewClientBuilder(). + WithMaxRetries(3). + WithRetryStrategy(httpx.ExponentialBackoffStrategy). + WithRetryBaseDelay(500 * time.Millisecond). + Build() + +// Use with generic client +client := httpx.NewGenericClient[User]( + httpx.WithHTTPClient[User](retryClient), + httpx.) + +response, err := client.Get("/users/123") +``` + +## Features + +### Request Builder + +The `RequestBuilder` provides a fluent, chainable API for constructing HTTP requests with comprehensive validation. + +#### Key Features + +- ✅ HTTP methods: GET, POST, PUT, DELETE, PATCH, HEAD, OPTIONS, TRACE, CONNECT +- ✅ Convenience methods for all standard HTTP methods (WithMethodGET, WithMethodPOST, WithMethodPUT, WithMethodDELETE, WithMethodPATCH, WithMethodHEAD, WithMethodOPTIONS, WithMethodTRACE, WithMethodCONNECT) +- ✅ Query parameters with automatic URL encoding +- ✅ Custom headers with validation +- ✅ Authentication (Basic Auth, Bearer Token) +- ✅ Multiple body formats (JSON, string, bytes, io.Reader) +- ✅ Context support for timeouts and cancellation +- ✅ Input validation with error accumulation +- ✅ Comprehensive error messages + +#### Usage Example + +```go +req, err := httpx.NewRequestBuilder("https://api.example.com"). + WithMethodPOST(). + WithPath("/users"). + WithQueryParam("notify", "true"). + WithHeader("Content-Type", "application/json"). + WithHeader("X-Request-ID", "unique-id-123"). + WithBearerAuth("your-token-here"). + WithJSONBody(map[string]string{ + "name": "John Doe", + "email": "john@example.com", + }). + Build() + +if err != nil { + // Handle validation errors + log.Fatal(err) +} +``` + +#### Validation Features + +The RequestBuilder validates inputs and accumulates errors: + +```go +builder := httpx.NewRequestBuilder("https://api.example.com") +builder.HTTPMethod("") // Error: empty method +builder.WithHeader("", "value") // Error: empty header key +builder.WithQueryParam("key=", "val") // Error: invalid character in key + +// Check for errors before building +if builder.HasErrors() { + for _, err := range builder.GetErrors() { + log.Printf("Validation error: %v", err) + } +} + +// Or let Build() report all errors +req, err := builder.Build() +if err != nil { + // err contains all accumulated validation errors + log.Fatal(err) +} +``` + +#### Reset and Reuse + +```go +builder := httpx.NewRequestBuilder("https://api.example.com") + +// Use builder +req1, _ := builder.WithWithMethodGET().WithPath("/users").Build() + +// Reset and reuse +builder.Reset() +req2, _ := builder.WithWithMethodPOST().WithPath("/posts").Build() +``` + +### Generic HTTP Client + +The `GenericClient` provides type-safe HTTP requests with automatic JSON marshaling and unmarshaling using Go generics. + +#### Key Features + +- 🎯 Type-safe responses with automatic JSON unmarshaling +- 🔄 Convenience methods: Get, Post, Put, Delete, Patch +- 🔌 Execute method for use with RequestBuilder +- 📦 ExecuteRaw for non-JSON responses +- 🌐 Base URL resolution for relative paths +- 📋 Default headers applied to all requests +- ❌ Structured error responses +- 🔁 Full integration with retry logic + +#### Basic Usage + +```go +type Post struct { + ID int `json:"id"` + Title string `json:"title"` + Body string `json:"body"` + UserID int `json:"userId"` +} + +client := httpx.NewGenericClient[Post]( + httpx.WithTimeout[Post](10 * time.Second), + httpx.WithMaxRetries[Post](3), + httpx.WithRetryStrategy[Post](httpx.ExponentialBackoffStrategy), +) + +// GET request +response, err := client.Get("https://api.example.com/posts/1") +if err != nil { + log.Fatal(err) +} +fmt.Printf("Title: %s\n", response.Data.Title) + +// POST request +newPost := Post{Title: "New Post", Body: "Content", UserID: 1} +postData, _ := json.Marshal(newPost) +response, err = client.Post("https://api.example.com/posts", bytes.NewReader(postData)) +``` + +#### With RequestBuilder + +Combine GenericClient with RequestBuilder for maximum flexibility: + +```go +type User struct { + ID int `json:"id"` + Name string `json:"name"` + Email string `json:"email"` +} + +client := httpx.NewGenericClient[User]( + httpx.WithTimeout[User](15 * time.Second), + httpx.WithMaxRetries[User](3), +) + +// Build complex request +req, err := httpx.NewRequestBuilder("https://api.example.com"). + WithMethodPOST(). + WithPath("/users"). + WithContentType("application/json"). + WithHeader("X-Request-ID", "unique-123"). + WithJSONBody(User{Name: "Jane", Email: "jane@example.com"}). + Build() + +if err != nil { + log.Fatal(err) +} + +// Execute with type safety +response, err := client.Execute(req) +if err != nil { + log.Fatal(err) +} + +fmt.Printf("Created user ID: %d\n", response.Data.ID) +``` + +#### Error Handling + +The generic client returns structured errors: + +```go +response, err := client.Get("/users/999999") +if err != nil { + // Check if it's an API error + if apiErr, ok := err.(*httpx.ErrorResponse); ok { + fmt.Printf("API Error %d: %s\n", apiErr.StatusCode, apiErr.Message) + // StatusCode: 404 + // Message: "User not found" + } else { + // Network error, parsing error, etc. + log.Printf("Request failed: %v\n", err) + } + return +} +``` + +#### Multiple Typed Clients + +Use different clients for different response types: + +```go +type User struct { /* ... */ } +type Post struct { /* ... */ } + +userClient := httpx.NewGenericClient[User]( + httpx.WithTimeout[User](10 * time.Second), +) + +postClient := httpx.NewGenericClient[Post]( + httpx.WithTimeout[Post](10 * time.Second), +) + +// Fetch user +userResp, _ := userClient.Get("/users/1") + +// Fetch user's posts +postsResp, _ := postClient.Get(fmt.Sprintf("/users/%d/posts", userResp.Data.ID)) +``` + +### Retry Logic + +The package provides transparent retry logic with configurable strategies. + +#### Retry Strategies + +##### Exponential Backoff (Recommended) + +Doubles the wait time between retries: + +```go +client := httpx.NewClientBuilder(). + WithMaxRetries(3). + WithRetryStrategy(httpx.ExponentialBackoffStrategy). + WithRetryBaseDelay(500 * time.Millisecond). + WithRetryMaxDelay(10 * time.Second). + Build() +``` + +Wait times: 500ms → 1s → 2s → 4s (capped at maxDelay) + +##### Fixed Delay + +Waits a constant duration between retries: + +```go +client := httpx.NewClientBuilder(). + WithMaxRetries(3). + WithRetryStrategy(httpx.FixedDelayStrategy). + WithRetryBaseDelay(1 * time.Second). + Build() +``` + +Wait times: 1s → 1s → 1s + +##### Jitter Backoff + +Adds randomization to exponential backoff to prevent thundering herd: + +```go +client := httpx.NewClientBuilder(). + WithMaxRetries(3). + WithRetryStrategy(httpx.JitterBackoffStrategy). + WithRetryBaseDelay(500 * time.Millisecond). + WithRetryMaxDelay(10 * time.Second). + Build() +``` + +Wait times: Random between 0-500ms → 0-1s → 0-2s + +#### What Gets Retried? + +The retry logic automatically retries: + +- Network errors (connection failures, timeouts) +- HTTP 5xx server errors (500-599) +- HTTP 429 (Too Many Requests) + +Does NOT retry: + +- HTTP 4xx client errors (except 429) +- HTTP 2xx/3xx successful responses +- Requests without GetBody (non-replayable requests) + +#### Retry with Generic Client + +```go +// Create retry client +retryClient := httpx.NewClientBuilder(). + WithMaxRetries(3). + WithRetryStrategy(httpx.ExponentialBackoffStrategy). + Build() + +// Use with generic client +client := httpx.NewGenericClient[User]( + httpx.WithHTTPClient[User](retryClient), + httpx.) + +// Requests automatically retry on failure +response, err := client.Get("/users/1") +``` + +### Client Builder + +The `ClientBuilder` provides fine-grained control over HTTP client configuration. + +#### Configuration Options + +```go +client := httpx.NewClientBuilder(). + // Timeouts + WithTimeout(30 * time.Second). + WithIdleConnTimeout(90 * time.Second). + WithTLSHandshakeTimeout(10 * time.Second). + WithExpectContinueTimeout(1 * time.Second). + + // Connection pooling + WithMaxIdleConns(100). + WithMaxIdleConnsPerHost(10). + WithDisableKeepAlive(false). + + // Retry configuration + WithMaxRetries(3). + WithRetryStrategy(httpx.ExponentialBackoffStrategy). + WithRetryBaseDelay(500 * time.Millisecond). + WithRetryMaxDelay(10 * time.Second). + + Build() +``` + +#### Default Values + +| Setting | Default | Valid Range | +|---------|---------|-------------| +| Timeout | 5s | 1s - 30s | +| MaxRetries | 3 | 1 - 10 | +| RetryBaseDelay | 500ms | 300ms - 5s | +| RetryMaxDelay | 10s | 300ms - 120s | +| MaxIdleConns | 100 | 1 - 200 | +| IdleConnTimeout | 90s | 1s - 120s | +| TLSHandshakeTimeout | 10s | 1s - 15s | + +The builder validates all settings and uses defaults for out-of-range values. + +### Logging + +The httpx package supports optional logging using Go's standard `log/slog` package. **Logging is disabled by default** to maintain clean, silent HTTP operations. Enable it when you need observability into retries, errors, and other HTTP client operations. + +#### Quick Start + +##### Basic Usage + +```go +import ( + "log/slog" + "os" + + "github.com/slashdevops/httpx" +) + +// Create a logger +logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{ + Level: slog.LevelWarn, +})) + +// Use with ClientBuilder +client := httpx.NewClientBuilder(). + WithMaxRetries(3). + WithLogger(logger). // Enable logging + Build() +``` + +##### With Generic Client + +```go +type User struct { + ID int `json:"id"` + Name string `json:"name"` +} + +logger := slog.New(slog.NewJSONHandler(os.Stderr, nil)) + +client := httpx.NewGenericClient[User]( + httpx.WithMaxRetries[User](3), + httpx.WithLogger[User](logger), +) +``` + +##### With NewHTTPRetryClient + +```go +logger := slog.New(slog.NewTextHandler(os.Stdout, nil)) + +client := httpx.NewHTTPRetryClient( + httpx.WithMaxRetriesRetry(3), + httpx.WithRetryStrategyRetry(httpx.ExponentialBackoff(500*time.Millisecond, 10*time.Second)), + httpx.WithLoggerRetry(logger), +) +``` + +#### What Gets Logged + +##### Retry Attempts (Warn Level) + +When a request fails and is being retried: + +``` +time=2026-01-17T21:00:00.000+00:00 level=WARN msg="HTTP request returned server error, retrying" attempt=1 max_retries=3 delay=500ms status_code=500 url=https://api.example.com/users method=GET +``` + +Attributes logged: + +- `attempt`: Current retry attempt number (1-indexed) +- `max_retries`: Maximum number of retries configured +- `delay`: How long the client will wait before retrying +- `status_code`: HTTP status code (for server errors) OR +- `error`: Error message (for network/connection errors) +- `url`: Full request URL +- `method`: HTTP method (GET, POST, etc.) + +##### All Retries Failed (Error Level) + +When all retry attempts are exhausted: + +``` +time=2026-01-17T21:00:00.500+00:00 level=ERROR msg="All retry attempts failed" attempts=4 status_code=503 url=https://api.example.com/users method=GET +``` + +Attributes logged: + +- `attempts`: Total number of attempts made (including initial request) +- `status_code` OR `error`: Final failure reason +- `url`: Full request URL +- `method`: HTTP method + +#### Logger Configuration + +##### Log Levels + +Choose the appropriate log level based on your needs: + +```go +// Only log final failures (recommended for production) +logger := slog.New(slog.NewJSONHandler(os.Stderr, &slog.HandlerOptions{ + Level: slog.LevelError, +})) + +// Log all retry attempts (useful for debugging) +logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{ + Level: slog.LevelWarn, +})) + +// Log everything including debug info from other packages +logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{ + Level: slog.LevelDebug, +})) +``` + +##### Output Formats + +###### Text Format (Development) + +Best for human readability during development: + +```go +logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{ + Level: slog.LevelWarn, +})) +``` + +Output: + +``` +time=2026-01-17T21:00:00.000+00:00 level=WARN msg="HTTP request returned server error, retrying" attempt=1 max_retries=3 delay=500ms status_code=500 +``` + +###### JSON Format (Production) + +Best for structured logging and log aggregation: + +```go +logger := slog.New(slog.NewJSONHandler(os.Stderr, &slog.HandlerOptions{ + Level: slog.LevelError, +})) +``` + +Output: + +```json +{"time":"2026-01-17T21:00:00.000Z","level":"ERROR","msg":"All retry attempts failed","attempts":4,"status_code":503,"url":"https://api.example.com/users","method":"GET"} +``` + +##### Writing to Files + +```go +logFile, err := os.OpenFile("http.log", os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0666) +if err != nil { + log.Fatal(err) +} +defer logFile.Close() + +logger := slog.New(slog.NewJSONHandler(logFile, &slog.HandlerOptions{ + Level: slog.LevelWarn, +})) +``` + +#### Logging Best Practices + +1. **Default to No Logging**: Keep logging disabled in production unless actively troubleshooting: + + ```go + // Production - no logging (default) + client := httpx.NewClientBuilder(). + WithMaxRetries(3). + Build() // No WithLogger() call = no logging + ``` + +2. **Use Structured Logging in Production**: JSON format is machine-readable and works well with log aggregators: + + ```go + logger := slog.New(slog.NewJSONHandler(os.Stderr, &slog.HandlerOptions{ + Level: slog.LevelError, // Only final failures + })) + ``` + +3. **Enable for Specific Troubleshooting**: Turn on logging temporarily when investigating issues: + + ```go + // Temporarily enable for debugging + var logger *slog.Logger + if os.Getenv("DEBUG_HTTP") != "" { + logger = slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{ + Level: slog.LevelWarn, + })) + } + + client := httpx.NewClientBuilder(). + WithMaxRetries(3). + WithLogger(logger). // Will be nil if not debugging + Build() + ``` + +4. **Add Context with Attributes**: Enhance logs with additional context: + + ```go + // Create logger with service context + logger := slog.New(slog.NewJSONHandler(os.Stderr, nil)). + With("service", "api-client"). + With("version", "1.0.0") + + client := httpx.NewClientBuilder(). + WithLogger(logger). + Build() + ``` + +5. **Different Loggers for Different Clients**: Use separate loggers for different clients to distinguish traffic: + + ```go + // User service client + userLogger := slog.New(slog.NewJSONHandler(os.Stderr, nil)). + With("client", "user-service") + userClient := httpx.NewClientBuilder(). + WithLogger(userLogger). + Build() + + // Payment service client + paymentLogger := slog.New(slog.NewJSONHandler(os.Stderr, nil)). + With("client", "payment-service") + paymentClient := httpx.NewClientBuilder(). + WithLogger(paymentLogger). + Build() + ``` + +#### Performance Considerations + +- **Minimal Overhead**: When logging is disabled (logger is `nil`), the overhead is just a simple nil check +- **No Allocations**: Log statements use slog's efficient attribute system +- **Deferred Work**: The logger only formats messages if the log level is enabled + +#### Disabling Logging + +Simply pass `nil` or omit the logger: + +```go +// Explicitly pass nil +client := httpx.NewClientBuilder(). + WithLogger(nil). // No logging + Build() + +// Or just don't call WithLogger +client := httpx.NewClientBuilder(). + WithMaxRetries(3). + Build() // No logging (default) +``` + +#### Migration Guide + +If you have existing code without logging, no changes are needed. The feature is fully backward compatible: + +```go +// Old code - still works, no logging +client := httpx.NewClientBuilder(). + WithMaxRetries(3). + Build() + +// New code - add logging when needed +logger := slog.New(slog.NewTextHandler(os.Stdout, nil)) +client := httpx.NewClientBuilder(). + WithMaxRetries(3). + WithLogger(logger). // Just add this line + Build() +``` + +#### Logging Examples + +##### Example 1: Development Debugging + +```go +package main + +import ( + "log/slog" + "os" + "time" + + "github.com/slashdevops/httpx" +) + +func main() { + // Text output with warn level for debugging + logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{ + Level: slog.LevelWarn, + })) + + client := httpx.NewClientBuilder(). + WithMaxRetries(3). + WithRetryBaseDelay(500 * time.Millisecond). + WithLogger(logger). + Build() + + // You'll see retry attempts in the console + resp, err := client.Get("https://api.example.com/flaky-endpoint") + if err != nil { + log.Fatal(err) + } + defer resp.Body.Close() +} +``` + +##### Example 2: Production Monitoring + +```go +package main + +import ( + "log/slog" + "os" + + "github.com/slashdevops/httpx" +) + +func main() { + // JSON output, only errors, to stderr + logger := slog.New(slog.NewJSONHandler(os.Stderr, &slog.HandlerOptions{ + Level: slog.LevelError, + })).With( + "service", "payment-processor", + "environment", "production", + ) + + client := httpx.NewClientBuilder(). + WithMaxRetries(3). + WithLogger(logger). + Build() + + // Only final failures will be logged + resp, err := client.Get("https://payment-api.example.com/status") + // ... +} +``` + +##### Example 3: Conditional Logging + +```go +package main + +import ( + "log/slog" + "os" + + "github.com/slashdevops/httpx" +) + +func createClient() *http.Client { + var logger *slog.Logger + + // Only enable logging if DEBUG environment variable is set + if os.Getenv("DEBUG") != "" { + logger = slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{ + Level: slog.LevelDebug, + })) + } + + return httpx.NewClientBuilder(). + WithMaxRetries(3). + WithLogger(logger). // Will be nil in production + Build() +} +``` + +#### Troubleshooting + +##### Not Seeing Any Logs? + +1. **Check logger level**: Make sure the level is set to at least `LevelWarn`: + + ```go + logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{ + Level: slog.LevelWarn, // Not Info or Debug + })) + ``` + +2. **Verify logger is passed**: Make sure you called `WithLogger()`: + + ```go + client := httpx.NewClientBuilder(). + WithLogger(logger). // Don't forget this! + Build() + ``` + +3. **Check if retries are happening**: Logs only appear when requests fail and retry. Successful first attempts don't log. + +##### Too Many Logs? + +1. **Increase log level** to `LevelError` to only see final failures +2. **Disable logging** in production environments where retry behavior is well understood +3. **Use sampling** if your log aggregation system supports it + +#### Logging Summary + +The logging feature in httpx provides: + +- ✅ **Optional** - Disabled by default, zero overhead when not in use +- ✅ **Standard** - Uses Go's `log/slog` package +- ✅ **Flexible** - Configurable output format, level, and destination +- ✅ **Informative** - Rich attributes for debugging and monitoring +- ✅ **Backward Compatible** - Existing code works without changes + +Enable it when you need visibility, keep it off for clean, silent operations. + +## Examples + +### Complete Example: CRUD Operations + +```go +package main + +import ( + "fmt" + "log" + "time" + + "github.com/slashdevops/httpx" +) + +type Todo struct { + ID int `json:"id"` + Title string `json:"title"` + Completed bool `json:"completed"` + UserID int `json:"userId"` +} + +func main() { + // Create retry client + retryClient := httpx.NewClientBuilder(). + WithMaxRetries(3). + WithRetryStrategy(httpx.ExponentialBackoffStrategy). + WithTimeout(10 * time.Second). + Build() + + // Create typed client + client := httpx.NewGenericClient[Todo]( + httpx.WithHTTPClient[Todo](retryClient), + httpx. httpx. ) + + // GET - Read + fmt.Println("Fetching todo...") + todo, err := client.Get("/todos/1") + if err != nil { + log.Fatal(err) + } + fmt.Printf("Todo: %s (completed: %v)\n", todo.Data.Title, todo.Data.Completed) + + // POST - Create + fmt.Println("\nCreating new todo...") + newTodo := Todo{ + Title: "Learn httputils", + Completed: false, + UserID: 1, + } + + req, _ := httpx.NewRequestBuilder("https://jsonplaceholder.typicode.com"). + WithMethodPOST(). + WithPath("/todos"). + WithContentType("application/json"). + WithJSONBody(newTodo). + Build() + + created, err := client.Execute(req) + if err != nil { + log.Fatal(err) + } + fmt.Printf("Created todo ID: %d\n", created.Data.ID) + + // PUT - Update + fmt.Println("\nUpdating todo...") + updateTodo := created.Data + updateTodo.Completed = true + + req, _ = httpx.NewRequestBuilder("https://jsonplaceholder.typicode.com"). + WithMethodPUT(). + WithPath(fmt.Sprintf("/todos/%d", updateTodo.ID)). + WithContentType("application/json"). + WithJSONBody(updateTodo). + Build() + + updated, err := client.Execute(req) + if err != nil { + log.Fatal(err) + } + fmt.Printf("Updated: completed = %v\n", updated.Data.Completed) + + // DELETE + fmt.Println("\nDeleting todo...") + deleteResp, err := client.Delete(fmt.Sprintf("/todos/%d", updateTodo.ID)) + if err != nil { + log.Fatal(err) + } + fmt.Printf("Deleted (status: %d)\n", deleteResp.StatusCode) +} +``` + +### Authentication Example + +```go +// Basic Authentication +req, err := httpx.NewRequestBuilder("https://api.example.com"). + WithMethodGET(). + WithPath("/protected/resource"). + WithBasicAuth("username", "password"). + Build() + +// Bearer Token Authentication +req, err := httpx.NewRequestBuilder("https://api.example.com"). + WithMethodGET(). + WithPath("/protected/resource"). + WithBearerAuth("your-jwt-token"). + Build() + +// With Generic Client +client := httpx.NewGenericClient[Resource]( + httpx. httpx.) +``` + +### Context and Timeout + +```go +// Request with timeout +ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) +defer cancel() + +req, err := httpx.NewRequestBuilder("https://api.example.com"). + WithMethodGET(). + WithPath("/slow-endpoint"). + Context(ctx). + Build() + +// Request with cancellation +ctx, cancel := context.WithCancel(context.Background()) + +go func() { + time.Sleep(2 * time.Second) + cancel() // Cancel after 2 seconds +}() + +req, err := httpx.NewRequestBuilder("https://api.example.com"). + WithMethodGET(). + WithPath("/endpoint"). + Context(ctx). + Build() +``` + +### Custom Headers and Query Parameters + +```go +req, err := httpx.NewRequestBuilder("https://api.example.com"). + WithMethodGET(). + WithPath("/search"). + WithQueryParam("q", "golang"). + WithQueryParam("sort", "relevance"). + WithQueryParam("limit", "10"). + WithHeader("Accept", "application/json"). + WithHeader("Accept-Language", "en-US"). + WithHeader("X-Request-ID", generateRequestID()). + WithHeader("X-Correlation-ID", getCorrelationID()). + WithUserAgent("MyApp/1.0 (Go)"). + Build() +``` + +## API Reference + +### RequestBuilder + +#### Constructor + +- `NewRequestBuilder(baseURL string) *RequestBuilder` + +#### HTTP Methods + +- `WithMethodGET() *RequestBuilder` +- `WithMethodPOST() *RequestBuilder` +- `WithMethodPUT() *RequestBuilder` +- `WithMethodDELETE() *RequestBuilder` +- `WithMethodPATCH() *RequestBuilder` +- `WithMethodHEAD() *RequestBuilder` +- `WithMethodOPTIONS() *RequestBuilder` +- `WithMethodTRACE() *RequestBuilder` +- `WithMethodCONNECT() *RequestBuilder` +- `WithMethod(method string) *RequestBuilder` - Custom HTTP method with validation + +#### URL and Parameters + +- `WithPath(path string) *RequestBuilder` - Set URL path +- `WithQueryParam(key, value string) *RequestBuilder` - Add single query parameter +- `QueryParams(params map[string]string) *RequestBuilder` - Add multiple query parameters + +#### Headers + +- `WithHeader(key, value string) *RequestBuilder` - Set single header +- `Headers(headers map[string]string) *RequestBuilder` - Set multiple headers +- `WithContentType(contentType string) *RequestBuilder` - Set Content-Type header +- `WithAccept(accept string) *RequestBuilder` - Set Accept header +- `WithUserAgent(userAgent string) *RequestBuilder` - Set User-Agent header + +#### Authentication + +- `WithBasicAuth(username, password string) *RequestBuilder` - Set Basic authentication +- `WithBearerAuth(token string) *RequestBuilder` - Set Bearer token authentication + +#### Body + +- `WithJSONBody(body any) *RequestBuilder` - Set JSON body (auto-marshals) +- `RawBody(body io.Reader) *RequestBuilder` - Set raw body +- `WithStringBody(body string) *RequestBuilder` - Set string body +- `BytesBody(body []byte) *RequestBuilder` - Set bytes body + +#### Other + +- `Context(ctx context.Context) *RequestBuilder` - Set request context +- `Build() (*http.Request, error)` - Build and validate request + +#### Error Handling + +- `HasErrors() bool` - Check if there are validation errors +- `GetErrors() []error` - Get all validation errors +- `Reset() *RequestBuilder` - Reset builder state + +### GenericClient[T any] + +#### Constructor + +- `NewGenericClient[T any](options ...GenericClientOption[T]) *GenericClient[T]` + +#### Options + +- `WithHTTPClient[T any](httpClient HTTPClient) GenericClientOption[T]` - Use a pre-configured HTTP client (takes precedence) +- `WithTimeout[T any](timeout time.Duration) GenericClientOption[T]` - Set request timeout +- `WithMaxRetries[T any](maxRetries int) GenericClientOption[T]` - Set maximum retry attempts +- `WithRetryStrategy[T any](strategy Strategy) GenericClientOption[T]` - Set retry strategy (fixed, jitter, exponential) +- `WithRetryStrategyAsString[T any](strategy string) GenericClientOption[T]` - Set retry strategy from string +- `WithRetryBaseDelay[T any](baseDelay time.Duration) GenericClientOption[T]` - Set base delay for retry strategies +- `WithRetryMaxDelay[T any](maxDelay time.Duration) GenericClientOption[T]` - Set maximum delay for retry strategies +- `WithMaxIdleConns[T any](maxIdleConns int) GenericClientOption[T]` - Set maximum idle connections +- `WithIdleConnTimeout[T any](idleConnTimeout time.Duration) GenericClientOption[T]` - Set idle connection timeout +- `WithTLSHandshakeTimeout[T any](tlsHandshakeTimeout time.Duration) GenericClientOption[T]` - Set TLS handshake timeout +- `WithExpectContinueTimeout[T any](expectContinueTimeout time.Duration) GenericClientOption[T]` - Set expect continue timeout +- `WithMaxIdleConnsPerHost[T any](maxIdleConnsPerHost int) GenericClientOption[T]` - Set maximum idle connections per host +- `WithDisableKeepAlive[T any](disableKeepAlive bool) GenericClientOption[T]` - Disable HTTP keep-alive + +#### Methods + +- `Execute(req *http.Request) (*Response[T], error)` - Execute request with type safety +- `ExecuteRaw(req *http.Request) (*http.Response, error)` - Execute and return raw response +- `Do(req *http.Request) (*Response[T], error)` - Alias for Execute +- `Get(url string) (*Response[T], error)` - Execute GET request +- `Post(url string, body io.Reader) (*Response[T], error)` - Execute POST request +- `Put(url string, body io.Reader) (*Response[T], error)` - Execute PUT request +- `Delete(url string) (*Response[T], error)` - Execute DELETE request +- `Patch(url string, body io.Reader) (*Response[T], error)` - Execute PATCH request +- `GetBaseURL() string` - Get configured base URL +- `GetDefaultHeaders() map[string]string` - Get configured headers + +### ClientBuilder + +#### Constructor + +- `NewClientBuilder() *ClientBuilder` + +#### Configuration Methods + +- `WithTimeout(timeout time.Duration) *ClientBuilder` +- `WithMaxRetries(maxRetries int) *ClientBuilder` +- `WithRetryStrategy(strategy Strategy) *ClientBuilder` +- `WithRetryBaseDelay(baseDelay time.Duration) *ClientBuilder` +- `WithRetryMaxDelay(maxDelay time.Duration) *ClientBuilder` +- `WithMaxIdleConns(maxIdleConns int) *ClientBuilder` +- `WithMaxIdleConnsPerHost(maxIdleConnsPerHost int) *ClientBuilder` +- `WithIdleConnTimeout(idleConnTimeout time.Duration) *ClientBuilder` +- `WithTLSHandshakeTimeout(tlsHandshakeTimeout time.Duration) *ClientBuilder` +- `WithExpectContinueTimeout(expectContinueTimeout time.Duration) *ClientBuilder` +- `WithDisableKeepAlive(disableKeepAlive bool) *ClientBuilder` +- `Build() *http.Client` - Build configured client + +### Retry Strategies + +- `ExponentialBackoff(base, maxDelay time.Duration) RetryStrategy` +- `FixedDelay(delay time.Duration) RetryStrategy` +- `JitterBackoff(base, maxDelay time.Duration) RetryStrategy` + +### Types + +#### Response[T any] + +```go +type Response[T any] struct { + Data T // Parsed response data + StatusCode int // HTTP status code + Headers http.Header // Response headers + RawBody []byte // Raw response body +} +``` + +#### ErrorResponse + +```go +type ErrorResponse struct { + Message string `json:"message,omitempty"` + StatusCode int `json:"statusCode,omitempty"` + ErrorMsg string `json:"error,omitempty"` + Details string `json:"details,omitempty"` +} +``` + +#### Strategy + +```go +const ( + FixedDelayStrategy Strategy = "fixed" + JitterBackoffStrategy Strategy = "jitter" + ExponentialBackoffStrategy Strategy = "exponential" +) +``` + +## Best Practices + +### 1. Always Check for Errors + +```go +req, err := httpx.NewRequestBuilder(baseURL). + WithMethodGET(). + WithPath("/endpoint"). + Build() + +if err != nil { + log.Printf("Request building failed: %v", err) + return +} +``` + +### 2. Use Type-Safe Clients for JSON APIs + +```go +// Define your model +type User struct { + ID int `json:"id"` + Name string `json:"name"` +} + +// Create typed client +client := httpx.NewGenericClient[User]( + httpx.) + +// Enjoy type safety +response, err := client.Get("/users/1") +// response.Data is User, not interface{} +``` + +### 3. Configure Retry Logic for Production + +```go +client := httpx.NewClientBuilder(). + WithMaxRetries(3). + WithRetryStrategy(httpx.ExponentialBackoffStrategy). + WithRetryBaseDelay(500 * time.Millisecond). + WithRetryMaxDelay(10 * time.Second). + WithTimeout(30 * time.Second). + Build() +``` + +### 4. Reuse HTTP Clients + +```go +// Create once, reuse many times +retryClient := httpx.NewClientBuilder(). + WithMaxRetries(3). + Build() + +userClient := httpx.NewGenericClient[User]( + httpx.WithHTTPClient[User](retryClient), +) + +postClient := httpx.NewGenericClient[Post]( + httpx.WithHTTPClient[Post](retryClient), +) +``` + +### 5. Use Context for Timeouts + +```go +ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) +defer cancel() + +req, err := httpx.NewRequestBuilder(baseURL). + WithMethodGET(). + WithPath("/endpoint"). + Context(ctx). + Build() +``` + +### 6. Validate Before Building + +```go +builder := httpx.NewRequestBuilder(baseURL). + WithMethodGET(). + WithPath("/endpoint") + +// Add potentially invalid inputs +builder.WithHeader(userProvidedKey, userProvidedValue) +builder.WithQueryParam(userProvidedParam, userProvidedValue) + +// Check for errors before building +if builder.HasErrors() { + for _, err := range builder.GetErrors() { + log.Printf("Validation error: %v", err) + } + return +} + +req, err := builder.Build() +``` + +### 7. Handle API Errors Properly + +```go +response, err := client.Get("/resource") +if err != nil { + if apiErr, ok := err.(*httpx.ErrorResponse); ok { + switch apiErr.StatusCode { + case 404: + log.Printf("Resource not found: %s", apiErr.Message) + case 401: + log.Printf("Authentication failed: %s", apiErr.Message) + case 429: + log.Printf("Rate limit exceeded: %s", apiErr.Message) + default: + log.Printf("API error %d: %s", apiErr.StatusCode, apiErr.Message) + } + } else { + log.Printf("Network error: %v", err) + } + + return +} +``` + +## Thread Safety + +All utilities in this package are safe for concurrent use: + +```go +client := httpx.NewGenericClient[User]( + httpx.) + +// Safe to use from multiple goroutines +var wg sync.WaitGroup +for i := 1; i <= 10; i++ { + wg.Add(1) + go func(id int) { + defer wg.Done() + user, err := client.Get(fmt.Sprintf("/users/%d", id)) + if err != nil { + log.Printf("Error fetching user %d: %v", id, err) + return + } + log.Printf("Fetched user: %s", user.Data.Name) + }(i) +} + +wg.Wait() +``` + +## Testing + +The package has comprehensive test coverage (88%+): + +```bash +go test ./... -v +go test ./... -cover +``` + +## Contributing + +Contributions are welcome! Please ensure: + +1. Build passes: `go build ./...` +2. All tests pass: `go test ./...` +3. Code is formatted: `go fmt ./...` +4. Linters pass: `golangci-lint run ./...` +5. Add tests for new features +6. Update documentation + +## License + +Apache License 2.0. See [LICENSE](LICENSE) for details. + +## Credits + +Developed by the slashdevops team using Agentic Development. Inspired by popular HTTP client libraries and Go best practices. diff --git a/docs.go b/docs.go new file mode 100644 index 0000000..9d05272 --- /dev/null +++ b/docs.go @@ -0,0 +1,411 @@ +// Package httpx provides comprehensive utilities for building and executing HTTP requests +// with advanced features including fluent request building, automatic retry logic, +// and type-safe generic clients. +// +// Zero Dependencies: This package is built entirely using the Go standard library, +// with no external dependencies. This ensures maximum reliability, security, and +// minimal maintenance overhead for your projects. +// +// This package is designed to simplify HTTP client development in Go by providing: +// - A fluent, chainable API for building HTTP requests with validation +// - Type-safe HTTP clients using Go generics for automatic JSON marshaling +// - Configurable retry logic with multiple backoff strategies +// - Comprehensive error handling and validation +// - Production-ready defaults with full customization support +// - Zero external dependencies - built purely with Go standard library +// +// # Quick Start +// +// Build and execute a simple GET request: +// +// req, err := httpx.NewRequestBuilder("https://api.example.com"). +// WithMethodGET(). +// Path("/users/123"). +// Header("Accept", "application/json"). +// Build() +// +// Use type-safe generic client: +// +// type User struct { +// ID int `json:"id"` +// Name string `json:"name"` +// } +// +// client := httpx.NewGenericClient[User]( +// httpx.// ) +// response, err := client.Get("/users/123") +// fmt.Printf("User: %s\n", response.Data.Name) +// +// # Request Builder +// +// The RequestBuilder provides a fluent API for constructing HTTP requests with +// comprehensive input validation and error accumulation. All inputs are validated +// before the request is built, ensuring early error detection. +// +// Basic usage: +// +// req, err := httpx.NewRequestBuilder("https://api.example.com"). +// WithMethodPOST(). +// Path("/users"). +// QueryParam("notify", "true"). +// Header("Content-Type", "application/json"). +// BearerAuth("your-token-here"). +// JSONBody(user). +// Build() +// +// Request builder features: +// - HTTP methods: GET, POST, PUT, DELETE, PATCH, HEAD, OPTIONS, TRACE, CONNECT +// - Convenience methods: WithMethodGET, WithMethodPOST, WithMethodPUT, WithMethodDELETE, WithMethodPATCH, WithMethodHEAD, WithMethodOPTIONS, WithMethodTRACE, WithMethodCONNECT +// - Query parameters with automatic URL encoding and validation +// - Custom headers with format validation +// - Authentication: Basic Auth and Bearer Token with validation +// - Multiple body formats: JSON (auto-marshal), string, bytes, io.Reader +// - Context support for timeouts and cancellation +// - Input validation with error accumulation +// - Detailed error messages indicating what failed +// - Reset and reuse builder +// +// Validation features: +// +// The builder validates all inputs and accumulates errors, allowing you to +// detect multiple issues at once: +// +// builder := httpx.NewRequestBuilder("https://api.example.com") +// builder.HTTPMethod("") // Error: empty method +// builder.WithHeader("", "value") // Error: empty header key +// builder.WithQueryParam("key=", "val") // Error: invalid character in key +// +// // Check accumulated errors +// if builder.HasErrors() { +// for _, err := range builder.GetErrors() { +// log.Printf("Validation error: %v", err) +// } +// } +// +// // Or let Build() report all errors +// req, err := builder.Build() // Returns all accumulated errors +// +// Builder reuse: +// +// builder := httpx.NewRequestBuilder("https://api.example.com") +// req1, _ := builder.WithWithMethodGET().WithPath("/users").Build() +// builder.Reset() // Clear state +// req2, _ := builder.WithWithMethodPOST().WithPath("/posts").Build() +// +// # Generic HTTP Client +// +// The GenericClient provides type-safe HTTP requests using Go generics with +// automatic JSON marshaling and unmarshaling. This eliminates the need for +// manual type assertions and reduces boilerplate code. +// +// Basic usage: +// +// type User struct { +// ID int `json:"id"` +// Name string `json:"name"` +// Email string `json:"email"` +// } +// +// client := httpx.NewGenericClient[User]( +// httpx.WithTimeout[User](10*time.Second), +// httpx.WithMaxRetries[User](3), +// httpx.WithRetryStrategy[User](httpx.ExponentialBackoffStrategy), +// ) +// +// // GET request - response.Data is strongly typed as User +// response, err := client.Get("/users/1") +// if err != nil { +// log.Fatal(err) +// } +// fmt.Printf("User: %s (%s)\n", response.Data.Name, response.Data.Email) +// +// Generic client features: +// - Type-safe responses with automatic JSON unmarshaling +// - Compile-time type checking for response data +// - Convenience methods: Get, Post, Put, Delete, Patch +// - Execute method for custom requests (works with RequestBuilder) +// - ExecuteRaw for non-JSON responses (images, files, etc.) +// - Flexible configuration via option pattern +// - Built-in retry logic with configurable strategies +// - Connection pooling and timeout configuration +// - TLS handshake and idle connection timeout settings +// - Structured error responses with ErrorResponse type +// - Full integration with ClientBuilder for complex configurations +// - Debug logging support (uses slog) +// +// Configuration options: +// - WithTimeout: Set request timeout +// - WithMaxRetries: Set maximum retry attempts +// - WithRetryStrategy: Configure retry strategy (fixed, jitter, exponential) +// - WithRetryBaseDelay: Set base delay for retry strategies +// - WithRetryMaxDelay: Set maximum delay for retry strategies +// - WithMaxIdleConns: Set maximum idle connections +// - WithIdleConnTimeout: Set idle connection timeout +// - WithTLSHandshakeTimeout: Set TLS handshake timeout +// - WithExpectContinueTimeout: Set expect continue timeout +// - WithMaxIdleConnsPerHost: Set maximum idle connections per host +// - WithDisableKeepAlive: Disable HTTP keep-alive +// - WithHTTPClient: Use a pre-configured HTTP client (takes precedence) +// +// Integration with RequestBuilder: +// +// req, err := httpx.NewRequestBuilder("https://api.example.com"). +// WithMethodPOST(). +// Path("/users"). +// ContentType("application/json"). +// Header("X-Request-ID", "unique-123"). +// JSONBody(newUser). +// Build() +// +// response, err := client.Execute(req) // Type-safe execution +// +// Multiple typed clients: +// +// userClient := httpx.NewGenericClient[User](...) +// postClient := httpx.NewGenericClient[Post](...) +// +// user, _ := userClient.Get("/users/1") +// posts, _ := postClient.Get(fmt.Sprintf("/users/%d/posts", user.Data.ID)) +// +// Error handling: +// +// response, err := client.Get("/users/999") +// if err != nil { +// if apiErr, ok := err.(*httpx.ErrorResponse); ok { +// // Structured API error +// fmt.Printf("API Error %d: %s\n", apiErr.StatusCode, apiErr.Message) +// } else { +// // Network error, parsing error, etc. +// log.Printf("Request failed: %v\n", err) +// } +// } +// +// # Retry Logic +// +// The package provides transparent retry logic that automatically retries failed +// requests using configurable backoff strategies. Retry logic preserves all +// request properties including headers and authentication. +// +// What gets retried: +// - Network errors (connection failures, timeouts) +// - HTTP 5xx server errors (500-599) +// - HTTP 429 (Too Many Requests) +// +// What does NOT get retried: +// - HTTP 4xx client errors (except 429) +// - HTTP 2xx/3xx successful responses +// - Requests without GetBody (non-replayable) +// +// Available retry strategies: +// +// 1. Exponential Backoff (recommended for most use cases): +// +// strategy := httpx.ExponentialBackoff(500*time.Millisecond, 10*time.Second) +// // Wait times: 500ms → 1s → 2s → 4s → 8s (capped at maxDelay) +// +// 2. Fixed Delay (useful for predictable retry timing): +// +// strategy := httpx.FixedDelay(1*time.Second) +// // Wait times: 1s → 1s → 1s +// +// 3. Jitter Backoff (prevents thundering herd problem): +// +// strategy := httpx.JitterBackoff(500*time.Millisecond, 10*time.Second) +// // Wait times: random(0-500ms) → random(0-1s) → random(0-2s) +// +// Direct usage (advanced): +// +// client := httpx.NewHTTPRetryClient( +// httpx.WithMaxRetriesRetry(3), +// httpx.WithRetryStrategyRetry(httpx.ExponentialBackoff(500*time.Millisecond, 10*time.Second)), +// httpx.WithBaseTransport(http.DefaultTransport), +// ) +// +// # Client Builder +// +// The ClientBuilder provides a fluent API for configuring HTTP clients with +// retry logic, timeouts, and connection pooling. All settings are validated +// and default to production-ready values if out of range. +// +// Basic configuration: +// +// client := httpx.NewClientBuilder(). +// WithTimeout(30 * time.Second). +// WithMaxRetries(3). +// WithRetryStrategy(httpx.ExponentialBackoffStrategy). +// Build() +// +// Advanced configuration: +// +// client := httpx.NewClientBuilder(). +// // Timeouts +// WithTimeout(30 * time.Second). +// WithIdleConnTimeout(90 * time.Second). +// WithTLSHandshakeTimeout(10 * time.Second). +// WithExpectContinueTimeout(1 * time.Second). +// +// // Connection pooling +// WithMaxIdleConns(100). +// WithMaxIdleConnsPerHost(10). +// WithDisableKeepAlive(false). +// +// // Retry configuration +// WithMaxRetries(3). +// WithRetryStrategy(httpx.ExponentialBackoffStrategy). +// WithRetryBaseDelay(500 * time.Millisecond). +// WithRetryMaxDelay(10 * time.Second). +// +// Build() +// +// Combine with GenericClient: +// +// retryClient := httpx.NewClientBuilder(). +// WithMaxRetries(3). +// WithRetryStrategy(httpx.ExponentialBackoffStrategy). +// Build() +// +// client := httpx.NewGenericClient[User]( +// httpx.WithHTTPClient[User](retryClient), +// httpx.// ) +// +// Default values (validated and adjusted if out of range): +// - Timeout: 5 seconds (valid: 1s-30s) +// - MaxRetries: 3 (valid: 1-10) +// - RetryBaseDelay: 500ms (valid: 300ms-5s) +// - RetryMaxDelay: 10s (valid: 300ms-120s) +// - MaxIdleConns: 100 (valid: 1-200) +// - IdleConnTimeout: 90s (valid: 1s-120s) +// - TLSHandshakeTimeout: 10s (valid: 1s-15s) +// +// # Error Handling +// +// The package provides comprehensive error handling with specific error types: +// +// 1. ErrorResponse (from GenericClient): +// Structured API errors with status code and message +// +// 2. ClientError: +// Generic HTTP client operation errors +// +// 3. RequestBuilder validation errors: +// Accumulated during building, reported at Build() time +// +// Error handling examples: +// +// // Generic client errors +// response, err := client.Get("/users/999") +// if err != nil { +// if apiErr, ok := err.(*httpx.ErrorResponse); ok { +// switch apiErr.StatusCode { +// case 404: +// log.Printf("Not found: %s", apiErr.Message) +// case 401: +// log.Printf("Unauthorized: %s", apiErr.Message) +// case 429: +// log.Printf("Rate limited: %s", apiErr.Message) +// default: +// log.Printf("API error %d: %s", apiErr.StatusCode, apiErr.Message) +// } +// } else { +// log.Printf("Network error: %v", err) +// } +// } +// +// // Builder validation errors +// builder := httpx.NewRequestBuilder("https://api.example.com") +// builder.WithHeader("", "value") // Invalid +// if builder.HasErrors() { +// for _, err := range builder.GetErrors() { +// log.Printf("Validation: %v", err) +// } +// } +// +// # Best Practices +// +// 1. Use type-safe clients for JSON APIs: +// +// client := httpx.NewGenericClient[User](...) +// response, err := client.Get("/users/1") +// // response.Data is User, not interface{} +// +// 2. Configure retry logic for production: +// +// retryClient := httpx.NewClientBuilder(). +// WithMaxRetries(3). +// WithRetryStrategy(httpx.ExponentialBackoffStrategy). +// Build() +// +// 3. Reuse HTTP clients (they're safe for concurrent use): +// +// client := httpx.NewGenericClient[User](...) +// // Use from multiple goroutines safely +// +// 4. Use contexts for timeouts and cancellation: +// +// ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) +// defer cancel() +// req, _ := builder.WithContext(ctx).Build() +// +// 5. Validate before building: +// +// if builder.HasErrors() { +// // Handle validation errors +// } +// +// 6. Handle API errors appropriately: +// +// if apiErr, ok := err.(*httpx.ErrorResponse); ok { +// // Handle specific status codes +// } +// +// # Thread Safety +// +// All utilities in this package are safe for concurrent use across multiple goroutines: +// - RequestBuilder instances should not be shared between goroutines +// - GenericClient instances are safe for concurrent use +// - HTTP clients built by ClientBuilder are safe for concurrent use +// - Retry logic preserves request immutability +// +// Example concurrent usage: +// +// client := httpx.NewGenericClient[User](...) +// +// var wg sync.WaitGroup +// for i := 1; i <= 10; i++ { +// wg.Add(1) +// go func(id int) { +// defer wg.Done() +// user, err := client.Get(fmt.Sprintf("/users/%d", id)) +// // Process user +// }(i) +// } +// wg.Wait() +// +// # Debugging +// +// The package uses slog for debug logging. Enable debug logging to see: +// - Request details (method, URL, headers, body) +// - Response details (status, headers, body) +// - Retry attempts and delays +// +// Enable debug logging: +// +// logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{ +// Level: slog.LevelDebug, +// })) +// slog.SetDefault(logger) +// +// # Documentation +// +// You can view the full documentation and examples locally by running: +// +// go doc -http=:8080 +// +// Then navigate to http://localhost:8080/pkg/github.com/slashdevops/httpx/ +// in your browser to browse the complete documentation, examples, and source code. +// +// # See Also +// +// For complete examples and API reference, see the README.md file or visit: +// https://pkg.go.dev/github.com/slashdevops/httpx +package httpx diff --git a/example_generic_client_test.go b/example_generic_client_test.go new file mode 100644 index 0000000..5ff4c05 --- /dev/null +++ b/example_generic_client_test.go @@ -0,0 +1,366 @@ +package httpx_test + +import ( + "fmt" + "log" + "net/http" + "time" + + "github.com/slashdevops/httpx" +) + +// User represents a user in the API +type User struct { + ID int `json:"id"` + Name string `json:"name"` + Email string `json:"email"` +} + +// Post represents a blog post +type Post struct { + ID int `json:"id"` + Title string `json:"title"` + Body string `json:"body"` + UserID int `json:"userId"` +} + +// ExampleNewGenericClient demonstrates basic usage of the generic client +func ExampleNewGenericClient() { + // Create a typed client for User responses with configuration + client := httpx.NewGenericClient[User]( + httpx.WithTimeout[User](10*time.Second), + httpx.WithMaxRetries[User](3), + httpx.WithRetryStrategy[User](httpx.ExponentialBackoffStrategy), + ) + + // Make a GET request with full URL + response, err := client.Get("https://api.example.com/users/1") + if err != nil { + log.Fatal(err) + } + + fmt.Printf("User: %s (%s)\n", response.Data.Name, response.Data.Email) +} + +// ExampleNewGenericClient_allOptions demonstrates using all configuration options +func ExampleNewGenericClient_allOptions() { + // Create a fully configured client + client := httpx.NewGenericClient[User]( + // Timeout configuration + httpx.WithTimeout[User](15*time.Second), + + // Retry configuration + httpx.WithMaxRetries[User](5), + httpx.WithRetryStrategy[User](httpx.JitterBackoffStrategy), + httpx.WithRetryBaseDelay[User](500*time.Millisecond), + httpx.WithRetryMaxDelay[User](10*time.Second), + + // Connection pooling + httpx.WithMaxIdleConns[User](100), + httpx.WithMaxIdleConnsPerHost[User](10), + httpx.WithIdleConnTimeout[User](90*time.Second), + + // TLS and handshake timeouts + httpx.WithTLSHandshakeTimeout[User](10*time.Second), + httpx.WithExpectContinueTimeout[User](1*time.Second), + + // Keep-alive + httpx.WithDisableKeepAlive[User](false), + ) + + response, err := client.Get("https://api.example.com/users/1") + if err != nil { + log.Fatal(err) + } + + fmt.Printf("User: %s\n", response.Data.Name) +} + +// ExampleNewGenericClient_withPreConfiguredClient demonstrates using WithHTTPClient +func ExampleNewGenericClient_withPreConfiguredClient() { + // Build a custom HTTP client using ClientBuilder + httpClient := httpx.NewClientBuilder(). + WithMaxRetries(3). + WithRetryStrategy(httpx.ExponentialBackoffStrategy). + WithRetryBaseDelay(500 * time.Millisecond). + WithTimeout(30 * time.Second). + WithMaxIdleConns(50). + Build() + + // Use it with GenericClient (takes precedence over other options) + client := httpx.NewGenericClient[User]( + httpx.WithHTTPClient[User](httpClient), + ) + + response, err := client.Get("https://api.example.com/users/1") + if err != nil { + log.Fatal(err) + } + + fmt.Printf("User: %s\n", response.Data.Name) +} + +// ExampleGenericClient_Execute demonstrates using Execute with RequestBuilder +func ExampleGenericClient_Execute() { + // Create a typed client for Post responses + client := httpx.NewGenericClient[Post]() + + // Build a request with RequestBuilder + req, err := httpx.NewRequestBuilder("https://jsonplaceholder.typicode.com"). + WithMethodGET(). + WithPath("/posts/1"). + WithAccept("application/json"). + Build() + if err != nil { + log.Fatal(err) + } + + // Execute the request + response, err := client.Execute(req) + if err != nil { + log.Fatal(err) + } + + fmt.Printf("Post: %s\n", response.Data.Title) +} + +// ExampleGenericClient_Post demonstrates making a POST request +func ExampleGenericClient_Post() { + client := httpx.NewGenericClient[Post]() + + // Create a new post using RequestBuilder + newPost := Post{ + Title: "My New Post", + Body: "This is the post content", + UserID: 1, + } + + req, err := httpx.NewRequestBuilder("https://jsonplaceholder.typicode.com"). + WithMethodPOST(). + WithPath("/posts"). + WithContentType("application/json"). + WithJSONBody(newPost). + Build() + if err != nil { + log.Fatal(err) + } + + response, err := client.Do(req) + if err != nil { + log.Fatal(err) + } + + fmt.Printf("Created post with ID: %d\n", response.Data.ID) +} + +// ExampleGenericClient_withRetry demonstrates using generic client with retry logic +func ExampleGenericClient_withRetry() { + // Option 1: Use ClientBuilder and pass to GenericClient + retryClient := httpx.NewClientBuilder(). + WithMaxRetries(3). + WithRetryStrategy(httpx.ExponentialBackoffStrategy). + WithRetryBaseDelay(500 * time.Millisecond). + WithTimeout(30 * time.Second). + Build() + + client := httpx.NewGenericClient[User]( + httpx.WithHTTPClient[User](retryClient), + ) + + // Option 2: Configure retry directly in GenericClient + client2 := httpx.NewGenericClient[User]( + httpx.WithMaxRetries[User](3), + httpx.WithRetryStrategy[User](httpx.ExponentialBackoffStrategy), + httpx.WithRetryBaseDelay[User](500*time.Millisecond), + httpx.WithTimeout[User](30*time.Second), + ) + + // Make requests - they will automatically retry on failures + response, err := client.Get("https://api.example.com/users/1") + if err != nil { + log.Fatal(err) + } + + fmt.Printf("User: %s\n", response.Data.Name) + + // Using second client + response2, err := client2.Get("https://api.example.com/users/2") + if err != nil { + log.Fatal(err) + } + + fmt.Printf("User: %s\n", response2.Data.Name) +} + +// ExampleGenericClient_multipleClients demonstrates using multiple typed clients +func ExampleGenericClient_multipleClients() { + baseURL := "https://api.example.com" + + // Create a client for User responses with specific configuration + userClient := httpx.NewGenericClient[User]( + httpx.WithTimeout[User](10*time.Second), + httpx.WithMaxRetries[User](3), + ) + + // Create a client for Post responses with different configuration + postClient := httpx.NewGenericClient[Post]( + httpx.WithTimeout[Post](15*time.Second), + httpx.WithMaxRetries[Post](5), + httpx.WithRetryStrategy[Post](httpx.JitterBackoffStrategy), + ) + + // Fetch user + userResp, err := userClient.Get(baseURL + "/users/1") + if err != nil { + log.Fatal(err) + } + + // Fetch posts by that user + postResp, err := postClient.Get(fmt.Sprintf("%s/users/%d/posts", baseURL, userResp.Data.ID)) + if err != nil { + log.Fatal(err) + } + + fmt.Printf("User %s has %d posts\n", userResp.Data.Name, len(postResp.Data.Title)) +} + +// ExampleGenericClient_ExecuteRaw demonstrates using ExecuteRaw for non-JSON responses +func ExampleGenericClient_ExecuteRaw() { + client := httpx.NewGenericClient[User]() + + req, err := http.NewRequest(http.MethodGet, "https://example.com/image.png", nil) + if err != nil { + log.Fatal(err) + } + + // Get raw response for binary data + response, err := client.ExecuteRaw(req) + if err != nil { + log.Fatal(err) + } + defer response.Body.Close() + + fmt.Printf("Content-Type: %s\n", response.Header.Get("Content-Type")) + fmt.Printf("Status: %d\n", response.StatusCode) +} + +// ExampleGenericClient_errorHandling demonstrates error handling +func ExampleGenericClient_errorHandling() { + client := httpx.NewGenericClient[User]() + + response, err := client.Get("https://api.example.com/users/999999") + if err != nil { + // Check if it's an ErrorResponse + if apiErr, ok := err.(*httpx.ErrorResponse); ok { + fmt.Printf("API Error: Status %d - %s\n", apiErr.StatusCode, apiErr.Message) + return + } + // Other errors (network, parsing, etc.) + log.Fatal(err) + } + + fmt.Printf("User: %s\n", response.Data.Name) +} + +// ExampleGenericClient_withCustomHeaders demonstrates adding custom headers per request +func ExampleGenericClient_withCustomHeaders() { + client := httpx.NewGenericClient[User]() + + // Build request with headers using RequestBuilder + req, err := httpx.NewRequestBuilder("https://api.example.com"). + WithMethodGET(). + WithPath("/users/1"). + WithHeader("Authorization", "Bearer token"). + WithHeader("X-Request-ID", "unique-id-123"). + WithHeader("X-Trace-ID", "trace-456"). + Build() + if err != nil { + log.Fatal(err) + } + + // Execute the request + response, err := client.Execute(req) + if err != nil { + log.Fatal(err) + } + + fmt.Printf("User: %s\n", response.Data.Name) +} + +// ExampleGenericClient_productionConfiguration demonstrates a production-ready configuration +func ExampleGenericClient_productionConfiguration() { + // Configure client with production-ready settings + client := httpx.NewGenericClient[User]( + // Aggressive retry for resilience + httpx.WithMaxRetries[User](5), + httpx.WithRetryStrategy[User](httpx.ExponentialBackoffStrategy), + httpx.WithRetryBaseDelay[User](500*time.Millisecond), + httpx.WithRetryMaxDelay[User](30*time.Second), + + // Reasonable timeout + httpx.WithTimeout[User](30*time.Second), + + // Connection pooling for performance + httpx.WithMaxIdleConns[User](100), + httpx.WithMaxIdleConnsPerHost[User](10), + httpx.WithIdleConnTimeout[User](90*time.Second), + + // TLS optimization + httpx.WithTLSHandshakeTimeout[User](10*time.Second), + ) + + // Build request with headers + req, err := httpx.NewRequestBuilder("https://api.example.com"). + WithMethodGET(). + WithPath("/users/1"). + WithHeader("Authorization", "Bearer prod-token"). + WithHeader("X-Request-ID", "req-123"). + WithUserAgent("MyApp/1.0.0"). + Build() + if err != nil { + log.Fatal(err) + } + + // Execute with automatic retries and error handling + response, err := client.Execute(req) + if err != nil { + if apiErr, ok := err.(*httpx.ErrorResponse); ok { + log.Printf("API Error: %d - %s", apiErr.StatusCode, apiErr.Message) + return + } + log.Fatal(err) + } + + fmt.Printf("User: %s (%s)\n", response.Data.Name, response.Data.Email) +} + +// ExampleGenericClient_retryStrategies demonstrates different retry strategies +func ExampleGenericClient_retryStrategies() { + // Fixed delay - predictable retry timing + fixedClient := httpx.NewGenericClient[User]( + httpx.WithRetryStrategyAsString[User]("fixed"), + httpx.WithMaxRetries[User](3), + httpx.WithRetryBaseDelay[User](1*time.Second), + ) + + // Exponential backoff - doubles delay each time + exponentialClient := httpx.NewGenericClient[User]( + httpx.WithRetryStrategy[User](httpx.ExponentialBackoffStrategy), + httpx.WithMaxRetries[User](5), + httpx.WithRetryBaseDelay[User](500*time.Millisecond), + httpx.WithRetryMaxDelay[User](10*time.Second), + ) + + // Jitter backoff - random delays to prevent thundering herd + jitterClient := httpx.NewGenericClient[User]( + httpx.WithRetryStrategy[User](httpx.JitterBackoffStrategy), + httpx.WithMaxRetries[User](3), + httpx.WithRetryBaseDelay[User](500*time.Millisecond), + httpx.WithRetryMaxDelay[User](5*time.Second), + ) + + // Use different clients based on use case + _, _ = fixedClient.Get("https://api.example.com/users/1") + _, _ = exponentialClient.Get("https://api.example.com/users/2") + _, _ = jitterClient.Get("https://api.example.com/users/3") +} diff --git a/example_http_client_test.go b/example_http_client_test.go new file mode 100644 index 0000000..14f4f5a --- /dev/null +++ b/example_http_client_test.go @@ -0,0 +1,265 @@ +package httpx_test + +import ( + "fmt" + "io" + "log" + "net/http" + "time" + + "github.com/slashdevops/httpx" +) + +// ExampleNewClientBuilder demonstrates creating a basic HTTP client with default settings +func ExampleNewClientBuilder() { + // Create client with default settings + client := httpx.NewClientBuilder().Build() + + // Use the client for HTTP requests + resp, err := client.Get("https://api.example.com/health") + if err != nil { + log.Fatal(err) + } + defer resp.Body.Close() + + fmt.Printf("Status: %s\n", resp.Status) + // Output would show: Status: 200 OK +} + +// ExampleNewClientBuilder_withTimeout demonstrates configuring a client with custom timeout +func ExampleNewClientBuilder_withTimeout() { + // Create client with custom timeout + client := httpx.NewClientBuilder(). + WithTimeout(10 * time.Second). + Build() + + resp, err := client.Get("https://api.example.com/users") + if err != nil { + log.Fatal(err) + } + defer resp.Body.Close() + + body, _ := io.ReadAll(resp.Body) + fmt.Printf("Response length: %d bytes\n", len(body)) +} + +// ExampleNewClientBuilder_withRetryStrategy demonstrates configuring retry behavior +func ExampleNewClientBuilder_withRetryStrategy() { + // Configure client with exponential backoff retry strategy + client := httpx.NewClientBuilder(). + WithMaxRetries(5). + WithRetryStrategy(httpx.ExponentialBackoffStrategy). + WithRetryBaseDelay(500 * time.Millisecond). + WithRetryMaxDelay(30 * time.Second). + Build() + + // The client will automatically retry on transient failures + resp, err := client.Get("https://api.example.com/data") + if err != nil { + log.Fatal(err) + } + defer resp.Body.Close() + + fmt.Printf("Status: %d\n", resp.StatusCode) +} + +// ExampleNewClientBuilder_fixedDelayStrategy demonstrates using fixed delay retry strategy +func ExampleNewClientBuilder_fixedDelayStrategy() { + // Configure client with fixed delay between retries + client := httpx.NewClientBuilder(). + WithMaxRetries(3). + WithRetryStrategyAsString("fixed"). + WithRetryBaseDelay(1 * time.Second). + Build() + + // Each retry will wait exactly 1 second + resp, err := client.Get("https://api.example.com/endpoint") + if err != nil { + log.Fatal(err) + } + defer resp.Body.Close() + + fmt.Printf("Success: %v\n", resp.StatusCode == 200) +} + +// ExampleNewClientBuilder_jitterBackoffStrategy demonstrates using jitter backoff for retry +func ExampleNewClientBuilder_jitterBackoffStrategy() { + // Configure client with jitter backoff to prevent thundering herd + client := httpx.NewClientBuilder(). + WithMaxRetries(4). + WithRetryStrategy(httpx.JitterBackoffStrategy). + WithRetryBaseDelay(500 * time.Millisecond). + WithRetryMaxDelay(10 * time.Second). + Build() + + // Retries will have randomized delays + resp, err := client.Get("https://api.example.com/resource") + if err != nil { + log.Fatal(err) + } + defer resp.Body.Close() + + fmt.Printf("Completed with status: %d\n", resp.StatusCode) +} + +// ExampleNewClientBuilder_connectionPooling demonstrates configuring connection pooling +func ExampleNewClientBuilder_connectionPooling() { + // Configure connection pooling for high-throughput scenarios + client := httpx.NewClientBuilder(). + WithMaxIdleConns(200). + WithMaxIdleConnsPerHost(20). + WithIdleConnTimeout(90 * time.Second). + Build() + + // Reuse connections efficiently across multiple requests + for i := 0; i < 10; i++ { + resp, err := client.Get(fmt.Sprintf("https://api.example.com/items/%d", i)) + if err != nil { + log.Printf("Request %d failed: %v", i, err) + continue + } + resp.Body.Close() + } + + fmt.Println("Completed batch requests") +} + +// ExampleNewClientBuilder_tlsConfiguration demonstrates TLS timeout settings +func ExampleNewClientBuilder_tlsConfiguration() { + // Configure TLS handshake timeout for secure connections + client := httpx.NewClientBuilder(). + WithTLSHandshakeTimeout(10 * time.Second). + WithExpectContinueTimeout(2 * time.Second). + Build() + + resp, err := client.Get("https://secure-api.example.com/data") + if err != nil { + log.Fatal(err) + } + defer resp.Body.Close() + + fmt.Printf("Secure request completed: %s\n", resp.Status) +} + +// ExampleNewClientBuilder_disableKeepAlive demonstrates disabling connection reuse +func ExampleNewClientBuilder_disableKeepAlive() { + // Disable keep-alive for scenarios requiring fresh connections + client := httpx.NewClientBuilder(). + WithDisableKeepAlive(true). + Build() + + // Each request will use a new connection + resp, err := client.Get("https://api.example.com/status") + if err != nil { + log.Fatal(err) + } + defer resp.Body.Close() + + fmt.Printf("Response: %s\n", resp.Status) +} + +// ExampleNewClientBuilder_productionConfig demonstrates a production-ready configuration +func ExampleNewClientBuilder_productionConfig() { + // Production-ready client with optimal settings + client := httpx.NewClientBuilder(). + // Timeouts + WithTimeout(30 * time.Second). + WithTLSHandshakeTimeout(10 * time.Second). + WithExpectContinueTimeout(1 * time.Second). + + // Connection pooling + WithMaxIdleConns(100). + WithMaxIdleConnsPerHost(10). + WithIdleConnTimeout(90 * time.Second). + + // Retry configuration + WithMaxRetries(5). + WithRetryStrategy(httpx.ExponentialBackoffStrategy). + WithRetryBaseDelay(500 * time.Millisecond). + WithRetryMaxDelay(30 * time.Second). + Build() + + // Create request with proper headers + req, err := http.NewRequest("GET", "https://api.example.com/v1/users", nil) + if err != nil { + log.Fatal(err) + } + req.Header.Set("Authorization", "Bearer prod-token-xyz") + req.Header.Set("User-Agent", "MyApp/2.0") + + // Execute with automatic retries and connection pooling + resp, err := client.Do(req) + if err != nil { + log.Fatal(err) + } + defer resp.Body.Close() + + body, _ := io.ReadAll(resp.Body) + fmt.Printf("Retrieved %d bytes with status %d\n", len(body), resp.StatusCode) +} + +// ExampleNewClientBuilder_allOptions demonstrates using all available configuration options +func ExampleNewClientBuilder_allOptions() { + // Comprehensive configuration showing all available options + client := httpx.NewClientBuilder(). + // HTTP client timeout + WithTimeout(20 * time.Second). + + // Connection pool settings + WithMaxIdleConns(150). + WithMaxIdleConnsPerHost(15). + WithIdleConnTimeout(60 * time.Second). + + // TLS and protocol settings + WithTLSHandshakeTimeout(8 * time.Second). + WithExpectContinueTimeout(2 * time.Second). + WithDisableKeepAlive(false). + + // Retry configuration + WithMaxRetries(4). + WithRetryStrategy(httpx.JitterBackoffStrategy). + WithRetryBaseDelay(300 * time.Millisecond). + WithRetryMaxDelay(20 * time.Second). + Build() + + // Use the fully configured client + resp, err := client.Get("https://api.example.com/comprehensive") + if err != nil { + log.Fatal(err) + } + defer resp.Body.Close() + + fmt.Printf("Request completed with all options configured\n") +} + +// ExampleNewClientBuilder_multipleClients demonstrates creating clients with different configurations +func ExampleNewClientBuilder_multipleClients() { + // Fast client for health checks + healthClient := httpx.NewClientBuilder(). + WithTimeout(2 * time.Second). + WithMaxRetries(1). + Build() + + // Standard client for API calls + apiClient := httpx.NewClientBuilder(). + WithTimeout(15 * time.Second). + WithMaxRetries(3). + WithRetryStrategy(httpx.ExponentialBackoffStrategy). + Build() + + // Heavy client for bulk operations + bulkClient := httpx.NewClientBuilder(). + WithTimeout(60 * time.Second). + WithMaxRetries(5). + WithRetryStrategy(httpx.JitterBackoffStrategy). + WithMaxIdleConns(200). + WithMaxIdleConnsPerHost(20). + Build() + + // Use different clients for different purposes + _, _ = healthClient.Get("https://api.example.com/health") + _, _ = apiClient.Get("https://api.example.com/users/123") + _, _ = bulkClient.Post("https://api.example.com/bulk-import", "application/json", nil) + + fmt.Println("Multiple clients with different configurations") +} diff --git a/example_http_retrier_test.go b/example_http_retrier_test.go new file mode 100644 index 0000000..c304ea4 --- /dev/null +++ b/example_http_retrier_test.go @@ -0,0 +1,223 @@ +package httpx_test + +import ( + "fmt" + "io" + "net/http" + "net/http/httptest" + "sync/atomic" + "time" + + "github.com/slashdevops/httpx" +) + +// Example demonstrates using exponential backoff. +func Example() { + var requestCount int32 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + count := atomic.AddInt32(&requestCount, 1) + if count <= 3 { // Fail first 3 times + fmt.Printf("Server: Request %d -> 500 Internal Server Error\n", count) + w.WriteHeader(http.StatusInternalServerError) + } else { + fmt.Printf("Server: Request %d -> 200 OK\n", count) + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte("Success after backoff")) + } + })) + defer server.Close() + + // Create a client with exponential backoff. + // Base delay 5ms, max delay 50ms, max 4 retries. + retryClient := httpx.NewHTTPRetryClient( + httpx.WithMaxRetriesRetry(4), + httpx.WithRetryStrategyRetry(httpx.ExponentialBackoff(5*time.Millisecond, 50*time.Millisecond)), + ) + + fmt.Println("Client: Making request with exponential backoff...") + resp, err := retryClient.Get(server.URL) + if err != nil { + fmt.Printf("Client: Request failed: %v\n", err) + return + } + defer resp.Body.Close() + + body, _ := io.ReadAll(resp.Body) + fmt.Printf("Client: Received response: Status=%s, Body='%s'\n", resp.Status, string(body)) + // Note: Duration will vary slightly, but should reflect increasing delays. + fmt.Printf("Client: Total time approx > %dms (due to backoff)\n", (5 + 10 + 20)) // 5ms + 10ms + 20ms delays + + // Output: + // Client: Making request with exponential backoff... + // Server: Request 1 -> 500 Internal Server Error + // Server: Request 2 -> 500 Internal Server Error + // Server: Request 3 -> 500 Internal Server Error + // Server: Request 4 -> 200 OK + // Client: Received response: Status=200 OK, Body='Success after backoff' + // Client: Total time approx > 35ms (due to backoff) +} + +// ExampleNewHTTPRetryClient_withExistingAuth demonstrates how the default client +// transparently preserves existing authentication headers in requests. +func ExampleNewHTTPRetryClient_withExistingAuth() { + var requestCount int32 + + // Create a server that requires authentication + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + count := atomic.AddInt32(&requestCount, 1) + auth := r.Header.Get("Authorization") + + if auth == "" { + fmt.Printf("Server: Request %d -> 401 Unauthorized (no auth)\n", count) + w.WriteHeader(http.StatusUnauthorized) + return + } + + fmt.Printf("Server: Request %d with %s -> ", count, auth) + if count <= 2 { + fmt.Println("500 Internal Server Error") + w.WriteHeader(http.StatusInternalServerError) + } else { + fmt.Println("200 OK") + w.WriteHeader(http.StatusOK) + if _, err := w.Write([]byte("Authenticated and retried successfully")); err != nil { + fmt.Printf("Failed to write response: %v\n", err) + } + } + })) + defer server.Close() + + // Create default client - works transparently with any existing auth + client := httpx.NewHTTPRetryClient( + httpx.WithMaxRetriesRetry(3), + httpx.WithRetryStrategyRetry(httpx.ExponentialBackoff(5*time.Millisecond, 50*time.Millisecond)), + ) + + // Create request with existing auth token (from your app's auth system) + req, _ := http.NewRequest("GET", server.URL, nil) + req.Header.Set("Authorization", "Bearer my-token-123") + + fmt.Println("Client: Making authenticated request...") + resp, err := client.Do(req) + if err != nil { + fmt.Printf("Client: Request failed: %v\n", err) + return + } + defer resp.Body.Close() + + body, _ := io.ReadAll(resp.Body) + fmt.Printf("Client: Success! Status=%s, Body='%s'\n", resp.Status, string(body)) + fmt.Printf("Client: Auth header preserved through %d retries\n", atomic.LoadInt32(&requestCount)) + + // Output: + // Client: Making authenticated request... + // Server: Request 1 with Bearer my-token-123 -> 500 Internal Server Error + // Server: Request 2 with Bearer my-token-123 -> 500 Internal Server Error + // Server: Request 3 with Bearer my-token-123 -> 200 OK + // Client: Success! Status=200 OK, Body='Authenticated and retried successfully' + // Client: Auth header preserved through 3 retries +} + +// ExampleNewClientBuilder_transparent demonstrates using the ClientBuilder +// for advanced configuration while maintaining transparent behavior. +func ExampleNewClientBuilder_transparent() { + // Create a simple test server + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // Echo back any custom headers that were sent + customValue := r.Header.Get("X-Custom-Header") + if customValue != "" { + fmt.Printf("Server: Received custom header: %s\n", customValue) + } + w.WriteHeader(http.StatusOK) + if _, err := w.Write([]byte("Custom headers preserved!")); err != nil { + fmt.Printf("Failed to write response: %v\n", err) + } + })) + defer server.Close() + + // Build client with custom settings - still works transparently + client := httpx.NewClientBuilder(). + WithMaxRetries(5). + WithRetryStrategy(httpx.JitterBackoffStrategy). + WithTimeout(10 * time.Second). + Build() + + // Create request with custom headers + req, _ := http.NewRequest("GET", server.URL, nil) + req.Header.Set("X-Custom-Header", "my-custom-value") + req.Header.Set("Authorization", "Bearer token-from-somewhere") + + fmt.Println("Client: Making request with custom headers...") + resp, err := client.Do(req) + if err != nil { + fmt.Printf("Client: Request failed: %v\n", err) + return + } + defer resp.Body.Close() + + body, _ := io.ReadAll(resp.Body) + fmt.Printf("Client: Response: %s\n", string(body)) + + // Output: + // Client: Making request with custom headers... + // Server: Received custom header: my-custom-value + // Client: Response: Custom headers preserved! +} + +// ExampleNewHTTPRetryClient_withCustomTransport demonstrates using a custom base transport +// with specific transport settings while maintaining transparent retry behavior. +func ExampleNewHTTPRetryClient_withCustomTransport() { + var requestCount int32 + + // Create a test server that fails initially to show retry behavior with custom transport + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + count := atomic.AddInt32(&requestCount, 1) + fmt.Printf("Server: Request %d from custom transport\n", count) + + if count <= 1 { + w.WriteHeader(http.StatusInternalServerError) + } else { + w.WriteHeader(http.StatusOK) + if _, err := w.Write([]byte("Custom transport with retries works!")); err != nil { + fmt.Printf("Failed to write response: %v\n", err) + } + } + })) + defer server.Close() + + // Create a custom transport with specific settings + customTransport := &http.Transport{ + MaxIdleConns: 50, // Custom connection pool size + IdleConnTimeout: 30 * time.Second, // Custom idle timeout + DisableKeepAlives: false, // Enable keep-alive + MaxIdleConnsPerHost: 10, // Custom per-host connection limit + TLSHandshakeTimeout: 5 * time.Second, // Custom TLS timeout + } + + // Create retry client with custom transport + client := httpx.NewHTTPRetryClient( + httpx.WithMaxRetriesRetry(3), + httpx.WithRetryStrategyRetry(httpx.ExponentialBackoff(5*time.Millisecond, 50*time.Millisecond)), + httpx.WithBaseTransport(customTransport), + ) + + fmt.Println("Client: Making request with custom transport...") + resp, err := client.Get(server.URL) + if err != nil { + fmt.Printf("Client: Request failed: %v\n", err) + return + } + defer resp.Body.Close() + + body, _ := io.ReadAll(resp.Body) + fmt.Printf("Client: Response: %s\n", string(body)) + fmt.Printf("Client: Custom transport config preserved (MaxIdleConns: %d)\n", + customTransport.MaxIdleConns) + + // Output: + // Client: Making request with custom transport... + // Server: Request 1 from custom transport + // Server: Request 2 from custom transport + // Client: Response: Custom transport with retries works! + // Client: Custom transport config preserved (MaxIdleConns: 50) +} diff --git a/example_logger_test.go b/example_logger_test.go new file mode 100644 index 0000000..041122d --- /dev/null +++ b/example_logger_test.go @@ -0,0 +1,191 @@ +package httpx_test + +import ( + "fmt" + "io" + "log/slog" + "net/http" + "net/http/httptest" + "os" + "sync/atomic" + "testing" + "time" + + "github.com/slashdevops/httpx" +) + +// TestExample_withLogger_retryLogging demonstrates how to enable logging for HTTP retries. +// By default, logging is disabled. Pass a *slog.Logger to see retry attempts. +func TestExample_withLogger_retryLogging(t *testing.T) { + t.Skip("This is a demonstration of logger usage, not an automated test") + attempts := atomic.Int32{} + + // Create a test server that fails twice, then succeeds + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + attempt := attempts.Add(1) + if attempt <= 2 { + w.WriteHeader(http.StatusInternalServerError) + return + } + w.WriteHeader(http.StatusOK) + fmt.Fprint(w, "Success") + })) + defer server.Close() + + // Create a logger with a custom level (Info level to see retry warnings) + logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{ + Level: slog.LevelInfo, + })) + + // Create client with logging enabled using ClientBuilder + client := httpx.NewClientBuilder(). + WithMaxRetries(3). + WithRetryBaseDelay(500 * time.Millisecond). // Use valid delay (>= 300ms) + WithRetryMaxDelay(10 * time.Second). // Use valid delay (>= 300ms) + WithRetryStrategy(httpx.ExponentialBackoffStrategy). + WithLogger(logger). + Build() + + fmt.Println("Making request with retry logging enabled...") + resp, err := client.Get(server.URL) + if err != nil { + fmt.Printf("Error: %v\n", err) + return + } + defer resp.Body.Close() + + body, _ := io.ReadAll(resp.Body) + fmt.Printf("Response: %s\n", body) + + // Logs will show retry warnings (omitted from output due to timestamps) + fmt.Println("Request succeeded after retries") +} + +// TestExample_withLogger_genericClient demonstrates logging with the GenericClient. +func TestExample_withLogger_genericClient(t *testing.T) { + t.Skip("This is a demonstration of logger usage, not an automated test") + type Response struct { + Message string `json:"message"` + } + + attempts := atomic.Int32{} + + // Create a test server that fails once, then succeeds + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + attempt := attempts.Add(1) + if attempt == 1 { + w.WriteHeader(http.StatusInternalServerError) + return + } + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + fmt.Fprint(w, `{"message":"Success"}`) + })) + defer server.Close() + + // Create a logger + logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{ + Level: slog.LevelWarn, + })) + + // Create generic client with logging + client := httpx.NewGenericClient[Response]( + httpx.WithMaxRetries[Response](2), + httpx.WithRetryBaseDelay[Response](500*time.Millisecond), // Use valid delay + httpx.WithRetryMaxDelay[Response](10*time.Second), // Use valid delay + httpx.WithRetryStrategy[Response](httpx.ExponentialBackoffStrategy), + httpx.WithLogger[Response](logger), + ) + + fmt.Println("Making typed request with retry logging...") + resp, err := client.Get(server.URL) + if err != nil { + fmt.Printf("Error: %v\n", err) + return + } + + fmt.Printf("Response: %s\n", resp.Data.Message) +} + +// Example_withLogger_disabled demonstrates the default behavior with no logging. +// This example shows that by default, logging is disabled for clean, silent operation. +func Example_withLogger_disabled() { + attempts := atomic.Int32{} + + // Create a test server that fails twice, then succeeds + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + attempt := attempts.Add(1) + if attempt <= 2 { + w.WriteHeader(http.StatusInternalServerError) + return + } + w.WriteHeader(http.StatusOK) + fmt.Fprint(w, "Success") + })) + defer server.Close() + + // Create client WITHOUT logging (default behavior) + // No logger means silent retries - clean operation without log noise + client := httpx.NewClientBuilder(). + WithMaxRetries(3). + WithRetryBaseDelay(500 * time.Millisecond). // Use valid delay + WithRetryMaxDelay(10 * time.Second). // Use valid delay + Build() // No WithLogger call = no logging + + fmt.Println("Making request with logging disabled (default)...") + resp, err := client.Get(server.URL) + if err != nil { + fmt.Printf("Error: %v\n", err) + return + } + defer resp.Body.Close() + + body, _ := io.ReadAll(resp.Body) + fmt.Printf("Response: %s\n", body) + fmt.Println("No retry logs appear - silent operation") + + // Output: + // Making request with logging disabled (default)... + // Response: Success + // No retry logs appear - silent operation +} + +// TestExample_newHTTPRetryClient_withLogger demonstrates using NewHTTPRetryClient with logging. +func TestExample_newHTTPRetryClient_withLogger(t *testing.T) { + t.Skip("This is a demonstration of logger usage, not an automated test") + attempts := atomic.Int32{} + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + attempt := attempts.Add(1) + if attempt == 1 { + w.WriteHeader(http.StatusBadGateway) + return + } + w.WriteHeader(http.StatusOK) + fmt.Fprint(w, "Success") + })) + defer server.Close() + + // Create a logger + logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{ + Level: slog.LevelWarn, + })) + + // Create retry client with logger + client := httpx.NewHTTPRetryClient( + httpx.WithMaxRetriesRetry(3), + httpx.WithRetryStrategyRetry(httpx.ExponentialBackoff(500*time.Millisecond, 10*time.Second)), + httpx.WithLoggerRetry(logger), + ) + + fmt.Println("Making request with NewHTTPRetryClient and logging...") + resp, err := client.Get(server.URL) + if err != nil { + fmt.Printf("Error: %v\n", err) + return + } + defer resp.Body.Close() + + body, _ := io.ReadAll(resp.Body) + fmt.Printf("Response: %s\n", body) +} diff --git a/example_request_builder_test.go b/example_request_builder_test.go new file mode 100644 index 0000000..c9af099 --- /dev/null +++ b/example_request_builder_test.go @@ -0,0 +1,330 @@ +package httpx_test + +import ( + "context" + "fmt" + "io" + "net/http" + "net/http/httptest" + + "github.com/slashdevops/httpx" +) + +// ExampleRequestBuilder_simpleGET demonstrates how to create a simple GET request. +func ExampleRequestBuilder_simpleGET() { + req, err := httpx.NewRequestBuilder("https://api.example.com"). + WithMethodGET(). + WithPath("/users"). + Build() + if err != nil { + fmt.Println("Error:", err) + return + } + + fmt.Println(req.Method) + fmt.Println(req.URL.String()) + + // Output: + // GET + // https://api.example.com/users +} + +// ExampleRequestBuilder_postWithJSON demonstrates how to create a POST request with JSON body. +func ExampleRequestBuilder_postWithJSON() { + type User struct { + Name string `json:"name"` + Email string `json:"email"` + } + + user := User{Name: "John Doe", Email: "john@example.com"} + + req, err := httpx.NewRequestBuilder("https://api.example.com"). + WithMethodPOST(). + WithPath("/users"). + WithJSONBody(user). + Build() + if err != nil { + fmt.Println("Error:", err) + return + } + + fmt.Println(req.Method) + fmt.Println(req.Header.Get("Content-Type")) + + // Output: + // POST + // application/json +} + +// ExampleRequestBuilder_withQueryParams demonstrates how to add query parameters. +func ExampleRequestBuilder_withQueryParams() { + req, err := httpx.NewRequestBuilder("https://api.example.com"). + WithMethodGET(). + WithPath("/search"). + WithQueryParam("q", "golang"). + WithQueryParam("limit", "10"). + WithQueryParam("offset", "0"). + Build() + if err != nil { + fmt.Println("Error:", err) + return + } + + fmt.Println(req.URL.String()) + + // Output: + // https://api.example.com/search?limit=10&offset=0&q=golang +} + +// ExampleRequestBuilder_withBasicAuth demonstrates how to use basic authentication. +func ExampleRequestBuilder_withBasicAuth() { + req, err := httpx.NewRequestBuilder("https://api.example.com"). + WithMethodGET(). + WithPath("/protected"). + WithBasicAuth("username", "password"). + Build() + if err != nil { + fmt.Println("Error:", err) + return + } + + authHeader := req.Header.Get("Authorization") + fmt.Println(authHeader[:6]) // Just print "Basic " prefix + + // Output: + // Basic +} + +// ExampleRequestBuilder_withBearerAuth demonstrates how to use bearer token authentication. +func ExampleRequestBuilder_withBearerAuth() { + req, err := httpx.NewRequestBuilder("https://api.example.com"). + WithMethodGET(). + WithPath("/api/data"). + WithBearerAuth("your-token-here"). + Build() + if err != nil { + fmt.Println("Error:", err) + return + } + + authHeader := req.Header.Get("Authorization") + fmt.Println(authHeader[:7]) // Just print "Bearer " prefix + + // Output: + // Bearer +} + +// ExampleRequestBuilder_withAcceptHeader demonstrates how to set the Accept header. +func ExampleRequestBuilder_withAcceptHeader() { + // Using the WithAccept() convenience method + req, err := httpx.NewRequestBuilder("https://api.example.com"). + WithMethodGET(). + WithPath("/api/data"). + WithAccept("application/json"). + Build() + if err != nil { + fmt.Println("Error:", err) + return + } + + fmt.Println(req.Header.Get("Accept")) + + // Output: + // application/json +} + +// ExampleRequestBuilder_withMultipleAcceptTypes demonstrates setting multiple Accept types with quality values. +func ExampleRequestBuilder_withMultipleAcceptTypes() { + req, err := httpx.NewRequestBuilder("https://api.example.com"). + WithMethodGET(). + WithPath("/content"). + WithAccept("application/json, application/xml;q=0.9, */*;q=0.8"). + Build() + if err != nil { + fmt.Println("Error:", err) + return + } + + fmt.Println(req.Header.Get("Accept")) + + // Output: + // application/json, application/xml;q=0.9, */*;q=0.8 +} + +// ExampleRequestBuilder_complexRequest demonstrates a complex request with multiple options. +func ExampleRequestBuilder_complexRequest() { + type RequestData struct { + Action string `json:"action"` + Count int `json:"count"` + } + + data := RequestData{Action: "update", Count: 5} + + req, err := httpx.NewRequestBuilder("https://api.example.com"). + WithMethodPUT(). + WithPath("/resources/123"). + WithQueryParam("force", "true"). + WithHeader("X-Custom-Header", "custom-value"). + WithUserAgent("MyApp/1.0"). + WithBearerAuth("token123"). + WithJSONBody(data). + Build() + if err != nil { + fmt.Println("Error:", err) + return + } + + fmt.Println(req.Method) + fmt.Println(req.URL.Path) + fmt.Println(req.Header.Get("User-Agent")) + fmt.Println(req.Header.Get("X-Custom-Header")) + fmt.Println(req.Header.Get("Content-Type")) + + // Output: + // PUT + // /resources/123 + // MyApp/1.0 + // custom-value + // application/json +} + +// ExampleRequestBuilder_withContext demonstrates how to use a context. +func ExampleRequestBuilder_withContext() { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + req, err := httpx.NewRequestBuilder("https://api.example.com"). + WithMethodGET(). + WithPath("/data"). + WithContext(ctx). + Build() + if err != nil { + fmt.Println("Error:", err) + return + } + + fmt.Println(req.Context() != nil) + + // Output: + // true +} + +// ExampleRequestBuilder_withMethodHEAD demonstrates how to create a HEAD request. +func ExampleRequestBuilder_withMethodHEAD() { + req, err := httpx.NewRequestBuilder("https://api.example.com"). + WithMethodHEAD(). + WithPath("/resource"). + Build() + if err != nil { + fmt.Println("Error:", err) + return + } + + fmt.Println(req.Method) + fmt.Println(req.URL.Path) + + // Output: + // HEAD + // /resource +} + +// ExampleRequestBuilder_withMethodOPTIONS demonstrates how to create an OPTIONS request. +func ExampleRequestBuilder_withMethodOPTIONS() { + req, err := httpx.NewRequestBuilder("https://api.example.com"). + WithMethodOPTIONS(). + WithPath("/api/users"). + Build() + if err != nil { + fmt.Println("Error:", err) + return + } + + fmt.Println(req.Method) + fmt.Println(req.URL.Path) + + // Output: + // OPTIONS + // /api/users +} + +// ExampleRequestBuilder_withMethodTRACE demonstrates how to create a TRACE request. +func ExampleRequestBuilder_withMethodTRACE() { + req, err := httpx.NewRequestBuilder("https://api.example.com"). + WithMethodTRACE(). + WithPath("/debug"). + Build() + if err != nil { + fmt.Println("Error:", err) + return + } + + fmt.Println(req.Method) + fmt.Println(req.URL.Path) + + // Output: + // TRACE + // /debug +} + +// ExampleRequestBuilder_withMethodCONNECT demonstrates how to create a CONNECT request. +func ExampleRequestBuilder_withMethodCONNECT() { + req, err := httpx.NewRequestBuilder("https://proxy.example.com"). + WithMethodCONNECT(). + WithPath("/tunnel"). + Build() + if err != nil { + fmt.Println("Error:", err) + return + } + + fmt.Println(req.Method) + fmt.Println(req.URL.Path) + + // Output: + // CONNECT + // /tunnel +} + +// ExampleRequestBuilder_fullExample demonstrates a complete end-to-end example with a test server. +func ExampleRequestBuilder_fullExample() { + // Create a test server + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`{"message":"success"}`)) + })) + defer server.Close() + + req, err := httpx.NewRequestBuilder(server.URL). + WithMethodGET(). + WithPath("/api/test"). + WithHeader("Accept", "application/json"). + Build() + if err != nil { + fmt.Println("Error:", err) + return + } + + // Execute the request + client := &http.Client{} + resp, err := client.Do(req) + if err != nil { + fmt.Println("Error:", err) + return + } + defer resp.Body.Close() + + // Read the response + body, err := io.ReadAll(resp.Body) + if err != nil { + fmt.Println("Error:", err) + return + } + + fmt.Println(resp.StatusCode) + fmt.Println(string(body)) + + // Output: + // 200 + // {"message":"success"} +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..bd44626 --- /dev/null +++ b/go.mod @@ -0,0 +1,3 @@ +module github.com/slashdevops/httpx + +go 1.22.0 diff --git a/http_client.go b/http_client.go new file mode 100644 index 0000000..1c365c3 --- /dev/null +++ b/http_client.go @@ -0,0 +1,387 @@ +package httpx + +import ( + "log/slog" + "net/http" + "time" +) + +const ( + ValidMaxIdleConns = 200 + ValidMinIdleConns = 1 + ValidMaxIdleConnsPerHost = 200 + ValidMinIdleConnsPerHost = 1 + ValidMaxIdleConnTimeout = 120 * time.Second + ValidMinIdleConnTimeout = 1 * time.Second + ValidMaxTLSHandshakeTimeout = 15 * time.Second + ValidMinTLSHandshakeTimeout = 1 * time.Second + ValidMaxExpectContinueTimeout = 5 * time.Second + ValidMinExpectContinueTimeout = 1 * time.Second + ValidMaxTimeout = 30 * time.Second + ValidMinTimeout = 1 * time.Second + ValidMaxRetries = 10 + ValidMinRetries = 1 + ValidMaxBaseDelay = 5 * time.Second + ValidMinBaseDelay = 300 * time.Millisecond + ValidMaxMaxDelay = 120 * time.Second + ValidMinMaxDelay = 300 * time.Millisecond + + // DefaultMaxRetries is the default number of retry attempts + DefaultMaxRetries = 3 + + // DefaultBaseDelay is the default base delay for backoff strategies + DefaultBaseDelay = 500 * time.Millisecond + + // DefaultMaxDelay is the default maximum delay for backoff strategies + DefaultMaxDelay = 10 * time.Second + + // DefaultMaxIdleConns is the default maximum number of idle connections + DefaultMaxIdleConns = 100 + + // DefaultIdleConnTimeout is the default idle connection timeout + DefaultIdleConnTimeout = 90 * time.Second + + // DefaultTLSHandshakeTimeout is the default TLS handshake timeout + DefaultTLSHandshakeTimeout = 10 * time.Second + + // DefaultExpectContinueTimeout is the default expect continue timeout + DefaultExpectContinueTimeout = 1 * time.Second + + // DefaultDisableKeepAlive is the default disable keep-alive setting + DefaultDisableKeepAlive = false + + // DefaultMaxIdleConnsPerHost is the default maximum number of idle connections per host + DefaultMaxIdleConnsPerHost = 100 + + // DefaultTimeout is the default timeout for HTTP requests + DefaultTimeout = 5 * time.Second +) + +// ClientError represents an error that occurs during HTTP client operations +type ClientError struct { + Message string +} + +func (e *ClientError) Error() string { + return e.Message +} + +// Strategy defines the type for retry strategies +// It is a string type to allow for easy conversion from string literals +// to the defined types +type Strategy string + +const ( + // FixedDelayStrategy represents a fixed delay retry strategy + // This strategy waits for a constant amount of time between retries + // regardless of the number of attempts made + FixedDelayStrategy Strategy = "fixed" + + // JitterBackoffStrategy represents a jitter backoff retry strategy + // This strategy adds randomness to the backoff delay to prevent + // synchronized retries across multiple clients + JitterBackoffStrategy Strategy = "jitter" + + // ExponentialBackoffStrategy represents an exponential backoff retry strategy + // This strategy increases the delay exponentially with each retry attempt, + // up to a maximum delay + ExponentialBackoffStrategy Strategy = "exponential" +) + +func (s Strategy) String() string { + return string(s) +} + +func (s Strategy) IsValid() bool { + switch s { + case FixedDelayStrategy, JitterBackoffStrategy, ExponentialBackoffStrategy: + return true + default: + return false + } +} + +// Client is a custom HTTP client with configurable settings +// and retry strategies. Works transparently with existing request headers. +// It preserves all headers without requiring explicit configuration. +type Client struct { + retryStrategyType Strategy // Store the type, not the function + maxIdleConns int + idleConnTimeout time.Duration + tlsHandshakeTimeout time.Duration + expectContinueTimeout time.Duration + maxIdleConnsPerHost int + timeout time.Duration + maxRetries int + retryBaseDelay time.Duration + retryMaxDelay time.Duration + disableKeepAlive bool + logger *slog.Logger // Optional logger (nil = no logging) +} + +// ClientBuilder is a builder for creating a custom HTTP client +type ClientBuilder struct { + client *Client +} + +// NewClientBuilder creates a new ClientBuilder with default settings +// and retry strategy +func NewClientBuilder() *ClientBuilder { + cb := &ClientBuilder{ + client: &Client{ + maxIdleConns: DefaultMaxIdleConns, + idleConnTimeout: DefaultIdleConnTimeout, + tlsHandshakeTimeout: DefaultTLSHandshakeTimeout, + expectContinueTimeout: DefaultExpectContinueTimeout, + disableKeepAlive: DefaultDisableKeepAlive, + maxIdleConnsPerHost: DefaultMaxIdleConnsPerHost, + timeout: DefaultTimeout, + maxRetries: DefaultMaxRetries, + retryStrategyType: ExponentialBackoffStrategy, + retryBaseDelay: DefaultBaseDelay, + retryMaxDelay: DefaultMaxDelay, + }, + } + return cb +} + +// WithMaxIdleConns sets the maximum number of idle connections +// and returns the ClientBuilder for method chaining +func (b *ClientBuilder) WithMaxIdleConns(maxIdleConns int) *ClientBuilder { + b.client.maxIdleConns = maxIdleConns + + return b +} + +// WithIdleConnTimeout sets the idle connection timeout +// and returns the ClientBuilder for method chaining +func (b *ClientBuilder) WithIdleConnTimeout(idleConnTimeout time.Duration) *ClientBuilder { + b.client.idleConnTimeout = idleConnTimeout + + return b +} + +// WithTLSHandshakeTimeout sets the TLS handshake timeout +// and returns the ClientBuilder for method chaining +func (b *ClientBuilder) WithTLSHandshakeTimeout(tlsHandshakeTimeout time.Duration) *ClientBuilder { + b.client.tlsHandshakeTimeout = tlsHandshakeTimeout + + return b +} + +// WithExpectContinueTimeout sets the expect continue timeout +// and returns the ClientBuilder for method chaining +func (b *ClientBuilder) WithExpectContinueTimeout(expectContinueTimeout time.Duration) *ClientBuilder { + b.client.expectContinueTimeout = expectContinueTimeout + + return b +} + +// WithDisableKeepAlive sets whether to disable keep-alive +// and returns the ClientBuilder for method chaining +func (b *ClientBuilder) WithDisableKeepAlive(disableKeepAlive bool) *ClientBuilder { + b.client.disableKeepAlive = disableKeepAlive + + return b +} + +// WithMaxIdleConnsPerHost sets the maximum number of idle connections per host +// and returns the ClientBuilder for method chaining +func (b *ClientBuilder) WithMaxIdleConnsPerHost(maxIdleConnsPerHost int) *ClientBuilder { + b.client.maxIdleConnsPerHost = maxIdleConnsPerHost + + return b +} + +// WithTimeout sets the timeout for HTTP requests +// and returns the ClientBuilder for method chaining +func (b *ClientBuilder) WithTimeout(timeout time.Duration) *ClientBuilder { + b.client.timeout = timeout + + return b +} + +// WithMaxRetries sets the maximum number of retry attempts +// and returns the ClientBuilder for method chaining +func (b *ClientBuilder) WithMaxRetries(maxRetries int) *ClientBuilder { + b.client.maxRetries = maxRetries + + return b +} + +// WithRetryBaseDelay sets the base delay for retry strategies +// and returns the ClientBuilder for method chaining +func (b *ClientBuilder) WithRetryBaseDelay(baseDelay time.Duration) *ClientBuilder { + b.client.retryBaseDelay = baseDelay + + return b +} + +// WithRetryMaxDelay sets the maximum delay for retry strategies +// and returns the ClientBuilder for method chaining +func (b *ClientBuilder) WithRetryMaxDelay(maxDelay time.Duration) *ClientBuilder { + b.client.retryMaxDelay = maxDelay + + return b +} + +// WithRetryStrategy sets the retry strategy type +// and returns the ClientBuilder for method chaining +func (b *ClientBuilder) WithRetryStrategy(strategy Strategy) *ClientBuilder { + b.client.retryStrategyType = strategy + + return b +} + +// WithRetryStrategyAsString sets the retry strategy type from a string +// and returns the ClientBuilder for method chaining +func (b *ClientBuilder) WithRetryStrategyAsString(strategy string) *ClientBuilder { + s := Strategy(strategy) + + if !s.IsValid() { + if b.client.logger != nil { + b.client.logger.Warn("Invalid retry strategy type, using default (Exponential)", "invalidValue", s, "defaultValue", ExponentialBackoffStrategy) + } + s = ExponentialBackoffStrategy + } + + b.client.retryStrategyType = s + + return b +} + +// WithLogger sets the logger for logging HTTP operations (retries, errors, etc.). +// Pass nil to disable logging (default behavior). +// and returns the ClientBuilder for method chaining +func (b *ClientBuilder) WithLogger(logger *slog.Logger) *ClientBuilder { + b.client.logger = logger + + return b +} + +// Build creates and returns a new HTTP client with the specified settings +// and retry strategy. The client works transparently, preserving any existing +// headers in requests without requiring explicit configuration. +func (b *ClientBuilder) Build() *http.Client { + if b.client.maxIdleConns < ValidMinIdleConns || b.client.maxIdleConns > ValidMaxIdleConns { + if b.client.logger != nil { + b.client.logger.Warn("Invalid max idle connections, using default value", "invalidValue", b.client.maxIdleConns, "defaultValue", DefaultMaxIdleConns) + } + + b.client.maxIdleConns = DefaultMaxIdleConns + } + + if b.client.idleConnTimeout < ValidMinIdleConnTimeout || b.client.idleConnTimeout > ValidMaxIdleConnTimeout { + if b.client.logger != nil { + b.client.logger.Warn("Invalid idle connection timeout, using default value", "invalidValue", b.client.idleConnTimeout, "defaultValue", DefaultIdleConnTimeout) + } + + b.client.idleConnTimeout = DefaultIdleConnTimeout + } + + if b.client.tlsHandshakeTimeout < ValidMinTLSHandshakeTimeout || b.client.tlsHandshakeTimeout > ValidMaxTLSHandshakeTimeout { + if b.client.logger != nil { + b.client.logger.Warn("Invalid TLS handshake timeout, using default value", "invalidValue", b.client.tlsHandshakeTimeout, "defaultValue", DefaultTLSHandshakeTimeout) + } + + b.client.tlsHandshakeTimeout = DefaultTLSHandshakeTimeout + } + + if b.client.expectContinueTimeout < ValidMinExpectContinueTimeout || b.client.expectContinueTimeout > ValidMaxExpectContinueTimeout { + if b.client.logger != nil { + b.client.logger.Warn("Invalid expect continue timeout, using default value", "invalidValue", b.client.expectContinueTimeout, "defaultValue", DefaultExpectContinueTimeout) + } + + b.client.expectContinueTimeout = DefaultExpectContinueTimeout + } + + if b.client.maxIdleConnsPerHost < ValidMinIdleConnsPerHost || b.client.maxIdleConnsPerHost > ValidMaxIdleConnsPerHost { + if b.client.logger != nil { + b.client.logger.Warn("Invalid max idle connections per host, using default value", "invalidValue", b.client.maxIdleConnsPerHost, "defaultValue", DefaultMaxIdleConnsPerHost) + } + + b.client.maxIdleConnsPerHost = DefaultMaxIdleConnsPerHost + } + + if b.client.timeout < ValidMinTimeout || b.client.timeout > ValidMaxTimeout { + if b.client.logger != nil { + b.client.logger.Warn("Invalid timeout, using default value", "invalidValue", b.client.timeout, "defaultValue", DefaultTimeout) + } + + b.client.timeout = DefaultTimeout + } + + if b.client.maxRetries < ValidMinRetries || b.client.maxRetries > ValidMaxRetries { + if b.client.logger != nil { + b.client.logger.Warn("Invalid max retries, using default value", "invalidValue", b.client.maxRetries, "defaultValue", DefaultMaxRetries) + } + + b.client.maxRetries = DefaultMaxRetries + } + + if b.client.retryBaseDelay < ValidMinBaseDelay || b.client.retryBaseDelay > ValidMaxBaseDelay { + if b.client.logger != nil { + b.client.logger.Warn("Invalid retry base delay, using default value", "invalidValue", b.client.retryBaseDelay, "defaultValue", DefaultBaseDelay) + } + + b.client.retryBaseDelay = DefaultBaseDelay + } + + if b.client.retryMaxDelay < ValidMinMaxDelay || b.client.retryMaxDelay > ValidMaxMaxDelay { + if b.client.logger != nil { + b.client.logger.Warn("Invalid retry max delay, using default value", "invalidValue", b.client.retryMaxDelay, "defaultValue", DefaultMaxDelay) + } + + b.client.retryMaxDelay = DefaultMaxDelay + } + + // Determine the final strategy type, defaulting if necessary + finalStrategyType := b.client.retryStrategyType + switch finalStrategyType { + case FixedDelayStrategy, JitterBackoffStrategy, ExponentialBackoffStrategy: + // Valid type provided + default: + if b.client.logger != nil { + b.client.logger.Warn("No valid retry strategy type set, using default (Exponential)", "currentType", finalStrategyType) + } + + finalStrategyType = ExponentialBackoffStrategy + } + + var finalRetryStrategy RetryStrategy + switch finalStrategyType { + case FixedDelayStrategy: + finalRetryStrategy = FixedDelay(b.client.retryBaseDelay) + case JitterBackoffStrategy: + finalRetryStrategy = JitterBackoff(b.client.retryBaseDelay, b.client.retryMaxDelay) + case ExponentialBackoffStrategy: + finalRetryStrategy = ExponentialBackoff(b.client.retryBaseDelay, b.client.retryMaxDelay) + default: + finalRetryStrategy = ExponentialBackoff(b.client.retryBaseDelay, b.client.retryMaxDelay) + } + + // Create the underlying standard transport + transport := &http.Transport{ + MaxIdleConns: b.client.maxIdleConns, + IdleConnTimeout: b.client.idleConnTimeout, + TLSHandshakeTimeout: b.client.tlsHandshakeTimeout, + ExpectContinueTimeout: b.client.expectContinueTimeout, + DisableKeepAlives: b.client.disableKeepAlive, + MaxIdleConnsPerHost: b.client.maxIdleConnsPerHost, + } + + // Create retry transport - this is the only layer needed for transparent operation + // It automatically preserves all existing headers without any explicit auth configuration + finalTransport := &retryTransport{ + Transport: transport, + MaxRetries: b.client.maxRetries, + RetryStrategy: finalRetryStrategy, + logger: b.client.logger, + } + + // Create the HTTP client with the specified settings + return &http.Client{ + Timeout: b.client.timeout, + Transport: finalTransport, + } +} diff --git a/http_client_test.go b/http_client_test.go new file mode 100644 index 0000000..fc6b764 --- /dev/null +++ b/http_client_test.go @@ -0,0 +1,203 @@ +package httpx + +import ( + "net/http" + "reflect" + "testing" + "time" +) + +// Helper functions to replace testify assertions +func assertEqual(t *testing.T, expected, actual any) { + t.Helper() + if !reflect.DeepEqual(expected, actual) { + t.Errorf("Expected %v, got %v", expected, actual) + } +} + +func assertTrue(t *testing.T, condition bool) { + t.Helper() + if !condition { + t.Error("Expected condition to be true") + } +} + +func assertNotNil(t *testing.T, value any) { + t.Helper() + if value == nil { + t.Error("Expected value to be non-nil") + } +} + +func TestClientBuilder_WithMethods(t *testing.T) { + builder := NewClientBuilder() + + // Test valid settings + builder.WithMaxIdleConns(50). + WithIdleConnTimeout(60 * time.Second). + WithTLSHandshakeTimeout(5 * time.Second). + WithExpectContinueTimeout(2 * time.Second). + WithDisableKeepAlive(true). + WithMaxIdleConnsPerHost(50). + WithTimeout(10 * time.Second). + WithMaxRetries(5). + WithRetryBaseDelay(100 * time.Millisecond). + WithRetryMaxDelay(5 * time.Second). + WithRetryStrategy(FixedDelayStrategy) + + client := builder.client + // Assert that the With... methods *set* the values on the internal client struct + assertEqual(t, 50, client.maxIdleConns) + assertEqual(t, 60*time.Second, client.idleConnTimeout) + assertEqual(t, 5*time.Second, client.tlsHandshakeTimeout) + assertEqual(t, 2*time.Second, client.expectContinueTimeout) + assertTrue(t, client.disableKeepAlive) + assertEqual(t, 50, client.maxIdleConnsPerHost) + assertEqual(t, 10*time.Second, client.timeout) + assertEqual(t, 5, client.maxRetries) + assertEqual(t, 100*time.Millisecond, client.retryBaseDelay) // Check the value *set* by WithRetryBaseDelay + assertEqual(t, 5*time.Second, client.retryMaxDelay) + // Check that the strategy type was set correctly + assertEqual(t, FixedDelayStrategy, client.retryStrategyType) // Check the type *set* by WithRetryStrategy + + // Test invalid settings (should use defaults or adjusted values) + builder = NewClientBuilder() // Reset builder + builder.WithMaxIdleConns(0). // Invalid, use default + WithIdleConnTimeout(0). // Invalid, use default + WithTLSHandshakeTimeout(0). // Invalid, use default + WithExpectContinueTimeout(0). // Invalid, use default + WithMaxIdleConnsPerHost(0). // Invalid, use default + WithTimeout(0). // Invalid, use default + WithMaxRetries(0). // Invalid, use default + WithRetryBaseDelay(1 * time.Millisecond). // Invalid, use default + WithRetryMaxDelay(50 * time.Millisecond). // Invalid, use default + WithRetryStrategy("invalid") // Invalid strategy type + + client = builder.client + // Assert that the *invalid* values were set by the With... methods (before Build validation) + assertEqual(t, 0, client.maxIdleConns) + assertEqual(t, 0*time.Second, client.idleConnTimeout) + assertEqual(t, 0*time.Second, client.tlsHandshakeTimeout) + assertEqual(t, 0*time.Second, client.expectContinueTimeout) + assertEqual(t, 0, client.maxIdleConnsPerHost) + assertEqual(t, 0*time.Second, client.timeout) + assertEqual(t, 0, client.maxRetries) + assertEqual(t, 1*time.Millisecond, client.retryBaseDelay) + assertEqual(t, 50*time.Millisecond, client.retryMaxDelay) + // Check that the invalid strategy type was set + assertEqual(t, Strategy("invalid"), client.retryStrategyType) +} + +func TestClientBuilder_Build(t *testing.T) { + baseDelay := 200 * time.Millisecond + maxDelay := 2 * time.Second + maxRetries := 4 + + builder := NewClientBuilder(). + WithMaxIdleConns(55). + WithIdleConnTimeout(65 * time.Second). + WithTLSHandshakeTimeout(6 * time.Second). + WithExpectContinueTimeout(3 * time.Second). + WithDisableKeepAlive(true). + WithMaxIdleConnsPerHost(55). + WithTimeout(15 * time.Second). + WithMaxRetries(maxRetries). + WithRetryBaseDelay(baseDelay). // Invalid, should be corrected to default + WithRetryMaxDelay(maxDelay). + WithRetryStrategy(JitterBackoffStrategy) + + httpClient := builder.Build() + + // Verify the HTTP client was built + assertNotNil(t, httpClient) + assertNotNil(t, httpClient.Transport) + + // Verify timeout + assertEqual(t, 15*time.Second, httpClient.Timeout) + + // Test the transport is a retry transport + if retryTrans, ok := httpClient.Transport.(*retryTransport); ok { + assertEqual(t, maxRetries, retryTrans.MaxRetries) + assertNotNil(t, retryTrans.RetryStrategy) + + // Test that the underlying transport has the right settings + if baseTrans, ok := retryTrans.Transport.(*http.Transport); ok { + assertEqual(t, 55, baseTrans.MaxIdleConns) + assertEqual(t, 65*time.Second, baseTrans.IdleConnTimeout) + assertEqual(t, 6*time.Second, baseTrans.TLSHandshakeTimeout) + assertEqual(t, 3*time.Second, baseTrans.ExpectContinueTimeout) + assertTrue(t, baseTrans.DisableKeepAlives) + assertEqual(t, 55, baseTrans.MaxIdleConnsPerHost) + } else { + t.Error("Expected underlying transport to be *http.Transport") + } + } else { + t.Error("Expected transport to be *retryTransport") + } +} + +func TestStrategyString(t *testing.T) { + assertEqual(t, "fixed", FixedDelayStrategy.String()) + assertEqual(t, "jitter", JitterBackoffStrategy.String()) + assertEqual(t, "exponential", ExponentialBackoffStrategy.String()) + assertEqual(t, "unknown", Strategy("unknown").String()) +} + +func TestClientError(t *testing.T) { + err := &ClientError{Message: "test error"} + assertEqual(t, "test error", err.Error()) +} + +func TestClientBuilder_WithRetryStrategyAsString(t *testing.T) { + tests := []struct { + name string + inputStrategy string + expectedType Strategy + expectWarning bool // Although we can't directly test logs here, good to note + }{ + { + name: "Valid Fixed Strategy", + inputStrategy: "fixed", + expectedType: FixedDelayStrategy, + expectWarning: false, + }, + { + name: "Valid Jitter Strategy", + inputStrategy: "jitter", + expectedType: JitterBackoffStrategy, + expectWarning: false, + }, + { + name: "Valid Exponential Strategy", + inputStrategy: "exponential", + expectedType: ExponentialBackoffStrategy, + expectWarning: false, + }, + { + name: "Invalid Strategy", + inputStrategy: "invalid-strategy", + expectedType: ExponentialBackoffStrategy, // Should default + expectWarning: true, + }, + { + name: "Empty Strategy", + inputStrategy: "", + expectedType: ExponentialBackoffStrategy, // Should default + expectWarning: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + builder := NewClientBuilder() // Start fresh for each test case + builder.WithRetryStrategyAsString(tt.inputStrategy) + + // Assert that the correct strategy *type* was set on the internal client struct + assertEqual(t, tt.expectedType, builder.client.retryStrategyType) + + // Note: We expect a warning log for invalid strategies, but testing logs + // usually requires more setup (e.g., capturing log output). + // This test focuses on the functional outcome (correct strategy type set). + }) + } +} diff --git a/http_generic_client.go b/http_generic_client.go new file mode 100644 index 0000000..958bce9 --- /dev/null +++ b/http_generic_client.go @@ -0,0 +1,443 @@ +package httpx + +import ( + "encoding/json" + "fmt" + "io" + "log/slog" + "net/http" + "strings" + "time" +) + +// HTTPClient is an interface that defines the methods required for making HTTP requests. +// This allows for easier testing and mocking of HTTP requests in unit tests. +type HTTPClient interface { + Do(req *http.Request) (*http.Response, error) +} + +// GenericClient is a generic HTTP client that can handle requests and responses with type safety. +// It wraps an HTTPClient and provides methods for executing typed HTTP requests. +type GenericClient[T any] struct { + httpClient HTTPClient + // Configuration fields for building HTTP client + customClient HTTPClient // If set, use this instead of building one + maxIdleConns *int + idleConnTimeout *time.Duration + tlsHandshakeTimeout *time.Duration + expectContinueTimeout *time.Duration + maxIdleConnsPerHost *int + timeout *time.Duration + maxRetries *int + retryBaseDelay *time.Duration + retryMaxDelay *time.Duration + retryStrategy *Strategy + disableKeepAlive *bool + logger *slog.Logger // Optional logger (nil = no logging) +} + +// GenericClientOption is a function type for configuring the GenericClient. +type GenericClientOption[T any] func(*GenericClient[T]) + +// Response represents the response from an HTTP request with generic type support. +type Response[T any] struct { + Data T + Headers http.Header + RawBody []byte + StatusCode int +} + +// ErrorResponse represents an error response from the API. +type ErrorResponse struct { + Message string `json:"message,omitempty"` + ErrorMsg string `json:"error,omitempty"` + Details string `json:"details,omitempty"` + StatusCode int `json:"statusCode,omitempty"` +} + +// Error implements the error interface for ErrorResponse. +// It returns a human-readable error message that includes the HTTP status code +// and any available error details from the API response. +func (e *ErrorResponse) Error() string { + if e.Message != "" { + return fmt.Sprintf("http %d: %s", e.StatusCode, e.Message) + } + + if e.ErrorMsg != "" { + return fmt.Sprintf("http %d: %s", e.StatusCode, e.ErrorMsg) + } + + return fmt.Sprintf("http %d: request failed", e.StatusCode) +} + +// NewGenericClient creates a new generic HTTP client with the specified type. +// By default, it builds an HTTP client using ClientBuilder with default settings. +// Use the provided options to customize the client behavior. +// If WithHTTPClient is used, it takes precedence over all other configuration options. +func NewGenericClient[T any](options ...GenericClientOption[T]) *GenericClient[T] { + client := &GenericClient[T]{} + + // Apply options + for _, option := range options { + option(client) + } + + // If a custom HTTP client was provided, use it + if client.customClient != nil { + client.httpClient = client.customClient + return client + } + + // Otherwise, build an HTTP client using ClientBuilder with the configured options + builder := NewClientBuilder() + + // Apply configuration if set + if client.maxIdleConns != nil { + builder.WithMaxIdleConns(*client.maxIdleConns) + } + + if client.idleConnTimeout != nil { + builder.WithIdleConnTimeout(*client.idleConnTimeout) + } + + if client.tlsHandshakeTimeout != nil { + builder.WithTLSHandshakeTimeout(*client.tlsHandshakeTimeout) + } + + if client.expectContinueTimeout != nil { + builder.WithExpectContinueTimeout(*client.expectContinueTimeout) + } + + if client.maxIdleConnsPerHost != nil { + builder.WithMaxIdleConnsPerHost(*client.maxIdleConnsPerHost) + } + + if client.timeout != nil { + builder.WithTimeout(*client.timeout) + } + + if client.maxRetries != nil { + builder.WithMaxRetries(*client.maxRetries) + } + + if client.retryBaseDelay != nil { + builder.WithRetryBaseDelay(*client.retryBaseDelay) + } + + if client.retryMaxDelay != nil { + builder.WithRetryMaxDelay(*client.retryMaxDelay) + } + + if client.retryStrategy != nil { + builder.WithRetryStrategy(*client.retryStrategy) + } + + if client.disableKeepAlive != nil { + builder.WithDisableKeepAlive(*client.disableKeepAlive) + } + + if client.logger != nil { + builder.WithLogger(client.logger) + } + + client.httpClient = builder.Build() + return client +} + +// WithHTTPClient configures the generic client to use a custom HTTPClient implementation. +// If httpClient is nil, the option is ignored and the client retains its current HTTPClient. +// This option takes precedence over all other configuration options. +// This is useful for using a pre-configured retry client or custom transport. +func WithHTTPClient[T any](httpClient HTTPClient) GenericClientOption[T] { + return func(c *GenericClient[T]) { + if httpClient != nil { + c.customClient = httpClient + } + } +} + +// WithTimeout sets the request timeout for the generic client. +// Uses ClientBuilder validation and defaults if the value is out of range. +func WithTimeout[T any](timeout time.Duration) GenericClientOption[T] { + return func(c *GenericClient[T]) { + c.timeout = &timeout + } +} + +// WithMaxIdleConns sets the maximum number of idle connections. +// Uses ClientBuilder validation and defaults if the value is out of range. +func WithMaxIdleConns[T any](maxIdleConns int) GenericClientOption[T] { + return func(c *GenericClient[T]) { + c.maxIdleConns = &maxIdleConns + } +} + +// WithIdleConnTimeout sets the idle connection timeout. +// Uses ClientBuilder validation and defaults if the value is out of range. +func WithIdleConnTimeout[T any](idleConnTimeout time.Duration) GenericClientOption[T] { + return func(c *GenericClient[T]) { + c.idleConnTimeout = &idleConnTimeout + } +} + +// WithTLSHandshakeTimeout sets the TLS handshake timeout. +// Uses ClientBuilder validation and defaults if the value is out of range. +func WithTLSHandshakeTimeout[T any](tlsHandshakeTimeout time.Duration) GenericClientOption[T] { + return func(c *GenericClient[T]) { + c.tlsHandshakeTimeout = &tlsHandshakeTimeout + } +} + +// WithExpectContinueTimeout sets the expect continue timeout. +// Uses ClientBuilder validation and defaults if the value is out of range. +func WithExpectContinueTimeout[T any](expectContinueTimeout time.Duration) GenericClientOption[T] { + return func(c *GenericClient[T]) { + c.expectContinueTimeout = &expectContinueTimeout + } +} + +// WithDisableKeepAlive sets whether to disable keep-alive. +func WithDisableKeepAlive[T any](disableKeepAlive bool) GenericClientOption[T] { + return func(c *GenericClient[T]) { + c.disableKeepAlive = &disableKeepAlive + } +} + +// WithMaxIdleConnsPerHost sets the maximum number of idle connections per host. +// Uses ClientBuilder validation and defaults if the value is out of range. +func WithMaxIdleConnsPerHost[T any](maxIdleConnsPerHost int) GenericClientOption[T] { + return func(c *GenericClient[T]) { + c.maxIdleConnsPerHost = &maxIdleConnsPerHost + } +} + +// WithMaxRetries sets the maximum number of retry attempts. +// Uses ClientBuilder validation and defaults if the value is out of range. +func WithMaxRetries[T any](maxRetries int) GenericClientOption[T] { + return func(c *GenericClient[T]) { + c.maxRetries = &maxRetries + } +} + +// WithRetryBaseDelay sets the base delay for retry strategies. +// Uses ClientBuilder validation and defaults if the value is out of range. +func WithRetryBaseDelay[T any](baseDelay time.Duration) GenericClientOption[T] { + return func(c *GenericClient[T]) { + c.retryBaseDelay = &baseDelay + } +} + +// WithRetryMaxDelay sets the maximum delay for retry strategies. +// Uses ClientBuilder validation and defaults if the value is out of range. +func WithRetryMaxDelay[T any](maxDelay time.Duration) GenericClientOption[T] { + return func(c *GenericClient[T]) { + c.retryMaxDelay = &maxDelay + } +} + +// WithRetryStrategy sets the retry strategy type (fixed, jitter, or exponential). +// Uses ClientBuilder validation and defaults if the value is invalid. +func WithRetryStrategy[T any](strategy Strategy) GenericClientOption[T] { + return func(c *GenericClient[T]) { + c.retryStrategy = &strategy + } +} + +// WithRetryStrategyAsString sets the retry strategy type from a string. +// Valid values: "fixed", "jitter", "exponential". +// Uses ClientBuilder validation and defaults if the value is invalid. +func WithRetryStrategyAsString[T any](strategy string) GenericClientOption[T] { + return func(c *GenericClient[T]) { + s := Strategy(strategy) + c.retryStrategy = &s + } +} + +// WithLogger sets the logger for logging HTTP operations (retries, errors, etc.). +// Pass nil to disable logging (default behavior). +func WithLogger[T any](logger *slog.Logger) GenericClientOption[T] { + return func(c *GenericClient[T]) { + c.logger = logger + } +} + +// Execute performs an HTTP request and returns a typed response. +// It executes the request, reads the response body, +// and unmarshals the JSON response into the generic type T. +// Returns an error if the HTTP status code is >= 400. +func (c *GenericClient[T]) Execute(req *http.Request) (*Response[T], error) { + // Log raw request details + if c.logger != nil { + c.logger.Debug("Executing HTTP request", + "method", req.Method, + "url", req.URL.String(), + ) + c.logger.Debug("Request headers", + "headers", req.Header, + ) + + // Log request body if present + if req.Body != nil { + body, err := io.ReadAll(req.Body) + req.Body.Close() + if err == nil { + c.logger.Debug("Request body (raw)", + "body", string(body), + "length", len(body), + ) + // Restore the body for actual request + req.Body = io.NopCloser(strings.NewReader(string(body))) + } + } + } + + // Execute the request + resp, err := c.httpClient.Do(req) + if err != nil { + return nil, fmt.Errorf("execute http request: %w", err) + } + defer resp.Body.Close() + + // Log raw response details + if c.logger != nil { + c.logger.Debug("Received HTTP response", + "status", resp.Status, + "status_code", resp.StatusCode, + "url", req.URL.String(), + "method", req.Method, + ) + c.logger.Debug("Response headers", + "headers", resp.Header, + ) + } + + // Read the response body + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("read response body: %w", err) + } + + // Log raw response body + if c.logger != nil { + c.logger.Debug("Response body (raw)", + "body", string(body), + "length", len(body), + "content_type", resp.Header.Get("Content-Type"), + ) + } + + // Check for HTTP errors + if resp.StatusCode >= 400 { + return nil, c.handleErrorResponse(resp.StatusCode, body) + } + + // Parse the response + response := &Response[T]{ + StatusCode: resp.StatusCode, + Headers: resp.Header, + RawBody: body, + } + + // Unmarshal JSON response if body is not empty + if len(body) > 0 { + if err := json.Unmarshal(body, &response.Data); err != nil { + return nil, fmt.Errorf("unmarshal response json: %w", err) + } + } + + return response, nil +} + +// ExecuteRaw performs an HTTP request and returns the raw response without unmarshaling. +// This is useful when you need direct access to the http.Response, such as for streaming +// or when the response is not JSON. +func (c *GenericClient[T]) ExecuteRaw(req *http.Request) (*http.Response, error) { + // Execute the request + resp, err := c.httpClient.Do(req) + if err != nil { + return nil, fmt.Errorf("http request failed: %w", err) + } + + return resp, nil +} + +// Do performs an HTTP request and returns a typed response. +// This method is designed to work seamlessly with the RequestBuilder. +// It's an alias for Execute but with a more familiar name for those used to http.Client.Do(). +func (c *GenericClient[T]) Do(req *http.Request) (*Response[T], error) { + return c.Execute(req) +} + +// Get performs a GET request to the specified URL and returns a typed response. +func (c *GenericClient[T]) Get(url string) (*Response[T], error) { + req, err := http.NewRequest(http.MethodGet, url, nil) + if err != nil { + return nil, fmt.Errorf("create GET request: %w", err) + } + + return c.Execute(req) +} + +// Post performs a POST request with the specified body and returns a typed response. +func (c *GenericClient[T]) Post(url string, body io.Reader) (*Response[T], error) { + req, err := http.NewRequest(http.MethodPost, url, body) + if err != nil { + return nil, fmt.Errorf("create POST request: %w", err) + } + + return c.Execute(req) +} + +// Put performs a PUT request with the specified body and returns a typed response. +func (c *GenericClient[T]) Put(url string, body io.Reader) (*Response[T], error) { + req, err := http.NewRequest(http.MethodPut, url, body) + if err != nil { + return nil, fmt.Errorf("create PUT request: %w", err) + } + + return c.Execute(req) +} + +// Delete performs a DELETE request and returns a typed response. +func (c *GenericClient[T]) Delete(url string) (*Response[T], error) { + req, err := http.NewRequest(http.MethodDelete, url, nil) + if err != nil { + return nil, fmt.Errorf("create DELETE request: %w", err) + } + + return c.Execute(req) +} + +// Patch performs a PATCH request with the specified body and returns a typed response. +func (c *GenericClient[T]) Patch(url string, body io.Reader) (*Response[T], error) { + req, err := http.NewRequest(http.MethodPatch, url, body) + if err != nil { + return nil, fmt.Errorf("create PATCH request: %w", err) + } + + return c.Execute(req) +} + +// handleErrorResponse handles HTTP error responses. +// It attempts to unmarshal the error response as JSON, and if that fails, +// uses the raw body as the error message. +func (c *GenericClient[T]) handleErrorResponse(statusCode int, body []byte) error { + errorResp := &ErrorResponse{ + StatusCode: statusCode, + } + + // Try to unmarshal error response + if len(body) > 0 { + if err := json.Unmarshal(body, errorResp); err != nil { + // If unmarshaling fails, use raw body as message + errorResp.Message = string(body) + } + } + + // Set default message if none provided + if errorResp.Message == "" && errorResp.ErrorMsg == "" { + errorResp.Message = http.StatusText(statusCode) + } + + return errorResp +} diff --git a/http_generic_client_test.go b/http_generic_client_test.go new file mode 100644 index 0000000..13132da --- /dev/null +++ b/http_generic_client_test.go @@ -0,0 +1,727 @@ +package httpx + +import ( + "bytes" + "context" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" +) + +// Test data structures for generic client tests +type User struct { + ID int `json:"id"` + Name string `json:"name"` + Email string `json:"email"` +} + +type Post struct { + ID int `json:"id"` + Title string `json:"title"` + Body string `json:"body"` + UserID int `json:"userId"` +} + +type APIError struct { + Code string `json:"code"` + Message string `json:"message"` +} + +// TestNewGenericClient tests the creation of a new generic client +func TestNewGenericClient(t *testing.T) { + t.Run("Default client", func(t *testing.T) { + client := NewGenericClient[User]() + + if client == nil { + t.Fatal("NewGenericClient returned nil") + } + + if client.httpClient == nil { + t.Error("httpClient should be initialized") + } + }) + + t.Run("With custom timeout", func(t *testing.T) { + timeout := 5 * time.Second + client := NewGenericClient[User]( + WithTimeout[User](timeout), + ) + + if httpClient, ok := client.httpClient.(*http.Client); ok { + if httpClient.Timeout != timeout { + t.Errorf("Expected timeout %v, got %v", timeout, httpClient.Timeout) + } + } else { + t.Error("httpClient should be *http.Client") + } + }) + + t.Run("With custom HTTP client", func(t *testing.T) { + customClient := &http.Client{ + Timeout: 10 * time.Second, + } + client := NewGenericClient[User]( + WithHTTPClient[User](customClient), + ) + + if client.httpClient != customClient { + t.Error("Expected custom HTTP client to be used") + } + }) + + t.Run("With nil HTTP client (should be ignored)", func(t *testing.T) { + client := NewGenericClient[User]( + WithHTTPClient[User](nil), + ) + + if client.httpClient == nil { + t.Error("httpClient should not be nil when nil option is provided") + } + }) +} + +// TestGenericClient_Execute tests the Execute method +func TestGenericClient_Execute(t *testing.T) { + t.Run("Successful GET request", func(t *testing.T) { + expectedUser := User{ID: 1, Name: "John Doe", Email: "john@example.com"} + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + t.Errorf("Expected GET method, got %s", r.Method) + } + w.Header().Set("Content-Type", "application/json") + if err := json.NewEncoder(w).Encode(expectedUser); err != nil { + t.Errorf("Failed to encode response: %v", err) + } + })) + defer server.Close() + + client := NewGenericClient[User]() + req, _ := http.NewRequest(http.MethodGet, server.URL, nil) + + resp, err := client.Execute(req) + if err != nil { + t.Fatalf("Execute failed: %v", err) + } + + if resp.StatusCode != http.StatusOK { + t.Errorf("Expected status %d, got %d", http.StatusOK, resp.StatusCode) + } + + if resp.Data.ID != expectedUser.ID { + t.Errorf("Expected ID %d, got %d", expectedUser.ID, resp.Data.ID) + } + + if resp.Data.Name != expectedUser.Name { + t.Errorf("Expected Name %s, got %s", expectedUser.Name, resp.Data.Name) + } + }) + + t.Run("HTTP error response", func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNotFound) + if err := json.NewEncoder(w).Encode(map[string]string{ + "message": "User not found", + }); err != nil { + t.Errorf("Failed to encode response: %v", err) + } + })) + defer server.Close() + + client := NewGenericClient[User]() + req, _ := http.NewRequest(http.MethodGet, server.URL, nil) + + _, err := client.Execute(req) + + if err == nil { + t.Fatal("Expected error for 404 response") + } + + errorResp, ok := err.(*ErrorResponse) + if !ok { + t.Fatalf("Expected ErrorResponse, got %T", err) + } + + if errorResp.StatusCode != http.StatusNotFound { + t.Errorf("Expected status code %d, got %d", http.StatusNotFound, errorResp.StatusCode) + } + + if !strings.Contains(errorResp.Message, "not found") { + t.Errorf("Expected error message about 'not found', got %s", errorResp.Message) + } + }) + + t.Run("Empty response body", func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNoContent) + })) + defer server.Close() + + client := NewGenericClient[User]() + req, _ := http.NewRequest(http.MethodGet, server.URL, nil) + + resp, err := client.Execute(req) + if err != nil { + t.Fatalf("Execute failed: %v", err) + } + + if resp.StatusCode != http.StatusNoContent { + t.Errorf("Expected status %d, got %d", http.StatusNoContent, resp.StatusCode) + } + + if len(resp.RawBody) != 0 { + t.Errorf("Expected empty body, got %d bytes", len(resp.RawBody)) + } + }) + + t.Run("Invalid JSON response", func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if _, err := w.Write([]byte("invalid json")); err != nil { + t.Errorf("Failed to write response: %v", err) + } + })) + defer server.Close() + + client := NewGenericClient[User]() + req, _ := http.NewRequest(http.MethodGet, server.URL, nil) + + _, err := client.Execute(req) + + if err == nil { + t.Fatal("Expected error for invalid JSON") + } + + if !strings.Contains(err.Error(), "unmarshal") { + t.Errorf("Expected unmarshal error, got: %v", err) + } + }) +} + +// TestGenericClient_ConvenienceMethods tests GET, POST, PUT, DELETE, PATCH methods +func TestGenericClient_ConvenienceMethods(t *testing.T) { + t.Run("Get method", func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + t.Errorf("Expected GET, got %s", r.Method) + } + if err := json.NewEncoder(w).Encode(User{ID: 1, Name: "John"}); err != nil { + t.Errorf("Failed to encode response: %v", err) + } + })) + defer server.Close() + + client := NewGenericClient[User]() + resp, err := client.Get(server.URL) + if err != nil { + t.Fatalf("Get failed: %v", err) + } + + if resp.Data.ID != 1 { + t.Errorf("Expected ID 1, got %d", resp.Data.ID) + } + }) + + t.Run("Post method", func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + t.Errorf("Expected POST, got %s", r.Method) + } + + var user User + if err := json.NewDecoder(r.Body).Decode(&user); err != nil { + t.Errorf("Failed to decode request: %v", err) + } + user.ID = 123 + if err := json.NewEncoder(w).Encode(user); err != nil { + t.Errorf("Failed to encode response: %v", err) + } + })) + defer server.Close() + + client := NewGenericClient[User]() + userData, _ := json.Marshal(User{Name: "Jane", Email: "jane@example.com"}) + resp, err := client.Post(server.URL, bytes.NewReader(userData)) + if err != nil { + t.Fatalf("Post failed: %v", err) + } + + if resp.Data.ID != 123 { + t.Errorf("Expected ID 123, got %d", resp.Data.ID) + } + }) + + t.Run("Put method", func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPut { + t.Errorf("Expected PUT, got %s", r.Method) + } + if err := json.NewEncoder(w).Encode(User{ID: 1, Name: "Updated"}); err != nil { + t.Errorf("Failed to encode response: %v", err) + } + })) + defer server.Close() + + client := NewGenericClient[User]() + userData, _ := json.Marshal(User{ID: 1, Name: "Updated"}) + resp, err := client.Put(server.URL, bytes.NewReader(userData)) + if err != nil { + t.Fatalf("Put failed: %v", err) + } + + if resp.Data.Name != "Updated" { + t.Errorf("Expected Name 'Updated', got %s", resp.Data.Name) + } + }) + + t.Run("Delete method", func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodDelete { + t.Errorf("Expected DELETE, got %s", r.Method) + } + w.WriteHeader(http.StatusNoContent) + })) + defer server.Close() + + client := NewGenericClient[User]() + resp, err := client.Delete(server.URL) + if err != nil { + t.Fatalf("Delete failed: %v", err) + } + + if resp.StatusCode != http.StatusNoContent { + t.Errorf("Expected status %d, got %d", http.StatusNoContent, resp.StatusCode) + } + }) + + t.Run("Patch method", func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPatch { + t.Errorf("Expected PATCH, got %s", r.Method) + } + if err := json.NewEncoder(w).Encode(User{ID: 1, Name: "Patched"}); err != nil { + t.Errorf("Failed to encode response: %v", err) + } + })) + defer server.Close() + + client := NewGenericClient[User]() + patchData, _ := json.Marshal(map[string]string{"name": "Patched"}) + resp, err := client.Patch(server.URL, bytes.NewReader(patchData)) + if err != nil { + t.Fatalf("Patch failed: %v", err) + } + + if resp.Data.Name != "Patched" { + t.Errorf("Expected Name 'Patched', got %s", resp.Data.Name) + } + }) +} + +// TestGenericClient_ExecuteRaw tests the ExecuteRaw method +func TestGenericClient_ExecuteRaw(t *testing.T) { + t.Run("Returns raw response", func(t *testing.T) { + expectedBody := "raw response body" + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if _, err := w.Write([]byte(expectedBody)); err != nil { + t.Errorf("Failed to write response: %v", err) + } + })) + defer server.Close() + + client := NewGenericClient[User]() + req, _ := http.NewRequest(http.MethodGet, server.URL, nil) + + resp, err := client.ExecuteRaw(req) + if err != nil { + t.Fatalf("ExecuteRaw failed: %v", err) + } + defer resp.Body.Close() + + body, _ := io.ReadAll(resp.Body) + if string(body) != expectedBody { + t.Errorf("Expected body %s, got %s", expectedBody, string(body)) + } + }) +} + +// TestGenericClient_Do tests the Do method alias +func TestGenericClient_Do(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if err := json.NewEncoder(w).Encode(User{ID: 1, Name: "Test"}); err != nil { + t.Errorf("Failed to encode response: %v", err) + } + })) + defer server.Close() + + client := NewGenericClient[User]() + req, _ := http.NewRequest(http.MethodGet, server.URL, nil) + + resp, err := client.Do(req) + if err != nil { + t.Fatalf("Do failed: %v", err) + } + + if resp.Data.ID != 1 { + t.Errorf("Expected ID 1, got %d", resp.Data.ID) + } +} + +// TestGenericClient_WithRequestBuilder tests integration with RequestBuilder +func TestGenericClient_WithRequestBuilder(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + t.Errorf("Expected POST, got %s", r.Method) + } + if r.Header.Get("Content-Type") != "application/json" { + t.Errorf("Expected Content-Type application/json, got %s", r.Header.Get("Content-Type")) + } + if r.Header.Get("Authorization") != "Bearer token123" { + t.Errorf("Expected Authorization header, got %s", r.Header.Get("Authorization")) + } + + var post Post + if err := json.NewDecoder(r.Body).Decode(&post); err != nil { + t.Errorf("Failed to decode request: %v", err) + } + post.ID = 42 + if err := json.NewEncoder(w).Encode(post); err != nil { + t.Errorf("Failed to encode response: %v", err) + } + })) + defer server.Close() + + // Create client + client := NewGenericClient[Post]() + + // Use RequestBuilder to create request with headers + post := Post{Title: "Test Post", Body: "Test content", UserID: 1} + req, err := NewRequestBuilder(server.URL). + WithMethodPOST(). + WithPath("/posts"). + WithContentType("application/json"). + WithHeader("Authorization", "Bearer token123"). + WithJSONBody(post). + Build() + if err != nil { + t.Fatalf("RequestBuilder.Build failed: %v", err) + } + + // Execute request with client + resp, err := client.Do(req) + if err != nil { + t.Fatalf("client.Do failed: %v", err) + } + + if resp.Data.ID != 42 { + t.Errorf("Expected ID 42, got %d", resp.Data.ID) + } + + if resp.Data.Title != "Test Post" { + t.Errorf("Expected Title 'Test Post', got %s", resp.Data.Title) + } +} + +// TestErrorResponse_Error tests the Error method of ErrorResponse +func TestErrorResponse_Error(t *testing.T) { + tests := []struct { + name string + err *ErrorResponse + expected string + }{ + { + name: "With Message field", + err: &ErrorResponse{ + StatusCode: 404, + Message: "Resource not found", + }, + expected: "http 404: Resource not found", + }, + { + name: "With ErrorMsg field", + err: &ErrorResponse{ + StatusCode: 500, + ErrorMsg: "Internal server error", + }, + expected: "http 500: Internal server error", + }, + { + name: "Without message", + err: &ErrorResponse{ + StatusCode: 400, + }, + expected: "http 400: request failed", + }, + { + name: "Message takes precedence over ErrorMsg", + err: &ErrorResponse{ + StatusCode: 403, + Message: "Forbidden", + ErrorMsg: "Access denied", + }, + expected: "http 403: Forbidden", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := tt.err.Error() + if result != tt.expected { + t.Errorf("Expected %q, got %q", tt.expected, result) + } + }) + } +} + +// TestGenericClient_MultipleTypes tests using multiple typed clients +func TestGenericClient_MultipleTypes(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if strings.Contains(r.URL.Path, "/users") { + if err := json.NewEncoder(w).Encode(User{ID: 1, Name: "John"}); err != nil { + t.Errorf("Failed to encode response: %v", err) + } + } else if strings.Contains(r.URL.Path, "/posts") { + if err := json.NewEncoder(w).Encode(Post{ID: 100, Title: "Test Post"}); err != nil { + t.Errorf("Failed to encode response: %v", err) + } + } + })) + defer server.Close() + + // Create two clients with different types + userClient := NewGenericClient[User]() + postClient := NewGenericClient[Post]() + + // Test user client + userResp, err := userClient.Get(server.URL + "/users/1") + if err != nil { + t.Fatalf("userClient.Get failed: %v", err) + } + + if userResp.Data.Name != "John" { + t.Errorf("Expected user name John, got %s", userResp.Data.Name) + } + + // Test post client + postResp, err := postClient.Get(server.URL + "/posts/100") + if err != nil { + t.Fatalf("postClient.Get failed: %v", err) + } + + if postResp.Data.Title != "Test Post" { + t.Errorf("Expected post title 'Test Post', got %s", postResp.Data.Title) + } +} + +// TestGenericClient_ContextPropagation tests context propagation +func TestGenericClient_ContextPropagation(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // Simulate slow response + time.Sleep(100 * time.Millisecond) + if err := json.NewEncoder(w).Encode(User{ID: 1}); err != nil { + t.Errorf("Failed to encode response: %v", err) + } + })) + defer server.Close() + + client := NewGenericClient[User]() + + // Create request with cancelled context + ctx, cancel := context.WithCancel(context.Background()) + cancel() // Cancel immediately + + req, _ := http.NewRequestWithContext(ctx, http.MethodGet, server.URL, nil) + + _, err := client.Execute(req) + + if err == nil { + t.Fatal("Expected error with cancelled context") + } + + if !strings.Contains(err.Error(), "context") { + t.Errorf("Expected context error, got: %v", err) + } +} + +// TestGenericClient_AllConfigurationOptions tests all the new configuration options +func TestGenericClient_AllConfigurationOptions(t *testing.T) { + t.Run("With all configuration options", func(t *testing.T) { + timeout := 15 * time.Second + maxRetries := 5 + baseDelay := 1 * time.Second + maxDelay := 10 * time.Second + + client := NewGenericClient[User]( + WithTimeout[User](timeout), + WithMaxRetries[User](maxRetries), + WithRetryBaseDelay[User](baseDelay), + WithRetryMaxDelay[User](maxDelay), + WithRetryStrategy[User](JitterBackoffStrategy), + WithMaxIdleConns[User](50), + WithIdleConnTimeout[User](60*time.Second), + WithTLSHandshakeTimeout[User](5*time.Second), + WithExpectContinueTimeout[User](2*time.Second), + WithMaxIdleConnsPerHost[User](25), + WithDisableKeepAlive[User](false), + ) + + if client == nil { + t.Fatal("NewGenericClient returned nil") + } + + if client.httpClient == nil { + t.Fatal("httpClient should be initialized") + } + + // Verify the client was built with an HTTP client + if httpClient, ok := client.httpClient.(*http.Client); ok { + if httpClient.Timeout != timeout { + t.Errorf("Expected timeout %v, got %v", timeout, httpClient.Timeout) + } + + // Verify the transport is configured + if httpClient.Transport == nil { + t.Error("Transport should be configured") + } + } else { + t.Error("Expected *http.Client as underlying client") + } + }) + + t.Run("WithRetryStrategyAsString", func(t *testing.T) { + client := NewGenericClient[User]( + WithRetryStrategyAsString[User]("fixed"), + WithMaxRetries[User](3), + ) + + if client == nil { + t.Fatal("NewGenericClient returned nil") + } + + if client.httpClient == nil { + t.Fatal("httpClient should be initialized") + } + }) + + t.Run("WithHTTPClient takes precedence", func(t *testing.T) { + customClient := &http.Client{ + Timeout: 99 * time.Second, + } + + client := NewGenericClient[User]( + WithTimeout[User](15*time.Second), // Should be ignored + WithMaxRetries[User](5), // Should be ignored + WithHTTPClient[User](customClient), // Should take precedence + ) + + if client == nil { + t.Fatal("NewGenericClient returned nil") + } + + if client.httpClient != customClient { + t.Error("Expected custom HTTP client to be used") + } + + if httpClient, ok := client.httpClient.(*http.Client); ok { + if httpClient.Timeout != 99*time.Second { + t.Errorf("Expected timeout 99s, got %v", httpClient.Timeout) + } + } + }) + + t.Run("Default client with no options", func(t *testing.T) { + client := NewGenericClient[User]() + + if client == nil { + t.Fatal("NewGenericClient returned nil") + } + + if client.httpClient == nil { + t.Fatal("httpClient should be initialized") + } + + // Should have default timeout from ClientBuilder + if httpClient, ok := client.httpClient.(*http.Client); ok { + if httpClient.Timeout != DefaultTimeout { + t.Errorf("Expected default timeout %v, got %v", DefaultTimeout, httpClient.Timeout) + } + } + }) + + t.Run("Invalid values should use ClientBuilder defaults", func(t *testing.T) { + // Use out-of-range values + client := NewGenericClient[User]( + WithTimeout[User](0), // Invalid, should use default + WithMaxRetries[User](999), // Invalid, should use default + WithMaxIdleConns[User](9999), // Invalid, should use default + ) + + if client == nil { + t.Fatal("NewGenericClient returned nil") + } + + if client.httpClient == nil { + t.Fatal("httpClient should be initialized") + } + + // ClientBuilder should have applied defaults + if httpClient, ok := client.httpClient.(*http.Client); ok { + // Should have been corrected to default + if httpClient.Timeout != DefaultTimeout { + t.Errorf("Expected default timeout %v, got %v", DefaultTimeout, httpClient.Timeout) + } + } + }) + + t.Run("Mix of options and WithHTTPClient", func(t *testing.T) { + // Build a client using ClientBuilder + httpClient := NewClientBuilder(). + WithMaxRetries(3). + WithRetryStrategy(ExponentialBackoffStrategy). + WithTimeout(10 * time.Second). + Build() + + // Use it with GenericClient + client := NewGenericClient[User]( + WithHTTPClient[User](httpClient), + ) + + if client == nil { + t.Fatal("NewGenericClient returned nil") + } + + if client.httpClient != httpClient { + t.Error("Expected the built HTTP client to be used") + } + }) +} + +// TestGenericClient_OptionsIntegration tests that options actually affect behavior +func TestGenericClient_OptionsIntegration(t *testing.T) { + t.Run("Configured client is usable", func(t *testing.T) { + client := NewGenericClient[User]( + WithTimeout[User](5*time.Second), + WithMaxRetries[User](2), + WithRetryStrategy[User](FixedDelayStrategy), + ) + + if client == nil { + t.Fatal("NewGenericClient returned nil") + } + + // The client should be functional (we won't make actual requests, + // but we can verify it's properly constructed) + if client.httpClient == nil { + t.Fatal("httpClient should be initialized") + } + + // Verify we can call methods without panicking + if httpClient, ok := client.httpClient.(*http.Client); ok { + if httpClient.Transport == nil { + t.Error("Transport should be configured") + } + } + }) +} diff --git a/http_request_builder.go b/http_request_builder.go new file mode 100644 index 0000000..2596001 --- /dev/null +++ b/http_request_builder.go @@ -0,0 +1,475 @@ +package httpx + +import ( + "bytes" + "context" + "encoding/base64" + "encoding/json" + "fmt" + "io" + "maps" + "net/http" + "net/url" + "slices" + "strings" +) + +// RequestBuilder provides a fluent API for building HTTP requests with and without body. +type RequestBuilder struct { + method string + baseURL string + path string + queryParams url.Values + headers map[string]string + body any + bodyReader io.Reader + ctx context.Context + errors []error +} + +// NewRequestBuilder creates a new RequestBuilder with the specified base URL. +func NewRequestBuilder(baseURL string) *RequestBuilder { + return &RequestBuilder{ + baseURL: baseURL, + queryParams: make(url.Values), + headers: make(map[string]string), + ctx: context.Background(), + errors: make([]error, 0), + } +} + +// WithMethod sets the HTTP method to the specified method. +// The method is normalized to uppercase and validated against standard HTTP methods. +func (rb *RequestBuilder) WithMethod(method string) *RequestBuilder { + if method == "" { + rb.addError(fmt.Errorf("http method cannot be empty")) + + return rb + } + + method = strings.ToUpper(strings.TrimSpace(method)) + if !isValidHTTPMethod(method) { + rb.addError(fmt.Errorf("invalid http method: %s", method)) + + return rb + } + + rb.method = method + + return rb +} + +// WithMethodGET sets the HTTP method to GET. +func (rb *RequestBuilder) WithMethodGET() *RequestBuilder { + rb.method = http.MethodGet + + return rb +} + +// WithMethodPOST sets the HTTP method to POST. +func (rb *RequestBuilder) WithMethodPOST() *RequestBuilder { + rb.method = http.MethodPost + + return rb +} + +// WithMethodPUT sets the HTTP method to PUT. +func (rb *RequestBuilder) WithMethodPUT() *RequestBuilder { + rb.method = http.MethodPut + + return rb +} + +// WithMethodDELETE sets the HTTP method to DELETE. +func (rb *RequestBuilder) WithMethodDELETE() *RequestBuilder { + rb.method = http.MethodDelete + + return rb +} + +// WithMethodPATCH sets the HTTP method to PATCH. +func (rb *RequestBuilder) WithMethodPATCH() *RequestBuilder { + rb.method = http.MethodPatch + + return rb +} + +// WithMethodHEAD sets the HTTP method to HEAD. +func (rb *RequestBuilder) WithMethodHEAD() *RequestBuilder { + rb.method = http.MethodHead + + return rb +} + +// WithMethodOPTIONS sets the HTTP method to OPTIONS. +func (rb *RequestBuilder) WithMethodOPTIONS() *RequestBuilder { + rb.method = http.MethodOptions + + return rb +} + +// WithMethodTRACE sets the HTTP method to TRACE. +func (rb *RequestBuilder) WithMethodTRACE() *RequestBuilder { + rb.method = http.MethodTrace + + return rb +} + +// WithMethodCONNECT sets the HTTP method to CONNECT. +func (rb *RequestBuilder) WithMethodCONNECT() *RequestBuilder { + rb.method = http.MethodConnect + + return rb +} + +// WithPath sets the path component of the URL. +func (rb *RequestBuilder) WithPath(path string) *RequestBuilder { + rb.path = path + + return rb +} + +// WithQueryParam adds a single query parameter. +func (rb *RequestBuilder) WithQueryParam(key, value string) *RequestBuilder { + if key == "" { + rb.addError(fmt.Errorf("query parameter key cannot be empty")) + + return rb + } + + if value == "" { + rb.addError(fmt.Errorf("query parameter value for key '%s' cannot be empty", key)) + + return rb + } + + // Validate query key format + if strings.ContainsAny(key, " \t\n\r=&") { + rb.addError(fmt.Errorf("invalid query parameter key format: '%s' (contains invalid characters)", key)) + + return rb + } + + rb.queryParams.Add(key, value) + + return rb +} + +// WithQueryParams adds multiple query parameters from a map. +func (rb *RequestBuilder) WithQueryParams(params map[string]string) *RequestBuilder { + for key, value := range params { + rb.queryParams.Add(key, value) + } + + return rb +} + +// WithHeader sets a single header. +func (rb *RequestBuilder) WithHeader(key, value string) *RequestBuilder { + if key == "" { + rb.addError(fmt.Errorf("header key cannot be empty")) + + return rb + } + + if value == "" { + rb.addError(fmt.Errorf("header value for key '%s' cannot be empty", key)) + + return rb + } + + // Validate header key format + if strings.ContainsAny(key, " \t\n\r") { + rb.addError(fmt.Errorf("invalid header key format: '%s' (contains whitespace)", key)) + + return rb + } + + rb.headers[key] = value + + return rb +} + +// WithHeaders sets multiple headers from a map. +func (rb *RequestBuilder) WithHeaders(headers map[string]string) *RequestBuilder { + maps.Copy(rb.headers, headers) + + return rb +} + +// WithBasicAuth sets the Authorization header for basic authentication. +func (rb *RequestBuilder) WithBasicAuth(username, password string) *RequestBuilder { + if username == "" { + rb.addError(fmt.Errorf("username for basic auth cannot be empty")) + + return rb + } + + if password == "" { + rb.addError(fmt.Errorf("password for basic auth cannot be empty")) + + return rb + } + + rb.headers["Authorization"] = "Basic " + basicAuth(username, password) + + return rb +} + +// WithBearerAuth sets the Authorization header for bearer token authentication. +func (rb *RequestBuilder) WithBearerAuth(token string) *RequestBuilder { + if token == "" { + rb.addError(fmt.Errorf("bearer token cannot be empty")) + + return rb + } + + rb.headers["Authorization"] = "Bearer " + token + + return rb +} + +// WithUserAgent sets the User-Agent header. +// The user agent is trimmed and validated to ensure it: +// - is non-empty after trimming +// - does not exceed 500 characters +// - does not contain control characters (\r, \n, \t) +func (rb *RequestBuilder) WithUserAgent(userAgent string) *RequestBuilder { + if userAgent == "" { + rb.addError(fmt.Errorf("user-agent cannot be empty")) + + return rb + } + + // Trim whitespace + userAgent = strings.TrimSpace(userAgent) + if userAgent == "" { + rb.addError(fmt.Errorf("user-agent cannot be empty after trimming whitespace")) + + return rb + } + + // Validate length + if len(userAgent) > 500 { + rb.addError(fmt.Errorf("user-agent is too long (max 500 characters), got %d characters", len(userAgent))) + + return rb + } + + // Validate that User-Agent doesn't contain control characters + if strings.ContainsAny(userAgent, "\r\n\t") { + rb.addError(fmt.Errorf("user-agent cannot contain control characters (\\r, \\n, \\t)")) + + return rb + } + + rb.headers["User-Agent"] = userAgent + + return rb +} + +// WithContentType sets the Content-Type header. +func (rb *RequestBuilder) WithContentType(contentType string) *RequestBuilder { + return rb.WithHeader("Content-Type", contentType) +} + +// WithAccept sets the Accept header. +func (rb *RequestBuilder) WithAccept(accept string) *RequestBuilder { + return rb.WithHeader("Accept", accept) +} + +// WithJSONBody sets the request body as JSON and sets the appropriate Content-Type header. +func (rb *RequestBuilder) WithJSONBody(body any) *RequestBuilder { + rb.body = body + rb.bodyReader = nil + rb.WithContentType("application/json") + + return rb +} + +// WithRawBody sets the request body from an io.Reader. +func (rb *RequestBuilder) WithRawBody(body io.Reader) *RequestBuilder { + rb.bodyReader = body + rb.body = nil + + return rb +} + +// WithStringBody sets the request body from a string. +func (rb *RequestBuilder) WithStringBody(body string) *RequestBuilder { + rb.bodyReader = strings.NewReader(body) + rb.body = nil + + return rb +} + +// WithBytesBody sets the request body from a byte slice. +func (rb *RequestBuilder) WithBytesBody(body []byte) *RequestBuilder { + rb.bodyReader = bytes.NewReader(body) + rb.body = nil + + return rb +} + +// WithContext sets the context for the request. +func (rb *RequestBuilder) WithContext(ctx context.Context) *RequestBuilder { + if ctx == nil { + rb.addError(fmt.Errorf("context cannot be nil")) + return rb + } + + rb.ctx = ctx + + return rb +} + +// Build creates an *http.Request from the builder configuration. +// Returns an error if any validation fails. +func (rb *RequestBuilder) Build() (*http.Request, error) { + // Check for any errors accumulated during building + if len(rb.errors) > 0 { + return nil, fmt.Errorf("request builder errors: %v", rb.errors) + } + + // Validate method + if rb.method == "" { + return nil, fmt.Errorf("HTTP method must be specified") + } + + // Build URL + u, err := url.Parse(rb.baseURL) + if err != nil { + return nil, fmt.Errorf("invalid base URL: %w", err) + } + + // Validate URL has scheme and host + if u.Scheme == "" { + return nil, fmt.Errorf("base URL must include a scheme (http or https)") + } + + if u.Host == "" { + return nil, fmt.Errorf("base URL must include a host") + } + + // Validate scheme + if u.Scheme != "http" && u.Scheme != "https" { + return nil, fmt.Errorf("unsupported url scheme: %s (only http and https are supported)", u.Scheme) + } + + // Add path + if rb.path != "" { + u.Path = strings.TrimSuffix(u.Path, "/") + "/" + strings.TrimPrefix(rb.path, "/") + } + + // Add query parameters + if len(rb.queryParams) > 0 { + q := u.Query() + + for key, values := range rb.queryParams { + for _, value := range values { + q.Add(key, value) + } + } + + u.RawQuery = q.Encode() + } + + // Prepare body + var bodyReader io.Reader + if rb.body != nil { + jsonData, err := json.Marshal(rb.body) + if err != nil { + return nil, fmt.Errorf("failed to marshal JSON body: %w", err) + } + + bodyReader = bytes.NewReader(jsonData) + } else if rb.bodyReader != nil { + bodyReader = rb.bodyReader + } + + // Create request + req, err := http.NewRequestWithContext(rb.ctx, rb.method, u.String(), bodyReader) + if err != nil { + return nil, fmt.Errorf("failed to create HTTP request: %w", err) + } + + // Set headers + for key, value := range rb.headers { + req.Header.Set(key, value) + } + + // Set GetBody for retry support if we have a body + if bodyReader != nil && rb.body != nil { + // For JSON bodies, we can recreate the body + req.GetBody = func() (io.ReadCloser, error) { + jsonData, err := json.Marshal(rb.body) + if err != nil { + return nil, err + } + + return io.NopCloser(bytes.NewReader(jsonData)), nil + } + } + + return req, nil +} + +// basicAuth encodes username and password for basic authentication. +func basicAuth(username, password string) string { + auth := username + ":" + password + + return base64Encode([]byte(auth)) +} + +// base64Encode encodes bytes to base64 string. +func base64Encode(data []byte) string { + return base64.StdEncoding.EncodeToString(data) +} + +// addError adds an error to the error collection. +func (rb *RequestBuilder) addError(err error) { + if err != nil { + rb.errors = append(rb.errors, err) + } +} + +// GetErrors returns all accumulated errors during the building process. +func (rb *RequestBuilder) GetErrors() []error { + return rb.errors +} + +// HasErrors returns true if there are any accumulated errors. +func (rb *RequestBuilder) HasErrors() bool { + return len(rb.errors) > 0 +} + +// Reset clears all errors and resets the builder to a clean state. +func (rb *RequestBuilder) Reset() *RequestBuilder { + rb.errors = make([]error, 0) + rb.method = "" + rb.path = "" + rb.queryParams = make(url.Values) + rb.headers = make(map[string]string) + rb.body = nil + rb.bodyReader = nil + rb.ctx = context.Background() + + return rb +} + +// isValidHTTPMethod checks if the provided method is a valid HTTP method. +func isValidHTTPMethod(method string) bool { + validMethods := []string{ + http.MethodGet, + http.MethodPost, + http.MethodPut, + http.MethodDelete, + http.MethodPatch, + http.MethodHead, + http.MethodOptions, + http.MethodTrace, + http.MethodConnect, + } + + return slices.Contains(validMethods, method) +} diff --git a/http_request_builder_test.go b/http_request_builder_test.go new file mode 100644 index 0000000..6cd5d73 --- /dev/null +++ b/http_request_builder_test.go @@ -0,0 +1,1120 @@ +package httpx + +import ( + "bytes" + "context" + "encoding/base64" + "encoding/json" + "io" + "net/http" + "strings" + "testing" +) + +// Test data structures +type TestData struct { + Name string `json:"name"` + Value int `json:"value"` +} + +func TestNewRequestBuilder(t *testing.T) { + tests := []struct { + name string + baseURL string + wantURL string + }{ + { + name: "simple URL", + baseURL: "https://api.example.com", + wantURL: "https://api.example.com", + }, + { + name: "URL with path", + baseURL: "https://api.example.com/v1", + wantURL: "https://api.example.com/v1", + }, + { + name: "URL with trailing slash", + baseURL: "https://api.example.com/", + wantURL: "https://api.example.com/", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + rb := NewRequestBuilder(tt.baseURL) + + if rb == nil { + t.Fatal("NewRequestBuilder returned nil") + } + + if rb.baseURL != tt.wantURL { + t.Errorf("NewRequestBuilder() baseURL = %v, want %v", rb.baseURL, tt.wantURL) + } + + if rb.queryParams == nil { + t.Error("NewRequestBuilder() queryParams not initialized") + } + + if rb.headers == nil { + t.Error("NewRequestBuilder() headers not initialized") + } + + if rb.ctx == nil { + t.Error("NewRequestBuilder() context not initialized") + } + }) + } +} + +func TestRequestBuilder_HTTPMethods(t *testing.T) { + rb := NewRequestBuilder("https://api.example.com") + + tests := []struct { + name string + function func() *RequestBuilder + expected string + }{ + {"GET", rb.WithMethodGET, http.MethodGet}, + {"POST", rb.WithMethodPOST, http.MethodPost}, + {"PUT", rb.WithMethodPUT, http.MethodPut}, + {"DELETE", rb.WithMethodDELETE, http.MethodDelete}, + {"PATCH", rb.WithMethodPATCH, http.MethodPatch}, + {"HEAD", rb.WithMethodHEAD, http.MethodHead}, + {"OPTIONS", rb.WithMethodOPTIONS, http.MethodOptions}, + {"TRACE", rb.WithMethodTRACE, http.MethodTrace}, + {"CONNECT", rb.WithMethodCONNECT, http.MethodConnect}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := tt.function() + if result.method != tt.expected { + t.Errorf("%s() method = %v, want %v", tt.name, result.method, tt.expected) + } + // Ensure fluent interface returns same instance + if result != rb { + t.Errorf("%s() returned different instance", tt.name) + } + }) + } +} + +func TestRequestBuilder_HTTPMethod(t *testing.T) { + tests := []struct { + name string + method string + expectError bool + }{ + {"Valid GET", "GET", false}, + {"Valid POST", "POST", false}, + {"Valid lowercase get", "get", false}, + {"Valid with spaces", " PUT ", false}, + {"Empty method", "", true}, + {"Invalid method", "INVALID", true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + rb := NewRequestBuilder("https://api.example.com") + rb.WithMethod(tt.method) + + if tt.expectError { + if !rb.HasErrors() { + t.Errorf("Expected error for method %s, but got none", tt.method) + } + } else { + if rb.HasErrors() { + t.Errorf("Unexpected error for method %s: %v", tt.method, rb.GetErrors()) + } + expectedMethod := strings.ToUpper(strings.TrimSpace(tt.method)) + if rb.method != expectedMethod { + t.Errorf("Expected method %s, got %s", expectedMethod, rb.method) + } + } + }) + } +} + +func TestRequestBuilder_HTTPMethodCustom(t *testing.T) { + rb := NewRequestBuilder("https://api.example.com") + + customMethod := "CUSTOM" + result := rb.WithMethod(customMethod) + + // Custom methods are now validated and should cause an error + if !result.HasErrors() { + t.Error("HTTPMethod() should reject invalid custom method") + } + + if result != rb { + t.Error("HTTPMethod() returned different instance") + } +} + +func TestRequestBuilder_WithPath(t *testing.T) { + rb := NewRequestBuilder("https://api.example.com") + + path := "/users/123" + result := rb.WithPath(path) + + if result.path != path { + t.Errorf("WithPath() path = %v, want %v", result.path, path) + } + + if result != rb { + t.Error("WithPath() returned different instance") + } +} + +func TestRequestBuilder_WithQueryParam(t *testing.T) { + rb := NewRequestBuilder("https://api.example.com") + + result := rb.WithQueryParam("key1", "value1").WithQueryParam("key2", "value2") + + if len(result.queryParams) != 2 { + t.Errorf("WithQueryParam() queryParams length = %v, want %v", len(result.queryParams), 2) + } + + if result.queryParams.Get("key1") != "value1" { + t.Errorf("WithQueryParam() key1 = %v, want %v", result.queryParams.Get("key1"), "value1") + } + + if result.queryParams.Get("key2") != "value2" { + t.Errorf("WithQueryParam() key2 = %v, want %v", result.queryParams.Get("key2"), "value2") + } + + if result != rb { + t.Error("WithQueryParam() returned different instance") + } +} + +func TestRequestBuilder_QueryParam_SpecialCharacters(t *testing.T) { + tests := []struct { + name string + key string + value string + expectedInURL string + description string + }{ + { + name: "spaces in value", + key: "query", + value: "hello world", + expectedInURL: "query=hello+world", + description: "spaces should be encoded as +", + }, + { + name: "equals sign", + key: "filter", + value: "a=b", + expectedInURL: "filter=a%3Db", + description: "= should be encoded as %3D", + }, + { + name: "ampersand", + key: "data", + value: "a&b", + expectedInURL: "data=a%26b", + description: "&& should be encoded as %26", + }, + { + name: "question mark", + key: "test", + value: "what?", + expectedInURL: "test=what%3F", + description: "? should be encoded as %3F", + }, + { + name: "double quotes", + key: "quote", + value: `"test"`, + expectedInURL: "quote=%22test%22", + description: `" should be encoded as %22`, + }, + { + name: "single quotes", + key: "quote", + value: "'test'", + expectedInURL: "quote=%27test%27", + description: "' should be encoded as %27", + }, + { + name: "plus sign", + key: "math", + value: "1+1", + expectedInURL: "math=1%2B1", + description: "+ should be encoded as %2B", + }, + { + name: "JQL-like query", + key: "jql", + value: `project = "TEST" AND type = Requirement`, + expectedInURL: "jql=project+%3D+%22TEST%22+AND+type+%3D+Requirement", + description: "complex JQL query should be properly encoded", + }, + { + name: "parentheses", + key: "expr", + value: "(a,b,c)", + expectedInURL: "expr=%28a%2Cb%2Cc%29", + description: "parentheses and commas should be encoded", + }, + { + name: "multiple special characters", + key: "complex", + value: `a=b&c=d?e="f"`, + expectedInURL: `complex=a%3Db%26c%3Dd%3Fe%3D%22f%22`, + description: "multiple special characters should all be encoded", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + rb := NewRequestBuilder("https://api.example.com"). + WithMethodGET(). + WithQueryParam(tt.key, tt.value) + + req, err := rb.Build() + if err != nil { + t.Fatalf("Build() failed: %v", err) + } + + actualQuery := req.URL.RawQuery + if actualQuery != tt.expectedInURL { + t.Errorf("%s\nGot: %s\nExpected: %s", tt.description, actualQuery, tt.expectedInURL) + } + }) + } +} + +func TestRequestBuilder_QueryParams(t *testing.T) { + rb := NewRequestBuilder("https://api.example.com") + + params := map[string]string{ + "param1": "value1", + "param2": "value2", + "param3": "value3", + } + + result := rb.WithQueryParams(params) + + if len(result.queryParams) != 3 { + t.Errorf("QueryParams() queryParams length = %v, want %v", len(result.queryParams), 3) + } + + for key, expectedValue := range params { + if result.queryParams.Get(key) != expectedValue { + t.Errorf("QueryParams() %s = %v, want %v", key, result.queryParams.Get(key), expectedValue) + } + } + + if result != rb { + t.Error("QueryParams() returned different instance") + } +} + +func TestRequestBuilder_WithHeader(t *testing.T) { + rb := NewRequestBuilder("https://api.example.com") + + result := rb.WithHeader("Content-Type", "application/json").WithHeader("Accept", "application/json") + + if len(result.headers) != 2 { + t.Errorf("WithHeader() headers length = %v, want %v", len(result.headers), 2) + } + + if result.headers["Content-Type"] != "application/json" { + t.Errorf("WithHeader() Content-Type = %v, want %v", result.headers["Content-Type"], "application/json") + } + + if result.headers["Accept"] != "application/json" { + t.Errorf("WithHeader() Accept = %v, want %v", result.headers["Accept"], "application/json") + } + + if result != rb { + t.Error("WithHeader() returned different instance") + } +} + +func TestRequestBuilder_Headers(t *testing.T) { + rb := NewRequestBuilder("https://api.example.com") + + headers := map[string]string{ + "Content-Type": "application/json", + "Accept": "application/json", + "User-Agent": "test-client/1.0", + } + + result := rb.WithHeaders(headers) + + if len(result.headers) != 3 { + t.Errorf("Headers() headers length = %v, want %v", len(result.headers), 3) + } + + for key, expectedValue := range headers { + if result.headers[key] != expectedValue { + t.Errorf("Headers() %s = %v, want %v", key, result.headers[key], expectedValue) + } + } + + if result != rb { + t.Error("Headers() returned different instance") + } +} + +func TestRequestBuilder_WithBasicAuth(t *testing.T) { + rb := NewRequestBuilder("https://api.example.com") + + username := "user" + password := "pass" + result := rb.WithBasicAuth(username, password) + + expectedAuth := "Basic " + base64.StdEncoding.EncodeToString([]byte(username+":"+password)) + + if result.headers["Authorization"] != expectedAuth { + t.Errorf("WithBasicAuth() Authorization = %v, want %v", result.headers["Authorization"], expectedAuth) + } + + if result != rb { + t.Error("WithBasicAuth() returned different instance") + } +} + +func TestRequestBuilder_WithBearerAuth(t *testing.T) { + rb := NewRequestBuilder("https://api.example.com") + + token := "abc123token" + result := rb.WithBearerAuth(token) + + expectedAuth := "Bearer " + token + + if result.headers["Authorization"] != expectedAuth { + t.Errorf("WithBearerAuth() Authorization = %v, want %v", result.headers["Authorization"], expectedAuth) + } + + if result != rb { + t.Error("WithBearerAuth() returned different instance") + } +} + +func TestRequestBuilder_WithUserAgent(t *testing.T) { + rb := NewRequestBuilder("https://api.example.com") + + userAgent := "test-client/1.0" + result := rb.WithUserAgent(userAgent) + + if result.headers["User-Agent"] != userAgent { + t.Errorf("WithUserAgent() User-Agent = %v, want %v", result.headers["User-Agent"], userAgent) + } + + if result != rb { + t.Error("WithUserAgent() returned different instance") + } +} + +func TestRequestBuilder_WithContentType(t *testing.T) { + rb := NewRequestBuilder("https://api.example.com") + + contentType := "application/json" + result := rb.WithContentType(contentType) + + if result.headers["Content-Type"] != contentType { + t.Errorf("WithContentType() Content-Type = %v, want %v", result.headers["Content-Type"], contentType) + } + + if result != rb { + t.Error("WithContentType() returned different instance") + } +} + +func TestRequestBuilder_Accept(t *testing.T) { + tests := []struct { + name string + accept string + }{ + { + name: "application/json", + accept: "application/json", + }, + { + name: "application/xml", + accept: "application/xml", + }, + { + name: "text/html", + accept: "text/html", + }, + { + name: "multiple types with quality", + accept: "application/json, text/html;q=0.9, */*;q=0.8", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + rb := NewRequestBuilder("https://api.example.com") + result := rb.WithAccept(tt.accept) + + if result.headers["Accept"] != tt.accept { + t.Errorf("Accept() Accept = %v, want %v", result.headers["Accept"], tt.accept) + } + + if result != rb { + t.Error("Accept() returned different instance") + } + + // Build and verify the request header is set + req, err := result.WithMethodGET().Build() + if err != nil { + t.Fatalf("Build() failed: %v", err) + } + + if req.Header.Get("Accept") != tt.accept { + t.Errorf("Built request Accept header = %v, want %v", req.Header.Get("Accept"), tt.accept) + } + }) + } +} + +func TestRequestBuilder_WithJSONBody(t *testing.T) { + rb := NewRequestBuilder("https://api.example.com") + + testData := TestData{Name: "test", Value: 42} + result := rb.WithJSONBody(testData) + + if result.body != testData { + t.Errorf("WithJSONBody() body = %v, want %v", result.body, testData) + } + + if result.bodyReader != nil { + t.Error("WithJSONBody() bodyReader should be nil when body is set") + } + + if result.headers["Content-Type"] != "application/json" { + t.Errorf("WithJSONBody() Content-Type = %v, want %v", result.headers["Content-Type"], "application/json") + } + + if result != rb { + t.Error("WithJSONBody() returned different instance") + } +} + +func TestRequestBuilder_RawBody(t *testing.T) { + rb := NewRequestBuilder("https://api.example.com") + + body := strings.NewReader("raw body content") + result := rb.WithRawBody(body) + + if result.bodyReader != body { + t.Errorf("RawBody() bodyReader = %v, want %v", result.bodyReader, body) + } + + if result.body != nil { + t.Error("RawBody() body should be nil when bodyReader is set") + } + + if result != rb { + t.Error("RawBody() returned different instance") + } +} + +func TestRequestBuilder_WithStringBody(t *testing.T) { + rb := NewRequestBuilder("https://api.example.com") + + bodyContent := "string body content" + result := rb.WithStringBody(bodyContent) + + if result.bodyReader == nil { + t.Error("WithStringBody() bodyReader should not be nil") + } + + if result.body != nil { + t.Error("WithStringBody() body should be nil when bodyReader is set") + } + + // Read the content to verify + if result.bodyReader != nil { + content, err := io.ReadAll(result.bodyReader) + if err != nil { + t.Fatalf("Failed to read bodyReader: %v", err) + } + if string(content) != bodyContent { + t.Errorf("WithStringBody() content = %v, want %v", string(content), bodyContent) + } + } + + if result != rb { + t.Error("WithStringBody() returned different instance") + } +} + +func TestRequestBuilder_BytesBody(t *testing.T) { + rb := NewRequestBuilder("https://api.example.com") + + bodyContent := []byte("bytes body content") + result := rb.WithBytesBody(bodyContent) + + if result.bodyReader == nil { + t.Error("BytesBody() bodyReader should not be nil") + } + + if result.body != nil { + t.Error("BytesBody() body should be nil when bodyReader is set") + } + + // Read the content to verify + if result.bodyReader != nil { + content, err := io.ReadAll(result.bodyReader) + if err != nil { + t.Fatalf("Failed to read bodyReader: %v", err) + } + if !bytes.Equal(content, bodyContent) { + t.Errorf("BytesBody() content = %v, want %v", content, bodyContent) + } + } + + if result != rb { + t.Error("BytesBody() returned different instance") + } +} + +type contextKey string + +func TestRequestBuilder_Context(t *testing.T) { + rb := NewRequestBuilder("https://api.example.com") + + ctx := context.WithValue(context.Background(), contextKey("testkey"), "value") + result := rb.WithContext(ctx) + + if result.ctx != ctx { + t.Errorf("Context() ctx = %v, want %v", result.ctx, ctx) + } + + if result != rb { + t.Error("Context() returned different instance") + } +} + +func TestRequestBuilder_Build_Success(t *testing.T) { + tests := []struct { + name string + setup func() *RequestBuilder + validate func(t *testing.T, req *http.Request) + }{ + { + name: "simple GET request", + setup: func() *RequestBuilder { + return NewRequestBuilder("https://api.example.com").WithMethodGET() + }, + validate: func(t *testing.T, req *http.Request) { + if req.Method != http.MethodGet { + t.Errorf("Expected method GET, got %s", req.Method) + } + if req.URL.String() != "https://api.example.com" { + t.Errorf("Expected URL https://api.example.com, got %s", req.URL.String()) + } + }, + }, + { + name: "POST with JSON body", + setup: func() *RequestBuilder { + testData := TestData{Name: "test", Value: 42} + return NewRequestBuilder("https://api.example.com"). + WithMethodPOST(). + WithPath("/users"). + WithJSONBody(testData) + }, + validate: func(t *testing.T, req *http.Request) { + if req.Method != http.MethodPost { + t.Errorf("Expected method POST, got %s", req.Method) + } + if req.URL.String() != "https://api.example.com/users" { + t.Errorf("Expected URL https://api.example.com/users, got %s", req.URL.String()) + } + if req.Header.Get("Content-Type") != "application/json" { + t.Errorf("Expected Content-Type application/json, got %s", req.Header.Get("Content-Type")) + } + + // Verify body content + if req.Body != nil { + bodyBytes, err := io.ReadAll(req.Body) + if err != nil { + t.Fatalf("Failed to read request body: %v", err) + } + + var testData TestData + if err := json.Unmarshal(bodyBytes, &testData); err != nil { + t.Fatalf("Failed to unmarshal JSON body: %v", err) + } + + if testData.Name != "test" || testData.Value != 42 { + t.Errorf("Unexpected body content: %+v", testData) + } + } + }, + }, + { + name: "GET with query parameters and headers", + setup: func() *RequestBuilder { + return NewRequestBuilder("https://api.example.com"). + WithMethodGET(). + WithPath("/search"). + WithQueryParam("q", "golang"). + WithQueryParam("limit", "10"). + WithHeader("Accept", "application/json"). + WithUserAgent("test-client/1.0") + }, + validate: func(t *testing.T, req *http.Request) { + expected := "https://api.example.com/search?limit=10&q=golang" + if req.URL.String() != expected { + t.Errorf("Expected URL %s, got %s", expected, req.URL.String()) + } + if req.Header.Get("Accept") != "application/json" { + t.Errorf("Expected Accept header application/json, got %s", req.Header.Get("Accept")) + } + if req.Header.Get("User-Agent") != "test-client/1.0" { + t.Errorf("Expected User-Agent test-client/1.0, got %s", req.Header.Get("User-Agent")) + } + }, + }, + { + name: "PUT with string body", + setup: func() *RequestBuilder { + return NewRequestBuilder("https://api.example.com"). + WithMethodPUT(). + WithPath("/data"). + WithStringBody("plain text content"). + WithContentType("text/plain") + }, + validate: func(t *testing.T, req *http.Request) { + if req.Method != http.MethodPut { + t.Errorf("Expected method PUT, got %s", req.Method) + } + if req.Header.Get("Content-Type") != "text/plain" { + t.Errorf("Expected Content-Type text/plain, got %s", req.Header.Get("Content-Type")) + } + }, + }, + { + name: "request with basic auth", + setup: func() *RequestBuilder { + return NewRequestBuilder("https://api.example.com"). + WithMethodGET(). + WithBasicAuth("user", "pass") + }, + validate: func(t *testing.T, req *http.Request) { + expected := "Basic " + base64.StdEncoding.EncodeToString([]byte("user:pass")) + if req.Header.Get("Authorization") != expected { + t.Errorf("Expected Authorization %s, got %s", expected, req.Header.Get("Authorization")) + } + }, + }, + { + name: "request with bearer auth", + setup: func() *RequestBuilder { + return NewRequestBuilder("https://api.example.com"). + WithMethodGET(). + WithBearerAuth("token123") + }, + validate: func(t *testing.T, req *http.Request) { + expected := "Bearer token123" + if req.Header.Get("Authorization") != expected { + t.Errorf("Expected Authorization %s, got %s", expected, req.Header.Get("Authorization")) + } + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + rb := tt.setup() + req, err := rb.Build() + if err != nil { + t.Fatalf("Build() failed: %v", err) + } + + if req == nil { + t.Fatal("Build() returned nil request") + } + + tt.validate(t, req) + }) + } +} + +func TestRequestBuilder_Build_Errors(t *testing.T) { + tests := []struct { + name string + setup func() *RequestBuilder + wantErr string + }{ + { + name: "missing HTTP method", + setup: func() *RequestBuilder { + return NewRequestBuilder("https://api.example.com") + }, + wantErr: "HTTP method must be specified", + }, + { + name: "invalid base URL", + setup: func() *RequestBuilder { + return NewRequestBuilder("://invalid-url").WithMethodGET() + }, + wantErr: "invalid base URL", + }, + { + name: "JSON marshal error", + setup: func() *RequestBuilder { + // Create a struct with an invalid JSON field (channel) + invalidData := make(chan int) + return NewRequestBuilder("https://api.example.com"). + WithMethodPOST(). + WithJSONBody(invalidData) + }, + wantErr: "failed to marshal JSON body", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + rb := tt.setup() + req, err := rb.Build() + + if err == nil { + t.Fatalf("Build() expected error containing %q, got nil", tt.wantErr) + } + + if !strings.Contains(err.Error(), tt.wantErr) { + t.Errorf("Build() error = %v, want error containing %q", err, tt.wantErr) + } + + if req != nil { + t.Error("Build() should return nil request on error") + } + }) + } +} + +func TestRequestBuilder_Build_GetBody(t *testing.T) { + testData := TestData{Name: "test", Value: 42} + rb := NewRequestBuilder("https://api.example.com"). + WithMethodPOST(). + WithJSONBody(testData) + + req, err := rb.Build() + if err != nil { + t.Fatalf("Build() failed: %v", err) + } + + // Test GetBody function for retry support + if req.GetBody == nil { + t.Error("Build() should set GetBody for JSON requests") + return + } + + // Call GetBody to get a new body reader + bodyReader, err := req.GetBody() + if err != nil { + t.Fatalf("GetBody() failed: %v", err) + } + + // Read and verify the body content + bodyBytes, err := io.ReadAll(bodyReader) + if err != nil { + t.Fatalf("Failed to read body from GetBody(): %v", err) + } + + var decodedData TestData + if err := json.Unmarshal(bodyBytes, &decodedData); err != nil { + t.Fatalf("Failed to unmarshal JSON from GetBody(): %v", err) + } + + if decodedData != testData { + t.Errorf("GetBody() returned different data: got %+v, want %+v", decodedData, testData) + } + + // Close the reader + if err := bodyReader.Close(); err != nil { + t.Errorf("Failed to close body reader: %v", err) + } +} + +func Test_basicAuth(t *testing.T) { + tests := []struct { + name string + username string + password string + want string + }{ + { + name: "simple credentials", + username: "user", + password: "pass", + want: base64.StdEncoding.EncodeToString([]byte("user:pass")), + }, + { + name: "empty username", + username: "", + password: "pass", + want: base64.StdEncoding.EncodeToString([]byte(":pass")), + }, + { + name: "empty password", + username: "user", + password: "", + want: base64.StdEncoding.EncodeToString([]byte("user:")), + }, + { + name: "both empty", + username: "", + password: "", + want: base64.StdEncoding.EncodeToString([]byte(":")), + }, + { + name: "special characters", + username: "user@domain.com", + password: "p@ss:w0rd!", + want: base64.StdEncoding.EncodeToString([]byte("user@domain.com:p@ss:w0rd!")), + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := basicAuth(tt.username, tt.password) + if got != tt.want { + t.Errorf("basicAuth() = %v, want %v", got, tt.want) + } + }) + } +} + +func Test_base64Encode(t *testing.T) { + tests := []struct { + name string + data []byte + want string + }{ + { + name: "simple text", + data: []byte("hello world"), + want: base64.StdEncoding.EncodeToString([]byte("hello world")), + }, + { + name: "empty data", + data: []byte(""), + want: "", + }, + { + name: "binary data", + data: []byte{0x00, 0x01, 0x02, 0xFF, 0xFE, 0xFD}, + want: base64.StdEncoding.EncodeToString([]byte{0x00, 0x01, 0x02, 0xFF, 0xFE, 0xFD}), + }, + { + name: "unicode characters", + data: []byte("Hello, 世界! 🌍"), + want: base64.StdEncoding.EncodeToString([]byte("Hello, 世界! 🌍")), + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := base64Encode(tt.data) + if got != tt.want { + t.Errorf("base64Encode() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestRequestBuilder_JiraJQLQuery(t *testing.T) { + tests := []struct { + name string + jql string + expectedInURL string + description string + }{ + { + name: "simple JQL query", + jql: `project = "TEST"`, + expectedInURL: `jql=project+%3D+%22TEST%22`, + description: "simple JQL with project filter", + }, + { + name: "JQL with AND operator", + jql: `project = "TEST" AND type = Requirement`, + expectedInURL: `jql=project+%3D+%22TEST%22+AND+type+%3D+Requirement`, + description: "JQL with AND operator should encode spaces and equals signs", + }, + { + name: "JQL with IN clause", + jql: `status IN (Accepted, Active)`, + expectedInURL: `jql=status+IN+%28Accepted%2C+Active%29`, + description: "JQL with IN clause should encode parentheses and commas", + }, + { + name: "JQL with NOT IN clause", + jql: `status NOT IN (Obsolete, Cancelled)`, + expectedInURL: `jql=status+NOT+IN+%28Obsolete%2C+Cancelled%29`, + description: "JQL with NOT IN clause", + }, + { + name: "JQL with date comparison", + jql: `statusCategoryChangedDate >= '2024-01-01'`, + expectedInURL: `jql=statusCategoryChangedDate+%3E%3D+%272024-01-01%27`, + description: "JQL with date and >= operator should encode properly", + }, + { + name: "complex JQL query", + jql: `project = "TEST" AND type = Requirement AND status NOT IN (Obsolete, Cancelled) AND status IN (Accepted, Active) AND statusCategoryChangedDate >= '2024-01-01' AND statusCategoryChangedDate < '2024-02-01' ORDER BY statusCategoryChangedDate DESC`, + expectedInURL: `jql=project+%3D+%22TEST%22+AND+type+%3D+Requirement+AND+status+NOT+IN+%28Obsolete%2C+Cancelled%29+AND+status+IN+%28Accepted%2C+Active%29+AND+statusCategoryChangedDate+%3E%3D+%272024-01-01%27+AND+statusCategoryChangedDate+%3C+%272024-02-01%27+ORDER+BY+statusCategoryChangedDate+DESC`, + description: "complex JQL query with multiple clauses", + }, + { + name: "JQL with special characters in text", + jql: `summary ~ "bug & issue"`, + expectedInURL: `jql=summary+~+%22bug+%26+issue%22`, + description: "JQL with ampersand in text search", + }, + { + name: "JQL with < operator", + jql: `created < '2024-01-01'`, + expectedInURL: `jql=created+%3C+%272024-01-01%27`, + description: "JQL with less-than operator", + }, + { + name: "JQL with <= operator", + jql: `created <= '2024-01-01'`, + expectedInURL: `jql=created+%3C%3D+%272024-01-01%27`, + description: "JQL with less-than-or-equal operator", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + rb := NewRequestBuilder("https://company.atlassian.net/rest/api/3"). + WithMethodGET(). + WithPath("/search"). + WithQueryParam("jql", tt.jql) + + req, err := rb.Build() + if err != nil { + t.Fatalf("Build() failed: %v", err) + } + + actualQuery := req.URL.RawQuery + if actualQuery != tt.expectedInURL { + t.Errorf("%s\nJQL: %s\nGot: %s\nExpected: %s", + tt.description, tt.jql, actualQuery, tt.expectedInURL) + } + + // Verify the URL can be parsed back correctly + parsedURL, err := req.URL.Parse(req.URL.String()) + if err != nil { + t.Fatalf("Failed to parse built URL: %v", err) + } + + // Verify we can extract the JQL parameter back + actualJQL := parsedURL.Query().Get("jql") + if actualJQL != tt.jql { + t.Errorf("Round-trip failed:\nOriginal: %s\nAfter: %s", tt.jql, actualJQL) + } + }) + } +} + +// TestRequestBuilder_NewHTTPMethods tests the newly added HTTP method convenience functions +func TestRequestBuilder_NewHTTPMethods(t *testing.T) { + tests := []struct { + name string + builder func() *RequestBuilder + expected string + }{ + { + name: "HEAD method", + builder: func() *RequestBuilder { return NewRequestBuilder("https://api.example.com").WithMethodHEAD() }, + expected: http.MethodHead, + }, + { + name: "OPTIONS method", + builder: func() *RequestBuilder { return NewRequestBuilder("https://api.example.com").WithMethodOPTIONS() }, + expected: http.MethodOptions, + }, + { + name: "TRACE method", + builder: func() *RequestBuilder { return NewRequestBuilder("https://api.example.com").WithMethodTRACE() }, + expected: http.MethodTrace, + }, + { + name: "CONNECT method", + builder: func() *RequestBuilder { return NewRequestBuilder("https://api.example.com").WithMethodCONNECT() }, + expected: http.MethodConnect, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + rb := tt.builder() + + // Check the method is set correctly + if rb.method != tt.expected { + t.Errorf("Expected method %s, got %s", tt.expected, rb.method) + } + + // Build the request and verify + req, err := rb.WithPath("/test").Build() + if err != nil { + t.Fatalf("Build() failed: %v", err) + } + + if req.Method != tt.expected { + t.Errorf("Built request method = %s, want %s", req.Method, tt.expected) + } + }) + } +} + +// TestRequestBuilder_AllHTTPMethodsIntegration tests all HTTP methods in a single integration test +func TestRequestBuilder_AllHTTPMethodsIntegration(t *testing.T) { + baseURL := "https://api.example.com" + path := "/resource" + + methods := map[string]struct { + builder func(*RequestBuilder) *RequestBuilder + expected string + }{ + "GET": {builder: (*RequestBuilder).WithMethodGET, expected: http.MethodGet}, + "POST": {builder: (*RequestBuilder).WithMethodPOST, expected: http.MethodPost}, + "PUT": {builder: (*RequestBuilder).WithMethodPUT, expected: http.MethodPut}, + "DELETE": {builder: (*RequestBuilder).WithMethodDELETE, expected: http.MethodDelete}, + "PATCH": {builder: (*RequestBuilder).WithMethodPATCH, expected: http.MethodPatch}, + "HEAD": {builder: (*RequestBuilder).WithMethodHEAD, expected: http.MethodHead}, + "OPTIONS": {builder: (*RequestBuilder).WithMethodOPTIONS, expected: http.MethodOptions}, + "TRACE": {builder: (*RequestBuilder).WithMethodTRACE, expected: http.MethodTrace}, + "CONNECT": {builder: (*RequestBuilder).WithMethodCONNECT, expected: http.MethodConnect}, + } + + for name, tc := range methods { + t.Run(name, func(t *testing.T) { + rb := NewRequestBuilder(baseURL) + rb = tc.builder(rb) + rb = rb.WithPath(path) + + req, err := rb.Build() + if err != nil { + t.Fatalf("Build() failed for %s: %v", name, err) + } + + if req.Method != tc.expected { + t.Errorf("Method = %s, want %s", req.Method, tc.expected) + } + + if req.URL.Path != path { + t.Errorf("Path = %s, want %s", req.URL.Path, path) + } + + if req.URL.Host != "api.example.com" { + t.Errorf("Host = %s, want api.example.com", req.URL.Host) + } + }) + } +} diff --git a/http_request_builder_validation_test.go b/http_request_builder_validation_test.go new file mode 100644 index 0000000..463216d --- /dev/null +++ b/http_request_builder_validation_test.go @@ -0,0 +1,318 @@ +package httpx + +import ( + "context" + "strings" + "testing" +) + +// TestRequestBuilder_ErrorHandling tests the error accumulation pattern +func TestRequestBuilder_ErrorHandling(t *testing.T) { + t.Run("HasErrors and GetErrors", func(t *testing.T) { + rb := NewRequestBuilder("https://api.example.com") + + if rb.HasErrors() { + t.Error("New builder should not have errors") + } + + if len(rb.GetErrors()) != 0 { + t.Error("New builder should have empty error slice") + } + + // Add an error by using invalid input + rb.WithMethod("") + + if !rb.HasErrors() { + t.Error("Builder should have errors after invalid method") + } + + errors := rb.GetErrors() + if len(errors) != 1 { + t.Errorf("Expected 1 error, got %d", len(errors)) + } + }) + + t.Run("Multiple errors accumulate", func(t *testing.T) { + rb := NewRequestBuilder("https://api.example.com") + rb.WithMethod("") + rb.WithHeader("", "value") + rb.WithQueryParam("", "value") + rb.WithBasicAuth("", "") + + if !rb.HasErrors() { + t.Error("Builder should have errors") + } + + errors := rb.GetErrors() + if len(errors) < 4 { + t.Errorf("Expected at least 4 errors, got %d", len(errors)) + } + }) + + t.Run("Build fails with accumulated errors", func(t *testing.T) { + rb := NewRequestBuilder("https://api.example.com") + rb.WithMethodGET() + rb.WithHeader("", "value") // Invalid header + + _, err := rb.Build() + if err == nil { + t.Error("Build() should fail with accumulated errors") + } + }) +} + +// TestRequestBuilder_Reset tests the Reset method +func TestRequestBuilder_Reset(t *testing.T) { + rb := NewRequestBuilder("https://api.example.com") + + // Configure builder + rb.WithMethodPOST(). + WithPath("/users"). + WithHeader("Content-Type", "application/json"). + WithQueryParam("foo", "bar"). + WithBasicAuth("user", "pass") + + // Add an error + rb.WithHeader("", "value") + + if !rb.HasErrors() { + t.Error("Builder should have errors before reset") + } + + // Reset + rb.Reset() + + if rb.HasErrors() { + t.Error("Builder should not have errors after reset") + } + + if rb.method != "" { + t.Error("Method should be cleared after reset") + } + + if rb.path != "" { + t.Error("Path should be cleared after reset") + } + + if len(rb.headers) != 0 { + t.Error("Headers should be cleared after reset") + } + + if len(rb.queryParams) != 0 { + t.Error("Query params should be cleared after reset") + } + + if rb.ctx == nil { + t.Error("Context should be initialized after reset") + } +} + +// TestRequestBuilder_ValidationErrors tests validation on various methods +func TestRequestBuilder_ValidationErrors(t *testing.T) { + t.Run("Empty header key", func(t *testing.T) { + rb := NewRequestBuilder("https://api.example.com") + rb.WithHeader("", "value") + + if !rb.HasErrors() { + t.Error("Should have error for empty header key") + } + }) + + t.Run("Empty header value", func(t *testing.T) { + rb := NewRequestBuilder("https://api.example.com") + rb.WithHeader("X-Test", "") + + if !rb.HasErrors() { + t.Error("Should have error for empty header value") + } + }) + + t.Run("Header key with whitespace", func(t *testing.T) { + rb := NewRequestBuilder("https://api.example.com") + rb.WithHeader("X Test", "value") + + if !rb.HasErrors() { + t.Error("Should have error for header key with whitespace") + } + }) + + t.Run("Empty query key", func(t *testing.T) { + rb := NewRequestBuilder("https://api.example.com") + rb.WithQueryParam("", "value") + + if !rb.HasErrors() { + t.Error("Should have error for empty query key") + } + }) + + t.Run("Empty query value", func(t *testing.T) { + rb := NewRequestBuilder("https://api.example.com") + rb.WithQueryParam("key", "") + + if !rb.HasErrors() { + t.Error("Should have error for empty query value") + } + }) + + t.Run("Query key with invalid characters", func(t *testing.T) { + rb := NewRequestBuilder("https://api.example.com") + rb.WithQueryParam("key=value", "test") + + if !rb.HasErrors() { + t.Error("Should have error for query key with invalid characters") + } + }) + + t.Run("Empty username for basic auth", func(t *testing.T) { + rb := NewRequestBuilder("https://api.example.com") + rb.WithBasicAuth("", "password") + + if !rb.HasErrors() { + t.Error("Should have error for empty username") + } + }) + + t.Run("Empty password for basic auth", func(t *testing.T) { + rb := NewRequestBuilder("https://api.example.com") + rb.WithBasicAuth("user", "") + + if !rb.HasErrors() { + t.Error("Should have error for empty password") + } + }) + + t.Run("Empty bearer token", func(t *testing.T) { + rb := NewRequestBuilder("https://api.example.com") + rb.WithBearerAuth("") + + if !rb.HasErrors() { + t.Error("Should have error for empty bearer token") + } + }) + + t.Run("Empty user agent", func(t *testing.T) { + rb := NewRequestBuilder("https://api.example.com") + rb.WithUserAgent("") + + if !rb.HasErrors() { + t.Error("Should have error for empty user agent") + } + }) + + t.Run("User agent with only whitespace", func(t *testing.T) { + rb := NewRequestBuilder("https://api.example.com") + rb.WithUserAgent(" ") + + if !rb.HasErrors() { + t.Error("Should have error for whitespace-only user agent") + } + }) + + t.Run("User agent too long", func(t *testing.T) { + rb := NewRequestBuilder("https://api.example.com") + longUA := strings.Repeat("a", 501) + rb.WithUserAgent(longUA) + + if !rb.HasErrors() { + t.Error("Should have error for user agent exceeding 500 characters") + } + }) + + t.Run("User agent with control characters", func(t *testing.T) { + rb := NewRequestBuilder("https://api.example.com") + rb.WithUserAgent("MyApp/1.0\nMalicious") + + if !rb.HasErrors() { + t.Error("Should have error for user agent with control characters") + } + }) + + t.Run("Nil context", func(t *testing.T) { + rb := NewRequestBuilder("https://api.example.com") + rb.WithContext(context.TODO()) + + if rb.HasErrors() { + t.Error("Should not have error for context.TODO()") + } + }) +} + +// TestRequestBuilder_BuildValidation tests Build-time validation +func TestRequestBuilder_BuildValidation(t *testing.T) { + t.Run("Missing method", func(t *testing.T) { + rb := NewRequestBuilder("https://api.example.com") + + _, err := rb.Build() + if err == nil { + t.Error("Build() should fail without method") + } + }) + + t.Run("Invalid base URL scheme", func(t *testing.T) { + rb := NewRequestBuilder("ftp://example.com") + rb.WithMethodGET() + + _, err := rb.Build() + if err == nil { + t.Error("Build() should fail with invalid URL scheme") + } + }) + + t.Run("Missing URL scheme", func(t *testing.T) { + rb := NewRequestBuilder("example.com") + rb.WithMethodGET() + + _, err := rb.Build() + if err == nil { + t.Error("Build() should fail without URL scheme") + } + }) + + t.Run("Missing URL host", func(t *testing.T) { + rb := NewRequestBuilder("http://") + rb.WithMethodGET() + + _, err := rb.Build() + if err == nil { + t.Error("Build() should fail without URL host") + } + }) +} + +// TestRequestBuilder_HTTPMethodValidation tests HTTPMethod validation +func TestRequestBuilder_HTTPMethodValidation(t *testing.T) { + tests := []struct { + name string + method string + expectError bool + expected string + }{ + {"Valid GET", "GET", false, "GET"}, + {"Valid POST", "POST", false, "POST"}, + {"Valid lowercase get", "get", false, "GET"}, + {"Valid with spaces", " PUT ", false, "PUT"}, + {"Empty method", "", true, ""}, + {"Invalid method", "INVALID", true, ""}, + {"Invalid method INVALID2", "SOMETHING", true, ""}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + rb := NewRequestBuilder("https://api.example.com") + rb.WithMethod(tt.method) + + if tt.expectError { + if !rb.HasErrors() { + t.Errorf("Expected error for method %s, but got none", tt.method) + } + } else { + if rb.HasErrors() { + t.Errorf("Unexpected error for method %s: %v", tt.method, rb.GetErrors()) + } + if rb.method != tt.expected { + t.Errorf("Expected method %s, got %s", tt.expected, rb.method) + } + } + }) + } +} diff --git a/http_retrier.go b/http_retrier.go new file mode 100644 index 0000000..6968b6c --- /dev/null +++ b/http_retrier.go @@ -0,0 +1,265 @@ +package httpx + +import ( + "errors" + "fmt" + "io" + "log/slog" + "math/rand" + "net/http" + "time" +) + +var ErrAllRetriesFailed = errors.New("all retry attempts failed") + +// RetryStrategy defines the function signature for different retry strategies +type RetryStrategy func(attempt int) time.Duration + +// ExponentialBackoff returns a RetryStrategy that calculates delays +// growing exponentially with each retry attempt, starting from base +// and capped at maxDelay. +func ExponentialBackoff(base, maxDelay time.Duration) RetryStrategy { + return func(attempt int) time.Duration { + // Special case from test: If base > maxDelay, the first attempt returns base, + // subsequent attempts calculate normally and cap at maxDelay. + if attempt == 0 && base > maxDelay { + return base + } + + // Calculate delay: base * 2^attempt + // Use uint for bit shift robustness, though overflow is unlikely before capping. + delay := base * (1 << uint(attempt)) + + // Cap at maxDelay. Also handle potential overflow resulting in negative/zero delay. + if delay > maxDelay || delay <= 0 { + delay = maxDelay + } + + // Note: The original check `if delay < base { delay = base }` is removed + // as the logic now correctly handles the base > maxDelay case based on the test, + // and for base <= maxDelay, the calculated delay won't be less than base for attempt >= 0. + return delay + } +} + +// FixedDelay returns a RetryStrategy that provides a constant delay +// for each retry attempt. +func FixedDelay(delay time.Duration) RetryStrategy { + return func(attempt int) time.Duration { + return delay + } +} + +// JitterBackoff returns a RetryStrategy that adds a random jitter +// to the exponential backoff delay calculated using base and maxDelay. +func JitterBackoff(base, maxDelay time.Duration) RetryStrategy { + expBackoff := ExponentialBackoff(base, maxDelay) + return func(attempt int) time.Duration { + baseDelay := expBackoff(attempt) + + // Add jitter: random duration between 0 and baseDelay/2 + jitter := time.Duration(rand.Int63n(int64(baseDelay / 2))) + + return baseDelay + jitter + } +} + +// retryTransport wraps http.RoundTripper to add retry logic +type retryTransport struct { + Transport http.RoundTripper // Underlying transport (e.g., http.DefaultTransport) + RetryStrategy RetryStrategy // The strategy function to calculate delay + MaxRetries int + logger *slog.Logger // Optional logger for retry operations (nil = no logging) +} + +// RoundTrip executes an HTTP request with retry logic +func (r *retryTransport) RoundTrip(req *http.Request) (*http.Response, error) { + var resp *http.Response + var err error + + // Ensure transport is set + transport := r.Transport + if transport == nil { + transport = http.DefaultTransport + } + + // Ensure a retry strategy is set, default to a basic exponential backoff + retryStrategy := r.RetryStrategy + if retryStrategy == nil { + retryStrategy = ExponentialBackoff(500*time.Millisecond, 10*time.Second) // Default strategy + } + + for attempt := 0; attempt <= r.MaxRetries; attempt++ { + // Clone the request body if it exists and is GetBody is defined + // This allows the body to be read multiple times on retries + if req.Body != nil && req.GetBody != nil { + bodyClone, err := req.GetBody() + if err != nil { + return nil, fmt.Errorf("failed to get request body for retry: %w", err) + } + + req.Body = bodyClone + } + + resp, err = transport.RoundTrip(req) + + // Success conditions: no error and status code below 500 + if err == nil && resp.StatusCode < http.StatusInternalServerError { + return resp, nil + } + + // If there was an error or a server-side error (5xx), prepare for retry + // Close response body to prevent resource leaks before retrying + if resp != nil { + // Drain the body before closing + _, copyErr := io.Copy(io.Discard, resp.Body) + closeErr := resp.Body.Close() + + if copyErr != nil { + // Prioritize returning the copy error + return nil, fmt.Errorf("failed to discard response body: %w", copyErr) + } + + if closeErr != nil { + return nil, fmt.Errorf("failed to close response body: %w", closeErr) + } + } + + // Check if we should retry + if attempt < r.MaxRetries { + delay := retryStrategy(attempt) + + // Log retry attempt if logger is configured + if r.logger != nil { + if err != nil { + r.logger.Warn("HTTP request failed, retrying", + "attempt", attempt+1, + "max_retries", r.MaxRetries, + "delay", delay, + "error", err, + "url", req.URL.String(), + "method", req.Method, + ) + } else if resp != nil { + r.logger.Warn("HTTP request returned server error, retrying", + "attempt", attempt+1, + "max_retries", r.MaxRetries, + "delay", delay, + "status_code", resp.StatusCode, + "url", req.URL.String(), + "method", req.Method, + ) + } + } + + time.Sleep(delay) + } else { + // Max retries reached, log and return the last error or a generic failure error + if r.logger != nil { + if err != nil { + r.logger.Error("All retry attempts failed", + "attempts", r.MaxRetries+1, + "error", err, + "url", req.URL.String(), + "method", req.Method, + ) + } else if resp != nil { + r.logger.Error("All retry attempts failed", + "attempts", r.MaxRetries+1, + "status_code", resp.StatusCode, + "url", req.URL.String(), + "method", req.Method, + ) + } + } + + if err != nil { + return nil, fmt.Errorf("all retries failed; last error: %w", err) + } + + // If the last attempt resulted in a 5xx response without a transport error + if resp != nil { + // Return a more specific error including the status code + return nil, fmt.Errorf("%w: last attempt failed with status %d", ErrAllRetriesFailed, resp.StatusCode) + } + + return nil, ErrAllRetriesFailed + } + } + + return nil, ErrAllRetriesFailed +} + +// RetryClientOption is a function type for configuring the retry HTTP client. +type RetryClientOption func(*retryClientConfig) + +// retryClientConfig holds configuration for building a retry HTTP client. +type retryClientConfig struct { + maxRetries int + strategy RetryStrategy + baseTransport http.RoundTripper + logger *slog.Logger +} + +// WithMaxRetriesRetry sets the maximum number of retry attempts for the retry client. +func WithMaxRetriesRetry(maxRetries int) RetryClientOption { + return func(c *retryClientConfig) { + c.maxRetries = maxRetries + } +} + +// WithRetryStrategyRetry sets the retry strategy for the retry client. +func WithRetryStrategyRetry(strategy RetryStrategy) RetryClientOption { + return func(c *retryClientConfig) { + c.strategy = strategy + } +} + +// WithBaseTransport sets the base HTTP transport for the retry client. +// If not provided, http.DefaultTransport will be used. +func WithBaseTransport(transport http.RoundTripper) RetryClientOption { + return func(c *retryClientConfig) { + c.baseTransport = transport + } +} + +// WithLoggerRetry sets the logger for the retry client. +// Pass nil to disable logging (default behavior). +func WithLoggerRetry(logger *slog.Logger) RetryClientOption { + return func(c *retryClientConfig) { + c.logger = logger + } +} + +// NewHTTPRetryClient creates a new http.Client configured with the retry transport. +// Use the provided options to customize the retry behavior. +// By default, it uses 3 retries with exponential backoff strategy and no logging. +func NewHTTPRetryClient(options ...RetryClientOption) *http.Client { + config := &retryClientConfig{ + maxRetries: DefaultMaxRetries, + strategy: ExponentialBackoff(DefaultBaseDelay, DefaultMaxDelay), + baseTransport: nil, + logger: nil, + } + + for _, option := range options { + option(config) + } + + if config.baseTransport == nil { + config.baseTransport = http.DefaultTransport + } + + if config.strategy == nil { + config.strategy = ExponentialBackoff(DefaultBaseDelay, DefaultMaxDelay) + } + + return &http.Client{ + Transport: &retryTransport{ + Transport: config.baseTransport, + MaxRetries: config.maxRetries, + RetryStrategy: config.strategy, + logger: config.logger, + }, + } +} diff --git a/http_retrier_test.go b/http_retrier_test.go new file mode 100644 index 0000000..854c718 --- /dev/null +++ b/http_retrier_test.go @@ -0,0 +1,684 @@ +package httpx + +import ( + "errors" + "fmt" + "io" + "math" + "net/http" + "net/http/httptest" + "strings" + "sync/atomic" + "testing" + "time" +) + +// --- Test Retry Strategies --- + +func TestExponentialBackoff(t *testing.T) { + base := 100 * time.Millisecond + max := 1 * time.Second + strategy := ExponentialBackoff(base, max) + + expectedDelays := []time.Duration{ + base, // attempt 0 -> base * 2^0 = base + base * 2, // attempt 1 -> base * 2^1 + base * 4, // attempt 2 -> base * 2^2 + base * 8, // attempt 3 -> base * 2^3 + max, // attempt 4 -> base * 2^4 = 1600ms > max, capped at max + max, // attempt 5 -> base * 2^5 = 3200ms > max, capped at max + } + + for i, expected := range expectedDelays { + actual := strategy(i) + if actual != expected { + t.Errorf("Attempt %d: Expected delay %v, got %v", i, expected, actual) + } + } + + for i, expected := range expectedDelays { + actual := strategy(i) + if actual != expected { + t.Errorf("Attempt %d: Expected delay %v, got %v", i, expected, actual) + } + } + + // Test case where base > max (should cap at max, but logic ensures base is min) + strategyHighBase := ExponentialBackoff(2*time.Second, 1*time.Second) + + if delay := strategyHighBase(0); delay != 2*time.Second { // Should return base even if > max initially + t.Errorf("High base test: Expected delay %v, got %v", 2*time.Second, delay) + } + + if delay := strategyHighBase(1); delay != 1*time.Second { // Subsequent attempts capped at max + t.Errorf("High base test attempt 1: Expected delay %v, got %v", 1*time.Second, delay) + } +} + +func TestFixedDelay(t *testing.T) { + delay := 500 * time.Millisecond + strategy := FixedDelay(delay) + + for i := range 5 { + actual := strategy(i) + if actual != delay { + t.Errorf("Attempt %d: Expected delay %v, got %v", i, delay, actual) + } + } +} + +func TestJitterBackoff(t *testing.T) { + base := 100 * time.Millisecond + max := 1 * time.Second + strategy := JitterBackoff(base, max) + expStrategy := ExponentialBackoff(base, max) + + for i := range 5 { + baseDelay := expStrategy(i) + actual := strategy(i) + + // Check if actual delay is within [baseDelay, baseDelay + baseDelay/2) + if actual < baseDelay || actual >= baseDelay+(baseDelay/2) { + // Allow for slight floating point inaccuracies if baseDelay/2 is very small + if math.Abs(float64(actual-(baseDelay+(baseDelay/2)))) > 1e-9 { + t.Errorf("Attempt %d: Expected delay between %v and %v, got %v", i, baseDelay, baseDelay+(baseDelay/2), actual) + } + } + } +} + +// --- Test retryTransport --- + +// mockRoundTripper allows mocking http.RoundTripper behavior. +type mockRoundTripper struct { + roundTripFunc func(req *http.Request) (*http.Response, error) +} + +func (m *mockRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) { + if m.roundTripFunc == nil { + // Default behavior: return a simple 200 OK response + return &http.Response{ + StatusCode: http.StatusOK, + Body: io.NopCloser(strings.NewReader("OK")), + Header: make(http.Header), + }, nil + } + + return m.roundTripFunc(req) +} + +func TestRetryTransport_SuccessOnFirstAttempt(t *testing.T) { + mockRT := &mockRoundTripper{ + roundTripFunc: func(req *http.Request) (*http.Response, error) { + return &http.Response{ + StatusCode: http.StatusOK, + Body: io.NopCloser(strings.NewReader("Success")), + Header: make(http.Header), + }, nil + }, + } + + retryRT := &retryTransport{ + Transport: mockRT, + MaxRetries: 3, + RetryStrategy: FixedDelay(1 * time.Millisecond), // Fast delay for testing + } + + req := httptest.NewRequest("GET", "http://example.com", nil) + resp, err := retryRT.RoundTrip(req) + if err != nil { + t.Fatalf("Expected no error, got %v", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + t.Errorf("Expected status code %d, got %d", http.StatusOK, resp.StatusCode) + } + bodyBytes, _ := io.ReadAll(resp.Body) + if string(bodyBytes) != "Success" { + t.Errorf("Expected body 'Success', got '%s'", string(bodyBytes)) + } +} + +func TestRetryTransport_SuccessAfterRetries(t *testing.T) { + var attempts int32 = 0 + targetAttempts := 2 // Succeed on the 3rd attempt (0, 1, 2) + + mockRT := &mockRoundTripper{ + roundTripFunc: func(req *http.Request) (*http.Response, error) { + currentAttempt := atomic.LoadInt32(&attempts) + atomic.AddInt32(&attempts, 1) + + if currentAttempt < int32(targetAttempts) { + return &http.Response{ + StatusCode: http.StatusInternalServerError, // Simulate server error + Body: io.NopCloser(strings.NewReader("Server Error")), + Header: make(http.Header), + }, nil // No transport error, just bad status + } + // Success on the target attempt + return &http.Response{ + StatusCode: http.StatusOK, + Body: io.NopCloser(strings.NewReader("Success")), + Header: make(http.Header), + }, nil + }, + } + + retryRT := &retryTransport{ + Transport: mockRT, + MaxRetries: 3, + RetryStrategy: FixedDelay(1 * time.Millisecond), // Use short delay + } + + req := httptest.NewRequest("GET", "http://example.com", nil) + resp, err := retryRT.RoundTrip(req) + if err != nil { + t.Fatalf("Expected no error, got %v", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + t.Errorf("Expected status code %d, got %d", http.StatusOK, resp.StatusCode) + } + if atomic.LoadInt32(&attempts) != int32(targetAttempts+1) { + t.Errorf("Expected %d attempts, got %d", targetAttempts+1, atomic.LoadInt32(&attempts)) + } +} + +func TestRetryTransport_FailureAfterMaxRetries_ServerError(t *testing.T) { + var attempts int32 = 0 + maxRetries := 2 + + mockRT := &mockRoundTripper{ + roundTripFunc: func(req *http.Request) (*http.Response, error) { + atomic.AddInt32(&attempts, 1) + return &http.Response{ + StatusCode: http.StatusServiceUnavailable, // Always fail + Body: io.NopCloser(strings.NewReader("Unavailable")), + Header: make(http.Header), + }, nil + }, + } + + retryRT := &retryTransport{ + Transport: mockRT, + MaxRetries: maxRetries, + RetryStrategy: FixedDelay(1 * time.Millisecond), + } + + req := httptest.NewRequest("GET", "http://example.com", nil) + resp, err := retryRT.RoundTrip(req) + + if err == nil { + t.Fatalf("Expected an error, got nil response: %v", resp) + } + if resp != nil { + t.Errorf("Expected nil response on final failure, got %v", resp) + } + if !errors.Is(err, ErrAllRetriesFailed) { + t.Errorf("Expected error to wrap ErrAllRetriesFailed, got %v", err) + } + expectedErrMsg := fmt.Sprintf("%s: last attempt failed with status %d", ErrAllRetriesFailed, http.StatusServiceUnavailable) + if err.Error() != expectedErrMsg { + t.Errorf("Expected error message '%s', got '%s'", expectedErrMsg, err.Error()) + } + + // Attempts = initial + maxRetries + if atomic.LoadInt32(&attempts) != int32(maxRetries+1) { + t.Errorf("Expected %d attempts, got %d", maxRetries+1, atomic.LoadInt32(&attempts)) + } +} + +func TestRetryTransport_FailureAfterMaxRetries_TransportError(t *testing.T) { + var attempts int32 = 0 + maxRetries := 1 + simulatedError := errors.New("simulated transport error") + + mockRT := &mockRoundTripper{ + roundTripFunc: func(req *http.Request) (*http.Response, error) { + atomic.AddInt32(&attempts, 1) + return nil, simulatedError // Always return a transport error + }, + } + + retryRT := &retryTransport{ + Transport: mockRT, + MaxRetries: maxRetries, + RetryStrategy: FixedDelay(1 * time.Millisecond), + } + + req := httptest.NewRequest("GET", "http://example.com", nil) + resp, err := retryRT.RoundTrip(req) + + if err == nil { + t.Fatalf("Expected an error, got nil response: %v", resp) + } + if resp != nil { + t.Errorf("Expected nil response on final failure, got %v", resp) + } + // Check if the original error is wrapped + if !errors.Is(err, simulatedError) { + t.Errorf("Expected error to wrap the original transport error '%v', but it didn't. Got: %v", simulatedError, err) + } + expectedErrMsgPrefix := "all retries failed; last error:" + if !strings.HasPrefix(err.Error(), expectedErrMsgPrefix) { + t.Errorf("Expected error message to start with '%s', got '%s'", expectedErrMsgPrefix, err.Error()) + } + + // Attempts = initial + maxRetries + if atomic.LoadInt32(&attempts) != int32(maxRetries+1) { + t.Errorf("Expected %d attempts, got %d", maxRetries+1, atomic.LoadInt32(&attempts)) + } +} + +func TestRetryTransport_RequestBodyCloning(t *testing.T) { + var attempts int32 = 0 + maxRetries := 1 + requestBodyContent := "Request Body Content" + + mockRT := &mockRoundTripper{ + roundTripFunc: func(req *http.Request) (*http.Response, error) { + currentAttempt := atomic.LoadInt32(&attempts) + atomic.AddInt32(&attempts, 1) + + // Verify body content on each attempt + bodyBytes, err := io.ReadAll(req.Body) + if err != nil { + t.Errorf("Attempt %d: Failed to read request body: %v", currentAttempt, err) + return nil, fmt.Errorf("failed reading body on attempt %d", currentAttempt) + } + if string(bodyBytes) != requestBodyContent { + t.Errorf("Attempt %d: Expected body '%s', got '%s'", currentAttempt, requestBodyContent, string(bodyBytes)) + } + + if currentAttempt == 0 { + // Fail first attempt + return &http.Response{ + StatusCode: http.StatusInternalServerError, + Body: io.NopCloser(strings.NewReader("Fail")), + Header: make(http.Header), + }, nil + } + // Succeed second attempt + return &http.Response{ + StatusCode: http.StatusOK, + Body: io.NopCloser(strings.NewReader("Success")), + Header: make(http.Header), + }, nil + }, + } + + retryRT := &retryTransport{ + Transport: mockRT, + MaxRetries: maxRetries, + RetryStrategy: FixedDelay(1 * time.Millisecond), + } + + // Create a request with a body that supports GetBody + body := strings.NewReader(requestBodyContent) + req := httptest.NewRequest("POST", "http://example.com", body) + // Crucially, set GetBody so the transport can re-read it + req.GetBody = func() (io.ReadCloser, error) { + return io.NopCloser(strings.NewReader(requestBodyContent)), nil + } + + resp, err := retryRT.RoundTrip(req) + if err != nil { + t.Fatalf("Expected no error, got %v", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + t.Errorf("Expected status OK, got %d", resp.StatusCode) + } + if atomic.LoadInt32(&attempts) != int32(maxRetries+1) { + t.Errorf("Expected %d attempts, got %d", maxRetries+1, atomic.LoadInt32(&attempts)) + } +} + +func TestRetryTransport_NilTransportUsesDefault(t *testing.T) { + // We can't easily intercept http.DefaultTransport, so we test indirectly + // by ensuring RoundTrip doesn't panic and potentially fails connecting + // to a non-existent local server, which implies it tried using *some* transport. + retryRT := &retryTransport{ + Transport: nil, // Explicitly nil + MaxRetries: 0, // No retries, just test the transport path + RetryStrategy: FixedDelay(1 * time.Millisecond), + } + + req := httptest.NewRequest("GET", "http://localhost:9999", nil) // Use a likely unavailable port + + _, err := retryRT.RoundTrip(req) + if err == nil { + t.Fatalf("Expected an error (likely connection refused), but got nil") + } + // We expect some kind of network error because DefaultTransport was used + if !strings.Contains(err.Error(), "connection refused") && !strings.Contains(err.Error(), "invalid URL") && !strings.Contains(err.Error(), "no such host") { + t.Logf("Received error: %v. This might be okay if DefaultTransport behavior changed.", err) + // Don't fail the test outright, but log it. The main point is no panic. + } +} + +func TestRetryTransport_NilRetryStrategyUsesDefault(t *testing.T) { + var attempts int32 = 0 + maxRetries := 1 + + mockRT := &mockRoundTripper{ + roundTripFunc: func(req *http.Request) (*http.Response, error) { + currentAttempt := atomic.LoadInt32(&attempts) + atomic.AddInt32(&attempts, 1) + + if currentAttempt == 0 { + // Fail first attempt + return &http.Response{ + StatusCode: http.StatusInternalServerError, + Body: io.NopCloser(strings.NewReader("Fail")), + Header: make(http.Header), + }, nil + } + // Succeed second attempt + return &http.Response{ + StatusCode: http.StatusOK, + Body: io.NopCloser(strings.NewReader("Success")), + Header: make(http.Header), + }, nil + }, + } + + retryRT := &retryTransport{ + Transport: mockRT, + MaxRetries: maxRetries, + RetryStrategy: nil, // Explicitly nil + } + + req := httptest.NewRequest("GET", "http://example.com", nil) + resp, err := retryRT.RoundTrip(req) + if err != nil { + t.Fatalf("Expected no error, got %v", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + t.Errorf("Expected status OK, got %d", resp.StatusCode) + } + // Check that it actually retried (implying a strategy was used) + if atomic.LoadInt32(&attempts) != int32(maxRetries+1) { + t.Errorf("Expected %d attempts (implying default strategy used), got %d", maxRetries+1, atomic.LoadInt32(&attempts)) + } +} + +func TestRetryTransport_NonRetryableError(t *testing.T) { + mockRT := &mockRoundTripper{ + roundTripFunc: func(req *http.Request) (*http.Response, error) { + // Simulate a client-side error (e.g., invalid URL structure, though RoundTrip usually catches this earlier) + // Or more realistically, an error that shouldn't be retried based on policy (though this transport retries all transport errors) + // For this test, let's just return a non-5xx status code which *shouldn't* be retried. + return &http.Response{ + StatusCode: http.StatusBadRequest, // 400 Bad Request + Body: io.NopCloser(strings.NewReader("Bad Request")), + Header: make(http.Header), + }, nil + }, + } + + retryRT := &retryTransport{ + Transport: mockRT, + MaxRetries: 3, + RetryStrategy: FixedDelay(1 * time.Millisecond), + } + + req := httptest.NewRequest("GET", "http://example.com", nil) + resp, err := retryRT.RoundTrip(req) + if err != nil { + t.Fatalf("Expected no error, got %v", err) + } + defer resp.Body.Close() + + // Should return immediately with the 400 status, no retries + if resp.StatusCode != http.StatusBadRequest { + t.Errorf("Expected status code %d, got %d", http.StatusBadRequest, resp.StatusCode) + } + // Ensure only one attempt was made (no retry occurred) + // Need a way to count attempts if the mock isn't designed for it. + // For this simple mock, we assume if status is < 500, it returns immediately. +} + +// --- Test NewClient --- + +func TestNewHTTPRetryClient(t *testing.T) { + maxRetries := 5 + strategy := FixedDelay(100 * time.Millisecond) + mockBaseTransport := &mockRoundTripper{} // Use a simple mock + + client := NewHTTPRetryClient( + WithMaxRetriesRetry(maxRetries), + WithRetryStrategyRetry(strategy), + WithBaseTransport(mockBaseTransport), + ) + + if client == nil { + t.Fatal("NewHTTPRetryClient returned nil") + } + + rt, ok := client.Transport.(*retryTransport) + if !ok { + t.Fatalf("Client transport is not of type *retryTransport, got %T", client.Transport) + } + + if rt.MaxRetries != maxRetries { + t.Errorf("Expected MaxRetries %d, got %d", maxRetries, rt.MaxRetries) + } + if rt.Transport != mockBaseTransport { + t.Errorf("Expected base transport to be the mock, got %v", rt.Transport) + } + // Comparing functions directly is tricky; we assume if it's not nil, it's the one we passed. + if rt.RetryStrategy == nil { + t.Error("Expected RetryStrategy to be set, got nil") + } + + // Test with defaults (should use http.DefaultTransport and default strategy) + clientDefaults := NewHTTPRetryClient() + rtDefault, ok := clientDefaults.Transport.(*retryTransport) + if !ok { + t.Fatalf("Client (defaults) transport is not of type *retryTransport, got %T", clientDefaults.Transport) + } + if rtDefault.Transport != http.DefaultTransport { + t.Errorf("Expected base transport to be http.DefaultTransport, got %v", rtDefault.Transport) + } + if rtDefault.MaxRetries != DefaultMaxRetries { + t.Errorf("Expected default max retries %d, got %d", DefaultMaxRetries, rtDefault.MaxRetries) + } + if rtDefault.RetryStrategy == nil { + t.Error("Expected default strategy to be set, got nil") + } + + // Test with nil strategy explicitly (should still use default ExponentialBackoff) + clientDefaultStrategy := NewHTTPRetryClient( + WithMaxRetriesRetry(maxRetries), + WithRetryStrategyRetry(nil), + WithBaseTransport(mockBaseTransport), + ) + + rtDefStrat, ok := clientDefaultStrategy.Transport.(*retryTransport) + if !ok { + t.Fatalf("Client (default strategy) transport is not of type *retryTransport, got %T", clientDefaultStrategy.Transport) + } + + if rtDefStrat.RetryStrategy == nil { + t.Error("Expected default RetryStrategy to be set, got nil") + } + // We can't easily compare the default strategy function, but we know it should be non-nil. +} + +// --- Helper for Body Closing/Draining Tests --- + +type errorReaderCloser struct { + readErr error + closeErr error + content string + readOnce bool // To simulate reading partially then erroring +} + +func (e *errorReaderCloser) Read(p []byte) (n int, err error) { + if e.readErr != nil && e.readOnce { + return 0, e.readErr + } + if len(e.content) == 0 { + return 0, io.EOF + } + n = copy(p, e.content) + e.content = e.content[n:] + e.readOnce = true // Mark as read once + return n, nil +} + +func (e *errorReaderCloser) Close() error { + return e.closeErr +} + +func TestRetryTransport_BodyDrainError(t *testing.T) { + simulatedReadError := errors.New("simulated read error during drain") + mockRT := &mockRoundTripper{ + roundTripFunc: func(req *http.Request) (*http.Response, error) { + // Fail the request with a 5xx status and a body that errors on read + return &http.Response{ + StatusCode: http.StatusInternalServerError, + Body: &errorReaderCloser{ + content: "some data", + readErr: simulatedReadError, // Error will occur when draining + }, + Header: make(http.Header), + }, nil + }, + } + + retryRT := &retryTransport{ + Transport: mockRT, + MaxRetries: 1, // Allow one retry attempt + RetryStrategy: FixedDelay(1 * time.Millisecond), + } + + req := httptest.NewRequest("GET", "http://example.com", nil) + _, err := retryRT.RoundTrip(req) + + if err == nil { + t.Fatal("Expected an error due to body drain failure, got nil") + } + + // The error should be related to failing to discard the body + expectedErrMsg := "failed to discard response body" + if !strings.Contains(err.Error(), expectedErrMsg) { + t.Errorf("Expected error message to contain '%s', got '%s'", expectedErrMsg, err.Error()) + } + // Check if the original read error is wrapped + if !errors.Is(err, simulatedReadError) { + t.Errorf("Expected error to wrap the original read error '%v', but it didn't. Got: %v", simulatedReadError, err) + } +} + +func TestRetryTransport_BodyCloseError(t *testing.T) { + simulatedCloseError := errors.New("simulated close error") + mockRT := &mockRoundTripper{ + roundTripFunc: func(req *http.Request) (*http.Response, error) { + // Fail the request with a 5xx status and a body that errors on close + return &http.Response{ + StatusCode: http.StatusInternalServerError, + Body: &errorReaderCloser{ + content: "some data", // Content drains successfully + closeErr: simulatedCloseError, // Error occurs on Close() + }, + Header: make(http.Header), + }, nil + }, + } + + retryRT := &retryTransport{ + Transport: mockRT, + MaxRetries: 1, // Allow one retry attempt + RetryStrategy: FixedDelay(1 * time.Millisecond), + } + + req := httptest.NewRequest("GET", "http://example.com", nil) + _, err := retryRT.RoundTrip(req) + + if err == nil { + t.Fatal("Expected an error due to body close failure, got nil") + } + + // The error should be related to failing to close the body + expectedErrMsg := "failed to close response body" + if !strings.Contains(err.Error(), expectedErrMsg) { + t.Errorf("Expected error message to contain '%s', got '%s'", expectedErrMsg, err.Error()) + } + // Check if the original close error is wrapped + if !errors.Is(err, simulatedCloseError) { + t.Errorf("Expected error to wrap the original close error '%v', but it didn't. Got: %v", simulatedCloseError, err) + } +} + +// Test case where GetBody itself returns an error +func TestRetryTransport_RequestBodyGetBodyError(t *testing.T) { + var attempts int32 = 0 + maxRetries := 1 + requestBodyContent := "Request Body Content" + getBodyError := errors.New("failed to get body") + + mockRT := &mockRoundTripper{ + roundTripFunc: func(req *http.Request) (*http.Response, error) { + currentAttempt := atomic.LoadInt32(&attempts) + atomic.AddInt32(&attempts, 1) + + // Fail first attempt to trigger retry + if currentAttempt == 0 { + return &http.Response{ + StatusCode: http.StatusInternalServerError, + Body: io.NopCloser(strings.NewReader("Fail")), + Header: make(http.Header), + }, nil + } + // This part should not be reached if GetBody fails + t.Errorf("RoundTrip called after GetBody should have failed") + return nil, errors.New("should not be reached") + }, + } + + retryRT := &retryTransport{ + Transport: mockRT, + MaxRetries: maxRetries, + RetryStrategy: FixedDelay(1 * time.Millisecond), + } + + body := strings.NewReader(requestBodyContent) + req := httptest.NewRequest("POST", "http://example.com", body) + // Set GetBody to return an error on the second call (after the first attempt fails) + getBodyAttempts := 0 + req.GetBody = func() (io.ReadCloser, error) { + getBodyAttempts++ + if getBodyAttempts > 1 { // Error on subsequent calls (i.e., during retry prep) + return nil, getBodyError + } + + return io.NopCloser(strings.NewReader(requestBodyContent)), nil + } + + _, err := retryRT.RoundTrip(req) + if err == nil { + t.Fatalf("Expected an error from GetBody, got nil") + } + + // Check if the error is the one from GetBody, wrapped + if !errors.Is(err, getBodyError) { + t.Errorf("Expected error to wrap GetBody error '%v', got: %v", getBodyError, err) + } + + expectedPrefix := "failed to get request body for retry:" + if !strings.HasPrefix(err.Error(), expectedPrefix) { + t.Errorf("Expected error message to start with '%s', got '%s'", expectedPrefix, err.Error()) + } + + // Should only have made the first attempt before failing on GetBody + if atomic.LoadInt32(&attempts) != 1 { + t.Errorf("Expected only 1 attempt before GetBody error, got %d", atomic.LoadInt32(&attempts)) + } +} diff --git a/logger_test.go b/logger_test.go new file mode 100644 index 0000000..20ac303 --- /dev/null +++ b/logger_test.go @@ -0,0 +1,220 @@ +package httpx + +import ( + "bytes" + "log/slog" + "net/http" + "net/http/httptest" + "strings" + "sync/atomic" + "testing" + "time" +) + +// TestRetryTransport_WithLogger verifies that logging works correctly when enabled +func TestRetryTransport_WithLogger(t *testing.T) { + attempts := atomic.Int32{} + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + attempt := attempts.Add(1) + if attempt < 2 { + w.WriteHeader(http.StatusInternalServerError) + return + } + w.WriteHeader(http.StatusOK) + })) + defer server.Close() + + var logBuf bytes.Buffer + logger := slog.New(slog.NewTextHandler(&logBuf, &slog.HandlerOptions{ + Level: slog.LevelWarn, + })) + + client := NewHTTPRetryClient( + WithMaxRetriesRetry(2), + WithRetryStrategyRetry(FixedDelay(10*time.Millisecond)), + WithLoggerRetry(logger), + ) + + resp, err := client.Get(server.URL) + if err != nil { + t.Fatalf("Request failed: %v", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + t.Errorf("Expected status 200, got %d", resp.StatusCode) + } + + // Verify log output contains retry information + logOutput := logBuf.String() + if !strings.Contains(logOutput, "HTTP request returned server error, retrying") { + t.Errorf("Expected retry log message, got: %s", logOutput) + } + + if !strings.Contains(logOutput, "attempt=1") { + t.Errorf("Expected attempt number in log, got: %s", logOutput) + } + + if !strings.Contains(logOutput, "status_code=500") { + t.Errorf("Expected status code in log, got: %s", logOutput) + } +} + +// TestRetryTransport_WithoutLogger verifies that no logging occurs when logger is nil +func TestRetryTransport_WithoutLogger(t *testing.T) { + attempts := atomic.Int32{} + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + attempt := attempts.Add(1) + if attempt < 2 { + w.WriteHeader(http.StatusInternalServerError) + return + } + w.WriteHeader(http.StatusOK) + })) + defer server.Close() + + // No logger provided (nil) + client := NewHTTPRetryClient( + WithMaxRetriesRetry(2), + WithRetryStrategyRetry(FixedDelay(10*time.Millisecond)), + ) + + resp, err := client.Get(server.URL) + if err != nil { + t.Fatalf("Request failed: %v", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + t.Errorf("Expected status 200, got %d", resp.StatusCode) + } + + // Test passes if no panic occurred - logging is safely disabled +} + +// TestRetryTransport_LoggerAllRetriesFailed verifies error logging when all retries fail +func TestRetryTransport_LoggerAllRetriesFailed(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusServiceUnavailable) + })) + defer server.Close() + + var logBuf bytes.Buffer + logger := slog.New(slog.NewTextHandler(&logBuf, &slog.HandlerOptions{ + Level: slog.LevelError, + })) + + client := NewHTTPRetryClient( + WithMaxRetriesRetry(2), + WithRetryStrategyRetry(FixedDelay(10*time.Millisecond)), + WithLoggerRetry(logger), + ) + + _, err := client.Get(server.URL) + if err == nil { + t.Fatal("Expected error when all retries fail") + } + + // Verify error log output + logOutput := logBuf.String() + if !strings.Contains(logOutput, "All retry attempts failed") { + t.Errorf("Expected final error log message, got: %s", logOutput) + } + + if !strings.Contains(logOutput, "attempts=3") { // 1 initial + 2 retries + t.Errorf("Expected attempt count in log, got: %s", logOutput) + } +} + +// TestClientBuilder_WithLogger verifies logger integration with ClientBuilder +func TestClientBuilder_WithLogger(t *testing.T) { + attempts := atomic.Int32{} + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + attempt := attempts.Add(1) + if attempt < 2 { + w.WriteHeader(http.StatusBadGateway) + return + } + w.WriteHeader(http.StatusOK) + })) + defer server.Close() + + var logBuf bytes.Buffer + logger := slog.New(slog.NewTextHandler(&logBuf, &slog.HandlerOptions{ + Level: slog.LevelWarn, + })) + + client := NewClientBuilder(). + WithMaxRetries(2). + WithRetryBaseDelay(500 * time.Millisecond). + WithRetryMaxDelay(10 * time.Second). + WithLogger(logger). + Build() + + resp, err := client.Get(server.URL) + if err != nil { + t.Fatalf("Request failed: %v", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + t.Errorf("Expected status 200, got %d", resp.StatusCode) + } + + // Verify logging occurred + logOutput := logBuf.String() + if !strings.Contains(logOutput, "retrying") { + t.Errorf("Expected retry log from ClientBuilder, got: %s", logOutput) + } +} + +// TestGenericClient_WithLogger verifies logger integration with GenericClient +func TestGenericClient_WithLogger(t *testing.T) { + type Response struct { + Message string `json:"message"` + } + + attempts := atomic.Int32{} + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + attempt := attempts.Add(1) + if attempt < 2 { + w.WriteHeader(http.StatusInternalServerError) + return + } + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`{"message":"success"}`)) + })) + defer server.Close() + + var logBuf bytes.Buffer + logger := slog.New(slog.NewTextHandler(&logBuf, &slog.HandlerOptions{ + Level: slog.LevelWarn, + })) + + client := NewGenericClient[Response]( + WithMaxRetries[Response](2), + WithRetryBaseDelay[Response](500*time.Millisecond), + WithRetryMaxDelay[Response](10*time.Second), + WithLogger[Response](logger), + ) + + resp, err := client.Get(server.URL) + if err != nil { + t.Fatalf("Request failed: %v", err) + } + + if resp.Data.Message != "success" { + t.Errorf("Expected message 'success', got '%s'", resp.Data.Message) + } + + // Verify logging occurred + logOutput := logBuf.String() + if !strings.Contains(logOutput, "retrying") { + t.Errorf("Expected retry log from GenericClient, got: %s", logOutput) + } +} From d89ff866ab924b9379f9f081caade6975013d4e7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christian=20Gonz=C3=A1lez=20Di=20Antonio?= Date: Sun, 18 Jan 2026 13:44:01 +0100 Subject: [PATCH 2/9] fix: github workflows and files --- .github/FUNDING.yml | 5 + .github/codeql/codeql-config.yml | 13 +++ .github/dependabot.yml | 11 ++ .github/release.yml | 15 +++ .github/workflows/codeql.yml | 100 ++++++++++++++++++ .github/workflows/{main.yaml => main.yml} | 0 .github/workflows/{pr.yaml => pr.yml} | 0 .../workflows/{release.yaml => release.yml} | 0 SECURITY.md | 14 +++ 9 files changed, 158 insertions(+) create mode 100644 .github/FUNDING.yml create mode 100644 .github/codeql/codeql-config.yml create mode 100644 .github/dependabot.yml create mode 100644 .github/release.yml create mode 100644 .github/workflows/codeql.yml rename .github/workflows/{main.yaml => main.yml} (100%) rename .github/workflows/{pr.yaml => pr.yml} (100%) rename .github/workflows/{release.yaml => release.yml} (100%) create mode 100644 SECURITY.md diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml new file mode 100644 index 0000000..2e5720d --- /dev/null +++ b/.github/FUNDING.yml @@ -0,0 +1,5 @@ +# These are supported funding model platforms +github: [christiangda] +liberapay: christiangda +patreon: christiangda +custom: ["https://paypal.me/slashdevops"] diff --git a/.github/codeql/codeql-config.yml b/.github/codeql/codeql-config.yml new file mode 100644 index 0000000..8554e6b --- /dev/null +++ b/.github/codeql/codeql-config.yml @@ -0,0 +1,13 @@ +name: CodeQL config +# queries: +# - name: Run custom queries +# uses: ./queries +# # Run all extra query suites, both because we want to +# # and because it'll act as extra testing. This is why +# # we include both even though one is a superset of the +# # other, because we're testing the parsing logic and +# # that the suites exist in the codeql bundle. +# - uses: security-extended +# - uses: security-and-quality +paths-ignore: + - mocks diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..e0871f9 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,11 @@ +# To get started with Dependabot version updates, you'll need to specify which +# package ecosystems to update and where the package manifests are located. +# Please see the documentation for all configuration options: +# https://docs.github.com/github/administering-a-repository/configuration-options-for-dependency-updates + +version: 2 +updates: + - package-ecosystem: "gomod" # See documentation for possible values + directory: "/" # Location of package manifests + schedule: + interval: "weekly" diff --git a/.github/release.yml b/.github/release.yml new file mode 100644 index 0000000..39d2e33 --- /dev/null +++ b/.github/release.yml @@ -0,0 +1,15 @@ +--- +# https://docs.github.com/es/repositories/releasing-projects-on-github/automatically-generated-release-notes +changelog: + categories: + - title: Breaking Changes 🛠 + labels: + - Semver-Major + - breaking-change + - title: New Features 🎉 + labels: + - Semver-Minor + - enhancement + - title: Other Changes + labels: + - "*" diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml new file mode 100644 index 0000000..73cce2b --- /dev/null +++ b/.github/workflows/codeql.yml @@ -0,0 +1,100 @@ +# For most projects, this workflow file will not need changing; you simply need +# to commit it to your repository. +# +# You may wish to alter this file to override the set of languages analyzed, +# or to provide custom queries or build logic. +# +# ******** NOTE ******** +# We have attempted to detect the languages in your repository. Please check +# the `language` matrix defined below to confirm you have the correct set of +# supported CodeQL languages. +# +name: "CodeQL Advanced" + +on: + push: + branches: ["main"] + pull_request: + branches: ["main"] + schedule: + - cron: "10 12 * * 3" + +jobs: + analyze: + name: Analyze (${{ matrix.language }}) + # Runner size impacts CodeQL analysis time. To learn more, please see: + # - https://gh.io/recommended-hardware-resources-for-running-codeql + # - https://gh.io/supported-runners-and-hardware-resources + # - https://gh.io/using-larger-runners (GitHub.com only) + # Consider using larger runners or machines with greater resources for possible analysis time improvements. + runs-on: ${{ (matrix.language == 'swift' && 'macos-latest') || 'ubuntu-latest' }} + permissions: + # required for all workflows + security-events: write + + # required to fetch internal or private CodeQL packs + packages: read + + # only required for workflows in private repositories + actions: read + contents: read + + strategy: + fail-fast: false + matrix: + include: + - language: actions + build-mode: none + - language: go + build-mode: autobuild + # CodeQL supports the following values keywords for 'language': 'actions', 'c-cpp', 'csharp', 'go', 'java-kotlin', 'javascript-typescript', 'python', 'ruby', 'swift' + # Use `c-cpp` to analyze code written in C, C++ or both + # Use 'java-kotlin' to analyze code written in Java, Kotlin or both + # Use 'javascript-typescript' to analyze code written in JavaScript, TypeScript or both + # To learn more about changing the languages that are analyzed or customizing the build mode for your analysis, + # see https://docs.github.com/en/code-security/code-scanning/creating-an-advanced-setup-for-code-scanning/customizing-your-advanced-setup-for-code-scanning. + # If you are analyzing a compiled language, you can modify the 'build-mode' for that language to customize how + # your codebase is analyzed, see https://docs.github.com/en/code-security/code-scanning/creating-an-advanced-setup-for-code-scanning/codeql-code-scanning-for-compiled-languages + steps: + - name: Checkout repository + uses: actions/checkout@v6 + + # Add any setup steps before running the `github/codeql-action/init` action. + # This includes steps like installing compilers or runtimes (`actions/setup-node` + # or others). This is typically only required for manual builds. + # - name: Setup runtime (example) + # uses: actions/setup-example@v1 + + # Initializes the CodeQL tools for scanning. + - name: Initialize CodeQL + uses: github/codeql-action/init@v4 + with: + languages: ${{ matrix.language }} + build-mode: ${{ matrix.build-mode }} + # If you wish to specify custom queries, you can do so here or in a config file. + # By default, queries listed here will override any specified in a config file. + # Prefix the list here with "+" to use these queries and those in the config file. + + # For more details on CodeQL's query packs, refer to: https://docs.github.com/en/code-security/code-scanning/automatically-scanning-your-code-for-vulnerabilities-and-errors/configuring-code-scanning#using-queries-in-ql-packs + # queries: security-extended,security-and-quality + + # If the analyze step fails for one of the languages you are analyzing with + # "We were unable to automatically build your code", modify the matrix above + # to set the build mode to "manual" for that language. Then modify this step + # to build your code. + # ℹ️ Command-line programs to run using the OS shell. + # 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun + - if: matrix.build-mode == 'manual' + shell: bash + run: | + echo 'If you are using a "manual" build mode for one or more of the' \ + 'languages you are analyzing, replace this with the commands to build' \ + 'your code, for example:' + echo ' make bootstrap' + echo ' make release' + exit 1 + + - name: Perform CodeQL Analysis + uses: github/codeql-action/analyze@v4 + with: + category: "/language:${{matrix.language}}" diff --git a/.github/workflows/main.yaml b/.github/workflows/main.yml similarity index 100% rename from .github/workflows/main.yaml rename to .github/workflows/main.yml diff --git a/.github/workflows/pr.yaml b/.github/workflows/pr.yml similarity index 100% rename from .github/workflows/pr.yaml rename to .github/workflows/pr.yml diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yml similarity index 100% rename from .github/workflows/release.yaml rename to .github/workflows/release.yml diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..7368260 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,14 @@ +# Security Policy + +This project is using [Github CodeQL](https://codeql.github.com/) tool to scan the code vulnerabilities and you can see the report in the pipeline +[![CodeQL Analysis](https://github.com/slashdevops/httpx/actions/workflows/codeql-analysis.yml/badge.svg)](https://github.com/slashdevops/httpx/actions/workflows/codeql-analysis.yml) + +## Supported Versions + +| Version | Supported | +| ------- | ------------------ | +| 0.0.x | :white_check_mark: | + +## Reporting a Vulnerability + +Use the [Project Issues --> Vulnerability](https://github.com/slashdevops/httpx/issues/new/choose) to report it From cf733e7ec6ad40a44ea2cb9ea4221879d55e16fd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christian=20Gonz=C3=A1lez=20Di=20Antonio?= Date: Sun, 18 Jan 2026 13:47:52 +0100 Subject: [PATCH 3/9] feat: update the go version --- go.mod | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/go.mod b/go.mod index bd44626..99f8867 100644 --- a/go.mod +++ b/go.mod @@ -1,3 +1,3 @@ module github.com/slashdevops/httpx -go 1.22.0 +go 1.25.2 From baa5bfbb5734eb3811a6ee106d17b09fa2161131 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christian=20Gonz=C3=A1lez=20Di=20Antonio?= Date: Sun, 18 Jan 2026 13:51:51 +0100 Subject: [PATCH 4/9] fix: install scc tools from binary --- .github/workflows/main.yml | 19 ++++++++++++++++++- .github/workflows/pr.yml | 19 +++++++++++++++++-- .github/workflows/release.yml | 19 +++++++++++++++++-- README.md | 2 ++ docs.go | 2 ++ go.mod | 2 +- 6 files changed, 57 insertions(+), 6 deletions(-) diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 90f6aa5..3148248 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -52,7 +52,24 @@ jobs: run: | echo "## Lines of code" >> $GITHUB_STEP_SUMMARY - go install github.com/boyter/scc/v3@latest + export TOOL_NAME="scc" + export GIT_ORG="boyter" + export GIT_REPO="scc" + export OS=$(uname -s | tr '[:upper:]' '[:lower:]') + export OS_ARCH=$(uname -m | tr '[:upper:]' '[:lower:]') + export ASSETS_NAME=$(gh release view --repo ${GIT_ORG}/${GIT_REPO} --json assets -q "[.assets[] | select(.name | contains(\"${TOOL_NAME}\") and contains(\"${OS}\") and contains(\"${OS_ARCH}\"))] | sort_by(.createdAt) | last.name") + export APP_NAME="${ASSETS_NAME%.*}" + + gh release download --repo $GIT_ORG/$GIT_REPO --pattern $ASSETS_NAME + unzip $ASSETS_NAME + mv $APP_NAME $TOOL_NAME + rm $ASSETS_NAME + + mv $TOOL_NAME ~/go/bin/$TOOL_NAME + ~/go/bin/$TOOL_NAME --version + + # go install github.com/boyter/scc/v3@latest + scc --format html-table . | tee -a $GITHUB_STEP_SUMMARY echo "" >> $GITHUB_STEP_SUMMARY diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index 46e0f67..e2741e6 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -55,9 +55,24 @@ jobs: - name: Lines of code run: | - echo "## Lines of code" >> $GITHUB_STEP_SUMMARY + export TOOL_NAME="scc" + export GIT_ORG="boyter" + export GIT_REPO="scc" + export OS=$(uname -s | tr '[:upper:]' '[:lower:]') + export OS_ARCH=$(uname -m | tr '[:upper:]' '[:lower:]') + export ASSETS_NAME=$(gh release view --repo ${GIT_ORG}/${GIT_REPO} --json assets -q "[.assets[] | select(.name | contains(\"${TOOL_NAME}\") and contains(\"${OS}\") and contains(\"${OS_ARCH}\"))] | sort_by(.createdAt) | last.name") + export APP_NAME="${ASSETS_NAME%.*}" + + gh release download --repo $GIT_ORG/$GIT_REPO --pattern $ASSETS_NAME + unzip $ASSETS_NAME + mv $APP_NAME $TOOL_NAME + rm $ASSETS_NAME + + mv $TOOL_NAME ~/go/bin/$TOOL_NAME + ~/go/bin/$TOOL_NAME --version + + # go install github.com/boyter/scc/v3@latest - go install github.com/boyter/scc/v3@latest scc --format html-table . | tee -a $GITHUB_STEP_SUMMARY echo "" >> $GITHUB_STEP_SUMMARY diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 1aa271a..5e9c027 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -40,9 +40,24 @@ jobs: - name: Lines of code run: | - echo "## Lines of code" >> $GITHUB_STEP_SUMMARY + export TOOL_NAME="scc" + export GIT_ORG="boyter" + export GIT_REPO="scc" + export OS=$(uname -s | tr '[:upper:]' '[:lower:]') + export OS_ARCH=$(uname -m | tr '[:upper:]' '[:lower:]') + export ASSETS_NAME=$(gh release view --repo ${GIT_ORG}/${GIT_REPO} --json assets -q "[.assets[] | select(.name | contains(\"${TOOL_NAME}\") and contains(\"${OS}\") and contains(\"${OS_ARCH}\"))] | sort_by(.createdAt) | last.name") + export APP_NAME="${ASSETS_NAME%.*}" + + gh release download --repo $GIT_ORG/$GIT_REPO --pattern $ASSETS_NAME + unzip $ASSETS_NAME + mv $APP_NAME $TOOL_NAME + rm $ASSETS_NAME + + mv $TOOL_NAME ~/go/bin/$TOOL_NAME + ~/go/bin/$TOOL_NAME --version + + # go install github.com/boyter/scc/v3@latest - go install github.com/boyter/scc/v3@latest scc --format html-table . | tee -a $GITHUB_STEP_SUMMARY echo "" >> $GITHUB_STEP_SUMMARY diff --git a/README.md b/README.md index 6a05c61..5a8ea20 100644 --- a/README.md +++ b/README.md @@ -39,6 +39,8 @@ A comprehensive Go package for building and executing HTTP requests with advance ## Installation +**Requirements:** Go 1.22 or higher + ```bash go get github.com/slashdevops/httpx ``` diff --git a/docs.go b/docs.go index 9d05272..3501858 100644 --- a/docs.go +++ b/docs.go @@ -2,6 +2,8 @@ // with advanced features including fluent request building, automatic retry logic, // and type-safe generic clients. // +// Requirements: Go 1.22 or higher is required to use this package. +// // Zero Dependencies: This package is built entirely using the Go standard library, // with no external dependencies. This ensures maximum reliability, security, and // minimal maintenance overhead for your projects. diff --git a/go.mod b/go.mod index 99f8867..bd44626 100644 --- a/go.mod +++ b/go.mod @@ -1,3 +1,3 @@ module github.com/slashdevops/httpx -go 1.25.2 +go 1.22.0 From b3ca029e2a840893ad39c490cdf937aefd5fc1fe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christian=20Gonz=C3=A1lez=20Di=20Antonio?= Date: Sun, 18 Jan 2026 13:53:33 +0100 Subject: [PATCH 5/9] fix: install scc tools from binary --- .github/workflows/main.yml | 2 ++ .github/workflows/pr.yml | 2 ++ .github/workflows/release.yml | 2 ++ 3 files changed, 6 insertions(+) diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 3148248..907f537 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -49,6 +49,8 @@ jobs: echo "" >> $GITHUB_STEP_SUMMARY - name: Lines of code + env: + GH_TOKEN: ${{ github.token }} run: | echo "## Lines of code" >> $GITHUB_STEP_SUMMARY diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index e2741e6..6bf2eea 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -54,6 +54,8 @@ jobs: echo "" >> $GITHUB_STEP_SUMMARY - name: Lines of code + env: + GH_TOKEN: ${{ github.token }} run: | export TOOL_NAME="scc" export GIT_ORG="boyter" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 5e9c027..c7439be 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -39,6 +39,8 @@ jobs: echo "" >> $GITHUB_STEP_SUMMARY - name: Lines of code + env: + GH_TOKEN: ${{ github.token }} run: | export TOOL_NAME="scc" export GIT_ORG="boyter" From c430513d6cab81c25f8920842ee1019ab0d8fb0b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christian=20Gonz=C3=A1lez=20Di=20Antonio?= Date: Sun, 18 Jan 2026 14:05:52 +0100 Subject: [PATCH 6/9] fix: install scc tools from binary --- .github/workflows/main.yml | 9 +++++---- .github/workflows/pr.yml | 7 +++++-- .github/workflows/release.yml | 7 +++++-- .gitignore | 2 ++ 4 files changed, 17 insertions(+), 8 deletions(-) diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 907f537..664ff12 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -52,13 +52,14 @@ jobs: env: GH_TOKEN: ${{ github.token }} run: | - echo "## Lines of code" >> $GITHUB_STEP_SUMMARY - export TOOL_NAME="scc" export GIT_ORG="boyter" export GIT_REPO="scc" - export OS=$(uname -s | tr '[:upper:]' '[:lower:]') - export OS_ARCH=$(uname -m | tr '[:upper:]' '[:lower:]') + export OS=$(uname -s) + export OS_ARCH=$(uname -m) + # Normalize architecture names to match asset naming + [[ "$OS_ARCH" == "aarch64" ]] && OS_ARCH="arm64" + [[ "$OS_ARCH" == "x86_64" ]] && OS_ARCH="x86_64" export ASSETS_NAME=$(gh release view --repo ${GIT_ORG}/${GIT_REPO} --json assets -q "[.assets[] | select(.name | contains(\"${TOOL_NAME}\") and contains(\"${OS}\") and contains(\"${OS_ARCH}\"))] | sort_by(.createdAt) | last.name") export APP_NAME="${ASSETS_NAME%.*}" diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index 6bf2eea..c09bd13 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -60,8 +60,11 @@ jobs: export TOOL_NAME="scc" export GIT_ORG="boyter" export GIT_REPO="scc" - export OS=$(uname -s | tr '[:upper:]' '[:lower:]') - export OS_ARCH=$(uname -m | tr '[:upper:]' '[:lower:]') + export OS=$(uname -s) + export OS_ARCH=$(uname -m) + # Normalize architecture names to match asset naming + [[ "$OS_ARCH" == "aarch64" ]] && OS_ARCH="arm64" + [[ "$OS_ARCH" == "x86_64" ]] && OS_ARCH="x86_64" export ASSETS_NAME=$(gh release view --repo ${GIT_ORG}/${GIT_REPO} --json assets -q "[.assets[] | select(.name | contains(\"${TOOL_NAME}\") and contains(\"${OS}\") and contains(\"${OS_ARCH}\"))] | sort_by(.createdAt) | last.name") export APP_NAME="${ASSETS_NAME%.*}" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index c7439be..f78ae74 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -45,8 +45,11 @@ jobs: export TOOL_NAME="scc" export GIT_ORG="boyter" export GIT_REPO="scc" - export OS=$(uname -s | tr '[:upper:]' '[:lower:]') - export OS_ARCH=$(uname -m | tr '[:upper:]' '[:lower:]') + export OS=$(uname -s) + export OS_ARCH=$(uname -m) + # Normalize architecture names to match asset naming + [[ "$OS_ARCH" == "aarch64" ]] && OS_ARCH="arm64" + [[ "$OS_ARCH" == "x86_64" ]] && OS_ARCH="x86_64" export ASSETS_NAME=$(gh release view --repo ${GIT_ORG}/${GIT_REPO} --json assets -q "[.assets[] | select(.name | contains(\"${TOOL_NAME}\") and contains(\"${OS}\") and contains(\"${OS_ARCH}\"))] | sort_by(.createdAt) | last.name") export APP_NAME="${ASSETS_NAME%.*}" diff --git a/.gitignore b/.gitignore index aaadf73..ed65f43 100644 --- a/.gitignore +++ b/.gitignore @@ -30,3 +30,5 @@ go.work.sum # Editor/IDE # .idea/ # .vscode/ + +out.json From 88b42f3680f2d510f28a56d20d1475b9f636e0cb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christian=20Gonz=C3=A1lez=20Di=20Antonio?= Date: Sun, 18 Jan 2026 14:07:57 +0100 Subject: [PATCH 7/9] fix: install scc tools from binary --- .github/workflows/main.yml | 11 ++++++++--- .github/workflows/pr.yml | 11 ++++++++--- .github/workflows/release.yml | 11 ++++++++--- 3 files changed, 24 insertions(+), 9 deletions(-) diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 664ff12..6d51dd1 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -61,11 +61,16 @@ jobs: [[ "$OS_ARCH" == "aarch64" ]] && OS_ARCH="arm64" [[ "$OS_ARCH" == "x86_64" ]] && OS_ARCH="x86_64" export ASSETS_NAME=$(gh release view --repo ${GIT_ORG}/${GIT_REPO} --json assets -q "[.assets[] | select(.name | contains(\"${TOOL_NAME}\") and contains(\"${OS}\") and contains(\"${OS_ARCH}\"))] | sort_by(.createdAt) | last.name") - export APP_NAME="${ASSETS_NAME%.*}" gh release download --repo $GIT_ORG/$GIT_REPO --pattern $ASSETS_NAME - unzip $ASSETS_NAME - mv $APP_NAME $TOOL_NAME + + # Extract based on file extension + if [[ "$ASSETS_NAME" == *.tar.gz ]]; then + tar -xzf $ASSETS_NAME + elif [[ "$ASSETS_NAME" == *.zip ]]; then + unzip $ASSETS_NAME + fi + rm $ASSETS_NAME mv $TOOL_NAME ~/go/bin/$TOOL_NAME diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index c09bd13..8ae21f0 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -66,11 +66,16 @@ jobs: [[ "$OS_ARCH" == "aarch64" ]] && OS_ARCH="arm64" [[ "$OS_ARCH" == "x86_64" ]] && OS_ARCH="x86_64" export ASSETS_NAME=$(gh release view --repo ${GIT_ORG}/${GIT_REPO} --json assets -q "[.assets[] | select(.name | contains(\"${TOOL_NAME}\") and contains(\"${OS}\") and contains(\"${OS_ARCH}\"))] | sort_by(.createdAt) | last.name") - export APP_NAME="${ASSETS_NAME%.*}" gh release download --repo $GIT_ORG/$GIT_REPO --pattern $ASSETS_NAME - unzip $ASSETS_NAME - mv $APP_NAME $TOOL_NAME + + # Extract based on file extension + if [[ "$ASSETS_NAME" == *.tar.gz ]]; then + tar -xzf $ASSETS_NAME + elif [[ "$ASSETS_NAME" == *.zip ]]; then + unzip $ASSETS_NAME + fi + rm $ASSETS_NAME mv $TOOL_NAME ~/go/bin/$TOOL_NAME diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index f78ae74..2aaa717 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -51,11 +51,16 @@ jobs: [[ "$OS_ARCH" == "aarch64" ]] && OS_ARCH="arm64" [[ "$OS_ARCH" == "x86_64" ]] && OS_ARCH="x86_64" export ASSETS_NAME=$(gh release view --repo ${GIT_ORG}/${GIT_REPO} --json assets -q "[.assets[] | select(.name | contains(\"${TOOL_NAME}\") and contains(\"${OS}\") and contains(\"${OS_ARCH}\"))] | sort_by(.createdAt) | last.name") - export APP_NAME="${ASSETS_NAME%.*}" gh release download --repo $GIT_ORG/$GIT_REPO --pattern $ASSETS_NAME - unzip $ASSETS_NAME - mv $APP_NAME $TOOL_NAME + + # Extract based on file extension + if [[ "$ASSETS_NAME" == *.tar.gz ]]; then + tar -xzf $ASSETS_NAME + elif [[ "$ASSETS_NAME" == *.zip ]]; then + unzip $ASSETS_NAME + fi + rm $ASSETS_NAME mv $TOOL_NAME ~/go/bin/$TOOL_NAME From a47d1cf3cca9dc16a9e14360adb0ef7bcc9212a1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christian=20Gonz=C3=A1lez=20Di=20Antonio?= Date: Sun, 18 Jan 2026 14:12:09 +0100 Subject: [PATCH 8/9] fix: install scc tools from binary --- .github/workflows/main.yml | 13 +++++++++---- .github/workflows/pr.yml | 13 +++++++++---- .github/workflows/release.yml | 13 +++++++++---- 3 files changed, 27 insertions(+), 12 deletions(-) diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 6d51dd1..d93282c 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -92,12 +92,17 @@ jobs: run: | echo "## Test Coverage" >> $GITHUB_STEP_SUMMARY - go install github.com/vladopajic/go-test-coverage/v2@latest - - # execute again to get the summary + # Generate coverage report using standard library tools echo "" >> $GITHUB_STEP_SUMMARY echo "### Coverage report" >> $GITHUB_STEP_SUMMARY - go-test-coverage --config=./.testcoverage.yml | sed 's/PASS/PASS ✅/g' | sed 's/FAIL/FAIL ❌/g' | tee -a $GITHUB_STEP_SUMMARY + echo '```' >> $GITHUB_STEP_SUMMARY + go tool cover -func=coverage.txt | tee -a $GITHUB_STEP_SUMMARY + echo '```' >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + + # Calculate total coverage percentage + total_coverage=$(go tool cover -func=coverage.txt | grep total | awk '{print $3}') + echo "**Total Coverage:** $total_coverage" >> $GITHUB_STEP_SUMMARY - name: Build run: | diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index 8ae21f0..3772aa7 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -97,9 +97,14 @@ jobs: run: | echo "## Test Coverage" >> $GITHUB_STEP_SUMMARY - go install github.com/vladopajic/go-test-coverage/v2@latest - - # execute again to get the summary + # Generate coverage report using standard library tools echo "" >> $GITHUB_STEP_SUMMARY echo "### Coverage report" >> $GITHUB_STEP_SUMMARY - go-test-coverage --config=./.testcoverage.yml | sed 's/PASS/PASS ✅/g' | sed 's/FAIL/FAIL ❌/g' | tee -a $GITHUB_STEP_SUMMARY + echo '```' >> $GITHUB_STEP_SUMMARY + go tool cover -func=coverage.txt | tee -a $GITHUB_STEP_SUMMARY + echo '```' >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + + # Calculate total coverage percentage + total_coverage=$(go tool cover -func=coverage.txt | grep total | awk '{print $3}') + echo "**Total Coverage:** $total_coverage" >> $GITHUB_STEP_SUMMARY diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 2aaa717..476a1fe 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -82,12 +82,17 @@ jobs: run: | echo "## Test Coverage" >> $GITHUB_STEP_SUMMARY - go install github.com/vladopajic/go-test-coverage/v2@latest - - # execute again to get the summary + # Generate coverage report using standard library tools echo "" >> $GITHUB_STEP_SUMMARY echo "### Coverage report" >> $GITHUB_STEP_SUMMARY - go-test-coverage --config=./.testcoverage.yml | sed 's/PASS/PASS ✅/g' | sed 's/FAIL/FAIL ❌/g' | tee -a $GITHUB_STEP_SUMMARY + echo '```' >> $GITHUB_STEP_SUMMARY + go tool cover -func=coverage.txt | tee -a $GITHUB_STEP_SUMMARY + echo '```' >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + + # Calculate total coverage percentage + total_coverage=$(go tool cover -func=coverage.txt | grep total | awk '{print $3}') + echo "**Total Coverage:** $total_coverage" >> $GITHUB_STEP_SUMMARY - name: Release uses: softprops/action-gh-release@v2 From 7a019130ab2f0a2ffbdc5181781ff1b896cd824c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christian=20Gonz=C3=A1lez=20Di=20Antonio?= Date: Sun, 18 Jan 2026 14:14:14 +0100 Subject: [PATCH 9/9] fix: workflows steps name --- .github/workflows/main.yml | 4 ++-- .github/workflows/pr.yml | 4 ++-- .github/workflows/release.yml | 4 ++-- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index d93282c..e520c6d 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -81,14 +81,14 @@ jobs: scc --format html-table . | tee -a $GITHUB_STEP_SUMMARY echo "" >> $GITHUB_STEP_SUMMARY - - name: test + - name: Test run: | echo "### Test report" >> $GITHUB_STEP_SUMMARY go test -race -coverprofile=coverage.txt -covermode=atomic -tags=unit ./... | tee -a $GITHUB_STEP_SUMMARY echo "" >> $GITHUB_STEP_SUMMARY - - name: test coverage + - name: Test coverage run: | echo "## Test Coverage" >> $GITHUB_STEP_SUMMARY diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index 3772aa7..0340984 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -86,14 +86,14 @@ jobs: scc --format html-table . | tee -a $GITHUB_STEP_SUMMARY echo "" >> $GITHUB_STEP_SUMMARY - - name: test + - name: Test run: | echo "### Test report" >> $GITHUB_STEP_SUMMARY go test -race -coverprofile=coverage.txt -covermode=atomic -tags=unit ./... | tee -a $GITHUB_STEP_SUMMARY echo "" >> $GITHUB_STEP_SUMMARY - - name: test coverage + - name: Test coverage run: | echo "## Test Coverage" >> $GITHUB_STEP_SUMMARY diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 476a1fe..a4276cd 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -71,14 +71,14 @@ jobs: scc --format html-table . | tee -a $GITHUB_STEP_SUMMARY echo "" >> $GITHUB_STEP_SUMMARY - - name: test + - name: Test run: | echo "### Test report" >> $GITHUB_STEP_SUMMARY go test -race -coverprofile=coverage.txt -covermode=atomic -tags=unit ./... | tee -a $GITHUB_STEP_SUMMARY echo "" >> $GITHUB_STEP_SUMMARY - - name: test coverage + - name: Test coverage run: | echo "## Test Coverage" >> $GITHUB_STEP_SUMMARY