-
Notifications
You must be signed in to change notification settings - Fork 27
feat(mongodb-atlas): add currency conversion support #66
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
malpou
wants to merge
6
commits into
opencost:main
Choose a base branch
from
malpou:feature/currency-conversion
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from 5 commits
Commits
Show all changes
6 commits
Select commit
Hold shift + click to select a range
a88ae8c
Add currency conversion support for MongoDB Atlas plugin
malpou 37625c0
don't initialize to default TTL value
malpou 04ac273
Update module references for `common/currency` package
malpou e72f807
Refactor currency handling by introducing `defaultCurrency` constant …
malpou 0cf7f09
Standardize naming conventions across currency package components. Us…
malpou 556dc03
refactor: Update MongoDB Atlas plugin to use currency package from op…
malpou File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,64 @@ | ||
| # Currency Package | ||
|
|
||
| Convert costs between currencies in OpenCost plugins using live exchange rates. | ||
|
|
||
| ## Quick Start | ||
|
|
||
| ```go | ||
| import "github.com/opencost/opencost-plugins/pkg/common/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. | ||
|
|
||
| ## MongoDB Atlas Example | ||
|
|
||
| ```go | ||
| // Config | ||
| type AtlasConfig struct { | ||
| TargetCurrency string `json:"target_currency"` | ||
| ExchangeAPIKey string `json:"exchange_api_key"` | ||
| } | ||
|
|
||
| // Usage | ||
| if atlasConfig.ExchangeAPIKey != "" { | ||
| converter, _ := currency.NewConverter(currency.Config{ | ||
| APIKey: atlasConfig.ExchangeAPIKey, | ||
| CacheTTL: 24 * time.Hour, | ||
| }) | ||
| } | ||
|
|
||
| // Convert costs | ||
| if converter != nil { | ||
| cost, _ = converter.Convert(cost, "USD", targetCurrency) | ||
| } | ||
| ``` | ||
|
|
||
| ## Testing | ||
|
|
||
| ```bash | ||
| cd pkg/common/currency | ||
| go test -v | ||
| ``` | ||
|
|
||
| Tests use mocks - no API calls needed. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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 | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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) | ||
| } | ||
| } |
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@malpou can we update this to remove currency conversion since we merged your PR upstream?
Uh oh!
There was an error while loading. Please reload this page.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Sorry for the long time of nothing happening been busy, @ameijer it should be good now 😄