Bot Download API #26

Merged
itzaname merged 27 commits from bot-dl into staging 2026-02-26 02:55:30 +00:00
8 changed files with 352 additions and 21 deletions

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

View File

@@ -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: |-

View File

@@ -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 {

View File

@@ -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) {

View File

@@ -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
Quaternions marked this conversation as resolved Outdated

Actually, probably need to update this since you're not really getting the dl url.

Actually, probably need to update this since you're not really getting the dl url.
// @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
Quaternions marked this conversation as resolved Outdated

You've got 3 404s but the only one will be used as you can see in the generated docs.

You've got 3 404s but the only one will be used as you can see in the generated docs.
id := ctx.Param("id")
timeID, err := strconv.ParseInt(id, 10, 64)
Quaternions marked this conversation as resolved Outdated

This is an RPC like action not a RESTful url. Do something like this:

  • /time/{id}/bot
  • /time/{id}/bot/replay (if you foresee returning bot metadata in /bot)
This is an RPC like action not a RESTful url. Do something like this: - /time/{id}/bot - /time/{id}/bot/replay (if you foresee returning bot metadata in /bot)
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
Quaternions marked this conversation as resolved
Review

You're using WithError but no error was actually set.

You're using `WithError` but no error was actually set.
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,
Quaternions marked this conversation as resolved Outdated

Use url.JoinPath as it will handle adding / as needed.

Use `url.JoinPath` as it will handle adding `/` as needed.
})
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
Quaternions marked this conversation as resolved
Review

Another error log when err would be nil.

Another error log when err would be nil.
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"
Quaternions marked this conversation as resolved Outdated

Simplify things and just 302 to the download url instead.

Simplify things and just 302 to the download url instead.
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"
"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
Quaternions marked this conversation as resolved Outdated

This aint no grpc client

This aint no grpc client
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(
Quaternions marked this conversation as resolved Outdated
timesHandler, err := handlers.NewTimesHandler(
    handlers.WithDataClient(cfg.dataClient),
    handlers.WithStorageUrl(cfg.storageUrl),
)
```go timesHandler, err := handlers.NewTimesHandler( handlers.WithDataClient(cfg.dataClient), handlers.WithStorageUrl(cfg.storageUrl), ) ```
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")
Quaternions marked this conversation as resolved Outdated

Missed this one sneaky snake_case. I'll note I don't know if duplicate groups can work like this but we will find out.

Missed this one sneaky snake_case. I'll note I don't know if duplicate groups can work like this but we will find out.
{
// 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) {

View File

@@ -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),
Quaternions marked this conversation as resolved Outdated

Honestly looking at this more I'm not sure why this http client struct is being pre-created instead of just being created in the one function that uses it.

Honestly looking at this more I'm not sure why this http client struct is being pre-created instead of just being created in the one function that uses it.
api.WithPort(ctx.Int("port")),
Quaternions marked this conversation as resolved Outdated

This is 10 nano seconds

10 * time.Second
This is 10 nano seconds ```go 10 * time.Second ```
api.WithDevClient(devConn),
api.WithDataClient(dataConn),
api.WithStorageUrl(storageUrl),
)
}
Quaternions marked this conversation as resolved Outdated

Set a timeout when making the client

Set a timeout when making the client