package main
import (
"context"
"crypto/tls"
"encoding/json"
"errors"
"flag"
"io"
"net/http"
"net/url"
"os"
"os/signal"
"strconv"
"sync"
"time"
"github.com/go-redis/redis/v8"
"github.com/gorilla/mux"
"github.com/rs/zerolog"
"github.com/rs/zerolog/log"
)
var (
errBadLogic = errors.New("we should not be here")
errUnexpectedParam = errors.New("got unexpected parameter")
errUnknownDeployment = errors.New("unknown deployment kind")
)
const (
serverDeploymentType = "SERVER_DEPLOYMENT_TYPE"
coingeckoAPIURLv3 = "https://api.coingecko.com/api/v3"
coincapAPIURLv2 = "https://api.coincap.io/v2"
getTimeout = 5
httpClientTimeout = 5
serverTLSReadTimeout = 15
serverTLSWriteTimeout = 15
defaultGracefulShutdown = 15
)
// https://docs.coincap.io/
type CoinCapAssetGetResponseData struct {
ID string `json:"id"`
Rank string `json:"rank"`
Symbol string `json:"symbol"`
Name string `json:"name"`
Supply string `json:"supply"`
MaxSupply string `json:"maxSupply"`
MarketCapUsd string `json:"marketCapUsd"`
VolumeUsd24Hr string `json:"volumeUsd24Hr"`
PriceUsd string `json:"priceUsd"`
ChangePercent24Hr string `json:"changePercent24Hr"`
Vwap24Hr string `json:"vwap24Hr"`
}
type priceResponseData struct {
Name string `json:"name"`
Price float64 `json:"price"`
Unit string `json:"unit"`
Err string `json:"err"`
IsSuccessful bool `json:"isSuccessful"`
}
type CoinCapAssetGetResponse struct {
Data CoinCapAssetGetResponseData `json:"data"`
TimeStamp int64 `json:"timestamp"`
}
type HTTPHandlerFunc func(http.ResponseWriter, *http.Request)
type HTTPHandler struct {
name string
function HTTPHandlerFunc
}
type priceChanStruct struct {
name string
price float64
}
type errorChanStruct struct {
hasError bool
err error
}
func GetProxiedClient() *http.Client {
transport := &http.Transport{
DisableKeepAlives: true,
Proxy: http.ProxyFromEnvironment,
}
client := &http.Client{
Transport: transport,
Timeout: httpClientTimeout * time.Second,
CheckRedirect: nil,
Jar: nil,
}
return client
}
// OWASP: https://cheatsheetseries.owasp.org/cheatsheets/REST_Security_Cheat_Sheet.html
func addSecureHeaders(writer *http.ResponseWriter) {
(*writer).Header().Set("Cache-Control", "no-store")
(*writer).Header().Set("Content-Security-Policy", "default-src https;")
(*writer).Header().Set("Strict-Transport-Security", "max-age=63072000;")
(*writer).Header().Set("X-Content-Type-Options", "nosniff")
(*writer).Header().Set("X-Frame-Options", "DENY")
(*writer).Header().Set("Access-Control-Allow-Methods", "GET,POST,PUT,DELETE,OPTIONS")
}
func getPriceFromCoinGecko(
ctx context.Context,
name, unit string,
wg *sync.WaitGroup,
priceChan chan<- priceChanStruct,
errChan chan<- errorChanStruct,
) {
defer wg.Done()
priceFloat := 0.
params := "/simple/price?ids=" + url.QueryEscape(name) + "&" +
"vs_currencies=" + url.QueryEscape(unit)
path := coingeckoAPIURLv3 + params
client := GetProxiedClient()
req, err := http.NewRequestWithContext(ctx, http.MethodGet, path, nil)
if err != nil {
priceChan <- priceChanStruct{name: name, price: priceFloat}
errChan <- errorChanStruct{hasError: true, err: err}
log.Error().Err(err)
return
}
resp, err := client.Do(req)
if err != nil {
priceChan <- priceChanStruct{name: name, price: priceFloat}
errChan <- errorChanStruct{hasError: true, err: err}
log.Error().Err(err).Send()
return
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
priceChan <- priceChanStruct{name: name, price: priceFloat}
errChan <- errorChanStruct{hasError: true, err: err}
log.Error().Err(err).Send()
}
jsonBody := make(map[string]interface{})
err = json.Unmarshal(body, &jsonBody)
if err != nil {
priceChan <- priceChanStruct{name: name, price: priceFloat}
errChan <- errorChanStruct{hasError: true, err: err}
log.Error().Err(err).Send()
}
price, isOk := jsonBody[name].(map[string]interface{})
if !isOk {
priceChan <- priceChanStruct{name: name, price: priceFloat}
errChan <- errorChanStruct{hasError: true, err: err}
log.Error().Err(err)
return
}
log.Info().Msg(string(body))
priceFloat, isOk = price[unit].(float64)
if !isOk {
priceChan <- priceChanStruct{name: name, price: priceFloat}
errChan <- errorChanStruct{hasError: true, err: err}
log.Error().Err(err)
return
}
priceChan <- priceChanStruct{name: name, price: priceFloat}
errChan <- errorChanStruct{hasError: false, err: nil}
}
func getPriceFromCoinCap(
ctx context.Context,
name string,
wg *sync.WaitGroup,
priceChan chan<- priceChanStruct,
errChan chan<- errorChanStruct,
) {
defer wg.Done()
priceFloat := 0.
params := "/assets/" + url.QueryEscape(name)
path := coincapAPIURLv2 + params
client := GetProxiedClient()
req, err := http.NewRequestWithContext(ctx, http.MethodGet, path, nil)
if err != nil {
priceChan <- priceChanStruct{name: name, price: priceFloat}
errChan <- errorChanStruct{hasError: true, err: err}
log.Error().Err(err)
return
}
resp, err := client.Do(req)
if err != nil {
priceChan <- priceChanStruct{name: name, price: priceFloat}
errChan <- errorChanStruct{hasError: true, err: err}
log.Error().Err(err).Send()
return
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
priceChan <- priceChanStruct{name: name, price: priceFloat}
errChan <- errorChanStruct{hasError: true, err: err}
log.Error().Err(err)
}
var coinCapAssetGetResponse CoinCapAssetGetResponse
err = json.Unmarshal(body, &coinCapAssetGetResponse)
if err != nil {
priceChan <- priceChanStruct{name: name, price: priceFloat}
errChan <- errorChanStruct{hasError: true, err: err}
log.Error().Err(err).Send()
}
priceFloat, err = strconv.ParseFloat(coinCapAssetGetResponse.Data.PriceUsd, 64)
if err != nil {
priceChan <- priceChanStruct{name: name, price: priceFloat}
errChan <- errorChanStruct{hasError: true, err: err}
log.Error().Err(err).Send()
}
log.Info().Msg(string(body))
priceChan <- priceChanStruct{name: name, price: priceFloat}
errChan <- errorChanStruct{hasError: false, err: nil}
}
func arbHandler(w http.ResponseWriter, r *http.Request) {
w.Header().Add("Content-Type", "application/json")
if r.Method != http.MethodGet {
http.Error(w, "Method is not supported.", http.StatusNotFound)
}
addSecureHeaders(&w)
var name string
var unit string
params := r.URL.Query()
for key, value := range params {
switch key {
case "name":
name = value[0]
case "unit":
unit = value[0]
default:
log.Error().Err(errUnexpectedParam)
}
}
priceChan := make(chan priceChanStruct, 1)
errChan := make(chan errorChanStruct, 1)
var waitGroup sync.WaitGroup
ctx, cancel := context.WithTimeout(context.Background(), getTimeout*time.Second)
defer cancel()
waitGroup.Add(1)
//nolint:contextcheck
getPriceFromCoinGecko(ctx, name, unit, &waitGroup, priceChan, errChan)
waitGroup.Wait()
select {
case err := <-errChan:
if err.hasError {
log.Error().Err(err.err)
}
default:
log.Error().Err(errBadLogic).Send()
}
var price priceChanStruct
select {
case priceCh := <-priceChan:
price = priceCh
default:
log.Error().Err(errBadLogic)
}
responseData := priceResponseData{
Name: price.name,
Price: price.price,
Unit: "USD",
Err: "",
IsSuccessful: true,
}
jsonResp, err := json.Marshal(responseData)
if err != nil {
cancel()
//nolint:gocritic
log.Fatal().Err(err)
}
_, err = w.Write(jsonResp)
if err != nil {
cancel()
log.Fatal().Err(err)
}
}
func coincapHandler(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "Method is not supported.", http.StatusNotFound)
}
w.Header().Add("Content-Type", "application/json")
addSecureHeaders(&w)
var name string
params := r.URL.Query()
for key, value := range params {
switch key {
case "name":
name = value[0]
default:
log.Error().Err(errUnexpectedParam).Send()
}
}
priceChan := make(chan priceChanStruct, 1)
errChan := make(chan errorChanStruct, 1)
var waitGroup sync.WaitGroup
waitGroup.Add(1)
ctx, cancel := context.WithTimeout(context.Background(), getTimeout*time.Second)
defer cancel()
getPriceFromCoinCap(ctx, name, &waitGroup, priceChan, errChan)
waitGroup.Wait()
select {
case err := <-errChan:
if err.hasError {
log.Error().Err(err.err)
}
default:
log.Error().Err(errBadLogic)
}
var price priceChanStruct
select {
case priceCh := <-priceChan:
price = priceCh
default:
log.Error().Err(errBadLogic)
}
responseData := priceResponseData{
Name: price.name,
Price: price.price,
Unit: "USD",
Err: "",
IsSuccessful: true,
}
jsonResp, err := json.Marshal(responseData)
if err != nil {
cancel()
log.Fatal().Err(err).Send()
}
_, err = w.Write(jsonResp)
if err != nil {
cancel()
log.Fatal().Err(err)
}
}
func setupLogging() {
zerolog.TimeFieldFormat = zerolog.TimeFormatUnix
}
func startServer(gracefulWait time.Duration,
handlers []HTTPHandler,
serverDeploymentType string, port string,
) {
route := mux.NewRouter()
cfg := &tls.Config{
MinVersion: tls.VersionTLS13,
}
srv := &http.Server{
Addr: "0.0.0.0:" + port,
WriteTimeout: time.Second * serverTLSWriteTimeout,
ReadTimeout: time.Second * serverTLSReadTimeout,
Handler: route,
TLSConfig: cfg,
}
for i := range len(handlers) {
route.HandleFunc(handlers[i].name, handlers[i].function)
}
go func() {
var certPath, keyPath string
switch os.Getenv(serverDeploymentType) {
case "deployment":
certPath = "/etc/letsencrypt/live/api.terminaldweller.com/fullchain.pem"
keyPath = "/etc/letsencrypt/live/api.terminaldweller.com/privkey.pem"
case "test":
certPath = "/certs/server.cert"
keyPath = "/certs/server.key"
default:
log.Error().Err(errUnknownDeployment).Send()
}
if err := srv.ListenAndServeTLS(certPath, keyPath); err != nil {
log.Error().Err(err)
}
}()
c := make(chan os.Signal, 1)
signal.Notify(c, os.Interrupt)
<-c
ctx, cancel := context.WithTimeout(context.Background(), gracefulWait)
defer cancel()
if err := srv.Shutdown(ctx); err != nil {
log.Error().Err(err)
}
log.Info().Msg("gracefully shut down the server")
}
func main() {
var gracefulWait time.Duration
var rdb *redis.Client
flag.DurationVar(
&gracefulWait,
"gracefulwait",
time.Second*defaultGracefulShutdown,
"the duration to wait during the graceful shutdown",
)
flagPort := flag.String("port", "8009", "determines the port the server will listen on")
redisDB := flag.Int64("redisdb", 1, "determines the db number")
redisAddress := flag.String("redisaddress", "redis:6379", "determines the address of the redis instance")
redisPassword := flag.String("redispassword", "", "determines the password of the redis db")
flag.Parse()
rdb = redis.NewClient(&redis.Options{
Addr: *redisAddress,
Password: *redisPassword,
DB: int(*redisDB),
})
defer rdb.Close()
setupLogging()
handlerFuncs := []HTTPHandler{
{name: "/crypto/v1/arb/gecko", function: arbHandler},
{name: "/crypto/v1/arb/coincap", function: coincapHandler},
}
startServer(gracefulWait, handlerFuncs, serverDeploymentType, *flagPort)
}