diff --git a/pkg/api/dto/times.go b/pkg/api/dto/times.go index 5a78e89..4a6cf1a 100644 --- a/pkg/api/dto/times.go +++ b/pkg/api/dto/times.go @@ -1,9 +1,10 @@ package dto import ( - "git.itzana.me/strafesnet/go-grpc/times" "strconv" "time" + + "git.itzana.me/strafesnet/go-grpc/times" ) type TimePlacement struct { @@ -41,6 +42,16 @@ type TimeData struct { GameID int32 `json:"game_id"` } // @name Time +type BotDownloadUrl struct { + Url string `json:"url"` +} // @name BotDownloadUrl + +type FileInfo struct { + ID string `json:"ID"` + Created int64 `json:"Created"` + Url string `json:"Url"` +} // @name FileInfo + // FromGRPC converts a TimeResponse protobuf message to a TimeData domain object func (t *TimeData) FromGRPC(resp *times.TimeResponse) *TimeData { if resp == nil { diff --git a/pkg/api/handlers/times.go b/pkg/api/handlers/times.go index e41b3f8..df5b033 100644 --- a/pkg/api/handlers/times.go +++ b/pkg/api/handlers/times.go @@ -1,12 +1,14 @@ package handlers import ( + "encoding/json" "fmt" "math" "net/http" "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" @@ -18,16 +20,20 @@ import ( // 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(options ...HandlerOption) (*TimesHandler, error) { +func NewTimesHandler(http_client *http.Client, storage_url string, options ...HandlerOption) (*TimesHandler, error) { baseHandler, err := NewHandler(options...) if err != nil { return nil, err } return &TimesHandler{ Handler: baseHandler, + client: http_client, + url: storage_url, }, nil } @@ -318,3 +324,158 @@ func (h *TimesHandler) GetPlacements(ctx *gin.Context) { 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 not found" +// @Failure 404 {object} dto.Error "Time does not have a Bot" +// @Failure 404 {object} dto.Error "Bot not found" +// @Failure default {object} dto.Error "General error response" +// @Router /time/{id} [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.WithError(err).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 := h.url + botData.FileID + + // 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 + } + + // Decode the JSON body into the FileInfo struct. + var info dto.FileInfo + 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 time data + ctx.JSON(http.StatusOK, dto.Response[dto.BotDownloadUrl]{ + Data: dto.BotDownloadUrl{ + Url: info.Url, + }, + }) +} diff --git a/pkg/api/router.go b/pkg/api/router.go index 6d56d64..181cd23 100644 --- a/pkg/api/router.go +++ b/pkg/api/router.go @@ -4,6 +4,9 @@ import ( "context" "errors" "fmt" + "net/http" + "time" + "git.itzana.me/StrafesNET/dev-service/pkg/api/middleware" "git.itzana.me/strafesnet/public-api/docs" "git.itzana.me/strafesnet/public-api/pkg/api/handlers" @@ -13,8 +16,6 @@ import ( ginSwagger "github.com/swaggo/gin-swagger" "github.com/urfave/cli/v2" "google.golang.org/grpc" - "net/http" - "time" ) // Option defines a function that configures a Router @@ -75,7 +76,7 @@ func setupRoutes(cfg *RouterConfig) (*gin.Engine, error) { } // Times handler - timesHandler, err := handlers.NewTimesHandler(handlerOptions...) + timesHandler, err := handlers.NewTimesHandler(HTTP_CLIENT, STORAGE_URL, handlerOptions...) if err != nil { return nil, err } @@ -121,7 +122,14 @@ func setupRoutes(cfg *RouterConfig) (*gin.Engine, error) { // Rank v1.GET("/rank", rankHandler.List) + } + v1_bots := r.Group("/api/v1") + { + // Auth middleware + v1_bots.Use(middleware.ValidateRequest("Storage", "Read", cfg.devClient)) + + v1_bots.GET("/time/:id/download-url", timesHandler.GetDownloadUrl) } r.GET("/docs/*any", ginSwagger.WrapHandler(swaggerfiles.Handler)) r.GET("/", func(ctx *gin.Context) {