HTTP Client
The okapi/client package is a small fluent HTTP client:
- Accepts
context.Contextat the terminal call - Supports middleware / interceptors
- Built-in retry policy with exponential backoff
- Zero dependency on the okapi server package — usable against any REST API
Quick Start
import (
"context"
"time"
"github.com/jkaninda/okapi/client"
)
c := client.New("https://api.example.com",
client.WithBearerToken(token),
client.WithUserAgent("my-app/1.0"),
client.WithTimeout(10*time.Second),
)
var user User
resp, err := c.Get("/users/42").
WithContext(ctx).
QueryParam("expand", "profile").
Do()
if err != nil {
return err
}
if err := resp.Error(); err != nil {
return err // *client.HTTPError for non-2xx
}
if err := resp.JSON(&user); err != nil {
return err
}
Do issues the request. Send is an alias for Do — use whichever verb reads better:
resp, err := c.Get("/users/42").Send() // identical to Do()
For the common “do request, decode the response, fail on non-2xx” path, use Decode:
var user User
err := c.Get("/users/42").Decode(&user)
Decode also works on a response you already hold — the format is chosen from the Content-Type:
resp, _ := c.Get("/users/42").Do()
err := resp.Decode(&user)
Client Options
Options passed to client.New apply to every request issued by the client.
| Option | Purpose |
|---|---|
WithHTTPClient(*http.Client) | Provide a pre-configured http.Client (TLS, transport) |
WithTimeout(d) | Default per-request timeout |
WithHeader(k, v) | Add one default header |
WithHeaders(map) | Merge multiple default headers |
WithBearerToken(token) | Set Authorization: Bearer <token> |
WithBasicAuth(u, p) | Set Authorization: Basic ... |
WithUserAgent(ua) | Set the default User-Agent |
WithMiddleware(mw...) | Append middleware to the chain |
WithRetry(policy) | Default retry policy |
Request Builder
Every verb method returns a *RequestBuilder that supports fluent configuration:
resp, err := c.Post("/items").
WithContext(ctx).
Header("X-Trace-Id", traceID).
QueryParam("dry_run", "true").
JSONBody(Item{Title: "hello"}).
Timeout(5*time.Second).
Do()
Terminal Methods
| Method | Behavior |
|---|---|
Do() | Issues the request, returns (*Response, error) |
Send() | Alias for Do() |
Decode(target) | Do() + Response.Decode into target; returns *HTTPError on non-2xx |
Body Encoders
| Method | Sets Content-Type |
|---|---|
JSONBody(v any) | application/json |
XMLBody(v any) | application/xml |
YAMLBody(v any) | application/yaml |
FormBody(map[string]string) | application/x-www-form-urlencoded |
Multipart(func(*multipart.Writer) error) | multipart/form-data; boundary=… |
RawBody([]byte) | none (set with Header) |
Body(io.Reader) | none (set with Header) |
Auth Helpers
c.Get("/me").BearerToken(jwt).Send()
c.Get("/admin").BasicAuth("user", "pass").Send()
Per-Request Overrides
Builders can override client defaults for a single call:
c.Get("/big").
Timeout(30 * time.Second).
Retry(client.RetryPolicy{MaxAttempts: 5, BaseDelay: 100 * time.Millisecond}).
Middleware(client.LoggingMiddleware(os.Stdout)).
Do()
Response
Do returns a *Response that wraps the underlying *http.Response with the body already read into memory:
resp.IsSuccess() // 2xx?
resp.Error() // *HTTPError for non-2xx, nil otherwise
resp.String() // body as string
resp.Body // []byte
resp.Decode(&target) // format chosen from Content-Type (xml/yaml/json)
resp.JSON(&target)
resp.XML(&target)
resp.YAML(&target)
resp.JSONPath("user.profile.name") // dot-path lookup in a JSON object
resp.Cookie("sid") // *http.Cookie or nil
resp.Header // *http.Header
resp.StatusCode // int
Decode inspects the response Content-Type: an xml type decodes as XML, a yaml type as YAML, and anything else as JSON. Use JSON / XML / YAML directly when you need explicit control.
Middleware
Middleware composes around the underlying transport:
type RoundTripFunc func(*http.Request) (*http.Response, error)
type Middleware func(next RoundTripFunc) RoundTripFunc
Client middlewares are outermost; per-request middlewares run next; the retry middleware sits innermost. Built-in middlewares:
| Middleware | Behavior |
|---|---|
LoggingMiddleware(io.Writer) | One line per request (method, URL, status, duration) |
UserAgentMiddleware(ua) | Forces User-Agent on every request |
RequestIDMiddleware() | Sets X-Request-Id (random hex) if absent |
Custom middleware example:
auth := func(next client.RoundTripFunc) client.RoundTripFunc {
return func(req *http.Request) (*http.Response, error) {
req.Header.Set("X-Service-Token", currentServiceToken())
return next(req)
}
}
c := client.New(baseURL, client.WithMiddleware(auth))
Retries
c := client.New(baseURL, client.WithRetry(client.RetryPolicy{
MaxAttempts: 4,
BaseDelay: 100 * time.Millisecond,
MaxDelay: 2 * time.Second,
}))
Defaults when fields are zero:
MaxAttempts <= 1→ no retriesRetryOnStatusnil → retries on408,429,500,502,503,504MaxDelayzero → backoff doubles indefinitely- Transport errors (network failures) always retry while attempts remain
Custom retry predicate:
client.RetryPolicy{
MaxAttempts: 3,
BaseDelay: 50 * time.Millisecond,
ShouldRetry: func(resp *http.Response, err error) bool {
return err != nil || (resp != nil && resp.StatusCode == http.StatusBadGateway)
},
}
Request bodies are buffered once and rewound between attempts, so retries work for POST/PUT/PATCH out of the box. Backoff is interrupted when the request context is cancelled.
Errors
resp, err := c.Get("/missing").Do()
if err != nil {
return err // transport/build error
}
if err := resp.Error(); err != nil {
var hErr *client.HTTPError
if errors.As(err, &hErr) {
fmt.Println(hErr.StatusCode, string(hErr.Body))
}
return err
}
Do (and its alias Send) never returns HTTPError — a non-2xx response is a valid response. Opt in via resp.Error() (or Decode, which does it for you).