Initial commit: Open sourcing all of the Maple Open Technologies code.
This commit is contained in:
commit
755d54a99d
2010 changed files with 448675 additions and 0 deletions
468
cloud/maplefile-backend/pkg/maplefile/client/client.go
Normal file
468
cloud/maplefile-backend/pkg/maplefile/client/client.go
Normal file
|
|
@ -0,0 +1,468 @@
|
|||
// 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
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue