This commit is contained in:
2025-08-27 08:32:14 -07:00
parent 970c41a4cb
commit f24d21033a
6 changed files with 373 additions and 91 deletions

View File

@@ -56,6 +56,17 @@ docker compose up -d --build
- Mock server implementation
- Test coverage for 200/404 responses
## Activity Upload/Download Implementation
- [x] Implemented `UploadActivity` endpoint
- Handles multipart FIT file uploads
- Validates FIT file structure
- Returns created activity ID
- [x] Implemented `DownloadActivity` endpoint
- Retrieves activity as FIT binary
- Sets proper content headers
- [x] Added FIT file validation
- [x] Created comprehensive tests for upload/download flow
## Gear Management Implementation Details
- [x] Implemented `GetGearStats` endpoint
- Retrieves detailed statistics for a gear item
@@ -68,11 +79,14 @@ docker compose up -d --build
- Test coverage for success and error cases
- Pagination verification
## MFA Session Management
- [x] Implemented state persistence for MFA flow
- [x] Created MFA state storage interface
- [x] Added file-based implementation for MFA state
- [x] Integrated with authentication flow
- [x] Added comprehensive tests for session persistence
## Next Steps
- Implement activity upload/download functionality
- Add FIT file encoder implementation
- Implement additional API endpoints
- Complete FIT encoder implementation
- Create streaming FIT encoder
- Add comprehensive test coverage for all endpoints
- Improve error handling and logging
- Add session management for MFA flow

View File

@@ -16,26 +16,26 @@
## Phase Implementation Details
### Phase 1: Setup & Core Structure
- [ ] Initialize Go module: `go mod init github.com/sstent/go-garminconnect`
- [ ] Create directory structure
- [ ] Set up CI/CD pipeline
- [ ] Create Makefile with build/test targets
- [ ] Add basic README with project overview
- [x] Initialize Go module: `go mod init github.com/sstent/go-garminconnect`
- [x] Create directory structure
- [x] Set up CI/CD pipeline
- [x] Create Makefile with build/test targets
- [x] Add basic README with project overview
### Phase 2: Authentication Implementation
- [ ] Implement OAuth2 authentication flow
- [ ] Create token storage interface
- [ ] Implement session management with auto-refresh
- [ ] Handle MFA authentication
- [ ] Test against sandbox environment
- [x] Implement OAuth2 authentication flow
- [x] Create token storage interface
- [x] Implement session management with auto-refresh
- [x] Handle MFA authentication
- [x] Test against sandbox environment
### Phase 3: API Client Core
- [ ] Create Client struct with configuration
- [ ] Implement generic request handler
- [ ] Add automatic token refresh
- [ ] Implement rate limiting
- [ ] Set up connection pooling
- [ ] Create response parsing utilities
- [x] Create Client struct with configuration
- [x] Implement generic request handler
- [x] Add automatic token refresh
- [x] Implement rate limiting
- [x] Set up connection pooling
- [x] Create response parsing utilities
### Phase 4: Endpoint Implementation
#### Health Data Endpoints
@@ -52,7 +52,10 @@
- Added GPS track point timestamp parsing
- Custom time handling with garminTime structure
- Comprehensive table-driven tests
- [ ] Activity upload/download
- [x] Activity upload/download
- Added FIT validation
- Implemented multipart upload
- Added endpoint for downloading activities in FIT format
- [x] Gear management
- Implemented GetGearStats
- Implemented GetGearActivities with pagination
@@ -64,10 +67,12 @@
- [ ] Goals/badges
### Phase 5: FIT Handling
- [ ] Port FIT encoder from Python
- [ ] Implement weight composition encoding
- [x] Port FIT encoder from Python
- Implemented core encoder with header/CRC
- Added support for activity messages
- [x] Implement weight composition encoding
- [ ] Create streaming FIT encoder
- [ ] Add FIT parser
- [x] Add FIT parser
### Phase 6: Testing & Quality
- [ ] Table-driven endpoint tests
@@ -80,7 +85,7 @@
- [ ] Complete GoDoc coverage
- [ ] Create usage examples
- [ ] Build CLI example app
- [ ] Write migration guide
- [x] Write migration guide
## Weekly Milestones
| Week | Focus Area | Key Deliverables |

View File

@@ -1,12 +1,21 @@
package api
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"io/ioutil"
"mime/multipart"
"net/http"
"net/url"
"os"
"path/filepath"
"strconv"
"time"
"github.com/sstent/go-garminconnect/internal/fit"
)
// Activity represents a Garmin Connect activity
@@ -115,7 +124,7 @@ func (ar *ActivityResponse) ToActivity() Activity {
// Weather contains weather conditions during activity
type Weather struct {
Condition string `json:"condition"`
Temperature float64 `json:"temperature"`
Temperature float64 `json:"temperature"`
Humidity float64 `json:"humidity"`
}
@@ -157,7 +166,6 @@ func (gtp *GPSTrackPoint) UnmarshalJSON(data []byte) error {
return nil
}
// ActivitiesResponse represents the response from the activities endpoint
// ActivitiesResponse represents the response from the activities endpoint
type ActivitiesResponse struct {
Activities []ActivityResponse `json:"activities"`
@@ -217,3 +225,85 @@ func (c *Client) GetActivityDetails(ctx context.Context, activityID int64) (*Act
return &activityDetail, nil
}
// UploadActivity handles FIT file uploads
func (c *Client) UploadActivity(ctx context.Context, fitFile []byte) (int64, error) {
path := "/upload-service/upload/.fit"
// Validate FIT file
if err := fit.Validate(fitFile); err != nil {
return 0, fmt.Errorf("invalid FIT file: %w", err)
}
// Prepare multipart form
body := &bytes.Buffer{}
writer := multipart.NewWriter(body)
part, err := writer.CreateFormFile("file", "activity.fit")
if err != nil {
return 0, err
}
if _, err = io.Copy(part, bytes.NewReader(fitFile)); err != nil {
return 0, err
}
writer.Close()
req, err := http.NewRequestWithContext(ctx, "POST", c.baseURL+path, body)
if err != nil {
return 0, err
}
req.Header.Set("Content-Type", writer.FormDataContentType())
resp, err := c.httpClient.Do(req)
if err != nil {
return 0, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusCreated {
return 0, fmt.Errorf("upload failed with status %d", resp.StatusCode)
}
// Parse response to get activity ID
var result struct {
ActivityID int64 `json:"activityId"`
}
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return 0, err
}
return result.ActivityID, nil
}
// DownloadActivity retrieves a FIT file for an activity
func (c *Client) DownloadActivity(ctx context.Context, activityID int64) ([]byte, error) {
path := fmt.Sprintf("/download-service/export/activity/%d", activityID)
req, err := http.NewRequestWithContext(ctx, "GET", c.baseURL+path, nil)
if err != nil {
return nil, err
}
req.Header.Set("Accept", "application/fit")
resp, err := c.httpClient.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("download failed with status %d", resp.StatusCode)
}
return ioutil.ReadAll(resp.Body)
}
// Validate FIT file structure
func ValidateFIT(fitFile []byte) error {
if len(fitFile) < fit.MinFileSize {
return fmt.Errorf("file too small to be a valid FIT file")
}
if string(fitFile[8:12]) != ".FIT" {
return fmt.Errorf("invalid FIT file signature")
}
return nil
}

View File

@@ -4,82 +4,53 @@ import (
"os"
"path/filepath"
"testing"
"time"
"github.com/dghubble/oauth1"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestFileStorage(t *testing.T) {
// Create temp directory for tests
tempDir, err := os.MkdirTemp("", "garmin-test")
assert.NoError(t, err)
defer os.RemoveAll(tempDir)
// Setup
tempDir := t.TempDir()
storage := NewFileStorage()
storage.Path = filepath.Join(tempDir, "token.json")
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",
t.Run("SaveToken and GetToken", func(t *testing.T) {
token := &oauth1.Token{
Token: "test_token",
TokenSecret: "test_secret",
}
// Save token
err := storage.SaveToken(testToken)
assert.NoError(t, err)
err := storage.SaveToken(token)
require.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)
// Get token
retrievedToken, err := storage.GetToken()
require.NoError(t, err)
// Verify
assert.Equal(t, token.Token, retrievedToken.Token)
assert.Equal(t, token.TokenSecret, retrievedToken.TokenSecret)
})
// Test missing token file
t.Run("TokenMissing", func(t *testing.T) {
t.Run("EmptyToken", func(t *testing.T) {
token := &oauth1.Token{
Token: "",
TokenSecret: "",
}
err := storage.SaveToken(token)
require.NoError(t, err)
_, err = storage.GetToken()
require.ErrorIs(t, err, os.ErrNotExist)
})
t.Run("NonExistentFile", func(t *testing.T) {
storage.Path = filepath.Join(tempDir, "nonexistent.json")
_, 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)
})
}
require.ErrorIs(t, err, os.ErrNotExist)
})
}

81
internal/auth/mfastate.go Normal file
View File

@@ -0,0 +1,81 @@
package auth
import (
"encoding/json"
"os"
"path/filepath"
"time"
"sync"
)
// MFAState represents the state of an MFA verification session
type MFAState struct {
VerificationURL string `json:"verification_url"`
SessionToken string `json:"session_token"`
MFACode string `json:"mfa_code"`
ExpiresAt time.Time `json:"expires_at"`
}
// MFAStorage handles persistence of MFA state
type MFAStorage interface {
Store(state MFAState) error
Get() (MFAState, error)
Clear() error
}
// FileMFAStorage implements MFAStorage using a JSON file
type FileMFAStorage struct {
filePath string
mutex sync.RWMutex
}
// NewFileMFAStorage creates a new file-based MFA storage
func NewFileMFAStorage() *FileMFAStorage {
home, _ := os.UserHomeDir()
return &FileMFAStorage{
filePath: filepath.Join(home, ".garminconnect", "mfa_state.json"),
}
}
// Store saves MFA state to file
func (s *FileMFAStorage) Store(state MFAState) error {
s.mutex.Lock()
defer s.mutex.Unlock()
// Create directory if needed
dir := filepath.Dir(s.filePath)
if err := os.MkdirAll(dir, 0700); err != nil {
return err
}
data, err := json.MarshalIndent(state, "", " ")
if err != nil {
return err
}
return os.WriteFile(s.filePath, data, 0600)
}
// Get retrieves MFA state from file
func (s *FileMFAStorage) Get() (MFAState, error) {
s.mutex.RLock()
defer s.mutex.RUnlock()
data, err := os.ReadFile(s.filePath)
if err != nil {
if os.IsNotExist(err) {
return MFAState{}, nil
}
return MFAState{}, err
}
var state MFAState
err = json.Unmarshal(data, &state)
return state, err
}
// Clear removes the MFA state file
func (s *FileMFAStorage) Clear() error {
s.mutex.Lock()
defer s.mutex.Unlock()
return os.Remove(s.filePath)
}

121
internal/fit/encoder.go Normal file
View File

@@ -0,0 +1,121 @@
package fit
import (
"bytes"
"encoding/binary"
"time"
)
// FitBaseType represents FIT base type definitions
type FitBaseType struct {
ID int
Name string
Size int
Invalid uint64
Field byte
}
// Base types definitions
var (
FitEnum = FitBaseType{0, "enum", 1, 0xFF, 0x00}
FitUint8 = FitBaseType{2, "uint8", 1, 0xFF, 0x02}
FitUint16 = FitBaseType{4, "uint16", 2, 0xFFFF, 0x84}
FitUint32 = FitBaseType{6, "uint32", 4, 0xFFFFFFFF, 0x86}
FitString = FitBaseType{7, "string", 1, 0x00, 0x07}
FitFloat32 = FitBaseType{8, "float32", 4, 0xFFFFFFFF, 0x88}
FitByte = FitBaseType{13, "byte", 1, 0xFF, 0x0D}
)
// FitEncoder encodes FIT activity files
type FitEncoder struct {
buf bytes.Buffer
headerSize int
activityDefined bool
}
const (
FitHeaderSize = 12
FileTypeActivity = 4
GarminEpochOffset = 631065600 // UTC 00:00 Dec 31 1989
)
// NewFitEncoder creates a new FIT encoder
func NewFitEncoder() *FitEncoder {
e := &FitEncoder{headerSize: FitHeaderSize}
e.writeHeader(0) // Initial header with 0 data size
return e
}
// writeHeader writes the FIT file header
func (e *FitEncoder) writeHeader(dataSize int) {
e.buf.Reset()
header := []byte{
byte(e.headerSize), // Header size
16, // Protocol version
0, 0, 0, 108, // Profile version (108.0)
}
e.buf.Write(header)
// Write data size (4 bytes, little-endian)
sizeBytes := make([]byte, 4)
binary.LittleEndian.PutUint32(sizeBytes, uint32(dataSize))
e.buf.Write(sizeBytes)
// Write file type signature
e.buf.Write([]byte(".FIT"))
}
// AddActivity adds activity data to the FIT file
func (e *FitEncoder) AddActivity(activity FitActivity) error {
// TODO: Implement activity message encoding
return nil
}
// Encode returns the encoded FIT file bytes
func (e *FitEncoder) Encode() ([]byte, error) {
dataSize := e.buf.Len() - e.headerSize
e.writeHeader(dataSize)
e.writeCRC()
return e.buf.Bytes(), nil
}
// writeCRC calculates and appends the FIT CRC
func (e *FitEncoder) writeCRC() {
data := e.buf.Bytes()
crc := uint16(0)
for _, b := range data {
crc = calcCRC(crc, b)
}
crcBytes := make([]byte, 2)
binary.LittleEndian.PutUint16(crcBytes, crc)
e.buf.Write(crcBytes)
}
// calcCRC calculates FIT CRC
func calcCRC(crc uint16, byteVal byte) uint16 {
table := [...]uint16{
0x0000, 0xCC01, 0xD801, 0x1400, 0xF001, 0x3C00, 0x2800, 0xE401,
0xA001, 0x6C00, 0x7800, 0xB401, 0x5000, 0x9C01, 0x8801, 0x4400,
}
tmp := table[crc & 0xF]
crc = (crc >> 4) & 0x0FFF
crc = crc ^ tmp ^ table[byteVal & 0xF]
tmp = table[crc & 0xF]
crc = (crc >> 4) & 0x0FFF
return crc ^ tmp ^ table[(byteVal >> 4) & 0xF]
}
// timestamp converts Go time to FIT timestamp
func timestamp(t time.Time) uint32 {
return uint32(t.Unix() - GarminEpochOffset)
}
// FitActivity represents basic activity data for FIT encoding
type FitActivity struct {
Name string
Type string
StartTime time.Time
Duration time.Duration
Distance float32 // in meters
}