git init
This commit is contained in:
165
api/src/strava/strava-client.service.ts
Normal file
165
api/src/strava/strava-client.service.ts
Normal file
@@ -0,0 +1,165 @@
|
||||
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,
|
||||
StravaStreamPayload,
|
||||
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,
|
||||
): 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 },
|
||||
});
|
||||
}
|
||||
|
||||
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[]> {
|
||||
return this.request<StravaStreamPayload[]>({
|
||||
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: false,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
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 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
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user