json-rest-api 0.1.1 → 1.0.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 +392 -130
- package/TODO.todo +113 -0
- package/api.js +628 -0
- package/docs/API.md +1306 -0
- package/docs/FIXES_APPLIED.md +59 -0
- package/docs/GUIDES.md +1792 -0
- package/docs/RESOURCES_IN_EXPRESS.md +456 -0
- package/docs/example-versioning.js +168 -0
- package/docs/example.js +195 -0
- package/index.js +58 -207
- package/package.json +15 -25
- package/plugins/API guide.md +364 -0
- package/plugins/api-registry.js +142 -0
- package/plugins/api-versioning.md +135 -0
- package/plugins/http.js +273 -0
- package/plugins/logging.js +255 -0
- package/plugins/memory.js +182 -0
- package/plugins/mysql.js +753 -0
- package/plugins/openapi.js +401 -0
- package/plugins/positioning.js +220 -0
- package/plugins/security.js +261 -0
- package/plugins/validation.js +173 -0
- package/plugins/versioning.js +288 -0
- package/resource-helper.js +79 -0
- package/schema.js +481 -0
- package/test-basic.js +107 -0
- package/.jshintrc +0 -48
- package/.npmignore +0 -15
- package/LICENSE +0 -20
- package/examples/client.js +0 -18
- package/examples/client2.js +0 -27
- package/examples/server.js +0 -23
- package/test.js +0 -101
package/docs/GUIDES.md
ADDED
|
@@ -0,0 +1,1792 @@
|
|
|
1
|
+
# JSON REST API - Complete Guide
|
|
2
|
+
|
|
3
|
+
Welcome to the JSON REST APIs! This guide will take you from zero to hero, one step at a time. We'll build a complete task management system to learn every feature of this library.
|
|
4
|
+
|
|
5
|
+
## Table of Contents
|
|
6
|
+
|
|
7
|
+
1. [Quick Start Guide](#quick-start-guide)
|
|
8
|
+
2. [Core Concepts Tutorial](#core-concepts-tutorial)
|
|
9
|
+
3. [Plugin Tutorials](#plugin-tutorials)
|
|
10
|
+
4. [Advanced Features](#advanced-features)
|
|
11
|
+
|
|
12
|
+
---
|
|
13
|
+
|
|
14
|
+
# Quick Start Guide
|
|
15
|
+
|
|
16
|
+
Let's build a simple task management API in 10 minutes!
|
|
17
|
+
|
|
18
|
+
## Step 1: Installation
|
|
19
|
+
|
|
20
|
+
```bash
|
|
21
|
+
npm init -y
|
|
22
|
+
npm install express mysql2
|
|
23
|
+
npm install jsonrestapi
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
## Step 2: Create Your First API
|
|
27
|
+
|
|
28
|
+
Create a file called `server.js`:
|
|
29
|
+
|
|
30
|
+
```javascript
|
|
31
|
+
import express from 'express';
|
|
32
|
+
import { createApi, Schema } from 'jsonrestapi';
|
|
33
|
+
|
|
34
|
+
// Create Express app
|
|
35
|
+
const app = express();
|
|
36
|
+
|
|
37
|
+
// Create your API with a name and version
|
|
38
|
+
const api = createApi({
|
|
39
|
+
name: 'taskmanager',
|
|
40
|
+
version: '1.0.0',
|
|
41
|
+
storage: 'memory', // We'll use memory for now
|
|
42
|
+
http: { basePath: '/api' } // This automatically adds HTTPPlugin!
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
// Define a schema for tasks
|
|
46
|
+
const taskSchema = new Schema({
|
|
47
|
+
id: { type: 'id' },
|
|
48
|
+
title: { type: 'string', required: true, min: 1, max: 200 },
|
|
49
|
+
description: { type: 'string', max: 1000 },
|
|
50
|
+
completed: { type: 'boolean', default: false },
|
|
51
|
+
priority: { type: 'string', default: 'medium' },
|
|
52
|
+
createdAt: { type: 'timestamp', default: () => Date.now() }
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
// Register the schema
|
|
56
|
+
api.addResource('tasks', taskSchema);
|
|
57
|
+
|
|
58
|
+
// Mount the API on Express
|
|
59
|
+
api.mount(app);
|
|
60
|
+
|
|
61
|
+
// Start the server
|
|
62
|
+
app.listen(3000, () => {
|
|
63
|
+
console.log('🚀 API running at http://localhost:3000');
|
|
64
|
+
console.log('📝 Try: GET http://localhost:3000/api/1.0.0/tasks');
|
|
65
|
+
});
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
### 💡 Behind the Scenes: What createApi Does
|
|
69
|
+
|
|
70
|
+
The `createApi()` helper is a convenience function that automatically configures plugins for you. Here's what the above code would look like WITHOUT the helper:
|
|
71
|
+
|
|
72
|
+
```javascript
|
|
73
|
+
import express from 'express';
|
|
74
|
+
import {
|
|
75
|
+
Api,
|
|
76
|
+
Schema,
|
|
77
|
+
ValidationPlugin,
|
|
78
|
+
MemoryPlugin,
|
|
79
|
+
HTTPPlugin
|
|
80
|
+
} from 'jsonrestapi';
|
|
81
|
+
|
|
82
|
+
const app = express();
|
|
83
|
+
|
|
84
|
+
// Manual approach - full control over plugins
|
|
85
|
+
const api = new Api({
|
|
86
|
+
name: 'taskmanager',
|
|
87
|
+
version: '1.0.0'
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
// Manually add each plugin
|
|
91
|
+
api.use(ValidationPlugin); // Always needed for schemas
|
|
92
|
+
api.use(MemoryPlugin); // For in-memory storage
|
|
93
|
+
api.use(HTTPPlugin, { basePath: '/api' }); // For REST endpoints
|
|
94
|
+
|
|
95
|
+
// Rest is the same...
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
The `createApi()` helper automatically adds plugins based on your options:
|
|
99
|
+
- `storage: 'memory'` → adds MemoryPlugin
|
|
100
|
+
- `storage: 'mysql'` → adds MySQLPlugin
|
|
101
|
+
- `http: { ... }` → adds HTTPPlugin
|
|
102
|
+
- `positioning: { ... }` → adds PositioningPlugin
|
|
103
|
+
- `versioning: { ... }` → adds VersioningPlugin
|
|
104
|
+
- ValidationPlugin is always added (unless `validation: false`)
|
|
105
|
+
|
|
106
|
+
Use `createApi()` for quick setup, or use `Api` directly when you need full control!
|
|
107
|
+
|
|
108
|
+
## Step 3: Test Your API
|
|
109
|
+
|
|
110
|
+
Run your server:
|
|
111
|
+
```bash
|
|
112
|
+
node server.js
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
Now you can:
|
|
116
|
+
|
|
117
|
+
```bash
|
|
118
|
+
# Create a task
|
|
119
|
+
curl -X POST http://localhost:3000/api/1.0.0/tasks \
|
|
120
|
+
-H "Content-Type: application/json" \
|
|
121
|
+
-d '{"data": {"type": "tasks", "attributes": {"title": "Learn JSON REST API"}}}'
|
|
122
|
+
|
|
123
|
+
# List all tasks
|
|
124
|
+
curl http://localhost:3000/api/1.0.0/tasks
|
|
125
|
+
|
|
126
|
+
# Get a specific task
|
|
127
|
+
curl http://localhost:3000/api/1.0.0/tasks/1
|
|
128
|
+
|
|
129
|
+
# Update a task
|
|
130
|
+
curl -X PATCH http://localhost:3000/api/1.0.0/tasks/1 \
|
|
131
|
+
-H "Content-Type: application/json" \
|
|
132
|
+
-d '{"data": {"attributes": {"completed": true}}}'
|
|
133
|
+
|
|
134
|
+
# Delete a task
|
|
135
|
+
curl -X DELETE http://localhost:3000/api/1.0.0/tasks/1
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
## Step 4: Add MySQL Storage
|
|
139
|
+
|
|
140
|
+
Let's make it persistent! Update your `server.js`:
|
|
141
|
+
|
|
142
|
+
```javascript
|
|
143
|
+
import { createApi, Schema, MySQLPlugin } from 'jsonrestapi';
|
|
144
|
+
|
|
145
|
+
// Create API with MySQL
|
|
146
|
+
const api = createApi({
|
|
147
|
+
name: 'taskmanager',
|
|
148
|
+
version: '1.0.0',
|
|
149
|
+
storage: 'mysql',
|
|
150
|
+
mysql: {
|
|
151
|
+
connection: {
|
|
152
|
+
host: 'localhost',
|
|
153
|
+
user: 'root',
|
|
154
|
+
password: 'password',
|
|
155
|
+
database: 'taskmanager'
|
|
156
|
+
}
|
|
157
|
+
},
|
|
158
|
+
http: { basePath: '/api' }
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
// Same schema as before
|
|
162
|
+
const taskSchema = new Schema({
|
|
163
|
+
id: { type: 'id' },
|
|
164
|
+
title: { type: 'string', required: true, min: 1, max: 200 },
|
|
165
|
+
description: { type: 'string', max: 1000 },
|
|
166
|
+
completed: { type: 'boolean', default: false },
|
|
167
|
+
priority: { type: 'string', default: 'medium' },
|
|
168
|
+
createdAt: { type: 'timestamp', default: () => Date.now() }
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
api.addResource('tasks', taskSchema);
|
|
172
|
+
|
|
173
|
+
// Magic! Sync the database automatically
|
|
174
|
+
await api.syncSchema(taskSchema, 'tasks');
|
|
175
|
+
|
|
176
|
+
api.mount(app);
|
|
177
|
+
```
|
|
178
|
+
|
|
179
|
+
## Step 5: Add Users with Relationships
|
|
180
|
+
|
|
181
|
+
Let's add a users table:
|
|
182
|
+
|
|
183
|
+
```javascript
|
|
184
|
+
// User schema
|
|
185
|
+
const userSchema = new Schema({
|
|
186
|
+
id: { type: 'id' },
|
|
187
|
+
username: { type: 'string', required: true, min: 3, max: 50 },
|
|
188
|
+
email: { type: 'string', required: true, lowercase: true },
|
|
189
|
+
passwordHash: { type: 'string', required: true },
|
|
190
|
+
createdAt: { type: 'timestamp', default: () => Date.now() }
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
// Update task schema to include user
|
|
194
|
+
const taskSchema = new Schema({
|
|
195
|
+
id: { type: 'id' },
|
|
196
|
+
userId: { type: 'id', required: true }, // Link to user
|
|
197
|
+
title: { type: 'string', required: true, min: 1, max: 200 },
|
|
198
|
+
description: { type: 'string', max: 1000 },
|
|
199
|
+
completed: { type: 'boolean', default: false },
|
|
200
|
+
priority: { type: 'string', default: 'medium' },
|
|
201
|
+
createdAt: { type: 'timestamp', default: () => Date.now() }
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
// Register both schemas
|
|
205
|
+
api.addResource('users', userSchema);
|
|
206
|
+
api.addResource('tasks', taskSchema);
|
|
207
|
+
|
|
208
|
+
// Sync both tables
|
|
209
|
+
await api.syncSchema(userSchema, 'users');
|
|
210
|
+
await api.syncSchema(taskSchema, 'tasks');
|
|
211
|
+
```
|
|
212
|
+
|
|
213
|
+
Congratulations! You now have a working API with two related tables! 🎉
|
|
214
|
+
|
|
215
|
+
---
|
|
216
|
+
|
|
217
|
+
# Core Concepts Tutorial
|
|
218
|
+
|
|
219
|
+
## Understanding Schemas
|
|
220
|
+
|
|
221
|
+
Schemas are the heart of your API. They define what your data looks like and how it's validated.
|
|
222
|
+
|
|
223
|
+
### Basic Types
|
|
224
|
+
|
|
225
|
+
```javascript
|
|
226
|
+
const productSchema = new Schema({
|
|
227
|
+
// Numeric types
|
|
228
|
+
id: { type: 'id' }, // Auto-incrementing ID
|
|
229
|
+
price: { type: 'number', min: 0 }, // Positive numbers only
|
|
230
|
+
stock: { type: 'number', default: 0 }, // Default value
|
|
231
|
+
|
|
232
|
+
// String types
|
|
233
|
+
name: { type: 'string', required: true },
|
|
234
|
+
sku: { type: 'string', uppercase: true }, // Auto-uppercase
|
|
235
|
+
description: { type: 'string', max: 1000 },
|
|
236
|
+
|
|
237
|
+
// Boolean
|
|
238
|
+
active: { type: 'boolean', default: true },
|
|
239
|
+
featured: { type: 'boolean' },
|
|
240
|
+
|
|
241
|
+
// Dates
|
|
242
|
+
createdAt: { type: 'timestamp' },
|
|
243
|
+
launchDate: { type: 'date' }, // YYYY-MM-DD
|
|
244
|
+
lastModified: { type: 'dateTime' }, // Full datetime
|
|
245
|
+
|
|
246
|
+
// Complex types
|
|
247
|
+
tags: { type: 'array', default: [] },
|
|
248
|
+
metadata: { type: 'object' },
|
|
249
|
+
|
|
250
|
+
// Special types
|
|
251
|
+
image: { type: 'blob' },
|
|
252
|
+
config: { type: 'serialize' } // Handles circular references
|
|
253
|
+
});
|
|
254
|
+
```
|
|
255
|
+
|
|
256
|
+
### Advanced Validation
|
|
257
|
+
|
|
258
|
+
```javascript
|
|
259
|
+
const advancedSchema = new Schema({
|
|
260
|
+
// Custom validation
|
|
261
|
+
email: {
|
|
262
|
+
type: 'string',
|
|
263
|
+
required: true,
|
|
264
|
+
lowercase: true,
|
|
265
|
+
validator: (value) => {
|
|
266
|
+
if (!value.includes('@')) return 'Invalid email';
|
|
267
|
+
}
|
|
268
|
+
},
|
|
269
|
+
|
|
270
|
+
// Conditional defaults
|
|
271
|
+
code: {
|
|
272
|
+
type: 'string',
|
|
273
|
+
default: () => 'PROD-' + Date.now()
|
|
274
|
+
},
|
|
275
|
+
|
|
276
|
+
// Trimming and length
|
|
277
|
+
title: {
|
|
278
|
+
type: 'string',
|
|
279
|
+
trim: 100, // Max 100 chars
|
|
280
|
+
notEmpty: true // Can't be empty string
|
|
281
|
+
},
|
|
282
|
+
|
|
283
|
+
// Null handling
|
|
284
|
+
notes: {
|
|
285
|
+
type: 'string',
|
|
286
|
+
canBeNull: true, // Allows null
|
|
287
|
+
emptyAsNull: true // "" becomes null
|
|
288
|
+
}
|
|
289
|
+
});
|
|
290
|
+
```
|
|
291
|
+
|
|
292
|
+
## Understanding the Api Class
|
|
293
|
+
|
|
294
|
+
The Api class is your central hub:
|
|
295
|
+
|
|
296
|
+
```javascript
|
|
297
|
+
// Create with options
|
|
298
|
+
const api = new Api({
|
|
299
|
+
name: 'myapp',
|
|
300
|
+
version: '1.0.0',
|
|
301
|
+
idProperty: 'id' // Default is 'id'
|
|
302
|
+
});
|
|
303
|
+
|
|
304
|
+
// Add plugins
|
|
305
|
+
api
|
|
306
|
+
.use(ValidationPlugin)
|
|
307
|
+
.use(MySQLPlugin, { connection: dbConfig })
|
|
308
|
+
.use(HTTPPlugin, { basePath: '/api' });
|
|
309
|
+
|
|
310
|
+
// Register schemas
|
|
311
|
+
api.addResource('products', productSchema);
|
|
312
|
+
|
|
313
|
+
// Use programmatically - OLD WAY (still works)
|
|
314
|
+
const product = await api.insert({
|
|
315
|
+
name: 'Awesome Widget',
|
|
316
|
+
price: 29.99
|
|
317
|
+
}, { type: 'products' });
|
|
318
|
+
|
|
319
|
+
// NEW WAY - Much more intuitive!
|
|
320
|
+
const product = await api.resources.products.create({
|
|
321
|
+
name: 'Awesome Widget',
|
|
322
|
+
price: 29.99
|
|
323
|
+
});
|
|
324
|
+
|
|
325
|
+
// All operations available
|
|
326
|
+
const found = await api.resources.products.get(123);
|
|
327
|
+
const updated = await api.resources.products.update(123, { price: 39.99 });
|
|
328
|
+
const results = await api.resources.products.query({ filter: { active: true } });
|
|
329
|
+
await api.resources.products.delete(123);
|
|
330
|
+
```
|
|
331
|
+
|
|
332
|
+
## Understanding Hooks
|
|
333
|
+
|
|
334
|
+
Hooks let you inject custom logic at any point:
|
|
335
|
+
|
|
336
|
+
```javascript
|
|
337
|
+
// Before validation
|
|
338
|
+
api.hook('beforeValidate', async (context) => {
|
|
339
|
+
// context.method: 'insert', 'update', 'delete', 'get', 'query'
|
|
340
|
+
// context.data: The data being processed
|
|
341
|
+
// context.options: Options passed to the method
|
|
342
|
+
|
|
343
|
+
if (context.method === 'insert') {
|
|
344
|
+
context.data.createdBy = context.options.userId;
|
|
345
|
+
}
|
|
346
|
+
});
|
|
347
|
+
|
|
348
|
+
// After validation
|
|
349
|
+
api.hook('afterValidate', async (context) => {
|
|
350
|
+
// context.errors: Array of validation errors
|
|
351
|
+
|
|
352
|
+
// Add custom validation
|
|
353
|
+
if (context.data.price > 1000 && !context.data.approved) {
|
|
354
|
+
context.errors.push({
|
|
355
|
+
field: 'price',
|
|
356
|
+
message: 'High-value items need approval',
|
|
357
|
+
code: 'NEEDS_APPROVAL'
|
|
358
|
+
});
|
|
359
|
+
}
|
|
360
|
+
});
|
|
361
|
+
|
|
362
|
+
// Transform results
|
|
363
|
+
api.hook('transformResult', async (context) => {
|
|
364
|
+
// context.result: The data being returned
|
|
365
|
+
|
|
366
|
+
if (context.result && context.options.type === 'products') {
|
|
367
|
+
// Add computed field
|
|
368
|
+
context.result.displayPrice = `$${context.result.price.toFixed(2)}`;
|
|
369
|
+
}
|
|
370
|
+
});
|
|
371
|
+
```
|
|
372
|
+
|
|
373
|
+
---
|
|
374
|
+
|
|
375
|
+
# Plugin Tutorials
|
|
376
|
+
|
|
377
|
+
## ValidationPlugin Tutorial
|
|
378
|
+
|
|
379
|
+
The ValidationPlugin is automatically included and provides schema validation.
|
|
380
|
+
|
|
381
|
+
### Step 1: Basic Validation
|
|
382
|
+
|
|
383
|
+
```javascript
|
|
384
|
+
const api = createApi({ storage: 'memory' });
|
|
385
|
+
|
|
386
|
+
// Define strict schema
|
|
387
|
+
const userSchema = new Schema({
|
|
388
|
+
username: {
|
|
389
|
+
type: 'string',
|
|
390
|
+
required: true,
|
|
391
|
+
min: 3,
|
|
392
|
+
max: 20,
|
|
393
|
+
pattern: /^[a-zA-Z0-9_]+$/ // Alphanumeric + underscore only
|
|
394
|
+
},
|
|
395
|
+
age: {
|
|
396
|
+
type: 'number',
|
|
397
|
+
min: 13,
|
|
398
|
+
max: 120
|
|
399
|
+
}
|
|
400
|
+
});
|
|
401
|
+
|
|
402
|
+
api.addResource('users', userSchema);
|
|
403
|
+
|
|
404
|
+
// This will fail validation
|
|
405
|
+
try {
|
|
406
|
+
await api.insert({
|
|
407
|
+
username: 'a', // Too short!
|
|
408
|
+
age: 200 // Too old!
|
|
409
|
+
}, { type: 'users' });
|
|
410
|
+
} catch (error) {
|
|
411
|
+
console.log(error.errors);
|
|
412
|
+
// [
|
|
413
|
+
// { field: 'username', message: 'username is too short' },
|
|
414
|
+
// { field: 'age', message: 'age is too high' }
|
|
415
|
+
// ]
|
|
416
|
+
}
|
|
417
|
+
```
|
|
418
|
+
|
|
419
|
+
### Step 2: Custom Validators
|
|
420
|
+
|
|
421
|
+
```javascript
|
|
422
|
+
const schema = new Schema({
|
|
423
|
+
password: {
|
|
424
|
+
type: 'string',
|
|
425
|
+
required: true,
|
|
426
|
+
validator: (value) => {
|
|
427
|
+
if (value.length < 8) return 'Password must be at least 8 characters';
|
|
428
|
+
if (!/[A-Z]/.test(value)) return 'Password must contain uppercase';
|
|
429
|
+
if (!/[0-9]/.test(value)) return 'Password must contain number';
|
|
430
|
+
}
|
|
431
|
+
},
|
|
432
|
+
confirmPassword: {
|
|
433
|
+
type: 'string',
|
|
434
|
+
required: true
|
|
435
|
+
}
|
|
436
|
+
});
|
|
437
|
+
|
|
438
|
+
// Cross-field validation
|
|
439
|
+
api.hook('afterValidate', async (context) => {
|
|
440
|
+
if (context.data.password !== context.data.confirmPassword) {
|
|
441
|
+
context.errors.push({
|
|
442
|
+
field: 'confirmPassword',
|
|
443
|
+
message: 'Passwords do not match'
|
|
444
|
+
});
|
|
445
|
+
}
|
|
446
|
+
});
|
|
447
|
+
```
|
|
448
|
+
|
|
449
|
+
### Step 3: Conditional Validation
|
|
450
|
+
|
|
451
|
+
```javascript
|
|
452
|
+
const orderSchema = new Schema({
|
|
453
|
+
type: { type: 'string', required: true },
|
|
454
|
+
amount: { type: 'number', required: true },
|
|
455
|
+
giftMessage: { type: 'string' }
|
|
456
|
+
});
|
|
457
|
+
|
|
458
|
+
api.hook('afterValidate', async (context) => {
|
|
459
|
+
const { data } = context;
|
|
460
|
+
|
|
461
|
+
// Gift orders require a message
|
|
462
|
+
if (data.type === 'gift' && !data.giftMessage) {
|
|
463
|
+
context.errors.push({
|
|
464
|
+
field: 'giftMessage',
|
|
465
|
+
message: 'Gift orders require a message'
|
|
466
|
+
});
|
|
467
|
+
}
|
|
468
|
+
});
|
|
469
|
+
```
|
|
470
|
+
|
|
471
|
+
### 🚀 Pro Tip: Cleaner Resource Definition
|
|
472
|
+
|
|
473
|
+
Instead of adding hooks separately, you can define them with the resource:
|
|
474
|
+
|
|
475
|
+
```javascript
|
|
476
|
+
const userSchema = new Schema({
|
|
477
|
+
email: { type: 'string', required: true, lowercase: true },
|
|
478
|
+
password: { type: 'string', required: true },
|
|
479
|
+
role: { type: 'string', default: 'user' }
|
|
480
|
+
});
|
|
481
|
+
|
|
482
|
+
const userHooks = {
|
|
483
|
+
async afterValidate(context) {
|
|
484
|
+
const { data, method, errors } = context;
|
|
485
|
+
|
|
486
|
+
// Check email uniqueness
|
|
487
|
+
if (method === 'insert' || method === 'update') {
|
|
488
|
+
const existing = await context.api.query({
|
|
489
|
+
filter: { email: data.email }
|
|
490
|
+
}, { type: 'users' });
|
|
491
|
+
|
|
492
|
+
if (existing.meta.total > 0) {
|
|
493
|
+
errors.push({
|
|
494
|
+
field: 'email',
|
|
495
|
+
message: 'Email already exists'
|
|
496
|
+
});
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
},
|
|
500
|
+
|
|
501
|
+
async beforeInsert(context) {
|
|
502
|
+
// Hash password
|
|
503
|
+
const bcrypt = await import('bcrypt');
|
|
504
|
+
context.data.password = await bcrypt.hash(context.data.password, 10);
|
|
505
|
+
},
|
|
506
|
+
|
|
507
|
+
async transformResult(context) {
|
|
508
|
+
// Never return password
|
|
509
|
+
if (context.result?.attributes) {
|
|
510
|
+
delete context.result.attributes.password;
|
|
511
|
+
}
|
|
512
|
+
}
|
|
513
|
+
};
|
|
514
|
+
|
|
515
|
+
// Add resource with schema AND hooks together!
|
|
516
|
+
api.addResource('users', userSchema, userHooks);
|
|
517
|
+
```
|
|
518
|
+
|
|
519
|
+
This approach keeps all resource logic in one place, making it easier to maintain!
|
|
520
|
+
|
|
521
|
+
## Modular Resources (Advanced)
|
|
522
|
+
|
|
523
|
+
When your API grows, you'll want to organize resources into separate files. Here's the challenge and solution:
|
|
524
|
+
|
|
525
|
+
### The Challenge: Why Not Use createApi() Everywhere?
|
|
526
|
+
|
|
527
|
+
You might think to do this in each resource file:
|
|
528
|
+
```javascript
|
|
529
|
+
// ❌ DON'T DO THIS - api/1.0.0/users.js
|
|
530
|
+
const api = createApi({ ... }); // Creates NEW api instance
|
|
531
|
+
api.addResource('users', schema);
|
|
532
|
+
export default api;
|
|
533
|
+
|
|
534
|
+
// ❌ DON'T DO THIS - api/1.0.0/products.js
|
|
535
|
+
const api = createApi({ ... }); // Creates ANOTHER api instance!
|
|
536
|
+
api.addResource('products', schema);
|
|
537
|
+
export default api;
|
|
538
|
+
```
|
|
539
|
+
|
|
540
|
+
**Problems:**
|
|
541
|
+
- Each file creates a separate API instance
|
|
542
|
+
- Multiple database connections (waste of resources)
|
|
543
|
+
- Resources can't interact with each other
|
|
544
|
+
- Configuration is duplicated everywhere
|
|
545
|
+
|
|
546
|
+
### The Solution: Shared API Instances
|
|
547
|
+
|
|
548
|
+
Resources in the same version should SHARE one API instance:
|
|
549
|
+
|
|
550
|
+
```javascript
|
|
551
|
+
// ✅ DO THIS - api/1.0.0/users.js
|
|
552
|
+
import { Api, Schema } from 'jsonrestapi';
|
|
553
|
+
|
|
554
|
+
// Get existing or create shared instance for v1.0.0
|
|
555
|
+
const api = Api.get('myapp', '1.0.0') || new Api({
|
|
556
|
+
name: 'myapp',
|
|
557
|
+
version: '1.0.0'
|
|
558
|
+
});
|
|
559
|
+
|
|
560
|
+
// Configure plugins only if not already done
|
|
561
|
+
if (!api.hasPlugin(HTTPPlugin)) {
|
|
562
|
+
api.use(HTTPPlugin, { basePath: '/api/1.0.0' });
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
// Define schema and hooks together
|
|
566
|
+
const userSchema = new Schema({ ... });
|
|
567
|
+
const userHooks = {
|
|
568
|
+
async beforeInsert(context) {
|
|
569
|
+
// Hash passwords, etc.
|
|
570
|
+
}
|
|
571
|
+
};
|
|
572
|
+
|
|
573
|
+
// Add resource with hooks
|
|
574
|
+
api.addResource('users', userSchema, userHooks);
|
|
575
|
+
|
|
576
|
+
export default api;
|
|
577
|
+
```
|
|
578
|
+
|
|
579
|
+
### Even Better: Using defineResource Helper
|
|
580
|
+
|
|
581
|
+
The library includes a helper that manages this for you:
|
|
582
|
+
|
|
583
|
+
```javascript
|
|
584
|
+
// api/config.js - Shared configuration
|
|
585
|
+
export const apiConfig = {
|
|
586
|
+
name: 'myapp',
|
|
587
|
+
mysql: {
|
|
588
|
+
connections: [{
|
|
589
|
+
name: 'main',
|
|
590
|
+
config: { host: 'localhost', ... }
|
|
591
|
+
}]
|
|
592
|
+
}
|
|
593
|
+
};
|
|
594
|
+
|
|
595
|
+
// api/1.0.0/users.js - Clean resource file
|
|
596
|
+
import { Schema, defineResource } from 'jsonrestapi/resource-helper.js';
|
|
597
|
+
import { apiConfig } from '../config.js';
|
|
598
|
+
|
|
599
|
+
export default defineResource('1.0.0', 'users', {
|
|
600
|
+
api: apiConfig, // Shared config
|
|
601
|
+
|
|
602
|
+
schema: new Schema({
|
|
603
|
+
id: { type: 'id' },
|
|
604
|
+
name: { type: 'string', required: true },
|
|
605
|
+
email: { type: 'string', required: true }
|
|
606
|
+
}),
|
|
607
|
+
|
|
608
|
+
hooks: {
|
|
609
|
+
async beforeInsert(context) {
|
|
610
|
+
// Your logic here
|
|
611
|
+
}
|
|
612
|
+
}
|
|
613
|
+
});
|
|
614
|
+
```
|
|
615
|
+
|
|
616
|
+
### Loading Resources in Your Server
|
|
617
|
+
|
|
618
|
+
```javascript
|
|
619
|
+
// server.js - Super clean!
|
|
620
|
+
import express from 'express';
|
|
621
|
+
const app = express();
|
|
622
|
+
|
|
623
|
+
// Option 1: Manual loading
|
|
624
|
+
const apis = [
|
|
625
|
+
await import('./api/1.0.0/users.js'),
|
|
626
|
+
await import('./api/1.0.0/products.js'),
|
|
627
|
+
];
|
|
628
|
+
apis.forEach(module => module.default.mount(app));
|
|
629
|
+
|
|
630
|
+
// Option 2: Auto-loading (like the old library!)
|
|
631
|
+
import { loadResourcesFromPath } from './load-resources.js';
|
|
632
|
+
await loadResourcesFromPath(join(__dirname, 'api'), app);
|
|
633
|
+
|
|
634
|
+
app.listen(3000);
|
|
635
|
+
```
|
|
636
|
+
|
|
637
|
+
### Key Concepts to Remember
|
|
638
|
+
|
|
639
|
+
1. **createApi()** = Creates a NEW api instance (use for simple, single-file APIs)
|
|
640
|
+
2. **Api.get() || new Api()** = Gets or creates a SHARED instance (use in resource files)
|
|
641
|
+
3. **defineResource()** = Helper that does the sharing for you (recommended)
|
|
642
|
+
4. **One API instance per version** = All resources in v1.0.0 share the same API
|
|
643
|
+
5. **api.resources.{name}** = NEW intuitive way to access resources!
|
|
644
|
+
|
|
645
|
+
This way your server stays clean (one line per resource), resources are modular, and everything shares the same database connections and configuration!
|
|
646
|
+
|
|
647
|
+
## Programmatic API Usage
|
|
648
|
+
|
|
649
|
+
The new programmatic API makes working with resources intuitive and chainable:
|
|
650
|
+
|
|
651
|
+
### Basic CRUD Operations
|
|
652
|
+
|
|
653
|
+
```javascript
|
|
654
|
+
// After registering a resource
|
|
655
|
+
api.addResource('users', userSchema);
|
|
656
|
+
|
|
657
|
+
// Create
|
|
658
|
+
const user = await api.resources.users.create({
|
|
659
|
+
username: 'johndoe',
|
|
660
|
+
email: 'john@example.com'
|
|
661
|
+
});
|
|
662
|
+
|
|
663
|
+
// Read
|
|
664
|
+
const found = await api.resources.users.get(user.data.id);
|
|
665
|
+
|
|
666
|
+
// Update
|
|
667
|
+
const updated = await api.resources.users.update(user.data.id, {
|
|
668
|
+
email: 'newemail@example.com'
|
|
669
|
+
});
|
|
670
|
+
|
|
671
|
+
// Delete
|
|
672
|
+
await api.resources.users.delete(user.data.id);
|
|
673
|
+
|
|
674
|
+
// Query
|
|
675
|
+
const results = await api.resources.users.query({
|
|
676
|
+
filter: { active: true },
|
|
677
|
+
sort: '-createdAt',
|
|
678
|
+
page: { size: 10, number: 1 }
|
|
679
|
+
});
|
|
680
|
+
```
|
|
681
|
+
|
|
682
|
+
### Batch Operations
|
|
683
|
+
|
|
684
|
+
```javascript
|
|
685
|
+
// Create multiple users at once
|
|
686
|
+
const users = await api.resources.users.batch.create([
|
|
687
|
+
{ username: 'user1', email: 'user1@example.com' },
|
|
688
|
+
{ username: 'user2', email: 'user2@example.com' },
|
|
689
|
+
{ username: 'user3', email: 'user3@example.com' }
|
|
690
|
+
]);
|
|
691
|
+
|
|
692
|
+
// Update multiple users
|
|
693
|
+
await api.resources.users.batch.update([
|
|
694
|
+
{ id: 1, data: { active: false } },
|
|
695
|
+
{ id: 2, data: { active: false } }
|
|
696
|
+
]);
|
|
697
|
+
|
|
698
|
+
// Delete multiple users
|
|
699
|
+
await api.resources.users.batch.delete([1, 2, 3]);
|
|
700
|
+
```
|
|
701
|
+
|
|
702
|
+
### Accessing Different Versions
|
|
703
|
+
|
|
704
|
+
```javascript
|
|
705
|
+
// Default: uses the current API version
|
|
706
|
+
const user = await api.resources.users.get(123);
|
|
707
|
+
|
|
708
|
+
// Access a specific version
|
|
709
|
+
const userV1 = await api.resources.users.version('1.0.0').get(123);
|
|
710
|
+
const userV2 = await api.resources.users.version('2.0.0').get(123);
|
|
711
|
+
|
|
712
|
+
// This is especially useful when migrating between versions
|
|
713
|
+
const oldData = await api.resources.users.version('1.0.0').query();
|
|
714
|
+
for (const item of oldData.data) {
|
|
715
|
+
await api.resources.users.version('2.0.0').create(migrateData(item));
|
|
716
|
+
}
|
|
717
|
+
```
|
|
718
|
+
|
|
719
|
+
### Direct Access to Schema and Hooks
|
|
720
|
+
|
|
721
|
+
```javascript
|
|
722
|
+
// Get the schema for a resource
|
|
723
|
+
const schema = api.resources.users.schema;
|
|
724
|
+
|
|
725
|
+
// Get the hooks for a resource
|
|
726
|
+
const hooks = api.resources.users.hooks;
|
|
727
|
+
|
|
728
|
+
// Check if a resource exists
|
|
729
|
+
if ('users' in api.resources) {
|
|
730
|
+
console.log('Users resource is available');
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
// List all resources
|
|
734
|
+
const allResources = Object.keys(api.resources);
|
|
735
|
+
console.log('Available resources:', allResources);
|
|
736
|
+
```
|
|
737
|
+
|
|
738
|
+
### Alternative Syntax
|
|
739
|
+
|
|
740
|
+
If you prefer function calls over property access:
|
|
741
|
+
|
|
742
|
+
```javascript
|
|
743
|
+
// Using resource() method
|
|
744
|
+
const user = await api.resource('users').get(123);
|
|
745
|
+
const posts = await api.resource('posts').query();
|
|
746
|
+
|
|
747
|
+
// This is useful when resource names are dynamic
|
|
748
|
+
const resourceName = 'users';
|
|
749
|
+
const data = await api.resource(resourceName).get(123);
|
|
750
|
+
```
|
|
751
|
+
|
|
752
|
+
## MySQLPlugin Tutorial
|
|
753
|
+
|
|
754
|
+
### Step 1: Basic Setup
|
|
755
|
+
|
|
756
|
+
```javascript
|
|
757
|
+
import { Api, MySQLPlugin, Schema } from 'jsonrestapi';
|
|
758
|
+
|
|
759
|
+
const api = new Api();
|
|
760
|
+
api.use(MySQLPlugin, {
|
|
761
|
+
connection: {
|
|
762
|
+
host: 'localhost',
|
|
763
|
+
user: 'root',
|
|
764
|
+
password: 'password',
|
|
765
|
+
database: 'myapp',
|
|
766
|
+
waitForConnections: true,
|
|
767
|
+
connectionLimit: 10,
|
|
768
|
+
queueLimit: 0
|
|
769
|
+
}
|
|
770
|
+
});
|
|
771
|
+
```
|
|
772
|
+
|
|
773
|
+
### Step 2: Schema Synchronization
|
|
774
|
+
|
|
775
|
+
```javascript
|
|
776
|
+
// Define your schema
|
|
777
|
+
const postSchema = new Schema({
|
|
778
|
+
id: { type: 'id' },
|
|
779
|
+
title: { type: 'string', required: true, max: 200 },
|
|
780
|
+
content: { type: 'string', text: true }, // Creates TEXT column
|
|
781
|
+
views: { type: 'number', default: 0 },
|
|
782
|
+
published: { type: 'boolean', default: false },
|
|
783
|
+
publishDate: { type: 'dateTime' },
|
|
784
|
+
tags: { type: 'array' }, // Stored as JSON
|
|
785
|
+
metadata: { type: 'object' } // Stored as JSON
|
|
786
|
+
});
|
|
787
|
+
|
|
788
|
+
// Sync creates/updates the table automatically!
|
|
789
|
+
await api.syncSchema(postSchema, 'posts');
|
|
790
|
+
```
|
|
791
|
+
|
|
792
|
+
### Step 3: Advanced MySQL Features
|
|
793
|
+
|
|
794
|
+
```javascript
|
|
795
|
+
// Multiple connections
|
|
796
|
+
api.use(MySQLPlugin, {
|
|
797
|
+
connections: [
|
|
798
|
+
{
|
|
799
|
+
name: 'primary',
|
|
800
|
+
config: { host: 'primary.db', ... }
|
|
801
|
+
},
|
|
802
|
+
{
|
|
803
|
+
name: 'replica',
|
|
804
|
+
config: { host: 'replica.db', ... }
|
|
805
|
+
}
|
|
806
|
+
]
|
|
807
|
+
});
|
|
808
|
+
|
|
809
|
+
// Use specific connection - OLD WAY
|
|
810
|
+
const data = await api.query({}, {
|
|
811
|
+
type: 'posts',
|
|
812
|
+
connection: 'replica' // Read from replica
|
|
813
|
+
});
|
|
814
|
+
|
|
815
|
+
// NEW WAY - Much cleaner!
|
|
816
|
+
const data = await api.resources.posts.query({}, {
|
|
817
|
+
connection: 'replica' // Read from replica
|
|
818
|
+
});
|
|
819
|
+
|
|
820
|
+
// Direct SQL access when needed
|
|
821
|
+
const { pool } = api.getConnection('primary');
|
|
822
|
+
const [rows] = await pool.query(
|
|
823
|
+
'SELECT * FROM posts WHERE MATCH(title, content) AGAINST(?)',
|
|
824
|
+
['search term']
|
|
825
|
+
);
|
|
826
|
+
```
|
|
827
|
+
|
|
828
|
+
### Step 4: Transactions
|
|
829
|
+
|
|
830
|
+
```javascript
|
|
831
|
+
const { pool } = api.getConnection();
|
|
832
|
+
const connection = await pool.getConnection();
|
|
833
|
+
|
|
834
|
+
try {
|
|
835
|
+
await connection.beginTransaction();
|
|
836
|
+
|
|
837
|
+
// Create author
|
|
838
|
+
const author = await api.insert({
|
|
839
|
+
name: 'John Doe',
|
|
840
|
+
email: 'john@example.com'
|
|
841
|
+
}, {
|
|
842
|
+
type: 'authors',
|
|
843
|
+
connection // Use transaction connection
|
|
844
|
+
});
|
|
845
|
+
|
|
846
|
+
// Create post
|
|
847
|
+
const post = await api.insert({
|
|
848
|
+
authorId: author.data.id,
|
|
849
|
+
title: 'My First Post',
|
|
850
|
+
content: 'Hello World!'
|
|
851
|
+
}, {
|
|
852
|
+
type: 'posts',
|
|
853
|
+
connection
|
|
854
|
+
});
|
|
855
|
+
|
|
856
|
+
await connection.commit();
|
|
857
|
+
console.log('Transaction successful!');
|
|
858
|
+
|
|
859
|
+
} catch (error) {
|
|
860
|
+
await connection.rollback();
|
|
861
|
+
console.error('Transaction failed:', error);
|
|
862
|
+
} finally {
|
|
863
|
+
connection.release();
|
|
864
|
+
}
|
|
865
|
+
```
|
|
866
|
+
|
|
867
|
+
## HTTPPlugin Tutorial
|
|
868
|
+
|
|
869
|
+
### Step 1: Basic HTTP Setup
|
|
870
|
+
|
|
871
|
+
```javascript
|
|
872
|
+
import express from 'express';
|
|
873
|
+
|
|
874
|
+
const app = express();
|
|
875
|
+
const api = createApi({
|
|
876
|
+
name: 'blog',
|
|
877
|
+
version: '1.0.0',
|
|
878
|
+
storage: 'memory',
|
|
879
|
+
http: {
|
|
880
|
+
basePath: '/api',
|
|
881
|
+
app // Pass Express app directly
|
|
882
|
+
}
|
|
883
|
+
});
|
|
884
|
+
|
|
885
|
+
// That's it! Your API is now available at:
|
|
886
|
+
// GET /api/1.0.0/posts
|
|
887
|
+
// GET /api/1.0.0/posts/:id
|
|
888
|
+
// POST /api/1.0.0/posts
|
|
889
|
+
// PATCH /api/1.0.0/posts/:id
|
|
890
|
+
// DELETE /api/1.0.0/posts/:id
|
|
891
|
+
```
|
|
892
|
+
|
|
893
|
+
### Step 2: Query Parameters
|
|
894
|
+
|
|
895
|
+
```javascript
|
|
896
|
+
// Filtering
|
|
897
|
+
GET /api/1.0.0/posts?filter[published]=true&filter[category]=tech
|
|
898
|
+
|
|
899
|
+
// Sorting (-prefix for descending)
|
|
900
|
+
GET /api/1.0.0/posts?sort=-createdAt,title
|
|
901
|
+
|
|
902
|
+
// Pagination
|
|
903
|
+
GET /api/1.0.0/posts?page[size]=10&page[number]=2
|
|
904
|
+
|
|
905
|
+
// Combined
|
|
906
|
+
GET /api/1.0.0/posts?filter[published]=true&sort=-views&page[size]=5
|
|
907
|
+
```
|
|
908
|
+
|
|
909
|
+
### Step 3: JSON:API Format
|
|
910
|
+
|
|
911
|
+
The HTTP plugin follows JSON:API specification:
|
|
912
|
+
|
|
913
|
+
```javascript
|
|
914
|
+
// Request body for POST/PATCH
|
|
915
|
+
{
|
|
916
|
+
"data": {
|
|
917
|
+
"type": "posts",
|
|
918
|
+
"attributes": {
|
|
919
|
+
"title": "Hello World",
|
|
920
|
+
"content": "My first post"
|
|
921
|
+
}
|
|
922
|
+
}
|
|
923
|
+
}
|
|
924
|
+
|
|
925
|
+
// Response format
|
|
926
|
+
{
|
|
927
|
+
"data": {
|
|
928
|
+
"id": "123",
|
|
929
|
+
"type": "posts",
|
|
930
|
+
"attributes": {
|
|
931
|
+
"title": "Hello World",
|
|
932
|
+
"content": "My first post",
|
|
933
|
+
"createdAt": 1639094400000
|
|
934
|
+
}
|
|
935
|
+
}
|
|
936
|
+
}
|
|
937
|
+
|
|
938
|
+
// Collection response
|
|
939
|
+
{
|
|
940
|
+
"data": [
|
|
941
|
+
{ "id": "1", "type": "posts", "attributes": {...} },
|
|
942
|
+
{ "id": "2", "type": "posts", "attributes": {...} }
|
|
943
|
+
],
|
|
944
|
+
"meta": {
|
|
945
|
+
"total": 50,
|
|
946
|
+
"pageSize": 10,
|
|
947
|
+
"pageNumber": 1,
|
|
948
|
+
"totalPages": 5
|
|
949
|
+
},
|
|
950
|
+
"links": {
|
|
951
|
+
"self": "/api/1.0.0/posts?page[number]=1",
|
|
952
|
+
"next": "/api/1.0.0/posts?page[number]=2",
|
|
953
|
+
"last": "/api/1.0.0/posts?page[number]=5"
|
|
954
|
+
}
|
|
955
|
+
}
|
|
956
|
+
```
|
|
957
|
+
|
|
958
|
+
### Step 4: Custom Middleware
|
|
959
|
+
|
|
960
|
+
```javascript
|
|
961
|
+
// Add middleware to all routes
|
|
962
|
+
api.useMiddleware((req, res, next) => {
|
|
963
|
+
console.log(`${req.method} ${req.path}`);
|
|
964
|
+
next();
|
|
965
|
+
});
|
|
966
|
+
|
|
967
|
+
// Add middleware to specific routes
|
|
968
|
+
api.useRouteMiddleware('post', '/:type', async (req, res, next) => {
|
|
969
|
+
// Check API key for POST requests
|
|
970
|
+
if (!req.headers['x-api-key']) {
|
|
971
|
+
return res.status(401).json({
|
|
972
|
+
errors: [{ status: '401', title: 'API key required' }]
|
|
973
|
+
});
|
|
974
|
+
}
|
|
975
|
+
next();
|
|
976
|
+
});
|
|
977
|
+
```
|
|
978
|
+
|
|
979
|
+
## PositioningPlugin Tutorial
|
|
980
|
+
|
|
981
|
+
Perfect for drag-and-drop interfaces!
|
|
982
|
+
|
|
983
|
+
### Step 1: Enable Positioning
|
|
984
|
+
|
|
985
|
+
```javascript
|
|
986
|
+
import { PositioningPlugin } from 'jsonrestapi';
|
|
987
|
+
|
|
988
|
+
const api = createApi({ storage: 'memory' });
|
|
989
|
+
api.use(PositioningPlugin);
|
|
990
|
+
|
|
991
|
+
const todoSchema = new Schema({
|
|
992
|
+
id: { type: 'id' },
|
|
993
|
+
title: { type: 'string', required: true },
|
|
994
|
+
position: { type: 'number' } // Required field!
|
|
995
|
+
});
|
|
996
|
+
|
|
997
|
+
api.addResource('todos', todoSchema);
|
|
998
|
+
```
|
|
999
|
+
|
|
1000
|
+
### Step 2: Insert with Position
|
|
1001
|
+
|
|
1002
|
+
```javascript
|
|
1003
|
+
// Insert at the end (default)
|
|
1004
|
+
const todo1 = await api.insert({
|
|
1005
|
+
title: 'First task'
|
|
1006
|
+
}, {
|
|
1007
|
+
type: 'todos',
|
|
1008
|
+
positioning: { enabled: true }
|
|
1009
|
+
});
|
|
1010
|
+
// position: 1
|
|
1011
|
+
|
|
1012
|
+
// Insert at the end explicitly
|
|
1013
|
+
const todo2 = await api.insert({
|
|
1014
|
+
title: 'Second task',
|
|
1015
|
+
beforeId: null // null = end
|
|
1016
|
+
}, {
|
|
1017
|
+
type: 'todos',
|
|
1018
|
+
positioning: { enabled: true }
|
|
1019
|
+
});
|
|
1020
|
+
// position: 2
|
|
1021
|
+
|
|
1022
|
+
// Insert before another item
|
|
1023
|
+
const todo3 = await api.insert({
|
|
1024
|
+
title: 'Urgent task',
|
|
1025
|
+
beforeId: todo1.data.id // Insert before first task
|
|
1026
|
+
}, {
|
|
1027
|
+
type: 'todos',
|
|
1028
|
+
positioning: { enabled: true }
|
|
1029
|
+
});
|
|
1030
|
+
// position: 1 (others shift up)
|
|
1031
|
+
```
|
|
1032
|
+
|
|
1033
|
+
### Step 3: Reposition Existing Items
|
|
1034
|
+
|
|
1035
|
+
```javascript
|
|
1036
|
+
// Move to end
|
|
1037
|
+
await api.reposition('todos', todoId, null);
|
|
1038
|
+
|
|
1039
|
+
// Move before another item
|
|
1040
|
+
await api.reposition('todos', todoId, beforeTodoId);
|
|
1041
|
+
|
|
1042
|
+
// Update with positioning
|
|
1043
|
+
await api.update(todoId, {
|
|
1044
|
+
title: 'Updated title',
|
|
1045
|
+
beforeId: anotherId // Also reposition
|
|
1046
|
+
}, {
|
|
1047
|
+
type: 'todos',
|
|
1048
|
+
positioning: { enabled: true }
|
|
1049
|
+
});
|
|
1050
|
+
```
|
|
1051
|
+
|
|
1052
|
+
### Step 4: Position Filters (Groups)
|
|
1053
|
+
|
|
1054
|
+
```javascript
|
|
1055
|
+
// Configure positioning within groups
|
|
1056
|
+
api.use(PositioningPlugin, {
|
|
1057
|
+
positionField: 'position',
|
|
1058
|
+
positionFilters: ['projectId', 'status'] // Separate positions per project/status
|
|
1059
|
+
});
|
|
1060
|
+
|
|
1061
|
+
// Now positions are scoped
|
|
1062
|
+
await api.insert({
|
|
1063
|
+
title: 'Project A Task',
|
|
1064
|
+
projectId: 1,
|
|
1065
|
+
status: 'active',
|
|
1066
|
+
beforeId: null
|
|
1067
|
+
}, {
|
|
1068
|
+
type: 'todos',
|
|
1069
|
+
positioning: { enabled: true }
|
|
1070
|
+
});
|
|
1071
|
+
// This gets position 1 within project=1, status=active
|
|
1072
|
+
```
|
|
1073
|
+
|
|
1074
|
+
### Step 5: Normalize Positions
|
|
1075
|
+
|
|
1076
|
+
```javascript
|
|
1077
|
+
// Remove gaps in position numbers
|
|
1078
|
+
await api.normalizePositions('todos', {
|
|
1079
|
+
projectId: 1 // Normalize within this project
|
|
1080
|
+
});
|
|
1081
|
+
|
|
1082
|
+
// Get next available position
|
|
1083
|
+
const nextPos = await api.getNextPosition('todos', {
|
|
1084
|
+
projectId: 1,
|
|
1085
|
+
status: 'active'
|
|
1086
|
+
});
|
|
1087
|
+
```
|
|
1088
|
+
|
|
1089
|
+
## VersioningPlugin Tutorial
|
|
1090
|
+
|
|
1091
|
+
### Part 1: API Versioning
|
|
1092
|
+
|
|
1093
|
+
```javascript
|
|
1094
|
+
// Version 1.0.0 of your API
|
|
1095
|
+
const apiV1 = createApi({
|
|
1096
|
+
name: 'blog',
|
|
1097
|
+
version: '1.0.0',
|
|
1098
|
+
storage: 'memory'
|
|
1099
|
+
});
|
|
1100
|
+
|
|
1101
|
+
const postSchemaV1 = new Schema({
|
|
1102
|
+
id: { type: 'id' },
|
|
1103
|
+
title: { type: 'string', required: true },
|
|
1104
|
+
content: { type: 'string', required: true }
|
|
1105
|
+
});
|
|
1106
|
+
|
|
1107
|
+
apiV1.addResource('posts', postSchemaV1);
|
|
1108
|
+
|
|
1109
|
+
// Version 2.0.0 - Added author field
|
|
1110
|
+
const apiV2 = createApi({
|
|
1111
|
+
name: 'blog',
|
|
1112
|
+
version: '2.0.0',
|
|
1113
|
+
storage: 'memory'
|
|
1114
|
+
});
|
|
1115
|
+
|
|
1116
|
+
const postSchemaV2 = new Schema({
|
|
1117
|
+
id: { type: 'id' },
|
|
1118
|
+
title: { type: 'string', required: true },
|
|
1119
|
+
content: { type: 'string', required: true },
|
|
1120
|
+
author: { type: 'string', required: true }, // NEW!
|
|
1121
|
+
tags: { type: 'array', default: [] } // NEW!
|
|
1122
|
+
});
|
|
1123
|
+
|
|
1124
|
+
apiV2.addResource('posts', postSchemaV2);
|
|
1125
|
+
```
|
|
1126
|
+
|
|
1127
|
+
### Part 2: Version Selection
|
|
1128
|
+
|
|
1129
|
+
```javascript
|
|
1130
|
+
// Get specific version
|
|
1131
|
+
const v1 = Api.get('blog', '1.0.0');
|
|
1132
|
+
|
|
1133
|
+
// Get latest
|
|
1134
|
+
const latest = Api.get('blog', 'latest');
|
|
1135
|
+
|
|
1136
|
+
// Get minimum version
|
|
1137
|
+
const v2plus = Api.get('blog', '2.0.0'); // Gets 2.0.0 or higher
|
|
1138
|
+
|
|
1139
|
+
// Use in your app
|
|
1140
|
+
async function createPost(data) {
|
|
1141
|
+
// Auto-select version based on data
|
|
1142
|
+
const hasAuthor = 'author' in data;
|
|
1143
|
+
const api = Api.get('blog', hasAuthor ? '2.0.0' : '1.0.0');
|
|
1144
|
+
|
|
1145
|
+
return api.insert(data, { type: 'posts' });
|
|
1146
|
+
}
|
|
1147
|
+
```
|
|
1148
|
+
|
|
1149
|
+
### Part 3: Resource Versioning
|
|
1150
|
+
|
|
1151
|
+
```javascript
|
|
1152
|
+
import { VersioningPlugin } from 'jsonrestapi';
|
|
1153
|
+
|
|
1154
|
+
const api = createApi({ storage: 'mysql' });
|
|
1155
|
+
api.use(VersioningPlugin, {
|
|
1156
|
+
trackHistory: true,
|
|
1157
|
+
optimisticLocking: true,
|
|
1158
|
+
versionField: 'version',
|
|
1159
|
+
lastModifiedField: 'lastModified',
|
|
1160
|
+
modifiedByField: 'modifiedBy'
|
|
1161
|
+
});
|
|
1162
|
+
|
|
1163
|
+
// Automatic version tracking
|
|
1164
|
+
const post = await api.insert({
|
|
1165
|
+
title: 'Original Title',
|
|
1166
|
+
content: 'Original content'
|
|
1167
|
+
}, {
|
|
1168
|
+
type: 'posts',
|
|
1169
|
+
userId: 'user123'
|
|
1170
|
+
});
|
|
1171
|
+
// version: 1, modifiedBy: 'user123'
|
|
1172
|
+
|
|
1173
|
+
// Update increments version
|
|
1174
|
+
const updated = await api.update(post.data.id, {
|
|
1175
|
+
title: 'Updated Title'
|
|
1176
|
+
}, {
|
|
1177
|
+
type: 'posts',
|
|
1178
|
+
userId: 'user456'
|
|
1179
|
+
});
|
|
1180
|
+
// version: 2, modifiedBy: 'user456'
|
|
1181
|
+
```
|
|
1182
|
+
|
|
1183
|
+
### Part 4: Version History
|
|
1184
|
+
|
|
1185
|
+
```javascript
|
|
1186
|
+
// Get full history
|
|
1187
|
+
const history = await api.getVersionHistory('posts', postId);
|
|
1188
|
+
// Returns all versions with timestamps
|
|
1189
|
+
|
|
1190
|
+
// Restore old version
|
|
1191
|
+
await api.restoreVersion('posts', postId, 1);
|
|
1192
|
+
// Post is now back to version 1 content (as version 3)
|
|
1193
|
+
|
|
1194
|
+
// Compare versions
|
|
1195
|
+
const diff = await api.diffVersions('posts', postId, 1, 2);
|
|
1196
|
+
console.log(diff);
|
|
1197
|
+
// {
|
|
1198
|
+
// version1: 1,
|
|
1199
|
+
// version2: 2,
|
|
1200
|
+
// changes: [
|
|
1201
|
+
// { field: 'title', oldValue: 'Original Title', newValue: 'Updated Title' }
|
|
1202
|
+
// ]
|
|
1203
|
+
// }
|
|
1204
|
+
```
|
|
1205
|
+
|
|
1206
|
+
### Part 5: Optimistic Locking
|
|
1207
|
+
|
|
1208
|
+
```javascript
|
|
1209
|
+
// Prevent concurrent updates
|
|
1210
|
+
const post = await api.get(postId, { type: 'posts' });
|
|
1211
|
+
|
|
1212
|
+
try {
|
|
1213
|
+
await api.update(postId, {
|
|
1214
|
+
title: 'New Title',
|
|
1215
|
+
version: post.data.attributes.version // Include current version
|
|
1216
|
+
}, { type: 'posts' });
|
|
1217
|
+
} catch (error) {
|
|
1218
|
+
if (error.code === 'VERSION_CONFLICT') {
|
|
1219
|
+
console.log('Someone else updated this post!');
|
|
1220
|
+
// Reload and try again
|
|
1221
|
+
}
|
|
1222
|
+
}
|
|
1223
|
+
```
|
|
1224
|
+
|
|
1225
|
+
## SecurityPlugin Tutorial
|
|
1226
|
+
|
|
1227
|
+
### Step 1: Basic Security
|
|
1228
|
+
|
|
1229
|
+
```javascript
|
|
1230
|
+
import { SecurityPlugin } from 'jsonrestapi';
|
|
1231
|
+
|
|
1232
|
+
api.use(SecurityPlugin, {
|
|
1233
|
+
// Rate limiting
|
|
1234
|
+
rateLimit: {
|
|
1235
|
+
windowMs: 15 * 60 * 1000, // 15 minutes
|
|
1236
|
+
max: 100, // 100 requests per window
|
|
1237
|
+
message: 'Too many requests'
|
|
1238
|
+
},
|
|
1239
|
+
|
|
1240
|
+
// CORS
|
|
1241
|
+
cors: {
|
|
1242
|
+
origin: ['https://app.example.com', 'http://localhost:3000'],
|
|
1243
|
+
credentials: true
|
|
1244
|
+
},
|
|
1245
|
+
|
|
1246
|
+
// Authentication
|
|
1247
|
+
authentication: {
|
|
1248
|
+
type: 'bearer', // or 'apikey', 'basic'
|
|
1249
|
+
required: true,
|
|
1250
|
+
header: 'Authorization'
|
|
1251
|
+
}
|
|
1252
|
+
});
|
|
1253
|
+
```
|
|
1254
|
+
|
|
1255
|
+
### Step 2: Token Authentication
|
|
1256
|
+
|
|
1257
|
+
```javascript
|
|
1258
|
+
api.use(SecurityPlugin, {
|
|
1259
|
+
authentication: {
|
|
1260
|
+
type: 'bearer',
|
|
1261
|
+
required: true
|
|
1262
|
+
},
|
|
1263
|
+
verifyToken: async (token, context) => {
|
|
1264
|
+
// Verify JWT token
|
|
1265
|
+
try {
|
|
1266
|
+
const decoded = jwt.verify(token, process.env.JWT_SECRET);
|
|
1267
|
+
return {
|
|
1268
|
+
id: decoded.userId,
|
|
1269
|
+
email: decoded.email,
|
|
1270
|
+
roles: decoded.roles
|
|
1271
|
+
};
|
|
1272
|
+
} catch (error) {
|
|
1273
|
+
throw new Error('Invalid token');
|
|
1274
|
+
}
|
|
1275
|
+
}
|
|
1276
|
+
});
|
|
1277
|
+
|
|
1278
|
+
// Token is now available in hooks
|
|
1279
|
+
api.hook('beforeInsert', async (context) => {
|
|
1280
|
+
context.data.createdBy = context.options.user?.id;
|
|
1281
|
+
});
|
|
1282
|
+
```
|
|
1283
|
+
|
|
1284
|
+
### Step 3: API Key Authentication
|
|
1285
|
+
|
|
1286
|
+
```javascript
|
|
1287
|
+
api.use(SecurityPlugin, {
|
|
1288
|
+
authentication: {
|
|
1289
|
+
type: 'apikey',
|
|
1290
|
+
header: 'X-API-Key',
|
|
1291
|
+
queryParam: 'apikey' // Also allow ?apikey=xxx
|
|
1292
|
+
},
|
|
1293
|
+
verifyToken: async (apiKey) => {
|
|
1294
|
+
// Look up API key in database
|
|
1295
|
+
const keyData = await findApiKey(apiKey);
|
|
1296
|
+
if (!keyData) throw new Error('Invalid API key');
|
|
1297
|
+
|
|
1298
|
+
return {
|
|
1299
|
+
id: keyData.userId,
|
|
1300
|
+
permissions: keyData.permissions
|
|
1301
|
+
};
|
|
1302
|
+
}
|
|
1303
|
+
});
|
|
1304
|
+
```
|
|
1305
|
+
|
|
1306
|
+
### Step 4: Public/Private Routes
|
|
1307
|
+
|
|
1308
|
+
```javascript
|
|
1309
|
+
api.use(SecurityPlugin, {
|
|
1310
|
+
publicRead: true, // Allow GET without auth
|
|
1311
|
+
authentication: {
|
|
1312
|
+
type: 'bearer',
|
|
1313
|
+
required: true
|
|
1314
|
+
}
|
|
1315
|
+
});
|
|
1316
|
+
|
|
1317
|
+
// Or fine-grained control
|
|
1318
|
+
api.hook('beforeValidate', async (context) => {
|
|
1319
|
+
const isPublic =
|
|
1320
|
+
context.options.type === 'posts' &&
|
|
1321
|
+
context.method === 'query';
|
|
1322
|
+
|
|
1323
|
+
if (!isPublic && !context.options.authenticated) {
|
|
1324
|
+
context.errors.push({
|
|
1325
|
+
field: null,
|
|
1326
|
+
message: 'Authentication required',
|
|
1327
|
+
code: 'AUTH_REQUIRED'
|
|
1328
|
+
});
|
|
1329
|
+
}
|
|
1330
|
+
});
|
|
1331
|
+
```
|
|
1332
|
+
|
|
1333
|
+
### Step 5: Security Headers
|
|
1334
|
+
|
|
1335
|
+
The SecurityPlugin automatically adds:
|
|
1336
|
+
- `X-Content-Type-Options: nosniff`
|
|
1337
|
+
- `X-Frame-Options: DENY`
|
|
1338
|
+
- `X-XSS-Protection: 1; mode=block`
|
|
1339
|
+
- `Strict-Transport-Security: max-age=31536000`
|
|
1340
|
+
- `Content-Security-Policy` with safe defaults
|
|
1341
|
+
|
|
1342
|
+
## LoggingPlugin Tutorial
|
|
1343
|
+
|
|
1344
|
+
### Step 1: Basic Logging
|
|
1345
|
+
|
|
1346
|
+
```javascript
|
|
1347
|
+
import { LoggingPlugin } from 'jsonrestapi';
|
|
1348
|
+
|
|
1349
|
+
api.use(LoggingPlugin, {
|
|
1350
|
+
level: 'info', // error, warn, info, debug
|
|
1351
|
+
format: 'json', // or 'pretty'
|
|
1352
|
+
includeRequest: true,
|
|
1353
|
+
includeResponse: true,
|
|
1354
|
+
includeTiming: true
|
|
1355
|
+
});
|
|
1356
|
+
|
|
1357
|
+
// Now all operations are logged!
|
|
1358
|
+
```
|
|
1359
|
+
|
|
1360
|
+
### Step 2: Custom Logger
|
|
1361
|
+
|
|
1362
|
+
```javascript
|
|
1363
|
+
import winston from 'winston';
|
|
1364
|
+
|
|
1365
|
+
const logger = winston.createLogger({
|
|
1366
|
+
level: 'info',
|
|
1367
|
+
format: winston.format.json(),
|
|
1368
|
+
transports: [
|
|
1369
|
+
new winston.transports.File({ filename: 'error.log', level: 'error' }),
|
|
1370
|
+
new winston.transports.File({ filename: 'combined.log' })
|
|
1371
|
+
]
|
|
1372
|
+
});
|
|
1373
|
+
|
|
1374
|
+
api.use(LoggingPlugin, {
|
|
1375
|
+
logger, // Use Winston instead of console
|
|
1376
|
+
sensitiveFields: ['password', 'creditCard', 'ssn']
|
|
1377
|
+
});
|
|
1378
|
+
```
|
|
1379
|
+
|
|
1380
|
+
### Step 3: Audit Logging
|
|
1381
|
+
|
|
1382
|
+
```javascript
|
|
1383
|
+
api.use(LoggingPlugin, {
|
|
1384
|
+
auditLog: true, // Log all data changes
|
|
1385
|
+
level: 'info'
|
|
1386
|
+
});
|
|
1387
|
+
|
|
1388
|
+
// Creates detailed audit trail
|
|
1389
|
+
// INFO: Resource created {
|
|
1390
|
+
// audit: true,
|
|
1391
|
+
// operation: 'create',
|
|
1392
|
+
// type: 'users',
|
|
1393
|
+
// id: '123',
|
|
1394
|
+
// userId: 'admin',
|
|
1395
|
+
// changes: { name: 'John', email: 'john@example.com' }
|
|
1396
|
+
// }
|
|
1397
|
+
```
|
|
1398
|
+
|
|
1399
|
+
### Step 4: Manual Logging
|
|
1400
|
+
|
|
1401
|
+
```javascript
|
|
1402
|
+
// Use the logger directly
|
|
1403
|
+
api.log.info('Processing payment', {
|
|
1404
|
+
orderId: '12345',
|
|
1405
|
+
amount: 99.99,
|
|
1406
|
+
currency: 'USD'
|
|
1407
|
+
});
|
|
1408
|
+
|
|
1409
|
+
api.log.error('Payment failed', {
|
|
1410
|
+
orderId: '12345',
|
|
1411
|
+
error: 'Insufficient funds',
|
|
1412
|
+
attempts: 3
|
|
1413
|
+
});
|
|
1414
|
+
|
|
1415
|
+
api.log.debug('Cache hit', {
|
|
1416
|
+
key: 'user:123',
|
|
1417
|
+
ttl: 3600
|
|
1418
|
+
});
|
|
1419
|
+
```
|
|
1420
|
+
|
|
1421
|
+
## OpenAPIPlugin Tutorial
|
|
1422
|
+
|
|
1423
|
+
### Step 1: Generate Documentation
|
|
1424
|
+
|
|
1425
|
+
```javascript
|
|
1426
|
+
import { OpenAPIPlugin } from 'jsonrestapi';
|
|
1427
|
+
|
|
1428
|
+
api.use(OpenAPIPlugin, {
|
|
1429
|
+
title: 'My Awesome API',
|
|
1430
|
+
version: '1.0.0',
|
|
1431
|
+
description: 'The best API ever created',
|
|
1432
|
+
servers: [
|
|
1433
|
+
{ url: 'https://api.example.com', description: 'Production' },
|
|
1434
|
+
{ url: 'http://localhost:3000', description: 'Development' }
|
|
1435
|
+
],
|
|
1436
|
+
contact: {
|
|
1437
|
+
name: 'API Support',
|
|
1438
|
+
email: 'support@example.com'
|
|
1439
|
+
}
|
|
1440
|
+
});
|
|
1441
|
+
|
|
1442
|
+
// Documentation available at:
|
|
1443
|
+
// GET /api/docs - Swagger UI
|
|
1444
|
+
// GET /api/openapi.json - OpenAPI spec
|
|
1445
|
+
```
|
|
1446
|
+
|
|
1447
|
+
### Step 2: Enhance Schema Documentation
|
|
1448
|
+
|
|
1449
|
+
```javascript
|
|
1450
|
+
const userSchema = new Schema({
|
|
1451
|
+
id: {
|
|
1452
|
+
type: 'id',
|
|
1453
|
+
description: 'Unique user identifier'
|
|
1454
|
+
},
|
|
1455
|
+
email: {
|
|
1456
|
+
type: 'string',
|
|
1457
|
+
required: true,
|
|
1458
|
+
description: 'User email address',
|
|
1459
|
+
example: 'user@example.com'
|
|
1460
|
+
},
|
|
1461
|
+
role: {
|
|
1462
|
+
type: 'string',
|
|
1463
|
+
enum: ['user', 'admin', 'moderator'],
|
|
1464
|
+
description: 'User role in the system',
|
|
1465
|
+
default: 'user'
|
|
1466
|
+
}
|
|
1467
|
+
});
|
|
1468
|
+
|
|
1469
|
+
// This generates rich OpenAPI documentation!
|
|
1470
|
+
```
|
|
1471
|
+
|
|
1472
|
+
---
|
|
1473
|
+
|
|
1474
|
+
# Advanced Features
|
|
1475
|
+
|
|
1476
|
+
## Cross-API Communication
|
|
1477
|
+
|
|
1478
|
+
APIs can talk to each other!
|
|
1479
|
+
|
|
1480
|
+
```javascript
|
|
1481
|
+
// Users API
|
|
1482
|
+
const usersApi = createApi({
|
|
1483
|
+
name: 'users',
|
|
1484
|
+
version: '1.0.0',
|
|
1485
|
+
storage: 'mysql'
|
|
1486
|
+
});
|
|
1487
|
+
|
|
1488
|
+
// Orders API
|
|
1489
|
+
const ordersApi = createApi({
|
|
1490
|
+
name: 'orders',
|
|
1491
|
+
version: '1.0.0',
|
|
1492
|
+
storage: 'mysql'
|
|
1493
|
+
});
|
|
1494
|
+
|
|
1495
|
+
// Orders can access users!
|
|
1496
|
+
ordersApi.hook('afterInsert', async (context) => {
|
|
1497
|
+
if (context.result) {
|
|
1498
|
+
// Automatically gets compatible users API
|
|
1499
|
+
const usersApi = ordersApi.apis.users;
|
|
1500
|
+
|
|
1501
|
+
// Get user details
|
|
1502
|
+
const user = await usersApi.get(
|
|
1503
|
+
context.result.userId,
|
|
1504
|
+
{ type: 'users' }
|
|
1505
|
+
);
|
|
1506
|
+
|
|
1507
|
+
// Send confirmation email
|
|
1508
|
+
await sendEmail(user.data.attributes.email, {
|
|
1509
|
+
subject: 'Order Confirmed',
|
|
1510
|
+
orderId: context.result.id
|
|
1511
|
+
});
|
|
1512
|
+
}
|
|
1513
|
+
});
|
|
1514
|
+
```
|
|
1515
|
+
|
|
1516
|
+
## Creating Custom Plugins
|
|
1517
|
+
|
|
1518
|
+
Build your own plugins!
|
|
1519
|
+
|
|
1520
|
+
```javascript
|
|
1521
|
+
const CachePlugin = {
|
|
1522
|
+
install(api, options = {}) {
|
|
1523
|
+
const cache = new Map();
|
|
1524
|
+
const ttl = options.ttl || 60000; // 1 minute default
|
|
1525
|
+
|
|
1526
|
+
// Cache GET requests
|
|
1527
|
+
api.hook('beforeGet', async (context) => {
|
|
1528
|
+
const key = `${context.options.type}:${context.id}`;
|
|
1529
|
+
const cached = cache.get(key);
|
|
1530
|
+
|
|
1531
|
+
if (cached && cached.expires > Date.now()) {
|
|
1532
|
+
context.result = cached.data;
|
|
1533
|
+
context.fromCache = true;
|
|
1534
|
+
return false; // Skip further processing
|
|
1535
|
+
}
|
|
1536
|
+
});
|
|
1537
|
+
|
|
1538
|
+
// Store in cache after GET
|
|
1539
|
+
api.hook('afterGet', async (context) => {
|
|
1540
|
+
if (!context.fromCache && context.result) {
|
|
1541
|
+
const key = `${context.options.type}:${context.id}`;
|
|
1542
|
+
cache.set(key, {
|
|
1543
|
+
data: context.result,
|
|
1544
|
+
expires: Date.now() + ttl
|
|
1545
|
+
});
|
|
1546
|
+
}
|
|
1547
|
+
});
|
|
1548
|
+
|
|
1549
|
+
// Invalidate on update/delete
|
|
1550
|
+
api.hook('afterUpdate', async (context) => {
|
|
1551
|
+
const key = `${context.options.type}:${context.id}`;
|
|
1552
|
+
cache.delete(key);
|
|
1553
|
+
});
|
|
1554
|
+
|
|
1555
|
+
api.hook('afterDelete', async (context) => {
|
|
1556
|
+
const key = `${context.options.type}:${context.id}`;
|
|
1557
|
+
cache.delete(key);
|
|
1558
|
+
});
|
|
1559
|
+
|
|
1560
|
+
// Add cache methods
|
|
1561
|
+
api.clearCache = () => cache.clear();
|
|
1562
|
+
api.getCacheSize = () => cache.size;
|
|
1563
|
+
}
|
|
1564
|
+
};
|
|
1565
|
+
|
|
1566
|
+
// Use it!
|
|
1567
|
+
api.use(CachePlugin, { ttl: 300000 }); // 5 minutes
|
|
1568
|
+
```
|
|
1569
|
+
|
|
1570
|
+
## Complex Validation Scenarios
|
|
1571
|
+
|
|
1572
|
+
```javascript
|
|
1573
|
+
// Product with complex rules
|
|
1574
|
+
const productSchema = new Schema({
|
|
1575
|
+
name: { type: 'string', required: true },
|
|
1576
|
+
price: { type: 'number', required: true, min: 0 },
|
|
1577
|
+
salePrice: { type: 'number', min: 0 },
|
|
1578
|
+
category: { type: 'string', required: true },
|
|
1579
|
+
stock: { type: 'number', default: 0, min: 0 },
|
|
1580
|
+
sku: { type: 'string', required: true }
|
|
1581
|
+
});
|
|
1582
|
+
|
|
1583
|
+
// Multi-field validation
|
|
1584
|
+
api.hook('afterValidate', async (context) => {
|
|
1585
|
+
const { data, errors, method } = context;
|
|
1586
|
+
|
|
1587
|
+
// Sale price must be less than regular price
|
|
1588
|
+
if (data.salePrice && data.salePrice >= data.price) {
|
|
1589
|
+
errors.push({
|
|
1590
|
+
field: 'salePrice',
|
|
1591
|
+
message: 'Sale price must be less than regular price'
|
|
1592
|
+
});
|
|
1593
|
+
}
|
|
1594
|
+
|
|
1595
|
+
// SKU must be unique
|
|
1596
|
+
if ((method === 'insert' || method === 'update') && data.sku) {
|
|
1597
|
+
const existing = await api.query({
|
|
1598
|
+
filter: { sku: data.sku }
|
|
1599
|
+
}, { type: 'products' });
|
|
1600
|
+
|
|
1601
|
+
const isDuplicate = method === 'insert'
|
|
1602
|
+
? existing.data.length > 0
|
|
1603
|
+
: existing.data.some(p => p.id !== context.id);
|
|
1604
|
+
|
|
1605
|
+
if (isDuplicate) {
|
|
1606
|
+
errors.push({
|
|
1607
|
+
field: 'sku',
|
|
1608
|
+
message: 'SKU already exists'
|
|
1609
|
+
});
|
|
1610
|
+
}
|
|
1611
|
+
}
|
|
1612
|
+
|
|
1613
|
+
// Category-specific validation
|
|
1614
|
+
if (data.category === 'electronics' && !data.warrantyPeriod) {
|
|
1615
|
+
errors.push({
|
|
1616
|
+
field: 'warrantyPeriod',
|
|
1617
|
+
message: 'Electronics must have warranty period'
|
|
1618
|
+
});
|
|
1619
|
+
}
|
|
1620
|
+
});
|
|
1621
|
+
```
|
|
1622
|
+
|
|
1623
|
+
## Performance Optimization
|
|
1624
|
+
|
|
1625
|
+
```javascript
|
|
1626
|
+
// Batch operations
|
|
1627
|
+
async function importProducts(products) {
|
|
1628
|
+
const batchSize = 100;
|
|
1629
|
+
const results = [];
|
|
1630
|
+
|
|
1631
|
+
for (let i = 0; i < products.length; i += batchSize) {
|
|
1632
|
+
const batch = products.slice(i, i + batchSize);
|
|
1633
|
+
|
|
1634
|
+
// Process batch in parallel
|
|
1635
|
+
const batchResults = await Promise.all(
|
|
1636
|
+
batch.map(product =>
|
|
1637
|
+
api.insert(product, { type: 'products' })
|
|
1638
|
+
.catch(error => ({ error, product }))
|
|
1639
|
+
)
|
|
1640
|
+
);
|
|
1641
|
+
|
|
1642
|
+
results.push(...batchResults);
|
|
1643
|
+
|
|
1644
|
+
// Progress
|
|
1645
|
+
console.log(`Imported ${Math.min(i + batchSize, products.length)} / ${products.length}`);
|
|
1646
|
+
}
|
|
1647
|
+
|
|
1648
|
+
return results;
|
|
1649
|
+
}
|
|
1650
|
+
|
|
1651
|
+
// Optimized queries
|
|
1652
|
+
const results = await api.query({
|
|
1653
|
+
filter: { category: 'electronics', active: true },
|
|
1654
|
+
sort: '-price',
|
|
1655
|
+
page: { size: 50 } // Don't fetch everything!
|
|
1656
|
+
}, {
|
|
1657
|
+
type: 'products',
|
|
1658
|
+
fields: ['id', 'name', 'price'] // Only needed fields
|
|
1659
|
+
});
|
|
1660
|
+
```
|
|
1661
|
+
|
|
1662
|
+
## Error Recovery
|
|
1663
|
+
|
|
1664
|
+
```javascript
|
|
1665
|
+
// Retry logic
|
|
1666
|
+
async function reliableInsert(data, options, maxRetries = 3) {
|
|
1667
|
+
let lastError;
|
|
1668
|
+
|
|
1669
|
+
for (let attempt = 1; attempt <= maxRetries; attempt++) {
|
|
1670
|
+
try {
|
|
1671
|
+
return await api.insert(data, options);
|
|
1672
|
+
} catch (error) {
|
|
1673
|
+
lastError = error;
|
|
1674
|
+
|
|
1675
|
+
// Don't retry validation errors
|
|
1676
|
+
if (error.status === 422) throw error;
|
|
1677
|
+
|
|
1678
|
+
// Exponential backoff
|
|
1679
|
+
const delay = Math.min(1000 * Math.pow(2, attempt - 1), 10000);
|
|
1680
|
+
console.log(`Attempt ${attempt} failed, retrying in ${delay}ms...`);
|
|
1681
|
+
|
|
1682
|
+
await new Promise(resolve => setTimeout(resolve, delay));
|
|
1683
|
+
}
|
|
1684
|
+
}
|
|
1685
|
+
|
|
1686
|
+
throw lastError;
|
|
1687
|
+
}
|
|
1688
|
+
|
|
1689
|
+
// Graceful degradation
|
|
1690
|
+
async function getProductWithFallback(id) {
|
|
1691
|
+
try {
|
|
1692
|
+
// Try primary database
|
|
1693
|
+
return await api.get(id, {
|
|
1694
|
+
type: 'products',
|
|
1695
|
+
connection: 'primary'
|
|
1696
|
+
});
|
|
1697
|
+
} catch (error) {
|
|
1698
|
+
console.warn('Primary database failed, trying cache...');
|
|
1699
|
+
|
|
1700
|
+
try {
|
|
1701
|
+
// Try cache
|
|
1702
|
+
return await cacheApi.get(id, { type: 'products' });
|
|
1703
|
+
} catch (cacheError) {
|
|
1704
|
+
console.error('Cache also failed, returning degraded response');
|
|
1705
|
+
|
|
1706
|
+
// Return degraded response
|
|
1707
|
+
return {
|
|
1708
|
+
data: {
|
|
1709
|
+
id,
|
|
1710
|
+
type: 'products',
|
|
1711
|
+
attributes: {
|
|
1712
|
+
name: 'Product temporarily unavailable',
|
|
1713
|
+
available: false
|
|
1714
|
+
}
|
|
1715
|
+
}
|
|
1716
|
+
};
|
|
1717
|
+
}
|
|
1718
|
+
}
|
|
1719
|
+
}
|
|
1720
|
+
```
|
|
1721
|
+
|
|
1722
|
+
## Testing Your API
|
|
1723
|
+
|
|
1724
|
+
```javascript
|
|
1725
|
+
import { createApi, Schema } from 'jsonrestapi';
|
|
1726
|
+
import { describe, it, expect, beforeEach } from 'vitest';
|
|
1727
|
+
|
|
1728
|
+
describe('User API', () => {
|
|
1729
|
+
let api;
|
|
1730
|
+
|
|
1731
|
+
beforeEach(() => {
|
|
1732
|
+
// Fresh API for each test
|
|
1733
|
+
api = createApi({
|
|
1734
|
+
name: 'test',
|
|
1735
|
+
version: '1.0.0',
|
|
1736
|
+
storage: 'memory'
|
|
1737
|
+
});
|
|
1738
|
+
|
|
1739
|
+
const userSchema = new Schema({
|
|
1740
|
+
id: { type: 'id' },
|
|
1741
|
+
email: { type: 'string', required: true },
|
|
1742
|
+
name: { type: 'string', required: true }
|
|
1743
|
+
});
|
|
1744
|
+
|
|
1745
|
+
api.addResource('users', userSchema);
|
|
1746
|
+
});
|
|
1747
|
+
|
|
1748
|
+
it('should create a user', async () => {
|
|
1749
|
+
const user = await api.insert({
|
|
1750
|
+
email: 'test@example.com',
|
|
1751
|
+
name: 'Test User'
|
|
1752
|
+
}, { type: 'users' });
|
|
1753
|
+
|
|
1754
|
+
expect(user.data.attributes.email).toBe('test@example.com');
|
|
1755
|
+
expect(user.data.id).toBeDefined();
|
|
1756
|
+
});
|
|
1757
|
+
|
|
1758
|
+
it('should validate required fields', async () => {
|
|
1759
|
+
await expect(
|
|
1760
|
+
api.insert({ email: 'test@example.com' }, { type: 'users' })
|
|
1761
|
+
).rejects.toThrow();
|
|
1762
|
+
});
|
|
1763
|
+
|
|
1764
|
+
it('should update users', async () => {
|
|
1765
|
+
const user = await api.insert({
|
|
1766
|
+
email: 'test@example.com',
|
|
1767
|
+
name: 'Original Name'
|
|
1768
|
+
}, { type: 'users' });
|
|
1769
|
+
|
|
1770
|
+
const updated = await api.update(user.data.id, {
|
|
1771
|
+
name: 'Updated Name'
|
|
1772
|
+
}, { type: 'users' });
|
|
1773
|
+
|
|
1774
|
+
expect(updated.data.attributes.name).toBe('Updated Name');
|
|
1775
|
+
});
|
|
1776
|
+
});
|
|
1777
|
+
```
|
|
1778
|
+
|
|
1779
|
+
---
|
|
1780
|
+
|
|
1781
|
+
Congratulations! You've mastered the JSON REST API library! 🎉
|
|
1782
|
+
|
|
1783
|
+
Remember:
|
|
1784
|
+
- Start simple with memory storage
|
|
1785
|
+
- Add MySQL when you need persistence
|
|
1786
|
+
- Use plugins to add features incrementally
|
|
1787
|
+
- Hooks are your friends for custom logic
|
|
1788
|
+
- The library handles versioning automatically
|
|
1789
|
+
- Always validate your data
|
|
1790
|
+
- Security first!
|
|
1791
|
+
|
|
1792
|
+
Happy coding! 🚀
|