Files
public-api/pkg/api/handlers/times.go
Rhys Lloyd 5a9bc0ea6c
Some checks failed
continuous-integration/drone/push Build is passing
continuous-integration/drone/pr Build is failing
use url.JoinPath
2026-02-24 08:00:01 -08:00

496 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, &times.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, &times.ListRequest{
Sort: uint32(query.SortBy),
Filter: &times.TimeFilter{
UserID: filter.UserID,
MapID: filter.MapID,
ModeID: filter.ModeID,
StyleID: filter.StyleID,
GameID: filter.GameID,
},
Page: &times.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, &times.WrListRequest{
Filter: &times.TimeFilter{
UserID: filter.UserID,
MapID: filter.MapID,
ModeID: filter.ModeID,
StyleID: filter.StyleID,
GameID: filter.GameID,
},
Page: &times.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, &times.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, &times.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.WithError(err).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,
},
})
}