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:
61
src/clients/Client.ts
Normal file
61
src/clients/Client.ts
Normal 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;
|
||||
184
src/clients/strava/StravaAuth.ts
Normal file
184
src/clients/strava/StravaAuth.ts
Normal 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;
|
||||
53
src/clients/strava/StravaClient.ts
Normal file
53
src/clients/strava/StravaClient.ts
Normal 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;
|
||||
45
src/clients/strava/StravaRequest.ts
Normal file
45
src/clients/strava/StravaRequest.ts
Normal 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;
|
||||
Reference in New Issue
Block a user