Compare commits
No commits in common. "03f8f3a920d42deb870ac8aa649984c5864c1227" and "ffc0b0d0acc11d2b3518cf843db6a720dc0e1a16" have entirely different histories.
03f8f3a920
...
ffc0b0d0ac
203
README.md
203
README.md
|
|
@ -1,203 +0,0 @@
|
||||||
# Currency Converter CLI
|
|
||||||
|
|
||||||
A command-line utility for converting currencies using CoinMarketCap API, built with Go following Clean Architecture principles.
|
|
||||||
|
|
||||||
## Quick Start
|
|
||||||
|
|
||||||
### Prerequisites
|
|
||||||
- Go 1.19 or higher
|
|
||||||
- CoinMarketCap API key (get from https://coinmarketcap.com/api/)
|
|
||||||
|
|
||||||
### Installation & Setup
|
|
||||||
1. Clone the repository
|
|
||||||
2. Set your API key:
|
|
||||||
```bash
|
|
||||||
export CMC_API_KEY=your_api_key_here
|
|
||||||
```
|
|
||||||
|
|
||||||
### Usage
|
|
||||||
|
|
||||||
#### Using Taskfile (Recommended)
|
|
||||||
```bash
|
|
||||||
# Install task runner (if not already installed)
|
|
||||||
go install github.com/go-task/task/v3/cmd/task@latest
|
|
||||||
|
|
||||||
# Build and run with parameters
|
|
||||||
task run -- 123.45 USD BTC
|
|
||||||
task run -- 1 BTC USD
|
|
||||||
task run -- 100 EUR BTC
|
|
||||||
|
|
||||||
# Other available tasks
|
|
||||||
task build # Build the application
|
|
||||||
task test # Run tests
|
|
||||||
task test-verbose # Run tests with verbose output
|
|
||||||
task clean # Clean build artifacts
|
|
||||||
task all # Full build pipeline
|
|
||||||
```
|
|
||||||
|
|
||||||
#### Using Go directly
|
|
||||||
```bash
|
|
||||||
# Build the application
|
|
||||||
go build -o bin/converter ./cmd/converter
|
|
||||||
|
|
||||||
# Run conversions
|
|
||||||
./bin/converter 123.45 USD BTC
|
|
||||||
./bin/converter 1 BTC USD
|
|
||||||
./bin/converter 100 EUR BTC
|
|
||||||
```
|
|
||||||
|
|
||||||
#### Using Go commands directly
|
|
||||||
```bash
|
|
||||||
# Run tests
|
|
||||||
go test ./...
|
|
||||||
|
|
||||||
# Build manually
|
|
||||||
mkdir -p bin && go build -o bin/converter ./cmd/converter
|
|
||||||
|
|
||||||
# Run with API key
|
|
||||||
CMC_API_KEY=your_key ./bin/converter 50 USD BTC
|
|
||||||
```
|
|
||||||
|
|
||||||
## Project Structure
|
|
||||||
|
|
||||||
```
|
|
||||||
converter/
|
|
||||||
├── cmd/converter/ # CLI application entry point
|
|
||||||
│ └── main.go # Main function, argument parsing, DI setup
|
|
||||||
├── app/ # Application layer (Use Cases)
|
|
||||||
│ ├── convert_currency.go # Core use case implementation
|
|
||||||
│ ├── convert_currency_test.go # Use case tests with mocks
|
|
||||||
│ └── mocks/ # Generated mocks (mockery)
|
|
||||||
│ └── RateProvider.go # Mock for RateProvider interface
|
|
||||||
├── domain/ # Domain layer (Business Logic)
|
|
||||||
│ ├── entities.go # Value objects (Currency, Money, Rate)
|
|
||||||
│ ├── entities_test.go # Domain entity tests
|
|
||||||
│ ├── services.go # Domain service (CurrencyConverter)
|
|
||||||
│ └── services_test.go # Domain service tests
|
|
||||||
├── infrastructure/ # Infrastructure layer (External APIs)
|
|
||||||
│ └── coinmarketcap/ # CoinMarketCap API client
|
|
||||||
│ ├── client.go # HTTP client with retry/fallback logic
|
|
||||||
│ └── models.go # API response DTOs
|
|
||||||
├── Taskfile.yml # Task runner configuration
|
|
||||||
├── go.mod # Go module definition
|
|
||||||
├── go.sum # Go module checksums
|
|
||||||
├── TASK.md # Original task requirements (Russian)
|
|
||||||
├── CLAUDE.md # Project configuration for Claude Code
|
|
||||||
└── README.md # This file
|
|
||||||
```
|
|
||||||
|
|
||||||
### Architecture Overview
|
|
||||||
|
|
||||||
This project implements **Clean Architecture** with clear separation of concerns:
|
|
||||||
|
|
||||||
- **Domain Layer**: Pure business logic with no external dependencies
|
|
||||||
- Value objects (Currency, Money, Rate) with validation
|
|
||||||
- Domain services (CurrencyConverter) for business operations
|
|
||||||
- Domain errors and business rules
|
|
||||||
|
|
||||||
- **Application Layer**: Use cases that orchestrate domain logic
|
|
||||||
- ConvertCurrencyUseCase: Main conversion workflow
|
|
||||||
- RateProvider interface: Defines contract for rate fetching
|
|
||||||
- DTO to domain object mapping with reversal detection
|
|
||||||
|
|
||||||
- **Infrastructure Layer**: External integrations and technical details
|
|
||||||
- CoinMarketCap API client with automatic retry logic
|
|
||||||
- HTTP error handling and response mapping
|
|
||||||
- Rate reversal logic (USD→BTC becomes BTC→USD inverted)
|
|
||||||
|
|
||||||
- **CLI Layer**: User interface and dependency injection
|
|
||||||
- Command-line parsing and validation
|
|
||||||
- Environment variable configuration
|
|
||||||
- Error formatting for end users
|
|
||||||
|
|
||||||
## Testing
|
|
||||||
|
|
||||||
The project includes comprehensive test coverage using **testify** and **mockery**:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Run all tests
|
|
||||||
task test
|
|
||||||
# or
|
|
||||||
go test ./...
|
|
||||||
|
|
||||||
# Run tests with verbose output
|
|
||||||
task test-verbose
|
|
||||||
# or
|
|
||||||
go test -v ./...
|
|
||||||
|
|
||||||
# Run tests with coverage
|
|
||||||
task test-coverage
|
|
||||||
# or
|
|
||||||
go test -coverprofile=coverage.out ./...
|
|
||||||
go tool cover -html=coverage.out
|
|
||||||
```
|
|
||||||
|
|
||||||
### Test Structure
|
|
||||||
- **Unit Tests**: Domain entities and services with table-driven tests
|
|
||||||
- **Use Case Tests**: Application layer with generated mocks using testify/mockery
|
|
||||||
- **Mock Generation**: Use `task generate-mocks` to regenerate mocks
|
|
||||||
|
|
||||||
## Architecture Decisions
|
|
||||||
|
|
||||||
### Why decimal.Decimal for Money Handling
|
|
||||||
|
|
||||||
We chose `shopspring/decimal` over floating-point numbers and other alternatives for the following critical reasons:
|
|
||||||
|
|
||||||
#### Financial Accuracy Requirements
|
|
||||||
- Floating point arithmetic cannot represent decimal numbers exactly
|
|
||||||
- Example: `0.1 + 0.2 ≠ 0.3` in floating-point arithmetic
|
|
||||||
- Currency conversion errors compound with multiple operations
|
|
||||||
- CoinMarketCap API returns exchange rates with high precision (8+ decimal places)
|
|
||||||
|
|
||||||
#### Real-world Impact
|
|
||||||
```go
|
|
||||||
// Problematic with float64
|
|
||||||
Bad: float64(123.45) * float64(0.000016) = 0.001975200000000001
|
|
||||||
|
|
||||||
// Accurate with decimal.Decimal
|
|
||||||
Good: decimal(123.45) * decimal(0.000016) = 0.001975200000000000
|
|
||||||
```
|
|
||||||
|
|
||||||
#### Industry Standards
|
|
||||||
- Banking and financial systems never use floating-point for monetary calculations
|
|
||||||
- Major payment APIs (Stripe, PayPal) use string representations to avoid precision loss
|
|
||||||
- `shopspring/decimal` is the Go standard for financial applications (used by Kubernetes billing, trading systems)
|
|
||||||
|
|
||||||
#### Alternative Analysis
|
|
||||||
- **big.Rat**: Overkill for this use case, overly complex API for basic currency operations
|
|
||||||
- **Integer scaling**: Requires manual precision management, error-prone across currencies with different decimal places (USD=2, BTC=8, ETH=18)
|
|
||||||
- **float64**: Unacceptable precision loss for financial data
|
|
||||||
|
|
||||||
#### Performance vs Precision Trade-off
|
|
||||||
- Using integer scaling would significantly increase development complexity with manual precision management across different currency types
|
|
||||||
- decimal.Decimal adds only microseconds vs potentially costly conversion errors
|
|
||||||
- Ensures user trust by avoiding strange rounding artifacts
|
|
||||||
- Better maintainability with clear intent and JSON-friendly serialization
|
|
||||||
|
|
||||||
**Conclusion**: The cost of precision errors in financial software far exceeds the minimal performance overhead of using decimal.Decimal.
|
|
||||||
|
|
||||||
### Currency Handling Strategy
|
|
||||||
|
|
||||||
Our implementation uses CoinMarketCap's `/v1/cryptocurrency/quotes/latest` endpoint with intelligent fallback logic:
|
|
||||||
|
|
||||||
- **Direct request**: Try the requested conversion (e.g., `USD → BTC`)
|
|
||||||
- **Fallback on empty data**: If no data returned, try reverse direction (`BTC → USD`) and invert the rate
|
|
||||||
- **Error handling**: Distinguish between unknown symbols, invalid parameters, and network issues
|
|
||||||
|
|
||||||
This approach handles both crypto-to-fiat and fiat-to-crypto conversions using only the cryptocurrency endpoint:
|
|
||||||
- **BTC → USD**: Direct API call works
|
|
||||||
- **USD → BTC**: API call fails (USD not in crypto DB), retry as BTC → USD, then invert rate
|
|
||||||
|
|
||||||
The automatic fallback with rate inversion eliminates the need for separate fiat currency handling while maintaining mathematical precision using decimal arithmetic.
|
|
||||||
|
|
||||||
### Currency Precision Strategy
|
|
||||||
|
|
||||||
To keep the implementation simple, we use a uniform precision rule:
|
|
||||||
|
|
||||||
- **All currencies**: 8 decimal places (covers cryptocurrency precision requirements)
|
|
||||||
|
|
||||||
This approach handles high-precision cryptocurrencies properly while being acceptable for fiat currencies (even though they typically use 2 decimal places). In a production environment, we would maintain per-currency precision metadata, but 8 decimals covers all practical use cases without overcomplicating the initial implementation.
|
|
||||||
|
|
||||||
## Development
|
|
||||||
|
|
||||||
This project follows Clean Architecture principles and SOLID design patterns as specified in the requirements.
|
|
||||||
59
Taskfile.yml
59
Taskfile.yml
|
|
@ -1,59 +0,0 @@
|
||||||
version: '3'
|
|
||||||
|
|
||||||
tasks:
|
|
||||||
test:
|
|
||||||
desc: Run all tests
|
|
||||||
cmds:
|
|
||||||
- go test ./...
|
|
||||||
|
|
||||||
test-verbose:
|
|
||||||
desc: Run all tests with verbose output
|
|
||||||
cmds:
|
|
||||||
- go test -v ./...
|
|
||||||
|
|
||||||
test-coverage:
|
|
||||||
desc: Run tests with coverage report
|
|
||||||
cmds:
|
|
||||||
- go test -coverprofile=coverage.out ./...
|
|
||||||
- go tool cover -html=coverage.out -o coverage.html
|
|
||||||
- echo "Coverage report generated at coverage.html"
|
|
||||||
|
|
||||||
build:
|
|
||||||
desc: Build the application
|
|
||||||
cmds:
|
|
||||||
- go build -o bin/converter ./cmd/converter
|
|
||||||
|
|
||||||
run:
|
|
||||||
desc: "Run the application with parameters (usage: task run -- <amount> <from> <to>)"
|
|
||||||
deps: [build]
|
|
||||||
cmds:
|
|
||||||
- "./bin/converter {{.CLI_ARGS}}"
|
|
||||||
|
|
||||||
clean:
|
|
||||||
desc: Clean build artifacts and coverage files
|
|
||||||
cmds:
|
|
||||||
- rm -rf bin/
|
|
||||||
- rm -f coverage.out coverage.html
|
|
||||||
|
|
||||||
lint:
|
|
||||||
desc: Run linter (if available)
|
|
||||||
cmds:
|
|
||||||
- go vet ./...
|
|
||||||
- go fmt ./...
|
|
||||||
|
|
||||||
deps:
|
|
||||||
desc: Download dependencies
|
|
||||||
cmds:
|
|
||||||
- go mod download
|
|
||||||
- go mod tidy
|
|
||||||
|
|
||||||
generate-mocks:
|
|
||||||
desc: Generate mocks using mockery
|
|
||||||
cmds:
|
|
||||||
- go run github.com/vektra/mockery/v2@latest --name=RateProvider --dir=./app --output=./app/mocks
|
|
||||||
|
|
||||||
all:
|
|
||||||
desc: Run full build pipeline
|
|
||||||
deps: [deps, generate-mocks, lint, test, build]
|
|
||||||
cmds:
|
|
||||||
- echo "Build pipeline completed successfully!"
|
|
||||||
|
|
@ -1,116 +0,0 @@
|
||||||
package app
|
|
||||||
|
|
||||||
import (
|
|
||||||
"converter/domain"
|
|
||||||
"converter/infrastructure/coinmarketcap"
|
|
||||||
"fmt"
|
|
||||||
|
|
||||||
"github.com/shopspring/decimal"
|
|
||||||
)
|
|
||||||
|
|
||||||
// Constants
|
|
||||||
const (
|
|
||||||
CurrencyPrecision = 8 // All currencies use 8 decimal precision
|
|
||||||
)
|
|
||||||
|
|
||||||
// RateProvider defines the contract for fetching exchange rates
|
|
||||||
// Note: The returned RateDTO may contain a reversed rate (e.g., if requesting USD->BTC
|
|
||||||
// but only BTC->USD is available), indicated by FromCode/ToCode being swapped from the request
|
|
||||||
type RateProvider interface {
|
|
||||||
GetRate(fromCode, toCode string) (coinmarketcap.RateDTO, error)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ConvertCurrencyUseCase handles currency conversion operations
|
|
||||||
type ConvertCurrencyUseCase struct {
|
|
||||||
rateProvider RateProvider
|
|
||||||
converter *domain.CurrencyConverter
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewConvertCurrencyUseCase creates a new ConvertCurrencyUseCase
|
|
||||||
func NewConvertCurrencyUseCase(rateProvider RateProvider, converter *domain.CurrencyConverter) *ConvertCurrencyUseCase {
|
|
||||||
return &ConvertCurrencyUseCase{
|
|
||||||
rateProvider: rateProvider,
|
|
||||||
converter: converter,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Execute performs currency conversion
|
|
||||||
func (uc *ConvertCurrencyUseCase) Execute(amount, fromCode, toCode string) (domain.Money, error) {
|
|
||||||
// Create currencies with uniform precision
|
|
||||||
fromCurrency, err := domain.NewCurrency(fromCode, fromCode, CurrencyPrecision)
|
|
||||||
if err != nil {
|
|
||||||
return domain.Money{}, err
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create money object from input
|
|
||||||
money, err := domain.NewMoney(amount, fromCurrency)
|
|
||||||
if err != nil {
|
|
||||||
return domain.Money{}, err
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get exchange rate DTO from provider
|
|
||||||
rateDTO, err := uc.rateProvider.GetRate(fromCode, toCode)
|
|
||||||
if err != nil {
|
|
||||||
return domain.Money{}, err
|
|
||||||
}
|
|
||||||
|
|
||||||
// Convert DTO to domain Rate (domain logic)
|
|
||||||
rate, err := uc.createDomainRate(rateDTO, fromCode, toCode)
|
|
||||||
if err != nil {
|
|
||||||
return domain.Money{}, err
|
|
||||||
}
|
|
||||||
|
|
||||||
// Convert using domain service
|
|
||||||
result, err := uc.converter.Convert(money, rate)
|
|
||||||
if err != nil {
|
|
||||||
return domain.Money{}, err
|
|
||||||
}
|
|
||||||
|
|
||||||
return result, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// createDomainRate converts RateDTO to domain.Rate with proper domain validation
|
|
||||||
func (uc *ConvertCurrencyUseCase) createDomainRate(dto coinmarketcap.RateDTO, requestedFromCode, requestedToCode string) (domain.Rate, error) {
|
|
||||||
// Convert price to decimal with proper precision first
|
|
||||||
priceStr := fmt.Sprintf("%.*f", CurrencyPrecision, dto.Price)
|
|
||||||
rateValue, err := decimal.NewFromString(priceStr)
|
|
||||||
if err != nil {
|
|
||||||
return domain.Rate{}, fmt.Errorf("invalid price format: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if we got a reversed rate and handle accordingly
|
|
||||||
if dto.FromCode == requestedToCode && dto.ToCode == requestedFromCode {
|
|
||||||
// Reversed: we got BTC->USD but requested USD->BTC
|
|
||||||
// dto.FromName always contains the crypto name (Bitcoin)
|
|
||||||
if rateValue.IsZero() {
|
|
||||||
return domain.Rate{}, fmt.Errorf("cannot invert zero price")
|
|
||||||
}
|
|
||||||
|
|
||||||
fromCurrency, err := domain.NewCurrency(requestedFromCode, dto.ToName, CurrencyPrecision)
|
|
||||||
if err != nil {
|
|
||||||
return domain.Rate{}, err
|
|
||||||
}
|
|
||||||
|
|
||||||
toCurrency, err := domain.NewCurrency(requestedToCode, dto.FromName, CurrencyPrecision)
|
|
||||||
if err != nil {
|
|
||||||
return domain.Rate{}, err
|
|
||||||
}
|
|
||||||
|
|
||||||
invertedRate := decimal.NewFromInt(1).Div(rateValue)
|
|
||||||
return domain.NewRate(fromCurrency, toCurrency, invertedRate, dto.Source)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Direct: we got exactly what we requested
|
|
||||||
// dto.FromName contains the crypto name
|
|
||||||
fromCurrency, err := domain.NewCurrency(dto.FromCode, dto.FromName, CurrencyPrecision)
|
|
||||||
if err != nil {
|
|
||||||
return domain.Rate{}, err
|
|
||||||
}
|
|
||||||
|
|
||||||
toCurrency, err := domain.NewCurrency(dto.ToCode, dto.ToName, CurrencyPrecision)
|
|
||||||
if err != nil {
|
|
||||||
return domain.Rate{}, err
|
|
||||||
}
|
|
||||||
|
|
||||||
return domain.NewRate(fromCurrency, toCurrency, rateValue, dto.Source)
|
|
||||||
}
|
|
||||||
|
|
@ -1,144 +0,0 @@
|
||||||
package app
|
|
||||||
|
|
||||||
import (
|
|
||||||
"converter/app/mocks"
|
|
||||||
"converter/domain"
|
|
||||||
"converter/infrastructure/coinmarketcap"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"github.com/stretchr/testify/assert"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestConvertCurrencyUseCase_Execute(t *testing.T) {
|
|
||||||
tests := []struct {
|
|
||||||
name string
|
|
||||||
amount string
|
|
||||||
fromCode string
|
|
||||||
toCode string
|
|
||||||
wantCode string
|
|
||||||
wantErr bool
|
|
||||||
setupMock func(*mocks.RateProvider)
|
|
||||||
}{
|
|
||||||
{
|
|
||||||
name: "direct conversion - crypto to fiat",
|
|
||||||
amount: "1.0",
|
|
||||||
fromCode: "BTC",
|
|
||||||
toCode: "USD",
|
|
||||||
wantCode: "USD",
|
|
||||||
wantErr: false,
|
|
||||||
setupMock: func(m *mocks.RateProvider) {
|
|
||||||
// Direct rate: BTC->USD
|
|
||||||
rateDTO := coinmarketcap.RateDTO{
|
|
||||||
FromCode: "BTC", // Crypto symbol
|
|
||||||
ToCode: "USD", // Fiat code
|
|
||||||
FromName: "Bitcoin", // Crypto name from API
|
|
||||||
ToName: "USD", // Fiat code
|
|
||||||
Price: 50000.0, // BTC price in USD
|
|
||||||
Source: "coinmarketcap",
|
|
||||||
}
|
|
||||||
m.On("GetRate", "BTC", "USD").Return(rateDTO, nil)
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "reversed conversion - fiat to crypto",
|
|
||||||
amount: "50000.0",
|
|
||||||
fromCode: "USD",
|
|
||||||
toCode: "BTC",
|
|
||||||
wantCode: "BTC",
|
|
||||||
wantErr: false,
|
|
||||||
setupMock: func(m *mocks.RateProvider) {
|
|
||||||
// Reversed rate: requested USD->BTC but got BTC->USD
|
|
||||||
rateDTO := coinmarketcap.RateDTO{
|
|
||||||
FromCode: "BTC", // What API actually returned
|
|
||||||
ToCode: "USD", // What API actually returned
|
|
||||||
FromName: "Bitcoin", // Crypto name from API
|
|
||||||
ToName: "USD", // Fiat code
|
|
||||||
Price: 50000.0, // BTC price in USD (will be inverted)
|
|
||||||
Source: "coinmarketcap",
|
|
||||||
}
|
|
||||||
m.On("GetRate", "USD", "BTC").Return(rateDTO, nil)
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "invalid amount",
|
|
||||||
amount: "invalid",
|
|
||||||
fromCode: "USD",
|
|
||||||
toCode: "BTC",
|
|
||||||
wantCode: "",
|
|
||||||
wantErr: true,
|
|
||||||
setupMock: func(m *mocks.RateProvider) {
|
|
||||||
// No mock setup needed - error occurs before calling provider
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "negative amount",
|
|
||||||
amount: "-50.00",
|
|
||||||
fromCode: "USD",
|
|
||||||
toCode: "BTC",
|
|
||||||
wantCode: "",
|
|
||||||
wantErr: true,
|
|
||||||
setupMock: func(m *mocks.RateProvider) {
|
|
||||||
// No mock setup needed - error occurs before calling provider
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "rate not found",
|
|
||||||
amount: "100.00",
|
|
||||||
fromCode: "XYZ",
|
|
||||||
toCode: "ABC",
|
|
||||||
wantCode: "",
|
|
||||||
wantErr: true,
|
|
||||||
setupMock: func(m *mocks.RateProvider) {
|
|
||||||
m.On("GetRate", "XYZ", "ABC").Return(coinmarketcap.RateDTO{}, domain.ErrRateNotFound)
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "provider error",
|
|
||||||
amount: "100.00",
|
|
||||||
fromCode: "USD",
|
|
||||||
toCode: "INVALID",
|
|
||||||
wantCode: "",
|
|
||||||
wantErr: true,
|
|
||||||
setupMock: func(m *mocks.RateProvider) {
|
|
||||||
m.On("GetRate", "USD", "INVALID").Return(coinmarketcap.RateDTO{}, domain.ErrUnsupportedCurrency)
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "empty currency code",
|
|
||||||
amount: "100.00",
|
|
||||||
fromCode: "",
|
|
||||||
toCode: "BTC",
|
|
||||||
wantCode: "",
|
|
||||||
wantErr: true,
|
|
||||||
setupMock: func(m *mocks.RateProvider) {
|
|
||||||
// No mock setup needed - error occurs before calling provider
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tt := range tests {
|
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
|
||||||
// Create fresh mock and use case for each test
|
|
||||||
mockProvider := mocks.NewRateProvider(t)
|
|
||||||
converter := domain.NewCurrencyConverter()
|
|
||||||
useCase := NewConvertCurrencyUseCase(mockProvider, converter)
|
|
||||||
|
|
||||||
// Setup mock expectations
|
|
||||||
tt.setupMock(mockProvider)
|
|
||||||
|
|
||||||
// Execute
|
|
||||||
result, err := useCase.Execute(tt.amount, tt.fromCode, tt.toCode)
|
|
||||||
|
|
||||||
// Assert error expectations
|
|
||||||
if tt.wantErr {
|
|
||||||
assert.Error(t, err, "Execute() should return error")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Assert success case
|
|
||||||
assert.NoError(t, err, "Execute() should not return error")
|
|
||||||
assert.Equal(t, tt.wantCode, result.Currency.Code, "Currency code mismatch")
|
|
||||||
assert.True(t, result.Amount.IsPositive(), "Amount should be positive")
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,56 +0,0 @@
|
||||||
// Code generated by mockery v2.53.5. DO NOT EDIT.
|
|
||||||
|
|
||||||
package mocks
|
|
||||||
|
|
||||||
import (
|
|
||||||
coinmarketcap "converter/infrastructure/coinmarketcap"
|
|
||||||
|
|
||||||
mock "github.com/stretchr/testify/mock"
|
|
||||||
)
|
|
||||||
|
|
||||||
// RateProvider is an autogenerated mock type for the RateProvider type
|
|
||||||
type RateProvider struct {
|
|
||||||
mock.Mock
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetRate provides a mock function with given fields: fromCode, toCode
|
|
||||||
func (_m *RateProvider) GetRate(fromCode string, toCode string) (coinmarketcap.RateDTO, error) {
|
|
||||||
ret := _m.Called(fromCode, toCode)
|
|
||||||
|
|
||||||
if len(ret) == 0 {
|
|
||||||
panic("no return value specified for GetRate")
|
|
||||||
}
|
|
||||||
|
|
||||||
var r0 coinmarketcap.RateDTO
|
|
||||||
var r1 error
|
|
||||||
if rf, ok := ret.Get(0).(func(string, string) (coinmarketcap.RateDTO, error)); ok {
|
|
||||||
return rf(fromCode, toCode)
|
|
||||||
}
|
|
||||||
if rf, ok := ret.Get(0).(func(string, string) coinmarketcap.RateDTO); ok {
|
|
||||||
r0 = rf(fromCode, toCode)
|
|
||||||
} else {
|
|
||||||
r0 = ret.Get(0).(coinmarketcap.RateDTO)
|
|
||||||
}
|
|
||||||
|
|
||||||
if rf, ok := ret.Get(1).(func(string, string) error); ok {
|
|
||||||
r1 = rf(fromCode, toCode)
|
|
||||||
} else {
|
|
||||||
r1 = ret.Error(1)
|
|
||||||
}
|
|
||||||
|
|
||||||
return r0, r1
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewRateProvider creates a new instance of RateProvider. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
|
|
||||||
// The first argument is typically a *testing.T value.
|
|
||||||
func NewRateProvider(t interface {
|
|
||||||
mock.TestingT
|
|
||||||
Cleanup(func())
|
|
||||||
}) *RateProvider {
|
|
||||||
mock := &RateProvider{}
|
|
||||||
mock.Mock.Test(t)
|
|
||||||
|
|
||||||
t.Cleanup(func() { mock.AssertExpectations(t) })
|
|
||||||
|
|
||||||
return mock
|
|
||||||
}
|
|
||||||
|
|
@ -1,52 +0,0 @@
|
||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"converter/app"
|
|
||||||
"converter/domain"
|
|
||||||
"converter/infrastructure/coinmarketcap"
|
|
||||||
"fmt"
|
|
||||||
"os"
|
|
||||||
)
|
|
||||||
|
|
||||||
const (
|
|
||||||
expectedArgs = 4 // program name + 3 arguments
|
|
||||||
apiKeyEnvVar = "CMC_API_KEY"
|
|
||||||
)
|
|
||||||
|
|
||||||
func main() {
|
|
||||||
// Check command line arguments
|
|
||||||
if len(os.Args) != expectedArgs {
|
|
||||||
fmt.Fprintf(os.Stderr, "Usage: %s <amount> <from_currency> <to_currency>\n", os.Args[0])
|
|
||||||
fmt.Fprintf(os.Stderr, "Example: %s 100.50 USD BTC\n", os.Args[0])
|
|
||||||
os.Exit(1)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Parse arguments
|
|
||||||
amount := os.Args[1]
|
|
||||||
fromCurrency := os.Args[2]
|
|
||||||
toCurrency := os.Args[3]
|
|
||||||
|
|
||||||
// Get API key from environment
|
|
||||||
apiKey := os.Getenv(apiKeyEnvVar)
|
|
||||||
if apiKey == "" {
|
|
||||||
fmt.Fprintf(os.Stderr, "Error: %s environment variable is required\n", apiKeyEnvVar)
|
|
||||||
fmt.Fprintf(os.Stderr, "Please set your CoinMarketCap API key:\n")
|
|
||||||
fmt.Fprintf(os.Stderr, "export %s=your_api_key_here\n", apiKeyEnvVar)
|
|
||||||
os.Exit(1)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Initialize dependencies
|
|
||||||
client := coinmarketcap.NewClient(apiKey)
|
|
||||||
converter := domain.NewCurrencyConverter()
|
|
||||||
useCase := app.NewConvertCurrencyUseCase(client, converter)
|
|
||||||
|
|
||||||
// Execute conversion
|
|
||||||
result, err := useCase.Execute(amount, fromCurrency, toCurrency)
|
|
||||||
if err != nil {
|
|
||||||
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
|
|
||||||
os.Exit(1)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Output result
|
|
||||||
fmt.Printf("%s %s\n", result.Amount.StringFixed(8), result.Currency.Code)
|
|
||||||
}
|
|
||||||
|
|
@ -1,131 +0,0 @@
|
||||||
package domain
|
|
||||||
|
|
||||||
import (
|
|
||||||
"errors"
|
|
||||||
"fmt"
|
|
||||||
"strings"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/shopspring/decimal"
|
|
||||||
)
|
|
||||||
|
|
||||||
// Domain-specific errors
|
|
||||||
var (
|
|
||||||
ErrInvalidCurrency = errors.New("invalid currency")
|
|
||||||
ErrInvalidAmount = errors.New("invalid amount")
|
|
||||||
ErrNegativeAmount = errors.New("amount cannot be negative")
|
|
||||||
ErrInvalidRate = errors.New("invalid exchange rate")
|
|
||||||
ErrCurrencyMismatch = errors.New("currency mismatch")
|
|
||||||
ErrRateNotFound = errors.New("exchange rate not found")
|
|
||||||
ErrUnsupportedCurrency = errors.New("unsupported currency")
|
|
||||||
)
|
|
||||||
|
|
||||||
// Currency represents a currency with its metadata
|
|
||||||
type Currency struct {
|
|
||||||
Code string
|
|
||||||
Name string
|
|
||||||
Precision int
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewCurrency creates a new Currency with validation
|
|
||||||
func NewCurrency(code, name string, precision int) (Currency, error) {
|
|
||||||
if strings.TrimSpace(code) == "" {
|
|
||||||
return Currency{}, ErrInvalidCurrency
|
|
||||||
}
|
|
||||||
|
|
||||||
if precision < 0 {
|
|
||||||
return Currency{}, ErrInvalidCurrency
|
|
||||||
}
|
|
||||||
|
|
||||||
return Currency{
|
|
||||||
Code: strings.ToUpper(strings.TrimSpace(code)),
|
|
||||||
Name: strings.TrimSpace(name),
|
|
||||||
Precision: precision,
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// String returns the currency code
|
|
||||||
func (c Currency) String() string {
|
|
||||||
return c.Code
|
|
||||||
}
|
|
||||||
|
|
||||||
// Money represents an amount in a specific currency
|
|
||||||
type Money struct {
|
|
||||||
Amount decimal.Decimal
|
|
||||||
Currency Currency
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewMoney creates a new Money instance from string amount
|
|
||||||
func NewMoney(amount string, currency Currency) (Money, error) {
|
|
||||||
if strings.TrimSpace(amount) == "" {
|
|
||||||
return Money{}, ErrInvalidAmount
|
|
||||||
}
|
|
||||||
|
|
||||||
decimalAmount, err := decimal.NewFromString(strings.TrimSpace(amount))
|
|
||||||
if err != nil {
|
|
||||||
return Money{}, ErrInvalidAmount
|
|
||||||
}
|
|
||||||
|
|
||||||
if decimalAmount.IsNegative() {
|
|
||||||
return Money{}, ErrNegativeAmount
|
|
||||||
}
|
|
||||||
|
|
||||||
return Money{
|
|
||||||
Amount: decimalAmount,
|
|
||||||
Currency: currency,
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewMoneyFromDecimal creates a new Money instance from decimal.Decimal
|
|
||||||
func NewMoneyFromDecimal(amount decimal.Decimal, currency Currency) (Money, error) {
|
|
||||||
if amount.IsNegative() {
|
|
||||||
return Money{}, ErrNegativeAmount
|
|
||||||
}
|
|
||||||
|
|
||||||
return Money{
|
|
||||||
Amount: amount,
|
|
||||||
Currency: currency,
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// String returns formatted money representation
|
|
||||||
func (m Money) String() string {
|
|
||||||
return fmt.Sprintf("%s %s", m.Amount.StringFixed(int32(m.Currency.Precision)), m.Currency.Code)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Rate represents an exchange rate between two currencies
|
|
||||||
type Rate struct {
|
|
||||||
From Currency
|
|
||||||
To Currency
|
|
||||||
Value decimal.Decimal
|
|
||||||
Timestamp time.Time
|
|
||||||
Source string
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewRate creates a new Rate with validation
|
|
||||||
func NewRate(from, to Currency, value decimal.Decimal, source string) (Rate, error) {
|
|
||||||
if from.Code == to.Code {
|
|
||||||
return Rate{}, ErrInvalidRate
|
|
||||||
}
|
|
||||||
|
|
||||||
if value.IsNegative() || value.IsZero() {
|
|
||||||
return Rate{}, ErrInvalidRate
|
|
||||||
}
|
|
||||||
|
|
||||||
return Rate{
|
|
||||||
From: from,
|
|
||||||
To: to,
|
|
||||||
Value: value,
|
|
||||||
Timestamp: time.Now(),
|
|
||||||
Source: strings.TrimSpace(source),
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
// String returns formatted rate representation
|
|
||||||
func (r Rate) String() string {
|
|
||||||
return fmt.Sprintf("1 %s = %s %s",
|
|
||||||
r.From.Code,
|
|
||||||
r.Value.StringFixed(int32(r.To.Precision)),
|
|
||||||
r.To.Code)
|
|
||||||
}
|
|
||||||
|
|
@ -1,219 +0,0 @@
|
||||||
package domain
|
|
||||||
|
|
||||||
import (
|
|
||||||
"strings"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"github.com/shopspring/decimal"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestNewCurrency(t *testing.T) {
|
|
||||||
tests := []struct {
|
|
||||||
name string
|
|
||||||
code string
|
|
||||||
currName string
|
|
||||||
precision int
|
|
||||||
wantErr bool
|
|
||||||
}{
|
|
||||||
{
|
|
||||||
name: "valid currency",
|
|
||||||
code: "USD",
|
|
||||||
currName: "US Dollar",
|
|
||||||
precision: 2,
|
|
||||||
wantErr: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "empty code",
|
|
||||||
code: "",
|
|
||||||
currName: "Test",
|
|
||||||
precision: 2,
|
|
||||||
wantErr: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "whitespace code",
|
|
||||||
code: " ",
|
|
||||||
currName: "Test",
|
|
||||||
precision: 2,
|
|
||||||
wantErr: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "negative precision",
|
|
||||||
code: "BTC",
|
|
||||||
currName: "Bitcoin",
|
|
||||||
precision: -1,
|
|
||||||
wantErr: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "lowercase code gets uppercase",
|
|
||||||
code: "btc",
|
|
||||||
currName: "Bitcoin",
|
|
||||||
precision: 8,
|
|
||||||
wantErr: false,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tt := range tests {
|
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
|
||||||
currency, err := NewCurrency(tt.code, tt.currName, tt.precision)
|
|
||||||
|
|
||||||
if tt.wantErr {
|
|
||||||
if err == nil {
|
|
||||||
t.Errorf("NewCurrency() expected error but got none")
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if err != nil {
|
|
||||||
t.Errorf("NewCurrency() unexpected error: %v", err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
expectedCode := strings.ToUpper(strings.TrimSpace(tt.code))
|
|
||||||
if currency.Code != expectedCode {
|
|
||||||
t.Errorf("NewCurrency() code = %s, want %s", currency.Code, expectedCode)
|
|
||||||
}
|
|
||||||
|
|
||||||
if currency.Precision != tt.precision {
|
|
||||||
t.Errorf("NewCurrency() precision = %d, want %d", currency.Precision, tt.precision)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestNewMoney(t *testing.T) {
|
|
||||||
currency, _ := NewCurrency("USD", "US Dollar", 2)
|
|
||||||
|
|
||||||
tests := []struct {
|
|
||||||
name string
|
|
||||||
amount string
|
|
||||||
currency Currency
|
|
||||||
wantErr bool
|
|
||||||
}{
|
|
||||||
{
|
|
||||||
name: "valid amount",
|
|
||||||
amount: "123.45",
|
|
||||||
currency: currency,
|
|
||||||
wantErr: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "zero amount",
|
|
||||||
amount: "0",
|
|
||||||
currency: currency,
|
|
||||||
wantErr: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "empty amount",
|
|
||||||
amount: "",
|
|
||||||
currency: currency,
|
|
||||||
wantErr: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "invalid amount format",
|
|
||||||
amount: "abc",
|
|
||||||
currency: currency,
|
|
||||||
wantErr: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "negative amount",
|
|
||||||
amount: "-123.45",
|
|
||||||
currency: currency,
|
|
||||||
wantErr: true,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tt := range tests {
|
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
|
||||||
money, err := NewMoney(tt.amount, tt.currency)
|
|
||||||
|
|
||||||
if tt.wantErr {
|
|
||||||
if err == nil {
|
|
||||||
t.Errorf("NewMoney() expected error but got none")
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if err != nil {
|
|
||||||
t.Errorf("NewMoney() unexpected error: %v", err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if money.Currency.Code != tt.currency.Code {
|
|
||||||
t.Errorf("NewMoney() currency = %s, want %s", money.Currency.Code, tt.currency.Code)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestNewRate(t *testing.T) {
|
|
||||||
usd, _ := NewCurrency("USD", "US Dollar", 2)
|
|
||||||
btc, _ := NewCurrency("BTC", "Bitcoin", 8)
|
|
||||||
|
|
||||||
tests := []struct {
|
|
||||||
name string
|
|
||||||
from Currency
|
|
||||||
to Currency
|
|
||||||
value string
|
|
||||||
source string
|
|
||||||
wantErr bool
|
|
||||||
}{
|
|
||||||
{
|
|
||||||
name: "valid rate",
|
|
||||||
from: usd,
|
|
||||||
to: btc,
|
|
||||||
value: "0.00001234",
|
|
||||||
source: "test",
|
|
||||||
wantErr: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "same currencies",
|
|
||||||
from: usd,
|
|
||||||
to: usd,
|
|
||||||
value: "1.0",
|
|
||||||
source: "test",
|
|
||||||
wantErr: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "negative rate",
|
|
||||||
from: usd,
|
|
||||||
to: btc,
|
|
||||||
value: "-0.00001234",
|
|
||||||
source: "test",
|
|
||||||
wantErr: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "zero rate",
|
|
||||||
from: usd,
|
|
||||||
to: btc,
|
|
||||||
value: "0",
|
|
||||||
source: "test",
|
|
||||||
wantErr: true,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tt := range tests {
|
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
|
||||||
value, _ := decimal.NewFromString(tt.value)
|
|
||||||
rate, err := NewRate(tt.from, tt.to, value, tt.source)
|
|
||||||
|
|
||||||
if tt.wantErr {
|
|
||||||
if err == nil {
|
|
||||||
t.Errorf("NewRate() expected error but got none")
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if err != nil {
|
|
||||||
t.Errorf("NewRate() unexpected error: %v", err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if rate.From.Code != tt.from.Code {
|
|
||||||
t.Errorf("NewRate() from = %s, want %s", rate.From.Code, tt.from.Code)
|
|
||||||
}
|
|
||||||
|
|
||||||
if rate.To.Code != tt.to.Code {
|
|
||||||
t.Errorf("NewRate() to = %s, want %s", rate.To.Code, tt.to.Code)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,22 +0,0 @@
|
||||||
package domain
|
|
||||||
|
|
||||||
|
|
||||||
// CurrencyConverter is a domain service for currency conversion operations
|
|
||||||
type CurrencyConverter struct{}
|
|
||||||
|
|
||||||
// NewCurrencyConverter creates a new CurrencyConverter
|
|
||||||
func NewCurrencyConverter() *CurrencyConverter {
|
|
||||||
return &CurrencyConverter{}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Convert converts money from one currency to another using the provided rate
|
|
||||||
func (c *CurrencyConverter) Convert(money Money, rate Rate) (Money, error) {
|
|
||||||
if money.Currency.Code != rate.From.Code {
|
|
||||||
return Money{}, ErrCurrencyMismatch
|
|
||||||
}
|
|
||||||
|
|
||||||
convertedAmount := money.Amount.Mul(rate.Value)
|
|
||||||
|
|
||||||
return NewMoneyFromDecimal(convertedAmount, rate.To)
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
@ -1,71 +0,0 @@
|
||||||
package domain
|
|
||||||
|
|
||||||
import (
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"github.com/shopspring/decimal"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestCurrencyConverter_Convert(t *testing.T) {
|
|
||||||
converter := NewCurrencyConverter()
|
|
||||||
|
|
||||||
usd, _ := NewCurrency("USD", "US Dollar", 2)
|
|
||||||
btc, _ := NewCurrency("BTC", "Bitcoin", 8)
|
|
||||||
eur, _ := NewCurrency("EUR", "Euro", 2)
|
|
||||||
|
|
||||||
tests := []struct {
|
|
||||||
name string
|
|
||||||
money Money
|
|
||||||
rate Rate
|
|
||||||
wantErr bool
|
|
||||||
wantCode string
|
|
||||||
}{
|
|
||||||
{
|
|
||||||
name: "successful conversion",
|
|
||||||
money: func() Money {
|
|
||||||
m, _ := NewMoney("100", usd)
|
|
||||||
return m
|
|
||||||
}(),
|
|
||||||
rate: func() Rate {
|
|
||||||
r, _ := NewRate(usd, btc, decimal.NewFromFloat(0.00002), "test")
|
|
||||||
return r
|
|
||||||
}(),
|
|
||||||
wantErr: false,
|
|
||||||
wantCode: "BTC",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "currency mismatch",
|
|
||||||
money: func() Money {
|
|
||||||
m, _ := NewMoney("100", eur)
|
|
||||||
return m
|
|
||||||
}(),
|
|
||||||
rate: func() Rate {
|
|
||||||
r, _ := NewRate(usd, btc, decimal.NewFromFloat(0.00002), "test")
|
|
||||||
return r
|
|
||||||
}(),
|
|
||||||
wantErr: true,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tt := range tests {
|
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
|
||||||
result, err := converter.Convert(tt.money, tt.rate)
|
|
||||||
|
|
||||||
if tt.wantErr {
|
|
||||||
if err == nil {
|
|
||||||
t.Errorf("Convert() expected error but got none")
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if err != nil {
|
|
||||||
t.Errorf("Convert() unexpected error: %v", err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if result.Currency.Code != tt.wantCode {
|
|
||||||
t.Errorf("Convert() currency = %s, want %s", result.Currency.Code, tt.wantCode)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
14
go.mod
14
go.mod
|
|
@ -2,16 +2,4 @@ module converter
|
||||||
|
|
||||||
go 1.24.3
|
go 1.24.3
|
||||||
|
|
||||||
require (
|
require github.com/shopspring/decimal v1.4.0
|
||||||
github.com/go-resty/resty/v2 v2.16.5
|
|
||||||
github.com/shopspring/decimal v1.4.0
|
|
||||||
)
|
|
||||||
|
|
||||||
require (
|
|
||||||
github.com/davecgh/go-spew v1.1.1 // indirect
|
|
||||||
github.com/pmezard/go-difflib v1.0.0 // indirect
|
|
||||||
github.com/stretchr/objx v0.5.2 // indirect
|
|
||||||
github.com/stretchr/testify v1.11.1 // indirect
|
|
||||||
golang.org/x/net v0.33.0 // indirect
|
|
||||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
|
||||||
)
|
|
||||||
|
|
|
||||||
17
go.sum
17
go.sum
|
|
@ -1,19 +1,2 @@
|
||||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
|
||||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
|
||||||
github.com/go-resty/resty/v2 v2.16.5 h1:hBKqmWrr7uRc3euHVqmh1HTHcKn99Smr7o5spptdhTM=
|
|
||||||
github.com/go-resty/resty/v2 v2.16.5/go.mod h1:hkJtXbA2iKHzJheXYvQ8snQES5ZLGKMwQ07xAwp/fiA=
|
|
||||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
|
||||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
|
||||||
github.com/shopspring/decimal v1.4.0 h1:bxl37RwXBklmTi0C79JfXCEBD1cqqHt0bbgBAGFp81k=
|
github.com/shopspring/decimal v1.4.0 h1:bxl37RwXBklmTi0C79JfXCEBD1cqqHt0bbgBAGFp81k=
|
||||||
github.com/shopspring/decimal v1.4.0/go.mod h1:gawqmDU56v4yIKSwfBSFip1HdCCXN8/+DMd9qYNcwME=
|
github.com/shopspring/decimal v1.4.0/go.mod h1:gawqmDU56v4yIKSwfBSFip1HdCCXN8/+DMd9qYNcwME=
|
||||||
github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY=
|
|
||||||
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
|
|
||||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
|
||||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
|
||||||
golang.org/x/net v0.33.0 h1:74SYHlV8BIgHIFC/LrYkOGIwL19eTYXQ5wc6TBuO36I=
|
|
||||||
golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4=
|
|
||||||
golang.org/x/time v0.6.0 h1:eTDhh4ZXt5Qf0augr54TN6suAUudPcawVZeIAPU7D4U=
|
|
||||||
golang.org/x/time v0.6.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
|
|
||||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
|
||||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
|
||||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
|
||||||
|
|
|
||||||
|
|
@ -1,136 +0,0 @@
|
||||||
package coinmarketcap
|
|
||||||
|
|
||||||
import (
|
|
||||||
"errors"
|
|
||||||
"fmt"
|
|
||||||
|
|
||||||
"github.com/go-resty/resty/v2"
|
|
||||||
)
|
|
||||||
|
|
||||||
// CoinMarketCap specific errors
|
|
||||||
var (
|
|
||||||
ErrUnknownSymbol = errors.New("unknown symbol")
|
|
||||||
ErrInvalidConvert = errors.New("invalid convert currency")
|
|
||||||
ErrAPIError = errors.New("coinmarketcap api error")
|
|
||||||
ErrInvalidResponse = errors.New("invalid response format")
|
|
||||||
)
|
|
||||||
|
|
||||||
const (
|
|
||||||
baseURL = "https://pro-api.coinmarketcap.com"
|
|
||||||
quotesPath = "/v1/cryptocurrency/quotes/latest"
|
|
||||||
)
|
|
||||||
|
|
||||||
// Client implements the RateProvider interface for CoinMarketCap API
|
|
||||||
type Client struct {
|
|
||||||
httpClient *resty.Client
|
|
||||||
apiKey string
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewClient creates a new CoinMarketCap API client
|
|
||||||
func NewClient(apiKey string) *Client {
|
|
||||||
client := resty.New().
|
|
||||||
SetBaseURL(baseURL).
|
|
||||||
SetHeader("Accept", "application/json").
|
|
||||||
SetHeader("X-CMC_PRO_API_KEY", apiKey)
|
|
||||||
|
|
||||||
return &Client{
|
|
||||||
httpClient: client,
|
|
||||||
apiKey: apiKey,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetRate fetches exchange rate from CoinMarketCap API
|
|
||||||
func (c *Client) GetRate(fromCode, toCode string) (RateDTO, error) {
|
|
||||||
// Try direct conversion first
|
|
||||||
rateDTO, err := c.tryGetRate(fromCode, toCode)
|
|
||||||
if err == nil {
|
|
||||||
return rateDTO, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// If empty result but no other error, try reverse
|
|
||||||
if err == ErrUnknownSymbol {
|
|
||||||
reverseDTO, reverseErr := c.tryGetRate(toCode, fromCode)
|
|
||||||
if reverseErr == nil {
|
|
||||||
return reverseDTO, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// If reverse request failed with invalid convert, that confirms the original
|
|
||||||
// "from" currency is indeed unknown (it's now the "convert" parameter)
|
|
||||||
if reverseErr == ErrInvalidConvert {
|
|
||||||
return RateDTO{}, err // Return original unknown symbol error
|
|
||||||
}
|
|
||||||
|
|
||||||
// Any other error from reverse request (network, auth, etc) should be surfaced
|
|
||||||
return RateDTO{}, reverseErr
|
|
||||||
}
|
|
||||||
|
|
||||||
// Return original error (either non-unknown-symbol error or first currency not in crypto database)
|
|
||||||
return RateDTO{}, err
|
|
||||||
}
|
|
||||||
|
|
||||||
// tryGetRate attempts to get rate in specified direction
|
|
||||||
func (c *Client) tryGetRate(fromCode, toCode string) (RateDTO, error) {
|
|
||||||
var response APIResponse
|
|
||||||
|
|
||||||
// Make API request
|
|
||||||
resp, err := c.httpClient.R().
|
|
||||||
SetQueryParam("symbol", fromCode).
|
|
||||||
SetQueryParam("convert", toCode).
|
|
||||||
SetResult(&response).
|
|
||||||
Get(quotesPath)
|
|
||||||
|
|
||||||
if err != nil {
|
|
||||||
return RateDTO{}, fmt.Errorf("%w: %v", ErrAPIError, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check HTTP status first
|
|
||||||
if resp.StatusCode() != 200 {
|
|
||||||
switch resp.StatusCode() {
|
|
||||||
case 400:
|
|
||||||
return RateDTO{}, ErrInvalidConvert // Invalid convert parameter
|
|
||||||
case 401:
|
|
||||||
return RateDTO{}, fmt.Errorf("%w: unauthorized - bad or missing API key", ErrAPIError)
|
|
||||||
default:
|
|
||||||
return RateDTO{}, fmt.Errorf("%w: HTTP %d", ErrAPIError, resp.StatusCode())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if data is empty - invalid currency
|
|
||||||
if len(response.Data) == 0 {
|
|
||||||
return RateDTO{}, ErrUnknownSymbol
|
|
||||||
}
|
|
||||||
|
|
||||||
// Extract rate from response
|
|
||||||
rateDTO, err := c.extractRateDTO(response, fromCode, toCode)
|
|
||||||
if err != nil {
|
|
||||||
return RateDTO{}, err
|
|
||||||
}
|
|
||||||
|
|
||||||
return rateDTO, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
// extractRateDTO converts API response to RateDTO
|
|
||||||
func (c *Client) extractRateDTO(response APIResponse, fromCode, toCode string) (RateDTO, error) {
|
|
||||||
// Get crypto data for the from currency
|
|
||||||
cryptoData, exists := response.Data[fromCode]
|
|
||||||
if !exists {
|
|
||||||
return RateDTO{}, ErrInvalidResponse
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get quote for the to currency
|
|
||||||
quote, exists := cryptoData.Quote[toCode]
|
|
||||||
if !exists {
|
|
||||||
return RateDTO{}, ErrInvalidResponse
|
|
||||||
}
|
|
||||||
|
|
||||||
// Return the rate exactly as API provided it
|
|
||||||
return RateDTO{
|
|
||||||
FromCode: fromCode,
|
|
||||||
ToCode: toCode,
|
|
||||||
FromName: cryptoData.Name,
|
|
||||||
ToName: toCode,
|
|
||||||
Price: quote.Price,
|
|
||||||
Source: "coinmarketcap",
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
|
|
@ -1,34 +0,0 @@
|
||||||
package coinmarketcap
|
|
||||||
|
|
||||||
// APIResponse represents the top-level CoinMarketCap API response
|
|
||||||
type APIResponse struct {
|
|
||||||
Status Status `json:"status"`
|
|
||||||
Data map[string]CryptoQuote `json:"data"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// Status represents the API response status
|
|
||||||
type Status struct {
|
|
||||||
ErrorCode int `json:"error_code"`
|
|
||||||
ErrorMessage *string `json:"error_message"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// CryptoQuote represents cryptocurrency data with quotes
|
|
||||||
type CryptoQuote struct {
|
|
||||||
Name string `json:"name"`
|
|
||||||
Quote map[string]CurrencyQuote `json:"quote"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// CurrencyQuote represents price quote in a specific currency
|
|
||||||
type CurrencyQuote struct {
|
|
||||||
Price float64 `json:"price"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// RateDTO represents exchange rate data from infrastructure layer
|
|
||||||
type RateDTO struct {
|
|
||||||
FromCode string
|
|
||||||
ToCode string
|
|
||||||
FromName string
|
|
||||||
ToName string
|
|
||||||
Price float64
|
|
||||||
Source string
|
|
||||||
}
|
|
||||||
Loading…
Reference in a new issue