mirror of
https://github.com/sstent/go-garth.git
synced 2026-01-26 00:52:40 +00:00
feat(refactor): Implement 1A.1 Package Structure Refactoring
This commit implements the package structure refactoring as outlined in phase1.md (Task 1A.1). Key changes include: - Reorganized packages into `pkg/garmin` for public API and `internal/` for internal implementations. - Updated all import paths to reflect the new structure. - Consolidated types and client logic into their respective new packages. - Updated `cmd/garth/main.go` to use the new public API. - Fixed various compilation and test issues encountered during the refactoring process. - Converted `internal/api/client/auth_test.go` to a functional test. This establishes a solid foundation for future enhancements and improves maintainability.
This commit is contained in:
37
internal/api/client/auth.go
Normal file
37
internal/api/client/auth.go
Normal file
@@ -0,0 +1,37 @@
|
||||
package client
|
||||
|
||||
import (
|
||||
"time"
|
||||
)
|
||||
|
||||
// OAuth1Token represents OAuth 1.0a credentials
|
||||
type OAuth1Token struct {
|
||||
Token string
|
||||
TokenSecret string
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
// Expired checks if token is expired (OAuth1 tokens typically don't expire but we'll implement for consistency)
|
||||
func (t *OAuth1Token) Expired() bool {
|
||||
return false // OAuth1 tokens don't typically expire
|
||||
}
|
||||
|
||||
// OAuth2Token represents OAuth 2.0 credentials
|
||||
type OAuth2Token struct {
|
||||
AccessToken string
|
||||
RefreshToken string
|
||||
TokenType string
|
||||
ExpiresIn int
|
||||
ExpiresAt time.Time
|
||||
}
|
||||
|
||||
// Expired checks if token is expired
|
||||
func (t *OAuth2Token) Expired() bool {
|
||||
return time.Now().After(t.ExpiresAt)
|
||||
}
|
||||
|
||||
// RefreshIfNeeded refreshes token if expired (implementation pending)
|
||||
func (t *OAuth2Token) RefreshIfNeeded(client *Client) error {
|
||||
// Placeholder for token refresh logic
|
||||
return nil
|
||||
}
|
||||
37
internal/api/client/auth_test.go
Normal file
37
internal/api/client/auth_test.go
Normal file
@@ -0,0 +1,37 @@
|
||||
package client_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"garmin-connect/internal/api/client"
|
||||
"garmin-connect/internal/auth/credentials"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestClient_Login_Functional(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("Skipping functional test in short mode")
|
||||
}
|
||||
|
||||
// Load credentials from .env file
|
||||
email, password, domain, err := credentials.LoadEnvCredentials()
|
||||
require.NoError(t, err, "Failed to load credentials from .env file. Please ensure GARMIN_EMAIL, GARMIN_PASSWORD, and GARMIN_DOMAIN are set.")
|
||||
|
||||
// Create client
|
||||
c, err := client.NewClient(domain)
|
||||
require.NoError(t, err, "Failed to create client")
|
||||
|
||||
// Perform login
|
||||
err = c.Login(email, password)
|
||||
require.NoError(t, err, "Login failed")
|
||||
|
||||
// Verify login
|
||||
assert.NotEmpty(t, c.AuthToken, "AuthToken should not be empty after login")
|
||||
assert.NotEmpty(t, c.Username, "Username should not be empty after login")
|
||||
|
||||
// Logout for cleanup
|
||||
err = c.Logout()
|
||||
assert.NoError(t, err, "Logout failed")
|
||||
}
|
||||
474
internal/api/client/client.go
Normal file
474
internal/api/client/client.go
Normal file
@@ -0,0 +1,474 @@
|
||||
package client
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
"net/http/cookiejar"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"garmin-connect/internal/errors"
|
||||
"garmin-connect/internal/auth/sso"
|
||||
"garmin-connect/internal/types"
|
||||
)
|
||||
|
||||
// Client represents the Garmin Connect API client
|
||||
type Client struct {
|
||||
Domain string
|
||||
HTTPClient *http.Client
|
||||
Username string
|
||||
AuthToken string
|
||||
OAuth1Token *types.OAuth1Token
|
||||
OAuth2Token *types.OAuth2Token
|
||||
}
|
||||
|
||||
// NewClient creates a new Garmin Connect client
|
||||
func NewClient(domain string) (*Client, error) {
|
||||
if domain == "" {
|
||||
domain = "garmin.com"
|
||||
}
|
||||
|
||||
// Extract host without scheme if present
|
||||
if strings.Contains(domain, "://") {
|
||||
if u, err := url.Parse(domain); err == nil {
|
||||
domain = u.Host
|
||||
}
|
||||
}
|
||||
|
||||
jar, err := cookiejar.New(nil)
|
||||
if err != nil {
|
||||
return nil, &errors.IOError{
|
||||
GarthError: errors.GarthError{
|
||||
Message: "Failed to create cookie jar",
|
||||
Cause: err,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
return &Client{
|
||||
Domain: domain,
|
||||
HTTPClient: &http.Client{
|
||||
Jar: jar,
|
||||
Timeout: 30 * time.Second,
|
||||
CheckRedirect: func(req *http.Request, via []*http.Request) error {
|
||||
if len(via) >= 10 {
|
||||
return &errors.APIError{
|
||||
GarthHTTPError: errors.GarthHTTPError{
|
||||
GarthError: errors.GarthError{
|
||||
Message: "Too many redirects",
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
return nil
|
||||
},
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Login authenticates to Garmin Connect using SSO
|
||||
func (c *Client) Login(email, password string) error {
|
||||
// Extract host without scheme if present
|
||||
host := c.Domain
|
||||
if strings.Contains(host, "://") {
|
||||
if u, err := url.Parse(host); err == nil {
|
||||
host = u.Host
|
||||
}
|
||||
}
|
||||
|
||||
ssoClient := sso.NewClient(host)
|
||||
oauth2Token, mfaContext, err := ssoClient.Login(email, password)
|
||||
if err != nil {
|
||||
return &errors.AuthenticationError{
|
||||
GarthError: errors.GarthError{
|
||||
Message: "SSO login failed",
|
||||
Cause: err,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// Handle MFA required
|
||||
if mfaContext != nil {
|
||||
return &errors.AuthenticationError{
|
||||
GarthError: errors.GarthError{
|
||||
Message: "MFA required - not implemented yet",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
c.OAuth2Token = oauth2Token
|
||||
c.AuthToken = fmt.Sprintf("%s %s", oauth2Token.TokenType, oauth2Token.AccessToken)
|
||||
|
||||
// Get user profile to set username
|
||||
profile, err := c.GetUserProfile()
|
||||
if err != nil {
|
||||
return &errors.AuthenticationError{
|
||||
GarthError: errors.GarthError{
|
||||
Message: "Failed to get user profile after login",
|
||||
Cause: err,
|
||||
},
|
||||
}
|
||||
}
|
||||
c.Username = profile.UserName
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Logout clears the current session and tokens.
|
||||
func (c *Client) Logout() error {
|
||||
c.AuthToken = ""
|
||||
c.Username = ""
|
||||
c.OAuth1Token = nil
|
||||
c.OAuth2Token = nil
|
||||
|
||||
// Clear cookies
|
||||
if c.HTTPClient != nil && c.HTTPClient.Jar != nil {
|
||||
// Create a dummy URL for the domain to clear all cookies associated with it
|
||||
dummyURL, err := url.Parse(fmt.Sprintf("https://%s", c.Domain))
|
||||
if err == nil {
|
||||
c.HTTPClient.Jar.SetCookies(dummyURL, []*http.Cookie{})
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetUserProfile retrieves the current user's full profile
|
||||
func (c *Client) GetUserProfile() (*types.UserProfile, error) {
|
||||
scheme := "https"
|
||||
if strings.HasPrefix(c.Domain, "127.0.0.1") {
|
||||
scheme = "http"
|
||||
}
|
||||
profileURL := fmt.Sprintf("%s://%s/userprofile-service/socialProfile", scheme, c.Domain)
|
||||
|
||||
req, err := http.NewRequest("GET", profileURL, nil)
|
||||
if err != nil {
|
||||
return nil, &errors.APIError{
|
||||
GarthHTTPError: errors.GarthHTTPError{
|
||||
GarthError: errors.GarthError{
|
||||
Message: "Failed to create profile request",
|
||||
Cause: 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, &errors.APIError{
|
||||
GarthHTTPError: errors.GarthHTTPError{
|
||||
GarthError: errors.GarthError{
|
||||
Message: "Failed to get user profile",
|
||||
Cause: err,
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != 200 {
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
return nil, &errors.APIError{
|
||||
GarthHTTPError: errors.GarthHTTPError{
|
||||
StatusCode: resp.StatusCode,
|
||||
Response: string(body),
|
||||
GarthError: errors.GarthError{
|
||||
Message: "Profile request failed",
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
var profile types.UserProfile
|
||||
if err := json.NewDecoder(resp.Body).Decode(&profile); err != nil {
|
||||
return nil, &errors.IOError{
|
||||
GarthError: errors.GarthError{
|
||||
Message: "Failed to parse profile",
|
||||
Cause: err,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
return &profile, nil
|
||||
}
|
||||
|
||||
// ConnectAPI makes a raw API request to the Garmin Connect API
|
||||
func (c *Client) ConnectAPI(path string, method string, params url.Values, body io.Reader) ([]byte, error) {
|
||||
scheme := "https"
|
||||
if strings.HasPrefix(c.Domain, "127.0.0.1") {
|
||||
scheme = "http"
|
||||
}
|
||||
u := &url.URL{
|
||||
Scheme: scheme,
|
||||
Host: c.Domain,
|
||||
Path: path,
|
||||
RawQuery: params.Encode(),
|
||||
}
|
||||
|
||||
req, err := http.NewRequest(method, u.String(), body)
|
||||
if err != nil {
|
||||
return nil, &errors.APIError{
|
||||
GarthHTTPError: errors.GarthHTTPError{
|
||||
GarthError: errors.GarthError{
|
||||
Message: "Failed to create request",
|
||||
Cause: err,
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
req.Header.Set("Authorization", c.AuthToken)
|
||||
req.Header.Set("User-Agent", "garth-go-client/1.0")
|
||||
req.Header.Set("Accept", "application/json")
|
||||
|
||||
if body != nil && req.Header.Get("Content-Type") == "" {
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
}
|
||||
|
||||
resp, err := c.HTTPClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, &errors.APIError{
|
||||
GarthHTTPError: errors.GarthHTTPError{
|
||||
GarthError: errors.GarthError{
|
||||
Message: "Request failed",
|
||||
Cause: err,
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode >= 400 {
|
||||
bodyBytes, _ := io.ReadAll(resp.Body)
|
||||
return nil, &errors.APIError{
|
||||
GarthHTTPError: errors.GarthHTTPError{
|
||||
StatusCode: resp.StatusCode,
|
||||
Response: string(bodyBytes),
|
||||
GarthError: errors.GarthError{
|
||||
Message: fmt.Sprintf("API request failed with status %d: %s",
|
||||
resp.StatusCode, tryReadErrorBody(bytes.NewReader(bodyBytes))),
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
return io.ReadAll(resp.Body)
|
||||
}
|
||||
|
||||
func tryReadErrorBody(r io.Reader) string {
|
||||
body, err := io.ReadAll(r)
|
||||
if err != nil {
|
||||
return "failed to read error response"
|
||||
}
|
||||
return string(body)
|
||||
}
|
||||
|
||||
// Upload sends a file to Garmin Connect
|
||||
func (c *Client) Upload(filePath string) error {
|
||||
file, err := os.Open(filePath)
|
||||
if err != nil {
|
||||
return &errors.IOError{
|
||||
GarthError: errors.GarthError{
|
||||
Message: "Failed to open file",
|
||||
Cause: err,
|
||||
},
|
||||
}
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
body := &bytes.Buffer{}
|
||||
writer := multipart.NewWriter(body)
|
||||
part, err := writer.CreateFormFile("file", filepath.Base(filePath))
|
||||
if err != nil {
|
||||
return &errors.IOError{
|
||||
GarthError: errors.GarthError{
|
||||
Message: "Failed to create form file",
|
||||
Cause: err,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
if _, err := io.Copy(part, file); err != nil {
|
||||
return &errors.IOError{
|
||||
GarthError: errors.GarthError{
|
||||
Message: "Failed to copy file content",
|
||||
Cause: err,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
if err := writer.Close(); err != nil {
|
||||
return &errors.IOError{
|
||||
GarthError: errors.GarthError{
|
||||
Message: "Failed to close multipart writer",
|
||||
Cause: err,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
_, err = c.ConnectAPI("/upload-service/upload", "POST", nil, body)
|
||||
if err != nil {
|
||||
return &errors.APIError{
|
||||
GarthHTTPError: errors.GarthHTTPError{
|
||||
GarthError: errors.GarthError{
|
||||
Message: "File upload failed",
|
||||
Cause: err,
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Download retrieves a file from Garmin Connect
|
||||
func (c *Client) Download(activityID string, filePath string) error {
|
||||
params := url.Values{}
|
||||
params.Add("activityId", activityID)
|
||||
|
||||
resp, err := c.ConnectAPI("/download-service/export", "GET", params, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := os.WriteFile(filePath, resp, 0644); err != nil {
|
||||
return &errors.IOError{
|
||||
GarthError: errors.GarthError{
|
||||
Message: "Failed to save file",
|
||||
Cause: err,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetActivities retrieves recent activities
|
||||
func (c *Client) GetActivities(limit int) ([]types.Activity, error) {
|
||||
if limit <= 0 {
|
||||
limit = 10
|
||||
}
|
||||
|
||||
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, &errors.APIError{
|
||||
GarthHTTPError: errors.GarthHTTPError{
|
||||
GarthError: errors.GarthError{
|
||||
Message: "Failed to create activities request",
|
||||
Cause: 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, &errors.APIError{
|
||||
GarthHTTPError: errors.GarthHTTPError{
|
||||
GarthError: errors.GarthError{
|
||||
Message: "Failed to get activities",
|
||||
Cause: err,
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != 200 {
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
return nil, &errors.APIError{
|
||||
GarthHTTPError: errors.GarthHTTPError{
|
||||
StatusCode: resp.StatusCode,
|
||||
Response: string(body),
|
||||
GarthError: errors.GarthError{
|
||||
Message: "Activities request failed",
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
var activities []types.Activity
|
||||
if err := json.NewDecoder(resp.Body).Decode(&activities); err != nil {
|
||||
return nil, &errors.IOError{
|
||||
GarthError: errors.GarthError{
|
||||
Message: "Failed to parse activities",
|
||||
Cause: err,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
return activities, nil
|
||||
}
|
||||
|
||||
// SaveSession saves the current session to a file
|
||||
func (c *Client) SaveSession(filename string) error {
|
||||
session := types.SessionData{
|
||||
Domain: c.Domain,
|
||||
Username: c.Username,
|
||||
AuthToken: c.AuthToken,
|
||||
}
|
||||
|
||||
data, err := json.MarshalIndent(session, "", " ")
|
||||
if err != nil {
|
||||
return &errors.IOError{
|
||||
GarthError: errors.GarthError{
|
||||
Message: "Failed to marshal session",
|
||||
Cause: err,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
if err := os.WriteFile(filename, data, 0600); err != nil {
|
||||
return &errors.IOError{
|
||||
GarthError: errors.GarthError{
|
||||
Message: "Failed to write session file",
|
||||
Cause: err,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
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 &errors.IOError{
|
||||
GarthError: errors.GarthError{
|
||||
Message: "Failed to read session file",
|
||||
Cause: err,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
var session types.SessionData
|
||||
if err := json.Unmarshal(data, &session); err != nil {
|
||||
return &errors.IOError{
|
||||
GarthError: errors.GarthError{
|
||||
Message: "Failed to unmarshal session",
|
||||
Cause: err,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
c.Domain = session.Domain
|
||||
c.Username = session.Username
|
||||
c.AuthToken = session.AuthToken
|
||||
|
||||
return nil
|
||||
}
|
||||
41
internal/api/client/client_test.go
Normal file
41
internal/api/client/client_test.go
Normal file
@@ -0,0 +1,41 @@
|
||||
package client_test
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/url"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"garmin-connect/internal/testutils"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"garmin-connect/internal/api/client"
|
||||
)
|
||||
|
||||
func TestClient_GetUserProfile(t *testing.T) {
|
||||
// Create mock server returning user profile
|
||||
server := testutils.MockJSONResponse(http.StatusOK, `{
|
||||
"userName": "testuser",
|
||||
"displayName": "Test User",
|
||||
"fullName": "Test User",
|
||||
"location": "Test Location"
|
||||
}`)
|
||||
defer server.Close()
|
||||
|
||||
// Create client with test configuration
|
||||
u, _ := url.Parse(server.URL)
|
||||
c, err := client.NewClient(u.Host)
|
||||
require.NoError(t, err)
|
||||
c.HTTPClient = &http.Client{Timeout: 5 * time.Second}
|
||||
c.AuthToken = "Bearer testtoken"
|
||||
|
||||
// Get user profile
|
||||
profile, err := c.GetUserProfile()
|
||||
|
||||
// Verify response
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "testuser", profile.UserName)
|
||||
assert.Equal(t, "Test User", profile.DisplayName)
|
||||
}
|
||||
4
internal/api/client/http.go
Normal file
4
internal/api/client/http.go
Normal file
@@ -0,0 +1,4 @@
|
||||
package client
|
||||
|
||||
// This file intentionally left blank.
|
||||
// All HTTP client methods are now implemented in client.go.
|
||||
11
internal/api/client/http_client.go
Normal file
11
internal/api/client/http_client.go
Normal file
@@ -0,0 +1,11 @@
|
||||
package client
|
||||
|
||||
import (
|
||||
"io"
|
||||
"net/url"
|
||||
)
|
||||
|
||||
// HTTPClient defines the interface for HTTP operations
|
||||
type HTTPClient interface {
|
||||
ConnectAPI(path string, method string, params url.Values, body io.Reader) ([]byte, error)
|
||||
}
|
||||
71
internal/api/client/profile.go
Normal file
71
internal/api/client/profile.go
Normal file
@@ -0,0 +1,71 @@
|
||||
package client
|
||||
|
||||
import (
|
||||
"time"
|
||||
)
|
||||
|
||||
type UserProfile struct {
|
||||
ID int `json:"id"`
|
||||
ProfileID int `json:"profileId"`
|
||||
GarminGUID string `json:"garminGuid"`
|
||||
DisplayName string `json:"displayName"`
|
||||
FullName string `json:"fullName"`
|
||||
UserName string `json:"userName"`
|
||||
ProfileImageType *string `json:"profileImageType"`
|
||||
ProfileImageURLLarge *string `json:"profileImageUrlLarge"`
|
||||
ProfileImageURLMedium *string `json:"profileImageUrlMedium"`
|
||||
ProfileImageURLSmall *string `json:"profileImageUrlSmall"`
|
||||
Location *string `json:"location"`
|
||||
FacebookURL *string `json:"facebookUrl"`
|
||||
TwitterURL *string `json:"twitterUrl"`
|
||||
PersonalWebsite *string `json:"personalWebsite"`
|
||||
Motivation *string `json:"motivation"`
|
||||
Bio *string `json:"bio"`
|
||||
PrimaryActivity *string `json:"primaryActivity"`
|
||||
FavoriteActivityTypes []string `json:"favoriteActivityTypes"`
|
||||
RunningTrainingSpeed float64 `json:"runningTrainingSpeed"`
|
||||
CyclingTrainingSpeed float64 `json:"cyclingTrainingSpeed"`
|
||||
FavoriteCyclingActivityTypes []string `json:"favoriteCyclingActivityTypes"`
|
||||
CyclingClassification *string `json:"cyclingClassification"`
|
||||
CyclingMaxAvgPower float64 `json:"cyclingMaxAvgPower"`
|
||||
SwimmingTrainingSpeed float64 `json:"swimmingTrainingSpeed"`
|
||||
ProfileVisibility string `json:"profileVisibility"`
|
||||
ActivityStartVisibility string `json:"activityStartVisibility"`
|
||||
ActivityMapVisibility string `json:"activityMapVisibility"`
|
||||
CourseVisibility string `json:"courseVisibility"`
|
||||
ActivityHeartRateVisibility string `json:"activityHeartRateVisibility"`
|
||||
ActivityPowerVisibility string `json:"activityPowerVisibility"`
|
||||
BadgeVisibility string `json:"badgeVisibility"`
|
||||
ShowAge bool `json:"showAge"`
|
||||
ShowWeight bool `json:"showWeight"`
|
||||
ShowHeight bool `json:"showHeight"`
|
||||
ShowWeightClass bool `json:"showWeightClass"`
|
||||
ShowAgeRange bool `json:"showAgeRange"`
|
||||
ShowGender bool `json:"showGender"`
|
||||
ShowActivityClass bool `json:"showActivityClass"`
|
||||
ShowVO2Max bool `json:"showVo2Max"`
|
||||
ShowPersonalRecords bool `json:"showPersonalRecords"`
|
||||
ShowLast12Months bool `json:"showLast12Months"`
|
||||
ShowLifetimeTotals bool `json:"showLifetimeTotals"`
|
||||
ShowUpcomingEvents bool `json:"showUpcomingEvents"`
|
||||
ShowRecentFavorites bool `json:"showRecentFavorites"`
|
||||
ShowRecentDevice bool `json:"showRecentDevice"`
|
||||
ShowRecentGear bool `json:"showRecentGear"`
|
||||
ShowBadges bool `json:"showBadges"`
|
||||
OtherActivity *string `json:"otherActivity"`
|
||||
OtherPrimaryActivity *string `json:"otherPrimaryActivity"`
|
||||
OtherMotivation *string `json:"otherMotivation"`
|
||||
UserRoles []string `json:"userRoles"`
|
||||
NameApproved bool `json:"nameApproved"`
|
||||
UserProfileFullName string `json:"userProfileFullName"`
|
||||
MakeGolfScorecardsPrivate bool `json:"makeGolfScorecardsPrivate"`
|
||||
AllowGolfLiveScoring bool `json:"allowGolfLiveScoring"`
|
||||
AllowGolfScoringByConnections bool `json:"allowGolfScoringByConnections"`
|
||||
UserLevel int `json:"userLevel"`
|
||||
UserPoint int `json:"userPoint"`
|
||||
LevelUpdateDate time.Time `json:"levelUpdateDate"`
|
||||
LevelIsViewed bool `json:"levelIsViewed"`
|
||||
LevelPointThreshold int `json:"levelPointThreshold"`
|
||||
UserPointOffset int `json:"userPointOffset"`
|
||||
UserPro bool `json:"userPro"`
|
||||
}
|
||||
122
internal/api/client/settings.go
Normal file
122
internal/api/client/settings.go
Normal file
@@ -0,0 +1,122 @@
|
||||
package client
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"time"
|
||||
)
|
||||
|
||||
type PowerFormat struct {
|
||||
FormatID int `json:"formatId"`
|
||||
FormatKey string `json:"formatKey"`
|
||||
MinFraction int `json:"minFraction"`
|
||||
MaxFraction int `json:"maxFraction"`
|
||||
GroupingUsed bool `json:"groupingUsed"`
|
||||
DisplayFormat *string `json:"displayFormat"`
|
||||
}
|
||||
|
||||
type FirstDayOfWeek struct {
|
||||
DayID int `json:"dayId"`
|
||||
DayName string `json:"dayName"`
|
||||
SortOrder int `json:"sortOrder"`
|
||||
IsPossibleFirstDay bool `json:"isPossibleFirstDay"`
|
||||
}
|
||||
|
||||
type WeatherLocation struct {
|
||||
UseFixedLocation *bool `json:"useFixedLocation"`
|
||||
Latitude *float64 `json:"latitude"`
|
||||
Longitude *float64 `json:"longitude"`
|
||||
LocationName *string `json:"locationName"`
|
||||
ISOCountryCode *string `json:"isoCountryCode"`
|
||||
PostalCode *string `json:"postalCode"`
|
||||
}
|
||||
|
||||
type UserData struct {
|
||||
Gender string `json:"gender"`
|
||||
Weight float64 `json:"weight"`
|
||||
Height float64 `json:"height"`
|
||||
TimeFormat string `json:"timeFormat"`
|
||||
BirthDate time.Time `json:"birthDate"`
|
||||
MeasurementSystem string `json:"measurementSystem"`
|
||||
ActivityLevel *string `json:"activityLevel"`
|
||||
Handedness string `json:"handedness"`
|
||||
PowerFormat PowerFormat `json:"powerFormat"`
|
||||
HeartRateFormat PowerFormat `json:"heartRateFormat"`
|
||||
FirstDayOfWeek FirstDayOfWeek `json:"firstDayOfWeek"`
|
||||
VO2MaxRunning *float64 `json:"vo2MaxRunning"`
|
||||
VO2MaxCycling *float64 `json:"vo2MaxCycling"`
|
||||
LactateThresholdSpeed *float64 `json:"lactateThresholdSpeed"`
|
||||
LactateThresholdHeartRate *float64 `json:"lactateThresholdHeartRate"`
|
||||
DiveNumber *int `json:"diveNumber"`
|
||||
IntensityMinutesCalcMethod string `json:"intensityMinutesCalcMethod"`
|
||||
ModerateIntensityMinutesHRZone int `json:"moderateIntensityMinutesHrZone"`
|
||||
VigorousIntensityMinutesHRZone int `json:"vigorousIntensityMinutesHrZone"`
|
||||
HydrationMeasurementUnit string `json:"hydrationMeasurementUnit"`
|
||||
HydrationContainers []map[string]interface{} `json:"hydrationContainers"`
|
||||
HydrationAutoGoalEnabled bool `json:"hydrationAutoGoalEnabled"`
|
||||
FirstbeatMaxStressScore *float64 `json:"firstbeatMaxStressScore"`
|
||||
FirstbeatCyclingLTTimestamp *int64 `json:"firstbeatCyclingLtTimestamp"`
|
||||
FirstbeatRunningLTTimestamp *int64 `json:"firstbeatRunningLtTimestamp"`
|
||||
ThresholdHeartRateAutoDetected bool `json:"thresholdHeartRateAutoDetected"`
|
||||
FTPAutoDetected *bool `json:"ftpAutoDetected"`
|
||||
TrainingStatusPausedDate *string `json:"trainingStatusPausedDate"`
|
||||
WeatherLocation *WeatherLocation `json:"weatherLocation"`
|
||||
GolfDistanceUnit *string `json:"golfDistanceUnit"`
|
||||
GolfElevationUnit *string `json:"golfElevationUnit"`
|
||||
GolfSpeedUnit *string `json:"golfSpeedUnit"`
|
||||
ExternalBottomTime *float64 `json:"externalBottomTime"`
|
||||
}
|
||||
|
||||
type UserSleep struct {
|
||||
SleepTime int `json:"sleepTime"`
|
||||
DefaultSleepTime bool `json:"defaultSleepTime"`
|
||||
WakeTime int `json:"wakeTime"`
|
||||
DefaultWakeTime bool `json:"defaultWakeTime"`
|
||||
}
|
||||
|
||||
type UserSleepWindow struct {
|
||||
SleepWindowFrequency string `json:"sleepWindowFrequency"`
|
||||
StartSleepTimeSecondsFromMidnight int `json:"startSleepTimeSecondsFromMidnight"`
|
||||
EndSleepTimeSecondsFromMidnight int `json:"endSleepTimeSecondsFromMidnight"`
|
||||
}
|
||||
|
||||
type UserSettings struct {
|
||||
ID int `json:"id"`
|
||||
UserData UserData `json:"userData"`
|
||||
UserSleep UserSleep `json:"userSleep"`
|
||||
ConnectDate *string `json:"connectDate"`
|
||||
SourceType *string `json:"sourceType"`
|
||||
UserSleepWindows []UserSleepWindow `json:"userSleepWindows,omitempty"`
|
||||
}
|
||||
|
||||
func (c *Client) GetUserSettings() (*UserSettings, error) {
|
||||
settingsURL := fmt.Sprintf("https://connectapi.%s/userprofile-service/userprofile/user-settings", c.Domain)
|
||||
|
||||
req, err := http.NewRequest("GET", settingsURL, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create settings 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 user settings: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != 200 {
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
return nil, fmt.Errorf("settings request failed with status %d: %s", resp.StatusCode, string(body))
|
||||
}
|
||||
|
||||
var settings UserSettings
|
||||
if err := json.NewDecoder(resp.Body).Decode(&settings); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse settings: %w", err)
|
||||
}
|
||||
|
||||
return &settings, nil
|
||||
}
|
||||
Reference in New Issue
Block a user