Added exercises; list improvements #38
@@ -0,0 +1,63 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
"net/http"
|
||||
|
||||
"github.com/robindittmar/dttmr-api/internal/api/request"
|
||||
"github.com/robindittmar/dttmr-api/internal/api/response"
|
||||
"github.com/robindittmar/dttmr-api/internal/domain"
|
||||
)
|
||||
|
||||
type ExerciseHandler struct {
|
||||
ExerciseService *domain.ExerciseService
|
||||
}
|
||||
|
||||
func NewExerciseHandler(exerciseService *domain.ExerciseService) *ExerciseHandler {
|
||||
return &ExerciseHandler{ExerciseService: exerciseService}
|
||||
}
|
||||
|
||||
// GetExercises handles fetching the list of exercises
|
||||
//
|
||||
// @Summary Get exercises route
|
||||
// @Description Gets a list of all exercises
|
||||
// @Tags Exercise
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param page query int false "page"
|
||||
// @Param count query int false "count"
|
||||
// @Success 200 {object} response.Paginated[domain.Exercise]
|
||||
// @Error 400 {object} response.ErrorResponse "failed to decode request body"
|
||||
// @Error 400 {object} response.ErrorResponse "invalid value for page"
|
||||
// @Error 400 {object} response.ErrorResponse "invalid value for count"
|
||||
// @Error 500 {object} response.ErrorResponse "failed to get exercises"
|
||||
// @Router /exercises [get]
|
||||
func (h *ExerciseHandler) GetExercises(w http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
|
||||
page, count, err := request.ParsePaginatedQueryParams(r)
|
||||
if err != nil {
|
||||
response.Error(ctx, w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
exercises, err := h.ExerciseService.GetExercises(ctx, page, count)
|
||||
if err != nil {
|
||||
slog.ErrorContext(ctx, "failed to get exercises", slog.Any("error", err))
|
||||
response.Error(ctx, w, http.StatusInternalServerError, "failed to get exercises")
|
||||
return
|
||||
}
|
||||
|
||||
total, err := h.ExerciseService.CountExercises(ctx)
|
||||
if err != nil {
|
||||
slog.ErrorContext(ctx, "failed to count exercises", slog.Any("error", err))
|
||||
response.Error(ctx, w, http.StatusInternalServerError, "failed to get exercises")
|
||||
return
|
||||
}
|
||||
|
||||
response.JSON(ctx, w, http.StatusOK, response.Paginated[domain.Exercise]{
|
||||
Count: len(exercises),
|
||||
Total: total,
|
||||
Data: exercises,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
package request
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"strconv"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrFailedToDecodeRequestQuery = errors.New("failed to decode request query")
|
||||
ErrInvalidPageValue = errors.New("invalid value for page")
|
||||
ErrInvalidCountValue = errors.New("invalid value for count")
|
||||
)
|
||||
|
||||
func ParsePaginatedQueryParams(r *http.Request) (int, int, error) {
|
||||
ctx := r.Context()
|
||||
|
||||
pageStr := r.URL.Query().Get("page")
|
||||
if pageStr == "" {
|
||||
pageStr = "1"
|
||||
}
|
||||
countStr := r.URL.Query().Get("count")
|
||||
if countStr == "" {
|
||||
countStr = "10"
|
||||
}
|
||||
|
||||
page, err := strconv.Atoi(pageStr)
|
||||
if err != nil {
|
||||
slog.ErrorContext(ctx,
|
||||
"failed to read page from query",
|
||||
slog.String("page", pageStr))
|
||||
return 0, 0, ErrFailedToDecodeRequestQuery
|
||||
}
|
||||
|
||||
count, err := strconv.Atoi(countStr)
|
||||
if err != nil {
|
||||
slog.ErrorContext(ctx,
|
||||
"failed to read count from query",
|
||||
slog.String("count", countStr))
|
||||
return 0, 0, ErrFailedToDecodeRequestQuery
|
||||
}
|
||||
|
||||
if page < 1 {
|
||||
slog.ErrorContext(ctx,
|
||||
"page parameter is invalid",
|
||||
slog.Int("page", page))
|
||||
return 0, 0, ErrInvalidPageValue
|
||||
}
|
||||
if count <= 0 {
|
||||
slog.ErrorContext(ctx,
|
||||
"count parameter is invalid",
|
||||
slog.Int("count", count))
|
||||
return 0, 0, ErrInvalidCountValue
|
||||
}
|
||||
|
||||
return page, count, nil
|
||||
}
|
||||
@@ -26,11 +26,13 @@ func NewMux(cfg Config) http.Handler {
|
||||
userService := domain.NewUserService(store.User)
|
||||
registrationService := domain.NewRegistrationService(store, userService, inviteService)
|
||||
listService := domain.NewListService(store, store.List)
|
||||
exerciseService := domain.NewExerciseService(store.Exercise)
|
||||
|
||||
authHandler := handler.NewAuthHandler(authService)
|
||||
inviteHandler := handler.NewInviteHandler(inviteService)
|
||||
userHandler := handler.NewUserHandler(userService, authService, registrationService)
|
||||
listHandler := handler.NewListHandler(listService, userService)
|
||||
exerciseHandler := handler.NewExerciseHandler(exerciseService)
|
||||
|
||||
protected := middleware.WithJWT(authService)
|
||||
|
||||
@@ -70,6 +72,9 @@ func NewMux(cfg Config) http.Handler {
|
||||
apiMux.Handle("POST /lists/items/{id}/complete", protected(listHandler.SetListItemCompleted))
|
||||
apiMux.Handle("GET /lists/{id}", protected(listHandler.GetListItems))
|
||||
|
||||
// Exercises
|
||||
apiMux.Handle("GET /exercises", protected(exerciseHandler.GetExercises))
|
||||
|
||||
mux := http.NewServeMux()
|
||||
mux.Handle("/api/v1/", http.StripPrefix("/api/v1", apiMux))
|
||||
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
package domain
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
)
|
||||
|
||||
type ExerciseRepository interface {
|
||||
CreateExercise(ctx context.Context) (*Exercise, error)
|
||||
DeleteExercise(ctx context.Context, id string) error
|
||||
GetExercises(ctx context.Context, offset int, count int) ([]Exercise, error)
|
||||
CountExercises(ctx context.Context) (int, error)
|
||||
}
|
||||
|
||||
type Exercise struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Equipment []Equipment `json:"equipment"`
|
||||
Metric Metric `json:"metric"`
|
||||
Load Load `json:"load"`
|
||||
Tags []string `json:"tags"`
|
||||
Notes *string `json:"notes"`
|
||||
ModifiedAt time.Time `json:"modified_at"`
|
||||
}
|
||||
|
||||
type ExerciseService struct {
|
||||
repo ExerciseRepository
|
||||
}
|
||||
|
||||
func NewExerciseService(r ExerciseRepository) *ExerciseService {
|
||||
return &ExerciseService{repo: r}
|
||||
}
|
||||
|
||||
func (s *ExerciseService) GetExercises(ctx context.Context, page int, count int) ([]Exercise, error) {
|
||||
offset := (page - 1) * count
|
||||
return s.repo.GetExercises(ctx, offset, count)
|
||||
}
|
||||
|
||||
func (s *ExerciseService) CountExercises(ctx context.Context) (int, error) {
|
||||
return s.repo.CountExercises(ctx)
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
package domain
|
||||
|
||||
import (
|
||||
"database/sql/driver"
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type Equipment int
|
||||
|
||||
const (
|
||||
EquipmentUnknown Equipment = iota
|
||||
EquipmentFloor
|
||||
EquipmentRings
|
||||
EquipmentPullUpBar
|
||||
EquipmentParallelBars
|
||||
EquipmentLowBar
|
||||
EquipmentParallettes
|
||||
EquipmentResistanceBand
|
||||
)
|
||||
|
||||
var equipmentNames = [...]string{
|
||||
EquipmentUnknown: "",
|
||||
EquipmentFloor: "floor",
|
||||
EquipmentRings: "rings",
|
||||
EquipmentPullUpBar: "pull_up_bar",
|
||||
EquipmentParallelBars: "parallel_bars",
|
||||
EquipmentLowBar: "low_bar",
|
||||
EquipmentParallettes: "parallettes",
|
||||
EquipmentResistanceBand: "resistance_band",
|
||||
}
|
||||
|
||||
var equipmentValues = func() map[string]Equipment {
|
||||
m := make(map[string]Equipment, len(equipmentNames))
|
||||
for i, name := range equipmentNames {
|
||||
m[name] = Equipment(i)
|
||||
}
|
||||
return m
|
||||
}()
|
||||
|
||||
func (e Equipment) String() string {
|
||||
if e < 0 || int(e) > len(equipmentNames) {
|
||||
return ""
|
||||
}
|
||||
return equipmentNames[e]
|
||||
}
|
||||
|
||||
func ParseEquipment(s string) (Equipment, error) {
|
||||
if e, ok := equipmentValues[s]; ok {
|
||||
return e, nil
|
||||
}
|
||||
return EquipmentUnknown, fmt.Errorf("equipment: unknown value %q", s)
|
||||
}
|
||||
|
||||
//func (e Equipment) MarshalJSON() ([]byte, error) {
|
||||
// s := e.String()
|
||||
// if s == "" {
|
||||
// return nil, fmt.Errorf("equipment: cannot marshal value %d", int(e))
|
||||
// }
|
||||
// return json.Marshal(s)
|
||||
//}
|
||||
//
|
||||
//func (e *Equipment) UnmarshalJSON(data []byte) error {
|
||||
// var s string
|
||||
// if err := json.Unmarshal(data, &s); err != nil {
|
||||
// return err
|
||||
// }
|
||||
//
|
||||
// v, err := ParseEquipment(s)
|
||||
// if err != nil {
|
||||
// return err
|
||||
// }
|
||||
//
|
||||
// *e = v
|
||||
// return nil
|
||||
//}
|
||||
|
||||
type EquipmentSet []Equipment
|
||||
|
||||
func (s *EquipmentSet) Scan(src any) error {
|
||||
if src == nil {
|
||||
*s = nil
|
||||
return nil
|
||||
}
|
||||
|
||||
var raw string
|
||||
switch v := src.(type) {
|
||||
case string:
|
||||
raw = v
|
||||
case []byte:
|
||||
raw = string(v)
|
||||
default:
|
||||
return fmt.Errorf("equipment: cannot scan %T", src)
|
||||
}
|
||||
|
||||
names, err := parseTextArray(raw)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
out := make(EquipmentSet, len(names))
|
||||
for i, name := range names {
|
||||
e, err := ParseEquipment(name)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
out[i] = e
|
||||
}
|
||||
*s = out
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s EquipmentSet) Value() (driver.Value, error) {
|
||||
names := make([]string, len(s))
|
||||
for i, e := range s {
|
||||
name := e.String()
|
||||
if name == "" {
|
||||
return nil, fmt.Errorf("equipment: cannot store value %d", int(e))
|
||||
}
|
||||
names[i] = name
|
||||
}
|
||||
|
||||
return "{" + strings.Join(names, ",") + "}", nil
|
||||
}
|
||||
|
||||
func parseTextArray(raw string) ([]string, error) {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if len(raw) < 2 || raw[0] != '{' || raw[len(raw)-1] != '}' {
|
||||
return nil, fmt.Errorf("equipment: malformed array %q", raw)
|
||||
}
|
||||
if inner := raw[1 : len(raw)-1]; inner != "" {
|
||||
return strings.Split(inner, ","), nil
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
package domain
|
||||
|
||||
import (
|
||||
"database/sql/driver"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
type Load int
|
||||
|
||||
const (
|
||||
LoadUnknown Load = iota
|
||||
LoadBodyweight
|
||||
LoadExternal
|
||||
)
|
||||
|
||||
var loadNames = [...]string{
|
||||
LoadUnknown: "",
|
||||
LoadBodyweight: "bodyweight",
|
||||
LoadExternal: "external",
|
||||
}
|
||||
|
||||
var loadValues = func() map[string]Load {
|
||||
m := make(map[string]Load, len(loadNames))
|
||||
for i, name := range loadNames {
|
||||
m[name] = Load(i)
|
||||
}
|
||||
return m
|
||||
}()
|
||||
|
||||
func (l Load) String() string {
|
||||
if l < 0 || int(l) > len(loadNames) {
|
||||
return ""
|
||||
}
|
||||
return loadNames[l]
|
||||
}
|
||||
|
||||
func ParseLoad(s string) (Load, error) {
|
||||
if l, ok := loadValues[s]; ok {
|
||||
return l, nil
|
||||
}
|
||||
return LoadUnknown, fmt.Errorf("load: unknown value %q", s)
|
||||
}
|
||||
|
||||
//func (l Load) MarshalJSON() ([]byte, error) {
|
||||
// s := l.String()
|
||||
// if s == "" {
|
||||
// return nil, fmt.Errorf("load: cannot marshal value %d", int(l))
|
||||
// }
|
||||
// return json.Marshal(s)
|
||||
//}
|
||||
//
|
||||
//func (l *Load) UnmarshalJSON(data []byte) error {
|
||||
// var s string
|
||||
// if err := json.Unmarshal(data, &s); err != nil {
|
||||
// return err
|
||||
// }
|
||||
//
|
||||
// v, err := ParseLoad(s)
|
||||
// if err != nil {
|
||||
// return err
|
||||
// }
|
||||
//
|
||||
// *l = v
|
||||
// return nil
|
||||
//}
|
||||
|
||||
func (l *Load) Scan(src any) error {
|
||||
var s string
|
||||
switch v := src.(type) {
|
||||
case nil:
|
||||
return fmt.Errorf("load: unexpected NULL")
|
||||
case string:
|
||||
s = v
|
||||
case []byte:
|
||||
s = string(v)
|
||||
default:
|
||||
return fmt.Errorf("load: cannot scan %T", src)
|
||||
}
|
||||
|
||||
parsed, err := ParseLoad(s)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
*l = parsed
|
||||
return nil
|
||||
}
|
||||
|
||||
func (l Load) Value() (driver.Value, error) {
|
||||
s := l.String()
|
||||
if s == "" {
|
||||
return nil, fmt.Errorf("load: cannot store value %d", int(l))
|
||||
}
|
||||
return s, nil
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
package domain
|
||||
|
||||
import (
|
||||
"database/sql/driver"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
type Metric int
|
||||
|
||||
const (
|
||||
MetricUnknown Metric = iota
|
||||
MetricReps
|
||||
MetricSeconds
|
||||
)
|
||||
|
||||
var metricNames = [...]string{
|
||||
MetricUnknown: "",
|
||||
MetricReps: "reps",
|
||||
MetricSeconds: "seconds",
|
||||
}
|
||||
|
||||
var metricValues = func() map[string]Metric {
|
||||
m := make(map[string]Metric, len(metricNames))
|
||||
for i, name := range metricNames {
|
||||
m[name] = Metric(i)
|
||||
}
|
||||
return m
|
||||
}()
|
||||
|
||||
func (m Metric) String() string {
|
||||
if m < 0 || int(m) > len(metricNames) {
|
||||
return ""
|
||||
}
|
||||
return metricNames[m]
|
||||
}
|
||||
|
||||
func ParseMetric(s string) (Metric, error) {
|
||||
if m, ok := metricValues[s]; ok {
|
||||
return m, nil
|
||||
}
|
||||
return MetricUnknown, fmt.Errorf("metric: unknown value %q", s)
|
||||
}
|
||||
|
||||
//func (m Metric) MarshalJSON() ([]byte, error) {
|
||||
// s := m.String()
|
||||
// if s == "" {
|
||||
// return nil, fmt.Errorf("metric: cannot marshal value %d", int(m))
|
||||
// }
|
||||
// return json.Marshal(s)
|
||||
//}
|
||||
//
|
||||
//func (m *Metric) UnmarshalJSON(data []byte) error {
|
||||
// var s string
|
||||
// if err := json.Unmarshal(data, &s); err != nil {
|
||||
// return err
|
||||
// }
|
||||
//
|
||||
// v, err := ParseMetric(s)
|
||||
// if err != nil {
|
||||
// return err
|
||||
// }
|
||||
//
|
||||
// *m = v
|
||||
// return nil
|
||||
//}
|
||||
|
||||
func (m *Metric) Scan(src any) error {
|
||||
var s string
|
||||
switch v := src.(type) {
|
||||
case nil:
|
||||
return fmt.Errorf("metric: unexpected NULL")
|
||||
case string:
|
||||
s = v
|
||||
case []byte:
|
||||
s = string(v)
|
||||
default:
|
||||
return fmt.Errorf("metric: cannot scan %T", src)
|
||||
}
|
||||
|
||||
parsed, err := ParseMetric(s)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
*m = parsed
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m Metric) Value() (driver.Value, error) {
|
||||
s := m.String()
|
||||
if s == "" {
|
||||
return nil, fmt.Errorf("metric: cannot store value %d", int(m))
|
||||
}
|
||||
return s, nil
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
"github.com/robindittmar/dttmr-api/internal/domain"
|
||||
)
|
||||
|
||||
var m = pgtype.NewMap()
|
||||
|
||||
type ExerciseRepo struct {
|
||||
Repo
|
||||
}
|
||||
|
||||
func (r *ExerciseRepo) CreateExercise(ctx context.Context) (*domain.Exercise, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (r *ExerciseRepo) DeleteExercise(ctx context.Context, id string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *ExerciseRepo) GetExercises(ctx context.Context, offset int, count int) ([]domain.Exercise, error) {
|
||||
rows, err := r.conn(ctx).QueryContext(ctx,
|
||||
"SELECT id, name, equipment, metric, load, tags, notes, modified_at FROM exercises WHERE user_id IS NULL ORDER BY modified_at DESC OFFSET $1 LIMIT $2",
|
||||
offset, count,
|
||||
)
|
||||
if err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, fmt.Errorf("failed to get exercises: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
exercises := make([]domain.Exercise, 0, count)
|
||||
for rows.Next() {
|
||||
var e domain.Exercise
|
||||
err = rows.Scan(&e.ID, &e.Name, (*domain.EquipmentSet)(&e.Equipment), &e.Metric, &e.Load, m.SQLScanner(&e.Tags), &e.Notes, &e.ModifiedAt)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
exercises = append(exercises, e)
|
||||
}
|
||||
|
||||
return exercises, nil
|
||||
}
|
||||
|
||||
func (r *ExerciseRepo) CountExercises(ctx context.Context) (int, error) {
|
||||
var count int
|
||||
err := r.conn(ctx).QueryRowContext(ctx,
|
||||
"SELECT COUNT(*) FROM exercises",
|
||||
).Scan(&count)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("failed to count exercises: %w", err)
|
||||
}
|
||||
|
||||
return count, nil
|
||||
}
|
||||
@@ -4,10 +4,11 @@ import "database/sql"
|
||||
|
||||
type Store struct {
|
||||
*Transactor
|
||||
Auth *AuthRepo
|
||||
Invite *InviteRepo
|
||||
List *ListRepo
|
||||
User *UserRepo
|
||||
Auth *AuthRepo
|
||||
Invite *InviteRepo
|
||||
List *ListRepo
|
||||
User *UserRepo
|
||||
Exercise *ExerciseRepo
|
||||
}
|
||||
|
||||
func NewStore(db *sql.DB) *Store {
|
||||
@@ -19,5 +20,6 @@ func NewStore(db *sql.DB) *Store {
|
||||
Invite: &InviteRepo{r},
|
||||
List: &ListRepo{r},
|
||||
User: &UserRepo{r},
|
||||
Exercise: &ExerciseRepo{r},
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user