Deploy Bots API #28

Merged
Quaternions merged 34 commits from staging into master 2026-02-28 02:33:43 +00:00
9 changed files with 358 additions and 22 deletions

View File

@@ -61,4 +61,9 @@ steps:
when: when:
branch: branch:
- master - master
- staging - staging
---
kind: signature
hmac: 7655eb6dead73d2ad977685120cee8562931036bb5d7fa59d30d5917840c4a22
...

View File

@@ -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": { "/user": {
"get": { "get": {
"security": [ "security": [

View File

@@ -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": { "/user": {
"get": { "get": {
"security": [ "security": [

View File

@@ -406,6 +406,36 @@ paths:
summary: Get time by ID summary: Get time by ID
tags: tags:
- times - 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: /time/placement:
get: get:
description: |- description: |-

View File

@@ -1,9 +1,10 @@
package dto package dto
import ( import (
"git.itzana.me/strafesnet/go-grpc/times"
"strconv" "strconv"
"time" "time"
"git.itzana.me/strafesnet/go-grpc/times"
) )
type TimePlacement struct { type TimePlacement struct {

View File

@@ -2,9 +2,10 @@ package handlers
import ( import (
"fmt" "fmt"
"strconv"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"google.golang.org/grpc" "google.golang.org/grpc"
"strconv"
) )
const ( const (
@@ -14,6 +15,7 @@ const (
// Handler is a base handler that provides common functionality for all HTTP handlers. // Handler is a base handler that provides common functionality for all HTTP handlers.
type Handler struct { type Handler struct {
dataClient *grpc.ClientConn dataClient *grpc.ClientConn
storageUrl string
} }
// HandlerOption defines a functional option for configuring a Handler // 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. // NewHandler creates a new Handler with the provided options.
// It requires both a datastore and an authentication service to function properly. // It requires both a datastore and an authentication service to function properly.
func NewHandler(options ...HandlerOption) (*Handler, error) { func NewHandler(options ...HandlerOption) (*Handler, error) {

View File

@@ -1,17 +1,22 @@
package handlers package handlers
import ( import (
"encoding/json"
"fmt" "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/go-grpc/times"
"git.itzana.me/strafesnet/public-api/pkg/api/dto" "git.itzana.me/strafesnet/public-api/pkg/api/dto"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
log "github.com/sirupsen/logrus" log "github.com/sirupsen/logrus"
"google.golang.org/grpc/codes" "google.golang.org/grpc/codes"
"google.golang.org/grpc/status" "google.golang.org/grpc/status"
"math"
"net/http"
"strconv"
"strings"
) )
// TimesHandler handles HTTP requests related to times. // TimesHandler handles HTTP requests related to times.
@@ -317,3 +322,167 @@ func (h *TimesHandler) GetPlacements(ctx *gin.Context) {
Data: ranks, 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, &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.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)
}

View File

@@ -4,6 +4,9 @@ import (
"context" "context"
"errors" "errors"
"fmt" "fmt"
"net/http"
"time"
"git.itzana.me/StrafesNET/dev-service/pkg/api/middleware" "git.itzana.me/StrafesNET/dev-service/pkg/api/middleware"
"git.itzana.me/strafesnet/public-api/docs" "git.itzana.me/strafesnet/public-api/docs"
"git.itzana.me/strafesnet/public-api/pkg/api/handlers" "git.itzana.me/strafesnet/public-api/pkg/api/handlers"
@@ -13,8 +16,6 @@ import (
ginSwagger "github.com/swaggo/gin-swagger" ginSwagger "github.com/swaggo/gin-swagger"
"github.com/urfave/cli/v2" "github.com/urfave/cli/v2"
"google.golang.org/grpc" "google.golang.org/grpc"
"net/http"
"time"
) )
// Option defines a function that configures a Router // Option defines a function that configures a Router
@@ -25,6 +26,8 @@ type RouterConfig struct {
port int port int
devClient *grpc.ClientConn devClient *grpc.ClientConn
dataClient *grpc.ClientConn dataClient *grpc.ClientConn
httpClient *http.Client
storageUrl string
context *cli.Context context *cli.Context
shutdownTimeout time.Duration 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 // WithShutdownTimeout sets the graceful shutdown timeout
func WithShutdownTimeout(timeout time.Duration) Option { func WithShutdownTimeout(timeout time.Duration) Option {
return func(cfg *RouterConfig) { return func(cfg *RouterConfig) {
@@ -75,7 +85,10 @@ func setupRoutes(cfg *RouterConfig) (*gin.Engine, error) {
} }
// Times handler // Times handler
timesHandler, err := handlers.NewTimesHandler(handlerOptions...) timesHandler, err := handlers.NewTimesHandler(
handlers.WithDataClient(cfg.dataClient),
handlers.WithStorageUrl(cfg.storageUrl),
)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -99,29 +112,36 @@ func setupRoutes(cfg *RouterConfig) (*gin.Engine, error) {
} }
docs.SwaggerInfo.BasePath = "/api/v1" docs.SwaggerInfo.BasePath = "/api/v1"
v1 := r.Group("/api/v1") dataGroup := r.Group("/api/v1")
{ {
// Auth middleware // Auth middleware
v1.Use(middleware.ValidateRequest("Data", "Read", cfg.devClient)) dataGroup.Use(middleware.ValidateRequest("Data", "Read", cfg.devClient))
// Times // Times
v1.GET("/time", timesHandler.List) dataGroup.GET("/time", timesHandler.List)
v1.GET("/time/worldrecord", timesHandler.WrList) dataGroup.GET("/time/worldrecord", timesHandler.WrList)
v1.GET("/time/placement", timesHandler.GetPlacements) dataGroup.GET("/time/placement", timesHandler.GetPlacements)
v1.GET("/time/:id", timesHandler.Get) dataGroup.GET("/time/:id", timesHandler.Get)
// Users // Users
v1.GET("/user", usersHandler.List) dataGroup.GET("/user", usersHandler.List)
v1.GET("/user/:id", usersHandler.Get) dataGroup.GET("/user/:id", usersHandler.Get)
v1.GET("/user/:id/rank", usersHandler.GetRank) dataGroup.GET("/user/:id/rank", usersHandler.GetRank)
// Maps // Maps
v1.GET("/map", mapsHandler.List) dataGroup.GET("/map", mapsHandler.List)
v1.GET("/map/:id", mapsHandler.Get) dataGroup.GET("/map/:id", mapsHandler.Get)
// Rank // Rank
v1.GET("/rank", rankHandler.List) dataGroup.GET("/rank", rankHandler.List)
}
botsGroup := r.Group("/api/v1")
{
// Auth middleware
botsGroup.Use(middleware.ValidateRequest("Data", "Bots", cfg.devClient))
botsGroup.GET("/time/:id/bot", timesHandler.GetDownloadUrl)
} }
r.GET("/docs/*any", ginSwagger.WrapHandler(swaggerfiles.Handler)) r.GET("/docs/*any", ginSwagger.WrapHandler(swaggerfiles.Handler))
r.GET("/", func(ctx *gin.Context) { r.GET("/", func(ctx *gin.Context) {

View File

@@ -31,6 +31,12 @@ func NewApiCommand() *cli.Command {
EnvVars: []string{"DATA_RPC_HOST"}, EnvVars: []string{"DATA_RPC_HOST"},
Value: "data-service:9000", Value: "data-service:9000",
}, },
&cli.StringFlag{
Name: "storage-host",
Usage: "Host of storage",
EnvVars: []string{"STORAGE_HOST"},
Value: "http://storage-service:9000/v1/file/",
},
}, },
} }
} }
@@ -48,10 +54,14 @@ func runAPI(ctx *cli.Context) error {
return err return err
} }
// Storage service http client
storageUrl := ctx.String("storage-host")
return api.NewRouter( return api.NewRouter(
api.WithContext(ctx), api.WithContext(ctx),
api.WithPort(ctx.Int("port")), api.WithPort(ctx.Int("port")),
api.WithDevClient(devConn), api.WithDevClient(devConn),
api.WithDataClient(dataConn), api.WithDataClient(dataConn),
api.WithStorageUrl(storageUrl),
) )
} }