diff --git a/.env.example b/.env.example index 72536cea..35496e05 100644 --- a/.env.example +++ b/.env.example @@ -10,6 +10,9 @@ HMAC_TIMESTAMP_AGE=5 ENVIRONMENT=local # local, staging, production SENTRY_DSN= HOST_DOMAIN=http://localhost:8000 +CURRENCIES_CACHE_DURATION=24 +PUBKEY_CACHE_DURATION=365 +INSTITUTIONS_CACHE_DURATION=24 # Database Config DB_NAME=paycrest @@ -25,6 +28,7 @@ REDIS_HOST=localhost REDIS_PORT=6379 REDIS_PASSWORD= REDIS_DB=0 +CACHE_VERSION=v1 # Order Config ORDER_FULFILLMENT_VALIDITY=10 # value in minutes diff --git a/config/redis.go b/config/redis.go index e7d32e56..00132d87 100644 --- a/config/redis.go +++ b/config/redis.go @@ -12,6 +12,7 @@ type RedisConfiguration struct { Port string Password string DB int + CacheVersion string } // RedisConfig retrieves the Redis configuration @@ -21,6 +22,7 @@ func RedisConfig() RedisConfiguration { Port: viper.GetString("REDIS_PORT"), Password: viper.GetString("REDIS_PASSWORD"), DB: viper.GetInt("REDIS_DB"), + CacheVersion: viper.GetString("CACHE_VERSION"), } } diff --git a/config/server.go b/config/server.go index 93119e5c..70221c0f 100644 --- a/config/server.go +++ b/config/server.go @@ -8,14 +8,17 @@ import ( // ServerConfiguration type defines the server configurations type ServerConfiguration struct { - Debug bool - Host string - Port string - Timezone string - AllowedHosts string - Environment string - SentryDSN string - HostDomain string + Debug bool + Host string + Port string + Timezone string + AllowedHosts string + Environment string + SentryDSN string + HostDomain string + CurrenciesCacheDuration int + InstitutionsCacheDuration int + PubKeyCacheDuration int } // ServerConfig sets the server configuration @@ -27,16 +30,22 @@ func ServerConfig() *ServerConfiguration { viper.SetDefault("ALLOWED_HOSTS", "*") viper.SetDefault("ENVIRONMENT", "local") viper.SetDefault("SENTRY_DSN", "") + viper.SetDefault("CURRENCIES_CACHE_DURATION", 24) + viper.SetDefault("INSTITUTIONS_CACHE_DURATION", 24) + viper.SetDefault("PUBKEY_CACHE_DURATION", 365) return &ServerConfiguration{ - Debug: viper.GetBool("DEBUG"), - Host: viper.GetString("SERVER_HOST"), - Port: viper.GetString("SERVER_PORT"), - Timezone: viper.GetString("SERVER_TIMEZONE"), - AllowedHosts: viper.GetString("ALLOWED_HOSTS"), - Environment: viper.GetString("ENVIRONMENT"), - SentryDSN: viper.GetString("SENTRY_DSN"), - HostDomain: viper.GetString("HOST_DOMAIN"), + Debug: viper.GetBool("DEBUG"), + Host: viper.GetString("SERVER_HOST"), + Port: viper.GetString("SERVER_PORT"), + Timezone: viper.GetString("SERVER_TIMEZONE"), + AllowedHosts: viper.GetString("ALLOWED_HOSTS"), + Environment: viper.GetString("ENVIRONMENT"), + SentryDSN: viper.GetString("SENTRY_DSN"), + HostDomain: viper.GetString("HOST_DOMAIN"), + CurrenciesCacheDuration: viper.GetInt("CURRENCIES_CACHE_DURATION"), + InstitutionsCacheDuration: viper.GetInt("INSTITUTIONS_CACHE_DURATION"), + PubKeyCacheDuration: viper.GetInt("PUBKEY_CACHE_DURATION"), } } diff --git a/go.mod b/go.mod index 27b8e9c5..dafb3b45 100644 --- a/go.mod +++ b/go.mod @@ -3,19 +3,22 @@ module github.com/paycrest/protocol go 1.22.0 require ( + github.com/alicebob/miniredis/v2 v2.34.0 github.com/anaskhan96/base58check v0.0.0-20181220122047-b05365d494c4 github.com/btcsuite/btcd/btcec/v2 v2.3.2 github.com/chadsr/logrus-sentry v0.4.1 github.com/getsentry/sentry-go v0.13.0 github.com/gin-gonic/gin v1.9.1 github.com/go-co-op/gocron v1.35.0 + github.com/go-redis/redismock/v9 v9.2.0 github.com/golang-jwt/jwt/v5 v5.0.0 github.com/jarcoal/httpmock v1.3.1 github.com/mailgun/mailgun-go/v3 v3.6.4 github.com/mattn/go-sqlite3 v1.14.16 github.com/opus-domini/fast-shot v0.10.0 github.com/paycrest/tron-wallet v1.0.13 - github.com/redis/go-redis/v9 v9.1.0 + github.com/prometheus/client_golang v1.16.0 + github.com/redis/go-redis/v9 v9.2.0 github.com/sendgrid/sendgrid-go v3.14.0+incompatible github.com/sirupsen/logrus v1.9.3 github.com/spf13/viper v1.16.0 @@ -29,6 +32,8 @@ require ( github.com/FactomProject/basen v0.0.0-20150613233007-fe3947df716e // indirect github.com/FactomProject/btcutilecc v0.0.0-20130527213604-d3a63a5752ec // indirect github.com/Microsoft/go-winio v0.6.1 // indirect + github.com/alicebob/gopher-json v0.0.0-20230218143504-906a9b012302 // indirect + github.com/beorn7/perks v1.0.1 // indirect github.com/bits-and-blooms/bitset v1.7.0 // indirect github.com/btcsuite/btcd v0.22.1 // indirect github.com/btcsuite/btcd/chaincfg/chainhash v1.0.2 // indirect @@ -62,17 +67,16 @@ require ( github.com/klauspost/compress v1.16.7 // indirect github.com/mailru/easyjson v0.0.0-20180823135443-60711f1a8329 // indirect github.com/mattn/go-runewidth v0.0.14 // indirect + github.com/matttproud/golang_protobuf_extensions v1.0.4 // indirect github.com/mmcloughlin/addchain v0.4.0 // indirect github.com/mr-tron/base58 v1.2.0 // indirect github.com/pkg/errors v0.9.1 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect - github.com/prometheus/client_golang v1.16.0 // indirect github.com/prometheus/client_model v0.4.0 // indirect github.com/prometheus/common v0.44.0 // indirect github.com/prometheus/procfs v0.11.0 // indirect github.com/rivo/uniseg v0.4.4 // indirect github.com/robfig/cron/v3 v3.0.1 // indirect - github.com/rogpeppe/go-internal v1.10.0 // indirect github.com/sendgrid/rest v2.6.9+incompatible // indirect github.com/sergi/go-diff v1.3.1 // indirect github.com/shengdoushi/base58 v1.0.0 // indirect @@ -82,6 +86,7 @@ require ( github.com/tklauser/go-sysconf v0.3.12 // indirect github.com/tklauser/numcpus v0.6.1 // indirect github.com/tyler-smith/go-bip32 v1.0.0 // indirect + github.com/yuin/gopher-lua v1.1.1 // indirect github.com/yusufpapurcu/wmi v1.2.3 // indirect go.uber.org/atomic v1.9.0 // indirect golang.org/x/exp v0.0.0-20230905200255-921286631fa9 // indirect diff --git a/go.sum b/go.sum index fca86ba5..b5818229 100644 --- a/go.sum +++ b/go.sum @@ -63,6 +63,10 @@ github.com/aead/siphash v1.0.1/go.mod h1:Nywa3cDsYNNK3gaciGTWPwHt0wlpNV15vwmswBA github.com/agext/levenshtein v1.2.3 h1:YB2fHEn0UJagG8T1rrWknE3ZQzWM06O8AMAatNn7lmo= github.com/agext/levenshtein v1.2.3/go.mod h1:JEDfjyjHDjOF/1e4FlBE/PkbqA9OfWu2ki2W0IB5558= github.com/ajg/form v1.5.1/go.mod h1:uL1WgH+h2mgNtvBq0339dVnzXdBETtL2LeUXaIv25UY= +github.com/alicebob/gopher-json v0.0.0-20230218143504-906a9b012302 h1:uvdUDbHQHO85qeSydJtItA4T55Pw6BtAejd0APRJOCE= +github.com/alicebob/gopher-json v0.0.0-20230218143504-906a9b012302/go.mod h1:SGnFV6hVsYE877CKEZ6tDNTjaSXYUk6QqoIK6PrAtcc= +github.com/alicebob/miniredis/v2 v2.34.0 h1:mBFWMaJSNL9RwdGRyEDoAAv8OQc5UlEhLDQggTglU/0= +github.com/alicebob/miniredis/v2 v2.34.0/go.mod h1:kWShP4b58T1CW0Y5dViCd5ztzrDqRWqM3nksiyXk5s8= github.com/anaskhan96/base58check v0.0.0-20181220122047-b05365d494c4 h1:FUDNaUiPOxrVtUmsRSdx7hrvCKXpfQafPpPU0Yh27os= github.com/anaskhan96/base58check v0.0.0-20181220122047-b05365d494c4/go.mod h1:glPG1rmt/bD3wEXWanFIuoPjC4MG+JEN+i7YhwEYA/Y= github.com/apparentlymart/go-textseg/v13 v13.0.0 h1:Y+KvPE1NYz0xl601PVImeQfFyEy6iT90AvPUL1NNfNw= @@ -73,10 +77,10 @@ github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= github.com/bits-and-blooms/bitset v1.7.0 h1:YjAGVd3XmtK9ktAbX8Zg2g2PwLIMjGREZJHlV4j7NEo= github.com/bits-and-blooms/bitset v1.7.0/go.mod h1:gIdJ4wp64HaoK2YrL1Q5/N7Y16edYb8uY+O0FJTyyDA= -github.com/bsm/ginkgo/v2 v2.9.5 h1:rtVBYPs3+TC5iLUVOis1B9tjLTup7Cj5IfzosKtvTJ0= -github.com/bsm/ginkgo/v2 v2.9.5/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c= -github.com/bsm/gomega v1.26.0 h1:LhQm+AFcgV2M0WyKroMASzAzCAJVpAxQXv4SaI9a69Y= -github.com/bsm/gomega v1.26.0/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0= +github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs= +github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c= +github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA= +github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0= github.com/btcsuite/btcd v0.20.1-beta/go.mod h1:wVuoA8VJLEcwgqHBwHmzLRazpKxTv13Px/pDuV7OomQ= github.com/btcsuite/btcd v0.22.1 h1:CnwP9LM/M9xuRrGSCGeMVs9iv09uMqwsVX7EeIpgV2c= github.com/btcsuite/btcd v0.22.1/go.mod h1:wqgTSL29+50LRkmOVknEdmt8ZojIzhuWvgu/iptuN7Y= @@ -237,6 +241,8 @@ github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJn github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= github.com/go-playground/validator/v10 v10.15.0 h1:nDU5XeOKtB3GEa+uB7GNYwhVKsgjAR7VgKoNB6ryXfw= github.com/go-playground/validator/v10 v10.15.0/go.mod h1:9iXMNT7sEkjXb0I+enO7QXmzG6QCsPWY4zveKFVRSyU= +github.com/go-redis/redismock/v9 v9.2.0 h1:ZrMYQeKPECZPjOj5u9eyOjg8Nnb0BS9lkVIZ6IpsKLw= +github.com/go-redis/redismock/v9 v9.2.0/go.mod h1:18KHfGDK4Y6c2R0H38EUGWAdc7ZQS9gfYxc94k7rWT0= github.com/go-stack/stack v1.8.1 h1:ntEHSVwIt7PNXNpgPmVfMrNhLtgjlmnZha2kOpuRiDw= github.com/go-stack/stack v1.8.1/go.mod h1:dcoOX6HbPZSZptuspn9bctJ+N/CnF5gGygcUP3XYfe4= github.com/go-test/deep v1.0.3 h1:ZrJSEWsXzPOxaZnFteGEfooLba+ju3FYIbOrS+rQd68= @@ -483,13 +489,19 @@ github.com/nats-io/jwt v0.3.0/go.mod h1:fRYCDE99xlTsqUzISS1Bi75UBJ6ljOJQOAAu5Vgl github.com/nats-io/nats.go v1.9.1/go.mod h1:ZjDU1L/7fJ09jvUSRVBR2e7+RnLiiIQyqyzEE/Zbp4w= github.com/nats-io/nkeys v0.1.0/go.mod h1:xpnFELMwJABBLVhffcfd1MZx6VsNRFpEugbxziKVo7w= github.com/nats-io/nuid v1.0.1/go.mod h1:19wcPz3Ph3q0Jbyiqsd0kePYG7A95tJPxeL+1OSON2c= +github.com/nxadm/tail v1.4.8 h1:nPr65rt6Y5JFSKQO7qToXr7pePgD6Gwiw05lkbyAQTE= +github.com/nxadm/tail v1.4.8/go.mod h1:+ncqLTQzXmGhMZNUePPaPqPvBxHAIsmXswZKocGu+AU= github.com/olekukonko/tablewriter v0.0.5 h1:P2Ga83D34wi1o9J6Wh1mRuqd4mF/x/lgBS7N7AbDhec= github.com/olekukonko/tablewriter v0.0.5/go.mod h1:hPp6KlRPjbx+hW8ykQs1w3UBbZlj6HuIJcUGPhkA7kY= github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= github.com/onsi/ginkgo v1.7.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= github.com/onsi/ginkgo v1.10.3/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= +github.com/onsi/ginkgo v1.16.5 h1:8xi0RTUf59SOSfEtZMvwTvXYMzG4gV23XVHOZiXNtnE= +github.com/onsi/ginkgo v1.16.5/go.mod h1:+E8gABHa3K6zRBolWtd+ROzc/U5bkGt0FwiG042wbpU= github.com/onsi/gomega v1.4.3/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1CpauHY= github.com/onsi/gomega v1.7.1/go.mod h1:XdKZgCCFLUoM/7CFJVPcG8C1xQ1AJ0vpAezJrB7JYyY= +github.com/onsi/gomega v1.25.0 h1:Vw7br2PCDYijJHSfBOWhov+8cAnUf8MfMaIOV323l6Y= +github.com/onsi/gomega v1.25.0/go.mod h1:r+zV744Re+DiYCIPRlYOTxn0YkOLcAnW8k1xXdMPGhM= github.com/opus-domini/fast-shot v0.10.0 h1:zWbPy6KJZvNs0pUa0erF9TyeDsLZHDVZf4oDHOd6JGY= github.com/opus-domini/fast-shot v0.10.0/go.mod h1:sg5+f0VviAIIdrv24WLHL6kV7kWs4PsVDbSkr2TPYWw= github.com/paycrest/tron-wallet v1.0.13 h1:TEkjovg6i2zBTZFMfYpBrwswwnYAy6Q/Vc6g12PZh54= @@ -515,8 +527,8 @@ github.com/prometheus/common v0.44.0 h1:+5BrQJwiBB9xsMygAB3TNvpQKOwlkc25LbISbrdO github.com/prometheus/common v0.44.0/go.mod h1:ofAIvZbQ1e/nugmZGz4/qCb9Ap1VoSTIO7x0VV9VvuY= github.com/prometheus/procfs v0.11.0 h1:5EAgkfkMl659uZPbe9AS2N68a7Cc1TJbPEuGzFuRbyk= github.com/prometheus/procfs v0.11.0/go.mod h1:nwNm2aOCAYw8uTR/9bWRREkZFxAUcWzPHWJq+XBB/FM= -github.com/redis/go-redis/v9 v9.1.0 h1:137FnGdk+EQdCbye1FW+qOEcY5S+SpY9T0NiuqvtfMY= -github.com/redis/go-redis/v9 v9.1.0/go.mod h1:urWj3He21Dj5k4TK1y59xH8Uj6ATueP8AH1cY3lZl4c= +github.com/redis/go-redis/v9 v9.2.0 h1:zwMdX0A4eVzse46YN18QhuDiM4uf3JmkOB4VZrdt5uI= +github.com/redis/go-redis/v9 v9.2.0/go.mod h1:hdY0cQFCN4fnSYT6TkisLufl/4W5UIXyv0b/CLO2V2M= github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= github.com/rivo/uniseg v0.4.4 h1:8TfxU8dW6PdqD27gjM8MVNuicgxIjxpm4K7x4jp8sis= github.com/rivo/uniseg v0.4.4/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= @@ -560,8 +572,6 @@ github.com/spf13/cast v1.3.0/go.mod h1:Qx5cxh0v+4UWYiBimWS+eyWzqEqokIECu5etghLkU github.com/spf13/cast v1.5.1 h1:R+kOtfhWQE6TVQzY+4D7wJLBgkdVasCEFxSUBYBYIlA= github.com/spf13/cast v1.5.1/go.mod h1:b9PdjNptOpzXr7Rq1q9gJML/2cdGQAo69NKzQ10KN48= github.com/spf13/cobra v0.0.5/go.mod h1:3K3wKZymM7VvHMDS9+Akkh4K60UwM26emMESw8tLCHU= -github.com/spf13/cobra v1.7.0 h1:hyqWnYt1ZQShIddO5kBpj3vu05/++x6tJ6dg8EC572I= -github.com/spf13/cobra v1.7.0/go.mod h1:uLxZILRyS/50WlhOIKD7W6V5bgeIt+4sICxh6uRMrb0= github.com/spf13/jwalterweatherman v1.0.0/go.mod h1:cQK4TGJAtQXfYWX+Ddv3mKDzgVb68N+wFjFa4jdeBTo= github.com/spf13/jwalterweatherman v1.1.0 h1:ue6voC5bR5F8YxI5S67j9i582FU4Qvo2bmqnqMYADFk= github.com/spf13/jwalterweatherman v1.1.0/go.mod h1:aNWZUN0dPAAO/Ljvb5BEdw96iTZ0EXowPYD95IqWIGo= @@ -637,6 +647,8 @@ github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9de github.com/yuin/goldmark v1.1.32/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k= +github.com/yuin/gopher-lua v1.1.1 h1:kYKnWBjvbNP4XLT3+bPEwAXJx262OhaHDWDVOPjL46M= +github.com/yuin/gopher-lua v1.1.1/go.mod h1:GBR0iDaNXjAgGg9zfCvksxSRnQx76gclCIb7kdAd1Pw= github.com/yusufpapurcu/wmi v1.2.3 h1:E1ctvB7uKFMOJw3fdOW32DwGE9I7t++CRUEMKvFoFiw= github.com/yusufpapurcu/wmi v1.2.3/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0= github.com/zclconf/go-cty v1.13.2 h1:4GvrUxe/QUDYuJKAav4EYqdM47/kZa672LwmXFmEKT0= @@ -1022,6 +1034,7 @@ gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k= gopkg.in/mgo.v2 v2.0.0-20180705113604-9856a29383ce/go.mod h1:yeKp02qBN3iKW1OzL3MGk2IdtZzaj7SFntXj72NppTA= gopkg.in/natefinch/lumberjack.v2 v2.0.0 h1:1Lc07Kr7qY4U2YPouBjpCLxpiyxIVoxqXgkXLknAOE8= gopkg.in/natefinch/lumberjack.v2 v2.0.0/go.mod h1:l0ndWWf7gzL7RNwBG7wST/UCcT4T24xpD6X8LsfU/+k= +gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 h1:uRGJdciOHaEIrze2W8Q3AKkepLTh2hOroT7a+7czfdQ= gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw= gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= diff --git a/routers/index.go b/routers/index.go index 53a82fb3..df6b04d6 100644 --- a/routers/index.go +++ b/routers/index.go @@ -1,9 +1,12 @@ package routers import ( + "log" "net/http" + "time" "github.com/gin-gonic/gin" + "github.com/paycrest/protocol/config" "github.com/paycrest/protocol/controllers" "github.com/paycrest/protocol/controllers/accounts" "github.com/paycrest/protocol/controllers/provider" @@ -14,6 +17,14 @@ import ( // RegisterRoutes add all routing list here automatically get main router func RegisterRoutes(route *gin.Engine) { + conf := config.RedisConfig() + serverConf := config.ServerConfig() + + cacheService, err := middleware.NewCacheService(conf) + if err != nil { + log.Fatalf("Failed to initialize cache: %v", err) + } + route.NoRoute(func(ctx *gin.Context) { u.APIResponse(ctx, http.StatusNotFound, "error", "Route Not Found", nil) }) @@ -30,14 +41,20 @@ func RegisterRoutes(route *gin.Engine) { v1.GET( "currencies", + cacheService.CacheMiddleware(time.Duration(serverConf.CurrenciesCacheDuration)*time.Hour), ctrl.GetFiatCurrencies, ) v1.GET( "institutions/:currency_code", + cacheService.CacheMiddleware(time.Duration(serverConf.InstitutionsCacheDuration)*time.Hour), ctrl.GetInstitutionsByCurrency, ) + v1.GET("pubkey", + cacheService.CacheMiddleware(time.Duration(serverConf.PubKeyCacheDuration)*24*time.Hour), + ctrl.GetAggregatorPublicKey, + ) + v1.GET("rates/:token/:amount/:fiat", ctrl.GetTokenRate) - v1.GET("pubkey", ctrl.GetAggregatorPublicKey) v1.POST("verify-account", ctrl.VerifyAccount) v1.GET("orders/:id", ctrl.GetLockPaymentOrderStatus) diff --git a/routers/middleware/caching.go b/routers/middleware/caching.go new file mode 100644 index 00000000..23f4cd17 --- /dev/null +++ b/routers/middleware/caching.go @@ -0,0 +1,255 @@ +package middleware + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "fmt" + "io/ioutil" + "net/http" + "time" + + "github.com/gin-gonic/gin" + "github.com/paycrest/aggregator/config" + "github.com/prometheus/client_golang/prometheus" + "github.com/redis/go-redis/v9" +) + +// CacheMetrics holds the metrics for cache hits and misses +type CacheMetrics struct { + hits prometheus.Counter + misses prometheus.Counter +} + +// CacheService handles Redis operations +type CacheService struct { + client *redis.Client + metrics CacheMetrics +} + +type CacheConfig struct { + Host string + Port string + Password string + DB int +} + +// NewCacheService creates a new Redis cache service +func NewCacheService(config config.RedisConfiguration) (*CacheService, error) { + metrics := CacheMetrics{ + hits: prometheus.NewCounter(prometheus.CounterOpts{ + Name: "cache_hits_total", + Help: "Total number of cache hits", + }), + misses: prometheus.NewCounter(prometheus.CounterOpts{ + Name: "cache_misses_total", + Help: "Total number of cache misses", + }), + } + prometheus.MustRegister(metrics.hits, metrics.misses) + + client := redis.NewClient(&redis.Options{ + Addr: fmt.Sprintf("%s:%s", config.Host, config.Port), + Password: config.Password, + DB: config.DB, + }) + + if err := client.Ping(context.Background()).Err(); err != nil { + return nil, fmt.Errorf("failed to connect to Redis: %v", err) + } + + return &CacheService{client: client, metrics: metrics}, nil +} + +func generateCacheKey(c *gin.Context) string { + conf := config.RedisConfig() + path := c.Request.URL.Path + switch { + case path == "/v1/currencies": + return fmt.Sprintf("%s:api:currencies:list", conf.CacheVersion) + case path == "/v1/pubkey": + return fmt.Sprintf("%s:api:aggregator:pubkey", conf.CacheVersion) + case len(c.Param("currency_code")) > 0: + return fmt.Sprintf("%s:api:institutions:%s", conf.CacheVersion, c.Param("currency_code")) + default: + return fmt.Sprintf("%s:api:%s", conf.CacheVersion, path) + } +} + +func generateETag(data []byte) string { + hash := sha256.Sum256(data) + return hex.EncodeToString(hash[:]) +} + +func (s *CacheService) CacheMiddleware(duration time.Duration) gin.HandlerFunc { + return func(c *gin.Context) { + key := generateCacheKey(c) + ctx := context.Background() + + // Check ETag + if etag := c.GetHeader("If-None-Match"); etag != "" { + if storedETag, _ := s.client.Get(ctx, key+":etag").Result(); etag == storedETag { + c.Status(http.StatusNotModified) + return + } + } + + // Try to get from cache + val, err := s.client.Get(ctx, key).Result() + if err == nil { + s.metrics.hits.Inc() + etag, _ := s.client.Get(ctx, key+":etag").Result() + + c.Header("X-Cache", "HIT") + c.Header("Cache-Control", fmt.Sprintf("max-age=%d, stale-while-revalidate=60", int(duration.Seconds()))) + c.Header("ETag", etag) + c.String(200, val) + + // Background revalidation if approaching expiry + if ttl, _ := s.client.TTL(ctx, key).Result(); ttl < time.Minute { + go s.revalidateCache(c.Copy(), key, duration) + } + return + } + + s.metrics.misses.Inc() + c.Header("X-Cache", "MISS") // Add this line + c.Writer = &cacheWriter{ResponseWriter: c.Writer, body: make([]byte, 0)} + c.Next() + + if c.Writer.Status() == 200 { + response := c.Writer.(*cacheWriter).body + etag := generateETag(response) + + s.client.Set(ctx, key, string(response), duration) + s.client.Set(ctx, key+":etag", etag, duration) + + c.Header("ETag", etag) + c.Header("Cache-Control", fmt.Sprintf("max-age=%d, stale-while-revalidate=60", int(duration.Seconds()))) + } + } +} + +type cacheWriter struct { + gin.ResponseWriter + body []byte +} + +func (w *cacheWriter) Write(b []byte) (int, error) { + w.body = append(w.body, b...) + return w.ResponseWriter.Write(b) +} + +func (s *CacheService) WarmCache(ctx context.Context) error { + conf := config.ServerConfig() + baseURL := conf.HostDomain + if baseURL == "" { + return fmt.Errorf("host domain is not set in the server configuration") + } + + // Create HTTP client with timeout + client := &http.Client{Timeout: 10 * time.Second} + + // Fetch currencies first + currenciesURL := fmt.Sprintf("%s/v1/currencies", baseURL) + resp, err := client.Get(currenciesURL) + if err != nil { + return fmt.Errorf("failed to fetch currencies from %s: %v", currenciesURL, err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("failed to fetch currencies: status code %d", resp.StatusCode) + } + + var currencies []string + if err := json.NewDecoder(resp.Body).Decode(¤cies); err != nil { + return fmt.Errorf("failed to decode currencies response: %v", err) + } + + // Use default currencies if none found + if len(currencies) == 0 { + currencies = []string{"USD", "EUR", "GBP"} + } + + // Cache currencies + currenciesKey := fmt.Sprintf("v1:api:currencies:list") + currenciesData, err := json.Marshal(currencies) + if err != nil { + return fmt.Errorf("failed to marshal currencies: %v", err) + } + if err := s.client.Set(ctx, currenciesKey, string(currenciesData), time.Duration(conf.CurrenciesCacheDuration)*time.Hour).Err(); err != nil { + return fmt.Errorf("failed to cache currencies: %v", err) + } + + // Cache pubkey + pubkeyURL := fmt.Sprintf("%s/v1/pubkey", baseURL) + if err := s.cacheEndpoint(ctx, pubkeyURL, "v1:api:aggregator:pubkey", time.Duration(conf.PubKeyCacheDuration)*time.Hour); err != nil { + return fmt.Errorf("failed to cache pubkey: %v", err) + } + + // Cache institutions for each currency + for _, currency := range currencies { + institutionsURL := fmt.Sprintf("%s/v1/institutions/%s", baseURL, currency) + key := fmt.Sprintf("v1:api:institutions:%s", currency) + if err := s.cacheEndpoint(ctx, institutionsURL, key, time.Duration(conf.InstitutionsCacheDuration)*time.Hour); err != nil { + return fmt.Errorf("failed to cache institutions for %s: %v", currency, err) + } + } + + return nil +} + +func (s *CacheService) cacheEndpoint(ctx context.Context, url, key string, duration time.Duration) error { + resp, err := http.Get(url) + if err != nil { + return fmt.Errorf("failed to fetch from %s: %v", url, err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("received non-200 status code (%d) from %s", resp.StatusCode, url) + } + + body, err := ioutil.ReadAll(resp.Body) + if err != nil { + return fmt.Errorf("failed to read response body from %s: %v", url, err) + } + + // Verify the response is valid JSON + var jsonCheck interface{} + if err := json.Unmarshal(body, &jsonCheck); err != nil { + return fmt.Errorf("invalid JSON response from %s: %v", url, err) + } + + // Generate and store ETag + etag := generateETag(body) + if err := s.client.Set(ctx, key+":etag", etag, duration).Err(); err != nil { + return fmt.Errorf("failed to cache etag for %s: %v", url, err) + } + + // Cache the response + if err := s.client.Set(ctx, key, string(body), duration).Err(); err != nil { + return fmt.Errorf("failed to cache response for %s: %v", url, err) + } + + return nil +} + +func (s *CacheService) revalidateCache(c *gin.Context, key string, duration time.Duration) { + ctx := context.Background() + req, _ := http.NewRequest(c.Request.Method, c.Request.URL.String(), nil) + resp, err := http.DefaultClient.Do(req) + if err != nil { + return + } + defer resp.Body.Close() + + if resp.StatusCode == http.StatusOK { + body, _ := ioutil.ReadAll(resp.Body) + etag := generateETag(body) + s.client.Set(ctx, key, string(body), duration) + s.client.Set(ctx, key+":etag", etag, duration) + } +} diff --git a/routers/middleware/caching_test.go b/routers/middleware/caching_test.go new file mode 100644 index 00000000..6294be6d --- /dev/null +++ b/routers/middleware/caching_test.go @@ -0,0 +1,142 @@ +package middleware + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "path" + "testing" + "time" + + "github.com/alicebob/miniredis/v2" + "github.com/gin-gonic/gin" + "github.com/prometheus/client_golang/prometheus" + "github.com/redis/go-redis/v9" + "github.com/spf13/viper" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func setupTestRedis() (*miniredis.Miniredis, *redis.Client) { + mr, err := miniredis.Run() + if err != nil { + panic(err) + } + + client := redis.NewClient(&redis.Options{ + Addr: mr.Addr(), + }) + + return mr, client +} + +func TestCacheMiddleware(t *testing.T) { + // Setup + gin.SetMode(gin.TestMode) + mr, client := setupTestRedis() + defer mr.Close() + + cacheService := &CacheService{ + client: client, + metrics: CacheMetrics{ + hits: prometheus.NewCounter(prometheus.CounterOpts{Name: "test_cache_hits_total"}), + misses: prometheus.NewCounter(prometheus.CounterOpts{Name: "test_cache_misses_total"}), + }, + } + + t.Run("CacheMiddleware", func(t *testing.T) { + // Create test router + router := gin.New() + router.GET("/v1/currencies", cacheService.CacheMiddleware(24*time.Hour), func(c *gin.Context) { + c.JSON(http.StatusOK, gin.H{"currencies": []string{"USD", "EUR", "GBP"}}) + }) + + w1 := httptest.NewRecorder() + req1, _ := http.NewRequest("GET", "/v1/currencies", nil) + router.ServeHTTP(w1, req1) + + assert.Equal(t, http.StatusOK, w1.Code) + assert.Equal(t, "MISS", w1.Header().Get("X-Cache")) + + key := "v1:api:currencies:list" + _, err := client.Get(context.Background(), key).Result() + assert.NoError(t, err) + + w2 := httptest.NewRecorder() + req2, _ := http.NewRequest("GET", "/v1/currencies", nil) + router.ServeHTTP(w2, req2) + + assert.Equal(t, http.StatusOK, w2.Code) + assert.Equal(t, "HIT", w2.Header().Get("X-Cache")) + }) + + t.Run("WarmCache", func(t *testing.T) { + // Verify Redis connection + ctx := context.Background() + err := client.Ping(ctx).Err() + require.NoError(t, err, "Redis connection failed") + + // Create mock server + mockServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + var response interface{} + + switch r.URL.Path { + case "/v1/currencies": + response = []string{"USD", "EUR", "GBP"} + case "/v1/pubkey": + response = map[string]string{"key": "test-key"} + default: + if matched, _ := path.Match("/v1/institutions/*", r.URL.Path); matched { + currency := path.Base(r.URL.Path) + response = map[string]interface{}{ + "institutions": []map[string]string{ + {"id": "bank1", "name": "Bank 1", "currency": currency}, + {"id": "bank2", "name": "Bank 2", "currency": currency}, + }, + } + } else { + http.NotFound(w, r) + return + } + } + + err := json.NewEncoder(w).Encode(response) + require.NoError(t, err, "Failed to encode response") + })) + defer mockServer.Close() + + // Set required configuration + viper.Reset() + viper.Set("HOST_DOMAIN", mockServer.URL) + viper.Set("CURRENCIES_CACHE_DURATION", 24) + viper.Set("INSTITUTIONS_CACHE_DURATION", 24) + viper.Set("PUBKEY_CACHE_DURATION", 365) + + // Execute warm cache + err = cacheService.WarmCache(ctx) + require.NoError(t, err, "WarmCache failed") + + // Verify caches + keys := []string{ + "v1:api:currencies:list", + "v1:api:aggregator:pubkey", + "v1:api:institutions:USD", + "v1:api:institutions:EUR", + "v1:api:institutions:GBP", + } + + for _, key := range keys { + t.Run(fmt.Sprintf("Verify cache for %s", key), func(t *testing.T) { + val, err := client.Get(ctx, key).Result() + require.NoError(t, err, "Failed to get key from cache") + require.NotEmpty(t, val, "Empty value for key") + + var jsonCheck interface{} + require.NoError(t, json.Unmarshal([]byte(val), &jsonCheck), "Invalid JSON for key") + }) + } + }) +} \ No newline at end of file