Files
go-garth/implementation-plan-steps-1-2.md

16 KiB

Implementation Plan for Steps 1 & 2: Project Structure and Client Refactoring

Overview

This document provides a detailed implementation plan for refactoring the existing Go code from main.go into a proper modular structure as outlined in the porting plan.

Current State Analysis

Existing Code in main.go (Lines 1-761)

The current main.go contains:

  • Client struct (lines 24-30) with domain, httpClient, username, authToken
  • Data models: SessionData, ActivityType, EventType, Activity, OAuth1Token, OAuth2Token, OAuthConsumer
  • OAuth functions: loadOAuthConsumer, generateNonce, generateTimestamp, percentEncode, createSignatureBaseString, createSigningKey, signRequest, createOAuth1AuthorizationHeader
  • SSO functions: getCSRFToken, extractTicket, exchangeOAuth1ForOAuth2, Login, loadEnvCredentials
  • Client methods: NewClient, getUserProfile, GetActivities, SaveSession, LoadSession
  • Main function with authentication flow and activity retrieval

Step 1: Project Structure Setup

Directory Structure to Create

garmin-connect/
├── client/
│   ├── client.go      # Core client logic
│   ├── auth.go        # Authentication handling
│   └── sso.go         # SSO authentication
├── data/
│   └── base.go        # Base data models and interfaces
├── types/
│   └── tokens.go      # Token structures
├── utils/
│   └── utils.go       # Utility functions
├── errors/
│   └── errors.go      # Custom error types
├── cmd/
│   └── garth/
│       └── main.go    # CLI tool (refactored from current main.go)
└── main.go            # Keep original temporarily for testing

Step 2: Core Client Refactoring - Detailed Implementation

2.1 Create types/tokens.go

Purpose: Centralize all token-related structures

package types

import "time"

// OAuth1Token represents OAuth1 token response
type OAuth1Token struct {
    OAuthToken       string `json:"oauth_token"`
    OAuthTokenSecret string `json:"oauth_token_secret"`
    MFAToken         string `json:"mfa_token,omitempty"`
    Domain           string `json:"domain"`
}

// OAuth2Token represents OAuth2 token response
type OAuth2Token struct {
    AccessToken  string    `json:"access_token"`
    TokenType    string    `json:"token_type"`
    ExpiresIn    int       `json:"expires_in"`
    RefreshToken string    `json:"refresh_token"`
    Scope        string    `json:"scope"`
    CreatedAt    time.Time // Added for expiration tracking
}

// OAuthConsumer represents OAuth consumer credentials
type OAuthConsumer struct {
    ConsumerKey    string `json:"consumer_key"`
    ConsumerSecret string `json:"consumer_secret"`
}

// SessionData represents saved session information
type SessionData struct {
    Domain    string `json:"domain"`
    Username  string `json:"username"`
    AuthToken string `json:"auth_token"`
}

2.2 Create client/client.go

Purpose: Core client functionality and HTTP operations

package client

import (
    "crypto/hmac"
    "crypto/rand"
    "crypto/sha1"
    "encoding/base64"
    "encoding/json"
    "fmt"
    "io"
    "net/http"
    "net/http/cookiejar"
    "net/url"
    "os"
    "regexp"
    "sort"
    "strconv"
    "strings"
    "time"
    
    "github.com/joho/godotenv"
    "garmin-connect/types"
)

// Client represents the Garmin Connect client
type Client struct {
    domain      string
    httpClient  *http.Client
    username    string
    authToken   string
    oauth1Token *types.OAuth1Token
    oauth2Token *types.OAuth2Token
}

// ConfigOption represents a client configuration option
type ConfigOption func(*Client)

// NewClient creates a new Garmin Connect client
func NewClient(domain string) (*Client, error) {
    jar, err := cookiejar.New(nil)
    if err != nil {
        return nil, fmt.Errorf("failed to create cookie jar: %w", err)
    }
    
    return &Client{
        domain: domain,
        httpClient: &http.Client{
            Jar:     jar,
            Timeout: 30 * time.Second,
        },
    }, nil
}

// Configure applies configuration options to the client
func (c *Client) Configure(opts ...ConfigOption) error {
    for _, opt := range opts {
        opt(c)
    }
    return nil
}

// ConnectAPI makes authenticated API calls to Garmin Connect
func (c *Client) ConnectAPI(path, method string, data interface{}) (interface{}, error) {
    // Implementation based on Python http.py Client.connectapi()
    // Should handle authentication, retries, and error responses
}

// Download downloads data from Garmin Connect
func (c *Client) Download(path string) ([]byte, error) {
    // Implementation for downloading files/data
}

// Upload uploads data to Garmin Connect
func (c *Client) Upload(filePath, uploadPath string) (map[string]interface{}, error) {
    // Implementation for uploading files/data
}

// GetUserProfile retrieves the current user's profile
func (c *Client) GetUserProfile() error {
    // Extracted from main.go getUserProfile method
}

// GetActivities retrieves recent activities
func (c *Client) GetActivities(limit int) ([]Activity, error) {
    // Extracted from main.go GetActivities method
}

// SaveSession saves the current session to a file
func (c *Client) SaveSession(filename string) error {
    // Extracted from main.go SaveSession method
}

// LoadSession loads a session from a file
func (c *Client) LoadSession(filename string) error {
    // Extracted from main.go LoadSession method
}

2.3 Create client/auth.go

Purpose: Authentication and token management

package client

import (
    "crypto/hmac"
    "crypto/rand"
    "crypto/sha1"
    "encoding/base64"
    "encoding/json"
    "fmt"
    "net/http"
    "net/url"
    "sort"
    "strconv"
    "strings"
    "time"
    
    "garmin-connect/types"
)

var oauthConsumer *types.OAuthConsumer

// loadOAuthConsumer loads OAuth consumer credentials
func loadOAuthConsumer() (*types.OAuthConsumer, error) {
    // Extracted from main.go loadOAuthConsumer function
}

// OAuth1 signing functions (extract from main.go)
func generateNonce() string
func generateTimestamp() string
func percentEncode(s string) string
func createSignatureBaseString(method, baseURL string, params map[string]string) string
func createSigningKey(consumerSecret, tokenSecret string) string
func signRequest(consumerSecret, tokenSecret, baseString string) string
func createOAuth1AuthorizationHeader(method, requestURL string, params map[string]string, consumerKey, consumerSecret, token, tokenSecret string) string

// Token expiration checking
func (t *types.OAuth2Token) IsExpired() bool {
    return time.Since(t.CreatedAt) > time.Duration(t.ExpiresIn)*time.Second
}

// MFA support placeholder
func (c *Client) HandleMFA(mfaToken string) error {
    // Placeholder for MFA handling
    return fmt.Errorf("MFA not yet implemented")
}

2.4 Create client/sso.go

Purpose: SSO authentication flow

package client

import (
    "encoding/json"
    "fmt"
    "io"
    "net/http"
    "net/url"
    "os"
    "regexp"
    "strings"
    
    "github.com/joho/godotenv"
    "garmin-connect/types"
)

var (
    csrfRegex   = regexp.MustCompile(`name="_csrf"\s+value="(.+?)"`)
    titleRegex  = regexp.MustCompile(`<title>(.+?)</title>`)
    ticketRegex = regexp.MustCompile(`embed\?ticket=([^"]+)"`)
)

// Login performs SSO login with email and password
func (c *Client) Login(email, password string) error {
    // Extracted from main.go Login method
}

// ResumeLogin resumes login after MFA
func (c *Client) ResumeLogin(mfaToken string) error {
    // New method for MFA completion
}

// SSO helper functions (extract from main.go)
func getCSRFToken(respBody string) string
func extractTicket(respBody string) string
func exchangeOAuth1ForOAuth2(oauth1Token *types.OAuth1Token, domain string) (*types.OAuth2Token, error)
func loadEnvCredentials() (email, password, domain string, err error)

2.5 Create data/base.go

Purpose: Base data models and interfaces

package data

import (
    "time"
    "garmin-connect/client"
)

// ActivityType represents the type of activity
type ActivityType struct {
    TypeID       int    `json:"typeId"`
    TypeKey      string `json:"typeKey"`
    ParentTypeID *int   `json:"parentTypeId,omitempty"`
}

// EventType represents the event type of an activity
type EventType struct {
    TypeID  int    `json:"typeId"`
    TypeKey string `json:"typeKey"`
}

// Activity represents a Garmin Connect activity
type Activity struct {
    ActivityID      int64        `json:"activityId"`
    ActivityName    string       `json:"activityName"`
    Description     string       `json:"description"`
    StartTimeLocal  string       `json:"startTimeLocal"`
    StartTimeGMT    string       `json:"startTimeGMT"`
    ActivityType    ActivityType `json:"activityType"`
    EventType       EventType    `json:"eventType"`
    Distance        float64      `json:"distance"`
    Duration        float64      `json:"duration"`
    ElapsedDuration float64      `json:"elapsedDuration"`
    MovingDuration  float64      `json:"movingDuration"`
    ElevationGain   float64      `json:"elevationGain"`
    ElevationLoss   float64      `json:"elevationLoss"`
    AverageSpeed    float64      `json:"averageSpeed"`
    MaxSpeed        float64      `json:"maxSpeed"`
    Calories        float64      `json:"calories"`
    AverageHR       float64      `json:"averageHR"`
    MaxHR           float64      `json:"maxHR"`
}

// Data interface for all data models
type Data interface {
    Get(day time.Time, client *client.Client) (interface{}, error)
    List(end time.Time, days int, client *client.Client, maxWorkers int) ([]interface{}, error)
}

2.6 Create errors/errors.go

Purpose: Custom error types for better error handling

package errors

import "fmt"

// GarthError represents a general Garth error
type GarthError struct {
    Message string
    Cause   error
}

func (e *GarthError) Error() string {
    if e.Cause != nil {
        return fmt.Sprintf("%s: %v", e.Message, e.Cause)
    }
    return e.Message
}

// GarthHTTPError represents an HTTP-related error
type GarthHTTPError struct {
    GarthError
    StatusCode int
    Response   string
}

func (e *GarthHTTPError) Error() string {
    return fmt.Sprintf("HTTP %d: %s", e.StatusCode, e.GarthError.Error())
}

2.7 Create utils/utils.go

Purpose: Utility functions

package utils

import (
    "strings"
    "time"
    "unicode"
)

// CamelToSnake converts CamelCase to snake_case
func CamelToSnake(s string) string {
    var result []rune
    for i, r := range s {
        if unicode.IsUpper(r) && i > 0 {
            result = append(result, '_')
        }
        result = append(result, unicode.ToLower(r))
    }
    return string(result)
}

// CamelToSnakeDict converts map keys from camelCase to snake_case
func CamelToSnakeDict(m map[string]interface{}) map[string]interface{} {
    result := make(map[string]interface{})
    for k, v := range m {
        result[CamelToSnake(k)] = v
    }
    return result
}

// FormatEndDate formats an end date interface to time.Time
func FormatEndDate(end interface{}) time.Time {
    switch v := end.(type) {
    case time.Time:
        return v
    case string:
        if t, err := time.Parse("2006-01-02", v); err == nil {
            return t
        }
    }
    return time.Now()
}

// DateRange generates a range of dates
func DateRange(end time.Time, days int) []time.Time {
    var dates []time.Time
    for i := 0; i < days; i++ {
        dates = append(dates, end.AddDate(0, 0, -i))
    }
    return dates
}

// GetLocalizedDateTime converts timestamps to localized time
func GetLocalizedDateTime(gmtTimestamp, localTimestamp int64) time.Time {
    // Implementation based on timezone offset
    return time.Unix(localTimestamp, 0)
}

2.8 Refactor main.go

Purpose: Simplified main function using the new client package

package main

import (
    "fmt"
    "log"
    "os"
    
    "garmin-connect/client"
    "garmin-connect/data"
)

func main() {
    // Load credentials from .env file
    email, password, domain, err := loadEnvCredentials()
    if err != nil {
        log.Fatalf("Failed to load credentials: %v", err)
    }

    // Create client
    garminClient, err := client.NewClient(domain)
    if err != nil {
        log.Fatalf("Failed to create client: %v", err)
    }

    // Try to load existing session first
    sessionFile := "garmin_session.json"
    if err := garminClient.LoadSession(sessionFile); err != nil {
        fmt.Println("No existing session found, logging in with credentials from .env...")
        
        if err := garminClient.Login(email, password); err != nil {
            log.Fatalf("Login failed: %v", err)
        }
        
        // Save session for future use
        if err := garminClient.SaveSession(sessionFile); err != nil {
            fmt.Printf("Failed to save session: %v\n", err)
        }
    } else {
        fmt.Println("Loaded existing session")
    }

    // Test getting activities
    activities, err := garminClient.GetActivities(5)
    if err != nil {
        log.Fatalf("Failed to get activities: %v", err)
    }

    // Display activities
    displayActivities(activities)
}

func displayActivities(activities []data.Activity) {
    fmt.Printf("\n=== Recent Activities ===\n")
    for i, activity := range activities {
        fmt.Printf("%d. %s\n", i+1, activity.ActivityName)
        fmt.Printf("   Type: %s\n", activity.ActivityType.TypeKey)
        fmt.Printf("   Date: %s\n", activity.StartTimeLocal)
        if activity.Distance > 0 {
            fmt.Printf("   Distance: %.2f km\n", activity.Distance/1000)
        }
        if activity.Duration > 0 {
            duration := time.Duration(activity.Duration) * time.Second
            fmt.Printf("   Duration: %v\n", duration.Round(time.Second))
        }
        fmt.Println()
    }
}

func loadEnvCredentials() (email, password, domain string, err error) {
    // This function should be moved to client package eventually
    // For now, keep it here to maintain functionality
    if err := godotenv.Load(); err != nil {
        return "", "", "", fmt.Errorf("failed to load .env file: %w", err)
    }
    
    email = os.Getenv("GARMIN_EMAIL")
    password = os.Getenv("GARMIN_PASSWORD")
    domain = os.Getenv("GARMIN_DOMAIN")
    
    if domain == "" {
        domain = "garmin.com"
    }
    
    if email == "" || password == "" {
        return "", "", "", fmt.Errorf("GARMIN_EMAIL and GARMIN_PASSWORD must be set in .env file")
    }
    
    return email, password, domain, nil
}

Implementation Order

  1. Create directory structure first
  2. Create types/tokens.go - Move all token structures
  3. Create errors/errors.go - Define custom error types
  4. Create utils/utils.go - Add utility functions
  5. Create client/auth.go - Extract authentication logic
  6. Create client/sso.go - Extract SSO logic
  7. Create data/base.go - Extract data models
  8. Create client/client.go - Extract client logic
  9. Refactor main.go - Update to use new packages
  10. Test the refactored code - Ensure functionality is preserved

Testing Strategy

After each major step:

  1. Run go build to check for compilation errors
  2. Test authentication flow if SSO logic was modified
  3. Test activity retrieval if client methods were changed
  4. Verify session save/load functionality

Key Considerations

  1. Maintain backward compatibility - Ensure existing functionality works
  2. Error handling - Use new custom error types appropriately
  3. Package imports - Update import paths correctly
  4. Visibility - Export only necessary functions/types (capitalize appropriately)
  5. Documentation - Add package and function documentation

This plan provides a systematic approach to refactoring the existing code while maintaining functionality and preparing for the addition of new features from the Python library.