Skip to content

Basic Requests

This guide covers everything you need to make HTTP requests with relay - from simple GETs to multipart file uploads, and from reading raw bytes to checking response success.

Creating a Client

Every relay interaction starts with a *relay.Client. Configure it once and reuse it across your application.

package main

import (
    "github.com/jhonsferg/relay"
)

func main() {
    client := relay.New(
        relay.WithBaseURL("https://api.example.com"),
        relay.WithTimeout(30 * time.Second),
    )
    _ = client
}

HTTP Methods

relay exposes a builder method for each standard HTTP verb. Each method returns a *relay.Request that you execute with client.Execute.

GET

package main

import (
    "context"
    "fmt"
    "time"

    "github.com/jhonsferg/relay"
)

func main() {
    client := relay.New(
        relay.WithBaseURL("https://api.example.com"),
        relay.WithTimeout(10 * time.Second),
    )

    req := client.Get("/users")
    resp, err := client.Execute(context.Background(), req)
    if err != nil {
        panic(err)
    }
    defer resp.Body.Close()

    fmt.Println("Status:", resp.StatusCode)
}

POST

package main

import (
    "context"
    "fmt"
    "time"

    "github.com/jhonsferg/relay"
)

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

type CreateUserResponse struct {
    ID    int    `json:"id"`
    Name  string `json:"name"`
    Email string `json:"email"`
}

func main() {
    client := relay.New(
        relay.WithBaseURL("https://api.example.com"),
        relay.WithTimeout(10 * time.Second),
    )

    body := CreateUserRequest{Name: "Alice", Email: "alice@example.com"}
    req := client.Post("/users").WithBody(body)
    resp, err := client.Execute(context.Background(), req)
    if err != nil {
        panic(err)
    }
    defer resp.Body.Close()

    user, err := relay.DecodeJSON[CreateUserResponse](resp)
    if err != nil {
        panic(err)
    }
    fmt.Printf("Created user %d: %s\n", user.ID, user.Name)
}

PUT

Use PUT to replace a resource entirely.

req := client.Put("/users/42").WithBody(UpdateUserRequest{
    Name:  "Alice Smith",
    Email: "alice.smith@example.com",
})
resp, err := client.Execute(ctx, req)

PATCH

Use PATCH for partial updates.

type PatchRequest struct {
    Name string `json:"name,omitempty"`
}

req := client.Patch("/users/42").WithBody(PatchRequest{Name: "Alice Updated"})
resp, err := client.Execute(ctx, req)

DELETE

req := client.Delete("/users/42")
resp, err := client.Execute(ctx, req)
if err != nil {
    panic(err)
}
if resp.IsSuccess() {
    fmt.Println("User deleted")
}

HEAD requests are useful to check if a resource exists or to inspect headers without downloading a body.

req := client.Head("/users/42")
resp, err := client.Execute(ctx, req)
if err != nil {
    panic(err)
}
fmt.Println("Content-Length:", resp.Header.Get("Content-Length"))
fmt.Println("Last-Modified:", resp.Header.Get("Last-Modified"))

Base URL Configuration

Set the base URL once on the client; request paths are resolved relative to it.

client := relay.New(
    relay.WithBaseURL("https://api.example.com/v2"),
)

// Resolves to https://api.example.com/v2/users
req := client.Get("/users")

Trailing slashes

relay trims trailing slashes from the base URL and leading slashes from paths before joining them, so WithBaseURL("https://example.com/v2/") and paths like "/users" or "users" all work correctly.

Adding Headers

Use WithHeader on the request builder to set individual headers.

req := client.Get("/users").
    WithHeader("X-Request-ID", "abc-123").
    WithHeader("Accept-Language", "en-US")

resp, err := client.Execute(ctx, req)

To add headers to every request, use WithDefaultHeader on the client:

client := relay.New(
    relay.WithBaseURL("https://api.example.com"),
    relay.WithHeader("X-App-Version", "2.1.0"),
)

Header precedence

Per-request headers set via req.WithHeader override client-level headers with the same name.

Query Parameters

Append query parameters with WithQueryParam. Multiple calls accumulate parameters.

req := client.Get("/users").
    WithQueryParam("page", "2").
    WithQueryParam("per_page", "50").
    WithQueryParam("sort", "created_at")

// Resulting URL: /users?page=2&per_page=50&sort=created_at
resp, err := client.Execute(ctx, req)

For building query strings dynamically:

params := map[string]string{
    "status": "active",
    "role":   "admin",
}

req := client.Get("/users")
for k, v := range params {
    req = req.WithQueryParam(k, v)
}

Request Body

JSON Body

Pass any Go value to WithBody. relay serializes it to JSON and sets Content-Type: application/json automatically.

type Order struct {
    ProductID int    `json:"product_id"`
    Quantity  int    `json:"quantity"`
    Note      string `json:"note,omitempty"`
}

req := client.Post("/orders").WithBody(Order{
    ProductID: 101,
    Quantity:  3,
    Note:      "Gift wrap please",
})

Form Data

To send application/x-www-form-urlencoded data, pass a url.Values:

import "net/url"

form := url.Values{}
form.Set("username", "alice")
form.Set("password", "secret")

req := client.Post("/login").
    WithHeader("Content-Type", "application/x-www-form-urlencoded").
    WithBody(strings.NewReader(form.Encode()))

Raw Bytes

For arbitrary binary payloads:

data := []byte{0x89, 0x50, 0x4E, 0x47} // PNG magic bytes (example)

req := client.Post("/upload").
    WithHeader("Content-Type", "image/png").
    WithBody(bytes.NewReader(data))

No Body

Methods like GET and DELETE should not have a body. Simply omit WithBody.

File Uploads (Multipart Form)

Use the standard library's multipart.Writer to build a multipart request body:

package main

import (
    "bytes"
    "context"
    "fmt"
    "mime/multipart"
    "os"

    "github.com/jhonsferg/relay"
)

func uploadFile(client *relay.Client, filePath string) error {
    fileData, err := os.ReadFile(filePath)
    if err != nil {
        return err
    }

    var buf bytes.Buffer
    mw := multipart.NewWriter(&buf)

    // Add a text field
    if err := mw.WriteField("description", "Profile photo"); err != nil {
        return err
    }

    // Add the file
    fw, err := mw.CreateFormFile("file", "photo.jpg")
    if err != nil {
        return err
    }
    if _, err := fw.Write(fileData); err != nil {
        return err
    }

    if err := mw.Close(); err != nil {
        return err
    }

    req := client.Post("/upload").
        WithHeader("Content-Type", mw.FormDataContentType()).
        WithBody(&buf)

    resp, err := client.Execute(context.Background(), req)
    if err != nil {
        return err
    }
    defer resp.Body.Close()

    fmt.Println("Upload status:", resp.StatusCode)
    return nil
}

Reading the Response

Status Code

resp, err := client.Execute(ctx, req)
if err != nil {
    panic(err)
}

fmt.Println("HTTP Status:", resp.StatusCode)
// e.g., 200, 201, 404, 500

Body as String

resp, err := client.Execute(ctx, req)
if err != nil {
    panic(err)
}

text, err := resp.Text()
if err != nil {
    panic(err)
}
fmt.Println(text)

Body can only be read once

resp.Body is an io.ReadCloser. After calling resp.Text() or resp.Bytes(), the body is consumed. Do not call them more than once, and always defer resp.Body.Close() when reading directly.

Body as Bytes

resp, err := client.Execute(ctx, req)
if err != nil {
    panic(err)
}

data, err := resp.Bytes()
if err != nil {
    panic(err)
}
fmt.Printf("Received %d bytes\n", len(data))

Body as JSON

Use the generic DecodeJSON[T] helper:

type Product struct {
    ID    int     `json:"id"`
    Name  string  `json:"name"`
    Price float64 `json:"price"`
}

resp, err := client.Execute(ctx, client.Get("/products/1"))
if err != nil {
    panic(err)
}

product, err := relay.DecodeJSON[Product](resp)
if err != nil {
    panic(err)
}
fmt.Printf("%s costs $%.2f\n", product.Name, product.Price)

Success and Error Checks

IsSuccess() returns true for 2xx status codes. IsError() returns true for 4xx and 5xx.

resp, err := client.Execute(ctx, req)
if err != nil {
    // Network error, timeout, circuit open, etc.
    panic(err)
}
defer resp.Body.Close()

if resp.IsSuccess() {
    fmt.Println("Request succeeded:", resp.StatusCode)
} else if resp.IsError() {
    body, _ := resp.Text()
    fmt.Printf("Request failed with %d: %s\n", resp.StatusCode, body)
}

HTTP errors vs Go errors

A 404 or 500 response does NOT return a non-nil Go error - err will be nil but resp.StatusCode will be 404/500. Use IsError() to detect HTTP-level failures. See Error Handling for the full picture.

Path Parameters Pattern

relay does not have built-in path parameter interpolation. Use fmt.Sprintf to build paths:

userID := 42
req := client.Get(fmt.Sprintf("/users/%d", userID))

// Or with string IDs
orgSlug := "acme-corp"
repoSlug := "my-app"
req = client.Get(fmt.Sprintf("/orgs/%s/repos/%s", orgSlug, repoSlug))

For more complex URL building, use net/url:

import "net/url"

base, _ := url.Parse("https://api.example.com")
ref, _ := url.Parse(fmt.Sprintf("/users/%d/settings", userID))
fullURL := base.ResolveReference(ref).String()

req := client.Get(fullURL)

Full Example: CRUD Operations

package main

import (
    "context"
    "fmt"
    "time"

    "github.com/jhonsferg/relay"
)

type Article struct {
    ID      int    `json:"id"`
    Title   string `json:"title"`
    Content string `json:"content"`
}

type ArticleInput struct {
    Title   string `json:"title"`
    Content string `json:"content"`
}

func main() {
    client := relay.New(
        relay.WithBaseURL("https://api.example.com/v1"),
        relay.WithTimeout(15 * time.Second),
        relay.WithBearerToken("my-secret-token"),
    )

    ctx := context.Background()

    // CREATE
    createReq := client.Post("/articles").WithBody(ArticleInput{
        Title:   "Hello, relay!",
        Content: "relay makes HTTP easy.",
    })
    createResp, err := client.Execute(ctx, createReq)
    if err != nil {
        panic(err)
    }
    article, _ := relay.DecodeJSON[Article](createResp)
    fmt.Printf("Created article %d: %s\n", article.ID, article.Title)

    // READ
    getReq := client.Get(fmt.Sprintf("/articles/%d", article.ID))
    getResp, err := client.Execute(ctx, getReq)
    if err != nil {
        panic(err)
    }
    fetched, _ := relay.DecodeJSON[Article](getResp)
    fmt.Printf("Fetched: %s\n", fetched.Title)

    // UPDATE
    patchReq := client.Patch(fmt.Sprintf("/articles/%d", article.ID)).
        WithBody(ArticleInput{Title: "Updated Title", Content: fetched.Content})
    _, err = client.Execute(ctx, patchReq)
    if err != nil {
        panic(err)
    }
    fmt.Println("Updated article")

    // DELETE
    deleteReq := client.Delete(fmt.Sprintf("/articles/%d", article.ID))
    deleteResp, err := client.Execute(ctx, deleteReq)
    if err != nil {
        panic(err)
    }
    if deleteResp.IsSuccess() {
        fmt.Println("Article deleted")
    }
}

Response Headers

Access response headers directly via resp.Header:

resp, err := client.Execute(ctx, req)
if err != nil {
    panic(err)
}

etag := resp.Header.Get("ETag")
contentType := resp.ContentType()
rateRemaining := resp.Header.Get("X-RateLimit-Remaining")

fmt.Printf("ETag: %s\n", etag)
fmt.Printf("Content-Type: %s\n", contentType)
fmt.Printf("Rate limit remaining: %s\n", rateRemaining)

Context and Cancellation

Every request accepts a context.Context. Use it for deadlines and cancellation:

ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()

req := client.Get("/slow-endpoint")
resp, err := client.Execute(ctx, req)
if err != nil {
    if errors.Is(err, context.DeadlineExceeded) {
        fmt.Println("Request timed out")
    }
    return
}

Next Steps