monorepo/cloud/maplefile-backend/pkg/maplefile/client/client.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
}