202 lines
5.6 KiB
TypeScript
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
|
|
);
|
|
}
|
|
}
|