diff --git a/docs/docs.go b/docs/docs.go index e7aecb5..7840bd3 100644 --- a/docs/docs.go +++ b/docs/docs.go @@ -454,6 +454,52 @@ const docTemplate = `{ } } }, + "/time/{id}/bot": { + "get": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "Get a HTTP 302 Redirect to the download url for the bot replay of a time by its ID if it exists", + "tags": [ + "times" + ], + "summary": "Get redirect to bot download url by time ID", + "parameters": [ + { + "type": "integer", + "description": "Time ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "302": { + "description": "Found", + "headers": { + "Location": { + "type": "string", + "description": "Redirect URL" + } + } + }, + "404": { + "description": "Time does not have a Bot", + "schema": { + "$ref": "#/definitions/Error" + } + }, + "default": { + "description": "General error response", + "schema": { + "$ref": "#/definitions/Error" + } + } + } + } + }, "/user": { "get": { "security": [ diff --git a/docs/swagger.json b/docs/swagger.json index 372f700..1c1c7a2 100644 --- a/docs/swagger.json +++ b/docs/swagger.json @@ -447,6 +447,52 @@ } } }, + "/time/{id}/bot": { + "get": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "Get a HTTP 302 Redirect to the download url for the bot replay of a time by its ID if it exists", + "tags": [ + "times" + ], + "summary": "Get redirect to bot download url by time ID", + "parameters": [ + { + "type": "integer", + "description": "Time ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "302": { + "description": "Found", + "headers": { + "Location": { + "type": "string", + "description": "Redirect URL" + } + } + }, + "404": { + "description": "Time does not have a Bot", + "schema": { + "$ref": "#/definitions/Error" + } + }, + "default": { + "description": "General error response", + "schema": { + "$ref": "#/definitions/Error" + } + } + } + } + }, "/user": { "get": { "security": [ diff --git a/docs/swagger.yaml b/docs/swagger.yaml index 6a8bb2b..78d10af 100644 --- a/docs/swagger.yaml +++ b/docs/swagger.yaml @@ -406,6 +406,36 @@ paths: summary: Get time by ID tags: - times + /time/{id}/bot: + get: + description: Get a HTTP 302 Redirect to the download url for the bot replay + of a time by its ID if it exists + parameters: + - description: Time ID + in: path + name: id + required: true + type: integer + responses: + "302": + description: Found + headers: + Location: + description: Redirect URL + type: string + "404": + description: Time does not have a Bot + schema: + $ref: '#/definitions/Error' + default: + description: General error response + schema: + $ref: '#/definitions/Error' + security: + - ApiKeyAuth: [] + summary: Get redirect to bot download url by time ID + tags: + - times /time/placement: get: description: |- diff --git a/pkg/api/dto/times.go b/pkg/api/dto/times.go index 5a78e89..6e46fc5 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 { diff --git a/pkg/api/handlers/handler.go b/pkg/api/handlers/handler.go index 71e6ffa..824a2df 100644 --- a/pkg/api/handlers/handler.go +++ b/pkg/api/handlers/handler.go @@ -2,9 +2,10 @@ package handlers import ( "fmt" + "strconv" + "github.com/gin-gonic/gin" "google.golang.org/grpc" - "strconv" ) const ( @@ -14,6 +15,7 @@ const ( // Handler is a base handler that provides common functionality for all HTTP handlers. type Handler struct { dataClient *grpc.ClientConn + storageUrl string } // HandlerOption defines a functional option for configuring a Handler @@ -26,6 +28,13 @@ func WithDataClient(dataClient *grpc.ClientConn) HandlerOption { } } +// WithStorageUrl sets the storage url +func WithStorageUrl(storageUrl string) HandlerOption { + return func(cfg *Handler) { + cfg.storageUrl = storageUrl + } +} + // NewHandler creates a new Handler with the provided options. // It requires both a datastore and an authentication service to function properly. func NewHandler(options ...HandlerOption) (*Handler, error) { diff --git a/pkg/api/handlers/times.go b/pkg/api/handlers/times.go index 73e87ab..fc74361 100644 --- a/pkg/api/handlers/times.go +++ b/pkg/api/handlers/times.go @@ -1,17 +1,22 @@ package handlers import ( + "encoding/json" "fmt" + "math" + "net/http" + "net/url" + "strconv" + "strings" + "time" + + "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" - "math" - "net/http" - "strconv" - "strings" ) // TimesHandler handles HTTP requests related to times. @@ -317,3 +322,167 @@ func (h *TimesHandler) GetPlacements(ctx *gin.Context) { Data: ranks, }) } + +// @Summary Get redirect to bot download url by time ID +// @Description Get a HTTP 302 Redirect to the download url for the bot replay of a time by its ID if it exists +// @Tags times +// @Security ApiKeyAuth +// @Param id path int true "Time ID" +// @Success 302 +// @Header 302 {string} Location "Redirect URL" +// @Failure 404 {object} dto.Error "Time does not have a Bot" +// @Failure default {object} dto.Error "General error response" +// @Router /time/{id}/bot [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.storageUrl, 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 := (&http.Client{ + Timeout: 10 * time.Second, + }).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.Redirect(http.StatusFound, info.Url) +} diff --git a/pkg/api/router.go b/pkg/api/router.go index 6d56d64..4878fb8 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 @@ -25,6 +26,8 @@ type RouterConfig struct { port int devClient *grpc.ClientConn dataClient *grpc.ClientConn + httpClient *http.Client + storageUrl string context *cli.Context shutdownTimeout time.Duration } @@ -57,6 +60,13 @@ func WithDataClient(conn *grpc.ClientConn) Option { } } +// WithStorageUrl sets the storage url +func WithStorageUrl(storageUrl string) Option { + return func(cfg *RouterConfig) { + cfg.storageUrl = storageUrl + } +} + // WithShutdownTimeout sets the graceful shutdown timeout func WithShutdownTimeout(timeout time.Duration) Option { return func(cfg *RouterConfig) { @@ -75,7 +85,10 @@ func setupRoutes(cfg *RouterConfig) (*gin.Engine, error) { } // Times handler - timesHandler, err := handlers.NewTimesHandler(handlerOptions...) + timesHandler, err := handlers.NewTimesHandler( + handlers.WithDataClient(cfg.dataClient), + handlers.WithStorageUrl(cfg.storageUrl), + ) if err != nil { return nil, err } @@ -99,29 +112,36 @@ func setupRoutes(cfg *RouterConfig) (*gin.Engine, error) { } docs.SwaggerInfo.BasePath = "/api/v1" - v1 := r.Group("/api/v1") + dataGroup := r.Group("/api/v1") { // Auth middleware - v1.Use(middleware.ValidateRequest("Data", "Read", cfg.devClient)) + dataGroup.Use(middleware.ValidateRequest("Data", "Read", cfg.devClient)) // Times - v1.GET("/time", timesHandler.List) - v1.GET("/time/worldrecord", timesHandler.WrList) - v1.GET("/time/placement", timesHandler.GetPlacements) - v1.GET("/time/:id", timesHandler.Get) + dataGroup.GET("/time", timesHandler.List) + dataGroup.GET("/time/worldrecord", timesHandler.WrList) + dataGroup.GET("/time/placement", timesHandler.GetPlacements) + dataGroup.GET("/time/:id", timesHandler.Get) // Users - v1.GET("/user", usersHandler.List) - v1.GET("/user/:id", usersHandler.Get) - v1.GET("/user/:id/rank", usersHandler.GetRank) + dataGroup.GET("/user", usersHandler.List) + dataGroup.GET("/user/:id", usersHandler.Get) + dataGroup.GET("/user/:id/rank", usersHandler.GetRank) // Maps - v1.GET("/map", mapsHandler.List) - v1.GET("/map/:id", mapsHandler.Get) + dataGroup.GET("/map", mapsHandler.List) + dataGroup.GET("/map/:id", mapsHandler.Get) // Rank - v1.GET("/rank", rankHandler.List) + dataGroup.GET("/rank", rankHandler.List) + } + botsGroup := r.Group("/api/v1") + { + // Auth middleware + botsGroup.Use(middleware.ValidateRequest("Bots", "Read", cfg.devClient)) + + botsGroup.GET("/time/:id/bot", timesHandler.GetDownloadUrl) } r.GET("/docs/*any", ginSwagger.WrapHandler(swaggerfiles.Handler)) r.GET("/", func(ctx *gin.Context) { diff --git a/pkg/cmds/api.go b/pkg/cmds/api.go index 1594d91..8a7dbd9 100644 --- a/pkg/cmds/api.go +++ b/pkg/cmds/api.go @@ -31,6 +31,12 @@ func NewApiCommand() *cli.Command { EnvVars: []string{"DATA_RPC_HOST"}, Value: "data-service:9000", }, + &cli.StringFlag{ + Name: "storage-host", + Usage: "Host of storage", + EnvVars: []string{"STORAGE_HOST"}, + Value: "storage-service:9000", + }, }, } } @@ -48,10 +54,14 @@ func runAPI(ctx *cli.Context) error { return err } + // Storage service http client + storageUrl := ctx.String("storage-host") + return api.NewRouter( api.WithContext(ctx), api.WithPort(ctx.Int("port")), api.WithDevClient(devConn), api.WithDataClient(dataConn), + api.WithStorageUrl(storageUrl), ) }