Files
strava-mcp/api/src/strava/strava-client.service.ts
Bastian Wagner 8c7a5cbb63 details
2026-06-17 15:10:26 +02:00

202 lines
5.6 KiB
TypeScript

import { HttpService } from '@nestjs/axios';
import { Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { AxiosError, AxiosRequestConfig } from 'axios';
import { firstValueFrom } from 'rxjs';
import { StravaRateLimitError } from './strava-rate-limit.error';
import {
StravaActivityPayload,
StravaLapPayload,
StravaStreamPayload,
StravaStreamsResponse,
StravaTokenPayload,
} from './strava.types';
@Injectable()
export class StravaClientService {
private readonly clientId: string;
private readonly clientSecret: string;
private readonly redirectUri: string;
constructor(
private readonly httpService: HttpService,
configService: ConfigService,
) {
this.clientId = this.required(configService, 'STRAVA_CLIENT_ID');
this.clientSecret = this.required(configService, 'STRAVA_CLIENT_SECRET');
this.redirectUri = this.required(configService, 'STRAVA_REDIRECT_URI');
}
buildAuthorizeUrl(state: string): string {
const params = new URLSearchParams({
client_id: this.clientId,
redirect_uri: this.redirectUri,
response_type: 'code',
approval_prompt: 'auto',
scope: 'read,activity:read_all',
state,
});
return `https://www.strava.com/oauth/authorize?${params.toString()}`;
}
async exchangeCode(code: string): Promise<StravaTokenPayload> {
return this.postToken({
client_id: this.clientId,
client_secret: this.clientSecret,
code,
grant_type: 'authorization_code',
});
}
async refreshToken(refreshToken: string): Promise<StravaTokenPayload> {
return this.postToken({
client_id: this.clientId,
client_secret: this.clientSecret,
grant_type: 'refresh_token',
refresh_token: refreshToken,
});
}
async listActivities(
accessToken: string,
page: number,
perPage = 100,
after?: number,
): Promise<StravaActivityPayload[]> {
return this.request<StravaActivityPayload[]>({
method: 'GET',
url: 'https://www.strava.com/api/v3/athlete/activities',
headers: this.authHeaders(accessToken),
params: {
page,
per_page: perPage,
...(after ? { after } : {}),
},
});
}
async getActivity(
accessToken: string,
stravaActivityId: string,
): Promise<StravaActivityPayload> {
return this.request<StravaActivityPayload>({
method: 'GET',
url: `https://www.strava.com/api/v3/activities/${stravaActivityId}`,
headers: this.authHeaders(accessToken),
});
}
async getActivityStreams(
accessToken: string,
stravaActivityId: string,
): Promise<StravaStreamPayload[]> {
const streams = await this.request<StravaStreamsResponse>({
method: 'GET',
url: `https://www.strava.com/api/v3/activities/${stravaActivityId}/streams`,
headers: this.authHeaders(accessToken),
params: {
keys: 'time,distance,latlng,altitude,velocity_smooth,heartrate,cadence,watts,temp,moving,grade_smooth',
key_by_type: true,
},
});
return this.normalizeStreamsResponse(streams);
}
async getActivityLaps(
accessToken: string,
stravaActivityId: string,
): Promise<StravaLapPayload[]> {
return this.request<StravaLapPayload[]>({
method: 'GET',
url: `https://www.strava.com/api/v3/activities/${stravaActivityId}/laps`,
headers: this.authHeaders(accessToken),
});
}
private async postToken(
body: Record<string, string>,
): Promise<StravaTokenPayload> {
return this.request<StravaTokenPayload>({
method: 'POST',
url: 'https://www.strava.com/oauth/token',
data: body,
});
}
private async request<T>(config: AxiosRequestConfig): Promise<T> {
try {
const response = await firstValueFrom(
this.httpService.request<T>(config),
);
return response.data;
} catch (error) {
if (this.isAxiosError(error) && error.response?.status === 429) {
throw new StravaRateLimitError(
'Strava API rate limit exceeded',
this.parseRetryAfter(error),
);
}
throw error;
}
}
private parseRetryAfter(error: AxiosError): Date | null {
const headers = error.response?.headers as
| Record<string, unknown>
| undefined;
const value = this.headerValue(headers?.['retry-after']);
const seconds = Number(value);
return Number.isFinite(seconds)
? new Date(Date.now() + seconds * 1000)
: null;
}
private headerValue(value: unknown): string | number | null {
if (Array.isArray(value)) {
const values = value as unknown[];
return this.headerValue(values[0]);
}
return typeof value === 'string' || typeof value === 'number'
? value
: null;
}
private authHeaders(accessToken: string): Record<string, string> {
return { Authorization: `Bearer ${accessToken}` };
}
private normalizeStreamsResponse(
streams: StravaStreamsResponse,
): StravaStreamPayload[] {
if (Array.isArray(streams)) {
return streams;
}
return Object.entries(streams).map(([type, stream]) => ({
type: stream.type ?? type,
data: Array.isArray(stream.data) ? stream.data : [],
series_type: stream.series_type,
original_size: stream.original_size,
resolution: stream.resolution,
}));
}
private required(configService: ConfigService, key: string): string {
const value = configService.get<string>(key);
if (!value) {
throw new Error(`Missing required environment variable: ${key}`);
}
return value;
}
private isAxiosError(error: unknown): error is AxiosError {
return (
typeof error === 'object' && error !== null && 'isAxiosError' in error
);
}
}