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:
44
backend/cmd/server/main.go
Normal file
44
backend/cmd/server/main.go
Normal 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)
|
||||
}
|
||||
}
|
||||
135
backend/internal/api/exercises.go
Normal file
135
backend/internal/api/exercises.go
Normal 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)
|
||||
}
|
||||
36
backend/internal/api/handlers.go
Normal file
36
backend/internal/api/handlers.go
Normal 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)
|
||||
}
|
||||
35
backend/internal/api/interfaces.go
Normal file
35
backend/internal/api/interfaces.go
Normal 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)
|
||||
}
|
||||
127
backend/internal/api/plans.go
Normal file
127
backend/internal/api/plans.go
Normal 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)
|
||||
}
|
||||
55
backend/internal/api/router.go
Normal file
55
backend/internal/api/router.go
Normal 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")
|
||||
}
|
||||
}
|
||||
166
backend/internal/api/sessions.go
Normal file
166
backend/internal/api/sessions.go
Normal 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)
|
||||
}
|
||||
Reference in New Issue
Block a user