3 Commits

Author SHA1 Message Date
Louis
7f8d962edd Adds initial README file
Adds a README file explaining the project, oAuth management, and infisical setup.

The oAuth management section describes the manual steps required to get a refresh token from Strava and how to use it.
The Infisical setup section explains how to set up the Infisical vault with the required variables.
2025-10-26 17:30:22 +01:00
Louis
929f0e38f6 feat: add dotenv and an option to check env variables 2025-10-26 16:59:38 +01:00
Louis
bb00f74d17 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
2025-10-26 16:37:25 +01:00
15 changed files with 5896 additions and 1 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
}

View File

@@ -1,3 +1,31 @@
# Strava-API
API used to communicate with my strava account, will be used as a display project
API used to communicate with my strava account, will be used as a display project
## WIP and strava oAuth management
Right now the oAuth management is a bit manual, I need to get the refresh token manually from strava website, then store it in Infisical vault (or env variable for testing).
### Steps to get refresh token
1. Go to this URL (replace client_id with your strava app client id, redirect_uri with your redirect uri, and scope with the scopes you need):
```https://www.strava.com/oauth/authorize?client_id=181987&response_type=code&redirect_uri=https://louisemard.dev&approval_prompt=force&scope=read,activity:read_all
```
2. Authorize the application and you will be redirected to the redirect_uri with a code in the URL.
3. Exchange the code for a refresh token by making a POST request to the Strava API:
```
POST https://www.strava.com/oauth/token
Content-Type: application/x-www-form-urlencoded
client_id=181987
client_secret=YOUR_CLIENT_SECRET
code=AUTHORIZATION_CODE
grant_type=authorization_code
```
4. The response will contain the refresh token, which you can then store in your Infisical vault or as an environment variable.
The `REFRESH_TOKEN` is then used by the application to get new access tokens when needed. It's linked to your Strava account and allows the application to access your data.
## Infisical setup
To use infisical you need to have the following variables in your vault (environment "dev"):
- `INFISICAL_CLIENT_SECRET`: your infisical client secret
- `INFISICAL_CLIENT_ID`: your infisical client id
- `INFISICAL_URL`: Infisical url, mine is `https://vault.louisemard.dev`
- `INFISICAL_PROJECT_ID`: Project where the secrets are stored, right now only one project is supported

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.2",
"typescript": "^5.3.2"
}
}

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

@@ -0,0 +1,63 @@
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() {
dotenv.config();
this.infisicalClient = new InfisicalSDK({
siteUrl: 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, checkEnv: boolean = false): Effect.Effect<string, Error, never> {
const self = this;
return Effect.gen(function* (this: Client) {
if (checkEnv && process.env[key]) {
yield* Effect.log('Using environment variable for key: ' + key);
return process.env[key]!;
}
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,55 @@
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;
// Retrieve credentials from Infisical, for refresh token as it represents user account
// There's an option to check env variables first
const refreshToken = yield* self.getCredential('REFRESH_TOKEN', true);
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"]
}