@nativesquare/soma 0.2.0 → 0.4.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.
- package/dist/client/index.d.ts +167 -0
- package/dist/client/index.d.ts.map +1 -1
- package/dist/client/index.js +150 -0
- package/dist/client/index.js.map +1 -1
- package/dist/component/_generated/api.d.ts +2 -0
- package/dist/component/_generated/api.d.ts.map +1 -1
- package/dist/component/_generated/api.js.map +1 -1
- package/dist/component/_generated/component.d.ts +56 -0
- package/dist/component/_generated/component.d.ts.map +1 -1
- package/dist/component/garmin.d.ts +110 -0
- package/dist/component/garmin.d.ts.map +1 -0
- package/dist/component/garmin.js +454 -0
- package/dist/component/garmin.js.map +1 -0
- package/dist/component/public.d.ts +761 -761
- package/dist/component/schema.d.ts +390 -388
- package/dist/component/schema.d.ts.map +1 -1
- package/dist/component/schema.js +3 -2
- package/dist/component/schema.js.map +1 -1
- package/dist/component/strava.d.ts +5 -4
- package/dist/component/strava.d.ts.map +1 -1
- package/dist/component/strava.js +18 -1
- package/dist/component/strava.js.map +1 -1
- package/dist/component/validators/activity.d.ts +42 -42
- package/dist/component/validators/body.d.ts +47 -47
- package/dist/component/validators/daily.d.ts +17 -17
- package/dist/component/validators/plannedWorkout.d.ts +5 -5
- package/dist/component/validators/samples.d.ts +2 -2
- package/dist/component/validators/shared.d.ts +17 -17
- package/dist/component/validators/sleep.d.ts +17 -17
- package/dist/garmin/activity.d.ts +101 -0
- package/dist/garmin/activity.d.ts.map +1 -0
- package/dist/garmin/activity.js +207 -0
- package/dist/garmin/activity.js.map +1 -0
- package/dist/garmin/auth.d.ts +65 -0
- package/dist/garmin/auth.d.ts.map +1 -0
- package/dist/garmin/auth.js +155 -0
- package/dist/garmin/auth.js.map +1 -0
- package/dist/garmin/body.d.ts +26 -0
- package/dist/garmin/body.d.ts.map +1 -0
- package/dist/garmin/body.js +44 -0
- package/dist/garmin/body.js.map +1 -0
- package/dist/garmin/client.d.ts +99 -0
- package/dist/garmin/client.d.ts.map +1 -0
- package/dist/garmin/client.js +153 -0
- package/dist/garmin/client.js.map +1 -0
- package/dist/garmin/daily.d.ts +74 -0
- package/dist/garmin/daily.d.ts.map +1 -0
- package/dist/garmin/daily.js +143 -0
- package/dist/garmin/daily.js.map +1 -0
- package/dist/garmin/index.d.ts +20 -0
- package/dist/garmin/index.d.ts.map +1 -0
- package/dist/garmin/index.js +21 -0
- package/dist/garmin/index.js.map +1 -0
- package/dist/garmin/maps/activity-type.d.ts +7 -0
- package/dist/garmin/maps/activity-type.d.ts.map +1 -0
- package/dist/garmin/maps/activity-type.js +98 -0
- package/dist/garmin/maps/activity-type.js.map +1 -0
- package/dist/garmin/maps/sleep-level.d.ts +6 -0
- package/dist/garmin/maps/sleep-level.d.ts.map +1 -0
- package/dist/garmin/maps/sleep-level.js +21 -0
- package/dist/garmin/maps/sleep-level.js.map +1 -0
- package/dist/garmin/menstruation.d.ts +23 -0
- package/dist/garmin/menstruation.d.ts.map +1 -0
- package/dist/garmin/menstruation.js +34 -0
- package/dist/garmin/menstruation.js.map +1 -0
- package/dist/garmin/sleep.d.ts +62 -0
- package/dist/garmin/sleep.d.ts.map +1 -0
- package/dist/garmin/sleep.js +125 -0
- package/dist/garmin/sleep.js.map +1 -0
- package/dist/garmin/sync.d.ts +39 -0
- package/dist/garmin/sync.d.ts.map +1 -0
- package/dist/garmin/sync.js +175 -0
- package/dist/garmin/sync.js.map +1 -0
- package/dist/garmin/types.d.ts +212 -0
- package/dist/garmin/types.d.ts.map +1 -0
- package/dist/garmin/types.js +8 -0
- package/dist/garmin/types.js.map +1 -0
- package/dist/validators.d.ts +6617 -0
- package/dist/validators.d.ts.map +1 -0
- package/dist/validators.js +78 -0
- package/dist/validators.js.map +1 -0
- package/package.json +9 -1
- package/src/client/index.ts +194 -1
- package/src/component/_generated/api.ts +2 -0
- package/src/component/_generated/component.ts +62 -0
- package/src/component/garmin.ts +534 -0
- package/src/component/schema.ts +3 -2
- package/src/component/strava.ts +23 -1
- package/src/garmin/activity.test.ts +178 -0
- package/src/garmin/activity.ts +272 -0
- package/src/garmin/auth.test.ts +128 -0
- package/src/garmin/auth.ts +249 -0
- package/src/garmin/body.ts +59 -0
- package/src/garmin/client.ts +254 -0
- package/src/garmin/daily.ts +211 -0
- package/src/garmin/index.ts +76 -0
- package/src/garmin/maps/activity-type.test.ts +78 -0
- package/src/garmin/maps/activity-type.ts +116 -0
- package/src/garmin/maps/sleep-level.ts +22 -0
- package/src/garmin/menstruation.ts +42 -0
- package/src/garmin/sleep.test.ts +110 -0
- package/src/garmin/sleep.ts +170 -0
- package/src/garmin/sync.ts +223 -0
- package/src/garmin/types.ts +338 -0
- package/src/validators.ts +89 -0
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
// ─── Garmin OAuth 1.0a Helpers ───────────────────────────────────────────────
|
|
2
|
+
// Pure helper functions for the Garmin OAuth 1.0a three-legged flow.
|
|
3
|
+
// Uses the Web Crypto API for HMAC-SHA1 signing and global `fetch`.
|
|
4
|
+
|
|
5
|
+
import type {
|
|
6
|
+
GarminOAuthRequestTokenResponse,
|
|
7
|
+
GarminOAuthAccessTokenResponse,
|
|
8
|
+
} from "./types.js";
|
|
9
|
+
|
|
10
|
+
const OAUTH_BASE_URL = "https://connectapi.garmin.com";
|
|
11
|
+
const AUTH_CONFIRM_URL = "https://connect.garmin.com/oauthConfirm";
|
|
12
|
+
|
|
13
|
+
// ─── OAuth 1.0a Signature ───────────────────────────────────────────────────
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Generate a random nonce for OAuth 1.0a requests.
|
|
17
|
+
*/
|
|
18
|
+
export function generateNonce(): string {
|
|
19
|
+
const bytes = new Uint8Array(16);
|
|
20
|
+
crypto.getRandomValues(bytes);
|
|
21
|
+
return Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join("");
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Get the current Unix timestamp in seconds.
|
|
26
|
+
*/
|
|
27
|
+
export function getTimestamp(): string {
|
|
28
|
+
return String(Math.floor(Date.now() / 1000));
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Percent-encode a string per RFC 3986 (used by OAuth 1.0a).
|
|
33
|
+
* Unlike encodeURIComponent, this also encodes `!`, `*`, `'`, `(`, `)`.
|
|
34
|
+
*/
|
|
35
|
+
export function percentEncode(str: string): string {
|
|
36
|
+
return encodeURIComponent(str).replace(
|
|
37
|
+
/[!'()*]/g,
|
|
38
|
+
(c) => `%${c.charCodeAt(0).toString(16).toUpperCase()}`,
|
|
39
|
+
);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Build the OAuth 1.0a signature base string and compute the HMAC-SHA1 signature.
|
|
44
|
+
*
|
|
45
|
+
* @param method - HTTP method (e.g., "POST", "GET")
|
|
46
|
+
* @param url - The base URL (without query string)
|
|
47
|
+
* @param params - All OAuth + request parameters sorted alphabetically
|
|
48
|
+
* @param consumerSecret - The application's consumer secret
|
|
49
|
+
* @param tokenSecret - The token secret (empty string for request token step)
|
|
50
|
+
*/
|
|
51
|
+
export async function buildOAuthSignature(
|
|
52
|
+
method: string,
|
|
53
|
+
url: string,
|
|
54
|
+
params: Record<string, string>,
|
|
55
|
+
consumerSecret: string,
|
|
56
|
+
tokenSecret: string = "",
|
|
57
|
+
): Promise<string> {
|
|
58
|
+
const sortedKeys = Object.keys(params).sort();
|
|
59
|
+
const paramString = sortedKeys
|
|
60
|
+
.map((key) => `${percentEncode(key)}=${percentEncode(params[key])}`)
|
|
61
|
+
.join("&");
|
|
62
|
+
|
|
63
|
+
const signatureBaseString = [
|
|
64
|
+
method.toUpperCase(),
|
|
65
|
+
percentEncode(url),
|
|
66
|
+
percentEncode(paramString),
|
|
67
|
+
].join("&");
|
|
68
|
+
|
|
69
|
+
const signingKey = `${percentEncode(consumerSecret)}&${percentEncode(tokenSecret)}`;
|
|
70
|
+
const encoder = new TextEncoder();
|
|
71
|
+
const key = await crypto.subtle.importKey(
|
|
72
|
+
"raw",
|
|
73
|
+
encoder.encode(signingKey),
|
|
74
|
+
{ name: "HMAC", hash: "SHA-1" },
|
|
75
|
+
false,
|
|
76
|
+
["sign"],
|
|
77
|
+
);
|
|
78
|
+
const signature = await crypto.subtle.sign(
|
|
79
|
+
"HMAC",
|
|
80
|
+
key,
|
|
81
|
+
encoder.encode(signatureBaseString),
|
|
82
|
+
);
|
|
83
|
+
return btoa(String.fromCharCode(...new Uint8Array(signature)));
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Build the `Authorization: OAuth ...` header value from OAuth parameters.
|
|
88
|
+
*/
|
|
89
|
+
export function buildOAuthHeader(params: Record<string, string>): string {
|
|
90
|
+
const entries = Object.entries(params)
|
|
91
|
+
.map(([key, value]) => `${percentEncode(key)}="${percentEncode(value)}"`)
|
|
92
|
+
.join(", ");
|
|
93
|
+
return `OAuth ${entries}`;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// ─── Step 1: Get Request Token ──────────────────────────────────────────────
|
|
97
|
+
|
|
98
|
+
export interface GetRequestTokenOptions {
|
|
99
|
+
consumerKey: string;
|
|
100
|
+
consumerSecret: string;
|
|
101
|
+
callbackUrl?: string;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Obtain an unauthorized request token from Garmin.
|
|
106
|
+
*
|
|
107
|
+
* This is Step 1 of the OAuth 1.0a three-legged flow:
|
|
108
|
+
* 1. Your server calls this function to get a temporary request token
|
|
109
|
+
* 2. Redirect the user to the returned `authUrl`
|
|
110
|
+
* 3. After the user authorizes, exchange the verifier for an access token
|
|
111
|
+
*
|
|
112
|
+
* @returns The request token, token secret, and the authorization URL
|
|
113
|
+
*/
|
|
114
|
+
export async function getRequestToken(
|
|
115
|
+
opts: GetRequestTokenOptions,
|
|
116
|
+
): Promise<GarminOAuthRequestTokenResponse & { authUrl: string }> {
|
|
117
|
+
const url = `${OAUTH_BASE_URL}/oauth-service/oauth/request_token`;
|
|
118
|
+
const nonce = generateNonce();
|
|
119
|
+
const timestamp = getTimestamp();
|
|
120
|
+
|
|
121
|
+
const oauthParams: Record<string, string> = {
|
|
122
|
+
oauth_consumer_key: opts.consumerKey,
|
|
123
|
+
oauth_nonce: nonce,
|
|
124
|
+
oauth_signature_method: "HMAC-SHA1",
|
|
125
|
+
oauth_timestamp: timestamp,
|
|
126
|
+
oauth_version: "1.0",
|
|
127
|
+
};
|
|
128
|
+
|
|
129
|
+
if (opts.callbackUrl) {
|
|
130
|
+
oauthParams.oauth_callback = opts.callbackUrl;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const signature = await buildOAuthSignature(
|
|
134
|
+
"POST",
|
|
135
|
+
url,
|
|
136
|
+
oauthParams,
|
|
137
|
+
opts.consumerSecret,
|
|
138
|
+
);
|
|
139
|
+
oauthParams.oauth_signature = signature;
|
|
140
|
+
|
|
141
|
+
const response = await fetch(url, {
|
|
142
|
+
method: "POST",
|
|
143
|
+
headers: {
|
|
144
|
+
Authorization: buildOAuthHeader(oauthParams),
|
|
145
|
+
},
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
if (!response.ok) {
|
|
149
|
+
const body = await response.text().catch(() => "");
|
|
150
|
+
throw new Error(
|
|
151
|
+
`Garmin OAuth error (getRequestToken): ${response.status} ${response.statusText} — ${body}`,
|
|
152
|
+
);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
const responseText = await response.text();
|
|
156
|
+
const parsed = new URLSearchParams(responseText);
|
|
157
|
+
const oauthToken = parsed.get("oauth_token");
|
|
158
|
+
const oauthTokenSecret = parsed.get("oauth_token_secret");
|
|
159
|
+
|
|
160
|
+
if (!oauthToken || !oauthTokenSecret) {
|
|
161
|
+
throw new Error(
|
|
162
|
+
`Garmin OAuth error: unexpected response format — ${responseText}`,
|
|
163
|
+
);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
const authUrl = opts.callbackUrl
|
|
167
|
+
? `${AUTH_CONFIRM_URL}?oauth_token=${encodeURIComponent(oauthToken)}&oauth_callback=${encodeURIComponent(opts.callbackUrl)}`
|
|
168
|
+
: `${AUTH_CONFIRM_URL}?oauth_token=${encodeURIComponent(oauthToken)}`;
|
|
169
|
+
|
|
170
|
+
return {
|
|
171
|
+
oauthToken,
|
|
172
|
+
oauthTokenSecret,
|
|
173
|
+
authUrl,
|
|
174
|
+
};
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// ─── Step 3: Exchange for Access Token ──────────────────────────────────────
|
|
178
|
+
|
|
179
|
+
export interface GetAccessTokenOptions {
|
|
180
|
+
consumerKey: string;
|
|
181
|
+
consumerSecret: string;
|
|
182
|
+
/** The request token from Step 1. */
|
|
183
|
+
token: string;
|
|
184
|
+
/** The request token secret from Step 1. */
|
|
185
|
+
tokenSecret: string;
|
|
186
|
+
/** The verifier from the OAuth callback (Step 2). */
|
|
187
|
+
verifier: string;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* Exchange a request token + verifier for a permanent access token.
|
|
192
|
+
*
|
|
193
|
+
* This is Step 3 of the OAuth 1.0a three-legged flow.
|
|
194
|
+
* The returned access token and secret are permanent — Garmin tokens
|
|
195
|
+
* do not expire and there is no refresh flow.
|
|
196
|
+
*/
|
|
197
|
+
export async function getAccessToken(
|
|
198
|
+
opts: GetAccessTokenOptions,
|
|
199
|
+
): Promise<GarminOAuthAccessTokenResponse> {
|
|
200
|
+
const url = `${OAUTH_BASE_URL}/oauth-service/oauth/access_token`;
|
|
201
|
+
const nonce = generateNonce();
|
|
202
|
+
const timestamp = getTimestamp();
|
|
203
|
+
|
|
204
|
+
const oauthParams: Record<string, string> = {
|
|
205
|
+
oauth_consumer_key: opts.consumerKey,
|
|
206
|
+
oauth_nonce: nonce,
|
|
207
|
+
oauth_signature_method: "HMAC-SHA1",
|
|
208
|
+
oauth_timestamp: timestamp,
|
|
209
|
+
oauth_token: opts.token,
|
|
210
|
+
oauth_verifier: opts.verifier,
|
|
211
|
+
oauth_version: "1.0",
|
|
212
|
+
};
|
|
213
|
+
|
|
214
|
+
const signature = await buildOAuthSignature(
|
|
215
|
+
"POST",
|
|
216
|
+
url,
|
|
217
|
+
oauthParams,
|
|
218
|
+
opts.consumerSecret,
|
|
219
|
+
opts.tokenSecret,
|
|
220
|
+
);
|
|
221
|
+
oauthParams.oauth_signature = signature;
|
|
222
|
+
|
|
223
|
+
const response = await fetch(url, {
|
|
224
|
+
method: "POST",
|
|
225
|
+
headers: {
|
|
226
|
+
Authorization: buildOAuthHeader(oauthParams),
|
|
227
|
+
},
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
if (!response.ok) {
|
|
231
|
+
const body = await response.text().catch(() => "");
|
|
232
|
+
throw new Error(
|
|
233
|
+
`Garmin OAuth error (getAccessToken): ${response.status} ${response.statusText} — ${body}`,
|
|
234
|
+
);
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
const responseText = await response.text();
|
|
238
|
+
const parsed = new URLSearchParams(responseText);
|
|
239
|
+
const oauthToken = parsed.get("oauth_token");
|
|
240
|
+
const oauthTokenSecret = parsed.get("oauth_token_secret");
|
|
241
|
+
|
|
242
|
+
if (!oauthToken || !oauthTokenSecret) {
|
|
243
|
+
throw new Error(
|
|
244
|
+
`Garmin OAuth error: unexpected access token response — ${responseText}`,
|
|
245
|
+
);
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
return { oauthToken, oauthTokenSecret };
|
|
249
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
// ─── Body Transformer ────────────────────────────────────────────────────────
|
|
2
|
+
// Transforms Garmin body composition data into the Soma Body schema shape.
|
|
3
|
+
|
|
4
|
+
import type { GarminBodyComposition } from "./types.js";
|
|
5
|
+
|
|
6
|
+
export type BodyData = ReturnType<typeof transformBody>;
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Transform a Garmin body composition record into a Soma Body document shape.
|
|
10
|
+
*
|
|
11
|
+
* @param body - The Garmin body composition data from the Health API
|
|
12
|
+
* @returns Soma Body fields (without connectionId/userId)
|
|
13
|
+
*/
|
|
14
|
+
export function transformBody(body: GarminBodyComposition) {
|
|
15
|
+
const measurementMs = body.measurementTimeInSeconds * 1000;
|
|
16
|
+
const timestamp = new Date(measurementMs).toISOString();
|
|
17
|
+
|
|
18
|
+
return {
|
|
19
|
+
metadata: {
|
|
20
|
+
start_time: timestamp,
|
|
21
|
+
end_time: timestamp,
|
|
22
|
+
},
|
|
23
|
+
|
|
24
|
+
measurements_data: buildMeasurementsData(body, timestamp),
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
|
29
|
+
|
|
30
|
+
function buildMeasurementsData(
|
|
31
|
+
body: GarminBodyComposition,
|
|
32
|
+
timestamp: string,
|
|
33
|
+
) {
|
|
34
|
+
if (
|
|
35
|
+
body.weightInGrams == null &&
|
|
36
|
+
body.bodyFatInPercent == null &&
|
|
37
|
+
body.bodyMassIndex == null &&
|
|
38
|
+
body.muscleMassInGrams == null &&
|
|
39
|
+
body.boneMassInGrams == null &&
|
|
40
|
+
body.bodyWaterInPercent == null
|
|
41
|
+
) {
|
|
42
|
+
return undefined;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
return {
|
|
46
|
+
measurements: [
|
|
47
|
+
{
|
|
48
|
+
measurement_time: timestamp,
|
|
49
|
+
weight_kg:
|
|
50
|
+
body.weightInGrams != null ? body.weightInGrams / 1000 : undefined,
|
|
51
|
+
BMI: body.bodyMassIndex,
|
|
52
|
+
bodyfat_percentage: body.bodyFatInPercent,
|
|
53
|
+
muscle_mass_g: body.muscleMassInGrams,
|
|
54
|
+
bone_mass_g: body.boneMassInGrams,
|
|
55
|
+
water_percentage: body.bodyWaterInPercent,
|
|
56
|
+
},
|
|
57
|
+
],
|
|
58
|
+
};
|
|
59
|
+
}
|
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
// ─── Garmin Health API Client ────────────────────────────────────────────────
|
|
2
|
+
// Lightweight, fetch-based client for the Garmin Health API.
|
|
3
|
+
// Every request is signed with OAuth 1.0a using the consumer and user tokens.
|
|
4
|
+
// Uses the Web Crypto API for HMAC-SHA1 signing and global `fetch`.
|
|
5
|
+
|
|
6
|
+
import type {
|
|
7
|
+
GarminActivity,
|
|
8
|
+
GarminDailySummary,
|
|
9
|
+
GarminSleep,
|
|
10
|
+
GarminBodyComposition,
|
|
11
|
+
GarminMenstrualCycleData,
|
|
12
|
+
} from "./types.js";
|
|
13
|
+
import {
|
|
14
|
+
generateNonce,
|
|
15
|
+
getTimestamp,
|
|
16
|
+
buildOAuthSignature,
|
|
17
|
+
buildOAuthHeader,
|
|
18
|
+
} from "./auth.js";
|
|
19
|
+
|
|
20
|
+
const DEFAULT_BASE_URL = "https://apis.garmin.com";
|
|
21
|
+
|
|
22
|
+
export interface GarminClientOptions {
|
|
23
|
+
/** Your application's consumer key (from Garmin Developer Portal). */
|
|
24
|
+
consumerKey: string;
|
|
25
|
+
/** Your application's consumer secret. */
|
|
26
|
+
consumerSecret: string;
|
|
27
|
+
/** The user's permanent OAuth access token. */
|
|
28
|
+
accessToken: string;
|
|
29
|
+
/** The user's permanent OAuth token secret. */
|
|
30
|
+
tokenSecret: string;
|
|
31
|
+
/**
|
|
32
|
+
* Base URL of the Garmin Health API.
|
|
33
|
+
* Defaults to `https://apis.garmin.com`.
|
|
34
|
+
*/
|
|
35
|
+
baseUrl?: string;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* A lightweight client for the Garmin Health API.
|
|
40
|
+
*
|
|
41
|
+
* All requests are signed with OAuth 1.0a. Time-range parameters
|
|
42
|
+
* use Unix epoch seconds for `uploadStartTimeInSeconds` and
|
|
43
|
+
* `uploadEndTimeInSeconds`.
|
|
44
|
+
*
|
|
45
|
+
* @example
|
|
46
|
+
* ```ts
|
|
47
|
+
* const client = new GarminClient({
|
|
48
|
+
* consumerKey: "your_key",
|
|
49
|
+
* consumerSecret: "your_secret",
|
|
50
|
+
* accessToken: "user_token",
|
|
51
|
+
* tokenSecret: "user_secret",
|
|
52
|
+
* });
|
|
53
|
+
*
|
|
54
|
+
* const dailies = await client.getDailies({
|
|
55
|
+
* uploadStartTimeInSeconds: startEpoch,
|
|
56
|
+
* uploadEndTimeInSeconds: endEpoch,
|
|
57
|
+
* });
|
|
58
|
+
* ```
|
|
59
|
+
*/
|
|
60
|
+
export class GarminClient {
|
|
61
|
+
private readonly consumerKey: string;
|
|
62
|
+
private readonly consumerSecret: string;
|
|
63
|
+
private readonly accessToken: string;
|
|
64
|
+
private readonly tokenSecret: string;
|
|
65
|
+
private readonly baseUrl: string;
|
|
66
|
+
|
|
67
|
+
constructor(opts: GarminClientOptions) {
|
|
68
|
+
this.consumerKey = opts.consumerKey;
|
|
69
|
+
this.consumerSecret = opts.consumerSecret;
|
|
70
|
+
this.accessToken = opts.accessToken;
|
|
71
|
+
this.tokenSecret = opts.tokenSecret;
|
|
72
|
+
this.baseUrl = (opts.baseUrl ?? DEFAULT_BASE_URL).replace(/\/+$/, "");
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// ─── Daily Summaries ────────────────────────────────────────────────────
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Get daily wellness summaries.
|
|
79
|
+
*
|
|
80
|
+
* Garmin API: `GET /wellness-api/rest/dailies`
|
|
81
|
+
*/
|
|
82
|
+
async getDailies(params: TimeRangeParams): Promise<GarminDailySummary[]> {
|
|
83
|
+
return this.get<GarminDailySummary[]>(
|
|
84
|
+
"/wellness-api/rest/dailies",
|
|
85
|
+
timeRangeQuery(params),
|
|
86
|
+
);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// ─── Activities ─────────────────────────────────────────────────────────
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Get activity summaries.
|
|
93
|
+
*
|
|
94
|
+
* Garmin API: `GET /wellness-api/rest/activities`
|
|
95
|
+
*/
|
|
96
|
+
async getActivities(params: TimeRangeParams): Promise<GarminActivity[]> {
|
|
97
|
+
return this.get<GarminActivity[]>(
|
|
98
|
+
"/wellness-api/rest/activities",
|
|
99
|
+
timeRangeQuery(params),
|
|
100
|
+
);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// ─── Sleep ──────────────────────────────────────────────────────────────
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Get sleep summaries.
|
|
107
|
+
*
|
|
108
|
+
* Garmin API: `GET /wellness-api/rest/sleeps`
|
|
109
|
+
*/
|
|
110
|
+
async getSleeps(params: TimeRangeParams): Promise<GarminSleep[]> {
|
|
111
|
+
return this.get<GarminSleep[]>(
|
|
112
|
+
"/wellness-api/rest/sleeps",
|
|
113
|
+
timeRangeQuery(params),
|
|
114
|
+
);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// ─── Body Composition ─────────────────────────────────────────────────
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Get body composition summaries.
|
|
121
|
+
*
|
|
122
|
+
* Garmin API: `GET /wellness-api/rest/bodyComps`
|
|
123
|
+
*/
|
|
124
|
+
async getBodyCompositions(
|
|
125
|
+
params: TimeRangeParams,
|
|
126
|
+
): Promise<GarminBodyComposition[]> {
|
|
127
|
+
return this.get<GarminBodyComposition[]>(
|
|
128
|
+
"/wellness-api/rest/bodyComps",
|
|
129
|
+
timeRangeQuery(params),
|
|
130
|
+
);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// ─── Menstrual Cycle ──────────────────────────────────────────────────
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Get menstrual cycle data.
|
|
137
|
+
*
|
|
138
|
+
* Garmin API: `GET /wellness-api/rest/menstrualCycleData`
|
|
139
|
+
*/
|
|
140
|
+
async getMenstrualCycleData(
|
|
141
|
+
params: TimeRangeParams,
|
|
142
|
+
): Promise<GarminMenstrualCycleData[]> {
|
|
143
|
+
return this.get<GarminMenstrualCycleData[]>(
|
|
144
|
+
"/wellness-api/rest/menstrualCycleData",
|
|
145
|
+
timeRangeQuery(params),
|
|
146
|
+
);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// ─── Backfill ─────────────────────────────────────────────────────────
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Request historical data backfill from Garmin.
|
|
153
|
+
*
|
|
154
|
+
* Garmin processes backfill requests asynchronously and delivers data
|
|
155
|
+
* via the configured webhook endpoint. Maximum range: 90 days per request.
|
|
156
|
+
*
|
|
157
|
+
* @param summaryType - The data type to backfill (e.g., "dailies", "activities", "sleeps", "bodyComps")
|
|
158
|
+
* @param params - Time range for the backfill
|
|
159
|
+
*/
|
|
160
|
+
async requestBackfill(
|
|
161
|
+
summaryType: string,
|
|
162
|
+
params: TimeRangeParams,
|
|
163
|
+
): Promise<void> {
|
|
164
|
+
const query = timeRangeQuery(params);
|
|
165
|
+
await this.get(
|
|
166
|
+
`/wellness-api/rest/backfill/${summaryType}`,
|
|
167
|
+
query,
|
|
168
|
+
);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// ─── Internal ─────────────────────────────────────────────────────────
|
|
172
|
+
|
|
173
|
+
private async get<T>(
|
|
174
|
+
path: string,
|
|
175
|
+
queryParams?: Record<string, string>,
|
|
176
|
+
): Promise<T> {
|
|
177
|
+
const fullUrl = `${this.baseUrl}${path}`;
|
|
178
|
+
const qs = queryParams
|
|
179
|
+
? `?${new URLSearchParams(queryParams).toString()}`
|
|
180
|
+
: "";
|
|
181
|
+
const requestUrl = `${fullUrl}${qs}`;
|
|
182
|
+
|
|
183
|
+
const nonce = generateNonce();
|
|
184
|
+
const timestamp = getTimestamp();
|
|
185
|
+
|
|
186
|
+
const oauthParams: Record<string, string> = {
|
|
187
|
+
oauth_consumer_key: this.consumerKey,
|
|
188
|
+
oauth_nonce: nonce,
|
|
189
|
+
oauth_signature_method: "HMAC-SHA1",
|
|
190
|
+
oauth_timestamp: timestamp,
|
|
191
|
+
oauth_token: this.accessToken,
|
|
192
|
+
oauth_version: "1.0",
|
|
193
|
+
};
|
|
194
|
+
|
|
195
|
+
// OAuth signature must include both OAuth params and query params
|
|
196
|
+
const allParams = { ...oauthParams, ...(queryParams ?? {}) };
|
|
197
|
+
const signature = await buildOAuthSignature(
|
|
198
|
+
"GET",
|
|
199
|
+
fullUrl,
|
|
200
|
+
allParams,
|
|
201
|
+
this.consumerSecret,
|
|
202
|
+
this.tokenSecret,
|
|
203
|
+
);
|
|
204
|
+
oauthParams.oauth_signature = signature;
|
|
205
|
+
|
|
206
|
+
const response = await fetch(requestUrl, {
|
|
207
|
+
method: "GET",
|
|
208
|
+
headers: {
|
|
209
|
+
Authorization: buildOAuthHeader(oauthParams),
|
|
210
|
+
Accept: "application/json",
|
|
211
|
+
},
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
if (!response.ok) {
|
|
215
|
+
const body = await response.text().catch(() => "");
|
|
216
|
+
throw new GarminApiError(
|
|
217
|
+
`Garmin API error: ${response.status} ${response.statusText}`,
|
|
218
|
+
response.status,
|
|
219
|
+
body,
|
|
220
|
+
);
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
return (await response.json()) as T;
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
|
228
|
+
|
|
229
|
+
export interface TimeRangeParams {
|
|
230
|
+
/** Start of the time range as Unix epoch seconds. */
|
|
231
|
+
uploadStartTimeInSeconds: number;
|
|
232
|
+
/** End of the time range as Unix epoch seconds. */
|
|
233
|
+
uploadEndTimeInSeconds: number;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
function timeRangeQuery(params: TimeRangeParams): Record<string, string> {
|
|
237
|
+
return {
|
|
238
|
+
uploadStartTimeInSeconds: String(params.uploadStartTimeInSeconds),
|
|
239
|
+
uploadEndTimeInSeconds: String(params.uploadEndTimeInSeconds),
|
|
240
|
+
};
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
// ─── Error ────────────────────────────────────────────────────────────────────
|
|
244
|
+
|
|
245
|
+
export class GarminApiError extends Error {
|
|
246
|
+
constructor(
|
|
247
|
+
message: string,
|
|
248
|
+
public readonly status: number,
|
|
249
|
+
public readonly body: string,
|
|
250
|
+
) {
|
|
251
|
+
super(message);
|
|
252
|
+
this.name = "GarminApiError";
|
|
253
|
+
}
|
|
254
|
+
}
|