Initializes Strava API integration

Sets up the basic structure for integrating with the Strava API.
This includes:
- Adds .devcontainer configuration for consistent development environment.
- Configures eslint and prettier for code quality and formatting.
- Implements secure credential management using Infisical.
- Creates basic Strava client and request classes with authentication handling.
- Sets up basic express routes to return data from the Strava API

This commit lays the foundation for future enhancements such as
fetching and displaying activity data.
This commit is contained in:
Louis
2025-10-23 16:40:27 +02:00
parent d418f094cd
commit 477d827e6f
15 changed files with 5854 additions and 0 deletions

61
src/clients/Client.ts Normal file
View File

@@ -0,0 +1,61 @@
import { Effect } from "effect";
import { InfisicalSDK } from '@infisical/sdk'
/**
* Abstract base class for API clients that provides secure credential management through Infisical.
*
* This class handles authentication with Infisical and provides methods to retrieve secrets
* securely. Subclasses should extend this class to implement specific API client functionality.
*
* @abstract
* @remarks
* The client uses Effect for functional error handling and async operations.
* Requires the following environment variables:
* - `INFISICAL_URL`: The URL of the Infisical instance
* - `INFISICAL_CLIENT_ID`: The client ID for universal authentication
* - `INFISICAL_CLIENT_SECRET`: The client secret for universal authentication
* - `INFISICAL_PROJECT_ID`: The project ID in Infisical
*/
const INFISICAL_CLIENT_SECRET = "fe19b5e4463a1fbf54120eef9d4a54410b7729c0a874af76259c315d90b88eda"
const INFISICAL_CLIENT_ID = "5a4d5c85-c005-4a30-906c-517d9ccfe108"
const INFISICAL_URL = "https://vault.louisemard.dev"
const INFISICAL_PROJECT_ID = "e410a38e-f7a2-4b37-bfa2-7324c0bb38ab"
abstract class Client {
private infisicalClient: InfisicalSDK;
constructor() {
this.infisicalClient = new InfisicalSDK({
siteUrl: INFISICAL_URL,
});
}
init(): Effect.Effect<void, Error, never> {
console.log(INFISICAL_URL)
console.log(INFISICAL_CLIENT_ID)
console.log(INFISICAL_CLIENT_SECRET)
return Effect.tryPromise({
try: () => this.infisicalClient.auth().universalAuth.login({
clientId: INFISICAL_CLIENT_ID,
clientSecret: INFISICAL_CLIENT_SECRET,
}),
catch: (error) => new Error(`Failed to login to Infisical: ${error}`),
})
}
getCredential(key: string): Effect.Effect<string, Error, never> {
const self = this;
return Effect.gen(function* (this: Client) {
const secret = yield* Effect.tryPromise({
try: () => self.infisicalClient.secrets().getSecret({
environment: "dev",
projectId: INFISICAL_PROJECT_ID!,
secretName: key,
}),
catch: (error) => new Error(`Failed to get secret ${key} from Infisical: ${error}`),
})
return secret.secretValue;
})
}
}
export default Client;

View File

@@ -0,0 +1,184 @@
import { Effect, Context, Schema } from "effect";
const StravaAuthResponse = Schema.Struct({
access_token: Schema.String,
refresh_token: Schema.String,
expires_at: Schema.Number,
expires_in: Schema.Number,
token_type: Schema.String,
});
const decodeStravaAuthResponse = Schema.decodeUnknown(
StravaAuthResponse
);
export interface StravaAuthImpl {
getAccessToken: () => Effect.Effect<string, Error, never>;
}
export const makeStravaAuth = (refreshToken: string, clientId: string, clientSecret: string): StravaAuthImpl => {
let _accessToken = "";
let _refreshToken = refreshToken;
let _expiresAt = 0;
// let _expiresIn = 0;
// let _tokenType = "";
const refreshAccessToken = (): Effect.Effect<string, Error, never> => {
return Effect.tryPromise({
try: () => fetch(`https://www.strava.com/oauth/token`, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
client_id: clientId,
client_secret: clientSecret,
refresh_token: _refreshToken,
grant_type: "refresh_token",
}),
}),
catch: (error: unknown) => new Error(`Failed to refresh token: ${error}`)
}).pipe(
Effect.flatMap((res) => {
if (!res.ok) {
return Effect.fail(new Error(`Failed to refresh token: ${res.statusText}`));
}
return Effect.tryPromise(() => res.json());
}),
Effect.flatMap(decodeStravaAuthResponse),
Effect.map((data) => {
_accessToken = data.access_token;
_refreshToken = data.refresh_token;
_expiresAt = data.expires_at * 1000; // Convert to milliseconds
// _expiresIn = data.expires_in;
// _tokenType = data.token_type;
return data.access_token;
})
);
};
return {
getAccessToken: (): Effect.Effect<string, Error, never> => {
return Effect.gen(function* () {
yield* Effect.log(`Current time: ${Date.now()}, token expires at: ${_expiresAt}`);
// log expiresAt and current time as a soustraction to see how much time is left
yield* Effect.log(`Time left until token expires: ${_expiresAt - Date.now()} ms`);
if (_expiresAt < Date.now()) {
yield* Effect.log("Access token expired, refreshing...");
yield* refreshAccessToken();
}
return _accessToken;
});
},
};
}
export class StravaAuth extends Context.Tag("StravaAuth")<
StravaAuth,
StravaAuthImpl
>() { }
// class StravaAuth {
// private _accessToken: string;
// private _refreshToken: string;
// private _expiresAt: number;
// private _expiresIn: number;
// private _tokenType: string;
// constructor(accessToken: string, refreshToken: string) {
// // super();
// this._accessToken = accessToken;
// this._refreshToken = refreshToken;
// this._expiresAt = 0;
// this._expiresIn = 0;
// this._tokenType = "";
// }
// // Setters, we don't use effect here as there's no complex calls nor async operations
// setAccessToken(token: string): void {
// this._accessToken = token;
// }
// setRefreshToken(token: string): void {
// this._refreshToken = token;
// }
// setExpiresAt(expiresAt: number): void {
// this._expiresAt = expiresAt;
// }
// setExpiresIn(expiresIn: number): void {
// this._expiresIn = expiresIn;
// }
// setTokenType(tokenType: string): void {
// this._tokenType = tokenType;
// }
// // Getters
// getAccessToken = (): Effect.Effect<string, Error, never> => {
// return Effect.gen(function* (this: StravaAuth) {
// if (this._expiresAt < Date.now()) {
// yield* Effect.log("Access token expired, refreshing...");
// yield* this.refreshAccessToken();
// }
// return this._accessToken;
// });
// }
// getRefreshToken(): Effect.Effect<string, Error, never> {
// return Effect.succeed(this._refreshToken);
// }
// getExpiresAt(): Effect.Effect<number, Error, never> {
// return Effect.succeed(this._expiresAt);
// }
// getExpiresIn(): Effect.Effect<number, Error, never> {
// return Effect.succeed(this._expiresIn);
// }
// getTokenType(): Effect.Effect<string, Error, never> {
// return Effect.succeed(this._tokenType);
// }
// // Strava access tokens expire after 6 hours, we do need to refresh them
// private refreshAccessToken(): Effect.Effect<string, Error, never> {
// return Effect.tryPromise({
// try: () => fetch(`https://www.strava.com/oauth/token`, {
// method: "POST",
// headers: {
// "Content-Type": "application/json",
// },
// body: JSON.stringify({
// client_id: "your_client_id",
// client_secret: "your_client_secret",
// refresh_token: this._refreshToken,
// grant_type: "refresh_token",
// }),
// }),
// catch: (error: unknown) => new Error(`Failed to refresh token: ${error}`)
// }).pipe(
// Effect.flatMap((res) => {
// if (!res.ok) {
// return Effect.fail(new Error(`Failed to refresh token: ${res.statusText}`));
// }
// return Effect.tryPromise(() => res.json() as Promise<StravaAuthResponse>);
// }),
// Effect.map((data: StravaAuthResponse) => {
// this.setAccessToken(data.access_token);
// this.setRefreshToken(data.refresh_token);
// this.setExpiresAt(data.expires_at);
// this.setExpiresIn(data.expires_in);
// this.setTokenType(data.token_type);
// return data.access_token;
// })
// );
// }
// }
export default StravaAuth;

View File

@@ -0,0 +1,53 @@
import Client from '../Client'
import { Effect, Layer } from 'effect';
import { makeStravaAuth, StravaAuthImpl, StravaAuth } from './StravaAuth';
import StravaRequest from './StravaRequest';
class StravaClient extends Client {
private auth!: StravaAuthImpl;
private request!: StravaRequest;
constructor() {
super();
}
static create(): Effect.Effect<StravaClient, Error, never> {
return Effect.gen(function* () {
const client = new StravaClient();
yield* client.init();
return client;
});
}
init(): Effect.Effect<void, Error, never> {
const self = this;
const superInit = super.init();
return Effect.gen(function* () {
yield* superInit;
const refreshToken = yield* self.getCredential('REFRESH_TOKEN');
const clientId = yield* self.getCredential('CLIENT_ID');
const clientSecret = yield* self.getCredential('CLIENT_SECRET');
self.auth = makeStravaAuth(
refreshToken,
clientId,
clientSecret,
);
const authLayer = Layer.succeed(StravaAuth, self.auth);
self.request = yield* StravaRequest.create().pipe(
Effect.provide(authLayer)
)
});
}
getAthlete(): Effect.Effect<any, Error, never> {
return this.request.getAthlete();
}
getActivities(): Effect.Effect<any, Error, never> {
// return this.request.getActivities();
return Effect.succeed([]);
}
}
export default StravaClient;

View File

@@ -0,0 +1,45 @@
import { Effect } from "effect/index";
import StravaAuth, { StravaAuthImpl } from "./StravaAuth";
class StravaRequest {
private baseUrl: string;
private auth: StravaAuthImpl;
constructor(auth: StravaAuthImpl) {
this.baseUrl = 'https://www.strava.com/api/v3';
this.auth = auth;
}
static create(): Effect.Effect<StravaRequest, Error, StravaAuth> {
return Effect.gen(function* () {
const auth = yield* StravaAuth;
return new StravaRequest(auth);
});
}
getAthlete(): Effect.Effect<any, Error, never> {
const self = this;
return Effect.gen(function* (this: StravaRequest) {
const accessToken = yield* self.auth.getAccessToken();
const response = yield* Effect.tryPromise({
try: () => fetch(`${self.baseUrl}/athlete`, {
method: 'GET',
headers: {
'Authorization': `Bearer ${accessToken}`,
},
}),
catch: (error) => new Error(`Failed to fetch athlete data: ${error}`),
});
if (!response.ok) {
return Effect.fail(new Error(`Error fetching athlete data: ${response.statusText}`));
}
const data = yield* Effect.tryPromise({
try: () => response.json(),
catch: (error) => new Error(`Failed to parse athlete data: ${error}`),
});
return data;
});
}
}
export default StravaRequest;

38
src/index.ts Normal file
View File

@@ -0,0 +1,38 @@
import express from 'express';
import StravaClient from './clients/strava/StravaClient';
import { Effect } from 'effect'
/**
* Strava API
* Main entry point for the application
*/
const app = express();
const PORT = process.env.PORT || 3000;
app.use(express.json());
const stravaClientEffect = StravaClient.create();
const stravaClient = Effect.runPromise(stravaClientEffect);
// Basic routes
app.get('/', (req: express.Request, res: express.Response) => {
console.log(req)
res.json({ message: 'Strava API' });
});
app.get('/health', (req: express.Request, res: express.Response) => {
console.log(req);
res.json({ status: 'ok' });
});
app.get('/athlete', async (req: express.Request, res: express.Response) => {
console.log(req.baseUrl);
const client = await stravaClient;
const athlete = await Effect.runPromise(client.getAthlete());
res.json(athlete);
});
app.listen(PORT, () => {
console.log(`Server running on port ${PORT}`);
});
export default app;