Merge pull request 'Bot Download API' (#26) from bot-dl into staging
All checks were successful
continuous-integration/drone/push Build is passing
All checks were successful
continuous-integration/drone/push Build is passing
Reviewed-on: #26 Reviewed-by: itzaname <itzaname@noreply@itzana.me>
This commit was merged in pull request #26.
This commit is contained in:
46
docs/docs.go
46
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": [
|
||||
|
||||
@@ -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": [
|
||||
|
||||
@@ -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: |-
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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),
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user