// 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 }