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/api.js ADDED
@@ -0,0 +1,628 @@
1
+ import semver from 'semver';
2
+
3
+ // Global registry for all APIs
4
+ const globalRegistry = new Map();
5
+
6
+ /**
7
+ * Core API class with plugin system for JSON REST APIs
8
+ */
9
+ export class Api {
10
+ constructor(options = {}) {
11
+
12
+ // Core properties
13
+ this.options = {
14
+ idProperty: 'id',
15
+ name: null,
16
+ version: null,
17
+ ...options
18
+ };
19
+
20
+ // Plugin system
21
+ this.plugins = [];
22
+ this.hooks = new Map();
23
+
24
+ // Storage for different implementations
25
+ this.implementers = new Map();
26
+
27
+ // Initialize hook points
28
+ this._initializeHooks();
29
+
30
+ // Auto-register if name and version provided
31
+ if (this.options.name && this.options.version) {
32
+ this.register();
33
+ }
34
+ }
35
+
36
+ /**
37
+ * Register this API in the global registry
38
+ */
39
+ register() {
40
+ const { name, version } = this.options;
41
+
42
+ if (!name || !version) {
43
+ throw new Error('API name and version required for registration');
44
+ }
45
+
46
+ if (!globalRegistry.has(name)) {
47
+ globalRegistry.set(name, new Map());
48
+ }
49
+
50
+ globalRegistry.get(name).set(version, this);
51
+ return this;
52
+ }
53
+
54
+ /**
55
+ * Get all versions of an API by name
56
+ */
57
+ static getVersions(name) {
58
+ const versions = globalRegistry.get(name);
59
+ return versions ? Array.from(versions.keys()) : [];
60
+ }
61
+
62
+ /**
63
+ * Get a compatible API version
64
+ */
65
+ static get(name, version = 'latest') {
66
+ const versions = globalRegistry.get(name);
67
+ if (!versions) return null;
68
+
69
+ // Sort versions for lookup
70
+ const sortedVersions = Array.from(versions.entries())
71
+ .sort(([a], [b]) => semver.compare(b, a));
72
+
73
+ // Latest version
74
+ if (version === 'latest') {
75
+ return sortedVersions[0]?.[1];
76
+ }
77
+
78
+ // Exact match
79
+ if (versions.has(version)) {
80
+ return versions.get(version);
81
+ }
82
+
83
+ // Compatible version
84
+ for (const [ver, api] of sortedVersions) {
85
+ // If no operators, treat as minimum version
86
+ if (!version.match(/[<>^~]/)) {
87
+ if (semver.gte(ver, version)) {
88
+ return api;
89
+ }
90
+ } else if (semver.satisfies(ver, version)) {
91
+ return api;
92
+ }
93
+ }
94
+
95
+ return null;
96
+ }
97
+
98
+ /**
99
+ * Get all registered APIs
100
+ */
101
+ static getRegistry() {
102
+ const registry = {};
103
+ for (const [name, versions] of globalRegistry) {
104
+ registry[name] = Array.from(versions.keys()).sort(semver.rcompare);
105
+ }
106
+ return registry;
107
+ }
108
+
109
+ /**
110
+ * Access other APIs by name
111
+ */
112
+ get apis() {
113
+ const self = this;
114
+ return new Proxy({}, {
115
+ get(target, prop) {
116
+ // Get a compatible API based on current version
117
+ return Api.get(prop, self.options.version);
118
+ }
119
+ });
120
+ }
121
+
122
+ /**
123
+ * Add a plugin to the API
124
+ */
125
+ use(plugin, options = {}) {
126
+ if (typeof plugin.install === 'function') {
127
+ plugin.install(this, options);
128
+ }
129
+ this.plugins.push({ plugin, options });
130
+ return this;
131
+ }
132
+
133
+ /**
134
+ * Check if a plugin is already installed
135
+ */
136
+ hasPlugin(plugin) {
137
+ return this.plugins.some(p => p.plugin === plugin);
138
+ }
139
+
140
+ /**
141
+ * Register a hook handler
142
+ */
143
+ hook(name, handler, priority = 50) {
144
+ if (!this.hooks.has(name)) {
145
+ this.hooks.set(name, []);
146
+ }
147
+ const handlers = this.hooks.get(name);
148
+ handlers.push({ handler, priority });
149
+ handlers.sort((a, b) => a.priority - b.priority);
150
+ return this;
151
+ }
152
+
153
+ /**
154
+ * Execute hooks
155
+ */
156
+ async executeHook(name, context) {
157
+ const handlers = this.hooks.get(name) || [];
158
+ for (const { handler } of handlers) {
159
+ const result = await handler(context);
160
+ if (result === false) break; // Allow hooks to stop the chain
161
+ }
162
+ return context;
163
+ }
164
+
165
+ /**
166
+ * Register an implementation for a method
167
+ */
168
+ implement(method, handler) {
169
+ this.implementers.set(method, handler);
170
+ return this;
171
+ }
172
+
173
+ /**
174
+ * Initialize core hooks
175
+ */
176
+ _initializeHooks() {
177
+ const hookNames = [
178
+ 'beforeValidate',
179
+ 'afterValidate',
180
+ 'beforeGet',
181
+ 'afterGet',
182
+ 'beforeQuery',
183
+ 'afterQuery',
184
+ 'beforeInsert',
185
+ 'afterInsert',
186
+ 'beforeUpdate',
187
+ 'afterUpdate',
188
+ 'beforeDelete',
189
+ 'afterDelete',
190
+ 'beforeSend',
191
+ 'transformResult'
192
+ ];
193
+
194
+ hookNames.forEach(name => this.hooks.set(name, []));
195
+ }
196
+
197
+ /**
198
+ * Get a single resource
199
+ */
200
+ async get(id, options = {}) {
201
+ const context = {
202
+ api: this,
203
+ method: 'get',
204
+ id,
205
+ options: { ...this.options, ...options },
206
+ result: null,
207
+ errors: []
208
+ };
209
+
210
+ // Execute before hook
211
+ await this.executeHook('beforeGet', context);
212
+
213
+ // Get the implementation
214
+ const impl = this.implementers.get('get');
215
+ if (!impl) {
216
+ throw new Error('No implementation for get method');
217
+ }
218
+
219
+ // Execute the implementation
220
+ context.result = await impl(context);
221
+
222
+ // Execute after hook
223
+ await this.executeHook('afterGet', context);
224
+
225
+ // Transform result
226
+ await this.executeHook('transformResult', context);
227
+
228
+ return this._formatResponse(context);
229
+ }
230
+
231
+ /**
232
+ * Query multiple resources
233
+ */
234
+ async query(params = {}, options = {}) {
235
+ const context = {
236
+ api: this,
237
+ method: 'query',
238
+ params,
239
+ options: { ...this.options, ...options },
240
+ results: [],
241
+ meta: {},
242
+ errors: []
243
+ };
244
+
245
+ // Execute before hook
246
+ await this.executeHook('beforeQuery', context);
247
+
248
+ // Get the implementation
249
+ const impl = this.implementers.get('query');
250
+ if (!impl) {
251
+ throw new Error('No implementation for query method');
252
+ }
253
+
254
+ // Execute the implementation
255
+ const queryResult = await impl(context);
256
+ context.results = queryResult.results || [];
257
+ context.meta = queryResult.meta || {};
258
+
259
+ // Execute after hook
260
+ await this.executeHook('afterQuery', context);
261
+
262
+ // Transform results
263
+ for (let i = 0; i < context.results.length; i++) {
264
+ const itemContext = { ...context, result: context.results[i] };
265
+ await this.executeHook('transformResult', itemContext);
266
+ context.results[i] = itemContext.result;
267
+ }
268
+
269
+ return this._formatQueryResponse(context);
270
+ }
271
+
272
+ /**
273
+ * Insert a new resource
274
+ */
275
+ async insert(data, options = {}) {
276
+ const context = {
277
+ api: this,
278
+ method: 'insert',
279
+ data,
280
+ options: { ...this.options, ...options },
281
+ result: null,
282
+ errors: []
283
+ };
284
+
285
+ // Validation
286
+ await this.executeHook('beforeValidate', context);
287
+ await this.executeHook('afterValidate', context);
288
+
289
+ if (context.errors.length > 0) {
290
+ throw this._createValidationError(context.errors);
291
+ }
292
+
293
+ // Execute before hook
294
+ await this.executeHook('beforeInsert', context);
295
+
296
+ // Get the implementation
297
+ const impl = this.implementers.get('insert');
298
+ if (!impl) {
299
+ throw new Error('No implementation for insert method');
300
+ }
301
+
302
+ // Execute the implementation
303
+ context.result = await impl(context);
304
+
305
+ // Execute after hook
306
+ await this.executeHook('afterInsert', context);
307
+
308
+ // Transform result
309
+ await this.executeHook('transformResult', context);
310
+
311
+ return this._formatResponse(context);
312
+ }
313
+
314
+ /**
315
+ * Update an existing resource
316
+ */
317
+ async update(id, data, options = {}) {
318
+ const context = {
319
+ api: this,
320
+ method: 'update',
321
+ id,
322
+ data,
323
+ options: { ...this.options, ...options },
324
+ result: null,
325
+ errors: []
326
+ };
327
+
328
+ // Validation
329
+ await this.executeHook('beforeValidate', context);
330
+ await this.executeHook('afterValidate', context);
331
+
332
+ if (context.errors.length > 0) {
333
+ throw this._createValidationError(context.errors);
334
+ }
335
+
336
+ // Execute before hook
337
+ await this.executeHook('beforeUpdate', context);
338
+
339
+ // Get the implementation
340
+ const impl = this.implementers.get('update');
341
+ if (!impl) {
342
+ throw new Error('No implementation for update method');
343
+ }
344
+
345
+ // Execute the implementation
346
+ context.result = await impl(context);
347
+
348
+ // Execute after hook
349
+ await this.executeHook('afterUpdate', context);
350
+
351
+ // Transform result
352
+ await this.executeHook('transformResult', context);
353
+
354
+ return this._formatResponse(context);
355
+ }
356
+
357
+ /**
358
+ * Delete a resource
359
+ */
360
+ async delete(id, options = {}) {
361
+ const context = {
362
+ api: this,
363
+ method: 'delete',
364
+ id,
365
+ options: { ...this.options, ...options },
366
+ result: null,
367
+ errors: []
368
+ };
369
+
370
+ // Execute before hook
371
+ await this.executeHook('beforeDelete', context);
372
+
373
+ // Get the implementation
374
+ const impl = this.implementers.get('delete');
375
+ if (!impl) {
376
+ throw new Error('No implementation for delete method');
377
+ }
378
+
379
+ // Execute the implementation
380
+ await impl(context);
381
+
382
+ // Execute after hook
383
+ await this.executeHook('afterDelete', context);
384
+
385
+ return { data: null };
386
+ }
387
+
388
+ /**
389
+ * Format a single resource response
390
+ */
391
+ _formatResponse(context) {
392
+ if (!context.result) {
393
+ return { data: null };
394
+ }
395
+
396
+ return {
397
+ data: this._formatResource(context.result, context.options.type)
398
+ };
399
+ }
400
+
401
+ /**
402
+ * Format a query response
403
+ */
404
+ _formatQueryResponse(context) {
405
+ return {
406
+ data: context.results.map(item =>
407
+ this._formatResource(item, context.options.type)
408
+ ),
409
+ meta: context.meta,
410
+ links: context.links || {}
411
+ };
412
+ }
413
+
414
+ /**
415
+ * Format a resource according to JSON:API
416
+ */
417
+ _formatResource(resource, type) {
418
+ if (!resource) return null;
419
+
420
+ const { [this.options.idProperty]: id, ...attributes } = resource;
421
+
422
+ return {
423
+ id: String(id),
424
+ type: type || 'resource',
425
+ attributes
426
+ };
427
+ }
428
+
429
+ /**
430
+ * Create a validation error
431
+ */
432
+ _createValidationError(errors) {
433
+ const error = new Error('Validation failed');
434
+ error.status = 422;
435
+ error.errors = errors.map(err => ({
436
+ status: '422',
437
+ title: 'Validation Error',
438
+ detail: err.message,
439
+ source: { pointer: `/data/attributes/${err.field}` }
440
+ }));
441
+ return error;
442
+ }
443
+
444
+ /**
445
+ * Create a search schema from an existing schema
446
+ */
447
+ createSearchSchema(schema, fields) {
448
+ const searchStructure = {};
449
+
450
+ // Include only specified fields
451
+ fields.forEach(field => {
452
+ if (schema.structure[field]) {
453
+ searchStructure[field] = { ...schema.structure[field] };
454
+ // Make search fields optional
455
+ delete searchStructure[field].required;
456
+ }
457
+ });
458
+
459
+ return new schema.constructor(searchStructure);
460
+ }
461
+
462
+ /**
463
+ * Register a resource type with schema and hooks
464
+ */
465
+ addResource(type, schema, hooks = {}) {
466
+ if (!type || typeof type !== 'string') {
467
+ throw new Error('Resource type must be a non-empty string');
468
+ }
469
+
470
+ if (!schema || typeof schema.validate !== 'function') {
471
+ throw new Error('Schema must have a validate method');
472
+ }
473
+
474
+ if (!this.schemas) {
475
+ this.schemas = new Map();
476
+ }
477
+
478
+ if (!this.resourceHooks) {
479
+ this.resourceHooks = new Map();
480
+ }
481
+
482
+ if (!this._resourceProxies) {
483
+ this._resourceProxies = new Map();
484
+ }
485
+
486
+ this.schemas.set(type, schema);
487
+
488
+ // Register hooks for this resource type
489
+ if (hooks && typeof hooks === 'object') {
490
+ for (const [hookName, handler] of Object.entries(hooks)) {
491
+ if (typeof handler === 'function') {
492
+ // Create a wrapper that only runs for this resource type
493
+ this.hook(hookName, async (context) => {
494
+ if (context.options.type === type) {
495
+ await handler.call(this, context);
496
+ }
497
+ }, 10); // Higher priority to run before other hooks
498
+ }
499
+ }
500
+ this.resourceHooks.set(type, hooks);
501
+ }
502
+
503
+ // Create resource proxy for intuitive access
504
+ const resourceProxy = this._createResourceProxy(type);
505
+ this._resourceProxies.set(type, resourceProxy);
506
+
507
+ return this;
508
+ }
509
+
510
+ /**
511
+ * Create a resource proxy for intuitive API access
512
+ */
513
+ _createResourceProxy(type) {
514
+ const api = this;
515
+
516
+ return {
517
+ // Get a single resource
518
+ get: (id, options = {}) => api.get(id, { ...options, type }),
519
+
520
+ // Query resources
521
+ query: (params = {}, options = {}) => api.query(params, { ...options, type }),
522
+
523
+ // Create a new resource
524
+ post: (data, options = {}) => api.insert(data, { ...options, type }),
525
+ create: (data, options = {}) => api.insert(data, { ...options, type }), // Alias
526
+
527
+ // Update a resource
528
+ put: (id, data, options = {}) => api.update(id, data, { ...options, type }),
529
+ update: (id, data, options = {}) => api.update(id, data, { ...options, type }), // Alias
530
+
531
+ // Delete a resource
532
+ delete: (id, options = {}) => api.delete(id, { ...options, type }),
533
+ remove: (id, options = {}) => api.delete(id, { ...options, type }), // Alias
534
+
535
+ // Access schema
536
+ get schema() {
537
+ return api.schemas.get(type);
538
+ },
539
+
540
+ // Access hooks
541
+ get hooks() {
542
+ return api.resourceHooks.get(type);
543
+ },
544
+
545
+ // Versioned access
546
+ version: (ver) => {
547
+ // Get or create versioned API
548
+ const versionedApi = Api.get(api.options.name, ver);
549
+ if (!versionedApi) {
550
+ throw new Error(`No API version ${ver} found for ${api.options.name}`);
551
+ }
552
+
553
+ // Return the resource proxy from the versioned API
554
+ const versionedResource = versionedApi._resourceProxies?.get(type);
555
+ if (!versionedResource) {
556
+ throw new Error(`Resource ${type} not found in API version ${ver}`);
557
+ }
558
+
559
+ return versionedResource;
560
+ },
561
+
562
+ // Batch operations
563
+ batch: {
564
+ create: (items, options = {}) =>
565
+ Promise.all(items.map(item => api.post(item, { ...options, type }))),
566
+
567
+ update: (updates, options = {}) =>
568
+ Promise.all(updates.map(({ id, data }) =>
569
+ api.update(id, data, { ...options, type })
570
+ )),
571
+
572
+ delete: (ids, options = {}) =>
573
+ Promise.all(ids.map(id => api.delete(id, { ...options, type })))
574
+ }
575
+ };
576
+ }
577
+
578
+ /**
579
+ * Get resources accessor
580
+ */
581
+ get resources() {
582
+ const self = this;
583
+
584
+ if (!this._resourcesProxy) {
585
+ this._resourcesProxy = new Proxy({}, {
586
+ get(target, prop) {
587
+ // Check if resource exists
588
+ const resourceProxy = self._resourceProxies?.get(prop);
589
+ if (!resourceProxy) {
590
+ throw new Error(`Resource '${prop}' not found. Did you forget to call addResource('${prop}', schema)?`);
591
+ }
592
+ return resourceProxy;
593
+ },
594
+
595
+ has(target, prop) {
596
+ return self._resourceProxies?.has(prop) || false;
597
+ },
598
+
599
+ ownKeys(target) {
600
+ return Array.from(self._resourceProxies?.keys() || []);
601
+ },
602
+
603
+ getOwnPropertyDescriptor(target, prop) {
604
+ if (self._resourceProxies?.has(prop)) {
605
+ return {
606
+ enumerable: true,
607
+ configurable: true,
608
+ value: self._resourceProxies.get(prop)
609
+ };
610
+ }
611
+ }
612
+ });
613
+ }
614
+
615
+ return this._resourcesProxy;
616
+ }
617
+
618
+ /**
619
+ * Alternative syntax - direct property access
620
+ */
621
+ resource(type) {
622
+ const resourceProxy = this._resourceProxies?.get(type);
623
+ if (!resourceProxy) {
624
+ throw new Error(`Resource '${type}' not found. Did you forget to call addResource('${type}', schema)?`);
625
+ }
626
+ return resourceProxy;
627
+ }
628
+ }