feat: Add REST API handlers for exercises, plans, and sessions

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Jan Novak
2026-01-19 19:50:09 +01:00
parent cd435a6569
commit e43373bfcf
7 changed files with 598 additions and 0 deletions

View File

@@ -0,0 +1,44 @@
package main
import (
"context"
"log/slog"
"net/http"
"os"
"training-tracker/internal/api"
"training-tracker/internal/storage"
)
func main() {
logger := slog.New(slog.NewJSONHandler(os.Stdout, nil))
slog.SetDefault(logger)
ctx := context.Background()
db, err := storage.NewDB(ctx)
if err != nil {
slog.Error("failed to connect to database", "error", err)
os.Exit(1)
}
defer db.Close()
if err := db.Migrate(ctx); err != nil {
slog.Error("failed to run migrations", "error", err)
os.Exit(1)
}
slog.Info("database migrations completed")
router := api.NewRouter(db)
port := os.Getenv("PORT")
if port == "" {
port = "8080"
}
slog.Info("server starting", "port", port)
if err := http.ListenAndServe(":"+port, router); err != nil {
slog.Error("server failed", "error", err)
os.Exit(1)
}
}

View File

@@ -0,0 +1,135 @@
package api
import (
"database/sql"
"net/http"
"training-tracker/internal/models"
)
type ExerciseHandler struct {
repo ExerciseRepository
}
func NewExerciseHandler(repo ExerciseRepository) *ExerciseHandler {
return &ExerciseHandler{repo: repo}
}
func (h *ExerciseHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
id, _ := parseID(r, "/api/exercises/")
switch r.Method {
case http.MethodGet:
if id > 0 {
h.getByID(w, r, id)
} else {
h.list(w, r)
}
case http.MethodPost:
h.create(w, r)
case http.MethodPut:
if id > 0 {
h.update(w, r, id)
} else {
respondError(w, http.StatusBadRequest, "exercise ID required")
}
case http.MethodDelete:
if id > 0 {
h.delete(w, r, id)
} else {
respondError(w, http.StatusBadRequest, "exercise ID required")
}
default:
respondError(w, http.StatusMethodNotAllowed, "method not allowed")
}
}
func (h *ExerciseHandler) list(w http.ResponseWriter, r *http.Request) {
exercises, err := h.repo.List(r.Context())
if err != nil {
respondError(w, http.StatusInternalServerError, "failed to list exercises")
return
}
if exercises == nil {
exercises = []models.Exercise{}
}
respondJSON(w, http.StatusOK, exercises)
}
func (h *ExerciseHandler) getByID(w http.ResponseWriter, r *http.Request, id int64) {
exercise, err := h.repo.GetByID(r.Context(), id)
if err != nil {
respondError(w, http.StatusInternalServerError, "failed to get exercise")
return
}
if exercise == nil {
respondError(w, http.StatusNotFound, "exercise not found")
return
}
respondJSON(w, http.StatusOK, exercise)
}
func (h *ExerciseHandler) create(w http.ResponseWriter, r *http.Request) {
var req models.CreateExerciseRequest
if err := decodeJSON(r, &req); err != nil {
respondError(w, http.StatusBadRequest, "invalid request body")
return
}
if req.Name == "" {
respondError(w, http.StatusBadRequest, "name is required")
return
}
if req.Type != models.ExerciseTypeStrength && req.Type != models.ExerciseTypeCardio {
respondError(w, http.StatusBadRequest, "type must be 'strength' or 'cardio'")
return
}
exercise, err := h.repo.Create(r.Context(), &req)
if err != nil {
respondError(w, http.StatusInternalServerError, "failed to create exercise")
return
}
respondJSON(w, http.StatusCreated, exercise)
}
func (h *ExerciseHandler) update(w http.ResponseWriter, r *http.Request, id int64) {
var req models.CreateExerciseRequest
if err := decodeJSON(r, &req); err != nil {
respondError(w, http.StatusBadRequest, "invalid request body")
return
}
if req.Name == "" {
respondError(w, http.StatusBadRequest, "name is required")
return
}
if req.Type != models.ExerciseTypeStrength && req.Type != models.ExerciseTypeCardio {
respondError(w, http.StatusBadRequest, "type must be 'strength' or 'cardio'")
return
}
exercise, err := h.repo.Update(r.Context(), id, &req)
if err != nil {
respondError(w, http.StatusInternalServerError, "failed to update exercise")
return
}
if exercise == nil {
respondError(w, http.StatusNotFound, "exercise not found")
return
}
respondJSON(w, http.StatusOK, exercise)
}
func (h *ExerciseHandler) delete(w http.ResponseWriter, r *http.Request, id int64) {
err := h.repo.Delete(r.Context(), id)
if err == sql.ErrNoRows {
respondError(w, http.StatusNotFound, "exercise not found")
return
}
if err != nil {
respondError(w, http.StatusInternalServerError, "failed to delete exercise")
return
}
w.WriteHeader(http.StatusNoContent)
}

View File

@@ -0,0 +1,36 @@
package api
import (
"encoding/json"
"net/http"
"strconv"
"strings"
)
// Response helpers
func respondJSON(w http.ResponseWriter, status int, data interface{}) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
if data != nil {
json.NewEncoder(w).Encode(data)
}
}
func respondError(w http.ResponseWriter, status int, message string) {
respondJSON(w, status, map[string]string{"error": message})
}
func parseID(r *http.Request, prefix string) (int64, error) {
path := strings.TrimPrefix(r.URL.Path, prefix)
path = strings.TrimSuffix(path, "/")
parts := strings.Split(path, "/")
if len(parts) == 0 {
return 0, nil
}
return strconv.ParseInt(parts[0], 10, 64)
}
func decodeJSON(r *http.Request, v interface{}) error {
return json.NewDecoder(r.Body).Decode(v)
}

View File

@@ -0,0 +1,35 @@
package api
import (
"context"
"training-tracker/internal/models"
)
// ExerciseRepository defines the interface for exercise storage operations
type ExerciseRepository interface {
List(ctx context.Context) ([]models.Exercise, error)
GetByID(ctx context.Context, id int64) (*models.Exercise, error)
Create(ctx context.Context, req *models.CreateExerciseRequest) (*models.Exercise, error)
Update(ctx context.Context, id int64, req *models.CreateExerciseRequest) (*models.Exercise, error)
Delete(ctx context.Context, id int64) error
}
// PlanRepository defines the interface for training plan storage operations
type PlanRepository interface {
List(ctx context.Context) ([]models.TrainingPlan, error)
GetByID(ctx context.Context, id int64) (*models.TrainingPlan, error)
Create(ctx context.Context, req *models.CreatePlanRequest) (*models.TrainingPlan, error)
Update(ctx context.Context, id int64, req *models.CreatePlanRequest) (*models.TrainingPlan, error)
Delete(ctx context.Context, id int64) error
}
// SessionRepository defines the interface for session storage operations
type SessionRepository interface {
List(ctx context.Context) ([]models.Session, error)
GetByID(ctx context.Context, id int64) (*models.Session, error)
Create(ctx context.Context, req *models.CreateSessionRequest) (*models.Session, error)
Update(ctx context.Context, id int64, req *models.CreateSessionRequest) (*models.Session, error)
Delete(ctx context.Context, id int64) error
AddEntry(ctx context.Context, sessionID int64, req *models.CreateSessionEntryRequest) (*models.SessionEntry, error)
}

View File

@@ -0,0 +1,127 @@
package api
import (
"database/sql"
"net/http"
"training-tracker/internal/models"
)
type PlanHandler struct {
repo PlanRepository
}
func NewPlanHandler(repo PlanRepository) *PlanHandler {
return &PlanHandler{repo: repo}
}
func (h *PlanHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
id, _ := parseID(r, "/api/plans/")
switch r.Method {
case http.MethodGet:
if id > 0 {
h.getByID(w, r, id)
} else {
h.list(w, r)
}
case http.MethodPost:
h.create(w, r)
case http.MethodPut:
if id > 0 {
h.update(w, r, id)
} else {
respondError(w, http.StatusBadRequest, "plan ID required")
}
case http.MethodDelete:
if id > 0 {
h.delete(w, r, id)
} else {
respondError(w, http.StatusBadRequest, "plan ID required")
}
default:
respondError(w, http.StatusMethodNotAllowed, "method not allowed")
}
}
func (h *PlanHandler) list(w http.ResponseWriter, r *http.Request) {
plans, err := h.repo.List(r.Context())
if err != nil {
respondError(w, http.StatusInternalServerError, "failed to list plans")
return
}
if plans == nil {
plans = []models.TrainingPlan{}
}
respondJSON(w, http.StatusOK, plans)
}
func (h *PlanHandler) getByID(w http.ResponseWriter, r *http.Request, id int64) {
plan, err := h.repo.GetByID(r.Context(), id)
if err != nil {
respondError(w, http.StatusInternalServerError, "failed to get plan")
return
}
if plan == nil {
respondError(w, http.StatusNotFound, "plan not found")
return
}
respondJSON(w, http.StatusOK, plan)
}
func (h *PlanHandler) create(w http.ResponseWriter, r *http.Request) {
var req models.CreatePlanRequest
if err := decodeJSON(r, &req); err != nil {
respondError(w, http.StatusBadRequest, "invalid request body")
return
}
if req.Name == "" {
respondError(w, http.StatusBadRequest, "name is required")
return
}
plan, err := h.repo.Create(r.Context(), &req)
if err != nil {
respondError(w, http.StatusInternalServerError, "failed to create plan")
return
}
respondJSON(w, http.StatusCreated, plan)
}
func (h *PlanHandler) update(w http.ResponseWriter, r *http.Request, id int64) {
var req models.CreatePlanRequest
if err := decodeJSON(r, &req); err != nil {
respondError(w, http.StatusBadRequest, "invalid request body")
return
}
if req.Name == "" {
respondError(w, http.StatusBadRequest, "name is required")
return
}
plan, err := h.repo.Update(r.Context(), id, &req)
if err != nil {
respondError(w, http.StatusInternalServerError, "failed to update plan")
return
}
if plan == nil {
respondError(w, http.StatusNotFound, "plan not found")
return
}
respondJSON(w, http.StatusOK, plan)
}
func (h *PlanHandler) delete(w http.ResponseWriter, r *http.Request, id int64) {
err := h.repo.Delete(r.Context(), id)
if err == sql.ErrNoRows {
respondError(w, http.StatusNotFound, "plan not found")
return
}
if err != nil {
respondError(w, http.StatusInternalServerError, "failed to delete plan")
return
}
w.WriteHeader(http.StatusNoContent)
}

View File

@@ -0,0 +1,55 @@
package api
import (
"net/http"
"strings"
"training-tracker/internal/storage"
)
// Router sets up and returns the HTTP router
type Router struct {
exerciseHandler *ExerciseHandler
planHandler *PlanHandler
sessionHandler *SessionHandler
}
// NewRouter creates a new router with all handlers
func NewRouter(db *storage.DB) *Router {
exerciseRepo := storage.NewExerciseRepository(db)
planRepo := storage.NewPlanRepository(db)
sessionRepo := storage.NewSessionRepository(db)
return &Router{
exerciseHandler: NewExerciseHandler(exerciseRepo),
planHandler: NewPlanHandler(planRepo),
sessionHandler: NewSessionHandler(sessionRepo),
}
}
// ServeHTTP implements http.Handler
func (router *Router) ServeHTTP(w http.ResponseWriter, r *http.Request) {
// Enable CORS
w.Header().Set("Access-Control-Allow-Origin", "*")
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS")
w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization")
if r.Method == http.MethodOptions {
w.WriteHeader(http.StatusOK)
return
}
path := r.URL.Path
switch {
case strings.HasPrefix(path, "/api/exercises"):
router.exerciseHandler.ServeHTTP(w, r)
case strings.HasPrefix(path, "/api/plans"):
router.planHandler.ServeHTTP(w, r)
case strings.HasPrefix(path, "/api/sessions"):
router.sessionHandler.ServeHTTP(w, r)
case path == "/api/health":
respondJSON(w, http.StatusOK, map[string]string{"status": "ok"})
default:
respondError(w, http.StatusNotFound, "not found")
}
}

View File

@@ -0,0 +1,166 @@
package api
import (
"database/sql"
"net/http"
"strings"
"training-tracker/internal/models"
)
type SessionHandler struct {
repo SessionRepository
}
func NewSessionHandler(repo SessionRepository) *SessionHandler {
return &SessionHandler{repo: repo}
}
func (h *SessionHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
path := strings.TrimPrefix(r.URL.Path, "/api/sessions")
path = strings.TrimPrefix(path, "/")
parts := strings.Split(path, "/")
if len(parts) >= 2 && parts[1] == "entries" {
id, err := parseID(r, "/api/sessions/")
if err != nil || id == 0 {
respondError(w, http.StatusBadRequest, "session ID required")
return
}
if r.Method == http.MethodPost {
h.addEntry(w, r, id)
} else {
respondError(w, http.StatusMethodNotAllowed, "method not allowed")
}
return
}
id, _ := parseID(r, "/api/sessions/")
switch r.Method {
case http.MethodGet:
if id > 0 {
h.getByID(w, r, id)
} else {
h.list(w, r)
}
case http.MethodPost:
h.create(w, r)
case http.MethodPut:
if id > 0 {
h.update(w, r, id)
} else {
respondError(w, http.StatusBadRequest, "session ID required")
}
case http.MethodDelete:
if id > 0 {
h.delete(w, r, id)
} else {
respondError(w, http.StatusBadRequest, "session ID required")
}
default:
respondError(w, http.StatusMethodNotAllowed, "method not allowed")
}
}
func (h *SessionHandler) list(w http.ResponseWriter, r *http.Request) {
sessions, err := h.repo.List(r.Context())
if err != nil {
respondError(w, http.StatusInternalServerError, "failed to list sessions")
return
}
if sessions == nil {
sessions = []models.Session{}
}
respondJSON(w, http.StatusOK, sessions)
}
func (h *SessionHandler) getByID(w http.ResponseWriter, r *http.Request, id int64) {
session, err := h.repo.GetByID(r.Context(), id)
if err != nil {
respondError(w, http.StatusInternalServerError, "failed to get session")
return
}
if session == nil {
respondError(w, http.StatusNotFound, "session not found")
return
}
respondJSON(w, http.StatusOK, session)
}
func (h *SessionHandler) create(w http.ResponseWriter, r *http.Request) {
var req models.CreateSessionRequest
if err := decodeJSON(r, &req); err != nil {
respondError(w, http.StatusBadRequest, "invalid request body")
return
}
if req.Date.IsZero() {
respondError(w, http.StatusBadRequest, "date is required")
return
}
session, err := h.repo.Create(r.Context(), &req)
if err != nil {
respondError(w, http.StatusInternalServerError, "failed to create session")
return
}
respondJSON(w, http.StatusCreated, session)
}
func (h *SessionHandler) update(w http.ResponseWriter, r *http.Request, id int64) {
var req models.CreateSessionRequest
if err := decodeJSON(r, &req); err != nil {
respondError(w, http.StatusBadRequest, "invalid request body")
return
}
if req.Date.IsZero() {
respondError(w, http.StatusBadRequest, "date is required")
return
}
session, err := h.repo.Update(r.Context(), id, &req)
if err != nil {
respondError(w, http.StatusInternalServerError, "failed to update session")
return
}
if session == nil {
respondError(w, http.StatusNotFound, "session not found")
return
}
respondJSON(w, http.StatusOK, session)
}
func (h *SessionHandler) delete(w http.ResponseWriter, r *http.Request, id int64) {
err := h.repo.Delete(r.Context(), id)
if err == sql.ErrNoRows {
respondError(w, http.StatusNotFound, "session not found")
return
}
if err != nil {
respondError(w, http.StatusInternalServerError, "failed to delete session")
return
}
w.WriteHeader(http.StatusNoContent)
}
func (h *SessionHandler) addEntry(w http.ResponseWriter, r *http.Request, sessionID int64) {
var req models.CreateSessionEntryRequest
if err := decodeJSON(r, &req); err != nil {
respondError(w, http.StatusBadRequest, "invalid request body")
return
}
if req.ExerciseID == 0 {
respondError(w, http.StatusBadRequest, "exercise_id is required")
return
}
entry, err := h.repo.AddEntry(r.Context(), sessionID, &req)
if err != nil {
respondError(w, http.StatusInternalServerError, "failed to add session entry")
return
}
respondJSON(w, http.StatusCreated, entry)
}