Public API (#253)
Closes #229 This is a MVP and only includes maps. Reviewed-on: #253 Reviewed-by: itzaname <itzaname@noreply@itzana.me> Co-authored-by: Rhys Lloyd <krakow20@gmail.com> Co-committed-by: Rhys Lloyd <krakow20@gmail.com>
This commit is contained in:
57
pkg/cmds/api.go
Normal file
57
pkg/cmds/api.go
Normal file
@@ -0,0 +1,57 @@
|
||||
package cmds
|
||||
|
||||
import (
|
||||
"git.itzana.me/strafesnet/maps-service/pkg/public_api"
|
||||
"github.com/urfave/cli/v2"
|
||||
"google.golang.org/grpc"
|
||||
"google.golang.org/grpc/credentials/insecure"
|
||||
)
|
||||
|
||||
func NewApiCommand() *cli.Command {
|
||||
return &cli.Command{
|
||||
Name: "api",
|
||||
Usage: "Run api service",
|
||||
Action: runAPI,
|
||||
Flags: []cli.Flag{
|
||||
&cli.IntFlag{
|
||||
Name: "port",
|
||||
Usage: "Listen port",
|
||||
EnvVars: []string{"PORT"},
|
||||
Value: 8080,
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "dev-rpc-host",
|
||||
Usage: "Host of dev rpc",
|
||||
EnvVars: []string{"DEV_RPC_HOST"},
|
||||
Value: "dev-service:8081",
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "maps-rpc-host",
|
||||
Usage: "Host of maps rpc",
|
||||
EnvVars: []string{"MAPS_RPC_HOST"},
|
||||
Value: "maptest-api:8081",
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func runAPI(ctx *cli.Context) error {
|
||||
// Dev service client
|
||||
devConn, err := grpc.Dial(ctx.String("dev-rpc-host"), grpc.WithTransportCredentials(insecure.NewCredentials()))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Data service client
|
||||
mapsConn, err := grpc.Dial(ctx.String("maps-rpc-host"), grpc.WithTransportCredentials(insecure.NewCredentials()))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return api.NewRouter(
|
||||
api.WithContext(ctx),
|
||||
api.WithPort(ctx.Int("port")),
|
||||
api.WithDevClient(devConn),
|
||||
api.WithMapsClient(mapsConn),
|
||||
)
|
||||
}
|
||||
47
pkg/public_api/dto/map.go
Normal file
47
pkg/public_api/dto/map.go
Normal file
@@ -0,0 +1,47 @@
|
||||
package dto
|
||||
|
||||
import (
|
||||
"git.itzana.me/strafesnet/go-grpc/maps_extended"
|
||||
"time"
|
||||
)
|
||||
|
||||
type MapFilter struct {
|
||||
GameID *uint32 `json:"game_id" form:"game_id"`
|
||||
} // @name MapFilter
|
||||
|
||||
type Map struct {
|
||||
ID int64 `json:"id"`
|
||||
DisplayName string `json:"display_name"`
|
||||
Creator string `json:"creator"`
|
||||
GameID uint32 `json:"game_id"`
|
||||
Date time.Time `json:"date"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
Submitter uint64 `json:"submitter"`
|
||||
Thumbnail uint64 `json:"thumbnail"`
|
||||
AssetVersion uint64 `json:"asset_version"`
|
||||
LoadCount uint32 `json:"load_count"`
|
||||
Modes uint32 `json:"modes"`
|
||||
} // @name Map
|
||||
|
||||
// FromGRPC converts a maps.MapResponse protobuf message to a Map domain object
|
||||
func (m *Map) FromGRPC(resp *maps_extended.MapResponse) *Map {
|
||||
if resp == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
m.ID = resp.ID
|
||||
m.DisplayName = resp.DisplayName
|
||||
m.Creator = resp.Creator
|
||||
m.Date = time.Unix(resp.Date, 0)
|
||||
m.GameID = resp.GameID
|
||||
m.CreatedAt = time.Unix(resp.CreatedAt, 0)
|
||||
m.UpdatedAt = time.Unix(resp.UpdatedAt, 0)
|
||||
m.Submitter = resp.Submitter
|
||||
m.Thumbnail = resp.Thumbnail
|
||||
m.AssetVersion = resp.AssetVersion
|
||||
m.LoadCount = resp.LoadCount
|
||||
m.Modes = resp.Modes
|
||||
|
||||
return m
|
||||
}
|
||||
52
pkg/public_api/dto/response.go
Normal file
52
pkg/public_api/dto/response.go
Normal file
@@ -0,0 +1,52 @@
|
||||
package dto
|
||||
|
||||
// @Description Generic response
|
||||
type Response[T any] struct {
|
||||
// Data contains the actual response payload
|
||||
Data T `json:"data"`
|
||||
} // @name Response
|
||||
|
||||
type PagedTotalResponse[T any] struct {
|
||||
// Data contains the actual response payload
|
||||
Data []T `json:"data"`
|
||||
|
||||
// Pagination contains information about paging
|
||||
Pagination PaginationWithTotal `json:"pagination"`
|
||||
} // @name PagedTotalResponse
|
||||
|
||||
// PaginationWithTotal holds information about the current page, total items, etc.
|
||||
type PaginationWithTotal struct {
|
||||
// Current page number
|
||||
Page int `json:"page"`
|
||||
|
||||
// Number of items per page
|
||||
PageSize int `json:"page_size"`
|
||||
|
||||
// Total number of items across all pages
|
||||
TotalItems int `json:"total_items"`
|
||||
|
||||
// Total number of pages
|
||||
TotalPages int `json:"total_pages"`
|
||||
} // @name PaginationWithTotal
|
||||
|
||||
type PagedResponse[T any] struct {
|
||||
// Data contains the actual response payload
|
||||
Data []T `json:"data"`
|
||||
|
||||
// Pagination contains information about paging
|
||||
Pagination Pagination `json:"pagination"`
|
||||
} // @name PagedResponse
|
||||
|
||||
// Pagination holds information about the current page.
|
||||
type Pagination struct {
|
||||
// Current page number
|
||||
Page int `json:"page"`
|
||||
|
||||
// Number of items per page
|
||||
PageSize int `json:"page_size"`
|
||||
} // @name Pagination
|
||||
|
||||
// Error holds error responses
|
||||
type Error struct {
|
||||
Error string `json:"error"`
|
||||
} // @name Error
|
||||
98
pkg/public_api/handlers/handler.go
Normal file
98
pkg/public_api/handlers/handler.go
Normal file
@@ -0,0 +1,98 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"github.com/gin-gonic/gin"
|
||||
"google.golang.org/grpc"
|
||||
"strconv"
|
||||
)
|
||||
|
||||
const (
|
||||
ErrMsgDataClient = "data client is required"
|
||||
)
|
||||
|
||||
// Handler is a base handler that provides common functionality for all HTTP handlers.
|
||||
type Handler struct {
|
||||
mapsClient *grpc.ClientConn
|
||||
}
|
||||
|
||||
// HandlerOption defines a functional option for configuring a Handler
|
||||
type HandlerOption func(*Handler)
|
||||
|
||||
// WithMapsClient sets the data client for the Handler
|
||||
func WithMapsClient(mapsClient *grpc.ClientConn) HandlerOption {
|
||||
return func(h *Handler) {
|
||||
h.mapsClient = mapsClient
|
||||
}
|
||||
}
|
||||
|
||||
// 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) {
|
||||
handler := &Handler{}
|
||||
|
||||
// Apply all provided options
|
||||
for _, option := range options {
|
||||
option(handler)
|
||||
}
|
||||
|
||||
// Validate required dependencies
|
||||
if err := handler.validateDependencies(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return handler, nil
|
||||
}
|
||||
|
||||
// validateDependencies ensures all required dependencies are properly set
|
||||
func (h *Handler) validateDependencies() error {
|
||||
if h.mapsClient == nil {
|
||||
return fmt.Errorf(ErrMsgDataClient)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// validateRange ensures a value is within the specified range, returning defaultValue if outside
|
||||
func validateRange(value, min, max, defaultValue int) int {
|
||||
if value < min {
|
||||
return defaultValue
|
||||
}
|
||||
if value > max {
|
||||
return max
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
// validateMin ensures a value is at least the minimum, returning defaultValue if below
|
||||
func validateMin(value, min, defaultValue int) int {
|
||||
if value < min {
|
||||
return defaultValue
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
// getPagination extracts pagination parameters from query string.
|
||||
// It applies validation rules to ensure parameters are within acceptable ranges.
|
||||
func getPagination(ctx *gin.Context, defaultPageSize, minPageSize, maxPageSize int) (pageSize, pageNumber int) {
|
||||
// Get page size from query string, parse to integer
|
||||
pageSizeStr := ctx.Query("page_size")
|
||||
if pageSizeStr != "" {
|
||||
pageSize, _ = strconv.Atoi(pageSizeStr)
|
||||
} else {
|
||||
pageSize = defaultPageSize
|
||||
}
|
||||
|
||||
// Get page number from query string, parse to integer
|
||||
pageNumberStr := ctx.Query("page_number")
|
||||
if pageNumberStr != "" {
|
||||
pageNumber, _ = strconv.Atoi(pageNumberStr)
|
||||
} else {
|
||||
pageNumber = 1 // Default to first page
|
||||
}
|
||||
|
||||
// Apply validation rules
|
||||
pageSize = validateRange(pageSize, minPageSize, maxPageSize, defaultPageSize)
|
||||
pageNumber = validateMin(pageNumber, 1, 1)
|
||||
|
||||
return pageSize, pageNumber
|
||||
}
|
||||
153
pkg/public_api/handlers/maps.go
Normal file
153
pkg/public_api/handlers/maps.go
Normal file
@@ -0,0 +1,153 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"git.itzana.me/strafesnet/go-grpc/maps_extended"
|
||||
"git.itzana.me/strafesnet/maps-service/pkg/public_api/dto"
|
||||
"github.com/gin-gonic/gin"
|
||||
log "github.com/sirupsen/logrus"
|
||||
"google.golang.org/grpc/codes"
|
||||
"google.golang.org/grpc/status"
|
||||
"net/http"
|
||||
"strconv"
|
||||
)
|
||||
|
||||
// MapHandler handles HTTP requests related to maps.
|
||||
type MapHandler struct {
|
||||
*Handler
|
||||
}
|
||||
|
||||
// NewMapHandler creates a new MapHandler with the provided options.
|
||||
func NewMapHandler(options ...HandlerOption) (*MapHandler, error) {
|
||||
baseHandler, err := NewHandler(options...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &MapHandler{
|
||||
Handler: baseHandler,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// @Summary Get map by ID
|
||||
// @Description Get a specific map by its ID
|
||||
// @Tags maps
|
||||
// @Produce json
|
||||
// @Security ApiKeyAuth
|
||||
// @Param id path int true "Map ID"
|
||||
// @Success 200 {object} dto.Response[dto.Map]
|
||||
// @Failure 404 {object} dto.Error "Map not found"
|
||||
// @Failure default {object} dto.Error "General error response"
|
||||
// @Router /map/{id} [get]
|
||||
func (h *MapHandler) Get(ctx *gin.Context) {
|
||||
// Extract map ID from path parameter
|
||||
id := ctx.Param("id")
|
||||
mapID, err := strconv.ParseInt(id, 10, 64)
|
||||
if err != nil {
|
||||
ctx.JSON(http.StatusBadRequest, dto.Error{
|
||||
Error: "Invalid map ID format",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Call the gRPC service
|
||||
mapData, err := maps_extended.NewMapsServiceClient(h.mapsClient).Get(ctx, &maps_extended.MapId{
|
||||
ID: mapID,
|
||||
})
|
||||
if err != nil {
|
||||
statusCode := http.StatusInternalServerError
|
||||
errorMessage := "Failed to get map"
|
||||
|
||||
// Check if it's a "not found" error
|
||||
if status.Code(err) == codes.NotFound {
|
||||
statusCode = http.StatusNotFound
|
||||
errorMessage = "Map not found"
|
||||
}
|
||||
|
||||
ctx.JSON(statusCode, dto.Error{
|
||||
Error: errorMessage,
|
||||
})
|
||||
log.WithError(err).Error(
|
||||
"Failed to get map",
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
// Convert gRPC MapResponse object to dto.Map object
|
||||
var mapDto dto.Map
|
||||
result := mapDto.FromGRPC(mapData)
|
||||
|
||||
// Return the map data
|
||||
ctx.JSON(http.StatusOK, dto.Response[dto.Map]{
|
||||
Data: *result,
|
||||
})
|
||||
}
|
||||
|
||||
// @Summary List maps
|
||||
// @Description Get a list of maps
|
||||
// @Tags maps
|
||||
// @Produce json
|
||||
// @Security ApiKeyAuth
|
||||
// @Param page_size query int false "Page size (max 100)" default(10) minimum(1) maximum(100)
|
||||
// @Param page_number query int false "Page number" default(1) minimum(1)
|
||||
// @Param filter query dto.MapFilter false "Map filter parameters"
|
||||
// @Success 200 {object} dto.PagedResponse[dto.Map]
|
||||
// @Failure default {object} dto.Error "General error response"
|
||||
// @Router /map [get]
|
||||
func (h *MapHandler) List(ctx *gin.Context) {
|
||||
// Extract and constrain pagination parameters
|
||||
query := struct {
|
||||
PageSize int `form:"page_size,default=10" binding:"min=1,max=100"`
|
||||
PageNumber int `form:"page_number,default=1" binding:"min=1"`
|
||||
SortBy int `form:"sort_by,default=0" binding:"min=0,max=3"`
|
||||
}{}
|
||||
if err := ctx.ShouldBindQuery(&query); err != nil {
|
||||
ctx.JSON(http.StatusBadRequest, dto.Error{
|
||||
Error: err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Get list filter
|
||||
var filter dto.MapFilter
|
||||
if err := ctx.ShouldBindQuery(&filter); err != nil {
|
||||
ctx.JSON(http.StatusBadRequest, dto.Error{
|
||||
Error: err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Call the gRPC service
|
||||
mapList, err := maps_extended.NewMapsServiceClient(h.mapsClient).List(ctx, &maps_extended.ListRequest{
|
||||
Filter: &maps_extended.MapFilter{
|
||||
GameID: filter.GameID,
|
||||
},
|
||||
Page: &maps_extended.Pagination{
|
||||
Size: uint32(query.PageSize),
|
||||
Number: uint32(query.PageNumber),
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
ctx.JSON(http.StatusInternalServerError, dto.Error{
|
||||
Error: "Failed to list maps",
|
||||
})
|
||||
log.WithError(err).Error(
|
||||
"Failed to list maps",
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
// Convert gRPC MapResponse objects to dto.Map objects
|
||||
dtoMaps := make([]dto.Map, len(mapList.Maps))
|
||||
for i, m := range mapList.Maps {
|
||||
var mapDto dto.Map
|
||||
dtoMaps[i] = *mapDto.FromGRPC(m)
|
||||
}
|
||||
|
||||
// Return the paged response
|
||||
ctx.JSON(http.StatusOK, dto.PagedResponse[dto.Map]{
|
||||
Data: dtoMaps,
|
||||
Pagination: dto.Pagination{
|
||||
Page: query.PageNumber,
|
||||
PageSize: query.PageSize,
|
||||
},
|
||||
})
|
||||
}
|
||||
159
pkg/public_api/router.go
Normal file
159
pkg/public_api/router.go
Normal file
@@ -0,0 +1,159 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"git.itzana.me/StrafesNET/dev-service/pkg/api/middleware"
|
||||
"git.itzana.me/strafesnet/maps-service/docs"
|
||||
"git.itzana.me/strafesnet/maps-service/pkg/public_api/handlers"
|
||||
"github.com/gin-gonic/gin"
|
||||
log "github.com/sirupsen/logrus"
|
||||
swaggerfiles "github.com/swaggo/files"
|
||||
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
|
||||
type Option func(*RouterConfig)
|
||||
|
||||
// RouterConfig holds all router configuration
|
||||
type RouterConfig struct {
|
||||
port int
|
||||
devClient *grpc.ClientConn
|
||||
mapsClient *grpc.ClientConn
|
||||
context *cli.Context
|
||||
shutdownTimeout time.Duration
|
||||
}
|
||||
|
||||
// WithPort sets the port for the server£
|
||||
func WithPort(port int) Option {
|
||||
return func(cfg *RouterConfig) {
|
||||
cfg.port = port
|
||||
}
|
||||
}
|
||||
|
||||
// WithContext sets the context for the server
|
||||
func WithContext(ctx *cli.Context) Option {
|
||||
return func(cfg *RouterConfig) {
|
||||
cfg.context = ctx
|
||||
}
|
||||
}
|
||||
|
||||
// WithDevClient sets the dev gRPC client
|
||||
func WithDevClient(conn *grpc.ClientConn) Option {
|
||||
return func(cfg *RouterConfig) {
|
||||
cfg.devClient = conn
|
||||
}
|
||||
}
|
||||
|
||||
// WithMapsClient sets the data gRPC client
|
||||
func WithMapsClient(conn *grpc.ClientConn) Option {
|
||||
return func(cfg *RouterConfig) {
|
||||
cfg.mapsClient = conn
|
||||
}
|
||||
}
|
||||
|
||||
// WithShutdownTimeout sets the graceful shutdown timeout
|
||||
func WithShutdownTimeout(timeout time.Duration) Option {
|
||||
return func(cfg *RouterConfig) {
|
||||
cfg.shutdownTimeout = timeout
|
||||
}
|
||||
}
|
||||
|
||||
func setupRoutes(cfg *RouterConfig) (*gin.Engine, error) {
|
||||
r := gin.Default()
|
||||
r.ForwardedByClientIP = true
|
||||
r.Use(gin.Logger())
|
||||
r.Use(gin.Recovery())
|
||||
|
||||
handlerOptions := []handlers.HandlerOption{
|
||||
handlers.WithMapsClient(cfg.mapsClient),
|
||||
}
|
||||
|
||||
// Maps handler
|
||||
mapsHandler, err := handlers.NewMapHandler(handlerOptions...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
docs.SwaggerInfo.BasePath = "/public-api/v1"
|
||||
public_api := r.Group("/public-api")
|
||||
{
|
||||
v1 := public_api.Group("/v1")
|
||||
{
|
||||
// Auth middleware
|
||||
v1.Use(middleware.ValidateRequest("Maps", "Read", cfg.devClient))
|
||||
|
||||
// Maps
|
||||
v1.GET("/map", mapsHandler.List)
|
||||
v1.GET("/map/:id", mapsHandler.Get)
|
||||
}
|
||||
|
||||
// Docs
|
||||
public_api.GET("/docs/*any", ginSwagger.WrapHandler(swaggerfiles.Handler))
|
||||
public_api.GET("/", func(ctx *gin.Context) {
|
||||
ctx.Redirect(http.StatusPermanentRedirect, "/docs/index.html")
|
||||
})
|
||||
}
|
||||
|
||||
return r, nil
|
||||
}
|
||||
|
||||
// NewRouter creates a new router with the given options
|
||||
func NewRouter(options ...Option) error {
|
||||
// Default configuration
|
||||
cfg := &RouterConfig{
|
||||
port: 8080, // Default port
|
||||
context: nil,
|
||||
shutdownTimeout: 5 * time.Second,
|
||||
}
|
||||
|
||||
// Apply options
|
||||
for _, option := range options {
|
||||
option(cfg)
|
||||
}
|
||||
|
||||
// Validate configuration
|
||||
if cfg.context == nil {
|
||||
return errors.New("context is required")
|
||||
}
|
||||
|
||||
if cfg.devClient == nil {
|
||||
return errors.New("dev client is required")
|
||||
}
|
||||
|
||||
routes, err := setupRoutes(cfg)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
log.Info("Starting server")
|
||||
|
||||
return runServer(cfg.context.Context, fmt.Sprint(":", cfg.port), routes, cfg.shutdownTimeout)
|
||||
}
|
||||
|
||||
func runServer(ctx context.Context, addr string, r *gin.Engine, shutdownTimeout time.Duration) error {
|
||||
srv := &http.Server{
|
||||
Addr: addr,
|
||||
Handler: r,
|
||||
}
|
||||
|
||||
// Run the server in a separate goroutine
|
||||
go func() {
|
||||
if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
|
||||
log.WithError(err).Fatal("web server exit")
|
||||
}
|
||||
}()
|
||||
|
||||
// Wait for a shutdown signal
|
||||
<-ctx.Done()
|
||||
|
||||
// Shutdown server gracefully
|
||||
ctxShutdown, cancel := context.WithTimeout(context.Background(), shutdownTimeout)
|
||||
defer cancel()
|
||||
return srv.Shutdown(ctxShutdown)
|
||||
}
|
||||
Reference in New Issue
Block a user