API Client

HTTP client utilities for calling Lokstra services locally and remotely

Overview

The api_client package provides utilities for making HTTP requests to Lokstra services, with support for both local (in-process) and remote (HTTP) communication. It includes type-safe response parsing, error handling, and automatic request/response formatting.

Import Path

import "github.com/primadi/lokstra/common/api_client"

Core Functions

FetchAndCast

Type-safe fetch helper with flexible options.

Signature:

func FetchAndCast[T any](
    client *ClientRouter,
    path string,
    opts ...FetchOption,
) (T, error)

Type Parameters:

Parameters:

Returns:

Example:

// Simple GET request
user, err := api_client.FetchAndCast[*User](client, "/users/123")
if err != nil {
    log.Fatal(err)
}

// POST request with body
created, err := api_client.FetchAndCast[*User](client, "/users",
    api_client.WithMethod("POST"),
    api_client.WithBody(newUser),
)

// Custom headers
data, err := api_client.FetchAndCast[*Data](client, "/data",
    api_client.WithHeaders(map[string]string{
        "Authorization": "Bearer " + token,
        "X-Request-ID":  requestID,
    }),
)

Features:


Fetch Options

WithMethod

Sets the HTTP method for the request.

Signature:

func WithMethod(method string) FetchOption

Example:

api_client.FetchAndCast[*User](client, "/users",
    api_client.WithMethod("POST"))

api_client.FetchAndCast[*User](client, "/users/123",
    api_client.WithMethod("PUT"))

WithBody

Sets the request body (auto-marshaled to JSON).

Signature:

func WithBody(body any) FetchOption

Example:

newUser := &User{
    Name:  "John Doe",
    Email: "john@example.com",
}

created, err := api_client.FetchAndCast[*User](client, "/users",
    api_client.WithMethod("POST"),
    api_client.WithBody(newUser),
)

WithHeaders

Sets custom headers for the request.

Signature:

func WithHeaders(headers map[string]string) FetchOption

Example:

data, err := api_client.FetchAndCast[*Data](client, "/data",
    api_client.WithHeaders(map[string]string{
        "Authorization":  "Bearer " + token,
        "X-Request-ID":   requestID,
        "X-Custom-Header": "custom-value",
    }),
)

WithFormatter

Sets a custom response formatter.

Signature:

func WithFormatter(formatter api_formatter.ResponseFormatter) FetchOption

Example:

customFormatter := &MyCustomFormatter{}

data, err := api_client.FetchAndCast[*Data](client, "/data",
    api_client.WithFormatter(customFormatter),
)

WithCustomFunc

Custom handling of response with full control.

Signature:

func WithCustomFunc(
    fn func(*http.Response, *api_formatter.ClientResponse) (any, error),
) FetchOption

Example:

data, err := api_client.FetchAndCast[*Data](client, "/data",
    api_client.WithCustomFunc(func(resp *http.Response, clientResp *api_formatter.ClientResponse) (any, error) {
        // Custom validation
        if resp.StatusCode == 204 {
            return &Data{Empty: true}, nil
        }
        
        // Custom parsing
        if clientResp.Data != nil {
            return parseCustomData(clientResp.Data), nil
        }
        
        return nil, nil // Continue with default flow
    }),
)

Error Handling

ApiError

Structured error with HTTP status code information.

Definition:

type ApiError struct {
    StatusCode int            // HTTP status code (400, 401, 404, 500, etc.)
    Code       string         // Error code (e.g., "VALIDATION_ERROR")
    Message    string         // Human-readable error message
    Details    map[string]any // Optional additional details
}

Methods:

func (e *ApiError) Error() string
func (e *ApiError) IsClientError() bool     // 4xx
func (e *ApiError) IsServerError() bool     // 5xx
func (e *ApiError) IsBadRequest() bool      // 400
func (e *ApiError) IsUnauthorized() bool    // 401
func (e *ApiError) IsForbidden() bool       // 403
func (e *ApiError) IsNotFound() bool        // 404

Example:

user, err := api_client.FetchAndCast[*User](client, "/users/123")
if err != nil {
    if apiErr, ok := err.(*api_client.ApiError); ok {
        switch {
        case apiErr.IsNotFound():
            return ctx.Api.NotFound("User not found")
        case apiErr.IsUnauthorized():
            return ctx.Api.Unauthorized("Authentication required")
        case apiErr.IsBadRequest():
            return ctx.Api.BadRequest(apiErr.Message)
        default:
            return ctx.Api.Error(apiErr.StatusCode, apiErr.Code, apiErr.Message)
        }
    }
    return ctx.Api.InternalError(err.Error())
}

NewApiError

Creates a new ApiError.

Signature:

func NewApiError(statusCode int, code, message string) *ApiError

Example:

err := api_client.NewApiError(404, "NOT_FOUND", "Resource not found")

NewApiErrorWithDetails

Creates ApiError with additional details.

Signature:

func NewApiErrorWithDetails(
    statusCode int,
    code, message string,
    details map[string]any,
) *ApiError

Example:

err := api_client.NewApiErrorWithDetails(
    400,
    "VALIDATION_ERROR",
    "Invalid input",
    map[string]any{
        "fields": []string{"email", "password"},
        "constraints": map[string]string{
            "email":    "must be valid email",
            "password": "must be at least 8 characters",
        },
    },
)

Complete Examples

Simple GET Request

package service

import (
    "github.com/primadi/lokstra/common/api_client"
    "github.com/primadi/lokstra/lokstra_registry"
)

type UserService struct {
    client *api_client.ClientRouter
}

func NewUserService() *UserService {
    return &UserService{
        client: lokstra_registry.GetClientRouter("user-service"),
    }
}

func (s *UserService) GetUser(id int) (*User, error) {
    path := fmt.Sprintf("/users/%d", id)
    return api_client.FetchAndCast[*User](s.client, path)
}

func (s *UserService) ListUsers() ([]*User, error) {
    return api_client.FetchAndCast[[]*User](s.client, "/users")
}

POST Request with Body

func (s *UserService) CreateUser(user *User) (*User, error) {
    return api_client.FetchAndCast[*User](s.client, "/users",
        api_client.WithMethod("POST"),
        api_client.WithBody(user),
    )
}

func (s *UserService) UpdateUser(id int, user *User) (*User, error) {
    path := fmt.Sprintf("/users/%d", id)
    return api_client.FetchAndCast[*User](s.client, path,
        api_client.WithMethod("PUT"),
        api_client.WithBody(user),
    )
}

func (s *UserService) DeleteUser(id int) error {
    path := fmt.Sprintf("/users/%d", id)
    _, err := api_client.FetchAndCast[any](s.client, path,
        api_client.WithMethod("DELETE"),
    )
    return err
}

Request with Authentication

type AuthenticatedService struct {
    client *api_client.ClientRouter
    token  string
}

func (s *AuthenticatedService) GetProtectedData() (*Data, error) {
    return api_client.FetchAndCast[*Data](s.client, "/protected/data",
        api_client.WithHeaders(map[string]string{
            "Authorization": "Bearer " + s.token,
        }),
    )
}

func (s *AuthenticatedService) CreateOrder(order *Order) (*Order, error) {
    return api_client.FetchAndCast[*Order](s.client, "/orders",
        api_client.WithMethod("POST"),
        api_client.WithBody(order),
        api_client.WithHeaders(map[string]string{
            "Authorization": "Bearer " + s.token,
            "X-Idempotency-Key": generateIdempotencyKey(),
        }),
    )
}

Error Handling Pattern

func (s *UserService) GetUser(ctx *request.Context, id int) error {
    path := fmt.Sprintf("/users/%d", id)
    user, err := api_client.FetchAndCast[*User](s.client, path)
    
    if err != nil {
        // Check if it's an API error
        if apiErr, ok := err.(*api_client.ApiError); ok {
            // Handle specific error types
            switch {
            case apiErr.IsNotFound():
                return ctx.Api.NotFound("User not found")
                
            case apiErr.IsUnauthorized():
                return ctx.Api.Unauthorized("Authentication required")
                
            case apiErr.IsBadRequest():
                return ctx.Api.BadRequest(apiErr.Message)
                
            case apiErr.IsServerError():
                log.Printf("Upstream server error: %v", apiErr)
                return ctx.Api.InternalError("Service temporarily unavailable")
                
            default:
                return ctx.Api.Error(apiErr.StatusCode, apiErr.Code, apiErr.Message)
            }
        }
        
        // Other error types (network, timeout, etc.)
        log.Printf("Request failed: %v", err)
        return ctx.Api.InternalError("Failed to fetch user")
    }
    
    return ctx.Api.Ok(user)
}

Custom Response Parsing

func (s *DataService) GetCustomData() (*CustomData, error) {
    return api_client.FetchAndCast[*CustomData](s.client, "/data",
        api_client.WithCustomFunc(func(resp *http.Response, clientResp *api_formatter.ClientResponse) (any, error) {
            // Handle empty response
            if resp.StatusCode == 204 {
                return &CustomData{Empty: true}, nil
            }
            
            // Validate custom headers
            if resp.Header.Get("X-Data-Version") != "v2" {
                return nil, fmt.Errorf("unsupported data version")
            }
            
            // Custom parsing logic
            if clientResp.Data != nil {
                data := &CustomData{}
                if err := parseCustomFormat(clientResp.Data, data); err != nil {
                    return nil, err
                }
                return data, nil
            }
            
            return nil, nil // Continue with default flow
        }),
    )
}

Pagination Pattern

type PaginatedResponse struct {
    Items      []*User `json:"items"`
    Page       int     `json:"page"`
    PageSize   int     `json:"page_size"`
    TotalItems int     `json:"total_items"`
    TotalPages int     `json:"total_pages"`
}

func (s *UserService) ListUsersPaginated(page, pageSize int) (*PaginatedResponse, error) {
    path := fmt.Sprintf("/users?page=%d&page_size=%d", page, pageSize)
    return api_client.FetchAndCast[*PaginatedResponse](s.client, path)
}

func (s *UserService) GetAllUsers() ([]*User, error) {
    var allUsers []*User
    page := 1
    pageSize := 100
    
    for {
        resp, err := s.ListUsersPaginated(page, pageSize)
        if err != nil {
            return nil, err
        }
        
        allUsers = append(allUsers, resp.Items...)
        
        if page >= resp.TotalPages {
            break
        }
        
        page++
    }
    
    return allUsers, nil
}

Retry Pattern

func (s *UserService) GetUserWithRetry(id int, maxRetries int) (*User, error) {
    path := fmt.Sprintf("/users/%d", id)
    
    var lastErr error
    for attempt := 0; attempt <= maxRetries; attempt++ {
        user, err := api_client.FetchAndCast[*User](s.client, path)
        if err == nil {
            return user, nil
        }
        
        // Check if error is retryable
        if apiErr, ok := err.(*api_client.ApiError); ok {
            if apiErr.IsClientError() {
                // Don't retry client errors (4xx)
                return nil, err
            }
        }
        
        lastErr = err
        
        // Wait before retry (exponential backoff)
        if attempt < maxRetries {
            time.Sleep(time.Duration(1<<attempt) * time.Second)
        }
    }
    
    return nil, fmt.Errorf("max retries exceeded: %w", lastErr)
}

Circuit Breaker Pattern

type CircuitBreaker struct {
    failures     int
    lastFailTime time.Time
    threshold    int
    timeout      time.Duration
    mu           sync.Mutex
}

func (s *UserService) GetUserWithCircuitBreaker(id int) (*User, error) {
    s.cb.mu.Lock()
    
    // Check if circuit is open
    if s.cb.failures >= s.cb.threshold {
        if time.Since(s.cb.lastFailTime) < s.cb.timeout {
            s.cb.mu.Unlock()
            return nil, fmt.Errorf("circuit breaker open")
        }
        // Reset after timeout
        s.cb.failures = 0
    }
    
    s.cb.mu.Unlock()
    
    // Make request
    path := fmt.Sprintf("/users/%d", id)
    user, err := api_client.FetchAndCast[*User](s.client, path)
    
    if err != nil {
        s.cb.mu.Lock()
        s.cb.failures++
        s.cb.lastFailTime = time.Now()
        s.cb.mu.Unlock()
        return nil, err
    }
    
    // Reset on success
    s.cb.mu.Lock()
    s.cb.failures = 0
    s.cb.mu.Unlock()
    
    return user, nil
}

Best Practices

1. Use Type-Safe FetchAndCast

// ✅ Good: Type-safe
user, err := api_client.FetchAndCast[*User](client, "/users/123")

// 🚫 Avoid: Untyped response
resp, err := client.GET("/users/123")
var user User
json.Unmarshal(resp.Body, &user)

2. Handle ApiError Properly

// ✅ Good: Check error type and handle appropriately
if apiErr, ok := err.(*api_client.ApiError); ok {
    switch {
    case apiErr.IsNotFound():
        return handleNotFound()
    case apiErr.IsUnauthorized():
        return handleUnauthorized()
    }
}

// 🚫 Avoid: Generic error handling
if err != nil {
    return err // Loses status code information
}

3. Use Options for Clarity

// ✅ Good: Clear intent
api_client.FetchAndCast[*User](client, "/users",
    api_client.WithMethod("POST"),
    api_client.WithBody(user),
    api_client.WithHeaders(headers),
)

// 🚫 Avoid: Positional arguments
client.Request("POST", "/users", user, headers)

4. Don’t Retry Client Errors

// ✅ Good: Only retry server errors
if apiErr, ok := err.(*api_client.ApiError); ok {
    if apiErr.IsClientError() {
        return err // Don't retry 4xx
    }
}

// 🚫 Avoid: Retrying all errors
for i := 0; i < 3; i++ {
    _, err := fetch()
    if err == nil {
        break
    }
}

5. Set Appropriate Timeouts

// ✅ Good: Configure timeout per service
client.Timeout = 10 * time.Second

// 🚫 Avoid: Using default timeout for all services
// (default is 30s, may be too long)

See Also