mirror of
https://github.com/IS1DI/x0gp.git
synced 2026-07-16 20:47:55 +00:00
feat(server): initial implementation
This commit captures the full server code accumulated across
several development sessions. It includes the following layers,
applied in roughly this order during development:
1. Base infrastructure
- cmd/poc-server HTTP+WebSocket server, /health, /stats
- internal/transport wire types (Envelope + all message types)
- internal/catalog, internal/realtime, internal/stats
- internal/storage/postgres with migrations 001-004
- .env, .env.example, docker-compose, Dockerfile, scripts/
2. Swagger documentation
- go get github.com/swaggo/http-swagger
- swag init produces docs/docs.go
- /swagger/index.html UI, /swagger/doc.json spec
- annotations on health/stats/ws and catalog handlers
3. Races API
- internal/races/{store,service,keyset,types}.go
- migration 005_races.sql: finished_races, race_plans, race_queue
- GET /api/races with keyset pagination, status filter
- GET /api/races/upcoming
- POST /api/races/queue/join, GET, DELETE
- POST/GET /api/races/plans, DELETE /{id}
- lobby.RaceMeta simplification (no host_id/host_name)
4. Races seeder
- internal/races/seed with deterministic generator
- --seed-races / --reset CLI flags in main.go
- 30 finished + 5 live + 5 plans + 4 queue entries
5. Drivers and clans
- internal/drivers, internal/clans (CRUD, validation)
- migration 008_drivers_clans.sql
- /api/clans and /api/drivers endpoints
- 3-letter uppercase nickname/tag validation
- lobby.DriverMeta: Nickname, AvatarURL, ClanID, ClanTag
6. Podium
- internal/transport.RacePodiumEntry
- lobby.SetDriverProfile for in-memory metadata sync
- migration 007_podium.sql (podium JSONB column)
- seeder populates top-3 per finished race
7. Live races persistence
- migration 009_live_persistence.sql: live_races,
live_race_drivers, lobby_drivers
- internal/races/live_store.go: LiveStore with write-side
mirror for lobby.Service mutations
- Service.collectLive and Upcoming read from Postgres
- main.go RestoreFromDB rehydrates lobby on startup
8. Unify live + finished into one races table
- migration 010_unify_races.sql: rename finished_races to
races, expand status CHECK, merge live rows
- PgStore now hosts both write paths; LiveStore is a
thin facade implementing lobby.Persistence
- seeder resetAll drops only finished/cancelled rows and
race_plans / race_queue / lobby_drivers
Each layer is consistent on its own; cross-layer changes are
visible in the file history. A future refactor may split this
commit into the per-stage boundaries listed above.
This commit is contained in:
@@ -0,0 +1,736 @@
|
||||
// HTTP handlers for the catalog (tracks + cars).
|
||||
//
|
||||
// All HTTP routes live under /api/*:
|
||||
// GET /api/tracks list tracks (optional ?scope=system|public|private&tag=)
|
||||
// GET /api/tracks/{id} get a single track
|
||||
// POST /api/tracks create a track
|
||||
// PUT /api/tracks/{id} update a track
|
||||
// DELETE /api/tracks/{id} delete a track
|
||||
// GET /api/cars list cars (optional ?scope=...&owner_id=...)
|
||||
// GET /api/cars/{id} get a single car
|
||||
// POST /api/cars create a car
|
||||
// PUT /api/cars/{id} update a car
|
||||
// DELETE /api/cars/{id} delete a car
|
||||
// GET /api/catalog combined snapshot + summary
|
||||
//
|
||||
// WebSocket access is wired up directly in main.go's readPump switch.
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/x0gp/server/internal/catalog"
|
||||
"github.com/x0gp/server/internal/transport"
|
||||
)
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Wire <-> internal conversion
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func waypointToWire(w catalog.Waypoint) transport.TrackWaypoint {
|
||||
return transport.TrackWaypoint{
|
||||
X: w.X,
|
||||
Y: w.Y,
|
||||
HeadingRad: w.HeadingRad,
|
||||
SpeedMs: w.SpeedMs,
|
||||
Curvature: w.Curvature,
|
||||
}
|
||||
}
|
||||
|
||||
func waypointsToWire(in []catalog.Waypoint) []transport.TrackWaypoint {
|
||||
if len(in) == 0 {
|
||||
return nil
|
||||
}
|
||||
out := make([]transport.TrackWaypoint, len(in))
|
||||
for i, w := range in {
|
||||
out[i] = waypointToWire(w)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func waypointsFromWire(in []transport.TrackWaypoint) []catalog.Waypoint {
|
||||
if len(in) == 0 {
|
||||
return nil
|
||||
}
|
||||
out := make([]catalog.Waypoint, len(in))
|
||||
for i, w := range in {
|
||||
out[i] = catalog.Waypoint{
|
||||
X: w.X, Y: w.Y, HeadingRad: w.HeadingRad,
|
||||
SpeedMs: w.SpeedMs, Curvature: w.Curvature,
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func boundsToWire(b catalog.Bounds) transport.TrackBounds {
|
||||
return transport.TrackBounds{
|
||||
MinX: b.MinX, MinY: b.MinY, MaxX: b.MaxX, MaxY: b.MaxY,
|
||||
LengthM: b.LengthM, WidthM: b.WidthM,
|
||||
}
|
||||
}
|
||||
|
||||
func trackToWire(t catalog.TrackMeta) transport.TrackWire {
|
||||
return transport.TrackWire{
|
||||
ID: t.ID,
|
||||
Name: t.Name,
|
||||
Description: t.Description,
|
||||
AuthorID: t.AuthorID,
|
||||
Visibility: string(t.Visibility),
|
||||
Scale: t.Scale,
|
||||
LengthM: t.LengthM,
|
||||
WidthM: t.WidthM,
|
||||
LaneWidthM: t.LaneWidthM,
|
||||
Bounds: boundsToWire(t.Bounds),
|
||||
Surface: string(t.Surface),
|
||||
Tags: t.Tags,
|
||||
Centerline: waypointsToWire(t.Centerline),
|
||||
BestLapMs: t.BestLapMs,
|
||||
BestLapHolder: t.BestLapHolder,
|
||||
CreatedMs: t.CreatedMs,
|
||||
UpdatedMs: t.UpdatedMs,
|
||||
}
|
||||
}
|
||||
|
||||
func tracksToWire(in []catalog.TrackMeta) []transport.TrackWire {
|
||||
if len(in) == 0 {
|
||||
return []transport.TrackWire{}
|
||||
}
|
||||
out := make([]transport.TrackWire, len(in))
|
||||
for i, t := range in {
|
||||
out[i] = trackToWire(t)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func trackFromWire(w transport.TrackWire) catalog.TrackMeta {
|
||||
return catalog.TrackMeta{
|
||||
ID: w.ID,
|
||||
Name: w.Name,
|
||||
Description: w.Description,
|
||||
AuthorID: w.AuthorID,
|
||||
Visibility: catalog.Visibility(w.Visibility),
|
||||
Scale: w.Scale,
|
||||
LengthM: w.LengthM,
|
||||
WidthM: w.WidthM,
|
||||
LaneWidthM: w.LaneWidthM,
|
||||
Bounds: catalog.Bounds{
|
||||
MinX: w.Bounds.MinX, MinY: w.Bounds.MinY,
|
||||
MaxX: w.Bounds.MaxX, MaxY: w.Bounds.MaxY,
|
||||
LengthM: w.Bounds.LengthM, WidthM: w.Bounds.WidthM,
|
||||
},
|
||||
Surface: catalog.Surface(w.Surface),
|
||||
Tags: w.Tags,
|
||||
Centerline: waypointsFromWire(w.Centerline),
|
||||
BestLapMs: w.BestLapMs,
|
||||
BestLapHolder: w.BestLapHolder,
|
||||
CreatedMs: w.CreatedMs,
|
||||
UpdatedMs: w.UpdatedMs,
|
||||
}
|
||||
}
|
||||
|
||||
func chassisToWire(c catalog.ChassisSpec) transport.ChassisWire {
|
||||
return transport.ChassisWire{
|
||||
Model: c.Model, Material: c.Material, Printed: c.Printed,
|
||||
WheelbaseMm: c.WheelbaseMm, TrackMm: c.TrackMm,
|
||||
}
|
||||
}
|
||||
|
||||
func motorToWire(m catalog.MotorSpec) transport.MotorWire {
|
||||
return transport.MotorWire{
|
||||
Kind: m.Kind, Class: m.Class, KV: m.KV, PowerW: m.PowerW,
|
||||
}
|
||||
}
|
||||
|
||||
func batteryToWire(b catalog.BatterySpec) transport.BatteryWire {
|
||||
return transport.BatteryWire{
|
||||
VoltageV: b.VoltageV, CapacityMah: b.CapacityMah,
|
||||
Cells: b.Cells, Chemistry: b.Chemistry,
|
||||
}
|
||||
}
|
||||
|
||||
func carToWire(c catalog.CarMeta) transport.CarWire {
|
||||
return transport.CarWire{
|
||||
ID: c.ID,
|
||||
Name: c.Name,
|
||||
OwnerID: c.OwnerID,
|
||||
Visibility: string(c.Visibility),
|
||||
Scale: c.Scale,
|
||||
LengthMm: c.LengthMm,
|
||||
WidthMm: c.WidthMm,
|
||||
HeightMm: c.HeightMm,
|
||||
WeightG: c.WeightG,
|
||||
Chassis: chassisToWire(c.Chassis),
|
||||
Motor: motorToWire(c.Motor),
|
||||
Battery: batteryToWire(c.Battery),
|
||||
Drive: string(c.Drive),
|
||||
TopSpeedMs: c.TopSpeedMs,
|
||||
ColorHex: c.ColorHex,
|
||||
AvatarURL: c.AvatarURL,
|
||||
Active: c.Active,
|
||||
// Stats are read-mostly; the server fills them on read.
|
||||
TotalDistanceM: c.TotalDistanceM,
|
||||
TotalRaces: c.TotalRaces,
|
||||
TotalLaps: c.TotalLaps,
|
||||
BestLapMs: c.BestLapMs,
|
||||
CreatedMs: c.CreatedMs,
|
||||
UpdatedMs: c.UpdatedMs,
|
||||
}
|
||||
}
|
||||
|
||||
func carsToWire(in []catalog.CarMeta) []transport.CarWire {
|
||||
if len(in) == 0 {
|
||||
return []transport.CarWire{}
|
||||
}
|
||||
out := make([]transport.CarWire, len(in))
|
||||
for i, c := range in {
|
||||
out[i] = carToWire(c)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func carFromWire(w transport.CarWire) catalog.CarMeta {
|
||||
return catalog.CarMeta{
|
||||
ID: w.ID,
|
||||
Name: w.Name,
|
||||
OwnerID: w.OwnerID,
|
||||
Visibility: catalog.Visibility(w.Visibility),
|
||||
Scale: w.Scale,
|
||||
LengthMm: w.LengthMm,
|
||||
WidthMm: w.WidthMm,
|
||||
HeightMm: w.HeightMm,
|
||||
WeightG: w.WeightG,
|
||||
Chassis: catalog.ChassisSpec{
|
||||
Model: w.Chassis.Model, Material: w.Chassis.Material,
|
||||
Printed: w.Chassis.Printed, WheelbaseMm: w.Chassis.WheelbaseMm,
|
||||
TrackMm: w.Chassis.TrackMm,
|
||||
},
|
||||
Motor: catalog.MotorSpec{
|
||||
Kind: w.Motor.Kind, Class: w.Motor.Class,
|
||||
KV: w.Motor.KV, PowerW: w.Motor.PowerW,
|
||||
},
|
||||
Battery: catalog.BatterySpec{
|
||||
VoltageV: w.Battery.VoltageV, CapacityMah: w.Battery.CapacityMah,
|
||||
Cells: w.Battery.Cells, Chemistry: w.Battery.Chemistry,
|
||||
},
|
||||
Drive: catalog.Drivetrain(w.Drive),
|
||||
TopSpeedMs: w.TopSpeedMs,
|
||||
ColorHex: w.ColorHex,
|
||||
AvatarURL: w.AvatarURL,
|
||||
Active: w.Active,
|
||||
CreatedMs: w.CreatedMs,
|
||||
UpdatedMs: w.UpdatedMs,
|
||||
}
|
||||
}
|
||||
|
||||
func summaryToWire(s catalog.Stats) transport.CatalogSummary {
|
||||
return transport.CatalogSummary{
|
||||
TracksTotal: s.TracksTotal,
|
||||
TracksSystem: s.TracksSystem,
|
||||
TracksPublic: s.TracksPublic,
|
||||
TracksPrivate: s.TracksPrivate,
|
||||
CarsTotal: s.CarsTotal,
|
||||
CarsSystem: s.CarsSystem,
|
||||
CarsPublic: s.CarsPublic,
|
||||
CarsPrivate: s.CarsPrivate,
|
||||
CarsActive: s.CarsActive,
|
||||
TSMs: time.Now().UnixMilli(),
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Patch helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func trackPatchFromWire(p transport.TrackPatch) catalog.UpdateTrackOptions {
|
||||
return catalog.UpdateTrackOptions{
|
||||
Name: p.Name,
|
||||
Description: p.Description,
|
||||
Visibility: visPtr(p.Visibility),
|
||||
LengthM: p.LengthM,
|
||||
WidthM: p.WidthM,
|
||||
LaneWidthM: p.LaneWidthM,
|
||||
Surface: surfacePtr(p.Surface),
|
||||
Tags: p.Tags,
|
||||
Centerline: waypointsFromWirePtr(p.Centerline),
|
||||
}
|
||||
}
|
||||
|
||||
func carPatchFromWire(p transport.CarPatch) catalog.UpdateCarOptions {
|
||||
return catalog.UpdateCarOptions{
|
||||
Name: p.Name,
|
||||
Visibility: visPtr(p.Visibility),
|
||||
LengthMm: p.LengthMm,
|
||||
WidthMm: p.WidthMm,
|
||||
HeightMm: p.HeightMm,
|
||||
WeightG: p.WeightG,
|
||||
Drive: drivePtr(p.Drive),
|
||||
TopSpeedMs: p.TopSpeedMs,
|
||||
ColorHex: p.ColorHex,
|
||||
Active: p.Active,
|
||||
Chassis: chassisFromPtr(p.Chassis),
|
||||
Motor: motorFromPtr(p.Motor),
|
||||
Battery: batteryFromPtr(p.Battery),
|
||||
}
|
||||
}
|
||||
|
||||
func visPtr(p *string) *catalog.Visibility {
|
||||
if p == nil {
|
||||
return nil
|
||||
}
|
||||
v := catalog.Visibility(*p)
|
||||
return &v
|
||||
}
|
||||
|
||||
func surfacePtr(p *string) *catalog.Surface {
|
||||
if p == nil {
|
||||
return nil
|
||||
}
|
||||
v := catalog.Surface(*p)
|
||||
return &v
|
||||
}
|
||||
|
||||
func drivePtr(p *string) *catalog.Drivetrain {
|
||||
if p == nil {
|
||||
return nil
|
||||
}
|
||||
v := catalog.Drivetrain(*p)
|
||||
return &v
|
||||
}
|
||||
|
||||
func waypointsFromWirePtr(p *[]transport.TrackWaypoint) *[]catalog.Waypoint {
|
||||
if p == nil {
|
||||
return nil
|
||||
}
|
||||
out := waypointsFromWire(*p)
|
||||
return &out
|
||||
}
|
||||
|
||||
func chassisFromPtr(p *transport.ChassisWire) *catalog.ChassisSpec {
|
||||
if p == nil {
|
||||
return nil
|
||||
}
|
||||
return &catalog.ChassisSpec{
|
||||
Model: p.Model, Material: p.Material, Printed: p.Printed,
|
||||
WheelbaseMm: p.WheelbaseMm, TrackMm: p.TrackMm,
|
||||
}
|
||||
}
|
||||
|
||||
func motorFromPtr(p *transport.MotorWire) *catalog.MotorSpec {
|
||||
if p == nil {
|
||||
return nil
|
||||
}
|
||||
return &catalog.MotorSpec{
|
||||
Kind: p.Kind, Class: p.Class, KV: p.KV, PowerW: p.PowerW,
|
||||
}
|
||||
}
|
||||
|
||||
func batteryFromPtr(p *transport.BatteryWire) *catalog.BatterySpec {
|
||||
if p == nil {
|
||||
return nil
|
||||
}
|
||||
return &catalog.BatterySpec{
|
||||
VoltageV: p.VoltageV, CapacityMah: p.CapacityMah,
|
||||
Cells: p.Cells, Chemistry: p.Chemistry,
|
||||
}
|
||||
}
|
||||
|
||||
func strFromPtr(p *string) string {
|
||||
if p == nil {
|
||||
return ""
|
||||
}
|
||||
return *p
|
||||
}
|
||||
|
||||
func filterTracksByTag(in []catalog.TrackMeta, tag string) []catalog.TrackMeta {
|
||||
out := make([]catalog.TrackMeta, 0, len(in))
|
||||
for _, t := range in {
|
||||
for _, tg := range t.Tags {
|
||||
if strings.EqualFold(tg, tag) {
|
||||
out = append(out, t)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// HTTP handlers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func writeJSON(w http.ResponseWriter, status int, body any) {
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
w.WriteHeader(status)
|
||||
_ = json.NewEncoder(w).Encode(body)
|
||||
}
|
||||
|
||||
func writeError(w http.ResponseWriter, status int, code, msg string) {
|
||||
writeJSON(w, status, map[string]any{
|
||||
"ok": false,
|
||||
"error": msg,
|
||||
"code": code,
|
||||
})
|
||||
}
|
||||
|
||||
func mapCatalogError(err error) (int, string) {
|
||||
switch {
|
||||
case errors.Is(err, catalog.ErrNotFound):
|
||||
return http.StatusNotFound, "not_found"
|
||||
case errors.Is(err, catalog.ErrAlreadyExists):
|
||||
return http.StatusConflict, "already_exists"
|
||||
case errors.Is(err, catalog.ErrInvalidInput):
|
||||
return http.StatusBadRequest, "invalid_input"
|
||||
case errors.Is(err, catalog.ErrImmutableSystem):
|
||||
return http.StatusForbidden, "immutable_system"
|
||||
default:
|
||||
return http.StatusInternalServerError, "internal"
|
||||
}
|
||||
}
|
||||
|
||||
// catalogHandler godoc
|
||||
// @Summary Combined catalog snapshot
|
||||
// @Description Returns the full in-memory catalog: all tracks, all cars, and aggregate counters. This is the cheapest way for a UI to bootstrap its initial state.
|
||||
// @Tags catalog
|
||||
// @Produce json
|
||||
// @Success 200 {object} map[string]interface{} "Snapshot of tracks, cars and summary counters"
|
||||
// @Failure 500 {object} map[string]interface{} "Internal server error"
|
||||
// @Router /api/catalog [get]
|
||||
func catalogHandler(svc *catalog.Service) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, _ *http.Request) {
|
||||
snap := svc.Snapshot()
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"tracks": tracksToWire(snap.Tracks),
|
||||
"cars": carsToWire(snap.Cars),
|
||||
"summary": summaryToWire(svc.Stats()),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// tracksHandler godoc
|
||||
// @Summary List tracks
|
||||
// @Description Returns all tracks visible to the caller. Optional query filters: `scope=system|public|private`, `tag=<string>`.
|
||||
// @Tags tracks
|
||||
// @Produce json
|
||||
// @Param scope query string false "Filter by visibility" Enums(system, public, private)
|
||||
// @Param tag query string false "Filter by tag (case-insensitive)"
|
||||
// @Success 200 {object} map[string]interface{} "List of tracks with count"
|
||||
// @Failure 500 {object} map[string]interface{} "Internal server error"
|
||||
// @Router /api/tracks [get]
|
||||
func tracksHandler(svc *catalog.Service) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
scope := r.URL.Query().Get("scope")
|
||||
tag := r.URL.Query().Get("tag")
|
||||
|
||||
var out []catalog.TrackMeta
|
||||
switch strings.ToLower(scope) {
|
||||
case "system":
|
||||
out = svc.ListTracksByVisibility(catalog.VisibilitySystem)
|
||||
case "public":
|
||||
out = svc.ListTracksByVisibility(catalog.VisibilityPublic)
|
||||
case "private":
|
||||
out = svc.ListTracksByVisibility(catalog.VisibilityPrivate)
|
||||
default:
|
||||
out = svc.ListTracks()
|
||||
}
|
||||
if tag != "" {
|
||||
out = filterTracksByTag(out, tag)
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"tracks": tracksToWire(out),
|
||||
"count": len(out),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// trackByIDHandler godoc
|
||||
// @Summary Get / update / delete a track by id
|
||||
// @Description Dispatches on HTTP method. The id is taken from the URL path (`/api/tracks/{id}`). System tracks are immutable and cannot be updated or deleted.
|
||||
// @Tags tracks
|
||||
// @Produce json
|
||||
// @Consume json
|
||||
// @Param id path string true "Track id"
|
||||
// @Param track body transport.TrackUpdateRequest false "Patch payload (only for PUT)"
|
||||
// @Success 200 {object} transport.TrackWire "Track payload (GET) or update ack (PUT)"
|
||||
// @Success 204 {object} transport.TrackAck "Delete ack"
|
||||
// @Failure 400 {object} map[string]interface{} "Bad request / invalid input"
|
||||
// @Failure 403 {object} map[string]interface{} "System track is immutable"
|
||||
// @Failure 404 {object} map[string]interface{} "Track not found"
|
||||
// @Failure 409 {object} map[string]interface{} "Track id already exists"
|
||||
// @Failure 500 {object} map[string]interface{} "Internal server error"
|
||||
// @Router /api/tracks/{id} [get]
|
||||
// @Router /api/tracks/{id} [put]
|
||||
// @Router /api/tracks/{id} [delete]
|
||||
func trackByIDHandler(svc *catalog.Service) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
id := strings.TrimPrefix(r.URL.Path, "/api/tracks/")
|
||||
if id == "" || strings.Contains(id, "/") {
|
||||
writeError(w, http.StatusBadRequest, "bad_request", "missing track id")
|
||||
return
|
||||
}
|
||||
switch r.Method {
|
||||
case http.MethodGet:
|
||||
t, ok := svc.GetTrack(id)
|
||||
if !ok {
|
||||
writeError(w, http.StatusNotFound, "not_found", "track not found")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, trackToWire(t))
|
||||
case http.MethodPut:
|
||||
var req transport.TrackUpdateRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "bad_request", err.Error())
|
||||
return
|
||||
}
|
||||
if req.ID == "" {
|
||||
req.ID = id
|
||||
}
|
||||
patch := trackPatchFromWire(req.Patch)
|
||||
updated, err := svc.UpdateTrack(r.Context(), req.ID, patch)
|
||||
if err != nil {
|
||||
status, code := mapCatalogError(err)
|
||||
writeError(w, status, code, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, transport.TrackAck{Ok: true, ID: updated.ID, Track: trackToWire(updated)})
|
||||
case http.MethodDelete:
|
||||
if err := svc.DeleteTrack(r.Context(), id); err != nil {
|
||||
status, code := mapCatalogError(err)
|
||||
writeError(w, status, code, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, transport.TrackAck{Ok: true, ID: id})
|
||||
default:
|
||||
w.Header().Set("Allow", "GET PUT DELETE")
|
||||
writeError(w, http.StatusMethodNotAllowed, "method_not_allowed", r.Method)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// createTrackHandler godoc
|
||||
// @Summary Create a track
|
||||
// @Description Creates a new track. The server fills `id` if the client sends an empty one. `author_id` should be the caller's id in production (auth is disabled in PoC).
|
||||
// @Tags tracks
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param track body transport.TrackCreateRequest true "Track to create"
|
||||
// @Success 201 {object} transport.TrackAck "Created"
|
||||
// @Failure 400 {object} map[string]interface{} "Invalid input"
|
||||
// @Failure 409 {object} map[string]interface{} "Track id already exists"
|
||||
// @Failure 500 {object} map[string]interface{} "Internal server error"
|
||||
// @Router /api/tracks [post]
|
||||
func createTrackHandler(svc *catalog.Service) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
var req transport.TrackCreateRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "bad_request", err.Error())
|
||||
return
|
||||
}
|
||||
in := trackFromWire(req.Track)
|
||||
created, err := svc.CreateTrack(r.Context(), catalog.CreateTrackOptions{
|
||||
ID: in.ID,
|
||||
Name: in.Name,
|
||||
Description: in.Description,
|
||||
AuthorID: in.AuthorID,
|
||||
Visibility: in.Visibility,
|
||||
Scale: in.Scale,
|
||||
LengthM: in.LengthM,
|
||||
WidthM: in.WidthM,
|
||||
LaneWidthM: in.LaneWidthM,
|
||||
Surface: in.Surface,
|
||||
Tags: in.Tags,
|
||||
Centerline: in.Centerline,
|
||||
})
|
||||
if err != nil {
|
||||
status, code := mapCatalogError(err)
|
||||
writeError(w, status, code, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusCreated, transport.TrackAck{Ok: true, ID: created.ID, Track: trackToWire(created)})
|
||||
}
|
||||
}
|
||||
|
||||
// carsHandler godoc
|
||||
// @Summary List cars
|
||||
// @Description Returns all cars visible to the caller. Optional query filters: `scope=system|public|private`, `owner_id=<string>`.
|
||||
// @Tags cars
|
||||
// @Produce json
|
||||
// @Param scope query string false "Filter by visibility" Enums(system, public, private)
|
||||
// @Param owner_id query string false "Filter by owner id"
|
||||
// @Success 200 {object} map[string]interface{} "List of cars with count"
|
||||
// @Failure 500 {object} map[string]interface{} "Internal server error"
|
||||
// @Router /api/cars [get]
|
||||
func carsHandler(svc *catalog.Service) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
scope := r.URL.Query().Get("scope")
|
||||
owner := r.URL.Query().Get("owner_id")
|
||||
|
||||
var out []catalog.CarMeta
|
||||
if owner != "" {
|
||||
out = svc.ListCarsByOwner(owner)
|
||||
} else {
|
||||
switch strings.ToLower(scope) {
|
||||
case "system":
|
||||
out = svc.ListCarsByVisibility(catalog.VisibilitySystem)
|
||||
case "public":
|
||||
out = svc.ListCarsByVisibility(catalog.VisibilityPublic)
|
||||
case "private":
|
||||
out = svc.ListCarsByVisibility(catalog.VisibilityPrivate)
|
||||
default:
|
||||
out = svc.ListCars()
|
||||
}
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"cars": carsToWire(out),
|
||||
"count": len(out),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// carByIDHandler godoc
|
||||
// @Summary Get / update / delete a car by id
|
||||
// @Description Dispatches on HTTP method. The id is taken from the URL path (`/api/cars/{id}`). System cars are immutable and cannot be updated or deleted.
|
||||
// @Tags cars
|
||||
// @Produce json
|
||||
// @Consume json
|
||||
// @Param id path string true "Car id"
|
||||
// @Param car body transport.CarUpdateRequest false "Patch payload (only for PUT)"
|
||||
// @Success 200 {object} transport.CarWire "Car payload (GET) or update ack (PUT)"
|
||||
// @Success 204 {object} transport.CarAck "Delete ack"
|
||||
// @Failure 400 {object} map[string]interface{} "Bad request / invalid input"
|
||||
// @Failure 403 {object} map[string]interface{} "System car is immutable"
|
||||
// @Failure 404 {object} map[string]interface{} "Car not found"
|
||||
// @Failure 409 {object} map[string]interface{} "Car id already exists"
|
||||
// @Failure 500 {object} map[string]interface{} "Internal server error"
|
||||
// @Router /api/cars/{id} [get]
|
||||
// @Router /api/cars/{id} [put]
|
||||
// @Router /api/cars/{id} [delete]
|
||||
func carByIDHandler(svc *catalog.Service) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
id := strings.TrimPrefix(r.URL.Path, "/api/cars/")
|
||||
if id == "" || strings.Contains(id, "/") {
|
||||
writeError(w, http.StatusBadRequest, "bad_request", "missing car id")
|
||||
return
|
||||
}
|
||||
switch r.Method {
|
||||
case http.MethodGet:
|
||||
c, ok := svc.GetCar(id)
|
||||
if !ok {
|
||||
writeError(w, http.StatusNotFound, "not_found", "car not found")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, carToWire(c))
|
||||
case http.MethodPut:
|
||||
var req transport.CarUpdateRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "bad_request", err.Error())
|
||||
return
|
||||
}
|
||||
if req.ID == "" {
|
||||
req.ID = id
|
||||
}
|
||||
patch := carPatchFromWire(req.Patch)
|
||||
updated, err := svc.UpdateCar(r.Context(), req.ID, patch)
|
||||
if err != nil {
|
||||
status, code := mapCatalogError(err)
|
||||
writeError(w, status, code, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, transport.CarAck{Ok: true, ID: updated.ID, Car: carToWire(updated)})
|
||||
case http.MethodDelete:
|
||||
if err := svc.DeleteCar(r.Context(), id); err != nil {
|
||||
status, code := mapCatalogError(err)
|
||||
writeError(w, status, code, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, transport.CarAck{Ok: true, ID: id})
|
||||
default:
|
||||
w.Header().Set("Allow", "GET PUT DELETE")
|
||||
writeError(w, http.StatusMethodNotAllowed, "method_not_allowed", r.Method)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// createCarHandler godoc
|
||||
// @Summary Create a car
|
||||
// @Description Creates a new car. The server fills `id` if the client sends an empty one. `owner_id` should be the caller's id in production (auth is disabled in PoC).
|
||||
// @Tags cars
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param car body transport.CarCreateRequest true "Car to create"
|
||||
// @Success 201 {object} transport.CarAck "Created"
|
||||
// @Failure 400 {object} map[string]interface{} "Invalid input"
|
||||
// @Failure 409 {object} map[string]interface{} "Car id already exists"
|
||||
// @Failure 500 {object} map[string]interface{} "Internal server error"
|
||||
// @Router /api/cars [post]
|
||||
func createCarHandler(svc *catalog.Service) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
var req transport.CarCreateRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "bad_request", err.Error())
|
||||
return
|
||||
}
|
||||
in := carFromWire(req.Car)
|
||||
created, err := svc.CreateCar(r.Context(), catalog.CreateCarOptions{
|
||||
ID: in.ID,
|
||||
Name: in.Name,
|
||||
OwnerID: in.OwnerID,
|
||||
Visibility: in.Visibility,
|
||||
Scale: in.Scale,
|
||||
LengthMm: in.LengthMm,
|
||||
WidthMm: in.WidthMm,
|
||||
HeightMm: in.HeightMm,
|
||||
WeightG: in.WeightG,
|
||||
Chassis: in.Chassis,
|
||||
Motor: in.Motor,
|
||||
Battery: in.Battery,
|
||||
Drive: in.Drive,
|
||||
TopSpeedMs: in.TopSpeedMs,
|
||||
ColorHex: in.ColorHex,
|
||||
AvatarURL: in.AvatarURL,
|
||||
})
|
||||
if err != nil {
|
||||
status, code := mapCatalogError(err)
|
||||
writeError(w, status, code, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusCreated, transport.CarAck{Ok: true, ID: created.ID, Car: carToWire(created)})
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Method routers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// tracksRouter routes /api/tracks to either list (GET) or create (POST).
|
||||
func tracksRouter(svc *catalog.Service) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.Method {
|
||||
case http.MethodGet:
|
||||
tracksHandler(svc)(w, r)
|
||||
case http.MethodPost:
|
||||
createTrackHandler(svc)(w, r)
|
||||
default:
|
||||
w.Header().Set("Allow", "GET POST")
|
||||
writeError(w, http.StatusMethodNotAllowed, "method_not_allowed", r.Method)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// carsRouter routes /api/cars to either list (GET) or create (POST).
|
||||
func carsRouter(svc *catalog.Service) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.Method {
|
||||
case http.MethodGet:
|
||||
carsHandler(svc)(w, r)
|
||||
case http.MethodPost:
|
||||
createCarHandler(svc)(w, r)
|
||||
default:
|
||||
w.Header().Set("Allow", "GET POST")
|
||||
writeError(w, http.StatusMethodNotAllowed, "method_not_allowed", r.Method)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,299 @@
|
||||
// WebSocket catalog handlers + event broadcaster.
|
||||
//
|
||||
// The HTTP side lives in catalog_handlers.go. This file owns the WS side:
|
||||
// - the catalogBroadcaster subscribes to catalog events and pushes
|
||||
// track_snapshot / car_snapshot / catalog_summary frames to all
|
||||
// connected clients via the realtime hub.
|
||||
// - handleTrackWSMessage / handleCarWSMessage dispatch individual
|
||||
// track_* / car_* requests from a single client.
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"log/slog"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/x0gp/server/internal/catalog"
|
||||
"github.com/x0gp/server/internal/realtime"
|
||||
"github.com/x0gp/server/internal/transport"
|
||||
)
|
||||
|
||||
// wsCtx returns a context for catalog mutations triggered from a
|
||||
// WebSocket message. We deliberately use Background() because the
|
||||
// per-message pgxpool query is already bounded by pgx's own
|
||||
// statement_timeout / connection settings.
|
||||
func wsCtx() context.Context {
|
||||
return context.Background()
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Broadcaster
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
type catalogBroadcaster struct {
|
||||
svc *catalog.Service
|
||||
hub *Hub
|
||||
log *slog.Logger
|
||||
stop chan struct{}
|
||||
done chan struct{}
|
||||
}
|
||||
|
||||
func newCatalogBroadcaster(svc *catalog.Service, hub *Hub, log *slog.Logger) *catalogBroadcaster {
|
||||
return &catalogBroadcaster{
|
||||
svc: svc,
|
||||
hub: hub,
|
||||
log: log,
|
||||
stop: make(chan struct{}),
|
||||
done: make(chan struct{}),
|
||||
}
|
||||
}
|
||||
|
||||
func (cb *catalogBroadcaster) Run() {
|
||||
defer close(cb.done)
|
||||
events, cancel := cb.svc.Subscribe()
|
||||
defer cancel()
|
||||
for {
|
||||
select {
|
||||
case <-cb.stop:
|
||||
return
|
||||
case ev, ok := <-events:
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
cb.broadcast(ev)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (cb *catalogBroadcaster) Stop() {
|
||||
select {
|
||||
case <-cb.stop:
|
||||
return
|
||||
default:
|
||||
close(cb.stop)
|
||||
}
|
||||
<-cb.done
|
||||
}
|
||||
|
||||
func (cb *catalogBroadcaster) broadcast(ev catalog.Event) {
|
||||
switch ev.Kind {
|
||||
case catalog.EventSnapshot:
|
||||
// Initial subscription push; clients request via track_list/car_list.
|
||||
return
|
||||
case catalog.EventTrackUpsert, catalog.EventTrackDelete:
|
||||
snap := cb.svc.Snapshot()
|
||||
cb.hub.Publish(&transport.Envelope{
|
||||
Type: transport.TypeTrackSnapshot,
|
||||
Payload: transport.TrackSnapshot{
|
||||
GeneratedMs: snap.GeneratedMs,
|
||||
Version: snap.Version,
|
||||
Tracks: tracksToWire(snap.Tracks),
|
||||
},
|
||||
})
|
||||
cb.hub.Publish(&transport.Envelope{
|
||||
Type: transport.TypeCatalogSummary,
|
||||
Payload: summaryToWire(cb.svc.Stats()),
|
||||
})
|
||||
case catalog.EventCarUpsert, catalog.EventCarDelete:
|
||||
snap := cb.svc.Snapshot()
|
||||
cb.hub.Publish(&transport.Envelope{
|
||||
Type: transport.TypeCarSnapshot,
|
||||
Payload: transport.CarSnapshot{
|
||||
GeneratedMs: snap.GeneratedMs,
|
||||
Version: snap.Version,
|
||||
Cars: carsToWire(snap.Cars),
|
||||
},
|
||||
})
|
||||
cb.hub.Publish(&transport.Envelope{
|
||||
Type: transport.TypeCatalogSummary,
|
||||
Payload: summaryToWire(cb.svc.Stats()),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Per-message WS handlers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// handleTrackWSMessage is called from the readPump dispatch on track_* messages.
|
||||
func handleTrackWSMessage(c *realtime.Client, svc *catalog.Service, env *transport.Envelope) {
|
||||
payloadBytes, _ := json.Marshal(env.Payload)
|
||||
switch env.Type {
|
||||
case transport.TypeTrackList:
|
||||
var req transport.TrackListRequest
|
||||
_ = json.Unmarshal(payloadBytes, &req)
|
||||
var out []catalog.TrackMeta
|
||||
switch strings.ToLower(req.Scope) {
|
||||
case "system":
|
||||
out = svc.ListTracksByVisibility(catalog.VisibilitySystem)
|
||||
case "public":
|
||||
out = svc.ListTracksByVisibility(catalog.VisibilityPublic)
|
||||
case "private":
|
||||
out = svc.ListTracksByVisibility(catalog.VisibilityPrivate)
|
||||
default:
|
||||
out = svc.ListTracks()
|
||||
}
|
||||
if req.Tag != "" {
|
||||
out = filterTracksByTag(out, req.Tag)
|
||||
}
|
||||
snap := svc.Snapshot()
|
||||
sendWS(c, transport.TypeTrackSnapshot, transport.TrackSnapshot{
|
||||
GeneratedMs: snap.GeneratedMs,
|
||||
Version: snap.Version,
|
||||
Tracks: tracksToWire(out),
|
||||
})
|
||||
case transport.TypeTrackGet:
|
||||
var req transport.TrackGetRequest
|
||||
_ = json.Unmarshal(payloadBytes, &req)
|
||||
if req.ID == "" {
|
||||
sendErrorWS(c, "track_get", "missing id")
|
||||
return
|
||||
}
|
||||
t, ok := svc.GetTrack(req.ID)
|
||||
if !ok {
|
||||
sendWS(c, transport.TypeTrackAck, transport.TrackAck{Ok: false, ID: req.ID, Error: "not_found"})
|
||||
return
|
||||
}
|
||||
sendWS(c, transport.TypeTrackSnapshot, transport.TrackSnapshot{
|
||||
Tracks: []transport.TrackWire{trackToWire(t)},
|
||||
})
|
||||
case transport.TypeTrackCreate:
|
||||
var req transport.TrackCreateRequest
|
||||
_ = json.Unmarshal(payloadBytes, &req)
|
||||
in := trackFromWire(req.Track)
|
||||
created, err := svc.CreateTrack(wsCtx(), catalog.CreateTrackOptions{
|
||||
ID: in.ID, Name: in.Name, Description: in.Description,
|
||||
AuthorID: in.AuthorID, Visibility: in.Visibility, Scale: in.Scale,
|
||||
LengthM: in.LengthM, WidthM: in.WidthM, LaneWidthM: in.LaneWidthM,
|
||||
Surface: in.Surface, Tags: in.Tags, Centerline: in.Centerline,
|
||||
})
|
||||
if err != nil {
|
||||
sendWS(c, transport.TypeTrackAck, transport.TrackAck{Ok: false, Error: err.Error()})
|
||||
return
|
||||
}
|
||||
sendWS(c, transport.TypeTrackAck, transport.TrackAck{Ok: true, ID: created.ID, Track: trackToWire(created)})
|
||||
case transport.TypeTrackUpdate:
|
||||
var req transport.TrackUpdateRequest
|
||||
_ = json.Unmarshal(payloadBytes, &req)
|
||||
patch := trackPatchFromWire(req.Patch)
|
||||
updated, err := svc.UpdateTrack(wsCtx(), req.ID, patch)
|
||||
if err != nil {
|
||||
sendWS(c, transport.TypeTrackAck, transport.TrackAck{Ok: false, ID: req.ID, Error: err.Error()})
|
||||
return
|
||||
}
|
||||
sendWS(c, transport.TypeTrackAck, transport.TrackAck{Ok: true, ID: updated.ID, Track: trackToWire(updated)})
|
||||
case transport.TypeTrackDelete:
|
||||
var req transport.TrackDeleteRequest
|
||||
_ = json.Unmarshal(payloadBytes, &req)
|
||||
if err := svc.DeleteTrack(wsCtx(), req.ID); err != nil {
|
||||
sendWS(c, transport.TypeTrackAck, transport.TrackAck{Ok: false, ID: req.ID, Error: err.Error()})
|
||||
return
|
||||
}
|
||||
sendWS(c, transport.TypeTrackAck, transport.TrackAck{Ok: true, ID: req.ID})
|
||||
}
|
||||
}
|
||||
|
||||
func handleCarWSMessage(c *realtime.Client, svc *catalog.Service, env *transport.Envelope) {
|
||||
payloadBytes, _ := json.Marshal(env.Payload)
|
||||
switch env.Type {
|
||||
case transport.TypeCarList:
|
||||
var req transport.CarListRequest
|
||||
_ = json.Unmarshal(payloadBytes, &req)
|
||||
var out []catalog.CarMeta
|
||||
if req.OwnerID != "" {
|
||||
out = svc.ListCarsByOwner(req.OwnerID)
|
||||
} else {
|
||||
switch strings.ToLower(req.Scope) {
|
||||
case "system":
|
||||
out = svc.ListCarsByVisibility(catalog.VisibilitySystem)
|
||||
case "public":
|
||||
out = svc.ListCarsByVisibility(catalog.VisibilityPublic)
|
||||
case "private":
|
||||
out = svc.ListCarsByVisibility(catalog.VisibilityPrivate)
|
||||
default:
|
||||
out = svc.ListCars()
|
||||
}
|
||||
}
|
||||
snap := svc.Snapshot()
|
||||
sendWS(c, transport.TypeCarSnapshot, transport.CarSnapshot{
|
||||
GeneratedMs: snap.GeneratedMs,
|
||||
Version: snap.Version,
|
||||
Cars: carsToWire(out),
|
||||
})
|
||||
case transport.TypeCarGet:
|
||||
var req transport.CarGetRequest
|
||||
_ = json.Unmarshal(payloadBytes, &req)
|
||||
if req.ID == "" {
|
||||
sendErrorWS(c, "car_get", "missing id")
|
||||
return
|
||||
}
|
||||
car, ok := svc.GetCar(req.ID)
|
||||
if !ok {
|
||||
sendWS(c, transport.TypeCarAck, transport.CarAck{Ok: false, ID: req.ID, Error: "not_found"})
|
||||
return
|
||||
}
|
||||
sendWS(c, transport.TypeCarSnapshot, transport.CarSnapshot{
|
||||
Cars: []transport.CarWire{carToWire(car)},
|
||||
})
|
||||
case transport.TypeCarCreate:
|
||||
var req transport.CarCreateRequest
|
||||
_ = json.Unmarshal(payloadBytes, &req)
|
||||
in := carFromWire(req.Car)
|
||||
created, err := svc.CreateCar(wsCtx(), catalog.CreateCarOptions{
|
||||
ID: in.ID, Name: in.Name, OwnerID: in.OwnerID, Visibility: in.Visibility,
|
||||
Scale: in.Scale, LengthMm: in.LengthMm, WidthMm: in.WidthMm,
|
||||
HeightMm: in.HeightMm, WeightG: in.WeightG, Chassis: in.Chassis,
|
||||
Motor: in.Motor, Battery: in.Battery, Drive: in.Drive,
|
||||
TopSpeedMs: in.TopSpeedMs, ColorHex: in.ColorHex, AvatarURL: in.AvatarURL,
|
||||
})
|
||||
if err != nil {
|
||||
sendWS(c, transport.TypeCarAck, transport.CarAck{Ok: false, Error: err.Error()})
|
||||
return
|
||||
}
|
||||
sendWS(c, transport.TypeCarAck, transport.CarAck{Ok: true, ID: created.ID, Car: carToWire(created)})
|
||||
case transport.TypeCarUpdate:
|
||||
var req transport.CarUpdateRequest
|
||||
_ = json.Unmarshal(payloadBytes, &req)
|
||||
patch := carPatchFromWire(req.Patch)
|
||||
updated, err := svc.UpdateCar(wsCtx(), req.ID, patch)
|
||||
if err != nil {
|
||||
sendWS(c, transport.TypeCarAck, transport.CarAck{Ok: false, ID: req.ID, Error: err.Error()})
|
||||
return
|
||||
}
|
||||
sendWS(c, transport.TypeCarAck, transport.CarAck{Ok: true, ID: updated.ID, Car: carToWire(updated)})
|
||||
case transport.TypeCarDelete:
|
||||
var req transport.CarDeleteRequest
|
||||
_ = json.Unmarshal(payloadBytes, &req)
|
||||
if err := svc.DeleteCar(wsCtx(), req.ID); err != nil {
|
||||
sendWS(c, transport.TypeCarAck, transport.CarAck{Ok: false, ID: req.ID, Error: err.Error()})
|
||||
return
|
||||
}
|
||||
sendWS(c, transport.TypeCarAck, transport.CarAck{Ok: true, ID: req.ID})
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Send helpers (non-blocking on full send buffer)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func sendWS(c *realtime.Client, t transport.MessageType, payload any) {
|
||||
data, err := transport.Encode(&transport.Envelope{
|
||||
Type: t,
|
||||
TSMs: time.Now().UnixMilli(),
|
||||
Payload: payload,
|
||||
})
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
select {
|
||||
case c.Send <- data:
|
||||
default:
|
||||
// Drop if the client is too slow.
|
||||
}
|
||||
}
|
||||
|
||||
func sendErrorWS(c *realtime.Client, code, msg string) {
|
||||
sendWS(c, transport.TypeError, transport.ErrorMsg{Code: code, Message: msg})
|
||||
}
|
||||
@@ -0,0 +1,418 @@
|
||||
// HTTP handlers for /api/drivers and /api/clans.
|
||||
//
|
||||
// Routes:
|
||||
//
|
||||
// POST /api/clans create clan
|
||||
// GET /api/clans list clans (limit, offset)
|
||||
// GET /api/clans/{id} get clan by id
|
||||
// PUT /api/clans/{id} update clan
|
||||
// DELETE /api/clans/{id} delete clan
|
||||
// GET /api/clans/by-tag/{tag} get clan by 3-letter tag
|
||||
//
|
||||
// POST /api/drivers create driver
|
||||
// GET /api/drivers list drivers (limit, offset, clan_id)
|
||||
// GET /api/drivers/{id} get driver by id
|
||||
// PUT /api/drivers/{id} update driver
|
||||
// DELETE /api/drivers/{id} delete driver
|
||||
// GET /api/drivers/by-nick/{nick} get driver by 3-letter nickname
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/x0gp/server/internal/clans"
|
||||
"github.com/x0gp/server/internal/drivers"
|
||||
"github.com/x0gp/server/internal/transport"
|
||||
)
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// /api/clans
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// clansCreateHandler godoc
|
||||
// @Summary Create a clan
|
||||
// @Description Creates a new clan. `tag` must be exactly 3 uppercase ASCII letters (e.g. "ACE") and unique.
|
||||
// @Tags clans
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param clan body transport.ClanCreateRequest true "Clan to create"
|
||||
// @Success 201 {object} transport.Clan "Created clan"
|
||||
// @Failure 400 {object} map[string]interface{} "Invalid input (tag must be 3 uppercase letters)"
|
||||
// @Failure 409 {object} map[string]interface{} "Clan with that tag already exists"
|
||||
// @Failure 500 {object} map[string]interface{} "Internal server error"
|
||||
// @Router /api/clans [post]
|
||||
func clansCreateHandler(svc *clans.Service) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
var req transport.ClanCreateRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "bad_request", err.Error())
|
||||
return
|
||||
}
|
||||
c, err := svc.Create(r.Context(), clans.CreateInput{
|
||||
ID: req.ID,
|
||||
Tag: req.Tag,
|
||||
Name: req.Name,
|
||||
AvatarURL: req.AvatarURL,
|
||||
})
|
||||
if err != nil {
|
||||
if errors.Is(err, clans.ErrInvalidInput) {
|
||||
writeError(w, http.StatusBadRequest, "invalid_input", err.Error())
|
||||
return
|
||||
}
|
||||
if errors.Is(err, clans.ErrAlreadyExists) {
|
||||
writeError(w, http.StatusConflict, "already_exists", err.Error())
|
||||
return
|
||||
}
|
||||
writeError(w, http.StatusInternalServerError, "internal", err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusCreated, clanToWire(c))
|
||||
}
|
||||
}
|
||||
|
||||
// clansListHandler godoc
|
||||
// @Summary List clans
|
||||
// @Description Returns clans ordered by tag. Limit 1..200, default 50.
|
||||
// @Tags clans
|
||||
// @Produce json
|
||||
// @Param limit query int false "Page size"
|
||||
// @Param offset query int false "Page offset"
|
||||
// @Success 200 {object} transport.ClanListResponse "Page of clans"
|
||||
// @Failure 500 {object} map[string]interface{} "Internal server error"
|
||||
// @Router /api/clans [get]
|
||||
func clansListHandler(svc *clans.Service) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
limit, _ := strconv.Atoi(r.URL.Query().Get("limit"))
|
||||
offset, _ := strconv.Atoi(r.URL.Query().Get("offset"))
|
||||
rows, err := svc.List(r.Context(), limit, offset)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "internal", err.Error())
|
||||
return
|
||||
}
|
||||
out := make([]transport.Clan, 0, len(rows))
|
||||
for _, c := range rows {
|
||||
out = append(out, clanToWire(c))
|
||||
}
|
||||
writeJSON(w, http.StatusOK, transport.ClanListResponse{Items: out, Count: len(out)})
|
||||
}
|
||||
}
|
||||
|
||||
// clansByIDHandler godoc
|
||||
// @Summary Get / update / delete a clan by id
|
||||
// @Description Dispatches on HTTP method.
|
||||
// @Tags clans
|
||||
// @Produce json
|
||||
// @Consume json
|
||||
// @Param id path string true "Clan id"
|
||||
// @Param clan body transport.ClanUpdateRequest false "Patch payload (only for PUT)"
|
||||
// @Success 200 {object} transport.Clan "Clan payload or update result"
|
||||
// @Success 204 {object} transport.Clan "Delete ack"
|
||||
// @Failure 400 {object} map[string]interface{} "Bad request"
|
||||
// @Failure 404 {object} map[string]interface{} "Clan not found"
|
||||
// @Failure 500 {object} map[string]interface{} "Internal server error"
|
||||
// @Router /api/clans/{id} [get]
|
||||
// @Router /api/clans/{id} [put]
|
||||
// @Router /api/clans/{id} [delete]
|
||||
func clansByIDHandler(svc *clans.Service) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
id := strings.TrimPrefix(r.URL.Path, "/api/clans/")
|
||||
if id == "" {
|
||||
writeError(w, http.StatusBadRequest, "bad_request", "missing clan id")
|
||||
return
|
||||
}
|
||||
// Special-case: /api/clans/by-tag/{tag}
|
||||
if strings.HasPrefix(id, "by-tag/") {
|
||||
tag := strings.TrimPrefix(id, "by-tag/")
|
||||
if tag == "" {
|
||||
writeError(w, http.StatusBadRequest, "bad_request", "missing clan tag")
|
||||
return
|
||||
}
|
||||
c, err := svc.GetByTag(r.Context(), tag)
|
||||
if err != nil {
|
||||
if errors.Is(err, clans.ErrNotFound) {
|
||||
writeError(w, http.StatusNotFound, "not_found", "clan not found")
|
||||
return
|
||||
}
|
||||
writeError(w, http.StatusInternalServerError, "internal", err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, clanToWire(c))
|
||||
return
|
||||
}
|
||||
switch r.Method {
|
||||
case http.MethodGet:
|
||||
c, err := svc.Get(r.Context(), id)
|
||||
if err != nil {
|
||||
if errors.Is(err, clans.ErrNotFound) {
|
||||
writeError(w, http.StatusNotFound, "not_found", "clan not found")
|
||||
return
|
||||
}
|
||||
writeError(w, http.StatusInternalServerError, "internal", err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, clanToWire(c))
|
||||
case http.MethodPut:
|
||||
var req transport.ClanUpdateRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "bad_request", err.Error())
|
||||
return
|
||||
}
|
||||
c, err := svc.Update(r.Context(), id, clans.UpdateInput{
|
||||
Name: req.Name,
|
||||
AvatarURL: req.AvatarURL,
|
||||
})
|
||||
if err != nil {
|
||||
if errors.Is(err, clans.ErrNotFound) {
|
||||
writeError(w, http.StatusNotFound, "not_found", "clan not found")
|
||||
return
|
||||
}
|
||||
writeError(w, http.StatusInternalServerError, "internal", err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, clanToWire(c))
|
||||
case http.MethodDelete:
|
||||
if err := svc.Delete(r.Context(), id); err != nil {
|
||||
if errors.Is(err, clans.ErrNotFound) {
|
||||
writeError(w, http.StatusNotFound, "not_found", "clan not found")
|
||||
return
|
||||
}
|
||||
writeError(w, http.StatusInternalServerError, "internal", err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "id": id})
|
||||
default:
|
||||
w.Header().Set("Allow", "GET PUT DELETE")
|
||||
writeError(w, http.StatusMethodNotAllowed, "method_not_allowed", r.Method)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// /api/drivers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// driversCreateHandler godoc
|
||||
// @Summary Create a driver
|
||||
// @Description Creates a new driver. `nickname` must be exactly 3 uppercase ASCII letters (e.g. "ACE") and unique. `clan_id` is optional and must reference an existing clan.
|
||||
// @Tags drivers
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param driver body transport.DriverCreateRequest true "Driver to create"
|
||||
// @Success 201 {object} transport.Driver "Created driver"
|
||||
// @Failure 400 {object} map[string]interface{} "Invalid input (nickname must be 3 uppercase letters)"
|
||||
// @Failure 409 {object} map[string]interface{} "Driver with that nickname already exists"
|
||||
// @Failure 500 {object} map[string]interface{} "Internal server error"
|
||||
// @Router /api/drivers [post]
|
||||
func driversCreateHandler(svc *drivers.Service) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
var req transport.DriverCreateRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "bad_request", err.Error())
|
||||
return
|
||||
}
|
||||
d, err := svc.Create(r.Context(), drivers.CreateInput{
|
||||
ID: req.ID,
|
||||
Nickname: req.Nickname,
|
||||
Name: req.Name,
|
||||
AvatarURL: req.AvatarURL,
|
||||
ClanID: req.ClanID,
|
||||
})
|
||||
if err != nil {
|
||||
if errors.Is(err, drivers.ErrInvalidInput) {
|
||||
writeError(w, http.StatusBadRequest, "invalid_input", err.Error())
|
||||
return
|
||||
}
|
||||
if errors.Is(err, drivers.ErrAlreadyExists) {
|
||||
writeError(w, http.StatusConflict, "already_exists", err.Error())
|
||||
return
|
||||
}
|
||||
writeError(w, http.StatusInternalServerError, "internal", err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusCreated, driverToWire(d))
|
||||
}
|
||||
}
|
||||
|
||||
// driversListHandler godoc
|
||||
// @Summary List drivers
|
||||
// @Description Returns drivers ordered by nickname. Optional `clan_id` filter.
|
||||
// @Tags drivers
|
||||
// @Produce json
|
||||
// @Param limit query int false "Page size"
|
||||
// @Param offset query int false "Page offset"
|
||||
// @Param clan_id query string false "Filter by clan id"
|
||||
// @Success 200 {object} transport.DriverListResponse "Page of drivers"
|
||||
// @Failure 500 {object} map[string]interface{} "Internal server error"
|
||||
// @Router /api/drivers [get]
|
||||
func driversListHandler(svc *drivers.Service) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
limit, _ := strconv.Atoi(r.URL.Query().Get("limit"))
|
||||
offset, _ := strconv.Atoi(r.URL.Query().Get("offset"))
|
||||
clanID := r.URL.Query().Get("clan_id")
|
||||
rows, err := svc.List(r.Context(), limit, offset, clanID)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "internal", err.Error())
|
||||
return
|
||||
}
|
||||
out := make([]transport.Driver, 0, len(rows))
|
||||
for _, d := range rows {
|
||||
out = append(out, driverToWire(d))
|
||||
}
|
||||
writeJSON(w, http.StatusOK, transport.DriverListResponse{Items: out, Count: len(out)})
|
||||
}
|
||||
}
|
||||
|
||||
// driversByIDHandler godoc
|
||||
// @Summary Get / update / delete a driver by id
|
||||
// @Description Dispatches on HTTP method. Path `/api/drivers/by-nick/{nick}` is a convenience that returns the driver by 3-letter nickname.
|
||||
// @Tags drivers
|
||||
// @Produce json
|
||||
// @Consume json
|
||||
// @Param id path string true "Driver id"
|
||||
// @Param driver body transport.DriverUpdateRequest false "Patch payload (only for PUT)"
|
||||
// @Success 200 {object} transport.Driver "Driver payload or update result"
|
||||
// @Success 204 {object} transport.Driver "Delete ack"
|
||||
// @Failure 400 {object} map[string]interface{} "Bad request"
|
||||
// @Failure 404 {object} map[string]interface{} "Driver not found"
|
||||
// @Failure 500 {object} map[string]interface{} "Internal server error"
|
||||
// @Router /api/drivers/{id} [get]
|
||||
// @Router /api/drivers/{id} [put]
|
||||
// @Router /api/drivers/{id} [delete]
|
||||
func driversByIDHandler(svc *drivers.Service) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
id := strings.TrimPrefix(r.URL.Path, "/api/drivers/")
|
||||
if id == "" {
|
||||
writeError(w, http.StatusBadRequest, "bad_request", "missing driver id")
|
||||
return
|
||||
}
|
||||
if strings.HasPrefix(id, "by-nick/") {
|
||||
nick := strings.TrimPrefix(id, "by-nick/")
|
||||
if nick == "" {
|
||||
writeError(w, http.StatusBadRequest, "bad_request", "missing driver nickname")
|
||||
return
|
||||
}
|
||||
d, err := svc.GetByNickname(r.Context(), nick)
|
||||
if err != nil {
|
||||
if errors.Is(err, drivers.ErrNotFound) {
|
||||
writeError(w, http.StatusNotFound, "not_found", "driver not found")
|
||||
return
|
||||
}
|
||||
writeError(w, http.StatusInternalServerError, "internal", err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, driverToWire(d))
|
||||
return
|
||||
}
|
||||
switch r.Method {
|
||||
case http.MethodGet:
|
||||
d, err := svc.Get(r.Context(), id)
|
||||
if err != nil {
|
||||
if errors.Is(err, drivers.ErrNotFound) {
|
||||
writeError(w, http.StatusNotFound, "not_found", "driver not found")
|
||||
return
|
||||
}
|
||||
writeError(w, http.StatusInternalServerError, "internal", err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, driverToWire(d))
|
||||
case http.MethodPut:
|
||||
var req transport.DriverUpdateRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "bad_request", err.Error())
|
||||
return
|
||||
}
|
||||
d, err := svc.Update(r.Context(), id, drivers.UpdateInput{
|
||||
Name: req.Name,
|
||||
AvatarURL: req.AvatarURL,
|
||||
ClanID: req.ClanID,
|
||||
})
|
||||
if err != nil {
|
||||
if errors.Is(err, drivers.ErrNotFound) {
|
||||
writeError(w, http.StatusNotFound, "not_found", "driver not found")
|
||||
return
|
||||
}
|
||||
writeError(w, http.StatusInternalServerError, "internal", err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, driverToWire(d))
|
||||
case http.MethodDelete:
|
||||
if err := svc.Delete(r.Context(), id); err != nil {
|
||||
if errors.Is(err, drivers.ErrNotFound) {
|
||||
writeError(w, http.StatusNotFound, "not_found", "driver not found")
|
||||
return
|
||||
}
|
||||
writeError(w, http.StatusInternalServerError, "internal", err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "id": id})
|
||||
default:
|
||||
w.Header().Set("Allow", "GET PUT DELETE")
|
||||
writeError(w, http.StatusMethodNotAllowed, "method_not_allowed", r.Method)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Conversions
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func clanToWire(c clans.Clan) transport.Clan {
|
||||
return transport.Clan{
|
||||
ID: c.ID,
|
||||
Tag: c.Tag,
|
||||
Name: c.Name,
|
||||
AvatarURL: c.AvatarURL,
|
||||
CreatedMs: c.CreatedMs,
|
||||
UpdatedMs: c.UpdatedMs,
|
||||
}
|
||||
}
|
||||
|
||||
func driverToWire(d drivers.Driver) transport.Driver {
|
||||
return transport.Driver{
|
||||
ID: d.ID,
|
||||
Nickname: d.Nickname,
|
||||
Name: d.Name,
|
||||
AvatarURL: d.AvatarURL,
|
||||
ClanID: d.ClanID,
|
||||
CreatedMs: d.CreatedMs,
|
||||
UpdatedMs: d.UpdatedMs,
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Method routers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// clansRouter routes /api/clans to GET (list) or POST (create).
|
||||
func clansRouter(svc *clans.Service) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.Method {
|
||||
case http.MethodGet:
|
||||
clansListHandler(svc)(w, r)
|
||||
case http.MethodPost:
|
||||
clansCreateHandler(svc)(w, r)
|
||||
default:
|
||||
w.Header().Set("Allow", "GET POST")
|
||||
writeError(w, http.StatusMethodNotAllowed, "method_not_allowed", r.Method)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// driversRouter routes /api/drivers to GET (list) or POST (create).
|
||||
func driversRouter(svc *drivers.Service) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.Method {
|
||||
case http.MethodGet:
|
||||
driversListHandler(svc)(w, r)
|
||||
case http.MethodPost:
|
||||
driversCreateHandler(svc)(w, r)
|
||||
default:
|
||||
w.Header().Set("Allow", "GET POST")
|
||||
writeError(w, http.StatusMethodNotAllowed, "method_not_allowed", r.Method)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,709 @@
|
||||
// Command poc-server is the minimal x0gp server for end-to-end PoC.
|
||||
//
|
||||
// Topology (PoC):
|
||||
//
|
||||
// browser -- WS --> poc-server -- in-process --> RaceEngine (60 Hz tick)
|
||||
// \-> Snapshot fan-out via Hub
|
||||
//
|
||||
// What's NOT here (intentionally, for PoC simplicity):
|
||||
// - auth / JWT (DevMode = true)
|
||||
// - Postgres / Redis (in-memory state)
|
||||
// - agent-linker (no real cars yet)
|
||||
// - overhead-CV
|
||||
// - billing
|
||||
// - rate limiting beyond per-send-buffer drop
|
||||
//
|
||||
// @title x0gp PoC server API
|
||||
// @version 0.1.0-poc
|
||||
// @description Minimal HTTP+WebSocket server for the x0gp RC racing PoC. REST endpoints under /api/* manage the track/car catalog; /health and /stats expose server liveness and realtime metrics; /ws carries the realtime race protocol.
|
||||
// @description Auth is intentionally disabled in PoC mode (X0GP_DEV_MODE=true). All catalog mutations are allowed without a JWT.
|
||||
// @BasePath /
|
||||
// @schemes http ws
|
||||
// @host localhost:8080
|
||||
// @contact.name x0gp
|
||||
// @license.name MIT
|
||||
package main
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"flag"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/signal"
|
||||
"sync"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/gorilla/websocket"
|
||||
httpSwagger "github.com/swaggo/http-swagger"
|
||||
|
||||
// Side-effect import: pulls in the generated OpenAPI spec at init().
|
||||
// `swag init` (see Makefile / docs/) regenerates this file from
|
||||
// the // @... annotations on the handlers.
|
||||
_ "github.com/x0gp/server/docs"
|
||||
|
||||
"github.com/x0gp/server/internal/catalog"
|
||||
"github.com/x0gp/server/internal/clans"
|
||||
"github.com/x0gp/server/internal/config"
|
||||
"github.com/x0gp/server/internal/control"
|
||||
"github.com/x0gp/server/internal/drivers"
|
||||
"github.com/x0gp/server/internal/lobby"
|
||||
"github.com/x0gp/server/internal/races"
|
||||
"github.com/x0gp/server/internal/races/seed"
|
||||
"github.com/x0gp/server/internal/realtime"
|
||||
"github.com/x0gp/server/internal/transport"
|
||||
"github.com/x0gp/server/internal/storage/postgres"
|
||||
)
|
||||
|
||||
const serverVersion = "0.1.0-poc"
|
||||
|
||||
var (
|
||||
upgrader = websocket.Upgrader{
|
||||
ReadBufferSize: 4096,
|
||||
WriteBufferSize: 4096,
|
||||
CheckOrigin: func(r *http.Request) bool { return true }, // PoC: dev only
|
||||
}
|
||||
)
|
||||
|
||||
func main() {
|
||||
var (
|
||||
addr = flag.String("addr", "", "override HTTP listen address")
|
||||
devMode = flag.Bool("dev", true, "enable development mode")
|
||||
seedRaces = flag.Bool("seed-races", false, "seed mock race data (finished/live/plans) on startup")
|
||||
seedReset = flag.Bool("reset", false, "with --seed-races, wipe seed-managed data before inserting")
|
||||
)
|
||||
flag.Parse()
|
||||
|
||||
if *addr != "" {
|
||||
_ = os.Setenv("X0GP_HTTP_ADDR", *addr)
|
||||
}
|
||||
if !*devMode {
|
||||
_ = os.Setenv("X0GP_DEV_MODE", "false")
|
||||
}
|
||||
if *seedRaces {
|
||||
_ = os.Setenv("X0GP_SEED_RACES", "1")
|
||||
}
|
||||
if *seedReset {
|
||||
_ = os.Setenv("X0GP_SEED_RESET", "1")
|
||||
}
|
||||
|
||||
cfg, err := config.Load()
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "config error: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
logger := newLogger(cfg.LogLevel)
|
||||
logger.Info("starting x0gp poc-server",
|
||||
"version", serverVersion,
|
||||
"addr", cfg.HTTPAddr,
|
||||
"tick_hz", cfg.TickRate,
|
||||
"snapshot_hz", cfg.SnapshotRate,
|
||||
"dev_mode", cfg.DevMode,
|
||||
)
|
||||
|
||||
ctx, cancel := signal.NotifyContext(context.Background(),
|
||||
syscall.SIGINT, syscall.SIGTERM)
|
||||
defer cancel()
|
||||
|
||||
// --- Database: fail-fast. ---
|
||||
if cfg.DatabaseURL == "" {
|
||||
logger.Error("DATABASE_URL is required")
|
||||
os.Exit(1)
|
||||
}
|
||||
bootCtx, bootCancel := context.WithTimeout(ctx, 15*time.Second)
|
||||
pool, err := postgres.Open(bootCtx, postgres.Config{URL: cfg.DatabaseURL})
|
||||
bootCancel()
|
||||
if err != nil {
|
||||
logger.Error("postgres connect failed", "err", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
defer pool.Close()
|
||||
|
||||
migCtx, migCancel := context.WithTimeout(ctx, 30*time.Second)
|
||||
if err := postgres.Migrate(migCtx, pool); err != nil {
|
||||
migCancel()
|
||||
logger.Error("postgres migrate failed", "err", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
migCancel()
|
||||
logger.Info("postgres ready")
|
||||
|
||||
engine := control.NewEngine(cfg.TickRate)
|
||||
hub := NewHub(logger)
|
||||
pgStore := postgres.NewPgStore(pool)
|
||||
cat, err := catalog.NewService(ctx, pgStore)
|
||||
if err != nil {
|
||||
logger.Error("catalog load failed", "err", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
defer cat.Stop()
|
||||
cb := newCatalogBroadcaster(cat, hub, logger)
|
||||
|
||||
// Lobby + races: live races live in lobby.Service, finished races get
|
||||
// snapshotted into Postgres via the lifecycle hook.
|
||||
lobbySvc := lobby.NewService()
|
||||
racesPgStore := races.NewPgStore(pool)
|
||||
racesSvc := races.NewService(racesPgStore, lobbySvc)
|
||||
liveStore := races.NewLiveStore(racesPgStore)
|
||||
lobbySvc.SetPersistence(liveStore)
|
||||
racesSvc.SetLiveStore(liveStore)
|
||||
|
||||
// Restore the in-memory lobby from Postgres so active races and
|
||||
// driver presence survive a restart.
|
||||
restoreCtx, restoreCancel := context.WithTimeout(ctx, 5*time.Second)
|
||||
if racesRestored, driversRestored, err := racesSvc.RestoreFromDB(restoreCtx); err != nil {
|
||||
logger.Warn("restore from db failed", "err", err)
|
||||
} else {
|
||||
logger.Info("restored lobby from db", "races", racesRestored, "drivers", driversRestored)
|
||||
}
|
||||
restoreCancel()
|
||||
|
||||
// Drivers and clans.
|
||||
clansSvc := clans.NewService(clans.NewPgStore(pool))
|
||||
driversSvc := drivers.NewService(drivers.NewPgStore(pool))
|
||||
|
||||
// Persist finished races so the /api/races list and keyset pagination
|
||||
// can serve historical data across restarts. The snapshot is best-
|
||||
// effort: errors are logged but never block the lobby.
|
||||
lobbySvc.SetRaceLifecycleHook(
|
||||
func(meta lobby.RaceMeta) {
|
||||
logger.Debug("race created", "race_id", meta.ID)
|
||||
},
|
||||
func(raceID string) {
|
||||
meta, err := lobbySvc.GetRace(raceID)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
if meta.Status != lobby.RaceStatusFinished {
|
||||
return
|
||||
}
|
||||
hookCtx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
|
||||
defer cancel()
|
||||
if err := racesSvc.PersistFinished(hookCtx, races.SnapshotFinished{
|
||||
Meta: meta,
|
||||
FinishedMs: time.Now().UnixMilli(),
|
||||
}); err != nil {
|
||||
logger.Warn("persist finished race failed", "race_id", raceID, "err", err)
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
// Scheduler: ticks every 5s, materialises due race_plans into the lobby
|
||||
// and auto-attaches queued drivers.
|
||||
scheduler := races.NewScheduler(racesPgStore, lobbySvc, 5*time.Second)
|
||||
|
||||
// Optional: seed mock race data on startup.
|
||||
if os.Getenv("X0GP_SEED_RACES") == "1" {
|
||||
seedCtx, seedCancel := context.WithTimeout(ctx, 30*time.Second)
|
||||
runner := seed.NewRunner(racesPgStore, lobbySvc, liveStore, clans.NewPgStore(pool), drivers.NewPgStore(pool))
|
||||
reset := os.Getenv("X0GP_SEED_RESET") == "1"
|
||||
summary, err := runner.Run(seedCtx, seed.Options{
|
||||
Reset: reset,
|
||||
Now: time.Now(),
|
||||
})
|
||||
seedCancel()
|
||||
if err != nil {
|
||||
logger.Error("seed races failed", "err", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
logger.Info("seed races done",
|
||||
"finished", summary.FinishedInserted,
|
||||
"live", summary.LiveCreated,
|
||||
"plans", summary.PlansInserted,
|
||||
"queue", summary.QueueInserted,
|
||||
"reset", reset,
|
||||
"dur_ms", summary.Duration.Milliseconds(),
|
||||
)
|
||||
}
|
||||
|
||||
var wg sync.WaitGroup
|
||||
wg.Add(4)
|
||||
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
hub.Run(ctx)
|
||||
}()
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
engine.Run(ctx)
|
||||
}()
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
cb.Run()
|
||||
}()
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
scheduler.Run(ctx)
|
||||
}()
|
||||
|
||||
// Snapshot publisher: engine -> hub.
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
snapshotLoop(ctx, engine, hub, logger)
|
||||
}()
|
||||
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/health", healthHandler(hub, engine))
|
||||
mux.HandleFunc("/stats", statsHandler(hub, engine))
|
||||
mux.HandleFunc("/api/catalog", catalogHandler(cat))
|
||||
mux.HandleFunc("/api/tracks", tracksRouter(cat))
|
||||
mux.HandleFunc("/api/tracks/", trackByIDHandler(cat))
|
||||
mux.HandleFunc("/api/cars", carsRouter(cat))
|
||||
mux.HandleFunc("/api/cars/", carByIDHandler(cat))
|
||||
mux.HandleFunc("/api/races", racesListHandler(racesSvc))
|
||||
mux.HandleFunc("/api/races/upcoming", racesUpcomingHandler(racesSvc))
|
||||
mux.HandleFunc("/api/races/queue", racesQueueRouter(racesSvc))
|
||||
mux.HandleFunc("/api/races/queue/join", racesQueueJoinHandler(racesSvc))
|
||||
mux.HandleFunc("/api/races/plans", racePlansRouter(racesSvc))
|
||||
mux.HandleFunc("/api/races/plans/", racePlansDeleteHandler(racesSvc))
|
||||
mux.HandleFunc("/api/clans", clansRouter(clansSvc))
|
||||
mux.HandleFunc("/api/clans/", clansByIDHandler(clansSvc))
|
||||
mux.HandleFunc("/api/drivers", driversRouter(driversSvc))
|
||||
mux.HandleFunc("/api/drivers/", driversByIDHandler(driversSvc))
|
||||
mux.HandleFunc("/ws", wsHandler(cfg, engine, hub, cat, logger))
|
||||
|
||||
// Swagger UI + OpenAPI spec.
|
||||
// UI: GET /swagger/index.html
|
||||
// Spec: GET /swagger/doc.json
|
||||
mux.Handle("/swagger/", httpSwagger.WrapHandler)
|
||||
|
||||
srv := &http.Server{
|
||||
Addr: cfg.HTTPAddr,
|
||||
Handler: withLogging(mux, logger),
|
||||
ReadHeaderTimeout: 5 * time.Second,
|
||||
}
|
||||
|
||||
// Start HTTP server.
|
||||
go func() {
|
||||
logger.Info("http listening", "addr", cfg.HTTPAddr)
|
||||
if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
|
||||
logger.Error("http server failed", "err", err)
|
||||
cancel()
|
||||
}
|
||||
}()
|
||||
|
||||
<-ctx.Done()
|
||||
logger.Info("shutdown signal received")
|
||||
|
||||
shutCtx, shutCancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer shutCancel()
|
||||
_ = srv.Shutdown(shutCtx)
|
||||
|
||||
engine.Stop()
|
||||
hub.Stop()
|
||||
cb.Stop()
|
||||
wg.Wait()
|
||||
logger.Info("shutdown complete")
|
||||
}
|
||||
|
||||
// snapshotLoop forwards RaceEngine snapshots to all connected clients.
|
||||
func snapshotLoop(ctx context.Context, e *control.Engine, h *Hub, logger *slog.Logger) {
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case snap, ok := <-e.Snapshots():
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
h.BroadcastSnapshot(snap)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// HubWrapper extends realtime.Hub with helpers tailored to this binary.
|
||||
// Using a thin wrapper keeps realtime package free of transport-specific deps.
|
||||
type Hub struct {
|
||||
*realtime.Hub
|
||||
}
|
||||
|
||||
func NewHub(logger *slog.Logger) *Hub {
|
||||
return &Hub{Hub: realtime.NewHub()}
|
||||
}
|
||||
|
||||
// BroadcastSnapshot wraps a RaceSnapshot in an envelope and broadcasts.
|
||||
func (h *Hub) BroadcastSnapshot(snap transport.RaceSnapshot) {
|
||||
h.Publish(&transport.Envelope{
|
||||
Type: transport.TypeRaceSnapshot,
|
||||
TSMs: time.Now().UnixMilli(),
|
||||
Payload: snap,
|
||||
})
|
||||
}
|
||||
|
||||
// HTTP handlers ------------------------------------------------------------
|
||||
|
||||
// healthHandler godoc
|
||||
// @Summary Liveness probe
|
||||
// @Description Returns 200 OK with current engine phase, WS connection count and total snapshot drops. Intended for load-balancer health checks.
|
||||
// @Tags system
|
||||
// @Produce json
|
||||
// @Success 200 {object} map[string]interface{} "Server is up"
|
||||
// @Router /health [get]
|
||||
func healthHandler(h *Hub, e *control.Engine) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, _ *http.Request) {
|
||||
stats := h.Stats()
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
"status": "ok",
|
||||
"version": serverVersion,
|
||||
"phase": e.Phase().String(),
|
||||
"tick": 0, // Engine has no public getter; would add later
|
||||
"connections": stats.Connections,
|
||||
"snapshot_drops": stats.DropsTotal,
|
||||
"time": time.Now().UTC().Format(time.RFC3339Nano),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// statsHandler godoc
|
||||
// @Summary Realtime runtime stats
|
||||
// @Description Returns the current race phase plus realtime counters: active WS connections and total snapshots dropped due to slow consumers.
|
||||
// @Tags system
|
||||
// @Produce json
|
||||
// @Success 200 {object} map[string]interface{} "Runtime counters"
|
||||
// @Router /stats [get]
|
||||
func statsHandler(h *Hub, e *control.Engine) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, _ *http.Request) {
|
||||
stats := h.Stats()
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
"connections": stats.Connections,
|
||||
"snapshot_drops": stats.DropsTotal,
|
||||
"race_phase": e.Phase().String(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// wsHandler godoc
|
||||
// @Summary WebSocket endpoint (binary JSON envelope protocol)
|
||||
// @Description Opens a bidirectional WebSocket for the realtime race protocol. The server replies with `server_hello`, then broadcasts `race_snapshot` frames at `X0GP_SNAPSHOT_HZ`.
|
||||
//
|
||||
// @Description ## Frame format
|
||||
// @Description Every frame is a JSON envelope of the form:
|
||||
// @Description ```json
|
||||
// @Description { "type": "<message_type>", "seq": 42, "ts_ms": 1700000000000, "payload": { ... } }
|
||||
// @Description ```
|
||||
// @Description `type` discriminates the payload; `payload` is documented per message type in the schemas listed in `components/schemas` (see `Envelope`, `ClientHello`, `ServerHello`, `InputState`, `JoinRace`, `LeaveRace`, `RaceSnapshot`, `InputAck`, `RaceEvent`, `Ping`, `Pong`, `ErrorMsg`, plus the catalog/lobby/stats envelopes).
|
||||
// @Description ## Client -> Server
|
||||
// @Description - `client_hello` — first frame after connect. Sent once.
|
||||
// @Description - `join_race` / `leave_race` — join or leave the race session.
|
||||
// @Description - `input_state` — high-rate control (steering/throttle/brake/gear). Send at 60-120 Hz.
|
||||
// @Description - `ping` — round-trip time probe.
|
||||
// @Description - `track_*` / `car_*` — catalog CRUD (mirror of the REST /api/* endpoints).
|
||||
// @Description - `lobby_*` — lobby management.
|
||||
// @Description - `stats_request` — ask for a stats snapshot.
|
||||
// @Description ## Server -> Client
|
||||
// @Description - `server_hello` — handshake reply; includes session id and engine config.
|
||||
// @Description - `race_snapshot` — periodic authoritative state broadcast.
|
||||
// @Description - `input_ack` — per-input acknowledgement (optional; drop-tolerant).
|
||||
// @Description - `race_event` — start/lap/sector/dnf/finish.
|
||||
// @Description - `track_snapshot` / `car_snapshot` / `catalog_summary` — catalog feeds on change.
|
||||
// @Description - `lobby_snapshot` — full lobby state on change.
|
||||
// @Description - `stats_snapshot` / `race_standings` / `lap_recorded` / `race_result` — stats feeds.
|
||||
// @Description - `error` — protocol or validation error.
|
||||
// @Description ## Notes
|
||||
// @Description - Origin checks are disabled in PoC mode (`CheckOrigin: always-allow`).
|
||||
// @Description - Ping interval: 20 s. Pong wait: 60 s. Max frame size: 8 KiB.
|
||||
// @Description - Input acks are best-effort and silently dropped under load.
|
||||
// @Tags websocket
|
||||
// @Success 101 {object} transport.Envelope "Switching protocols. Subsequent frames follow the envelope schema."
|
||||
// @Router /ws [get]
|
||||
func wsHandler(cfg *config.Config, e *control.Engine, h *Hub, cat *catalog.Service, logger *slog.Logger) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
conn, err := upgrader.Upgrade(w, r, nil)
|
||||
if err != nil {
|
||||
logger.Warn("ws upgrade failed", "err", err)
|
||||
return
|
||||
}
|
||||
client := &realtime.Client{
|
||||
ID: uuid.NewString(),
|
||||
Send: make(chan []byte, 64),
|
||||
Done: make(chan struct{}),
|
||||
}
|
||||
h.Register(client)
|
||||
logger.Info("client connected",
|
||||
"client_id", client.ID,
|
||||
"remote", r.RemoteAddr,
|
||||
)
|
||||
go writePump(client, conn, logger)
|
||||
go readPump(cfg, client, conn, e, h, cat, logger)
|
||||
}
|
||||
}
|
||||
|
||||
// Per-connection goroutines ----------------------------------------------
|
||||
|
||||
const (
|
||||
pongWait = 60 * time.Second
|
||||
pingPeriod = 20 * time.Second
|
||||
writeTimeout = 5 * time.Second
|
||||
)
|
||||
|
||||
func writePump(c *realtime.Client, conn *websocket.Conn, logger *slog.Logger) {
|
||||
ticker := time.NewTicker(pingPeriod)
|
||||
defer func() {
|
||||
ticker.Stop()
|
||||
_ = conn.Close()
|
||||
c.Close()
|
||||
}()
|
||||
for {
|
||||
select {
|
||||
case <-c.Done:
|
||||
return
|
||||
case msg, ok := <-c.Send:
|
||||
if !ok {
|
||||
_ = conn.WriteMessage(websocket.CloseMessage, []byte{})
|
||||
return
|
||||
}
|
||||
_ = conn.SetWriteDeadline(time.Now().Add(writeTimeout))
|
||||
if err := conn.WriteMessage(websocket.TextMessage, msg); err != nil {
|
||||
logger.Debug("ws write failed", "client_id", c.ID, "err", err)
|
||||
return
|
||||
}
|
||||
case <-ticker.C:
|
||||
_ = conn.SetWriteDeadline(time.Now().Add(writeTimeout))
|
||||
if err := conn.WriteMessage(websocket.PingMessage, nil); err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func readPump(cfg *config.Config, c *realtime.Client, conn *websocket.Conn, e *control.Engine, h *Hub, cat *catalog.Service, logger *slog.Logger) {
|
||||
defer func() {
|
||||
h.Unregister(c)
|
||||
logger.Info("client disconnected", "client_id", c.ID)
|
||||
_ = conn.Close()
|
||||
}()
|
||||
|
||||
conn.SetReadLimit(8192)
|
||||
_ = conn.SetReadDeadline(time.Now().Add(pongWait))
|
||||
conn.SetPongHandler(func(string) error {
|
||||
_ = conn.SetReadDeadline(time.Now().Add(pongWait))
|
||||
return nil
|
||||
})
|
||||
|
||||
var seq uint64
|
||||
|
||||
for {
|
||||
_, raw, err := conn.ReadMessage()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
env, err := transport.Decode(raw)
|
||||
if err != nil {
|
||||
sendError(c, "decode_error", err.Error())
|
||||
continue
|
||||
}
|
||||
seq++
|
||||
env.Seq = seq
|
||||
|
||||
switch env.Type {
|
||||
case transport.TypeClientHello:
|
||||
handleClientHello(c, e, cfg, env)
|
||||
case transport.TypeJoinRace:
|
||||
handleJoinRace(c, e, env)
|
||||
case transport.TypeLeaveRace:
|
||||
handleLeaveRace(c, e, env)
|
||||
case transport.TypeInputState:
|
||||
handleInput(c, e, env)
|
||||
case transport.TypePing:
|
||||
handlePing(c, env)
|
||||
case transport.TypeChatMessage:
|
||||
// Not implemented in PoC; acknowledge silently.
|
||||
case transport.TypeTrackList,
|
||||
transport.TypeTrackGet,
|
||||
transport.TypeTrackCreate,
|
||||
transport.TypeTrackUpdate,
|
||||
transport.TypeTrackDelete:
|
||||
handleTrackWSMessage(c, cat, env)
|
||||
case transport.TypeCarList,
|
||||
transport.TypeCarGet,
|
||||
transport.TypeCarCreate,
|
||||
transport.TypeCarUpdate,
|
||||
transport.TypeCarDelete:
|
||||
handleCarWSMessage(c, cat, env)
|
||||
default:
|
||||
sendError(c, "unknown_type", string(env.Type))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Message handlers --------------------------------------------------------
|
||||
|
||||
func handleClientHello(c *realtime.Client, e *control.Engine, cfg *config.Config, env *transport.Envelope) {
|
||||
hello := transport.ServerHello{
|
||||
SessionID: c.ID,
|
||||
RaceTick: 0,
|
||||
}
|
||||
hello.ServerVersion = serverVersion
|
||||
hello.Config.TickRate = cfg.TickRate
|
||||
hello.Config.SnapshotRate = cfg.SnapshotRate
|
||||
hello.Config.MaxCars = 4
|
||||
c.SessionID = c.ID
|
||||
|
||||
c.Send <- mustEncode(transport.TypeServerHello, hello)
|
||||
}
|
||||
|
||||
func handleJoinRace(c *realtime.Client, e *control.Engine, env *transport.Envelope) {
|
||||
payload, _ := env.Payload.(map[string]any)
|
||||
slot := 0
|
||||
name := "anon"
|
||||
if payload != nil {
|
||||
if v, ok := payload["car_slot"].(float64); ok {
|
||||
slot = int(v)
|
||||
}
|
||||
if v, ok := payload["car_name"].(string); ok && v != "" {
|
||||
name = v
|
||||
}
|
||||
}
|
||||
car, err := e.AddCar(c.ID, name, slot)
|
||||
if err != nil {
|
||||
sendError(c, "join_failed", err.Error())
|
||||
return
|
||||
}
|
||||
c.Send <- mustEncode(transport.TypeRaceEvent, transport.RaceEvent{
|
||||
Event: "joined",
|
||||
CarID: car.ID,
|
||||
TSMs: time.Now().UnixMilli(),
|
||||
})
|
||||
}
|
||||
|
||||
func handleLeaveRace(c *realtime.Client, e *control.Engine, _ *transport.Envelope) {
|
||||
e.RemoveCar(c.ID)
|
||||
}
|
||||
|
||||
func handleInput(c *realtime.Client, e *control.Engine, env *transport.Envelope) {
|
||||
payload, ok := env.Payload.(map[string]any)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
in := transport.InputState{
|
||||
Steering: getFloat(payload, "steering"),
|
||||
Throttle: getFloat(payload, "throttle"),
|
||||
Brake: getFloat(payload, "brake"),
|
||||
}
|
||||
if v, ok := payload["gear"].(float64); ok {
|
||||
in.Gear = int(v)
|
||||
}
|
||||
car := e.GetCarByDriver(c.ID)
|
||||
if car == nil {
|
||||
return
|
||||
}
|
||||
car.ApplyInput(in, 1.0/60.0)
|
||||
|
||||
// Optional lightweight ack (clients can disable for max throughput).
|
||||
ack := transport.InputAck{
|
||||
Seq: env.Seq,
|
||||
AppliedAtMs: time.Now().UnixMilli(),
|
||||
ServerTickMs: time.Now().UnixMilli(),
|
||||
}
|
||||
select {
|
||||
case c.Send <- mustEncode(transport.TypeInputAck, ack):
|
||||
default:
|
||||
// Drop ack silently.
|
||||
}
|
||||
}
|
||||
|
||||
func handlePing(c *realtime.Client, env *transport.Envelope) {
|
||||
payload, _ := env.Payload.(map[string]any)
|
||||
clientTS := int64(0)
|
||||
if payload != nil {
|
||||
if v, ok := payload["client_ts_ms"].(float64); ok {
|
||||
clientTS = int64(v)
|
||||
}
|
||||
}
|
||||
c.Send <- mustEncode(transport.TypePong, transport.Pong{
|
||||
ClientTSMs: clientTS,
|
||||
ServerTSMs: time.Now().UnixMilli(),
|
||||
})
|
||||
}
|
||||
|
||||
// Helpers ------------------------------------------------------------------
|
||||
|
||||
func sendError(c *realtime.Client, code, msg string) {
|
||||
select {
|
||||
case c.Send <- mustEncode(transport.TypeError, transport.ErrorMsg{
|
||||
Code: code, Message: msg,
|
||||
}):
|
||||
default:
|
||||
}
|
||||
}
|
||||
|
||||
func mustEncode(t transport.MessageType, payload any) []byte {
|
||||
data, err := transport.Encode(&transport.Envelope{
|
||||
Type: t,
|
||||
TSMs: time.Now().UnixMilli(),
|
||||
Payload: payload,
|
||||
})
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
return data
|
||||
}
|
||||
|
||||
func getFloat(m map[string]any, key string) float64 {
|
||||
if v, ok := m[key].(float64); ok {
|
||||
return v
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func newLogger(level string) *slog.Logger {
|
||||
var lvl slog.Level
|
||||
switch level {
|
||||
case "debug":
|
||||
lvl = slog.LevelDebug
|
||||
case "warn":
|
||||
lvl = slog.LevelWarn
|
||||
case "error":
|
||||
lvl = slog.LevelError
|
||||
default:
|
||||
lvl = slog.LevelInfo
|
||||
}
|
||||
h := slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{Level: lvl})
|
||||
return slog.New(h)
|
||||
}
|
||||
|
||||
func withLogging(next http.Handler, logger *slog.Logger) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
start := time.Now()
|
||||
rw := &statusRecorder{ResponseWriter: w, status: 200}
|
||||
next.ServeHTTP(rw, r)
|
||||
logger.Debug("http",
|
||||
"method", r.Method,
|
||||
"path", r.URL.Path,
|
||||
"status", rw.status,
|
||||
"dur_ms", time.Since(start).Milliseconds(),
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
type statusRecorder struct {
|
||||
http.ResponseWriter
|
||||
status int
|
||||
}
|
||||
|
||||
func (r *statusRecorder) WriteHeader(code int) {
|
||||
r.status = code
|
||||
r.ResponseWriter.WriteHeader(code)
|
||||
}
|
||||
|
||||
// Hijack passes through to the underlying ResponseWriter so that the
|
||||
// gorilla/websocket upgrader can take over the connection.
|
||||
func (r *statusRecorder) Hijack() (net.Conn, *bufio.ReadWriter, error) {
|
||||
if h, ok := r.ResponseWriter.(http.Hijacker); ok {
|
||||
return h.Hijack()
|
||||
}
|
||||
return nil, nil, http.ErrNotSupported
|
||||
}
|
||||
@@ -0,0 +1,506 @@
|
||||
// HTTP handlers for the races surface (list / upcoming / queue / plans).
|
||||
//
|
||||
// Routes:
|
||||
//
|
||||
// GET /api/races list races (keyset paginated)
|
||||
// GET /api/races/upcoming next N lobby|countdown races
|
||||
// POST /api/races/queue/join enqueue driver on the next N upcoming
|
||||
// GET /api/races/queue list driver's queue entries
|
||||
// DELETE /api/races/queue remove a single (driver, race) entry
|
||||
// POST /api/races/plans create a race plan
|
||||
// GET /api/races/plans list race plans (keyset paginated)
|
||||
// DELETE /api/races/plans/{id} delete a plan
|
||||
//
|
||||
// Query parameters:
|
||||
//
|
||||
// GET /api/races:
|
||||
// status comma-separated list, e.g. "racing,finished"
|
||||
// track_id filter by physical track id (no custom lobbies yet)
|
||||
// cursor opaque keyset cursor (returned in next_cursor)
|
||||
// limit max items per page (default 50, max 200)
|
||||
//
|
||||
// GET /api/races/upcoming:
|
||||
// limit number of races to return (default 3, max 16)
|
||||
//
|
||||
// GET /api/races/queue:
|
||||
// driver_id (or X-Driver-Id header)
|
||||
//
|
||||
// GET /api/races/plans:
|
||||
// cursor, limit
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/x0gp/server/internal/lobby"
|
||||
"github.com/x0gp/server/internal/races"
|
||||
"github.com/x0gp/server/internal/transport"
|
||||
)
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// /api/races
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// racesListHandler godoc
|
||||
// @Summary List races (keyset paginated)
|
||||
// @Description Returns one keyset page of races. Live races (status lobby|racing) come from in-memory lobby.Service; finished races come from Postgres. The two sources are merged and ordered by (started_ms DESC, id ASC). The cursor is opaque: pass back the next_cursor verbatim from a previous page.
|
||||
// @Description ## Filters
|
||||
// @Description - `status` (comma-separated, e.g. "racing,finished"). Recognised values: all, lobby, racing, finished.
|
||||
// @Description - `track_id` (exact match against a physical track id; custom lobbies are not supported in this build).
|
||||
// @Description - `limit` (1..200, default 50)
|
||||
// @Tags races
|
||||
// @Produce json
|
||||
// @Param status query string false "Comma-separated status filter" Enums(all, lobby, racing, finished)
|
||||
// @Param track_id query string false "Filter by physical track id"
|
||||
// @Param cursor query string false "Opaque keyset cursor (next_cursor from previous page)"
|
||||
// @Param limit query int false "Page size (1..200, default 50)"
|
||||
// @Success 200 {object} transport.RaceListResponse "Page of races"
|
||||
// @Failure 400 {object} map[string]interface{} "Bad request / invalid cursor / invalid status"
|
||||
// @Failure 500 {object} map[string]interface{} "Internal server error"
|
||||
// @Router /api/races [get]
|
||||
func racesListHandler(svc *races.Service) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
filter, err := parseRaceListFilter(r)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "bad_request", err.Error())
|
||||
return
|
||||
}
|
||||
res, err := svc.ListRaces(r.Context(), filter)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "internal", err.Error())
|
||||
return
|
||||
}
|
||||
items := make([]transport.LobbyRace, 0, len(res.Items))
|
||||
for _, it := range res.Items {
|
||||
if it.Source == "finished" && len(it.Podium) > 0 {
|
||||
items = append(items, lobbyToWireFinished(it.Meta, it.Podium))
|
||||
} else {
|
||||
items = append(items, lobbyToWire(it.Meta))
|
||||
}
|
||||
}
|
||||
writeJSON(w, http.StatusOK, transport.RaceListResponse{
|
||||
Items: items,
|
||||
NextCursor: res.NextCursor,
|
||||
Count: len(items),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func parseRaceListFilter(r *http.Request) (races.ListFilter, error) {
|
||||
q := r.URL.Query()
|
||||
cur, err := races.DecodeCursor(q.Get("cursor"))
|
||||
if err != nil {
|
||||
return races.ListFilter{}, err
|
||||
}
|
||||
statuses, err := races.ParseStatusFilter(q.Get("status"))
|
||||
if err != nil {
|
||||
return races.ListFilter{}, err
|
||||
}
|
||||
limit, _ := strconv.Atoi(q.Get("limit"))
|
||||
return races.ListFilter{
|
||||
Statuses: statuses,
|
||||
TrackID: q.Get("track_id"),
|
||||
Cursor: cur,
|
||||
Limit: limit,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// /api/races/upcoming
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// racesUpcomingHandler godoc
|
||||
// @Summary Next N upcoming races
|
||||
// @Description Returns up to `limit` races currently in status lobby|countdown, ordered soonest first. Useful for the "join queue" UI and dashboard.
|
||||
// @Tags races
|
||||
// @Produce json
|
||||
// @Param limit query int false "Number of races to return (1..16, default 3)"
|
||||
// @Success 200 {object} transport.RaceUpcomingResponse "Upcoming races"
|
||||
// @Failure 500 {object} map[string]interface{} "Internal server error"
|
||||
// @Router /api/races/upcoming [get]
|
||||
func racesUpcomingHandler(svc *races.Service) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
limit, _ := strconv.Atoi(r.URL.Query().Get("limit"))
|
||||
if limit <= 0 {
|
||||
limit = 3
|
||||
}
|
||||
items, err := svc.Upcoming(r.Context(), limit)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "internal", err.Error())
|
||||
return
|
||||
}
|
||||
out := make([]transport.RaceUpcomingItem, 0, len(items))
|
||||
for _, it := range items {
|
||||
out = append(out, transport.RaceUpcomingItem{
|
||||
Meta: lobbyToWire(it.Meta),
|
||||
QueueLen: it.QueueLen,
|
||||
PlanID: it.PlanID,
|
||||
StartAtMs: it.StartAtMs,
|
||||
})
|
||||
}
|
||||
writeJSON(w, http.StatusOK, transport.RaceUpcomingResponse{Items: out, Count: len(out)})
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// /api/races/queue
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// racesQueueJoinHandler godoc
|
||||
// @Summary Enqueue driver on next N upcoming races
|
||||
// @Description Puts the driver in the queue for each of the next N (default 3) lobby|countdown races, soonest first. If a race has a free slot, the driver is also attached to it (best effort). Idempotent per (driver, race).
|
||||
// @Tags races
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param X-Driver-Id header string false "Driver id (alternative to body)"
|
||||
// @Param body body transport.RaceQueueJoinRequest false "Driver id and optional limit"
|
||||
// @Success 200 {object} transport.RaceQueueJoinResponse "Queue entries created"
|
||||
// @Failure 400 {object} map[string]interface{} "driver_id required"
|
||||
// @Failure 500 {object} map[string]interface{} "Internal server error"
|
||||
// @Router /api/races/queue/join [post]
|
||||
func racesQueueJoinHandler(svc *races.Service) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
var req transport.RaceQueueJoinRequest
|
||||
if r.ContentLength > 0 {
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "bad_request", err.Error())
|
||||
return
|
||||
}
|
||||
}
|
||||
driverID := req.DriverID
|
||||
if driverID == "" {
|
||||
driverID = r.Header.Get("X-Driver-Id")
|
||||
}
|
||||
if driverID == "" {
|
||||
driverID = r.URL.Query().Get("driver_id")
|
||||
}
|
||||
if driverID == "" {
|
||||
writeError(w, http.StatusBadRequest, "bad_request", "driver_id required")
|
||||
return
|
||||
}
|
||||
limit := req.Limit
|
||||
if limit <= 0 {
|
||||
limit = 3
|
||||
}
|
||||
res, err := svc.JoinUpcoming(r.Context(), driverID, limit)
|
||||
if err != nil {
|
||||
if errors.Is(err, races.ErrInvalidInput) {
|
||||
writeError(w, http.StatusBadRequest, "invalid_input", err.Error())
|
||||
return
|
||||
}
|
||||
writeError(w, http.StatusInternalServerError, "internal", err.Error())
|
||||
return
|
||||
}
|
||||
joined := make([]transport.RaceQueueEntry, 0, len(res.Joined))
|
||||
for _, q := range res.Joined {
|
||||
joined = append(joined, transport.RaceQueueEntry{
|
||||
DriverID: q.DriverID,
|
||||
RaceID: q.RaceID,
|
||||
PlanID: q.PlanID,
|
||||
EnqueuedMs: q.EnqueuedMs,
|
||||
})
|
||||
}
|
||||
writeJSON(w, http.StatusOK, transport.RaceQueueJoinResponse{
|
||||
Joined: joined,
|
||||
Skipped: res.Skipped,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// racesQueueListHandler godoc
|
||||
// @Summary List driver's queue entries
|
||||
// @Description Returns the queue entries for a driver, oldest first.
|
||||
// @Tags races
|
||||
// @Produce json
|
||||
// @Param X-Driver-Id header string false "Driver id"
|
||||
// @Param driver_id query string false "Driver id (alternative to header)"
|
||||
// @Success 200 {object} transport.RaceQueueListResponse "Queue entries"
|
||||
// @Failure 400 {object} map[string]interface{} "driver_id required"
|
||||
// @Failure 500 {object} map[string]interface{} "Internal server error"
|
||||
// @Router /api/races/queue [get]
|
||||
func racesQueueListHandler(svc *races.Service) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
driverID := r.URL.Query().Get("driver_id")
|
||||
if driverID == "" {
|
||||
driverID = r.Header.Get("X-Driver-Id")
|
||||
}
|
||||
if driverID == "" {
|
||||
writeError(w, http.StatusBadRequest, "bad_request", "driver_id required")
|
||||
return
|
||||
}
|
||||
entries, err := svc.ListQueue(r.Context(), driverID)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "internal", err.Error())
|
||||
return
|
||||
}
|
||||
out := make([]transport.RaceQueueEntry, 0, len(entries))
|
||||
for _, q := range entries {
|
||||
out = append(out, transport.RaceQueueEntry{
|
||||
DriverID: q.DriverID,
|
||||
RaceID: q.RaceID,
|
||||
PlanID: q.PlanID,
|
||||
EnqueuedMs: q.EnqueuedMs,
|
||||
})
|
||||
}
|
||||
writeJSON(w, http.StatusOK, transport.RaceQueueListResponse{Items: out, Count: len(out)})
|
||||
}
|
||||
}
|
||||
|
||||
// racesQueueDeleteHandler godoc
|
||||
// @Summary Leave a queue entry
|
||||
// @Description Removes a single (driver, race) entry from the queue. Does not detach the driver from the race if already joined.
|
||||
// @Tags races
|
||||
// @Produce json
|
||||
// @Param X-Driver-Id header string false "Driver id"
|
||||
// @Param driver_id query string false "Driver id"
|
||||
// @Param race_id query string true "Race id"
|
||||
// @Success 200 {object} map[string]interface{} "ok"
|
||||
// @Failure 400 {object} map[string]interface{} "driver_id and race_id required"
|
||||
// @Failure 404 {object} map[string]interface{} "queue entry not found"
|
||||
// @Failure 500 {object} map[string]interface{} "Internal server error"
|
||||
// @Router /api/races/queue [delete]
|
||||
func racesQueueDeleteHandler(svc *races.Service) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
driverID := r.URL.Query().Get("driver_id")
|
||||
raceID := r.URL.Query().Get("race_id")
|
||||
if driverID == "" {
|
||||
driverID = r.Header.Get("X-Driver-Id")
|
||||
}
|
||||
if driverID == "" || raceID == "" {
|
||||
writeError(w, http.StatusBadRequest, "bad_request", "driver_id and race_id required")
|
||||
return
|
||||
}
|
||||
if err := svc.LeaveQueue(r.Context(), driverID, raceID); err != nil {
|
||||
if errors.Is(err, races.ErrNotFound) {
|
||||
writeError(w, http.StatusNotFound, "not_found", "queue entry not found")
|
||||
return
|
||||
}
|
||||
writeError(w, http.StatusInternalServerError, "internal", err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"ok": true})
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// /api/races/plans
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// racePlansCreateHandler godoc
|
||||
// @Summary Create a race plan
|
||||
// @Description Creates a recurring or one-off scheduled race. The scheduler materialises a lobby race when `next_fire_ms` <= now. If `interval_s` > 0 the plan repeats; if `count` > 0 the plan disables itself after that many fires.
|
||||
// @Tags plans
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param plan body transport.RacePlanCreateRequest true "Plan to create"
|
||||
// @Success 201 {object} transport.RacePlan "Created plan"
|
||||
// @Failure 400 {object} map[string]interface{} "Invalid input"
|
||||
// @Failure 409 {object} map[string]interface{} "Plan id already exists"
|
||||
// @Failure 500 {object} map[string]interface{} "Internal server error"
|
||||
// @Router /api/races/plans [post]
|
||||
func racePlansCreateHandler(svc *races.Service) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
var req transport.RacePlanCreateRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "bad_request", err.Error())
|
||||
return
|
||||
}
|
||||
enabled := true
|
||||
if req.Enabled != nil {
|
||||
enabled = *req.Enabled
|
||||
}
|
||||
p, err := svc.CreatePlan(r.Context(), races.CreatePlanInput{
|
||||
ID: req.ID,
|
||||
Name: req.Name,
|
||||
TrackID: req.TrackID,
|
||||
MaxCars: req.MaxCars,
|
||||
Laps: req.Laps,
|
||||
TimeLimitS: req.TimeLimitS,
|
||||
StartAtMs: req.StartAtMs,
|
||||
IntervalS: req.IntervalS,
|
||||
Count: req.Count,
|
||||
Enabled: enabled,
|
||||
})
|
||||
if err != nil {
|
||||
if errors.Is(err, races.ErrInvalidInput) {
|
||||
writeError(w, http.StatusBadRequest, "invalid_input", err.Error())
|
||||
return
|
||||
}
|
||||
if errors.Is(err, races.ErrAlreadyExists) {
|
||||
writeError(w, http.StatusConflict, "already_exists", err.Error())
|
||||
return
|
||||
}
|
||||
writeError(w, http.StatusInternalServerError, "internal", err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusCreated, racePlanToWire(p))
|
||||
}
|
||||
}
|
||||
|
||||
// racePlansListHandler godoc
|
||||
// @Summary List race plans (keyset paginated)
|
||||
// @Description Returns race plans in ascending start_at order. Pass back the `next_cursor` to get the next page.
|
||||
// @Tags plans
|
||||
// @Produce json
|
||||
// @Param cursor query string false "Opaque keyset cursor"
|
||||
// @Param limit query int false "Page size (default 50)"
|
||||
// @Success 200 {object} transport.RacePlanListResponse "Page of plans"
|
||||
// @Failure 400 {object} map[string]interface{} "Invalid cursor"
|
||||
// @Failure 500 {object} map[string]interface{} "Internal server error"
|
||||
// @Router /api/races/plans [get]
|
||||
func racePlansListHandler(svc *races.Service) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
cur, err := races.DecodeCursor(r.URL.Query().Get("cursor"))
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "bad_request", err.Error())
|
||||
return
|
||||
}
|
||||
limit, _ := strconv.Atoi(r.URL.Query().Get("limit"))
|
||||
if limit <= 0 {
|
||||
limit = 50
|
||||
}
|
||||
plans, err := svc.ListPlans(r.Context(), cur, limit)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "internal", err.Error())
|
||||
return
|
||||
}
|
||||
hasMore := false
|
||||
if len(plans) > limit {
|
||||
hasMore = true
|
||||
plans = plans[:limit]
|
||||
}
|
||||
out := make([]transport.RacePlan, 0, len(plans))
|
||||
for _, p := range plans {
|
||||
out = append(out, racePlanToWire(p))
|
||||
}
|
||||
nextCur := ""
|
||||
if hasMore && len(out) > 0 {
|
||||
last := out[len(out)-1]
|
||||
nextCur = races.Cursor{Ms: last.StartAtMs, ID: last.ID}.Encode()
|
||||
}
|
||||
writeJSON(w, http.StatusOK, transport.RacePlanListResponse{
|
||||
Items: out,
|
||||
NextCursor: nextCur,
|
||||
Count: len(out),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// racePlansDeleteHandler godoc
|
||||
// @Summary Delete a race plan
|
||||
// @Description Removes a plan by id. Materialised races already in the lobby are not affected.
|
||||
// @Tags plans
|
||||
// @Produce json
|
||||
// @Param id path string true "Plan id"
|
||||
// @Success 200 {object} map[string]interface{} "ok"
|
||||
// @Failure 404 {object} map[string]interface{} "Plan not found"
|
||||
// @Failure 500 {object} map[string]interface{} "Internal server error"
|
||||
// @Router /api/races/plans/{id} [delete]
|
||||
func racePlansDeleteHandler(svc *races.Service) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
id := strings.TrimPrefix(r.URL.Path, "/api/races/plans/")
|
||||
if id == "" || strings.Contains(id, "/") {
|
||||
writeError(w, http.StatusBadRequest, "bad_request", "missing plan id")
|
||||
return
|
||||
}
|
||||
if err := svc.DeletePlan(r.Context(), id); err != nil {
|
||||
if errors.Is(err, races.ErrNotFound) {
|
||||
writeError(w, http.StatusNotFound, "not_found", "plan not found")
|
||||
return
|
||||
}
|
||||
writeError(w, http.StatusInternalServerError, "internal", err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "id": id})
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Method routers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// racesQueueRouter routes /api/races/queue to GET (list) or DELETE (leave).
|
||||
func racesQueueRouter(svc *races.Service) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.Method {
|
||||
case http.MethodGet:
|
||||
racesQueueListHandler(svc)(w, r)
|
||||
case http.MethodDelete:
|
||||
racesQueueDeleteHandler(svc)(w, r)
|
||||
default:
|
||||
w.Header().Set("Allow", "GET DELETE")
|
||||
writeError(w, http.StatusMethodNotAllowed, "method_not_allowed", r.Method)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// racePlansRouter routes /api/races/plans to GET (list) or POST (create).
|
||||
func racePlansRouter(svc *races.Service) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.Method {
|
||||
case http.MethodGet:
|
||||
racePlansListHandler(svc)(w, r)
|
||||
case http.MethodPost:
|
||||
racePlansCreateHandler(svc)(w, r)
|
||||
default:
|
||||
w.Header().Set("Allow", "GET POST")
|
||||
writeError(w, http.StatusMethodNotAllowed, "method_not_allowed", r.Method)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func lobbyToWire(m lobby.RaceMeta) transport.LobbyRace {
|
||||
return transport.LobbyRace{
|
||||
ID: m.ID,
|
||||
Name: m.Name,
|
||||
TrackID: m.TrackID,
|
||||
MaxCars: m.MaxCars,
|
||||
Laps: m.Laps,
|
||||
TimeLimitS: m.TimeLimitS,
|
||||
DriverIDs: append([]string(nil), m.DriverIDs...),
|
||||
Status: string(m.Status),
|
||||
CreatedMs: m.CreatedMs,
|
||||
StartedMs: m.StartedMs,
|
||||
}
|
||||
}
|
||||
|
||||
// lobbyToWireFinished builds the wire form for a finished race and
|
||||
// attaches the top-3 podium.
|
||||
func lobbyToWireFinished(m lobby.RaceMeta, podium []races.PodiumEntry) transport.LobbyRace {
|
||||
w := lobbyToWire(m)
|
||||
if len(podium) > 0 {
|
||||
w.Podium = make([]transport.RacePodiumEntry, 0, len(podium))
|
||||
for _, p := range podium {
|
||||
w.Podium = append(w.Podium, transport.RacePodiumEntry{
|
||||
Position: p.Position,
|
||||
DriverID: p.DriverID,
|
||||
Name: p.Name,
|
||||
TotalTimeMs: p.TotalTimeMs,
|
||||
})
|
||||
}
|
||||
}
|
||||
return w
|
||||
}
|
||||
|
||||
func racePlanToWire(p races.RacePlan) transport.RacePlan {
|
||||
return transport.RacePlan{
|
||||
ID: p.ID,
|
||||
Name: p.Name,
|
||||
TrackID: p.TrackID,
|
||||
MaxCars: p.MaxCars,
|
||||
Laps: p.Laps,
|
||||
TimeLimitS: p.TimeLimitS,
|
||||
StartAtMs: p.StartAtMs,
|
||||
IntervalS: p.IntervalS,
|
||||
Count: p.Count,
|
||||
Enabled: p.Enabled,
|
||||
CreatedMs: p.CreatedMs,
|
||||
UpdatedMs: p.UpdatedMs,
|
||||
NextFireMs: p.NextFireMs,
|
||||
FiresDone: p.FiresDone,
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user