mirror of
https://github.com/sstent/go-garminconnect.git
synced 2026-02-05 22:12:20 +00:00
sync
This commit is contained in:
49
internal/api/activities.go
Normal file
49
internal/api/activities.go
Normal file
@@ -0,0 +1,49 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Activity represents a Garmin Connect activity
|
||||
type Activity struct {
|
||||
ActivityID int64 `json:"activityId"`
|
||||
Name string `json:"activityName"`
|
||||
Type string `json:"activityType"`
|
||||
StartTime time.Time `json:"startTimeLocal"`
|
||||
Duration float64 `json:"duration"`
|
||||
Distance float64 `json:"distance"`
|
||||
}
|
||||
|
||||
// ActivitiesResponse represents the response from the activities endpoint
|
||||
type ActivitiesResponse struct {
|
||||
Activities []Activity `json:"activities"`
|
||||
Pagination Pagination `json:"pagination"`
|
||||
}
|
||||
|
||||
// Pagination represents pagination information in API responses
|
||||
type Pagination struct {
|
||||
PageSize int `json:"pageSize"`
|
||||
TotalCount int `json:"totalCount"`
|
||||
Page int `json:"page"`
|
||||
}
|
||||
|
||||
// GetActivities retrieves a list of activities with pagination
|
||||
func (c *Client) GetActivities(ctx context.Context, page int, pageSize int) ([]Activity, *Pagination, error) {
|
||||
path := "/activitylist-service/activities/search"
|
||||
query := fmt.Sprintf("?page=%d&pageSize=%d", page, pageSize)
|
||||
|
||||
var response ActivitiesResponse
|
||||
err := c.Get(ctx, path+query, &response)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("failed to get activities: %w", err)
|
||||
}
|
||||
|
||||
// Validate we received some activities
|
||||
if len(response.Activities) == 0 {
|
||||
return nil, nil, fmt.Errorf("no activities found")
|
||||
}
|
||||
|
||||
return response.Activities, &response.Pagination, nil
|
||||
}
|
||||
121
internal/api/client.go
Normal file
121
internal/api/client.go
Normal file
@@ -0,0 +1,121 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"time"
|
||||
|
||||
"golang.org/x/time/rate"
|
||||
)
|
||||
|
||||
// Client handles communication with the Garmin Connect API
|
||||
type Client struct {
|
||||
baseURL *url.URL
|
||||
httpClient *http.Client
|
||||
limiter *rate.Limiter
|
||||
logger Logger
|
||||
}
|
||||
|
||||
// NewClient creates a new API client
|
||||
func NewClient(baseURL string, httpClient *http.Client) (*Client, error) {
|
||||
u, err := url.Parse(baseURL)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid base URL: %w", err)
|
||||
}
|
||||
|
||||
if httpClient == nil {
|
||||
httpClient = http.DefaultClient
|
||||
}
|
||||
|
||||
return &Client{
|
||||
baseURL: u,
|
||||
httpClient: httpClient,
|
||||
limiter: rate.NewLimiter(rate.Every(time.Second/10), 10), // 10 requests per second
|
||||
logger: &stdLogger{},
|
||||
}, nil
|
||||
}
|
||||
|
||||
// SetLogger sets the client's logger
|
||||
func (c *Client) SetLogger(logger Logger) {
|
||||
c.logger = logger
|
||||
}
|
||||
|
||||
// SetRateLimit configures the rate limiter
|
||||
func (c *Client) SetRateLimit(interval time.Duration, burst int) {
|
||||
c.limiter = rate.NewLimiter(rate.Every(interval), burst)
|
||||
}
|
||||
|
||||
// Get performs a GET request
|
||||
func (c *Client) Get(ctx context.Context, path string, v interface{}) error {
|
||||
return c.doRequest(ctx, http.MethodGet, path, nil, v)
|
||||
}
|
||||
|
||||
// Post performs a POST request
|
||||
func (c *Client) Post(ctx context.Context, path string, body io.Reader, v interface{}) error {
|
||||
return c.doRequest(ctx, http.MethodPost, path, body, v)
|
||||
}
|
||||
|
||||
func (c *Client) doRequest(ctx context.Context, method, path string, body io.Reader, v interface{}) error {
|
||||
// Wait for rate limiter
|
||||
if err := c.limiter.Wait(ctx); err != nil {
|
||||
return fmt.Errorf("rate limit wait failed: %w", err)
|
||||
}
|
||||
|
||||
// Create request
|
||||
u := c.baseURL.ResolveReference(&url.URL{Path: path})
|
||||
req, err := http.NewRequestWithContext(ctx, method, u.String(), body)
|
||||
if err != nil {
|
||||
return fmt.Errorf("create request failed: %w", err)
|
||||
}
|
||||
|
||||
// Set headers
|
||||
req.Header.Set("Accept", "application/json")
|
||||
if body != nil {
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
}
|
||||
|
||||
c.logger.Debugf("Request: %s %s", method, u.String())
|
||||
|
||||
// Execute request
|
||||
resp, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("request failed: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
c.logger.Debugf("Response status: %s", resp.Status)
|
||||
|
||||
// Handle non-200 responses
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
return fmt.Errorf("unexpected status code: %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
// Parse response
|
||||
if v == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
if err := json.NewDecoder(resp.Body).Decode(v); err != nil {
|
||||
return fmt.Errorf("decode response failed: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Logger defines the logging interface for the client
|
||||
type Logger interface {
|
||||
Debugf(format string, args ...interface{})
|
||||
Infof(format string, args ...interface{})
|
||||
Errorf(format string, args ...interface{})
|
||||
}
|
||||
|
||||
// stdLogger is the default logger that uses the standard log package
|
||||
type stdLogger struct{}
|
||||
|
||||
func (l *stdLogger) Debugf(format string, args ...interface{}) {}
|
||||
func (l *stdLogger) Infof(format string, args ...interface{}) {}
|
||||
func (l *stdLogger) Errorf(format string, args ...interface{}) {}
|
||||
38
internal/api/user.go
Normal file
38
internal/api/user.go
Normal file
@@ -0,0 +1,38 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
// UserProfile represents a Garmin Connect user profile
|
||||
type UserProfile struct {
|
||||
DisplayName string `json:"displayName"`
|
||||
FullName string `json:"fullName"`
|
||||
EmailAddress string `json:"emailAddress"`
|
||||
Username string `json:"username"`
|
||||
ProfileID string `json:"profileId"`
|
||||
ProfileImage string `json:"profileImageUrlLarge"`
|
||||
Location string `json:"location"`
|
||||
FitnessLevel string `json:"fitnessLevel"`
|
||||
Height float64 `json:"height"`
|
||||
Weight float64 `json:"weight"`
|
||||
Birthdate string `json:"birthDate"`
|
||||
}
|
||||
|
||||
// GetUserProfile retrieves the user's profile information
|
||||
func (c *Client) GetUserProfile(ctx context.Context) (*UserProfile, error) {
|
||||
var profile UserProfile
|
||||
path := "/userprofile-service/socialProfile"
|
||||
|
||||
if err := c.Get(ctx, path, &profile); err != nil {
|
||||
return nil, fmt.Errorf("failed to get user profile: %w", err)
|
||||
}
|
||||
|
||||
// Handle empty profile response
|
||||
if profile.ProfileID == "" {
|
||||
return nil, fmt.Errorf("user profile not found")
|
||||
}
|
||||
|
||||
return &profile, nil
|
||||
}
|
||||
92
internal/auth/auth.go
Normal file
92
internal/auth/auth.go
Normal file
@@ -0,0 +1,92 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/dghubble/oauth1"
|
||||
)
|
||||
|
||||
// OAuthConfig holds OAuth1 configuration for Garmin Connect
|
||||
type OAuthConfig struct {
|
||||
ConsumerKey string
|
||||
ConsumerSecret string
|
||||
}
|
||||
|
||||
// TokenStorage defines the interface for storing and retrieving OAuth tokens
|
||||
type TokenStorage interface {
|
||||
GetToken() (*oauth1.Token, error)
|
||||
SaveToken(*oauth1.Token) error
|
||||
}
|
||||
|
||||
// Authenticate initiates the OAuth1 authentication flow
|
||||
func Authenticate(w http.ResponseWriter, r *http.Request, config *OAuthConfig, storage TokenStorage) {
|
||||
// Create OAuth1 config
|
||||
oauthConfig := oauth1.Config{
|
||||
ConsumerKey: config.ConsumerKey,
|
||||
ConsumerSecret: config.ConsumerSecret,
|
||||
CallbackURL: "http://localhost:8080/callback",
|
||||
Endpoint: oauth1.Endpoint{
|
||||
RequestTokenURL: "https://connect.garmin.com/oauth-service/oauth/request_token",
|
||||
AuthorizeURL: "https://connect.garmin.com/oauth-service/oauth/authorize",
|
||||
AccessTokenURL: "https://connect.garmin.com/oauth-service/oauth/access_token",
|
||||
},
|
||||
}
|
||||
|
||||
// Get request token
|
||||
requestToken, _, err := oauthConfig.RequestToken()
|
||||
if err != nil {
|
||||
http.Error(w, "Failed to get request token", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// Save request token secret temporarily (for callback)
|
||||
// In a real application, you'd store this in a session
|
||||
|
||||
// Redirect to authorization URL
|
||||
authURL, err := oauthConfig.AuthorizationURL(requestToken)
|
||||
if err != nil {
|
||||
http.Error(w, "Failed to get authorization URL", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
http.Redirect(w, r, authURL.String(), http.StatusTemporaryRedirect)
|
||||
}
|
||||
|
||||
// Callback handles OAuth1 callback
|
||||
func Callback(w http.ResponseWriter, r *http.Request, config *OAuthConfig, storage TokenStorage, requestSecret string) {
|
||||
// Get request token and verifier from query params
|
||||
requestToken := r.URL.Query().Get("oauth_token")
|
||||
verifier := r.URL.Query().Get("oauth_verifier")
|
||||
|
||||
// Create OAuth1 config
|
||||
oauthConfig := oauth1.Config{
|
||||
ConsumerKey: config.ConsumerKey,
|
||||
ConsumerSecret: config.ConsumerSecret,
|
||||
Endpoint: oauth1.Endpoint{
|
||||
RequestTokenURL: "https://connect.garmin.com/oauth-service/oauth/request_token",
|
||||
AccessTokenURL: "https://connect.garmin.com/oauth-service/oauth/access_token",
|
||||
},
|
||||
}
|
||||
|
||||
// Get access token
|
||||
accessToken, accessSecret, err := oauthConfig.AccessToken(requestToken, requestSecret, verifier)
|
||||
if err != nil {
|
||||
http.Error(w, "Failed to get access token", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// Create token and save
|
||||
token := &oauth1.Token{
|
||||
Token: accessToken,
|
||||
TokenSecret: accessSecret,
|
||||
}
|
||||
|
||||
err = storage.SaveToken(token)
|
||||
if err != nil {
|
||||
http.Error(w, "Failed to save token", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write([]byte("Authentication successful!"))
|
||||
}
|
||||
59
internal/auth/filestorage.go
Normal file
59
internal/auth/filestorage.go
Normal file
@@ -0,0 +1,59 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"github.com/dghubble/oauth1"
|
||||
)
|
||||
|
||||
// FileStorage implements TokenStorage using a JSON file
|
||||
type FileStorage struct {
|
||||
Path string
|
||||
}
|
||||
|
||||
// NewFileStorage creates a new FileStorage instance
|
||||
func NewFileStorage() *FileStorage {
|
||||
// Default to storing token in user's home directory
|
||||
home, _ := os.UserHomeDir()
|
||||
return &FileStorage{
|
||||
Path: filepath.Join(home, ".garminconnect", "token.json"),
|
||||
}
|
||||
}
|
||||
|
||||
// GetToken retrieves token from file
|
||||
func (s *FileStorage) GetToken() (*oauth1.Token, error) {
|
||||
data, err := os.ReadFile(s.Path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var token oauth1.Token
|
||||
err = json.Unmarshal(data, &token)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Check if token is expired
|
||||
if token.Token == "" || token.TokenSecret == "" {
|
||||
return nil, os.ErrNotExist
|
||||
}
|
||||
|
||||
return &token, nil
|
||||
}
|
||||
|
||||
// SaveToken saves token to file
|
||||
func (s *FileStorage) SaveToken(token *oauth1.Token) error {
|
||||
// Create directory if it doesn't exist
|
||||
dir := filepath.Dir(s.Path)
|
||||
if err := os.MkdirAll(dir, 0700); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
data, err := json.MarshalIndent(token, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return os.WriteFile(s.Path, data, 0600)
|
||||
}
|
||||
85
internal/auth/filestorage_test.go
Normal file
85
internal/auth/filestorage_test.go
Normal file
@@ -0,0 +1,85 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/dghubble/oauth1"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestFileStorage(t *testing.T) {
|
||||
// Create temp directory for tests
|
||||
tempDir, err := os.MkdirTemp("", "garmin-test")
|
||||
assert.NoError(t, err)
|
||||
defer os.RemoveAll(tempDir)
|
||||
|
||||
storage := &FileStorage{
|
||||
Path: filepath.Join(tempDir, "token.json"),
|
||||
}
|
||||
|
||||
// Test saving and loading token
|
||||
t.Run("SaveAndLoadToken", func(t *testing.T) {
|
||||
testToken := &oauth1.Token{
|
||||
Token: "access-token",
|
||||
TokenSecret: "access-secret",
|
||||
}
|
||||
|
||||
// Save token
|
||||
err := storage.SaveToken(testToken)
|
||||
assert.NoError(t, err)
|
||||
|
||||
// Load token
|
||||
loadedToken, err := storage.GetToken()
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, testToken.Token, loadedToken.Token)
|
||||
assert.Equal(t, testToken.TokenSecret, loadedToken.TokenSecret)
|
||||
})
|
||||
|
||||
// Test missing token file
|
||||
t.Run("TokenMissing", func(t *testing.T) {
|
||||
_, err := storage.GetToken()
|
||||
assert.ErrorIs(t, err, os.ErrNotExist)
|
||||
})
|
||||
|
||||
// Test token expiration
|
||||
t.Run("TokenExpiration", func(t *testing.T) {
|
||||
testCases := []struct {
|
||||
name string
|
||||
token *oauth1.Token
|
||||
expected bool
|
||||
}{
|
||||
{
|
||||
name: "EmptyToken",
|
||||
token: &oauth1.Token{},
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "ValidToken",
|
||||
token: &oauth1.Token{
|
||||
Token: "valid",
|
||||
TokenSecret: "valid",
|
||||
},
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "ExpiredToken",
|
||||
token: &oauth1.Token{
|
||||
Token: "expired",
|
||||
TokenSecret: "expired",
|
||||
CreatedAt: time.Now().Add(-200 * 24 * time.Hour), // 200 days ago
|
||||
},
|
||||
expected: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
expired := storage.TokenExpired(tc.token)
|
||||
assert.Equal(t, tc.expected, expired)
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
42
internal/auth/mfa.go
Normal file
42
internal/auth/mfa.go
Normal file
@@ -0,0 +1,42 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
// MFAHandler handles multi-factor authentication
|
||||
func MFAHandler(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.Method {
|
||||
case "GET":
|
||||
// Show MFA form
|
||||
fmt.Fprintf(w, `<html>
|
||||
<body>
|
||||
<form method="POST">
|
||||
<label>MFA Code: <input type="text" name="mfa_code"></label>
|
||||
<button type="submit">Verify</button>
|
||||
</form>
|
||||
</body>
|
||||
</html>`)
|
||||
case "POST":
|
||||
// Process MFA code
|
||||
code := r.FormValue("mfa_code")
|
||||
// Validate MFA code - in a real app, this would be sent to Garmin
|
||||
if len(code) != 6 {
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
w.Write([]byte("Invalid MFA code format. Please enter a 6-digit code."))
|
||||
return
|
||||
}
|
||||
|
||||
// Store MFA verification status in session
|
||||
// In a real app, we'd store this in a session store
|
||||
w.Write([]byte("MFA verification successful! Please return to your application."))
|
||||
}
|
||||
}
|
||||
|
||||
// RequiresMFA checks if MFA is required based on Garmin response
|
||||
func RequiresMFA(err error) bool {
|
||||
// In a real implementation, we'd check the error type
|
||||
// or response from Garmin to determine if MFA is needed
|
||||
return err != nil && err.Error() == "mfa_required"
|
||||
}
|
||||
111
internal/fit/decoder.go
Normal file
111
internal/fit/decoder.go
Normal file
@@ -0,0 +1,111 @@
|
||||
package fit
|
||||
|
||||
import (
|
||||
"encoding/binary"
|
||||
"errors"
|
||||
"io"
|
||||
"os"
|
||||
)
|
||||
|
||||
const (
|
||||
headerSize = 12
|
||||
protocolMajor = 2
|
||||
)
|
||||
|
||||
// FileHeader represents the header of a FIT file
|
||||
type FileHeader struct {
|
||||
Size uint8
|
||||
Protocol uint8
|
||||
Profile [4]byte
|
||||
DataSize uint32
|
||||
Signature [4]byte
|
||||
}
|
||||
|
||||
// Activity represents activity data from a FIT file
|
||||
type Activity struct {
|
||||
Type string
|
||||
StartTime int64
|
||||
TotalDistance float64
|
||||
Duration float64
|
||||
}
|
||||
|
||||
// Decoder parses FIT files
|
||||
type Decoder struct {
|
||||
r io.Reader
|
||||
}
|
||||
|
||||
// NewDecoder creates a new FIT decoder
|
||||
func NewDecoder(r io.Reader) *Decoder {
|
||||
return &Decoder{r: r}
|
||||
}
|
||||
|
||||
// Parse decodes the FIT file and returns the activity data
|
||||
func (d *Decoder) Parse() (*Activity, error) {
|
||||
var header FileHeader
|
||||
if err := binary.Read(d.r, binary.LittleEndian, &header); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Validate header
|
||||
if header.Protocol != protocolMajor {
|
||||
return nil, errors.New("unsupported FIT protocol version")
|
||||
}
|
||||
|
||||
// For simplicity, we'll just extract basic activity data
|
||||
activity := &Activity{}
|
||||
|
||||
// Skip to activity record (simplified for example)
|
||||
// In a real implementation, we would parse the file structure properly
|
||||
for {
|
||||
var recordHeader uint8
|
||||
if err := binary.Read(d.r, binary.LittleEndian, &recordHeader); err == io.EOF {
|
||||
break
|
||||
} else if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if recordHeader == 0x21 { // Activity record header (example value)
|
||||
var record struct {
|
||||
Type uint8
|
||||
StartTime int64
|
||||
TotalDistance float32
|
||||
Duration uint32
|
||||
}
|
||||
if err := binary.Read(d.r, binary.LittleEndian, &record); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
activity.Type = activityType(record.Type)
|
||||
activity.StartTime = record.StartTime
|
||||
activity.TotalDistance = float64(record.TotalDistance)
|
||||
activity.Duration = float64(record.Duration)
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return activity, nil
|
||||
}
|
||||
|
||||
func activityType(t uint8) string {
|
||||
switch t {
|
||||
case 1:
|
||||
return "Running"
|
||||
case 2:
|
||||
return "Cycling"
|
||||
case 3:
|
||||
return "Swimming"
|
||||
default:
|
||||
return "Unknown"
|
||||
}
|
||||
}
|
||||
|
||||
// ReadFile reads and parses a FIT file
|
||||
func ReadFile(path string) (*Activity, error) {
|
||||
file, err := os.Open(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
return NewDecoder(file).Parse()
|
||||
}
|
||||
Reference in New Issue
Block a user