mirror of
https://github.com/IS1DI/x0gp.git
synced 2026-07-16 20:47:55 +00:00
877 lines
28 KiB
Go
877 lines
28 KiB
Go
// 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 (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"net/http"
|
|
"strconv"
|
|
"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, activeTracks map[string]bool) 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,
|
|
IsActive: activeTracks[t.ID],
|
|
}
|
|
}
|
|
|
|
func tracksToWire(in []catalog.TrackMeta, activeTracks map[string]bool) []transport.TrackWire {
|
|
if len(in) == 0 {
|
|
return []transport.TrackWire{}
|
|
}
|
|
out := make([]transport.TrackWire, len(in))
|
|
for i, t := range in {
|
|
out[i] = trackToWire(t, activeTracks)
|
|
}
|
|
return out
|
|
}
|
|
|
|
func getActiveTracks(ctx context.Context, cat *catalog.Service) map[string]bool {
|
|
active := make(map[string]bool)
|
|
calendar, err := cat.GetTrackCalendar(ctx)
|
|
if err != nil {
|
|
return active
|
|
}
|
|
now := time.Now()
|
|
for _, entry := range calendar {
|
|
if (now.Equal(entry.StartAt) || now.After(entry.StartAt)) && now.Before(entry.EndAt) {
|
|
active[entry.TrackID] = true
|
|
}
|
|
}
|
|
return active
|
|
}
|
|
|
|
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, r *http.Request) {
|
|
snap := svc.Snapshot()
|
|
activeTracks := getActiveTracks(r.Context(), svc)
|
|
writeJSON(w, http.StatusOK, map[string]any{
|
|
"tracks": tracksToWire(snap.Tracks, activeTracks),
|
|
"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)
|
|
}
|
|
activeTracks := getActiveTracks(r.Context(), svc)
|
|
writeJSON(w, http.StatusOK, map[string]any{
|
|
"tracks": tracksToWire(out, activeTracks),
|
|
"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
|
|
}
|
|
activeTracks := getActiveTracks(r.Context(), svc)
|
|
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, activeTracks))
|
|
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, activeTracks)})
|
|
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
|
|
}
|
|
activeTracks := getActiveTracks(r.Context(), svc)
|
|
writeJSON(w, http.StatusCreated, transport.TrackAck{Ok: true, ID: created.ID, Track: trackToWire(created, activeTracks)})
|
|
}
|
|
}
|
|
|
|
// 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)
|
|
}
|
|
}
|
|
}
|
|
|
|
// trackCalendarRouter routes /api/tracks/calendar to GET or POST
|
|
func trackCalendarRouter(cat *catalog.Service) http.HandlerFunc {
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
switch r.Method {
|
|
case http.MethodGet:
|
|
getTrackCalendarHandler(cat)(w, r)
|
|
case http.MethodPost:
|
|
createTrackCalendarHandler(cat)(w, r)
|
|
default:
|
|
w.Header().Set("Allow", "GET, POST")
|
|
writeError(w, http.StatusMethodNotAllowed, "method_not_allowed", "use GET or POST")
|
|
}
|
|
}
|
|
}
|
|
|
|
// getTrackCalendarHandler godoc
|
|
// @Summary Get Track Calendar
|
|
// @Description Returns the full calendar of track active periods.
|
|
// @Tags tracks
|
|
// @Produce json
|
|
// @Success 200 {object} transport.TrackCalendarResponse "List of scheduled active periods"
|
|
// @Failure 500 {object} map[string]interface{} "Internal error"
|
|
// @Router /api/tracks/calendar [get]
|
|
func getTrackCalendarHandler(cat *catalog.Service) http.HandlerFunc {
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
entries, err := cat.GetTrackCalendar(r.Context())
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, "internal", err.Error())
|
|
return
|
|
}
|
|
|
|
items := make([]transport.TrackCalendarEntry, len(entries))
|
|
for i, entry := range entries {
|
|
items[i] = transport.TrackCalendarEntry{
|
|
ID: entry.ID,
|
|
TrackID: entry.TrackID,
|
|
StartAt: entry.StartAt,
|
|
EndAt: entry.EndAt,
|
|
}
|
|
}
|
|
writeJSON(w, http.StatusOK, transport.TrackCalendarResponse{Items: items})
|
|
}
|
|
}
|
|
|
|
// createTrackCalendarHandler godoc
|
|
// @Summary Schedule Track Active Period
|
|
// @Description Schedules an active period for a physical track.
|
|
// @Tags tracks
|
|
// @Accept json
|
|
// @Produce json
|
|
// @Param payload body transport.TrackCalendarCreateRequest true "Calendar entry details"
|
|
// @Success 201 {object} transport.TrackCalendarEntry "Created calendar entry"
|
|
// @Failure 400 {object} map[string]interface{} "Invalid input"
|
|
// @Failure 404 {object} map[string]interface{} "Track not found"
|
|
// @Failure 500 {object} map[string]interface{} "Internal error"
|
|
// @Router /api/tracks/calendar [post]
|
|
func createTrackCalendarHandler(cat *catalog.Service) http.HandlerFunc {
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
var req transport.TrackCalendarCreateRequest
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
writeError(w, http.StatusBadRequest, "invalid_json", err.Error())
|
|
return
|
|
}
|
|
|
|
entry, err := cat.CreateTrackCalendar(r.Context(), req.TrackID, req.StartAt, req.EndAt)
|
|
if err != nil {
|
|
if errors.Is(err, catalog.ErrNotFound) {
|
|
writeError(w, http.StatusNotFound, "not_found", err.Error())
|
|
return
|
|
}
|
|
if errors.Is(err, catalog.ErrInvalidInput) {
|
|
writeError(w, http.StatusBadRequest, "invalid_input", err.Error())
|
|
return
|
|
}
|
|
writeError(w, http.StatusInternalServerError, "internal", err.Error())
|
|
return
|
|
}
|
|
|
|
writeJSON(w, http.StatusCreated, transport.TrackCalendarEntry{
|
|
ID: entry.ID,
|
|
TrackID: entry.TrackID,
|
|
StartAt: entry.StartAt,
|
|
EndAt: entry.EndAt,
|
|
})
|
|
}
|
|
}
|
|
|
|
// deleteTrackCalendarHandler godoc
|
|
// @Summary Delete Scheduled Period
|
|
// @Description Deletes a scheduled active period by ID.
|
|
// @Tags tracks
|
|
// @Param id path int true "Calendar Entry ID"
|
|
// @Success 204 "No Content"
|
|
// @Failure 404 {object} map[string]interface{} "Not found"
|
|
// @Failure 500 {object} map[string]interface{} "Internal error"
|
|
// @Router /api/tracks/calendar/{id} [delete]
|
|
func deleteTrackCalendarHandler(cat *catalog.Service) http.HandlerFunc {
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
idStr := strings.TrimPrefix(r.URL.Path, "/api/tracks/calendar/")
|
|
id, err := strconv.Atoi(idStr)
|
|
if err != nil {
|
|
writeError(w, http.StatusBadRequest, "invalid_id", "id must be an integer")
|
|
return
|
|
}
|
|
|
|
err = cat.DeleteTrackCalendar(r.Context(), id)
|
|
if err != nil {
|
|
if errors.Is(err, catalog.ErrNotFound) {
|
|
writeError(w, http.StatusNotFound, "not_found", err.Error())
|
|
return
|
|
}
|
|
writeError(w, http.StatusInternalServerError, "internal", err.Error())
|
|
return
|
|
}
|
|
w.WriteHeader(http.StatusNoContent)
|
|
}
|
|
}
|