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 { return this.postToken({ client_id: this.clientId, client_secret: this.clientSecret, code, grant_type: 'authorization_code', }); } async refreshToken(refreshToken: string): Promise { 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 { return this.request({ 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 { return this.request({ method: 'GET', url: `https://www.strava.com/api/v3/activities/${stravaActivityId}`, headers: this.authHeaders(accessToken), }); } async getActivityStreams( accessToken: string, stravaActivityId: string, ): Promise { const streams = await this.request({ 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 { return this.request({ method: 'GET', url: `https://www.strava.com/api/v3/activities/${stravaActivityId}/laps`, headers: this.authHeaders(accessToken), }); } private async postToken( body: Record, ): Promise { return this.request({ method: 'POST', url: 'https://www.strava.com/oauth/token', data: body, }); } private async request(config: AxiosRequestConfig): Promise { try { const response = await firstValueFrom( this.httpService.request(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 | 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 { 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(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 ); } }