468 lines
14 KiB
Go
468 lines
14 KiB
Go
// Package client provides a Go SDK for interacting with the MapleFile API.
|
|
package client
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"strings"
|
|
"sync"
|
|
"sync/atomic"
|
|
"time"
|
|
)
|
|
|
|
// Logger is an interface for logging API requests.
|
|
// This allows the client to work with any logging library (zap, logrus, etc.)
|
|
type Logger interface {
|
|
// Debug logs a debug message with optional key-value pairs
|
|
Debug(msg string, keysAndValues ...interface{})
|
|
// Info logs an info message with optional key-value pairs
|
|
Info(msg string, keysAndValues ...interface{})
|
|
// Warn logs a warning message with optional key-value pairs
|
|
Warn(msg string, keysAndValues ...interface{})
|
|
// Error logs an error message with optional key-value pairs
|
|
Error(msg string, keysAndValues ...interface{})
|
|
}
|
|
|
|
// Client is the MapleFile API client.
|
|
type Client struct {
|
|
baseURL string
|
|
httpClient *http.Client
|
|
logger Logger
|
|
|
|
// Token storage with mutex for thread safety
|
|
mu sync.RWMutex
|
|
accessToken string
|
|
refreshToken string
|
|
|
|
// Callback when tokens are refreshed
|
|
// Parameters: accessToken, refreshToken, accessTokenExpiryDate (RFC3339 format)
|
|
onTokenRefresh func(accessToken, refreshToken, accessTokenExpiryDate string)
|
|
|
|
// Flag to prevent recursive token refresh (atomic for lock-free reads)
|
|
isRefreshing atomic.Bool
|
|
}
|
|
|
|
// Predefined environment URLs
|
|
const (
|
|
// ProductionURL is the production API endpoint
|
|
ProductionURL = "https://maplefile.ca"
|
|
|
|
// LocalURL is the default local development API endpoint
|
|
LocalURL = "http://localhost:8000"
|
|
)
|
|
|
|
// Config holds the configuration for creating a new Client.
|
|
type Config struct {
|
|
// BaseURL is the base URL of the MapleFile API (e.g., "https://maplefile.ca")
|
|
// You can use predefined constants: ProductionURL or LocalURL
|
|
BaseURL string
|
|
|
|
// HTTPClient is an optional custom HTTP client. If nil, a default client with 30s timeout is used.
|
|
HTTPClient *http.Client
|
|
|
|
// Logger is an optional logger for API request logging. If nil, no logging is performed.
|
|
Logger Logger
|
|
}
|
|
|
|
// New creates a new MapleFile API client with the given configuration.
|
|
//
|
|
// Security Note: This client uses Go's standard http.Client without certificate
|
|
// pinning. This is intentional and secure because:
|
|
//
|
|
// 1. TLS termination is handled by a reverse proxy (Caddy/Nginx) in production,
|
|
// which manages certificates via Let's Encrypt with automatic renewal.
|
|
// 2. Go's default TLS configuration already validates certificate chains,
|
|
// expiration, and hostname matching against system CA roots.
|
|
// 3. The application uses end-to-end encryption (E2EE) - even if TLS were
|
|
// compromised, attackers would only see encrypted data they cannot decrypt.
|
|
// 4. Certificate pinning would require app updates every 90 days (Let's Encrypt
|
|
// rotation) or risk bricking deployed applications.
|
|
//
|
|
// See: docs/OWASP_AUDIT_REPORT.md (Finding 4.1) for full security analysis.
|
|
func New(cfg Config) *Client {
|
|
httpClient := cfg.HTTPClient
|
|
if httpClient == nil {
|
|
// Standard HTTP client with timeout. Certificate pinning is intentionally
|
|
// not implemented - see security note above.
|
|
httpClient = &http.Client{
|
|
Timeout: 30 * time.Second,
|
|
}
|
|
}
|
|
|
|
// Ensure baseURL doesn't have trailing slash
|
|
baseURL := strings.TrimSuffix(cfg.BaseURL, "/")
|
|
|
|
return &Client{
|
|
baseURL: baseURL,
|
|
httpClient: httpClient,
|
|
logger: cfg.Logger,
|
|
}
|
|
}
|
|
|
|
// NewProduction creates a new MapleFile API client configured for production.
|
|
func NewProduction() *Client {
|
|
return New(Config{BaseURL: ProductionURL})
|
|
}
|
|
|
|
// NewLocal creates a new MapleFile API client configured for local development.
|
|
func NewLocal() *Client {
|
|
return New(Config{BaseURL: LocalURL})
|
|
}
|
|
|
|
// NewWithURL creates a new MapleFile API client with a custom URL.
|
|
func NewWithURL(baseURL string) *Client {
|
|
return New(Config{BaseURL: baseURL})
|
|
}
|
|
|
|
// SetTokens sets the access and refresh tokens for authentication.
|
|
func (c *Client) SetTokens(accessToken, refreshToken string) {
|
|
c.mu.Lock()
|
|
defer c.mu.Unlock()
|
|
c.accessToken = accessToken
|
|
c.refreshToken = refreshToken
|
|
}
|
|
|
|
// GetTokens returns the current access and refresh tokens.
|
|
func (c *Client) GetTokens() (accessToken, refreshToken string) {
|
|
c.mu.RLock()
|
|
defer c.mu.RUnlock()
|
|
return c.accessToken, c.refreshToken
|
|
}
|
|
|
|
// OnTokenRefresh sets a callback function that will be called when tokens are refreshed.
|
|
// This is useful for persisting the new tokens to storage.
|
|
// The callback receives: accessToken, refreshToken, and accessTokenExpiryDate (RFC3339 format).
|
|
func (c *Client) OnTokenRefresh(callback func(accessToken, refreshToken, accessTokenExpiryDate string)) {
|
|
c.onTokenRefresh = callback
|
|
}
|
|
|
|
// SetBaseURL changes the base URL of the API.
|
|
// This is useful for switching between environments at runtime.
|
|
func (c *Client) SetBaseURL(baseURL string) {
|
|
c.mu.Lock()
|
|
defer c.mu.Unlock()
|
|
c.baseURL = strings.TrimSuffix(baseURL, "/")
|
|
}
|
|
|
|
// GetBaseURL returns the current base URL.
|
|
func (c *Client) GetBaseURL() string {
|
|
c.mu.RLock()
|
|
defer c.mu.RUnlock()
|
|
return c.baseURL
|
|
}
|
|
|
|
// Health checks if the API is healthy.
|
|
func (c *Client) Health(ctx context.Context) (*HealthResponse, error) {
|
|
var resp HealthResponse
|
|
if err := c.doRequest(ctx, "GET", "/health", nil, &resp, false); err != nil {
|
|
return nil, err
|
|
}
|
|
return &resp, nil
|
|
}
|
|
|
|
// Version returns the API version information.
|
|
func (c *Client) Version(ctx context.Context) (*VersionResponse, error) {
|
|
var resp VersionResponse
|
|
if err := c.doRequest(ctx, "GET", "/version", nil, &resp, false); err != nil {
|
|
return nil, err
|
|
}
|
|
return &resp, nil
|
|
}
|
|
|
|
// doRequest performs an HTTP request with automatic token refresh on 401.
|
|
func (c *Client) doRequest(ctx context.Context, method, path string, body interface{}, result interface{}, requiresAuth bool) error {
|
|
return c.doRequestWithRetry(ctx, method, path, body, result, requiresAuth, true)
|
|
}
|
|
|
|
// doRequestWithRetry performs an HTTP request with optional retry on 401.
|
|
func (c *Client) doRequestWithRetry(ctx context.Context, method, path string, body interface{}, result interface{}, requiresAuth bool, allowRetry bool) error {
|
|
// Build URL
|
|
url := c.baseURL + path
|
|
|
|
// Log API request
|
|
if c.logger != nil {
|
|
c.logger.Info("API request", "method", method, "url", url)
|
|
}
|
|
|
|
// Prepare request body
|
|
var bodyReader io.Reader
|
|
if body != nil {
|
|
jsonData, err := json.Marshal(body)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to marshal request body: %w", err)
|
|
}
|
|
bodyReader = bytes.NewReader(jsonData)
|
|
}
|
|
|
|
// Create request
|
|
req, err := http.NewRequestWithContext(ctx, method, url, bodyReader)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to create request: %w", err)
|
|
}
|
|
|
|
// Set headers
|
|
if body != nil {
|
|
req.Header.Set("Content-Type", "application/json")
|
|
}
|
|
// Accept both standard JSON and RFC 9457 problem+json responses
|
|
req.Header.Set("Accept", "application/json, application/problem+json")
|
|
|
|
// Add authorization header if required
|
|
if requiresAuth {
|
|
c.mu.RLock()
|
|
token := c.accessToken
|
|
c.mu.RUnlock()
|
|
|
|
if token == "" {
|
|
return &APIError{
|
|
ProblemDetail: ProblemDetail{
|
|
Status: 401,
|
|
Title: "Unauthorized",
|
|
Detail: "No access token available",
|
|
},
|
|
}
|
|
}
|
|
req.Header.Set("Authorization", fmt.Sprintf("JWT %s", token))
|
|
}
|
|
|
|
// Execute request
|
|
resp, err := c.httpClient.Do(req)
|
|
if err != nil {
|
|
if c.logger != nil {
|
|
c.logger.Error("API request failed", "method", method, "url", url, "error", err.Error())
|
|
}
|
|
return fmt.Errorf("failed to execute request: %w", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
// Log API response
|
|
if c.logger != nil {
|
|
c.logger.Info("API response", "method", method, "url", url, "status", resp.StatusCode)
|
|
}
|
|
|
|
// Read response body
|
|
respBody, err := io.ReadAll(resp.Body)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to read response body: %w", err)
|
|
}
|
|
|
|
// Handle 401 with automatic token refresh
|
|
// Use atomic.Bool for lock-free check to avoid unnecessary lock acquisition
|
|
if resp.StatusCode == http.StatusUnauthorized && requiresAuth && allowRetry && !c.isRefreshing.Load() {
|
|
c.mu.Lock()
|
|
// Double-check under lock and verify refresh token exists
|
|
if c.refreshToken != "" && !c.isRefreshing.Load() {
|
|
c.isRefreshing.Store(true)
|
|
c.mu.Unlock()
|
|
|
|
// Attempt to refresh token
|
|
refreshErr := c.refreshAccessToken(ctx)
|
|
c.isRefreshing.Store(false)
|
|
|
|
if refreshErr == nil {
|
|
// Retry the original request without allowing another retry
|
|
return c.doRequestWithRetry(ctx, method, path, body, result, requiresAuth, false)
|
|
}
|
|
} else {
|
|
c.mu.Unlock()
|
|
}
|
|
}
|
|
|
|
// Handle error status codes
|
|
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
|
return parseErrorResponse(respBody, resp.StatusCode)
|
|
}
|
|
|
|
// Parse successful response
|
|
if result != nil && len(respBody) > 0 {
|
|
if err := json.Unmarshal(respBody, result); err != nil {
|
|
return fmt.Errorf("failed to parse response: %w", err)
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// refreshAccessToken attempts to refresh the access token using the refresh token.
|
|
func (c *Client) refreshAccessToken(ctx context.Context) error {
|
|
c.mu.RLock()
|
|
refreshToken := c.refreshToken
|
|
c.mu.RUnlock()
|
|
|
|
if refreshToken == "" {
|
|
return fmt.Errorf("no refresh token available")
|
|
}
|
|
|
|
// Build refresh request
|
|
url := c.baseURL + "/api/v1/token/refresh"
|
|
|
|
reqBody := map[string]string{
|
|
"value": refreshToken, // Backend expects "value" field, not "refresh_token"
|
|
}
|
|
jsonData, err := json.Marshal(reqBody)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
req, err := http.NewRequestWithContext(ctx, "POST", url, bytes.NewReader(jsonData))
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
req.Header.Set("Content-Type", "application/json")
|
|
req.Header.Set("Accept", "application/json")
|
|
|
|
resp, err := c.httpClient.Do(req)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
respBody, err := io.ReadAll(resp.Body)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
if resp.StatusCode != http.StatusOK {
|
|
return parseErrorResponse(respBody, resp.StatusCode)
|
|
}
|
|
|
|
// Parse the refresh response
|
|
// Note: Backend returns access_token_expiry_date and refresh_token_expiry_date,
|
|
// but the callback currently only passes tokens. Expiry dates are available
|
|
// in the LoginResponse type if needed for future enhancements.
|
|
var tokenResp struct {
|
|
AccessToken string `json:"access_token"`
|
|
RefreshToken string `json:"refresh_token"`
|
|
AccessTokenExpiryDate string `json:"access_token_expiry_date"`
|
|
RefreshTokenExpiryDate string `json:"refresh_token_expiry_date"`
|
|
}
|
|
if err := json.Unmarshal(respBody, &tokenResp); err != nil {
|
|
return err
|
|
}
|
|
|
|
// Update stored tokens
|
|
c.mu.Lock()
|
|
c.accessToken = tokenResp.AccessToken
|
|
c.refreshToken = tokenResp.RefreshToken
|
|
c.mu.Unlock()
|
|
|
|
// Notify callback if set, passing the expiry date so callers can track actual expiration
|
|
if c.onTokenRefresh != nil {
|
|
c.onTokenRefresh(tokenResp.AccessToken, tokenResp.RefreshToken, tokenResp.AccessTokenExpiryDate)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// doRequestRaw performs an HTTP request and returns the raw response body.
|
|
// This is useful for endpoints that return non-JSON responses.
|
|
func (c *Client) doRequestRaw(ctx context.Context, method, path string, body interface{}, requiresAuth bool) ([]byte, error) {
|
|
return c.doRequestRawWithRetry(ctx, method, path, body, requiresAuth, true)
|
|
}
|
|
|
|
// doRequestRawWithRetry performs an HTTP request with optional retry on 401.
|
|
func (c *Client) doRequestRawWithRetry(ctx context.Context, method, path string, body interface{}, requiresAuth bool, allowRetry bool) ([]byte, error) {
|
|
// Build URL
|
|
url := c.baseURL + path
|
|
|
|
// Log API request
|
|
if c.logger != nil {
|
|
c.logger.Info("API request", "method", method, "url", url)
|
|
}
|
|
|
|
// Prepare request body - we need to be able to re-read it for retry
|
|
var bodyData []byte
|
|
if body != nil {
|
|
var err error
|
|
bodyData, err = json.Marshal(body)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to marshal request body: %w", err)
|
|
}
|
|
}
|
|
|
|
// Create request
|
|
var bodyReader io.Reader
|
|
if bodyData != nil {
|
|
bodyReader = bytes.NewReader(bodyData)
|
|
}
|
|
req, err := http.NewRequestWithContext(ctx, method, url, bodyReader)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to create request: %w", err)
|
|
}
|
|
|
|
// Set headers
|
|
if body != nil {
|
|
req.Header.Set("Content-Type", "application/json")
|
|
}
|
|
|
|
// Add authorization header if required
|
|
if requiresAuth {
|
|
c.mu.RLock()
|
|
token := c.accessToken
|
|
c.mu.RUnlock()
|
|
|
|
if token == "" {
|
|
return nil, &APIError{
|
|
ProblemDetail: ProblemDetail{
|
|
Status: 401,
|
|
Title: "Unauthorized",
|
|
Detail: "No access token available",
|
|
},
|
|
}
|
|
}
|
|
req.Header.Set("Authorization", fmt.Sprintf("JWT %s", token))
|
|
}
|
|
|
|
// Execute request
|
|
resp, err := c.httpClient.Do(req)
|
|
if err != nil {
|
|
if c.logger != nil {
|
|
c.logger.Error("API request failed", "method", method, "url", url, "error", err.Error())
|
|
}
|
|
return nil, fmt.Errorf("failed to execute request: %w", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
// Log API response
|
|
if c.logger != nil {
|
|
c.logger.Info("API response", "method", method, "url", url, "status", resp.StatusCode)
|
|
}
|
|
|
|
// Read response body
|
|
respBody, err := io.ReadAll(resp.Body)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to read response body: %w", err)
|
|
}
|
|
|
|
// Handle 401 with automatic token refresh
|
|
if resp.StatusCode == http.StatusUnauthorized && requiresAuth && allowRetry && !c.isRefreshing.Load() {
|
|
c.mu.Lock()
|
|
if c.refreshToken != "" && !c.isRefreshing.Load() {
|
|
c.isRefreshing.Store(true)
|
|
c.mu.Unlock()
|
|
|
|
// Attempt to refresh token
|
|
refreshErr := c.refreshAccessToken(ctx)
|
|
c.isRefreshing.Store(false)
|
|
|
|
if refreshErr == nil {
|
|
// Retry the original request without allowing another retry
|
|
return c.doRequestRawWithRetry(ctx, method, path, body, requiresAuth, false)
|
|
}
|
|
} else {
|
|
c.mu.Unlock()
|
|
}
|
|
}
|
|
|
|
// Handle error status codes
|
|
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
|
return nil, parseErrorResponse(respBody, resp.StatusCode)
|
|
}
|
|
|
|
return respBody, nil
|
|
}
|