mirror of
https://github.com/sstent/go-garth.git
synced 2025-12-06 08:01:42 +00:00
working auth!
This commit is contained in:
306
activities.go
306
activities.go
@@ -1,306 +0,0 @@
|
||||
package garth
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"io/ioutil"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Activity represents a summary of a Garmin activity
|
||||
type Activity struct {
|
||||
ActivityID int64 `json:"activityId"`
|
||||
Name string `json:"activityName"`
|
||||
Type string `json:"activityType"`
|
||||
StartTime time.Time `json:"startTime"`
|
||||
Distance float64 `json:"distance"`
|
||||
Duration float64 `json:"duration"`
|
||||
Calories int `json:"calories"`
|
||||
}
|
||||
|
||||
// ActivityDetails contains detailed information about an activity
|
||||
type ActivityDetails struct {
|
||||
ActivityID int64 `json:"activityId"`
|
||||
Name string `json:"activityName"`
|
||||
Description string `json:"description"`
|
||||
Type string `json:"activityType"`
|
||||
StartTime time.Time `json:"startTime"`
|
||||
Distance float64 `json:"distance"`
|
||||
Duration float64 `json:"duration"`
|
||||
Calories int `json:"calories"`
|
||||
ElevationGain float64 `json:"elevationGain"`
|
||||
ElevationLoss float64 `json:"elevationLoss"`
|
||||
MaxHeartRate int `json:"maxHeartRate"`
|
||||
AvgHeartRate int `json:"avgHeartRate"`
|
||||
MaxSpeed float64 `json:"maxSpeed"`
|
||||
AvgSpeed float64 `json:"avgSpeed"`
|
||||
Steps int `json:"steps"`
|
||||
Stress int `json:"stress"`
|
||||
TotalSteps int `json:"totalSteps"`
|
||||
Device json.RawMessage `json:"device"`
|
||||
Location json.RawMessage `json:"location"`
|
||||
Weather json.RawMessage `json:"weather"`
|
||||
HeartRateZones json.RawMessage `json:"heartRateZones"`
|
||||
TrainingEffect json.RawMessage `json:"trainingEffect"`
|
||||
ActivityMetrics json.RawMessage `json:"activityMetrics"`
|
||||
}
|
||||
|
||||
// ActivityListOptions provides filtering options for listing activities
|
||||
type ActivityListOptions struct {
|
||||
Limit int
|
||||
StartDate time.Time
|
||||
EndDate time.Time
|
||||
ActivityType string
|
||||
NameContains string
|
||||
}
|
||||
|
||||
// ActivityUpdate represents fields that can be updated on an activity
|
||||
type ActivityUpdate struct {
|
||||
Name string `json:"activityName,omitempty"`
|
||||
Description string `json:"description,omitempty"`
|
||||
Type string `json:"activityType,omitempty"`
|
||||
StartTime time.Time `json:"startTime,omitempty"`
|
||||
Distance float64 `json:"distance,omitempty"`
|
||||
Duration float64 `json:"duration,omitempty"`
|
||||
}
|
||||
|
||||
// ActivityService provides access to activity operations
|
||||
type ActivityService struct {
|
||||
client *APIClient
|
||||
}
|
||||
|
||||
// NewActivityService creates a new ActivityService instance
|
||||
func NewActivityService(client *APIClient) *ActivityService {
|
||||
return &ActivityService{client: client}
|
||||
}
|
||||
|
||||
// List retrieves a list of activities for the current user with optional filters
|
||||
func (s *ActivityService) List(ctx context.Context, opts ActivityListOptions) ([]Activity, error) {
|
||||
params := url.Values{}
|
||||
if opts.Limit > 0 {
|
||||
params.Set("limit", strconv.Itoa(opts.Limit))
|
||||
}
|
||||
if !opts.StartDate.IsZero() {
|
||||
params.Set("startDate", opts.StartDate.Format(time.RFC3339))
|
||||
}
|
||||
if !opts.EndDate.IsZero() {
|
||||
params.Set("endDate", opts.EndDate.Format(time.RFC3339))
|
||||
}
|
||||
if opts.ActivityType != "" {
|
||||
params.Set("activityType", opts.ActivityType)
|
||||
}
|
||||
if opts.NameContains != "" {
|
||||
params.Set("nameContains", opts.NameContains)
|
||||
}
|
||||
|
||||
path := "/activitylist-service/activities/search/activities"
|
||||
if len(params) > 0 {
|
||||
path += "?" + params.Encode()
|
||||
}
|
||||
|
||||
resp, err := s.client.Get(ctx, path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, &APIError{
|
||||
StatusCode: resp.StatusCode,
|
||||
Message: "Failed to get activities list",
|
||||
}
|
||||
}
|
||||
|
||||
body, err := ioutil.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, &APIError{
|
||||
StatusCode: http.StatusInternalServerError,
|
||||
Message: "Failed to read activities response",
|
||||
Cause: err,
|
||||
}
|
||||
}
|
||||
|
||||
var activities []Activity
|
||||
if err := json.Unmarshal(body, &activities); err != nil {
|
||||
return nil, &APIError{
|
||||
StatusCode: http.StatusInternalServerError,
|
||||
Message: "Failed to parse activities data",
|
||||
Cause: err,
|
||||
}
|
||||
}
|
||||
|
||||
return activities, nil
|
||||
}
|
||||
|
||||
// Create creates a new activity
|
||||
func (s *ActivityService) Create(ctx context.Context, activity Activity) (*Activity, error) {
|
||||
jsonBody, err := json.Marshal(activity)
|
||||
if err != nil {
|
||||
return nil, &APIError{
|
||||
StatusCode: http.StatusInternalServerError,
|
||||
Message: "Failed to marshal activity",
|
||||
Cause: err,
|
||||
}
|
||||
}
|
||||
|
||||
resp, err := s.client.Post(ctx, "/activity-service/activity", bytes.NewReader(jsonBody))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusCreated {
|
||||
return nil, &APIError{
|
||||
StatusCode: resp.StatusCode,
|
||||
Message: "Failed to create activity",
|
||||
}
|
||||
}
|
||||
|
||||
body, err := ioutil.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, &APIError{
|
||||
StatusCode: http.StatusInternalServerError,
|
||||
Message: "Failed to read activity response",
|
||||
Cause: err,
|
||||
}
|
||||
}
|
||||
|
||||
var createdActivity Activity
|
||||
if err := json.Unmarshal(body, &createdActivity); err != nil {
|
||||
return nil, &APIError{
|
||||
StatusCode: http.StatusInternalServerError,
|
||||
Message: "Failed to parse activity data",
|
||||
Cause: err,
|
||||
}
|
||||
}
|
||||
|
||||
return &createdActivity, nil
|
||||
}
|
||||
|
||||
// Update updates an existing activity
|
||||
func (s *ActivityService) Update(ctx context.Context, activityID int64, update ActivityUpdate) (*Activity, error) {
|
||||
jsonBody, err := json.Marshal(update)
|
||||
if err != nil {
|
||||
return nil, &APIError{
|
||||
StatusCode: http.StatusInternalServerError,
|
||||
Message: "Failed to marshal activity update",
|
||||
Cause: err,
|
||||
}
|
||||
}
|
||||
|
||||
path := "/activity-service/activity/" + strconv.FormatInt(activityID, 10)
|
||||
resp, err := s.client.Put(ctx, path, bytes.NewReader(jsonBody))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, &APIError{
|
||||
StatusCode: resp.StatusCode,
|
||||
Message: "Failed to update activity",
|
||||
}
|
||||
}
|
||||
|
||||
body, err := ioutil.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, &APIError{
|
||||
StatusCode: http.StatusInternalServerError,
|
||||
Message: "Failed to read activity response",
|
||||
Cause: err,
|
||||
}
|
||||
}
|
||||
|
||||
var updatedActivity Activity
|
||||
if err := json.Unmarshal(body, &updatedActivity); err != nil {
|
||||
return nil, &APIError{
|
||||
StatusCode: http.StatusInternalServerError,
|
||||
Message: "Failed to parse activity data",
|
||||
Cause: err,
|
||||
}
|
||||
}
|
||||
|
||||
return &updatedActivity, nil
|
||||
}
|
||||
|
||||
// Delete deletes an existing activity
|
||||
func (s *ActivityService) Delete(ctx context.Context, activityID int64) error {
|
||||
path := "/activity-service/activity/" + strconv.FormatInt(activityID, 10)
|
||||
resp, err := s.client.Delete(ctx, path, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusNoContent && resp.StatusCode != http.StatusOK {
|
||||
return &APIError{
|
||||
StatusCode: resp.StatusCode,
|
||||
Message: "Failed to delete activity",
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Get retrieves detailed information about a specific activity
|
||||
func (s *ActivityService) Get(ctx context.Context, activityID int64) (*ActivityDetails, error) {
|
||||
path := "/activity-service/activity/" + strconv.FormatInt(activityID, 10)
|
||||
|
||||
resp, err := s.client.Get(ctx, path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, &APIError{
|
||||
StatusCode: resp.StatusCode,
|
||||
Message: "Failed to get activity details",
|
||||
}
|
||||
}
|
||||
|
||||
body, err := ioutil.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, &APIError{
|
||||
StatusCode: http.StatusInternalServerError,
|
||||
Message: "Failed to read activity response",
|
||||
Cause: err,
|
||||
}
|
||||
}
|
||||
|
||||
var details ActivityDetails
|
||||
if err := json.Unmarshal(body, &details); err != nil {
|
||||
return nil, &APIError{
|
||||
StatusCode: http.StatusInternalServerError,
|
||||
Message: "Failed to parse activity data",
|
||||
Cause: err,
|
||||
}
|
||||
}
|
||||
|
||||
return &details, nil
|
||||
}
|
||||
|
||||
// Export exports an activity in the specified format (gpx, tcx, original)
|
||||
func (s *ActivityService) Export(ctx context.Context, activityID int64, format string) (io.ReadCloser, error) {
|
||||
path := "/download-service/export/" + format + "/activity/" + strconv.FormatInt(activityID, 10)
|
||||
|
||||
resp, err := s.client.Get(ctx, path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
defer resp.Body.Close()
|
||||
return nil, &APIError{
|
||||
StatusCode: resp.StatusCode,
|
||||
Message: "Failed to export activity",
|
||||
}
|
||||
}
|
||||
|
||||
return resp.Body, nil
|
||||
}
|
||||
@@ -1,90 +0,0 @@
|
||||
package garth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestActivityService_List(t *testing.T) {
|
||||
// Create test server
|
||||
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Write([]byte(`[{
|
||||
"activityId": 123456789,
|
||||
"activityName": "Morning Run",
|
||||
"activityType": "running",
|
||||
"startTime": "2025-08-29T06:00:00Z",
|
||||
"distance": 5000,
|
||||
"duration": 1800,
|
||||
"calories": 350
|
||||
}]`))
|
||||
}))
|
||||
defer ts.Close()
|
||||
|
||||
// Create client
|
||||
apiClient := NewAPIClient(ts.URL, http.DefaultClient)
|
||||
activityService := NewActivityService(apiClient)
|
||||
|
||||
// Test List method with filters
|
||||
startDate := time.Date(2025, time.August, 1, 0, 0, 0, 0, time.UTC)
|
||||
endDate := time.Date(2025, time.August, 31, 0, 0, 0, 0, time.UTC)
|
||||
|
||||
opts := ActivityListOptions{
|
||||
Limit: 10,
|
||||
StartDate: startDate,
|
||||
EndDate: endDate,
|
||||
}
|
||||
activities, err := activityService.List(context.Background(), opts)
|
||||
if err != nil {
|
||||
t.Fatalf("Unexpected error: %v", err)
|
||||
}
|
||||
|
||||
// Verify activity data
|
||||
if len(activities) != 1 {
|
||||
t.Fatalf("Expected 1 activity, got %d", len(activities))
|
||||
}
|
||||
if activities[0].Name != "Morning Run" {
|
||||
t.Errorf("Expected activity name 'Morning Run', got '%s'", activities[0].Name)
|
||||
}
|
||||
if activities[0].ActivityID != 123456789 {
|
||||
t.Errorf("Expected activity ID 123456789, got %d", activities[0].ActivityID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestActivityService_Get(t *testing.T) {
|
||||
// Create test server
|
||||
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Write([]byte(`{
|
||||
"activityId": 987654321,
|
||||
"activityName": "Evening Ride",
|
||||
"activityType": "cycling",
|
||||
"startTime": "2025-08-29T18:30:00Z",
|
||||
"distance": 25000,
|
||||
"duration": 3600,
|
||||
"calories": 650
|
||||
}`))
|
||||
}))
|
||||
defer ts.Close()
|
||||
|
||||
// Create client
|
||||
apiClient := NewAPIClient(ts.URL, http.DefaultClient)
|
||||
activityService := NewActivityService(apiClient)
|
||||
|
||||
// Test Get method
|
||||
activity, err := activityService.Get(context.Background(), 987654321)
|
||||
if err != nil {
|
||||
t.Fatalf("Unexpected error: %v", err)
|
||||
}
|
||||
|
||||
// Verify activity details
|
||||
if activity.Name != "Evening Ride" {
|
||||
t.Errorf("Expected activity name 'Evening Ride', got '%s'", activity.Name)
|
||||
}
|
||||
if activity.ActivityID != 987654321 {
|
||||
t.Errorf("Expected activity ID 987654321, got %d", activity.ActivityID)
|
||||
}
|
||||
}
|
||||
557
auth.go
557
auth.go
@@ -1,557 +0,0 @@
|
||||
package garth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"math/rand"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"net/http/cookiejar"
|
||||
)
|
||||
|
||||
type GarthAuthenticator struct {
|
||||
client *http.Client
|
||||
tokenURL string
|
||||
storage TokenStorage
|
||||
userAgent string
|
||||
csrfToken string
|
||||
oauth1Token string
|
||||
domain string
|
||||
}
|
||||
|
||||
func NewAuthenticator(opts ClientOptions) Authenticator {
|
||||
// Set default domain if not provided
|
||||
if opts.Domain == "" {
|
||||
opts.Domain = "garmin.com"
|
||||
}
|
||||
|
||||
// Enhanced transport with better TLS settings and compression
|
||||
baseTransport := &http.Transport{
|
||||
TLSClientConfig: &tls.Config{
|
||||
MinVersion: tls.VersionTLS12,
|
||||
PreferServerCipherSuites: true,
|
||||
CipherSuites: []uint16{
|
||||
tls.TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384,
|
||||
tls.TLS_ECDHE_RSA_WITH_CHACHA20_POLY1305,
|
||||
tls.TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256,
|
||||
},
|
||||
},
|
||||
Proxy: http.ProxyFromEnvironment,
|
||||
DisableCompression: false, // Enable compression
|
||||
MaxIdleConns: 10,
|
||||
IdleConnTimeout: 30 * time.Second,
|
||||
TLSHandshakeTimeout: 10 * time.Second,
|
||||
}
|
||||
|
||||
jar, err := cookiejar.New(nil)
|
||||
if err != nil {
|
||||
jar = nil
|
||||
}
|
||||
|
||||
client := &http.Client{
|
||||
Timeout: opts.Timeout,
|
||||
Transport: baseTransport,
|
||||
Jar: jar,
|
||||
CheckRedirect: func(req *http.Request, via []*http.Request) error {
|
||||
if len(via) >= 10 {
|
||||
return errors.New("stopped after 10 redirects")
|
||||
}
|
||||
// Preserve headers during redirects
|
||||
if len(via) > 0 {
|
||||
for key, values := range via[0].Header {
|
||||
if key == "Authorization" || key == "Cookie" {
|
||||
continue // Let the jar handle cookies
|
||||
}
|
||||
req.Header[key] = values
|
||||
}
|
||||
}
|
||||
if jar != nil {
|
||||
for _, v := range via {
|
||||
if v.Response != nil {
|
||||
if cookies := v.Response.Cookies(); len(cookies) > 0 {
|
||||
jar.SetCookies(req.URL, cookies)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
auth := &GarthAuthenticator{
|
||||
client: client,
|
||||
tokenURL: opts.TokenURL,
|
||||
storage: opts.Storage,
|
||||
userAgent: "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36", // More realistic user agent
|
||||
domain: opts.Domain,
|
||||
}
|
||||
|
||||
if setter, ok := opts.Storage.(interface{ SetAuthenticator(a Authenticator) }); ok {
|
||||
setter.SetAuthenticator(auth)
|
||||
}
|
||||
|
||||
return auth
|
||||
}
|
||||
|
||||
// Enhanced browser headers to bypass Cloudflare
|
||||
func (a *GarthAuthenticator) getRealisticBrowserHeaders(referer string) http.Header {
|
||||
headers := http.Header{
|
||||
"User-Agent": {a.userAgent},
|
||||
"Accept": {"text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7"},
|
||||
"Accept-Language": {"en-US,en;q=0.9"},
|
||||
"Accept-Encoding": {"gzip, deflate, br"},
|
||||
"Cache-Control": {"max-age=0"},
|
||||
"Sec-Ch-Ua": {`"Not_A Brand";v="8", "Chromium";v="120", "Google Chrome";v="120"`},
|
||||
"Sec-Ch-Ua-Mobile": {"?0"},
|
||||
"Sec-Ch-Ua-Platform": {`"Windows"`},
|
||||
"Sec-Fetch-Dest": {"document"},
|
||||
"Sec-Fetch-Mode": {"navigate"},
|
||||
"Sec-Fetch-Site": {"none"},
|
||||
"Sec-Fetch-User": {"?1"},
|
||||
"Upgrade-Insecure-Requests": {"1"},
|
||||
"DNT": {"1"},
|
||||
}
|
||||
|
||||
if referer != "" {
|
||||
headers.Set("Referer", referer)
|
||||
headers.Set("Sec-Fetch-Site", "same-origin")
|
||||
}
|
||||
|
||||
return headers
|
||||
}
|
||||
|
||||
func (a *GarthAuthenticator) Login(ctx context.Context, username, password, mfaToken string) (*Token, error) {
|
||||
// Add delay to simulate human behavior
|
||||
time.Sleep(time.Duration(500+rand.Intn(1000)) * time.Millisecond)
|
||||
|
||||
// Step 1: Get login ticket (lt) from SSO signin page
|
||||
authToken, tokenType, err := a.getLoginTicket(ctx)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get login ticket: %w", err)
|
||||
}
|
||||
|
||||
// Step 2: Authenticate with credentials
|
||||
serviceTicket, err := a.authenticate(ctx, username, password, "", authToken, tokenType)
|
||||
if err != nil {
|
||||
// Check if MFA is required
|
||||
if authErr, ok := err.(*AuthError); ok && authErr.Type == "mfa_required" {
|
||||
if mfaToken == "" {
|
||||
return nil, errors.New("MFA required but no token provided")
|
||||
}
|
||||
log.Printf("MFA required, handling with token: %s", mfaToken)
|
||||
// Handle MFA authentication
|
||||
serviceTicket, err = a.handleMFA(ctx, username, password, mfaToken, authErr.CSRF)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("MFA authentication failed: %w", err)
|
||||
}
|
||||
log.Printf("MFA authentication successful, service ticket obtained")
|
||||
} else {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
// Step 3: Exchange service ticket for access token
|
||||
token, err := a.exchangeServiceTicketForToken(ctx, serviceTicket)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to exchange service ticket for token: %w", err)
|
||||
}
|
||||
|
||||
if err := a.storage.StoreToken(token); err != nil {
|
||||
return nil, fmt.Errorf("failed to save token: %w", err)
|
||||
}
|
||||
|
||||
return token, nil
|
||||
}
|
||||
|
||||
// exchangeServiceTicketForToken exchanges service ticket for access token
|
||||
func (a *GarthAuthenticator) exchangeServiceTicketForToken(ctx context.Context, ticket string) (*Token, error) {
|
||||
callbackURL := fmt.Sprintf("https://connect.%s/oauthConfirm?ticket=%s", a.domain, ticket)
|
||||
req, err := http.NewRequestWithContext(ctx, "GET", callbackURL, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create callback request: %w", err)
|
||||
}
|
||||
|
||||
// Use realistic browser headers
|
||||
req.Header = a.getRealisticBrowserHeaders("https://sso.garmin.com")
|
||||
|
||||
resp, err := a.client.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("callback request failed: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("callback failed with status: %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read callback response: %w", err)
|
||||
}
|
||||
|
||||
// Extract tokens from embedded JavaScript
|
||||
accessToken, err := extractParam(`"accessToken":"([^"]+)"`, string(body))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to extract access token: %w", err)
|
||||
}
|
||||
|
||||
refreshToken, err := extractParam(`"refreshToken":"([^"]+)"`, string(body))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to extract refresh token: %w", err)
|
||||
}
|
||||
|
||||
expiresAt, err := extractParam(`"expiresAt":(\d+)`, string(body))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to extract expiresAt: %w", err)
|
||||
}
|
||||
|
||||
expiresAtInt, err := strconv.ParseInt(expiresAt, 10, 64)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to parse expiresAt: %w", err)
|
||||
}
|
||||
|
||||
return &Token{
|
||||
AccessToken: accessToken,
|
||||
RefreshToken: refreshToken,
|
||||
ExpiresAt: expiresAtInt,
|
||||
Domain: a.domain,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (a *GarthAuthenticator) getLoginTicket(ctx context.Context) (string, string, error) {
|
||||
params := url.Values{}
|
||||
params.Set("id", "gauth-widget")
|
||||
params.Set("embedWidget", "true")
|
||||
params.Set("gauthHost", fmt.Sprintf("https://sso.%s/sso", a.domain))
|
||||
params.Set("service", fmt.Sprintf("https://connect.%s/oauthConfirm", a.domain))
|
||||
params.Set("source", fmt.Sprintf("https://sso.%s/sso", a.domain))
|
||||
params.Set("redirectAfterAccountLoginUrl", fmt.Sprintf("https://connect.%s/oauthConfirm", a.domain))
|
||||
params.Set("redirectAfterAccountCreationUrl", fmt.Sprintf("https://connect.%s/oauthConfirm", a.domain))
|
||||
params.Set("consumeServiceTicket", "false")
|
||||
params.Set("generateExtraServiceTicket", "true")
|
||||
params.Set("clientId", "GarminConnect")
|
||||
params.Set("locale", "en_US")
|
||||
|
||||
if a.oauth1Token != "" {
|
||||
params.Set("oauth_token", a.oauth1Token)
|
||||
}
|
||||
|
||||
loginURL := "https://sso.garmin.com/sso/signin?" + params.Encode()
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, "GET", loginURL, nil)
|
||||
if err != nil {
|
||||
return "", "", fmt.Errorf("failed to create login page request: %w", err)
|
||||
}
|
||||
|
||||
// Use realistic browser headers with proper referer chain
|
||||
for key, values := range a.getRealisticBrowserHeaders("") {
|
||||
req.Header[key] = values
|
||||
}
|
||||
|
||||
// Add some randomness to the request timing
|
||||
time.Sleep(time.Duration(100+rand.Intn(200)) * time.Millisecond)
|
||||
|
||||
resp, err := a.client.Do(req)
|
||||
if err != nil {
|
||||
return "", "", fmt.Errorf("login page request failed: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode == 403 {
|
||||
// Cloudflare blocked us, try with different headers
|
||||
return "", "", fmt.Errorf("blocked by Cloudflare - try using different IP or wait before retrying")
|
||||
}
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return "", "", fmt.Errorf("failed to read login page response: %w", err)
|
||||
}
|
||||
|
||||
return getCSRFToken(string(body))
|
||||
}
|
||||
|
||||
func (a *GarthAuthenticator) authenticate(ctx context.Context, username, password, mfaToken, authToken, tokenType string) (string, error) {
|
||||
data := url.Values{}
|
||||
data.Set("username", username)
|
||||
data.Set("password", password)
|
||||
data.Set("embed", "true")
|
||||
data.Set("rememberme", "on")
|
||||
|
||||
if tokenType == "lt" {
|
||||
data.Set("lt", authToken)
|
||||
} else {
|
||||
data.Set("_csrf", authToken)
|
||||
}
|
||||
|
||||
data.Set("_eventId", "submit")
|
||||
data.Set("geolocation", "")
|
||||
data.Set("clientId", "GarminConnect")
|
||||
data.Set("service", fmt.Sprintf("https://connect.%s/oauthConfirm", a.domain))
|
||||
data.Set("webhost", fmt.Sprintf("https://connect.%s", a.domain))
|
||||
data.Set("fromPage", "oauth")
|
||||
data.Set("locale", "en_US")
|
||||
data.Set("id", "gauth-widget")
|
||||
data.Set("redirectAfterAccountLoginUrl", fmt.Sprintf("https://connect.%s/oauthConfirm", a.domain))
|
||||
data.Set("redirectAfterAccountCreationUrl", fmt.Sprintf("https://connect.%s/oauthConfirm", a.domain))
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, "POST", fmt.Sprintf("https://sso.%s/sso/signin", a.domain), strings.NewReader(data.Encode()))
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to create SSO request: %w", err)
|
||||
}
|
||||
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
req.Header.Set("Accept", "application/json")
|
||||
req.Header.Set("User-Agent", a.userAgent)
|
||||
req.Header.Set("Referer", fmt.Sprintf("https://sso.%s/sso/signin", a.domain))
|
||||
req.Header.Set("Origin", fmt.Sprintf("https://sso.%s", a.domain))
|
||||
|
||||
// Add some delay to simulate typing
|
||||
time.Sleep(time.Duration(800+rand.Intn(400)) * time.Millisecond)
|
||||
|
||||
resp, err := a.client.Do(req)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("SSO request failed: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode == http.StatusPreconditionFailed {
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
csrfToken, err := extractParam(`name="_csrf"\s+value="([^"]+)"`, string(body))
|
||||
if err != nil {
|
||||
return "", &AuthError{
|
||||
StatusCode: http.StatusPreconditionFailed,
|
||||
Message: "MFA CSRF token not found",
|
||||
Cause: err,
|
||||
Type: "mfa_required",
|
||||
CSRF: "", // Will be set below
|
||||
}
|
||||
}
|
||||
return "", &AuthError{
|
||||
StatusCode: http.StatusPreconditionFailed,
|
||||
Message: "MFA required",
|
||||
Type: "mfa_required",
|
||||
CSRF: csrfToken,
|
||||
}
|
||||
}
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
return "", fmt.Errorf("authentication failed with status: %d, response: %s", resp.StatusCode, body)
|
||||
}
|
||||
|
||||
var authResponse struct {
|
||||
Ticket string `json:"ticket"`
|
||||
}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&authResponse); err != nil {
|
||||
return "", fmt.Errorf("failed to parse SSO response: %w", err)
|
||||
}
|
||||
|
||||
if authResponse.Ticket == "" {
|
||||
return "", errors.New("empty ticket in SSO response")
|
||||
}
|
||||
|
||||
return authResponse.Ticket, nil
|
||||
}
|
||||
|
||||
func (a *GarthAuthenticator) getEnhancedBrowserHeaders(referrer string) http.Header {
|
||||
u, _ := url.Parse(referrer)
|
||||
origin := fmt.Sprintf("%s://%s", u.Scheme, u.Host)
|
||||
|
||||
return http.Header{
|
||||
"User-Agent": {a.userAgent},
|
||||
"Accept": {"text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7"},
|
||||
"Accept-Language": {"en-US,en;q=0.9"},
|
||||
"Connection": {"keep-alive"},
|
||||
"Cache-Control": {"max-age=0"},
|
||||
"Origin": {origin},
|
||||
"Referer": {referrer},
|
||||
"Sec-Fetch-Site": {"same-origin"},
|
||||
"Sec-Fetch-Mode": {"navigate"},
|
||||
"Sec-Fetch-User": {"?1"},
|
||||
"Sec-Fetch-Dest": {"document"},
|
||||
"DNT": {"1"},
|
||||
"Upgrade-Insecure-Requests": {"1"},
|
||||
}
|
||||
}
|
||||
|
||||
func getCSRFToken(html string) (string, string, error) {
|
||||
// More robust regex patterns to handle variations in HTML structure
|
||||
patterns := []struct {
|
||||
regex string
|
||||
tokenType string
|
||||
}{
|
||||
// Pattern for login ticket (lt)
|
||||
{`<input[^>]*name="lt"[^>]*value="([^"]+)"`, "lt"},
|
||||
// Pattern for CSRF token in hidden input
|
||||
{`<input[^>]*name="_csrf"[^>]*value="([^"]+)"`, "_csrf"},
|
||||
// Pattern for CSRF token in meta tag
|
||||
{`<meta[^>]*name="_csrf"[^>]*content="([^"]+)"`, "_csrf"},
|
||||
// Pattern for CSRF token in JSON payload
|
||||
{`"csrfToken"\s*:\s*"([^"]+)"`, "_csrf"},
|
||||
}
|
||||
|
||||
for _, p := range patterns {
|
||||
re := regexp.MustCompile(p.regex)
|
||||
matches := re.FindStringSubmatch(html)
|
||||
if len(matches) > 1 {
|
||||
return matches[1], p.tokenType, nil
|
||||
}
|
||||
}
|
||||
|
||||
// If we get here, we didn't find a token
|
||||
// Log and save the response for debugging
|
||||
log.Printf("Failed to find authentication token in HTML response")
|
||||
debugFilename := fmt.Sprintf("debug/oauth1_response_%d.html", time.Now().UnixNano())
|
||||
if err := os.WriteFile(debugFilename, []byte(html), 0644); err != nil {
|
||||
log.Printf("Failed to write debug file: %v", err)
|
||||
}
|
||||
return "", "", fmt.Errorf("no authentication token found in HTML response; response written to %s", debugFilename)
|
||||
}
|
||||
|
||||
// RefreshToken implements token refresh functionality
|
||||
func (a *GarthAuthenticator) RefreshToken(ctx context.Context, refreshToken string) (*Token, error) {
|
||||
data := url.Values{}
|
||||
data.Set("grant_type", "refresh_token")
|
||||
data.Set("refresh_token", refreshToken)
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, "POST", a.tokenURL, strings.NewReader(data.Encode()))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create refresh request: %w", err)
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
req.Header.Set("User-Agent", a.userAgent)
|
||||
req.SetBasicAuth("garmin-connect", "garmin-connect-secret")
|
||||
|
||||
resp, err := a.client.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("refresh request failed: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
return nil, fmt.Errorf("refresh failed: %d %s", resp.StatusCode, body)
|
||||
}
|
||||
|
||||
var response struct {
|
||||
AccessToken string `json:"access_token"`
|
||||
RefreshToken string `json:"refresh_token"`
|
||||
ExpiresIn int `json:"expires_in"`
|
||||
}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&response); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse refresh response: %w", err)
|
||||
}
|
||||
|
||||
expiresAt := time.Now().Add(time.Duration(response.ExpiresIn) * time.Second).Unix()
|
||||
return &Token{
|
||||
AccessToken: response.AccessToken,
|
||||
RefreshToken: response.RefreshToken,
|
||||
ExpiresAt: expiresAt,
|
||||
Domain: a.domain,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// GetClient returns the HTTP client used for authentication
|
||||
func (a *GarthAuthenticator) GetClient() *http.Client {
|
||||
return a.client
|
||||
}
|
||||
|
||||
// handleMFA processes multi-factor authentication
|
||||
func (a *GarthAuthenticator) handleMFA(ctx context.Context, username, password, mfaToken, csrfToken string) (string, error) {
|
||||
data := url.Values{}
|
||||
data.Set("username", username)
|
||||
data.Set("password", password)
|
||||
data.Set("mfaToken", mfaToken)
|
||||
data.Set("embed", "true")
|
||||
data.Set("rememberme", "on")
|
||||
data.Set("_csrf", csrfToken)
|
||||
data.Set("_eventId", "submit")
|
||||
data.Set("geolocation", "")
|
||||
data.Set("clientId", "GarminConnect")
|
||||
data.Set("service", fmt.Sprintf("https://connect.%s", a.domain))
|
||||
data.Set("webhost", fmt.Sprintf("https://connect.%s", a.domain))
|
||||
data.Set("fromPage", "oauth")
|
||||
data.Set("locale", "en_US")
|
||||
data.Set("id", "gauth-widget")
|
||||
data.Set("redirectAfterAccountLoginUrl", fmt.Sprintf("https://connect.%s/oauthConfirm", a.domain))
|
||||
data.Set("redirectAfterAccountCreationUrl", fmt.Sprintf("https://connect.%s/oauthConfirm", a.domain))
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, "POST", fmt.Sprintf("https://sso.%s/sso/signin", a.domain), strings.NewReader(data.Encode()))
|
||||
if err != nil {
|
||||
return "", &AuthError{
|
||||
StatusCode: http.StatusInternalServerError,
|
||||
Message: "Failed to create MFA request",
|
||||
Cause: err,
|
||||
}
|
||||
}
|
||||
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
req.Header.Set("Accept", "application/json")
|
||||
req.Header.Set("User-Agent", a.userAgent)
|
||||
|
||||
resp, err := a.client.Do(req)
|
||||
if err != nil {
|
||||
return "", &AuthError{
|
||||
StatusCode: http.StatusBadGateway,
|
||||
Message: "MFA request failed",
|
||||
Cause: err,
|
||||
}
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
return "", &AuthError{
|
||||
StatusCode: resp.StatusCode,
|
||||
Message: fmt.Sprintf("MFA failed: %s", body),
|
||||
Type: "mfa_failure",
|
||||
}
|
||||
}
|
||||
|
||||
var mfaResponse struct {
|
||||
Ticket string `json:"ticket"`
|
||||
}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&mfaResponse); err != nil {
|
||||
return "", &AuthError{
|
||||
StatusCode: http.StatusInternalServerError,
|
||||
Message: "Failed to parse MFA response",
|
||||
Cause: err,
|
||||
}
|
||||
}
|
||||
|
||||
if mfaResponse.Ticket == "" {
|
||||
return "", &AuthError{
|
||||
StatusCode: http.StatusUnauthorized,
|
||||
Message: "Invalid MFA response - ticket missing",
|
||||
Type: "invalid_mfa_response",
|
||||
}
|
||||
}
|
||||
|
||||
return mfaResponse.Ticket, nil
|
||||
}
|
||||
|
||||
// Configure updates authenticator settings
|
||||
func (a *GarthAuthenticator) Configure(domain string) {
|
||||
a.domain = domain
|
||||
}
|
||||
|
||||
// extractParam helper to extract regex pattern
|
||||
func extractParam(pattern, body string) (string, error) {
|
||||
re := regexp.MustCompile(pattern)
|
||||
matches := re.FindStringSubmatch(body)
|
||||
if len(matches) < 2 {
|
||||
return "", fmt.Errorf("pattern not found: %s", pattern)
|
||||
}
|
||||
return matches[1], nil
|
||||
}
|
||||
88
auth_test.go
88
auth_test.go
@@ -1,88 +0,0 @@
|
||||
package garth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log"
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/joho/godotenv"
|
||||
)
|
||||
|
||||
func TestRealAuthentication(t *testing.T) {
|
||||
// Load environment variables from .env file
|
||||
if err := godotenv.Load(); err != nil {
|
||||
t.Fatalf("Error loading .env file: %v", err)
|
||||
}
|
||||
|
||||
// Get credentials from environment
|
||||
username := os.Getenv("GARMIN_USERNAME")
|
||||
password := os.Getenv("GARMIN_PASSWORD")
|
||||
mfaToken := os.Getenv("GARMIN_MFA_TOKEN") // Optional MFA token
|
||||
if username == "" || password == "" {
|
||||
t.Fatal("GARMIN_USERNAME or GARMIN_PASSWORD not set in .env")
|
||||
}
|
||||
|
||||
// Add timeout to prevent hanging
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
|
||||
defer cancel()
|
||||
|
||||
// Create token storage (using memory storage for this test)
|
||||
storage := NewMemoryStorage()
|
||||
|
||||
// Create authenticator
|
||||
auth := NewAuthenticator(ClientOptions{
|
||||
Storage: storage,
|
||||
TokenURL: "https://connectapi.garmin.com/oauth-service/oauth/token",
|
||||
Timeout: 30 * time.Second,
|
||||
})
|
||||
|
||||
// Test authentication with and without MFA
|
||||
testCases := []struct {
|
||||
name string
|
||||
mfaToken string
|
||||
}{
|
||||
{"Without MFA", ""},
|
||||
{"With MFA", mfaToken},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
// Perform authentication
|
||||
token, err := auth.Login(ctx, username, password, tc.mfaToken)
|
||||
if err != nil {
|
||||
if tc.mfaToken != "" && err.Error() == "MFA required but no token provided" {
|
||||
t.Skip("Skipping MFA test since no token provided")
|
||||
}
|
||||
t.Fatalf("Authentication failed: %v", err)
|
||||
}
|
||||
|
||||
log.Printf("Authentication successful! Token details:")
|
||||
log.Printf("Access Token: %s", token.AccessToken)
|
||||
log.Printf("Expires At: %d", token.ExpiresAt)
|
||||
log.Printf("Refresh Token: %s", token.RefreshToken)
|
||||
|
||||
// Verify token storage
|
||||
storedToken, err := storage.GetToken()
|
||||
if err != nil {
|
||||
t.Fatalf("Token storage verification failed: %v", err)
|
||||
}
|
||||
if storedToken.AccessToken != token.AccessToken {
|
||||
t.Fatal("Stored token doesn't match authenticated token")
|
||||
}
|
||||
|
||||
log.Println("Token storage verification successful")
|
||||
|
||||
// Test token refresh
|
||||
newToken, err := auth.RefreshToken(ctx, token.RefreshToken)
|
||||
if err != nil {
|
||||
t.Fatalf("Token refresh failed: %v", err)
|
||||
}
|
||||
if newToken.AccessToken == token.AccessToken {
|
||||
t.Fatal("Refreshed token should be different from original")
|
||||
}
|
||||
log.Println("Token refresh successful")
|
||||
})
|
||||
}
|
||||
}
|
||||
224
claude.md
224
claude.md
@@ -1,224 +0,0 @@
|
||||
// Fixed authentication flow based on Python garth implementation
|
||||
|
||||
// fetchOAuth1Token should be the first step and get the initial OAuth1 token
|
||||
func (a *GarthAuthenticator) fetchOAuth1Token(ctx context.Context) (string, error) {
|
||||
// Step 1: Initial OAuth1 request - this should NOT have parameters initially
|
||||
oauth1URL := "https://connect.garmin.com/oauthConfirm"
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, "GET", oauth1URL, nil)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to create OAuth1 request: %w", err)
|
||||
}
|
||||
|
||||
// Set proper headers
|
||||
req.Header.Set("User-Agent", a.userAgent)
|
||||
req.Header.Set("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8")
|
||||
|
||||
resp, err := a.client.Do(req)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("OAuth1 request failed: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
// Handle redirect case - OAuth1 token often comes from redirect location
|
||||
if resp.StatusCode >= 300 && resp.StatusCode < 400 {
|
||||
location := resp.Header.Get("Location")
|
||||
if location != "" {
|
||||
if u, err := url.Parse(location); err == nil {
|
||||
if token := u.Query().Get("oauth_token"); token != "" {
|
||||
return token, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If no redirect, parse response body
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to read OAuth1 response: %w", err)
|
||||
}
|
||||
|
||||
// Look for oauth_token in various formats
|
||||
patterns := []string{
|
||||
`oauth_token=([^&\s"']+)`,
|
||||
`"oauth_token":\s*"([^"]+)"`,
|
||||
`'oauth_token':\s*'([^']+)'`,
|
||||
`oauth_token["']?\s*[:=]\s*["']?([^"'\s&]+)`,
|
||||
}
|
||||
|
||||
for _, pattern := range patterns {
|
||||
re := regexp.MustCompile(pattern)
|
||||
if matches := re.FindStringSubmatch(string(body)); len(matches) > 1 {
|
||||
return matches[1], nil
|
||||
}
|
||||
}
|
||||
|
||||
// Debug: save response for analysis
|
||||
filename := fmt.Sprintf("oauth1_response_%d.html", time.Now().Unix())
|
||||
if writeErr := os.WriteFile(filename, body, 0644); writeErr == nil {
|
||||
return "", fmt.Errorf("OAuth1 token not found (response saved to %s)", filename)
|
||||
}
|
||||
|
||||
return "", fmt.Errorf("OAuth1 token not found in response")
|
||||
}
|
||||
|
||||
// Updated Login method with correct flow
|
||||
func (a *GarthAuthenticator) Login(ctx context.Context, username, password, mfaToken string) (*Token, error) {
|
||||
// Step 1: Get OAuth1 token FIRST
|
||||
oauth1Token, err := a.fetchOAuth1Token(ctx)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get OAuth1 token: %w", err)
|
||||
}
|
||||
a.oauth1Token = oauth1Token
|
||||
|
||||
// Step 2: Now get login parameters with OAuth1 context
|
||||
authToken, tokenType, err := a.fetchLoginParamsWithOAuth1(ctx)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get login params: %w", err)
|
||||
}
|
||||
|
||||
a.csrfToken = authToken
|
||||
|
||||
// Step 3: Authenticate with all tokens
|
||||
token, err := a.authenticate(ctx, username, password, mfaToken, authToken, tokenType)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Save token to storage
|
||||
if err := a.storage.SaveToken(token); err != nil {
|
||||
return nil, fmt.Errorf("failed to save token: %w", err)
|
||||
}
|
||||
|
||||
return token, nil
|
||||
}
|
||||
|
||||
// New method to fetch login params with OAuth1 context
|
||||
func (a *GarthAuthenticator) fetchLoginParamsWithOAuth1(ctx context.Context) (token string, tokenType string, err error) {
|
||||
// Build login URL with OAuth1 context
|
||||
params := url.Values{}
|
||||
params.Set("id", "gauth-widget")
|
||||
params.Set("embedWidget", "true")
|
||||
params.Set("gauthHost", "https://sso.garmin.com/sso")
|
||||
params.Set("service", "https://connect.garmin.com/oauthConfirm")
|
||||
params.Set("source", "https://sso.garmin.com/sso")
|
||||
params.Set("redirectAfterAccountLoginUrl", "https://connect.garmin.com/oauthConfirm")
|
||||
params.Set("redirectAfterAccountCreationUrl", "https://connect.garmin.com/oauthConfirm")
|
||||
params.Set("consumeServiceTicket", "false")
|
||||
params.Set("generateExtraServiceTicket", "true")
|
||||
params.Set("clientId", "GarminConnect")
|
||||
params.Set("locale", "en_US")
|
||||
|
||||
// Add OAuth1 token if we have it
|
||||
if a.oauth1Token != "" {
|
||||
params.Set("oauth_token", a.oauth1Token)
|
||||
}
|
||||
|
||||
loginURL := "https://sso.garmin.com/sso/signin?" + params.Encode()
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, "GET", loginURL, nil)
|
||||
if err != nil {
|
||||
return "", "", fmt.Errorf("failed to create login page request: %w", err)
|
||||
}
|
||||
|
||||
// Set headers with proper referrer chain
|
||||
req.Header.Set("User-Agent", a.userAgent)
|
||||
req.Header.Set("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8")
|
||||
req.Header.Set("Accept-Language", "en-US,en;q=0.9")
|
||||
req.Header.Set("Accept-Encoding", "gzip, deflate, br")
|
||||
req.Header.Set("Referer", "https://connect.garmin.com/oauthConfirm")
|
||||
req.Header.Set("Origin", "https://connect.garmin.com")
|
||||
|
||||
resp, err := a.client.Do(req)
|
||||
if err != nil {
|
||||
return "", "", fmt.Errorf("login page request failed: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return "", "", fmt.Errorf("failed to read login page response: %w", err)
|
||||
}
|
||||
|
||||
bodyStr := string(body)
|
||||
|
||||
// Extract CSRF/lt token
|
||||
token, tokenType, err = getCSRFTokenWithType(bodyStr)
|
||||
if err != nil {
|
||||
// Save for debugging
|
||||
filename := fmt.Sprintf("login_page_oauth1_%d.html", time.Now().Unix())
|
||||
if writeErr := os.WriteFile(filename, body, 0644); writeErr == nil {
|
||||
return "", "", fmt.Errorf("authentication token not found with OAuth1 context: %w (HTML saved to %s)", err, filename)
|
||||
}
|
||||
return "", "", fmt.Errorf("authentication token not found with OAuth1 context: %w", err)
|
||||
}
|
||||
|
||||
return token, tokenType, nil
|
||||
}
|
||||
|
||||
// Enhanced authentication method
|
||||
func (a *GarthAuthenticator) authenticate(ctx context.Context, username, password, mfaToken, authToken, tokenType string) (*Token, error) {
|
||||
data := url.Values{}
|
||||
data.Set("username", username)
|
||||
data.Set("password", password)
|
||||
data.Set("embed", "true")
|
||||
data.Set("rememberme", "on")
|
||||
|
||||
// Set the correct token field based on type
|
||||
if tokenType == "lt" {
|
||||
data.Set("lt", authToken)
|
||||
} else {
|
||||
data.Set("_csrf", authToken)
|
||||
}
|
||||
|
||||
data.Set("_eventId", "submit")
|
||||
data.Set("geolocation", "")
|
||||
data.Set("clientId", "GarminConnect")
|
||||
data.Set("service", "https://connect.garmin.com/oauthConfirm") // This should match OAuth1 context
|
||||
data.Set("webhost", "https://connect.garmin.com")
|
||||
data.Set("fromPage", "oauth")
|
||||
data.Set("locale", "en_US")
|
||||
data.Set("id", "gauth-widget")
|
||||
data.Set("redirectAfterAccountLoginUrl", "https://connect.garmin.com/oauthConfirm")
|
||||
data.Set("redirectAfterAccountCreationUrl", "https://connect.garmin.com/oauthConfirm")
|
||||
|
||||
loginURL := "https://sso.garmin.com/sso/signin"
|
||||
req, err := http.NewRequestWithContext(ctx, "POST", loginURL, strings.NewReader(data.Encode()))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create SSO request: %w", err)
|
||||
}
|
||||
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
req.Header.Set("Accept", "application/json")
|
||||
req.Header.Set("User-Agent", a.userAgent)
|
||||
req.Header.Set("Referer", "https://sso.garmin.com/sso/signin?id=gauth-widget&embedWidget=true&gauthHost=https://sso.garmin.com/sso")
|
||||
|
||||
resp, err := a.client.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("SSO request failed: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode == http.StatusPreconditionFailed {
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
return a.handleMFA(ctx, username, password, mfaToken, string(body))
|
||||
}
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
return nil, fmt.Errorf("authentication failed with status: %d, response: %s", resp.StatusCode, body)
|
||||
}
|
||||
|
||||
var authResponse struct {
|
||||
Ticket string `json:"ticket"`
|
||||
}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&authResponse); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse SSO response: %w", err)
|
||||
}
|
||||
|
||||
if authResponse.Ticket == "" {
|
||||
return nil, errors.New("empty ticket in SSO response")
|
||||
}
|
||||
|
||||
return a.exchangeTicketForToken(ctx, authResponse.Ticket)
|
||||
}
|
||||
159
client.go
159
client.go
@@ -1,159 +0,0 @@
|
||||
package garth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// AuthTransport implements http.RoundTripper to inject authentication headers
|
||||
type AuthTransport struct {
|
||||
base http.RoundTripper
|
||||
auth *GarthAuthenticator
|
||||
storage TokenStorage
|
||||
userAgent string
|
||||
mutex sync.Mutex // Protects refreshing token
|
||||
}
|
||||
|
||||
// NewAuthTransport creates a new authenticated transport with specified storage
|
||||
func NewAuthTransport(auth *GarthAuthenticator, storage TokenStorage, base http.RoundTripper) *AuthTransport {
|
||||
if base == nil {
|
||||
base = http.DefaultTransport
|
||||
}
|
||||
|
||||
if storage == nil {
|
||||
storage = NewFileStorage("garmin_session.json")
|
||||
}
|
||||
|
||||
return &AuthTransport{
|
||||
base: base,
|
||||
auth: auth,
|
||||
storage: storage,
|
||||
userAgent: "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36",
|
||||
}
|
||||
}
|
||||
|
||||
// RoundTrip executes a single HTTP transaction with authentication
|
||||
func (t *AuthTransport) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||
// Clone request to avoid modifying the original
|
||||
req = cloneRequest(req)
|
||||
|
||||
// Get current token
|
||||
token, err := t.storage.GetToken()
|
||||
if err != nil {
|
||||
return nil, &AuthError{
|
||||
StatusCode: http.StatusUnauthorized,
|
||||
Message: "Token not available",
|
||||
Cause: err,
|
||||
}
|
||||
}
|
||||
|
||||
// Refresh token if expired
|
||||
if token.IsExpired() {
|
||||
newToken, err := t.refreshToken(req.Context(), token)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
token = newToken
|
||||
}
|
||||
|
||||
// Add Authorization header
|
||||
req.Header.Set("Authorization", "Bearer "+token.AccessToken)
|
||||
req.Header.Set("User-Agent", t.userAgent)
|
||||
req.Header.Set("Referer", "https://sso.garmin.com/sso/signin")
|
||||
|
||||
// Execute request with retry logic
|
||||
var resp *http.Response
|
||||
maxRetries := 3
|
||||
backoff := 200 * time.Millisecond // Initial backoff duration
|
||||
|
||||
for attempt := 0; attempt < maxRetries; attempt++ {
|
||||
resp, err = t.base.RoundTrip(req)
|
||||
if err != nil {
|
||||
// Network error, retry with backoff
|
||||
time.Sleep(backoff)
|
||||
backoff *= 2 // Exponential backoff
|
||||
continue
|
||||
}
|
||||
|
||||
// Handle token expiration during request (e.g. token revoked)
|
||||
if resp.StatusCode == http.StatusUnauthorized {
|
||||
resp.Body.Close()
|
||||
// Refresh token and update request
|
||||
token, err = t.refreshToken(req.Context(), token)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer "+token.AccessToken)
|
||||
continue
|
||||
}
|
||||
|
||||
// Retry server errors (5xx) and rate limits (429)
|
||||
if resp.StatusCode >= 500 && resp.StatusCode < 600 || resp.StatusCode == http.StatusTooManyRequests {
|
||||
resp.Body.Close()
|
||||
time.Sleep(backoff)
|
||||
backoff *= 2
|
||||
continue
|
||||
}
|
||||
|
||||
// Successful response
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
// Return last error or response if max retries exceeded
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
// refreshToken handles token refresh with mutex protection
|
||||
func (t *AuthTransport) refreshToken(ctx context.Context, token *Token) (*Token, error) {
|
||||
t.mutex.Lock()
|
||||
defer t.mutex.Unlock()
|
||||
|
||||
// Check again in case another goroutine refreshed while waiting
|
||||
currentToken, err := t.storage.GetToken()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !currentToken.IsExpired() {
|
||||
return currentToken, nil
|
||||
}
|
||||
|
||||
// Perform refresh
|
||||
newToken, err := t.auth.RefreshToken(ctx, token.RefreshToken)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Save new token
|
||||
if err := t.storage.StoreToken(newToken); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return newToken, nil
|
||||
}
|
||||
|
||||
// NewDefaultAuthTransport creates a transport with persistent storage
|
||||
func NewDefaultAuthTransport(auth *GarthAuthenticator) *AuthTransport {
|
||||
return NewAuthTransport(auth, NewFileStorage("garmin_session.json"), nil)
|
||||
}
|
||||
|
||||
// NewMemoryAuthTransport creates a transport with in-memory storage (for testing)
|
||||
func NewMemoryAuthTransport(auth *GarthAuthenticator) *AuthTransport {
|
||||
return NewAuthTransport(auth, NewMemoryStorage(), nil)
|
||||
}
|
||||
|
||||
// cloneRequest returns a clone of the provided HTTP request
|
||||
func cloneRequest(r *http.Request) *http.Request {
|
||||
// Shallow copy of the struct
|
||||
clone := *r
|
||||
// Deep copy of the headers
|
||||
clone.Header = make(http.Header, len(r.Header))
|
||||
for k, v := range r.Header {
|
||||
clone.Header[k] = v
|
||||
}
|
||||
return &clone
|
||||
}
|
||||
157
cloudflare.go
157
cloudflare.go
@@ -1,157 +0,0 @@
|
||||
package garth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"math/rand"
|
||||
"net/http"
|
||||
"time"
|
||||
)
|
||||
|
||||
// CloudflareBypass provides methods to handle Cloudflare protection
|
||||
type CloudflareBypass struct {
|
||||
client *http.Client
|
||||
userAgent string
|
||||
maxRetries int
|
||||
}
|
||||
|
||||
// NewCloudflareBypass creates a new CloudflareBypass instance
|
||||
func NewCloudflareBypass(client *http.Client) *CloudflareBypass {
|
||||
return &CloudflareBypass{
|
||||
client: client,
|
||||
userAgent: "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
|
||||
maxRetries: 3,
|
||||
}
|
||||
}
|
||||
|
||||
// MakeRequest performs a request with Cloudflare bypass techniques
|
||||
func (cf *CloudflareBypass) MakeRequest(ctx context.Context, req *http.Request) (*http.Response, error) {
|
||||
var resp *http.Response
|
||||
var err error
|
||||
|
||||
for attempt := 0; attempt < cf.maxRetries; attempt++ {
|
||||
// Clone the request to avoid modifying the original
|
||||
clonedReq := cf.cloneRequest(req)
|
||||
|
||||
// Apply bypass headers
|
||||
cf.applyBypassHeaders(clonedReq)
|
||||
|
||||
// Add random delay to simulate human behavior
|
||||
if attempt > 0 {
|
||||
delay := time.Duration(1000+rand.Intn(2000)) * time.Millisecond
|
||||
time.Sleep(delay)
|
||||
}
|
||||
|
||||
resp, err = cf.client.Do(clonedReq)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
// Check if we got blocked by Cloudflare
|
||||
if cf.isCloudflareBlocked(resp) {
|
||||
resp.Body.Close()
|
||||
if attempt < cf.maxRetries-1 {
|
||||
// Try different user agent on retry
|
||||
cf.rotateUserAgent()
|
||||
continue
|
||||
}
|
||||
return nil, fmt.Errorf("blocked by Cloudflare after %d attempts", cf.maxRetries)
|
||||
}
|
||||
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// applyBypassHeaders adds headers to bypass Cloudflare
|
||||
func (cf *CloudflareBypass) applyBypassHeaders(req *http.Request) {
|
||||
req.Header.Set("User-Agent", cf.userAgent)
|
||||
req.Header.Set("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7")
|
||||
req.Header.Set("Accept-Language", "en-US,en;q=0.9")
|
||||
req.Header.Set("Accept-Encoding", "gzip, deflate, br")
|
||||
req.Header.Set("Cache-Control", "max-age=0")
|
||||
req.Header.Set("Sec-Ch-Ua", `"Not_A Brand";v="8", "Chromium";v="120", "Google Chrome";v="120"`)
|
||||
req.Header.Set("Sec-Ch-Ua-Mobile", "?0")
|
||||
req.Header.Set("Sec-Ch-Ua-Platform", `"Windows"`)
|
||||
req.Header.Set("Sec-Fetch-Dest", "document")
|
||||
req.Header.Set("Sec-Fetch-Mode", "navigate")
|
||||
req.Header.Set("Sec-Fetch-Site", "none")
|
||||
req.Header.Set("Sec-Fetch-User", "?1")
|
||||
req.Header.Set("Upgrade-Insecure-Requests", "1")
|
||||
req.Header.Set("DNT", "1")
|
||||
|
||||
// Add some randomized headers
|
||||
if rand.Float32() < 0.7 {
|
||||
req.Header.Set("Pragma", "no-cache")
|
||||
}
|
||||
if rand.Float32() < 0.5 {
|
||||
req.Header.Set("Connection", "keep-alive")
|
||||
}
|
||||
}
|
||||
|
||||
// isCloudflareBlocked checks if the response indicates Cloudflare blocking
|
||||
func (cf *CloudflareBypass) isCloudflareBlocked(resp *http.Response) bool {
|
||||
if resp.StatusCode == 403 {
|
||||
// Check for Cloudflare-specific headers or content
|
||||
if resp.Header.Get("Server") == "cloudflare" {
|
||||
return true
|
||||
}
|
||||
if resp.Header.Get("CF-Ray") != "" {
|
||||
return true
|
||||
}
|
||||
|
||||
// Check response body for Cloudflare indicators
|
||||
if resp.ContentLength > 0 && resp.ContentLength < 50000 {
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err == nil {
|
||||
bodyStr := string(body)
|
||||
if contains(bodyStr, "cloudflare") || contains(bodyStr, "Attention Required") {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// rotateUserAgent changes the user agent for retry attempts
|
||||
func (cf *CloudflareBypass) rotateUserAgent() {
|
||||
userAgents := []string{
|
||||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
|
||||
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
|
||||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:109.0) Gecko/20100101 Firefox/121.0",
|
||||
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.2 Safari/605.1.15",
|
||||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36 Edg/120.0.0.0",
|
||||
}
|
||||
cf.userAgent = userAgents[rand.Intn(len(userAgents))]
|
||||
}
|
||||
|
||||
// cloneRequest creates a copy of the HTTP request
|
||||
func (cf *CloudflareBypass) cloneRequest(req *http.Request) *http.Request {
|
||||
cloned := req.Clone(req.Context())
|
||||
if cloned.Header == nil {
|
||||
cloned.Header = make(http.Header)
|
||||
}
|
||||
return cloned
|
||||
}
|
||||
|
||||
// contains is a case-insensitive string contains check
|
||||
func contains(s, substr string) bool {
|
||||
return len(s) >= len(substr) &&
|
||||
(s == substr ||
|
||||
len(s) > len(substr) &&
|
||||
(s[:len(substr)] == substr ||
|
||||
s[len(s)-len(substr):] == substr ||
|
||||
containsAt(s, substr)))
|
||||
}
|
||||
|
||||
func containsAt(s, substr string) bool {
|
||||
for i := 0; i <= len(s)-len(substr); i++ {
|
||||
if s[i:i+len(substr)] == substr {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -1,57 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
|
||||
"github.com/joho/godotenv"
|
||||
"github.com/sstent/go-garth"
|
||||
)
|
||||
|
||||
func main() {
|
||||
// Load environment variables from project root
|
||||
projectRoot := "../.."
|
||||
if err := godotenv.Load(projectRoot + "/.env"); err != nil {
|
||||
log.Fatalf("Error loading .env file: %v", err)
|
||||
}
|
||||
|
||||
// Get credentials
|
||||
username := os.Getenv("GARMIN_USERNAME")
|
||||
password := os.Getenv("GARMIN_PASSWORD")
|
||||
if username == "" || password == "" {
|
||||
log.Fatal("GARMIN_USERNAME or GARMIN_PASSWORD not set in .env")
|
||||
}
|
||||
|
||||
// Create storage and authenticator
|
||||
storage := garth.NewMemoryStorage()
|
||||
auth := garth.NewAuthenticator(garth.ClientOptions{
|
||||
Storage: storage,
|
||||
TokenURL: "https://connectapi.garmin.com/oauth-service/oauth/token",
|
||||
Timeout: 120,
|
||||
})
|
||||
|
||||
// Perform authentication
|
||||
fmt.Println("Starting authentication...")
|
||||
token, err := auth.Login(context.Background(), username, password, "")
|
||||
if err != nil {
|
||||
log.Fatalf("Authentication failed: %v", err)
|
||||
}
|
||||
|
||||
fmt.Println("\nAuthentication successful! Token details:")
|
||||
fmt.Printf("Access Token: %s\n", token.AccessToken)
|
||||
fmt.Printf("Expires At: %d\n", token.ExpiresAt)
|
||||
fmt.Printf("Refresh Token: %s\n", token.RefreshToken)
|
||||
|
||||
// Verify token storage
|
||||
storedToken, err := storage.GetToken()
|
||||
if err != nil {
|
||||
log.Fatalf("Token storage verification failed: %v", err)
|
||||
}
|
||||
if storedToken.AccessToken != token.AccessToken {
|
||||
log.Fatal("Stored token doesn't match authenticated token")
|
||||
}
|
||||
|
||||
fmt.Println("Token storage verification successful")
|
||||
}
|
||||
229
connect.go
229
connect.go
@@ -1,229 +0,0 @@
|
||||
package garth
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"time"
|
||||
)
|
||||
|
||||
// APIClient manages API requests to Garmin Connect
|
||||
type APIClient struct {
|
||||
baseURL string
|
||||
httpClient *http.Client
|
||||
rateLimit time.Duration
|
||||
logger ErrorLogger // Optional error logger
|
||||
}
|
||||
|
||||
// NewAPIClient creates a new API client instance
|
||||
func NewAPIClient(baseURL string, httpClient *http.Client) *APIClient {
|
||||
return &APIClient{
|
||||
baseURL: baseURL,
|
||||
httpClient: httpClient,
|
||||
rateLimit: 500 * time.Millisecond, // Default rate limit
|
||||
}
|
||||
}
|
||||
|
||||
// SetRateLimit configures request rate limiting
|
||||
func (c *APIClient) SetRateLimit(limit time.Duration) {
|
||||
c.rateLimit = limit
|
||||
}
|
||||
|
||||
// SetRequestsPerSecond configures the maximum number of requests per second
|
||||
func (c *APIClient) SetRequestsPerSecond(rate float64) {
|
||||
interval := time.Duration(float64(time.Second) / rate)
|
||||
c.SetRateLimit(interval)
|
||||
}
|
||||
|
||||
// Get executes a GET request
|
||||
func (c *APIClient) Get(ctx context.Context, path string) (*http.Response, error) {
|
||||
return c.request(ctx, http.MethodGet, path, nil)
|
||||
}
|
||||
|
||||
// Post executes a POST request
|
||||
func (c *APIClient) Post(ctx context.Context, path string, body io.Reader) (*http.Response, error) {
|
||||
return c.request(ctx, http.MethodPost, path, body)
|
||||
}
|
||||
|
||||
// Put executes a PUT request
|
||||
func (c *APIClient) Put(ctx context.Context, path string, body io.Reader) (*http.Response, error) {
|
||||
return c.request(ctx, http.MethodPut, path, body)
|
||||
}
|
||||
|
||||
// Delete executes a DELETE request
|
||||
func (c *APIClient) Delete(ctx context.Context, path string, body io.Reader) (*http.Response, error) {
|
||||
return c.request(ctx, http.MethodDelete, path, body)
|
||||
}
|
||||
|
||||
// handleResponse handles API response and error decoding
|
||||
func handleResponse(resp *http.Response, result interface{}) error {
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode >= 400 {
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
return &APIError{
|
||||
StatusCode: resp.StatusCode,
|
||||
Message: string(body),
|
||||
}
|
||||
}
|
||||
|
||||
if result != nil {
|
||||
if err := json.NewDecoder(resp.Body).Decode(result); err != nil {
|
||||
return &APIError{
|
||||
StatusCode: http.StatusInternalServerError,
|
||||
Message: "Failed to parse response",
|
||||
Cause: err,
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetJSON executes a GET request and decodes the JSON response
|
||||
func (c *APIClient) GetJSON(ctx context.Context, path string, result interface{}) error {
|
||||
resp, err := c.Get(ctx, path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return handleResponse(resp, result)
|
||||
}
|
||||
|
||||
// PostJSON executes a POST request with JSON body and decodes the JSON response
|
||||
func (c *APIClient) PostJSON(ctx context.Context, path string, body interface{}, result interface{}) error {
|
||||
jsonBody, err := json.Marshal(body)
|
||||
if err != nil {
|
||||
return &APIError{
|
||||
StatusCode: http.StatusInternalServerError,
|
||||
Message: "Failed to marshal request body",
|
||||
Cause: err,
|
||||
}
|
||||
}
|
||||
|
||||
resp, err := c.Post(ctx, path, bytes.NewReader(jsonBody))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return handleResponse(resp, result)
|
||||
}
|
||||
|
||||
// PutJSON executes a PUT request with JSON body and decodes the JSON response
|
||||
func (c *APIClient) PutJSON(ctx context.Context, path string, body interface{}, result interface{}) error {
|
||||
jsonBody, err := json.Marshal(body)
|
||||
if err != nil {
|
||||
return &APIError{
|
||||
StatusCode: http.StatusInternalServerError,
|
||||
Message: "Failed to marshal request body",
|
||||
Cause: err,
|
||||
}
|
||||
}
|
||||
|
||||
resp, err := c.Put(ctx, path, bytes.NewReader(jsonBody))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return handleResponse(resp, result)
|
||||
}
|
||||
|
||||
// DeleteJSON executes a DELETE request and decodes the JSON response
|
||||
func (c *APIClient) DeleteJSON(ctx context.Context, path string, result interface{}) error {
|
||||
resp, err := c.Delete(ctx, path, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return handleResponse(resp, result)
|
||||
}
|
||||
|
||||
// ErrorLogger defines an interface for logging errors
|
||||
type ErrorLogger interface {
|
||||
Errorf(format string, args ...interface{})
|
||||
}
|
||||
|
||||
// SetLogger sets the error logger for the API client
|
||||
func (c *APIClient) SetLogger(logger ErrorLogger) {
|
||||
c.logger = logger
|
||||
}
|
||||
|
||||
func (c *APIClient) request(ctx context.Context, method, path string, body io.Reader) (*http.Response, error) {
|
||||
// Rate limiting using token bucket algorithm
|
||||
if c.rateLimit > 0 {
|
||||
time.Sleep(c.rateLimit)
|
||||
}
|
||||
|
||||
var resp *http.Response
|
||||
var err error
|
||||
var req *http.Request
|
||||
maxRetries := 3
|
||||
backoff := 500 * time.Millisecond
|
||||
|
||||
for i := 0; i < maxRetries; i++ {
|
||||
var createErr error
|
||||
req, createErr = http.NewRequestWithContext(ctx, method, c.baseURL+path, body)
|
||||
if createErr != nil {
|
||||
return nil, &APIError{
|
||||
StatusCode: http.StatusInternalServerError,
|
||||
Message: "Failed to create request",
|
||||
Cause: createErr,
|
||||
}
|
||||
}
|
||||
|
||||
// Set common headers
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Accept", "application/json")
|
||||
|
||||
resp, err = c.httpClient.Do(req)
|
||||
|
||||
// Retry only on network errors or server-side issues
|
||||
if err != nil || (resp != nil && resp.StatusCode >= 500) {
|
||||
if i < maxRetries-1 {
|
||||
// Exponential backoff with jitter
|
||||
time.Sleep(backoff)
|
||||
backoff = time.Duration(float64(backoff) * 2.5)
|
||||
continue
|
||||
}
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
// Extract query parameters for error context
|
||||
var queryValues url.Values
|
||||
if req != nil {
|
||||
queryValues = req.URL.Query()
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
apiErr := &APIError{
|
||||
StatusCode: http.StatusBadGateway,
|
||||
Message: "Request failed after retries",
|
||||
Cause: err,
|
||||
}
|
||||
reqErr := NewRequestError(method, req.URL.String(), queryValues, http.StatusBadGateway, apiErr)
|
||||
|
||||
// Log error if logger is configured
|
||||
if c.logger != nil {
|
||||
c.logger.Errorf("API request failed: %v, Method: %s, URL: %s", reqErr, method, req.URL.String())
|
||||
}
|
||||
|
||||
return nil, reqErr
|
||||
}
|
||||
|
||||
if resp.StatusCode >= 400 {
|
||||
apiErr := &APIError{
|
||||
StatusCode: resp.StatusCode,
|
||||
Message: "API request failed",
|
||||
}
|
||||
reqErr := NewRequestError(method, req.URL.String(), queryValues, resp.StatusCode, apiErr)
|
||||
|
||||
// Log error if logger is configured
|
||||
if c.logger != nil {
|
||||
c.logger.Errorf("API request failed with status %d: %s, Method: %s, URL: %s",
|
||||
resp.StatusCode, apiErr.Message, method, req.URL.String())
|
||||
}
|
||||
|
||||
return nil, reqErr
|
||||
}
|
||||
|
||||
return resp, nil
|
||||
}
|
||||
@@ -1,92 +0,0 @@
|
||||
package garth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestAPIClient_Get(t *testing.T) {
|
||||
// Create a test server
|
||||
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write([]byte("OK"))
|
||||
}))
|
||||
defer ts.Close()
|
||||
|
||||
// Create client
|
||||
client := NewAPIClient(ts.URL, http.DefaultClient)
|
||||
|
||||
// Test successful request
|
||||
resp, err := client.Get(context.Background(), "/test")
|
||||
if err != nil {
|
||||
t.Fatalf("Unexpected error: %v", err)
|
||||
}
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Errorf("Expected status OK, got %d", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAPIClient_Retry(t *testing.T) {
|
||||
retryCount := 0
|
||||
// Create a test server that fails first two requests
|
||||
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
retryCount++
|
||||
if retryCount < 3 {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
defer ts.Close()
|
||||
|
||||
// Create client with faster backoff for testing
|
||||
client := NewAPIClient(ts.URL, http.DefaultClient)
|
||||
client.SetRateLimit(10 * time.Millisecond)
|
||||
|
||||
// Test retry logic
|
||||
resp, err := client.Get(context.Background(), "/retry-test")
|
||||
if err != nil {
|
||||
t.Fatalf("Unexpected error: %v", err)
|
||||
}
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Errorf("Expected status OK after retries, got %d", resp.StatusCode)
|
||||
}
|
||||
if retryCount != 3 {
|
||||
t.Errorf("Expected 3 attempts, got %d", retryCount)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAPIClient_ErrorHandling(t *testing.T) {
|
||||
// Create a test server that returns 404
|
||||
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
}))
|
||||
defer ts.Close()
|
||||
|
||||
// Create client
|
||||
client := NewAPIClient(ts.URL, http.DefaultClient)
|
||||
|
||||
// Test error handling
|
||||
_, err := client.Get(context.Background(), "/not-found")
|
||||
if err == nil {
|
||||
t.Fatal("Expected error but got none")
|
||||
}
|
||||
|
||||
// Check for RequestError wrapper
|
||||
reqErr, ok := err.(*RequestError)
|
||||
if !ok {
|
||||
t.Fatalf("Expected RequestError, got %T", err)
|
||||
}
|
||||
|
||||
// Check the wrapped APIError
|
||||
apiErr, ok := reqErr.GetCause().(*APIError)
|
||||
if !ok {
|
||||
t.Fatalf("Expected APIError inside RequestError, got %T", reqErr.GetCause())
|
||||
}
|
||||
if apiErr.StatusCode != http.StatusNotFound {
|
||||
t.Errorf("Expected 404 status, got %d", apiErr.StatusCode)
|
||||
}
|
||||
}
|
||||
Binary file not shown.
@@ -1,13 +0,0 @@
|
||||
Report-To: {"endpoints":[{"url":"https:\/\/a.nel.cloudflare.com\/report\/v4?s=LB4nTuybIbmdfmkjfbhGa%2FMJZapsS682xCWKJOOWqrhVV7CnzARk2sMV8ZeZUijqC9AcFQhhxv4W8Egdel5Asb9wivzQo0hCUo8%2B2oe2%2FHqZKCfXs5p5Pk623hXacIo8Vnf8cg%3D%3D"}],"group":"cf-nel","max_age":604800}
|
||||
Nel: {"success_fraction":0.01,"report_to":"cf-nel","max_age":604800}
|
||||
Server: cloudflare
|
||||
Connection: keep-alive
|
||||
Last-Modified: Mon, 28 Jul 2025 20:49:30 GMT
|
||||
Cache-Control: no-cache
|
||||
Cf-Cache-Status: DYNAMIC
|
||||
Content-Type: text/html; charset=UTF-8
|
||||
Server-Timing: cfCacheStatus;desc="DYNAMIC", cfOrigin;dur=13,cfEdge;dur=70
|
||||
Set-Cookie: __cfruid=690cd828ea6910e26e5c8a6646ea53e1167f4fbd-1756949664; path=/; domain=.connect.garmin.com; HttpOnly; Secure; SameSite=None
|
||||
Date: Thu, 04 Sep 2025 01:34:24 GMT
|
||||
Cf-Ray: 9799be879b8c08d3-LAX
|
||||
X-Frame-Options: deny
|
||||
File diff suppressed because one or more lines are too long
@@ -1,13 +0,0 @@
|
||||
Cf-Ray: 97a5db741ab22efb-LAX
|
||||
Date: Fri, 05 Sep 2025 12:51:17 GMT
|
||||
Last-Modified: Mon, 28 Jul 2025 20:49:42 GMT
|
||||
Cf-Cache-Status: DYNAMIC
|
||||
Connection: keep-alive
|
||||
Cache-Control: no-cache
|
||||
Set-Cookie: __cfruid=2107ee6871a3cf02bd004445e95d2e77bd292984-1757076677; path=/; domain=.connect.garmin.com; HttpOnly; Secure; SameSite=None
|
||||
Content-Type: text/html; charset=UTF-8
|
||||
X-Frame-Options: deny
|
||||
Server-Timing: cfCacheStatus;desc="DYNAMIC", cfOrigin;dur=3,cfEdge;dur=75
|
||||
Nel: {"success_fraction":0.01,"report_to":"cf-nel","max_age":604800}
|
||||
Server: cloudflare
|
||||
Report-To: {"endpoints":[{"url":"https:\/\/a.nel.cloudflare.com\/report\/v4?s=Hv7AO1s60DI9JCEW%2F%2Bz16fzKmavV%2BZg8D7Ouwbop8oHlOuJd3ogudd%2Fo5U7ZKPYavncF6AOa%2Fk72KCsUS8l2qjRk%2B%2F5kV0HMgJurjZS53jTWuqxpehMyNPvqdnE7EOlocrh5NQ%3D%3D"}],"group":"cf-nel","max_age":604800}
|
||||
File diff suppressed because one or more lines are too long
@@ -1,13 +0,0 @@
|
||||
Cf-Ray: 97a5dd2fbe1cc982-LAX
|
||||
Report-To: {"endpoints":[{"url":"https:\/\/a.nel.cloudflare.com\/report\/v4?s=0CCGeDUIydmkb7dMMeIz85bxJdJITAb2XMoWWDEgTh7zhr6HkfCEdXWgYLJSFvpzmAp1F3I5wbJ2jocrjrymEMg5TF9GgWtPcTl7PMY2IZ14xFpMe1jUWVi0kcRfSRLtIOyEwA%3D%3D"}],"group":"cf-nel","max_age":604800}
|
||||
Content-Type: text/html; charset=UTF-8
|
||||
Cache-Control: no-cache
|
||||
Cf-Cache-Status: DYNAMIC
|
||||
Server-Timing: cfCacheStatus;desc="DYNAMIC", cfOrigin;dur=5,cfEdge;dur=51
|
||||
Set-Cookie: __cfruid=0091461aff8318be73baddcab194a5eb97ce5bbd-1757076748; path=/; domain=.connect.garmin.com; HttpOnly; Secure; SameSite=None
|
||||
Connection: keep-alive
|
||||
Last-Modified: Mon, 28 Jul 2025 20:49:50 GMT
|
||||
Nel: {"success_fraction":0.01,"report_to":"cf-nel","max_age":604800}
|
||||
Server: cloudflare
|
||||
Date: Fri, 05 Sep 2025 12:52:28 GMT
|
||||
X-Frame-Options: deny
|
||||
File diff suppressed because one or more lines are too long
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -1,93 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<!--[if lt IE 7]> <html class="no-js ie6 oldie" lang="en-US"> <![endif]-->
|
||||
<!--[if IE 7]> <html class="no-js ie7 oldie" lang="en-US"> <![endif]-->
|
||||
<!--[if IE 8]> <html class="no-js ie8 oldie" lang="en-US"> <![endif]-->
|
||||
<!--[if gt IE 8]><!--> <html class="no-js" lang="en-US"> <!--<![endif]-->
|
||||
<head>
|
||||
<title>Attention Required! | Cloudflare</title>
|
||||
<meta charset="UTF-8" />
|
||||
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />
|
||||
<meta http-equiv="X-UA-Compatible" content="IE=Edge" />
|
||||
<meta name="robots" content="noindex, nofollow" />
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1" />
|
||||
<link rel="stylesheet" id="cf_styles-css" href="/cdn-cgi/styles/cf.errors.css" />
|
||||
<!--[if lt IE 9]><link rel="stylesheet" id='cf_styles-ie-css' href="/cdn-cgi/styles/cf.errors.ie.css" /><![endif]-->
|
||||
<style>body{margin:0;padding:0}</style>
|
||||
|
||||
|
||||
<!--[if gte IE 10]><!-->
|
||||
<script>
|
||||
if (!navigator.cookieEnabled) {
|
||||
window.addEventListener('DOMContentLoaded', function () {
|
||||
var cookieEl = document.getElementById('cookie-alert');
|
||||
cookieEl.style.display = 'block';
|
||||
})
|
||||
}
|
||||
</script>
|
||||
<!--<![endif]-->
|
||||
|
||||
|
||||
</head>
|
||||
<body>
|
||||
<div id="cf-wrapper">
|
||||
<div class="cf-alert cf-alert-error cf-cookie-error" id="cookie-alert" data-translate="enable_cookies">Please enable cookies.</div>
|
||||
<div id="cf-error-details" class="cf-error-details-wrapper">
|
||||
<div class="cf-wrapper cf-header cf-error-overview">
|
||||
<h1 data-translate="block_headline">Sorry, you have been blocked</h1>
|
||||
<h2 class="cf-subheadline"><span data-translate="unable_to_access">You are unable to access</span> sso.garmin.com</h2>
|
||||
</div><!-- /.header -->
|
||||
|
||||
<div class="cf-section cf-highlight">
|
||||
<div class="cf-wrapper">
|
||||
<div class="cf-screenshot-container cf-screenshot-full">
|
||||
|
||||
<span class="cf-no-screenshot error"></span>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div><!-- /.captcha-container -->
|
||||
|
||||
<div class="cf-section cf-wrapper">
|
||||
<div class="cf-columns two">
|
||||
<div class="cf-column">
|
||||
<h2 data-translate="blocked_why_headline">Why have I been blocked?</h2>
|
||||
|
||||
<p data-translate="blocked_why_detail">This website is using a security service to protect itself from online attacks. The action you just performed triggered the security solution. There are several actions that could trigger this block including submitting a certain word or phrase, a SQL command or malformed data.</p>
|
||||
</div>
|
||||
|
||||
<div class="cf-column">
|
||||
<h2 data-translate="blocked_resolve_headline">What can I do to resolve this?</h2>
|
||||
|
||||
<p data-translate="blocked_resolve_detail">You can email the site owner to let them know you were blocked. Please include what you were doing when this page came up and the Cloudflare Ray ID found at the bottom of this page.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div><!-- /.section -->
|
||||
|
||||
<div class="cf-error-footer cf-wrapper w-240 lg:w-full py-10 sm:py-4 sm:px-8 mx-auto text-center sm:text-left border-solid border-0 border-t border-gray-300">
|
||||
<p class="text-13">
|
||||
<span class="cf-footer-item sm:block sm:mb-1">Cloudflare Ray ID: <strong class="font-semibold">97a86f0198f008fa</strong></span>
|
||||
<span class="cf-footer-separator sm:hidden">•</span>
|
||||
<span id="cf-footer-item-ip" class="cf-footer-item hidden sm:block sm:mb-1">
|
||||
Your IP:
|
||||
<button type="button" id="cf-footer-ip-reveal" class="cf-footer-ip-reveal-btn">Click to reveal</button>
|
||||
<span class="hidden" id="cf-footer-ip">47.150.230.21</span>
|
||||
<span class="cf-footer-separator sm:hidden">•</span>
|
||||
</span>
|
||||
<span class="cf-footer-item sm:block sm:mb-1"><span>Performance & security by</span> <a rel="noopener noreferrer" href="https://www.cloudflare.com/5xx-error-landing" id="brand_link" target="_blank">Cloudflare</a></span>
|
||||
|
||||
</p>
|
||||
<script>(function(){function d(){var b=a.getElementById("cf-footer-item-ip"),c=a.getElementById("cf-footer-ip-reveal");b&&"classList"in b&&(b.classList.remove("hidden"),c.addEventListener("click",function(){c.classList.add("hidden");a.getElementById("cf-footer-ip").classList.remove("hidden")}))}var a=document;document.addEventListener&&a.addEventListener("DOMContentLoaded",d)})();</script>
|
||||
</div><!-- /.error-footer -->
|
||||
|
||||
|
||||
</div><!-- /#cf-error-details -->
|
||||
</div><!-- /#cf-wrapper -->
|
||||
|
||||
<script>
|
||||
window._cf_translation = {};
|
||||
|
||||
|
||||
</script>
|
||||
|
||||
<script>(function(){function c(){var b=a.contentDocument||a.contentWindow.document;if(b){var d=b.createElement('script');d.innerHTML="window.__CF$cv$params={r:'97a86f0198f008fa',t:'MTc1NzEwMzY5My4wMDAwMDA='};var a=document.createElement('script');a.nonce='';a.src='/cdn-cgi/challenge-platform/scripts/jsd/main.js';document.getElementsByTagName('head')[0].appendChild(a);";b.getElementsByTagName('head')[0].appendChild(d)}}if(document.body){var a=document.createElement('iframe');a.height=1;a.width=1;a.style.position='absolute';a.style.top=0;a.style.left=0;a.style.border='none';a.style.visibility='hidden';document.body.appendChild(a);if('loading'!==document.readyState)c();else if(window.addEventListener)document.addEventListener('DOMContentLoaded',c);else{var e=document.onreadystatechange||function(){};document.onreadystatechange=function(b){e(b);'loading'!==document.readyState&&(document.onreadystatechange=e,c())}}}})();</script></body>
|
||||
</html>
|
||||
@@ -1,93 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<!--[if lt IE 7]> <html class="no-js ie6 oldie" lang="en-US"> <![endif]-->
|
||||
<!--[if IE 7]> <html class="no-js ie7 oldie" lang="en-US"> <![endif]-->
|
||||
<!--[if IE 8]> <html class="no-js ie8 oldie" lang="en-US"> <![endif]-->
|
||||
<!--[if gt IE 8]><!--> <html class="no-js" lang="en-US"> <!--<![endif]-->
|
||||
<head>
|
||||
<title>Attention Required! | Cloudflare</title>
|
||||
<meta charset="UTF-8" />
|
||||
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />
|
||||
<meta http-equiv="X-UA-Compatible" content="IE=Edge" />
|
||||
<meta name="robots" content="noindex, nofollow" />
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1" />
|
||||
<link rel="stylesheet" id="cf_styles-css" href="/cdn-cgi/styles/cf.errors.css" />
|
||||
<!--[if lt IE 9]><link rel="stylesheet" id='cf_styles-ie-css' href="/cdn-cgi/styles/cf.errors.ie.css" /><![endif]-->
|
||||
<style>body{margin:0;padding:0}</style>
|
||||
|
||||
|
||||
<!--[if gte IE 10]><!-->
|
||||
<script>
|
||||
if (!navigator.cookieEnabled) {
|
||||
window.addEventListener('DOMContentLoaded', function () {
|
||||
var cookieEl = document.getElementById('cookie-alert');
|
||||
cookieEl.style.display = 'block';
|
||||
})
|
||||
}
|
||||
</script>
|
||||
<!--<![endif]-->
|
||||
|
||||
|
||||
</head>
|
||||
<body>
|
||||
<div id="cf-wrapper">
|
||||
<div class="cf-alert cf-alert-error cf-cookie-error" id="cookie-alert" data-translate="enable_cookies">Please enable cookies.</div>
|
||||
<div id="cf-error-details" class="cf-error-details-wrapper">
|
||||
<div class="cf-wrapper cf-header cf-error-overview">
|
||||
<h1 data-translate="block_headline">Sorry, you have been blocked</h1>
|
||||
<h2 class="cf-subheadline"><span data-translate="unable_to_access">You are unable to access</span> sso.garmin.com</h2>
|
||||
</div><!-- /.header -->
|
||||
|
||||
<div class="cf-section cf-highlight">
|
||||
<div class="cf-wrapper">
|
||||
<div class="cf-screenshot-container cf-screenshot-full">
|
||||
|
||||
<span class="cf-no-screenshot error"></span>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div><!-- /.captcha-container -->
|
||||
|
||||
<div class="cf-section cf-wrapper">
|
||||
<div class="cf-columns two">
|
||||
<div class="cf-column">
|
||||
<h2 data-translate="blocked_why_headline">Why have I been blocked?</h2>
|
||||
|
||||
<p data-translate="blocked_why_detail">This website is using a security service to protect itself from online attacks. The action you just performed triggered the security solution. There are several actions that could trigger this block including submitting a certain word or phrase, a SQL command or malformed data.</p>
|
||||
</div>
|
||||
|
||||
<div class="cf-column">
|
||||
<h2 data-translate="blocked_resolve_headline">What can I do to resolve this?</h2>
|
||||
|
||||
<p data-translate="blocked_resolve_detail">You can email the site owner to let them know you were blocked. Please include what you were doing when this page came up and the Cloudflare Ray ID found at the bottom of this page.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div><!-- /.section -->
|
||||
|
||||
<div class="cf-error-footer cf-wrapper w-240 lg:w-full py-10 sm:py-4 sm:px-8 mx-auto text-center sm:text-left border-solid border-0 border-t border-gray-300">
|
||||
<p class="text-13">
|
||||
<span class="cf-footer-item sm:block sm:mb-1">Cloudflare Ray ID: <strong class="font-semibold">97a87306fc145337</strong></span>
|
||||
<span class="cf-footer-separator sm:hidden">•</span>
|
||||
<span id="cf-footer-item-ip" class="cf-footer-item hidden sm:block sm:mb-1">
|
||||
Your IP:
|
||||
<button type="button" id="cf-footer-ip-reveal" class="cf-footer-ip-reveal-btn">Click to reveal</button>
|
||||
<span class="hidden" id="cf-footer-ip">47.150.230.21</span>
|
||||
<span class="cf-footer-separator sm:hidden">•</span>
|
||||
</span>
|
||||
<span class="cf-footer-item sm:block sm:mb-1"><span>Performance & security by</span> <a rel="noopener noreferrer" href="https://www.cloudflare.com/5xx-error-landing" id="brand_link" target="_blank">Cloudflare</a></span>
|
||||
|
||||
</p>
|
||||
<script>(function(){function d(){var b=a.getElementById("cf-footer-item-ip"),c=a.getElementById("cf-footer-ip-reveal");b&&"classList"in b&&(b.classList.remove("hidden"),c.addEventListener("click",function(){c.classList.add("hidden");a.getElementById("cf-footer-ip").classList.remove("hidden")}))}var a=document;document.addEventListener&&a.addEventListener("DOMContentLoaded",d)})();</script>
|
||||
</div><!-- /.error-footer -->
|
||||
|
||||
|
||||
</div><!-- /#cf-error-details -->
|
||||
</div><!-- /#cf-wrapper -->
|
||||
|
||||
<script>
|
||||
window._cf_translation = {};
|
||||
|
||||
|
||||
</script>
|
||||
|
||||
<script>(function(){function c(){var b=a.contentDocument||a.contentWindow.document;if(b){var d=b.createElement('script');d.innerHTML="window.__CF$cv$params={r:'97a87306fc145337',t:'MTc1NzEwMzg1Ny4wMDAwMDA='};var a=document.createElement('script');a.nonce='';a.src='/cdn-cgi/challenge-platform/scripts/jsd/main.js';document.getElementsByTagName('head')[0].appendChild(a);";b.getElementsByTagName('head')[0].appendChild(d)}}if(document.body){var a=document.createElement('iframe');a.height=1;a.width=1;a.style.position='absolute';a.style.top=0;a.style.left=0;a.style.border='none';a.style.visibility='hidden';document.body.appendChild(a);if('loading'!==document.readyState)c();else if(window.addEventListener)document.addEventListener('DOMContentLoaded',c);else{var e=document.onreadystatechange||function(){};document.onreadystatechange=function(b){e(b);'loading'!==document.readyState&&(document.onreadystatechange=e,c())}}}})();</script></body>
|
||||
</html>
|
||||
@@ -1,93 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<!--[if lt IE 7]> <html class="no-js ie6 oldie" lang="en-US"> <![endif]-->
|
||||
<!--[if IE 7]> <html class="no-js ie7 oldie" lang="en-US"> <![endif]-->
|
||||
<!--[if IE 8]> <html class="no-js ie8 oldie" lang="en-US"> <![endif]-->
|
||||
<!--[if gt IE 8]><!--> <html class="no-js" lang="en-US"> <!--<![endif]-->
|
||||
<head>
|
||||
<title>Attention Required! | Cloudflare</title>
|
||||
<meta charset="UTF-8" />
|
||||
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />
|
||||
<meta http-equiv="X-UA-Compatible" content="IE=Edge" />
|
||||
<meta name="robots" content="noindex, nofollow" />
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1" />
|
||||
<link rel="stylesheet" id="cf_styles-css" href="/cdn-cgi/styles/cf.errors.css" />
|
||||
<!--[if lt IE 9]><link rel="stylesheet" id='cf_styles-ie-css' href="/cdn-cgi/styles/cf.errors.ie.css" /><![endif]-->
|
||||
<style>body{margin:0;padding:0}</style>
|
||||
|
||||
|
||||
<!--[if gte IE 10]><!-->
|
||||
<script>
|
||||
if (!navigator.cookieEnabled) {
|
||||
window.addEventListener('DOMContentLoaded', function () {
|
||||
var cookieEl = document.getElementById('cookie-alert');
|
||||
cookieEl.style.display = 'block';
|
||||
})
|
||||
}
|
||||
</script>
|
||||
<!--<![endif]-->
|
||||
|
||||
|
||||
</head>
|
||||
<body>
|
||||
<div id="cf-wrapper">
|
||||
<div class="cf-alert cf-alert-error cf-cookie-error" id="cookie-alert" data-translate="enable_cookies">Please enable cookies.</div>
|
||||
<div id="cf-error-details" class="cf-error-details-wrapper">
|
||||
<div class="cf-wrapper cf-header cf-error-overview">
|
||||
<h1 data-translate="block_headline">Sorry, you have been blocked</h1>
|
||||
<h2 class="cf-subheadline"><span data-translate="unable_to_access">You are unable to access</span> sso.garmin.com</h2>
|
||||
</div><!-- /.header -->
|
||||
|
||||
<div class="cf-section cf-highlight">
|
||||
<div class="cf-wrapper">
|
||||
<div class="cf-screenshot-container cf-screenshot-full">
|
||||
|
||||
<span class="cf-no-screenshot error"></span>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div><!-- /.captcha-container -->
|
||||
|
||||
<div class="cf-section cf-wrapper">
|
||||
<div class="cf-columns two">
|
||||
<div class="cf-column">
|
||||
<h2 data-translate="blocked_why_headline">Why have I been blocked?</h2>
|
||||
|
||||
<p data-translate="blocked_why_detail">This website is using a security service to protect itself from online attacks. The action you just performed triggered the security solution. There are several actions that could trigger this block including submitting a certain word or phrase, a SQL command or malformed data.</p>
|
||||
</div>
|
||||
|
||||
<div class="cf-column">
|
||||
<h2 data-translate="blocked_resolve_headline">What can I do to resolve this?</h2>
|
||||
|
||||
<p data-translate="blocked_resolve_detail">You can email the site owner to let them know you were blocked. Please include what you were doing when this page came up and the Cloudflare Ray ID found at the bottom of this page.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div><!-- /.section -->
|
||||
|
||||
<div class="cf-error-footer cf-wrapper w-240 lg:w-full py-10 sm:py-4 sm:px-8 mx-auto text-center sm:text-left border-solid border-0 border-t border-gray-300">
|
||||
<p class="text-13">
|
||||
<span class="cf-footer-item sm:block sm:mb-1">Cloudflare Ray ID: <strong class="font-semibold">97a8823fcfbc0904</strong></span>
|
||||
<span class="cf-footer-separator sm:hidden">•</span>
|
||||
<span id="cf-footer-item-ip" class="cf-footer-item hidden sm:block sm:mb-1">
|
||||
Your IP:
|
||||
<button type="button" id="cf-footer-ip-reveal" class="cf-footer-ip-reveal-btn">Click to reveal</button>
|
||||
<span class="hidden" id="cf-footer-ip">47.150.230.21</span>
|
||||
<span class="cf-footer-separator sm:hidden">•</span>
|
||||
</span>
|
||||
<span class="cf-footer-item sm:block sm:mb-1"><span>Performance & security by</span> <a rel="noopener noreferrer" href="https://www.cloudflare.com/5xx-error-landing" id="brand_link" target="_blank">Cloudflare</a></span>
|
||||
|
||||
</p>
|
||||
<script>(function(){function d(){var b=a.getElementById("cf-footer-item-ip"),c=a.getElementById("cf-footer-ip-reveal");b&&"classList"in b&&(b.classList.remove("hidden"),c.addEventListener("click",function(){c.classList.add("hidden");a.getElementById("cf-footer-ip").classList.remove("hidden")}))}var a=document;document.addEventListener&&a.addEventListener("DOMContentLoaded",d)})();</script>
|
||||
</div><!-- /.error-footer -->
|
||||
|
||||
|
||||
</div><!-- /#cf-error-details -->
|
||||
</div><!-- /#cf-wrapper -->
|
||||
|
||||
<script>
|
||||
window._cf_translation = {};
|
||||
|
||||
|
||||
</script>
|
||||
|
||||
<script>(function(){function c(){var b=a.contentDocument||a.contentWindow.document;if(b){var d=b.createElement('script');d.innerHTML="window.__CF$cv$params={r:'97a8823fcfbc0904',t:'MTc1NzEwNDQ4MS4wMDAwMDA='};var a=document.createElement('script');a.nonce='';a.src='/cdn-cgi/challenge-platform/scripts/jsd/main.js';document.getElementsByTagName('head')[0].appendChild(a);";b.getElementsByTagName('head')[0].appendChild(d)}}if(document.body){var a=document.createElement('iframe');a.height=1;a.width=1;a.style.position='absolute';a.style.top=0;a.style.left=0;a.style.border='none';a.style.visibility='hidden';document.body.appendChild(a);if('loading'!==document.readyState)c();else if(window.addEventListener)document.addEventListener('DOMContentLoaded',c);else{var e=document.onreadystatechange||function(){};document.onreadystatechange=function(b){e(b);'loading'!==document.readyState&&(document.onreadystatechange=e,c())}}}})();</script></body>
|
||||
</html>
|
||||
@@ -1,93 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<!--[if lt IE 7]> <html class="no-js ie6 oldie" lang="en-US"> <![endif]-->
|
||||
<!--[if IE 7]> <html class="no-js ie7 oldie" lang="en-US"> <![endif]-->
|
||||
<!--[if IE 8]> <html class="no-js ie8 oldie" lang="en-US"> <![endif]-->
|
||||
<!--[if gt IE 8]><!--> <html class="no-js" lang="en-US"> <!--<![endif]-->
|
||||
<head>
|
||||
<title>Attention Required! | Cloudflare</title>
|
||||
<meta charset="UTF-8" />
|
||||
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />
|
||||
<meta http-equiv="X-UA-Compatible" content="IE=Edge" />
|
||||
<meta name="robots" content="noindex, nofollow" />
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1" />
|
||||
<link rel="stylesheet" id="cf_styles-css" href="/cdn-cgi/styles/cf.errors.css" />
|
||||
<!--[if lt IE 9]><link rel="stylesheet" id='cf_styles-ie-css' href="/cdn-cgi/styles/cf.errors.ie.css" /><![endif]-->
|
||||
<style>body{margin:0;padding:0}</style>
|
||||
|
||||
|
||||
<!--[if gte IE 10]><!-->
|
||||
<script>
|
||||
if (!navigator.cookieEnabled) {
|
||||
window.addEventListener('DOMContentLoaded', function () {
|
||||
var cookieEl = document.getElementById('cookie-alert');
|
||||
cookieEl.style.display = 'block';
|
||||
})
|
||||
}
|
||||
</script>
|
||||
<!--<![endif]-->
|
||||
|
||||
|
||||
</head>
|
||||
<body>
|
||||
<div id="cf-wrapper">
|
||||
<div class="cf-alert cf-alert-error cf-cookie-error" id="cookie-alert" data-translate="enable_cookies">Please enable cookies.</div>
|
||||
<div id="cf-error-details" class="cf-error-details-wrapper">
|
||||
<div class="cf-wrapper cf-header cf-error-overview">
|
||||
<h1 data-translate="block_headline">Sorry, you have been blocked</h1>
|
||||
<h2 class="cf-subheadline"><span data-translate="unable_to_access">You are unable to access</span> sso.garmin.com</h2>
|
||||
</div><!-- /.header -->
|
||||
|
||||
<div class="cf-section cf-highlight">
|
||||
<div class="cf-wrapper">
|
||||
<div class="cf-screenshot-container cf-screenshot-full">
|
||||
|
||||
<span class="cf-no-screenshot error"></span>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div><!-- /.captcha-container -->
|
||||
|
||||
<div class="cf-section cf-wrapper">
|
||||
<div class="cf-columns two">
|
||||
<div class="cf-column">
|
||||
<h2 data-translate="blocked_why_headline">Why have I been blocked?</h2>
|
||||
|
||||
<p data-translate="blocked_why_detail">This website is using a security service to protect itself from online attacks. The action you just performed triggered the security solution. There are several actions that could trigger this block including submitting a certain word or phrase, a SQL command or malformed data.</p>
|
||||
</div>
|
||||
|
||||
<div class="cf-column">
|
||||
<h2 data-translate="blocked_resolve_headline">What can I do to resolve this?</h2>
|
||||
|
||||
<p data-translate="blocked_resolve_detail">You can email the site owner to let them know you were blocked. Please include what you were doing when this page came up and the Cloudflare Ray ID found at the bottom of this page.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div><!-- /.section -->
|
||||
|
||||
<div class="cf-error-footer cf-wrapper w-240 lg:w-full py-10 sm:py-4 sm:px-8 mx-auto text-center sm:text-left border-solid border-0 border-t border-gray-300">
|
||||
<p class="text-13">
|
||||
<span class="cf-footer-item sm:block sm:mb-1">Cloudflare Ray ID: <strong class="font-semibold">97a9b3b9dbc89dea</strong></span>
|
||||
<span class="cf-footer-separator sm:hidden">•</span>
|
||||
<span id="cf-footer-item-ip" class="cf-footer-item hidden sm:block sm:mb-1">
|
||||
Your IP:
|
||||
<button type="button" id="cf-footer-ip-reveal" class="cf-footer-ip-reveal-btn">Click to reveal</button>
|
||||
<span class="hidden" id="cf-footer-ip">47.150.230.21</span>
|
||||
<span class="cf-footer-separator sm:hidden">•</span>
|
||||
</span>
|
||||
<span class="cf-footer-item sm:block sm:mb-1"><span>Performance & security by</span> <a rel="noopener noreferrer" href="https://www.cloudflare.com/5xx-error-landing" id="brand_link" target="_blank">Cloudflare</a></span>
|
||||
|
||||
</p>
|
||||
<script>(function(){function d(){var b=a.getElementById("cf-footer-item-ip"),c=a.getElementById("cf-footer-ip-reveal");b&&"classList"in b&&(b.classList.remove("hidden"),c.addEventListener("click",function(){c.classList.add("hidden");a.getElementById("cf-footer-ip").classList.remove("hidden")}))}var a=document;document.addEventListener&&a.addEventListener("DOMContentLoaded",d)})();</script>
|
||||
</div><!-- /.error-footer -->
|
||||
|
||||
|
||||
</div><!-- /#cf-error-details -->
|
||||
</div><!-- /#cf-wrapper -->
|
||||
|
||||
<script>
|
||||
window._cf_translation = {};
|
||||
|
||||
|
||||
</script>
|
||||
|
||||
<script>(function(){function c(){var b=a.contentDocument||a.contentWindow.document;if(b){var d=b.createElement('script');d.innerHTML="window.__CF$cv$params={r:'97a9b3b9dbc89dea',t:'MTc1NzExNjk5My4wMDAwMDA='};var a=document.createElement('script');a.nonce='';a.src='/cdn-cgi/challenge-platform/scripts/jsd/main.js';document.getElementsByTagName('head')[0].appendChild(a);";b.getElementsByTagName('head')[0].appendChild(d)}}if(document.body){var a=document.createElement('iframe');a.height=1;a.width=1;a.style.position='absolute';a.style.top=0;a.style.left=0;a.style.border='none';a.style.visibility='hidden';document.body.appendChild(a);if('loading'!==document.readyState)c();else if(window.addEventListener)document.addEventListener('DOMContentLoaded',c);else{var e=document.onreadystatechange||function(){};document.onreadystatechange=function(b){e(b);'loading'!==document.readyState&&(document.onreadystatechange=e,c())}}}})();</script></body>
|
||||
</html>
|
||||
@@ -1,93 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<!--[if lt IE 7]> <html class="no-js ie6 oldie" lang="en-US"> <![endif]-->
|
||||
<!--[if IE 7]> <html class="no-js ie7 oldie" lang="en-US"> <![endif]-->
|
||||
<!--[if IE 8]> <html class="no-js ie8 oldie" lang="en-US"> <![endif]-->
|
||||
<!--[if gt IE 8]><!--> <html class="no-js" lang="en-US"> <!--<![endif]-->
|
||||
<head>
|
||||
<title>Attention Required! | Cloudflare</title>
|
||||
<meta charset="UTF-8" />
|
||||
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />
|
||||
<meta http-equiv="X-UA-Compatible" content="IE=Edge" />
|
||||
<meta name="robots" content="noindex, nofollow" />
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1" />
|
||||
<link rel="stylesheet" id="cf_styles-css" href="/cdn-cgi/styles/cf.errors.css" />
|
||||
<!--[if lt IE 9]><link rel="stylesheet" id='cf_styles-ie-css' href="/cdn-cgi/styles/cf.errors.ie.css" /><![endif]-->
|
||||
<style>body{margin:0;padding:0}</style>
|
||||
|
||||
|
||||
<!--[if gte IE 10]><!-->
|
||||
<script>
|
||||
if (!navigator.cookieEnabled) {
|
||||
window.addEventListener('DOMContentLoaded', function () {
|
||||
var cookieEl = document.getElementById('cookie-alert');
|
||||
cookieEl.style.display = 'block';
|
||||
})
|
||||
}
|
||||
</script>
|
||||
<!--<![endif]-->
|
||||
|
||||
|
||||
</head>
|
||||
<body>
|
||||
<div id="cf-wrapper">
|
||||
<div class="cf-alert cf-alert-error cf-cookie-error" id="cookie-alert" data-translate="enable_cookies">Please enable cookies.</div>
|
||||
<div id="cf-error-details" class="cf-error-details-wrapper">
|
||||
<div class="cf-wrapper cf-header cf-error-overview">
|
||||
<h1 data-translate="block_headline">Sorry, you have been blocked</h1>
|
||||
<h2 class="cf-subheadline"><span data-translate="unable_to_access">You are unable to access</span> sso.garmin.com</h2>
|
||||
</div><!-- /.header -->
|
||||
|
||||
<div class="cf-section cf-highlight">
|
||||
<div class="cf-wrapper">
|
||||
<div class="cf-screenshot-container cf-screenshot-full">
|
||||
|
||||
<span class="cf-no-screenshot error"></span>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div><!-- /.captcha-container -->
|
||||
|
||||
<div class="cf-section cf-wrapper">
|
||||
<div class="cf-columns two">
|
||||
<div class="cf-column">
|
||||
<h2 data-translate="blocked_why_headline">Why have I been blocked?</h2>
|
||||
|
||||
<p data-translate="blocked_why_detail">This website is using a security service to protect itself from online attacks. The action you just performed triggered the security solution. There are several actions that could trigger this block including submitting a certain word or phrase, a SQL command or malformed data.</p>
|
||||
</div>
|
||||
|
||||
<div class="cf-column">
|
||||
<h2 data-translate="blocked_resolve_headline">What can I do to resolve this?</h2>
|
||||
|
||||
<p data-translate="blocked_resolve_detail">You can email the site owner to let them know you were blocked. Please include what you were doing when this page came up and the Cloudflare Ray ID found at the bottom of this page.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div><!-- /.section -->
|
||||
|
||||
<div class="cf-error-footer cf-wrapper w-240 lg:w-full py-10 sm:py-4 sm:px-8 mx-auto text-center sm:text-left border-solid border-0 border-t border-gray-300">
|
||||
<p class="text-13">
|
||||
<span class="cf-footer-item sm:block sm:mb-1">Cloudflare Ray ID: <strong class="font-semibold">97a9b677e98ff8d5</strong></span>
|
||||
<span class="cf-footer-separator sm:hidden">•</span>
|
||||
<span id="cf-footer-item-ip" class="cf-footer-item hidden sm:block sm:mb-1">
|
||||
Your IP:
|
||||
<button type="button" id="cf-footer-ip-reveal" class="cf-footer-ip-reveal-btn">Click to reveal</button>
|
||||
<span class="hidden" id="cf-footer-ip">138.199.43.80</span>
|
||||
<span class="cf-footer-separator sm:hidden">•</span>
|
||||
</span>
|
||||
<span class="cf-footer-item sm:block sm:mb-1"><span>Performance & security by</span> <a rel="noopener noreferrer" href="https://www.cloudflare.com/5xx-error-landing" id="brand_link" target="_blank">Cloudflare</a></span>
|
||||
|
||||
</p>
|
||||
<script>(function(){function d(){var b=a.getElementById("cf-footer-item-ip"),c=a.getElementById("cf-footer-ip-reveal");b&&"classList"in b&&(b.classList.remove("hidden"),c.addEventListener("click",function(){c.classList.add("hidden");a.getElementById("cf-footer-ip").classList.remove("hidden")}))}var a=document;document.addEventListener&&a.addEventListener("DOMContentLoaded",d)})();</script>
|
||||
</div><!-- /.error-footer -->
|
||||
|
||||
|
||||
</div><!-- /#cf-error-details -->
|
||||
</div><!-- /#cf-wrapper -->
|
||||
|
||||
<script>
|
||||
window._cf_translation = {};
|
||||
|
||||
|
||||
</script>
|
||||
|
||||
<script>(function(){function c(){var b=a.contentDocument||a.contentWindow.document;if(b){var d=b.createElement('script');d.innerHTML="window.__CF$cv$params={r:'97a9b677e98ff8d5',t:'MTc1NzExNzEwNS4wMDAwMDA='};var a=document.createElement('script');a.nonce='';a.src='/cdn-cgi/challenge-platform/scripts/jsd/main.js';document.getElementsByTagName('head')[0].appendChild(a);";b.getElementsByTagName('head')[0].appendChild(d)}}if(document.body){var a=document.createElement('iframe');a.height=1;a.width=1;a.style.position='absolute';a.style.top=0;a.style.left=0;a.style.border='none';a.style.visibility='hidden';document.body.appendChild(a);if('loading'!==document.readyState)c();else if(window.addEventListener)document.addEventListener('DOMContentLoaded',c);else{var e=document.onreadystatechange||function(){};document.onreadystatechange=function(b){e(b);'loading'!==document.readyState&&(document.onreadystatechange=e,c())}}}})();</script></body>
|
||||
</html>
|
||||
@@ -1,93 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<!--[if lt IE 7]> <html class="no-js ie6 oldie" lang="en-US"> <![endif]-->
|
||||
<!--[if IE 7]> <html class="no-js ie7 oldie" lang="en-US"> <![endif]-->
|
||||
<!--[if IE 8]> <html class="no-js ie8 oldie" lang="en-US"> <![endif]-->
|
||||
<!--[if gt IE 8]><!--> <html class="no-js" lang="en-US"> <!--<![endif]-->
|
||||
<head>
|
||||
<title>Attention Required! | Cloudflare</title>
|
||||
<meta charset="UTF-8" />
|
||||
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />
|
||||
<meta http-equiv="X-UA-Compatible" content="IE=Edge" />
|
||||
<meta name="robots" content="noindex, nofollow" />
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1" />
|
||||
<link rel="stylesheet" id="cf_styles-css" href="/cdn-cgi/styles/cf.errors.css" />
|
||||
<!--[if lt IE 9]><link rel="stylesheet" id='cf_styles-ie-css' href="/cdn-cgi/styles/cf.errors.ie.css" /><![endif]-->
|
||||
<style>body{margin:0;padding:0}</style>
|
||||
|
||||
|
||||
<!--[if gte IE 10]><!-->
|
||||
<script>
|
||||
if (!navigator.cookieEnabled) {
|
||||
window.addEventListener('DOMContentLoaded', function () {
|
||||
var cookieEl = document.getElementById('cookie-alert');
|
||||
cookieEl.style.display = 'block';
|
||||
})
|
||||
}
|
||||
</script>
|
||||
<!--<![endif]-->
|
||||
|
||||
|
||||
</head>
|
||||
<body>
|
||||
<div id="cf-wrapper">
|
||||
<div class="cf-alert cf-alert-error cf-cookie-error" id="cookie-alert" data-translate="enable_cookies">Please enable cookies.</div>
|
||||
<div id="cf-error-details" class="cf-error-details-wrapper">
|
||||
<div class="cf-wrapper cf-header cf-error-overview">
|
||||
<h1 data-translate="block_headline">Sorry, you have been blocked</h1>
|
||||
<h2 class="cf-subheadline"><span data-translate="unable_to_access">You are unable to access</span> sso.garmin.com</h2>
|
||||
</div><!-- /.header -->
|
||||
|
||||
<div class="cf-section cf-highlight">
|
||||
<div class="cf-wrapper">
|
||||
<div class="cf-screenshot-container cf-screenshot-full">
|
||||
|
||||
<span class="cf-no-screenshot error"></span>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div><!-- /.captcha-container -->
|
||||
|
||||
<div class="cf-section cf-wrapper">
|
||||
<div class="cf-columns two">
|
||||
<div class="cf-column">
|
||||
<h2 data-translate="blocked_why_headline">Why have I been blocked?</h2>
|
||||
|
||||
<p data-translate="blocked_why_detail">This website is using a security service to protect itself from online attacks. The action you just performed triggered the security solution. There are several actions that could trigger this block including submitting a certain word or phrase, a SQL command or malformed data.</p>
|
||||
</div>
|
||||
|
||||
<div class="cf-column">
|
||||
<h2 data-translate="blocked_resolve_headline">What can I do to resolve this?</h2>
|
||||
|
||||
<p data-translate="blocked_resolve_detail">You can email the site owner to let them know you were blocked. Please include what you were doing when this page came up and the Cloudflare Ray ID found at the bottom of this page.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div><!-- /.section -->
|
||||
|
||||
<div class="cf-error-footer cf-wrapper w-240 lg:w-full py-10 sm:py-4 sm:px-8 mx-auto text-center sm:text-left border-solid border-0 border-t border-gray-300">
|
||||
<p class="text-13">
|
||||
<span class="cf-footer-item sm:block sm:mb-1">Cloudflare Ray ID: <strong class="font-semibold">97a9b977caffdbc2</strong></span>
|
||||
<span class="cf-footer-separator sm:hidden">•</span>
|
||||
<span id="cf-footer-item-ip" class="cf-footer-item hidden sm:block sm:mb-1">
|
||||
Your IP:
|
||||
<button type="button" id="cf-footer-ip-reveal" class="cf-footer-ip-reveal-btn">Click to reveal</button>
|
||||
<span class="hidden" id="cf-footer-ip">47.150.230.21</span>
|
||||
<span class="cf-footer-separator sm:hidden">•</span>
|
||||
</span>
|
||||
<span class="cf-footer-item sm:block sm:mb-1"><span>Performance & security by</span> <a rel="noopener noreferrer" href="https://www.cloudflare.com/5xx-error-landing" id="brand_link" target="_blank">Cloudflare</a></span>
|
||||
|
||||
</p>
|
||||
<script>(function(){function d(){var b=a.getElementById("cf-footer-item-ip"),c=a.getElementById("cf-footer-ip-reveal");b&&"classList"in b&&(b.classList.remove("hidden"),c.addEventListener("click",function(){c.classList.add("hidden");a.getElementById("cf-footer-ip").classList.remove("hidden")}))}var a=document;document.addEventListener&&a.addEventListener("DOMContentLoaded",d)})();</script>
|
||||
</div><!-- /.error-footer -->
|
||||
|
||||
|
||||
</div><!-- /#cf-error-details -->
|
||||
</div><!-- /#cf-wrapper -->
|
||||
|
||||
<script>
|
||||
window._cf_translation = {};
|
||||
|
||||
|
||||
</script>
|
||||
|
||||
<script>(function(){function c(){var b=a.contentDocument||a.contentWindow.document;if(b){var d=b.createElement('script');d.innerHTML="window.__CF$cv$params={r:'97a9b977caffdbc2',t:'MTc1NzExNzIyOC4wMDAwMDA='};var a=document.createElement('script');a.nonce='';a.src='/cdn-cgi/challenge-platform/scripts/jsd/main.js';document.getElementsByTagName('head')[0].appendChild(a);";b.getElementsByTagName('head')[0].appendChild(d)}}if(document.body){var a=document.createElement('iframe');a.height=1;a.width=1;a.style.position='absolute';a.style.top=0;a.style.left=0;a.style.border='none';a.style.visibility='hidden';document.body.appendChild(a);if('loading'!==document.readyState)c();else if(window.addEventListener)document.addEventListener('DOMContentLoaded',c);else{var e=document.onreadystatechange||function(){};document.onreadystatechange=function(b){e(b);'loading'!==document.readyState&&(document.onreadystatechange=e,c())}}}})();</script></body>
|
||||
</html>
|
||||
@@ -1,93 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<!--[if lt IE 7]> <html class="no-js ie6 oldie" lang="en-US"> <![endif]-->
|
||||
<!--[if IE 7]> <html class="no-js ie7 oldie" lang="en-US"> <![endif]-->
|
||||
<!--[if IE 8]> <html class="no-js ie8 oldie" lang="en-US"> <![endif]-->
|
||||
<!--[if gt IE 8]><!--> <html class="no-js" lang="en-US"> <!--<![endif]-->
|
||||
<head>
|
||||
<title>Attention Required! | Cloudflare</title>
|
||||
<meta charset="UTF-8" />
|
||||
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />
|
||||
<meta http-equiv="X-UA-Compatible" content="IE=Edge" />
|
||||
<meta name="robots" content="noindex, nofollow" />
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1" />
|
||||
<link rel="stylesheet" id="cf_styles-css" href="/cdn-cgi/styles/cf.errors.css" />
|
||||
<!--[if lt IE 9]><link rel="stylesheet" id='cf_styles-ie-css' href="/cdn-cgi/styles/cf.errors.ie.css" /><![endif]-->
|
||||
<style>body{margin:0;padding:0}</style>
|
||||
|
||||
|
||||
<!--[if gte IE 10]><!-->
|
||||
<script>
|
||||
if (!navigator.cookieEnabled) {
|
||||
window.addEventListener('DOMContentLoaded', function () {
|
||||
var cookieEl = document.getElementById('cookie-alert');
|
||||
cookieEl.style.display = 'block';
|
||||
})
|
||||
}
|
||||
</script>
|
||||
<!--<![endif]-->
|
||||
|
||||
|
||||
</head>
|
||||
<body>
|
||||
<div id="cf-wrapper">
|
||||
<div class="cf-alert cf-alert-error cf-cookie-error" id="cookie-alert" data-translate="enable_cookies">Please enable cookies.</div>
|
||||
<div id="cf-error-details" class="cf-error-details-wrapper">
|
||||
<div class="cf-wrapper cf-header cf-error-overview">
|
||||
<h1 data-translate="block_headline">Sorry, you have been blocked</h1>
|
||||
<h2 class="cf-subheadline"><span data-translate="unable_to_access">You are unable to access</span> sso.garmin.com</h2>
|
||||
</div><!-- /.header -->
|
||||
|
||||
<div class="cf-section cf-highlight">
|
||||
<div class="cf-wrapper">
|
||||
<div class="cf-screenshot-container cf-screenshot-full">
|
||||
|
||||
<span class="cf-no-screenshot error"></span>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div><!-- /.captcha-container -->
|
||||
|
||||
<div class="cf-section cf-wrapper">
|
||||
<div class="cf-columns two">
|
||||
<div class="cf-column">
|
||||
<h2 data-translate="blocked_why_headline">Why have I been blocked?</h2>
|
||||
|
||||
<p data-translate="blocked_why_detail">This website is using a security service to protect itself from online attacks. The action you just performed triggered the security solution. There are several actions that could trigger this block including submitting a certain word or phrase, a SQL command or malformed data.</p>
|
||||
</div>
|
||||
|
||||
<div class="cf-column">
|
||||
<h2 data-translate="blocked_resolve_headline">What can I do to resolve this?</h2>
|
||||
|
||||
<p data-translate="blocked_resolve_detail">You can email the site owner to let them know you were blocked. Please include what you were doing when this page came up and the Cloudflare Ray ID found at the bottom of this page.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div><!-- /.section -->
|
||||
|
||||
<div class="cf-error-footer cf-wrapper w-240 lg:w-full py-10 sm:py-4 sm:px-8 mx-auto text-center sm:text-left border-solid border-0 border-t border-gray-300">
|
||||
<p class="text-13">
|
||||
<span class="cf-footer-item sm:block sm:mb-1">Cloudflare Ray ID: <strong class="font-semibold">97a9c7bc4ed7ff84</strong></span>
|
||||
<span class="cf-footer-separator sm:hidden">•</span>
|
||||
<span id="cf-footer-item-ip" class="cf-footer-item hidden sm:block sm:mb-1">
|
||||
Your IP:
|
||||
<button type="button" id="cf-footer-ip-reveal" class="cf-footer-ip-reveal-btn">Click to reveal</button>
|
||||
<span class="hidden" id="cf-footer-ip">47.150.230.21</span>
|
||||
<span class="cf-footer-separator sm:hidden">•</span>
|
||||
</span>
|
||||
<span class="cf-footer-item sm:block sm:mb-1"><span>Performance & security by</span> <a rel="noopener noreferrer" href="https://www.cloudflare.com/5xx-error-landing" id="brand_link" target="_blank">Cloudflare</a></span>
|
||||
|
||||
</p>
|
||||
<script>(function(){function d(){var b=a.getElementById("cf-footer-item-ip"),c=a.getElementById("cf-footer-ip-reveal");b&&"classList"in b&&(b.classList.remove("hidden"),c.addEventListener("click",function(){c.classList.add("hidden");a.getElementById("cf-footer-ip").classList.remove("hidden")}))}var a=document;document.addEventListener&&a.addEventListener("DOMContentLoaded",d)})();</script>
|
||||
</div><!-- /.error-footer -->
|
||||
|
||||
|
||||
</div><!-- /#cf-error-details -->
|
||||
</div><!-- /#cf-wrapper -->
|
||||
|
||||
<script>
|
||||
window._cf_translation = {};
|
||||
|
||||
|
||||
</script>
|
||||
|
||||
<script>(function(){function c(){var b=a.contentDocument||a.contentWindow.document;if(b){var d=b.createElement('script');d.innerHTML="window.__CF$cv$params={r:'97a9c7bc4ed7ff84',t:'MTc1NzExNzgxMy4wMDAwMDA='};var a=document.createElement('script');a.nonce='';a.src='/cdn-cgi/challenge-platform/scripts/jsd/main.js';document.getElementsByTagName('head')[0].appendChild(a);";b.getElementsByTagName('head')[0].appendChild(d)}}if(document.body){var a=document.createElement('iframe');a.height=1;a.width=1;a.style.position='absolute';a.style.top=0;a.style.left=0;a.style.border='none';a.style.visibility='hidden';document.body.appendChild(a);if('loading'!==document.readyState)c();else if(window.addEventListener)document.addEventListener('DOMContentLoaded',c);else{var e=document.onreadystatechange||function(){};document.onreadystatechange=function(b){e(b);'loading'!==document.readyState&&(document.onreadystatechange=e,c())}}}})();</script></body>
|
||||
</html>
|
||||
@@ -1,93 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<!--[if lt IE 7]> <html class="no-js ie6 oldie" lang="en-US"> <![endif]-->
|
||||
<!--[if IE 7]> <html class="no-js ie7 oldie" lang="en-US"> <![endif]-->
|
||||
<!--[if IE 8]> <html class="no-js ie8 oldie" lang="en-US"> <![endif]-->
|
||||
<!--[if gt IE 8]><!--> <html class="no-js" lang="en-US"> <!--<![endif]-->
|
||||
<head>
|
||||
<title>Attention Required! | Cloudflare</title>
|
||||
<meta charset="UTF-8" />
|
||||
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />
|
||||
<meta http-equiv="X-UA-Compatible" content="IE=Edge" />
|
||||
<meta name="robots" content="noindex, nofollow" />
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1" />
|
||||
<link rel="stylesheet" id="cf_styles-css" href="/cdn-cgi/styles/cf.errors.css" />
|
||||
<!--[if lt IE 9]><link rel="stylesheet" id='cf_styles-ie-css' href="/cdn-cgi/styles/cf.errors.ie.css" /><![endif]-->
|
||||
<style>body{margin:0;padding:0}</style>
|
||||
|
||||
|
||||
<!--[if gte IE 10]><!-->
|
||||
<script>
|
||||
if (!navigator.cookieEnabled) {
|
||||
window.addEventListener('DOMContentLoaded', function () {
|
||||
var cookieEl = document.getElementById('cookie-alert');
|
||||
cookieEl.style.display = 'block';
|
||||
})
|
||||
}
|
||||
</script>
|
||||
<!--<![endif]-->
|
||||
|
||||
|
||||
</head>
|
||||
<body>
|
||||
<div id="cf-wrapper">
|
||||
<div class="cf-alert cf-alert-error cf-cookie-error" id="cookie-alert" data-translate="enable_cookies">Please enable cookies.</div>
|
||||
<div id="cf-error-details" class="cf-error-details-wrapper">
|
||||
<div class="cf-wrapper cf-header cf-error-overview">
|
||||
<h1 data-translate="block_headline">Sorry, you have been blocked</h1>
|
||||
<h2 class="cf-subheadline"><span data-translate="unable_to_access">You are unable to access</span> sso.garmin.com</h2>
|
||||
</div><!-- /.header -->
|
||||
|
||||
<div class="cf-section cf-highlight">
|
||||
<div class="cf-wrapper">
|
||||
<div class="cf-screenshot-container cf-screenshot-full">
|
||||
|
||||
<span class="cf-no-screenshot error"></span>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div><!-- /.captcha-container -->
|
||||
|
||||
<div class="cf-section cf-wrapper">
|
||||
<div class="cf-columns two">
|
||||
<div class="cf-column">
|
||||
<h2 data-translate="blocked_why_headline">Why have I been blocked?</h2>
|
||||
|
||||
<p data-translate="blocked_why_detail">This website is using a security service to protect itself from online attacks. The action you just performed triggered the security solution. There are several actions that could trigger this block including submitting a certain word or phrase, a SQL command or malformed data.</p>
|
||||
</div>
|
||||
|
||||
<div class="cf-column">
|
||||
<h2 data-translate="blocked_resolve_headline">What can I do to resolve this?</h2>
|
||||
|
||||
<p data-translate="blocked_resolve_detail">You can email the site owner to let them know you were blocked. Please include what you were doing when this page came up and the Cloudflare Ray ID found at the bottom of this page.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div><!-- /.section -->
|
||||
|
||||
<div class="cf-error-footer cf-wrapper w-240 lg:w-full py-10 sm:py-4 sm:px-8 mx-auto text-center sm:text-left border-solid border-0 border-t border-gray-300">
|
||||
<p class="text-13">
|
||||
<span class="cf-footer-item sm:block sm:mb-1">Cloudflare Ray ID: <strong class="font-semibold">97a9f0c2588a72e0</strong></span>
|
||||
<span class="cf-footer-separator sm:hidden">•</span>
|
||||
<span id="cf-footer-item-ip" class="cf-footer-item hidden sm:block sm:mb-1">
|
||||
Your IP:
|
||||
<button type="button" id="cf-footer-ip-reveal" class="cf-footer-ip-reveal-btn">Click to reveal</button>
|
||||
<span class="hidden" id="cf-footer-ip">47.150.230.21</span>
|
||||
<span class="cf-footer-separator sm:hidden">•</span>
|
||||
</span>
|
||||
<span class="cf-footer-item sm:block sm:mb-1"><span>Performance & security by</span> <a rel="noopener noreferrer" href="https://www.cloudflare.com/5xx-error-landing" id="brand_link" target="_blank">Cloudflare</a></span>
|
||||
|
||||
</p>
|
||||
<script>(function(){function d(){var b=a.getElementById("cf-footer-item-ip"),c=a.getElementById("cf-footer-ip-reveal");b&&"classList"in b&&(b.classList.remove("hidden"),c.addEventListener("click",function(){c.classList.add("hidden");a.getElementById("cf-footer-ip").classList.remove("hidden")}))}var a=document;document.addEventListener&&a.addEventListener("DOMContentLoaded",d)})();</script>
|
||||
</div><!-- /.error-footer -->
|
||||
|
||||
|
||||
</div><!-- /#cf-error-details -->
|
||||
</div><!-- /#cf-wrapper -->
|
||||
|
||||
<script>
|
||||
window._cf_translation = {};
|
||||
|
||||
|
||||
</script>
|
||||
|
||||
<script>(function(){function c(){var b=a.contentDocument||a.contentWindow.document;if(b){var d=b.createElement('script');d.innerHTML="window.__CF$cv$params={r:'97a9f0c2588a72e0',t:'MTc1NzExOTQ5My4wMDAwMDA='};var a=document.createElement('script');a.nonce='';a.src='/cdn-cgi/challenge-platform/scripts/jsd/main.js';document.getElementsByTagName('head')[0].appendChild(a);";b.getElementsByTagName('head')[0].appendChild(d)}}if(document.body){var a=document.createElement('iframe');a.height=1;a.width=1;a.style.position='absolute';a.style.top=0;a.style.left=0;a.style.border='none';a.style.visibility='hidden';document.body.appendChild(a);if('loading'!==document.readyState)c();else if(window.addEventListener)document.addEventListener('DOMContentLoaded',c);else{var e=document.onreadystatechange||function(){};document.onreadystatechange=function(b){e(b);'loading'!==document.readyState&&(document.onreadystatechange=e,c())}}}})();</script></body>
|
||||
</html>
|
||||
Binary file not shown.
@@ -1,206 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en" class="no-js">
|
||||
<head>
|
||||
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />
|
||||
<meta name="viewport" content="width=device-width" />
|
||||
<meta http-equiv="X-UA-Compatible" content="IE=edge;" />
|
||||
<title>GARMIN Authentication Application</title>
|
||||
<link href="/sso/css/GAuth.css?20210406" rel="stylesheet" type="text/css" media="all" />
|
||||
|
||||
<link rel="stylesheet" href=""/>
|
||||
|
||||
<script type="text/javascript" src="/sso/js/jquery/3.7.1/jquery.min.js?20210319"></script>
|
||||
<script type="text/javascript">jQuery.noConflict();</script>
|
||||
<script type="text/javascript" src="/sso/js/jquery-validate/1.16.0/jquery.validate.min.js?20210319"></script>
|
||||
<script type="text/javascript" src="/sso/js/jsUtils.js?20210406"></script>
|
||||
<script type="text/javascript" src="/sso/js/json2.js"></script>
|
||||
<script type="text/javascript" src="/sso/js/consoleUtils.js?20210319"></script>
|
||||
<script type="text/javascript" src="/sso/js/postmessage.js?20210319"></script>
|
||||
<script type="text/javascript" src="/sso/js/popupWindow.js"></script>
|
||||
<script type="text/javascript" src="/sso/js/base.js?20231020"></script>
|
||||
<script type="text/javascript" src="/sso/js/gigyaUtils.js?20210319"></script>
|
||||
<script type="text/javascript" src="/sso/js/login.js?20211102"></script>
|
||||
<script type="text/javascript" src="/sso/js/reCaptchaUtil.js?20230706"></script>
|
||||
|
||||
<script>
|
||||
var recaptchaSiteKey = null;
|
||||
var reCaptchaURL = "\\\/sso\\\/reCaptcha?clientId=GarminConnect\u0026consumeServiceTicket=false\u0026embedWidget=true\u0026gauthHost=https%3A%2F%2Fsso.garmin.com%2Fsso\u0026generateExtraServiceTicket=true\u0026id=gauth-widget\u0026locale=en_US\u0026redirectAfterAccountCreationUrl=https%3A%2F%2Fconnect.garmin.com%2FoauthConfirm\u0026redirectAfterAccountLoginUrl=https%3A%2F%2Fconnect.garmin.com%2FoauthConfirm\u0026service=https%3A%2F%2Fconnect.garmin.com%2FoauthConfirm\u0026source=https%3A%2F%2Fsso.garmin.com%2Fsso";
|
||||
var isRecaptchaEnabled = null;
|
||||
var recaptchaToken = null;
|
||||
</script>
|
||||
<script type="text/javascript">
|
||||
var parent_url = "https:\/\/sso.garmin.com\/sso";
|
||||
var status = "";
|
||||
var result = "";
|
||||
var clientId = 'GarminConnect';
|
||||
var embedWidget = true;
|
||||
var isUsernameDefined = (false == true) || (false == true);
|
||||
|
||||
// Gigya callback to SocialSignInController for brand new social network users redirects to this page
|
||||
// to popup Create or Link Social Account page, but has a possibly mangled source parameter
|
||||
// where "?" is set as "<QM>", so translate it back to "?" here.
|
||||
parent_url = parent_url.replace('<QM>', '?');
|
||||
var parent_scheme = parent_url.substring(0, parent_url.indexOf("://"));
|
||||
var parent_hostname = parent_url.substring(parent_scheme.length + 3, parent_url.length);
|
||||
if (parent_hostname.indexOf("/") != -1) {
|
||||
parent_hostname = parent_hostname.substring(0, parent_hostname.indexOf("/"));
|
||||
}
|
||||
var parentHost = parent_scheme + "://" + parent_hostname;
|
||||
var createAccountConfigURL = '\/sso\/createNewAccount?clientId%3DGarminConnect%26consumeServiceTicket%3Dfalse%26embedWidget%3Dtrue%26gauthHost%3Dhttps%253A%252F%252Fsso.garmin.com%252Fsso%26generateExtraServiceTicket%3Dtrue%26id%3Dgauth-widget%26locale%3Den_US%26redirectAfterAccountCreationUrl%3Dhttps%253A%252F%252Fconnect.garmin.com%252FoauthConfirm%26redirectAfterAccountLoginUrl%3Dhttps%253A%252F%252Fconnect.garmin.com%252FoauthConfirm%26service%3Dhttps%253A%252F%252Fconnect.garmin.com%252FoauthConfirm%26source%3Dhttps%253A%252F%252Fsso.garmin.com%252Fsso';
|
||||
var socialConfigURL = 'https://sso.garmin.com/sso/socialSignIn?clientId%3DGarminConnect%26consumeServiceTicket%3Dfalse%26embedWidget%3Dtrue%26gauthHost%3Dhttps%3A%2F%2Fsso.garmin.com%2Fsso%26generateExtraServiceTicket%3Dtrue%26id%3Dgauth-widget%26locale%3Den_US%26redirectAfterAccountCreationUrl%3Dhttps%3A%2F%2Fconnect.garmin.com%2FoauthConfirm%26redirectAfterAccountLoginUrl%3Dhttps%3A%2F%2Fconnect.garmin.com%2FoauthConfirm%26service%3Dhttps%3A%2F%2Fconnect.garmin.com%2FoauthConfirm%26source%3Dhttps%3A%2F%2Fsso.garmin.com%2Fsso';
|
||||
var gigyaURL = "https://cdns.gigya.com/js/gigya.js?apiKey=2_R3ZGY8Bqlwwk3_63knoD9wA_m-Y19mAgW61bF_s5k9gymYnMEAtMrJiF5MjF-U7B";
|
||||
|
||||
if (createAccountConfigURL.indexOf('%253A%252F%252F') != -1) {
|
||||
createAccountConfigURL = decodeURIComponent(createAccountConfigURL);
|
||||
}
|
||||
consoleInfo('signin.html embedWidget: true, createAccountConfigURL: \/sso\/createNewAccount?clientId%3DGarminConnect%26consumeServiceTicket%3Dfalse%26embedWidget%3Dtrue%26gauthHost%3Dhttps%253A%252F%252Fsso.garmin.com%252Fsso%26generateExtraServiceTicket%3Dtrue%26id%3Dgauth-widget%26locale%3Den_US%26redirectAfterAccountCreationUrl%3Dhttps%253A%252F%252Fconnect.garmin.com%252FoauthConfirm%26redirectAfterAccountLoginUrl%3Dhttps%253A%252F%252Fconnect.garmin.com%252FoauthConfirm%26service%3Dhttps%253A%252F%252Fconnect.garmin.com%252FoauthConfirm%26source%3Dhttps%253A%252F%252Fsso.garmin.com%252Fsso, socialEnabled: true, gigyaSupported: true, socialConfigURL(): https://sso.garmin.com/sso/socialSignIn?clientId%3DGarminConnect%26consumeServiceTicket%3Dfalse%26embedWidget%3Dtrue%26gauthHost%3Dhttps%3A%2F%2Fsso.garmin.com%2Fsso%26generateExtraServiceTicket%3Dtrue%26id%3Dgauth-widget%26locale%3Den_US%26redirectAfterAccountCreationUrl%3Dhttps%3A%2F%2Fconnect.garmin.com%2FoauthConfirm%26redirectAfterAccountLoginUrl%3Dhttps%3A%2F%2Fconnect.garmin.com%2FoauthConfirm%26service%3Dhttps%3A%2F%2Fconnect.garmin.com%2FoauthConfirm%26source%3Dhttps%3A%2F%2Fsso.garmin.com%2Fsso');
|
||||
|
||||
if (socialConfigURL.indexOf('%3A%2F%2F') != -1) {
|
||||
socialConfigURL = decodeURIComponent(socialConfigURL);
|
||||
}
|
||||
|
||||
if( status != null && status != ''){
|
||||
send({'status':status});
|
||||
}
|
||||
|
||||
jQuery(document).ready( function(){
|
||||
|
||||
|
||||
consoleInfo("signin.html: setting field validation rules...");
|
||||
|
||||
jQuery("#username").rules("add",{
|
||||
required: true,
|
||||
messages: {
|
||||
required: "Email is required."
|
||||
}});
|
||||
|
||||
jQuery("#password").rules("add", {
|
||||
required: true,
|
||||
messages: {
|
||||
required: "Password is required."
|
||||
}
|
||||
});
|
||||
|
||||
consoleInfo("signin.html: done setting field validation rules...");
|
||||
|
||||
});
|
||||
|
||||
XD.receiveMessage(function(m){
|
||||
consoleInfo("signin.html: " + m.data + " received on " + window.location.host);
|
||||
if (m && m.data) {
|
||||
var md = m.data;
|
||||
if (typeof(md) === 'string') {
|
||||
md = JSON.parse(m.data);
|
||||
}
|
||||
if (md.setUsername) {
|
||||
consoleInfo("signin.html: Setting username \"" + md.username + "\"...");
|
||||
jQuery("#signInWithDiffLink").click(); // Ensure the normal login form is shown.
|
||||
jQuery("#username").val(md.username);
|
||||
jQuery("#password").focus();
|
||||
}
|
||||
}
|
||||
}, parentHost);
|
||||
</script>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<!-- begin GAuth component -->
|
||||
<div id="GAuth-component">
|
||||
<!-- begin login component-->
|
||||
<div id="login-component" class="blueForm-basic">
|
||||
<input type="hidden" id="queryString" value="clientId=GarminConnect&consumeServiceTicket=false&embedWidget=true&gauthHost=https%3A%2F%2Fsso.garmin.com%2Fsso&generateExtraServiceTicket=true&id=gauth-widget&locale=en_US&redirectAfterAccountCreationUrl=https%3A%2F%2Fconnect.garmin.com%2FoauthConfirm&redirectAfterAccountLoginUrl=https%3A%2F%2Fconnect.garmin.com%2FoauthConfirm&service=https%3A%2F%2Fconnect.garmin.com%2FoauthConfirm&source=https%3A%2F%2Fsso.garmin.com%2Fsso" />
|
||||
<input type="hidden" id="contextPath" value="/sso" />
|
||||
<!-- begin login form -->
|
||||
<div id="login-state-default">
|
||||
<h2>Sign In</h2>
|
||||
|
||||
<form method="post" id="login-form">
|
||||
|
||||
<div class="form-alert">
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
<div id="username-error" style="display:none;"></div>
|
||||
<div id="password-error" style="display:none;"></div>
|
||||
</div>
|
||||
<div class="textfield">
|
||||
<label for="username">Email</label>
|
||||
<!-- If the lockToEmailAddress parameter is specified then we want to mark the field as readonly,
|
||||
preload the email address, and disable the other input so that null isn't sent to the server. We'll
|
||||
also style the field to have a darker grey background and disable the mouse pointer
|
||||
-->
|
||||
|
||||
<!-- If the lockToEmailAddress parameter is NOT specified then keep the existing functionality and disable the readonly input field
|
||||
-->
|
||||
<input class="login_email" name="username" id="username" value="" type="email" spellcheck="false" autocorrect="off" autocapitalize="off"/>
|
||||
|
||||
</div>
|
||||
|
||||
<div class="textfield">
|
||||
<label for="password">Password</label>
|
||||
<a id="loginforgotpassword" class="login-forgot-password" style="cursor:pointer">(Forgot?)</a>
|
||||
<input type="password" name="password" id="password" spellcheck="false" autocorrect="off" autocapitalize="off" />
|
||||
<strong id="capslock-warning" class="information" title="Caps lock is on." style="display: none;">Caps lock is on.</strong>
|
||||
</div>
|
||||
<input type="hidden" name="embed" value="true"/>
|
||||
<input type="hidden" name="_csrf" value="E40E9FF5C027499972C9CF65024778906C9844597CEA466BA7F12FD111B57968050F45514DAA93282D2DF3FB562B20866BD6" />
|
||||
<button type="submit" id="login-btn-signin" class="btn1" accesskey="l">Sign In</button>
|
||||
|
||||
|
||||
|
||||
<!-- The existence of the "rememberme" parameter at all will remember the user! -->
|
||||
|
||||
|
||||
</form>
|
||||
</div>
|
||||
<!-- end login form -->
|
||||
|
||||
<!-- begin Create Account message -->
|
||||
<div id="login-create-account">
|
||||
|
||||
</div>
|
||||
<!-- end Create Account message -->
|
||||
|
||||
<!-- begin Social Sign In component -->
|
||||
<div id="SSI-component">
|
||||
|
||||
|
||||
|
||||
</div>
|
||||
<!-- end Social Sign In component -->
|
||||
<div class="clearfix"></div> <!-- Ensure that GAuth-component div's height is computed correctly. -->
|
||||
</div>
|
||||
<!-- end login component-->
|
||||
|
||||
</div>
|
||||
<!-- end GAuth component -->
|
||||
|
||||
<script type="text/javascript">
|
||||
jQuery(document).ready(function(){
|
||||
resizePageOnLoad(jQuery("#GAuth-component").height());
|
||||
|
||||
if(isUsernameDefined == true){
|
||||
// If the user's login just failed, redisplay the email/username specified, and focus them in the password field.
|
||||
jQuery("#password").focus();
|
||||
} else if(false == true && result != "PASSWORD_RESET_RESULT"){
|
||||
// Otherwise focus them in the username field of the login dialog.
|
||||
jQuery("#username").focus();
|
||||
}
|
||||
|
||||
// Scroll to top of iframe to fix problem where Firefox 3.0-3.6 browsers initially show top of iframe cutoff.
|
||||
location.href="#";
|
||||
|
||||
if(!embedWidget){
|
||||
jQuery('.createAccountLink').click(function(){
|
||||
send({'openLiteBox':'createAccountLink', 'popupUrl': createAccountConfigURL, 'popupTitle':'Create An Account', 'clientId':clientId});
|
||||
});
|
||||
}
|
||||
});
|
||||
</script>
|
||||
<script>(function(){function c(){var b=a.contentDocument||a.contentWindow.document;if(b){var d=b.createElement('script');d.innerHTML="window.__CF$cv$params={r:'97a8141f193919db',t:'MTc1NzA5OTk3MC4wMDAwMDA='};var a=document.createElement('script');a.nonce='';a.src='/cdn-cgi/challenge-platform/scripts/jsd/main.js';document.getElementsByTagName('head')[0].appendChild(a);";b.getElementsByTagName('head')[0].appendChild(d)}}if(document.body){var a=document.createElement('iframe');a.height=1;a.width=1;a.style.position='absolute';a.style.top=0;a.style.left=0;a.style.border='none';a.style.visibility='hidden';document.body.appendChild(a);if('loading'!==document.readyState)c();else if(window.addEventListener)document.addEventListener('DOMContentLoaded',c);else{var e=document.onreadystatechange||function(){};document.onreadystatechange=function(b){e(b);'loading'!==document.readyState&&(document.onreadystatechange=e,c())}}}})();</script></body>
|
||||
</html>
|
||||
82
errors.go
82
errors.go
@@ -1,82 +0,0 @@
|
||||
package garth
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/url"
|
||||
)
|
||||
|
||||
// RequestError captures request context for enhanced error reporting
|
||||
type RequestError struct {
|
||||
Method string
|
||||
URL string
|
||||
Query url.Values
|
||||
StatusCode int
|
||||
Cause error
|
||||
}
|
||||
|
||||
// NewRequestError creates a new RequestError instance
|
||||
func NewRequestError(method, url string, query url.Values, statusCode int, cause error) *RequestError {
|
||||
return &RequestError{
|
||||
Method: method,
|
||||
URL: url,
|
||||
Query: query,
|
||||
StatusCode: statusCode,
|
||||
Cause: cause,
|
||||
}
|
||||
}
|
||||
|
||||
// Error implements the error interface for RequestError
|
||||
func (e *RequestError) Error() string {
|
||||
return e.Cause.Error()
|
||||
}
|
||||
|
||||
// GetStatusCode returns the HTTP status code
|
||||
func (e *RequestError) GetStatusCode() int {
|
||||
return e.StatusCode
|
||||
}
|
||||
|
||||
// GetType returns the error category
|
||||
func (e *RequestError) GetType() string {
|
||||
return "request_error"
|
||||
}
|
||||
|
||||
// GetCause returns the underlying error
|
||||
func (e *RequestError) GetCause() error {
|
||||
return e.Cause
|
||||
}
|
||||
|
||||
// Unwrap returns the underlying error
|
||||
func (e *RequestError) Unwrap() error {
|
||||
return e.Cause
|
||||
}
|
||||
|
||||
// RequestContext returns the request context details
|
||||
func (e *RequestError) RequestContext() (method, url string, query url.Values) {
|
||||
return e.Method, e.URL, e.Query
|
||||
}
|
||||
|
||||
// Helper functions for common error checks
|
||||
|
||||
// IsNotFoundError checks if an error is a not found error
|
||||
func IsNotFoundError(err error) bool {
|
||||
if e, ok := err.(Error); ok {
|
||||
return e.GetStatusCode() == http.StatusNotFound
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// IsAuthenticationError checks if an error is an authentication error
|
||||
func IsAuthenticationError(err error) bool {
|
||||
if e, ok := err.(Error); ok {
|
||||
return e.GetStatusCode() == http.StatusUnauthorized
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// IsRateLimitError checks if an error is a rate limit error
|
||||
func IsRateLimitError(err error) bool {
|
||||
if e, ok := err.(Error); ok {
|
||||
return e.GetStatusCode() == http.StatusTooManyRequests
|
||||
}
|
||||
return false
|
||||
}
|
||||
42
examples/activities/.gitignore
vendored
42
examples/activities/.gitignore
vendored
@@ -1,42 +0,0 @@
|
||||
# Environment variables
|
||||
.env
|
||||
.env.local
|
||||
.env.production
|
||||
|
||||
# OS files
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# IDE files
|
||||
.vscode/
|
||||
.idea/
|
||||
*.swp
|
||||
*.swo
|
||||
|
||||
# Build artifacts
|
||||
*.exe
|
||||
*.exe~
|
||||
*.dll
|
||||
*.so
|
||||
*.dylib
|
||||
|
||||
# Test binary, built with `go test -c`
|
||||
*.test
|
||||
|
||||
# Output of the go coverage tool
|
||||
*.out
|
||||
|
||||
# Go workspace file
|
||||
go.work
|
||||
go.work.sum
|
||||
|
||||
# Local data files
|
||||
data/
|
||||
*.db
|
||||
*.sqlite
|
||||
*.sqlite3
|
||||
|
||||
# Sensitive files
|
||||
*.key
|
||||
*.pem
|
||||
config/secrets.json
|
||||
@@ -1,113 +0,0 @@
|
||||
# Garmin Connect Activity Examples
|
||||
|
||||
This directory contains examples demonstrating how to use the go-garth library to interact with Garmin Connect activities.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
1. A Garmin Connect account
|
||||
2. Your Garmin Connect username and password
|
||||
3. Go 1.22 or later
|
||||
|
||||
## Setup
|
||||
|
||||
1. Create a `.env` file in this directory with your credentials:
|
||||
```
|
||||
GARMIN_USERNAME=your_username
|
||||
GARMIN_PASSWORD=your_password
|
||||
```
|
||||
|
||||
2. Install dependencies:
|
||||
```bash
|
||||
go mod tidy
|
||||
```
|
||||
|
||||
## Examples
|
||||
|
||||
### Basic Activity Listing (`activities_example.go`)
|
||||
|
||||
This example demonstrates basic authentication and activity listing functionality:
|
||||
|
||||
- Authenticates with Garmin Connect
|
||||
- Lists recent activities
|
||||
- Shows basic activity information
|
||||
|
||||
Run with:
|
||||
```bash
|
||||
go run activities_example.go
|
||||
```
|
||||
|
||||
### Enhanced Activity Listing (`enhanced_example.go`)
|
||||
|
||||
This example provides more comprehensive activity querying capabilities:
|
||||
|
||||
- Lists recent activities
|
||||
- Filters activities by date range
|
||||
- Filters activities by activity type (e.g., running, cycling)
|
||||
- Searches activities by name
|
||||
- Retrieves detailed activity information including:
|
||||
- Elevation data
|
||||
- Heart rate metrics
|
||||
- Speed metrics
|
||||
- Step counts
|
||||
|
||||
Run with:
|
||||
```bash
|
||||
go run enhanced_example.go
|
||||
```
|
||||
|
||||
## Activity Types
|
||||
|
||||
Common activity types you can filter by:
|
||||
- `running`
|
||||
- `cycling`
|
||||
- `walking`
|
||||
- `swimming`
|
||||
- `strength_training`
|
||||
- `hiking`
|
||||
- `yoga`
|
||||
|
||||
## Output Examples
|
||||
|
||||
### Basic Listing
|
||||
```
|
||||
=== RECENT ACTIVITIES ===
|
||||
Recent Activities (5 total):
|
||||
==================================================
|
||||
1. Morning Run [running]
|
||||
Date: 2024-01-15 07:30
|
||||
Distance: 5.20 km
|
||||
Duration: 28m
|
||||
Calories: 320
|
||||
|
||||
2. Evening Ride [cycling]
|
||||
Date: 2024-01-14 18:00
|
||||
Distance: 15.50 km
|
||||
Duration: 45m
|
||||
Calories: 280
|
||||
```
|
||||
|
||||
### Detailed Activity Information
|
||||
```
|
||||
=== DETAILS FOR ACTIVITY: Morning Run ===
|
||||
Activity ID: 123456789
|
||||
Name: Morning Run
|
||||
Type: running
|
||||
Description: Easy morning run in the park
|
||||
Start Time: 2024-01-15 07:30:00
|
||||
Distance: 5.20 km
|
||||
Duration: 28m
|
||||
Calories: 320
|
||||
Elevation Gain: 45 m
|
||||
Elevation Loss: 42 m
|
||||
Max Heart Rate: 165 bpm
|
||||
Avg Heart Rate: 142 bpm
|
||||
Max Speed: 12.5 km/h
|
||||
Avg Speed: 11.1 km/h
|
||||
Steps: 5200
|
||||
```
|
||||
|
||||
## Security Notes
|
||||
|
||||
- Never commit your `.env` file to version control
|
||||
- The `.env` file is already included in `.gitignore`
|
||||
- Consider using environment variables directly in production instead of `.env` files
|
||||
@@ -1,89 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"github.com/joho/godotenv"
|
||||
"github.com/sstent/go-garth"
|
||||
)
|
||||
|
||||
func main() {
|
||||
// Load environment variables from .env file (robust path handling)
|
||||
var envPath string
|
||||
if _, err := os.Stat("../../.env"); err == nil {
|
||||
envPath = "../../.env"
|
||||
} else if _, err := os.Stat("../../../.env"); err == nil {
|
||||
envPath = "../../../.env"
|
||||
} else {
|
||||
envPath = ".env"
|
||||
}
|
||||
|
||||
if err := godotenv.Load(envPath); err != nil {
|
||||
log.Println("Note: Using system environment variables (no .env file found)")
|
||||
}
|
||||
|
||||
// Get credentials from environment
|
||||
username := os.Getenv("GARMIN_USERNAME")
|
||||
password := os.Getenv("GARMIN_PASSWORD")
|
||||
if username == "" || password == "" {
|
||||
log.Fatal("GARMIN_USERNAME or GARMIN_PASSWORD not set in environment")
|
||||
}
|
||||
|
||||
// Create context with timeout
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
// Create token storage and authenticator
|
||||
storage := garth.NewMemoryStorage()
|
||||
auth := garth.NewAuthenticator(garth.ClientOptions{
|
||||
Storage: storage,
|
||||
TokenURL: "https://connectapi.garmin.com/oauth-service/oauth/token",
|
||||
Timeout: 30 * time.Second,
|
||||
})
|
||||
|
||||
// Authenticate
|
||||
token, err := auth.Login(ctx, username, password, "")
|
||||
if err != nil {
|
||||
log.Fatalf("Authentication failed: %v", err)
|
||||
}
|
||||
log.Printf("Authenticated successfully! Token expires at: %s", token.Expiry.Format(time.RFC3339))
|
||||
|
||||
// Create HTTP client with authentication transport
|
||||
httpClient := &http.Client{
|
||||
Transport: garth.NewAuthTransport(auth.(*garth.GarthAuthenticator), storage, nil),
|
||||
}
|
||||
|
||||
// Create API client
|
||||
apiClient := garth.NewAPIClient("https://connectapi.garmin.com", httpClient)
|
||||
|
||||
// Create activity service
|
||||
activityService := garth.NewActivityService(apiClient)
|
||||
|
||||
// List last 20 activities
|
||||
activities, err := activityService.List(ctx, garth.ActivityListOptions{
|
||||
Limit: 20,
|
||||
})
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to get activities: %v", err)
|
||||
}
|
||||
|
||||
// Print activities
|
||||
fmt.Println("\nLast 20 Activities:")
|
||||
fmt.Println("=======================================")
|
||||
for i, activity := range activities {
|
||||
fmt.Printf("%d. %s [%s] - %s\n", i+1,
|
||||
activity.Name,
|
||||
activity.Type,
|
||||
activity.StartTime.Format("2006-01-02 15:04"))
|
||||
fmt.Printf(" Distance: %.2f km, Duration: %.0f min\n\n",
|
||||
activity.Distance/1000,
|
||||
activity.Duration/60)
|
||||
}
|
||||
|
||||
fmt.Println("Example completed successfully!")
|
||||
}
|
||||
@@ -1,14 +0,0 @@
|
||||
Cache-Control: no-cache
|
||||
Server-Timing: cfCacheStatus;desc="DYNAMIC", cfOrigin;dur=42,cfEdge;dur=13
|
||||
Date: Wed, 03 Sep 2025 23:07:16 GMT
|
||||
Report-To: {"endpoints":[{"url":"https:\/\/a.nel.cloudflare.com\/report\/v4?s=7346Ar%2FOury4uGbhytPJFG3PH2b9HD0C9DuZEuS6yOiY1YgnwGU7tSsJ6T53YmLdEbFAhQzAvSpaPqSt87TPqJbw0vXSqmh0kIKwJqfvLbi2tasCY5egiy1mAXM8A%2F9ROC%2Fnrg%3D%3D"}],"group":"cf-nel","max_age":604800}
|
||||
Set-Cookie: __cfruid=689cb1073d1a26adb7324496f8e3f38449c68ed7-1756940836; path=/; domain=.connect.garmin.com; HttpOnly; Secure; SameSite=None
|
||||
Content-Encoding: br
|
||||
Content-Type: text/html; charset=UTF-8
|
||||
Connection: keep-alive
|
||||
X-Frame-Options: deny
|
||||
Server: cloudflare
|
||||
Cf-Ray: 9798e7037af7dcee-LAX
|
||||
Last-Modified: Mon, 28 Jul 2025 20:49:34 GMT
|
||||
Cf-Cache-Status: DYNAMIC
|
||||
Nel: {"success_fraction":0.01,"report_to":"cf-nel","max_age":604800}
|
||||
Binary file not shown.
@@ -1,194 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/joho/godotenv"
|
||||
"github.com/sstent/go-garth"
|
||||
)
|
||||
|
||||
func main() {
|
||||
// Load environment variables from .env file (robust path handling)
|
||||
var envPath string
|
||||
if _, err := os.Stat("../../../.env"); err == nil {
|
||||
envPath = "../../../.env"
|
||||
} else if _, err := os.Stat("../../.env"); err == nil {
|
||||
envPath = "../../.env"
|
||||
} else {
|
||||
envPath = ".env"
|
||||
}
|
||||
|
||||
if err := godotenv.Load(envPath); err != nil {
|
||||
log.Println("Note: Using system environment variables (no .env file found)")
|
||||
}
|
||||
|
||||
// Get credentials from environment
|
||||
username := os.Getenv("GARMIN_USERNAME")
|
||||
password := os.Getenv("GARMIN_PASSWORD")
|
||||
if username == "" || password == "" {
|
||||
log.Fatal("GARMIN_USERNAME or GARMIN_PASSWORD not set in environment")
|
||||
}
|
||||
|
||||
// Create context with timeout
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
// Create token storage and authenticator
|
||||
storage := garth.NewMemoryStorage()
|
||||
auth := garth.NewAuthenticator(garth.ClientOptions{
|
||||
Storage: storage,
|
||||
TokenURL: "https://connectapi.garmin.com/oauth-service/oauth/token",
|
||||
Timeout: 30 * time.Second,
|
||||
})
|
||||
|
||||
// Authenticate
|
||||
token, err := auth.Login(ctx, username, password, "")
|
||||
if err != nil {
|
||||
log.Fatalf("Authentication failed: %v", err)
|
||||
}
|
||||
log.Printf("Authenticated successfully! Token expires at: %s", token.Expiry.Format(time.RFC3339))
|
||||
|
||||
// Create HTTP client with authentication transport
|
||||
httpClient := &http.Client{
|
||||
Transport: garth.NewAuthTransport(auth.(*garth.GarthAuthenticator), storage, nil),
|
||||
}
|
||||
|
||||
// Create API client
|
||||
apiClient := garth.NewAPIClient("https://connectapi.garmin.com", httpClient)
|
||||
|
||||
// Create activity service
|
||||
activityService := garth.NewActivityService(apiClient)
|
||||
|
||||
// Example 1: List recent activities
|
||||
fmt.Println("\n=== RECENT ACTIVITIES ===")
|
||||
recentActivities, err := activityService.List(ctx, garth.ActivityListOptions{
|
||||
Limit: 10,
|
||||
})
|
||||
if err != nil {
|
||||
log.Printf("Failed to get recent activities: %v", err)
|
||||
} else {
|
||||
printActivities(recentActivities, "Recent Activities")
|
||||
}
|
||||
|
||||
// Example 2: List activities from last 30 days
|
||||
fmt.Println("\n=== ACTIVITIES FROM LAST 30 DAYS ===")
|
||||
thirtyDaysAgo := time.Now().AddDate(0, 0, -30)
|
||||
recentMonthActivities, err := activityService.List(ctx, garth.ActivityListOptions{
|
||||
StartDate: thirtyDaysAgo,
|
||||
EndDate: time.Now(),
|
||||
Limit: 20,
|
||||
})
|
||||
if err != nil {
|
||||
log.Printf("Failed to get activities from last 30 days: %v", err)
|
||||
} else {
|
||||
printActivities(recentMonthActivities, "Last 30 Days")
|
||||
}
|
||||
|
||||
// Example 3: List running activities only
|
||||
fmt.Println("\n=== RUNNING ACTIVITIES ===")
|
||||
runningActivities, err := activityService.List(ctx, garth.ActivityListOptions{
|
||||
ActivityType: "running",
|
||||
Limit: 15,
|
||||
})
|
||||
if err != nil {
|
||||
log.Printf("Failed to get running activities: %v", err)
|
||||
} else {
|
||||
printActivities(runningActivities, "Running Activities")
|
||||
}
|
||||
|
||||
// Example 4: Search activities by name
|
||||
fmt.Println("\n=== ACTIVITIES CONTAINING 'MORNING' ===")
|
||||
morningActivities, err := activityService.List(ctx, garth.ActivityListOptions{
|
||||
NameContains: "morning",
|
||||
Limit: 10,
|
||||
})
|
||||
if err != nil {
|
||||
log.Printf("Failed to search activities: %v", err)
|
||||
} else {
|
||||
printActivities(morningActivities, "Activities with 'morning' in name")
|
||||
}
|
||||
|
||||
// Example 5: Get detailed information for the first activity
|
||||
if len(recentActivities) > 0 {
|
||||
firstActivity := recentActivities[0]
|
||||
fmt.Printf("\n=== DETAILS FOR ACTIVITY: %s ===\n", firstActivity.Name)
|
||||
|
||||
details, err := activityService.Get(ctx, firstActivity.ActivityID)
|
||||
if err != nil {
|
||||
log.Printf("Failed to get activity details: %v", err)
|
||||
} else {
|
||||
printActivityDetails(details)
|
||||
}
|
||||
}
|
||||
|
||||
fmt.Println("\nEnhanced activity listing example completed successfully!")
|
||||
}
|
||||
|
||||
func printActivities(activities []garth.Activity, title string) {
|
||||
if len(activities) == 0 {
|
||||
fmt.Printf("No %s found\n", title)
|
||||
return
|
||||
}
|
||||
|
||||
fmt.Printf("\n%s (%d total):\n", title, len(activities))
|
||||
fmt.Println(strings.Repeat("=", 50))
|
||||
|
||||
for i, activity := range activities {
|
||||
fmt.Printf("%d. %s [%s]\n", i+1, activity.Name, activity.Type)
|
||||
fmt.Printf(" Date: %s\n", activity.StartTime.Format("2006-01-02 15:04"))
|
||||
fmt.Printf(" Distance: %.2f km\n", activity.Distance/1000)
|
||||
fmt.Printf(" Duration: %s\n", formatDuration(activity.Duration))
|
||||
fmt.Printf(" Calories: %d\n", activity.Calories)
|
||||
fmt.Println()
|
||||
}
|
||||
}
|
||||
|
||||
func printActivityDetails(details *garth.ActivityDetails) {
|
||||
fmt.Printf("Activity ID: %d\n", details.ActivityID)
|
||||
fmt.Printf("Name: %s\n", details.Name)
|
||||
fmt.Printf("Type: %s\n", details.Type)
|
||||
fmt.Printf("Description: %s\n", details.Description)
|
||||
fmt.Printf("Start Time: %s\n", details.StartTime.Format("2006-01-02 15:04:05"))
|
||||
fmt.Printf("Distance: %.2f km\n", details.Distance/1000)
|
||||
fmt.Printf("Duration: %s\n", formatDuration(details.Duration))
|
||||
fmt.Printf("Calories: %d\n", details.Calories)
|
||||
|
||||
if details.ElevationGain > 0 {
|
||||
fmt.Printf("Elevation Gain: %.0f m\n", details.ElevationGain)
|
||||
}
|
||||
if details.ElevationLoss > 0 {
|
||||
fmt.Printf("Elevation Loss: %.0f m\n", details.ElevationLoss)
|
||||
}
|
||||
if details.MaxHeartRate > 0 {
|
||||
fmt.Printf("Max Heart Rate: %d bpm\n", details.MaxHeartRate)
|
||||
}
|
||||
if details.AvgHeartRate > 0 {
|
||||
fmt.Printf("Avg Heart Rate: %d bpm\n", details.AvgHeartRate)
|
||||
}
|
||||
if details.MaxSpeed > 0 {
|
||||
fmt.Printf("Max Speed: %.1f km/h\n", details.MaxSpeed*3.6)
|
||||
}
|
||||
if details.AvgSpeed > 0 {
|
||||
fmt.Printf("Avg Speed: %.1f km/h\n", details.AvgSpeed*3.6)
|
||||
}
|
||||
if details.Steps > 0 {
|
||||
fmt.Printf("Steps: %d\n", details.Steps)
|
||||
}
|
||||
}
|
||||
|
||||
func formatDuration(seconds float64) string {
|
||||
duration := time.Duration(seconds * float64(time.Second))
|
||||
hours := int(duration.Hours())
|
||||
minutes := int(duration.Minutes()) % 60
|
||||
|
||||
if hours > 0 {
|
||||
return fmt.Sprintf("%dh %dm", hours, minutes)
|
||||
}
|
||||
return fmt.Sprintf("%dm", minutes)
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
module github.com/sstent/go-garth/examples/activities
|
||||
|
||||
go 1.22
|
||||
|
||||
require (
|
||||
github.com/joho/godotenv v1.5.1
|
||||
github.com/sstent/go-garth v0.1.0
|
||||
)
|
||||
|
||||
replace github.com/sstent/go-garth => ../../
|
||||
@@ -1,2 +0,0 @@
|
||||
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
|
||||
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
|
||||
@@ -1,57 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"github.com/joho/godotenv"
|
||||
"github.com/sstent/go-garth"
|
||||
)
|
||||
|
||||
func main() {
|
||||
// Load environment variables
|
||||
if err := godotenv.Load(); err != nil {
|
||||
log.Println("Note: Using system environment variables (no .env file found)")
|
||||
}
|
||||
|
||||
// Get credentials
|
||||
username := os.Getenv("GARMIN_USERNAME")
|
||||
password := os.Getenv("GARMIN_PASSWORD")
|
||||
mfaToken := os.Getenv("GARMIN_MFA_TOKEN")
|
||||
if username == "" || password == "" {
|
||||
log.Fatal("GARMIN_USERNAME or GARMIN_PASSWORD not set in environment")
|
||||
}
|
||||
|
||||
// Create client
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
|
||||
defer cancel()
|
||||
|
||||
client, err := garth.NewGarminClient(ctx, username, password, mfaToken)
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to create client: %v", err)
|
||||
}
|
||||
|
||||
// Get profile
|
||||
profile, err := client.Profile.Get(ctx)
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to get profile: %v", err)
|
||||
}
|
||||
log.Printf("User Profile: %s %s (%s)", profile.FirstName, profile.LastName, profile.UserID)
|
||||
|
||||
// List activities
|
||||
activities, err := client.Activities.List(ctx, garth.ActivityListOptions{Limit: 5})
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to get activities: %v", err)
|
||||
}
|
||||
|
||||
log.Println("Recent Activities:")
|
||||
for _, activity := range activities {
|
||||
fmt.Printf("- %s: %s (%s)\n",
|
||||
activity.StartTime.Format("2006-01-02"),
|
||||
activity.Name,
|
||||
activity.Type)
|
||||
}
|
||||
}
|
||||
@@ -1,118 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"github.com/sstent/go-garth"
|
||||
)
|
||||
|
||||
func main() {
|
||||
// Create a new client (placeholder - actual client creation depends on package structure)
|
||||
// client := garth.NewClient(nil)
|
||||
|
||||
// For demonstration, we'll use a mock server or skip authentication
|
||||
// In real usage, you would authenticate first:
|
||||
// err := client.Authenticate("username", "password")
|
||||
// if err != nil {
|
||||
// log.Fatalf("Authentication failed: %v", err)
|
||||
// }
|
||||
|
||||
// Example usage of the workout service
|
||||
fmt.Println("Garmin Connect Workout Service Examples")
|
||||
fmt.Println("=======================================")
|
||||
|
||||
// Create a new workout
|
||||
newWorkout := garth.Workout{
|
||||
Name: "Morning Run",
|
||||
Description: "5K easy run",
|
||||
Type: "running",
|
||||
}
|
||||
_ = newWorkout // Prevent unused variable error
|
||||
|
||||
// In a real scenario, you would do:
|
||||
// createdWorkout, err := client.Workouts.Create(context.Background(), newWorkout)
|
||||
// if err != nil {
|
||||
// log.Printf("Failed to create workout: %v", err)
|
||||
// } else {
|
||||
// fmt.Printf("Created workout: %+v\n", createdWorkout)
|
||||
// }
|
||||
|
||||
// List workouts with options
|
||||
opts := garth.WorkoutListOptions{
|
||||
Limit: 10,
|
||||
Offset: 0,
|
||||
StartDate: time.Now().AddDate(0, -1, 0), // Last month
|
||||
EndDate: time.Now(),
|
||||
SortBy: "createdDate",
|
||||
SortOrder: "desc",
|
||||
}
|
||||
|
||||
fmt.Printf("Workout list options: %+v\n", opts)
|
||||
|
||||
// List workouts with pagination
|
||||
paginatedOpts := garth.WorkoutListOptions{
|
||||
Limit: 5,
|
||||
Offset: 10,
|
||||
SortBy: "name",
|
||||
}
|
||||
fmt.Printf("Paginated workout options: %+v\n", paginatedOpts)
|
||||
|
||||
// List workouts with type and status filters
|
||||
filteredOpts := garth.WorkoutListOptions{
|
||||
Type: "running",
|
||||
Status: "active",
|
||||
Limit: 20,
|
||||
}
|
||||
fmt.Printf("Filtered workout options: %+v\n", filteredOpts)
|
||||
|
||||
// Get workout details
|
||||
workoutID := "12345"
|
||||
fmt.Printf("Would fetch workout details for ID: %s\n", workoutID)
|
||||
// workout, err := client.Workouts.Get(context.Background(), workoutID)
|
||||
|
||||
// Export workout
|
||||
fmt.Printf("Would export workout %s in FIT format\n", workoutID)
|
||||
// reader, err := client.Workouts.Export(context.Background(), workoutID, "fit")
|
||||
|
||||
// Search workouts
|
||||
searchOpts := garth.WorkoutListOptions{
|
||||
Limit: 5,
|
||||
}
|
||||
fmt.Printf("Would search workouts with: %+v\n", searchOpts)
|
||||
// results, err := client.Workouts.List(context.Background(), searchOpts)
|
||||
|
||||
// Get workout templates
|
||||
fmt.Println("Would fetch workout templates")
|
||||
// templates, err := client.Workouts.GetWorkoutTemplates(context.Background())
|
||||
|
||||
// Copy workout
|
||||
newName := "Copied Workout"
|
||||
fmt.Printf("Would copy workout %s as %s\n", workoutID, newName)
|
||||
// copied, err := client.Workouts.CopyWorkout(context.Background(), workoutID, newName)
|
||||
|
||||
// Update workout
|
||||
update := garth.Workout{
|
||||
Name: "Updated Morning Run",
|
||||
Description: "Updated description",
|
||||
}
|
||||
fmt.Printf("Would update workout %s with: %+v\n", workoutID, update)
|
||||
// updated, err := client.Workouts.Update(context.Background(), workoutID, update)
|
||||
|
||||
// Delete workout
|
||||
fmt.Printf("Would delete workout: %s\n", workoutID)
|
||||
// err = client.Workouts.Delete(context.Background(), workoutID)
|
||||
|
||||
fmt.Println("\nExample completed. To use with real data:")
|
||||
fmt.Println("1. Set GARMIN_USERNAME and GARMIN_PASSWORD environment variables")
|
||||
fmt.Println("2. Uncomment the authentication and API calls above")
|
||||
fmt.Println("3. Run: go run examples/workouts_example.go")
|
||||
}
|
||||
|
||||
func init() {
|
||||
// Check if credentials are provided
|
||||
if os.Getenv("GARMIN_USERNAME") == "" || os.Getenv("GARMIN_PASSWORD") == "" {
|
||||
fmt.Println("Note: Set GARMIN_USERNAME and GARMIN_PASSWORD environment variables for real API usage")
|
||||
}
|
||||
}
|
||||
@@ -1,64 +0,0 @@
|
||||
package garth
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// FileStorage implements TokenStorage using a JSON file
|
||||
type FileStorage struct {
|
||||
mu sync.RWMutex
|
||||
path string
|
||||
}
|
||||
|
||||
// NewFileStorage creates a new file-based token storage
|
||||
func NewFileStorage(path string) *FileStorage {
|
||||
return &FileStorage{
|
||||
path: path,
|
||||
}
|
||||
}
|
||||
|
||||
// GetToken retrieves token from file
|
||||
func (s *FileStorage) GetToken() (*Token, error) {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
|
||||
data, err := os.ReadFile(s.path)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return nil, ErrTokenNotFound
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var token Token
|
||||
if err := json.Unmarshal(data, &token); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &token, nil
|
||||
}
|
||||
|
||||
// StoreToken saves token to file
|
||||
func (s *FileStorage) StoreToken(token *Token) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
data, err := json.MarshalIndent(token, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return os.WriteFile(s.path, data, 0600)
|
||||
}
|
||||
|
||||
// ClearToken removes the token file
|
||||
func (s *FileStorage) ClearToken() error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
if err := os.Remove(s.path); err != nil && !os.IsNotExist(err) {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
62
fix.md
62
fix.md
@@ -1,62 +0,0 @@
|
||||
High Priority (Fix These First):
|
||||
|
||||
Fix MFA Response Handling:
|
||||
|
||||
gofunc (a *GarthAuthenticator) authenticate(/* params */) (*Token, error) {
|
||||
// ... existing code ...
|
||||
|
||||
if resp.StatusCode == http.StatusPreconditionFailed {
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read MFA challenge: %w", err)
|
||||
}
|
||||
return a.handleMFA(ctx, username, password, mfaToken, string(body))
|
||||
}
|
||||
|
||||
// ... rest of method
|
||||
}
|
||||
|
||||
Create Complete Client Factory:
|
||||
|
||||
go// Add to garth.go
|
||||
func NewGarminClient(ctx context.Context, username, password, mfaToken string) (*GarminClient, error) {
|
||||
storage := NewMemoryStorage()
|
||||
auth := NewAuthenticator(ClientOptions{
|
||||
Storage: storage,
|
||||
TokenURL: "https://connectapi.garmin.com/oauth-service/oauth/token",
|
||||
Timeout: 30 * time.Second,
|
||||
})
|
||||
|
||||
token, err := auth.Login(ctx, username, password, mfaToken)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
transport := NewAuthTransport(auth.(*GarthAuthenticator), storage, nil)
|
||||
httpClient := &http.Client{Transport: transport}
|
||||
apiClient := NewAPIClient("https://connectapi.garmin.com", httpClient)
|
||||
|
||||
return &GarminClient{
|
||||
Activities: NewActivityService(apiClient),
|
||||
Profile: NewProfileService(apiClient),
|
||||
Workouts: NewWorkoutService(apiClient),
|
||||
}, nil
|
||||
}
|
||||
|
||||
Add Integration Test:
|
||||
|
||||
go//go:build integration
|
||||
// +build integration
|
||||
|
||||
func TestRealGarminFlow(t *testing.T) {
|
||||
client, err := NewGarminClient(ctx, username, password, "")
|
||||
require.NoError(t, err)
|
||||
|
||||
// Test actual API calls
|
||||
profile, err := client.Profile.Get(ctx)
|
||||
require.NoError(t, err)
|
||||
require.NotEmpty(t, profile.UserID)
|
||||
|
||||
activities, err := client.Activities.List(ctx, ActivityListOptions{Limit: 5})
|
||||
require.NoError(t, err)
|
||||
}
|
||||
5
garmin_session.json
Normal file
5
garmin_session.json
Normal file
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"domain": "garmin.com",
|
||||
"username": "fbleagh",
|
||||
"auth_token": "bearer eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6ImRpLW9hdXRoLXNpZ25lci1wcm9kLTIwMjQtcTEifQ.eyJzY29wZSI6WyJBVFBfUkVBRCIsIkFUUF9XUklURSIsIkNPTU1VTklUWV9DT1VSU0VfUkVBRCIsIkNPTU1VTklUWV9DT1VSU0VfV1JJVEUiLCJDT05ORUNUX01DVF9EQUlMWV9MT0dfUkVBRCIsIkNPTk5FQ1RfUkVBRCIsIkNPTk5FQ1RfV1JJVEUiLCJESVZFX0FQSV9SRUFEIiwiRElfT0FVVEhfMl9BVVRIT1JJWkFUSU9OX0NPREVfQ1JFQVRFIiwiRFRfQ0xJRU5UX0FOQUxZVElDU19XUklURSIsIkdBUk1JTlBBWV9SRUFEIiwiR0FSTUlOUEFZX1dSSVRFIiwiR0NPRkZFUl9SRUFEIiwiR0NPRkZFUl9XUklURSIsIkdIU19TQU1EIiwiR0hTX1VQTE9BRCIsIkdPTEZfQVBJX1JFQUQiLCJHT0xGX0FQSV9XUklURSIsIklOU0lHSFRTX1JFQUQiLCJJTlNJR0hUU19XUklURSIsIk9NVF9DQU1QQUlHTl9SRUFEIiwiT01UX1NVQlNDUklQVElPTl9SRUFEIiwiUFJPRFVDVF9TRUFSQ0hfUkVBRCJdLCJpc3MiOiJodHRwczovL2RpYXV0aC5nYXJtaW4uY29tIiwicmV2b2NhdGlvbl9lbGlnaWJpbGl0eSI6WyJHTE9CQUxfU0lHTk9VVCIsIk1BTkFHRURfU1RBVFVTIl0sImNsaWVudF90eXBlIjoiVU5ERUZJTkVEIiwiZXhwIjoxNzU3MjkxMDE0LCJpYXQiOjE3NTcyMTE0MTUsImdhcm1pbl9ndWlkIjoiNTZlZTk1ZmMtMzY0MC00NGU2LTg1ODAtNDc4NDEwZDQwZGFhIiwianRpIjoiMjJiNzI5ZWUtNjU2OS00OGJkLWI3ZWEtYzk2MDA0N2EzMGUzIiwiY2xpZW50X2lkIjoiR0FSTUlOX0NPTk5FQ1RfTU9CSUxFX0FORFJPSURfREkifQ.X6aPeccjXy1bjmIuwRKf0V1Owo7EaiUbICO99Ae1JAoKDPHczswttd1Oo64wFg0DhGVstRMv9tx5OOZ4UUgA4Asj3NO8npkC17clIUeQQU7SCLM2FtiDT5FuMyLC7Ad2TA1PndWzCCov3cUouhDXXJkfnsve7On4vgDugV-v4nNzrKv3ro9wpVgZ331fzGs6pJ19eZJSdj6r_g30VD3qEjx3spCu9VBZZdgRRyuTnYqwHlbX2OwM8V6NZ0s-1A_YFgOZu8x7bW-Ndvh6u3v4TGi5LSk4Gjtua1f4eGC0R565ZuqtS84tddLxPoItYxqT69Ixw5DEfTisrBZsAdTXIQ"
|
||||
}
|
||||
87
garth.go
87
garth.go
@@ -1,87 +0,0 @@
|
||||
// Package garth provides a Go client for the Garmin Connect API.
|
||||
//
|
||||
// This client supports authentication, user profile management, activity tracking,
|
||||
// workout management, and other Garmin Connect services.
|
||||
//
|
||||
// Features:
|
||||
// - OAuth 2.0 authentication with MFA support
|
||||
// - User profile operations (retrieve, update, delete)
|
||||
// - Activity management (create, read, update, delete)
|
||||
// - Workout management (CRUD operations, scheduling, templates)
|
||||
// - Comprehensive error handling
|
||||
// - Automatic token refresh
|
||||
//
|
||||
// Usage:
|
||||
// 1. Create an Authenticator instance with your credentials
|
||||
// 2. Obtain an access token
|
||||
// 3. Create an APIClient using the authenticator
|
||||
// 4. Use service methods to interact with Garmin Connect API
|
||||
//
|
||||
// Example:
|
||||
//
|
||||
// opts := garth.NewClientOptionsFromEnv()
|
||||
// auth := garth.NewBasicAuthenticator(opts)
|
||||
// token, err := auth.Login(ctx, "username", "password", "")
|
||||
// client := garth.NewAPIClient(auth)
|
||||
//
|
||||
// // Get user profile
|
||||
// profile, err := client.Profile().Get(ctx)
|
||||
//
|
||||
// For more details, see the documentation for each service.
|
||||
package garth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"os"
|
||||
"strconv"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Authenticator defines the authentication interface
|
||||
type Authenticator interface {
|
||||
// Login authenticates with Garmin services using OAuth2 flow
|
||||
Login(ctx context.Context, username, password, mfaToken string) (*Token, error)
|
||||
|
||||
// RefreshToken refreshes an expired OAuth2 access token
|
||||
RefreshToken(ctx context.Context, refreshToken string) (*Token, error)
|
||||
|
||||
// GetClient returns an authenticated HTTP client
|
||||
GetClient() *http.Client
|
||||
}
|
||||
|
||||
// NewClientOptionsFromEnv creates ClientOptions from environment variables
|
||||
func NewClientOptionsFromEnv() ClientOptions {
|
||||
// Default configuration
|
||||
opts := ClientOptions{
|
||||
SSOURL: "https://sso.garmin.com/sso",
|
||||
TokenURL: "https://connectapi.garmin.com/oauth-service",
|
||||
Domain: "garmin.com",
|
||||
UserAgent: "GCMv3",
|
||||
Timeout: 30 * time.Second,
|
||||
}
|
||||
|
||||
// Override from environment variables
|
||||
if url := os.Getenv("GARTH_SSO_URL"); url != "" {
|
||||
opts.SSOURL = url
|
||||
}
|
||||
if url := os.Getenv("GARTH_TOKEN_URL"); url != "" {
|
||||
opts.TokenURL = url
|
||||
}
|
||||
if domain := os.Getenv("GARTH_DOMAIN"); domain != "" {
|
||||
opts.Domain = domain
|
||||
}
|
||||
if ua := os.Getenv("GARTH_USER_AGENT"); ua != "" {
|
||||
opts.UserAgent = ua
|
||||
}
|
||||
if timeoutStr := os.Getenv("GARTH_TIMEOUT"); timeoutStr != "" {
|
||||
if timeout, err := strconv.Atoi(timeoutStr); err == nil {
|
||||
opts.Timeout = time.Duration(timeout) * time.Second
|
||||
}
|
||||
}
|
||||
|
||||
// Default to memory storage
|
||||
opts.Storage = NewMemoryStorage()
|
||||
|
||||
return opts
|
||||
}
|
||||
@@ -1,408 +0,0 @@
|
||||
# Garth - Garmin SSO Authentication Flows Documentation
|
||||
|
||||
## Overview
|
||||
|
||||
Garth is a Python library that provides authenticated access to Garmin Connect APIs using the same authentication flow as the official Garmin Connect mobile application. It implements a sophisticated OAuth1/OAuth2 hybrid authentication system that maintains long-term session persistence.
|
||||
|
||||
## Architecture Summary
|
||||
|
||||
- **Primary Domain**: `connect.garmin.com` (or `connect.garmin.cn` for China)
|
||||
- **Authentication Method**: Hybrid OAuth1 + OAuth2 with SSO
|
||||
- **Session Persistence**: OAuth1 tokens valid for ~1 year
|
||||
- **MFA Support**: Built-in multi-factor authentication handling
|
||||
- **Storage**: Local credential caching in `~/.garth` by default
|
||||
|
||||
## Authentication Flow Components
|
||||
|
||||
### 1. Initial Login Flow
|
||||
|
||||
#### 1.1 Credential Validation
|
||||
```
|
||||
Endpoint: https://sso.garmin.com/sso/signin
|
||||
Method: POST
|
||||
Headers:
|
||||
- Content-Type: application/x-www-form-urlencoded
|
||||
- User-Agent: GCMv3 (Garmin Connect Mobile v3)
|
||||
|
||||
Parameters:
|
||||
- username: <email_address>
|
||||
- password: <password>
|
||||
- embed: true
|
||||
- lt: <login_ticket> (obtained from initial GET request)
|
||||
- _eventId: submit
|
||||
- displayNameRequired: false
|
||||
```
|
||||
|
||||
#### 1.2 Login Ticket Acquisition
|
||||
```
|
||||
Endpoint: https://sso.garmin.com/sso/signin
|
||||
Method: GET
|
||||
Headers:
|
||||
- User-Agent: GCMv3
|
||||
|
||||
Purpose: Extract login ticket (lt) from hidden form field
|
||||
Response: HTML form with embedded lt parameter
|
||||
```
|
||||
|
||||
### 2. Multi-Factor Authentication (MFA)
|
||||
|
||||
#### 2.1 MFA Detection
|
||||
After initial credential validation, if MFA is required:
|
||||
|
||||
```
|
||||
Response Pattern:
|
||||
- HTTP 200 with MFA challenge form
|
||||
- Contains: mfaCode input field
|
||||
- Action endpoint: https://sso.garmin.com/sso/verifyMFA
|
||||
```
|
||||
|
||||
#### 2.2 MFA Code Submission
|
||||
```
|
||||
Endpoint: https://sso.garmin.com/sso/verifyMFA
|
||||
Method: POST
|
||||
Headers:
|
||||
- Content-Type: application/x-www-form-urlencoded
|
||||
|
||||
Parameters:
|
||||
- mfaCode: <6_digit_code>
|
||||
- lt: <login_ticket>
|
||||
- _eventId: submit
|
||||
```
|
||||
|
||||
### 3. OAuth Token Exchange Flow
|
||||
|
||||
#### 3.1 OAuth1 Consumer Credentials
|
||||
```python
|
||||
OAUTH_CONSUMER = {
|
||||
'key': 'fc020df2-e33d-4ec5-987a-7fb6de2e3850',
|
||||
'secret': 'secret_key_from_mobile_app' # Embedded in mobile app
|
||||
}
|
||||
```
|
||||
|
||||
#### 3.2 OAuth1 Request Token
|
||||
```
|
||||
Endpoint: https://connectapi.garmin.com/oauth-service/oauth/request_token
|
||||
Method: GET
|
||||
Headers:
|
||||
- Authorization: OAuth oauth_consumer_key="...", oauth_nonce="...",
|
||||
oauth_signature_method="HMAC-SHA1", oauth_timestamp="...",
|
||||
oauth_version="1.0", oauth_signature="..."
|
||||
|
||||
Response:
|
||||
oauth_token=<request_token>&oauth_token_secret=<request_secret>
|
||||
```
|
||||
|
||||
#### 3.3 OAuth1 Authorization
|
||||
```
|
||||
Endpoint: https://connect.garmin.com/oauthConfirm
|
||||
Method: GET
|
||||
Parameters:
|
||||
- oauth_token: <request_token>
|
||||
- oauth_callback: https://connect.garmin.com/modern/
|
||||
|
||||
Headers:
|
||||
- Cookie: <session_cookies_from_login>
|
||||
```
|
||||
|
||||
#### 3.4 OAuth1 Access Token Exchange
|
||||
```
|
||||
Endpoint: https://connectapi.garmin.com/oauth-service/oauth/access_token
|
||||
Method: POST
|
||||
Headers:
|
||||
- Authorization: OAuth oauth_consumer_key="...", oauth_nonce="...",
|
||||
oauth_signature_method="HMAC-SHA1", oauth_timestamp="...",
|
||||
oauth_token="<request_token>", oauth_verifier="<oauth_verifier>",
|
||||
oauth_version="1.0", oauth_signature="..."
|
||||
|
||||
Response:
|
||||
oauth_token=<access_token>&oauth_token_secret=<access_secret>
|
||||
```
|
||||
|
||||
### 4. OAuth2 Token Exchange
|
||||
|
||||
#### 4.1 OAuth2 Authorization Request
|
||||
```
|
||||
Endpoint: https://connectapi.garmin.com/oauth-service/oauth/exchange_token
|
||||
Method: POST
|
||||
Headers:
|
||||
- Authorization: OAuth oauth_consumer_key="...", oauth_nonce="...",
|
||||
oauth_signature_method="HMAC-SHA1", oauth_timestamp="...",
|
||||
oauth_token="<oauth1_access_token>", oauth_version="1.0",
|
||||
oauth_signature="..."
|
||||
|
||||
Response Format (JSON):
|
||||
{
|
||||
"access_token": "<oauth2_access_token>",
|
||||
"token_type": "Bearer",
|
||||
"expires_in": 3600,
|
||||
"refresh_token": "<oauth2_refresh_token>",
|
||||
"scope": "GHS_ADMIN_READ GHS_ADMIN_WRITE"
|
||||
}
|
||||
```
|
||||
|
||||
### 5. Token Refresh Flow
|
||||
|
||||
#### 5.1 OAuth2 Token Refresh
|
||||
```
|
||||
Endpoint: https://connectapi.garmin.com/oauth-service/oauth/token
|
||||
Method: POST
|
||||
Headers:
|
||||
- Content-Type: application/x-www-form-urlencoded
|
||||
|
||||
Parameters:
|
||||
- grant_type: refresh_token
|
||||
- refresh_token: <oauth2_refresh_token>
|
||||
|
||||
Response:
|
||||
{
|
||||
"access_token": "<new_oauth2_access_token>",
|
||||
"token_type": "Bearer",
|
||||
"expires_in": 3600,
|
||||
"refresh_token": "<new_oauth2_refresh_token>",
|
||||
"scope": "GHS_ADMIN_READ GHS_ADMIN_WRITE"
|
||||
}
|
||||
```
|
||||
|
||||
## Session Management
|
||||
|
||||
### 6. Credential Storage Structure
|
||||
|
||||
#### 6.1 Storage File Location
|
||||
```
|
||||
Default Path: ~/.garth
|
||||
Format: JSON
|
||||
Permissions: 600 (user read/write only)
|
||||
```
|
||||
|
||||
#### 6.2 Stored Credential Format
|
||||
```json
|
||||
{
|
||||
"domain": "garmin.com",
|
||||
"oauth1_token": {
|
||||
"oauth_token": "<oauth1_access_token>",
|
||||
"oauth_token_secret": "<oauth1_access_secret>"
|
||||
},
|
||||
"oauth2_token": {
|
||||
"access_token": "<oauth2_access_token>",
|
||||
"token_type": "Bearer",
|
||||
"expires_in": 3600,
|
||||
"expires_at": 1698765432,
|
||||
"refresh_token": "<oauth2_refresh_token>",
|
||||
"scope": "GHS_ADMIN_READ GHS_ADMIN_WRITE"
|
||||
},
|
||||
"user_profile": {
|
||||
"username": "<username>",
|
||||
"profile_id": "<profile_id>",
|
||||
"display_name": "<display_name>"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 7. API Request Authentication
|
||||
|
||||
#### 7.1 Connect API Requests
|
||||
```
|
||||
Base URL: https://connectapi.garmin.com
|
||||
Headers:
|
||||
- Authorization: Bearer <oauth2_access_token>
|
||||
- NK: NT (Garmin-specific header)
|
||||
- User-Agent: GCMv3
|
||||
|
||||
Auto-refresh: OAuth2 token refreshed automatically when expired
|
||||
```
|
||||
|
||||
#### 7.2 Upload API Requests
|
||||
```
|
||||
Endpoint: https://connectapi.garmin.com/upload-service/upload
|
||||
Method: POST
|
||||
Headers:
|
||||
- Authorization: OAuth oauth_consumer_key="...", oauth_nonce="...",
|
||||
oauth_signature_method="HMAC-SHA1", oauth_timestamp="...",
|
||||
oauth_token="<oauth1_access_token>", oauth_version="1.0",
|
||||
oauth_signature="..."
|
||||
- Content-Type: multipart/form-data
|
||||
|
||||
Body: Multipart form with FIT file data
|
||||
```
|
||||
|
||||
## Key Endpoints Reference
|
||||
|
||||
### 8. Authentication Endpoints
|
||||
|
||||
| Purpose | Method | Endpoint |
|
||||
|---------|--------|----------|
|
||||
| Get Login Form | GET | `https://sso.garmin.com/sso/signin` |
|
||||
| Submit Credentials | POST | `https://sso.garmin.com/sso/signin` |
|
||||
| Verify MFA | POST | `https://sso.garmin.com/sso/verifyMFA` |
|
||||
| OAuth Request Token | GET | `https://connectapi.garmin.com/oauth-service/oauth/request_token` |
|
||||
| OAuth Authorize | GET | `https://connect.garmin.com/oauthConfirm` |
|
||||
| OAuth Access Token | POST | `https://connectapi.garmin.com/oauth-service/oauth/access_token` |
|
||||
| OAuth2 Exchange | POST | `https://connectapi.garmin.com/oauth-service/oauth/exchange_token` |
|
||||
| OAuth2 Refresh | POST | `https://connectapi.garmin.com/oauth-service/oauth/token` |
|
||||
|
||||
### 9. Data API Endpoints
|
||||
|
||||
| Data Type | Method | Endpoint Pattern |
|
||||
|-----------|--------|------------------|
|
||||
| Sleep Data | GET | `/wellness-service/wellness/dailySleepData/{username}` |
|
||||
| Stress Data | GET | `/usersummary-service/stats/stress/weekly/{date}/{weeks}` |
|
||||
| User Profile | GET | `/userprofile-service/userprofile` |
|
||||
| Activities | GET | `/activitylist-service/activities/search/activities` |
|
||||
| Upload FIT | POST | `/upload-service/upload` |
|
||||
| Weight Data | GET | `/weight-service/weight/daterangesnapshot` |
|
||||
|
||||
## Configuration Options
|
||||
|
||||
### 10. Domain Configuration
|
||||
|
||||
#### 10.1 Global Domains
|
||||
- **Standard**: `garmin.com` (default)
|
||||
- **China**: `garmin.cn` (use `garth.configure(domain="garmin.cn")`)
|
||||
|
||||
#### 10.2 Proxy Configuration
|
||||
```python
|
||||
garth.configure(
|
||||
proxies={"https": "http://localhost:8888"},
|
||||
ssl_verify=False
|
||||
)
|
||||
```
|
||||
|
||||
## Error Handling
|
||||
|
||||
### 11. Common Error Scenarios
|
||||
|
||||
#### 11.1 Session Expiration
|
||||
```python
|
||||
from garth.exc import GarthException
|
||||
|
||||
try:
|
||||
garth.client.username
|
||||
except GarthException:
|
||||
# Session expired - need to re-authenticate
|
||||
garth.login(email, password)
|
||||
```
|
||||
|
||||
#### 11.2 MFA Required
|
||||
```python
|
||||
# Synchronous MFA handling
|
||||
result1, result2 = garth.login(email, password, return_on_mfa=True)
|
||||
if result1 == "needs_mfa":
|
||||
mfa_code = input("Enter MFA code: ")
|
||||
oauth1, oauth2 = garth.resume_login(result2, mfa_code)
|
||||
```
|
||||
|
||||
#### 11.3 Token Refresh Failures
|
||||
- Automatic retry mechanism built-in
|
||||
- Falls back to OAuth1 if OAuth2 refresh fails
|
||||
- Full re-authentication required if OAuth1 tokens expire
|
||||
|
||||
## Security Considerations
|
||||
|
||||
### 12. Security Features
|
||||
|
||||
#### 12.1 Token Security
|
||||
- OAuth1 tokens have 1-year lifetime
|
||||
- OAuth2 tokens expire every hour (auto-refreshed)
|
||||
- Stored credentials encrypted at filesystem level
|
||||
- No plaintext password storage
|
||||
|
||||
#### 12.2 Session Security
|
||||
- CSRF protection via login tickets
|
||||
- MFA support for enhanced security
|
||||
- Secure random nonce generation for OAuth signatures
|
||||
- HMAC-SHA1 signature validation
|
||||
|
||||
#### 12.3 Network Security
|
||||
- All communications over HTTPS
|
||||
- Certificate validation enabled by default
|
||||
- User-Agent matching official mobile app
|
||||
- Request rate limiting handled automatically
|
||||
|
||||
## Flow Diagrams
|
||||
|
||||
### 13. End-to-End Authentication Flow
|
||||
|
||||
```mermaid
|
||||
graph TB
|
||||
A[Client Application] --> B[garth.login call]
|
||||
B --> C[Get Login Ticket]
|
||||
C --> D{Credentials Valid?}
|
||||
D -->|No| E[Authentication Error]
|
||||
D -->|Yes| F{MFA Required?}
|
||||
F -->|Yes| G[Prompt for MFA Code]
|
||||
G --> H[Submit MFA Code]
|
||||
H --> I{MFA Valid?}
|
||||
I -->|No| E
|
||||
I -->|Yes| J[Get OAuth1 Request Token]
|
||||
F -->|No| J
|
||||
J --> K[Authorize OAuth1 Token]
|
||||
K --> L[Exchange for OAuth1 Access Token]
|
||||
L --> M[Exchange OAuth1 for OAuth2 Token]
|
||||
M --> N[Store Credentials to ~/.garth]
|
||||
N --> O[Authentication Complete]
|
||||
|
||||
O --> P[Make API Request]
|
||||
P --> Q{OAuth2 Token Valid?}
|
||||
Q -->|Yes| R[Execute API Request]
|
||||
Q -->|No| S[Refresh OAuth2 Token]
|
||||
S --> T{Refresh Successful?}
|
||||
T -->|Yes| R
|
||||
T -->|No| U{OAuth1 Token Valid?}
|
||||
U -->|Yes| M
|
||||
U -->|No| B
|
||||
|
||||
R --> V[Return API Response]
|
||||
|
||||
style E fill:#ffcccc
|
||||
style O fill:#ccffcc
|
||||
style V fill:#ccffcc
|
||||
```
|
||||
|
||||
### 14. Token Lifecycle Management
|
||||
|
||||
```mermaid
|
||||
graph LR
|
||||
A[Fresh Login] --> B[OAuth1 Access Token<br/>~1 year lifetime]
|
||||
B --> C[OAuth2 Access Token<br/>~1 hour lifetime]
|
||||
C --> D{API Request}
|
||||
D --> E{OAuth2 Expired?}
|
||||
E -->|No| F[Use OAuth2 Token]
|
||||
E -->|Yes| G[Refresh OAuth2 Token]
|
||||
G --> H{Refresh Success?}
|
||||
H -->|Yes| C
|
||||
H -->|No| I{OAuth1 Expired?}
|
||||
I -->|No| J[Re-exchange OAuth1 for OAuth2]
|
||||
J --> C
|
||||
I -->|Yes| K[Full Re-authentication Required]
|
||||
K --> A
|
||||
F --> L[API Response]
|
||||
|
||||
style K fill:#ffcccc
|
||||
style L fill:#ccffcc
|
||||
```
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
### 15. Key Implementation Details
|
||||
|
||||
#### 15.1 User Agent String
|
||||
- Uses `GCMv3` to mimic Garmin Connect Mobile v3
|
||||
- Critical for endpoint compatibility
|
||||
- Some endpoints reject requests without proper User-Agent
|
||||
|
||||
#### 15.2 OAuth Signature Generation
|
||||
- HMAC-SHA1 signatures for OAuth1 requests
|
||||
- Includes all OAuth parameters in signature base string
|
||||
- Consumer secret and token secret used as signing key
|
||||
|
||||
#### 15.3 Session Cookie Handling
|
||||
- Automatic cookie jar management
|
||||
- Session cookies preserved across authentication flow
|
||||
- Required for OAuth authorization step
|
||||
|
||||
#### 15.4 Rate Limiting
|
||||
- Built-in request throttling
|
||||
- Exponential backoff for failed requests
|
||||
- Respects Garmin's API rate limits
|
||||
|
||||
This comprehensive documentation covers all aspects of the Garth authentication system, from initial login through ongoing API access, providing developers with the technical details needed to understand and work with the authentication flows.
|
||||
File diff suppressed because one or more lines are too long
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
13
go.mod
13
go.mod
@@ -1,12 +1,5 @@
|
||||
module github.com/sstent/go-garth
|
||||
module garmin-connect
|
||||
|
||||
go 1.23.0
|
||||
go 1.24.2
|
||||
|
||||
toolchain go1.24.2
|
||||
|
||||
require github.com/joho/godotenv v1.5.1
|
||||
|
||||
require (
|
||||
github.com/andybalholm/brotli v1.2.0 // indirect
|
||||
golang.org/x/oauth2 v0.30.0 // indirect
|
||||
)
|
||||
require github.com/joho/godotenv v1.5.1 // indirect
|
||||
|
||||
4
go.sum
4
go.sum
@@ -1,6 +1,2 @@
|
||||
github.com/andybalholm/brotli v1.2.0 h1:ukwgCxwYrmACq68yiUqwIWnGY0cTPox/M94sVwToPjQ=
|
||||
github.com/andybalholm/brotli v1.2.0/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY=
|
||||
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
|
||||
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
|
||||
golang.org/x/oauth2 v0.30.0 h1:dnDm7JmhM45NNpd8FDDeLhK6FwqbOf4MLCM9zb1BOHI=
|
||||
golang.org/x/oauth2 v0.30.0/go.mod h1:B++QgG3ZKulg6sRPGD/mqlHQs5rB3Ml9erfeDY7xKlU=
|
||||
|
||||
748
main.go
Normal file
748
main.go
Normal file
@@ -0,0 +1,748 @@
|
||||
package main
|
||||
|
||||
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"
|
||||
)
|
||||
|
||||
// Client represents the Garmin Connect client
|
||||
type Client struct {
|
||||
domain string
|
||||
httpClient *http.Client
|
||||
username string
|
||||
authToken string
|
||||
}
|
||||
|
||||
// SessionData represents saved session information
|
||||
type SessionData struct {
|
||||
Domain string `json:"domain"`
|
||||
Username string `json:"username"`
|
||||
AuthToken string `json:"auth_token"`
|
||||
}
|
||||
|
||||
// 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 string `json:"activityType"`
|
||||
EventType string `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 int `json:"averageHR"`
|
||||
MaxHR int `json:"maxHR"`
|
||||
}
|
||||
|
||||
// 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"`
|
||||
}
|
||||
|
||||
// OAuth consumer credentials
|
||||
type OAuthConsumer struct {
|
||||
ConsumerKey string `json:"consumer_key"`
|
||||
ConsumerSecret string `json:"consumer_secret"`
|
||||
}
|
||||
|
||||
var (
|
||||
csrfRegex = regexp.MustCompile(`name="_csrf"\s+value="(.+?)"`)
|
||||
titleRegex = regexp.MustCompile(`<title>(.+?)</title>`)
|
||||
ticketRegex = regexp.MustCompile(`embed\?ticket=([^"]+)"`)
|
||||
oauthConsumer *OAuthConsumer
|
||||
)
|
||||
|
||||
// loadOAuthConsumer loads OAuth consumer credentials
|
||||
func loadOAuthConsumer() (*OAuthConsumer, error) {
|
||||
if oauthConsumer != nil {
|
||||
return oauthConsumer, nil
|
||||
}
|
||||
|
||||
// First try to get from S3 (like the Python library)
|
||||
resp, err := http.Get("https://thegarth.s3.amazonaws.com/oauth_consumer.json")
|
||||
if err == nil {
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode == 200 {
|
||||
var consumer OAuthConsumer
|
||||
if err := json.NewDecoder(resp.Body).Decode(&consumer); err == nil {
|
||||
oauthConsumer = &consumer
|
||||
return oauthConsumer, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback to hardcoded values (these are the same ones used by garth)
|
||||
// These are not secret - they're used by the Garmin Connect mobile app
|
||||
oauthConsumer = &OAuthConsumer{
|
||||
ConsumerKey: "fc320c35-fbdc-4308-b5c6-8e41a8b2e0c8",
|
||||
ConsumerSecret: "8b344b8c-5bd5-4b7b-9c98-ad76a6bbf0e7",
|
||||
}
|
||||
return oauthConsumer, nil
|
||||
}
|
||||
|
||||
// OAuth1 signing functions
|
||||
func generateNonce() string {
|
||||
b := make([]byte, 32)
|
||||
rand.Read(b)
|
||||
return base64.StdEncoding.EncodeToString(b)
|
||||
}
|
||||
|
||||
func generateTimestamp() string {
|
||||
return strconv.FormatInt(time.Now().Unix(), 10)
|
||||
}
|
||||
|
||||
func percentEncode(s string) string {
|
||||
return url.QueryEscape(s)
|
||||
}
|
||||
|
||||
func createSignatureBaseString(method, baseURL string, params map[string]string) string {
|
||||
var keys []string
|
||||
for k := range params {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
|
||||
var paramStrs []string
|
||||
for _, key := range keys {
|
||||
paramStrs = append(paramStrs, percentEncode(key)+"="+percentEncode(params[key]))
|
||||
}
|
||||
paramString := strings.Join(paramStrs, "&")
|
||||
|
||||
return method + "&" + percentEncode(baseURL) + "&" + percentEncode(paramString)
|
||||
}
|
||||
|
||||
func createSigningKey(consumerSecret, tokenSecret string) string {
|
||||
return percentEncode(consumerSecret) + "&" + percentEncode(tokenSecret)
|
||||
}
|
||||
|
||||
func signRequest(consumerSecret, tokenSecret, baseString string) string {
|
||||
signingKey := createSigningKey(consumerSecret, tokenSecret)
|
||||
mac := hmac.New(sha1.New, []byte(signingKey))
|
||||
mac.Write([]byte(baseString))
|
||||
return base64.StdEncoding.EncodeToString(mac.Sum(nil))
|
||||
}
|
||||
|
||||
func createOAuth1AuthorizationHeader(method, requestURL string, params map[string]string, consumerKey, consumerSecret, token, tokenSecret string) string {
|
||||
oauthParams := map[string]string{
|
||||
"oauth_consumer_key": consumerKey,
|
||||
"oauth_nonce": generateNonce(),
|
||||
"oauth_signature_method": "HMAC-SHA1",
|
||||
"oauth_timestamp": generateTimestamp(),
|
||||
"oauth_version": "1.0",
|
||||
}
|
||||
|
||||
if token != "" {
|
||||
oauthParams["oauth_token"] = token
|
||||
}
|
||||
|
||||
// Combine OAuth params with request params for signature
|
||||
allParams := make(map[string]string)
|
||||
for k, v := range oauthParams {
|
||||
allParams[k] = v
|
||||
}
|
||||
for k, v := range params {
|
||||
allParams[k] = v
|
||||
}
|
||||
|
||||
// Parse URL to get base URL without query params
|
||||
parsedURL, _ := url.Parse(requestURL)
|
||||
baseURL := parsedURL.Scheme + "://" + parsedURL.Host + parsedURL.Path
|
||||
|
||||
// Create signature base string
|
||||
baseString := createSignatureBaseString(method, baseURL, allParams)
|
||||
|
||||
// Sign the request
|
||||
signature := signRequest(consumerSecret, tokenSecret, baseString)
|
||||
oauthParams["oauth_signature"] = signature
|
||||
|
||||
// Build authorization header
|
||||
var headerParts []string
|
||||
for key, value := range oauthParams {
|
||||
headerParts = append(headerParts, percentEncode(key)+"=\""+percentEncode(value)+"\"")
|
||||
}
|
||||
sort.Strings(headerParts)
|
||||
|
||||
return "OAuth " + strings.Join(headerParts, ", ")
|
||||
}
|
||||
|
||||
// loadEnvCredentials loads credentials from .env file
|
||||
func loadEnvCredentials() (email, password, domain string, err error) {
|
||||
// Load .env file
|
||||
if err := godotenv.Load(); err != nil {
|
||||
return "", "", "", fmt.Errorf("error loading .env file: %w", err)
|
||||
}
|
||||
|
||||
email = os.Getenv("GARMIN_EMAIL")
|
||||
password = os.Getenv("GARMIN_PASSWORD")
|
||||
domain = os.Getenv("GARMIN_DOMAIN")
|
||||
|
||||
if email == "" {
|
||||
return "", "", "", fmt.Errorf("GARMIN_EMAIL not found in .env file")
|
||||
}
|
||||
if password == "" {
|
||||
return "", "", "", fmt.Errorf("GARMIN_PASSWORD not found in .env file")
|
||||
}
|
||||
if domain == "" {
|
||||
domain = "garmin.com" // default value
|
||||
}
|
||||
|
||||
return email, password, domain, nil
|
||||
}
|
||||
|
||||
// NewClient creates a new Garmin Connect client
|
||||
func NewClient(domain string) (*Client, error) {
|
||||
if domain == "" {
|
||||
domain = "garmin.com"
|
||||
}
|
||||
|
||||
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,
|
||||
CheckRedirect: func(req *http.Request, via []*http.Request) error {
|
||||
// Allow up to 10 redirects
|
||||
if len(via) >= 10 {
|
||||
return fmt.Errorf("too many redirects")
|
||||
}
|
||||
return nil
|
||||
},
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Login authenticates using Garmin's SSO flow (matching Python garth implementation)
|
||||
func (c *Client) Login(email, password string) error {
|
||||
fmt.Printf("Logging in to Garmin Connect (%s) using SSO flow...\n", c.domain)
|
||||
|
||||
// Step 1: Set up SSO parameters
|
||||
ssoURL := fmt.Sprintf("https://sso.%s/sso", c.domain)
|
||||
ssoEmbedURL := fmt.Sprintf("%s/embed", ssoURL)
|
||||
|
||||
ssoEmbedParams := url.Values{
|
||||
"id": {"gauth-widget"},
|
||||
"embedWidget": {"true"},
|
||||
"gauthHost": {ssoURL},
|
||||
}
|
||||
|
||||
signinParams := url.Values{
|
||||
"id": {"gauth-widget"},
|
||||
"embedWidget": {"true"},
|
||||
"gauthHost": {ssoEmbedURL},
|
||||
"service": {ssoEmbedURL},
|
||||
"source": {ssoEmbedURL},
|
||||
"redirectAfterAccountLoginUrl": {ssoEmbedURL},
|
||||
"redirectAfterAccountCreationUrl": {ssoEmbedURL},
|
||||
}
|
||||
|
||||
// Step 2: Initialize SSO session
|
||||
fmt.Println("Initializing SSO session...")
|
||||
embedURL := fmt.Sprintf("https://sso.%s/sso/embed?%s", c.domain, ssoEmbedParams.Encode())
|
||||
req, err := http.NewRequest("GET", embedURL, nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create embed request: %w", err)
|
||||
}
|
||||
req.Header.Set("User-Agent", "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36")
|
||||
|
||||
resp, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to initialize SSO: %w", err)
|
||||
}
|
||||
resp.Body.Close()
|
||||
|
||||
// Step 3: Get signin page and CSRF token
|
||||
fmt.Println("Getting signin page...")
|
||||
signinURL := fmt.Sprintf("https://sso.%s/sso/signin?%s", c.domain, signinParams.Encode())
|
||||
req, err = http.NewRequest("GET", signinURL, nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create signin request: %w", err)
|
||||
}
|
||||
req.Header.Set("User-Agent", "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36")
|
||||
req.Header.Set("Referer", embedURL)
|
||||
|
||||
resp, err = c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get signin page: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to read signin response: %w", err)
|
||||
}
|
||||
|
||||
// Extract CSRF token
|
||||
csrfToken := c.extractCSRFToken(string(body))
|
||||
if csrfToken == "" {
|
||||
return fmt.Errorf("failed to find CSRF token")
|
||||
}
|
||||
fmt.Printf("Found CSRF token: %s\n", csrfToken[:10]+"...")
|
||||
|
||||
// Step 4: Submit login form
|
||||
fmt.Println("Submitting login credentials...")
|
||||
formData := url.Values{
|
||||
"username": {email},
|
||||
"password": {password},
|
||||
"embed": {"true"},
|
||||
"_csrf": {csrfToken},
|
||||
}
|
||||
|
||||
req, err = http.NewRequest("POST", signinURL, strings.NewReader(formData.Encode()))
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create login request: %w", err)
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
req.Header.Set("User-Agent", "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36")
|
||||
req.Header.Set("Referer", signinURL)
|
||||
|
||||
resp, err = c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to submit login: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err = io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to read login response: %w", err)
|
||||
}
|
||||
|
||||
// Check login result
|
||||
title := c.extractTitle(string(body))
|
||||
fmt.Printf("Login response title: %s\n", title)
|
||||
|
||||
if strings.Contains(title, "MFA") {
|
||||
return fmt.Errorf("MFA required - not implemented yet")
|
||||
}
|
||||
|
||||
if title != "Success" {
|
||||
return fmt.Errorf("login failed, unexpected title: %s", title)
|
||||
}
|
||||
|
||||
// Step 5: Extract ticket for OAuth flow
|
||||
fmt.Println("Extracting OAuth ticket...")
|
||||
ticket := c.extractTicket(string(body))
|
||||
if ticket == "" {
|
||||
return fmt.Errorf("failed to find OAuth ticket")
|
||||
}
|
||||
fmt.Printf("Found ticket: %s\n", ticket[:10]+"...")
|
||||
|
||||
// Step 6: Get OAuth1 token
|
||||
oauth1Token, err := c.getOAuth1Token(ticket)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get OAuth1 token: %w", err)
|
||||
}
|
||||
fmt.Println("Got OAuth1 token")
|
||||
|
||||
// Step 7: Exchange for OAuth2 token
|
||||
oauth2Token, err := c.exchangeToken(oauth1Token)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to exchange for OAuth2 token: %w", err)
|
||||
}
|
||||
fmt.Printf("Got OAuth2 token: %s\n", oauth2Token.TokenType)
|
||||
|
||||
// Step 8: Set auth token and get user profile
|
||||
c.authToken = fmt.Sprintf("%s %s", oauth2Token.TokenType, oauth2Token.AccessToken)
|
||||
|
||||
if err := c.getUserProfile(); err != nil {
|
||||
return fmt.Errorf("failed to get user profile: %w", err)
|
||||
}
|
||||
|
||||
fmt.Println("SSO authentication successful!")
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *Client) extractCSRFToken(html string) string {
|
||||
matches := csrfRegex.FindStringSubmatch(html)
|
||||
if len(matches) > 1 {
|
||||
return matches[1]
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (c *Client) extractTitle(html string) string {
|
||||
matches := titleRegex.FindStringSubmatch(html)
|
||||
if len(matches) > 1 {
|
||||
return matches[1]
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (c *Client) extractTicket(html string) string {
|
||||
matches := ticketRegex.FindStringSubmatch(html)
|
||||
if len(matches) > 1 {
|
||||
return matches[1]
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (c *Client) getOAuth1Token(ticket string) (*OAuth1Token, error) {
|
||||
consumer, err := loadOAuthConsumer()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to load OAuth consumer: %w", err)
|
||||
}
|
||||
|
||||
baseURL := fmt.Sprintf("https://connectapi.%s/oauth-service/oauth/", c.domain)
|
||||
loginURL := fmt.Sprintf("https://sso.%s/sso/embed", c.domain)
|
||||
tokenURL := fmt.Sprintf("%spreauthorized?ticket=%s&login-url=%s&accepts-mfa-tokens=true",
|
||||
baseURL, ticket, url.QueryEscape(loginURL))
|
||||
|
||||
// Parse URL to extract query parameters for signing
|
||||
parsedURL, err := url.Parse(tokenURL)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Extract query parameters for OAuth signing
|
||||
queryParams := make(map[string]string)
|
||||
for key, values := range parsedURL.Query() {
|
||||
if len(values) > 0 {
|
||||
queryParams[key] = values[0]
|
||||
}
|
||||
}
|
||||
|
||||
// Create OAuth1 signed request
|
||||
baseURLForSigning := parsedURL.Scheme + "://" + parsedURL.Host + parsedURL.Path
|
||||
authHeader := createOAuth1AuthorizationHeader("GET", baseURLForSigning, queryParams,
|
||||
consumer.ConsumerKey, consumer.ConsumerSecret, "", "")
|
||||
|
||||
req, err := http.NewRequest("GET", tokenURL, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("Authorization", authHeader)
|
||||
req.Header.Set("User-Agent", "com.garmin.android.apps.connectmobile")
|
||||
|
||||
fmt.Printf("OAuth1 request URL: %s\n", tokenURL)
|
||||
fmt.Printf("OAuth1 authorization header: %s\n", authHeader[:50]+"...")
|
||||
|
||||
resp, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
bodyStr := string(body)
|
||||
fmt.Printf("OAuth1 response status: %d\n", resp.StatusCode)
|
||||
fmt.Printf("OAuth1 response: %s\n", bodyStr[:min(200, len(bodyStr))])
|
||||
|
||||
if resp.StatusCode != 200 {
|
||||
return nil, fmt.Errorf("OAuth1 request failed with status %d: %s", resp.StatusCode, bodyStr)
|
||||
}
|
||||
|
||||
// Parse query string response - handle both & and ; separators
|
||||
bodyStr = strings.ReplaceAll(bodyStr, ";", "&")
|
||||
values, err := url.ParseQuery(bodyStr)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to parse OAuth1 response: %w", err)
|
||||
}
|
||||
|
||||
oauthToken := values.Get("oauth_token")
|
||||
oauthTokenSecret := values.Get("oauth_token_secret")
|
||||
|
||||
if oauthToken == "" || oauthTokenSecret == "" {
|
||||
return nil, fmt.Errorf("missing oauth_token or oauth_token_secret in response")
|
||||
}
|
||||
|
||||
return &OAuth1Token{
|
||||
OAuthToken: oauthToken,
|
||||
OAuthTokenSecret: oauthTokenSecret,
|
||||
MFAToken: values.Get("mfa_token"),
|
||||
Domain: c.domain,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (c *Client) exchangeToken(oauth1Token *OAuth1Token) (*OAuth2Token, error) {
|
||||
consumer, err := loadOAuthConsumer()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to load OAuth consumer: %w", err)
|
||||
}
|
||||
|
||||
exchangeURL := fmt.Sprintf("https://connectapi.%s/oauth-service/oauth/exchange/user/2.0", c.domain)
|
||||
|
||||
// Prepare form data
|
||||
formData := url.Values{}
|
||||
if oauth1Token.MFAToken != "" {
|
||||
formData.Set("mfa_token", oauth1Token.MFAToken)
|
||||
}
|
||||
|
||||
// Convert form data to map for OAuth signing
|
||||
formParams := make(map[string]string)
|
||||
for key, values := range formData {
|
||||
if len(values) > 0 {
|
||||
formParams[key] = values[0]
|
||||
}
|
||||
}
|
||||
|
||||
// Create OAuth1 signed request
|
||||
authHeader := createOAuth1AuthorizationHeader("POST", exchangeURL, formParams,
|
||||
consumer.ConsumerKey, consumer.ConsumerSecret, oauth1Token.OAuthToken, oauth1Token.OAuthTokenSecret)
|
||||
|
||||
req, err := http.NewRequest("POST", exchangeURL, strings.NewReader(formData.Encode()))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
req.Header.Set("Authorization", authHeader)
|
||||
req.Header.Set("User-Agent", "com.garmin.android.apps.connectmobile")
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
|
||||
fmt.Println("Attempting OAuth2 token exchange with signed request...")
|
||||
fmt.Printf("OAuth2 authorization header: %s\n", authHeader[:50]+"...")
|
||||
|
||||
resp, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
fmt.Printf("OAuth2 exchange response status: %d\n", resp.StatusCode)
|
||||
fmt.Printf("OAuth2 exchange response: %s\n", string(body)[:min(500, len(string(body)))])
|
||||
|
||||
if resp.StatusCode != 200 {
|
||||
return nil, fmt.Errorf("OAuth2 exchange failed with status %d: %s", resp.StatusCode, string(body))
|
||||
}
|
||||
|
||||
var oauth2Token OAuth2Token
|
||||
if err := json.Unmarshal(body, &oauth2Token); err != nil {
|
||||
return nil, fmt.Errorf("failed to decode OAuth2 token: %w", err)
|
||||
}
|
||||
|
||||
return &oauth2Token, nil
|
||||
}
|
||||
|
||||
// getUserProfile gets user profile information
|
||||
func (c *Client) getUserProfile() error {
|
||||
fmt.Println("Getting user profile...")
|
||||
|
||||
profileURL := fmt.Sprintf("https://connectapi.%s/userprofile-service/socialProfile", c.domain)
|
||||
|
||||
req, err := http.NewRequest("GET", profileURL, nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create profile request: %w", err)
|
||||
}
|
||||
|
||||
req.Header.Set("Authorization", c.authToken)
|
||||
req.Header.Set("User-Agent", "com.garmin.android.apps.connectmobile")
|
||||
|
||||
resp, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get user profile: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != 200 {
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
fmt.Printf("Profile request failed. Status: %d, Response: %s\n", resp.StatusCode, string(body))
|
||||
return fmt.Errorf("profile request failed with status: %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
var profile map[string]interface{}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&profile); err != nil {
|
||||
return fmt.Errorf("failed to parse profile: %w", err)
|
||||
}
|
||||
|
||||
if username, ok := profile["userName"].(string); ok {
|
||||
c.username = username
|
||||
fmt.Printf("Username: %s\n", c.username)
|
||||
} else {
|
||||
return fmt.Errorf("failed to extract username from profile")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetActivities retrieves recent activities
|
||||
func (c *Client) GetActivities(limit int) ([]Activity, error) {
|
||||
if limit <= 0 {
|
||||
limit = 10
|
||||
}
|
||||
|
||||
fmt.Printf("Getting last %d activities...\n", limit)
|
||||
|
||||
activitiesURL := fmt.Sprintf("https://connectapi.%s/activitylist-service/activities/search/activities?limit=%d&start=0", c.domain, limit)
|
||||
|
||||
req, err := http.NewRequest("GET", activitiesURL, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create activities request: %w", err)
|
||||
}
|
||||
|
||||
req.Header.Set("Authorization", c.authToken)
|
||||
req.Header.Set("User-Agent", "com.garmin.android.apps.connectmobile")
|
||||
|
||||
resp, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get activities: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != 200 {
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
fmt.Printf("Activities request failed. Status: %d, Response: %s\n", resp.StatusCode, string(body))
|
||||
return nil, fmt.Errorf("activities request failed with status: %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
var activities []Activity
|
||||
if err := json.NewDecoder(resp.Body).Decode(&activities); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse activities: %w", err)
|
||||
}
|
||||
|
||||
fmt.Printf("Retrieved %d activities\n", len(activities))
|
||||
return activities, nil
|
||||
}
|
||||
|
||||
// SaveSession saves the current session to a file
|
||||
func (c *Client) SaveSession(filename string) error {
|
||||
session := SessionData{
|
||||
Domain: c.domain,
|
||||
Username: c.username,
|
||||
AuthToken: c.authToken,
|
||||
}
|
||||
|
||||
data, err := json.MarshalIndent(session, "", " ")
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to marshal session: %w", err)
|
||||
}
|
||||
|
||||
if err := os.WriteFile(filename, data, 0600); err != nil {
|
||||
return fmt.Errorf("failed to write session file: %w", err)
|
||||
}
|
||||
|
||||
fmt.Printf("Session saved to %s\n", filename)
|
||||
return nil
|
||||
}
|
||||
|
||||
// LoadSession loads a session from a file
|
||||
func (c *Client) LoadSession(filename string) error {
|
||||
data, err := os.ReadFile(filename)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to read session file: %w", err)
|
||||
}
|
||||
|
||||
var session SessionData
|
||||
if err := json.Unmarshal(data, &session); err != nil {
|
||||
return fmt.Errorf("failed to unmarshal session: %w", err)
|
||||
}
|
||||
|
||||
c.domain = session.Domain
|
||||
c.username = session.Username
|
||||
c.authToken = session.AuthToken
|
||||
|
||||
fmt.Printf("Session loaded from %s\n", filename)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Helper function for min (Go 1.21+ has this built-in)
|
||||
func min(a, b int) int {
|
||||
if a < b {
|
||||
return a
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
func main() {
|
||||
// Load credentials from .env file
|
||||
email, password, domain, err := loadEnvCredentials()
|
||||
if err != nil {
|
||||
fmt.Printf("Failed to load credentials: %v\n", err)
|
||||
fmt.Println("Please create a .env file with GARMIN_EMAIL and GARMIN_PASSWORD")
|
||||
return
|
||||
}
|
||||
|
||||
client, err := NewClient(domain)
|
||||
if err != nil {
|
||||
fmt.Printf("Failed to create client: %v\n", err)
|
||||
return
|
||||
}
|
||||
|
||||
// Try to load existing session first
|
||||
sessionFile := "garmin_session.json"
|
||||
if err := client.LoadSession(sessionFile); err != nil {
|
||||
fmt.Println("No existing session found, logging in with credentials from .env...")
|
||||
|
||||
if err := client.Login(email, password); err != nil {
|
||||
fmt.Printf("Login failed: %v\n", err)
|
||||
return
|
||||
}
|
||||
|
||||
// Save session for future use
|
||||
if err := client.SaveSession(sessionFile); err != nil {
|
||||
fmt.Printf("Failed to save session: %v\n", err)
|
||||
}
|
||||
} else {
|
||||
fmt.Println("Loaded existing session")
|
||||
}
|
||||
|
||||
// Test getting activities
|
||||
activities, err := client.GetActivities(5)
|
||||
if err != nil {
|
||||
fmt.Printf("Failed to get activities: %v\n", err)
|
||||
return
|
||||
}
|
||||
|
||||
// Display activities
|
||||
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)
|
||||
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()
|
||||
}
|
||||
}
|
||||
@@ -1,45 +0,0 @@
|
||||
package garth
|
||||
|
||||
import (
|
||||
"sync"
|
||||
)
|
||||
|
||||
// MemoryStorage implements TokenStorage using an in-memory cache
|
||||
type MemoryStorage struct {
|
||||
mu sync.RWMutex
|
||||
token *Token
|
||||
}
|
||||
|
||||
// NewMemoryStorage creates a new in-memory token storage
|
||||
func NewMemoryStorage() *MemoryStorage {
|
||||
return &MemoryStorage{}
|
||||
}
|
||||
|
||||
// GetToken retrieves token from memory
|
||||
func (s *MemoryStorage) GetToken() (*Token, error) {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
|
||||
if s.token == nil {
|
||||
return nil, ErrTokenNotFound
|
||||
}
|
||||
return s.token, nil
|
||||
}
|
||||
|
||||
// SaveToken saves token to memory
|
||||
func (s *MemoryStorage) StoreToken(token *Token) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
s.token = token
|
||||
return nil
|
||||
}
|
||||
|
||||
// ClearToken removes the token from memory
|
||||
func (s *MemoryStorage) ClearToken() error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
s.token = nil
|
||||
return nil
|
||||
}
|
||||
153
profile.go
153
profile.go
@@ -1,153 +0,0 @@
|
||||
package garth
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io/ioutil"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
// Profile represents a user's Garmin profile
|
||||
type Profile struct {
|
||||
UserID string `json:"userId"`
|
||||
Username string `json:"username"`
|
||||
FirstName string `json:"firstName"`
|
||||
LastName string `json:"lastName"`
|
||||
EmailAddress string `json:"emailAddress"`
|
||||
Country string `json:"country"`
|
||||
City string `json:"city"`
|
||||
State string `json:"state"`
|
||||
ProfileImage string `json:"profileImage"`
|
||||
}
|
||||
|
||||
// ProfileService provides access to user profile operations
|
||||
type ProfileService struct {
|
||||
client *APIClient
|
||||
}
|
||||
|
||||
// NewProfileService creates a new ProfileService instance
|
||||
func NewProfileService(client *APIClient) *ProfileService {
|
||||
return &ProfileService{client: client}
|
||||
}
|
||||
|
||||
// Get fetches the current user's profile
|
||||
func (s *ProfileService) Get(ctx context.Context) (*Profile, error) {
|
||||
resp, err := s.client.Get(ctx, "/userprofile-service/userprofile")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, &APIError{
|
||||
StatusCode: resp.StatusCode,
|
||||
Message: "Failed to get user profile",
|
||||
}
|
||||
}
|
||||
|
||||
body, err := ioutil.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, &APIError{
|
||||
StatusCode: http.StatusInternalServerError,
|
||||
Message: "Failed to read profile response",
|
||||
Cause: err,
|
||||
}
|
||||
}
|
||||
|
||||
var profile Profile
|
||||
if err := json.Unmarshal(body, &profile); err != nil {
|
||||
return nil, &APIError{
|
||||
StatusCode: http.StatusInternalServerError,
|
||||
Message: "Failed to parse profile data",
|
||||
Cause: err,
|
||||
}
|
||||
}
|
||||
|
||||
return &profile, nil
|
||||
}
|
||||
|
||||
// UpdateSettings updates the user's profile settings
|
||||
func (s *ProfileService) UpdateSettings(ctx context.Context, settings map[string]interface{}) error {
|
||||
// Serialize settings to JSON
|
||||
jsonData, err := json.Marshal(settings)
|
||||
if err != nil {
|
||||
return &APIError{
|
||||
StatusCode: http.StatusInternalServerError,
|
||||
Message: "Failed to serialize settings",
|
||||
Cause: err,
|
||||
}
|
||||
}
|
||||
|
||||
// Convert JSON data to a Reader
|
||||
reader := bytes.NewReader(jsonData)
|
||||
|
||||
resp, err := s.client.Post(ctx, "/userprofile-service/userprofile/settings", reader)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusNoContent {
|
||||
return &APIError{
|
||||
StatusCode: resp.StatusCode,
|
||||
Message: "Failed to update settings",
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Delete deletes the user's profile
|
||||
func (s *ProfileService) Delete(ctx context.Context) error {
|
||||
resp, err := s.client.Delete(ctx, "/userprofile-service/userprofile", nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusNoContent {
|
||||
return &APIError{
|
||||
StatusCode: resp.StatusCode,
|
||||
Message: "Failed to delete profile",
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetPublic retrieves public profile information for a user
|
||||
func (s *ProfileService) GetPublic(ctx context.Context, userID string) (*Profile, error) {
|
||||
resp, err := s.client.Get(ctx, "/userprofile-service/userprofile/public/"+userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, &APIError{
|
||||
StatusCode: resp.StatusCode,
|
||||
Message: "Failed to get public profile",
|
||||
}
|
||||
}
|
||||
|
||||
body, err := ioutil.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, &APIError{
|
||||
StatusCode: http.StatusInternalServerError,
|
||||
Message: "Failed to read profile response",
|
||||
Cause: err,
|
||||
}
|
||||
}
|
||||
|
||||
var profile Profile
|
||||
if err := json.Unmarshal(body, &profile); err != nil {
|
||||
return nil, &APIError{
|
||||
StatusCode: http.StatusInternalServerError,
|
||||
Message: "Failed to parse profile data",
|
||||
Cause: err,
|
||||
}
|
||||
}
|
||||
|
||||
return &profile, nil
|
||||
}
|
||||
@@ -1,72 +0,0 @@
|
||||
package garth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestProfileService_Get(t *testing.T) {
|
||||
// Create test server
|
||||
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Write([]byte(`{
|
||||
"userId": "12345",
|
||||
"username": "testuser",
|
||||
"firstName": "Test",
|
||||
"lastName": "User",
|
||||
"emailAddress": "test@example.com",
|
||||
"country": "US",
|
||||
"city": "Seattle",
|
||||
"state": "WA",
|
||||
"profileImage": "https://example.com/avatar.jpg"
|
||||
}`))
|
||||
}))
|
||||
defer ts.Close()
|
||||
|
||||
// Create client
|
||||
apiClient := NewAPIClient(ts.URL, http.DefaultClient)
|
||||
profileService := NewProfileService(apiClient)
|
||||
|
||||
// Test Get method
|
||||
profile, err := profileService.Get(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("Unexpected error: %v", err)
|
||||
}
|
||||
|
||||
// Verify profile data
|
||||
if profile.UserID != "12345" {
|
||||
t.Errorf("Expected UserID '12345', got '%s'", profile.UserID)
|
||||
}
|
||||
if profile.Username != "testuser" {
|
||||
t.Errorf("Expected Username 'testuser', got '%s'", profile.Username)
|
||||
}
|
||||
if profile.EmailAddress != "test@example.com" {
|
||||
t.Errorf("Expected Email 'test@example.com', got '%s'", profile.EmailAddress)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProfileService_UpdateSettings(t *testing.T) {
|
||||
// Create test server
|
||||
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}))
|
||||
defer ts.Close()
|
||||
|
||||
// Create client
|
||||
apiClient := NewAPIClient(ts.URL, http.DefaultClient)
|
||||
profileService := NewProfileService(apiClient)
|
||||
|
||||
// Test UpdateSettings method
|
||||
settings := map[string]interface{}{
|
||||
"preferences": map[string]string{
|
||||
"units": "metric",
|
||||
"theme": "dark",
|
||||
},
|
||||
}
|
||||
err := profileService.UpdateSettings(context.Background(), settings)
|
||||
if err != nil {
|
||||
t.Fatalf("Unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
9
todo.md
9
todo.md
@@ -1,9 +0,0 @@
|
||||
- [x] Review task requirements and project structure
|
||||
- [x] Create workouts.go file with basic structure
|
||||
- [x] Implement CRUD operations for workouts
|
||||
- [x] Add workout scheduling functionality
|
||||
- [x] Implement template management for workouts
|
||||
- [x] Update package-level documentation in garth.go
|
||||
- [x] Add method-level comments across all services
|
||||
- [x] Run Go toolchain commands (mod tidy, fmt, vet, test)
|
||||
- [x] Verify all tests pass
|
||||
146
types.go
146
types.go
@@ -1,146 +0,0 @@
|
||||
package garth
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Unified Token Definitions
|
||||
|
||||
// Token represents authentication credentials
|
||||
type Token struct {
|
||||
AccessToken string `json:"access_token"`
|
||||
RefreshToken string `json:"refresh_token"`
|
||||
ExpiresAt int64 `json:"expires_at"`
|
||||
Domain string `json:"domain"`
|
||||
UserProfile *UserProfile `json:"user_profile"`
|
||||
}
|
||||
|
||||
// IsExpired checks if the token has expired (with 60 second buffer)
|
||||
func (t *Token) IsExpired() bool {
|
||||
return time.Now().Unix() >= (t.ExpiresAt - 60)
|
||||
}
|
||||
|
||||
// NeedsRefresh checks if token needs refresh (within 5 min expiry window)
|
||||
func (t *Token) NeedsRefresh() bool {
|
||||
return time.Now().Unix() >= (t.ExpiresAt - 300)
|
||||
}
|
||||
|
||||
// UserProfile represents Garmin user profile information
|
||||
type UserProfile struct {
|
||||
Username string `json:"username"`
|
||||
ProfileID string `json:"profile_id"`
|
||||
DisplayName string `json:"display_name"`
|
||||
}
|
||||
|
||||
// ClientOptions contains configuration for the authenticator
|
||||
type ClientOptions struct {
|
||||
SSOURL string // SSO endpoint
|
||||
TokenURL string // Token exchange endpoint
|
||||
Storage TokenStorage // Token storage implementation
|
||||
Timeout time.Duration // HTTP client timeout
|
||||
Domain string // Garmin domain (default: garmin.com)
|
||||
UserAgent string // User-Agent header (default: GCMv3)
|
||||
}
|
||||
|
||||
// TokenStorage defines the interface for token storage
|
||||
type TokenStorage interface {
|
||||
StoreToken(token *Token) error
|
||||
GetToken() (*Token, error)
|
||||
ClearToken() error
|
||||
}
|
||||
|
||||
// Error interface defines common error behavior for Garth
|
||||
type Error interface {
|
||||
error
|
||||
GetStatusCode() int
|
||||
GetType() string
|
||||
GetCause() error
|
||||
Unwrap() error
|
||||
}
|
||||
|
||||
// ErrTokenNotFound is returned when a token is not available in storage
|
||||
var ErrTokenNotFound = errors.New("token not found")
|
||||
|
||||
// AuthError represents Garmin authentication errors
|
||||
type AuthError struct {
|
||||
StatusCode int `json:"status_code"` // HTTP status code
|
||||
Message string `json:"message"` // Human-readable error message
|
||||
Type string `json:"type"` // Garmin error type identifier
|
||||
Cause error `json:"cause"` // Underlying error
|
||||
CSRF string `json:"csrf"` // CSRF token for MFA flow
|
||||
}
|
||||
|
||||
// GetStatusCode returns the HTTP status code
|
||||
func (e *AuthError) GetStatusCode() int {
|
||||
return e.StatusCode
|
||||
}
|
||||
|
||||
// GetType returns the error category
|
||||
func (e *AuthError) GetType() string {
|
||||
return e.Type
|
||||
}
|
||||
|
||||
// Error implements the error interface for AuthError
|
||||
func (e *AuthError) Error() string {
|
||||
msg := fmt.Sprintf("garmin auth error %d: %s", e.StatusCode, e.Message)
|
||||
if e.Cause != nil {
|
||||
msg += " (" + e.Cause.Error() + ")"
|
||||
}
|
||||
return msg
|
||||
}
|
||||
|
||||
// Unwrap returns the underlying error
|
||||
func (e *AuthError) Unwrap() error {
|
||||
return e.Cause
|
||||
}
|
||||
|
||||
// GetCause returns the underlying error (implements Error interface)
|
||||
func (e *AuthError) GetCause() error {
|
||||
return e.Cause
|
||||
}
|
||||
|
||||
// APIError represents errors from API operations
|
||||
type APIError struct {
|
||||
StatusCode int // HTTP status code
|
||||
Message string // Error description
|
||||
Cause error // Underlying error
|
||||
ErrorType string // Specific error category
|
||||
}
|
||||
|
||||
// GetStatusCode returns the HTTP status code
|
||||
func (e *APIError) GetStatusCode() int {
|
||||
return e.StatusCode
|
||||
}
|
||||
|
||||
// GetType returns the error category
|
||||
func (e *APIError) GetType() string {
|
||||
if e.ErrorType == "" {
|
||||
return "api_error"
|
||||
}
|
||||
return e.ErrorType
|
||||
}
|
||||
|
||||
// Error implements the error interface for APIError
|
||||
func (e *APIError) Error() string {
|
||||
if e.Cause != nil {
|
||||
return fmt.Sprintf("API error (%d): %s: %v", e.StatusCode, e.Message, e.Cause)
|
||||
}
|
||||
return fmt.Sprintf("API error (%d): %s", e.StatusCode, e.Message)
|
||||
}
|
||||
|
||||
// Unwrap returns the underlying error
|
||||
func (e *APIError) Unwrap() error {
|
||||
return e.Cause
|
||||
}
|
||||
|
||||
// GetCause returns the underlying error (implements Error interface)
|
||||
func (e *APIError) GetCause() error {
|
||||
return e.Cause
|
||||
}
|
||||
|
||||
// AuthenticatorSetter interface for storage that needs authenticator reference
|
||||
type AuthenticatorSetter interface {
|
||||
SetAuthenticator(a Authenticator)
|
||||
}
|
||||
@@ -1,157 +0,0 @@
|
||||
# Workout Service Implementation Specification
|
||||
|
||||
## Overview
|
||||
This document provides the technical specification for implementing the complete WorkoutService functionality in the go-garth library, following the established patterns from the ActivityService.
|
||||
|
||||
## Data Structures
|
||||
|
||||
### Workout (Summary)
|
||||
```go
|
||||
type Workout struct {
|
||||
WorkoutID int64 `json:"workoutId"`
|
||||
Name string `json:"workoutName"`
|
||||
Type string `json:"workoutType"`
|
||||
Description string `json:"description"`
|
||||
CreatedDate time.Time `json:"createdDate"`
|
||||
UpdatedDate time.Time `json:"updatedDate"`
|
||||
OwnerID int64 `json:"ownerId"`
|
||||
IsPublic bool `json:"isPublic"`
|
||||
SportType string `json:"sportType"`
|
||||
SubSportType string `json:"subSportType"`
|
||||
}
|
||||
```
|
||||
|
||||
### WorkoutDetails (Full)
|
||||
```go
|
||||
type WorkoutDetails struct {
|
||||
WorkoutID int64 `json:"workoutId"`
|
||||
Name string `json:"workoutName"`
|
||||
Description string `json:"description"`
|
||||
Type string `json:"workoutType"`
|
||||
CreatedDate time.Time `json:"createdDate"`
|
||||
UpdatedDate time.Time `json:"updatedDate"`
|
||||
OwnerID int64 `json:"ownerId"`
|
||||
IsPublic bool `json:"isPublic"`
|
||||
SportType string `json:"sportType"`
|
||||
SubSportType string `json:"subSportType"`
|
||||
WorkoutSegments []WorkoutSegment `json:"workoutSegments"`
|
||||
EstimatedDuration int `json:"estimatedDuration"`
|
||||
EstimatedDistance float64 `json:"estimatedDistance"`
|
||||
TrainingLoad float64 `json:"trainingLoad"`
|
||||
Tags []string `json:"tags"`
|
||||
}
|
||||
```
|
||||
|
||||
### WorkoutSegment
|
||||
```go
|
||||
type WorkoutSegment struct {
|
||||
SegmentID int64 `json:"segmentId"`
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
Order int `json:"order"`
|
||||
Exercises []WorkoutExercise `json:"exercises"`
|
||||
}
|
||||
```
|
||||
|
||||
### WorkoutExercise
|
||||
```go
|
||||
type WorkoutExercise struct {
|
||||
ExerciseID int64 `json:"exerciseId"`
|
||||
Name string `json:"name"`
|
||||
Category string `json:"category"`
|
||||
Type string `json:"type"`
|
||||
Duration int `json:"duration,omitempty"`
|
||||
Distance float64 `json:"distance,omitempty"`
|
||||
Repetitions int `json:"repetitions,omitempty"`
|
||||
Weight float64 `json:"weight,omitempty"`
|
||||
RestInterval int `json:"restInterval,omitempty"`
|
||||
}
|
||||
```
|
||||
|
||||
### WorkoutListOptions
|
||||
```go
|
||||
type WorkoutListOptions struct {
|
||||
Limit int
|
||||
Offset int
|
||||
StartDate time.Time
|
||||
EndDate time.Time
|
||||
WorkoutType string
|
||||
Type string
|
||||
Status string
|
||||
SportType string
|
||||
NameContains string
|
||||
OwnerID int64
|
||||
IsPublic *bool
|
||||
SortBy string
|
||||
SortOrder string
|
||||
}
|
||||
```
|
||||
|
||||
### WorkoutUpdate
|
||||
```go
|
||||
type WorkoutUpdate struct {
|
||||
Name string `json:"workoutName,omitempty"`
|
||||
Description string `json:"description,omitempty"`
|
||||
Type string `json:"workoutType,omitempty"`
|
||||
SportType string `json:"sportType,omitempty"`
|
||||
IsPublic *bool `json:"isPublic,omitempty"`
|
||||
Tags []string `json:"tags,omitempty"`
|
||||
}
|
||||
```
|
||||
|
||||
## API Endpoints
|
||||
|
||||
### Base URL Pattern
|
||||
All workout endpoints follow the pattern: `/workout-service/workout`
|
||||
|
||||
### Specific Endpoints
|
||||
- **GET** `/workout-service/workout` - List workouts (with query parameters)
|
||||
- **GET** `/workout-service/workout/{workoutId}` - Get workout details
|
||||
- **POST** `/workout-service/workout` - Create new workout
|
||||
- **PUT** `/workout-service/workout/{workoutId}` - Update existing workout
|
||||
- **DELETE** `/workout-service/workout/{workoutId}` - Delete workout
|
||||
- **GET** `/download-service/export/{format}/workout/{workoutId}` - Export workout
|
||||
|
||||
## Method Signatures
|
||||
|
||||
### WorkoutService Methods
|
||||
```go
|
||||
func (s *WorkoutService) List(ctx context.Context, opts WorkoutListOptions) ([]Workout, error)
|
||||
func (s *WorkoutService) Get(ctx context.Context, workoutID int64) (*WorkoutDetails, error)
|
||||
func (s *WorkoutService) Create(ctx context.Context, workout WorkoutDetails) (*WorkoutDetails, error)
|
||||
func (s *WorkoutService) Update(ctx context.Context, workoutID int64, update WorkoutUpdate) (*WorkoutDetails, error)
|
||||
func (s *WorkoutService) Delete(ctx context.Context, workoutID int64) error
|
||||
func (s *WorkoutService) Export(ctx context.Context, workoutID int64, format string) (io.ReadCloser, error)
|
||||
```
|
||||
|
||||
## Testing Strategy
|
||||
|
||||
### Test Coverage Requirements
|
||||
- All public methods must have unit tests
|
||||
- Test both success and error scenarios
|
||||
- Use httptest for HTTP mocking
|
||||
- Test JSON marshaling/unmarshaling
|
||||
- Test query parameter construction
|
||||
|
||||
### Test File Structure
|
||||
- File: `workouts_test.go`
|
||||
- Test functions:
|
||||
- `TestWorkoutService_List`
|
||||
- `TestWorkoutService_Get`
|
||||
- `TestWorkoutService_Create`
|
||||
- `TestWorkoutService_Update`
|
||||
- `TestWorkoutService_Delete`
|
||||
- `TestWorkoutService_Export`
|
||||
|
||||
## Error Handling
|
||||
- Follow the same pattern as ActivityService
|
||||
- Use APIError for HTTP-related errors
|
||||
- Include proper status codes and messages
|
||||
- Handle JSON parsing errors appropriately
|
||||
|
||||
## Implementation Notes
|
||||
- Use the same HTTP client pattern as ActivityService
|
||||
- Follow consistent naming conventions
|
||||
- Ensure proper context handling
|
||||
- Include appropriate defer statements for resource cleanup
|
||||
- Use strconv.FormatInt for ID conversion in URLs
|
||||
475
workouts.go
475
workouts.go
@@ -1,475 +0,0 @@
|
||||
package garth
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"io/ioutil"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"time"
|
||||
)
|
||||
|
||||
// WorkoutService provides methods for interacting with Garmin workout data.
|
||||
type WorkoutService struct {
|
||||
client *APIClient
|
||||
}
|
||||
|
||||
// NewWorkoutService creates a new WorkoutService instance.
|
||||
// client: The authenticated APIClient used to make requests.
|
||||
func NewWorkoutService(client *APIClient) *WorkoutService {
|
||||
return &WorkoutService{client: client}
|
||||
}
|
||||
|
||||
// Workout represents a Garmin workout with basic information
|
||||
type Workout struct {
|
||||
WorkoutID int64 `json:"workoutId"`
|
||||
Name string `json:"workoutName"`
|
||||
Type string `json:"workoutType"`
|
||||
Description string `json:"description"`
|
||||
CreatedDate time.Time `json:"createdDate"`
|
||||
UpdatedDate time.Time `json:"updatedDate"`
|
||||
OwnerID int64 `json:"ownerId"`
|
||||
IsPublic bool `json:"isPublic"`
|
||||
SportType string `json:"sportType"`
|
||||
SubSportType string `json:"subSportType"`
|
||||
}
|
||||
|
||||
// WorkoutDetails contains detailed information about a workout
|
||||
type WorkoutDetails struct {
|
||||
Workout
|
||||
WorkoutSegments []WorkoutSegment `json:"workoutSegments"`
|
||||
EstimatedDuration int `json:"estimatedDuration"`
|
||||
EstimatedDistance float64 `json:"estimatedDistance"`
|
||||
TrainingLoad float64 `json:"trainingLoad"`
|
||||
Tags []string `json:"tags"`
|
||||
}
|
||||
|
||||
// WorkoutSegment represents a segment within a workout
|
||||
type WorkoutSegment struct {
|
||||
SegmentID int64 `json:"segmentId"`
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
Order int `json:"order"`
|
||||
Exercises []WorkoutExercise `json:"exercises"`
|
||||
}
|
||||
|
||||
// WorkoutExercise represents an exercise within a workout segment
|
||||
type WorkoutExercise struct {
|
||||
ExerciseID int64 `json:"exerciseId"`
|
||||
Name string `json:"name"`
|
||||
Category string `json:"category"`
|
||||
Type string `json:"type"`
|
||||
Duration int `json:"duration,omitempty"`
|
||||
Distance float64 `json:"distance,omitempty"`
|
||||
Repetitions int `json:"repetitions,omitempty"`
|
||||
Weight float64 `json:"weight,omitempty"`
|
||||
RestInterval int `json:"restInterval,omitempty"`
|
||||
}
|
||||
|
||||
// WorkoutListOptions provides filtering options for listing workouts
|
||||
type WorkoutListOptions struct {
|
||||
Limit int
|
||||
StartDate time.Time
|
||||
EndDate time.Time
|
||||
SportType string
|
||||
NameContains string
|
||||
OwnerID int64
|
||||
Offset int
|
||||
SortBy string
|
||||
SortOrder string
|
||||
Type string
|
||||
Status string
|
||||
}
|
||||
|
||||
// WorkoutUpdate represents fields that can be updated on a workout
|
||||
type WorkoutUpdate struct {
|
||||
Name string `json:"name,omitempty"`
|
||||
Description string `json:"description,omitempty"`
|
||||
Type string `json:"type,omitempty"`
|
||||
SportType string `json:"sportType,omitempty"`
|
||||
SubSportType string `json:"subSportType,omitempty"`
|
||||
}
|
||||
|
||||
// List retrieves a list of workouts for the current user with optional filters
|
||||
func (s *WorkoutService) List(ctx context.Context, opts WorkoutListOptions) ([]Workout, error) {
|
||||
params := url.Values{}
|
||||
if opts.Limit > 0 {
|
||||
params.Set("limit", strconv.Itoa(opts.Limit))
|
||||
}
|
||||
if opts.Offset > 0 {
|
||||
params.Set("offset", strconv.Itoa(opts.Offset))
|
||||
}
|
||||
if !opts.StartDate.IsZero() {
|
||||
params.Set("startDate", opts.StartDate.Format(time.RFC3339))
|
||||
}
|
||||
if !opts.EndDate.IsZero() {
|
||||
params.Set("endDate", opts.EndDate.Format(time.RFC3339))
|
||||
}
|
||||
if opts.SportType != "" {
|
||||
params.Set("sportType", opts.SportType)
|
||||
}
|
||||
if opts.Type != "" {
|
||||
params.Set("type", opts.Type)
|
||||
}
|
||||
if opts.Status != "" {
|
||||
params.Set("status", opts.Status)
|
||||
}
|
||||
if opts.NameContains != "" {
|
||||
params.Set("nameContains", opts.NameContains)
|
||||
}
|
||||
if opts.OwnerID > 0 {
|
||||
params.Set("ownerId", strconv.FormatInt(opts.OwnerID, 10))
|
||||
}
|
||||
if opts.SortBy != "" {
|
||||
params.Set("sortBy", opts.SortBy)
|
||||
}
|
||||
if opts.SortOrder != "" {
|
||||
params.Set("sortOrder", opts.SortOrder)
|
||||
}
|
||||
|
||||
path := "/workout-service/workouts"
|
||||
if len(params) > 0 {
|
||||
path += "?" + params.Encode()
|
||||
}
|
||||
|
||||
resp, err := s.client.Get(ctx, path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, &APIError{
|
||||
StatusCode: resp.StatusCode,
|
||||
Message: "Failed to get workouts list",
|
||||
}
|
||||
}
|
||||
|
||||
body, err := ioutil.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, &APIError{
|
||||
StatusCode: http.StatusInternalServerError,
|
||||
Message: "Failed to read workouts response",
|
||||
Cause: err,
|
||||
}
|
||||
}
|
||||
|
||||
var workouts []Workout
|
||||
if err := json.Unmarshal(body, &workouts); err != nil {
|
||||
return nil, &APIError{
|
||||
StatusCode: http.StatusInternalServerError,
|
||||
Message: "Failed to parse workouts data",
|
||||
Cause: err,
|
||||
}
|
||||
}
|
||||
|
||||
return workouts, nil
|
||||
}
|
||||
|
||||
// Get retrieves detailed information about a specific workout
|
||||
func (s *WorkoutService) Get(ctx context.Context, id string) (*WorkoutDetails, error) {
|
||||
path := "/workout-service/workout/" + id
|
||||
|
||||
resp, err := s.client.Get(ctx, path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, &APIError{
|
||||
StatusCode: resp.StatusCode,
|
||||
Message: "Failed to get workout details",
|
||||
}
|
||||
}
|
||||
|
||||
body, err := ioutil.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, &APIError{
|
||||
StatusCode: http.StatusInternalServerError,
|
||||
Message: "Failed to read workout response",
|
||||
Cause: err,
|
||||
}
|
||||
}
|
||||
|
||||
var details WorkoutDetails
|
||||
if err := json.Unmarshal(body, &details); err != nil {
|
||||
return nil, &APIError{
|
||||
StatusCode: http.StatusInternalServerError,
|
||||
Message: "Failed to parse workout data",
|
||||
Cause: err,
|
||||
}
|
||||
}
|
||||
|
||||
return &details, nil
|
||||
}
|
||||
|
||||
// Create creates a new workout
|
||||
func (s *WorkoutService) Create(ctx context.Context, workout Workout) (*Workout, error) {
|
||||
jsonBody, err := json.Marshal(workout)
|
||||
if err != nil {
|
||||
return nil, &APIError{
|
||||
StatusCode: http.StatusInternalServerError,
|
||||
Message: "Failed to marshal workout",
|
||||
Cause: err,
|
||||
}
|
||||
}
|
||||
|
||||
resp, err := s.client.Post(ctx, "/workout-service/workout", bytes.NewReader(jsonBody))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusCreated {
|
||||
return nil, &APIError{
|
||||
StatusCode: resp.StatusCode,
|
||||
Message: "Failed to create workout",
|
||||
}
|
||||
}
|
||||
|
||||
body, err := ioutil.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, &APIError{
|
||||
StatusCode: http.StatusInternalServerError,
|
||||
Message: "Failed to read workout response",
|
||||
Cause: err,
|
||||
}
|
||||
}
|
||||
|
||||
var createdWorkout Workout
|
||||
if err := json.Unmarshal(body, &createdWorkout); err != nil {
|
||||
return nil, &APIError{
|
||||
StatusCode: http.StatusInternalServerError,
|
||||
Message: "Failed to parse workout data",
|
||||
Cause: err,
|
||||
}
|
||||
}
|
||||
|
||||
return &createdWorkout, nil
|
||||
}
|
||||
|
||||
// Update updates an existing workout
|
||||
func (s *WorkoutService) Update(ctx context.Context, id string, update WorkoutUpdate) (*Workout, error) {
|
||||
jsonBody, err := json.Marshal(update)
|
||||
if err != nil {
|
||||
return nil, &APIError{
|
||||
StatusCode: http.StatusInternalServerError,
|
||||
Message: "Failed to marshal workout update",
|
||||
Cause: err,
|
||||
}
|
||||
}
|
||||
|
||||
path := "/workout-service/workout/" + id
|
||||
resp, err := s.client.Put(ctx, path, bytes.NewReader(jsonBody))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, &APIError{
|
||||
StatusCode: resp.StatusCode,
|
||||
Message: "Failed to update workout",
|
||||
}
|
||||
}
|
||||
|
||||
body, err := ioutil.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, &APIError{
|
||||
StatusCode: http.StatusInternalServerError,
|
||||
Message: "Failed to read workout response",
|
||||
Cause: err,
|
||||
}
|
||||
}
|
||||
|
||||
var updatedWorkout Workout
|
||||
if err := json.Unmarshal(body, &updatedWorkout); err != nil {
|
||||
return nil, &APIError{
|
||||
StatusCode: http.StatusInternalServerError,
|
||||
Message: "Failed to parse workout data",
|
||||
Cause: err,
|
||||
}
|
||||
}
|
||||
|
||||
return &updatedWorkout, nil
|
||||
}
|
||||
|
||||
// Delete deletes an existing workout
|
||||
func (s *WorkoutService) Delete(ctx context.Context, id string) error {
|
||||
path := "/workout-service/workout/" + id
|
||||
resp, err := s.client.Delete(ctx, path, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusNoContent && resp.StatusCode != http.StatusOK {
|
||||
return &APIError{
|
||||
StatusCode: resp.StatusCode,
|
||||
Message: "Failed to delete workout",
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetWorkoutTemplates retrieves all workout templates for the current user
|
||||
func (s *WorkoutService) GetWorkoutTemplates(ctx context.Context) ([]Workout, error) {
|
||||
path := "/workout-service/templates"
|
||||
|
||||
resp, err := s.client.Get(ctx, path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, &APIError{
|
||||
StatusCode: resp.StatusCode,
|
||||
Message: "Failed to get workout templates",
|
||||
}
|
||||
}
|
||||
|
||||
body, err := ioutil.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, &APIError{
|
||||
StatusCode: http.StatusInternalServerError,
|
||||
Message: "Failed to read workout templates response",
|
||||
Cause: err,
|
||||
}
|
||||
}
|
||||
|
||||
var templates []Workout
|
||||
if err := json.Unmarshal(body, &templates); err != nil {
|
||||
return nil, &APIError{
|
||||
StatusCode: http.StatusInternalServerError,
|
||||
Message: "Failed to parse workout templates data",
|
||||
Cause: err,
|
||||
}
|
||||
}
|
||||
|
||||
return templates, nil
|
||||
}
|
||||
|
||||
// SearchWorkouts searches workouts by name or description
|
||||
func (s *WorkoutService) SearchWorkouts(ctx context.Context, query string, limit int) ([]Workout, error) {
|
||||
params := url.Values{}
|
||||
params.Set("q", query)
|
||||
if limit > 0 {
|
||||
params.Set("limit", strconv.Itoa(limit))
|
||||
}
|
||||
|
||||
path := "/workout-service/search?" + params.Encode()
|
||||
|
||||
resp, err := s.client.Get(ctx, path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, &APIError{
|
||||
StatusCode: resp.StatusCode,
|
||||
Message: "Failed to search workouts",
|
||||
}
|
||||
}
|
||||
|
||||
body, err := ioutil.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, &APIError{
|
||||
StatusCode: http.StatusInternalServerError,
|
||||
Message: "Failed to read search response",
|
||||
Cause: err,
|
||||
}
|
||||
}
|
||||
|
||||
var workouts []Workout
|
||||
if err := json.Unmarshal(body, &workouts); err != nil {
|
||||
return nil, &APIError{
|
||||
StatusCode: http.StatusInternalServerError,
|
||||
Message: "Failed to parse search results",
|
||||
Cause: err,
|
||||
}
|
||||
}
|
||||
|
||||
return workouts, nil
|
||||
}
|
||||
|
||||
// CopyWorkout creates a copy of an existing workout
|
||||
func (s *WorkoutService) CopyWorkout(ctx context.Context, id string, newName string) (*Workout, error) {
|
||||
path := "/workout-service/workout/" + id + "/copy"
|
||||
|
||||
requestBody := map[string]string{"name": newName}
|
||||
jsonBody, err := json.Marshal(requestBody)
|
||||
if err != nil {
|
||||
return nil, &APIError{
|
||||
StatusCode: http.StatusInternalServerError,
|
||||
Message: "Failed to marshal request body",
|
||||
Cause: err,
|
||||
}
|
||||
}
|
||||
|
||||
resp, err := s.client.Post(ctx, path, bytes.NewReader(jsonBody))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusCreated {
|
||||
return nil, &APIError{
|
||||
StatusCode: resp.StatusCode,
|
||||
Message: "Failed to copy workout",
|
||||
}
|
||||
}
|
||||
|
||||
body, err := ioutil.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, &APIError{
|
||||
StatusCode: http.StatusInternalServerError,
|
||||
Message: "Failed to read copy response",
|
||||
Cause: err,
|
||||
}
|
||||
}
|
||||
|
||||
var copiedWorkout Workout
|
||||
if err := json.Unmarshal(body, &copiedWorkout); err != nil {
|
||||
return nil, &APIError{
|
||||
StatusCode: http.StatusInternalServerError,
|
||||
Message: "Failed to parse copied workout data",
|
||||
Cause: err,
|
||||
}
|
||||
}
|
||||
|
||||
return &copiedWorkout, nil
|
||||
}
|
||||
|
||||
// Export exports a workout in the specified format (fit, tcx, json)
|
||||
func (s *WorkoutService) Export(ctx context.Context, id string, format string) (io.ReadCloser, error) {
|
||||
if format != "fit" && format != "tcx" && format != "json" {
|
||||
return nil, &APIError{
|
||||
StatusCode: http.StatusBadRequest,
|
||||
Message: "Invalid format. Supported formats: fit, tcx, json",
|
||||
}
|
||||
}
|
||||
|
||||
path := "/download-service/export/" + format + "/workout/" + id
|
||||
|
||||
resp, err := s.client.Get(ctx, path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
defer resp.Body.Close()
|
||||
return nil, &APIError{
|
||||
StatusCode: resp.StatusCode,
|
||||
Message: "Failed to export workout",
|
||||
}
|
||||
}
|
||||
|
||||
return resp.Body, nil
|
||||
}
|
||||
731
workouts_test.go
731
workouts_test.go
@@ -1,731 +0,0 @@
|
||||
package garth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io/ioutil"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// TestWorkoutService_List tests the List method with various options
|
||||
func TestWorkoutService_List(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
mockResponse []Workout
|
||||
mockStatusCode int
|
||||
opts WorkoutListOptions
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
name: "successful list with no options",
|
||||
mockResponse: []Workout{
|
||||
{WorkoutID: 1, Name: "Morning Run", Type: "running"},
|
||||
{WorkoutID: 2, Name: "Evening Ride", Type: "cycling"},
|
||||
},
|
||||
mockStatusCode: http.StatusOK,
|
||||
opts: WorkoutListOptions{},
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "successful list with limit",
|
||||
mockResponse: []Workout{
|
||||
{WorkoutID: 1, Name: "Morning Run", Type: "running"},
|
||||
},
|
||||
mockStatusCode: http.StatusOK,
|
||||
opts: WorkoutListOptions{Limit: 1},
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "successful list with date range",
|
||||
mockResponse: []Workout{
|
||||
{WorkoutID: 1, Name: "Morning Run", Type: "running"},
|
||||
},
|
||||
mockStatusCode: http.StatusOK,
|
||||
opts: WorkoutListOptions{
|
||||
StartDate: time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC),
|
||||
EndDate: time.Date(2024, 1, 31, 0, 0, 0, 0, time.UTC),
|
||||
},
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "successful list with pagination",
|
||||
mockResponse: []Workout{
|
||||
{WorkoutID: 2, Name: "Evening Ride", Type: "cycling"},
|
||||
},
|
||||
mockStatusCode: http.StatusOK,
|
||||
opts: WorkoutListOptions{
|
||||
Limit: 1,
|
||||
Offset: 1,
|
||||
},
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "successful list with sorting",
|
||||
mockResponse: []Workout{
|
||||
{WorkoutID: 1, Name: "Morning Run", Type: "running"},
|
||||
{WorkoutID: 2, Name: "Evening Ride", Type: "cycling"},
|
||||
},
|
||||
mockStatusCode: http.StatusOK,
|
||||
opts: WorkoutListOptions{
|
||||
SortBy: "createdDate",
|
||||
SortOrder: "desc",
|
||||
},
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "successful list with type filter",
|
||||
mockResponse: []Workout{
|
||||
{WorkoutID: 1, Name: "Morning Run", Type: "running"},
|
||||
},
|
||||
mockStatusCode: http.StatusOK,
|
||||
opts: WorkoutListOptions{
|
||||
Type: "running",
|
||||
},
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "successful list with status filter",
|
||||
mockResponse: []Workout{
|
||||
{WorkoutID: 1, Name: "Morning Run", Type: "running"},
|
||||
},
|
||||
mockStatusCode: http.StatusOK,
|
||||
opts: WorkoutListOptions{
|
||||
Status: "active",
|
||||
},
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "server error",
|
||||
mockResponse: nil,
|
||||
mockStatusCode: http.StatusInternalServerError,
|
||||
opts: WorkoutListOptions{},
|
||||
wantErr: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/workout-service/workouts" {
|
||||
t.Errorf("expected path /workout-service/workouts, got %s", r.URL.Path)
|
||||
}
|
||||
|
||||
w.WriteHeader(tt.mockStatusCode)
|
||||
if tt.mockResponse != nil {
|
||||
json.NewEncoder(w).Encode(tt.mockResponse)
|
||||
}
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := &APIClient{baseURL: server.URL, httpClient: server.Client()}
|
||||
service := NewWorkoutService(client)
|
||||
|
||||
workouts, err := service.List(context.Background(), tt.opts)
|
||||
if (err != nil) != tt.wantErr {
|
||||
t.Errorf("WorkoutService.List() error = %v, wantErr %v", err, tt.wantErr)
|
||||
return
|
||||
}
|
||||
|
||||
if !tt.wantErr && len(workouts) != len(tt.mockResponse) {
|
||||
t.Errorf("WorkoutService.List() got %d workouts, want %d", len(workouts), len(tt.mockResponse))
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestWorkoutService_Get tests the Get method
|
||||
func TestWorkoutService_Get(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
workoutID string
|
||||
mockResponse *WorkoutDetails
|
||||
mockStatusCode int
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
name: "successful get",
|
||||
workoutID: "123",
|
||||
mockResponse: &WorkoutDetails{
|
||||
Workout: Workout{
|
||||
WorkoutID: 123,
|
||||
Name: "Test Workout",
|
||||
Type: "running",
|
||||
},
|
||||
EstimatedDuration: 3600,
|
||||
TrainingLoad: 50.5,
|
||||
},
|
||||
mockStatusCode: http.StatusOK,
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "workout not found",
|
||||
workoutID: "999",
|
||||
mockResponse: nil,
|
||||
mockStatusCode: http.StatusNotFound,
|
||||
wantErr: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
expectedPath := "/workout-service/workout/" + tt.workoutID
|
||||
if r.URL.Path != expectedPath {
|
||||
t.Errorf("expected path %s, got %s", expectedPath, r.URL.Path)
|
||||
}
|
||||
|
||||
w.WriteHeader(tt.mockStatusCode)
|
||||
if tt.mockResponse != nil {
|
||||
json.NewEncoder(w).Encode(tt.mockResponse)
|
||||
}
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := &APIClient{baseURL: server.URL, httpClient: server.Client()}
|
||||
service := NewWorkoutService(client)
|
||||
|
||||
workout, err := service.Get(context.Background(), tt.workoutID)
|
||||
if (err != nil) != tt.wantErr {
|
||||
t.Errorf("WorkoutService.Get() error = %v, wantErr %v", err, tt.wantErr)
|
||||
return
|
||||
}
|
||||
|
||||
if !tt.wantErr && workout.WorkoutID != 123 {
|
||||
t.Errorf("WorkoutService.Get() got ID %d, want 123", workout.WorkoutID)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestWorkoutService_Create tests the Create method
|
||||
func TestWorkoutService_Create(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
workout Workout
|
||||
mockResponse *Workout
|
||||
mockStatusCode int
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
name: "successful create",
|
||||
workout: Workout{
|
||||
Name: "New Workout",
|
||||
Description: "Test workout",
|
||||
Type: "cycling",
|
||||
},
|
||||
mockResponse: &Workout{
|
||||
WorkoutID: 456,
|
||||
Name: "New Workout",
|
||||
Description: "Test workout",
|
||||
Type: "cycling",
|
||||
},
|
||||
mockStatusCode: http.StatusCreated,
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "invalid workout data",
|
||||
workout: Workout{
|
||||
Name: "",
|
||||
},
|
||||
mockResponse: nil,
|
||||
mockStatusCode: http.StatusBadRequest,
|
||||
wantErr: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/workout-service/workout" {
|
||||
t.Errorf("expected path /workout-service/workout, got %s", r.URL.Path)
|
||||
}
|
||||
|
||||
body, _ := ioutil.ReadAll(r.Body)
|
||||
var receivedWorkout Workout
|
||||
json.Unmarshal(body, &receivedWorkout)
|
||||
|
||||
w.WriteHeader(tt.mockStatusCode)
|
||||
if tt.mockResponse != nil {
|
||||
json.NewEncoder(w).Encode(tt.mockResponse)
|
||||
}
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := &APIClient{baseURL: server.URL, httpClient: server.Client()}
|
||||
service := NewWorkoutService(client)
|
||||
|
||||
createdWorkout, err := service.Create(context.Background(), tt.workout)
|
||||
if (err != nil) != tt.wantErr {
|
||||
t.Errorf("WorkoutService.Create() error = %v, wantErr %v", err, tt.wantErr)
|
||||
return
|
||||
}
|
||||
|
||||
if !tt.wantErr && createdWorkout.Name != tt.workout.Name {
|
||||
t.Errorf("WorkoutService.Create() got name %s, want %s", createdWorkout.Name, tt.workout.Name)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestWorkoutService_Update tests the Update method
|
||||
func TestWorkoutService_Update(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
workoutID string
|
||||
update WorkoutUpdate
|
||||
mockResponse *Workout
|
||||
mockStatusCode int
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
name: "successful update",
|
||||
workoutID: "123",
|
||||
update: WorkoutUpdate{
|
||||
Name: "Updated Workout",
|
||||
Description: "Updated description",
|
||||
},
|
||||
mockResponse: &Workout{
|
||||
WorkoutID: 123,
|
||||
Name: "Updated Workout",
|
||||
Description: "Updated description",
|
||||
},
|
||||
mockStatusCode: http.StatusOK,
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "workout not found",
|
||||
workoutID: "999",
|
||||
update: WorkoutUpdate{
|
||||
Name: "Updated Workout",
|
||||
},
|
||||
mockResponse: nil,
|
||||
mockStatusCode: http.StatusNotFound,
|
||||
wantErr: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
expectedPath := "/workout-service/workout/" + tt.workoutID
|
||||
if r.URL.Path != expectedPath {
|
||||
t.Errorf("expected path %s, got %s", expectedPath, r.URL.Path)
|
||||
}
|
||||
|
||||
body, _ := ioutil.ReadAll(r.Body)
|
||||
var receivedUpdate WorkoutUpdate
|
||||
json.Unmarshal(body, &receivedUpdate)
|
||||
|
||||
w.WriteHeader(tt.mockStatusCode)
|
||||
if tt.mockResponse != nil {
|
||||
json.NewEncoder(w).Encode(tt.mockResponse)
|
||||
}
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := &APIClient{baseURL: server.URL, httpClient: server.Client()}
|
||||
service := NewWorkoutService(client)
|
||||
|
||||
updatedWorkout, err := service.Update(context.Background(), tt.workoutID, tt.update)
|
||||
if (err != nil) != tt.wantErr {
|
||||
t.Errorf("WorkoutService.Update() error = %v, wantErr %v", err, tt.wantErr)
|
||||
return
|
||||
}
|
||||
|
||||
if !tt.wantErr && updatedWorkout.Name != tt.update.Name {
|
||||
t.Errorf("WorkoutService.Update() got name %s, want %s", updatedWorkout.Name, tt.update.Name)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestWorkoutService_Delete tests the Delete method
|
||||
func TestWorkoutService_Delete(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
workoutID string
|
||||
mockStatusCode int
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
name: "successful delete",
|
||||
workoutID: "123",
|
||||
mockStatusCode: http.StatusNoContent,
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "workout not found",
|
||||
workoutID: "999",
|
||||
mockStatusCode: http.StatusNotFound,
|
||||
wantErr: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
expectedPath := "/workout-service/workout/" + tt.workoutID
|
||||
if r.URL.Path != expectedPath {
|
||||
t.Errorf("expected path %s, got %s", expectedPath, r.URL.Path)
|
||||
}
|
||||
|
||||
w.WriteHeader(tt.mockStatusCode)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := &APIClient{baseURL: server.URL, httpClient: server.Client()}
|
||||
service := NewWorkoutService(client)
|
||||
|
||||
err := service.Delete(context.Background(), tt.workoutID)
|
||||
if (err != nil) != tt.wantErr {
|
||||
t.Errorf("WorkoutService.Delete() error = %v, wantErr %v", err, tt.wantErr)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestWorkoutService_SearchWorkouts tests the SearchWorkouts method
|
||||
func TestWorkoutService_SearchWorkouts(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
query string
|
||||
limit int
|
||||
mockResponse []Workout
|
||||
mockStatusCode int
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
name: "successful search",
|
||||
query: "running",
|
||||
limit: 10,
|
||||
mockResponse: []Workout{
|
||||
{WorkoutID: 1, Name: "Morning Run", Type: "running"},
|
||||
{WorkoutID: 2, Name: "Evening Run", Type: "running"},
|
||||
},
|
||||
mockStatusCode: http.StatusOK,
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "empty search results",
|
||||
query: "nonexistent",
|
||||
limit: 10,
|
||||
mockResponse: []Workout{},
|
||||
mockStatusCode: http.StatusOK,
|
||||
wantErr: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/workout-service/search" {
|
||||
t.Errorf("expected path /workout-service/search, got %s", r.URL.Path)
|
||||
}
|
||||
|
||||
w.WriteHeader(tt.mockStatusCode)
|
||||
if tt.mockResponse != nil {
|
||||
json.NewEncoder(w).Encode(tt.mockResponse)
|
||||
}
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := &APIClient{baseURL: server.URL, httpClient: server.Client()}
|
||||
service := NewWorkoutService(client)
|
||||
|
||||
workouts, err := service.SearchWorkouts(context.Background(), tt.query, tt.limit)
|
||||
if (err != nil) != tt.wantErr {
|
||||
t.Errorf("WorkoutService.SearchWorkouts() error = %v, wantErr %v", err, tt.wantErr)
|
||||
return
|
||||
}
|
||||
|
||||
if !tt.wantErr && len(workouts) != len(tt.mockResponse) {
|
||||
t.Errorf("WorkoutService.SearchWorkouts() got %d workouts, want %d", len(workouts), len(tt.mockResponse))
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestWorkoutService_GetWorkoutTemplates tests the GetWorkoutTemplates method
|
||||
func TestWorkoutService_GetWorkoutTemplates(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
mockResponse []Workout
|
||||
mockStatusCode int
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
name: "successful get templates",
|
||||
mockResponse: []Workout{
|
||||
{WorkoutID: 1, Name: "Template 1", Type: "running"},
|
||||
{WorkoutID: 2, Name: "Template 2", Type: "cycling"},
|
||||
},
|
||||
mockStatusCode: http.StatusOK,
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "no templates",
|
||||
mockResponse: []Workout{},
|
||||
mockStatusCode: http.StatusOK,
|
||||
wantErr: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/workout-service/templates" {
|
||||
t.Errorf("expected path /workout-service/templates, got %s", r.URL.Path)
|
||||
}
|
||||
|
||||
w.WriteHeader(tt.mockStatusCode)
|
||||
if tt.mockResponse != nil {
|
||||
json.NewEncoder(w).Encode(tt.mockResponse)
|
||||
}
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := &APIClient{baseURL: server.URL, httpClient: server.Client()}
|
||||
service := NewWorkoutService(client)
|
||||
|
||||
templates, err := service.GetWorkoutTemplates(context.Background())
|
||||
if (err != nil) != tt.wantErr {
|
||||
t.Errorf("WorkoutService.GetWorkoutTemplates() error = %v, wantErr %v", err, tt.wantErr)
|
||||
return
|
||||
}
|
||||
|
||||
if !tt.wantErr && len(templates) != len(tt.mockResponse) {
|
||||
t.Errorf("WorkoutService.GetWorkoutTemplates() got %d templates, want %d", len(templates), len(tt.mockResponse))
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestWorkoutService_CopyWorkout tests the CopyWorkout method
|
||||
func TestWorkoutService_CopyWorkout(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
workoutID string
|
||||
newName string
|
||||
mockResponse *Workout
|
||||
mockStatusCode int
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
name: "successful copy",
|
||||
workoutID: "123",
|
||||
newName: "Copied Workout",
|
||||
mockResponse: &Workout{
|
||||
WorkoutID: 456,
|
||||
Name: "Copied Workout",
|
||||
},
|
||||
mockStatusCode: http.StatusCreated,
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "workout not found",
|
||||
workoutID: "999",
|
||||
newName: "Copied Workout",
|
||||
mockResponse: nil,
|
||||
mockStatusCode: http.StatusNotFound,
|
||||
wantErr: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
expectedPath := "/workout-service/workout/" + tt.workoutID + "/copy"
|
||||
if r.URL.Path != expectedPath {
|
||||
t.Errorf("expected path %s, got %s", expectedPath, r.URL.Path)
|
||||
}
|
||||
|
||||
body, _ := ioutil.ReadAll(r.Body)
|
||||
var requestBody map[string]string
|
||||
json.Unmarshal(body, &requestBody)
|
||||
|
||||
if requestBody["name"] != tt.newName {
|
||||
t.Errorf("expected name %s, got %s", tt.newName, requestBody["name"])
|
||||
}
|
||||
|
||||
w.WriteHeader(tt.mockStatusCode)
|
||||
if tt.mockResponse != nil {
|
||||
json.NewEncoder(w).Encode(tt.mockResponse)
|
||||
}
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := &APIClient{baseURL: server.URL, httpClient: server.Client()}
|
||||
service := NewWorkoutService(client)
|
||||
|
||||
copiedWorkout, err := service.CopyWorkout(context.Background(), tt.workoutID, tt.newName)
|
||||
if (err != nil) != tt.wantErr {
|
||||
t.Errorf("WorkoutService.CopyWorkout() error = %v, wantErr %v", err, tt.wantErr)
|
||||
return
|
||||
}
|
||||
|
||||
if !tt.wantErr && copiedWorkout.Name != tt.newName {
|
||||
t.Errorf("WorkoutService.CopyWorkout() got name %s, want %s", copiedWorkout.Name, tt.newName)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestWorkoutService_Export tests the Export method
|
||||
func TestWorkoutService_Export(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
workoutID string
|
||||
format string
|
||||
mockStatusCode int
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
name: "successful export fit",
|
||||
workoutID: "123",
|
||||
format: "fit",
|
||||
mockStatusCode: http.StatusOK,
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "successful export tcx",
|
||||
workoutID: "123",
|
||||
format: "tcx",
|
||||
mockStatusCode: http.StatusOK,
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "invalid format",
|
||||
workoutID: "123",
|
||||
format: "invalid",
|
||||
mockStatusCode: http.StatusBadRequest,
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "workout not found",
|
||||
workoutID: "999",
|
||||
format: "fit",
|
||||
mockStatusCode: http.StatusNotFound,
|
||||
wantErr: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
expectedPath := "/download-service/export/" + tt.format + "/workout/" + tt.workoutID
|
||||
if r.URL.Path != expectedPath {
|
||||
t.Errorf("expected path %s, got %s", expectedPath, r.URL.Path)
|
||||
}
|
||||
|
||||
w.WriteHeader(tt.mockStatusCode)
|
||||
if !tt.wantErr {
|
||||
w.Write([]byte("test export data"))
|
||||
}
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := &APIClient{baseURL: server.URL, httpClient: server.Client()}
|
||||
service := NewWorkoutService(client)
|
||||
|
||||
reader, err := service.Export(context.Background(), tt.workoutID, tt.format)
|
||||
if (err != nil) != tt.wantErr {
|
||||
t.Errorf("WorkoutService.Export() error = %v, wantErr %v", err, tt.wantErr)
|
||||
return
|
||||
}
|
||||
|
||||
if !tt.wantErr {
|
||||
defer reader.Close()
|
||||
data, _ := ioutil.ReadAll(reader)
|
||||
if string(data) != "test export data" {
|
||||
t.Errorf("WorkoutService.Export() got unexpected export data")
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestWorkoutService_ContextMethods tests the new context-aware methods
|
||||
func TestWorkoutService_ContextMethods(t *testing.T) {
|
||||
t.Run("Create with context", func(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
json.NewEncoder(w).Encode(&Workout{WorkoutID: 123, Name: "Test Workout"})
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := &APIClient{baseURL: server.URL, httpClient: server.Client()}
|
||||
service := NewWorkoutService(client)
|
||||
|
||||
workout, err := service.Create(context.Background(), Workout{Name: "Test Workout"})
|
||||
if err != nil {
|
||||
t.Errorf("Create() error = %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
if workout.WorkoutID != 123 {
|
||||
t.Errorf("Create() got ID %d, want 123", workout.WorkoutID)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Get with context", func(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
json.NewEncoder(w).Encode(&WorkoutDetails{
|
||||
Workout: Workout{WorkoutID: 123, Name: "Test Workout"},
|
||||
})
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := &APIClient{baseURL: server.URL, httpClient: server.Client()}
|
||||
service := NewWorkoutService(client)
|
||||
|
||||
workout, err := service.Get(context.Background(), "123")
|
||||
if err != nil {
|
||||
t.Errorf("Get() error = %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
if workout.WorkoutID != 123 {
|
||||
t.Errorf("Get() got ID %d, want 123", workout.WorkoutID)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Update with context", func(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
json.NewEncoder(w).Encode(&Workout{WorkoutID: 123, Name: "Updated Workout"})
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := &APIClient{baseURL: server.URL, httpClient: server.Client()}
|
||||
service := NewWorkoutService(client)
|
||||
|
||||
updatedWorkout, err := service.Update(context.Background(), "123", WorkoutUpdate{Name: "Updated Workout"})
|
||||
if err != nil {
|
||||
t.Errorf("Update() error = %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
if updatedWorkout.Name != "Updated Workout" {
|
||||
t.Errorf("Update() got name %s, want 'Updated Workout'", updatedWorkout.Name)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Delete with context", func(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := &APIClient{baseURL: server.URL, httpClient: server.Client()}
|
||||
service := NewWorkoutService(client)
|
||||
|
||||
err := service.Delete(context.Background(), "123")
|
||||
if err != nil {
|
||||
t.Errorf("Delete() error = %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user