2
0
Эх сурвалжийг харах

feat: Add generic currency conversion package (#3315)

Signed-off-by: Malthe Poulsen <malthe@grundtvigsvej.dk>
Signed-off-by: malpou <malthe@grundtvigsvej.dk>
Malthe Poulsen 8 сар өмнө
parent
commit
676c8c072c

+ 64 - 0
pkg/currency/README.md

@@ -0,0 +1,64 @@
+# Currency Package
+
+Convert costs between currencies in OpenCost using live exchange rates. This package provides a reusable currency conversion utility for OpenCost components and plugins.
+
+## Quick Start
+
+```go
+import "github.com/opencost/opencost/pkg/currency"
+
+config := currency.Config{
+    APIKey:   "your-api-key",
+    CacheTTL: 24 * time.Hour,
+}
+
+converter, err := currency.NewConverter(config)
+if err != nil {
+    log.Fatal(err)
+}
+
+// Convert 100 USD to EUR
+amount, err := converter.Convert(100.0, "USD", "EUR")
+```
+
+## Setup
+
+Get a free API key from [exchangerate-api.com](https://www.exchangerate-api.com/) (1,500 requests/month).
+
+## How it Works
+
+The package fetches exchange rates and caches them for 24 hours. This keeps API usage low - most plugins use under 50 requests per month.
+
+Supports all ISO 4217 currencies (161 total). Thread-safe with automatic cache cleanup.
+
+## Example Usage in Plugins
+
+```go
+// Plugin config
+type PluginConfig struct {
+    TargetCurrency  string `json:"target_currency"`
+    ExchangeAPIKey  string `json:"exchange_api_key"`
+}
+
+// Initialize converter
+if config.ExchangeAPIKey != "" {
+    converter, _ := currency.NewConverter(currency.Config{
+        APIKey:   config.ExchangeAPIKey,
+        CacheTTL: 24 * time.Hour,
+    })
+}
+
+// Convert costs
+if converter != nil {
+    cost, _ = converter.Convert(cost, "USD", targetCurrency)
+}
+```
+
+## Testing
+
+```bash
+cd pkg/currency
+go test -v
+```
+
+Tests use mocks - no API calls needed.

+ 99 - 0
pkg/currency/cache.go

@@ -0,0 +1,99 @@
+package currency
+
+import (
+	"sync"
+	"time"
+)
+
+type memoryCache struct {
+	mu      sync.RWMutex
+	data    map[string]*cachedRates
+	ttl     time.Duration
+	janitor *time.Ticker
+}
+
+func newMemoryCache(ttl time.Duration) *memoryCache {
+	if ttl == 0 {
+		ttl = 24 * time.Hour
+	}
+
+	cache := &memoryCache{
+		data:    make(map[string]*cachedRates),
+		ttl:     ttl,
+		janitor: time.NewTicker(ttl / 2),
+	}
+
+	go cache.cleanup()
+
+	return cache
+}
+
+func (c *memoryCache) get(baseCurrency string) (*cachedRates, bool) {
+	c.mu.RLock()
+	defer c.mu.RUnlock()
+
+	rates, exists := c.data[baseCurrency]
+	if !exists {
+		return nil, false
+	}
+
+	if time.Now().After(rates.validUntil) {
+		return nil, false
+	}
+
+	return rates, true
+}
+
+func (c *memoryCache) set(baseCurrency string, rates *cachedRates) {
+	c.mu.Lock()
+	defer c.mu.Unlock()
+
+	rates.validUntil = rates.fetchedAt.Add(c.ttl)
+	c.data[baseCurrency] = rates
+}
+
+func (c *memoryCache) clear() {
+	c.mu.Lock()
+	defer c.mu.Unlock()
+
+	c.data = make(map[string]*cachedRates)
+}
+
+func (c *memoryCache) cleanup() {
+	for range c.janitor.C {
+		c.removeExpired()
+	}
+}
+
+func (c *memoryCache) removeExpired() {
+	c.mu.Lock()
+	defer c.mu.Unlock()
+
+	now := time.Now()
+	for key, rates := range c.data {
+		if now.After(rates.validUntil) {
+			delete(c.data, key)
+		}
+	}
+}
+
+func (c *memoryCache) stop() {
+	if c.janitor != nil {
+		c.janitor.Stop()
+	}
+}
+
+func (c *memoryCache) stats() (entries int, oldestEntry time.Time) {
+	c.mu.RLock()
+	defer c.mu.RUnlock()
+
+	entries = len(c.data)
+
+	for _, rates := range c.data {
+		if oldestEntry.IsZero() || rates.fetchedAt.Before(oldestEntry) {
+			oldestEntry = rates.fetchedAt
+		}
+	}
+
+	return entries, oldestEntry
+}

+ 170 - 0
pkg/currency/cache_test.go

@@ -0,0 +1,170 @@
+package currency
+
+import (
+	"testing"
+	"time"
+)
+
+func TestMemoryCache_SetAndGet(t *testing.T) {
+	cache := newMemoryCache(1 * time.Hour)
+	defer cache.stop()
+
+	// Test setting and getting rates
+	rates := &cachedRates{
+		rates: map[string]float64{
+			"EUR": 0.85,
+			"GBP": 0.73,
+		},
+		baseCode:  "USD",
+		fetchedAt: time.Now(),
+	}
+
+	cache.set("USD", rates)
+
+	// Test successful get
+	retrieved, found := cache.get("USD")
+	if !found {
+		t.Error("expected to find cached rates")
+	}
+
+	if retrieved.baseCode != "USD" {
+		t.Errorf("expected base code USD, got %s", retrieved.baseCode)
+	}
+
+	if len(retrieved.rates) != 2 {
+		t.Errorf("expected 2 rates, got %d", len(retrieved.rates))
+	}
+
+	// Test non-existent key
+	_, found = cache.get("EUR")
+	if found {
+		t.Error("expected not to find rates for EUR")
+	}
+}
+
+func TestMemoryCache_Expiration(t *testing.T) {
+	// Use short TTL for testing
+	cache := newMemoryCache(100 * time.Millisecond)
+	defer cache.stop()
+
+	rates := &cachedRates{
+		rates: map[string]float64{
+			"EUR": 0.85,
+		},
+		baseCode:  "USD",
+		fetchedAt: time.Now(),
+	}
+
+	cache.set("USD", rates)
+
+	// Should find it immediately
+	_, found := cache.get("USD")
+	if !found {
+		t.Error("expected to find cached rates immediately")
+	}
+
+	// Wait for expiration
+	time.Sleep(150 * time.Millisecond)
+
+	// Should not find it after expiration
+	_, found = cache.get("USD")
+	if found {
+		t.Error("expected rates to be expired")
+	}
+}
+
+func TestMemoryCache_Clear(t *testing.T) {
+	cache := newMemoryCache(1 * time.Hour)
+	defer cache.stop()
+
+	// Add multiple entries
+	for _, base := range []string{"USD", "EUR", "GBP"} {
+		rates := &cachedRates{
+			rates:     map[string]float64{"TEST": 1.0},
+			baseCode:  base,
+			fetchedAt: time.Now(),
+		}
+		cache.set(base, rates)
+	}
+
+	// Verify all entries exist
+	for _, base := range []string{"USD", "EUR", "GBP"} {
+		_, found := cache.get(base)
+		if !found {
+			t.Errorf("expected to find rates for %s", base)
+		}
+	}
+
+	// Clear cache
+	cache.clear()
+
+	// Verify all entries are gone
+	for _, base := range []string{"USD", "EUR", "GBP"} {
+		_, found := cache.get(base)
+		if found {
+			t.Errorf("expected not to find rates for %s after clear", base)
+		}
+	}
+}
+
+func TestMemoryCache_Stats(t *testing.T) {
+	cache := newMemoryCache(1 * time.Hour)
+	defer cache.stop()
+
+	// Initially empty
+	entries, _ := cache.stats()
+	if entries != 0 {
+		t.Errorf("expected 0 entries, got %d", entries)
+	}
+
+	// Add entries
+	now := time.Now()
+	for i, base := range []string{"USD", "EUR", "GBP"} {
+		rates := &cachedRates{
+			rates:     map[string]float64{"TEST": 1.0},
+			baseCode:  base,
+			fetchedAt: now.Add(time.Duration(i) * time.Minute),
+		}
+		cache.set(base, rates)
+	}
+
+	entries, oldest := cache.stats()
+	if entries != 3 {
+		t.Errorf("expected 3 entries, got %d", entries)
+	}
+
+	// The oldest should be the first one we added (USD)
+	if !oldest.Equal(now) {
+		t.Errorf("expected oldest entry to be %v, got %v", now, oldest)
+	}
+}
+
+func TestMemoryCache_Cleanup(t *testing.T) {
+	// Use very short TTL for testing
+	cache := newMemoryCache(50 * time.Millisecond)
+	defer cache.stop()
+
+	// Add entry
+	rates := &cachedRates{
+		rates:     map[string]float64{"EUR": 0.85},
+		baseCode:  "USD",
+		fetchedAt: time.Now(),
+	}
+	cache.set("USD", rates)
+
+	// Verify it exists
+	entries, _ := cache.stats()
+	if entries != 1 {
+		t.Errorf("expected 1 entry, got %d", entries)
+	}
+
+	// Wait for cleanup cycle (janitor runs every TTL/2 = 25ms)
+	// Wait a bit longer to ensure cleanup has run
+	time.Sleep(100 * time.Millisecond)
+
+	// Verify it's been cleaned up
+	entries, _ = cache.stats()
+	if entries != 0 {
+		t.Errorf("expected 0 entries after cleanup, got %d", entries)
+	}
+}

+ 89 - 0
pkg/currency/client.go

@@ -0,0 +1,89 @@
+package currency
+
+import (
+	"encoding/json"
+	"fmt"
+	"io"
+	"net/http"
+	"time"
+)
+
+const (
+	apiBaseURL = "https://v6.exchangerate-api.com/v6"
+	userAgent  = "opencost-plugins/1.0"
+)
+
+type httpClient interface {
+	Do(req *http.Request) (*http.Response, error)
+}
+
+type exchangeRateClient struct {
+	apiKey     string
+	httpClient httpClient
+	timeout    time.Duration
+}
+
+func newExchangeRateClient(apiKey string, timeout time.Duration) *exchangeRateClient {
+	if timeout == 0 {
+		timeout = 10 * time.Second
+	}
+
+	return &exchangeRateClient{
+		apiKey: apiKey,
+		httpClient: &http.Client{
+			Timeout: timeout,
+		},
+		timeout: timeout,
+	}
+}
+
+func (c *exchangeRateClient) fetchRates(baseCurrency string) (*exchangeRateResponse, error) {
+	if c.apiKey == "" {
+		return nil, fmt.Errorf("API key is required")
+	}
+
+	if baseCurrency == "" {
+		baseCurrency = "USD"
+	}
+
+	url := fmt.Sprintf("%s/%s/latest/%s", apiBaseURL, c.apiKey, baseCurrency)
+
+	req, err := http.NewRequest("GET", url, nil)
+	if err != nil {
+		return nil, fmt.Errorf("failed to create request: %w", err)
+	}
+
+	req.Header.Set("User-Agent", userAgent)
+	req.Header.Set("Accept", "application/json")
+
+	resp, err := c.httpClient.Do(req)
+	if err != nil {
+		return nil, fmt.Errorf("failed to fetch exchange rates: %w", err)
+	}
+	defer resp.Body.Close()
+
+	if resp.StatusCode != http.StatusOK {
+		body, _ := io.ReadAll(resp.Body)
+		return nil, fmt.Errorf("API request failed with status %d: %s", resp.StatusCode, string(body))
+	}
+
+	body, err := io.ReadAll(resp.Body)
+	if err != nil {
+		return nil, fmt.Errorf("failed to read response body: %w", err)
+	}
+
+	var response exchangeRateResponse
+	if err := json.Unmarshal(body, &response); err != nil {
+		return nil, fmt.Errorf("failed to parse response: %w", err)
+	}
+
+	if response.Result != "success" {
+		return nil, fmt.Errorf("API returned error result: %s", response.Result)
+	}
+
+	if len(response.ConversionRates) == 0 {
+		return nil, fmt.Errorf("no conversion rates returned")
+	}
+
+	return &response, nil
+}

+ 105 - 0
pkg/currency/converter.go

@@ -0,0 +1,105 @@
+package currency
+
+import (
+	"fmt"
+	"strings"
+	"sync"
+	"time"
+)
+
+type currencyConverter struct {
+	client client
+	cache  cache
+	config Config
+	mu     sync.RWMutex
+}
+
+func NewConverter(config Config) (Converter, error) {
+	if config.APIKey == "" {
+		return nil, fmt.Errorf("API key is required")
+	}
+
+	if config.CacheTTL == 0 {
+		config.CacheTTL = 24 * time.Hour
+	}
+
+	if config.APITimeout == 0 {
+		config.APITimeout = 10 * time.Second
+	}
+
+	client := newExchangeRateClient(config.APIKey, config.APITimeout)
+	cache := newMemoryCache(config.CacheTTL)
+
+	return &currencyConverter{
+		client: client,
+		cache:  cache,
+		config: config,
+	}, nil
+}
+
+func (c *currencyConverter) Convert(amount float64, from, to string) (float64, error) {
+	from = strings.ToUpper(strings.TrimSpace(from))
+	to = strings.ToUpper(strings.TrimSpace(to))
+
+	if from == to {
+		return amount, nil
+	}
+
+	rate, err := c.GetRate(from, to)
+	if err != nil {
+		return 0, fmt.Errorf("failed to get exchange rate from %s to %s: %w", from, to, err)
+	}
+
+	return amount * rate, nil
+}
+
+func (c *currencyConverter) GetRate(from, to string) (float64, error) {
+	from = strings.ToUpper(strings.TrimSpace(from))
+	to = strings.ToUpper(strings.TrimSpace(to))
+
+	if from == to {
+		return 1.0, nil
+	}
+
+	cachedRates, found := c.cache.get(from)
+	if found && cachedRates.rates != nil {
+		if rate, exists := cachedRates.rates[to]; exists {
+			return rate, nil
+		}
+	}
+
+	rates, err := c.fetchAndCacheRates(from)
+	if err != nil {
+		return 0, err
+	}
+
+	rate, exists := rates[to]
+	if !exists {
+		return 0, fmt.Errorf("currency %s not supported or not found in exchange rates", to)
+	}
+
+	return rate, nil
+}
+
+func (c *currencyConverter) fetchAndCacheRates(baseCurrency string) (map[string]float64, error) {
+	c.mu.Lock()
+	defer c.mu.Unlock()
+
+	if cachedRates, found := c.cache.get(baseCurrency); found {
+		return cachedRates.rates, nil
+	}
+
+	response, err := c.client.fetchRates(baseCurrency)
+	if err != nil {
+		return nil, fmt.Errorf("failed to fetch rates from API: %w", err)
+	}
+
+	cachedRates := &cachedRates{
+		rates:     response.ConversionRates,
+		baseCode:  response.BaseCode,
+		fetchedAt: time.Now(),
+	}
+	c.cache.set(baseCurrency, cachedRates)
+
+	return response.ConversionRates, nil
+}

+ 228 - 0
pkg/currency/converter_test.go

@@ -0,0 +1,228 @@
+package currency
+
+import (
+	"fmt"
+	"testing"
+	"time"
+)
+
+type mockClient struct {
+	rates map[string]map[string]float64
+	err   error
+}
+
+func (m *mockClient) fetchRates(baseCurrency string) (*exchangeRateResponse, error) {
+	if m.err != nil {
+		return nil, m.err
+	}
+
+	rates, exists := m.rates[baseCurrency]
+	if !exists {
+		return nil, fmt.Errorf("no rates for base currency %s", baseCurrency)
+	}
+
+	return &exchangeRateResponse{
+		Result:          "success",
+		BaseCode:        baseCurrency,
+		ConversionRates: rates,
+	}, nil
+}
+
+type mockCache struct {
+	data map[string]*cachedRates
+}
+
+func newMockCache() *mockCache {
+	return &mockCache{
+		data: make(map[string]*cachedRates),
+	}
+}
+
+func (m *mockCache) get(baseCurrency string) (*cachedRates, bool) {
+	rates, exists := m.data[baseCurrency]
+	if !exists || time.Now().After(rates.validUntil) {
+		return nil, false
+	}
+	return rates, true
+}
+
+func (m *mockCache) set(baseCurrency string, rates *cachedRates) {
+	m.data[baseCurrency] = rates
+}
+
+func (m *mockCache) clear() {
+	m.data = make(map[string]*cachedRates)
+}
+
+func TestCurrencyConverter_Convert(t *testing.T) {
+	mockClient := &mockClient{
+		rates: map[string]map[string]float64{
+			"USD": {
+				"USD": 1.0,
+				"EUR": 0.85,
+				"GBP": 0.73,
+				"JPY": 110.0,
+			},
+			"EUR": {
+				"EUR": 1.0,
+				"USD": 1.18,
+				"GBP": 0.86,
+				"JPY": 129.53,
+			},
+		},
+	}
+
+	converter := &currencyConverter{
+		client: mockClient,
+		cache:  newMockCache(),
+		config: Config{APIKey: "test"},
+	}
+
+	tests := []struct {
+		name        string
+		amount      float64
+		from        string
+		to          string
+		expected    float64
+		expectError bool
+	}{
+		{
+			name:     "USD to EUR",
+			amount:   100,
+			from:     "USD",
+			to:       "EUR",
+			expected: 85,
+		},
+		{
+			name:     "USD to GBP",
+			amount:   100,
+			from:     "USD",
+			to:       "GBP",
+			expected: 73,
+		},
+		{
+			name:     "EUR to USD",
+			amount:   100,
+			from:     "EUR",
+			to:       "USD",
+			expected: 118,
+		},
+		{
+			name:     "Same currency",
+			amount:   100,
+			from:     "USD",
+			to:       "USD",
+			expected: 100,
+		},
+		{
+			name:     "Case insensitive",
+			amount:   100,
+			from:     "usd",
+			to:       "eur",
+			expected: 85,
+		},
+		{
+			name:        "Unsupported currency",
+			amount:      100,
+			from:        "USD",
+			to:          "XYZ",
+			expectError: true,
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			result, err := converter.Convert(tt.amount, tt.from, tt.to)
+
+			if tt.expectError {
+				if err == nil {
+					t.Errorf("expected error but got none")
+				}
+				return
+			}
+
+			if err != nil {
+				t.Errorf("unexpected error: %v", err)
+				return
+			}
+
+			if result != tt.expected {
+				t.Errorf("expected %f, got %f", tt.expected, result)
+			}
+		})
+	}
+}
+
+func TestCurrencyConverter_GetRate(t *testing.T) {
+	mockClient := &mockClient{
+		rates: map[string]map[string]float64{
+			"USD": {
+				"USD": 1.0,
+				"EUR": 0.85,
+				"GBP": 0.73,
+			},
+		},
+	}
+
+	converter := &currencyConverter{
+		client: mockClient,
+		cache:  newMockCache(),
+		config: Config{APIKey: "test"},
+	}
+
+	// Test getting rate
+	rate, err := converter.GetRate("USD", "EUR")
+	if err != nil {
+		t.Errorf("unexpected error: %v", err)
+	}
+	if rate != 0.85 {
+		t.Errorf("expected rate 0.85, got %f", rate)
+	}
+
+	// Test same currency
+	rate, err = converter.GetRate("USD", "USD")
+	if err != nil {
+		t.Errorf("unexpected error: %v", err)
+	}
+	if rate != 1.0 {
+		t.Errorf("expected rate 1.0, got %f", rate)
+	}
+
+	// Test cache hit
+	rate, err = converter.GetRate("USD", "EUR")
+	if err != nil {
+		t.Errorf("unexpected error: %v", err)
+	}
+	if rate != 0.85 {
+		t.Errorf("expected cached rate 0.85, got %f", rate)
+	}
+}
+
+func TestNewConverter(t *testing.T) {
+	// Test with empty API key
+	_, err := NewConverter(Config{})
+	if err == nil {
+		t.Error("expected error for empty API key")
+	}
+
+	// Test with valid config
+	converter, err := NewConverter(Config{APIKey: "test-key"})
+	if err != nil {
+		t.Errorf("unexpected error: %v", err)
+	}
+
+	// Convert to concrete type to access internal fields
+	cc, ok := converter.(*currencyConverter)
+	if !ok {
+		t.Error("expected converter to be of type *currencyConverter")
+		return
+	}
+
+	if cc.config.CacheTTL != 24*time.Hour {
+		t.Errorf("expected default cache TTL of 24h, got %v", cc.config.CacheTTL)
+	}
+
+	if cc.config.APITimeout != 10*time.Second {
+		t.Errorf("expected default API timeout of 10s, got %v", cc.config.APITimeout)
+	}
+}

+ 60 - 0
pkg/currency/types.go

@@ -0,0 +1,60 @@
+package currency
+
+import (
+	"time"
+)
+
+// Config holds configuration for the currency converter
+type Config struct {
+	APIKey     string
+	CacheTTL   time.Duration
+	APITimeout time.Duration
+}
+
+// Converter interface defines currency conversion operations
+type Converter interface {
+	// Convert converts an amount from one currency to another
+	Convert(amount float64, from, to string) (float64, error)
+
+	// GetRate returns the exchange rate between two currencies
+	GetRate(from, to string) (float64, error)
+}
+
+// exchangeRateResponse represents the API response from exchangerate-api.com
+type exchangeRateResponse struct {
+	Result             string             `json:"result"`
+	Documentation      string             `json:"documentation"`
+	TermsOfUse         string             `json:"terms_of_use"`
+	TimeLastUpdateUnix int64              `json:"time_last_update_unix"`
+	TimeLastUpdateUTC  string             `json:"time_last_update_utc"`
+	TimeNextUpdateUnix int64              `json:"time_next_update_unix"`
+	TimeNextUpdateUTC  string             `json:"time_next_update_utc"`
+	BaseCode           string             `json:"base_code"`
+	ConversionRates    map[string]float64 `json:"conversion_rates"`
+}
+
+// cachedRates stores exchange rates with metadata
+type cachedRates struct {
+	rates      map[string]float64
+	baseCode   string
+	fetchedAt  time.Time
+	validUntil time.Time
+}
+
+// client interface for fetching exchange rates
+type client interface {
+	// fetchRates fetches current exchange rates for a base currency
+	fetchRates(baseCurrency string) (*exchangeRateResponse, error)
+}
+
+// cache interface for storing exchange rates
+type cache interface {
+	// get retrieves cached rates for a base currency
+	get(baseCurrency string) (*cachedRates, bool)
+
+	// set stores rates for a base currency with TTL
+	set(baseCurrency string, rates *cachedRates)
+
+	// clear removes all cached rates
+	clear()
+}