rueda 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/LICENSE +21 -0
- data/README.md +105 -0
- data/SPEC.md +228 -0
- data/exe/rueda +5 -0
- data/lib/rueda/activities.rb +33 -0
- data/lib/rueda/auth/api_key.rb +44 -0
- data/lib/rueda/auth/authenticator.rb +30 -0
- data/lib/rueda/auth/secret_file.rb +43 -0
- data/lib/rueda/auth/token.rb +35 -0
- data/lib/rueda/auth/token_cache.rb +41 -0
- data/lib/rueda/cli.rb +191 -0
- data/lib/rueda/errors.rb +50 -0
- data/lib/rueda/garmin/client.rb +107 -0
- data/lib/rueda/model/activity.rb +24 -0
- data/lib/rueda/normalize/activity.rb +139 -0
- data/lib/rueda/output/csv.rb +15 -0
- data/lib/rueda/output/json.rb +22 -0
- data/lib/rueda/output/ndjson.rb +11 -0
- data/lib/rueda/output/saved_file.rb +17 -0
- data/lib/rueda/output/table.rb +57 -0
- data/lib/rueda/output.rb +30 -0
- data/lib/rueda/version.rb +3 -0
- data/lib/rueda.rb +13 -0
- metadata +79 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: eb546a4621da61ec3d034e31f02bf92707e0f255876d55b19998fede94d857da
|
|
4
|
+
data.tar.gz: 82371c1594ba2a17140c370f0804b80f3cec82c05935db3274fd21c2edb0cd07
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: e3c2d941a4c3fce7ebee89868cb0ebb1701a88f0a3af7c3f5723dc21afe11f09e39829965aec7aeb3a8d69dab6da9b188ae91571759a39743b7d73a5582f8922
|
|
7
|
+
data.tar.gz: 896de09eb7cd26ad7a426b6f4935bb676c156f0ebf57d62d0671c8d929738e055961707646ddd5771a9fa9e7b18315f6aa61ecb632cf0365aae3accd574cd4d8
|
data/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Ariel Rzezak
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
# rueda
|
|
2
|
+
|
|
3
|
+
A read-only command-line client for the Garmin Connect Personal API. It fetches your activities and prints them as a table, JSON, NDJSON, or CSV.
|
|
4
|
+
|
|
5
|
+
```
|
|
6
|
+
$ rueda activities --since 2025-09-30
|
|
7
|
+
DATE TYPE NAME KM MOVING KM/H ELEV HR CAD
|
|
8
|
+
2025-10-05 road_biking Activity 1 26.0 1:05:05 23.9 155 146 86
|
|
9
|
+
2025-10-03 road_biking Activity 2 29.3 1:11:32 24.6 160 120 74
|
|
10
|
+
2025-10-02 road_biking Activity 3 35.7 1:32:40 23.1 195 136 73
|
|
11
|
+
2025-09-30 road_biking Activity 4 35.7 1:29:24 23.9 202 133 76
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
`SPEC.md` is the full specification.
|
|
15
|
+
|
|
16
|
+
## Install
|
|
17
|
+
|
|
18
|
+
rueda needs Ruby 3.4 or later.
|
|
19
|
+
|
|
20
|
+
```bash
|
|
21
|
+
gem install rueda
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
To run it from a clone instead, run `bundle install` and use `exe/rueda`.
|
|
25
|
+
|
|
26
|
+
## Set up your API key
|
|
27
|
+
|
|
28
|
+
1. In Garmin Connect, go to **Settings → Personal API** and create a key. Choose how long it lasts (30, 90, 180, or 365 days). Garmin shows it only once, so copy it right away.
|
|
29
|
+
2. With the key copied to the clipboard, save it where rueda looks for it:
|
|
30
|
+
|
|
31
|
+
```bash
|
|
32
|
+
mkdir -p ~/.config/rueda && (umask 077 && pbpaste > ~/.config/rueda/api_key)
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
The file holds only the key, with `0600` permissions. If `~/.config` is in a dotfiles repo or synced, exclude `rueda/`. You can also set `GARMIN_API_KEY` instead, which takes precedence over the file. rueda never accepts the key as a command-line argument, so it stays out of your shell history and the process list.
|
|
36
|
+
|
|
37
|
+
3. Check it works:
|
|
38
|
+
|
|
39
|
+
```
|
|
40
|
+
$ rueda auth check
|
|
41
|
+
Token OK, valid until 2026-11-11 11:01 -03.
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
When the key expires, create a new one and replace the old one in the same place.
|
|
45
|
+
|
|
46
|
+
## Usage
|
|
47
|
+
|
|
48
|
+
```bash
|
|
49
|
+
rueda activities # the last 30 days
|
|
50
|
+
rueda activities --since 2025-09-01 --until 2025-09-30
|
|
51
|
+
rueda activities --type road_biking --limit 5 # --type takes Garmin's type key and can repeat
|
|
52
|
+
rueda activity 900000001 # one activity
|
|
53
|
+
rueda activity 900000001 --fit # save the original file as activity-900000001.zip
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
In a terminal, rueda prints a table. When its output goes to a pipe or a file, it prints JSON, so scripts and agents get stable data by default:
|
|
57
|
+
|
|
58
|
+
```
|
|
59
|
+
$ rueda activities --limit 1 | jq
|
|
60
|
+
{
|
|
61
|
+
"schema_version": 1,
|
|
62
|
+
"query": {
|
|
63
|
+
"since": "2025-09-06",
|
|
64
|
+
"until": "2025-10-06",
|
|
65
|
+
"type": []
|
|
66
|
+
},
|
|
67
|
+
"count": 1,
|
|
68
|
+
"activities": [
|
|
69
|
+
{
|
|
70
|
+
"garmin_id": 900000001,
|
|
71
|
+
"name": "Activity 1",
|
|
72
|
+
"type": "road_biking",
|
|
73
|
+
"started_at": "2025-10-05T17:55:26Z",
|
|
74
|
+
"local_date": "2025-10-05",
|
|
75
|
+
"distance_km": 26.0,
|
|
76
|
+
"moving_time_s": 3905,
|
|
77
|
+
"elapsed_time_s": 3916,
|
|
78
|
+
"avg_speed_kmh": 23.9,
|
|
79
|
+
"max_speed_kmh": 35.0,
|
|
80
|
+
"elevation_gain_m": 155,
|
|
81
|
+
"elevation_loss_m": 147,
|
|
82
|
+
"avg_hr_bpm": 146,
|
|
83
|
+
"max_hr_bpm": 170,
|
|
84
|
+
"avg_power_w": null,
|
|
85
|
+
"calories": 728,
|
|
86
|
+
"avg_cadence_rpm": 86,
|
|
87
|
+
"max_cadence_rpm": 125
|
|
88
|
+
}
|
|
89
|
+
]
|
|
90
|
+
}
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
Units are in the field names, and missing data is `null`, never `0`.
|
|
94
|
+
|
|
95
|
+
Other options:
|
|
96
|
+
|
|
97
|
+
- `--format table|json|ndjson|csv` picks the format. NDJSON prints one activity per line; CSV prints every field, in schema order.
|
|
98
|
+
- `--raw` prints Garmin's JSON as it arrives, for fields rueda doesn't map yet. It works only with `json` and `ndjson`, and its shape is not guaranteed.
|
|
99
|
+
- `--quiet` hides warnings. `--timeout SECONDS` changes the network timeout (30 by default).
|
|
100
|
+
|
|
101
|
+
Data goes to stdout, and warnings and errors go to stderr. Each kind of error has its own exit code, such as 3 for no API key and 4 when Garmin rejects the key; `SPEC.md` lists them all.
|
|
102
|
+
|
|
103
|
+
## License
|
|
104
|
+
|
|
105
|
+
MIT. See `LICENSE`.
|
data/SPEC.md
ADDED
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
# rueda — a Garmin training data client
|
|
2
|
+
|
|
3
|
+
> The name comes from the Spanish cycling term _ir a rueda_ ("riding on a wheel", i.e. drafting): it sits behind Garmin and lets Garmin do the work.
|
|
4
|
+
|
|
5
|
+
## What it is
|
|
6
|
+
|
|
7
|
+
A read-only command-line client for the **Garmin Connect Personal API**. It fetches data on demand, normalizes it into its own stable schema, and prints it in the requested format.
|
|
8
|
+
|
|
9
|
+
It serves two kinds of consumers equally:
|
|
10
|
+
|
|
11
|
+
- **Humans** at a terminal: readable tables, and pipes into other tools.
|
|
12
|
+
- **Agents** (Claude Code and similar) via the shell: stable JSON, predictable errors and exit codes.
|
|
13
|
+
|
|
14
|
+
## What it is not
|
|
15
|
+
|
|
16
|
+
- It does not sync or store data. Garmin is the source of truth; what happens to the output is up to the caller.
|
|
17
|
+
- It does not write to Garmin. The Personal API is read-only.
|
|
18
|
+
- It has no GUI and no server.
|
|
19
|
+
- It is not multi-user: one account, the one that owns the key.
|
|
20
|
+
|
|
21
|
+
The only thing persisted locally is the cached access token.
|
|
22
|
+
|
|
23
|
+
## v1 scope
|
|
24
|
+
|
|
25
|
+
- Authentication with a personal API key, plus token caching.
|
|
26
|
+
- Listing activities with filters.
|
|
27
|
+
- Activity detail.
|
|
28
|
+
- Downloading an activity's original file (a ZIP containing the .FIT).
|
|
29
|
+
- Output as `table`, `json`, `ndjson`, and `csv`.
|
|
30
|
+
|
|
31
|
+
## Architecture
|
|
32
|
+
|
|
33
|
+
```
|
|
34
|
+
lib/rueda/
|
|
35
|
+
auth/ # key → token, caching, renewal
|
|
36
|
+
garmin/ # HTTP client and endpoints (the only code that knows Garmin)
|
|
37
|
+
model/ # Activity and other normalized types
|
|
38
|
+
normalize/ # Garmin JSON → model
|
|
39
|
+
output/ # formatters: table, json, ndjson, csv
|
|
40
|
+
cli/ # commands; a thin layer over the above
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
## Stack
|
|
44
|
+
|
|
45
|
+
- Ruby ≥ 3.4, distributed as a gem.
|
|
46
|
+
- CLI: `OptionParser` from the stdlib. Four commands don't need a framework.
|
|
47
|
+
- Tests: Minitest with WebMock. Linter: Standard (no rule configuration).
|
|
48
|
+
|
|
49
|
+
## Authentication
|
|
50
|
+
|
|
51
|
+
Flow per the official Personal API FAQ:
|
|
52
|
+
|
|
53
|
+
1. The user creates a key in Garmin Connect → Settings → Personal API. It is shown only once and expires after the chosen period (30, 90, 180, or 365 days).
|
|
54
|
+
2. The client exchanges the key for a token: `POST https://personal.connectapi.garmin.com/personalapitoken-service/personal-api/personal-api-key/token` with the header `Authorization: ApiKey <key>`.
|
|
55
|
+
3. The response body is the bare JWT, not JSON (see `test/fixtures/token.txt`). The token is valid for 24 h (or until the key expires). There are no refresh tokens: when it expires, request a new one with the key.
|
|
56
|
+
4. Data requests use `Authorization: Bearer <token>`.
|
|
57
|
+
|
|
58
|
+
### Key sources (in order of precedence)
|
|
59
|
+
|
|
60
|
+
1. `--api-key-file <path>`
|
|
61
|
+
2. The `GARMIN_API_KEY` environment variable
|
|
62
|
+
3. The default file `~/.config/rueda/api_key`
|
|
63
|
+
|
|
64
|
+
The key is **not** accepted as a direct argument: it would end up in shell history and the process list.
|
|
65
|
+
|
|
66
|
+
### Token cache
|
|
67
|
+
|
|
68
|
+
- File `~/.config/rueda/token.json`, created with `0600` permissions.
|
|
69
|
+
- Reused if it was issued for the current key (store a hash of the key, never the key itself) and has more than one hour left before the JWT `exp`.
|
|
70
|
+
- On a `401`, discard the cache, request a new token, and retry **once**.
|
|
71
|
+
- Warn on stderr if the key file or token file has permissions looser than `0600`.
|
|
72
|
+
|
|
73
|
+
## Garmin endpoints
|
|
74
|
+
|
|
75
|
+
| Purpose | Request | Status |
|
|
76
|
+
| --------------- | --------------------------------------------------------------------------------------------------- | -------------------------------------------------------- |
|
|
77
|
+
| Token | `POST /personalapitoken-service/personal-api/personal-api-key/token` | Confirmed (official FAQ and fixture; not in the OpenAPI) |
|
|
78
|
+
| List activities | `GET /activitylist-service/activities/search/activities?start&limit&startDate&endDate` | Confirmed (OpenAPI and fixture) |
|
|
79
|
+
| Detail | `GET /activity-service/activity/{id}` | Confirmed (OpenAPI; `404` if the activity doesn't exist) |
|
|
80
|
+
| Original file | `GET /download-service/files/activity/{id}` → ZIP | Confirmed (OpenAPI) |
|
|
81
|
+
|
|
82
|
+
Host: `https://personal.connectapi.garmin.com`, from the OpenAPI `servers` field (which also lists `https://personal.connectapi.garmin.cn`).
|
|
83
|
+
|
|
84
|
+
List response fields match the OpenAPI: `activityId`, `activityName`, `startTimeLocal` and `startTimeGMT` (`yyyy-MM-dd HH:mm:ss`, no offset), `activityType.typeKey`, `distance` in meters, `movingDuration` and `elapsedDuration` in seconds (floats), `elevationGain` and `elevationLoss` in meters, `averageHR`, `maxHR`, `averageSpeed` and `maxSpeed` in m/s, `avgPower` in watts, `calories` in kcal. Fields can be absent or `null` (for example `averageHR`).
|
|
85
|
+
|
|
86
|
+
Rate limits: neither the FAQ nor the OpenAPI documents `429` or a limit. Handle `429` anyway, without assuming a number.
|
|
87
|
+
|
|
88
|
+
Pagination: `start` is a zero-based offset; `limit` defaults to 99, and rueda requests pages of 99. Advance by the number of records received (not by `limit`, since Garmin may return fewer) and stop on an empty page.
|
|
89
|
+
|
|
90
|
+
Detail response: the type is in `activityTypeDTO.typeKey` and the measurements are in `summaryDTO`, with the same names as the list except `averagePower` (list: `avgPower`) and `averageBikeCadence`/`maxBikeCadence` (list: `averageBikingCadenceInRevPerMinute`/`maxBikingCadenceInRevPerMinute`). Start times look like `2025-10-05T14:55:26.0` instead of `2025-10-05 14:55:26`. Confirmed by `test/fixtures/activity_detail.json`.
|
|
91
|
+
|
|
92
|
+
Type filter: Garmin's `activityType` parameter takes a single parent type key (such as `cycling`), not a `typeKey` like `road_biking`. rueda filters by `--type` itself after fetching and does not send `activityType`.
|
|
93
|
+
|
|
94
|
+
## Commands
|
|
95
|
+
|
|
96
|
+
```
|
|
97
|
+
rueda auth check
|
|
98
|
+
rueda activities [--since DATE] [--until DATE] [--type TYPE] [--limit N]
|
|
99
|
+
rueda activity <id>
|
|
100
|
+
rueda activity <id> --fit [-o FILE]
|
|
101
|
+
rueda version
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
Global options: `--format table|json|ndjson|csv`, `--raw`, `--api-key-file PATH`, `--timeout SECONDS`, `--quiet`.
|
|
105
|
+
|
|
106
|
+
### `rueda auth check`
|
|
107
|
+
|
|
108
|
+
Checks that a key is available and that Garmin accepts it: it always requests a new token, skipping the cache (so a revoked key fails here), then caches it. Prints when that token expires. Never shows credentials.
|
|
109
|
+
|
|
110
|
+
### `rueda activities`
|
|
111
|
+
|
|
112
|
+
- `--since` / `--until`: `YYYY-MM-DD` dates, in local time. Defaults to the last 30 days: `--until` defaults to today and `--since` to 30 days before `--until`. Both ends are inclusive.
|
|
113
|
+
- `--type`: filter by Garmin's `typeKey` (see [Activity types](#activity-types)). Repeatable.
|
|
114
|
+
- `--limit`: cap on results. No cap by default within the date range.
|
|
115
|
+
- Order: most recent first.
|
|
116
|
+
|
|
117
|
+
### `rueda activity <id>`
|
|
118
|
+
|
|
119
|
+
Detail for one activity, using the same schema as the list plus any optional fields the detail provides (none yet). `<id>` must be a positive integer. JSON output is `{"schema_version": 1, "activity": {...}}`; `ndjson`, `csv` and `table` print one activity as they would in a list. `--raw` prints Garmin's detail object.
|
|
120
|
+
|
|
121
|
+
With `--fit`, downloads the original ZIP. By default it saves `activity-<id>.zip` in the current directory and reports the path on stderr. It does not overwrite an existing file unless `--force` is given (exit 2 otherwise). With `-o -` it writes to stdout, and refuses if stdout is a terminal. A response that isn't a ZIP exits 8 and writes nothing.
|
|
122
|
+
|
|
123
|
+
## Output
|
|
124
|
+
|
|
125
|
+
### Formats
|
|
126
|
+
|
|
127
|
+
| Format | Use |
|
|
128
|
+
| -------- | -------------------------------------------------------------------------- |
|
|
129
|
+
| `table` | Default when stdout is a terminal. For reading, not parsing. |
|
|
130
|
+
| `json` | Default when stdout is not a terminal. An object with metadata and a list. |
|
|
131
|
+
| `ndjson` | One activity per line. For streams and `jq`. |
|
|
132
|
+
| `csv` | Header in the first row; columns in schema order. |
|
|
133
|
+
|
|
134
|
+
`--raw` returns Garmin's JSON unnormalized (only with `json`/`ndjson`): a plain array of Garmin's activity objects with `json`, one per line with `ndjson`, and no envelope. `--type` and `--limit` still apply. With `--raw` and no `--format`, the output is `json` even in a terminal. Useful for debugging and for reading fields not yet mapped. Its shape is not guaranteed.
|
|
135
|
+
|
|
136
|
+
The `table` columns for activities are `DATE` (`local_date`), `TYPE`, `NAME`, `KM`, `MOVING` (moving time as `h:mm:ss`), `KM/H` (average), `ELEV` (gain), `HR` (average) and `CAD` (average biking cadence). Missing values show as `—`, and control characters (such as terminal escape codes) are removed. `ndjson` prints nothing for an empty result; `table` and `csv` print just the header.
|
|
137
|
+
|
|
138
|
+
### JSON envelope
|
|
139
|
+
|
|
140
|
+
```json
|
|
141
|
+
{
|
|
142
|
+
"schema_version": 1,
|
|
143
|
+
"query": { "since": "2026-10-01", "until": "2026-11-10", "type": ["road_biking"] },
|
|
144
|
+
"count": 1,
|
|
145
|
+
"activities": [{ "...": "see schema" }]
|
|
146
|
+
}
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
### Activity schema (v1)
|
|
150
|
+
|
|
151
|
+
| Field | Type | Notes |
|
|
152
|
+
| ------------------ | --------------- | ---------------------------------------------------------------------------------------------- |
|
|
153
|
+
| `garmin_id` | integer | Stable Garmin identifier. |
|
|
154
|
+
| `name` | string \| null | Activity name. |
|
|
155
|
+
| `type` | string | Garmin's `typeKey`, unchanged. |
|
|
156
|
+
| `started_at` | string | ISO 8601, UTC. |
|
|
157
|
+
| `local_date` | string | `YYYY-MM-DD` in the activity's local time. |
|
|
158
|
+
| `distance_km` | number \| null | One decimal place. |
|
|
159
|
+
| `moving_time_s` | integer \| null | From `movingDuration`, rounded. |
|
|
160
|
+
| `elapsed_time_s` | integer \| null | From `elapsedDuration` (not `duration`), rounded. |
|
|
161
|
+
| `avg_speed_kmh` | number \| null | Computed from distance and moving time, one decimal place. Falls back to elapsed time if moving time is missing. |
|
|
162
|
+
| `max_speed_kmh` | number \| null | One decimal place. |
|
|
163
|
+
| `elevation_gain_m` | integer \| null | |
|
|
164
|
+
| `elevation_loss_m` | integer \| null | |
|
|
165
|
+
| `avg_hr_bpm` | integer \| null | |
|
|
166
|
+
| `max_hr_bpm` | integer \| null | |
|
|
167
|
+
| `avg_power_w` | integer \| null | |
|
|
168
|
+
| `calories` | integer \| null | |
|
|
169
|
+
| `avg_cadence_rpm` | integer \| null | Biking cadence, revolutions per minute. `null` when Garmin has none. |
|
|
170
|
+
| `max_cadence_rpm` | integer \| null | Biking cadence, revolutions per minute. `null` when Garmin has none. |
|
|
171
|
+
|
|
172
|
+
Schema rules:
|
|
173
|
+
|
|
174
|
+
- Units go in the field name.
|
|
175
|
+
- Missing data is `null`, never `0`.
|
|
176
|
+
- New fields are added without bumping `schema_version`. Renaming, removing, or changing a field's type does bump it.
|
|
177
|
+
- Until v1 is first released, the schema can still change without a bump.
|
|
178
|
+
|
|
179
|
+
### Activity types
|
|
180
|
+
|
|
181
|
+
`type` is Garmin's `typeKey` as-is, with no mapping (for example `road_biking`). Seen in fixtures: `road_biking`. The OpenAPI's examples use `road_cycling`, but the fixtures show `road_biking`; the fixtures win.
|
|
182
|
+
|
|
183
|
+
## Errors and exit codes
|
|
184
|
+
|
|
185
|
+
Data goes to stdout; logs, warnings, and errors go to stderr. With `--format json`, errors are also emitted as JSON on stderr:
|
|
186
|
+
|
|
187
|
+
```json
|
|
188
|
+
{ "error": { "code": "auth_failed", "message": "The key has expired or been revoked." } }
|
|
189
|
+
```
|
|
190
|
+
|
|
191
|
+
| Exit code | `error.code` | Case |
|
|
192
|
+
| --------- | -------------- | ------------------------------------------------------------ |
|
|
193
|
+
| 0 | — | OK (including an empty result). |
|
|
194
|
+
| 1 | `unexpected` | Unexpected error (a bug in rueda). |
|
|
195
|
+
| 2 | `usage` | Invalid arguments. |
|
|
196
|
+
| 3 | `no_api_key` | No key found. |
|
|
197
|
+
| 4 | `auth_failed` | Garmin rejected the key or token (`401` or `403`). |
|
|
198
|
+
| 5 | `not_found` | Garmin returned `404` (for example, no such activity). |
|
|
199
|
+
| 6 | `rate_limited` | Garmin returned `429`. |
|
|
200
|
+
| 7 | `network` | Timeout or connection failure. |
|
|
201
|
+
| 8 | `bad_response` | Response had an unexpected shape or was malformed. |
|
|
202
|
+
| 9 | `garmin_error` | Garmin returned any other error status (for example `5xx`). |
|
|
203
|
+
|
|
204
|
+
## Tests
|
|
205
|
+
|
|
206
|
+
- Unit tests for `normalize/` against real (anonymized) JSON fixtures.
|
|
207
|
+
- CLI integration tests with WebMock: output formats, exit codes, and key precedence.
|
|
208
|
+
- A separate command or script, outside CI, to record fixtures with a real key.
|
|
209
|
+
|
|
210
|
+
## Roadmap
|
|
211
|
+
|
|
212
|
+
Out of scope for v1, in order:
|
|
213
|
+
|
|
214
|
+
1. **Health data:** sleep, HRV, Body Battery, resting heart rate. Same pattern: endpoint → normalizer → schema.
|
|
215
|
+
2. **MCP server:** an adapter over the same core, for clients without a shell.
|
|
216
|
+
|
|
217
|
+
## Open decisions
|
|
218
|
+
|
|
219
|
+
- **Aggregates (`rueda stats`).** Out for now: the client fetches data and consumers do the math. Reconsider if agents keep repeating the same calculations.
|
|
220
|
+
- **Distribution:** an installable gem, or also a standalone binary.
|
|
221
|
+
- **Confirm `rueda` is available** on RubyGems and GitHub.
|
|
222
|
+
|
|
223
|
+
## References
|
|
224
|
+
|
|
225
|
+
- [Garmin Connect Personal API](https://developer.garmin.com/gc-developer-program/personal/personal-api/)
|
|
226
|
+
- [Personal API FAQ](https://developer.garmin.com/gc-developer-program/personal/personal-faq/)
|
|
227
|
+
- [Personal API Swagger](https://developer.garmin.com/gc-developer-program/personal/swagger-docs/)
|
|
228
|
+
- [Official Python client](https://github.com/garmin/connect-personal-api-python)
|
data/exe/rueda
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
require_relative "normalize/activity"
|
|
2
|
+
|
|
3
|
+
module Rueda
|
|
4
|
+
class Activities
|
|
5
|
+
def initialize(client:, authenticator:)
|
|
6
|
+
@client = client
|
|
7
|
+
@authenticator = authenticator
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
def list(from:, to:, types:, limit:, raw: false)
|
|
11
|
+
@authenticator.with_token do |token|
|
|
12
|
+
matching = @client.each_activity(token, from:, to:).lazy.select { of_type?(it, types) }
|
|
13
|
+
matching = matching.map { Normalize::Activity.call(it) } unless raw
|
|
14
|
+
limit ? matching.first(limit) : matching.to_a
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def find(id, raw: false)
|
|
19
|
+
detail = @authenticator.with_token { @client.activity(it, id) }
|
|
20
|
+
raw ? detail : Normalize::ActivityDetail.call(detail)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def fit_file(id)
|
|
24
|
+
@authenticator.with_token { @client.activity_file(it, id) }
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
private
|
|
28
|
+
|
|
29
|
+
def of_type?(raw, types)
|
|
30
|
+
types.empty? || types.include?(Normalize::Activity.type(raw))
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
require_relative "../errors"
|
|
2
|
+
require_relative "secret_file"
|
|
3
|
+
|
|
4
|
+
module Rueda
|
|
5
|
+
module Auth
|
|
6
|
+
class ApiKey
|
|
7
|
+
ENV_VAR = "GARMIN_API_KEY".freeze
|
|
8
|
+
|
|
9
|
+
def initialize(file:, env:, default_path:, warn:)
|
|
10
|
+
@file = file
|
|
11
|
+
@env = env
|
|
12
|
+
@default_path = default_path
|
|
13
|
+
@warn = warn
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def value
|
|
17
|
+
from_env || from_file || raise(NoApiKey, missing_message)
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
private
|
|
21
|
+
|
|
22
|
+
def from_env
|
|
23
|
+
presence(@env[ENV_VAR]) unless @file
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def from_file
|
|
27
|
+
presence(SecretFile.new(@file || @default_path, warn: @warn).read)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def presence(text)
|
|
31
|
+
text = text.to_s.strip
|
|
32
|
+
text unless text.empty?
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def missing_message
|
|
36
|
+
if @file
|
|
37
|
+
"No key in the file passed with --api-key-file."
|
|
38
|
+
else
|
|
39
|
+
"No API key: set #{ENV_VAR} or create #{@default_path}"
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
require_relative "../errors"
|
|
2
|
+
|
|
3
|
+
module Rueda
|
|
4
|
+
module Auth
|
|
5
|
+
class Authenticator
|
|
6
|
+
def initialize(api_key:, client:, cache:)
|
|
7
|
+
@api_key = api_key
|
|
8
|
+
@client = client
|
|
9
|
+
@cache = cache
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def token
|
|
13
|
+
@cache.fetch(@api_key) || fresh_token
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def fresh_token
|
|
17
|
+
@client.token(@api_key).tap { @cache.store(@api_key, it) }
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def with_token
|
|
21
|
+
current = token
|
|
22
|
+
begin
|
|
23
|
+
yield current
|
|
24
|
+
rescue AuthFailed
|
|
25
|
+
yield fresh_token
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
require "fileutils"
|
|
2
|
+
|
|
3
|
+
module Rueda
|
|
4
|
+
module Auth
|
|
5
|
+
class SecretFile
|
|
6
|
+
PRIVATE = 0o600
|
|
7
|
+
|
|
8
|
+
def initialize(path, warn:)
|
|
9
|
+
@path = path
|
|
10
|
+
@warn = warn
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def read
|
|
14
|
+
return unless File.file?(@path)
|
|
15
|
+
|
|
16
|
+
warn_if_loose
|
|
17
|
+
File.read(@path)
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def write(content)
|
|
21
|
+
FileUtils.mkdir_p(File.dirname(@path), mode: 0o700)
|
|
22
|
+
File.open(@path, "w", PRIVATE) do |file|
|
|
23
|
+
file.chmod(PRIVATE)
|
|
24
|
+
file.write(content)
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
private
|
|
29
|
+
|
|
30
|
+
def warn_if_loose
|
|
31
|
+
@warn.call("#{@path} has permissions #{format("%04o", mode)}; it should be 0600") if loose?
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def loose?
|
|
35
|
+
mode.anybits?(0o077)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def mode
|
|
39
|
+
File.stat(@path).mode & 0o777
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
require "json"
|
|
2
|
+
require_relative "../errors"
|
|
3
|
+
|
|
4
|
+
module Rueda
|
|
5
|
+
module Auth
|
|
6
|
+
class Token
|
|
7
|
+
FORMAT = /\A[\w-]+\.[\w-]+\.[\w-]*\z/
|
|
8
|
+
|
|
9
|
+
attr_reader :expires_at
|
|
10
|
+
|
|
11
|
+
def initialize(jwt)
|
|
12
|
+
raise BadResponse, "The token is not a JWT." unless jwt.match?(FORMAT)
|
|
13
|
+
|
|
14
|
+
@jwt = jwt
|
|
15
|
+
@expires_at = Time.at(Integer(claims.fetch("exp"))).utc
|
|
16
|
+
rescue JSON::ParserError, KeyError, TypeError, ArgumentError
|
|
17
|
+
raise BadResponse, "The token has no readable expiry."
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def expires_within?(seconds)
|
|
21
|
+
expires_at - Time.now < seconds
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def to_s = @jwt
|
|
25
|
+
|
|
26
|
+
def inspect = "#<#{self.class} expires_at=#{expires_at}>"
|
|
27
|
+
|
|
28
|
+
private
|
|
29
|
+
|
|
30
|
+
def claims
|
|
31
|
+
JSON.parse(@jwt.split(".")[1].tr("-_", "+/").unpack1("m"))
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
require "digest"
|
|
2
|
+
require "json"
|
|
3
|
+
require_relative "../errors"
|
|
4
|
+
require_relative "secret_file"
|
|
5
|
+
require_relative "token"
|
|
6
|
+
|
|
7
|
+
module Rueda
|
|
8
|
+
module Auth
|
|
9
|
+
class TokenCache
|
|
10
|
+
MIN_REMAINING = 3600
|
|
11
|
+
|
|
12
|
+
def initialize(path:, warn:)
|
|
13
|
+
@file = SecretFile.new(path, warn:)
|
|
14
|
+
@warn = warn
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def fetch(key)
|
|
18
|
+
content = @file.read
|
|
19
|
+
return unless content
|
|
20
|
+
|
|
21
|
+
data = JSON.parse(content)
|
|
22
|
+
return unless data.is_a?(Hash) && data["key_sha256"] == digest(key)
|
|
23
|
+
|
|
24
|
+
token = Token.new(data["token"].to_s)
|
|
25
|
+
token unless token.expires_within?(MIN_REMAINING)
|
|
26
|
+
rescue JSON::ParserError, SystemCallError, BadResponse
|
|
27
|
+
nil
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def store(key, token)
|
|
31
|
+
@file.write(JSON.generate(key_sha256: digest(key), token: token.to_s))
|
|
32
|
+
rescue SystemCallError => e
|
|
33
|
+
@warn.call("could not cache the token (#{e.class})")
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
private
|
|
37
|
+
|
|
38
|
+
def digest(key) = Digest::SHA256.hexdigest(key)
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|