klaim 1.8.87 → 1.9.1
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 +163 -153
- package/deno.json +1 -1
- package/dist/klaim.cjs +1 -1
- package/dist/klaim.es.js +123 -75
- package/dist/klaim.umd.js +1 -1
- package/package.json +4 -4
- package/src/core/Element.ts +24 -0
- package/src/core/Klaim.ts +29 -6
- package/src/tools/rateLimit.ts +88 -0
- package/tests/10.ratelimit.test.ts +104 -0
package/README.md
CHANGED
|
@@ -6,19 +6,20 @@
|
|
|
6
6
|
- [Next features](#-next-features)
|
|
7
7
|
- [Installation](#-installation)
|
|
8
8
|
- [Usage](#-usage)
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
9
|
+
- [Basic API Configuration](#basic-api-configuration)
|
|
10
|
+
- [Route Definition](#route-definition)
|
|
11
|
+
- [Request Handling](#request-handling)
|
|
12
|
+
- [Groups](#groups)
|
|
13
|
+
- [API Groups](#api-groups)
|
|
14
|
+
- [Route Groups](#route-groups)
|
|
15
|
+
- [Nested Groups](#nested-groups)
|
|
16
|
+
- [Group Configuration](#group-configuration)
|
|
17
|
+
- [Middleware Usage](#middleware-usage)
|
|
18
|
+
- [Hook Subscription](#hook-subscription)
|
|
19
|
+
- [Caching Requests](#caching-requests)
|
|
20
|
+
- [Retry Mechanism](#retry-mechanism)
|
|
21
|
+
- [Rate Limiting](#rate-limiting)
|
|
22
|
+
- [Response Validation](#response-validation)
|
|
22
23
|
- [Links](#-links)
|
|
23
24
|
- [Contributing](#-contributing)
|
|
24
25
|
- [License](#-license)
|
|
@@ -35,13 +36,13 @@
|
|
|
35
36
|
- **Hook System**: Subscribe to hooks to monitor and react to specific events.
|
|
36
37
|
- **Caching**: Enable caching on requests to reduce network load and improve performance.
|
|
37
38
|
- **Retry Mechanism**: Automatically retry failed requests to enhance reliability.
|
|
39
|
+
- **Rate Limiting**: Control the frequency of API calls to prevent abuse and respect API provider limits.
|
|
38
40
|
- **TypeScript Support**: Fully typed for enhanced code quality and developer experience.
|
|
39
41
|
- **Response Validation**: Validate responses using schemas for increased reliability and consistency.
|
|
40
42
|
- **Pagination**: Handle paginated requests easily with support for both page and offset based pagination.
|
|
41
43
|
|
|
42
44
|
## ⌛ Next features
|
|
43
45
|
|
|
44
|
-
- Rate Limiting (Version: 1.9)
|
|
45
46
|
- Login (Version: 1.10)
|
|
46
47
|
- Time Out (Version: 1.11)
|
|
47
48
|
- Error Handling (Version: 1.12)
|
|
@@ -73,18 +74,18 @@ import {Api, Route} from 'klaim';
|
|
|
73
74
|
|
|
74
75
|
// Your simple Todo type
|
|
75
76
|
type Todo = {
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
77
|
+
userId: number;
|
|
78
|
+
id: number;
|
|
79
|
+
title: string;
|
|
80
|
+
completed: boolean;
|
|
80
81
|
};
|
|
81
82
|
|
|
82
83
|
// Create a new API with the name "hello" and the base URL "https://jsonplaceholder.typicode.com/"
|
|
83
84
|
Api.create("hello", "https://jsonplaceholder.typicode.com/", () => {
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
85
|
+
// Define routes for the API
|
|
86
|
+
Route.get<Todo[]>("listTodos", "todos");
|
|
87
|
+
Route.get<Todo>("getTodo", "todos/[id]");
|
|
88
|
+
Route.post<Todo>("addTodo", "todos");
|
|
88
89
|
});
|
|
89
90
|
```
|
|
90
91
|
|
|
@@ -95,28 +96,28 @@ custom configurations:
|
|
|
95
96
|
|
|
96
97
|
```typescript
|
|
97
98
|
Api.create("api", "https://api.example.com", () => {
|
|
98
|
-
|
|
99
|
-
|
|
99
|
+
// Basic GET route
|
|
100
|
+
Route.get("listUsers", "/users");
|
|
100
101
|
|
|
101
|
-
|
|
102
|
-
|
|
102
|
+
// GET route with URL parameter
|
|
103
|
+
Route.get("getUser", "/users/[id]");
|
|
103
104
|
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
105
|
+
// POST route with custom headers and body
|
|
106
|
+
Route.post("createUser", "/users", {
|
|
107
|
+
"Content-Type": "application/json"
|
|
108
|
+
}, {userId: 1, name: "John Doe"});
|
|
108
109
|
|
|
109
|
-
|
|
110
|
-
|
|
110
|
+
// PUT route with parameter
|
|
111
|
+
Route.put("updateUser", "/users/[id]");
|
|
111
112
|
|
|
112
|
-
|
|
113
|
-
|
|
113
|
+
// DELETE route
|
|
114
|
+
Route.delete("deleteUser", "/users/[id]");
|
|
114
115
|
|
|
115
|
-
|
|
116
|
-
|
|
116
|
+
// PATCH route
|
|
117
|
+
Route.patch("updateUserStatus", "/users/[id]/status");
|
|
117
118
|
|
|
118
|
-
|
|
119
|
-
|
|
119
|
+
// OPTIONS route
|
|
120
|
+
Route.options("userOptions", "/users");
|
|
120
121
|
});
|
|
121
122
|
```
|
|
122
123
|
|
|
@@ -134,17 +135,17 @@ import {Group, Api, Route} from 'klaim';
|
|
|
134
135
|
|
|
135
136
|
// Create a group for user-related services
|
|
136
137
|
Group.create("userServices", () => {
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
138
|
+
// Authentication API
|
|
139
|
+
Api.create("auth", "https://auth.example.com", () => {
|
|
140
|
+
Route.post("login", "/login");
|
|
141
|
+
Route.post("register", "/register");
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
// User Management API
|
|
145
|
+
Api.create("users", "https://users.example.com", () => {
|
|
146
|
+
Route.get("list", "/users");
|
|
147
|
+
Route.get("getOne", "/users/[id]");
|
|
148
|
+
});
|
|
148
149
|
}).withRetry(3); // Apply retry mechanism to all APIs in the group
|
|
149
150
|
|
|
150
151
|
// Access grouped APIs
|
|
@@ -158,18 +159,18 @@ Organize routes within an API into logical groups:
|
|
|
158
159
|
|
|
159
160
|
```typescript
|
|
160
161
|
Api.create("hello", "https://api.example.com/", () => {
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
162
|
+
// Group user-related routes
|
|
163
|
+
Group.create("users", () => {
|
|
164
|
+
Route.get<User[]>("list", "/users");
|
|
165
|
+
Route.get<User>("getOne", "/users/[id]");
|
|
166
|
+
Route.post<User>("create", "/users");
|
|
167
|
+
}).withCache(60); // Cache all user routes for 60 seconds
|
|
168
|
+
|
|
169
|
+
// Group product-related routes
|
|
170
|
+
Group.create("products", () => {
|
|
171
|
+
Route.get("list", "/products");
|
|
172
|
+
Route.get("getOne", "/products/[id]");
|
|
173
|
+
});
|
|
173
174
|
});
|
|
174
175
|
|
|
175
176
|
// Use grouped routes
|
|
@@ -183,27 +184,27 @@ Create complex hierarchies with nested groups:
|
|
|
183
184
|
|
|
184
185
|
```typescript
|
|
185
186
|
Group.create("services", () => {
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
187
|
+
// Internal services group
|
|
188
|
+
Group.create("internal", () => {
|
|
189
|
+
Api.create("logs", "https://logs.internal.example.com", () => {
|
|
190
|
+
Route.post("write", "/logs");
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
Api.create("metrics", "https://metrics.internal.example.com", () => {
|
|
194
|
+
Route.post("track", "/metrics");
|
|
195
|
+
});
|
|
196
|
+
}).withRetry(5); // More retries for internal services
|
|
197
|
+
|
|
198
|
+
// External services group
|
|
199
|
+
Group.create("external", () => {
|
|
200
|
+
Api.create("weather", "https://api.weather.com", () => {
|
|
201
|
+
Route.get("forecast", "/forecast/[city]");
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
Api.create("geocoding", "https://api.geocoding.com", () => {
|
|
205
|
+
Route.get("search", "/search/[query]");
|
|
206
|
+
});
|
|
207
|
+
}).withCache(300); // Cache external services longer
|
|
207
208
|
});
|
|
208
209
|
|
|
209
210
|
// Access nested groups
|
|
@@ -217,22 +218,22 @@ Groups can share configuration among all their members:
|
|
|
217
218
|
|
|
218
219
|
```typescript
|
|
219
220
|
Group.create("apis", () => {
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
221
|
+
Api.create("service1", "https://api1.example.com", () => {
|
|
222
|
+
Route.get("test", "/test");
|
|
223
|
+
});
|
|
223
224
|
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
225
|
+
Api.create("service2", "https://api2.example.com", () => {
|
|
226
|
+
Route.get("test", "/test");
|
|
227
|
+
});
|
|
227
228
|
})
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
229
|
+
.withCache(60) // Enable caching for all APIs
|
|
230
|
+
.withRetry(3) // Enable retries for all APIs
|
|
231
|
+
.before(({config}) => { // Add authentication for all APIs
|
|
232
|
+
config.headers.Authorization = `Bearer ${getToken()}`;
|
|
233
|
+
})
|
|
234
|
+
.after(({data}) => { // Process all responses
|
|
235
|
+
logResponse(data);
|
|
236
|
+
});
|
|
236
237
|
```
|
|
237
238
|
|
|
238
239
|
### Request Handling
|
|
@@ -260,16 +261,16 @@ and `after` middleware to process responses:
|
|
|
260
261
|
|
|
261
262
|
```typescript
|
|
262
263
|
Api.create("hello", "https://jsonplaceholder.typicode.com/", () => {
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
264
|
+
// With before middleware
|
|
265
|
+
Route.get<Todo>("getRandomTodo", "todos")
|
|
266
|
+
.before(({url}) => {
|
|
267
|
+
const random = Math.floor(Math.random() * 10) + 1;
|
|
268
|
+
return {url: `${url}/${random}`};
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
// With after middleware
|
|
272
|
+
Route.get<Todo>("getFirstTodo", "todos")
|
|
273
|
+
.after(({data: [first]}) => ({data: first}));
|
|
273
274
|
});
|
|
274
275
|
```
|
|
275
276
|
|
|
@@ -283,7 +284,7 @@ import {Hook} from 'klaim';
|
|
|
283
284
|
|
|
284
285
|
// Subscribe to the "hello.getFirstTodo" hook
|
|
285
286
|
Hook.subscribe("hello.getFirstTodo", ({url}) => {
|
|
286
|
-
|
|
287
|
+
console.log(`Requesting ${url}`);
|
|
287
288
|
});
|
|
288
289
|
```
|
|
289
290
|
|
|
@@ -298,14 +299,14 @@ You can enable caching on individual routes:
|
|
|
298
299
|
|
|
299
300
|
```typescript
|
|
300
301
|
Api.create("hello", "https://jsonplaceholder.typicode.com/", () => {
|
|
301
|
-
|
|
302
|
-
|
|
302
|
+
// Get a list of todos with default cache duration (20 seconds)
|
|
303
|
+
Route.get<Todo[]>("listTodos", "todos").withCache();
|
|
303
304
|
|
|
304
|
-
|
|
305
|
-
|
|
305
|
+
// Get a specific todo by id with custom cache duration (300 seconds)
|
|
306
|
+
Route.get<Todo>("getTodo", "todos/[id]").withCache(300);
|
|
306
307
|
|
|
307
|
-
|
|
308
|
-
|
|
308
|
+
// Add a new todo (no cache)
|
|
309
|
+
Route.post<Todo>("addTodo", "todos");
|
|
309
310
|
});
|
|
310
311
|
```
|
|
311
312
|
|
|
@@ -317,49 +318,58 @@ You can also enable caching for all routes defined within an API:
|
|
|
317
318
|
|
|
318
319
|
```typescript
|
|
319
320
|
Api.create("hello", "https://jsonplaceholder.typicode.com/", () => {
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
321
|
+
// Define routes for the API
|
|
322
|
+
Route.get<Todo[]>("listTodos", "todos");
|
|
323
|
+
Route.get<Todo>("getTodo", "todos/[id]");
|
|
324
|
+
Route.post<Todo>("addTodo", "todos");
|
|
324
325
|
}).withCache(); // Enable default cache duration (20 seconds) for all routes
|
|
325
326
|
```
|
|
326
327
|
|
|
327
328
|
### Retry Mechanism
|
|
328
329
|
|
|
329
|
-
|
|
330
|
-
routes or for the entire API.
|
|
331
|
-
|
|
332
|
-
#### Retry on Individual Routes
|
|
333
|
-
|
|
334
|
-
Enable retry on individual routes:
|
|
330
|
+
Configure automatic retries for failed requests:
|
|
335
331
|
|
|
336
332
|
```typescript
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
// Get a specific todo by id with retry mechanism (specified to 5)
|
|
342
|
-
Route.get<Todo>("getTodo", "todos/[id]").withRetry(5);
|
|
333
|
+
// Apply retry at the API level
|
|
334
|
+
Api.create("api", "https://api.example.com", () => {
|
|
335
|
+
Route.get("users", "/users");
|
|
336
|
+
}).withRetry(3); // Will retry failed requests up to 3 times
|
|
343
337
|
|
|
344
|
-
|
|
345
|
-
|
|
338
|
+
// Apply retry at the route level
|
|
339
|
+
Api.create("api", "https://api.example.com", () => {
|
|
340
|
+
Route.get("unstableRoute", "/unstable-endpoint").withRetry(5); // Will retry up to 5 times
|
|
346
341
|
});
|
|
347
342
|
```
|
|
348
343
|
|
|
349
|
-
|
|
344
|
+
### Rate Limiting
|
|
350
345
|
|
|
351
|
-
|
|
346
|
+
Control the frequency of API calls to prevent abuse and respect API provider rate limits:
|
|
352
347
|
|
|
353
348
|
```typescript
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
349
|
+
// Apply rate limiting at the API level
|
|
350
|
+
Api.create("api", "https://api.example.com", () => {
|
|
351
|
+
Route.get("users", "/users");
|
|
352
|
+
Route.get("posts", "/posts");
|
|
353
|
+
}).withRate({ limit: 5, duration: 10 }); // Max 5 requests every 10 seconds for this API
|
|
354
|
+
|
|
355
|
+
// Apply rate limiting at the route level
|
|
356
|
+
Api.create("api", "https://api.example.com", () => {
|
|
357
|
+
// This route has its own stricter limits
|
|
358
|
+
Route.get("expensive", "/expensive-operation").withRate({ limit: 2, duration: 60 }); // Max 2 requests per minute
|
|
359
|
+
|
|
360
|
+
// This route uses the default limits (5 per 10 seconds if not specified)
|
|
361
|
+
Route.get("normal", "/normal-operation").withRate();
|
|
362
|
+
});
|
|
361
363
|
|
|
362
|
-
|
|
364
|
+
// Handling rate limit errors
|
|
365
|
+
try {
|
|
366
|
+
await Klaim.api.expensive();
|
|
367
|
+
} catch (error) {
|
|
368
|
+
if (error.message.includes('Rate limit exceeded')) {
|
|
369
|
+
console.log('Please wait before trying again');
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
```
|
|
363
373
|
|
|
364
374
|
### Response Validation
|
|
365
375
|
|
|
@@ -378,15 +388,15 @@ import * as yup from 'yup';
|
|
|
378
388
|
|
|
379
389
|
// Define the schema using Yup
|
|
380
390
|
const todoSchema = yup.object().shape({
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
391
|
+
userId: yup.number().required(),
|
|
392
|
+
id: yup.number().min(1).max(10).required(),
|
|
393
|
+
title: yup.string().required(),
|
|
394
|
+
completed: yup.boolean().required()
|
|
385
395
|
});
|
|
386
396
|
|
|
387
397
|
Api.create("hello", "https://jsonplaceholder.typicode.com/", () => {
|
|
388
|
-
|
|
389
|
-
|
|
398
|
+
// Get a specific todo by id with validation
|
|
399
|
+
Route.get<Todo>("getTodo", "todos/[id]").validate(todoSchema);
|
|
390
400
|
});
|
|
391
401
|
|
|
392
402
|
// This request will fail because the id is out of range
|
|
@@ -403,12 +413,12 @@ Configure pagination for routes that require it:
|
|
|
403
413
|
```typescript
|
|
404
414
|
// Basic usage with custom limit and offset parameter
|
|
405
415
|
Api.create("api", "https://api.example.com", () => {
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
416
|
+
Route.get("list", "/items").withPagination({
|
|
417
|
+
limit: 20, // Items per page
|
|
418
|
+
page: 1, // Default page number
|
|
419
|
+
pageParam: "offset", // Parameter name for page/offset or any other custom parameter
|
|
420
|
+
limitParam: "limit" // Parameter name for limit
|
|
421
|
+
}); // All options are optional
|
|
412
422
|
});
|
|
413
423
|
|
|
414
424
|
// Using paginated endpoints
|
package/deno.json
CHANGED
package/dist/klaim.cjs
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
"use strict";var
|
|
1
|
+
"use strict";var L=Object.defineProperty;var G=(n,t,e)=>t in n?L(n,t,{enumerable:!0,configurable:!0,writable:!0,value:e}):n[t]=e;var o=(n,t,e)=>G(n,typeof t!="symbol"?t+"":t,e);Object.defineProperty(exports,Symbol.toStringTag,{value:"Module"});function T(n){return n.replace(/([-_][a-z])/gi,t=>t.toUpperCase().replace("-","").replace("_","")).replace(/(^\w)/,t=>t.toLowerCase())}function M(n){return n.trim().replace(/^\/|\/$/g,"")}const O={limit:5,duration:10},$=new Map;function v(n,t){const e=Date.now(),a=t.duration*1e3;let r=$.get(n);r||(r={timestamps:[]},$.set(n,r));const i=r.timestamps.filter(s=>e-s<a);return i.length>=t.limit?!1:(i.push(e),r.timestamps=i,!0)}function N(n,t){const e=$.get(n);if(!e||e.timestamps.length<t.limit)return 0;const a=Date.now(),r=t.duration*1e3,l=[...e.timestamps].sort((u,f)=>u-f)[0]+r-a;return Math.max(0,l)}const j={page:1,pageParam:"page",limit:10,limitParam:"limit"};class k{constructor(t,e,a,r={}){o(this,"type");o(this,"name");o(this,"url");o(this,"headers");o(this,"parent");o(this,"method");o(this,"arguments",new Set);o(this,"schema");o(this,"pagination");o(this,"callbacks",{before:null,after:null,call:null});o(this,"cache",!1);o(this,"retry",!1);o(this,"rate",!1);o(this,"withCache",(t=20)=>(this.cache=t,this));o(this,"withRetry",(t=2)=>(this.retry=t,this));o(this,"withRate",(t={})=>(this.rate={...O,...t},this));this.type=t,this.name=T(e),this.name!==e&&console.warn(`Name "${e}" has been camelCased to "${this.name}"`),this.url=M(a),this.headers=r||{}}before(t){return this.callbacks.before=t,this}after(t){return this.callbacks.after=t,this}onCall(t){return this.callbacks.call=t,this}withPagination(t={}){return this.pagination={...j,...t},this}}const d=class d{constructor(){o(this,"cache");this.cache=new Map}static get i(){return d._instance||(d._instance=new d),d._instance}set(t,e,a=0){const r=Date.now()+a;this.cache.set(t,{data:e,expiry:r})}has(t){const e=this.cache.get(t);return e?Date.now()>e.expiry?(this.cache.delete(t),!1):!0:!1}get(t){return this.has(t)?this.cache.get(t).data:null}};o(d,"_instance");let P=d;function H(n){let a=2166136261;for(let i=0;i<n.length;i++)a^=n.charCodeAt(i),a*=16777619;let r=(a>>>0).toString(16).padStart(8,"0");for(;r.length<32;)a^=r.charCodeAt(r.length%r.length),a*=16777619,r+=(a>>>0).toString(16).padStart(8,"0");return r.substring(0,32)}async function W(n,t,e){const a=`${n.toString()}${JSON.stringify(t)}`,r=H(a);if(P.i.has(r))return P.i.get(r);const s=await(await fetch(n,t)).json();return P.i.set(r,s,e),s}class _{static subscribe(t,e){this._callbacks.set(t,e)}static run(t){const e=this._callbacks.get(t);e&&e()}}o(_,"_callbacks",new Map);const A={};function q(n,t){return async(...e)=>{if(t.pagination){const[i=0,s={},l={}]=e;return x(n,t,i,s,l)}const[a={},r={}]=e;return x(n,t,void 0,a,r)}}async function x(n,t,e,a={},r={}){const i=n.split(".");let s;for(let w=0;w<i.length;w++){const C=i[w];if(s=c.i.getApi(C),s)break}if(!t||!s||t.type!=="route"||s.type!=="api")throw new Error(`Invalid path: ${n}.${t.name}`);let l=z(`${s.url}/${t.url}`,t,a);if(t.pagination&&typeof e<"u"){const{pageParam:w="page",limit:C=10,limitParam:D="limit"}=t.pagination,E=new URLSearchParams;E.append(w,String(e)),E.append(D,String(C));const I=l.includes("?")?"&":"?";l=`${l}${I}${E.toString()}`}let u={};r&&t.method!=="GET"&&(u.body=JSON.stringify(r)),u.headers={"Content-Type":"application/json",...s.headers,...t.headers},u.method=t.method;const{beforeRoute:f,beforeApi:y,beforeUrl:h,beforeConfig:b}=B({route:t,api:s,url:l,config:u});l=h,u=b,c.updateElement(y),c.updateElement(f);let g=await V(s,t,l,u);t.schema&&"validate"in t.schema&&(g=await t.schema.validate(g));const{afterRoute:m,afterApi:K,afterData:U}=Q({route:t,api:s,response:g,data:g});return c.updateElement(K),c.updateElement(m),_.run(`${s.name}.${t.name}`),U}async function J(n,t,e,a){return n?await W(t,e,a.cache):await(await fetch(t,e)).json()}async function V(n,t,e,a){var f,y;const r=n.cache||t.cache,i=t.retry||n.retry||0;if(t.rate){const h=`${n.name}.${t.name}`;if(!v(h,t.rate)){const g=N(h,t.rate),m=Math.ceil(g/1e3);throw new Error(`Rate limit exceeded for ${h}. Try again in ${m} seconds.`)}}else if(n.rate){const h=`${n.name}`;if(!v(h,n.rate)){const g=N(h,n.rate),m=Math.ceil(g/1e3);throw new Error(`Rate limit exceeded for ${n.name} API. Try again in ${m} seconds.`)}}let s,l=!1,u=0;for(;u<=i&&!l;)try{(f=t.callbacks)!=null&&f.call?t.callbacks.call({}):(y=n.callbacks)!=null&&y.call&&n.callbacks.call({}),s=await J(!!r,e,a,n),l=!0}catch(h){if(u++,u>i)throw h.message=`Failed to fetch ${e} after ${i} attempts`,h}return s}function z(n,t,e){let a=n;return t.arguments.forEach(r=>{if(e[r]===void 0)throw new Error(`Argument ${r} is missing`);a=a.replace(`[${r}]`,e[r])}),a}function B({route:n,api:t,url:e,config:a}){var i,s;const r=(s=(i=n.callbacks).before)==null?void 0:s.call(i,{route:n,api:t,url:e,config:a});return{beforeRoute:(r==null?void 0:r.route)||n,beforeApi:(r==null?void 0:r.api)||t,beforeUrl:(r==null?void 0:r.url)||e,beforeConfig:(r==null?void 0:r.config)||a}}function Q({route:n,api:t,response:e,data:a}){var i,s;const r=(s=(i=n.callbacks).after)==null?void 0:s.call(i,{route:n,api:t,response:e,data:a});return{afterRoute:(r==null?void 0:r.route)||n,afterApi:(r==null?void 0:r.api)||t,afterResponse:(r==null?void 0:r.response)||e,afterData:(r==null?void 0:r.data)||a}}const p=class p{constructor(){o(this,"_elements",new Map);o(this,"_currentParent",null)}static get i(){return p._instance||(p._instance=new p),p._instance}registerElement(t){const e=this._currentParent;e&&(t.parent=this.getFullPath(e));const a=this.getElementKey(t);if(this._elements.set(a,t),t.type==="api"||t.type==="group"){let r=A;if(e){const i=this.getFullPath(e).split(".");for(const s of i)r[s]||(r[s]={}),r=r[s]}r[t.name]||(r[t.name]={})}}getCurrentParent(){return this._currentParent}setCurrentParent(t){const e=this._elements.get(t);if(!e||e.type!=="api"&&e.type!=="group")throw new Error(`Element ${t} not found or not a valid parent type`);this._currentParent=e}clearCurrentParent(){this._currentParent=null}registerRoute(t){if(!this._currentParent)throw new Error("No current parent set, use Route only inside Api or Group create callback");t.parent=this.getFullPath(this._currentParent);const e=this.getElementKey(t);this._elements.set(e,t),this.addToKlaimRoute(t)}addToKlaimRoute(t){if(!t.parent)return;let e=A;const a=t.parent.split(".");for(const r of a)e[r]||(e[r]={}),e=e[r];e[t.name]=q(t.parent,t)}getElementKey(t){return t?t.parent?`${t.parent}.${t.name}`:t.name:""}getFullPath(t){if(!t)return"";if(!t.parent)return t.name;const e=[t.name];let a=t;for(;a.parent;){const r=this._elements.get(a.parent);if(!r)break;e.unshift(r.name),a=r}return e.join(".")}getRoute(t,e){return this._elements.get(`${t}.${e}`)}getChildren(t){const e=[];return this._elements.forEach(a=>{a.parent===t&&e.push(a)}),e}static updateElement(t){return p.i._elements.get(p.i.getElementKey(t))||t}getApi(t){const e=this._elements.get(t);if(!e){for(const[a,r]of this._elements.entries())if(r.type==="api"&&a.endsWith(`.${t}`))return r;return}return e.type==="api"?e:this.findApi(e)}findApi(t){if(!t||!t.parent)return;const e=t.parent.split(".");for(let a=e.length;a>=0;a--){const r=e.slice(0,a).join("."),i=this._elements.get(r);if((i==null?void 0:i.type)==="api")return i}}};o(p,"_instance");let c=p;class S extends k{static create(t,e,a,r={}){const i=T(t);i!==t&&console.warn(`API name "${t}" has been camelCased to "${i}"`);const s=new S(i,e,r),l=c.i.getCurrentParent();c.i.registerElement(s);const u=l?c.i.getFullPath(l):"",f=u?`${u}.${i}`:i;return c.i.setCurrentParent(f),a(),l?c.i.setCurrentParent(c.i.getFullPath(l)):c.i.clearCurrentParent(),s}constructor(t,e,a={}){super("api",t,e,a)}}class F extends k{static create(t,e){const a=T(t),r=c.i.getCurrentParent(),i=r?c.i.getFullPath(r):"",s=i?`${i}.${a}`:a,l=new F(a,"");a!==t&&console.warn(`Group name "${t}" has been camelCased to "${a}"`),c.i.registerElement(l);const u=c.i.getCurrentParent();return c.i.setCurrentParent(s),e(),u?c.i.setCurrentParent(c.i.getFullPath(u)):c.i.clearCurrentParent(),l}constructor(t,e,a={}){super("group",t,e,a)}withCache(t=20){return super.withCache(t),c.i.getChildren(c.i.getFullPath(this)).forEach(e=>{e.cache||(e.cache=t)}),this}withRetry(t=2){return super.withRetry(t),c.i.getChildren(c.i.getFullPath(this)).forEach(e=>{e.retry||(e.retry=t)}),this}before(t){return super.before(t),c.i.getChildren(c.i.getFullPath(this)).forEach(e=>{e.callbacks.before||(e.callbacks.before=t)}),this}after(t){return super.after(t),c.i.getChildren(c.i.getFullPath(this)).forEach(e=>{e.callbacks.after||(e.callbacks.after=t)}),this}onCall(t){return super.onCall(t),c.i.getChildren(c.i.getFullPath(this)).forEach(e=>{e.callbacks.call||(e.callbacks.call=t)}),this}}class R extends k{constructor(t,e,a={},r="GET"){super("route",t,e,a),this.method=r,this.detectArguments()}static createRoute(t,e,a={},r){const i=new R(t,e,a,r);return c.i.registerRoute(i),i}static get(t,e,a={}){return this.createRoute(t,e,a,"GET")}static post(t,e,a={}){return this.createRoute(t,e,a,"POST")}static put(t,e,a={}){return this.createRoute(t,e,a,"PUT")}static delete(t,e,a={}){return this.createRoute(t,e,a,"DELETE")}static patch(t,e,a={}){return this.createRoute(t,e,a,"PATCH")}static options(t,e,a={}){return this.createRoute(t,e,a,"OPTIONS")}detectArguments(){const t=this.url.match(/\[([^\]]+)]/g);t&&t.forEach(e=>{const a=e.replace("[","").replace("]","");this.arguments.add(a)})}validate(t){return this.schema=t,this}}exports.Api=S;exports.Group=F;exports.Hook=_;exports.Klaim=A;exports.Registry=c;exports.Route=R;
|
package/dist/klaim.es.js
CHANGED
|
@@ -1,19 +1,40 @@
|
|
|
1
|
-
var
|
|
2
|
-
var
|
|
3
|
-
var o = (n, t, e) =>
|
|
4
|
-
function
|
|
1
|
+
var L = Object.defineProperty;
|
|
2
|
+
var G = (n, t, e) => t in n ? L(n, t, { enumerable: !0, configurable: !0, writable: !0, value: e }) : n[t] = e;
|
|
3
|
+
var o = (n, t, e) => G(n, typeof t != "symbol" ? t + "" : t, e);
|
|
4
|
+
function A(n) {
|
|
5
5
|
return n.replace(/([-_][a-z])/gi, (t) => t.toUpperCase().replace("-", "").replace("_", "")).replace(/(^\w)/, (t) => t.toLowerCase());
|
|
6
6
|
}
|
|
7
|
-
function
|
|
7
|
+
function M(n) {
|
|
8
8
|
return n.trim().replace(/^\/|\/$/g, "");
|
|
9
9
|
}
|
|
10
10
|
const O = {
|
|
11
|
+
limit: 5,
|
|
12
|
+
duration: 10
|
|
13
|
+
// seconds
|
|
14
|
+
}, b = /* @__PURE__ */ new Map();
|
|
15
|
+
function k(n, t) {
|
|
16
|
+
const e = Date.now(), a = t.duration * 1e3;
|
|
17
|
+
let r = b.get(n);
|
|
18
|
+
r || (r = { timestamps: [] }, b.set(n, r));
|
|
19
|
+
const i = r.timestamps.filter(
|
|
20
|
+
(s) => e - s < a
|
|
21
|
+
);
|
|
22
|
+
return i.length >= t.limit ? !1 : (i.push(e), r.timestamps = i, !0);
|
|
23
|
+
}
|
|
24
|
+
function _(n, t) {
|
|
25
|
+
const e = b.get(n);
|
|
26
|
+
if (!e || e.timestamps.length < t.limit)
|
|
27
|
+
return 0;
|
|
28
|
+
const a = Date.now(), r = t.duration * 1e3, l = [...e.timestamps].sort((h, f) => h - f)[0] + r - a;
|
|
29
|
+
return Math.max(0, l);
|
|
30
|
+
}
|
|
31
|
+
const j = {
|
|
11
32
|
page: 1,
|
|
12
33
|
pageParam: "page",
|
|
13
34
|
limit: 10,
|
|
14
35
|
limitParam: "limit"
|
|
15
36
|
};
|
|
16
|
-
class
|
|
37
|
+
class T {
|
|
17
38
|
/**
|
|
18
39
|
* Creates a new element with the specified properties
|
|
19
40
|
* @param {("api"|"route"|"group")} type - Element type identifier
|
|
@@ -38,6 +59,7 @@ class E {
|
|
|
38
59
|
});
|
|
39
60
|
o(this, "cache", !1);
|
|
40
61
|
o(this, "retry", !1);
|
|
62
|
+
o(this, "rate", !1);
|
|
41
63
|
/**
|
|
42
64
|
* Enables response caching for this element
|
|
43
65
|
* @param {number} [duration=20] - Cache duration in seconds
|
|
@@ -50,7 +72,20 @@ class E {
|
|
|
50
72
|
* @returns {this} The element instance for chaining
|
|
51
73
|
*/
|
|
52
74
|
o(this, "withRetry", (t = 2) => (this.retry = t, this));
|
|
53
|
-
|
|
75
|
+
/**
|
|
76
|
+
* Enables rate limiting for this element
|
|
77
|
+
* @param {Partial<IRateLimitConfig>} [config] - Rate limiting configuration options
|
|
78
|
+
* @returns {this} The element instance for chaining
|
|
79
|
+
* @example
|
|
80
|
+
* ```typescript
|
|
81
|
+
* Route.get("getUser", "/users/[id]").withRate({ limit: 5, duration: 10 });
|
|
82
|
+
* ```
|
|
83
|
+
*/
|
|
84
|
+
o(this, "withRate", (t = {}) => (this.rate = {
|
|
85
|
+
...O,
|
|
86
|
+
...t
|
|
87
|
+
}, this));
|
|
88
|
+
this.type = t, this.name = A(e), this.name !== e && console.warn(`Name "${e}" has been camelCased to "${this.name}"`), this.url = M(a), this.headers = r || {};
|
|
54
89
|
}
|
|
55
90
|
/**
|
|
56
91
|
* Adds a before-request middleware callback
|
|
@@ -92,12 +127,12 @@ class E {
|
|
|
92
127
|
*/
|
|
93
128
|
withPagination(t = {}) {
|
|
94
129
|
return this.pagination = {
|
|
95
|
-
...
|
|
130
|
+
...j,
|
|
96
131
|
...t
|
|
97
132
|
}, this;
|
|
98
133
|
}
|
|
99
134
|
}
|
|
100
|
-
const
|
|
135
|
+
const d = class d {
|
|
101
136
|
/**
|
|
102
137
|
* Private constructor to enforce singleton pattern.
|
|
103
138
|
* Initializes an empty cache storage.
|
|
@@ -125,7 +160,7 @@ const p = class p {
|
|
|
125
160
|
* ```
|
|
126
161
|
*/
|
|
127
162
|
static get i() {
|
|
128
|
-
return
|
|
163
|
+
return d._instance || (d._instance = new d()), d._instance;
|
|
129
164
|
}
|
|
130
165
|
/**
|
|
131
166
|
* Stores a value in the cache with an optional time-to-live duration.
|
|
@@ -188,9 +223,9 @@ const p = class p {
|
|
|
188
223
|
*
|
|
189
224
|
* @private
|
|
190
225
|
*/
|
|
191
|
-
o(
|
|
192
|
-
let
|
|
193
|
-
function
|
|
226
|
+
o(d, "_instance");
|
|
227
|
+
let P = d;
|
|
228
|
+
function H(n) {
|
|
194
229
|
let a = 2166136261;
|
|
195
230
|
for (let i = 0; i < n.length; i++)
|
|
196
231
|
a ^= n.charCodeAt(i), a *= 16777619;
|
|
@@ -199,14 +234,14 @@ function I(n) {
|
|
|
199
234
|
a ^= r.charCodeAt(r.length % r.length), a *= 16777619, r += (a >>> 0).toString(16).padStart(8, "0");
|
|
200
235
|
return r.substring(0, 32);
|
|
201
236
|
}
|
|
202
|
-
async function
|
|
203
|
-
const a = `${n.toString()}${JSON.stringify(t)}`, r =
|
|
204
|
-
if (
|
|
205
|
-
return
|
|
237
|
+
async function W(n, t, e) {
|
|
238
|
+
const a = `${n.toString()}${JSON.stringify(t)}`, r = H(a);
|
|
239
|
+
if (P.i.has(r))
|
|
240
|
+
return P.i.get(r);
|
|
206
241
|
const s = await (await fetch(n, t)).json();
|
|
207
|
-
return
|
|
242
|
+
return P.i.set(r, s, e), s;
|
|
208
243
|
}
|
|
209
|
-
class
|
|
244
|
+
class R {
|
|
210
245
|
/**
|
|
211
246
|
* Registers a callback function for a specific route.
|
|
212
247
|
* If a callback already exists for the route, it will be replaced.
|
|
@@ -248,33 +283,33 @@ class k {
|
|
|
248
283
|
*
|
|
249
284
|
* @private
|
|
250
285
|
*/
|
|
251
|
-
o(
|
|
252
|
-
const
|
|
253
|
-
function
|
|
286
|
+
o(R, "_callbacks", /* @__PURE__ */ new Map());
|
|
287
|
+
const F = {};
|
|
288
|
+
function q(n, t) {
|
|
254
289
|
return async (...e) => {
|
|
255
290
|
if (t.pagination) {
|
|
256
291
|
const [i = 0, s = {}, l = {}] = e;
|
|
257
|
-
return
|
|
292
|
+
return S(n, t, i, s, l);
|
|
258
293
|
}
|
|
259
294
|
const [a = {}, r = {}] = e;
|
|
260
|
-
return
|
|
295
|
+
return S(n, t, void 0, a, r);
|
|
261
296
|
};
|
|
262
297
|
}
|
|
263
|
-
async function
|
|
298
|
+
async function S(n, t, e, a = {}, r = {}) {
|
|
264
299
|
const i = n.split(".");
|
|
265
300
|
let s;
|
|
266
|
-
for (let
|
|
267
|
-
const
|
|
268
|
-
if (s = c.i.getApi(
|
|
301
|
+
for (let w = 0; w < i.length; w++) {
|
|
302
|
+
const E = i[w];
|
|
303
|
+
if (s = c.i.getApi(E), s) break;
|
|
269
304
|
}
|
|
270
305
|
if (!t || !s || t.type !== "route" || s.type !== "api")
|
|
271
306
|
throw new Error(`Invalid path: ${n}.${t.name}`);
|
|
272
|
-
let l =
|
|
307
|
+
let l = z(`${s.url}/${t.url}`, t, a);
|
|
273
308
|
if (t.pagination && typeof e < "u") {
|
|
274
|
-
const { pageParam:
|
|
275
|
-
|
|
276
|
-
const
|
|
277
|
-
l = `${l}${
|
|
309
|
+
const { pageParam: w = "page", limit: E = 10, limitParam: I = "limit" } = t.pagination, $ = new URLSearchParams();
|
|
310
|
+
$.append(w, String(e)), $.append(I, String(E));
|
|
311
|
+
const K = l.includes("?") ? "&" : "?";
|
|
312
|
+
l = `${l}${K}${$.toString()}`;
|
|
278
313
|
}
|
|
279
314
|
let h = {};
|
|
280
315
|
r && t.method !== "GET" && (h.body = JSON.stringify(r)), h.headers = {
|
|
@@ -284,37 +319,50 @@ async function A(n, t, e, a = {}, r = {}) {
|
|
|
284
319
|
}, h.method = t.method;
|
|
285
320
|
const {
|
|
286
321
|
beforeRoute: f,
|
|
287
|
-
beforeApi:
|
|
288
|
-
beforeUrl:
|
|
289
|
-
beforeConfig:
|
|
290
|
-
} =
|
|
291
|
-
l =
|
|
292
|
-
let
|
|
293
|
-
t.schema && "validate" in t.schema && (
|
|
322
|
+
beforeApi: y,
|
|
323
|
+
beforeUrl: u,
|
|
324
|
+
beforeConfig: C
|
|
325
|
+
} = B({ route: t, api: s, url: l, config: h });
|
|
326
|
+
l = u, h = C, c.updateElement(y), c.updateElement(f);
|
|
327
|
+
let g = await V(s, t, l, h);
|
|
328
|
+
t.schema && "validate" in t.schema && (g = await t.schema.validate(g));
|
|
294
329
|
const {
|
|
295
|
-
afterRoute:
|
|
296
|
-
afterApi:
|
|
297
|
-
afterData:
|
|
298
|
-
} =
|
|
299
|
-
return c.updateElement(
|
|
330
|
+
afterRoute: m,
|
|
331
|
+
afterApi: U,
|
|
332
|
+
afterData: D
|
|
333
|
+
} = Q({ route: t, api: s, response: g, data: g });
|
|
334
|
+
return c.updateElement(U), c.updateElement(m), R.run(`${s.name}.${t.name}`), D;
|
|
300
335
|
}
|
|
301
|
-
async function
|
|
302
|
-
return n ? await
|
|
336
|
+
async function J(n, t, e, a) {
|
|
337
|
+
return n ? await W(t, e, a.cache) : await (await fetch(t, e)).json();
|
|
303
338
|
}
|
|
304
|
-
async function
|
|
305
|
-
var f,
|
|
339
|
+
async function V(n, t, e, a) {
|
|
340
|
+
var f, y;
|
|
306
341
|
const r = n.cache || t.cache, i = t.retry || n.retry || 0;
|
|
342
|
+
if (t.rate) {
|
|
343
|
+
const u = `${n.name}.${t.name}`;
|
|
344
|
+
if (!k(u, t.rate)) {
|
|
345
|
+
const g = _(u, t.rate), m = Math.ceil(g / 1e3);
|
|
346
|
+
throw new Error(`Rate limit exceeded for ${u}. Try again in ${m} seconds.`);
|
|
347
|
+
}
|
|
348
|
+
} else if (n.rate) {
|
|
349
|
+
const u = `${n.name}`;
|
|
350
|
+
if (!k(u, n.rate)) {
|
|
351
|
+
const g = _(u, n.rate), m = Math.ceil(g / 1e3);
|
|
352
|
+
throw new Error(`Rate limit exceeded for ${n.name} API. Try again in ${m} seconds.`);
|
|
353
|
+
}
|
|
354
|
+
}
|
|
307
355
|
let s, l = !1, h = 0;
|
|
308
356
|
for (; h <= i && !l; )
|
|
309
357
|
try {
|
|
310
|
-
(f = t.callbacks) != null && f.call ? t.callbacks.call({}) : (
|
|
311
|
-
} catch (
|
|
358
|
+
(f = t.callbacks) != null && f.call ? t.callbacks.call({}) : (y = n.callbacks) != null && y.call && n.callbacks.call({}), s = await J(!!r, e, a, n), l = !0;
|
|
359
|
+
} catch (u) {
|
|
312
360
|
if (h++, h > i)
|
|
313
|
-
throw
|
|
361
|
+
throw u.message = `Failed to fetch ${e} after ${i} attempts`, u;
|
|
314
362
|
}
|
|
315
363
|
return s;
|
|
316
364
|
}
|
|
317
|
-
function
|
|
365
|
+
function z(n, t, e) {
|
|
318
366
|
let a = n;
|
|
319
367
|
return t.arguments.forEach((r) => {
|
|
320
368
|
if (e[r] === void 0)
|
|
@@ -322,7 +370,7 @@ function W(n, t, e) {
|
|
|
322
370
|
a = a.replace(`[${r}]`, e[r]);
|
|
323
371
|
}), a;
|
|
324
372
|
}
|
|
325
|
-
function
|
|
373
|
+
function B({ route: n, api: t, url: e, config: a }) {
|
|
326
374
|
var i, s;
|
|
327
375
|
const r = (s = (i = n.callbacks).before) == null ? void 0 : s.call(i, { route: n, api: t, url: e, config: a });
|
|
328
376
|
return {
|
|
@@ -332,7 +380,7 @@ function J({ route: n, api: t, url: e, config: a }) {
|
|
|
332
380
|
beforeConfig: (r == null ? void 0 : r.config) || a
|
|
333
381
|
};
|
|
334
382
|
}
|
|
335
|
-
function
|
|
383
|
+
function Q({ route: n, api: t, response: e, data: a }) {
|
|
336
384
|
var i, s;
|
|
337
385
|
const r = (s = (i = n.callbacks).after) == null ? void 0 : s.call(i, { route: n, api: t, response: e, data: a });
|
|
338
386
|
return {
|
|
@@ -342,7 +390,7 @@ function z({ route: n, api: t, response: e, data: a }) {
|
|
|
342
390
|
afterData: (r == null ? void 0 : r.data) || a
|
|
343
391
|
};
|
|
344
392
|
}
|
|
345
|
-
const
|
|
393
|
+
const p = class p {
|
|
346
394
|
constructor() {
|
|
347
395
|
/**
|
|
348
396
|
* Map storing all registered elements with their full paths as keys
|
|
@@ -360,7 +408,7 @@ const u = class u {
|
|
|
360
408
|
* @returns The singleton Registry instance
|
|
361
409
|
*/
|
|
362
410
|
static get i() {
|
|
363
|
-
return
|
|
411
|
+
return p._instance || (p._instance = new p()), p._instance;
|
|
364
412
|
}
|
|
365
413
|
/**
|
|
366
414
|
* Registers a new element in the registry and updates the Klaim object structure
|
|
@@ -377,7 +425,7 @@ const u = class u {
|
|
|
377
425
|
e && (t.parent = this.getFullPath(e));
|
|
378
426
|
const a = this.getElementKey(t);
|
|
379
427
|
if (this._elements.set(a, t), t.type === "api" || t.type === "group") {
|
|
380
|
-
let r =
|
|
428
|
+
let r = F;
|
|
381
429
|
if (e) {
|
|
382
430
|
const i = this.getFullPath(e).split(".");
|
|
383
431
|
for (const s of i)
|
|
@@ -432,11 +480,11 @@ const u = class u {
|
|
|
432
480
|
*/
|
|
433
481
|
addToKlaimRoute(t) {
|
|
434
482
|
if (!t.parent) return;
|
|
435
|
-
let e =
|
|
483
|
+
let e = F;
|
|
436
484
|
const a = t.parent.split(".");
|
|
437
485
|
for (const r of a)
|
|
438
486
|
e[r] || (e[r] = {}), e = e[r];
|
|
439
|
-
e[t.name] =
|
|
487
|
+
e[t.name] = q(t.parent, t);
|
|
440
488
|
}
|
|
441
489
|
/**
|
|
442
490
|
* Generates a unique key for an element based on its path
|
|
@@ -494,7 +542,7 @@ const u = class u {
|
|
|
494
542
|
* @returns The updated element
|
|
495
543
|
*/
|
|
496
544
|
static updateElement(t) {
|
|
497
|
-
return
|
|
545
|
+
return p.i._elements.get(p.i.getElementKey(t)) || t;
|
|
498
546
|
}
|
|
499
547
|
/**
|
|
500
548
|
* Retrieves an API element by name, searching through the entire registry if necessary
|
|
@@ -528,9 +576,9 @@ const u = class u {
|
|
|
528
576
|
}
|
|
529
577
|
}
|
|
530
578
|
};
|
|
531
|
-
o(
|
|
532
|
-
let c =
|
|
533
|
-
class
|
|
579
|
+
o(p, "_instance");
|
|
580
|
+
let c = p;
|
|
581
|
+
class v extends T {
|
|
534
582
|
/**
|
|
535
583
|
* Creates and registers a new API instance with the given configuration
|
|
536
584
|
*
|
|
@@ -550,9 +598,9 @@ class _ extends E {
|
|
|
550
598
|
* ```
|
|
551
599
|
*/
|
|
552
600
|
static create(t, e, a, r = {}) {
|
|
553
|
-
const i =
|
|
601
|
+
const i = A(t);
|
|
554
602
|
i !== t && console.warn(`API name "${t}" has been camelCased to "${i}"`);
|
|
555
|
-
const s = new
|
|
603
|
+
const s = new v(i, e, r), l = c.i.getCurrentParent();
|
|
556
604
|
c.i.registerElement(s);
|
|
557
605
|
const h = l ? c.i.getFullPath(l) : "", f = h ? `${h}.${i}` : i;
|
|
558
606
|
return c.i.setCurrentParent(f), a(), l ? c.i.setCurrentParent(c.i.getFullPath(l)) : c.i.clearCurrentParent(), s;
|
|
@@ -570,7 +618,7 @@ class _ extends E {
|
|
|
570
618
|
super("api", t, e, a);
|
|
571
619
|
}
|
|
572
620
|
}
|
|
573
|
-
class
|
|
621
|
+
class N extends T {
|
|
574
622
|
/**
|
|
575
623
|
* Creates a new group and registers it in the Registry.
|
|
576
624
|
* Supports nested groups and inheritable configurations.
|
|
@@ -596,7 +644,7 @@ class F extends E {
|
|
|
596
644
|
* ```
|
|
597
645
|
*/
|
|
598
646
|
static create(t, e) {
|
|
599
|
-
const a =
|
|
647
|
+
const a = A(t), r = c.i.getCurrentParent(), i = r ? c.i.getFullPath(r) : "", s = i ? `${i}.${a}` : a, l = new N(a, "");
|
|
600
648
|
a !== t && console.warn(`Group name "${t}" has been camelCased to "${a}"`), c.i.registerElement(l);
|
|
601
649
|
const h = c.i.getCurrentParent();
|
|
602
650
|
return c.i.setCurrentParent(s), e(), h ? c.i.setCurrentParent(c.i.getFullPath(h)) : c.i.clearCurrentParent(), l;
|
|
@@ -720,7 +768,7 @@ class F extends E {
|
|
|
720
768
|
}), this;
|
|
721
769
|
}
|
|
722
770
|
}
|
|
723
|
-
class
|
|
771
|
+
class x extends T {
|
|
724
772
|
/**
|
|
725
773
|
* Creates a new Route instance.
|
|
726
774
|
*
|
|
@@ -743,7 +791,7 @@ class S extends E {
|
|
|
743
791
|
* @private
|
|
744
792
|
*/
|
|
745
793
|
static createRoute(t, e, a = {}, r) {
|
|
746
|
-
const i = new
|
|
794
|
+
const i = new x(t, e, a, r);
|
|
747
795
|
return c.i.registerRoute(i), i;
|
|
748
796
|
}
|
|
749
797
|
/**
|
|
@@ -903,10 +951,10 @@ class S extends E {
|
|
|
903
951
|
}
|
|
904
952
|
}
|
|
905
953
|
export {
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
954
|
+
v as Api,
|
|
955
|
+
N as Group,
|
|
956
|
+
R as Hook,
|
|
957
|
+
F as Klaim,
|
|
910
958
|
c as Registry,
|
|
911
|
-
|
|
959
|
+
x as Route
|
|
912
960
|
};
|
package/dist/klaim.umd.js
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
(function(
|
|
1
|
+
(function(h,p){typeof exports=="object"&&typeof module<"u"?p(exports):typeof define=="function"&&define.amd?define(["exports"],p):(h=typeof globalThis<"u"?globalThis:h||self,p(h.klaim={}))})(this,function(h){"use strict";var Q=Object.defineProperty;var X=(h,p,P)=>p in h?Q(h,p,{enumerable:!0,configurable:!0,writable:!0,value:P}):h[p]=P;var o=(h,p,P)=>X(h,typeof p!="symbol"?p+"":p,P);function p(n){return n.replace(/([-_][a-z])/gi,t=>t.toUpperCase().replace("-","").replace("_","")).replace(/(^\w)/,t=>t.toLowerCase())}function P(n){return n.trim().replace(/^\/|\/$/g,"")}const I={limit:5,duration:10},$=new Map;function K(n,t){const e=Date.now(),a=t.duration*1e3;let r=$.get(n);r||(r={timestamps:[]},$.set(n,r));const i=r.timestamps.filter(s=>e-s<a);return i.length>=t.limit?!1:(i.push(e),r.timestamps=i,!0)}function U(n,t){const e=$.get(n);if(!e||e.timestamps.length<t.limit)return 0;const a=Date.now(),r=t.duration*1e3,l=[...e.timestamps].sort((u,d)=>u-d)[0]+r-a;return Math.max(0,l)}const L={page:1,pageParam:"page",limit:10,limitParam:"limit"};class A{constructor(t,e,a,r={}){o(this,"type");o(this,"name");o(this,"url");o(this,"headers");o(this,"parent");o(this,"method");o(this,"arguments",new Set);o(this,"schema");o(this,"pagination");o(this,"callbacks",{before:null,after:null,call:null});o(this,"cache",!1);o(this,"retry",!1);o(this,"rate",!1);o(this,"withCache",(t=20)=>(this.cache=t,this));o(this,"withRetry",(t=2)=>(this.retry=t,this));o(this,"withRate",(t={})=>(this.rate={...I,...t},this));this.type=t,this.name=p(e),this.name!==e&&console.warn(`Name "${e}" has been camelCased to "${this.name}"`),this.url=P(a),this.headers=r||{}}before(t){return this.callbacks.before=t,this}after(t){return this.callbacks.after=t,this}onCall(t){return this.callbacks.call=t,this}withPagination(t={}){return this.pagination={...L,...t},this}}const w=class w{constructor(){o(this,"cache");this.cache=new Map}static get i(){return w._instance||(w._instance=new w),w._instance}set(t,e,a=0){const r=Date.now()+a;this.cache.set(t,{data:e,expiry:r})}has(t){const e=this.cache.get(t);return e?Date.now()>e.expiry?(this.cache.delete(t),!1):!0:!1}get(t){return this.has(t)?this.cache.get(t).data:null}};o(w,"_instance");let y=w;function G(n){let a=2166136261;for(let i=0;i<n.length;i++)a^=n.charCodeAt(i),a*=16777619;let r=(a>>>0).toString(16).padStart(8,"0");for(;r.length<32;)a^=r.charCodeAt(r.length%r.length),a*=16777619,r+=(a>>>0).toString(16).padStart(8,"0");return r.substring(0,32)}async function M(n,t,e){const a=`${n.toString()}${JSON.stringify(t)}`,r=G(a);if(y.i.has(r))return y.i.get(r);const s=await(await fetch(n,t)).json();return y.i.set(r,s,e),s}class T{static subscribe(t,e){this._callbacks.set(t,e)}static run(t){const e=this._callbacks.get(t);e&&e()}}o(T,"_callbacks",new Map);const k={};function O(n,t){return async(...e)=>{if(t.pagination){const[i=0,s={},l={}]=e;return D(n,t,i,s,l)}const[a={},r={}]=e;return D(n,t,void 0,a,r)}}async function D(n,t,e,a={},r={}){const i=n.split(".");let s;for(let C=0;C<i.length;C++){const v=i[C];if(s=c.i.getApi(v),s)break}if(!t||!s||t.type!=="route"||s.type!=="api")throw new Error(`Invalid path: ${n}.${t.name}`);let l=H(`${s.url}/${t.url}`,t,a);if(t.pagination&&typeof e<"u"){const{pageParam:C="page",limit:v=10,limitParam:z="limit"}=t.pagination,N=new URLSearchParams;N.append(C,String(e)),N.append(z,String(v));const B=l.includes("?")?"&":"?";l=`${l}${B}${N.toString()}`}let u={};r&&t.method!=="GET"&&(u.body=JSON.stringify(r)),u.headers={"Content-Type":"application/json",...s.headers,...t.headers},u.method=t.method;const{beforeRoute:d,beforeApi:E,beforeUrl:f,beforeConfig:R}=W({route:t,api:s,url:l,config:u});l=f,u=R,c.updateElement(E),c.updateElement(d);let m=await j(s,t,l,u);t.schema&&"validate"in t.schema&&(m=await t.schema.validate(m));const{afterRoute:b,afterApi:J,afterData:V}=q({route:t,api:s,response:m,data:m});return c.updateElement(J),c.updateElement(b),T.run(`${s.name}.${t.name}`),V}async function x(n,t,e,a){return n?await M(t,e,a.cache):await(await fetch(t,e)).json()}async function j(n,t,e,a){var d,E;const r=n.cache||t.cache,i=t.retry||n.retry||0;if(t.rate){const f=`${n.name}.${t.name}`;if(!K(f,t.rate)){const m=U(f,t.rate),b=Math.ceil(m/1e3);throw new Error(`Rate limit exceeded for ${f}. Try again in ${b} seconds.`)}}else if(n.rate){const f=`${n.name}`;if(!K(f,n.rate)){const m=U(f,n.rate),b=Math.ceil(m/1e3);throw new Error(`Rate limit exceeded for ${n.name} API. Try again in ${b} seconds.`)}}let s,l=!1,u=0;for(;u<=i&&!l;)try{(d=t.callbacks)!=null&&d.call?t.callbacks.call({}):(E=n.callbacks)!=null&&E.call&&n.callbacks.call({}),s=await x(!!r,e,a,n),l=!0}catch(f){if(u++,u>i)throw f.message=`Failed to fetch ${e} after ${i} attempts`,f}return s}function H(n,t,e){let a=n;return t.arguments.forEach(r=>{if(e[r]===void 0)throw new Error(`Argument ${r} is missing`);a=a.replace(`[${r}]`,e[r])}),a}function W({route:n,api:t,url:e,config:a}){var i,s;const r=(s=(i=n.callbacks).before)==null?void 0:s.call(i,{route:n,api:t,url:e,config:a});return{beforeRoute:(r==null?void 0:r.route)||n,beforeApi:(r==null?void 0:r.api)||t,beforeUrl:(r==null?void 0:r.url)||e,beforeConfig:(r==null?void 0:r.config)||a}}function q({route:n,api:t,response:e,data:a}){var i,s;const r=(s=(i=n.callbacks).after)==null?void 0:s.call(i,{route:n,api:t,response:e,data:a});return{afterRoute:(r==null?void 0:r.route)||n,afterApi:(r==null?void 0:r.api)||t,afterResponse:(r==null?void 0:r.response)||e,afterData:(r==null?void 0:r.data)||a}}const g=class g{constructor(){o(this,"_elements",new Map);o(this,"_currentParent",null)}static get i(){return g._instance||(g._instance=new g),g._instance}registerElement(t){const e=this._currentParent;e&&(t.parent=this.getFullPath(e));const a=this.getElementKey(t);if(this._elements.set(a,t),t.type==="api"||t.type==="group"){let r=k;if(e){const i=this.getFullPath(e).split(".");for(const s of i)r[s]||(r[s]={}),r=r[s]}r[t.name]||(r[t.name]={})}}getCurrentParent(){return this._currentParent}setCurrentParent(t){const e=this._elements.get(t);if(!e||e.type!=="api"&&e.type!=="group")throw new Error(`Element ${t} not found or not a valid parent type`);this._currentParent=e}clearCurrentParent(){this._currentParent=null}registerRoute(t){if(!this._currentParent)throw new Error("No current parent set, use Route only inside Api or Group create callback");t.parent=this.getFullPath(this._currentParent);const e=this.getElementKey(t);this._elements.set(e,t),this.addToKlaimRoute(t)}addToKlaimRoute(t){if(!t.parent)return;let e=k;const a=t.parent.split(".");for(const r of a)e[r]||(e[r]={}),e=e[r];e[t.name]=O(t.parent,t)}getElementKey(t){return t?t.parent?`${t.parent}.${t.name}`:t.name:""}getFullPath(t){if(!t)return"";if(!t.parent)return t.name;const e=[t.name];let a=t;for(;a.parent;){const r=this._elements.get(a.parent);if(!r)break;e.unshift(r.name),a=r}return e.join(".")}getRoute(t,e){return this._elements.get(`${t}.${e}`)}getChildren(t){const e=[];return this._elements.forEach(a=>{a.parent===t&&e.push(a)}),e}static updateElement(t){return g.i._elements.get(g.i.getElementKey(t))||t}getApi(t){const e=this._elements.get(t);if(!e){for(const[a,r]of this._elements.entries())if(r.type==="api"&&a.endsWith(`.${t}`))return r;return}return e.type==="api"?e:this.findApi(e)}findApi(t){if(!t||!t.parent)return;const e=t.parent.split(".");for(let a=e.length;a>=0;a--){const r=e.slice(0,a).join("."),i=this._elements.get(r);if((i==null?void 0:i.type)==="api")return i}}};o(g,"_instance");let c=g;class _ extends A{static create(t,e,a,r={}){const i=p(t);i!==t&&console.warn(`API name "${t}" has been camelCased to "${i}"`);const s=new _(i,e,r),l=c.i.getCurrentParent();c.i.registerElement(s);const u=l?c.i.getFullPath(l):"",d=u?`${u}.${i}`:i;return c.i.setCurrentParent(d),a(),l?c.i.setCurrentParent(c.i.getFullPath(l)):c.i.clearCurrentParent(),s}constructor(t,e,a={}){super("api",t,e,a)}}class S extends A{static create(t,e){const a=p(t),r=c.i.getCurrentParent(),i=r?c.i.getFullPath(r):"",s=i?`${i}.${a}`:a,l=new S(a,"");a!==t&&console.warn(`Group name "${t}" has been camelCased to "${a}"`),c.i.registerElement(l);const u=c.i.getCurrentParent();return c.i.setCurrentParent(s),e(),u?c.i.setCurrentParent(c.i.getFullPath(u)):c.i.clearCurrentParent(),l}constructor(t,e,a={}){super("group",t,e,a)}withCache(t=20){return super.withCache(t),c.i.getChildren(c.i.getFullPath(this)).forEach(e=>{e.cache||(e.cache=t)}),this}withRetry(t=2){return super.withRetry(t),c.i.getChildren(c.i.getFullPath(this)).forEach(e=>{e.retry||(e.retry=t)}),this}before(t){return super.before(t),c.i.getChildren(c.i.getFullPath(this)).forEach(e=>{e.callbacks.before||(e.callbacks.before=t)}),this}after(t){return super.after(t),c.i.getChildren(c.i.getFullPath(this)).forEach(e=>{e.callbacks.after||(e.callbacks.after=t)}),this}onCall(t){return super.onCall(t),c.i.getChildren(c.i.getFullPath(this)).forEach(e=>{e.callbacks.call||(e.callbacks.call=t)}),this}}class F extends A{constructor(t,e,a={},r="GET"){super("route",t,e,a),this.method=r,this.detectArguments()}static createRoute(t,e,a={},r){const i=new F(t,e,a,r);return c.i.registerRoute(i),i}static get(t,e,a={}){return this.createRoute(t,e,a,"GET")}static post(t,e,a={}){return this.createRoute(t,e,a,"POST")}static put(t,e,a={}){return this.createRoute(t,e,a,"PUT")}static delete(t,e,a={}){return this.createRoute(t,e,a,"DELETE")}static patch(t,e,a={}){return this.createRoute(t,e,a,"PATCH")}static options(t,e,a={}){return this.createRoute(t,e,a,"OPTIONS")}detectArguments(){const t=this.url.match(/\[([^\]]+)]/g);t&&t.forEach(e=>{const a=e.replace("[","").replace("]","");this.arguments.add(a)})}validate(t){return this.schema=t,this}}h.Api=_,h.Group=S,h.Hook=T,h.Klaim=k,h.Registry=c,h.Route=F,Object.defineProperty(h,Symbol.toStringTag,{value:"Module"})});
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "klaim",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.9.1",
|
|
4
4
|
"author": "antharuu",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -9,15 +9,15 @@
|
|
|
9
9
|
"main": "dist/klaim.cjs.js",
|
|
10
10
|
"module": "dist/klaim.es.js",
|
|
11
11
|
"devDependencies": {
|
|
12
|
-
"@eslint/js": "9.
|
|
12
|
+
"@eslint/js": "9.26.0",
|
|
13
13
|
"@stylistic/eslint-plugin": "4.2.0",
|
|
14
|
-
"@types/bun": "
|
|
14
|
+
"@types/bun": "^1.2.11",
|
|
15
15
|
"@types/eslint__js": "9.14.0",
|
|
16
16
|
"@types/node": "^22.15.3",
|
|
17
17
|
"@vitest/coverage-v8": "^3.1.2",
|
|
18
18
|
"@vitest/ui": "^3.1.2",
|
|
19
19
|
"dotenv-cli": "^8.0.0",
|
|
20
|
-
"eslint": "9.
|
|
20
|
+
"eslint": "9.26.0",
|
|
21
21
|
"eslint-plugin-jsdoc": "50.6.11",
|
|
22
22
|
"eslint-plugin-simple-import-sort": "12.1.1",
|
|
23
23
|
"jsdom": "^26.1.0",
|
package/src/core/Element.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import cleanUrl from "../tools/cleanUrl";
|
|
2
2
|
import toCamelCase from "../tools/toCamelCase";
|
|
3
|
+
import { IRateLimitConfig, DEFAULT_RATE_LIMIT_CONFIG } from "../tools/rateLimit";
|
|
3
4
|
|
|
4
5
|
/**
|
|
5
6
|
* Type definition for HTTP headers
|
|
@@ -96,6 +97,8 @@ export interface IElement {
|
|
|
96
97
|
cache: false | number;
|
|
97
98
|
/** Number of retry attempts, or false if retries are disabled */
|
|
98
99
|
retry: false | number;
|
|
100
|
+
/** Rate limiting configuration, or false if rate limiting is disabled */
|
|
101
|
+
rate: false | IRateLimitConfig;
|
|
99
102
|
/** Reference to parent element name */
|
|
100
103
|
parent?: string;
|
|
101
104
|
/** HTTP method for routes */
|
|
@@ -124,6 +127,9 @@ export interface IElement {
|
|
|
124
127
|
|
|
125
128
|
/** Configures pagination settings */
|
|
126
129
|
withPagination(config?: IPaginationConfig): this;
|
|
130
|
+
|
|
131
|
+
/** Enables rate limiting */
|
|
132
|
+
withRate(config?: Partial<IRateLimitConfig>): this;
|
|
127
133
|
}
|
|
128
134
|
|
|
129
135
|
/**
|
|
@@ -162,6 +168,7 @@ export abstract class Element implements IElement {
|
|
|
162
168
|
|
|
163
169
|
public cache: false | number = false;
|
|
164
170
|
public retry: false | number = false;
|
|
171
|
+
public rate: false | IRateLimitConfig = false;
|
|
165
172
|
|
|
166
173
|
/**
|
|
167
174
|
* Creates a new element with the specified properties
|
|
@@ -257,4 +264,21 @@ export abstract class Element implements IElement {
|
|
|
257
264
|
};
|
|
258
265
|
return this;
|
|
259
266
|
}
|
|
267
|
+
|
|
268
|
+
/**
|
|
269
|
+
* Enables rate limiting for this element
|
|
270
|
+
* @param {Partial<IRateLimitConfig>} [config] - Rate limiting configuration options
|
|
271
|
+
* @returns {this} The element instance for chaining
|
|
272
|
+
* @example
|
|
273
|
+
* ```typescript
|
|
274
|
+
* Route.get("getUser", "/users/[id]").withRate({ limit: 5, duration: 10 });
|
|
275
|
+
* ```
|
|
276
|
+
*/
|
|
277
|
+
public withRate = (config: Partial<IRateLimitConfig> = {}): this => {
|
|
278
|
+
this.rate = {
|
|
279
|
+
...DEFAULT_RATE_LIMIT_CONFIG,
|
|
280
|
+
...config
|
|
281
|
+
};
|
|
282
|
+
return this;
|
|
283
|
+
};
|
|
260
284
|
}
|
package/src/core/Klaim.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import fetchWithCache from "../tools/fetchWithCache";
|
|
2
|
+
import { checkRateLimit, getTimeUntilNextRequest } from "../tools/rateLimit";
|
|
2
3
|
import {IElement} from "./Element";
|
|
3
4
|
import {Hook} from "./Hook";
|
|
4
5
|
import {Registry} from "./Registry";
|
|
@@ -41,10 +42,7 @@ export type IApiReference = Record<string, IRouteReference>;
|
|
|
41
42
|
* await Klaim.apiName.routeName();
|
|
42
43
|
*
|
|
43
44
|
* // With pagination
|
|
44
|
-
* await Klaim.apiName.routeName(2
|
|
45
|
-
*
|
|
46
|
-
* // With pagination and additional parameters
|
|
47
|
-
* await Klaim.apiName.routeName(2, 10, { filter: 'active' });
|
|
45
|
+
* await Klaim.apiName.routeName(2); // Page 2
|
|
48
46
|
* ```
|
|
49
47
|
*/
|
|
50
48
|
export const Klaim: IApiReference = {};
|
|
@@ -191,14 +189,14 @@ async function fetchData(
|
|
|
191
189
|
}
|
|
192
190
|
|
|
193
191
|
/**
|
|
194
|
-
* Performs a fetch request with retry capability
|
|
192
|
+
* Performs a fetch request with retry capability and rate limiting
|
|
195
193
|
*
|
|
196
194
|
* @param api - API element containing retry settings
|
|
197
195
|
* @param route - Route element containing retry settings
|
|
198
196
|
* @param url - The URL to fetch from
|
|
199
197
|
* @param config - Fetch configuration options
|
|
200
198
|
* @returns Promise resolving to the parsed response
|
|
201
|
-
* @throws Error after all retry attempts fail
|
|
199
|
+
* @throws Error after all retry attempts fail or if rate limited
|
|
202
200
|
*/
|
|
203
201
|
async function fetchWithRetry(
|
|
204
202
|
api: IElement,
|
|
@@ -208,6 +206,31 @@ async function fetchWithRetry(
|
|
|
208
206
|
): Promise<any> {
|
|
209
207
|
const withCache = api.cache || route.cache;
|
|
210
208
|
const maxRetries = (route.retry || api.retry) || 0;
|
|
209
|
+
|
|
210
|
+
// Check rate limiting
|
|
211
|
+
// Si la route a sa propre configuration de limite, on l'utilise avec une clé spécifique à la route
|
|
212
|
+
if (route.rate) {
|
|
213
|
+
const routeKey = `${api.name}.${route.name}`;
|
|
214
|
+
const allowed = checkRateLimit(routeKey, route.rate);
|
|
215
|
+
|
|
216
|
+
if (!allowed) {
|
|
217
|
+
const waitTime = getTimeUntilNextRequest(routeKey, route.rate);
|
|
218
|
+
const waitSeconds = Math.ceil(waitTime / 1000);
|
|
219
|
+
throw new Error(`Rate limit exceeded for ${routeKey}. Try again in ${waitSeconds} seconds.`);
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
// Si l'API a une configuration de limite et que la route n'en a pas, utiliser une clé au niveau de l'API
|
|
223
|
+
else if (api.rate) {
|
|
224
|
+
const apiKey = `${api.name}`;
|
|
225
|
+
const allowed = checkRateLimit(apiKey, api.rate);
|
|
226
|
+
|
|
227
|
+
if (!allowed) {
|
|
228
|
+
const waitTime = getTimeUntilNextRequest(apiKey, api.rate);
|
|
229
|
+
const waitSeconds = Math.ceil(waitTime / 1000);
|
|
230
|
+
throw new Error(`Rate limit exceeded for ${api.name} API. Try again in ${waitSeconds} seconds.`);
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
211
234
|
let response;
|
|
212
235
|
let success = false;
|
|
213
236
|
let attempt = 0;
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Configuration interface for rate limiting settings
|
|
3
|
+
* @interface IRateLimitConfig
|
|
4
|
+
* @property {number} limit - Maximum number of requests allowed in the given duration
|
|
5
|
+
* @property {number} duration - Time window in seconds for the rate limit
|
|
6
|
+
*/
|
|
7
|
+
export interface IRateLimitConfig {
|
|
8
|
+
limit: number;
|
|
9
|
+
duration: number;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Default configuration for rate limiting
|
|
14
|
+
* @constant DEFAULT_RATE_LIMIT_CONFIG
|
|
15
|
+
*/
|
|
16
|
+
export const DEFAULT_RATE_LIMIT_CONFIG: IRateLimitConfig = {
|
|
17
|
+
limit: 5,
|
|
18
|
+
duration: 10 // seconds
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
// Store request timestamps for rate limiting
|
|
22
|
+
type RequestLog = {
|
|
23
|
+
timestamps: number[]; // Timestamps of requests
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
// Global request log
|
|
27
|
+
const requestLogs: Map<string, RequestLog> = new Map();
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Checks if a request should be rate limited
|
|
31
|
+
*
|
|
32
|
+
* @param key - Unique identifier for the API/route combination
|
|
33
|
+
* @param config - Rate limiting configuration
|
|
34
|
+
* @returns True if the request should be allowed, false if it should be rate limited
|
|
35
|
+
*/
|
|
36
|
+
export function checkRateLimit(key: string, config: IRateLimitConfig): boolean {
|
|
37
|
+
const now = Date.now();
|
|
38
|
+
const timeWindow = config.duration * 1000; // Convert to milliseconds
|
|
39
|
+
|
|
40
|
+
// Get or initialize request log for this key
|
|
41
|
+
let requestLog = requestLogs.get(key);
|
|
42
|
+
if (!requestLog) {
|
|
43
|
+
requestLog = { timestamps: [] };
|
|
44
|
+
requestLogs.set(key, requestLog);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// Clean up old timestamps outside the current time window
|
|
48
|
+
const validTimestamps = requestLog.timestamps.filter(
|
|
49
|
+
time => now - time < timeWindow
|
|
50
|
+
);
|
|
51
|
+
|
|
52
|
+
// Check if we've hit the rate limit
|
|
53
|
+
if (validTimestamps.length >= config.limit) {
|
|
54
|
+
return false; // Rate limited
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// Add current timestamp and update the log
|
|
58
|
+
validTimestamps.push(now);
|
|
59
|
+
requestLog.timestamps = validTimestamps;
|
|
60
|
+
|
|
61
|
+
return true; // Request allowed
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Calculates time remaining until the next request is allowed
|
|
66
|
+
*
|
|
67
|
+
* @param key - Unique identifier for the API/route combination
|
|
68
|
+
* @param config - Rate limiting configuration
|
|
69
|
+
* @returns Time in milliseconds until the next request is allowed, or 0 if not rate limited
|
|
70
|
+
*/
|
|
71
|
+
export function getTimeUntilNextRequest(key: string, config: IRateLimitConfig): number {
|
|
72
|
+
const requestLog = requestLogs.get(key);
|
|
73
|
+
if (!requestLog || requestLog.timestamps.length < config.limit) {
|
|
74
|
+
return 0; // Not rate limited
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const now = Date.now();
|
|
78
|
+
const timeWindow = config.duration * 1000;
|
|
79
|
+
|
|
80
|
+
// Sort timestamps in ascending order
|
|
81
|
+
const sortedTimestamps = [...requestLog.timestamps].sort((a, b) => a - b);
|
|
82
|
+
|
|
83
|
+
// Oldest timestamp + timeWindow = when it will expire
|
|
84
|
+
const oldestValidTime = sortedTimestamps[0];
|
|
85
|
+
const timeUntilExpiry = (oldestValidTime + timeWindow) - now;
|
|
86
|
+
|
|
87
|
+
return Math.max(0, timeUntilExpiry);
|
|
88
|
+
}
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import { describe, expect, it, vi, beforeEach } from "vitest";
|
|
2
|
+
import { Api, Klaim, Route } from "../src";
|
|
3
|
+
|
|
4
|
+
// Mock fetch pour simuler les réponses API sans faire de vraies requêtes
|
|
5
|
+
global.fetch = vi.fn(() =>
|
|
6
|
+
Promise.resolve({
|
|
7
|
+
json: () => Promise.resolve({ success: true }),
|
|
8
|
+
})
|
|
9
|
+
) as any;
|
|
10
|
+
|
|
11
|
+
// Réinitialiser les mocks entre chaque test
|
|
12
|
+
beforeEach(() => {
|
|
13
|
+
vi.clearAllMocks();
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
describe("Rate Limiting", () => {
|
|
17
|
+
it("should allow requests within the rate limit", async () => {
|
|
18
|
+
const apiName = "testRateApi";
|
|
19
|
+
const apiUrl = "https://example.com";
|
|
20
|
+
const routeName = "testRateRoute";
|
|
21
|
+
|
|
22
|
+
Api.create(apiName, apiUrl, () => {
|
|
23
|
+
Route.get(routeName, "/test").withRate({ limit: 3, duration: 10 });
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
// Devrait permettre 3 requêtes successives sans problème
|
|
27
|
+
await Klaim[apiName][routeName]();
|
|
28
|
+
await Klaim[apiName][routeName]();
|
|
29
|
+
await Klaim[apiName][routeName]();
|
|
30
|
+
|
|
31
|
+
// Vérifier que fetch a été appelé 3 fois
|
|
32
|
+
expect(fetch).toHaveBeenCalledTimes(3);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it("should block requests that exceed the rate limit", async () => {
|
|
36
|
+
const apiName = "testRateApi2";
|
|
37
|
+
const apiUrl = "https://example.com";
|
|
38
|
+
const routeName = "testRateRoute2";
|
|
39
|
+
|
|
40
|
+
Api.create(apiName, apiUrl, () => {
|
|
41
|
+
Route.get(routeName, "/test").withRate({ limit: 2, duration: 10 });
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
// Les deux premières requêtes devraient réussir
|
|
45
|
+
await Klaim[apiName][routeName]();
|
|
46
|
+
await Klaim[apiName][routeName]();
|
|
47
|
+
|
|
48
|
+
// La troisième requête devrait être bloquée
|
|
49
|
+
await expect(Klaim[apiName][routeName]()).rejects.toThrow(/Rate limit exceeded/);
|
|
50
|
+
|
|
51
|
+
// Vérifier que fetch n'a été appelé que 2 fois
|
|
52
|
+
expect(fetch).toHaveBeenCalledTimes(2);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it("should apply rate limits at the API level", async () => {
|
|
56
|
+
const apiName = "testRateApi3";
|
|
57
|
+
const apiUrl = "https://example.com";
|
|
58
|
+
const routeName1 = "testRateRoute3A";
|
|
59
|
+
const routeName2 = "testRateRoute3B";
|
|
60
|
+
|
|
61
|
+
Api.create(apiName, apiUrl, () => {
|
|
62
|
+
Route.get(routeName1, "/test1");
|
|
63
|
+
Route.get(routeName2, "/test2");
|
|
64
|
+
}).withRate({ limit: 3, duration: 10 });
|
|
65
|
+
|
|
66
|
+
// Différentes routes mais même API - devrait compter pour la même limite
|
|
67
|
+
await Klaim[apiName][routeName1]();
|
|
68
|
+
await Klaim[apiName][routeName1]();
|
|
69
|
+
await Klaim[apiName][routeName2]();
|
|
70
|
+
|
|
71
|
+
// La quatrième requête devrait être bloquée
|
|
72
|
+
await expect(Klaim[apiName][routeName1]()).rejects.toThrow(/Rate limit exceeded/);
|
|
73
|
+
|
|
74
|
+
// Vérifier que fetch n'a été appelé que 3 fois
|
|
75
|
+
expect(fetch).toHaveBeenCalledTimes(3);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it("should respect route-specific rate limits over API-level limits", async () => {
|
|
79
|
+
const apiName = "testRateApi4";
|
|
80
|
+
const apiUrl = "https://example.com";
|
|
81
|
+
const routeName1 = "testRateRoute4A";
|
|
82
|
+
const routeName2 = "testRateRoute4B";
|
|
83
|
+
|
|
84
|
+
Api.create(apiName, apiUrl, () => {
|
|
85
|
+
Route.get(routeName1, "/test1").withRate({ limit: 1, duration: 10 }); // Limite plus stricte
|
|
86
|
+
Route.get(routeName2, "/test2"); // Utilise la limite de l'API
|
|
87
|
+
}).withRate({ limit: 5, duration: 10 });
|
|
88
|
+
|
|
89
|
+
// La première route a une limite de 1
|
|
90
|
+
await Klaim[apiName][routeName1]();
|
|
91
|
+
await expect(Klaim[apiName][routeName1]()).rejects.toThrow(/Rate limit exceeded/);
|
|
92
|
+
|
|
93
|
+
// La deuxième route utilise la limite de l'API (5)
|
|
94
|
+
await Klaim[apiName][routeName2]();
|
|
95
|
+
await Klaim[apiName][routeName2]();
|
|
96
|
+
await Klaim[apiName][routeName2]();
|
|
97
|
+
await Klaim[apiName][routeName2]();
|
|
98
|
+
await Klaim[apiName][routeName2]();
|
|
99
|
+
await expect(Klaim[apiName][routeName2]()).rejects.toThrow(/Rate limit exceeded/);
|
|
100
|
+
|
|
101
|
+
// Vérifier que fetch a été appelé 6 fois (1 + 5)
|
|
102
|
+
expect(fetch).toHaveBeenCalledTimes(6);
|
|
103
|
+
});
|
|
104
|
+
});
|