@rapidd/core 2.1.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.
Files changed (59) hide show
  1. package/.dockerignore +71 -0
  2. package/.env.example +70 -0
  3. package/.gitignore +11 -0
  4. package/LICENSE +15 -0
  5. package/README.md +231 -0
  6. package/bin/cli.js +145 -0
  7. package/config/app.json +166 -0
  8. package/config/rate-limit.json +12 -0
  9. package/dist/main.js +26 -0
  10. package/dockerfile +57 -0
  11. package/locales/ar_SA.json +179 -0
  12. package/locales/de_DE.json +179 -0
  13. package/locales/en_US.json +180 -0
  14. package/locales/es_ES.json +179 -0
  15. package/locales/fr_FR.json +179 -0
  16. package/locales/it_IT.json +179 -0
  17. package/locales/ja_JP.json +179 -0
  18. package/locales/pt_BR.json +179 -0
  19. package/locales/ru_RU.json +179 -0
  20. package/locales/tr_TR.json +179 -0
  21. package/main.ts +25 -0
  22. package/package.json +126 -0
  23. package/prisma/schema.prisma +9 -0
  24. package/prisma.config.ts +12 -0
  25. package/public/static/favicon.ico +0 -0
  26. package/public/static/image/logo.png +0 -0
  27. package/routes/api/v1/index.ts +113 -0
  28. package/src/app.ts +197 -0
  29. package/src/auth/Auth.ts +446 -0
  30. package/src/auth/stores/ISessionStore.ts +19 -0
  31. package/src/auth/stores/MemoryStore.ts +70 -0
  32. package/src/auth/stores/RedisStore.ts +92 -0
  33. package/src/auth/stores/index.ts +149 -0
  34. package/src/config/acl.ts +9 -0
  35. package/src/config/rls.ts +38 -0
  36. package/src/core/dmmf.ts +226 -0
  37. package/src/core/env.ts +183 -0
  38. package/src/core/errors.ts +87 -0
  39. package/src/core/i18n.ts +144 -0
  40. package/src/core/middleware.ts +123 -0
  41. package/src/core/prisma.ts +236 -0
  42. package/src/index.ts +112 -0
  43. package/src/middleware/model.ts +61 -0
  44. package/src/orm/Model.ts +881 -0
  45. package/src/orm/QueryBuilder.ts +2078 -0
  46. package/src/plugins/auth.ts +162 -0
  47. package/src/plugins/language.ts +79 -0
  48. package/src/plugins/rateLimit.ts +210 -0
  49. package/src/plugins/response.ts +80 -0
  50. package/src/plugins/rls.ts +51 -0
  51. package/src/plugins/security.ts +23 -0
  52. package/src/plugins/upload.ts +299 -0
  53. package/src/types.ts +308 -0
  54. package/src/utils/ApiClient.ts +526 -0
  55. package/src/utils/Mailer.ts +348 -0
  56. package/src/utils/index.ts +25 -0
  57. package/templates/email/example.ejs +17 -0
  58. package/templates/layouts/email.ejs +35 -0
  59. package/tsconfig.json +33 -0
@@ -0,0 +1,526 @@
1
+ import path from 'path';
2
+
3
+ // ── Types ────────────────────────────────────────────────────────────────────
4
+
5
+ export interface ServiceConfig {
6
+ hostname: string;
7
+ path?: string;
8
+ port?: number;
9
+ secure?: boolean;
10
+ headers?: Record<string, string>;
11
+ queries?: Record<string, string>;
12
+ authorization?: AuthConfig;
13
+ endpoints?: Record<string, EndpointConfig>;
14
+ }
15
+
16
+ export interface EndpointConfig {
17
+ path: string;
18
+ method?: string;
19
+ headers?: Record<string, string>;
20
+ queries?: Record<string, string>;
21
+ }
22
+
23
+ export interface AuthConfig {
24
+ type: 'basic' | 'bearer' | 'api-key' | 'oauth2';
25
+ 'auth-header'?: string;
26
+ // Basic
27
+ username?: string;
28
+ password?: string;
29
+ // Bearer / X-Auth
30
+ token?: string;
31
+ // API Key
32
+ key?: string;
33
+ 'key-header'?: string;
34
+ // OAuth2
35
+ hostname?: string;
36
+ token_path?: string;
37
+ client_id?: string;
38
+ client_secret?: string;
39
+ grant_type?: string;
40
+ scope?: string;
41
+ secure?: boolean;
42
+ params?: Record<string, string>;
43
+ }
44
+
45
+ export interface RequestOptions {
46
+ params?: Record<string, string>;
47
+ queries?: Record<string, unknown>;
48
+ headers?: Record<string, string>;
49
+ body?: unknown;
50
+ timeout?: number;
51
+ signal?: AbortSignal;
52
+ }
53
+
54
+ export interface ApiResponse<T = unknown> {
55
+ ok: boolean;
56
+ status: number;
57
+ data: T;
58
+ headers: Headers;
59
+ }
60
+
61
+ export class ApiClientError extends Error {
62
+ status: number;
63
+ response: unknown;
64
+ url: string;
65
+
66
+ constructor(message: string, status: number, response: unknown, url: string) {
67
+ super(message);
68
+ this.name = 'ApiClientError';
69
+ this.status = status;
70
+ this.response = response;
71
+ this.url = url;
72
+ }
73
+ }
74
+
75
+ // ── Configuration ────────────────────────────────────────────────────────────
76
+
77
+ const REQUEST_TIMEOUT = parseInt(process.env.REQUEST_TIMEOUT || '10000', 10);
78
+ const MAX_RETRIES = parseInt(process.env.API_MAX_RETRIES || '2', 10);
79
+
80
+ let _config: { services?: Record<string, ServiceConfig> } = { services: {} };
81
+ let _configLoaded = false;
82
+ const _tokenCache = new Map<string, { access_token: string; token_type: string; expires: number }>();
83
+
84
+ function getConfig(): { services?: Record<string, ServiceConfig> } {
85
+ if (!_configLoaded) {
86
+ try {
87
+ _config = require(path.join(process.cwd(), 'config', 'app.json'));
88
+ } catch {
89
+ _config = { services: {} };
90
+ }
91
+ _configLoaded = true;
92
+ }
93
+ return _config;
94
+ }
95
+
96
+ function getService(name: string): ServiceConfig {
97
+ const config = getConfig();
98
+ const service = config.services?.[name];
99
+ if (!service) {
100
+ const available = Object.keys(config.services || {}).join(', ') || 'none';
101
+ throw new Error(`Service '${name}' not found. Available: ${available}`);
102
+ }
103
+ return service;
104
+ }
105
+
106
+ // ── Helpers ──────────────────────────────────────────────────────────────────
107
+
108
+ function resolvePath(template: string, params: Record<string, string> = {}): string {
109
+ return template.replace(/\{\{(.+?)\}\}/g, (_, key) => {
110
+ const trimmed = key.trim();
111
+ if (!(trimmed in params)) {
112
+ throw new Error(`Missing path parameter: ${trimmed}`);
113
+ }
114
+ return encodeURIComponent(params[trimmed]);
115
+ });
116
+ }
117
+
118
+ function buildUrl(
119
+ service: ServiceConfig,
120
+ endpointPath: string,
121
+ queries: Record<string, unknown> = {}
122
+ ): string {
123
+ const protocol = service.secure !== false ? 'https' : 'http';
124
+ const port = service.port ? `:${service.port}` : '';
125
+ const basePath = service.path || '';
126
+
127
+ const url = new URL(`${protocol}://${service.hostname}${port}${basePath}${endpointPath}`);
128
+
129
+ const allQueries = { ...(service.queries || {}), ...queries };
130
+ for (const [key, value] of Object.entries(allQueries)) {
131
+ if (value !== undefined && value !== null) {
132
+ if (Array.isArray(value)) {
133
+ value.forEach((v, i) => url.searchParams.append(`${key}[${i}]`, String(v)));
134
+ } else if (typeof value === 'object') {
135
+ for (const [k, v] of Object.entries(value)) {
136
+ url.searchParams.append(`${key}[${k}]`, String(v));
137
+ }
138
+ } else {
139
+ url.searchParams.append(key, String(value));
140
+ }
141
+ }
142
+ }
143
+
144
+ return url.toString();
145
+ }
146
+
147
+ async function getOAuthToken(serviceName: string, auth: AuthConfig): Promise<{ access_token: string; token_type: string }> {
148
+ const cached = _tokenCache.get(serviceName);
149
+ if (cached && cached.expires > Date.now() + 60000) {
150
+ return cached;
151
+ }
152
+
153
+ const tokenUrl = `${auth.secure !== false ? 'https' : 'http'}://${auth.hostname}${auth.token_path}`;
154
+
155
+ const body = new URLSearchParams({
156
+ grant_type: auth.grant_type || 'client_credentials',
157
+ client_id: auth.client_id!,
158
+ client_secret: auth.client_secret!,
159
+ ...(auth.scope && { scope: auth.scope }),
160
+ ...(auth.params || {})
161
+ });
162
+
163
+ const response = await fetch(tokenUrl, {
164
+ method: 'POST',
165
+ headers: {
166
+ 'Content-Type': 'application/x-www-form-urlencoded',
167
+ 'Accept': 'application/json'
168
+ },
169
+ body
170
+ });
171
+
172
+ if (!response.ok) {
173
+ const text = await response.text();
174
+ throw new Error(`OAuth2 token request failed (${response.status}): ${text}`);
175
+ }
176
+
177
+ const tokenData = await response.json() as { access_token: string; token_type?: string; expires_in?: number };
178
+
179
+ if (!tokenData.access_token) {
180
+ throw new Error('Invalid OAuth2 response: missing access_token');
181
+ }
182
+
183
+ const expiresIn = tokenData.expires_in || 3600;
184
+ const token = {
185
+ access_token: tokenData.access_token,
186
+ token_type: tokenData.token_type || 'Bearer',
187
+ expires: Date.now() + (expiresIn * 900) // 90% of actual expiry
188
+ };
189
+
190
+ _tokenCache.set(serviceName, token);
191
+ return token;
192
+ }
193
+
194
+ async function applyAuth(
195
+ headers: Record<string, string>,
196
+ serviceName: string,
197
+ auth: AuthConfig
198
+ ): Promise<void> {
199
+ const authHeader = auth['auth-header'] || 'Authorization';
200
+
201
+ switch (auth.type) {
202
+ case 'basic': {
203
+ if (!auth.username || !auth.password) {
204
+ throw new Error('Basic auth requires username and password');
205
+ }
206
+ const credentials = Buffer.from(`${auth.username}:${auth.password}`).toString('base64');
207
+ headers[authHeader] = `Basic ${credentials}`;
208
+ break;
209
+ }
210
+ case 'bearer': {
211
+ if (!auth.token) throw new Error('Bearer auth requires token');
212
+ headers[authHeader] = `Bearer ${auth.token}`;
213
+ break;
214
+ }
215
+ case 'api-key': {
216
+ if (!auth.key) throw new Error('API key auth requires key');
217
+ headers[auth['key-header'] || 'X-API-Key'] = auth.key;
218
+ break;
219
+ }
220
+ case 'oauth2': {
221
+ const token = await getOAuthToken(serviceName, auth);
222
+ headers[authHeader] = `${token.token_type} ${token.access_token}`;
223
+ break;
224
+ }
225
+ }
226
+ }
227
+
228
+ async function parseResponse(response: Response): Promise<unknown> {
229
+ const contentType = response.headers.get('content-type') || '';
230
+
231
+ if (contentType.includes('application/json')) {
232
+ return response.json();
233
+ }
234
+ if (contentType.includes('text/')) {
235
+ return response.text();
236
+ }
237
+ return response.arrayBuffer();
238
+ }
239
+
240
+ // ── Main API ─────────────────────────────────────────────────────────────────
241
+
242
+ /**
243
+ * Lightweight, config-driven API client.
244
+ *
245
+ * @example
246
+ * // Using predefined service/endpoint from config/app.json
247
+ * const user = await ApiClient.call('GoogleAPI', 'getUserInfo', {
248
+ * headers: { Authorization: `Bearer ${token}` }
249
+ * });
250
+ *
251
+ * @example
252
+ * // Ad-hoc request
253
+ * const data = await ApiClient.fetch('https://api.example.com/users', {
254
+ * method: 'POST',
255
+ * body: { name: 'John' }
256
+ * });
257
+ *
258
+ * @example
259
+ * // Fluent builder
260
+ * const response = await ApiClient.to('https://api.example.com')
261
+ * .bearer(token)
262
+ * .post('/users', { name: 'John' });
263
+ */
264
+ export const ApiClient = {
265
+ /**
266
+ * Call a predefined service endpoint from config/app.json
267
+ */
268
+ async call<T = unknown>(
269
+ serviceName: string,
270
+ endpointName: string,
271
+ options: RequestOptions = {}
272
+ ): Promise<T> {
273
+ const service = getService(serviceName);
274
+ const endpoint = service.endpoints?.[endpointName];
275
+
276
+ if (!endpoint) {
277
+ const available = Object.keys(service.endpoints || {}).join(', ') || 'none';
278
+ throw new Error(`Endpoint '${endpointName}' not found in '${serviceName}'. Available: ${available}`);
279
+ }
280
+
281
+ const resolvedPath = resolvePath(endpoint.path, options.params);
282
+ const url = buildUrl(service, resolvedPath, {
283
+ ...(endpoint.queries || {}),
284
+ ...(options.queries || {})
285
+ });
286
+
287
+ const headers: Record<string, string> = {
288
+ 'Content-Type': 'application/json',
289
+ 'Accept': 'application/json',
290
+ ...(service.headers || {}),
291
+ ...(endpoint.headers || {}),
292
+ ...(options.headers || {})
293
+ };
294
+
295
+ if (service.authorization) {
296
+ await applyAuth(headers, serviceName, service.authorization);
297
+ }
298
+
299
+ return this.fetch<T>(url, {
300
+ method: endpoint.method || 'GET',
301
+ headers,
302
+ body: options.body,
303
+ timeout: options.timeout,
304
+ signal: options.signal
305
+ });
306
+ },
307
+
308
+ /**
309
+ * Make an ad-hoc fetch request with retries and error handling
310
+ */
311
+ async fetch<T = unknown>(
312
+ url: string,
313
+ options: {
314
+ method?: string;
315
+ headers?: Record<string, string>;
316
+ body?: unknown;
317
+ timeout?: number;
318
+ signal?: AbortSignal;
319
+ retries?: number;
320
+ } = {}
321
+ ): Promise<T> {
322
+ const { method = 'GET', headers = {}, body, timeout = REQUEST_TIMEOUT, retries = MAX_RETRIES } = options;
323
+
324
+ const fetchOptions: RequestInit = {
325
+ method,
326
+ headers: {
327
+ 'Content-Type': 'application/json',
328
+ 'Accept': 'application/json',
329
+ ...headers
330
+ },
331
+ signal: options.signal
332
+ };
333
+
334
+ if (body !== undefined && !['GET', 'HEAD'].includes(method)) {
335
+ fetchOptions.body = typeof body === 'string' ? body : JSON.stringify(body);
336
+ }
337
+
338
+ let lastError: Error | null = null;
339
+
340
+ for (let attempt = 0; attempt <= retries; attempt++) {
341
+ const controller = new AbortController();
342
+ const timeoutId = setTimeout(() => controller.abort(), timeout);
343
+
344
+ if (!options.signal) {
345
+ fetchOptions.signal = controller.signal;
346
+ }
347
+
348
+ try {
349
+ const response = await fetch(url, fetchOptions);
350
+ clearTimeout(timeoutId);
351
+
352
+ const data = await parseResponse(response);
353
+
354
+ if (!response.ok) {
355
+ throw new ApiClientError(
356
+ `HTTP ${response.status}`,
357
+ response.status,
358
+ data,
359
+ url
360
+ );
361
+ }
362
+
363
+ return data as T;
364
+ } catch (error) {
365
+ clearTimeout(timeoutId);
366
+ lastError = error as Error;
367
+
368
+ // Don't retry client errors (4xx)
369
+ if (error instanceof ApiClientError && error.status >= 400 && error.status < 500) {
370
+ throw error;
371
+ }
372
+
373
+ // Retry with exponential backoff
374
+ if (attempt < retries) {
375
+ await new Promise(r => setTimeout(r, Math.min(1000 * Math.pow(2, attempt), 5000)));
376
+ }
377
+ }
378
+ }
379
+
380
+ throw lastError;
381
+ },
382
+
383
+ /**
384
+ * Create a fluent request builder for ad-hoc requests
385
+ */
386
+ to(baseUrl: string) {
387
+ return new RequestBuilder(baseUrl);
388
+ },
389
+
390
+ /**
391
+ * Clear OAuth token cache (useful for testing or token revocation)
392
+ */
393
+ clearTokenCache(serviceName?: string): void {
394
+ if (serviceName) {
395
+ _tokenCache.delete(serviceName);
396
+ } else {
397
+ _tokenCache.clear();
398
+ }
399
+ },
400
+
401
+ /**
402
+ * List available services from config
403
+ */
404
+ listServices(): string[] {
405
+ return Object.keys(getConfig().services || {});
406
+ },
407
+
408
+ /**
409
+ * List endpoints for a service
410
+ */
411
+ listEndpoints(serviceName: string): string[] {
412
+ const service = getService(serviceName);
413
+ return Object.keys(service.endpoints || {});
414
+ }
415
+ };
416
+
417
+ // ── Fluent Builder ───────────────────────────────────────────────────────────
418
+
419
+ class RequestBuilder {
420
+ private _baseUrl: string;
421
+ private _headers: Record<string, string> = {};
422
+ private _timeout: number = REQUEST_TIMEOUT;
423
+ private _retries: number = MAX_RETRIES;
424
+
425
+ constructor(baseUrl: string) {
426
+ this._baseUrl = baseUrl.replace(/\/$/, '');
427
+ }
428
+
429
+ header(key: string, value: string): this {
430
+ this._headers[key] = value;
431
+ return this;
432
+ }
433
+
434
+ headers(headers: Record<string, string>): this {
435
+ Object.assign(this._headers, headers);
436
+ return this;
437
+ }
438
+
439
+ bearer(token: string): this {
440
+ this._headers['Authorization'] = `Bearer ${token}`;
441
+ return this;
442
+ }
443
+
444
+ basic(username: string, password: string): this {
445
+ const credentials = Buffer.from(`${username}:${password}`).toString('base64');
446
+ this._headers['Authorization'] = `Basic ${credentials}`;
447
+ return this;
448
+ }
449
+
450
+ apiKey(key: string, header = 'X-API-Key'): this {
451
+ this._headers[header] = key;
452
+ return this;
453
+ }
454
+
455
+ timeout(ms: number): this {
456
+ this._timeout = ms;
457
+ return this;
458
+ }
459
+
460
+ retries(count: number): this {
461
+ this._retries = count;
462
+ return this;
463
+ }
464
+
465
+ private _url(path: string, queries?: Record<string, unknown>): string {
466
+ const url = new URL(path.startsWith('/') ? `${this._baseUrl}${path}` : path);
467
+ if (queries) {
468
+ for (const [key, value] of Object.entries(queries)) {
469
+ if (value !== undefined && value !== null) {
470
+ url.searchParams.append(key, String(value));
471
+ }
472
+ }
473
+ }
474
+ return url.toString();
475
+ }
476
+
477
+ async get<T = unknown>(path: string, queries?: Record<string, unknown>): Promise<T> {
478
+ return ApiClient.fetch<T>(this._url(path, queries), {
479
+ method: 'GET',
480
+ headers: this._headers,
481
+ timeout: this._timeout,
482
+ retries: this._retries
483
+ });
484
+ }
485
+
486
+ async post<T = unknown>(path: string, body?: unknown): Promise<T> {
487
+ return ApiClient.fetch<T>(this._url(path), {
488
+ method: 'POST',
489
+ headers: this._headers,
490
+ body,
491
+ timeout: this._timeout,
492
+ retries: this._retries
493
+ });
494
+ }
495
+
496
+ async put<T = unknown>(path: string, body?: unknown): Promise<T> {
497
+ return ApiClient.fetch<T>(this._url(path), {
498
+ method: 'PUT',
499
+ headers: this._headers,
500
+ body,
501
+ timeout: this._timeout,
502
+ retries: this._retries
503
+ });
504
+ }
505
+
506
+ async patch<T = unknown>(path: string, body?: unknown): Promise<T> {
507
+ return ApiClient.fetch<T>(this._url(path), {
508
+ method: 'PATCH',
509
+ headers: this._headers,
510
+ body,
511
+ timeout: this._timeout,
512
+ retries: this._retries
513
+ });
514
+ }
515
+
516
+ async delete<T = unknown>(path: string): Promise<T> {
517
+ return ApiClient.fetch<T>(this._url(path), {
518
+ method: 'DELETE',
519
+ headers: this._headers,
520
+ timeout: this._timeout,
521
+ retries: this._retries
522
+ });
523
+ }
524
+ }
525
+
526
+ export default ApiClient;