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.

Removes nodemon configuration

Removes the nodemon.json file as its configuration is no longer needed.

fix: remove clg
This commit is contained in:
Louis
2025-10-23 16:40:27 +02:00
parent d418f094cd
commit bb00f74d17
14 changed files with 5863 additions and 0 deletions

View File

@@ -0,0 +1,8 @@
node_modules
npm-debug.log
dist
.git
.gitignore
README.md
.env
.env.local

35
.devcontainer/Dockerfile Normal file
View File

@@ -0,0 +1,35 @@
FROM node:22-bullseye
# Install additional tools
RUN apt-get update && apt-get install -y \
git \
curl \
wget \
vim \
nano \
build-essential \
python3 \
&& rm -rf /var/lib/apt/lists/*
# Create a non-root user 'node' if not exists (usually already in official node image)
# Set up environment
ENV NODE_ENV=development
# Install global npm packages
RUN npm install -g \
@types/node \
typescript \
ts-node \
nodemon
WORKDIR /workspace
# Copy package files
COPY package*.json ./
# Install dependencies
RUN npm install
EXPOSE 3000 5000
CMD ["npm", "run", "dev"]

View File

@@ -0,0 +1,45 @@
{
"name": "TypeScript Node.js",
"dockerFile": "Dockerfile",
"context": "..",
"remoteUser": "node",
"features": {
"ghcr.io/devcontainers/features/git:1": {},
"ghcr.io/devcontainers/features/github-cli:1": {}
},
"customizations": {
"vscode": {
"extensions": [
"ms-vscode.vscode-typescript-next",
"ESLint.eslint",
"Prettier.prettier-vscode",
"ms-vscode.makefile-tools",
"ms-vscode-remote.remote-containers"
],
"settings": {
"editor.defaultFormatter": "esbenp.prettier-vscode",
"editor.formatOnSave": true,
"[typescript]": {
"editor.defaultFormatter": "esbenp.prettier-vscode"
},
"[json]": {
"editor.defaultFormatter": "esbenp.prettier-vscode"
},
"typescript.tsdk": "node_modules/typescript/lib",
"typescript.enablePromptUseWorkspaceTsdk": true,
"typescript.format.enable": true
}
}
},
"forwardPorts": [
3000,
5000
],
"postCreateCommand": "npm install",
"mounts": [
"source=${localEnv:HOME}${localEnv:USERPROFILE}/.ssh,target=/home/node/.ssh,type=bind,consistency=cached"
],
"remoteEnv": {
"NODE_ENV": "development"
}
}

22
.eslintrc.json Normal file
View File

@@ -0,0 +1,22 @@
{
"env": {
"node": true,
"es2020": true
},
"extends": [
"eslint:recommended",
"plugin:@typescript-eslint/recommended"
],
"parser": "@typescript-eslint/parser",
"parserOptions": {
"ecmaVersion": 2020,
"sourceType": "module",
"project": "./tsconfig.json"
},
"plugins": ["@typescript-eslint"],
"rules": {
"@typescript-eslint/explicit-module-boundary-types": "warn",
"@typescript-eslint/no-unused-vars": ["error", { "argsIgnorePattern": "^_" }],
"no-console": "warn"
}
}

8
.prettierrc.json Normal file
View File

@@ -0,0 +1,8 @@
{
"semi": true,
"trailingComma": "es5",
"singleQuote": true,
"printWidth": 100,
"tabWidth": 2,
"useTabs": false
}

4
.vscode/settings.json vendored Normal file
View File

@@ -0,0 +1,4 @@
{
"editor.fontFamily": "Fira Code",
"editor.fontLigatures": true
}

5297
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

38
package.json Normal file
View File

@@ -0,0 +1,38 @@
{
"name": "strava-api",
"version": "1.0.0",
"description": "API to communicate with Strava account",
"main": "dist/index.js",
"scripts": {
"dev": "nodemon --exec ts-node src/index.ts",
"build": "tsc",
"start": "node dist/index.js",
"lint": "eslint src --ext .ts",
"lint:fix": "eslint src --ext .ts --fix",
"format": "prettier --write \"src/**/*.ts\"",
"test": "echo \"Error: no test specified\" && exit 1"
},
"keywords": [
"strava",
"api"
],
"author": "",
"license": "MIT",
"dependencies": {
"@infisical/sdk": "^4.0.6",
"@types/express": "^5.0.3",
"dotenv": "^17.2.3",
"effect": "^3.18.4",
"express": "^5.1.0"
},
"devDependencies": {
"@types/node": "^20.10.0",
"@typescript-eslint/eslint-plugin": "^6.13.0",
"@typescript-eslint/parser": "^6.13.0",
"eslint": "^8.54.0",
"nodemon": "^3.0.2",
"prettier": "^3.1.0",
"ts-node": "^10.9.1",
"typescript": "^5.3.2"
}
}

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

@@ -0,0 +1,61 @@
import { Effect } from "effect";
import { InfisicalSDK } from '@infisical/sdk'
import dotenv from 'dotenv';
/**
* 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
*/
abstract class Client {
// log env variables to verify they are loaded
private infisicalClient: InfisicalSDK;
constructor() {
this.infisicalClient = new InfisicalSDK({
siteUrl: process.env.INFISICAL_URL,
});
dotenv.config();
console.log("INFISICAL_URL:", process.env.INFISICAL_URL);
}
init(): Effect.Effect<void, Error, never> {
return Effect.tryPromise({
try: () => this.infisicalClient.auth().universalAuth.login({
clientId: process.env.INFISICAL_CLIENT_ID!,
clientSecret: process.env.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: process.env.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;

25
tsconfig.json Normal file
View File

@@ -0,0 +1,25 @@
{
"compilerOptions": {
"target": "ES2020",
"module": "commonjs",
"lib": ["ES2020", "DOM"],
"outDir": "./dist",
"rootDir": "./src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"moduleResolution": "node",
"allowSyntheticDefaultImports": true,
"declaration": true,
"declarationMap": true,
"sourceMap": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": true
},
"include": ["src"],
"exclude": ["node_modules", "dist"]
}