494 lines
13 KiB
Go
494 lines
13 KiB
Go
package handlers
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"math"
|
|
"net/http"
|
|
"net/url"
|
|
"strconv"
|
|
"strings"
|
|
|
|
"git.itzana.me/strafesnet/go-grpc/bots"
|
|
"git.itzana.me/strafesnet/go-grpc/times"
|
|
"git.itzana.me/strafesnet/public-api/pkg/api/dto"
|
|
"github.com/gin-gonic/gin"
|
|
log "github.com/sirupsen/logrus"
|
|
"google.golang.org/grpc/codes"
|
|
"google.golang.org/grpc/status"
|
|
)
|
|
|
|
// TimesHandler handles HTTP requests related to times.
|
|
type TimesHandler struct {
|
|
*Handler
|
|
client *http.Client
|
|
url string
|
|
}
|
|
|
|
// NewTimesHandler creates a new TimesHandler with the provided options.
|
|
func NewTimesHandler(httpClient *http.Client, storageUrl string, options ...HandlerOption) (*TimesHandler, error) {
|
|
baseHandler, err := NewHandler(options...)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return &TimesHandler{
|
|
Handler: baseHandler,
|
|
client: httpClient,
|
|
url: storageUrl,
|
|
}, nil
|
|
}
|
|
|
|
// @Summary Get time by ID
|
|
// @Description Get a specific time by its ID
|
|
// @Tags times
|
|
// @Produce json
|
|
// @Security ApiKeyAuth
|
|
// @Param id path int true "Time ID"
|
|
// @Success 200 {object} dto.Response[dto.TimeData]
|
|
// @Failure 404 {object} dto.Error "Time not found"
|
|
// @Failure default {object} dto.Error "General error response"
|
|
// @Router /time/{id} [get]
|
|
func (h *TimesHandler) Get(ctx *gin.Context) {
|
|
// Extract time ID from path parameter
|
|
id := ctx.Param("id")
|
|
timeID, err := strconv.ParseInt(id, 10, 64)
|
|
if err != nil {
|
|
ctx.JSON(http.StatusBadRequest, dto.Error{
|
|
Error: "Invalid time ID format",
|
|
})
|
|
return
|
|
}
|
|
|
|
// Call the gRPC service
|
|
timeData, err := times.NewTimesServiceClient(h.dataClient).Get(ctx, ×.IdMessage{
|
|
ID: timeID,
|
|
})
|
|
if err != nil {
|
|
statusCode := http.StatusInternalServerError
|
|
errorMessage := "Failed to get time"
|
|
|
|
// Check if it's a "not found" error
|
|
if status.Code(err) == codes.NotFound {
|
|
statusCode = http.StatusNotFound
|
|
errorMessage = "Time not found"
|
|
}
|
|
|
|
ctx.JSON(statusCode, dto.Error{
|
|
Error: errorMessage,
|
|
})
|
|
log.WithError(err).Error(
|
|
"Failed to get time",
|
|
)
|
|
return
|
|
}
|
|
|
|
// Convert gRPC TimeResponse object to dto.TimeData object
|
|
var time dto.TimeData
|
|
result := time.FromGRPC(timeData)
|
|
|
|
// Return the time data
|
|
ctx.JSON(http.StatusOK, dto.Response[dto.TimeData]{
|
|
Data: *result,
|
|
})
|
|
}
|
|
|
|
// @Summary List times
|
|
// @Description Get a list of times
|
|
// @Tags times
|
|
// @Produce json
|
|
// @Security ApiKeyAuth
|
|
// @Param page_size query int false "Page size (max 100)" default(10) minimum(1) maximum(100)
|
|
// @Param page_number query int false "Page number" default(1) minimum(1)
|
|
// @Param filter query dto.TimeFilter false "Time filter parameters"
|
|
// @Param sort_by query string false "Sort field (time ASC, time DESC, date ASC, date DESC)" Enums(0, 1, 2, 3) default(0)
|
|
// @Success 200 {object} dto.PagedTotalResponse[dto.TimeData]
|
|
// @Failure default {object} dto.Error "General error response"
|
|
// @Router /time [get]
|
|
func (h *TimesHandler) List(ctx *gin.Context) {
|
|
// Extract and constrain pagination parameters
|
|
query := struct {
|
|
PageSize int `form:"page_size,default=10" binding:"min=1,max=100"`
|
|
PageNumber int `form:"page_number,default=1" binding:"min=1"`
|
|
SortBy int `form:"sort_by,default=0" binding:"min=0,max=3"`
|
|
}{}
|
|
if err := ctx.ShouldBindQuery(&query); err != nil {
|
|
ctx.JSON(http.StatusBadRequest, dto.Error{
|
|
Error: err.Error(),
|
|
})
|
|
return
|
|
}
|
|
|
|
// Get list filter
|
|
var filter dto.TimeFilter
|
|
if err := ctx.ShouldBindQuery(&filter); err != nil {
|
|
ctx.JSON(http.StatusBadRequest, dto.Error{
|
|
Error: err.Error(),
|
|
})
|
|
return
|
|
}
|
|
|
|
// Call the gRPC service
|
|
timeList, err := times.NewTimesServiceClient(h.dataClient).List(ctx, ×.ListRequest{
|
|
Sort: uint32(query.SortBy),
|
|
Filter: ×.TimeFilter{
|
|
UserID: filter.UserID,
|
|
MapID: filter.MapID,
|
|
ModeID: filter.ModeID,
|
|
StyleID: filter.StyleID,
|
|
GameID: filter.GameID,
|
|
},
|
|
Page: ×.Pagination{
|
|
Size: int32(query.PageSize),
|
|
Number: int32(query.PageNumber),
|
|
},
|
|
})
|
|
if err != nil {
|
|
ctx.JSON(http.StatusInternalServerError, dto.Error{
|
|
Error: "Failed to list times",
|
|
})
|
|
log.WithError(err).Error(
|
|
"Failed to list times",
|
|
)
|
|
return
|
|
}
|
|
|
|
// Convert gRPC TimeResponse objects to dto.TimeData objects
|
|
dtoTimes := make([]dto.TimeData, len(timeList.Times))
|
|
for i, t := range timeList.Times {
|
|
var time dto.TimeData
|
|
dtoTimes[i] = *time.FromGRPC(t)
|
|
}
|
|
|
|
// Return the paged response
|
|
ctx.JSON(http.StatusOK, dto.PagedTotalResponse[dto.TimeData]{
|
|
Data: dtoTimes,
|
|
Pagination: dto.PaginationWithTotal{
|
|
Page: query.PageNumber,
|
|
PageSize: query.PageSize,
|
|
TotalItems: int(timeList.Total),
|
|
TotalPages: int(math.Ceil(float64(timeList.Total) / float64(query.PageSize))),
|
|
},
|
|
})
|
|
}
|
|
|
|
// @Summary Get world records
|
|
// @Description Get a list of world records sorted by most recent
|
|
// @Description NOTE: World records are recalutated once every hour and this endpoint is not realtime
|
|
// @Tags times
|
|
// @Produce json
|
|
// @Security ApiKeyAuth
|
|
// @Param page_size query int false "Page size (max 100)" default(10) minimum(1) maximum(100)
|
|
// @Param page_number query int false "Page number" default(1) minimum(1)
|
|
// @Param filter query dto.TimeFilter false "Time filter parameters"
|
|
// @Success 200 {object} dto.PagedResponse[dto.TimeData]
|
|
// @Failure default {object} dto.Error "General error response"
|
|
// @Router /time/worldrecord [get]
|
|
func (h *TimesHandler) WrList(ctx *gin.Context) {
|
|
// Extract and constrain pagination parameters
|
|
query := struct {
|
|
PageSize int `form:"page_size,default=10" binding:"min=1,max=100"`
|
|
PageNumber int `form:"page_number,default=1" binding:"min=1"`
|
|
}{}
|
|
if err := ctx.ShouldBindQuery(&query); err != nil {
|
|
ctx.JSON(http.StatusBadRequest, dto.Error{
|
|
Error: err.Error(),
|
|
})
|
|
return
|
|
}
|
|
|
|
// Get list filter
|
|
var filter dto.TimeFilter
|
|
if err := ctx.ShouldBindQuery(&filter); err != nil {
|
|
ctx.JSON(http.StatusBadRequest, dto.Error{
|
|
Error: err.Error(),
|
|
})
|
|
return
|
|
}
|
|
|
|
// Call the gRPC service
|
|
timeList, err := times.NewTimesServiceClient(h.dataClient).ListWr(ctx, ×.WrListRequest{
|
|
Filter: ×.TimeFilter{
|
|
UserID: filter.UserID,
|
|
MapID: filter.MapID,
|
|
ModeID: filter.ModeID,
|
|
StyleID: filter.StyleID,
|
|
GameID: filter.GameID,
|
|
},
|
|
Page: ×.Pagination{
|
|
Size: int32(query.PageSize),
|
|
Number: int32(query.PageNumber),
|
|
},
|
|
})
|
|
if err != nil {
|
|
ctx.JSON(http.StatusInternalServerError, dto.Error{
|
|
Error: "Failed to list world records",
|
|
})
|
|
log.WithError(err).Error(
|
|
"Failed to list world records",
|
|
)
|
|
return
|
|
}
|
|
|
|
// Convert gRPC TimeResponse objects to dto.TimeData objects
|
|
dtoTimes := make([]dto.TimeData, len(timeList.Times))
|
|
for i, t := range timeList.Times {
|
|
var time dto.TimeData
|
|
dtoTimes[i] = *time.FromGRPC(t)
|
|
}
|
|
|
|
// Return the paged response
|
|
ctx.JSON(http.StatusOK, dto.PagedResponse[dto.TimeData]{
|
|
Data: dtoTimes,
|
|
Pagination: dto.Pagination{
|
|
Page: query.PageNumber,
|
|
PageSize: query.PageSize,
|
|
},
|
|
})
|
|
}
|
|
|
|
// @Summary Get placement batch
|
|
// @Description Get placement information for multiple times
|
|
// @Description Invalid or not found time IDs are omitted in the response
|
|
// @Tags times
|
|
// @Produce json
|
|
// @Security ApiKeyAuth
|
|
// @Param ids query []int64 true "Comma-separated array of time IDs (25 Limit)"
|
|
// @Success 200 {object} dto.Response[[]dto.TimePlacement]
|
|
// @Failure 400 {object} dto.Error "Invalid request"
|
|
// @Failure default {object} dto.Error "General error response"
|
|
// @Router /time/placement [get]
|
|
func (h *TimesHandler) GetPlacements(ctx *gin.Context) {
|
|
// Get the comma-separated IDs from query parameter
|
|
idsParam := ctx.Query("ids")
|
|
if idsParam == "" {
|
|
ctx.JSON(http.StatusBadRequest, dto.Error{
|
|
Error: "ids parameter is required",
|
|
})
|
|
return
|
|
}
|
|
|
|
// Split the comma-separated string and convert to int64 slice
|
|
idStrings := strings.Split(idsParam, ",")
|
|
var ids []int64
|
|
|
|
for _, idStr := range idStrings {
|
|
idStr = strings.TrimSpace(idStr)
|
|
if idStr == "" {
|
|
continue
|
|
}
|
|
|
|
id, err := strconv.ParseInt(idStr, 10, 64)
|
|
if err != nil {
|
|
ctx.JSON(http.StatusBadRequest, dto.Error{
|
|
Error: fmt.Sprintf("Invalid ID format: %s", idStr),
|
|
})
|
|
return
|
|
}
|
|
ids = append(ids, id)
|
|
}
|
|
|
|
// Validate that we have at least one time ID
|
|
if len(ids) == 0 {
|
|
ctx.JSON(http.StatusBadRequest, dto.Error{
|
|
Error: "At least one time ID is required",
|
|
})
|
|
return
|
|
}
|
|
|
|
// Ensure we don't have more than 25
|
|
if len(ids) > 25 {
|
|
ctx.JSON(http.StatusBadRequest, dto.Error{
|
|
Error: "Maximum of 25 IDs allowed",
|
|
})
|
|
return
|
|
}
|
|
|
|
// Call the gRPC service
|
|
rankList, err := times.NewTimesServiceClient(h.dataClient).RankBatch(ctx, ×.IdListMessage{
|
|
ID: ids,
|
|
})
|
|
if err != nil {
|
|
ctx.JSON(http.StatusInternalServerError, dto.Error{
|
|
Error: "Failed to get rank data",
|
|
})
|
|
log.WithError(err).Error("Failed to get rank data")
|
|
return
|
|
}
|
|
|
|
// Convert gRPC response to DTO format
|
|
ranks := make([]dto.TimePlacement, len(rankList.Ranks))
|
|
for i, rank := range rankList.Ranks {
|
|
var timeRank dto.TimePlacement
|
|
ranks[i] = *timeRank.FromGRPC(rank)
|
|
}
|
|
ctx.JSON(http.StatusOK, dto.Response[[]dto.TimePlacement]{
|
|
Data: ranks,
|
|
})
|
|
}
|
|
|
|
// @Summary Get bot download url by time ID
|
|
// @Description Get a download url for the bot replay of a time by its ID if it exists
|
|
// @Tags times
|
|
// @Produce json
|
|
// @Security ApiKeyAuth
|
|
// @Param id path int true "Time ID"
|
|
// @Success 200 {object} dto.Response[dto.BotDownloadUrl]
|
|
// @Failure 404 {object} dto.Error "Time does not have a Bot"
|
|
// @Failure default {object} dto.Error "General error response"
|
|
// @Router /time/{id}/download-url [get]
|
|
func (h *TimesHandler) GetDownloadUrl(ctx *gin.Context) {
|
|
// Extract time ID from path parameter
|
|
id := ctx.Param("id")
|
|
timeID, err := strconv.ParseInt(id, 10, 64)
|
|
if err != nil {
|
|
ctx.JSON(http.StatusBadRequest, dto.Error{
|
|
Error: "Invalid time ID format",
|
|
})
|
|
return
|
|
}
|
|
|
|
// Call the gRPC service
|
|
timeData, err := times.NewTimesServiceClient(h.dataClient).Get(ctx, ×.IdMessage{
|
|
ID: timeID,
|
|
})
|
|
if err != nil {
|
|
statusCode := http.StatusInternalServerError
|
|
errorMessage := "Failed to get time"
|
|
|
|
// Check if it's a "not found" error
|
|
if status.Code(err) == codes.NotFound {
|
|
statusCode = http.StatusNotFound
|
|
errorMessage = "Time not found"
|
|
}
|
|
|
|
ctx.JSON(statusCode, dto.Error{
|
|
Error: errorMessage,
|
|
})
|
|
log.WithError(err).Error(
|
|
"Failed to get time",
|
|
)
|
|
return
|
|
}
|
|
|
|
// check if bot exists
|
|
if timeData.Bot == nil {
|
|
statusCode := http.StatusNotFound
|
|
errorMessage := "Time does not have a Bot"
|
|
|
|
ctx.JSON(statusCode, dto.Error{
|
|
Error: errorMessage,
|
|
})
|
|
log.Error("Time does not have a Bot")
|
|
return
|
|
}
|
|
|
|
// Call the gRPC service
|
|
botData, err := bots.NewBotsServiceClient(h.dataClient).Get(ctx, &bots.IdMessage{
|
|
ID: timeData.Bot.ID,
|
|
})
|
|
if err != nil {
|
|
statusCode := http.StatusInternalServerError
|
|
errorMessage := "Failed to get bot"
|
|
|
|
// Check if it's a "not found" error
|
|
if status.Code(err) == codes.NotFound {
|
|
statusCode = http.StatusNotFound
|
|
errorMessage = "Bot not found"
|
|
}
|
|
|
|
ctx.JSON(statusCode, dto.Error{
|
|
Error: errorMessage,
|
|
})
|
|
log.WithError(err).Error(
|
|
"Failed to get bot",
|
|
)
|
|
return
|
|
}
|
|
|
|
// fetch download url from storage service
|
|
// Build the full URL.
|
|
fullURL, err := url.JoinPath(h.url, botData.FileID)
|
|
if err != nil {
|
|
statusCode := http.StatusInternalServerError
|
|
errorMessage := "Error joining Url"
|
|
|
|
ctx.JSON(statusCode, dto.Error{
|
|
Error: errorMessage,
|
|
})
|
|
log.WithError(err).Error(
|
|
"Error joining Url",
|
|
)
|
|
return
|
|
}
|
|
|
|
// Create the request with the supplied context so callers can cancel it.
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, fullURL, nil)
|
|
if err != nil {
|
|
statusCode := http.StatusInternalServerError
|
|
errorMessage := "Error creating http request to storage"
|
|
|
|
ctx.JSON(statusCode, dto.Error{
|
|
Error: errorMessage,
|
|
})
|
|
log.WithError(err).Error(
|
|
"Error creating http request to storage",
|
|
)
|
|
return
|
|
}
|
|
|
|
// Send the request.
|
|
resp, err := h.client.Do(req)
|
|
if err != nil {
|
|
statusCode := http.StatusInternalServerError
|
|
errorMessage := "Storage http request failed"
|
|
|
|
ctx.JSON(statusCode, dto.Error{
|
|
Error: errorMessage,
|
|
})
|
|
log.WithError(err).Error(
|
|
"Storage http request failed",
|
|
)
|
|
return
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
// check status
|
|
if resp.StatusCode != 200 {
|
|
statusCode := http.StatusInternalServerError
|
|
errorMessage := "Unexpected status"
|
|
|
|
ctx.JSON(statusCode, dto.Error{
|
|
Error: errorMessage,
|
|
})
|
|
log.Error("Unexpected status")
|
|
return
|
|
}
|
|
|
|
type storageResp struct {
|
|
ID string `json:"ID"`
|
|
Created int64 `json:"Created"`
|
|
Url string `json:"Url"`
|
|
}
|
|
// Decode the JSON body into the storageResp struct.
|
|
var info storageResp
|
|
if err := json.NewDecoder(resp.Body).Decode(&info); err != nil {
|
|
statusCode := http.StatusInternalServerError
|
|
errorMessage := "Error decoding json"
|
|
|
|
ctx.JSON(statusCode, dto.Error{
|
|
Error: errorMessage,
|
|
})
|
|
log.WithError(err).Error(
|
|
"Error decoding json",
|
|
)
|
|
return
|
|
}
|
|
|
|
// Return the download url
|
|
ctx.JSON(http.StatusOK, dto.Response[dto.BotDownloadUrl]{
|
|
Data: dto.BotDownloadUrl{
|
|
Url: info.Url,
|
|
},
|
|
})
|
|
}
|