Files
qfixdpl/src/client/api/rest/controller.go
2026-06-23 10:56:11 -03:00

470 lines
14 KiB
Go

package rest
import (
"fmt"
"log/slog"
"net/http"
"strings"
"time"
"github.com/gin-gonic/gin"
"github.com/gomodule/redigo/redis"
"github.com/sasha-s/go-deadlock"
uuid "github.com/satori/go.uuid"
"github.com/shopspring/decimal"
"quantex.com/qfixdpl/src/app"
"quantex.com/qfixdpl/src/app/version"
"quantex.com/qfixdpl/src/client/store"
"quantex.com/qfixdpl/src/common/tracerr"
"quantex.com/qfixdpl/src/domain"
)
const (
ProdEnv = "prod"
TokenExpireTime = 1200 * time.Second
)
const (
responseKey = "responseKey"
sessionTokenKey = "sessionTokenKey"
)
type Controller struct {
pool *redis.Pool
userData app.UserDataProvider
store *store.Store
tradeProvider TradeProvider
config Config
notify domain.Notifier
authMutex deadlock.Mutex
}
func newController(pool *redis.Pool, userData app.UserDataProvider,
s *store.Store, tradeProvider TradeProvider, config Config, n domain.Notifier,
) *Controller {
return &Controller{
pool: pool,
userData: userData,
store: s,
tradeProvider: tradeProvider,
config: config,
notify: n,
}
}
func (cont *Controller) GetUser(ctx *gin.Context) app.User {
// This is set on the AuthRequired middleware
response, ok := ctx.Get(responseKey)
if !ok {
// TODO log this issue
ctx.JSON(http.StatusInternalServerError, HTTPError{Error: "internal server error"})
}
val, ok := response.([]uint8)
if !ok {
// TODO log this issue
ctx.JSON(http.StatusInternalServerError, HTTPError{Error: "internal server error"})
}
return cont.userData.GetUserByEmail(string(val))
}
// Login godoc
// @Summary Login
// @Description Authenticate a User using credentials
// @Tags auth
// @Accept json
// @Produce json
// @Param credentials body Credentials true "Authentication"
// @Success 200 {object} Session
// @Failure 400 {object} HTTPError
// @Failure 401 {object} HTTPError
// @Failure 404 {object} HTTPError
// @Failure 500 {object} HTTPError
// @Router /auth/login [post]
func (cont *Controller) Login(ctx *gin.Context) {
defer cont.authMutex.Unlock()
cont.authMutex.Lock()
setHeaders(ctx, cont.config)
// Get the JSON body and decode into credentials
var creds Credentials
if err := ctx.ShouldBindJSON(&creds); err != nil {
ctx.JSON(http.StatusBadRequest, HTTPError{Error: err.Error()})
return
}
// Get the expected Password from our in memory map
expectedUser := cont.userData.GetUserByEmail(creds.Email)
// If a Password exists for the given User
// AND, if it is the same as the Password we received, the we can move ahead
// if NOT, then we return an "Unauthorized" status
if expectedUser.Email == "" || expectedUser.Password != creds.Password {
ctx.JSON(http.StatusUnauthorized, HTTPError{Error: "Invalid credentials"})
return
}
// Create a new random session token
sessionToken := uuid.NewV4().String()
// Set the token in the cache, along with the User whom it represents
// The token has an expiry time of 120 seconds
conn := cont.pool.Get()
defer func() {
err := conn.Close()
if err != nil {
e := tracerr.Errorf("error closing connection: %w", err)
slog.Error(e.Error())
}
}()
_, err := conn.Do("SETEX", sessionToken, TokenExpireTime, creds.Email)
if err != nil {
slog.Error(tracerr.Errorf("Error setting token in redis cache: %w", err).Error())
// If there is an error in setting the cache, return an internal server error
ctx.JSON(http.StatusInternalServerError, HTTPError{Error: err.Error()})
return
}
// Finally, we set the client cookie for "session_token" as the session token we just generated
// we also set an expiry time of 120 seconds, the same as the cache
// Finally, we set the client cookie for "session_token" as the session token we just generated
// we also set an expiry time of TokenExpireTime, the same as the cache
cookie := &http.Cookie{
Name: "session_token",
Value: sessionToken,
HttpOnly: true,
Path: "/",
Secure: true,
Expires: time.Now().Add(TokenExpireTime),
}
if version.Environment() == version.EnvironmentTypeDev {
cookie.SameSite = http.SameSiteNoneMode
}
http.SetCookie(ctx.Writer, cookie)
ctx.JSON(http.StatusOK, Session{Email: creds.Email})
}
// Refresh godoc
// @Summary Refresh the authorization token
// @Description This endpoint must be called periodically to get a new token before the current one expires
// @Tags auth
// @Accept json
// @Produce json
// @Success 200 {object} Msg
// @Failure 400 {object} HTTPError
// @Failure 401 {object} HTTPError
// @Failure 404 {object} HTTPError
// @Failure 500 {object} HTTPError
// @Router /auth/refresh [get]
func (cont *Controller) Refresh(ctx *gin.Context) {
defer cont.authMutex.Unlock()
cont.authMutex.Lock()
setHeaders(ctx, cont.config)
// (BEGIN) The code until this point is the same as the first part of the `Welcome` route
response, ok1 := ctx.Get(responseKey)
if !ok1 {
ctx.JSON(http.StatusInternalServerError, gin.H{"error": "internal server error"})
}
// (END) The code uptil this point is the same as the first part of the `Welcome` route
// Now, create a new session token for the current User
newSessionToken := uuid.NewV4().String()
conn := cont.pool.Get()
defer func() {
err := conn.Close()
if err != nil {
e := tracerr.Errorf("error closing connection: %w", err)
slog.Error(e.Error())
}
}()
_, err := conn.Do("SETEX", newSessionToken, "120", fmt.Sprintf("%s", response))
if err != nil {
ctx.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
// Set the new token as the Users `session_token` cookie
cookie := &http.Cookie{
Name: "session_token",
Value: newSessionToken,
HttpOnly: true,
Path: "/",
Secure: true,
Expires: time.Now().Add(TokenExpireTime),
}
if version.Environment() == version.EnvironmentTypeDev {
cookie.SameSite = http.SameSiteNoneMode
}
http.SetCookie(ctx.Writer, cookie)
ctx.JSON(http.StatusOK, Msg{Text: "Token updated"})
}
// HealthCheck godoc
// @Summary Health check
// @Description Return service health
// @Tags health
// @Produce json
// @Success 200 {object} map[string]string
// @Router /health [get]
func (cont *Controller) HealthCheck(ctx *gin.Context) {
// ensure CORS and other headers are set consistently
setHeaders(ctx, cont.config)
status := struct {
Status string `json:"status"`
Build string `json:"build"`
Sha string `json:"sha"`
JwtAuthentications string `json:"jwtAuthentications,omitempty"`
}{
Status: "ok",
Build: version.BuildBranch(),
Sha: version.BuildHash(),
}
// Only check JWT authentication if enabled
if cont.config.EnableJWTAuth {
status.JwtAuthentications = "ok"
user, err := cont.store.UserByEmail("fede")
if err != nil || user == nil {
status.JwtAuthentications = "error"
status.Status = "degraded"
err = tracerr.Errorf("error fetching user: %w", err)
slog.Error(err.Error())
ctx.JSON(http.StatusInternalServerError, status)
return
}
}
// return a minimal JSON health response
ctx.JSON(http.StatusOK, status)
}
// revive:disable:cyclomatic // We need this complexity
func setHeaders(ctx *gin.Context, config Config) {
origin := ctx.Request.Header.Get("Origin")
if allowed(origin, config) {
ctx.Writer.Header().Set("Access-Control-Allow-Origin", origin)
ctx.Writer.Header().Set("Access-Control-Allow-Credentials", "true")
ctx.Writer.Header().Set("Access-Control-Allow-Headers",
"Content-Type, Content-Length, Accept-Encoding, X-CSRF-Token, Authorization, accept, origin,"+
"UserCache-Control, X-Requested-With")
ctx.Writer.Header().Set("Access-Control-Allow-Methods", "POST, OPTIONS, GET, PUT")
}
}
func allowed(origin string, config Config) bool {
if version.Environment() == version.EnvironmentTypeProd {
return origin == "https://monitor.quantex.com.ar"
}
for _, o := range config.AllowedOrigins {
if o == origin {
return true
}
}
return false
}
// GetTrades godoc
// @Summary List active trades
// @Description Returns all active List Trading trades
// @Tags fix
// @Produce json
// @Success 200 {array} domain.ListTrade
// @Router /qfixdpl/v1/trades [get]
func (cont *Controller) GetTrades(ctx *gin.Context) {
trades := cont.tradeProvider.GetTrades()
ctx.JSON(http.StatusOK, trades)
}
// GetLogs godoc
// @Summary Get raw FIX logs for a trade
// @Description Returns raw FIX message logs for a given QuoteReqID
// @Tags fix
// @Produce json
// @Param quoteReqID path string true "QuoteReqID"
// @Success 200 {object} domain.Logs
// @Router /qfixdpl/v1/trades/{quoteReqID}/logs [get]
func (cont *Controller) GetLogs(ctx *gin.Context) {
quoteReqID := ctx.Param("quoteReqID")
logs, err := cont.store.GetLogsByQuoteReqID(quoteReqID)
if err != nil {
err = tracerr.Errorf("GetLogs: error fetching logs (quoteReqID=%s): %w", quoteReqID, err)
slog.Error(err.Error())
ctx.JSON(http.StatusInternalServerError, HTTPError{Error: "error fetching logs"})
return
}
ctx.JSON(http.StatusOK, logs)
}
// AllMessages godoc
// @Summary List FIX application messages of the day after the caller's last-seen sequence
// @Description Returns today's FIX application messages (no admin: heartbeats/logon/logout/etc.) with MsgSeqNum greater than the caller's last-seen sequence per direction. "In" is the last MsgSeqNum the caller received on the IN side; "Out" is the same for OUT. Pass 0 to receive everything on that side. Sorted by CreatedAt ascending.
// @Tags fix
// @Accept json
// @Produce json
// @Param body body AllMessagesRequest true "API key and last-seen MsgSeqNum per direction"
// @Success 200 {array} domain.Message
// @Failure 400 {object} HTTPError
// @Failure 401 {object} HTTPError
// @Router /qfixdpl/v1/messages [post]
func (cont *Controller) AllMessages(ctx *gin.Context) {
setHeaders(ctx, cont.config)
var req AllMessagesRequest
if err := ctx.ShouldBindJSON(&req); err != nil {
ctx.JSON(http.StatusBadRequest, HTTPError{Error: err.Error()})
return
}
if !cont.checkServiceAPIKey(req.APIKey) {
ctx.JSON(http.StatusUnauthorized, HTTPError{Error: "Not allowed to perform this request"})
return
}
ctx.JSON(http.StatusOK, cont.tradeProvider.GetAllMessages(req.In, req.Out))
}
// checkServiceAPIKey returns true when the provided key matches the configured
// service-to-service shared secret. Empty configured key is always rejected
// to avoid open authentication when misconfigured.
func (cont *Controller) checkServiceAPIKey(key string) bool {
return cont.config.ServiceAPIKey != "" && key == cont.config.ServiceAPIKey
}
// GetPendingQuoteRequests godoc
// @Summary List pending QuoteRequests
// @Description Returns all QuoteRequests received from TW that have not been quoted yet by the dealer
// @Tags fix
// @Produce json
// @Success 200 {array} domain.ListTrade
// @Router /qfixdpl/v1/quote-requests [get]
func (cont *Controller) GetPendingQuoteRequests(ctx *gin.Context) {
pending := cont.tradeProvider.GetPendingQuoteRequests()
ctx.JSON(http.StatusOK, pending)
}
// SendQuote godoc
// @Summary Send a Quote for a pending QuoteRequest
// @Description Builds and sends a Quote (35=S) to TW for an existing QuoteRequest at the given price
// @Tags fix
// @Accept json
// @Produce json
// @Param body body SendQuoteRequest true "Quote to send"
// @Success 200 {object} Msg
// @Failure 400 {object} HTTPError
// @Failure 404 {object} HTTPError
// @Failure 409 {object} HTTPError
// @Failure 500 {object} HTTPError
// @Router /qfixdpl/v1/quotes [post]
func (cont *Controller) SendQuote(ctx *gin.Context) {
setHeaders(ctx, cont.config)
var req SendQuoteRequest
if err := ctx.ShouldBindJSON(&req); err != nil {
ctx.JSON(http.StatusBadRequest, HTTPError{Error: err.Error()})
return
}
if !cont.checkServiceAPIKey(req.APIKey) {
ctx.JSON(http.StatusUnauthorized, HTTPError{Error: "Not allowed to perform this request"})
return
}
price, err := decimal.NewFromString(req.Price)
if err != nil {
ctx.JSON(http.StatusBadRequest, HTTPError{Error: "invalid price: " + err.Error()})
return
}
if err := cont.tradeProvider.SendQuote(req.QuoteReqID, price); err != nil {
msg := err.Error()
switch {
case strings.Contains(msg, "not found"):
ctx.JSON(http.StatusNotFound, HTTPError{Error: "quoteReqID not found"})
case strings.Contains(msg, "already sent"):
ctx.JSON(http.StatusConflict, HTTPError{Error: "quote already sent for this quoteReqID"})
default:
ctx.JSON(http.StatusInternalServerError, HTTPError{Error: "failed to send quote"})
}
return
}
ctx.JSON(http.StatusOK, Msg{Text: "Quote sent"})
}
// CancelQuote godoc
// @Summary Cancel a Quote for a QuoteRequest
// @Description Builds and sends a QuoteCancel (35=Z) to TW for an existing QuoteRequest
// @Tags fix
// @Accept json
// @Produce json
// @Param body body CancelQuoteRequest true "Quote cancel request"
// @Success 200 {object} Msg
// @Failure 400 {object} HTTPError
// @Failure 401 {object} HTTPError
// @Failure 404 {object} HTTPError
// @Failure 409 {object} HTTPError
// @Failure 500 {object} HTTPError
// @Router /qfixdpl/v1/quotes/cancel [post]
func (cont *Controller) CancelQuote(ctx *gin.Context) {
setHeaders(ctx, cont.config)
var req CancelQuoteRequest
if err := ctx.ShouldBindJSON(&req); err != nil {
ctx.JSON(http.StatusBadRequest, HTTPError{Error: err.Error()})
return
}
if !cont.checkServiceAPIKey(req.APIKey) {
ctx.JSON(http.StatusUnauthorized, HTTPError{Error: "Not allowed to perform this request"})
return
}
if err := cont.tradeProvider.CancelQuote(req.QuoteReqID, req.Text); err != nil {
msg := err.Error()
switch {
case strings.Contains(msg, "not found"):
ctx.JSON(http.StatusNotFound, HTTPError{Error: "quoteReqID not found"})
case strings.Contains(msg, "cannot respond"):
ctx.JSON(http.StatusConflict, HTTPError{Error: "quote request cannot be cancelled"})
default:
ctx.JSON(http.StatusInternalServerError, HTTPError{Error: "failed to cancel quote"})
}
return
}
ctx.JSON(http.StatusOK, Msg{Text: "Quote cancel sent"})
}