@20syldev/api 4.1.0 → 4.3.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/src/routes/get.ts CHANGED
@@ -173,6 +173,52 @@ 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
+
199
+ // Geographic distance and bearing between two coordinates
200
+ router.get('/:version/geo', (req: Request, res: Response) => {
201
+ const { lat1, lon1, lat2, lon2 } = req.query;
202
+ const { version } = req.params;
203
+
204
+ const geo = (req.module as { geo?: (a: string, b: string, c: string, d: string) => unknown }).geo;
205
+ if (!geo) {
206
+ error(res, 404, `Endpoint not available in ${version}.`, `${version}/geo`);
207
+ return;
208
+ }
209
+ if (lat1 === undefined || lon1 === undefined || lat2 === undefined || lon2 === undefined) {
210
+ error(res, 400, 'Please provide lat1, lon1, lat2 and lon2', `${version}/geo`);
211
+ return;
212
+ }
213
+
214
+ try {
215
+ const result = geo(lat1 as string, lon1 as string, lat2 as string, lon2 as string);
216
+ res.jsonResponse(result);
217
+ } catch (err) {
218
+ error(res, 400, (err as Error).message, `${req.version}/geo`);
219
+ }
220
+ });
221
+
176
222
  // GET planning error
177
223
  router.get('/:version/hyperplanning', (_req: Request, res: Response) => {
178
224
  error(res, 405, 'This endpoint only supports POST requests.');
@@ -219,6 +265,33 @@ router.get('/:version/levenshtein', (req: Request, res: Response) => {
219
265
  }
220
266
  });
221
267
 
268
+ // Generate a color palette from a base color
269
+ router.get('/:version/palette', (req: Request, res: Response) => {
270
+ const { color, type } = req.query;
271
+ const { version } = req.params;
272
+
273
+ const palette = (req.module as { palette?: (c: string, t: string) => unknown }).palette;
274
+ if (!palette) {
275
+ error(res, 404, `Endpoint not available in ${version}.`, `${version}/palette`);
276
+ return;
277
+ }
278
+ if (!color) {
279
+ error(res, 400, 'Please provide a base color (?color=#ff6600)', `${version}/palette`);
280
+ return;
281
+ }
282
+ if (!type) {
283
+ error(res, 400, 'Please provide a palette type (&type=complementary)', `${version}/palette`);
284
+ return;
285
+ }
286
+
287
+ try {
288
+ const result = palette(color as string, type as string);
289
+ res.jsonResponse(result);
290
+ } catch (err) {
291
+ error(res, 400, (err as Error).message, `${req.version}/palette`);
292
+ }
293
+ });
294
+
222
295
  // Generate personal data
223
296
  router.get('/:version/personal', (req: Request, res: Response) => {
224
297
  try {
@@ -229,6 +302,32 @@ router.get('/:version/personal', (req: Request, res: Response) => {
229
302
  }
230
303
  });
231
304
 
305
+ // Generate a placeholder image or skeleton
306
+ router.get('/:version/placeholder', (req: Request, res: Response) => {
307
+ const { type = 'image' } = req.query;
308
+ const { version } = req.params;
309
+
310
+ const placeholder = (
311
+ req.module as {
312
+ placeholder?: (
313
+ t: string,
314
+ q: Record<string, string | undefined>,
315
+ ) => { type: string; contentType: string; body: Buffer | string };
316
+ }
317
+ ).placeholder;
318
+ if (!placeholder) {
319
+ error(res, 404, `Endpoint not available in ${version}.`, `${version}/placeholder`);
320
+ return;
321
+ }
322
+
323
+ try {
324
+ const result = placeholder(type as string, req.query as Record<string, string | undefined>);
325
+ res.type(result.contentType).send(result.body);
326
+ } catch (err) {
327
+ error(res, 400, (err as Error).message, `${req.version}/placeholder`);
328
+ }
329
+ });
330
+
232
331
  // Generate QR Code
233
332
  router.get('/:version/qrcode', async (req: Request, res: Response) => {
234
333
  const { url } = req.query;
@@ -269,6 +368,70 @@ router.get('/:version/statistics', (req: Request, res: Response) => {
269
368
  }
270
369
  });
271
370
 
371
+ // Text utilities (slug, stats, lorem, number)
372
+ router.get('/:version/text', (req: Request, res: Response) => {
373
+ const { method, value, type, count, lang, text } = req.query;
374
+ const { version } = req.params;
375
+
376
+ const textMod = (req.module as { text?: Record<string, (...args: string[]) => unknown> }).text;
377
+ if (!textMod) {
378
+ error(res, 404, `Endpoint not available in ${version}.`, `${version}/text`);
379
+ return;
380
+ }
381
+ if (!method || !textMod[method as string]) {
382
+ error(res, 400, 'Please provide a valid method (?method={slug|stats|lorem|number})', `${version}/text`);
383
+ return;
384
+ }
385
+
386
+ try {
387
+ let result: unknown;
388
+ switch (method) {
389
+ case 'slug':
390
+ case 'stats':
391
+ result = textMod[method as string]!((value ?? text) as string);
392
+ break;
393
+ case 'lorem':
394
+ result = textMod.lorem!((type as string) || 'words', (count as string) || '5');
395
+ break;
396
+ case 'number':
397
+ result = textMod.number!(value as string, (lang as string) || 'en');
398
+ break;
399
+ default:
400
+ throw new Error('Unknown method');
401
+ }
402
+ res.jsonResponse({ method, result });
403
+ } catch (err) {
404
+ error(res, 400, (err as Error).message, `${req.version}/text`);
405
+ }
406
+ });
407
+
408
+ // Validate data (luhn, iban, email)
409
+ router.get('/:version/validate', (req: Request, res: Response) => {
410
+ const { type, value } = req.query;
411
+ const { version } = req.params;
412
+
413
+ const validate = (req.module as { validate?: Record<string, (v: string) => unknown> }).validate;
414
+ if (!validate) {
415
+ error(res, 404, `Endpoint not available in ${version}.`, `${version}/validate`);
416
+ return;
417
+ }
418
+ if (!type || !validate[type as string]) {
419
+ error(res, 400, 'Please provide a valid type (?type={luhn|iban|email})', `${version}/validate`);
420
+ return;
421
+ }
422
+ if (!value) {
423
+ error(res, 400, 'Please provide a value (&value={value})', `${version}/validate`);
424
+ return;
425
+ }
426
+
427
+ try {
428
+ const result = validate[type as string]!(value as string);
429
+ res.jsonResponse(result);
430
+ } catch (err) {
431
+ error(res, 400, (err as Error).message, `${req.version}/validate`);
432
+ }
433
+ });
434
+
272
435
  // GET tic-tac-toe errors
273
436
  router.get('/:version/tic-tac-toe', (_req: Request, res: Response) => {
274
437
  error(res, 405, 'This endpoint only supports POST requests.');
@@ -1,13 +1,12 @@
1
1
  import { Router, type Request, type Response } from 'express';
2
2
  import { ticTacToeStorage } from '../storage/index.js';
3
3
  import { error } from '../utils/response.js';
4
- import { supportsRest } from '../utils/version.js';
5
4
 
6
5
  const router = Router();
7
6
 
8
- // Play a tic-tac-toe move (REST: PATCH /tic-tac-toe/:game)
7
+ // Play a tic-tac-toe move
9
8
  router.patch('/:version/tic-tac-toe/:game', (req: Request, res: Response) => {
10
- if (!supportsRest(req)) {
9
+ if (req.version !== 'v4') {
11
10
  error(res, 405, 'PATCH is only supported in v4+.', `${req.version}/tic-tac-toe`);
12
11
  return;
13
12
  }
@@ -51,7 +51,13 @@ 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!);
55
+ assert.ok('geo' in endpoints.get!);
56
+ assert.ok('palette' in endpoints.get!);
57
+ assert.ok('placeholder' in endpoints.get!);
54
58
  assert.ok('statistics' in endpoints.get!);
59
+ assert.ok('text' in endpoints.get!);
60
+ assert.ok('validate' in endpoints.get!);
55
61
  });
56
62
 
57
63
  test('invalid version returns 404', async () => {
@@ -156,6 +162,37 @@ describe('GET /v4/domain', () => {
156
162
  });
157
163
  });
158
164
 
165
+ describe('GET /v4/encode', () => {
166
+ test('base64 encode', async () => {
167
+ const { status, body } = await getJson('/v4/encode?method=base64encode&text=hello');
168
+ assert.equal(status, 200);
169
+ assert.equal(body.result, 'aGVsbG8=');
170
+ });
171
+
172
+ test('rot13', async () => {
173
+ const { body } = await getJson('/v4/encode?method=rot13&text=Hello');
174
+ assert.equal(body.result, 'Uryyb');
175
+ });
176
+
177
+ test('missing method returns 400', async () => {
178
+ const { status } = await getJson('/v4/encode?text=hello');
179
+ assert.equal(status, 400);
180
+ });
181
+ });
182
+
183
+ describe('GET /v4/geo', () => {
184
+ test('Paris-NYC distance', async () => {
185
+ const { body } = await getJson('/v4/geo?lat1=48.8566&lon1=2.3522&lat2=40.7128&lon2=-74.006');
186
+ const distance = body.distance as { km: number };
187
+ assert.ok(Math.abs(distance.km - 5837) < 50);
188
+ });
189
+
190
+ test('missing param returns 400', async () => {
191
+ const { status } = await getJson('/v4/geo?lat1=0&lon1=0');
192
+ assert.equal(status, 400);
193
+ });
194
+ });
195
+
159
196
  describe('GET /v4/infos', () => {
160
197
  test('returns API infos', async () => {
161
198
  const { status, body } = await getJson('/v4/infos');
@@ -173,6 +210,18 @@ describe('GET /v4/levenshtein', () => {
173
210
  });
174
211
  });
175
212
 
213
+ describe('GET /v4/palette', () => {
214
+ test('triadic returns 3 colors', async () => {
215
+ const { body } = await getJson('/v4/palette?color=%23ff6600&type=triadic');
216
+ assert.equal((body.colors as unknown[]).length, 3);
217
+ });
218
+
219
+ test('invalid type returns 400', async () => {
220
+ const { status } = await getJson('/v4/palette?color=%23ff6600&type=rainbow');
221
+ assert.equal(status, 400);
222
+ });
223
+ });
224
+
176
225
  describe('GET /v4/personal', () => {
177
226
  test('returns generated identity', async () => {
178
227
  const { status, body } = await getJson('/v4/personal');
@@ -183,6 +232,22 @@ describe('GET /v4/personal', () => {
183
232
  });
184
233
  });
185
234
 
235
+ describe('GET /v4/placeholder', () => {
236
+ test('image returns SVG', async () => {
237
+ const res = await fetch(`${baseUrl}/v4/placeholder?type=image&width=100&height=50`);
238
+ assert.equal(res.status, 200);
239
+ assert.match(res.headers.get('content-type') ?? '', /image\/svg\+xml/);
240
+ });
241
+
242
+ test('skeleton returns SVG', async () => {
243
+ const res = await fetch(`${baseUrl}/v4/placeholder?type=skeleton&width=200&height=100`);
244
+ assert.equal(res.status, 200);
245
+ assert.match(res.headers.get('content-type') ?? '', /image\/svg\+xml/);
246
+ const body = await res.text();
247
+ assert.match(body, /<svg/);
248
+ });
249
+ });
250
+
186
251
  describe('GET /v4/qrcode', () => {
187
252
  test('returns data URL', async () => {
188
253
  const { status, body } = await getJson('/v4/qrcode?url=https%3A%2F%2Fexample.com');
@@ -205,6 +270,23 @@ describe('GET /v4/statistics', () => {
205
270
  });
206
271
  });
207
272
 
273
+ describe('GET /v4/text', () => {
274
+ test('slug', async () => {
275
+ const { body } = await getJson('/v4/text?method=slug&value=Hello%20World');
276
+ assert.equal(body.result, 'hello-world');
277
+ });
278
+
279
+ test('number en', async () => {
280
+ const { body } = await getJson('/v4/text?method=number&value=42&lang=en');
281
+ assert.equal(body.result, 'forty-two');
282
+ });
283
+
284
+ test('lorem words', async () => {
285
+ const { body } = await getJson('/v4/text?method=lorem&type=words&count=5');
286
+ assert.equal((body.result as string).split(' ').length, 5);
287
+ });
288
+ });
289
+
208
290
  describe('GET /v4/time', () => {
209
291
  test('live', async () => {
210
292
  const { status, body } = await getJson('/v4/time');
@@ -232,6 +314,23 @@ describe('GET /v4/username', () => {
232
314
  });
233
315
  });
234
316
 
317
+ describe('GET /v4/validate', () => {
318
+ test('valid luhn', async () => {
319
+ const { body } = await getJson('/v4/validate?type=luhn&value=4111111111111111');
320
+ assert.equal(body.valid, true);
321
+ });
322
+
323
+ test('valid email', async () => {
324
+ const { body } = await getJson('/v4/validate?type=email&value=hello%40example.com');
325
+ assert.equal(body.valid, true);
326
+ });
327
+
328
+ test('invalid type returns 400', async () => {
329
+ const { status } = await getJson('/v4/validate?type=foo&value=bar');
330
+ assert.equal(status, 400);
331
+ });
332
+ });
333
+
235
334
  describe('GET /v4/website', () => {
236
335
  const originalFetch = globalThis.fetch;
237
336
 
@@ -272,10 +371,10 @@ describe('GET /v4/website', () => {
272
371
  assert.ok('stats' in body);
273
372
  });
274
373
 
275
- test('?key=stats.5 returns sub-key', async () => {
276
- const { status, body } = await getJson('/v4/website?key=stats.5');
374
+ test('?key=active returns sub-key', async () => {
375
+ const { status, body } = await getJson('/v4/website?key=active');
277
376
  assert.equal(status, 200);
278
- assert.ok('stats.5' in body);
377
+ assert.ok('active' in body);
279
378
  });
280
379
 
281
380
  test('?key=invalid.path returns 404', async () => {
@@ -0,0 +1,23 @@
1
+ import { test, describe } from 'node:test';
2
+ import { strict as assert } from 'node:assert';
3
+ import captcha from '../../src/modules/v4/captcha.js';
4
+
5
+ describe('captcha', () => {
6
+ test('returns a Buffer for valid text', () => {
7
+ const result = captcha('hello');
8
+ assert.ok(Buffer.isBuffer(result));
9
+ assert.ok(result.length > 0);
10
+ });
11
+
12
+ test('PNG starts with correct magic bytes', () => {
13
+ const result = captcha('test');
14
+ assert.equal(result[0], 0x89);
15
+ assert.equal(result[1], 0x50); // P
16
+ assert.equal(result[2], 0x4e); // N
17
+ assert.equal(result[3], 0x47); // G
18
+ });
19
+
20
+ test('throws on missing text', () => {
21
+ assert.throws(() => captcha(''));
22
+ });
23
+ });
@@ -0,0 +1,50 @@
1
+ import { test, describe } from 'node:test';
2
+ import { strict as assert } from 'node:assert';
3
+ import chat from '../../src/modules/v4/chat.js';
4
+ import type { ChatStorage } from '../../src/types/storage.js';
5
+
6
+ function makeStorage(): ChatStorage {
7
+ return { messages: [], privateChats: {}, sessions: {}, rateLimits: {} };
8
+ }
9
+
10
+ describe('chat', () => {
11
+ test('sends a public message', () => {
12
+ const storage = makeStorage();
13
+ const result = chat('message', { username: 'alice', message: 'hello', session: 'a1', storage });
14
+ assert.ok((result as { message: string }).message.includes('sent'));
15
+ });
16
+
17
+ test('fetches public messages', () => {
18
+ const storage = makeStorage();
19
+ chat('message', { username: 'alice', message: 'test', session: 'a1', storage });
20
+ const result = chat('fetch', { username: 'bob', storage });
21
+ assert.ok(Array.isArray(result));
22
+ assert.equal((result as { message: string }[])[0]!.message, 'test');
23
+ });
24
+
25
+ test('sends and retrieves private messages', () => {
26
+ const storage = makeStorage();
27
+ chat('message', { username: 'alice', message: 'secret', session: 'a1', token: 'tok1', storage });
28
+ const result = chat('private', { username: 'bob', token: 'tok1', storage });
29
+ assert.ok(Array.isArray(result));
30
+ });
31
+
32
+ test('clears a private chat', () => {
33
+ const storage = makeStorage();
34
+ chat('message', { username: 'alice', message: 'secret', session: 'a1', token: 'tok2', storage });
35
+ const result = chat('clear', { username: 'alice', session: 'a1', token: 'tok2', storage });
36
+ assert.ok((result as { message: string }).message.includes('cleared'));
37
+ });
38
+
39
+ test('throws on missing username', () => {
40
+ assert.throws(() => chat('message', { username: '', message: 'hi', session: 's', storage: makeStorage() }), /username/);
41
+ });
42
+
43
+ test('throws on invalid action', () => {
44
+ assert.throws(() => chat('delete', { username: 'alice', storage: makeStorage() }), /Invalid action/);
45
+ });
46
+
47
+ test('throws on fetch with no messages', () => {
48
+ assert.throws(() => chat('fetch', { username: 'alice', storage: makeStorage() }), /No messages/);
49
+ });
50
+ });
@@ -0,0 +1,57 @@
1
+ import { test, describe } from 'node:test';
2
+ import { strict as assert } from 'node:assert';
3
+ import placeholder from '../../src/modules/v4/placeholder.js';
4
+
5
+ describe('placeholder', () => {
6
+ test('image returns SVG with text', () => {
7
+ const result = placeholder('image', { width: '200', height: '100', text: 'Hello' });
8
+ assert.equal(result.type, 'image');
9
+ assert.equal(result.contentType, 'image/svg+xml');
10
+ assert.match(result.body, /<svg/);
11
+ assert.match(result.body, /Hello/);
12
+ });
13
+
14
+ test('skeleton returns SVG with animation', () => {
15
+ const result = placeholder('skeleton', { width: '300', height: '200' });
16
+ assert.equal(result.type, 'skeleton');
17
+ assert.equal(result.contentType, 'image/svg+xml');
18
+ assert.match(result.body, /<svg/);
19
+ });
20
+
21
+ test('default dimensions 800x600', () => {
22
+ const result = placeholder('image', {});
23
+ assert.match(result.body, /width="800"/);
24
+ assert.match(result.body, /height="600"/);
25
+ });
26
+
27
+ test('skeleton with avatar', () => {
28
+ const result = placeholder('skeleton', { avatar: 'circle' });
29
+ assert.match(result.body, /<circle/);
30
+ });
31
+
32
+ test('skeleton with rows', () => {
33
+ const result = placeholder('skeleton', { rows: '5' });
34
+ assert.match(result.body, /<rect/);
35
+ });
36
+
37
+ test('animate none removes animation', () => {
38
+ const result = placeholder('skeleton', { animate: 'none' });
39
+ assert.ok(!result.body.includes('<animate'));
40
+ });
41
+
42
+ test('throws on invalid type', () => {
43
+ assert.throws(() => placeholder('video', {}), /Type must be one of/);
44
+ });
45
+
46
+ test('throws on invalid width', () => {
47
+ assert.throws(() => placeholder('image', { width: '5000' }), /width/);
48
+ });
49
+
50
+ test('throws on invalid avatar shape', () => {
51
+ assert.throws(() => placeholder('skeleton', { avatar: 'hexagon' }), /avatar/);
52
+ });
53
+
54
+ test('throws on invalid speed', () => {
55
+ assert.throws(() => placeholder('skeleton', { speed: '99' }), /speed/);
56
+ });
57
+ });
@@ -0,0 +1,14 @@
1
+ import { test, describe } from 'node:test';
2
+ import { strict as assert } from 'node:assert';
3
+ import qrcode from '../../src/modules/v4/qrcode.js';
4
+
5
+ describe('qrcode', () => {
6
+ test('returns data URL for valid url', async () => {
7
+ const result = await qrcode('https://example.com');
8
+ assert.match(result, /^data:image\/png;base64,/);
9
+ });
10
+
11
+ test('throws on missing url', async () => {
12
+ await assert.rejects(() => qrcode(''));
13
+ });
14
+ });
@@ -0,0 +1,52 @@
1
+ import { test, describe } from 'node:test';
2
+ import { strict as assert } from 'node:assert';
3
+ import tic_tac_toe from '../../src/modules/v4/tic_tac_toe.js';
4
+ import type { TicTacToeStorage } from '../../src/types/storage.js';
5
+
6
+ function makeStorage(): TicTacToeStorage {
7
+ return { games: {}, sessions: {}, rateLimits: {} };
8
+ }
9
+
10
+ describe('tic_tac_toe', () => {
11
+ test('list returns empty games array', () => {
12
+ const result = tic_tac_toe('list', { storage: makeStorage() });
13
+ assert.ok(Array.isArray(result.games));
14
+ });
15
+
16
+ test('play creates a game and makes a move', () => {
17
+ const storage = makeStorage();
18
+ const result = tic_tac_toe('play', { username: 'alice', move: '1-1', session: 'a1', game: 'TEST1', storage });
19
+ assert.ok((result.message as string).includes('Move sent'));
20
+ });
21
+
22
+ test('fetch returns game state', () => {
23
+ const storage = makeStorage();
24
+ tic_tac_toe('play', { username: 'alice', move: '1-1', session: 'a1', game: 'TEST2', storage });
25
+ const result = tic_tac_toe('fetch', { username: 'alice', game: 'TEST2', storage });
26
+ assert.equal(result.id, 'TEST2');
27
+ });
28
+
29
+ test('forfeit throws on non-player', () => {
30
+ const storage = makeStorage();
31
+ tic_tac_toe('play', { username: 'alice', move: '1-1', session: 'a1', game: 'TEST3', storage });
32
+ assert.throws(
33
+ () => tic_tac_toe('forfeit', { username: 'charlie', session: 'c1', game: 'TEST3', storage }),
34
+ /not a player/,
35
+ );
36
+ });
37
+
38
+ test('throws on missing username', () => {
39
+ assert.throws(() => tic_tac_toe('play', { move: '1-1', session: 's', game: 'G', storage: makeStorage() }), /username/);
40
+ });
41
+
42
+ test('throws on missing move', () => {
43
+ assert.throws(
44
+ () => tic_tac_toe('play', { username: 'alice', session: 's', game: 'G', storage: makeStorage() }),
45
+ /move/i,
46
+ );
47
+ });
48
+
49
+ test('throws on invalid action', () => {
50
+ assert.throws(() => tic_tac_toe('reset', { username: 'alice', storage: makeStorage() }), /Invalid action/);
51
+ });
52
+ });