Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 27 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

```
Expand Down
42 changes: 42 additions & 0 deletions handlers/named_symbols.go
Original file line number Diff line number Diff line change
@@ -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)
}
121 changes: 121 additions & 0 deletions handlers/named_symbols_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
}
})
}
}
4 changes: 2 additions & 2 deletions handlers/rates_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand Down
7 changes: 7 additions & 0 deletions handlers/routes.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
package handlers

const (
LatestPath = "/v1/rates/latest"
SymbolsPath = "/v1/rates/symbols"
CurrenciesPath = "/v1/currencies"
)
125 changes: 82 additions & 43 deletions handlers/service.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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) {
Expand All @@ -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{

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@copilot you must ensure that this map contains all currency symbols above, or you can do it the other way around which is derive the currencies above from this maps keys

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done in commit 9e70e9c. Currencies is now derived from the keys of CurrencyNames, so they're always in sync — adding a currency to CurrencyNames automatically makes it available in Currencies.

"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
}()
2 changes: 1 addition & 1 deletion handlers/symbols_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
11 changes: 11 additions & 0 deletions routers/currencies.go
Original file line number Diff line number Diff line change
@@ -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)))
}
4 changes: 2 additions & 2 deletions routers/rates.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)))
}
Loading