Skip to content

Repository files navigation

🌐 fetch-go

An ergonomic, axios-inspired HTTP client for Go

Go Version Go Reference License: MIT Go Report Card Tests


fetch is a lightweight, zero-dependency HTTP client for Go that brings the ergonomics of JavaScript's axios to idiomatic Go. It wraps net/http with a fluent builder API, typed errors, interceptors, and first-class JSON support — without hiding the standard library from you.

err := fetch.Get("https://jsonplaceholder.typicode.com/posts").Scan(&posts)
if err != nil {
    log.Fatal("Fetch error:", err)
}

log.Printf("First post: %+v", posts)


// -------------> With Client <------------- //

client := fetch.New("https://api.example.com")

var users []User
err := client.Get("/users").
    WithParam("page", "1").
    WithBearerToken(token).
    Scan(&users)

// POST with JSON body
resp, err := client.Post("/users", User{Name: "Alice"}).Do()

// PUT
resp, err := client.Put("/users/1", User{Name: "Alice Updated"}).Do()

// PATCH
resp, err := client.Patch("/users/1", map[string]string{"name": "Alice"}).Do()

// DELETE (body is optional — pass nil)
resp, err := client.Delete("/users/1", nil).Do()

Table of Contents


Features

  • Fluent builder API — chain methods naturally, no boilerplate
  • All HTTP verbsGET, POST, PUT, PATCH, DELETE, HEAD, OPTIONS
  • Body formats — JSON, form-encoded, multipart, plain text, raw io.Reader
  • Typed errors — non-2xx responses become *FetchError with status helpers
  • Interceptors — hook into every request and response (logging, auth, retries)
  • First-class JSONScan(&v) encodes the body and decodes the response in one call
  • Auth helpersWithBearerToken, WithBasicAuth
  • Zero external dependencies — only the Go standard library
  • Context-aware — every request respects context.Context for cancellation and deadlines
  • Safe for concurrent use — share one Client across all goroutines

Requirements

Requirement Version
Go 1.22 or higher

No external dependencies. go.sum will be empty.


Installation

go get github.com/rixotech/fetch-go@latest

Then import it in your code:

import "github.com/rixotech/fetch-go"

Quick Start

package main

import (
    "context"
    "fmt"
    "log"
    "time"

    "github.com/rixotech/fetch-go"
)

type Post struct {
    ID    int    `json:"id"`
    Title string `json:"title"`
    Body  string `json:"body"`
}

func main() {
    // Create a reusable client
    client, err := fetch.New("https://jsonplaceholder.typicode.com",
        fetch.WithTimeout(10*time.Second),
    )
    if err != nil {
        log.Fatal(err)
    }

    ctx := context.Background()

    // GET — decode response into a struct
    var post Post
    if err := client.GetWithContext(ctx, "/posts/1").Scan(&post); err != nil {
        log.Fatal(err)
    }
    fmt.Println(post.Title)

    // POST — send JSON body, decode created resource
    var created Post
    err = client.PostWithContext(ctx, "/posts", Post{Title: "Hello", Body: "World"}).
        Scan(&created)
    if err != nil {
        log.Fatal(err)
    }
    fmt.Println(created.ID)
}

Usage Guide

Creating a Client

A Client is bound to a base URL and reused across requests. Create it once and share it freely — it is safe for concurrent use.

client, err := fetch.New("https://api.example.com")

With options:

client, err := fetch.New("https://api.example.com",
    // Total timeout per request (default: 30s)
    fetch.WithTimeout(15*time.Second),

    // Headers sent with every request
    fetch.WithDefaultHeaders(map[string]string{
        "Accept":     "application/json",
        "User-Agent": "my-app/1.0",
    }),

    // Supply your own *http.Client (custom TLS, proxy, cookie jar, etc.)
    fetch.WithHTTPClient(myHTTPClient),

    // Disable automatic *FetchError for non-2xx responses
    fetch.WithoutErrorOnStatus(),
)

Making Requests

Every method returns a *Request builder. Nothing is sent until you call Do() or Scan().

// GET
resp, err := client.Get("/users").Do()

// POST with JSON body
resp, err := client.Post("/users", User{Name: "Alice"}).Do()

// PUT
resp, err := client.Put("/users/1", User{Name: "Alice Updated"}).Do()

// PATCH
resp, err := client.Patch("/users/1", map[string]string{"name": "Alice"}).Do()

// DELETE (body is optional — pass nil)
resp, err := client.Delete("/users/1", nil).Do()

// HEAD
resp, err := client.Head("/users").Do()

// OPTIONS
resp, err := client.Options("/users").Do()


// With context
ctx := context.Background()

resp, err := client.GetWithContext(ctx, "/users").Do()

// POST with JSON body
resp, err := client.PostWithContext(ctx, "/users", User{Name: "Alice"}).Do()

// PUT
resp, err := client.PutWithContext(ctx, "/users/1", User{Name: "Alice Updated"}).Do()

// PATCH
resp, err := client.PatchWithContext(ctx, "/users/1", map[string]string{"name": "Alice"}).Do()

// DELETE (body is optional — pass nil)
resp, err := client.DeleteWithContext(ctx, "/users/1", nil).Do()

Query Parameters

// Set multiple at once
resp, err := client.Get("/search").
    WithParams(map[string]string{
        "q":     "golang",
        "page":  "1",
        "limit": "20",
    }).
    Do()

// Or set individually
resp, err := client.Get("/search").
    WithParam("q", "golang").
    WithParam("page", "1").
    Do()

// Resulting URL: https://api.example.com/search?limit=20&page=1&q=golang

Headers & Auth

// Set multiple headers
resp, err := client.Get("/data").
    WithHeaders(map[string]string{
        "X-Request-ID": "abc-123",
        "X-Tenant-ID":  "acme",
    }).
    Do()

// Set a single header
resp, err := client.Get("/data").
    WithHeader("X-Request-ID", "abc-123").
    Do()

// Bearer token shorthand
resp, err := client.Get("/profile").
    WithBearerToken("your-jwt-token").
    Do()

// HTTP Basic Auth
resp, err := client.Get("/admin").
    WithBasicAuth("username", "password").
    Do()

Per-request headers always override client-level default headers for the same key.


Request Bodies

JSON (default for Post, Put, Patch):

type CreateUserReq struct {
    Name  string `json:"name"`
    Email string `json:"email"`
}

resp, err := client.Post("/users", CreateUserReq{
    Name:  "Alice",
    Email: "alice@example.com",
}).Do()

Override / replace the body at any point in the chain:

// JSON
resp, err := client.Post("/data", nil).
    WithJSONBody(map[string]any{"key": "value"}).
    Do()

// URL-encoded form
resp, err := client.Post("/login", nil).
    WithFormBody(map[string]string{
        "username": "alice",
        "password": "s3cr3t",
    }).
    Do()

// Multipart form (file upload)
fileBytes, _ := os.ReadFile("avatar.png")
resp, err := client.Post("/upload", nil).
    WithMultipartBody(map[string]any{
        "avatar": fileBytes,  // []byte → file field
        "caption": "My photo", // string → text field
    }).
    Do()

// Plain text
resp, err := client.Post("/logs", nil).
    WithTextBody("something happened at 12:00").
    Do()

// Raw reader (e.g. a file, a buffer)
f, _ := os.Open("data.bin")
defer f.Close()
resp, err := client.Post("/upload", nil).
    WithRawBody(f, "application/octet-stream").
    Do()

Reading Responses

Scan — the fastest path for JSON APIs:

var user User
// Executes the request AND decodes the JSON body in one call.
err := client.Get("/users/1").Scan(&user)

Do — when you need the response itself:

resp, err := client.Get("/users/1").Do()
if err != nil {
    return err
}

// Decode options (call exactly one — each closes the body)
var user User
err = resp.JSON(&user)      // JSON → struct
text, err := resp.Text()    // body as string
raw, err := resp.Bytes()    // body as []byte

// Status helpers
resp.IsOK()           // 2xx
resp.IsRedirect()     // 3xx
resp.IsClientError()  // 4xx
resp.IsServerError()  // 5xx

// Underlying *http.Response (body not yet read)
resp.StatusCode   // int
resp.Status       // "200 OK"
resp.Header       // http.Header
resp.Raw          // *http.Response

Error Handling

By default, any non-2xx response is returned as *FetchError. This means you never need to check resp.IsOK() manually.

var user User
err := client.Get("/users/99").Scan(&user)
if err != nil {
    if fe, ok := fetch.AsFetchError(err); ok {
        // HTTP-level error
        fmt.Println(fe.StatusCode) // e.g. 404
        fmt.Println(fe.Status)     // e.g. "404 Not Found"
        fmt.Println(string(fe.Body)) // raw response body

        switch {
        case fe.IsNotFound():
            // handle 404
        case fe.IsUnauthorized():
            // handle 401 — refresh token, redirect to login, etc.
        case fe.IsForbidden():
            // handle 403
        case fe.IsServerError():
            // handle 5xx
        }
    }
    // Network / timeout / interceptor error
    return err
}

Opt out of automatic error wrapping (handle every status yourself):

client, _ := fetch.New("https://api.example.com",
    fetch.WithoutErrorOnStatus(),
)

resp, err := client.Get("/maybe-404").Do()
if err != nil {
    return err // only network errors reach here
}
if !resp.IsOK() {
    // decide what to do with 4xx / 5xx
}

Interceptors

Interceptors run on every request/response made by a client — ideal for cross-cutting concerns like logging, auth token injection, and metrics.

// ── Request interceptor ───────────────────────────────────────────────────
// Injects a correlation ID into every outgoing request.
client.UseRequest(func(req *http.Request) (*http.Request, error) {
    req.Header.Set("X-Request-ID", uuid.New().String())
    return req, nil
})

// Refreshes an expired bearer token transparently.
client.UseRequest(func(req *http.Request) (*http.Request, error) {
    token, err := tokenStore.Valid()
    if err != nil {
        return nil, err // aborts the request
    }
    req.Header.Set("Authorization", "Bearer "+token)
    return req, nil
})

// ── Response interceptor ──────────────────────────────────────────────────
// Structured request logging.
client.UseResponse(func(resp *http.Response) (*http.Response, error) {
    log.Printf("[fetch] %s %s → %s",
        resp.Request.Method,
        resp.Request.URL.Path,
        resp.Status,
    )
    return resp, nil
})

// Multiple interceptors are executed in registration order.
client.UseRequest(interceptorA, interceptorB, interceptorC)

Package-level API

For scripts and one-off requests where creating a Client is overkill, use the package-level functions. They work without a base URL.

// No client needed — just pass a full URL.
var post Post
err := fetch.Get("https://jsonplaceholder.typicode.com/posts/1").
    Scan(&post)

err = fetch.Post("https://example.com/events",
    map[string]string{"event": "signup"},
).Do()

// Adjust the default timeout globally.
fetch.SetDefaultTimeout(5 * time.Second)

API Reference

Client

Method Description
fetch.New(baseURL, ...Option) Create a new client
client.UseRequest(...fn) Register request interceptor(s)
client.UseResponse(...fn) Register response interceptor(s)

Options

Option Description Default
WithTimeout(d) Request timeout 30s
WithHTTPClient(c) Custom *http.Client built-in
WithDefaultHeaders(m) Headers sent with every request
WithoutErrorOnStatus() Disable *FetchError for non-2xx enabled

*Request (builder)

Method Description
WithParam(key, value) Set a single query parameter
WithParams(map) Set multiple query parameters
WithHeader(key, value) Set a single header
WithHeaders(map) Set multiple headers
WithBearerToken(token) Set Authorization: Bearer <token>
WithBasicAuth(user, pass) Set HTTP Basic Auth header
WithJSONBody(v) Set a JSON-encoded body
WithFormBody(map) Set a URL-encoded form body
WithMultipartBody(map) Set a multipart/form-data body
WithTextBody(s) Set a plain-text body
WithRawBody(r, contentType) Set an arbitrary io.Reader body
Do() Execute → (*Response, error)
Scan(v) Execute + JSON-decode → error

*Response

Method Description
JSON(v) Decode body as JSON into v
XML(v) Decode body as XML into v
Text() Read body as string
Bytes() Read body as []byte
IsOK() true for 2xx
IsRedirect() true for 3xx
IsClientError() true for 4xx
IsServerError() true for 5xx

*FetchError

Field / Method Description
StatusCode int HTTP status code
Status string HTTP status string (e.g. "404 Not Found")
Body []byte Raw response body
Header http.Header Response headers
IsNotFound() true for 404
IsUnauthorized() true for 401
IsForbidden() true for 403
IsServerError() true for 5xx
fetch.AsFetchError(err) Unwrap any error to *FetchError

Contributing

Contributions are welcome and appreciated. Please follow these steps.

1. Fork & clone

git clone https://github.com/rixotech/fetch-go.git
cd fetch

2. Create a feature branch

git checkout -b feat/your-feature-name
# or for bug fixes:
git checkout -b fix/what-was-broken

3. Make your changes

  • Keep the public API backward compatible unless the change is intentional and documented.
  • Add or update tests for every changed behaviour.
  • Ensure all exported symbols have Go doc comments.

4. Run tests and checks

# All tests must pass
go test ./... -race -count=1

# No vet warnings
go vet ./...

# Format your code
gofmt -w .

5. Commit with a clear message

We follow Conventional Commits:

git commit -m "feat: add WithRetry option for automatic retries"
git commit -m "fix: nil pointer when base URL has trailing slash"
git commit -m "docs: add multipart upload example to README"
git commit -m "test: cover 401 interceptor refresh scenario"

6. Open a Pull Request

Push your branch and open a PR against main. Include:

  • What the change does
  • Why it is needed
  • Any breaking changes (if applicable)

Reporting bugs

Open an issue at github.com/rixotech/fetch-go/issues and include:

  • Go version (go version)
  • OS and architecture
  • A minimal code snippet that reproduces the bug
  • Expected vs actual behaviour

Suggesting features

Open a GitHub Discussion before opening a PR for large changes, so the design can be agreed on first.


Changelog

v0.1.0 — Initial Release

  • Fluent request builder with full HTTP verb support
  • JSON, form, multipart, text, and raw body types
  • Typed *FetchError with status helpers
  • Request and response interceptor chains
  • WithBearerToken and WithBasicAuth auth helpers
  • Scan(v) one-liner for JSON decode
  • Package-level convenience API

License

Released under the MIT License. Copyright (c) 2026 RixoTech.


Made with ☕ and Go · Documentation · Report a Bug

About

An ergonomic, axios-inspired HTTP client for Go — fluent builder API, typed errors, interceptors, and first-class JSON support with zero external dependencies.

Topics

Resources

Stars

1 star

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages