@20syldev/api 4.0.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.0.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.0.0 build
35
+ > @20syldev/api@4.2.0 build
36
36
  > tsc
37
37
 
38
- > @20syldev/api@4.0.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.0.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",
@@ -8,8 +8,9 @@
8
8
  "dev": "tsx watch src/app.ts",
9
9
  "start": "node dist/app.js",
10
10
  "build": "tsc",
11
- "check": "tsc --noEmit && eslint src/",
12
- "format": "prettier --write src/ && eslint --fix src/",
11
+ "check": "tsc --noEmit -p tsconfig.test.json && eslint src/ tests/",
12
+ "format": "prettier --write src/ tests/ && eslint --fix src/ tests/",
13
+ "test": "NODE_ENV=test node --import tsx --test --test-force-exit tests/unit/*.test.ts tests/integration/*.test.ts",
13
14
  "upgrade:minor": "npm upgrade",
14
15
  "upgrade:major": "npx npm-check-updates -u && npm install"
15
16
  },
@@ -37,7 +38,7 @@
37
38
  "@eslint/js": "^9.24.0",
38
39
  "@types/cors": "^2.8.17",
39
40
  "@types/express": "^5.0.0",
40
- "@types/node": "^22.0.0",
41
+ "@types/node": "^22.19.17",
41
42
  "@types/qrcode": "^1.5.6",
42
43
  "@types/uuid": "^10.0.0",
43
44
  "eslint": "^9.24.0",
package/src/app.ts CHANGED
@@ -39,6 +39,10 @@ app.use(getRoutes);
39
39
  app.use(postRoutes);
40
40
 
41
41
  // Start server
42
- app.listen(env.PORT, () =>
43
- console.log(`API is running on\n - http://127.0.0.1:${env.PORT}\n - http://localhost:${env.PORT}`),
44
- );
42
+ if (process.env.NODE_ENV !== 'test') {
43
+ app.listen(env.PORT, () =>
44
+ console.log(`API is running on\n - http://127.0.0.1:${env.PORT}\n - http://localhost:${env.PORT}`),
45
+ );
46
+ }
47
+
48
+ export default app;
@@ -11,10 +11,22 @@ export interface VersionConfig {
11
11
  endpoints: {
12
12
  get: Endpoint[];
13
13
  post: Endpoint[];
14
+ patch?: Endpoint[];
15
+ delete?: Endpoint[];
14
16
  };
15
17
  modules: typeof apiv3 | typeof apiv4;
16
18
  }
17
19
 
20
+ /**
21
+ * Merges a base list of endpoints with additions, deduplicating by `name`.
22
+ * If an addition has the same name as a base entry, it overrides it.
23
+ */
24
+ const merge = (base: Endpoint[], additions: Endpoint[]): Endpoint[] => {
25
+ const map = new Map(base.map((e) => [e.name, e]));
26
+ for (const e of additions) map.set(e.name, e);
27
+ return [...map.values()];
28
+ };
29
+
18
30
  const v1 = {
19
31
  get: [
20
32
  { name: 'algorithms', path: '/algorithms?method={algorithm}&value={value}(&value2={value2})' },
@@ -32,9 +44,8 @@ const v1 = {
32
44
  };
33
45
 
34
46
  const v2 = {
35
- get: [...v1.get, { name: 'chat', path: '/chat' }],
36
- post: [
37
- ...v1.post,
47
+ get: merge(v1.get, [{ name: 'chat', path: '/chat' }]),
48
+ post: merge(v1.post, [
38
49
  {
39
50
  name: 'chat',
40
51
  children: {
@@ -51,24 +62,28 @@ const v2 = {
51
62
  list: '/tic-tac-toe/list',
52
63
  } as Record<string, string>,
53
64
  },
54
- { name: 'token', path: '/token' },
55
- ],
65
+ ]),
56
66
  };
57
67
 
58
68
  const v3 = {
59
- get: [
60
- ...v2.get,
69
+ get: merge(v2.get, [
61
70
  { name: 'levenshtein', path: '/levenshtein?str1={string}&str2={string}' },
62
71
  {
63
72
  name: 'time',
64
73
  path: '/time(?type={type}&start={timestamp}&end={timestamp}&format={format}&timezone={timezone})',
65
74
  },
66
- ],
67
- post: [...v2.post, { name: 'hyperplanning', path: '/hyperplanning' }],
75
+ ]),
76
+ post: merge(v2.post, [{ name: 'hyperplanning', path: '/hyperplanning' }]),
68
77
  };
69
78
 
70
79
  const v4 = {
71
- get: [...v3.get],
80
+ get: merge(v3.get, [
81
+ { name: 'dice', path: '/dice?roll={NdX+M}' },
82
+ { name: 'encode', path: '/encode?method={method}&text={text}(&shift={shift})' },
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}' },
86
+ ]),
72
87
  post: [...v3.post],
73
88
  };
74
89
 
package/src/constants.ts CHANGED
@@ -1,3 +1,6 @@
1
+ import pkg from '../package.json' with { type: 'json' };
2
+
3
+ export const APP_VERSION = pkg.version;
1
4
  export const MAX_LOG_ENTRIES = 1000;
2
5
  export const RATE_LIMIT_WINDOW = 10_000;
3
6
  export const RATE_LIMIT_MAX = 50;
@@ -26,3 +29,19 @@ export const STATUS_MESSAGES: Record<number, string> = {
26
29
  };
27
30
 
28
31
  export const START_TIME = Date.now();
32
+
33
+ export const ROMAN_VALUES: [number, string][] = [
34
+ [1000, 'M'],
35
+ [900, 'CM'],
36
+ [500, 'D'],
37
+ [400, 'CD'],
38
+ [100, 'C'],
39
+ [90, 'XC'],
40
+ [50, 'L'],
41
+ [40, 'XL'],
42
+ [10, 'X'],
43
+ [9, 'IX'],
44
+ [5, 'V'],
45
+ [4, 'IV'],
46
+ [1, 'I'],
47
+ ];
@@ -1,4 +1,4 @@
1
- import { MAX_FACTORIAL, MAX_GCD_VALUE, MAX_PRIME_LIST, MAX_STRING_LENGTH } from '../../constants.js';
1
+ import { MAX_FACTORIAL, MAX_GCD_VALUE, MAX_PRIME_LIST, MAX_STRING_LENGTH, ROMAN_VALUES } from '../../constants.js';
2
2
 
3
3
  export function anagram(value: string, value2: string): boolean {
4
4
  if (!value) throw new Error('First value is required');
@@ -153,3 +153,45 @@ export function reverse(value: string): string {
153
153
 
154
154
  return value.split('').reverse().join('');
155
155
  }
156
+
157
+ export function roman(value: string): number | string {
158
+ if (!value) throw new Error('A value is required');
159
+
160
+ const num = Number(value);
161
+ if (!isNaN(num)) {
162
+ if (!Number.isInteger(num)) throw new Error('Value must be an integer');
163
+ if (num < 1 || num > 3999) throw new Error('Number must be between 1 and 3999');
164
+
165
+ let result = '';
166
+ let n = num;
167
+ for (const [val, sym] of ROMAN_VALUES) {
168
+ while (n >= val) {
169
+ result += sym;
170
+ n -= val;
171
+ }
172
+ }
173
+ return result;
174
+ }
175
+
176
+ if (typeof value !== 'string') throw new Error('Value must be a number or a Roman numeral');
177
+ const upper = value.toUpperCase();
178
+ if (!/^[MDCLXVI]+$/.test(upper)) throw new Error('Invalid Roman numeral');
179
+
180
+ let result = 0;
181
+ let i = 0;
182
+ while (i < upper.length) {
183
+ const two = upper.slice(i, i + 2);
184
+ const match = ROMAN_VALUES.find(([, sym]) => sym === two);
185
+ if (match) {
186
+ result += match[0];
187
+ i += 2;
188
+ } else {
189
+ const one = ROMAN_VALUES.find(([, sym]) => sym === upper[i]);
190
+ if (!one) throw new Error('Invalid Roman numeral');
191
+ result += one[0];
192
+ i += 1;
193
+ }
194
+ }
195
+
196
+ return result;
197
+ }
@@ -0,0 +1,39 @@
1
+ export interface DiceResult {
2
+ roll: string;
3
+ count: number;
4
+ sides: number;
5
+ modifier: number;
6
+ results: number[];
7
+ total: number;
8
+ }
9
+
10
+ export default function dice(roll: string): DiceResult {
11
+ if (!roll) throw new Error('A roll notation is required (e.g. 2d6+3)');
12
+ if (typeof roll !== 'string') throw new Error('Roll must be a string');
13
+
14
+ const match = /^(\d*)d(\d+)([+-]\d+)?$/i.exec(roll.trim().replace(/\s+/g, '+'));
15
+ if (!match) throw new Error('Invalid notation. Use NdX or NdX+M (e.g. 2d6+3)');
16
+
17
+ const count = match[1] ? parseInt(match[1], 10) : 1;
18
+ const sides = parseInt(match[2]!, 10);
19
+ const modifier = match[3] ? parseInt(match[3], 10) : 0;
20
+
21
+ if (count < 1 || count > 100) throw new Error('Number of dice must be between 1 and 100');
22
+ if (sides < 2 || sides > 1000) throw new Error('Number of sides must be between 2 and 1000');
23
+
24
+ const results: number[] = [];
25
+ for (let i = 0; i < count; i++) {
26
+ results.push(1 + Math.floor(Math.random() * sides));
27
+ }
28
+
29
+ const total = results.reduce((a, b) => a + b, 0) + modifier;
30
+
31
+ return {
32
+ roll: `${count}d${sides}${modifier ? (modifier > 0 ? `+${modifier}` : modifier) : ''}`,
33
+ count,
34
+ sides,
35
+ modifier,
36
+ results,
37
+ total,
38
+ };
39
+ }
@@ -0,0 +1,170 @@
1
+ import { MAX_STRING_LENGTH } from '../../constants.js';
2
+
3
+ const MORSE_TABLE: Record<string, string> = {
4
+ A: '.-',
5
+ B: '-...',
6
+ C: '-.-.',
7
+ D: '-..',
8
+ E: '.',
9
+ F: '..-.',
10
+ G: '--.',
11
+ H: '....',
12
+ I: '..',
13
+ J: '.---',
14
+ K: '-.-',
15
+ L: '.-..',
16
+ M: '--',
17
+ N: '-.',
18
+ O: '---',
19
+ P: '.--.',
20
+ Q: '--.-',
21
+ R: '.-.',
22
+ S: '...',
23
+ T: '-',
24
+ U: '..-',
25
+ V: '...-',
26
+ W: '.--',
27
+ X: '-..-',
28
+ Y: '-.--',
29
+ Z: '--..',
30
+ '0': '-----',
31
+ '1': '.----',
32
+ '2': '..---',
33
+ '3': '...--',
34
+ '4': '....-',
35
+ '5': '.....',
36
+ '6': '-....',
37
+ '7': '--...',
38
+ '8': '---..',
39
+ '9': '----.',
40
+ '.': '.-.-.-',
41
+ ',': '--..--',
42
+ '?': '..--..',
43
+ "'": '.----.',
44
+ '!': '-.-.--',
45
+ '/': '-..-.',
46
+ '(': '-.--.',
47
+ ')': '-.--.-',
48
+ '&': '.-...',
49
+ ':': '---...',
50
+ ';': '-.-.-.',
51
+ '=': '-...-',
52
+ '+': '.-.-.',
53
+ '-': '-....-',
54
+ _: '..--.-',
55
+ '"': '.-..-.',
56
+ $: '...-..-',
57
+ '@': '.--.-.',
58
+ };
59
+
60
+ const MORSE_REVERSE: Record<string, string> = Object.fromEntries(Object.entries(MORSE_TABLE).map(([k, v]) => [v, k]));
61
+
62
+ function checkText(value: string): void {
63
+ if (!value) throw new Error('A value is required');
64
+ if (typeof value !== 'string') throw new Error('Value must be a string');
65
+ if (value.length > MAX_STRING_LENGTH)
66
+ throw new Error(`Value must be less than ${MAX_STRING_LENGTH} characters long`);
67
+ }
68
+
69
+ export function base64encode(value: string): string {
70
+ checkText(value);
71
+ return Buffer.from(value, 'utf-8').toString('base64');
72
+ }
73
+
74
+ export function base64decode(value: string): string {
75
+ checkText(value);
76
+ if (!/^[A-Za-z0-9+/]*={0,2}$/.test(value)) throw new Error('Invalid Base64 string');
77
+ return Buffer.from(value, 'base64').toString('utf-8');
78
+ }
79
+
80
+ export function urlencode(value: string): string {
81
+ checkText(value);
82
+ return encodeURIComponent(value);
83
+ }
84
+
85
+ export function urldecode(value: string): string {
86
+ checkText(value);
87
+ try {
88
+ return decodeURIComponent(value);
89
+ } catch {
90
+ throw new Error('Invalid URL-encoded string');
91
+ }
92
+ }
93
+
94
+ export function morse(value: string): string {
95
+ checkText(value);
96
+ return value
97
+ .toUpperCase()
98
+ .split(' ')
99
+ .map((word) =>
100
+ word
101
+ .split('')
102
+ .map((c) => {
103
+ if (c === '\n' || c === '\t') return '';
104
+ const code = MORSE_TABLE[c];
105
+ if (!code) throw new Error(`Unsupported character in Morse code: '${c}'`);
106
+ return code;
107
+ })
108
+ .filter(Boolean)
109
+ .join(' '),
110
+ )
111
+ .join(' / ');
112
+ }
113
+
114
+ export function unmorse(value: string): string {
115
+ checkText(value);
116
+ return value
117
+ .split(' / ')
118
+ .map((word) =>
119
+ word
120
+ .split(' ')
121
+ .map((code) => {
122
+ if (!code) return '';
123
+ const char = MORSE_REVERSE[code];
124
+ if (!char) throw new Error(`Unsupported Morse sequence: '${code}'`);
125
+ return char;
126
+ })
127
+ .join(''),
128
+ )
129
+ .join(' ');
130
+ }
131
+
132
+ export function rot13(value: string): string {
133
+ checkText(value);
134
+ return value.replace(/[a-zA-Z]/g, (c) => {
135
+ const base = c <= 'Z' ? 65 : 97;
136
+ return String.fromCharCode(((c.charCodeAt(0) - base + 13) % 26) + base);
137
+ });
138
+ }
139
+
140
+ export function caesar(value: string, shift: string): string {
141
+ checkText(value);
142
+ const n = Number(shift);
143
+ if (isNaN(n)) throw new Error('Shift must be a number');
144
+ const s = ((n % 26) + 26) % 26;
145
+ return value.replace(/[a-zA-Z]/g, (c) => {
146
+ const base = c <= 'Z' ? 65 : 97;
147
+ return String.fromCharCode(((c.charCodeAt(0) - base + s) % 26) + base);
148
+ });
149
+ }
150
+
151
+ export function binary(value: string): string {
152
+ checkText(value);
153
+ return value
154
+ .split('')
155
+ .map((c) => c.charCodeAt(0).toString(2).padStart(8, '0'))
156
+ .join(' ');
157
+ }
158
+
159
+ export function unbinary(value: string): string {
160
+ checkText(value);
161
+ if (!/^[01\s]+$/.test(value)) throw new Error('Invalid binary string');
162
+ return value
163
+ .trim()
164
+ .split(/\s+/)
165
+ .map((b) => {
166
+ if (b.length !== 8) throw new Error('Each binary group must contain 8 bits');
167
+ return String.fromCharCode(parseInt(b, 2));
168
+ })
169
+ .join('');
170
+ }
@@ -0,0 +1,55 @@
1
+ import { MAX_STRING_LENGTH } from '../../constants.js';
2
+
3
+ export interface StatisticsResult {
4
+ count: number;
5
+ sum: number;
6
+ min: number;
7
+ max: number;
8
+ range: number;
9
+ mean: number;
10
+ median: number;
11
+ mode: number[];
12
+ variance: number;
13
+ stddev: number;
14
+ }
15
+
16
+ export default function statistics(values: string): StatisticsResult {
17
+ if (!values) throw new Error('A list of values is required');
18
+ if (typeof values !== 'string') throw new Error('Values must be a comma-separated string');
19
+
20
+ const arr = values.split(',').map((v) => Number(v.trim()));
21
+
22
+ if (arr.some(isNaN)) throw new Error('Values must contain only numbers');
23
+ if (arr.length < 1) throw new Error('At least one value is required');
24
+ if (arr.length > MAX_STRING_LENGTH) throw new Error(`Cannot process more than ${MAX_STRING_LENGTH} values`);
25
+
26
+ const count = arr.length;
27
+ const sum = arr.reduce((a, b) => a + b, 0);
28
+ const min = Math.min(...arr);
29
+ const max = Math.max(...arr);
30
+ const mean = sum / count;
31
+
32
+ const sorted = [...arr].sort((a, b) => a - b);
33
+ const median = count % 2 === 0 ? (sorted[count / 2 - 1]! + sorted[count / 2]!) / 2 : sorted[Math.floor(count / 2)]!;
34
+
35
+ const counts = new Map<number, number>();
36
+ for (const n of arr) counts.set(n, (counts.get(n) ?? 0) + 1);
37
+ const maxCount = Math.max(...counts.values());
38
+ const mode = maxCount === 1 ? [] : [...counts.entries()].filter(([, c]) => c === maxCount).map(([n]) => n);
39
+
40
+ const variance = arr.reduce((acc, n) => acc + (n - mean) ** 2, 0) / count;
41
+ const stddev = Math.sqrt(variance);
42
+
43
+ return {
44
+ count,
45
+ sum: +sum.toFixed(6),
46
+ min,
47
+ max,
48
+ range: max - min,
49
+ mean: +mean.toFixed(6),
50
+ median: +median.toFixed(6),
51
+ mode,
52
+ variance: +variance.toFixed(6),
53
+ stddev: +stddev.toFixed(6),
54
+ };
55
+ }