Use connectapi.garmin.com directly instead of web proxy
All checks were successful
Deploy / deploy (push) Successful in 1m38s

The connect.garmin.com/modern/proxy URL returns HTML (website) instead
of JSON API responses. Garth library uses connectapi.garmin.com subdomain
directly, which is the actual API endpoint.

- Change base URL from connect.garmin.com/modern/proxy to connectapi.garmin.com
- Update User-Agent to match garth library: GCM-iOS-5.19.1.2
- Factor out headers into getGarminHeaders() to avoid duplication
- Remove NK header (not needed when using connectapi subdomain)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-01-15 13:38:55 +00:00
parent 51f4c8eb80
commit 59d70ee414
2 changed files with 27 additions and 41 deletions

View File

@@ -173,17 +173,14 @@ describe("fetchGarminData", () => {
json: () => Promise.resolve(mockResponse), json: () => Promise.resolve(mockResponse),
}); });
await fetchGarminData("/wellness/daily/123", { await fetchGarminData("/wellness/daily/123", "test-token");
oauth2Token: "test-token",
});
expect(global.fetch).toHaveBeenCalledWith( expect(global.fetch).toHaveBeenCalledWith(
"https://connect.garmin.com/modern/proxy/wellness/daily/123", "https://connectapi.garmin.com/wellness/daily/123",
{ {
headers: { headers: {
Authorization: "Bearer test-token", Authorization: "Bearer test-token",
NK: "NT", "User-Agent": "GCM-iOS-5.19.1.2",
"User-Agent": "GCM-iOS-5.7.2.1",
}, },
}, },
); );
@@ -210,7 +207,7 @@ describe("fetchGarminData", () => {
}); });
await expect( await expect(
fetchGarminData("/wellness/daily/123", { oauth2Token: "test-token" }), fetchGarminData("/wellness/daily/123", "test-token"),
).rejects.toThrow("Garmin API error: 401"); ).rejects.toThrow("Garmin API error: 401");
}); });
@@ -221,7 +218,7 @@ describe("fetchGarminData", () => {
}); });
await expect( await expect(
fetchGarminData("/wellness/daily/123", { oauth2Token: "test-token" }), fetchGarminData("/wellness/daily/123", "test-token"),
).rejects.toThrow("Garmin API error: 403"); ).rejects.toThrow("Garmin API error: 403");
}); });
@@ -232,7 +229,7 @@ describe("fetchGarminData", () => {
}); });
await expect( await expect(
fetchGarminData("/wellness/daily/123", { oauth2Token: "test-token" }), fetchGarminData("/wellness/daily/123", "test-token"),
).rejects.toThrow("Garmin API error: 500"); ).rejects.toThrow("Garmin API error: 500");
}); });
@@ -240,7 +237,7 @@ describe("fetchGarminData", () => {
global.fetch = vi.fn().mockRejectedValue(new Error("Network error")); global.fetch = vi.fn().mockRejectedValue(new Error("Network error"));
await expect( await expect(
fetchGarminData("/wellness/daily/123", { oauth2Token: "test-token" }), fetchGarminData("/wellness/daily/123", "test-token"),
).rejects.toThrow("Network error"); ).rejects.toThrow("Network error");
}); });
}); });
@@ -267,7 +264,7 @@ describe("fetchHrvStatus", () => {
expect(result).toBe("Balanced"); expect(result).toBe("Balanced");
expect(global.fetch).toHaveBeenCalledWith( expect(global.fetch).toHaveBeenCalledWith(
"https://connect.garmin.com/modern/proxy/hrv-service/hrv/2024-01-15", "https://connectapi.garmin.com/hrv-service/hrv/2024-01-15",
expect.objectContaining({ expect.objectContaining({
headers: expect.objectContaining({ headers: expect.objectContaining({
Authorization: "Bearer test-token", Authorization: "Bearer test-token",
@@ -354,7 +351,7 @@ describe("fetchBodyBattery", () => {
yesterdayLow: 25, yesterdayLow: 25,
}); });
expect(global.fetch).toHaveBeenCalledWith( expect(global.fetch).toHaveBeenCalledWith(
"https://connect.garmin.com/modern/proxy/usersummary-service/stats/bodyBattery/dates/2024-01-15", "https://connectapi.garmin.com/usersummary-service/stats/bodyBattery/dates/2024-01-15",
expect.objectContaining({ expect.objectContaining({
headers: expect.objectContaining({ headers: expect.objectContaining({
Authorization: "Bearer test-token", Authorization: "Bearer test-token",
@@ -457,7 +454,7 @@ describe("fetchIntensityMinutes", () => {
expect(result).toBe(75); expect(result).toBe(75);
expect(global.fetch).toHaveBeenCalledWith( expect(global.fetch).toHaveBeenCalledWith(
"https://connect.garmin.com/modern/proxy/fitnessstats-service/activity", "https://connectapi.garmin.com/fitnessstats-service/activity",
expect.objectContaining({ expect.objectContaining({
headers: expect.objectContaining({ headers: expect.objectContaining({
Authorization: "Bearer test-token", Authorization: "Bearer test-token",

View File

@@ -4,10 +4,15 @@
import { logger } from "@/lib/logger"; import { logger } from "@/lib/logger";
import type { GarminTokens, HrvStatus } from "@/types"; import type { GarminTokens, HrvStatus } from "@/types";
const GARMIN_BASE_URL = "https://connect.garmin.com/modern/proxy"; // Use connectapi subdomain directly (same as garth library)
const GARMIN_API_URL = "https://connectapi.garmin.com";
interface GarminApiOptions { // Headers matching garth library's http.py USER_AGENT
oauth2Token: string; function getGarminHeaders(oauth2Token: string): Record<string, string> {
return {
Authorization: `Bearer ${oauth2Token}`,
"User-Agent": "GCM-iOS-5.19.1.2",
};
} }
export interface BodyBatteryData { export interface BodyBatteryData {
@@ -17,14 +22,10 @@ export interface BodyBatteryData {
export async function fetchGarminData( export async function fetchGarminData(
endpoint: string, endpoint: string,
options: GarminApiOptions, oauth2Token: string,
): Promise<unknown> { ): Promise<unknown> {
const response = await fetch(`${GARMIN_BASE_URL}${endpoint}`, { const response = await fetch(`${GARMIN_API_URL}${endpoint}`, {
headers: { headers: getGarminHeaders(oauth2Token),
Authorization: `Bearer ${options.oauth2Token}`,
NK: "NT",
"User-Agent": "GCM-iOS-5.7.2.1",
},
}); });
if (!response.ok) { if (!response.ok) {
@@ -58,12 +59,8 @@ export async function fetchHrvStatus(
oauth2Token: string, oauth2Token: string,
): Promise<HrvStatus> { ): Promise<HrvStatus> {
try { try {
const response = await fetch(`${GARMIN_BASE_URL}/hrv-service/hrv/${date}`, { const response = await fetch(`${GARMIN_API_URL}/hrv-service/hrv/${date}`, {
headers: { headers: getGarminHeaders(oauth2Token),
Authorization: `Bearer ${oauth2Token}`,
NK: "NT",
"User-Agent": "GCM-iOS-5.7.2.1",
},
}); });
if (!response.ok) { if (!response.ok) {
@@ -113,13 +110,9 @@ export async function fetchBodyBattery(
): Promise<BodyBatteryData> { ): Promise<BodyBatteryData> {
try { try {
const response = await fetch( const response = await fetch(
`${GARMIN_BASE_URL}/usersummary-service/stats/bodyBattery/dates/${date}`, `${GARMIN_API_URL}/usersummary-service/stats/bodyBattery/dates/${date}`,
{ {
headers: { headers: getGarminHeaders(oauth2Token),
Authorization: `Bearer ${oauth2Token}`,
NK: "NT",
"User-Agent": "GCM-iOS-5.7.2.1",
},
}, },
); );
@@ -172,13 +165,9 @@ export async function fetchIntensityMinutes(
): Promise<number> { ): Promise<number> {
try { try {
const response = await fetch( const response = await fetch(
`${GARMIN_BASE_URL}/fitnessstats-service/activity`, `${GARMIN_API_URL}/fitnessstats-service/activity`,
{ {
headers: { headers: getGarminHeaders(oauth2Token),
Authorization: `Bearer ${oauth2Token}`,
NK: "NT",
"User-Agent": "GCM-iOS-5.7.2.1",
},
}, },
); );