json-rest-api 0.1.0 → 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.
@@ -0,0 +1,456 @@
1
+ # Organizing Resources in Express with jsonrestapi
2
+
3
+ This guide shows you how to organize your API resources in a scalable, maintainable way with minimal server setup.
4
+
5
+ ## Directory Structure
6
+
7
+ ```
8
+ project/
9
+ ├── server.js
10
+ ├── api/
11
+ │ ├── 1.0.0/
12
+ │ │ ├── users.js # Self-contained users resource
13
+ │ │ ├── products.js # Self-contained products resource
14
+ │ │ └── orders.js # Self-contained orders resource
15
+ │ └── 2.0.0/
16
+ │ ├── users.js # Updated users resource
17
+ │ ├── products.js # Updated products resource
18
+ │ └── orders.js # Orders resource
19
+ └── config/
20
+ └── database.js # Database configuration
21
+ ```
22
+
23
+ ## Resource File Structure
24
+
25
+ Each resource file is self-contained and handles its own setup.
26
+
27
+ ### Example: api/1.0.0/users.js
28
+
29
+ ```javascript
30
+ import { Api, Schema, MySQLPlugin, ValidationPlugin, HTTPPlugin } from 'jsonrestapi';
31
+ import { dbConfig } from '../../config/database.js';
32
+
33
+ // Get or create the API instance for this version
34
+ const api = Api.get('myapp', '1.0.0') || new Api({
35
+ name: 'myapp',
36
+ version: '1.0.0'
37
+ });
38
+
39
+ // Ensure plugins are loaded (safe to call multiple times)
40
+ api
41
+ .use(ValidationPlugin)
42
+ .use(MySQLPlugin, {
43
+ connections: [{
44
+ name: 'main',
45
+ config: dbConfig
46
+ }]
47
+ })
48
+ .use(HTTPPlugin, {
49
+ basePath: '/api/1.0.0'
50
+ });
51
+
52
+ // Define schema
53
+ const userSchema = new Schema({
54
+ id: { type: 'id' },
55
+ username: { type: 'string', required: true, min: 3, max: 50 },
56
+ email: { type: 'string', required: true, lowercase: true },
57
+ password: { type: 'string', required: true, min: 8 },
58
+ firstName: { type: 'string', max: 100 },
59
+ lastName: { type: 'string', max: 100 },
60
+ role: { type: 'string', default: 'user' },
61
+ active: { type: 'boolean', default: true },
62
+ createdAt: { type: 'timestamp', default: () => Date.now() },
63
+ updatedAt: { type: 'timestamp' }
64
+ });
65
+
66
+ // Define hooks for this resource
67
+ const userHooks = {
68
+ async afterValidate(context) {
69
+ const { data, method, errors } = context;
70
+
71
+ if (method === 'insert' || method === 'update') {
72
+ // Check for duplicate email
73
+ const existing = await context.api.query({
74
+ filter: { email: data.email }
75
+ }, { type: 'users' });
76
+
77
+ if (existing.meta.total > 0 && existing.results[0].id !== data.id) {
78
+ errors.push({
79
+ field: 'email',
80
+ message: 'Email already in use',
81
+ code: 'DUPLICATE_EMAIL'
82
+ });
83
+ }
84
+ }
85
+ },
86
+
87
+ async transformResult(context) {
88
+ const { result } = context;
89
+
90
+ // Never return password field
91
+ if (result && result.attributes) {
92
+ delete result.attributes.password;
93
+ }
94
+
95
+ // Add computed fields
96
+ if (result && result.attributes) {
97
+ result.attributes.fullName =
98
+ `${result.attributes.firstName || ''} ${result.attributes.lastName || ''}`.trim();
99
+ }
100
+ },
101
+
102
+ async beforeInsert(context) {
103
+ const { data } = context;
104
+
105
+ // Hash password before storing
106
+ if (data.password) {
107
+ const bcrypt = await import('bcrypt');
108
+ data.password = await bcrypt.hash(data.password, 10);
109
+ }
110
+
111
+ // Set timestamps
112
+ data.createdAt = Date.now();
113
+ data.updatedAt = Date.now();
114
+ }
115
+ };
116
+
117
+ // Add the resource with schema and hooks
118
+ api.addResource('users', userSchema, userHooks);
119
+
120
+ // Export for server to mount
121
+ export default api;
122
+ ```
123
+
124
+ ### Even Cleaner Approach with Shared Configuration
125
+
126
+ The library includes a helper that manages shared API instances per version:
127
+
128
+ ```javascript
129
+ // api/config.js - Shared configuration
130
+ import { dbConfig } from '../config/database.js';
131
+
132
+ export const apiConfig = {
133
+ name: 'myapp',
134
+ mysql: {
135
+ connections: [{
136
+ name: 'main',
137
+ config: dbConfig
138
+ }]
139
+ },
140
+ http: {
141
+ // Additional HTTP options
142
+ }
143
+ };
144
+ ```
145
+
146
+ Then use the helper in your resources:
147
+
148
+ ```javascript
149
+ // api/1.0.0/users.js
150
+ import { Schema, defineResource } from 'jsonrestapi/resource-helper.js';
151
+ import { apiConfig } from '../config.js';
152
+
153
+ export default defineResource('1.0.0', 'users', {
154
+ api: apiConfig, // Shared configuration
155
+ schema: new Schema({
156
+ id: { type: 'id' },
157
+ username: { type: 'string', required: true, min: 3, max: 50 },
158
+ email: { type: 'string', required: true, lowercase: true },
159
+ password: { type: 'string', required: true, min: 8 },
160
+ firstName: { type: 'string', max: 100 },
161
+ lastName: { type: 'string', max: 100 },
162
+ role: { type: 'string', default: 'user' },
163
+ active: { type: 'boolean', default: true },
164
+ createdAt: { type: 'timestamp', default: () => Date.now() },
165
+ updatedAt: { type: 'timestamp' }
166
+ }),
167
+
168
+ storage: {
169
+ table: 'users',
170
+ searchFields: ['username', 'email', 'firstName', 'lastName']
171
+ },
172
+
173
+ hooks: {
174
+ async afterValidate(context) {
175
+ const { data, method, errors } = context;
176
+
177
+ if (method === 'insert' || method === 'update') {
178
+ const existing = await context.api.query({
179
+ filter: { email: data.email }
180
+ }, { type: 'users' });
181
+
182
+ if (existing.meta.total > 0 && existing.results[0].id !== data.id) {
183
+ errors.push({
184
+ field: 'email',
185
+ message: 'Email already in use',
186
+ code: 'DUPLICATE_EMAIL'
187
+ });
188
+ }
189
+ }
190
+ },
191
+
192
+ async transformResult(context) {
193
+ const { result } = context;
194
+
195
+ if (result?.attributes) {
196
+ delete result.attributes.password;
197
+ result.attributes.fullName =
198
+ `${result.attributes.firstName || ''} ${result.attributes.lastName || ''}`.trim();
199
+ }
200
+ },
201
+
202
+ async beforeInsert(context) {
203
+ const bcrypt = await import('bcrypt');
204
+ context.data.password = await bcrypt.hash(context.data.password, 10);
205
+ context.data.createdAt = Date.now();
206
+ context.data.updatedAt = Date.now();
207
+ }
208
+ }
209
+ });
210
+ ```
211
+
212
+ ## Minimal Server Setup
213
+
214
+ ### Option 1: Manual Loading (Most Control)
215
+
216
+ ```javascript
217
+ // server.js
218
+ import express from 'express';
219
+ import session from 'express-session';
220
+
221
+ const app = express();
222
+
223
+ // Middleware
224
+ app.use(express.json());
225
+ app.use(session({
226
+ secret: process.env.SESSION_SECRET,
227
+ resave: false,
228
+ saveUninitialized: false
229
+ }));
230
+
231
+ // Load all resources - ONE LINE PER RESOURCE!
232
+ const apis = [
233
+ await import('./api/1.0.0/users.js'),
234
+ await import('./api/1.0.0/products.js'),
235
+ await import('./api/1.0.0/orders.js'),
236
+ await import('./api/2.0.0/users.js'),
237
+ await import('./api/2.0.0/products.js'),
238
+ await import('./api/2.0.0/orders.js'),
239
+ ];
240
+
241
+ // Mount all APIs - that's it!
242
+ apis.forEach(module => module.default.mount(app));
243
+
244
+ // Start server
245
+ app.listen(3000, () => {
246
+ console.log('Server running on http://localhost:3000');
247
+ });
248
+ ```
249
+
250
+ ### Option 2: Auto-Loading (Like the Old Library)
251
+
252
+ Create a helper function:
253
+
254
+ ```javascript
255
+ // load-resources.js
256
+ import { readdir } from 'fs/promises';
257
+ import { join } from 'path';
258
+
259
+ export async function loadResourcesFromPath(apiPath, app) {
260
+ const versions = await readdir(apiPath);
261
+
262
+ for (const version of versions) {
263
+ const versionPath = join(apiPath, version);
264
+ const files = await readdir(versionPath);
265
+
266
+ for (const file of files) {
267
+ if (file.endsWith('.js')) {
268
+ const module = await import(join(versionPath, file));
269
+ if (module.default?.mount) {
270
+ module.default.mount(app);
271
+ }
272
+ }
273
+ }
274
+ }
275
+ }
276
+ ```
277
+
278
+ Then in your server:
279
+
280
+ ```javascript
281
+ // server.js
282
+ import express from 'express';
283
+ import { fileURLToPath } from 'url';
284
+ import { dirname, join } from 'path';
285
+ import { loadResourcesFromPath } from './load-resources.js';
286
+
287
+ const __dirname = dirname(fileURLToPath(import.meta.url));
288
+ const app = express();
289
+
290
+ app.use(express.json());
291
+
292
+ // ONE LINE to load everything!
293
+ await loadResourcesFromPath(join(__dirname, 'api'), app);
294
+
295
+ app.listen(3000, () => {
296
+ console.log('Server running on http://localhost:3000');
297
+ });
298
+ ```
299
+
300
+ ## Programmatic Access Between Resources
301
+
302
+ Resources that share an API instance can interact with each other using the new intuitive API:
303
+
304
+ ```javascript
305
+ // api/1.0.0/orders.js
306
+ import { Api, Schema } from 'jsonrestapi';
307
+
308
+ const api = Api.get('myapp', '1.0.0');
309
+
310
+ // Define orders schema
311
+ const orderSchema = new Schema({
312
+ id: { type: 'id' },
313
+ userId: { type: 'id', required: true },
314
+ productId: { type: 'id', required: true },
315
+ quantity: { type: 'number', min: 1 },
316
+ status: { type: 'string', default: 'pending' }
317
+ });
318
+
319
+ const orderHooks = {
320
+ async afterInsert(context) {
321
+ const { result } = context;
322
+ const order = result.data;
323
+
324
+ // Access other resources using the intuitive API
325
+ const user = await api.resources.users.get(order.attributes.userId);
326
+ const product = await api.resources.products.get(order.attributes.productId);
327
+
328
+ // Send email notification
329
+ await sendOrderConfirmation(user.data.attributes.email, {
330
+ order,
331
+ product: product.data.attributes
332
+ });
333
+ },
334
+
335
+ async transformResult(context) {
336
+ const { result } = context;
337
+
338
+ if (result?.data) {
339
+ // Enrich order with user and product data
340
+ const [user, product] = await Promise.all([
341
+ api.resources.users.get(result.data.attributes.userId),
342
+ api.resources.products.get(result.data.attributes.productId)
343
+ ]);
344
+
345
+ result.data.relationships = {
346
+ user: { data: { type: 'users', id: user.data.id } },
347
+ product: { data: { type: 'products', id: product.data.id } }
348
+ };
349
+
350
+ result.included = [
351
+ user.data,
352
+ product.data
353
+ ];
354
+ }
355
+ }
356
+ };
357
+
358
+ api.addResource('orders', orderSchema, orderHooks);
359
+
360
+ export default api;
361
+ ```
362
+
363
+ ## Adding Authentication/Permissions
364
+
365
+ You can add middleware to your resources:
366
+
367
+ ```javascript
368
+ // api/1.0.0/secure-resource.js
369
+ import { Schema } from 'jsonrestapi';
370
+ import { createResource } from '../resource-helper.js';
371
+
372
+ const api = createResource('1.0.0', 'secrets', {
373
+ schema: new Schema({
374
+ id: { type: 'id' },
375
+ content: { type: 'string', required: true },
376
+ level: { type: 'number', default: 1 }
377
+ })
378
+ });
379
+
380
+ // Add authentication middleware
381
+ api.useMiddleware((req, res, next) => {
382
+ if (!req.session?.userId) {
383
+ return res.status(401).json({
384
+ error: 'Authentication required'
385
+ });
386
+ }
387
+ next();
388
+ });
389
+
390
+ // Or use hooks for fine-grained control
391
+ api.hook('beforeOperation', async (context) => {
392
+ if (context.options.type !== 'secrets') return;
393
+
394
+ const { method, request } = context;
395
+
396
+ if (!request.session?.userId) {
397
+ throw Object.assign(new Error('Authentication required'), {
398
+ status: 401
399
+ });
400
+ }
401
+
402
+ if (method === 'delete' && request.session.role !== 'admin') {
403
+ throw Object.assign(new Error('Admin access required'), {
404
+ status: 403
405
+ });
406
+ }
407
+ });
408
+
409
+ export default api;
410
+ ```
411
+
412
+ ## Why Not Use createApi()?
413
+
414
+ You might wonder why we don't use `createApi()` in each resource file. Here's why:
415
+
416
+ 1. **Shared Instance**: All resources in a version should share the same API instance, not create separate ones
417
+ 2. **Single Configuration**: Database connections, plugins, etc. should be configured once per version
418
+ 3. **Resource Interaction**: Resources can query each other when they share an API instance
419
+ 4. **Memory Efficiency**: One connection pool, one router, one set of hooks per version
420
+
421
+ The `defineResource()` helper gives us the best of both worlds:
422
+ - Simple like `createApi()`
423
+ - But properly manages shared instances
424
+ - Allows resource-specific configuration
425
+
426
+ ## Benefits
427
+
428
+ 1. **Minimal Server Code**: Server setup is just a few lines
429
+ 2. **Self-Contained Resources**: Each resource file has everything it needs
430
+ 3. **No Repetition**: The helper function eliminates boilerplate
431
+ 4. **Easy to Add Resources**: Just create a file and it works
432
+ 5. **Version Isolation**: Each version's resources are independent
433
+ 6. **Familiar Pattern**: Similar to the old library's approach but cleaner
434
+
435
+ ## Quick Start
436
+
437
+ 1. Create your resource file:
438
+ ```javascript
439
+ // api/1.0.0/my-resource.js
440
+ import { Schema } from 'jsonrestapi';
441
+ import { createResource } from '../resource-helper.js';
442
+
443
+ export default createResource('1.0.0', 'my-resource', {
444
+ schema: new Schema({
445
+ id: { type: 'id' },
446
+ name: { type: 'string', required: true }
447
+ })
448
+ });
449
+ ```
450
+
451
+ 2. That's it! The resource is automatically available at:
452
+ - GET /api/1.0.0/my-resource
453
+ - GET /api/1.0.0/my-resource/:id
454
+ - POST /api/1.0.0/my-resource
455
+ - PATCH /api/1.0.0/my-resource/:id
456
+ - DELETE /api/1.0.0/my-resource/:id
@@ -0,0 +1,168 @@
1
+ import express from 'express';
2
+ import { Api, Schema, createApi } from './index.js';
3
+
4
+ // Example: Automatic API Versioning
5
+ // The library handles all version negotiation automatically!
6
+
7
+ // Version 1.0.0 - Original User API
8
+ const userApiV1 = createApi({
9
+ name: 'users',
10
+ version: '1.0.0',
11
+ storage: 'memory'
12
+ });
13
+
14
+ const userSchemaV1 = new Schema({
15
+ id: { type: 'id' },
16
+ name: { type: 'string', required: true },
17
+ email: { type: 'string', required: true }
18
+ });
19
+
20
+ userApiV1.addResource('users', userSchemaV1);
21
+
22
+ // Version 2.0.0 - Added phone number
23
+ const userApiV2 = createApi({
24
+ name: 'users',
25
+ version: '2.0.0',
26
+ storage: 'memory'
27
+ });
28
+
29
+ const userSchemaV2 = new Schema({
30
+ id: { type: 'id' },
31
+ name: { type: 'string', required: true },
32
+ email: { type: 'string', required: true },
33
+ phone: { type: 'string' }, // New field!
34
+ active: { type: 'boolean', default: true } // New field!
35
+ });
36
+
37
+ userApiV2.addResource('users', userSchemaV2);
38
+
39
+ // Version 2.1.0 - Added address
40
+ const userApiV2_1 = createApi({
41
+ name: 'users',
42
+ version: '2.1.0',
43
+ storage: 'memory'
44
+ });
45
+
46
+ const userSchemaV2_1 = new Schema({
47
+ id: { type: 'id' },
48
+ name: { type: 'string', required: true },
49
+ email: { type: 'string', required: true },
50
+ phone: { type: 'string' },
51
+ active: { type: 'boolean', default: true },
52
+ address: { type: 'object' } // New field!
53
+ });
54
+
55
+ userApiV2_1.addResource('users', userSchemaV2_1);
56
+
57
+ // USAGE - The library handles version selection automatically!
58
+
59
+ // 1. Get specific version
60
+ const api1 = Api.get('users', '1.0.0');
61
+ console.log('Got v1.0.0:', api1.options.version);
62
+
63
+ // 2. Get latest version
64
+ const apiLatest = Api.get('users', 'latest');
65
+ console.log('Got latest:', apiLatest.options.version); // 2.1.0
66
+
67
+ // 3. Get minimum version (2.0.0 or higher)
68
+ const api2Plus = Api.get('users', '2.0.0');
69
+ console.log('Got 2.0.0+:', api2Plus.options.version); // 2.1.0 (highest compatible)
70
+
71
+ // 4. From within an API, access other APIs automatically
72
+ const ordersApi = createApi({
73
+ name: 'orders',
74
+ version: '1.0.0',
75
+ storage: 'memory'
76
+ });
77
+
78
+ // The orders API can access the users API automatically
79
+ ordersApi.hook('afterInsert', async (context) => {
80
+ if (context.options.type === 'orders') {
81
+ // This automatically gets a compatible users API!
82
+ const usersApi = ordersApi.apis.users;
83
+
84
+ if (usersApi) {
85
+ const user = await usersApi.get(context.data.userId, { type: 'users' });
86
+ console.log('Order created for user:', user);
87
+ }
88
+ }
89
+ });
90
+
91
+ // 5. Express setup with automatic version routing
92
+ const app = express();
93
+
94
+ // Mount all versions - the library handles routing!
95
+ userApiV1.mount(app, '/api'); // Available at /api/1.0.0/users
96
+ userApiV2.mount(app, '/api'); // Available at /api/2.0.0/users
97
+ userApiV2_1.mount(app, '/api'); // Available at /api/2.1.0/users
98
+
99
+ // Version negotiation middleware
100
+ app.use('/api/users/*', (req, res, next) => {
101
+ // Get requested version from header or query
102
+ const requestedVersion =
103
+ req.headers['api-version'] ||
104
+ req.query.v ||
105
+ 'latest';
106
+
107
+ // Library finds the right version automatically
108
+ const api = Api.get('users', requestedVersion);
109
+
110
+ if (!api) {
111
+ return res.status(404).json({
112
+ errors: [{
113
+ status: '404',
114
+ title: 'Version Not Found',
115
+ detail: `No compatible version for users API ${requestedVersion}`
116
+ }]
117
+ });
118
+ }
119
+
120
+ // Forward to the correct API version
121
+ api.router(req, res, next);
122
+ });
123
+
124
+ // 6. Programmatic usage with automatic version selection
125
+ async function createUser(data, minVersion = '1.0.0') {
126
+ // Get a compatible API automatically
127
+ const api = Api.get('users', minVersion);
128
+
129
+ if (!api) {
130
+ throw new Error(`No users API version >= ${minVersion}`);
131
+ }
132
+
133
+ return api.insert(data, { type: 'users' });
134
+ }
135
+
136
+ // This works with v1.0.0 data
137
+ await createUser({
138
+ name: 'John Doe',
139
+ email: 'john@example.com'
140
+ }, '1.0.0');
141
+
142
+ // This requires v2.0.0+ for phone field
143
+ await createUser({
144
+ name: 'Jane Doe',
145
+ email: 'jane@example.com',
146
+ phone: '+1234567890'
147
+ }, '2.0.0');
148
+
149
+ // Client examples:
150
+
151
+ // Via header
152
+ fetch('/api/users', {
153
+ headers: {
154
+ 'API-Version': '2.0.0' // Get v2.0.0 or higher
155
+ }
156
+ });
157
+
158
+ // Via query parameter
159
+ fetch('/api/users?v=1.0.0'); // Get exactly v1.0.0
160
+
161
+ // Via path (automatic routing)
162
+ fetch('/api/2.1.0/users'); // Get exactly v2.1.0
163
+
164
+ // Show all registered APIs
165
+ console.log('Registered APIs:', Api.getRegistry());
166
+ // Output: { users: ['2.1.0', '2.0.0', '1.0.0'] }
167
+
168
+ export { userApiV1, userApiV2, userApiV2_1 };