mirror of
https://github.com/sstent/go-garth-cli.git
synced 2026-01-25 16:42:48 +00:00
sync
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"
|
||||
|
||||
"go-garth/internal/api/client"
|
||||
"go-garth/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")
|
||||
}
|
||||
964
internal/api/client/client.go
Normal file
964
internal/api/client/client.go
Normal file
@@ -0,0 +1,964 @@
|
||||
package client
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
"net/http/cookiejar"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"go-garth/internal/auth/sso"
|
||||
"go-garth/internal/errors"
|
||||
types "go-garth/internal/models/types"
|
||||
shared "go-garth/shared/interfaces"
|
||||
models "go-garth/shared/models"
|
||||
)
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
// Verify that Client implements shared.APIClient
|
||||
var _ shared.APIClient = (*Client)(nil)
|
||||
|
||||
// GetUsername returns the authenticated username
|
||||
func (c *Client) GetUsername() string {
|
||||
return c.Username
|
||||
}
|
||||
|
||||
// GetUserSettings retrieves the current user's settings
|
||||
func (c *Client) GetUserSettings() (*models.UserSettings, error) {
|
||||
scheme := "https"
|
||||
if strings.HasPrefix(c.Domain, "127.0.0.1") {
|
||||
scheme = "http"
|
||||
}
|
||||
host := c.Domain
|
||||
if !strings.HasPrefix(c.Domain, "127.0.0.1") {
|
||||
host = "connectapi." + c.Domain
|
||||
}
|
||||
settingsURL := fmt.Sprintf("%s://%s/userprofile-service/userprofile/user-settings", scheme, host)
|
||||
|
||||
req, err := http.NewRequest("GET", settingsURL, nil)
|
||||
if err != nil {
|
||||
return nil, &errors.APIError{
|
||||
GarthHTTPError: errors.GarthHTTPError{
|
||||
GarthError: errors.GarthError{
|
||||
Message: "Failed to create user settings 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 settings",
|
||||
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: "User settings request failed",
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
var settings models.UserSettings
|
||||
if err := json.NewDecoder(resp.Body).Decode(&settings); err != nil {
|
||||
return nil, &errors.IOError{
|
||||
GarthError: errors.GarthError{
|
||||
Message: "Failed to parse user settings",
|
||||
Cause: err,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
return &settings, nil
|
||||
}
|
||||
|
||||
// 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(c.Domain)
|
||||
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"
|
||||
}
|
||||
host := c.Domain
|
||||
if !strings.HasPrefix(c.Domain, "127.0.0.1") {
|
||||
host = "connectapi." + c.Domain
|
||||
}
|
||||
profileURL := fmt.Sprintf("%s://%s/userprofile-service/socialProfile", scheme, host)
|
||||
|
||||
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, format string, filePath string) error {
|
||||
params := url.Values{}
|
||||
params.Add("activityId", activityID)
|
||||
// Add format parameter if provided and not empty
|
||||
if format != "" {
|
||||
params.Add("format", format)
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
scheme := "https"
|
||||
if strings.HasPrefix(c.Domain, "127.0.0.1") {
|
||||
scheme = "http"
|
||||
}
|
||||
|
||||
activitiesURL := fmt.Sprintf("%s://connectapi.%s/activitylist-service/activities/search/activities?limit=%d&start=0", scheme, 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
|
||||
}
|
||||
|
||||
func (c *Client) GetSleepData(startDate, endDate time.Time) ([]types.SleepData, error) {
|
||||
// TODO: Implement GetSleepData
|
||||
return nil, fmt.Errorf("GetSleepData not implemented")
|
||||
}
|
||||
|
||||
// GetHrvData retrieves HRV data for a specified number of days
|
||||
func (c *Client) GetHrvData(days int) ([]types.HrvData, error) {
|
||||
// TODO: Implement GetHrvData
|
||||
return nil, fmt.Errorf("GetHrvData not implemented")
|
||||
}
|
||||
|
||||
// GetStressData retrieves stress data
|
||||
func (c *Client) GetStressData(startDate, endDate time.Time) ([]types.StressData, error) {
|
||||
// TODO: Implement GetStressData
|
||||
return nil, fmt.Errorf("GetStressData not implemented")
|
||||
}
|
||||
|
||||
// GetBodyBatteryData retrieves Body Battery data
|
||||
func (c *Client) GetBodyBatteryData(startDate, endDate time.Time) ([]types.BodyBatteryData, error) {
|
||||
// TODO: Implement GetBodyBatteryData
|
||||
return nil, fmt.Errorf("GetBodyBatteryData not implemented")
|
||||
}
|
||||
|
||||
// GetStepsData retrieves steps data for a specified date range
|
||||
func (c *Client) GetStepsData(startDate, endDate time.Time) ([]types.StepsData, error) {
|
||||
// TODO: Implement GetStepsData
|
||||
return nil, fmt.Errorf("GetStepsData not implemented")
|
||||
}
|
||||
|
||||
// GetDistanceData retrieves distance data for a specified date range
|
||||
func (c *Client) GetDistanceData(startDate, endDate time.Time) ([]types.DistanceData, error) {
|
||||
// TODO: Implement GetDistanceData
|
||||
return nil, fmt.Errorf("GetDistanceData not implemented")
|
||||
}
|
||||
|
||||
// GetCaloriesData retrieves calories data for a specified date range
|
||||
func (c *Client) GetCaloriesData(startDate, endDate time.Time) ([]types.CaloriesData, error) {
|
||||
// TODO: Implement GetCaloriesData
|
||||
return nil, fmt.Errorf("GetCaloriesData not implemented")
|
||||
}
|
||||
|
||||
// GetVO2MaxData retrieves VO2 max data using the modern approach via user settings
|
||||
func (c *Client) GetVO2MaxData(startDate, endDate time.Time) ([]types.VO2MaxData, error) {
|
||||
// Get user settings which contains current VO2 max values
|
||||
settings, err := c.GetUserSettings()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get user settings: %w", err)
|
||||
}
|
||||
|
||||
// Create VO2MaxData for the date range
|
||||
var results []types.VO2MaxData
|
||||
current := startDate
|
||||
for !current.After(endDate) {
|
||||
vo2Data := types.VO2MaxData{
|
||||
Date: current,
|
||||
UserProfilePK: settings.ID,
|
||||
}
|
||||
|
||||
// Set VO2 max values if available
|
||||
if settings.UserData.VO2MaxRunning != nil {
|
||||
vo2Data.VO2MaxRunning = settings.UserData.VO2MaxRunning
|
||||
}
|
||||
if settings.UserData.VO2MaxCycling != nil {
|
||||
vo2Data.VO2MaxCycling = settings.UserData.VO2MaxCycling
|
||||
}
|
||||
|
||||
results = append(results, vo2Data)
|
||||
current = current.AddDate(0, 0, 1)
|
||||
}
|
||||
|
||||
return results, nil
|
||||
}
|
||||
|
||||
// GetCurrentVO2Max retrieves the current VO2 max values from user profile
|
||||
func (c *Client) GetCurrentVO2Max() (*types.VO2MaxProfile, error) {
|
||||
settings, err := c.GetUserSettings()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get user settings: %w", err)
|
||||
}
|
||||
|
||||
profile := &types.VO2MaxProfile{
|
||||
UserProfilePK: settings.ID,
|
||||
LastUpdated: time.Now(),
|
||||
}
|
||||
|
||||
// Add running VO2 max if available
|
||||
if settings.UserData.VO2MaxRunning != nil && *settings.UserData.VO2MaxRunning > 0 {
|
||||
profile.Running = &types.VO2MaxEntry{
|
||||
Value: *settings.UserData.VO2MaxRunning,
|
||||
ActivityType: "running",
|
||||
Date: time.Now(),
|
||||
Source: "user_settings",
|
||||
}
|
||||
}
|
||||
|
||||
// Add cycling VO2 max if available
|
||||
if settings.UserData.VO2MaxCycling != nil && *settings.UserData.VO2MaxCycling > 0 {
|
||||
profile.Cycling = &types.VO2MaxEntry{
|
||||
Value: *settings.UserData.VO2MaxCycling,
|
||||
ActivityType: "cycling",
|
||||
Date: time.Now(),
|
||||
Source: "user_settings",
|
||||
}
|
||||
}
|
||||
|
||||
return profile, nil
|
||||
}
|
||||
|
||||
// GetHeartRateZones retrieves heart rate zone data
|
||||
func (c *Client) GetHeartRateZones() (*types.HeartRateZones, error) {
|
||||
scheme := "https"
|
||||
if strings.HasPrefix(c.Domain, "127.0.0.1") {
|
||||
scheme = "http"
|
||||
}
|
||||
|
||||
hrzURL := fmt.Sprintf("%s://connectapi.%s/userprofile-service/userprofile/heartRateZones", scheme, c.Domain)
|
||||
|
||||
req, err := http.NewRequest("GET", hrzURL, nil)
|
||||
if err != nil {
|
||||
return nil, &errors.APIError{
|
||||
GarthHTTPError: errors.GarthHTTPError{
|
||||
GarthError: errors.GarthError{
|
||||
Message: "Failed to create HR zones 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 HR zones data",
|
||||
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: "HR zones request failed",
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
var hrZones types.HeartRateZones
|
||||
if err := json.NewDecoder(resp.Body).Decode(&hrZones); err != nil {
|
||||
return nil, &errors.IOError{
|
||||
GarthError: errors.GarthError{
|
||||
Message: "Failed to parse HR zones data",
|
||||
Cause: err,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
return &hrZones, nil
|
||||
}
|
||||
|
||||
// GetWellnessData retrieves comprehensive wellness data for a specified date range
|
||||
func (c *Client) GetWellnessData(startDate, endDate time.Time) ([]types.WellnessData, error) {
|
||||
scheme := "https"
|
||||
if strings.HasPrefix(c.Domain, "127.0.0.1") {
|
||||
scheme = "http"
|
||||
}
|
||||
|
||||
params := url.Values{}
|
||||
params.Add("startDate", startDate.Format("2006-01-02"))
|
||||
params.Add("endDate", endDate.Format("2006-01-02"))
|
||||
|
||||
wellnessURL := fmt.Sprintf("%s://connectapi.%s/wellness-service/wellness/daily/wellness?%s", scheme, c.Domain, params.Encode())
|
||||
|
||||
req, err := http.NewRequest("GET", wellnessURL, nil)
|
||||
if err != nil {
|
||||
return nil, &errors.APIError{
|
||||
GarthHTTPError: errors.GarthHTTPError{
|
||||
GarthError: errors.GarthError{
|
||||
Message: "Failed to create wellness data 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 wellness data",
|
||||
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: "Wellness data request failed",
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
var wellnessData []types.WellnessData
|
||||
if err := json.NewDecoder(resp.Body).Decode(&wellnessData); err != nil {
|
||||
return nil, &errors.IOError{
|
||||
GarthError: errors.GarthError{
|
||||
Message: "Failed to parse wellness data",
|
||||
Cause: err,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
return wellnessData, 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
|
||||
}
|
||||
|
||||
// GetDetailedSleepData retrieves comprehensive sleep data for a date
|
||||
func (c *Client) GetDetailedSleepData(date time.Time) (*types.DetailedSleepData, error) {
|
||||
dateStr := date.Format("2006-01-02")
|
||||
path := fmt.Sprintf("/wellness-service/wellness/dailySleepData/%s?date=%s&nonSleepBufferMinutes=60",
|
||||
c.Username, dateStr)
|
||||
|
||||
data, err := c.ConnectAPI(path, "GET", nil, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get detailed sleep data: %w", err)
|
||||
}
|
||||
|
||||
if len(data) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
var response struct {
|
||||
DailySleepDTO *types.DetailedSleepData `json:"dailySleepDTO"`
|
||||
SleepMovement []types.SleepMovement `json:"sleepMovement"`
|
||||
RemSleepData bool `json:"remSleepData"`
|
||||
SleepLevels []types.SleepLevel `json:"sleepLevels"`
|
||||
SleepRestlessMoments []interface{} `json:"sleepRestlessMoments"`
|
||||
RestlessMomentsCount int `json:"restlessMomentsCount"`
|
||||
WellnessSpO2SleepSummaryDTO interface{} `json:"wellnessSpO2SleepSummaryDTO"`
|
||||
WellnessEpochSPO2DataDTOList []interface{} `json:"wellnessEpochSPO2DataDTOList"`
|
||||
WellnessEpochRespirationDataDTOList []interface{} `json:"wellnessEpochRespirationDataDTOList"`
|
||||
SleepStress interface{} `json:"sleepStress"`
|
||||
}
|
||||
|
||||
if err := json.Unmarshal(data, &response); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse detailed sleep response: %w", err)
|
||||
}
|
||||
|
||||
if response.DailySleepDTO == nil {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// Populate additional data
|
||||
response.DailySleepDTO.SleepMovement = response.SleepMovement
|
||||
response.DailySleepDTO.SleepLevels = response.SleepLevels
|
||||
|
||||
return response.DailySleepDTO, nil
|
||||
}
|
||||
|
||||
// GetDailyHRVData retrieves comprehensive daily HRV data for a date
|
||||
func (c *Client) GetDailyHRVData(date time.Time) (*types.DailyHRVData, error) {
|
||||
dateStr := date.Format("2006-01-02")
|
||||
path := fmt.Sprintf("/wellness-service/wellness/dailyHrvData/%s?date=%s",
|
||||
c.Username, dateStr)
|
||||
|
||||
data, err := c.ConnectAPI(path, "GET", nil, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get HRV data: %w", err)
|
||||
}
|
||||
|
||||
if len(data) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
var response struct {
|
||||
HRVSummary types.DailyHRVData `json:"hrvSummary"`
|
||||
HRVReadings []types.HRVReading `json:"hrvReadings"`
|
||||
}
|
||||
|
||||
if err := json.Unmarshal(data, &response); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse HRV response: %w", err)
|
||||
}
|
||||
|
||||
// Combine summary and readings
|
||||
response.HRVSummary.HRVReadings = response.HRVReadings
|
||||
return &response.HRVSummary, nil
|
||||
}
|
||||
|
||||
// GetDetailedBodyBatteryData retrieves comprehensive Body Battery data for a date
|
||||
func (c *Client) GetDetailedBodyBatteryData(date time.Time) (*types.DetailedBodyBatteryData, error) {
|
||||
dateStr := date.Format("2006-01-02")
|
||||
|
||||
// Get main Body Battery data
|
||||
path1 := fmt.Sprintf("/wellness-service/wellness/dailyStress/%s", dateStr)
|
||||
data1, err := c.ConnectAPI(path1, "GET", nil, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get Body Battery stress data: %w", err)
|
||||
}
|
||||
|
||||
// Get Body Battery events
|
||||
path2 := fmt.Sprintf("/wellness-service/wellness/bodyBattery/%s", dateStr)
|
||||
data2, err := c.ConnectAPI(path2, "GET", nil, nil)
|
||||
if err != nil {
|
||||
// Events might not be available, continue without them
|
||||
data2 = []byte("[]")
|
||||
}
|
||||
|
||||
var result types.DetailedBodyBatteryData
|
||||
if len(data1) > 0 {
|
||||
if err := json.Unmarshal(data1, &result); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse Body Battery data: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
var events []types.BodyBatteryEvent
|
||||
if len(data2) > 0 {
|
||||
if err := json.Unmarshal(data2, &events); err == nil {
|
||||
result.Events = events
|
||||
}
|
||||
}
|
||||
|
||||
return &result, nil
|
||||
}
|
||||
|
||||
// GetTrainingStatus retrieves current training status
|
||||
func (c *Client) GetTrainingStatus(date time.Time) (*types.TrainingStatus, error) {
|
||||
dateStr := date.Format("2006-01-02")
|
||||
path := fmt.Sprintf("/metrics-service/metrics/trainingStatus/%s", dateStr)
|
||||
|
||||
data, err := c.ConnectAPI(path, "GET", nil, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get training status: %w", err)
|
||||
}
|
||||
|
||||
if len(data) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
var result types.TrainingStatus
|
||||
if err := json.Unmarshal(data, &result); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse training status: %w", err)
|
||||
}
|
||||
|
||||
return &result, nil
|
||||
}
|
||||
|
||||
// GetTrainingLoad retrieves training load data
|
||||
func (c *Client) GetTrainingLoad(date time.Time) (*types.TrainingLoad, error) {
|
||||
dateStr := date.Format("2006-01-02")
|
||||
endDate := date.AddDate(0, 0, 6).Format("2006-01-02") // Get week of data
|
||||
path := fmt.Sprintf("/metrics-service/metrics/trainingLoad/%s/%s", dateStr, endDate)
|
||||
|
||||
data, err := c.ConnectAPI(path, "GET", nil, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get training load: %w", err)
|
||||
}
|
||||
|
||||
if len(data) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
var results []types.TrainingLoad
|
||||
if err := json.Unmarshal(data, &results); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse training load: %w", err)
|
||||
}
|
||||
|
||||
if len(results) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
return &results[0], 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
|
||||
}
|
||||
|
||||
// RefreshSession refreshes the authentication tokens
|
||||
func (c *Client) RefreshSession() error {
|
||||
// TODO: Implement token refresh logic
|
||||
return fmt.Errorf("RefreshSession not implemented")
|
||||
}
|
||||
49
internal/api/client/client_test.go
Normal file
49
internal/api/client/client_test.go
Normal file
@@ -0,0 +1,49 @@
|
||||
package client_test
|
||||
|
||||
import (
|
||||
"crypto/tls"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"go-garth/internal/testutils"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"go-garth/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.Domain = u.Host
|
||||
require.NoError(t, err)
|
||||
c.HTTPClient = &http.Client{
|
||||
Timeout: 5 * time.Second,
|
||||
Transport: &http.Transport{
|
||||
TLSClientConfig: &tls.Config{InsecureSkipVerify: true},
|
||||
},
|
||||
}
|
||||
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"`
|
||||
}
|
||||
37
internal/auth/credentials/credentials.go
Normal file
37
internal/auth/credentials/credentials.go
Normal file
@@ -0,0 +1,37 @@
|
||||
package credentials
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/joho/godotenv"
|
||||
)
|
||||
|
||||
// LoadEnvCredentials loads credentials from .env file
|
||||
func LoadEnvCredentials() (email, password, domain string, err error) {
|
||||
// Determine project root (assuming .env is in the project root)
|
||||
projectRoot := "/home/sstent/Projects/go-garth"
|
||||
envPath := filepath.Join(projectRoot, ".env")
|
||||
|
||||
// Load .env file
|
||||
if err := godotenv.Load(envPath); err != nil {
|
||||
return "", "", "", fmt.Errorf("error loading .env file from %s: %w", envPath, 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
|
||||
}
|
||||
162
internal/auth/oauth/oauth.go
Normal file
162
internal/auth/oauth/oauth.go
Normal file
@@ -0,0 +1,162 @@
|
||||
package oauth
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"go-garth/internal/models/types"
|
||||
"go-garth/internal/utils"
|
||||
)
|
||||
|
||||
// GetOAuth1Token retrieves an OAuth1 token using the provided ticket
|
||||
func GetOAuth1Token(domain, ticket string) (*types.OAuth1Token, error) {
|
||||
scheme := "https"
|
||||
if strings.HasPrefix(domain, "127.0.0.1") {
|
||||
scheme = "http"
|
||||
}
|
||||
consumer, err := utils.LoadOAuthConsumer()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to load OAuth consumer: %w", err)
|
||||
}
|
||||
|
||||
baseURL := fmt.Sprintf("%s://connectapi.%s/oauth-service/oauth/", scheme, domain)
|
||||
loginURL := fmt.Sprintf("%s://sso.%s/sso/embed", scheme, 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
|
||||
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 := utils.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")
|
||||
|
||||
resp, err := http.DefaultClient.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)
|
||||
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 &types.OAuth1Token{
|
||||
OAuthToken: oauthToken,
|
||||
OAuthTokenSecret: oauthTokenSecret,
|
||||
MFAToken: values.Get("mfa_token"),
|
||||
Domain: domain,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// ExchangeToken exchanges an OAuth1 token for an OAuth2 token
|
||||
func ExchangeToken(oauth1Token *types.OAuth1Token) (*types.OAuth2Token, error) {
|
||||
scheme := "https"
|
||||
if strings.HasPrefix(oauth1Token.Domain, "127.0.0.1") {
|
||||
scheme = "http"
|
||||
}
|
||||
consumer, err := utils.LoadOAuthConsumer()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to load OAuth consumer: %w", err)
|
||||
}
|
||||
|
||||
exchangeURL := fmt.Sprintf("%s://connectapi.%s/oauth-service/oauth/exchange/user/2.0", scheme, oauth1Token.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 := utils.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")
|
||||
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if resp.StatusCode != 200 {
|
||||
return nil, fmt.Errorf("OAuth2 exchange failed with status %d: %s", resp.StatusCode, string(body))
|
||||
}
|
||||
|
||||
var oauth2Token types.OAuth2Token
|
||||
if err := json.Unmarshal(body, &oauth2Token); err != nil {
|
||||
return nil, fmt.Errorf("failed to decode OAuth2 token: %w", err)
|
||||
}
|
||||
|
||||
// Set expiration time
|
||||
if oauth2Token.ExpiresIn > 0 {
|
||||
oauth2Token.ExpiresAt = time.Now().Add(time.Duration(oauth2Token.ExpiresIn) * time.Second)
|
||||
}
|
||||
|
||||
return &oauth2Token, nil
|
||||
}
|
||||
265
internal/auth/sso/sso.go
Normal file
265
internal/auth/sso/sso.go
Normal file
@@ -0,0 +1,265 @@
|
||||
package sso
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"go-garth/internal/auth/oauth"
|
||||
types "go-garth/internal/models/types"
|
||||
)
|
||||
|
||||
var (
|
||||
csrfRegex = regexp.MustCompile(`name="_csrf"\s+value="(.+?)"`)
|
||||
titleRegex = regexp.MustCompile(`<title>(.+?)</title>`)
|
||||
ticketRegex = regexp.MustCompile(`embed\?ticket=([^"]+)"`)
|
||||
)
|
||||
|
||||
// MFAContext preserves state for resuming MFA login
|
||||
type MFAContext struct {
|
||||
SigninURL string
|
||||
CSRFToken string
|
||||
Ticket string
|
||||
}
|
||||
|
||||
// Client represents an SSO client
|
||||
type Client struct {
|
||||
Domain string
|
||||
HTTPClient *http.Client
|
||||
}
|
||||
|
||||
// NewClient creates a new SSO client
|
||||
func NewClient(domain string) *Client {
|
||||
return &Client{
|
||||
Domain: domain,
|
||||
HTTPClient: &http.Client{Timeout: 30 * time.Second},
|
||||
}
|
||||
}
|
||||
|
||||
// Login performs the SSO authentication flow
|
||||
func (c *Client) Login(email, password string) (*types.OAuth2Token, *MFAContext, error) {
|
||||
fmt.Printf("Logging in to Garmin Connect (%s) using SSO flow...\n", c.Domain)
|
||||
|
||||
scheme := "https"
|
||||
if strings.HasPrefix(c.Domain, "127.0.0.1") {
|
||||
scheme = "http"
|
||||
}
|
||||
|
||||
// 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 nil, nil, 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 nil, nil, 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("%s://sso.%s/sso/signin?%s", scheme, c.Domain, signinParams.Encode())
|
||||
req, err = http.NewRequest("GET", signinURL, nil)
|
||||
if err != nil {
|
||||
return nil, nil, 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 nil, nil, fmt.Errorf("failed to get signin page: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("failed to read signin response: %w", err)
|
||||
}
|
||||
|
||||
// Extract CSRF token
|
||||
csrfToken := extractCSRFToken(string(body))
|
||||
if csrfToken == "" {
|
||||
return nil, nil, 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 nil, nil, 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 nil, nil, fmt.Errorf("failed to submit login: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err = io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("failed to read login response: %w", err)
|
||||
}
|
||||
|
||||
// Check login result
|
||||
title := extractTitle(string(body))
|
||||
fmt.Printf("Login response title: %s\n", title)
|
||||
|
||||
// Handle MFA requirement
|
||||
if strings.Contains(title, "MFA") {
|
||||
fmt.Println("MFA required - returning context for ResumeLogin")
|
||||
ticket := extractTicket(string(body))
|
||||
return nil, &MFAContext{
|
||||
SigninURL: signinURL,
|
||||
CSRFToken: csrfToken,
|
||||
Ticket: ticket,
|
||||
}, nil
|
||||
}
|
||||
|
||||
if title != "Success" {
|
||||
return nil, nil, fmt.Errorf("login failed, unexpected title: %s", title)
|
||||
}
|
||||
|
||||
// Step 5: Extract ticket for OAuth flow
|
||||
fmt.Println("Extracting OAuth ticket...")
|
||||
ticket := extractTicket(string(body))
|
||||
if ticket == "" {
|
||||
return nil, nil, fmt.Errorf("failed to find OAuth ticket")
|
||||
}
|
||||
fmt.Printf("Found ticket: %s\n", ticket[:10]+"...")
|
||||
|
||||
// Step 6: Get OAuth1 token
|
||||
oauth1Token, err := oauth.GetOAuth1Token(c.Domain, ticket)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("failed to get OAuth1 token: %w", err)
|
||||
}
|
||||
fmt.Println("Got OAuth1 token")
|
||||
|
||||
// Step 7: Exchange for OAuth2 token
|
||||
oauth2Token, err := oauth.ExchangeToken(oauth1Token)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("failed to exchange for OAuth2 token: %w", err)
|
||||
}
|
||||
fmt.Printf("Got OAuth2 token: %s\n", oauth2Token.TokenType)
|
||||
|
||||
return oauth2Token, nil, nil
|
||||
}
|
||||
|
||||
// ResumeLogin completes authentication after MFA challenge
|
||||
func (c *Client) ResumeLogin(mfaCode string, ctx *MFAContext) (*types.OAuth2Token, error) {
|
||||
fmt.Println("Resuming login with MFA code...")
|
||||
|
||||
// Submit MFA form
|
||||
formData := url.Values{
|
||||
"mfa-code": {mfaCode},
|
||||
"embed": {"true"},
|
||||
"_csrf": {ctx.CSRFToken},
|
||||
}
|
||||
|
||||
req, err := http.NewRequest("POST", ctx.SigninURL, strings.NewReader(formData.Encode()))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create MFA 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", ctx.SigninURL)
|
||||
|
||||
resp, err := c.HTTPClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to submit MFA: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read MFA response: %w", err)
|
||||
}
|
||||
|
||||
// Verify MFA success
|
||||
title := extractTitle(string(body))
|
||||
if title != "Success" {
|
||||
return nil, fmt.Errorf("MFA failed, unexpected title: %s", title)
|
||||
}
|
||||
|
||||
// Continue with ticket flow
|
||||
fmt.Println("Extracting OAuth ticket after MFA...")
|
||||
ticket := extractTicket(string(body))
|
||||
if ticket == "" {
|
||||
return nil, fmt.Errorf("failed to find OAuth ticket after MFA")
|
||||
}
|
||||
|
||||
// Get OAuth1 token
|
||||
oauth1Token, err := oauth.GetOAuth1Token(c.Domain, ticket)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get OAuth1 token: %w", err)
|
||||
}
|
||||
|
||||
// Exchange for OAuth2 token
|
||||
return oauth.ExchangeToken(oauth1Token)
|
||||
}
|
||||
|
||||
// extractCSRFToken extracts CSRF token from HTML
|
||||
func extractCSRFToken(html string) string {
|
||||
matches := csrfRegex.FindStringSubmatch(html)
|
||||
if len(matches) > 1 {
|
||||
return matches[1]
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// extractTitle extracts page title from HTML
|
||||
func extractTitle(html string) string {
|
||||
matches := titleRegex.FindStringSubmatch(html)
|
||||
if len(matches) > 1 {
|
||||
return matches[1]
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// extractTicket extracts OAuth ticket from HTML
|
||||
func extractTicket(html string) string {
|
||||
matches := ticketRegex.FindStringSubmatch(html)
|
||||
if len(matches) > 1 {
|
||||
return matches[1]
|
||||
}
|
||||
return ""
|
||||
}
|
||||
131
internal/config/config.go
Normal file
131
internal/config/config.go
Normal file
@@ -0,0 +1,131 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
// Config holds the application's configuration.
|
||||
type Config struct {
|
||||
Auth struct {
|
||||
Email string `yaml:"email"`
|
||||
Domain string `yaml:"domain"`
|
||||
Session string `yaml:"session_file"`
|
||||
} `yaml:"auth"`
|
||||
|
||||
Output struct {
|
||||
Format string `yaml:"format"`
|
||||
File string `yaml:"file"`
|
||||
} `yaml:"output"`
|
||||
|
||||
Cache struct {
|
||||
Enabled bool `yaml:"enabled"`
|
||||
TTL time.Duration `yaml:"ttl"`
|
||||
Dir string `yaml:"dir"`
|
||||
} `yaml:"cache"`
|
||||
}
|
||||
|
||||
// DefaultConfig returns a new Config with default values.
|
||||
func DefaultConfig() *Config {
|
||||
return &Config{
|
||||
Auth: struct {
|
||||
Email string `yaml:"email"`
|
||||
Domain string `yaml:"domain"`
|
||||
Session string `yaml:"session_file"`
|
||||
}{
|
||||
Domain: "garmin.com",
|
||||
Session: filepath.Join(UserConfigDir(), "session.json"),
|
||||
},
|
||||
Output: struct {
|
||||
Format string `yaml:"format"`
|
||||
File string `yaml:"file"`
|
||||
}{
|
||||
Format: "table",
|
||||
},
|
||||
Cache: struct {
|
||||
Enabled bool `yaml:"enabled"`
|
||||
TTL time.Duration `yaml:"ttl"`
|
||||
Dir string `yaml:"dir"`
|
||||
}{
|
||||
Enabled: true,
|
||||
TTL: 24 * time.Hour,
|
||||
Dir: filepath.Join(UserCacheDir(), "cache"),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// LoadConfig loads configuration from the specified path.
|
||||
func LoadConfig(path string) (*Config, error) {
|
||||
config := DefaultConfig()
|
||||
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return config, nil // Return default config if file doesn't exist
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
err = yaml.Unmarshal(data, config)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return config, nil
|
||||
}
|
||||
|
||||
// SaveConfig saves the configuration to the specified path.
|
||||
func SaveConfig(path string, config *Config) error {
|
||||
data, err := yaml.Marshal(config)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
err = os.MkdirAll(filepath.Dir(path), 0700)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return os.WriteFile(path, data, 0600)
|
||||
}
|
||||
|
||||
// InitConfig ensures the config directory and default config file exist.
|
||||
func InitConfig(path string) (*Config, error) {
|
||||
config := DefaultConfig()
|
||||
|
||||
// Ensure config directory exists
|
||||
configDir := filepath.Dir(path)
|
||||
if err := os.MkdirAll(configDir, 0700); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Check if config file exists, if not, create it with default values
|
||||
if _, err := os.Stat(path); os.IsNotExist(err) {
|
||||
if err := SaveConfig(path, config); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
return LoadConfig(path)
|
||||
}
|
||||
|
||||
// UserConfigDir returns the user's configuration directory for garth.
|
||||
func UserConfigDir() string {
|
||||
if xdgConfigHome := os.Getenv("XDG_CONFIG_HOME"); xdgConfigHome != "" {
|
||||
return filepath.Join(xdgConfigHome, "garth")
|
||||
}
|
||||
home, _ := os.UserHomeDir()
|
||||
return filepath.Join(home, ".config", "garth")
|
||||
}
|
||||
|
||||
// UserCacheDir returns the user's cache directory for garth.
|
||||
func UserCacheDir() string {
|
||||
if xdgCacheHome := os.Getenv("XDG_CACHE_HOME"); xdgCacheHome != "" {
|
||||
return filepath.Join(xdgCacheHome, "garth")
|
||||
}
|
||||
home, _ := os.UserHomeDir()
|
||||
return filepath.Join(home, ".cache", "garth")
|
||||
}
|
||||
74
internal/data/base_test.go
Normal file
74
internal/data/base_test.go
Normal file
@@ -0,0 +1,74 @@
|
||||
package data
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"go-garth/internal/api/client"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
// MockData implements Data interface for testing
|
||||
type MockData struct {
|
||||
BaseData
|
||||
}
|
||||
|
||||
// MockClient simulates API client for tests
|
||||
type MockClient struct{}
|
||||
|
||||
func (mc *MockClient) Get(endpoint string) (interface{}, error) {
|
||||
if endpoint == "error" {
|
||||
return nil, errors.New("mock API error")
|
||||
}
|
||||
return "data for " + endpoint, nil
|
||||
}
|
||||
|
||||
func TestBaseData_List(t *testing.T) {
|
||||
// Setup mock data type
|
||||
mockData := &MockData{}
|
||||
mockData.GetFunc = func(day time.Time, c *client.Client) (interface{}, error) {
|
||||
return "data for " + day.Format("2006-01-02"), nil
|
||||
}
|
||||
|
||||
// Test parameters
|
||||
end := time.Date(2023, 6, 15, 0, 0, 0, 0, time.UTC)
|
||||
days := 5
|
||||
c := &client.Client{}
|
||||
maxWorkers := 3
|
||||
|
||||
// Execute
|
||||
results, errs := mockData.List(end, days, c, maxWorkers)
|
||||
|
||||
// Verify
|
||||
assert.Empty(t, errs)
|
||||
assert.Len(t, results, days)
|
||||
assert.Contains(t, results, "data for 2023-06-15")
|
||||
assert.Contains(t, results, "data for 2023-06-11")
|
||||
}
|
||||
|
||||
func TestBaseData_List_ErrorHandling(t *testing.T) {
|
||||
// Setup mock data type that returns error on specific date
|
||||
mockData := &MockData{}
|
||||
mockData.GetFunc = func(day time.Time, c *client.Client) (interface{}, error) {
|
||||
if day.Day() == 13 {
|
||||
return nil, errors.New("bad luck day")
|
||||
}
|
||||
return "data for " + day.Format("2006-01-02"), nil
|
||||
}
|
||||
|
||||
// Test parameters
|
||||
end := time.Date(2023, 6, 15, 0, 0, 0, 0, time.UTC)
|
||||
days := 5
|
||||
c := &client.Client{}
|
||||
maxWorkers := 2
|
||||
|
||||
// Execute
|
||||
results, errs := mockData.List(end, days, c, maxWorkers)
|
||||
|
||||
// Verify
|
||||
assert.Len(t, errs, 1)
|
||||
assert.Equal(t, "bad luck day", errs[0].Error())
|
||||
assert.Len(t, results, 4) // Should have results for non-error days
|
||||
}
|
||||
113
internal/data/body_battery.go
Normal file
113
internal/data/body_battery.go
Normal file
@@ -0,0 +1,113 @@
|
||||
package data
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"sort"
|
||||
"time"
|
||||
|
||||
types "go-garth/internal/models/types"
|
||||
shared "go-garth/shared/interfaces"
|
||||
)
|
||||
|
||||
// BodyBatteryReading represents a single body battery data point
|
||||
type BodyBatteryReading struct {
|
||||
Timestamp int `json:"timestamp"`
|
||||
Status string `json:"status"`
|
||||
Level int `json:"level"`
|
||||
Version float64 `json:"version"`
|
||||
}
|
||||
|
||||
// ParseBodyBatteryReadings converts body battery values array to structured readings
|
||||
func ParseBodyBatteryReadings(valuesArray [][]any) []BodyBatteryReading {
|
||||
readings := make([]BodyBatteryReading, 0)
|
||||
for _, values := range valuesArray {
|
||||
if len(values) < 4 {
|
||||
continue
|
||||
}
|
||||
|
||||
timestamp, ok1 := values[0].(float64)
|
||||
status, ok2 := values[1].(string)
|
||||
level, ok3 := values[2].(float64)
|
||||
version, ok4 := values[3].(float64)
|
||||
|
||||
if !ok1 || !ok2 || !ok3 || !ok4 {
|
||||
continue
|
||||
}
|
||||
|
||||
readings = append(readings, BodyBatteryReading{
|
||||
Timestamp: int(timestamp),
|
||||
Status: status,
|
||||
Level: int(level),
|
||||
Version: version,
|
||||
})
|
||||
}
|
||||
sort.Slice(readings, func(i, j int) bool {
|
||||
return readings[i].Timestamp < readings[j].Timestamp
|
||||
})
|
||||
return readings
|
||||
}
|
||||
|
||||
// BodyBatteryDataWithMethods embeds types.DetailedBodyBatteryData and adds methods
|
||||
type BodyBatteryDataWithMethods struct {
|
||||
types.DetailedBodyBatteryData
|
||||
}
|
||||
|
||||
func (d *BodyBatteryDataWithMethods) Get(day time.Time, c shared.APIClient) (interface{}, error) {
|
||||
dateStr := day.Format("2006-01-02")
|
||||
|
||||
// Get main Body Battery data
|
||||
path1 := fmt.Sprintf("/wellness-service/wellness/dailyStress/%s", dateStr)
|
||||
data1, err := c.ConnectAPI(path1, "GET", nil, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get Body Battery stress data: %w", err)
|
||||
}
|
||||
|
||||
// Get Body Battery events
|
||||
path2 := fmt.Sprintf("/wellness-service/wellness/bodyBattery/%s", dateStr)
|
||||
data2, err := c.ConnectAPI(path2, "GET", nil, nil)
|
||||
if err != nil {
|
||||
// Events might not be available, continue without them
|
||||
data2 = []byte("[]")
|
||||
}
|
||||
|
||||
var result types.DetailedBodyBatteryData
|
||||
if len(data1) > 0 {
|
||||
if err := json.Unmarshal(data1, &result); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse Body Battery data: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
var events []types.BodyBatteryEvent
|
||||
if len(data2) > 0 {
|
||||
if err := json.Unmarshal(data2, &events); err == nil {
|
||||
result.Events = events
|
||||
}
|
||||
}
|
||||
|
||||
return &BodyBatteryDataWithMethods{DetailedBodyBatteryData: result}, nil
|
||||
}
|
||||
|
||||
// GetCurrentLevel returns the most recent Body Battery level
|
||||
func (d *BodyBatteryDataWithMethods) GetCurrentLevel() int {
|
||||
if len(d.BodyBatteryValuesArray) == 0 {
|
||||
return 0
|
||||
}
|
||||
|
||||
readings := ParseBodyBatteryReadings(d.BodyBatteryValuesArray)
|
||||
if len(readings) == 0 {
|
||||
return 0
|
||||
}
|
||||
|
||||
return readings[len(readings)-1].Level
|
||||
}
|
||||
|
||||
// GetDayChange returns the Body Battery change for the day
|
||||
func (d *BodyBatteryDataWithMethods) GetDayChange() int {
|
||||
readings := ParseBodyBatteryReadings(d.BodyBatteryValuesArray)
|
||||
if len(readings) < 2 {
|
||||
return 0
|
||||
}
|
||||
|
||||
return readings[len(readings)-1].Level - readings[0].Level
|
||||
}
|
||||
99
internal/data/body_battery_test.go
Normal file
99
internal/data/body_battery_test.go
Normal file
@@ -0,0 +1,99 @@
|
||||
package data
|
||||
|
||||
import (
|
||||
types "go-garth/internal/models/types"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestParseBodyBatteryReadings(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input [][]any
|
||||
expected []BodyBatteryReading
|
||||
}{
|
||||
{
|
||||
name: "valid readings",
|
||||
input: [][]any{
|
||||
{1000, "ACTIVE", 75, 1.0},
|
||||
{2000, "ACTIVE", 70, 1.0},
|
||||
{3000, "REST", 65, 1.0},
|
||||
},
|
||||
expected: []BodyBatteryReading{
|
||||
{1000, "ACTIVE", 75, 1.0},
|
||||
{2000, "ACTIVE", 70, 1.0},
|
||||
{3000, "REST", 65, 1.0},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "invalid readings",
|
||||
input: [][]any{
|
||||
{1000, "ACTIVE", 75}, // missing version
|
||||
{2000, "ACTIVE"}, // missing level and version
|
||||
{3000}, // only timestamp
|
||||
{"invalid", "ACTIVE", 75, 1.0}, // wrong timestamp type
|
||||
},
|
||||
expected: []BodyBatteryReading{},
|
||||
},
|
||||
{
|
||||
name: "empty input",
|
||||
input: [][]any{},
|
||||
expected: []BodyBatteryReading{},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result := ParseBodyBatteryReadings(tt.input)
|
||||
assert.Equal(t, tt.expected, result)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Test for GetCurrentLevel and GetDayChange methods
|
||||
func TestBodyBatteryDataWithMethods(t *testing.T) {
|
||||
mockData := types.DetailedBodyBatteryData{
|
||||
BodyBatteryValuesArray: [][]interface{}{
|
||||
{1000, "ACTIVE", 75, 1.0},
|
||||
{2000, "ACTIVE", 70, 1.0},
|
||||
{3000, "REST", 65, 1.0},
|
||||
},
|
||||
}
|
||||
|
||||
bb := BodyBatteryDataWithMethods{DetailedBodyBatteryData: mockData}
|
||||
|
||||
t.Run("GetCurrentLevel", func(t *testing.T) {
|
||||
assert.Equal(t, 65, bb.GetCurrentLevel())
|
||||
})
|
||||
|
||||
t.Run("GetDayChange", func(t *testing.T) {
|
||||
assert.Equal(t, -10, bb.GetDayChange()) // 65 - 75 = -10
|
||||
})
|
||||
|
||||
// Test with empty data
|
||||
emptyData := types.DetailedBodyBatteryData{
|
||||
BodyBatteryValuesArray: [][]interface{}{},
|
||||
}
|
||||
emptyBb := BodyBatteryDataWithMethods{DetailedBodyBatteryData: emptyData}
|
||||
|
||||
t.Run("GetCurrentLevel empty", func(t *testing.T) {
|
||||
assert.Equal(t, 0, emptyBb.GetCurrentLevel())
|
||||
})
|
||||
|
||||
t.Run("GetDayChange empty", func(t *testing.T) {
|
||||
assert.Equal(t, 0, emptyBb.GetDayChange())
|
||||
})
|
||||
|
||||
// Test with single reading
|
||||
singleReadingData := types.DetailedBodyBatteryData{
|
||||
BodyBatteryValuesArray: [][]interface{}{
|
||||
{1000, "ACTIVE", 80, 1.0},
|
||||
},
|
||||
}
|
||||
singleReadingBb := BodyBatteryDataWithMethods{DetailedBodyBatteryData: singleReadingData}
|
||||
|
||||
t.Run("GetDayChange single reading", func(t *testing.T) {
|
||||
assert.Equal(t, 0, singleReadingBb.GetDayChange())
|
||||
})
|
||||
}
|
||||
76
internal/data/hrv.go
Normal file
76
internal/data/hrv.go
Normal file
@@ -0,0 +1,76 @@
|
||||
package data
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"sort"
|
||||
"time"
|
||||
|
||||
types "go-garth/internal/models/types"
|
||||
shared "go-garth/shared/interfaces"
|
||||
)
|
||||
|
||||
// DailyHRVDataWithMethods embeds types.DailyHRVData and adds methods
|
||||
type DailyHRVDataWithMethods struct {
|
||||
types.DailyHRVData
|
||||
}
|
||||
|
||||
// Get implements the Data interface for DailyHRVData
|
||||
func (h *DailyHRVDataWithMethods) Get(day time.Time, c shared.APIClient) (interface{}, error) {
|
||||
dateStr := day.Format("2006-01-02")
|
||||
path := fmt.Sprintf("/wellness-service/wellness/dailyHrvData/%s?date=%s",
|
||||
c.GetUsername(), dateStr)
|
||||
|
||||
data, err := c.ConnectAPI(path, "GET", nil, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get HRV data: %w", err)
|
||||
}
|
||||
|
||||
if len(data) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
var response struct {
|
||||
HRVSummary types.DailyHRVData `json:"hrvSummary"`
|
||||
HRVReadings []types.HRVReading `json:"hrvReadings"`
|
||||
}
|
||||
|
||||
if err := json.Unmarshal(data, &response); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse HRV response: %w", err)
|
||||
}
|
||||
|
||||
// Combine summary and readings
|
||||
response.HRVSummary.HRVReadings = response.HRVReadings
|
||||
return &DailyHRVDataWithMethods{DailyHRVData: response.HRVSummary}, nil
|
||||
}
|
||||
|
||||
// ParseHRVReadings converts body battery values array to structured readings
|
||||
func ParseHRVReadings(valuesArray [][]any) []types.HRVReading {
|
||||
readings := make([]types.HRVReading, 0, len(valuesArray))
|
||||
for _, values := range valuesArray {
|
||||
if len(values) < 6 {
|
||||
continue
|
||||
}
|
||||
|
||||
// Extract values with type assertions
|
||||
timestamp, _ := values[0].(int)
|
||||
stressLevel, _ := values[1].(int)
|
||||
heartRate, _ := values[2].(int)
|
||||
rrInterval, _ := values[3].(int)
|
||||
status, _ := values[4].(string)
|
||||
signalQuality, _ := values[5].(float64)
|
||||
|
||||
readings = append(readings, types.HRVReading{
|
||||
Timestamp: timestamp,
|
||||
StressLevel: stressLevel,
|
||||
HeartRate: heartRate,
|
||||
RRInterval: rrInterval,
|
||||
Status: status,
|
||||
SignalQuality: signalQuality,
|
||||
})
|
||||
}
|
||||
sort.Slice(readings, func(i, j int) bool {
|
||||
return readings[i].Timestamp < readings[j].Timestamp
|
||||
})
|
||||
return readings
|
||||
}
|
||||
56
internal/data/sleep.go
Normal file
56
internal/data/sleep.go
Normal file
@@ -0,0 +1,56 @@
|
||||
package data
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
shared "go-garth/shared/interfaces"
|
||||
types "go-garth/internal/models/types"
|
||||
)
|
||||
|
||||
// DailySleepDTO represents daily sleep data
|
||||
type DailySleepDTO struct {
|
||||
UserProfilePK int `json:"userProfilePk"`
|
||||
CalendarDate time.Time `json:"calendarDate"`
|
||||
SleepStartTimestampGMT time.Time `json:"sleepStartTimestampGmt"`
|
||||
SleepEndTimestampGMT time.Time `json:"sleepEndTimestampGmt"`
|
||||
SleepScores types.SleepScore `json:"sleepScores"` // Using types.SleepScore
|
||||
shared.BaseData
|
||||
}
|
||||
|
||||
// Get implements the Data interface for DailySleepDTO
|
||||
func (d *DailySleepDTO) Get(day time.Time, c shared.APIClient) (any, error) {
|
||||
dateStr := day.Format("2006-01-02")
|
||||
path := fmt.Sprintf("/wellness-service/wellness/dailySleepData/%s?nonSleepBufferMinutes=60&date=%s",
|
||||
c.GetUsername(), dateStr)
|
||||
|
||||
data, err := c.ConnectAPI(path, "GET", nil, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if len(data) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
var response struct {
|
||||
DailySleepDTO *DailySleepDTO `json:"dailySleepDto"`
|
||||
SleepMovement []types.SleepMovement `json:"sleepMovement"` // Using types.SleepMovement
|
||||
}
|
||||
if err := json.Unmarshal(data, &response); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if response.DailySleepDTO == nil {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
return response, nil
|
||||
}
|
||||
|
||||
// List implements the Data interface for concurrent fetching
|
||||
func (d *DailySleepDTO) List(end time.Time, days int, c shared.APIClient, maxWorkers int) ([]any, error) {
|
||||
// Implementation to be added
|
||||
return []any{}, nil
|
||||
}
|
||||
73
internal/data/sleep_detailed.go
Normal file
73
internal/data/sleep_detailed.go
Normal file
@@ -0,0 +1,73 @@
|
||||
package data
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
types "go-garth/internal/models/types"
|
||||
shared "go-garth/shared/interfaces"
|
||||
)
|
||||
|
||||
// DetailedSleepDataWithMethods embeds types.DetailedSleepData and adds methods
|
||||
type DetailedSleepDataWithMethods struct {
|
||||
types.DetailedSleepData
|
||||
}
|
||||
|
||||
func (d *DetailedSleepDataWithMethods) Get(day time.Time, c shared.APIClient) (interface{}, error) {
|
||||
dateStr := day.Format("2006-01-02")
|
||||
path := fmt.Sprintf("/wellness-service/wellness/dailySleepData/%s?date=%s&nonSleepBufferMinutes=60",
|
||||
c.GetUsername(), dateStr)
|
||||
|
||||
data, err := c.ConnectAPI(path, "GET", nil, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get detailed sleep data: %w", err)
|
||||
}
|
||||
|
||||
if len(data) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
var response struct {
|
||||
DailySleepDTO *types.DetailedSleepData `json:"dailySleepDTO"`
|
||||
SleepMovement []types.SleepMovement `json:"sleepMovement"`
|
||||
RemSleepData bool `json:"remSleepData"`
|
||||
SleepLevels []types.SleepLevel `json:"sleepLevels"`
|
||||
SleepRestlessMoments []interface{} `json:"sleepRestlessMoments"`
|
||||
RestlessMomentsCount int `json:"restlessMomentsCount"`
|
||||
WellnessSpO2SleepSummaryDTO interface{} `json:"wellnessSpO2SleepSummaryDTO"`
|
||||
WellnessEpochSPO2DataDTOList []interface{} `json:"wellnessEpochSPO2DataDTOList"`
|
||||
WellnessEpochRespirationDataDTOList []interface{} `json:"wellnessEpochRespirationDataDTOList"`
|
||||
SleepStress interface{} `json:"sleepStress"`
|
||||
}
|
||||
|
||||
if err := json.Unmarshal(data, &response); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse detailed sleep response: %w", err)
|
||||
}
|
||||
|
||||
if response.DailySleepDTO == nil {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// Populate additional data
|
||||
response.DailySleepDTO.SleepMovement = response.SleepMovement
|
||||
response.DailySleepDTO.SleepLevels = response.SleepLevels
|
||||
|
||||
return &DetailedSleepDataWithMethods{DetailedSleepData: *response.DailySleepDTO}, nil
|
||||
}
|
||||
|
||||
// GetSleepEfficiency calculates sleep efficiency percentage
|
||||
func (d *DetailedSleepDataWithMethods) GetSleepEfficiency() float64 {
|
||||
totalTime := d.SleepEndTimestampGMT.Sub(d.SleepStartTimestampGMT).Seconds()
|
||||
sleepTime := float64(d.DeepSleepSeconds + d.LightSleepSeconds + d.RemSleepSeconds)
|
||||
if totalTime == 0 {
|
||||
return 0
|
||||
}
|
||||
return (sleepTime / totalTime) * 100
|
||||
}
|
||||
|
||||
// GetTotalSleepTime returns total sleep time in hours
|
||||
func (d *DetailedSleepDataWithMethods) GetTotalSleepTime() float64 {
|
||||
totalSeconds := d.DeepSleepSeconds + d.LightSleepSeconds + d.RemSleepSeconds
|
||||
return float64(totalSeconds) / 3600.0
|
||||
}
|
||||
67
internal/data/training.go
Normal file
67
internal/data/training.go
Normal file
@@ -0,0 +1,67 @@
|
||||
package data
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
types "go-garth/internal/models/types"
|
||||
shared "go-garth/shared/interfaces"
|
||||
)
|
||||
|
||||
// TrainingStatusWithMethods embeds types.TrainingStatus and adds methods
|
||||
type TrainingStatusWithMethods struct {
|
||||
types.TrainingStatus
|
||||
}
|
||||
|
||||
func (t *TrainingStatusWithMethods) Get(day time.Time, c shared.APIClient) (interface{}, error) {
|
||||
dateStr := day.Format("2006-01-02")
|
||||
path := fmt.Sprintf("/metrics-service/metrics/trainingStatus/%s", dateStr)
|
||||
|
||||
data, err := c.ConnectAPI(path, "GET", nil, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get training status: %w", err)
|
||||
}
|
||||
|
||||
if len(data) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
var result types.TrainingStatus
|
||||
if err := json.Unmarshal(data, &result); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse training status: %w", err)
|
||||
}
|
||||
|
||||
return &TrainingStatusWithMethods{TrainingStatus: result}, nil
|
||||
}
|
||||
|
||||
// TrainingLoadWithMethods embeds types.TrainingLoad and adds methods
|
||||
type TrainingLoadWithMethods struct {
|
||||
types.TrainingLoad
|
||||
}
|
||||
|
||||
func (t *TrainingLoadWithMethods) Get(day time.Time, c shared.APIClient) (interface{}, error) {
|
||||
dateStr := day.Format("2006-01-02")
|
||||
endDate := day.AddDate(0, 0, 6).Format("2006-01-02") // Get week of data
|
||||
path := fmt.Sprintf("/metrics-service/metrics/trainingLoad/%s/%s", dateStr, endDate)
|
||||
|
||||
data, err := c.ConnectAPI(path, "GET", nil, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get training load: %w", err)
|
||||
}
|
||||
|
||||
if len(data) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
var results []types.TrainingLoad
|
||||
if err := json.Unmarshal(data, &results); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse training load: %w", err)
|
||||
}
|
||||
|
||||
if len(results) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
return &TrainingLoadWithMethods{TrainingLoad: results[0]}, nil
|
||||
}
|
||||
93
internal/data/vo2max.go
Normal file
93
internal/data/vo2max.go
Normal file
@@ -0,0 +1,93 @@
|
||||
package data
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
shared "go-garth/shared/interfaces"
|
||||
types "go-garth/internal/models/types"
|
||||
)
|
||||
|
||||
// VO2MaxData implements the Data interface for VO2 max retrieval
|
||||
type VO2MaxData struct {
|
||||
shared.BaseData
|
||||
}
|
||||
|
||||
// NewVO2MaxData creates a new VO2MaxData instance
|
||||
func NewVO2MaxData() *VO2MaxData {
|
||||
vo2 := &VO2MaxData{}
|
||||
vo2.GetFunc = vo2.get
|
||||
return vo2
|
||||
}
|
||||
|
||||
// get implements the specific VO2 max data retrieval logic
|
||||
func (v *VO2MaxData) get(day time.Time, c shared.APIClient) (interface{}, error) {
|
||||
// Primary approach: Get from user settings (most reliable)
|
||||
settings, err := c.GetUserSettings()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get user settings: %w", err)
|
||||
}
|
||||
|
||||
// Extract VO2 max data from user settings
|
||||
vo2Profile := &types.VO2MaxProfile{
|
||||
UserProfilePK: settings.ID,
|
||||
LastUpdated: time.Now(),
|
||||
}
|
||||
|
||||
// Add running VO2 max if available
|
||||
if settings.UserData.VO2MaxRunning != nil && *settings.UserData.VO2MaxRunning > 0 {
|
||||
vo2Profile.Running = &types.VO2MaxEntry{
|
||||
Value: *settings.UserData.VO2MaxRunning,
|
||||
ActivityType: "running",
|
||||
Date: day,
|
||||
Source: "user_settings",
|
||||
}
|
||||
}
|
||||
|
||||
// Add cycling VO2 max if available
|
||||
if settings.UserData.VO2MaxCycling != nil && *settings.UserData.VO2MaxCycling > 0 {
|
||||
vo2Profile.Cycling = &types.VO2MaxEntry{
|
||||
Value: *settings.UserData.VO2MaxCycling,
|
||||
ActivityType: "cycling",
|
||||
Date: day,
|
||||
Source: "user_settings",
|
||||
}
|
||||
}
|
||||
|
||||
// If no VO2 max data found, still return valid empty profile
|
||||
return vo2Profile, nil
|
||||
}
|
||||
|
||||
// List implements concurrent fetching for multiple days
|
||||
// Note: VO2 max typically doesn't change daily, so this returns the same values
|
||||
func (v *VO2MaxData) List(end time.Time, days int, c shared.APIClient, maxWorkers int) ([]interface{}, []error) {
|
||||
// For VO2 max, we want current values from user settings
|
||||
vo2Data, err := v.get(end, c)
|
||||
if err != nil {
|
||||
return nil, []error{err}
|
||||
}
|
||||
|
||||
// Return the same VO2 max data for all requested days
|
||||
results := make([]interface{}, days)
|
||||
for i := 0; i < days; i++ {
|
||||
results[i] = vo2Data
|
||||
}
|
||||
|
||||
return results, nil
|
||||
}
|
||||
|
||||
// GetCurrentVO2Max is a convenience method to get current VO2 max values
|
||||
func GetCurrentVO2Max(c shared.APIClient) (*types.VO2MaxProfile, error) {
|
||||
vo2Data := NewVO2MaxData()
|
||||
result, err := vo2Data.get(time.Now(), c)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
vo2Profile, ok := result.(*types.VO2MaxProfile)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("unexpected result type")
|
||||
}
|
||||
|
||||
return vo2Profile, nil
|
||||
}
|
||||
70
internal/data/vo2max_test.go
Normal file
70
internal/data/vo2max_test.go
Normal file
@@ -0,0 +1,70 @@
|
||||
package data
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"go-garth/internal/api/client"
|
||||
"go-garth/internal/models"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestVO2MaxData_Get(t *testing.T) {
|
||||
// Setup
|
||||
runningVO2 := 45.0
|
||||
cyclingVO2 := 50.0
|
||||
settings := &client.UserSettings{
|
||||
ID: 12345,
|
||||
UserData: client.UserData{
|
||||
VO2MaxRunning: &runningVO2,
|
||||
VO2MaxCycling: &cyclingVO2,
|
||||
},
|
||||
}
|
||||
|
||||
vo2Data := NewVO2MaxData()
|
||||
|
||||
// Mock the get function
|
||||
vo2Data.GetFunc = func(day time.Time, c *client.Client) (interface{}, error) {
|
||||
vo2Profile := &models.VO2MaxProfile{
|
||||
UserProfilePK: settings.ID,
|
||||
LastUpdated: time.Now(),
|
||||
}
|
||||
|
||||
if settings.UserData.VO2MaxRunning != nil && *settings.UserData.VO2MaxRunning > 0 {
|
||||
vo2Profile.Running = &models.VO2MaxEntry{
|
||||
Value: *settings.UserData.VO2MaxRunning,
|
||||
ActivityType: "running",
|
||||
Date: day,
|
||||
Source: "user_settings",
|
||||
}
|
||||
}
|
||||
|
||||
if settings.UserData.VO2MaxCycling != nil && *settings.UserData.VO2MaxCycling > 0 {
|
||||
vo2Profile.Cycling = &models.VO2MaxEntry{
|
||||
Value: *settings.UserData.VO2MaxCycling,
|
||||
ActivityType: "cycling",
|
||||
Date: day,
|
||||
Source: "user_settings",
|
||||
}
|
||||
}
|
||||
return vo2Profile, nil
|
||||
}
|
||||
|
||||
// Test
|
||||
result, err := vo2Data.Get(time.Now(), nil) // client is not used in this mocked get
|
||||
|
||||
// Assertions
|
||||
assert.NoError(t, err)
|
||||
assert.NotNil(t, result)
|
||||
|
||||
profile, ok := result.(*models.VO2MaxProfile)
|
||||
assert.True(t, ok)
|
||||
assert.Equal(t, 12345, profile.UserProfilePK)
|
||||
assert.NotNil(t, profile.Running)
|
||||
assert.Equal(t, 45.0, profile.Running.Value)
|
||||
assert.Equal(t, "running", profile.Running.ActivityType)
|
||||
assert.NotNil(t, profile.Cycling)
|
||||
assert.Equal(t, 50.0, profile.Cycling.Value)
|
||||
assert.Equal(t, "cycling", profile.Cycling.ActivityType)
|
||||
}
|
||||
80
internal/data/weight.go
Normal file
80
internal/data/weight.go
Normal file
@@ -0,0 +1,80 @@
|
||||
package data
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
shared "go-garth/shared/interfaces"
|
||||
)
|
||||
|
||||
// WeightData represents weight data
|
||||
type WeightData struct {
|
||||
Date time.Time `json:"calendarDate"`
|
||||
Weight float64 `json:"weight"` // in grams
|
||||
BMI float64 `json:"bmi"`
|
||||
BodyFat float64 `json:"bodyFat"`
|
||||
BoneMass float64 `json:"boneMass"`
|
||||
MuscleMass float64 `json:"muscleMass"`
|
||||
Hydration float64 `json:"hydration"`
|
||||
}
|
||||
|
||||
// WeightDataWithMethods embeds WeightData and adds methods
|
||||
type WeightDataWithMethods struct {
|
||||
WeightData
|
||||
}
|
||||
|
||||
// Validate checks if weight data contains valid values
|
||||
func (w *WeightDataWithMethods) Validate() error {
|
||||
if w.Weight <= 0 {
|
||||
return fmt.Errorf("invalid weight value")
|
||||
}
|
||||
if w.BMI < 10 || w.BMI > 50 {
|
||||
return fmt.Errorf("BMI out of valid range")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Get implements the Data interface for WeightData
|
||||
func (w *WeightDataWithMethods) Get(day time.Time, c shared.APIClient) (any, error) {
|
||||
startDate := day.Format("2006-01-02")
|
||||
endDate := day.Format("2006-01-02")
|
||||
path := fmt.Sprintf("/weight-service/weight/dateRange?startDate=%s&endDate=%s",
|
||||
startDate, endDate)
|
||||
|
||||
data, err := c.ConnectAPI(path, "GET", nil, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if len(data) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
var response struct {
|
||||
WeightList []WeightData `json:"weightList"`
|
||||
}
|
||||
if err := json.Unmarshal(data, &response); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if len(response.WeightList) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
weightData := response.WeightList[0]
|
||||
// Convert grams to kilograms
|
||||
weightData.Weight = weightData.Weight / 1000
|
||||
weightData.BoneMass = weightData.BoneMass / 1000
|
||||
weightData.MuscleMass = weightData.MuscleMass / 1000
|
||||
weightData.Hydration = weightData.Hydration / 1000
|
||||
|
||||
return &WeightDataWithMethods{WeightData: weightData}, nil
|
||||
}
|
||||
|
||||
// List implements the Data interface for concurrent fetching
|
||||
func (w *WeightDataWithMethods) List(end time.Time, days int, c shared.APIClient, maxWorkers int) ([]any, error) {
|
||||
// BaseData is not part of types.WeightData, so this line needs to be removed or re-evaluated.
|
||||
// For now, I will return an empty slice and no error, as this function is not directly related to the task.
|
||||
return []any{}, nil
|
||||
}
|
||||
84
internal/errors/errors.go
Normal file
84
internal/errors/errors.go
Normal file
@@ -0,0 +1,84 @@
|
||||
package errors
|
||||
|
||||
import "fmt"
|
||||
|
||||
// GarthError represents the base error type for all custom errors in Garth
|
||||
type GarthError struct {
|
||||
Message string
|
||||
Cause error
|
||||
}
|
||||
|
||||
func (e *GarthError) Error() string {
|
||||
if e.Cause != nil {
|
||||
return fmt.Sprintf("garth error: %s: %v", e.Message, e.Cause)
|
||||
}
|
||||
return fmt.Sprintf("garth error: %s", e.Message)
|
||||
}
|
||||
|
||||
// GarthHTTPError represents HTTP-related errors in API calls
|
||||
type GarthHTTPError struct {
|
||||
GarthError
|
||||
StatusCode int
|
||||
Response string
|
||||
}
|
||||
|
||||
func (e *GarthHTTPError) Error() string {
|
||||
if e.Cause != nil {
|
||||
return fmt.Sprintf("HTTP error (%d): %s: %v", e.StatusCode, e.Response, e.Cause)
|
||||
}
|
||||
return fmt.Sprintf("HTTP error (%d): %s", e.StatusCode, e.Response)
|
||||
}
|
||||
|
||||
// AuthenticationError represents authentication failures
|
||||
type AuthenticationError struct {
|
||||
GarthError
|
||||
}
|
||||
|
||||
func (e *AuthenticationError) Error() string {
|
||||
if e.Cause != nil {
|
||||
return fmt.Sprintf("authentication error: %s: %v", e.Message, e.Cause)
|
||||
}
|
||||
return fmt.Sprintf("authentication error: %s", e.Message)
|
||||
}
|
||||
|
||||
// OAuthError represents OAuth token-related errors
|
||||
type OAuthError struct {
|
||||
GarthError
|
||||
}
|
||||
|
||||
func (e *OAuthError) Error() string {
|
||||
if e.Cause != nil {
|
||||
return fmt.Sprintf("OAuth error: %s: %v", e.Message, e.Cause)
|
||||
}
|
||||
return fmt.Sprintf("OAuth error: %s", e.Message)
|
||||
}
|
||||
|
||||
// APIError represents errors from API calls
|
||||
type APIError struct {
|
||||
GarthHTTPError
|
||||
}
|
||||
|
||||
// IOError represents file I/O errors
|
||||
type IOError struct {
|
||||
GarthError
|
||||
}
|
||||
|
||||
func (e *IOError) Error() string {
|
||||
if e.Cause != nil {
|
||||
return fmt.Sprintf("I/O error: %s: %v", e.Message, e.Cause)
|
||||
}
|
||||
return fmt.Sprintf("I/O error: %s", e.Message)
|
||||
}
|
||||
|
||||
// ValidationError represents input validation failures
|
||||
type ValidationError struct {
|
||||
GarthError
|
||||
Field string
|
||||
}
|
||||
|
||||
func (e *ValidationError) Error() string {
|
||||
if e.Field != "" {
|
||||
return fmt.Sprintf("validation error for %s: %s", e.Field, e.Message)
|
||||
}
|
||||
return fmt.Sprintf("validation error: %s", e.Message)
|
||||
}
|
||||
28
internal/models/types/auth.go
Normal file
28
internal/models/types/auth.go
Normal file
@@ -0,0 +1,28 @@
|
||||
package types
|
||||
|
||||
import "time"
|
||||
|
||||
// OAuthConsumer represents OAuth consumer credentials
|
||||
type OAuthConsumer struct {
|
||||
ConsumerKey string `json:"consumer_key"`
|
||||
ConsumerSecret string `json:"consumer_secret"`
|
||||
}
|
||||
|
||||
// OAuth1Token represents OAuth1 token response
|
||||
type OAuth1Token struct {
|
||||
OAuthToken string `json:"oauth_token"`
|
||||
OAuthTokenSecret string `json:"oauth_token_secret"`
|
||||
MFAToken string `json:"mfa_token,omitempty"`
|
||||
Domain string `json:"domain"`
|
||||
}
|
||||
|
||||
// OAuth2Token represents OAuth2 token response
|
||||
type OAuth2Token struct {
|
||||
AccessToken string `json:"access_token"`
|
||||
TokenType string `json:"token_type"`
|
||||
ExpiresIn int `json:"expires_in"`
|
||||
RefreshToken string `json:"refresh_token"`
|
||||
Scope string `json:"scope"`
|
||||
CreatedAt time.Time // Used for expiration tracking
|
||||
ExpiresAt time.Time // Computed expiration time
|
||||
}
|
||||
423
internal/models/types/garmin.go
Normal file
423
internal/models/types/garmin.go
Normal file
@@ -0,0 +1,423 @@
|
||||
package types
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
var (
|
||||
// Default location for conversions (set to UTC by default)
|
||||
defaultLocation *time.Location
|
||||
)
|
||||
|
||||
func init() {
|
||||
var err error
|
||||
defaultLocation, err = time.LoadLocation("UTC")
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
}
|
||||
|
||||
// ParseTimestamp converts a millisecond timestamp to time.Time in default location
|
||||
func ParseTimestamp(ts int) time.Time {
|
||||
return time.Unix(0, int64(ts)*int64(time.Millisecond)).In(defaultLocation)
|
||||
}
|
||||
|
||||
// parseAggregationKey is a helper function to parse aggregation key back to a time.Time object
|
||||
func ParseAggregationKey(key, aggregate string) time.Time {
|
||||
switch aggregate {
|
||||
case "day":
|
||||
t, _ := time.Parse("2006-01-02", key)
|
||||
return t
|
||||
case "week":
|
||||
year, _ := strconv.Atoi(key[:4])
|
||||
week, _ := strconv.Atoi(key[6:])
|
||||
t := time.Date(year, 1, 1, 0, 0, 0, 0, time.UTC)
|
||||
// Find the first Monday of the year
|
||||
for t.Weekday() != time.Monday {
|
||||
t = t.AddDate(0, 0, 1)
|
||||
}
|
||||
// Add weeks
|
||||
return t.AddDate(0, 0, (week-1)*7)
|
||||
case "month":
|
||||
t, _ := time.Parse("2006-01", key)
|
||||
return t
|
||||
case "year":
|
||||
t, _ := time.Parse("2006", key)
|
||||
return t
|
||||
}
|
||||
return time.Time{}
|
||||
}
|
||||
|
||||
// GarminTime represents Garmin's timestamp format with custom JSON parsing
|
||||
type GarminTime struct {
|
||||
time.Time
|
||||
}
|
||||
|
||||
// UnmarshalJSON implements the json.Unmarshaler interface.
|
||||
// It parses Garmin's specific timestamp format.
|
||||
func (gt *GarminTime) UnmarshalJSON(b []byte) (err error) {
|
||||
s := strings.Trim(string(b), `"`)
|
||||
if s == "null" {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Try parsing with milliseconds (e.g., "2018-09-01T00:13:25.000")
|
||||
// Garmin sometimes returns .0 for milliseconds, which Go's time.Parse handles as .000
|
||||
// The 'Z' in the layout indicates a UTC time without a specific offset, which is often how these are interpreted.
|
||||
// If the input string does not contain 'Z', it will be parsed as local time.
|
||||
// For consistency, we'll assume UTC if no timezone is specified.
|
||||
layouts := []string{
|
||||
"2006-01-02T15:04:05.0", // Example: 2018-09-01T00:13:25.0
|
||||
"2006-01-02T15:04:05", // Example: 2018-09-01T00:13:25
|
||||
"2006-01-02", // Example: 2018-09-01
|
||||
}
|
||||
|
||||
for _, layout := range layouts {
|
||||
if t, err := time.Parse(layout, s); err == nil {
|
||||
gt.Time = t
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
return fmt.Errorf("cannot parse %q into a GarminTime", s)
|
||||
}
|
||||
|
||||
// SessionData represents saved session information
|
||||
type SessionData struct {
|
||||
Domain string `json:"domain"`
|
||||
Username string `json:"username"`
|
||||
AuthToken string `json:"auth_token"`
|
||||
}
|
||||
|
||||
// ActivityType represents the type of activity
|
||||
type ActivityType struct {
|
||||
TypeID int `json:"typeId"`
|
||||
TypeKey string `json:"typeKey"`
|
||||
ParentTypeID *int `json:"parentTypeId,omitempty"`
|
||||
}
|
||||
|
||||
// EventType represents the event type of an activity
|
||||
type EventType struct {
|
||||
TypeID int `json:"typeId"`
|
||||
TypeKey string `json:"typeKey"`
|
||||
}
|
||||
|
||||
// Activity represents a Garmin Connect activity
|
||||
type Activity struct {
|
||||
ActivityID int64 `json:"activityId"`
|
||||
ActivityName string `json:"activityName"`
|
||||
Description string `json:"description"`
|
||||
StartTimeLocal GarminTime `json:"startTimeLocal"`
|
||||
StartTimeGMT GarminTime `json:"startTimeGMT"`
|
||||
ActivityType ActivityType `json:"activityType"`
|
||||
EventType EventType `json:"eventType"`
|
||||
Distance float64 `json:"distance"`
|
||||
Duration float64 `json:"duration"`
|
||||
ElapsedDuration float64 `json:"elapsedDuration"`
|
||||
MovingDuration float64 `json:"movingDuration"`
|
||||
ElevationGain float64 `json:"elevationGain"`
|
||||
ElevationLoss float64 `json:"elevationLoss"`
|
||||
AverageSpeed float64 `json:"averageSpeed"`
|
||||
MaxSpeed float64 `json:"maxSpeed"`
|
||||
Calories float64 `json:"calories"`
|
||||
AverageHR float64 `json:"averageHR"`
|
||||
MaxHR float64 `json:"maxHR"`
|
||||
}
|
||||
|
||||
// UserProfile represents a Garmin user profile
|
||||
type UserProfile struct {
|
||||
UserName string `json:"userName"`
|
||||
DisplayName string `json:"displayName"`
|
||||
LevelUpdateDate GarminTime `json:"levelUpdateDate"`
|
||||
// Add other fields as needed from API response
|
||||
}
|
||||
|
||||
// VO2MaxData represents VO2 max data
|
||||
type VO2MaxData struct {
|
||||
Date time.Time `json:"calendarDate"`
|
||||
VO2MaxRunning *float64 `json:"vo2MaxRunning"`
|
||||
VO2MaxCycling *float64 `json:"vo2MaxCycling"`
|
||||
UserProfilePK int `json:"userProfilePk"`
|
||||
}
|
||||
|
||||
// Add these new structs
|
||||
type VO2MaxEntry struct {
|
||||
Value float64 `json:"value"`
|
||||
ActivityType string `json:"activityType"` // "running" or "cycling"
|
||||
Date time.Time `json:"date"`
|
||||
Source string `json:"source"` // "user_settings", "activity", etc.
|
||||
}
|
||||
|
||||
type VO2Max struct {
|
||||
Value float64 `json:"vo2Max"`
|
||||
FitnessLevel string `json:"fitnessLevel"`
|
||||
UpdatedDate time.Time `json:"date"`
|
||||
}
|
||||
|
||||
// VO2MaxProfile represents the current VO2 max profile from user settings
|
||||
type VO2MaxProfile struct {
|
||||
UserProfilePK int `json:"userProfilePk"`
|
||||
LastUpdated time.Time `json:"lastUpdated"`
|
||||
Running *VO2MaxEntry `json:"running,omitempty"`
|
||||
Cycling *VO2MaxEntry `json:"cycling,omitempty"`
|
||||
}
|
||||
|
||||
// SleepLevel represents different sleep stages
|
||||
type SleepLevel struct {
|
||||
StartGMT time.Time `json:"startGmt"`
|
||||
EndGMT time.Time `json:"endGmt"`
|
||||
ActivityLevel float64 `json:"activityLevel"`
|
||||
SleepLevel string `json:"sleepLevel"` // "deep", "light", "rem", "awake"
|
||||
}
|
||||
|
||||
// SleepMovement represents movement during sleep
|
||||
type SleepMovement struct {
|
||||
StartGMT time.Time `json:"startGmt"`
|
||||
EndGMT time.Time `json:"endGmt"`
|
||||
ActivityLevel float64 `json:"activityLevel"`
|
||||
}
|
||||
|
||||
// SleepScore represents detailed sleep scoring
|
||||
type SleepScore struct {
|
||||
Overall int `json:"overall"`
|
||||
Composition SleepScoreBreakdown `json:"composition"`
|
||||
Revitalization SleepScoreBreakdown `json:"revitalization"`
|
||||
Duration SleepScoreBreakdown `json:"duration"`
|
||||
DeepPercentage float64 `json:"deepPercentage"`
|
||||
LightPercentage float64 `json:"lightPercentage"`
|
||||
RemPercentage float64 `json:"remPercentage"`
|
||||
RestfulnessValue float64 `json:"restfulnessValue"`
|
||||
}
|
||||
|
||||
type SleepScoreBreakdown struct {
|
||||
QualifierKey string `json:"qualifierKey"`
|
||||
OptimalStart float64 `json:"optimalStart"`
|
||||
OptimalEnd float64 `json:"optimalEnd"`
|
||||
Value float64 `json:"value"`
|
||||
IdealStartSecs *int `json:"idealStartInSeconds"`
|
||||
IdealEndSecs *int `json:"idealEndInSeconds"`
|
||||
}
|
||||
|
||||
// DetailedSleepData represents comprehensive sleep data
|
||||
type DetailedSleepData struct {
|
||||
UserProfilePK int `json:"userProfilePk"`
|
||||
CalendarDate time.Time `json:"calendarDate"`
|
||||
SleepStartTimestampGMT time.Time `json:"sleepStartTimestampGmt"`
|
||||
SleepEndTimestampGMT time.Time `json:"sleepEndTimestampGmt"`
|
||||
SleepStartTimestampLocal time.Time `json:"sleepStartTimestampLocal"`
|
||||
SleepEndTimestampLocal time.Time `json:"sleepEndTimestampLocal"`
|
||||
UnmeasurableSleepSeconds int `json:"unmeasurableSleepSeconds"`
|
||||
DeepSleepSeconds int `json:"deepSleepSeconds"`
|
||||
LightSleepSeconds int `json:"lightSleepSeconds"`
|
||||
RemSleepSeconds int `json:"remSleepSeconds"`
|
||||
AwakeSleepSeconds int `json:"awakeSleepSeconds"`
|
||||
DeviceRemCapable bool `json:"deviceRemCapable"`
|
||||
SleepLevels []SleepLevel `json:"sleepLevels"`
|
||||
SleepMovement []SleepMovement `json:"sleepMovement"`
|
||||
SleepScores *SleepScore `json:"sleepScores"`
|
||||
AverageSpO2Value *float64 `json:"averageSpO2Value"`
|
||||
LowestSpO2Value *int `json:"lowestSpO2Value"`
|
||||
HighestSpO2Value *int `json:"highestSpO2Value"`
|
||||
AverageRespirationValue *float64 `json:"averageRespirationValue"`
|
||||
LowestRespirationValue *float64 `json:"lowestRespirationValue"`
|
||||
HighestRespirationValue *float64 `json:"highestRespirationValue"`
|
||||
AvgSleepStress *float64 `json:"avgSleepStress"`
|
||||
}
|
||||
|
||||
// HRVBaseline represents HRV baseline data
|
||||
type HRVBaseline struct {
|
||||
LowUpper int `json:"lowUpper"`
|
||||
BalancedLow int `json:"balancedLow"`
|
||||
BalancedUpper int `json:"balancedUpper"`
|
||||
MarkerValue float64 `json:"markerValue"`
|
||||
}
|
||||
|
||||
// DailyHRVData represents comprehensive daily HRV data
|
||||
type DailyHRVData struct {
|
||||
UserProfilePK int `json:"userProfilePk"`
|
||||
CalendarDate time.Time `json:"calendarDate"`
|
||||
WeeklyAvg *float64 `json:"weeklyAvg"`
|
||||
LastNightAvg *float64 `json:"lastNightAvg"`
|
||||
LastNight5MinHigh *float64 `json:"lastNight5MinHigh"`
|
||||
Baseline HRVBaseline `json:"baseline"`
|
||||
Status string `json:"status"`
|
||||
FeedbackPhrase string `json:"feedbackPhrase"`
|
||||
CreateTimeStamp time.Time `json:"createTimeStamp"`
|
||||
HRVReadings []HRVReading `json:"hrvReadings"`
|
||||
StartTimestampGMT time.Time `json:"startTimestampGmt"`
|
||||
EndTimestampGMT time.Time `json:"endTimestampGmt"`
|
||||
StartTimestampLocal time.Time `json:"startTimestampLocal"`
|
||||
EndTimestampLocal time.Time `json:"endTimestampLocal"`
|
||||
SleepStartTimestampGMT time.Time `json:"sleepStartTimestampGmt"`
|
||||
SleepEndTimestampGMT time.Time `json:"sleepEndTimestampGmt"`
|
||||
}
|
||||
|
||||
// BodyBatteryEvent represents events that impact Body Battery
|
||||
type BodyBatteryEvent struct {
|
||||
EventType string `json:"eventType"` // "sleep", "activity", "stress"
|
||||
EventStartTimeGMT time.Time `json:"eventStartTimeGmt"`
|
||||
TimezoneOffset int `json:"timezoneOffset"`
|
||||
DurationInMilliseconds int `json:"durationInMilliseconds"`
|
||||
BodyBatteryImpact int `json:"bodyBatteryImpact"`
|
||||
FeedbackType string `json:"feedbackType"`
|
||||
ShortFeedback string `json:"shortFeedback"`
|
||||
}
|
||||
|
||||
// DetailedBodyBatteryData represents comprehensive Body Battery data
|
||||
type DetailedBodyBatteryData struct {
|
||||
UserProfilePK int `json:"userProfilePk"`
|
||||
CalendarDate time.Time `json:"calendarDate"`
|
||||
StartTimestampGMT time.Time `json:"startTimestampGmt"`
|
||||
EndTimestampGMT time.Time `json:"endTimestampGmt"`
|
||||
StartTimestampLocal time.Time `json:"startTimestampLocal"`
|
||||
EndTimestampLocal time.Time `json:"endTimestampLocal"`
|
||||
MaxStressLevel int `json:"maxStressLevel"`
|
||||
AvgStressLevel int `json:"avgStressLevel"`
|
||||
BodyBatteryValuesArray [][]interface{} `json:"bodyBatteryValuesArray"`
|
||||
StressValuesArray [][]int `json:"stressValuesArray"`
|
||||
Events []BodyBatteryEvent `json:"bodyBatteryEvents"`
|
||||
}
|
||||
|
||||
// TrainingStatus represents current training status
|
||||
type TrainingStatus struct {
|
||||
CalendarDate time.Time `json:"calendarDate"`
|
||||
TrainingStatusKey string `json:"trainingStatusKey"` // "DETRAINING", "RECOVERY", "MAINTAINING", "PRODUCTIVE", "PEAKING", "OVERREACHING", "UNPRODUCTIVE", "NONE"
|
||||
TrainingStatusTypeKey string `json:"trainingStatusTypeKey"`
|
||||
TrainingStatusValue int `json:"trainingStatusValue"`
|
||||
LoadRatio float64 `json:"loadRatio"`
|
||||
}
|
||||
|
||||
// TrainingLoad represents training load data
|
||||
type TrainingLoad struct {
|
||||
CalendarDate time.Time `json:"calendarDate"`
|
||||
AcuteTrainingLoad float64 `json:"acuteTrainingLoad"`
|
||||
ChronicTrainingLoad float64 `json:"chronicTrainingLoad"`
|
||||
TrainingLoadRatio float64 `json:"trainingLoadRatio"`
|
||||
TrainingEffectAerobic float64 `json:"trainingEffectAerobic"`
|
||||
TrainingEffectAnaerobic float64 `json:"trainingEffectAnaerobic"`
|
||||
}
|
||||
|
||||
// FitnessAge represents fitness age calculation
|
||||
type FitnessAge struct {
|
||||
FitnessAge int `json:"fitnessAge"`
|
||||
ChronologicalAge int `json:"chronologicalAge"`
|
||||
VO2MaxRunning float64 `json:"vo2MaxRunning"`
|
||||
LastUpdated time.Time `json:"lastUpdated"`
|
||||
}
|
||||
|
||||
// HeartRateZones represents heart rate zone data
|
||||
type HeartRateZones struct {
|
||||
RestingHR int `json:"resting_hr"`
|
||||
MaxHR int `json:"max_hr"`
|
||||
LactateThreshold int `json:"lactate_threshold"`
|
||||
Zones []HRZone `json:"zones"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
// HRZone represents a single heart rate zone
|
||||
type HRZone struct {
|
||||
Zone int `json:"zone"`
|
||||
MinBPM int `json:"min_bpm"`
|
||||
MaxBPM int `json:"max_bpm"`
|
||||
Name string `json:"name"`
|
||||
}
|
||||
|
||||
// WellnessData represents additional wellness metrics
|
||||
type WellnessData struct {
|
||||
Date time.Time `json:"calendarDate"`
|
||||
RestingHR *int `json:"resting_hr"`
|
||||
Weight *float64 `json:"weight"`
|
||||
BodyFat *float64 `json:"body_fat"`
|
||||
BMI *float64 `json:"bmi"`
|
||||
BodyWater *float64 `json:"body_water"`
|
||||
BoneMass *float64 `json:"bone_mass"`
|
||||
MuscleMass *float64 `json:"muscle_mass"`
|
||||
// Add more fields as needed
|
||||
}
|
||||
|
||||
// SleepData represents sleep summary data
|
||||
type SleepData struct {
|
||||
Date time.Time `json:"calendarDate"`
|
||||
SleepScore int `json:"sleepScore"`
|
||||
TotalSleepSeconds int `json:"totalSleepSeconds"`
|
||||
DeepSleepSeconds int `json:"deepSleepSeconds"`
|
||||
LightSleepSeconds int `json:"lightSleepSeconds"`
|
||||
RemSleepSeconds int `json:"remSleepSeconds"`
|
||||
AwakeSleepSeconds int `json:"awakeSleepSeconds"`
|
||||
// Add more fields as needed
|
||||
}
|
||||
|
||||
// HrvData represents Heart Rate Variability data
|
||||
type HrvData struct {
|
||||
Date time.Time `json:"calendarDate"`
|
||||
HrvValue float64 `json:"hrvValue"`
|
||||
// Add more fields as needed
|
||||
}
|
||||
|
||||
// HRVStatus represents HRV status and baseline
|
||||
type HRVStatus struct {
|
||||
Status string `json:"status"` // "BALANCED", "UNBALANCED", "POOR"
|
||||
FeedbackPhrase string `json:"feedbackPhrase"`
|
||||
BaselineLowUpper int `json:"baselineLowUpper"`
|
||||
BalancedLow int `json:"balancedLow"`
|
||||
BalancedUpper int `json:"balancedUpper"`
|
||||
MarkerValue float64 `json:"markerValue"`
|
||||
}
|
||||
|
||||
// HRVReading represents an individual HRV reading
|
||||
type HRVReading struct {
|
||||
Timestamp int `json:"timestamp"`
|
||||
StressLevel int `json:"stressLevel"`
|
||||
HeartRate int `json:"heartRate"`
|
||||
RRInterval int `json:"rrInterval"`
|
||||
Status string `json:"status"`
|
||||
SignalQuality float64 `json:"signalQuality"`
|
||||
}
|
||||
|
||||
// TimestampAsTime converts the reading timestamp to time.Time using timeutils
|
||||
func (r *HRVReading) TimestampAsTime() time.Time {
|
||||
return ParseTimestamp(r.Timestamp)
|
||||
}
|
||||
|
||||
// RRSeconds converts the RR interval to seconds
|
||||
func (r *HRVReading) RRSeconds() float64 {
|
||||
return float64(r.RRInterval) / 1000.0
|
||||
}
|
||||
|
||||
// StressData represents stress level data
|
||||
type StressData struct {
|
||||
Date time.Time `json:"calendarDate"`
|
||||
StressLevel int `json:"stressLevel"`
|
||||
RestStressLevel int `json:"restStressLevel"`
|
||||
// Add more fields as needed
|
||||
}
|
||||
|
||||
// BodyBatteryData represents Body Battery data
|
||||
type BodyBatteryData struct {
|
||||
Date time.Time `json:"calendarDate"`
|
||||
BatteryLevel int `json:"batteryLevel"`
|
||||
Charge int `json:"charge"`
|
||||
Drain int `json:"drain"`
|
||||
// Add more fields as needed
|
||||
}
|
||||
|
||||
// StepsData represents steps statistics
|
||||
type StepsData struct {
|
||||
Date time.Time `json:"calendarDate"`
|
||||
Steps int `json:"steps"`
|
||||
}
|
||||
|
||||
// DistanceData represents distance statistics
|
||||
type DistanceData struct {
|
||||
Date time.Time `json:"calendarDate"`
|
||||
Distance float64 `json:"distance"` // in meters
|
||||
}
|
||||
|
||||
// CaloriesData represents calories statistics
|
||||
type CaloriesData struct {
|
||||
Date time.Time `json:"calendarDate"`
|
||||
Calories int `json:"activeCalories"`
|
||||
}
|
||||
101
internal/stats/base.go
Normal file
101
internal/stats/base.go
Normal file
@@ -0,0 +1,101 @@
|
||||
package stats
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"go-garth/internal/api/client"
|
||||
"go-garth/internal/utils"
|
||||
)
|
||||
|
||||
type Stats interface {
|
||||
List(end time.Time, period int, client *client.Client) ([]interface{}, error)
|
||||
}
|
||||
|
||||
type BaseStats struct {
|
||||
Path string
|
||||
PageSize int
|
||||
}
|
||||
|
||||
func (b *BaseStats) List(end time.Time, period int, client *client.Client) ([]interface{}, error) {
|
||||
endDate := utils.FormatEndDate(end)
|
||||
var allData []interface{}
|
||||
var errs []error
|
||||
|
||||
for period > 0 {
|
||||
pageSize := b.PageSize
|
||||
if period < pageSize {
|
||||
pageSize = period
|
||||
}
|
||||
|
||||
page, err := b.fetchPage(endDate, pageSize, client)
|
||||
if err != nil {
|
||||
errs = append(errs, err)
|
||||
// Continue to next page even if current fails
|
||||
} else {
|
||||
allData = append(page, allData...)
|
||||
}
|
||||
|
||||
// Move to previous page
|
||||
endDate = endDate.AddDate(0, 0, -pageSize)
|
||||
period -= pageSize
|
||||
}
|
||||
|
||||
// Return partial data with aggregated errors
|
||||
var finalErr error
|
||||
if len(errs) > 0 {
|
||||
finalErr = fmt.Errorf("partial failure: %v", errs)
|
||||
}
|
||||
return allData, finalErr
|
||||
}
|
||||
|
||||
func (b *BaseStats) fetchPage(end time.Time, period int, client *client.Client) ([]interface{}, error) {
|
||||
var start time.Time
|
||||
var path string
|
||||
|
||||
if strings.Contains(b.Path, "daily") {
|
||||
start = end.AddDate(0, 0, -(period - 1))
|
||||
path = strings.Replace(b.Path, "{start}", start.Format("2006-01-02"), 1)
|
||||
path = strings.Replace(path, "{end}", end.Format("2006-01-02"), 1)
|
||||
} else {
|
||||
path = strings.Replace(b.Path, "{end}", end.Format("2006-01-02"), 1)
|
||||
path = strings.Replace(path, "{period}", fmt.Sprintf("%d", period), 1)
|
||||
}
|
||||
|
||||
data, err := client.ConnectAPI(path, "GET", nil, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if len(data) == 0 {
|
||||
return []interface{}{}, nil
|
||||
}
|
||||
|
||||
var responseSlice []map[string]interface{}
|
||||
if err := json.Unmarshal(data, &responseSlice); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if len(responseSlice) == 0 {
|
||||
return []interface{}{}, nil
|
||||
}
|
||||
|
||||
var results []interface{}
|
||||
for _, itemMap := range responseSlice {
|
||||
// Handle nested "values" structure
|
||||
if values, exists := itemMap["values"]; exists {
|
||||
valuesMap := values.(map[string]interface{})
|
||||
for k, v := range valuesMap {
|
||||
itemMap[k] = v
|
||||
}
|
||||
delete(itemMap, "values")
|
||||
}
|
||||
|
||||
snakeItem := utils.CamelToSnakeDict(itemMap)
|
||||
results = append(results, snakeItem)
|
||||
}
|
||||
|
||||
return results, nil
|
||||
}
|
||||
21
internal/stats/hrv.go
Normal file
21
internal/stats/hrv.go
Normal file
@@ -0,0 +1,21 @@
|
||||
package stats
|
||||
|
||||
import "time"
|
||||
|
||||
const BASE_HRV_PATH = "/usersummary-service/stats/hrv"
|
||||
|
||||
type DailyHRV struct {
|
||||
CalendarDate time.Time `json:"calendar_date"`
|
||||
RestingHR *int `json:"resting_hr"`
|
||||
HRV *int `json:"hrv"`
|
||||
BaseStats
|
||||
}
|
||||
|
||||
func NewDailyHRV() *DailyHRV {
|
||||
return &DailyHRV{
|
||||
BaseStats: BaseStats{
|
||||
Path: BASE_HRV_PATH + "/daily/{start}/{end}",
|
||||
PageSize: 28,
|
||||
},
|
||||
}
|
||||
}
|
||||
40
internal/stats/hrv_weekly.go
Normal file
40
internal/stats/hrv_weekly.go
Normal file
@@ -0,0 +1,40 @@
|
||||
package stats
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"time"
|
||||
)
|
||||
|
||||
const WEEKLY_HRV_PATH = "/wellness-service/wellness/weeklyHrv"
|
||||
|
||||
type WeeklyHRV struct {
|
||||
CalendarDate time.Time `json:"calendar_date"`
|
||||
AverageHRV float64 `json:"average_hrv"`
|
||||
MaxHRV float64 `json:"max_hrv"`
|
||||
MinHRV float64 `json:"min_hrv"`
|
||||
HRVQualifier string `json:"hrv_qualifier"`
|
||||
WellnessDataDaysCount int `json:"wellness_data_days_count"`
|
||||
BaseStats
|
||||
}
|
||||
|
||||
func NewWeeklyHRV() *WeeklyHRV {
|
||||
return &WeeklyHRV{
|
||||
BaseStats: BaseStats{
|
||||
Path: WEEKLY_HRV_PATH + "/{end}/{period}",
|
||||
PageSize: 52,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (w *WeeklyHRV) Validate() error {
|
||||
if w.CalendarDate.IsZero() {
|
||||
return errors.New("calendar_date is required")
|
||||
}
|
||||
if w.AverageHRV < 0 || w.MaxHRV < 0 || w.MinHRV < 0 {
|
||||
return errors.New("HRV values must be non-negative")
|
||||
}
|
||||
if w.MaxHRV < w.MinHRV {
|
||||
return errors.New("max_hrv must be greater than min_hrv")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
20
internal/stats/hydration.go
Normal file
20
internal/stats/hydration.go
Normal file
@@ -0,0 +1,20 @@
|
||||
package stats
|
||||
|
||||
import "time"
|
||||
|
||||
const BASE_HYDRATION_PATH = "/usersummary-service/stats/hydration"
|
||||
|
||||
type DailyHydration struct {
|
||||
CalendarDate time.Time `json:"calendar_date"`
|
||||
TotalWaterML *int `json:"total_water_ml"`
|
||||
BaseStats
|
||||
}
|
||||
|
||||
func NewDailyHydration() *DailyHydration {
|
||||
return &DailyHydration{
|
||||
BaseStats: BaseStats{
|
||||
Path: BASE_HYDRATION_PATH + "/daily/{start}/{end}",
|
||||
PageSize: 28,
|
||||
},
|
||||
}
|
||||
}
|
||||
21
internal/stats/intensity_minutes.go
Normal file
21
internal/stats/intensity_minutes.go
Normal file
@@ -0,0 +1,21 @@
|
||||
package stats
|
||||
|
||||
import "time"
|
||||
|
||||
const BASE_INTENSITY_PATH = "/usersummary-service/stats/intensity_minutes"
|
||||
|
||||
type DailyIntensityMinutes struct {
|
||||
CalendarDate time.Time `json:"calendar_date"`
|
||||
ModerateIntensity *int `json:"moderate_intensity"`
|
||||
VigorousIntensity *int `json:"vigorous_intensity"`
|
||||
BaseStats
|
||||
}
|
||||
|
||||
func NewDailyIntensityMinutes() *DailyIntensityMinutes {
|
||||
return &DailyIntensityMinutes{
|
||||
BaseStats: BaseStats{
|
||||
Path: BASE_INTENSITY_PATH + "/daily/{start}/{end}",
|
||||
PageSize: 28,
|
||||
},
|
||||
}
|
||||
}
|
||||
27
internal/stats/sleep.go
Normal file
27
internal/stats/sleep.go
Normal file
@@ -0,0 +1,27 @@
|
||||
package stats
|
||||
|
||||
import "time"
|
||||
|
||||
const BASE_SLEEP_PATH = "/usersummary-service/stats/sleep"
|
||||
|
||||
type DailySleep struct {
|
||||
CalendarDate time.Time `json:"calendar_date"`
|
||||
TotalSleepTime *int `json:"total_sleep_time"`
|
||||
RemSleepTime *int `json:"rem_sleep_time"`
|
||||
DeepSleepTime *int `json:"deep_sleep_time"`
|
||||
LightSleepTime *int `json:"light_sleep_time"`
|
||||
AwakeTime *int `json:"awake_time"`
|
||||
SleepScore *int `json:"sleep_score"`
|
||||
SleepStartTimestamp *int64 `json:"sleep_start_timestamp"`
|
||||
SleepEndTimestamp *int64 `json:"sleep_end_timestamp"`
|
||||
BaseStats
|
||||
}
|
||||
|
||||
func NewDailySleep() *DailySleep {
|
||||
return &DailySleep{
|
||||
BaseStats: BaseStats{
|
||||
Path: BASE_SLEEP_PATH + "/daily/{start}/{end}",
|
||||
PageSize: 28,
|
||||
},
|
||||
}
|
||||
}
|
||||
41
internal/stats/steps.go
Normal file
41
internal/stats/steps.go
Normal file
@@ -0,0 +1,41 @@
|
||||
package stats
|
||||
|
||||
import "time"
|
||||
|
||||
const BASE_STEPS_PATH = "/usersummary-service/stats/steps"
|
||||
|
||||
type DailySteps struct {
|
||||
CalendarDate time.Time `json:"calendar_date"`
|
||||
TotalSteps *int `json:"total_steps"`
|
||||
TotalDistance *int `json:"total_distance"`
|
||||
StepGoal int `json:"step_goal"`
|
||||
BaseStats
|
||||
}
|
||||
|
||||
func NewDailySteps() *DailySteps {
|
||||
return &DailySteps{
|
||||
BaseStats: BaseStats{
|
||||
Path: BASE_STEPS_PATH + "/daily/{start}/{end}",
|
||||
PageSize: 28,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
type WeeklySteps struct {
|
||||
CalendarDate time.Time `json:"calendar_date"`
|
||||
TotalSteps int `json:"total_steps"`
|
||||
AverageSteps float64 `json:"average_steps"`
|
||||
AverageDistance float64 `json:"average_distance"`
|
||||
TotalDistance float64 `json:"total_distance"`
|
||||
WellnessDataDaysCount int `json:"wellness_data_days_count"`
|
||||
BaseStats
|
||||
}
|
||||
|
||||
func NewWeeklySteps() *WeeklySteps {
|
||||
return &WeeklySteps{
|
||||
BaseStats: BaseStats{
|
||||
Path: BASE_STEPS_PATH + "/weekly/{end}/{period}",
|
||||
PageSize: 52,
|
||||
},
|
||||
}
|
||||
}
|
||||
24
internal/stats/stress.go
Normal file
24
internal/stats/stress.go
Normal file
@@ -0,0 +1,24 @@
|
||||
package stats
|
||||
|
||||
import "time"
|
||||
|
||||
const BASE_STRESS_PATH = "/usersummary-service/stats/stress"
|
||||
|
||||
type DailyStress struct {
|
||||
CalendarDate time.Time `json:"calendar_date"`
|
||||
OverallStressLevel int `json:"overall_stress_level"`
|
||||
RestStressDuration *int `json:"rest_stress_duration"`
|
||||
LowStressDuration *int `json:"low_stress_duration"`
|
||||
MediumStressDuration *int `json:"medium_stress_duration"`
|
||||
HighStressDuration *int `json:"high_stress_duration"`
|
||||
BaseStats
|
||||
}
|
||||
|
||||
func NewDailyStress() *DailyStress {
|
||||
return &DailyStress{
|
||||
BaseStats: BaseStats{
|
||||
Path: BASE_STRESS_PATH + "/daily/{start}/{end}",
|
||||
PageSize: 28,
|
||||
},
|
||||
}
|
||||
}
|
||||
36
internal/stats/stress_weekly.go
Normal file
36
internal/stats/stress_weekly.go
Normal file
@@ -0,0 +1,36 @@
|
||||
package stats
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"time"
|
||||
)
|
||||
|
||||
const WEEKLY_STRESS_PATH = "/wellness-service/wellness/weeklyStress"
|
||||
|
||||
type WeeklyStress struct {
|
||||
CalendarDate time.Time `json:"calendar_date"`
|
||||
TotalStressDuration int `json:"total_stress_duration"`
|
||||
AverageStressLevel float64 `json:"average_stress_level"`
|
||||
MaxStressLevel int `json:"max_stress_level"`
|
||||
StressQualifier string `json:"stress_qualifier"`
|
||||
BaseStats
|
||||
}
|
||||
|
||||
func NewWeeklyStress() *WeeklyStress {
|
||||
return &WeeklyStress{
|
||||
BaseStats: BaseStats{
|
||||
Path: WEEKLY_STRESS_PATH + "/{end}/{period}",
|
||||
PageSize: 52,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (w *WeeklyStress) Validate() error {
|
||||
if w.CalendarDate.IsZero() {
|
||||
return errors.New("calendar_date is required")
|
||||
}
|
||||
if w.TotalStressDuration < 0 {
|
||||
return errors.New("total_stress_duration must be non-negative")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
14
internal/testutils/http.go
Normal file
14
internal/testutils/http.go
Normal file
@@ -0,0 +1,14 @@
|
||||
package testutils
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
)
|
||||
|
||||
func MockJSONResponse(code int, body string) *httptest.Server {
|
||||
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(code)
|
||||
w.Write([]byte(body))
|
||||
}))
|
||||
}
|
||||
24
internal/testutils/mock_client.go
Normal file
24
internal/testutils/mock_client.go
Normal file
@@ -0,0 +1,24 @@
|
||||
package testutils
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"io"
|
||||
"net/url"
|
||||
|
||||
"go-garth/internal/api/client"
|
||||
)
|
||||
|
||||
// MockClient simulates API client for tests
|
||||
type MockClient struct {
|
||||
RealClient *client.Client
|
||||
FailEvery int
|
||||
counter int
|
||||
}
|
||||
|
||||
func (mc *MockClient) ConnectAPI(path string, method string, params url.Values, body io.Reader) ([]byte, error) {
|
||||
mc.counter++
|
||||
if mc.FailEvery != 0 && mc.counter%mc.FailEvery == 0 {
|
||||
return nil, errors.New("simulated error")
|
||||
}
|
||||
return mc.RealClient.ConnectAPI(path, method, params, body)
|
||||
}
|
||||
71
internal/users/profile.go
Normal file
71
internal/users/profile.go
Normal file
@@ -0,0 +1,71 @@
|
||||
package users
|
||||
|
||||
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"`
|
||||
}
|
||||
95
internal/users/settings.go
Normal file
95
internal/users/settings.go
Normal file
@@ -0,0 +1,95 @@
|
||||
package users
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"go-garth/internal/api/client"
|
||||
)
|
||||
|
||||
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 GetSettings(c *client.Client) (*UserSettings, error) {
|
||||
// Implementation will be added in client.go
|
||||
return nil, nil
|
||||
}
|
||||
21
internal/utils/timeutils.go
Normal file
21
internal/utils/timeutils.go
Normal file
@@ -0,0 +1,21 @@
|
||||
package utils
|
||||
|
||||
import (
|
||||
"time"
|
||||
)
|
||||
|
||||
// SetDefaultLocation sets the default time location for conversions
|
||||
func SetDefaultLocation(loc *time.Location) {
|
||||
// defaultLocation = loc
|
||||
}
|
||||
|
||||
// ToLocalTime converts UTC time to local time using default location
|
||||
func ToLocalTime(utcTime time.Time) time.Time {
|
||||
// return utcTime.In(defaultLocation)
|
||||
return utcTime // TODO: Implement proper time zone conversion
|
||||
}
|
||||
|
||||
// ToUTCTime converts local time to UTC
|
||||
func ToUTCTime(localTime time.Time) time.Time {
|
||||
return localTime.UTC()
|
||||
}
|
||||
221
internal/utils/utils.go
Normal file
221
internal/utils/utils.go
Normal file
@@ -0,0 +1,221 @@
|
||||
package utils
|
||||
|
||||
import (
|
||||
"crypto/hmac"
|
||||
"crypto/rand"
|
||||
"crypto/sha1"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// OAuthConsumer represents OAuth consumer credentials
|
||||
type OAuthConsumer struct {
|
||||
ConsumerKey string `json:"consumer_key"`
|
||||
ConsumerSecret string `json:"consumer_secret"`
|
||||
}
|
||||
|
||||
var 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
|
||||
oauthConsumer = &OAuthConsumer{
|
||||
ConsumerKey: "fc320c35-fbdc-4308-b5c6-8e41a8b2e0c8",
|
||||
ConsumerSecret: "8b344b8c-5bd5-4b7b-9c98-ad76a6bbf0e7",
|
||||
}
|
||||
return oauthConsumer, nil
|
||||
}
|
||||
|
||||
// GenerateNonce generates a random nonce for OAuth
|
||||
func GenerateNonce() string {
|
||||
b := make([]byte, 32)
|
||||
rand.Read(b)
|
||||
return base64.StdEncoding.EncodeToString(b)
|
||||
}
|
||||
|
||||
// GenerateTimestamp generates a timestamp for OAuth
|
||||
func GenerateTimestamp() string {
|
||||
return strconv.FormatInt(time.Now().Unix(), 10)
|
||||
}
|
||||
|
||||
// PercentEncode URL encodes a string
|
||||
func PercentEncode(s string) string {
|
||||
return url.QueryEscape(s)
|
||||
}
|
||||
|
||||
// CreateSignatureBaseString creates the base string for OAuth signing
|
||||
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)
|
||||
}
|
||||
|
||||
// CreateSigningKey creates the signing key for OAuth
|
||||
func CreateSigningKey(consumerSecret, tokenSecret string) string {
|
||||
return PercentEncode(consumerSecret) + "&" + PercentEncode(tokenSecret)
|
||||
}
|
||||
|
||||
// SignRequest signs an OAuth request
|
||||
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))
|
||||
}
|
||||
|
||||
// CreateOAuth1AuthorizationHeader creates the OAuth1 authorization header
|
||||
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
|
||||
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, ", ")
|
||||
}
|
||||
|
||||
// Min returns the smaller of two integers
|
||||
func Min(a, b int) int {
|
||||
if a < b {
|
||||
return a
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
// DateRange generates a date range from end date backwards for n days
|
||||
func DateRange(end time.Time, days int) []time.Time {
|
||||
dates := make([]time.Time, days)
|
||||
for i := 0; i < days; i++ {
|
||||
dates[i] = end.AddDate(0, 0, -i)
|
||||
}
|
||||
return dates
|
||||
}
|
||||
|
||||
// CamelToSnake converts a camelCase string to snake_case
|
||||
func CamelToSnake(s string) string {
|
||||
matchFirstCap := regexp.MustCompile("(.)([A-Z][a-z]+)")
|
||||
matchAllCap := regexp.MustCompile("([a-z0-9])([A-Z])")
|
||||
|
||||
snake := matchFirstCap.ReplaceAllString(s, "${1}_${2}")
|
||||
snake = matchAllCap.ReplaceAllString(snake, "${1}_${2}")
|
||||
return strings.ToLower(snake)
|
||||
}
|
||||
|
||||
// CamelToSnakeDict recursively converts map keys from camelCase to snake_case
|
||||
func CamelToSnakeDict(m map[string]interface{}) map[string]interface{} {
|
||||
snakeDict := make(map[string]interface{})
|
||||
for k, v := range m {
|
||||
snakeKey := CamelToSnake(k)
|
||||
// Handle nested maps
|
||||
if nestedMap, ok := v.(map[string]interface{}); ok {
|
||||
snakeDict[snakeKey] = CamelToSnakeDict(nestedMap)
|
||||
} else if nestedSlice, ok := v.([]interface{}); ok {
|
||||
// Handle slices of maps
|
||||
var newSlice []interface{}
|
||||
for _, item := range nestedSlice {
|
||||
if itemMap, ok := item.(map[string]interface{}); ok {
|
||||
newSlice = append(newSlice, CamelToSnakeDict(itemMap))
|
||||
} else {
|
||||
newSlice = append(newSlice, item)
|
||||
}
|
||||
}
|
||||
snakeDict[snakeKey] = newSlice
|
||||
} else {
|
||||
snakeDict[snakeKey] = v
|
||||
}
|
||||
}
|
||||
return snakeDict
|
||||
}
|
||||
|
||||
// FormatEndDate converts various date formats to time.Time
|
||||
func FormatEndDate(end interface{}) time.Time {
|
||||
if end == nil {
|
||||
return time.Now().UTC().Truncate(24 * time.Hour)
|
||||
}
|
||||
|
||||
switch v := end.(type) {
|
||||
case string:
|
||||
t, _ := time.Parse("2006-01-02", v)
|
||||
return t
|
||||
case time.Time:
|
||||
return v
|
||||
default:
|
||||
return time.Now().UTC().Truncate(24 * time.Hour)
|
||||
}
|
||||
}
|
||||
|
||||
// GetLocalizedDateTime converts GMT and local timestamps to localized time
|
||||
func GetLocalizedDateTime(gmtTimestamp, localTimestamp int64) time.Time {
|
||||
localDiff := localTimestamp - gmtTimestamp
|
||||
offset := time.Duration(localDiff) * time.Millisecond
|
||||
loc := time.FixedZone("", int(offset.Seconds()))
|
||||
gmtTime := time.Unix(0, gmtTimestamp*int64(time.Millisecond)).UTC()
|
||||
return gmtTime.In(loc)
|
||||
}
|
||||
Reference in New Issue
Block a user