// 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=`. // @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=`. // @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) } } }