diff --git a/README.md b/README.md index be5b71b..9bc9a90 100644 --- a/README.md +++ b/README.md @@ -110,6 +110,33 @@ curl "http://localhost:8000/v1/rates/symbols" {"date":"2025-12-05","symbols":["EUR","USD","GBP","JPY","CHF","AUD","CAD"]} ``` +### Get All Available Currencies with Names + +``` +GET /v1/currencies +``` + +Returns the latest available currencies from the database, each with a human-readable name. Returns `404` if no symbols data has been stored yet. + +#### Example Request + +```bash +curl "http://localhost:8000/v1/currencies" +``` + +#### Example Response + +```json +{ + "date": "2025-12-05", + "data": [ + {"symbol": "EUR", "name": "Euro"}, + {"symbol": "USD", "name": "US Dollar"}, + {"symbol": "GBP", "name": "British Pound Sterling"} + ] +} +``` + ### Get Latest Exchange Rates ``` diff --git a/handlers/named_symbols.go b/handlers/named_symbols.go new file mode 100644 index 0000000..c4a8208 --- /dev/null +++ b/handlers/named_symbols.go @@ -0,0 +1,42 @@ +package handlers + +import ( + "context" + "encoding/json" + "net/http" + + "github.com/kamaal111/forex-api/database" + "github.com/kamaal111/forex-api/utils" +) + +func GetCurrencies(writer http.ResponseWriter, request *http.Request) { + ctx := context.Background() + client, err := database.CreateClient(ctx) + if err != nil { + utils.ErrorHandler(writer, err.Error(), http.StatusInternalServerError) + return + } + defer client.Close() + + repo := NewFirestoreRatesRepository(ctx, client) + service := NewRatesService(repo) + + record, err := service.GetAllNamedSymbols() + if err != nil { + utils.ErrorHandler(writer, err.Error(), http.StatusInternalServerError) + return + } + if record == nil { + utils.ErrorHandler(writer, "symbols not found", http.StatusNotFound) + return + } + + output, err := json.Marshal(record) + if err != nil { + utils.ErrorHandler(writer, err.Error(), http.StatusInternalServerError) + return + } + + writer.Header().Set("content-type", "application/json") + writer.Write(output) +} diff --git a/handlers/named_symbols_test.go b/handlers/named_symbols_test.go new file mode 100644 index 0000000..f96394d --- /dev/null +++ b/handlers/named_symbols_test.go @@ -0,0 +1,121 @@ +package handlers + +import ( + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "testing" + + "github.com/kamaal111/forex-api/utils" +) + +func TestableCurrenciesHandler(repo RatesRepository) http.HandlerFunc { + return func(writer http.ResponseWriter, request *http.Request) { + service := NewRatesService(repo) + + record, err := service.GetAllNamedSymbols() + if err != nil { + utils.ErrorHandler(writer, err.Error(), http.StatusInternalServerError) + return + } + if record == nil { + utils.ErrorHandler(writer, "symbols not found", http.StatusNotFound) + return + } + + output, err := json.Marshal(record) + if err != nil { + utils.ErrorHandler(writer, err.Error(), http.StatusInternalServerError) + return + } + + writer.Header().Set("content-type", "application/json") + writer.Write(output) + } +} + +func TestGetCurrenciesHandler(t *testing.T) { + tests := []struct { + name string + mockRecord *SymbolsRecord + mockErr error + wantStatusCode int + wantSymbols []NamedSymbol + }{ + { + name: "returns named symbols for symbols that have rates in the database", + mockRecord: &SymbolsRecord{Date: "2025-11-21", Symbols: []string{"EUR", "USD", "GBP"}}, + mockErr: nil, + wantStatusCode: http.StatusOK, + wantSymbols: []NamedSymbol{ + {Symbol: "EUR", Name: "Euro"}, + {Symbol: "USD", Name: "US Dollar"}, + {Symbol: "GBP", Name: "British Pound Sterling"}, + }, + }, + { + name: "returns 404 when no data exists in the database", + mockRecord: nil, + mockErr: nil, + wantStatusCode: http.StatusNotFound, + wantSymbols: nil, + }, + { + name: "returns 500 on database error", + mockRecord: nil, + mockErr: errors.New("database error"), + wantStatusCode: http.StatusInternalServerError, + wantSymbols: nil, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + mockRepo := &MockRatesRepository{ + GetAllSymbolsFunc: func() (*SymbolsRecord, error) { + return tt.mockRecord, tt.mockErr + }, + } + + handler := TestableCurrenciesHandler(mockRepo) + + req := httptest.NewRequest(http.MethodGet, CurrenciesPath, nil) + recorder := httptest.NewRecorder() + + handler(recorder, req) + + if recorder.Code != tt.wantStatusCode { + t.Errorf("GetCurrencies() status = %d, want %d", recorder.Code, tt.wantStatusCode) + } + + if tt.wantSymbols != nil { + contentType := recorder.Header().Get("content-type") + if contentType != "application/json" { + t.Errorf("GetCurrencies() content-type = %q, want %q", contentType, "application/json") + } + + var record CurrenciesRecord + if err := json.NewDecoder(recorder.Body).Decode(&record); err != nil { + t.Fatalf("failed to decode response: %v", err) + } + + if len(record.Data) != len(tt.wantSymbols) { + t.Errorf("GetCurrencies() returned %d symbols, want %d", len(record.Data), len(tt.wantSymbols)) + } + + for i, expected := range tt.wantSymbols { + if i >= len(record.Data) { + break + } + if record.Data[i].Symbol != expected.Symbol { + t.Errorf("GetCurrencies() data[%d].symbol = %q, want %q", i, record.Data[i].Symbol, expected.Symbol) + } + if record.Data[i].Name != expected.Name { + t.Errorf("GetCurrencies() data[%d].name = %q, want %q", i, record.Data[i].Name, expected.Name) + } + } + } + }) + } +} diff --git a/handlers/rates_test.go b/handlers/rates_test.go index 232ef1a..1ec1f6f 100644 --- a/handlers/rates_test.go +++ b/handlers/rates_test.go @@ -111,7 +111,7 @@ func TestGetLatestHandler(t *testing.T) { handler := TestableHandler(mockRepo) - req := httptest.NewRequest(http.MethodGet, "/v1/rates/latest"+tt.queryParams, nil) + req := httptest.NewRequest(http.MethodGet, LatestPath+tt.queryParams, nil) recorder := httptest.NewRecorder() handler(recorder, req) @@ -192,7 +192,7 @@ func TestGetLatestHandler_SymbolsFiltering(t *testing.T) { handler := TestableHandler(mockRepo) - req := httptest.NewRequest(http.MethodGet, "/v1/rates/latest?symbols="+tt.symbols, nil) + req := httptest.NewRequest(http.MethodGet, LatestPath+"?symbols="+tt.symbols, nil) recorder := httptest.NewRecorder() handler(recorder, req) diff --git a/handlers/routes.go b/handlers/routes.go new file mode 100644 index 0000000..366366e --- /dev/null +++ b/handlers/routes.go @@ -0,0 +1,7 @@ +package handlers + +const ( + LatestPath = "/v1/rates/latest" + SymbolsPath = "/v1/rates/symbols" + CurrenciesPath = "/v1/currencies" +) diff --git a/handlers/service.go b/handlers/service.go index cb10766..ead8989 100644 --- a/handlers/service.go +++ b/handlers/service.go @@ -17,6 +17,16 @@ type SymbolsRecord struct { Symbols []string `json:"symbols" firestore:"symbols"` } +type NamedSymbol struct { + Symbol string `json:"symbol"` + Name string `json:"name"` +} + +type CurrenciesRecord struct { + Date string `json:"date"` + Data []NamedSymbol `json:"data"` +} + type RatesRepository interface { GetLatestRate(base string) (*ExchangeRateRecord, error) GetAllSymbols() (*SymbolsRecord, error) @@ -64,6 +74,27 @@ func (s *RatesService) GetAllSymbols() (*SymbolsRecord, error) { return s.Repository.GetAllSymbols() } +func (s *RatesService) GetAllNamedSymbols() (*CurrenciesRecord, error) { + record, err := s.Repository.GetAllSymbols() + if err != nil { + return nil, err + } + if record == nil { + return nil, nil + } + + named := make([]NamedSymbol, 0, len(record.Symbols)) + for _, symbol := range record.Symbols { + name, ok := CurrencyNames[symbol] + if !ok { + name = symbol + } + named = append(named, NamedSymbol{Symbol: symbol, Name: name}) + } + + return &CurrenciesRecord{Date: record.Date, Data: named}, nil +} + func NormalizeBase(base string) string { normalized := strings.ToUpper(strings.TrimSpace(base)) if !utils.ArrayContains(Currencies, normalized) { @@ -88,47 +119,55 @@ func MakeSymbolsArray(raw string, base string) []string { return symbolsArray } -var Currencies = []string{ - "EUR", - "USD", - "JPY", - "BGN", - "CYP", - "CZK", - "DKK", - "EEK", - "GBP", - "HUF", - "LTL", - "LVL", - "MTL", - "PLN", - "ROL", - "RON", - "SEK", - "SIT", - "SKK", - "CHF", - "ISK", - "ILS", - "NOK", - "HRK", - "RUB", - "TRL", - "TRY", - "AUD", - "BRL", - "CAD", - "CNY", - "HKD", - "IDR", - "INR", - "KRW", - "MXN", - "MYR", - "NZD", - "PHP", - "SGD", - "THB", - "ZAR", +var CurrencyNames = map[string]string{ + "EUR": "Euro", + "USD": "US Dollar", + "JPY": "Japanese Yen", + "BGN": "Bulgarian Lev", + "CYP": "Cypriot Pound", + "CZK": "Czech Koruna", + "DKK": "Danish Krone", + "EEK": "Estonian Kroon", + "GBP": "British Pound Sterling", + "HUF": "Hungarian Forint", + "LTL": "Lithuanian Litas", + "LVL": "Latvian Lats", + "MTL": "Maltese Lira", + "PLN": "Polish Zloty", + "ROL": "Romanian Leu (old)", + "RON": "Romanian Leu", + "SEK": "Swedish Krona", + "SIT": "Slovenian Tolar", + "SKK": "Slovak Koruna", + "CHF": "Swiss Franc", + "ISK": "Icelandic Krona", + "ILS": "Israeli New Shekel", + "NOK": "Norwegian Krone", + "HRK": "Croatian Kuna", + "RUB": "Russian Ruble", + "TRL": "Turkish Lira (old)", + "TRY": "Turkish Lira", + "AUD": "Australian Dollar", + "BRL": "Brazilian Real", + "CAD": "Canadian Dollar", + "CNY": "Chinese Yuan", + "HKD": "Hong Kong Dollar", + "IDR": "Indonesian Rupiah", + "INR": "Indian Rupee", + "KRW": "South Korean Won", + "MXN": "Mexican Peso", + "MYR": "Malaysian Ringgit", + "NZD": "New Zealand Dollar", + "PHP": "Philippine Peso", + "SGD": "Singapore Dollar", + "THB": "Thai Baht", + "ZAR": "South African Rand", } + +var Currencies = func() []string { + keys := make([]string, 0, len(CurrencyNames)) + for k := range CurrencyNames { + keys = append(keys, k) + } + return keys +}() diff --git a/handlers/symbols_test.go b/handlers/symbols_test.go index 95cf43c..6a5b927 100644 --- a/handlers/symbols_test.go +++ b/handlers/symbols_test.go @@ -76,7 +76,7 @@ func TestGetSymbolsHandler(t *testing.T) { handler := TestableSymbolsHandler(mockRepo) - req := httptest.NewRequest(http.MethodGet, "/v1/rates/symbols", nil) + req := httptest.NewRequest(http.MethodGet, SymbolsPath, nil) recorder := httptest.NewRecorder() handler(recorder, req) diff --git a/routers/currencies.go b/routers/currencies.go new file mode 100644 index 0000000..bad5f33 --- /dev/null +++ b/routers/currencies.go @@ -0,0 +1,11 @@ +package routers + +import ( + "net/http" + + "github.com/kamaal111/forex-api/handlers" +) + +func currenciesGroup(mux *http.ServeMux) { + mux.Handle(handlers.CurrenciesPath, loggerMiddleware(http.HandlerFunc(handlers.GetCurrencies))) +} diff --git a/routers/rates.go b/routers/rates.go index 425ae7e..e39501e 100644 --- a/routers/rates.go +++ b/routers/rates.go @@ -7,6 +7,6 @@ import ( ) func ratesGroup(mux *http.ServeMux) { - mux.Handle("/v1/rates/latest", loggerMiddleware(http.HandlerFunc(handlers.GetLatest))) - mux.Handle("/v1/rates/symbols", loggerMiddleware(http.HandlerFunc(handlers.GetSymbols))) + mux.Handle(handlers.LatestPath, loggerMiddleware(http.HandlerFunc(handlers.GetLatest))) + mux.Handle(handlers.SymbolsPath, loggerMiddleware(http.HandlerFunc(handlers.GetSymbols))) } diff --git a/routers/routers.go b/routers/routers.go index a499235..fc69733 100644 --- a/routers/routers.go +++ b/routers/routers.go @@ -17,6 +17,7 @@ func Start() { mux := http.NewServeMux() ratesGroup(mux) + currenciesGroup(mux) mux.Handle("/", loggerMiddleware(http.HandlerFunc(notFound))) log.Printf("Listening on %s...", serverAddress) diff --git a/test/integration/helpers.go b/test/integration/helpers.go index e7f7abc..46b18e4 100644 --- a/test/integration/helpers.go +++ b/test/integration/helpers.go @@ -156,6 +156,11 @@ func (s *ServerProcess) GetSymbols() (*http.Response, error) { return http.Get(url) } +func (s *ServerProcess) GetCurrencies() (*http.Response, error) { + url := fmt.Sprintf("%s/v1/currencies", s.baseURL) + return http.Get(url) +} + func (s *ServerProcess) GetLatest(base, symbols string) (*http.Response, error) { url := fmt.Sprintf("%s/v1/rates/latest", s.baseURL) if base != "" || symbols != "" { diff --git a/test/integration/integration_test.go b/test/integration/integration_test.go index 3adae6d..16d7f32 100644 --- a/test/integration/integration_test.go +++ b/test/integration/integration_test.go @@ -22,6 +22,16 @@ type ErrorResponse struct { Message string `json:"message"` } +type NamedSymbol struct { + Symbol string `json:"symbol"` + Name string `json:"name"` +} + +type CurrenciesRecord struct { + Date string `json:"date"` + Data []NamedSymbol `json:"data"` +} + func TestGetLatestEndpoint(t *testing.T) { if testing.Short() { t.Skip("Skipping integration test in short mode") @@ -322,6 +332,159 @@ func TestGetSymbolsEndpoint(t *testing.T) { }) } +func TestGetCurrenciesEndpoint(t *testing.T) { + if testing.Short() { + t.Skip("Skipping integration test in short mode") + } + + tc := NewTestContext() + if err := tc.Setup(0); err != nil { + t.Fatalf("Failed to setup test context: %v", err) + } + defer tc.Teardown() + + t.Run("returns 404 when no data exists in the database", func(t *testing.T) { + if err := tc.ClearCollection("symbols"); err != nil { + t.Fatalf("Failed to clear collection: %v", err) + } + + resp, err := tc.Server.GetCurrencies() + if err != nil { + t.Fatalf("Failed to make request: %v", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusNotFound { + t.Errorf("Expected status 404, got %d", resp.StatusCode) + } + }) + + t.Run("returns named symbols with human-readable names", func(t *testing.T) { + if err := tc.ClearCollection("symbols"); err != nil { + t.Fatalf("Failed to clear collection: %v", err) + } + + _, err := tc.DB.Collection("symbols").Doc("2025-11-21").Set(tc.Ctx, map[string]interface{}{ + "date": "2025-11-21", + "symbols": []string{"EUR", "USD", "GBP"}, + }) + if err != nil { + t.Fatalf("Failed to seed data: %v", err) + } + + resp, err := tc.Server.GetCurrencies() + if err != nil { + t.Fatalf("Failed to make request: %v", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) + t.Fatalf("Expected status 200, got %d: %s", resp.StatusCode, string(body)) + } + + var record CurrenciesRecord + if err := json.NewDecoder(resp.Body).Decode(&record); err != nil { + t.Fatalf("Failed to decode response: %v", err) + } + + if record.Date != "2025-11-21" { + t.Errorf("Expected date %q, got %q", "2025-11-21", record.Date) + } + + wantSymbols := []NamedSymbol{ + {Symbol: "EUR", Name: "Euro"}, + {Symbol: "USD", Name: "US Dollar"}, + {Symbol: "GBP", Name: "British Pound Sterling"}, + } + + if len(record.Data) != len(wantSymbols) { + t.Fatalf("Expected %d symbols, got %d: %v", len(wantSymbols), len(record.Data), record.Data) + } + + for i, want := range wantSymbols { + got := record.Data[i] + if got.Symbol != want.Symbol { + t.Errorf("data[%d].symbol = %q, want %q", i, got.Symbol, want.Symbol) + } + if got.Name != want.Name { + t.Errorf("data[%d].name = %q, want %q", i, got.Name, want.Name) + } + } + }) + + t.Run("returns only the latest symbols from the database", func(t *testing.T) { + if err := tc.ClearCollection("symbols"); err != nil { + t.Fatalf("Failed to clear collection: %v", err) + } + + _, err := tc.DB.Collection("symbols").Doc("2025-11-20").Set(tc.Ctx, map[string]interface{}{ + "date": "2025-11-20", + "symbols": []string{"EUR", "USD"}, + }) + if err != nil { + t.Fatalf("Failed to seed older data: %v", err) + } + + _, err = tc.DB.Collection("symbols").Doc("2025-11-21").Set(tc.Ctx, map[string]interface{}{ + "date": "2025-11-21", + "symbols": []string{"EUR", "USD", "GBP"}, + }) + if err != nil { + t.Fatalf("Failed to seed latest data: %v", err) + } + + resp, err := tc.Server.GetCurrencies() + if err != nil { + t.Fatalf("Failed to make request: %v", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) + t.Fatalf("Expected status 200, got %d: %s", resp.StatusCode, string(body)) + } + + var record CurrenciesRecord + if err := json.NewDecoder(resp.Body).Decode(&record); err != nil { + t.Fatalf("Failed to decode response: %v", err) + } + + if record.Date != "2025-11-21" { + t.Errorf("Expected date %q, got %q", "2025-11-21", record.Date) + } + + if len(record.Data) != 3 { + t.Errorf("Expected 3 symbols from latest entry, got %d: %v", len(record.Data), record.Data) + } + }) + + t.Run("response has application/json content-type", func(t *testing.T) { + if err := tc.ClearCollection("symbols"); err != nil { + t.Fatalf("Failed to clear collection: %v", err) + } + + _, err := tc.DB.Collection("symbols").Doc("2025-11-21").Set(tc.Ctx, map[string]interface{}{ + "date": "2025-11-21", + "symbols": []string{"EUR"}, + }) + if err != nil { + t.Fatalf("Failed to seed data: %v", err) + } + + resp, err := tc.Server.GetCurrencies() + if err != nil { + t.Fatalf("Failed to make request: %v", err) + } + defer resp.Body.Close() + + contentType := resp.Header.Get("Content-Type") + if contentType != "application/json" { + t.Errorf("Expected Content-Type application/json, got %s", contentType) + } + }) +} + func TestContentType(t *testing.T) { if testing.Short() { t.Skip("Skipping integration test in short mode")