diff --git a/.env b/.env deleted file mode 100644 index a86729d..0000000 --- a/.env +++ /dev/null @@ -1,26 +0,0 @@ -# Server Configuration -PORT=8080 -SERVER_READ_TIMEOUT=10s -SERVER_WRITE_TIMEOUT=10s -SERVER_IDLE_TIMEOUT=60s - -# Logging Configuration -LOG_LEVEL=INFO -LOG_FORMAT=json - -# Database Configuration -DB_HOST=localhost -DB_PORT=5432 -DB_USER=postgres -DB_PASSWORD=password -DB_NAME=strive -DB_SSL_MODE=disable -DB_MAX_CONNS=25 -DB_MIN_CONNS=5 - -# JWT Configuration -JWT_SECRET=your-secret-key-change-in-production - -# Development Settings -# For development, you can use a simple secret like: dev-secret-key-12345 -# For production, use a strong, random secret key diff --git a/cmd/server/main.go b/cmd/server/main.go index 70da3a9..737cbb8 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -122,7 +122,7 @@ func setupServices(db *database.Database, cfg *config.Config) *Services { var exerciseService services.ExerciseService if cfg.Wger.Enabled { wgerClient := clients.NewWgerClient(&cfg.Wger, logger.New(cfg.Log.Level, cfg.Log.Format)) - cacheService := services.NewExerciseCacheService(exerciseRepo, wgerClient, logger.New(cfg.Log.Level, cfg.Log.Format), 24*time.Hour) + cacheService := services.NewExerciseCacheService(exerciseRepo, wgerClient, logger.New(cfg.Log.Level, cfg.Log.Format), 1*time.Minute) exerciseService = services.NewExerciseService(exerciseRepo, cacheService, logger.New(cfg.Log.Level, cfg.Log.Format)) } else { exerciseService = nil diff --git a/docker-compose.yml b/docker-compose.yml index 9a1069c..aed7f80 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -15,6 +15,7 @@ services: - DB_PASSWORD=password - DB_NAME=strive - DB_SSL_MODE=disable + - JWT_SECRET=your-very-secure-jwt-secret-32-chars-minimum-for-local-dev - JWT_ISSUER=strive-api - JWT_AUDIENCE=strive-app - JWT_CLOCK_SKEW=2m @@ -34,7 +35,7 @@ services: - WGER_API_ENABLED=true - WGER_API_BASE_URL=https://wger.de/api/v2 - WGER_API_KEY= - - WGER_API_TIMEOUT=30s + - WGER_API_TIMEOUT=120s - WGER_API_RETRY_COUNT=3 - SECURITY_HSTS_MAX_AGE=31536000 - SECURITY_HSTS_INCLUDE_SUBDOMAINS=true diff --git a/internal/clients/wger_client.go b/internal/clients/wger_client.go index 5c23805..05e4fd4 100644 --- a/internal/clients/wger_client.go +++ b/internal/clients/wger_client.go @@ -35,27 +35,48 @@ func NewWgerClient(cfg *config.WgerConfig, logger *logger.Logger) *WgerClient { func (c *WgerClient) GetAllExercises(ctx context.Context) ([]models.WgerExercise, error) { var allExercises []models.WgerExercise - url := fmt.Sprintf("%s/exerciseinfo/?limit=100", c.baseURL) + url := fmt.Sprintf("%s/exerciseinfo/?limit=20", c.baseURL) + page := 1 + + c.logger.Info("Starting to fetch all exercises with pagination", "base_url", url) for url != "" { + c.logger.Info("Fetching exercises page", "page", page, "url", url) + var response models.WgerExerciseListResponse err := c.makeRequest(ctx, url, &response) if err != nil { + c.logger.Error("Failed to fetch exercises page", "page", page, "error", err) return nil, err } allExercises = append(allExercises, response.Results...) + c.logger.Info("Successfully fetched exercises page", "page", page, "count", len(response.Results), "total_so_far", len(allExercises)) if response.Next != nil { url = *response.Next + page++ + time.Sleep(500 * time.Millisecond) } else { url = "" } } + c.logger.Info("Successfully fetched all exercises", "total_count", len(allExercises), "total_pages", page) return allExercises, nil } +func (c *WgerClient) GetExercisesLimited(ctx context.Context, limit int) ([]models.WgerExercise, error) { + url := fmt.Sprintf("%s/exerciseinfo/?limit=%d", c.baseURL, limit) + var response models.WgerExerciseListResponse + err := c.makeRequest(ctx, url, &response) + if err != nil { + return nil, err + } + + return response.Results, nil +} + func (c *WgerClient) GetExerciseByID(ctx context.Context, id int) (*models.WgerExercise, error) { url := fmt.Sprintf("%s/exerciseinfo/%d/", c.baseURL, id) var result models.WgerExercise @@ -210,13 +231,17 @@ func (c *WgerClient) makeRequest(ctx context.Context, url string, result interfa for attempt := 0; attempt <= c.retryCount; attempt++ { if attempt > 0 { backoff := time.Duration(attempt) * time.Second - c.logger.Debug("Retrying request", "attempt", attempt, "backoff", backoff, "url", url) + c.logger.Info("Retrying request", "attempt", attempt, "backoff", backoff, "url", url) time.Sleep(backoff) } + startTime := time.Now() + c.logger.Info("Starting request to wger API", "url", url, "attempt", attempt+1, "timeout", c.httpClient.Timeout) + req, err := http.NewRequestWithContext(ctx, "GET", url, http.NoBody) if err != nil { lastErr = fmt.Errorf("failed to create request: %w", err) + c.logger.Error("Failed to create request", "error", err, "url", url) continue } @@ -226,16 +251,22 @@ func (c *WgerClient) makeRequest(ctx context.Context, url string, result interfa req.Header.Set("Authorization", fmt.Sprintf("Token %s", c.apiKey)) } - c.logger.Debug("Making request to wger API", "url", url, "attempt", attempt+1) + c.logger.Info("Making HTTP request", "url", url, "attempt", attempt+1, "headers", req.Header) resp, err := c.httpClient.Do(req) + requestDuration := time.Since(startTime) + if err != nil { lastErr = fmt.Errorf("failed to make request: %w", err) - c.logger.Warn("Request failed, will retry", "error", err, "url", url, "attempt", attempt+1) + c.logger.Warn("Request failed, will retry", "error", err, "url", url, "attempt", attempt+1, "duration", requestDuration) continue } + c.logger.Info("Received response", "status", resp.StatusCode, "url", url, "duration", requestDuration) + if resp.StatusCode == http.StatusOK { + c.logger.Info("Reading response body", "url", url, "content_length", resp.ContentLength) + body, err := io.ReadAll(resp.Body) _ = resp.Body.Close() @@ -245,13 +276,20 @@ func (c *WgerClient) makeRequest(ctx context.Context, url string, result interfa continue } + previewSize := 200 + if len(body) < previewSize { + previewSize = len(body) + } + c.logger.Info("Successfully read response body", "url", url, "body_size", len(body), "body_preview", string(body[:previewSize])) + if err := json.Unmarshal(body, &result); err != nil { lastErr = fmt.Errorf("failed to unmarshal response: %w", err) - c.logger.Warn("Failed to unmarshal response, will retry", "error", err, "url", url, "attempt", attempt+1) + c.logger.Warn("Failed to unmarshal response, will retry", + "error", err, "url", url, "attempt", attempt+1, "body_preview", string(body[:previewSize])) continue } - c.logger.Debug("Successfully received response from wger API", "url", url, "attempt", attempt+1) + c.logger.Info("Successfully parsed response from wger API", "url", url, "attempt", attempt+1) return nil } diff --git a/internal/clients/wger_client_test.go b/internal/clients/wger_client_test.go index 720c171..230261f 100644 --- a/internal/clients/wger_client_test.go +++ b/internal/clients/wger_client_test.go @@ -61,24 +61,24 @@ func TestWgerClient_GetAllExercises(t *testing.T) { UUID: "test-uuid-1", Name: "Bench Press", Description: "A chest exercise", - Category: 1, - Muscles: []int{1, 2}, - Equipment: []int{1}, + Category: models.WgerCategory{ID: 1, Name: "Chest"}, + Muscles: []models.WgerMuscle{{ID: 1, Name: "Pectorals"}, {ID: 2, Name: "Triceps"}}, + Equipment: []models.WgerEquipment{{ID: 1, Name: "Barbell"}}, }, { ID: 2, UUID: "test-uuid-2", Name: "Squat", Description: "A leg exercise", - Category: 2, - Muscles: []int{3, 4}, - Equipment: []int{2}, + Category: models.WgerCategory{ID: 2, Name: "Legs"}, + Muscles: []models.WgerMuscle{{ID: 3, Name: "Quadriceps"}, {ID: 4, Name: "Glutes"}}, + Equipment: []models.WgerEquipment{{ID: 2, Name: "Barbell"}}, }, } handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { assert.Equal(t, "/exerciseinfo/", r.URL.Path) - assert.Contains(t, r.URL.RawQuery, "limit=100") + assert.Contains(t, r.URL.RawQuery, "limit=20") response := models.WgerExerciseListResponse{ Count: 2, @@ -109,9 +109,9 @@ func TestWgerClient_GetExerciseByID(t *testing.T) { UUID: "test-uuid", Name: "Bench Press", Description: "A chest exercise", - Category: 1, - Muscles: []int{1, 2}, - Equipment: []int{1}, + Category: models.WgerCategory{ID: 1, Name: "Chest"}, + Muscles: []models.WgerMuscle{{ID: 1, Name: "Pectorals"}, {ID: 2, Name: "Triceps"}}, + Equipment: []models.WgerEquipment{{ID: 1, Name: "Barbell"}}, } handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { diff --git a/internal/models/wger_models.go b/internal/models/wger_models.go index 4c582d3..b4dca08 100644 --- a/internal/models/wger_models.go +++ b/internal/models/wger_models.go @@ -1,19 +1,35 @@ package models +import ( + "encoding/json" +) + type WgerExercise struct { - ID int `json:"id"` - UUID string `json:"uuid"` - Name string `json:"name"` - Description string `json:"description"` - Category int `json:"category"` - Muscles []int `json:"muscles"` - MusclesSecondary []int `json:"muscles_secondary"` - Equipment []int `json:"equipment"` - Language int `json:"language"` - License int `json:"license"` - LicenseAuthor string `json:"license_author"` - Variations []int `json:"variations"` - CreationDate string `json:"creation_date"` + ID int `json:"id"` + UUID string `json:"uuid"` + Name string `json:"name"` + Description string `json:"description"` + Category WgerCategory `json:"category"` + Muscles []WgerMuscle `json:"muscles"` + MusclesSecondary []WgerMuscle `json:"muscles_secondary"` + Equipment []WgerEquipment `json:"equipment"` + Language int `json:"language"` + License WgerLicense `json:"license"` + LicenseAuthor string `json:"license_author"` + Variations json.RawMessage `json:"variations"` + CreationDate string `json:"creation_date"` +} + +type WgerCategory struct { + ID int `json:"id"` + Name string `json:"name"` +} + +type WgerLicense struct { + ID int `json:"id"` + FullName string `json:"full_name"` + ShortName string `json:"short_name"` + URL string `json:"url"` } type WgerExerciseListResponse struct { @@ -49,14 +65,27 @@ type WgerEquipmentListResponse struct { Results []WgerEquipment `json:"results"` } -type WgerCategory struct { - ID int `json:"id"` - Name string `json:"name"` -} - type WgerCategoryListResponse struct { Count int `json:"count"` Next *string `json:"next"` Previous *string `json:"previous"` Results []WgerCategory `json:"results"` } + +func (w *WgerExercise) GetVariations() []int { + if w.Variations == nil { + return nil + } + + var variations []int + if err := json.Unmarshal(w.Variations, &variations); err == nil { + return variations + } + + var singleVariation int + if err := json.Unmarshal(w.Variations, &singleVariation); err == nil { + return []int{singleVariation} + } + + return nil +} diff --git a/internal/services/exercise_cache_service.go b/internal/services/exercise_cache_service.go index d76837d..c9909e3 100644 --- a/internal/services/exercise_cache_service.go +++ b/internal/services/exercise_cache_service.go @@ -43,21 +43,33 @@ func NewExerciseCacheService( func (s *exerciseCacheService) RefreshCache(ctx context.Context) error { s.logger.Info("Starting cache refresh from wger API") + s.logger.Info("Step 1: Clearing existing cache") if err := s.exerciseRepo.ClearCache(ctx); err != nil { + s.logger.Error("Failed to clear existing cache", "error", err) return fmt.Errorf("failed to clear existing cache: %w", err) } + s.logger.Info("Successfully cleared existing cache") + s.logger.Info("Step 2: Caching muscle groups") if err := s.cacheMuscleGroups(ctx); err != nil { + s.logger.Error("Failed to cache muscle groups", "error", err) return fmt.Errorf("failed to cache muscle groups: %w", err) } + s.logger.Info("Successfully cached muscle groups") + s.logger.Info("Step 3: Caching equipment") if err := s.cacheEquipment(ctx); err != nil { + s.logger.Error("Failed to cache equipment", "error", err) return fmt.Errorf("failed to cache equipment: %w", err) } + s.logger.Info("Successfully cached equipment") + s.logger.Info("Step 4: Caching exercises") if err := s.cacheExercises(ctx); err != nil { + s.logger.Error("Failed to cache exercises", "error", err) return fmt.Errorf("failed to cache exercises: %w", err) } + s.logger.Info("Successfully cached exercises") s.logger.Info("Cache refresh completed successfully") return nil @@ -133,27 +145,34 @@ func (s *exerciseCacheService) cacheEquipment(ctx context.Context) error { } func (s *exerciseCacheService) cacheExercises(ctx context.Context) error { - s.logger.Debug("Caching exercises from wger API") + s.logger.Info("Starting to cache exercises from wger API with pagination") - exercises, err := s.wgerClient.GetAllExercises(ctx) + wgerCtx, cancel := context.WithTimeout(context.Background(), 10*time.Minute) + defer cancel() + + s.logger.Info("Created new context for wger API", "timeout", "10m") + allExercises, err := s.wgerClient.GetAllExercises(wgerCtx) if err != nil { + s.logger.Error("Failed to get exercises from wger API", "error", err) return fmt.Errorf("failed to get exercises from wger API: %w", err) } + s.logger.Info("Successfully received exercises from wger API", "count", len(allExercises)) + now := time.Now() expiresAt := now.Add(s.cacheTTL) - for i := range exercises { - ex := &exercises[i] + for i := range allExercises { + ex := &allExercises[i] exercise := &models.Exercise{ ID: uuid.New(), WgerID: ex.ID, WgerUUID: ex.UUID, Name: ex.Name, Description: ex.Description, - Category: ex.Category, + Category: ex.Category.ID, Language: ex.Language, - License: ex.License, + License: ex.License.ID, LicenseAuthor: ex.LicenseAuthor, CreationDate: parseDate(ex.CreationDate), CachedAt: now, @@ -175,17 +194,20 @@ func (s *exerciseCacheService) cacheExercises(ctx context.Context) error { s.logger.Error("Failed to cache exercise equipment", "error", err, "exercise", ex.Name) } - s.cacheExerciseVariations(ctx, exercise.ID, ex.Variations) + variations := ex.GetVariations() + if len(variations) > 0 { + s.cacheExerciseVariations(ctx, exercise.ID, variations) + } } - s.logger.Info("Successfully cached exercises", "count", len(exercises)) + s.logger.Info("Successfully cached exercises", "count", len(allExercises)) return nil } func (s *exerciseCacheService) cacheExerciseMuscleGroups( ctx context.Context, exerciseID uuid.UUID, - primaryMuscles, secondaryMuscles []int, + primaryMuscles, secondaryMuscles []models.WgerMuscle, ) error { muscleGroups, err := s.exerciseRepo.GetMuscleGroups(ctx) if err != nil { @@ -197,18 +219,18 @@ func (s *exerciseCacheService) cacheExerciseMuscleGroups( muscleGroupMap[mg.WgerID] = mg.ID } - for _, muscleID := range primaryMuscles { - if muscleGroupID, exists := muscleGroupMap[muscleID]; exists { + for _, muscle := range primaryMuscles { + if muscleGroupID, exists := muscleGroupMap[muscle.ID]; exists { if err := s.exerciseRepo.SaveExerciseMuscleGroup(ctx, exerciseID, muscleGroupID, true); err != nil { - s.logger.Warn("Failed to save primary muscle group", "error", err, "muscle_id", muscleID) + s.logger.Warn("Failed to save primary muscle group", "error", err, "muscle_id", muscle.ID) } } } - for _, muscleID := range secondaryMuscles { - if muscleGroupID, exists := muscleGroupMap[muscleID]; exists { + for _, muscle := range secondaryMuscles { + if muscleGroupID, exists := muscleGroupMap[muscle.ID]; exists { if err := s.exerciseRepo.SaveExerciseMuscleGroup(ctx, exerciseID, muscleGroupID, false); err != nil { - s.logger.Warn("Failed to save secondary muscle group", "error", err, "muscle_id", muscleID) + s.logger.Warn("Failed to save secondary muscle group", "error", err, "muscle_id", muscle.ID) } } } @@ -216,21 +238,21 @@ func (s *exerciseCacheService) cacheExerciseMuscleGroups( return nil } -func (s *exerciseCacheService) cacheExerciseEquipment(ctx context.Context, exerciseID uuid.UUID, equipmentIDs []int) error { - equipment, err := s.exerciseRepo.GetEquipment(ctx) +func (s *exerciseCacheService) cacheExerciseEquipment(ctx context.Context, exerciseID uuid.UUID, equipment []models.WgerEquipment) error { + equipmentList, err := s.exerciseRepo.GetEquipment(ctx) if err != nil { return fmt.Errorf("failed to get equipment: %w", err) } equipmentMap := make(map[int]uuid.UUID) - for _, eq := range equipment { + for _, eq := range equipmentList { equipmentMap[eq.WgerID] = eq.ID } - for _, equipmentID := range equipmentIDs { - if eqID, exists := equipmentMap[equipmentID]; exists { + for _, eq := range equipment { + if eqID, exists := equipmentMap[eq.ID]; exists { if err := s.exerciseRepo.SaveExerciseEquipment(ctx, exerciseID, eqID); err != nil { - s.logger.Warn("Failed to save exercise equipment", "error", err, "equipment_id", equipmentID) + s.logger.Warn("Failed to save exercise equipment", "error", err, "equipment_id", eq.ID) } } }