@20syldev/api 4.1.0 → 4.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -2,7 +2,7 @@
2
2
  <a href="https://api.sylvain.sh"><img src="https://api.sylvain.sh/favicon.ico" alt="Logo" width="25%" height="auto"/></a>
3
3
 
4
4
  # API Personnelle
5
- [![Version](https://custom-icon-badges.demolab.com/badge/Version%20:-v4.1.0-6479ee?logo=api.sylvain.sh&labelColor=23272A)](https://github.com/20syldev/api/releases/latest)
5
+ [![Version](https://custom-icon-badges.demolab.com/badge/Version%20:-v4.2.0-6479ee?logo=api.sylvain.sh&labelColor=23272A)](https://github.com/20syldev/api/releases/latest)
6
6
  </div>
7
7
 
8
8
  ---
@@ -32,10 +32,10 @@ Pour démarrer un serveur local avec tous les endpoints :
32
32
  $ npm run build && npm start
33
33
  ```
34
34
  ```console
35
- > @20syldev/api@4.1.0 build
35
+ > @20syldev/api@4.2.0 build
36
36
  > tsc
37
37
 
38
- > @20syldev/api@4.1.0 start
38
+ > @20syldev/api@4.2.0 start
39
39
  > node dist/app.js
40
40
 
41
41
  API is running on
@@ -93,16 +93,21 @@ import {
93
93
  chat, // Système de chat temporaire
94
94
  color, // Génération de couleurs aléatoires
95
95
  convert, // Conversions d'unités
96
+ dice, // Lanceur de dés RPG
96
97
  domain, // Informations de domaine aléatoires
98
+ encode, // Encodage / décodage (base64, morse, rot13, caesar, binaire)
97
99
  hash, // Hachage de texte
98
100
  hyperplanning, // Analyse de calendriers
99
101
  levenshtein, // Distance entre chaînes
100
102
  personal, // Informations personnelles aléatoires
101
103
  qrcode, // Génération de QR codes
104
+ statistics, // Statistiques descriptives
102
105
  tic_tac_toe, // Jeu de morpion
103
106
  time, // Informations temporelles
107
+ text, // Utilitaires texte (slug, stats, lorem, nombre en lettres)
104
108
  token, // Génération de jetons sécurisés
105
- username // Génération de noms d'utilisateur
109
+ username, // Génération de noms d'utilisateur
110
+ validate // Validation (Luhn, IBAN, email)
106
111
  } from '@20syldev/api/v4';
107
112
  ```
108
113
 
package/package.json CHANGED
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "4.1.0",
2
+ "version": "4.2.0",
3
3
  "name": "@20syldev/api",
4
4
  "description": "Node.js API with multiple features. Check the documentation at https://docs.sylvain.sh",
5
5
  "main": "dist/app.js",
@@ -79,7 +79,10 @@ const v3 = {
79
79
  const v4 = {
80
80
  get: merge(v3.get, [
81
81
  { name: 'dice', path: '/dice?roll={NdX+M}' },
82
+ { name: 'encode', path: '/encode?method={method}&text={text}(&shift={shift})' },
82
83
  { name: 'statistics', path: '/statistics?values={n1,n2,n3,...}' },
84
+ { name: 'text', path: '/text?method={method}(&value={value}&type={type}&count={count}&lang={lang})' },
85
+ { name: 'validate', path: '/validate?type={type}&value={value}' },
83
86
  ]),
84
87
  post: [...v3.post],
85
88
  };
@@ -8,7 +8,7 @@ const __dirname = dirname(__filename);
8
8
 
9
9
  export function setupCors(app: Express): void {
10
10
  app.set('trust proxy', 1);
11
- app.use(cors({ methods: ['GET', 'POST', 'PATCH', 'DELETE', 'OPTIONS'] }));
11
+ app.use(cors({ methods: ['GET', 'POST'] }));
12
12
  app.use(express.urlencoded({ extended: true }));
13
13
  app.use(express.json());
14
14
 
@@ -34,10 +34,8 @@ export default function chat(action: string, params: ChatParams): ChatMessage[]
34
34
  return getPrivateChat(params, privateChats);
35
35
  } else if (action === 'fetch') {
36
36
  return fetchMessages(messages);
37
- } else if (action === 'clear') {
38
- return clearPrivateChat(params, privateChats, sessions, u);
39
37
  } else {
40
- throw new Error('Invalid action. Use "message", "private", "fetch", or "clear"');
38
+ throw new Error('Invalid action. Use "message", "private", or "fetch"');
41
39
  }
42
40
  }
43
41
 
@@ -99,24 +97,3 @@ function fetchMessages(messages: ChatMessage[]): ChatMessage[] {
99
97
  if (messages.length > 0) return messages;
100
98
  throw new Error('No messages stored.');
101
99
  }
102
-
103
- function clearPrivateChat(
104
- params: ChatParams,
105
- privateChats: Record<string, ChatMessage[]>,
106
- sessions: Record<string, { user: string; last: number }>,
107
- u: string,
108
- ): { message: string } {
109
- const { token, session } = params;
110
-
111
- if (!token) throw new Error('Please provide a valid token');
112
- if (!session) throw new Error('Please provide a valid session ID');
113
- if (sessions[u] && sessions[u].user !== session) {
114
- throw new Error('Session ID mismatch');
115
- }
116
- if (!privateChats[token]) throw new Error('Invalid or expired token.');
117
-
118
- delete privateChats[token];
119
- delete sessions[u];
120
-
121
- return { message: 'Private chat cleared successfully' };
122
- }
@@ -44,43 +44,11 @@ export default function tic_tac_toe(action: string, params: TicTacToeParams): Re
44
44
  return playMove(params, games, sessions, u, now);
45
45
  } else if (action === 'fetch') {
46
46
  return fetchGame(params, games, u);
47
- } else if (action === 'forfeit') {
48
- return forfeitGame(params, games, sessions);
49
47
  } else {
50
- throw new Error('Invalid action. Use "play", "fetch", "list", or "forfeit"');
48
+ throw new Error('Invalid action. Use "play", "fetch", or "list"');
51
49
  }
52
50
  }
53
51
 
54
- function forfeitGame(
55
- params: TicTacToeParams,
56
- games: Record<string, TicTacToeGame>,
57
- sessions: Record<string, { user: string; last: number }>,
58
- ): Record<string, unknown> {
59
- const { game, session } = params;
60
-
61
- if (!game) throw new Error('Please provide a valid game ID');
62
- if (!session) throw new Error('Please provide a valid session ID');
63
- if (!games[game]) throw new Error('Game not found');
64
-
65
- const u = params.username!.toLowerCase();
66
- if (sessions[u] && sessions[u].user !== session) {
67
- throw new Error('Session ID mismatch');
68
- }
69
-
70
- const players = games[game]!.players;
71
- if (!players.includes(u)) throw new Error('You are not a player in this game');
72
-
73
- const winner = players.find((p) => p !== u) ?? null;
74
- delete games[game];
75
- delete sessions[u];
76
-
77
- return {
78
- message: `${params.username} forfeited the game.${winner ? ` ${winner} wins.` : ''}`,
79
- winner,
80
- loser: params.username,
81
- };
82
- }
83
-
84
52
  function playMove(
85
53
  params: TicTacToeParams,
86
54
  games: Record<string, TicTacToeGame>,
package/src/modules/v4.ts CHANGED
@@ -5,13 +5,16 @@ export { default as color } from './v4/color.js';
5
5
  export { default as convert } from './v4/convert.js';
6
6
  export { default as dice } from './v4/dice.js';
7
7
  export { default as domain } from './v4/domain.js';
8
+ export * as encode from './v4/encode.js';
8
9
  export { default as hash } from './v4/hash.js';
9
10
  export { default as hyperplanning } from './v4/hyperplanning.js';
10
11
  export { default as levenshtein } from './v4/levenshtein.js';
11
12
  export { default as personal } from './v4/personal.js';
12
13
  export { default as qrcode } from './v4/qrcode.js';
13
14
  export { default as statistics } from './v4/statistics.js';
15
+ export * as text from './v4/text.js';
14
16
  export { default as tic_tac_toe } from './v4/tic_tac_toe.js';
15
17
  export { default as time } from './v4/time.js';
16
18
  export { default as token } from './v4/token.js';
17
19
  export { default as username } from './v4/username.js';
20
+ export * as validate from './v4/validate.js';
package/src/routes/get.ts CHANGED
@@ -173,6 +173,29 @@ router.get('/:version/dice', (req: Request, res: Response) => {
173
173
  }
174
174
  });
175
175
 
176
+ // Encode / decode text
177
+ router.get('/:version/encode', (req: Request, res: Response) => {
178
+ const { method, text, shift } = req.query;
179
+ const { version } = req.params;
180
+
181
+ const encode = (req.module as { encode?: Record<string, (v: string, v2?: string) => string> }).encode;
182
+ if (!encode) {
183
+ error(res, 404, `Endpoint not available in ${version}.`, `${version}/encode`);
184
+ return;
185
+ }
186
+ if (!method || !encode[method as string]) {
187
+ error(res, 400, 'Please provide a valid method (?method={method})', `${version}/encode`);
188
+ return;
189
+ }
190
+
191
+ try {
192
+ const result = encode[method as string]!(text as string, shift as string);
193
+ res.jsonResponse({ method, result });
194
+ } catch (err) {
195
+ error(res, 400, (err as Error).message, `${req.version}/encode`);
196
+ }
197
+ });
198
+
176
199
  // GET planning error
177
200
  router.get('/:version/hyperplanning', (_req: Request, res: Response) => {
178
201
  error(res, 405, 'This endpoint only supports POST requests.');
@@ -269,6 +292,70 @@ router.get('/:version/statistics', (req: Request, res: Response) => {
269
292
  }
270
293
  });
271
294
 
295
+ // Text utilities (slug, stats, lorem, number)
296
+ router.get('/:version/text', (req: Request, res: Response) => {
297
+ const { method, value, type, count, lang, text } = req.query;
298
+ const { version } = req.params;
299
+
300
+ const textMod = (req.module as { text?: Record<string, (...args: string[]) => unknown> }).text;
301
+ if (!textMod) {
302
+ error(res, 404, `Endpoint not available in ${version}.`, `${version}/text`);
303
+ return;
304
+ }
305
+ if (!method || !textMod[method as string]) {
306
+ error(res, 400, 'Please provide a valid method (?method={slug|stats|lorem|number})', `${version}/text`);
307
+ return;
308
+ }
309
+
310
+ try {
311
+ let result: unknown;
312
+ switch (method) {
313
+ case 'slug':
314
+ case 'stats':
315
+ result = textMod[method as string]!((value ?? text) as string);
316
+ break;
317
+ case 'lorem':
318
+ result = textMod.lorem!((type as string) || 'words', (count as string) || '5');
319
+ break;
320
+ case 'number':
321
+ result = textMod.number!(value as string, (lang as string) || 'en');
322
+ break;
323
+ default:
324
+ throw new Error('Unknown method');
325
+ }
326
+ res.jsonResponse({ method, result });
327
+ } catch (err) {
328
+ error(res, 400, (err as Error).message, `${req.version}/text`);
329
+ }
330
+ });
331
+
332
+ // Validate data (luhn, iban, email)
333
+ router.get('/:version/validate', (req: Request, res: Response) => {
334
+ const { type, value } = req.query;
335
+ const { version } = req.params;
336
+
337
+ const validate = (req.module as { validate?: Record<string, (v: string) => unknown> }).validate;
338
+ if (!validate) {
339
+ error(res, 404, `Endpoint not available in ${version}.`, `${version}/validate`);
340
+ return;
341
+ }
342
+ if (!type || !validate[type as string]) {
343
+ error(res, 400, 'Please provide a valid type (?type={luhn|iban|email})', `${version}/validate`);
344
+ return;
345
+ }
346
+ if (!value) {
347
+ error(res, 400, 'Please provide a value (&value={value})', `${version}/validate`);
348
+ return;
349
+ }
350
+
351
+ try {
352
+ const result = validate[type as string]!(value as string);
353
+ res.jsonResponse(result);
354
+ } catch (err) {
355
+ error(res, 400, (err as Error).message, `${req.version}/validate`);
356
+ }
357
+ });
358
+
272
359
  // GET tic-tac-toe errors
273
360
  router.get('/:version/tic-tac-toe', (_req: Request, res: Response) => {
274
361
  error(res, 405, 'This endpoint only supports POST requests.');
@@ -51,7 +51,10 @@ describe('GET / (version listing)', () => {
51
51
  assert.equal(body.version, 'v4');
52
52
  const endpoints = body.endpoints as Record<string, Record<string, string>>;
53
53
  assert.ok('dice' in endpoints.get!);
54
+ assert.ok('encode' in endpoints.get!);
54
55
  assert.ok('statistics' in endpoints.get!);
56
+ assert.ok('text' in endpoints.get!);
57
+ assert.ok('validate' in endpoints.get!);
55
58
  });
56
59
 
57
60
  test('invalid version returns 404', async () => {
@@ -148,6 +151,24 @@ describe('GET /v4/dice', () => {
148
151
  });
149
152
  });
150
153
 
154
+ describe('GET /v4/encode', () => {
155
+ test('base64 encode', async () => {
156
+ const { status, body } = await getJson('/v4/encode?method=base64encode&text=hello');
157
+ assert.equal(status, 200);
158
+ assert.equal(body.result, 'aGVsbG8=');
159
+ });
160
+
161
+ test('rot13', async () => {
162
+ const { body } = await getJson('/v4/encode?method=rot13&text=Hello');
163
+ assert.equal(body.result, 'Uryyb');
164
+ });
165
+
166
+ test('missing method returns 400', async () => {
167
+ const { status } = await getJson('/v4/encode?text=hello');
168
+ assert.equal(status, 400);
169
+ });
170
+ });
171
+
151
172
  describe('GET /v4/domain', () => {
152
173
  test('has TLD', async () => {
153
174
  const { status, body } = await getJson('/v4/domain');
@@ -205,6 +226,23 @@ describe('GET /v4/statistics', () => {
205
226
  });
206
227
  });
207
228
 
229
+ describe('GET /v4/text', () => {
230
+ test('slug', async () => {
231
+ const { body } = await getJson('/v4/text?method=slug&value=Hello%20World');
232
+ assert.equal(body.result, 'hello-world');
233
+ });
234
+
235
+ test('number en', async () => {
236
+ const { body } = await getJson('/v4/text?method=number&value=42&lang=en');
237
+ assert.equal(body.result, 'forty-two');
238
+ });
239
+
240
+ test('lorem words', async () => {
241
+ const { body } = await getJson('/v4/text?method=lorem&type=words&count=5');
242
+ assert.equal((body.result as string).split(' ').length, 5);
243
+ });
244
+ });
245
+
208
246
  describe('GET /v4/time', () => {
209
247
  test('live', async () => {
210
248
  const { status, body } = await getJson('/v4/time');
@@ -222,6 +260,23 @@ describe('GET /v4/time', () => {
222
260
  });
223
261
  });
224
262
 
263
+ describe('GET /v4/validate', () => {
264
+ test('valid luhn', async () => {
265
+ const { body } = await getJson('/v4/validate?type=luhn&value=4111111111111111');
266
+ assert.equal(body.valid, true);
267
+ });
268
+
269
+ test('valid email', async () => {
270
+ const { body } = await getJson('/v4/validate?type=email&value=hello%40example.com');
271
+ assert.equal(body.valid, true);
272
+ });
273
+
274
+ test('invalid type returns 400', async () => {
275
+ const { status } = await getJson('/v4/validate?type=foo&value=bar');
276
+ assert.equal(status, 400);
277
+ });
278
+ });
279
+
225
280
  describe('GET /v4/username', () => {
226
281
  test('has fields', async () => {
227
282
  const { status, body } = await getJson('/v4/username');
package/docs/changelog.md DELETED
@@ -1,333 +0,0 @@
1
- # Changelog
2
-
3
- ## Exemples curl
4
-
5
- ```bash
6
- # dice
7
- curl -X GET "http://127.0.0.1:3000/v4/dice?roll=2d6%2B3"
8
-
9
- # encode
10
- curl -X GET "http://127.0.0.1:3000/v4/encode?method=base64encode&text=hello"
11
- curl -X GET "http://127.0.0.1:3000/v4/encode?method=morse&text=SOS"
12
- curl -X GET "http://127.0.0.1:3000/v4/encode?method=caesar&text=hello&shift=3"
13
-
14
- # geo
15
- curl -X GET "http://127.0.0.1:3000/v4/geo?lat1=48.8566&lon1=2.3522&lat2=43.2965&lon2=5.3698"
16
-
17
- # palette
18
- curl -X GET "http://127.0.0.1:3000/v4/palette?color=%23ff6600&type=triadic"
19
-
20
- # statistics
21
- curl -X GET "http://127.0.0.1:3000/v4/statistics?values=1,2,3,4,5"
22
-
23
- # text - stats
24
- curl -X GET "http://127.0.0.1:3000/v4/text?method=stats&value=Hello+world"
25
-
26
- # text - slug
27
- curl -X GET "http://127.0.0.1:3000/v4/text?method=slug&value=Héllo+Wörld%21"
28
-
29
- # text - lorem
30
- curl -X GET "http://127.0.0.1:3000/v4/text?method=lorem&type=sentences&count=3"
31
-
32
- # text - number
33
- curl -X GET "http://127.0.0.1:3000/v4/text?method=number&value=42&lang=fr"
34
-
35
- # validate - luhn
36
- curl -X GET "http://127.0.0.1:3000/v4/validate?type=luhn&value=4532015112830366"
37
-
38
- # validate - iban
39
- curl -X GET "http://127.0.0.1:3000/v4/validate?type=iban&value=FR7630006000011234567890189"
40
-
41
- # validate - email
42
- curl -X GET "http://127.0.0.1:3000/v4/validate?type=email&value=user%40example.com"
43
-
44
- # algorithms - roman
45
- curl -X GET "http://127.0.0.1:3000/v4/algorithms?method=roman&value=42"
46
- curl -X GET "http://127.0.0.1:3000/v4/algorithms?method=roman&value=XLII"
47
-
48
- # REST v4.2 - jouer un coup (PATCH)
49
- curl -X PATCH "http://127.0.0.1:3000/v4/tic-tac-toe/{gameId}" \
50
- -H "Content-Type: application/json" \
51
- -d '{"username":"alice","move":"A1","session":"abc123"}'
52
-
53
- # REST v4.2 - forfait (DELETE tic-tac-toe)
54
- curl -X DELETE "http://127.0.0.1:3000/v4/tic-tac-toe/{gameId}" \
55
- -H "Content-Type: application/json" \
56
- -d '{"username":"alice","session":"abc123"}'
57
-
58
- # REST v4.2 - vider un chat (DELETE chat)
59
- curl -X DELETE "http://127.0.0.1:3000/v4/chat/{token}" \
60
- -H "Content-Type: application/json" \
61
- -d '{"username":"alice","session":"abc123"}'
62
- ```
63
-
64
-
65
-
66
- ## v4.2.0
67
-
68
- ### REST endpoints pour tic-tac-toe et chat
69
-
70
- Les endpoints existants ont été étendus avec des méthodes HTTP REST. Uniquement disponible en `v4+`.
71
-
72
- #### PATCH `/v4/tic-tac-toe/:game`
73
-
74
- Jouer un coup dans une partie.
75
-
76
- **Body (JSON) :**
77
- | Param | Type | Description |
78
- |-------|------|-------------|
79
- | `username` | string | Nom du joueur |
80
- | `move` | string | Case jouée (ex: `A1`) |
81
- | `session` | string | ID de session |
82
-
83
- ---
84
-
85
- #### DELETE `/v4/tic-tac-toe/:game`
86
-
87
- Abandonner une partie (forfeit).
88
-
89
- **Body (JSON) :**
90
- | Param | Type | Description |
91
- |-------|------|-------------|
92
- | `username` | string | Nom du joueur |
93
- | `session` | string | ID de session |
94
-
95
- ---
96
-
97
- #### DELETE `/v4/chat/:token`
98
-
99
- Effacer tous les messages d'un chat privé (clear).
100
-
101
- **Body (JSON) :**
102
- | Param | Type | Description |
103
- |-------|------|-------------|
104
- | `username` | string | Nom de l'utilisateur |
105
- | `session` | string | ID de session |
106
-
107
- ---
108
-
109
- ### Autres changements v4.2.0
110
-
111
- - CORS : ajout de `PATCH`, `DELETE`, `OPTIONS` dans les headers autorisés
112
- - Nouveau helper `supportsRest()` dans `src/utils/version.ts`
113
-
114
- ---
115
-
116
- ## v4.1.0
117
-
118
- ### Nouveaux endpoints
119
-
120
- #### GET `/v4/dice`
121
-
122
- Lance des dés en notation RPG.
123
-
124
- **Query params :**
125
- | Param | Requis | Description | Exemple |
126
- |-------|--------|-------------|---------|
127
- | `roll` | oui | Notation NdX ou NdX+M | `2d6+3` |
128
-
129
- **Note :** le `+` dans l'URL peut être encodé en espace (`2d6 3`) — les deux sont acceptés.
130
-
131
- **Réponse :**
132
- ```json
133
- {
134
- "roll": "2d6+3",
135
- "count": 2,
136
- "sides": 6,
137
- "modifier": 3,
138
- "results": [4, 2],
139
- "total": 9
140
- }
141
- ```
142
-
143
- Limites : 1–100 dés, 2–1000 faces.
144
-
145
- ---
146
-
147
- #### GET `/v4/encode`
148
-
149
- Encode ou décode du texte dans différents formats.
150
-
151
- **Query params :**
152
- | Param | Requis | Description |
153
- |-------|--------|-------------|
154
- | `method` | oui | Voir liste ci-dessous |
155
- | `value` | oui | Texte à traiter |
156
- | `shift` | non | Décalage pour Caesar (défaut : 13) |
157
-
158
- **Méthodes disponibles :**
159
- | `method` | Description |
160
- |----------|-------------|
161
- | `base64encode` | Encode en Base64 |
162
- | `base64decode` | Décode du Base64 |
163
- | `urlencode` | Encode pour URL |
164
- | `urldecode` | Décode une URL |
165
- | `morse` | Texte → Morse |
166
- | `unmorse` | Morse → Texte |
167
- | `rot13` | ROT-13 |
168
- | `caesar` | Chiffre de César (nécessite `shift`) |
169
- | `binary` | Texte → binaire 8 bits |
170
- | `unbinary` | Binaire → texte |
171
-
172
- ---
173
-
174
- #### GET `/v4/geo`
175
-
176
- Calcule la distance et le cap entre deux coordonnées GPS (Haversine).
177
-
178
- **Query params :**
179
- | Param | Requis | Description |
180
- |-------|--------|-------------|
181
- | `lat1` | oui | Latitude point A (-90/+90) |
182
- | `lon1` | oui | Longitude point A (-180/+180) |
183
- | `lat2` | oui | Latitude point B |
184
- | `lon2` | oui | Longitude point B |
185
-
186
- **Réponse :**
187
- ```json
188
- {
189
- "distance": { "km": 1214.652, "miles": 754.801, "nauticalMiles": 655.879 },
190
- "bearing": { "degrees": 157.34, "cardinal": "SSE" },
191
- "from": { "lat": 48.8566, "lon": 2.3522 },
192
- "to": { "lat": 43.2965, "lon": 5.3698 }
193
- }
194
- ```
195
-
196
- ---
197
-
198
- #### GET `/v4/palette`
199
-
200
- Génère une palette de couleurs harmonieuse à partir d'une couleur HEX.
201
-
202
- **Query params :**
203
- | Param | Requis | Description |
204
- |-------|--------|-------------|
205
- | `color` | oui | Couleur base en HEX (`#RRGGBB`) |
206
- | `type` | oui | Type de palette |
207
-
208
- **Types disponibles :**
209
- | `type` | Couleurs générées |
210
- |--------|------------------|
211
- | `complementary` | 2 couleurs (opposées) |
212
- | `triadic` | 3 couleurs (120°) |
213
- | `analogous` | 5 couleurs (-60° à +60°) |
214
- | `tetradic` | 4 couleurs (90°) |
215
- | `split-complementary` | 3 couleurs |
216
-
217
- Chaque couleur retournée a `hex`, `rgb`, `hsl`.
218
-
219
- ---
220
-
221
- #### GET `/v4/statistics`
222
-
223
- Calcule des statistiques descriptives sur une liste de nombres.
224
-
225
- **Query params :**
226
- | Param | Requis | Description |
227
- |-------|--------|-------------|
228
- | `values` | oui | Nombres séparés par des virgules |
229
-
230
- **Réponse :**
231
- ```json
232
- {
233
- "count": 5,
234
- "sum": 15,
235
- "min": 1,
236
- "max": 5,
237
- "range": 4,
238
- "mean": 3,
239
- "median": 3,
240
- "mode": [],
241
- "variance": 2,
242
- "stddev": 1.414214
243
- }
244
- ```
245
-
246
- ---
247
-
248
- #### GET `/v4/text`
249
-
250
- Manipulation et génération de texte.
251
-
252
- **Query params :**
253
- | Param | Requis | Description |
254
- |-------|--------|-------------|
255
- | `action` | oui | Voir liste ci-dessous |
256
- | `value` | non | Texte source (selon action) |
257
- | `type` | non | `words`, `sentences`, `paragraphs` (pour `lorem`) |
258
- | `count` | non | Nombre d'éléments (pour `lorem`, défaut 5) |
259
- | `lang` | non | `fr` ou `en` (pour `number`) |
260
-
261
- **Actions disponibles :**
262
- | `action` | Description | Params requis |
263
- |----------|-------------|---------------|
264
- | `stats` | Statistiques du texte | `value` |
265
- | `slug` | Convertit en slug URL | `value` |
266
- | `lorem` | Génère du Lorem Ipsum | `type`, `count` |
267
- | `number` | Nombre en lettres | `value`, `lang` |
268
-
269
- **Réponse `stats` :**
270
- ```json
271
- {
272
- "characters": 42,
273
- "charactersNoSpaces": 35,
274
- "words": 8,
275
- "sentences": 2,
276
- "paragraphs": 1,
277
- "readingTime": "3s",
278
- "mostFrequentChar": "e"
279
- }
280
- ```
281
-
282
- ---
283
-
284
- #### GET `/v4/validate`
285
-
286
- Valide différents formats de données.
287
-
288
- **Query params :**
289
- | Param | Requis | Description |
290
- |-------|--------|-------------|
291
- | `type` | oui | `luhn`, `iban`, `email` |
292
- | `value` | oui | Valeur à valider |
293
-
294
- **Réponse `luhn` (numéro de carte) :**
295
- ```json
296
- { "valid": true, "value": "4532015112830366" }
297
- ```
298
-
299
- **Réponse `iban` :**
300
- ```json
301
- { "valid": true, "value": "FR7630006000011234567890189", "country": "FR" }
302
- ```
303
-
304
- **Réponse `email` :**
305
- ```json
306
- { "valid": true, "value": "user@example.com" }
307
- ```
308
-
309
- ---
310
-
311
- ### Ajout à un endpoint existant
312
-
313
- #### GET `/v4/algorithms` — conversion de chiffres romains
314
-
315
- Nouvelle action `roman` sur l'endpoint algorithms.
316
-
317
- **Query params :**
318
- | Param | Requis | Description |
319
- |-------|--------|-------------|
320
- | `method` | oui | `roman` |
321
- | `value` | oui | Nombre entier (1–3999) ou chiffre romain |
322
-
323
- Bidirectionnel : `42` → `XLII`, `XLII` → `42`.
324
-
325
- ---
326
-
327
- ### Infrastructure v4.1.0
328
-
329
- - **`src/app.ts`** : `export default app` + guard `NODE_ENV !== 'test'` pour les tests d'intégration
330
- - **`src/config/versions.ts`** : helper `merge()` pour dédoublonner les endpoints par `name`
331
- - **`tsconfig.test.json`** : étend le tsconfig principal, inclut `src/` et `tests/`
332
- - **`tests/tsconfig.json`** : pointe vers `tsconfig.test.json` pour la résolution IDE
333
- - **`package.json`** : script `npm test` avec `--test-force-exit` (requis car `setTimeout` dans chat/tic-tac-toe)
@@ -1,53 +0,0 @@
1
- export interface GeoResult {
2
- distance: { km: number; miles: number; nauticalMiles: number };
3
- bearing: { degrees: number; cardinal: string };
4
- from: { lat: number; lon: number };
5
- to: { lat: number; lon: number };
6
- }
7
-
8
- const EARTH_RADIUS_KM = 6371;
9
- const CARDINALS = ['N', 'NNE', 'NE', 'ENE', 'E', 'ESE', 'SE', 'SSE', 'S', 'SSW', 'SW', 'WSW', 'W', 'WNW', 'NW', 'NNW'];
10
-
11
- function toRad(deg: number): number {
12
- return (deg * Math.PI) / 180;
13
- }
14
-
15
- function toDeg(rad: number): number {
16
- return (rad * 180) / Math.PI;
17
- }
18
-
19
- function parseCoord(value: string, name: string, min: number, max: number): number {
20
- const n = Number(value);
21
- if (isNaN(n)) throw new Error(`${name} must be a number`);
22
- if (n < min || n > max) throw new Error(`${name} must be between ${min} and ${max}`);
23
- return n;
24
- }
25
-
26
- export default function geo(lat1: string, lon1: string, lat2: string, lon2: string): GeoResult {
27
- const a = parseCoord(lat1, 'lat1', -90, 90);
28
- const b = parseCoord(lon1, 'lon1', -180, 180);
29
- const c = parseCoord(lat2, 'lat2', -90, 90);
30
- const d = parseCoord(lon2, 'lon2', -180, 180);
31
-
32
- const dLat = toRad(c - a);
33
- const dLon = toRad(d - b);
34
- const sa = Math.sin(dLat / 2) ** 2 + Math.cos(toRad(a)) * Math.cos(toRad(c)) * Math.sin(dLon / 2) ** 2;
35
- const distance = 2 * EARTH_RADIUS_KM * Math.asin(Math.sqrt(sa));
36
-
37
- const y = Math.sin(toRad(d - b)) * Math.cos(toRad(c));
38
- const x =
39
- Math.cos(toRad(a)) * Math.sin(toRad(c)) - Math.sin(toRad(a)) * Math.cos(toRad(c)) * Math.cos(toRad(d - b));
40
- const bearing = (toDeg(Math.atan2(y, x)) + 360) % 360;
41
- const cardinal = CARDINALS[Math.round(bearing / 22.5) % 16]!;
42
-
43
- return {
44
- distance: {
45
- km: +distance.toFixed(3),
46
- miles: +(distance * 0.621371).toFixed(3),
47
- nauticalMiles: +(distance * 0.539957).toFixed(3),
48
- },
49
- bearing: { degrees: +bearing.toFixed(2), cardinal },
50
- from: { lat: a, lon: b },
51
- to: { lat: c, lon: d },
52
- };
53
- }
@@ -1,103 +0,0 @@
1
- export interface PaletteColor {
2
- hex: string;
3
- rgb: string;
4
- hsl: string;
5
- }
6
-
7
- export interface PaletteResult {
8
- base: PaletteColor;
9
- type: string;
10
- colors: PaletteColor[];
11
- }
12
-
13
- function hexToRgb(hex: string): [number, number, number] {
14
- const clean = hex.replace('#', '');
15
- if (!/^[0-9a-fA-F]{6}$/.test(clean)) throw new Error('Invalid HEX color (use #RRGGBB)');
16
- return [parseInt(clean.slice(0, 2), 16), parseInt(clean.slice(2, 4), 16), parseInt(clean.slice(4, 6), 16)];
17
- }
18
-
19
- function rgbToHsl(r: number, g: number, b: number): [number, number, number] {
20
- const r1 = r / 255,
21
- g1 = g / 255,
22
- b1 = b / 255;
23
- const max = Math.max(r1, g1, b1),
24
- min = Math.min(r1, g1, b1);
25
- const l = (max + min) / 2;
26
-
27
- if (max === min) return [0, 0, l];
28
-
29
- const d = max - min;
30
- const s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
31
- let h = 0;
32
- if (max === r1) h = (g1 - b1) / d + (g1 < b1 ? 6 : 0);
33
- else if (max === g1) h = (b1 - r1) / d + 2;
34
- else h = (r1 - g1) / d + 4;
35
-
36
- return [(h * 60 + 360) % 360, s, l];
37
- }
38
-
39
- function hslToRgb(h: number, s: number, l: number): [number, number, number] {
40
- const c = (1 - Math.abs(2 * l - 1)) * s;
41
- const x = c * (1 - Math.abs(((h / 60) % 2) - 1));
42
- const m = l - c / 2;
43
- let r1 = 0,
44
- g1 = 0,
45
- b1 = 0;
46
- if (h < 60) [r1, g1, b1] = [c, x, 0];
47
- else if (h < 120) [r1, g1, b1] = [x, c, 0];
48
- else if (h < 180) [r1, g1, b1] = [0, c, x];
49
- else if (h < 240) [r1, g1, b1] = [0, x, c];
50
- else if (h < 300) [r1, g1, b1] = [x, 0, c];
51
- else [r1, g1, b1] = [c, 0, x];
52
- return [Math.round((r1 + m) * 255), Math.round((g1 + m) * 255), Math.round((b1 + m) * 255)];
53
- }
54
-
55
- function format(r: number, g: number, b: number): PaletteColor {
56
- const [h, s, l] = rgbToHsl(r, g, b);
57
- return {
58
- hex: `#${[r, g, b].map((x) => x.toString(16).padStart(2, '0')).join('')}`,
59
- rgb: `rgb(${r}, ${g}, ${b})`,
60
- hsl: `hsl(${h.toFixed(1)}, ${(s * 100).toFixed(1)}%, ${(l * 100).toFixed(1)}%)`,
61
- };
62
- }
63
-
64
- function fromHueShifts(base: [number, number, number], shifts: number[]): PaletteColor[] {
65
- const [h, s, l] = base;
66
- return shifts.map((shift) => {
67
- const newH = (h + shift + 360) % 360;
68
- const [r, g, b] = hslToRgb(newH, s, l);
69
- return format(r, g, b);
70
- });
71
- }
72
-
73
- export default function palette(color: string, type: string): PaletteResult {
74
- if (!color) throw new Error('A base color is required');
75
- if (!type) throw new Error('A palette type is required');
76
-
77
- const [r, g, b] = hexToRgb(color);
78
- const base = format(r, g, b);
79
- const hsl = rgbToHsl(r, g, b);
80
-
81
- let colors: PaletteColor[];
82
- switch (type) {
83
- case 'complementary':
84
- colors = fromHueShifts(hsl, [0, 180]);
85
- break;
86
- case 'triadic':
87
- colors = fromHueShifts(hsl, [0, 120, 240]);
88
- break;
89
- case 'analogous':
90
- colors = fromHueShifts(hsl, [-60, -30, 0, 30, 60]);
91
- break;
92
- case 'tetradic':
93
- colors = fromHueShifts(hsl, [0, 90, 180, 270]);
94
- break;
95
- case 'split-complementary':
96
- colors = fromHueShifts(hsl, [0, 150, 210]);
97
- break;
98
- default:
99
- throw new Error('Type must be one of: complementary, triadic, analogous, tetradic, split-complementary');
100
- }
101
-
102
- return { base, type, colors };
103
- }
@@ -1,122 +0,0 @@
1
- import { createCanvas } from 'canvas';
2
-
3
- export interface PlaceholderOptions {
4
- width: number;
5
- height: number;
6
- bg?: string;
7
- color?: string;
8
- text?: string;
9
- rows?: number;
10
- avatar?: boolean;
11
- }
12
-
13
- function parseSize(value: string | undefined, name: string, def: number): number {
14
- if (value === undefined) return def;
15
- const n = Number(value);
16
- if (isNaN(n)) throw new Error(`${name} must be a number`);
17
- if (n < 1 || n > 4000) throw new Error(`${name} must be between 1 and 4000`);
18
- return Math.floor(n);
19
- }
20
-
21
- function normalizeColor(value: string | undefined, def: string): string {
22
- if (!value) return def;
23
- const clean = value.startsWith('#') ? value : `#${value}`;
24
- if (!/^#[0-9a-fA-F]{3,6}$/.test(clean)) throw new Error('Invalid color (use hex like #ff6600)');
25
- return clean;
26
- }
27
-
28
- function generateImage(opts: PlaceholderOptions): Buffer {
29
- const { width, height } = opts;
30
- const bg = normalizeColor(opts.bg, '#cccccc');
31
- const color = normalizeColor(opts.color, '#333333');
32
- const text = opts.text ?? `${width}×${height}`;
33
-
34
- const canvas = createCanvas(width, height);
35
- const ctx = canvas.getContext('2d');
36
-
37
- ctx.fillStyle = bg;
38
- ctx.fillRect(0, 0, width, height);
39
-
40
- const fontSize = Math.max(12, Math.min(width, height) / 8);
41
- ctx.font = `bold ${fontSize}px sans-serif`;
42
- ctx.fillStyle = color;
43
- ctx.textAlign = 'center';
44
- ctx.textBaseline = 'middle';
45
- ctx.fillText(text, width / 2, height / 2);
46
-
47
- return canvas.toBuffer('image/png');
48
- }
49
-
50
- function generateSkeleton(opts: PlaceholderOptions): string {
51
- const { width, height } = opts;
52
- const bg = normalizeColor(opts.bg, '#e2e8f0');
53
- const shimmer = normalizeColor(opts.color, '#f1f5f9');
54
- const rows = Math.max(1, Math.min(20, opts.rows ?? 3));
55
- const avatar = !!opts.avatar;
56
-
57
- const padding = Math.min(width, height) * 0.05;
58
- const avatarSize = avatar ? Math.min(width, height) * 0.2 : 0;
59
- const lineHeight = (height - padding * 2 - avatarSize - (avatar ? padding : 0)) / rows;
60
- const lineThickness = Math.max(8, lineHeight * 0.5);
61
- const radius = lineThickness / 2;
62
-
63
- const lines: string[] = [];
64
- const startY = padding + (avatar ? avatarSize + padding : 0);
65
- for (let i = 0; i < rows; i++) {
66
- const y = startY + i * lineHeight + (lineHeight - lineThickness) / 2;
67
- const lineWidth = (width - padding * 2) * (i === rows - 1 ? 0.6 : 0.95);
68
- lines.push(
69
- `<rect x="${padding}" y="${y}" width="${lineWidth}" height="${lineThickness}" rx="${radius}" fill="url(#shimmer)" />`,
70
- );
71
- }
72
-
73
- const avatarShape = avatar
74
- ? `<circle cx="${padding + avatarSize / 2}" cy="${padding + avatarSize / 2}" r="${avatarSize / 2}" fill="url(#shimmer)" />`
75
- : '';
76
-
77
- return `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}">
78
- <defs>
79
- <linearGradient id="shimmer" x1="0%" y1="0%" x2="100%" y2="0%">
80
- <stop offset="0%" stop-color="${bg}">
81
- <animate attributeName="offset" values="-2; 1" dur="1.5s" repeatCount="indefinite" />
82
- </stop>
83
- <stop offset="50%" stop-color="${shimmer}">
84
- <animate attributeName="offset" values="-1.5; 1.5" dur="1.5s" repeatCount="indefinite" />
85
- </stop>
86
- <stop offset="100%" stop-color="${bg}">
87
- <animate attributeName="offset" values="-1; 2" dur="1.5s" repeatCount="indefinite" />
88
- </stop>
89
- </linearGradient>
90
- </defs>
91
- <rect width="${width}" height="${height}" fill="${bg}" />
92
- ${avatarShape}
93
- ${lines.join('\n ')}
94
- </svg>`;
95
- }
96
-
97
- export interface PlaceholderResult {
98
- type: string;
99
- contentType: string;
100
- body: Buffer | string;
101
- }
102
-
103
- export default function placeholder(type: string, query: Record<string, string | undefined>): PlaceholderResult {
104
- const opts: PlaceholderOptions = {
105
- width: parseSize(query.width, 'width', 800),
106
- height: parseSize(query.height, 'height', 600),
107
- bg: query.bg,
108
- color: query.color,
109
- text: query.text,
110
- rows: query.rows ? parseInt(query.rows, 10) : undefined,
111
- avatar: query.avatar === 'true' || query.avatar === '1',
112
- };
113
-
114
- switch (type) {
115
- case 'image':
116
- return { type, contentType: 'image/png', body: generateImage(opts) };
117
- case 'skeleton':
118
- return { type, contentType: 'image/svg+xml', body: generateSkeleton(opts) };
119
- default:
120
- throw new Error('Type must be one of: image, skeleton');
121
- }
122
- }
@@ -1,72 +0,0 @@
1
- import { Router, type Request, type Response } from 'express';
2
- import { chatStorage, ticTacToeStorage } from '../storage/index.js';
3
- import { error } from '../utils/response.js';
4
- import { supportsRest } from '../utils/version.js';
5
-
6
- const router = Router();
7
-
8
- // Forfeit a tic-tac-toe game (REST: DELETE /tic-tac-toe/:game)
9
- router.delete('/:version/tic-tac-toe/:game', (req: Request, res: Response) => {
10
- if (!supportsRest(req)) {
11
- error(res, 405, 'DELETE is only supported in v4+.', `${req.version}/tic-tac-toe`);
12
- return;
13
- }
14
-
15
- const game = req.params.game as string;
16
- const { username, session } = (req.body as Record<string, string>) || {};
17
-
18
- if (!username) {
19
- error(res, 400, 'Please provide a username (?username={username})');
20
- return;
21
- }
22
- if (!session) {
23
- error(res, 400, 'Please provide a valid session ID (&session={ID})');
24
- return;
25
- }
26
-
27
- try {
28
- const result = req.module.tic_tac_toe('forfeit', {
29
- username,
30
- session,
31
- game,
32
- storage: ticTacToeStorage,
33
- });
34
- res.jsonResponse(result);
35
- } catch (err) {
36
- error(res, 400, (err as Error).message);
37
- }
38
- });
39
-
40
- // Clear a private chat (REST: DELETE /chat/:token)
41
- router.delete('/:version/chat/:token', (req: Request, res: Response) => {
42
- if (!supportsRest(req)) {
43
- error(res, 405, 'DELETE is only supported in v4+.', `${req.version}/chat`);
44
- return;
45
- }
46
-
47
- const token = req.params.token as string;
48
- const { username, session } = (req.body as Record<string, string>) || {};
49
-
50
- if (!username) {
51
- error(res, 400, 'Please provide a username (?username={username})');
52
- return;
53
- }
54
- if (!session) {
55
- error(res, 400, 'Please provide a valid session ID (&session={ID})');
56
- return;
57
- }
58
-
59
- try {
60
- const result = req.module.chat('clear', {
61
- username,
62
- token,
63
- session,
64
- storage: chatStorage,
65
- });
66
- res.jsonResponse(result);
67
- } catch (err) {
68
- error(res, 400, (err as Error).message);
69
- }
70
- });
71
-
72
- export default router;
@@ -1,45 +0,0 @@
1
- import { Router, type Request, type Response } from 'express';
2
- import { ticTacToeStorage } from '../storage/index.js';
3
- import { error } from '../utils/response.js';
4
- import { supportsRest } from '../utils/version.js';
5
-
6
- const router = Router();
7
-
8
- // Play a tic-tac-toe move (REST: PATCH /tic-tac-toe/:game)
9
- router.patch('/:version/tic-tac-toe/:game', (req: Request, res: Response) => {
10
- if (!supportsRest(req)) {
11
- error(res, 405, 'PATCH is only supported in v4+.', `${req.version}/tic-tac-toe`);
12
- return;
13
- }
14
-
15
- const game = req.params.game as string;
16
- const { username, move, session } = (req.body as Record<string, string>) || {};
17
-
18
- if (!username) {
19
- error(res, 400, 'Please provide a username (?username={username})');
20
- return;
21
- }
22
- if (!move) {
23
- error(res, 400, 'Please provide a valid move (&move={move})');
24
- return;
25
- }
26
- if (!session) {
27
- error(res, 400, 'Please provide a valid session ID (&session={ID})');
28
- return;
29
- }
30
-
31
- try {
32
- const result = req.module.tic_tac_toe('play', {
33
- username,
34
- move,
35
- session,
36
- game,
37
- storage: ticTacToeStorage,
38
- });
39
- res.jsonResponse(result);
40
- } catch (err) {
41
- error(res, 400, (err as Error).message);
42
- }
43
- });
44
-
45
- export default router;
@@ -1,5 +0,0 @@
1
- import type { Request } from 'express';
2
-
3
- const REST_VERSIONS = new Set(['v4']);
4
-
5
- export const supportsRest = (req: Request): boolean => REST_VERSIONS.has(req.version);
@@ -1,45 +0,0 @@
1
- import { test, describe } from 'node:test';
2
- import { strict as assert } from 'node:assert';
3
- import geo from '../../src/modules/v4/geo.js';
4
-
5
- describe('geo', () => {
6
- test('Paris to NYC distance ~5837 km', () => {
7
- const result = geo('48.8566', '2.3522', '40.7128', '-74.006');
8
- assert.ok(Math.abs(result.distance.km - 5837) < 50, `expected ~5837, got ${result.distance.km}`);
9
- });
10
-
11
- test('same point returns 0 km', () => {
12
- const result = geo('48.8566', '2.3522', '48.8566', '2.3522');
13
- assert.equal(result.distance.km, 0);
14
- });
15
-
16
- test('miles and nautical miles match km conversion', () => {
17
- const result = geo('0', '0', '0', '90');
18
- assert.ok(Math.abs(result.distance.miles - result.distance.km * 0.621371) < 0.01);
19
- assert.ok(Math.abs(result.distance.nauticalMiles - result.distance.km * 0.539957) < 0.01);
20
- });
21
-
22
- test('bearing N for due north', () => {
23
- const result = geo('0', '0', '10', '0');
24
- assert.equal(result.bearing.degrees, 0);
25
- assert.equal(result.bearing.cardinal, 'N');
26
- });
27
-
28
- test('bearing E for due east', () => {
29
- const result = geo('0', '0', '0', '10');
30
- assert.equal(result.bearing.degrees, 90);
31
- assert.equal(result.bearing.cardinal, 'E');
32
- });
33
-
34
- test('throws on invalid latitude', () => {
35
- assert.throws(() => geo('100', '0', '0', '0'), /lat1 must be between/);
36
- });
37
-
38
- test('throws on invalid longitude', () => {
39
- assert.throws(() => geo('0', '200', '0', '0'), /lon1 must be between/);
40
- });
41
-
42
- test('throws on non-numeric coords', () => {
43
- assert.throws(() => geo('abc', '0', '0', '0'), /must be a number/);
44
- });
45
- });
@@ -1,53 +0,0 @@
1
- import { test, describe } from 'node:test';
2
- import { strict as assert } from 'node:assert';
3
- import palette from '../../src/modules/v4/palette.js';
4
-
5
- describe('palette', () => {
6
- test('complementary returns 2 colors', () => {
7
- const result = palette('#ff6600', 'complementary');
8
- assert.equal(result.colors.length, 2);
9
- assert.equal(result.type, 'complementary');
10
- });
11
-
12
- test('triadic returns 3 colors', () => {
13
- const result = palette('#ff6600', 'triadic');
14
- assert.equal(result.colors.length, 3);
15
- });
16
-
17
- test('analogous returns 5 colors', () => {
18
- const result = palette('#ff6600', 'analogous');
19
- assert.equal(result.colors.length, 5);
20
- });
21
-
22
- test('tetradic returns 4 colors', () => {
23
- const result = palette('#0066ff', 'tetradic');
24
- assert.equal(result.colors.length, 4);
25
- });
26
-
27
- test('split-complementary returns 3 colors', () => {
28
- const result = palette('#0066ff', 'split-complementary');
29
- assert.equal(result.colors.length, 3);
30
- });
31
-
32
- test('each color has hex/rgb/hsl', () => {
33
- const result = palette('#ff6600', 'triadic');
34
- for (const c of result.colors) {
35
- assert.match(c.hex, /^#[0-9a-f]{6}$/);
36
- assert.match(c.rgb, /^rgb\(/);
37
- assert.match(c.hsl, /^hsl\(/);
38
- }
39
- });
40
-
41
- test('base color is included', () => {
42
- const result = palette('#ff6600', 'complementary');
43
- assert.equal(result.base.hex, '#ff6600');
44
- });
45
-
46
- test('throws on invalid hex', () => {
47
- assert.throws(() => palette('#zzz', 'triadic'), /Invalid HEX/);
48
- });
49
-
50
- test('throws on unknown type', () => {
51
- assert.throws(() => palette('#ff6600', 'rainbow'), /must be one of/);
52
- });
53
- });