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
- Create directory structure first
- Create types/tokens.go - Move all token structures
- Create errors/errors.go - Define custom error types
- Create utils/utils.go - Add utility functions
- Create client/auth.go - Extract authentication logic
- Create client/sso.go - Extract SSO logic
- Create data/base.go - Extract data models
- Create client/client.go - Extract client logic
- Refactor main.go - Update to use new packages
- Test the refactored code - Ensure functionality is preserved
Testing Strategy
After each major step:
- Run
go buildto check for compilation errors - Test authentication flow if SSO logic was modified
- Test activity retrieval if client methods were changed
- Verify session save/load functionality
Key Considerations
- Maintain backward compatibility - Ensure existing functionality works
- Error handling - Use new custom error types appropriately
- Package imports - Update import paths correctly
- Visibility - Export only necessary functions/types (capitalize appropriately)
- 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.