From 465093343ddd398ce5f6377fc9c472d8251c618b Mon Sep 17 00:00:00 2001 From: Peter Stone Date: Fri, 23 Jan 2026 21:37:18 -1000 Subject: Refactor: reduce code duplication with shared abstractions - Add BaseClient HTTP abstraction (internal/api/http.go) to eliminate duplicated HTTP boilerplate across Todoist, Trello, and PlanToEat clients - Add response helpers (internal/handlers/response.go) for JSON/HTML responses - Add generic cache wrapper (internal/handlers/cache.go) using Go generics - Consolidate HandleCompleteAtom/HandleUncompleteAtom into handleAtomToggle - Merge TabsHandler into Handler, delete tabs.go - Extract sortTasksByUrgency and filterAndSortTrelloTasks helpers - Update tests to work with new BaseClient structure Co-Authored-By: Claude Opus 4.5 --- internal/api/http.go | 143 +++++++++++++++++++++++ internal/api/plantoeat.go | 67 ++++------- internal/api/todoist.go | 264 ++++++++----------------------------------- internal/api/todoist_test.go | 38 +++---- internal/api/trello.go | 211 ++++++++-------------------------- internal/api/trello_test.go | 35 ++---- 6 files changed, 277 insertions(+), 481 deletions(-) create mode 100644 internal/api/http.go (limited to 'internal/api') diff --git a/internal/api/http.go b/internal/api/http.go new file mode 100644 index 0000000..8854625 --- /dev/null +++ b/internal/api/http.go @@ -0,0 +1,143 @@ +package api + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "time" +) + +// BaseClient provides common HTTP functionality for API clients +type BaseClient struct { + HTTPClient *http.Client + BaseURL string +} + +// NewBaseClient creates a new BaseClient with default settings +func NewBaseClient(baseURL string) BaseClient { + return BaseClient{ + HTTPClient: &http.Client{Timeout: 15 * time.Second}, + BaseURL: baseURL, + } +} + +// Get performs a GET request and decodes the JSON response +func (c *BaseClient) Get(ctx context.Context, path string, headers map[string]string, result interface{}) error { + req, err := http.NewRequestWithContext(ctx, "GET", c.BaseURL+path, nil) + if err != nil { + return fmt.Errorf("failed to create request: %w", err) + } + + for k, v := range headers { + req.Header.Set(k, v) + } + + return c.doJSON(req, result) +} + +// Post performs a POST request with JSON body and decodes the response +func (c *BaseClient) Post(ctx context.Context, path string, headers map[string]string, body interface{}, result interface{}) error { + var bodyReader io.Reader + if body != nil { + jsonData, err := json.Marshal(body) + if err != nil { + return fmt.Errorf("failed to marshal request body: %w", err) + } + bodyReader = bytes.NewBuffer(jsonData) + } + + req, err := http.NewRequestWithContext(ctx, "POST", c.BaseURL+path, bodyReader) + if err != nil { + return fmt.Errorf("failed to create request: %w", err) + } + + if body != nil { + req.Header.Set("Content-Type", "application/json") + } + + for k, v := range headers { + req.Header.Set(k, v) + } + + return c.doJSON(req, result) +} + +// PostForm performs a POST request with form-encoded body +func (c *BaseClient) PostForm(ctx context.Context, path string, headers map[string]string, formData string, result interface{}) error { + req, err := http.NewRequestWithContext(ctx, "POST", c.BaseURL+path, bytes.NewBufferString(formData)) + if err != nil { + return fmt.Errorf("failed to create request: %w", err) + } + + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + for k, v := range headers { + req.Header.Set(k, v) + } + + return c.doJSON(req, result) +} + +// PostEmpty performs a POST request with no body and expects no response body +func (c *BaseClient) PostEmpty(ctx context.Context, path string, headers map[string]string) error { + req, err := http.NewRequestWithContext(ctx, "POST", c.BaseURL+path, nil) + if err != nil { + return fmt.Errorf("failed to create request: %w", err) + } + + for k, v := range headers { + req.Header.Set(k, v) + } + + resp, err := c.HTTPClient.Do(req) + if err != nil { + return fmt.Errorf("request failed: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + body, _ := io.ReadAll(resp.Body) + return fmt.Errorf("API error (status %d): %s", resp.StatusCode, string(body)) + } + + return nil +} + +// Put performs a PUT request with form-encoded body +func (c *BaseClient) Put(ctx context.Context, path string, headers map[string]string, formData string, result interface{}) error { + req, err := http.NewRequestWithContext(ctx, "PUT", c.BaseURL+path, bytes.NewBufferString(formData)) + if err != nil { + return fmt.Errorf("failed to create request: %w", err) + } + + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + for k, v := range headers { + req.Header.Set(k, v) + } + + return c.doJSON(req, result) +} + +// doJSON executes the request and decodes JSON response +func (c *BaseClient) doJSON(req *http.Request, result interface{}) error { + resp, err := c.HTTPClient.Do(req) + if err != nil { + return fmt.Errorf("request failed: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + body, _ := io.ReadAll(resp.Body) + return fmt.Errorf("API error (status %d): %s", resp.StatusCode, string(body)) + } + + if result != nil { + if err := json.NewDecoder(resp.Body).Decode(result); err != nil { + return fmt.Errorf("failed to decode response: %w", err) + } + } + + return nil +} diff --git a/internal/api/plantoeat.go b/internal/api/plantoeat.go index 1dae246..eb29c63 100644 --- a/internal/api/plantoeat.go +++ b/internal/api/plantoeat.go @@ -2,35 +2,32 @@ package api import ( "context" - "encoding/json" "fmt" - "io" - "net/http" "time" "task-dashboard/internal/models" ) -const ( - planToEatBaseURL = "https://www.plantoeat.com/api/v2" -) +const planToEatBaseURL = "https://www.plantoeat.com/api/v2" // PlanToEatClient handles interactions with the PlanToEat API type PlanToEatClient struct { - apiKey string - httpClient *http.Client + BaseClient + apiKey string } // NewPlanToEatClient creates a new PlanToEat API client func NewPlanToEatClient(apiKey string) *PlanToEatClient { return &PlanToEatClient{ - apiKey: apiKey, - httpClient: &http.Client{ - Timeout: 15 * time.Second, - }, + BaseClient: NewBaseClient(planToEatBaseURL), + apiKey: apiKey, } } +func (c *PlanToEatClient) authHeaders() map[string]string { + return map[string]string{"Authorization": "Bearer " + c.apiKey} +} + // planToEatPlannerItem represents a planner item from the API type planToEatPlannerItem struct { ID int `json:"id"` @@ -51,59 +48,35 @@ type planToEatResponse struct { // GetUpcomingMeals fetches meals for the next N days func (c *PlanToEatClient) GetUpcomingMeals(ctx context.Context, days int) ([]models.Meal, error) { if days <= 0 { - days = 7 // Default to 7 days + days = 7 } startDate := time.Now() endDate := startDate.AddDate(0, 0, days) - req, err := http.NewRequestWithContext(ctx, "GET", planToEatBaseURL+"/planner_items", nil) - if err != nil { - return nil, fmt.Errorf("failed to create request: %w", err) - } - - // Add query parameters - q := req.URL.Query() - q.Add("start_date", startDate.Format("2006-01-02")) - q.Add("end_date", endDate.Format("2006-01-02")) - req.URL.RawQuery = q.Encode() - - // Add API key (check docs for correct header name) - req.Header.Set("Authorization", "Bearer "+c.apiKey) - - resp, err := c.httpClient.Do(req) - if err != nil { - return nil, fmt.Errorf("failed to fetch meals: %w", err) - } - defer resp.Body.Close() - - if resp.StatusCode != http.StatusOK { - body, _ := io.ReadAll(resp.Body) - return nil, fmt.Errorf("plantoeat API error (status %d): %s", resp.StatusCode, string(body)) - } + path := fmt.Sprintf("/planner_items?start_date=%s&end_date=%s", + startDate.Format("2006-01-02"), + endDate.Format("2006-01-02")) var apiResponse planToEatResponse - if err := json.NewDecoder(resp.Body).Decode(&apiResponse); err != nil { - return nil, fmt.Errorf("failed to decode response: %w", err) + if err := c.Get(ctx, path, c.authHeaders(), &apiResponse); err != nil { + return nil, fmt.Errorf("failed to fetch meals: %w", err) } - // Convert to our model meals := make([]models.Meal, 0, len(apiResponse.Items)) for _, item := range apiResponse.Items { mealDate, err := time.Parse("2006-01-02", item.Date) if err != nil { - continue // Skip invalid dates + continue } - meal := models.Meal{ + meals = append(meals, models.Meal{ ID: fmt.Sprintf("%d", item.ID), RecipeName: item.Recipe.Title, Date: mealDate, MealType: normalizeMealType(item.MealType), RecipeURL: item.Recipe.URL, - } - - meals = append(meals, meal) + }) } return meals, nil @@ -121,18 +94,16 @@ func normalizeMealType(mealType string) string { case "snack", "Snack": return "snack" default: - return "dinner" // Default to dinner + return "dinner" } } // GetRecipes fetches recipes (for Phase 2) func (c *PlanToEatClient) GetRecipes(ctx context.Context) error { - // This will be implemented in Phase 2 return fmt.Errorf("not implemented yet") } // AddMealToPlanner adds a meal to the planner (for Phase 2) func (c *PlanToEatClient) AddMealToPlanner(ctx context.Context, recipeID string, date time.Time, mealType string) error { - // This will be implemented in Phase 2 return fmt.Errorf("not implemented yet") } diff --git a/internal/api/todoist.go b/internal/api/todoist.go index b3d4579..6c998cf 100644 --- a/internal/api/todoist.go +++ b/internal/api/todoist.go @@ -1,12 +1,8 @@ package api import ( - "bytes" "context" - "encoding/json" "fmt" - "io" - "net/http" "time" "task-dashboard/internal/models" @@ -19,22 +15,24 @@ const ( // TodoistClient handles interactions with the Todoist API type TodoistClient struct { + BaseClient + syncClient BaseClient apiKey string - baseURL string - httpClient *http.Client } // NewTodoistClient creates a new Todoist API client func NewTodoistClient(apiKey string) *TodoistClient { return &TodoistClient{ - apiKey: apiKey, - baseURL: todoistBaseURL, - httpClient: &http.Client{ - Timeout: 15 * time.Second, - }, + BaseClient: NewBaseClient(todoistBaseURL), + syncClient: NewBaseClient(todoistSyncBaseURL), + apiKey: apiKey, } } +func (c *TodoistClient) authHeaders() map[string]string { + return map[string]string{"Authorization": "Bearer " + c.apiKey} +} + // todoistTaskResponse represents the API response structure type todoistTaskResponse struct { ID string `json:"id"` @@ -93,34 +91,15 @@ type SyncProjectResponse struct { // GetTasks fetches all active tasks from Todoist func (c *TodoistClient) GetTasks(ctx context.Context) ([]models.Task, error) { - req, err := http.NewRequestWithContext(ctx, "GET", c.baseURL+"/tasks", nil) - if err != nil { - return nil, fmt.Errorf("failed to create request: %w", err) - } - - req.Header.Set("Authorization", "Bearer "+c.apiKey) - - resp, err := c.httpClient.Do(req) - if err != nil { - return nil, fmt.Errorf("failed to fetch tasks: %w", err) - } - defer resp.Body.Close() - - if resp.StatusCode != http.StatusOK { - body, _ := io.ReadAll(resp.Body) - return nil, fmt.Errorf("todoist API error (status %d): %s", resp.StatusCode, string(body)) - } - var apiTasks []todoistTaskResponse - if err := json.NewDecoder(resp.Body).Decode(&apiTasks); err != nil { - return nil, fmt.Errorf("failed to decode response: %w", err) + if err := c.Get(ctx, "/tasks", c.authHeaders(), &apiTasks); err != nil { + return nil, fmt.Errorf("failed to fetch tasks: %w", err) } // Fetch projects to get project names projects, err := c.GetProjects(ctx) projectMap := make(map[string]string) if err == nil { - // Build map of project ID to name for _, proj := range projects { projectMap[proj.ID] = proj.Name } @@ -141,24 +120,11 @@ func (c *TodoistClient) GetTasks(ctx context.Context) ([]models.Task, error) { URL: apiTask.URL, } - // Parse created_at if createdAt, err := time.Parse(time.RFC3339, apiTask.CreatedAt); err == nil { task.CreatedAt = createdAt } - // Parse due date - if apiTask.Due != nil { - var dueDate time.Time - if apiTask.Due.Datetime != "" { - dueDate, err = time.Parse(time.RFC3339, apiTask.Due.Datetime) - } else if apiTask.Due.Date != "" { - dueDate, err = time.Parse("2006-01-02", apiTask.Due.Date) - } - if err == nil { - task.DueDate = &dueDate - } - } - + task.DueDate = parseDueDate(apiTask.Due) tasks = append(tasks, task) } @@ -167,30 +133,11 @@ func (c *TodoistClient) GetTasks(ctx context.Context) ([]models.Task, error) { // GetProjects fetches all projects func (c *TodoistClient) GetProjects(ctx context.Context) ([]models.Project, error) { - req, err := http.NewRequestWithContext(ctx, "GET", c.baseURL+"/projects", nil) - if err != nil { - return nil, fmt.Errorf("failed to create request: %w", err) - } - - req.Header.Set("Authorization", "Bearer "+c.apiKey) - - resp, err := c.httpClient.Do(req) - if err != nil { - return nil, fmt.Errorf("failed to fetch projects: %w", err) - } - defer resp.Body.Close() - - if resp.StatusCode != http.StatusOK { - body, _ := io.ReadAll(resp.Body) - return nil, fmt.Errorf("todoist API error (status %d): %s", resp.StatusCode, string(body)) - } - var apiProjects []todoistProjectResponse - if err := json.NewDecoder(resp.Body).Decode(&apiProjects); err != nil { - return nil, fmt.Errorf("failed to decode response: %w", err) + if err := c.Get(ctx, "/projects", c.authHeaders(), &apiProjects); err != nil { + return nil, fmt.Errorf("failed to fetch projects: %w", err) } - // Convert to model projects := make([]models.Project, 0, len(apiProjects)) for _, apiProj := range apiProjects { projects = append(projects, models.Project{ @@ -203,46 +150,19 @@ func (c *TodoistClient) GetProjects(ctx context.Context) ([]models.Project, erro } // Sync performs an incremental sync using the Sync API v9 -// If syncToken is empty or "*", a full sync is performed -// Returns the new sync token and the sync response func (c *TodoistClient) Sync(ctx context.Context, syncToken string) (*TodoistSyncResponse, error) { if syncToken == "" { syncToken = "*" // Full sync } - // Prepare sync request payload := map[string]interface{}{ "sync_token": syncToken, "resource_types": []string{"items", "projects"}, } - jsonData, err := json.Marshal(payload) - if err != nil { - return nil, fmt.Errorf("failed to marshal sync request: %w", err) - } - - req, err := http.NewRequestWithContext(ctx, "POST", todoistSyncBaseURL+"/sync", bytes.NewBuffer(jsonData)) - if err != nil { - return nil, fmt.Errorf("failed to create sync request: %w", err) - } - - req.Header.Set("Authorization", "Bearer "+c.apiKey) - req.Header.Set("Content-Type", "application/json") - - resp, err := c.httpClient.Do(req) - if err != nil { - return nil, fmt.Errorf("failed to perform sync: %w", err) - } - defer resp.Body.Close() - - if resp.StatusCode != http.StatusOK { - body, _ := io.ReadAll(resp.Body) - return nil, fmt.Errorf("todoist sync API error (status %d): %s", resp.StatusCode, string(body)) - } - var syncResp TodoistSyncResponse - if err := json.NewDecoder(resp.Body).Decode(&syncResp); err != nil { - return nil, fmt.Errorf("failed to decode sync response: %w", err) + if err := c.syncClient.Post(ctx, "/sync", c.authHeaders(), payload, &syncResp); err != nil { + return nil, fmt.Errorf("failed to perform sync: %w", err) } return &syncResp, nil @@ -252,7 +172,6 @@ func (c *TodoistClient) Sync(ctx context.Context, syncToken string) (*TodoistSyn func ConvertSyncItemsToTasks(items []SyncItemResponse, projectMap map[string]string) []models.Task { tasks := make([]models.Task, 0, len(items)) for _, item := range items { - // Skip completed or deleted items if item.IsCompleted || item.IsDeleted { continue } @@ -269,27 +188,13 @@ func ConvertSyncItemsToTasks(items []SyncItemResponse, projectMap map[string]str URL: fmt.Sprintf("https://todoist.com/app/task/%s", item.ID), } - // Parse added_at if item.AddedAt != "" { if createdAt, err := time.Parse(time.RFC3339, item.AddedAt); err == nil { task.CreatedAt = createdAt } } - // Parse due date - if item.Due != nil { - var dueDate time.Time - var err error - if item.Due.Datetime != "" { - dueDate, err = time.Parse(time.RFC3339, item.Due.Datetime) - } else if item.Due.Date != "" { - dueDate, err = time.Parse("2006-01-02", item.Due.Date) - } - if err == nil { - task.DueDate = &dueDate - } - } - + task.DueDate = parseDueDate(item.Due) tasks = append(tasks, task) } return tasks @@ -308,55 +213,22 @@ func BuildProjectMapFromSync(projects []SyncProjectResponse) map[string]string { // CreateTask creates a new task in Todoist func (c *TodoistClient) CreateTask(ctx context.Context, content, projectID string, dueDate *time.Time, priority int) (*models.Task, error) { - // Prepare request payload - payload := map[string]interface{}{ - "content": content, - } - + payload := map[string]interface{}{"content": content} if projectID != "" { payload["project_id"] = projectID } - if dueDate != nil { payload["due_date"] = dueDate.Format("2006-01-02") } - if priority > 0 { payload["priority"] = priority } - jsonData, err := json.Marshal(payload) - if err != nil { - return nil, fmt.Errorf("failed to marshal request: %w", err) - } - - // Create POST request - req, err := http.NewRequestWithContext(ctx, "POST", c.baseURL+"/tasks", bytes.NewBuffer(jsonData)) - if err != nil { - return nil, fmt.Errorf("failed to create request: %w", err) - } - req.Header.Set("Authorization", "Bearer "+c.apiKey) - req.Header.Set("Content-Type", "application/json") - - // Execute request - resp, err := c.httpClient.Do(req) - if err != nil { - return nil, fmt.Errorf("failed to create task: %w", err) - } - defer resp.Body.Close() - - if resp.StatusCode != http.StatusOK { - body, _ := io.ReadAll(resp.Body) - return nil, fmt.Errorf("todoist API error (status %d): %s", resp.StatusCode, string(body)) - } - - // Decode response var apiTask todoistTaskResponse - if err := json.NewDecoder(resp.Body).Decode(&apiTask); err != nil { - return nil, fmt.Errorf("failed to decode response: %w", err) + if err := c.Post(ctx, "/tasks", c.authHeaders(), payload, &apiTask); err != nil { + return nil, fmt.Errorf("failed to create task: %w", err) } - // Convert to our model task := &models.Task{ ID: apiTask.ID, Content: apiTask.Content, @@ -368,100 +240,58 @@ func (c *TodoistClient) CreateTask(ctx context.Context, content, projectID strin URL: apiTask.URL, } - // Parse created_at if createdAt, err := time.Parse(time.RFC3339, apiTask.CreatedAt); err == nil { task.CreatedAt = createdAt } - // Parse due date - if apiTask.Due != nil { - var taskDueDate time.Time - if apiTask.Due.Datetime != "" { - taskDueDate, err = time.Parse(time.RFC3339, apiTask.Due.Datetime) - } else if apiTask.Due.Date != "" { - taskDueDate, err = time.Parse("2006-01-02", apiTask.Due.Date) - } - if err == nil { - task.DueDate = &taskDueDate - } - } - + task.DueDate = parseDueDate(apiTask.Due) return task, nil } // UpdateTask updates a task with the specified changes func (c *TodoistClient) UpdateTask(ctx context.Context, taskID string, updates map[string]interface{}) error { - jsonData, err := json.Marshal(updates) - if err != nil { - return fmt.Errorf("failed to marshal updates: %w", err) - } - - url := fmt.Sprintf("%s/tasks/%s", c.baseURL, taskID) - req, err := http.NewRequestWithContext(ctx, "POST", url, bytes.NewBuffer(jsonData)) - if err != nil { - return fmt.Errorf("failed to create request: %w", err) - } - req.Header.Set("Authorization", "Bearer "+c.apiKey) - req.Header.Set("Content-Type", "application/json") - - resp, err := c.httpClient.Do(req) - if err != nil { + path := fmt.Sprintf("/tasks/%s", taskID) + if err := c.Post(ctx, path, c.authHeaders(), updates, nil); err != nil { return fmt.Errorf("failed to update task: %w", err) } - defer resp.Body.Close() - - if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusNoContent { - body, _ := io.ReadAll(resp.Body) - return fmt.Errorf("todoist API error (status %d): %s", resp.StatusCode, string(body)) - } - return nil } // CompleteTask marks a task as complete in Todoist func (c *TodoistClient) CompleteTask(ctx context.Context, taskID string) error { - // Create POST request to close endpoint - url := fmt.Sprintf("%s/tasks/%s/close", c.baseURL, taskID) - req, err := http.NewRequestWithContext(ctx, "POST", url, nil) - if err != nil { - return fmt.Errorf("failed to create request: %w", err) - } - req.Header.Set("Authorization", "Bearer "+c.apiKey) - - // Execute request - resp, err := c.httpClient.Do(req) - if err != nil { + path := fmt.Sprintf("/tasks/%s/close", taskID) + if err := c.PostEmpty(ctx, path, c.authHeaders()); err != nil { return fmt.Errorf("failed to complete task: %w", err) } - defer resp.Body.Close() - - if resp.StatusCode != http.StatusNoContent && resp.StatusCode != http.StatusOK { - body, _ := io.ReadAll(resp.Body) - return fmt.Errorf("todoist API error (status %d): %s", resp.StatusCode, string(body)) - } - return nil } // ReopenTask marks a completed task as active in Todoist func (c *TodoistClient) ReopenTask(ctx context.Context, taskID string) error { - url := fmt.Sprintf("%s/tasks/%s/reopen", c.baseURL, taskID) - req, err := http.NewRequestWithContext(ctx, "POST", url, nil) - if err != nil { - return fmt.Errorf("failed to create request: %w", err) - } - req.Header.Set("Authorization", "Bearer "+c.apiKey) - - resp, err := c.httpClient.Do(req) - if err != nil { + path := fmt.Sprintf("/tasks/%s/reopen", taskID) + if err := c.PostEmpty(ctx, path, c.authHeaders()); err != nil { return fmt.Errorf("failed to reopen task: %w", err) } - defer resp.Body.Close() + return nil +} - if resp.StatusCode != http.StatusNoContent && resp.StatusCode != http.StatusOK { - body, _ := io.ReadAll(resp.Body) - return fmt.Errorf("todoist API error (status %d): %s", resp.StatusCode, string(body)) +// parseDueDate parses due date from API response +func parseDueDate(due *struct { + Date string `json:"date"` + Datetime string `json:"datetime"` +}) *time.Time { + if due == nil { + return nil + } + var dueDate time.Time + var err error + if due.Datetime != "" { + dueDate, err = time.Parse(time.RFC3339, due.Datetime) + } else if due.Date != "" { + dueDate, err = time.Parse("2006-01-02", due.Date) } - - return nil + if err != nil { + return nil + } + return &dueDate } diff --git a/internal/api/todoist_test.go b/internal/api/todoist_test.go index 56b1484..88f94f8 100644 --- a/internal/api/todoist_test.go +++ b/internal/api/todoist_test.go @@ -10,6 +10,14 @@ import ( "time" ) +// newTestTodoistClient creates a TodoistClient for testing with custom base URL +func newTestTodoistClient(baseURL, apiKey string) *TodoistClient { + client := NewTodoistClient(apiKey) + client.BaseClient.BaseURL = baseURL + client.syncClient.BaseURL = baseURL + return client +} + func TestTodoistClient_CreateTask(t *testing.T) { // Mock server server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { @@ -59,11 +67,7 @@ func TestTodoistClient_CreateTask(t *testing.T) { defer server.Close() // Create client with mock server URL - client := &TodoistClient{ - apiKey: "test-key", - baseURL: server.URL, - httpClient: &http.Client{}, - } + client := newTestTodoistClient(server.URL, "test-key") // Test CreateTask ctx := context.Background() @@ -122,11 +126,7 @@ func TestTodoistClient_CreateTask_WithDueDate(t *testing.T) { defer server.Close() // Create client - client := &TodoistClient{ - apiKey: "test-key", - baseURL: server.URL, - httpClient: &http.Client{}, - } + client := newTestTodoistClient(server.URL, "test-key") // Test CreateTask with due date ctx := context.Background() @@ -170,11 +170,7 @@ func TestTodoistClient_CompleteTask(t *testing.T) { defer server.Close() // Create client - client := &TodoistClient{ - apiKey: "test-key", - baseURL: server.URL, - httpClient: &http.Client{}, - } + client := newTestTodoistClient(server.URL, "test-key") // Test CompleteTask ctx := context.Background() @@ -194,11 +190,7 @@ func TestTodoistClient_CompleteTask_Error(t *testing.T) { defer server.Close() // Create client - client := &TodoistClient{ - apiKey: "test-key", - baseURL: server.URL, - httpClient: &http.Client{}, - } + client := newTestTodoistClient(server.URL, "test-key") // Test CompleteTask with error ctx := context.Background() @@ -235,11 +227,7 @@ func TestTodoistClient_GetProjects(t *testing.T) { defer server.Close() // Create client - client := &TodoistClient{ - apiKey: "test-key", - baseURL: server.URL, - httpClient: &http.Client{}, - } + client := newTestTodoistClient(server.URL, "test-key") // Test GetProjects ctx := context.Background() diff --git a/internal/api/trello.go b/internal/api/trello.go index 4c4dc95..4cf7e9e 100644 --- a/internal/api/trello.go +++ b/internal/api/trello.go @@ -2,44 +2,41 @@ package api import ( "context" - "encoding/json" "fmt" - "io" "log" - "net/http" "net/url" "sort" - "strings" "sync" "time" "task-dashboard/internal/models" ) -const ( - trelloBaseURL = "https://api.trello.com/1" -) +const trelloBaseURL = "https://api.trello.com/1" // TrelloClient handles interactions with the Trello API type TrelloClient struct { - apiKey string - token string - baseURL string - httpClient *http.Client + BaseClient + apiKey string + token string } // NewTrelloClient creates a new Trello API client func NewTrelloClient(apiKey, token string) *TrelloClient { return &TrelloClient{ - apiKey: apiKey, - token: token, - baseURL: trelloBaseURL, - httpClient: &http.Client{ - Timeout: 15 * time.Second, - }, + BaseClient: NewBaseClient(trelloBaseURL), + apiKey: apiKey, + token: token, } } +func (c *TrelloClient) authParams() url.Values { + params := url.Values{} + params.Set("key", c.apiKey) + params.Set("token", c.token) + return params +} + // trelloBoardResponse represents a board from the API type trelloBoardResponse struct { ID string `json:"id"` @@ -65,43 +62,22 @@ type trelloListResponse struct { // GetBoards fetches all boards for the authenticated user func (c *TrelloClient) GetBoards(ctx context.Context) ([]models.Board, error) { - params := url.Values{} - params.Set("key", c.apiKey) - params.Set("token", c.token) + params := c.authParams() params.Set("filter", "open") - params.Set("fields", "id,name") // Only fetch required fields - - reqURL := fmt.Sprintf("%s/members/me/boards?%s", c.baseURL, params.Encode()) - req, err := http.NewRequestWithContext(ctx, "GET", reqURL, nil) - if err != nil { - return nil, fmt.Errorf("failed to create request: %w", err) - } - - resp, err := c.httpClient.Do(req) - if err != nil { - return nil, fmt.Errorf("failed to fetch boards: %w", err) - } - defer resp.Body.Close() - - if resp.StatusCode != http.StatusOK { - body, _ := io.ReadAll(resp.Body) - return nil, fmt.Errorf("trello API error (status %d): %s", resp.StatusCode, string(body)) - } + params.Set("fields", "id,name") var apiBoards []trelloBoardResponse - if err := json.NewDecoder(resp.Body).Decode(&apiBoards); err != nil { - return nil, fmt.Errorf("failed to decode response: %w", err) + if err := c.Get(ctx, "/members/me/boards?"+params.Encode(), nil, &apiBoards); err != nil { + return nil, fmt.Errorf("failed to fetch boards: %w", err) } - // Convert to our model boards := make([]models.Board, 0, len(apiBoards)) for _, apiBoard := range apiBoards { - board := models.Board{ + boards = append(boards, models.Board{ ID: apiBoard.ID, Name: apiBoard.Name, - Cards: []models.Card{}, // Will be populated by GetCards - } - boards = append(boards, board) + Cards: []models.Card{}, + }) } return boards, nil @@ -109,32 +85,14 @@ func (c *TrelloClient) GetBoards(ctx context.Context) ([]models.Board, error) { // GetCards fetches all cards for a specific board func (c *TrelloClient) GetCards(ctx context.Context, boardID string) ([]models.Card, error) { - params := url.Values{} - params.Set("key", c.apiKey) - params.Set("token", c.token) + params := c.authParams() params.Set("filter", "visible") - params.Set("fields", "id,name,idList,due,url,idBoard") // Only fetch required fields - - reqURL := fmt.Sprintf("%s/boards/%s/cards?%s", c.baseURL, boardID, params.Encode()) - req, err := http.NewRequestWithContext(ctx, "GET", reqURL, nil) - if err != nil { - return nil, fmt.Errorf("failed to create request: %w", err) - } - - resp, err := c.httpClient.Do(req) - if err != nil { - return nil, fmt.Errorf("failed to fetch cards: %w", err) - } - defer resp.Body.Close() - - if resp.StatusCode != http.StatusOK { - body, _ := io.ReadAll(resp.Body) - return nil, fmt.Errorf("trello API error (status %d): %s", resp.StatusCode, string(body)) - } + params.Set("fields", "id,name,idList,due,url,idBoard") var apiCards []trelloCardResponse - if err := json.NewDecoder(resp.Body).Decode(&apiCards); err != nil { - return nil, fmt.Errorf("failed to decode response: %w", err) + path := fmt.Sprintf("/boards/%s/cards?%s", boardID, params.Encode()) + if err := c.Get(ctx, path, nil, &apiCards); err != nil { + return nil, fmt.Errorf("failed to fetch cards: %w", err) } log.Printf("Trello GetCards: board %s returned %d cards from API", boardID, len(apiCards)) @@ -143,15 +101,13 @@ func (c *TrelloClient) GetCards(ctx context.Context, boardID string) ([]models.C lists, err := c.getLists(ctx, boardID) listMap := make(map[string]string) if err != nil { - log.Printf("Warning: failed to fetch lists for board %s, cards will have empty list names: %v", boardID, err) + log.Printf("Warning: failed to fetch lists for board %s: %v", boardID, err) } else { - // Build map of list ID to name for _, list := range lists { listMap[list.ID] = list.Name } } - // Convert to our model cards := make([]models.Card, 0, len(apiCards)) for _, apiCard := range apiCards { card := models.Card{ @@ -162,10 +118,8 @@ func (c *TrelloClient) GetCards(ctx context.Context, boardID string) ([]models.C URL: apiCard.URL, } - // Parse due date if present if apiCard.Due != nil && *apiCard.Due != "" { - dueDate, err := time.Parse(time.RFC3339, *apiCard.Due) - if err == nil { + if dueDate, err := time.Parse(time.RFC3339, *apiCard.Due); err == nil { card.DueDate = &dueDate } } @@ -178,34 +132,15 @@ func (c *TrelloClient) GetCards(ctx context.Context, boardID string) ([]models.C // getLists fetches lists for a board func (c *TrelloClient) getLists(ctx context.Context, boardID string) ([]models.List, error) { - params := url.Values{} - params.Set("key", c.apiKey) - params.Set("token", c.token) - params.Set("fields", "id,name") // Only fetch required fields - - reqURL := fmt.Sprintf("%s/boards/%s/lists?%s", c.baseURL, boardID, params.Encode()) - req, err := http.NewRequestWithContext(ctx, "GET", reqURL, nil) - if err != nil { - return nil, fmt.Errorf("failed to create request: %w", err) - } - - resp, err := c.httpClient.Do(req) - if err != nil { - return nil, fmt.Errorf("failed to fetch lists: %w", err) - } - defer resp.Body.Close() - - if resp.StatusCode != http.StatusOK { - body, _ := io.ReadAll(resp.Body) - return nil, fmt.Errorf("trello API error (status %d): %s", resp.StatusCode, string(body)) - } + params := c.authParams() + params.Set("fields", "id,name") var apiLists []trelloListResponse - if err := json.NewDecoder(resp.Body).Decode(&apiLists); err != nil { - return nil, fmt.Errorf("failed to decode response: %w", err) + path := fmt.Sprintf("/boards/%s/lists?%s", boardID, params.Encode()) + if err := c.Get(ctx, path, nil, &apiLists); err != nil { + return nil, fmt.Errorf("failed to fetch lists: %w", err) } - // Convert to model lists := make([]models.List, 0, len(apiLists)) for _, apiList := range apiLists { lists = append(lists, models.List{ @@ -222,7 +157,7 @@ func (c *TrelloClient) GetLists(ctx context.Context, boardID string) ([]models.L return c.getLists(ctx, boardID) } -// GetBoardsWithCards fetches all boards and their cards in one call +// GetBoardsWithCards fetches all boards and their cards concurrently func (c *TrelloClient) GetBoardsWithCards(ctx context.Context) ([]models.Board, error) { boards, err := c.GetBoards(ctx) if err != nil { @@ -237,24 +172,19 @@ func (c *TrelloClient) GetBoardsWithCards(ctx context.Context) ([]models.Board, go func(i int) { defer wg.Done() - // Acquire semaphore sem <- struct{}{} defer func() { <-sem }() - // Fetch cards cards, err := c.GetCards(ctx, boards[i].ID) if err != nil { log.Printf("Error fetching cards for board %s (%s): %v", boards[i].Name, boards[i].ID, err) } else { - // Set BoardName for each card for j := range cards { cards[j].BoardName = boards[i].Name } - // It is safe to write to specific indices of the slice concurrently boards[i].Cards = cards } - // Fetch lists lists, err := c.getLists(ctx, boards[i].ID) if err != nil { log.Printf("Error fetching lists for board %s: %v", boards[i].Name, err) @@ -266,18 +196,15 @@ func (c *TrelloClient) GetBoardsWithCards(ctx context.Context) ([]models.Board, wg.Wait() - // Sort boards: Non-empty boards first, newest card activity, then alphabetical by name - // Trello card IDs are chronologically sortable (newer IDs > older IDs) + // Sort boards: Non-empty boards first, newest card activity, then alphabetical sort.Slice(boards, func(i, j int) bool { hasCardsI := len(boards[i].Cards) > 0 hasCardsJ := len(boards[j].Cards) > 0 - // 1. Prioritize boards with cards if hasCardsI != hasCardsJ { - return hasCardsI // true (non-empty) comes before false + return hasCardsI } - // 2. If both have cards, compare by newest card (max ID) if hasCardsI && hasCardsJ { maxIDI := "" for _, card := range boards[i].Cards { @@ -294,11 +221,10 @@ func (c *TrelloClient) GetBoardsWithCards(ctx context.Context) ([]models.Board, } if maxIDI != maxIDJ { - return maxIDI > maxIDJ // Newer (larger) ID comes first + return maxIDI > maxIDJ } } - // 3. Fallback to alphabetical by name return boards[i].Name < boards[j].Name }) @@ -307,48 +233,21 @@ func (c *TrelloClient) GetBoardsWithCards(ctx context.Context) ([]models.Board, // CreateCard creates a new card in the specified list func (c *TrelloClient) CreateCard(ctx context.Context, listID, name, description string, dueDate *time.Time) (*models.Card, error) { - // Prepare request payload - data := url.Values{} - data.Set("key", c.apiKey) - data.Set("token", c.token) + data := c.authParams() data.Set("idList", listID) data.Set("name", name) - if description != "" { data.Set("desc", description) } - if dueDate != nil { data.Set("due", dueDate.Format(time.RFC3339)) } - // Create POST request - reqURL := c.baseURL + "/cards" - req, err := http.NewRequestWithContext(ctx, "POST", reqURL, strings.NewReader(data.Encode())) - if err != nil { - return nil, fmt.Errorf("failed to create request: %w", err) - } - req.Header.Set("Content-Type", "application/x-www-form-urlencoded") - - // Execute request - resp, err := c.httpClient.Do(req) - if err != nil { - return nil, fmt.Errorf("failed to create card: %w", err) - } - defer resp.Body.Close() - - if resp.StatusCode != http.StatusOK { - body, _ := io.ReadAll(resp.Body) - return nil, fmt.Errorf("trello API error (status %d): %s", resp.StatusCode, string(body)) - } - - // Decode response var apiCard trelloCardResponse - if err := json.NewDecoder(resp.Body).Decode(&apiCard); err != nil { - return nil, fmt.Errorf("failed to decode response: %w", err) + if err := c.PostForm(ctx, "/cards", nil, data.Encode(), &apiCard); err != nil { + return nil, fmt.Errorf("failed to create card: %w", err) } - // Convert to our model card := &models.Card{ ID: apiCard.ID, Name: apiCard.Name, @@ -356,10 +255,8 @@ func (c *TrelloClient) CreateCard(ctx context.Context, listID, name, description URL: apiCard.URL, } - // Parse due date if present if apiCard.Due != nil && *apiCard.Due != "" { - parsedDate, err := time.Parse(time.RFC3339, *apiCard.Due) - if err == nil { + if parsedDate, err := time.Parse(time.RFC3339, *apiCard.Due); err == nil { card.DueDate = &parsedDate } } @@ -369,35 +266,15 @@ func (c *TrelloClient) CreateCard(ctx context.Context, listID, name, description // UpdateCard updates a card with the specified changes func (c *TrelloClient) UpdateCard(ctx context.Context, cardID string, updates map[string]interface{}) error { - // Prepare request payload - data := url.Values{} - data.Set("key", c.apiKey) - data.Set("token", c.token) - - // Add updates to payload + data := c.authParams() for key, value := range updates { data.Set(key, fmt.Sprintf("%v", value)) } - // Create PUT request - reqURL := fmt.Sprintf("%s/cards/%s", c.baseURL, cardID) - req, err := http.NewRequestWithContext(ctx, "PUT", reqURL, strings.NewReader(data.Encode())) - if err != nil { - return fmt.Errorf("failed to create request: %w", err) - } - req.Header.Set("Content-Type", "application/x-www-form-urlencoded") - - // Execute request - resp, err := c.httpClient.Do(req) - if err != nil { + path := fmt.Sprintf("/cards/%s", cardID) + if err := c.Put(ctx, path, nil, data.Encode(), nil); err != nil { return fmt.Errorf("failed to update card: %w", err) } - defer resp.Body.Close() - - if resp.StatusCode != http.StatusOK { - body, _ := io.ReadAll(resp.Body) - return fmt.Errorf("trello API error (status %d): %s", resp.StatusCode, string(body)) - } return nil } diff --git a/internal/api/trello_test.go b/internal/api/trello_test.go index b43b55e..7433ff0 100644 --- a/internal/api/trello_test.go +++ b/internal/api/trello_test.go @@ -12,6 +12,13 @@ import ( "time" ) +// newTestTrelloClient creates a TrelloClient for testing with custom base URL +func newTestTrelloClient(baseURL, apiKey, token string) *TrelloClient { + client := NewTrelloClient(apiKey, token) + client.BaseClient.BaseURL = baseURL + return client +} + func TestTrelloClient_CreateCard(t *testing.T) { // Mock server server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { @@ -65,12 +72,7 @@ func TestTrelloClient_CreateCard(t *testing.T) { defer server.Close() // Create client with mock server URL - client := &TrelloClient{ - apiKey: "test-key", - token: "test-token", - baseURL: server.URL, - httpClient: &http.Client{}, - } + client := newTestTrelloClient(server.URL, "test-key", "test-token") // Test CreateCard ctx := context.Background() @@ -125,12 +127,7 @@ func TestTrelloClient_CreateCard_WithDueDate(t *testing.T) { defer server.Close() // Create client - client := &TrelloClient{ - apiKey: "test-key", - token: "test-token", - baseURL: server.URL, - httpClient: &http.Client{}, - } + client := newTestTrelloClient(server.URL, "test-key", "test-token") // Test CreateCard with due date ctx := context.Background() @@ -184,12 +181,7 @@ func TestTrelloClient_UpdateCard(t *testing.T) { defer server.Close() // Create client - client := &TrelloClient{ - apiKey: "test-key", - token: "test-token", - baseURL: server.URL, - httpClient: &http.Client{}, - } + client := newTestTrelloClient(server.URL, "test-key", "test-token") // Test UpdateCard ctx := context.Background() @@ -214,12 +206,7 @@ func TestTrelloClient_UpdateCard_Error(t *testing.T) { defer server.Close() // Create client - client := &TrelloClient{ - apiKey: "test-key", - token: "test-token", - baseURL: server.URL, - httpClient: &http.Client{}, - } + client := newTestTrelloClient(server.URL, "test-key", "test-token") // Test UpdateCard with error ctx := context.Background() -- cgit v1.2.3