@hitchy/plugin-odem-rest 0.5.6 → 0.7.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.
@@ -2,7 +2,7 @@
2
2
 
3
3
  module.exports = function() {
4
4
  const Config = this.config;
5
- const Services = this.runtime.services;
5
+ const Services = this.services;
6
6
 
7
7
  const CommonlyAcceptedHeaders = [
8
8
  "Accept", "Accept-Language", "Authorization", "Content-Language",
@@ -28,7 +28,7 @@ module.exports = function() {
28
28
  /**
29
29
  * Handles CORS-related behaviours.
30
30
  *
31
- * @name api.runtime.services.OdemRestCors
31
+ * @name api.services.OdemRestCors
32
32
  */
33
33
  class OdemRestCors {
34
34
  /**
@@ -84,7 +84,7 @@ module.exports = function() {
84
84
  static getRequestFilterForModel( model ) { // eslint-disable-line no-unused-vars
85
85
  return ( req, res, next ) => {
86
86
  if ( !res.headersSent ) {
87
- if ( Services.OdemRestSchema.mayBeExposed( req, model ) ) {
87
+ if ( Services.OdemSchema.mayBeExposed( req, model ) ) {
88
88
  this.handleMethods( model, null, req, res, Accepted.model.methods );
89
89
 
90
90
  if ( res.hasHeader( "Access-Control-Allow-Origin" ) ) {
@@ -112,7 +112,7 @@ module.exports = function() {
112
112
  static getRequestFilterForModelSchema( model ) { // eslint-disable-line no-unused-vars
113
113
  return ( req, res, next ) => {
114
114
  if ( !res.headersSent ) {
115
- if ( Services.OdemRestSchema.mayBeExposed( req, model ) ) {
115
+ if ( Services.OdemSchema.mayBeExposed( req, model ) ) {
116
116
  this.handleMethods( model, null, req, res, Accepted.schema.methods );
117
117
 
118
118
  if ( res.hasHeader( "Access-Control-Allow-Origin" ) ) {
@@ -140,7 +140,7 @@ module.exports = function() {
140
140
  static getRequestFilterForModelItem( model ) { // eslint-disable-line no-unused-vars
141
141
  return ( req, res, next ) => {
142
142
  if ( !res.headersSent && req.params.uuid !== ".schema" ) {
143
- if ( Services.OdemRestSchema.mayBeExposed( req, model ) ) {
143
+ if ( Services.OdemSchema.mayBeExposed( req, model ) ) {
144
144
  this.handleMethods( model, req.params.uuid, req, res, Accepted.item.methods );
145
145
 
146
146
  if ( res.hasHeader( "Access-Control-Allow-Origin" ) ) {
package/index.js CHANGED
@@ -4,7 +4,7 @@ const { posix: { resolve } } = require( "path" );
4
4
 
5
5
  module.exports = function() {
6
6
  const api = this;
7
- const { runtime: { services: Services, models: Models }, utility: { case: Case } } = api;
7
+ const { services: Services, models: Models, utility: { case: Case } } = api;
8
8
 
9
9
  const logDebug = api.log( "hitchy:odem:rest:debug" );
10
10
  const logError = api.log( "hitchy:odem:rest:error" );
@@ -70,7 +70,7 @@ module.exports = function() {
70
70
  const routeName = Case.pascalToKebab( name );
71
71
  const model = Models[name] || {};
72
72
 
73
- addRoutesOnModel( routes, urlPrefix, routeName, model, convenience );
73
+ addRoutesOnModel( routes, urlPrefix, routeName, name, model, convenience );
74
74
  }
75
75
 
76
76
  routes.set( "HEAD " + resolve( urlPrefix, ":model" ), ( _, res ) => res.status( 404 ).send() );
@@ -80,6 +80,20 @@ module.exports = function() {
80
80
  },
81
81
  };
82
82
 
83
+ /**
84
+ * Generates JSON response indicating rejection of access.
85
+ *
86
+ * @param {Hitchy.Core.ServerResponse} res response manager
87
+ * @returns {void}
88
+ */
89
+ function resAccessForbidden( res ) {
90
+ res
91
+ .status( 403 )
92
+ .json( {
93
+ error: "access forbidden",
94
+ } );
95
+ }
96
+
83
97
  /**
84
98
  * Adds routes handling common requests not related to particular model.
85
99
  *
@@ -90,7 +104,7 @@ module.exports = function() {
90
104
  * @returns {void}
91
105
  */
92
106
  function addGlobalRoutes( routes, urlPrefix, models ) {
93
- const { runtime: { services: { Model: BaseModel, OdemRestSchema } }, utility: { case: { pascalToKebab } } } = api;
107
+ const { services: { Model: BaseModel, OdemSchema }, utility: { case: { pascalToKebab } } } = api;
94
108
 
95
109
  routes.set( `GET ${resolve( urlPrefix, ".schema" )}`, reqFetchSchemata );
96
110
 
@@ -101,7 +115,12 @@ module.exports = function() {
101
115
  * @param {Hitchy.Core.ServerResponse} res response manager
102
116
  * @returns {void}
103
117
  */
104
- function reqFetchSchemata( req, res ) {
118
+ async function reqFetchSchemata( req, res ) {
119
+ if ( api.plugins.authentication && !await Services.Authorization.mayAccess( req.user, "@hitchy.odem.schema" ) ) {
120
+ resAccessForbidden( res );
121
+ return;
122
+ }
123
+
105
124
  const modelKeys = Object.keys( models );
106
125
  const numModels = modelKeys.length;
107
126
 
@@ -111,11 +130,11 @@ module.exports = function() {
111
130
  const model = models[modelKeys[i]];
112
131
 
113
132
  if ( model.prototype instanceof BaseModel &&
114
- OdemRestSchema.mayBeExposed( req, model ) &&
115
- OdemRestSchema.mayBePromoted( req, model ) ) {
133
+ OdemSchema.mayBeExposed( req, model ) &&
134
+ OdemSchema.mayBePromoted( req, model ) ) {
116
135
  const slug = pascalToKebab( model.name );
117
136
 
118
- result[slug] = OdemRestSchema.extractPublicData( model );
137
+ result[slug] = OdemSchema.extractPublicData( model );
119
138
  }
120
139
  }
121
140
 
@@ -130,12 +149,13 @@ module.exports = function() {
130
149
  * route patterns into function handling requests matching that pattern
131
150
  * @param {string} urlPrefix common prefix to use on every route regarding any model-related processing
132
151
  * @param {string} routeName name of model to be used in path name of request
152
+ * @param {string} modelName name of model derived from name extracted from path name of request in `routeName`
133
153
  * @param {class<Model>} Model model class
134
154
  * @param {boolean} includeConvenienceRoutes set true to include additional set of routes for controlling all action via GET-requests
135
155
  * @returns {void}
136
156
  */
137
- function addRoutesOnModel( routes, urlPrefix, routeName, Model, includeConvenienceRoutes ) {
138
- const { Model: BaseModel, OdemRestSchema: Schema, OdemUtilityUuid: { ptnUuid } } = Services;
157
+ function addRoutesOnModel( routes, urlPrefix, routeName, modelName, Model, includeConvenienceRoutes ) {
158
+ const { Model: BaseModel, OdemSchema: Schema, OdemUtilityUuid: { ptnUuid } } = Services;
139
159
 
140
160
  const modelUrl = resolve( urlPrefix, routeName );
141
161
 
@@ -182,7 +202,7 @@ module.exports = function() {
182
202
  * @returns {void}
183
203
  */
184
204
  function reqSuccess( req, res ) {
185
- if ( Services.OdemRestSchema.mayBeExposed( req, Model ) ) {
205
+ if ( Services.OdemSchema.mayBeExposed( req, Model ) ) {
186
206
  res.status( 200 ).send();
187
207
  } else {
188
208
  res.status( 403 ).send();
@@ -209,10 +229,15 @@ module.exports = function() {
209
229
  * @param {Hitchy.Core.ServerResponse} res API for creating response
210
230
  * @returns {void}
211
231
  */
212
- function reqFetchSchema( req, res ) {
232
+ async function reqFetchSchema( req, res ) {
213
233
  logDebug( "got request fetching schema" );
214
234
 
215
- if ( !Services.OdemRestSchema.mayBeExposed( req, Model ) ) {
235
+ if ( api.plugins.authentication && !await Services.Authorization.mayAccess( req.user, `@hitchy.odem.model.${modelName}.schema` ) ) {
236
+ resAccessForbidden( res );
237
+ return;
238
+ }
239
+
240
+ if ( !Services.OdemSchema.mayBeExposed( req, Model ) ) {
216
241
  res.status( 403 ).json( { error: "access forbidden by model" } );
217
242
  return;
218
243
  }
@@ -228,23 +253,28 @@ module.exports = function() {
228
253
  * @param {Hitchy.Core.ServerResponse} res API for creating response
229
254
  * @returns {Promise|undefined} promises request processed successfully
230
255
  */
231
- function reqCheckItem( req, res ) {
256
+ async function reqCheckItem( req, res ) {
232
257
  logDebug( "got request checking if some item exists" );
233
258
 
259
+ if ( api.plugins.authentication && !await Services.Authorization.mayAccess( req.user, `@hitchy.odem.model.${modelName}.check` ) ) {
260
+ resAccessForbidden( res );
261
+ return;
262
+ }
263
+
234
264
  if ( !Schema.mayBeExposed( req, Model ) ) {
235
265
  res.status( 403 ).json( { error: "access forbidden by model" } );
236
- return undefined;
266
+ return;
237
267
  }
238
268
 
239
269
  const { uuid } = req.params;
240
270
  if ( !ptnUuid.test( uuid ) ) {
241
271
  res.status( 400 ).send();
242
- return undefined;
272
+ return;
243
273
  }
244
274
 
245
275
  const item = new Model( uuid ); // eslint-disable-line new-cap
246
276
 
247
- return item.$exists
277
+ await item.$exists
248
278
  .then( exists => {
249
279
  res.status( exists ? 200 : 404 ).send();
250
280
  } )
@@ -259,26 +289,31 @@ module.exports = function() {
259
289
  *
260
290
  * @param {Hitchy.Core.IncomingMessage} req description of request
261
291
  * @param {Hitchy.Core.ServerResponse} res API for creating response
262
- * @returns {Promise} promises request processed successfully
292
+ * @returns {Promise<void>} promises request processed successfully
263
293
  */
264
- function reqFetchItem( req, res ) {
294
+ async function reqFetchItem( req, res ) {
265
295
  logDebug( "got request fetching some item" );
266
296
 
297
+ if ( api.plugins.authentication && !await Services.Authorization.mayAccess( req.user, `@hitchy.odem.model.${modelName}.read` ) ) {
298
+ resAccessForbidden( res );
299
+ return;
300
+ }
301
+
267
302
  if ( !Schema.mayBeExposed( req, Model ) ) {
268
303
  res.status( 403 ).json( { error: "access forbidden by model" } );
269
- return undefined;
304
+ return;
270
305
  }
271
306
 
272
307
  const { uuid } = req.params;
273
308
  if ( !ptnUuid.test( uuid ) ) {
274
309
  res.status( 400 ).json( { error: "invalid UUID" } );
275
- return undefined;
310
+ return;
276
311
  }
277
312
 
278
313
  const item = new Model( uuid ); // eslint-disable-line new-cap
279
314
 
280
- return item.load()
281
- .then( loaded => res.json( loaded.toObject( { serialized: true } ) ) )
315
+ await item.load()
316
+ .then( loaded => res.json( Schema.filterItem( loaded.toObject( { serialized: true } ), req, Model, "read" ) ) )
282
317
  .catch( error => {
283
318
  logError( "fetching %s:", routeName, error );
284
319
 
@@ -287,6 +322,7 @@ module.exports = function() {
287
322
  res.status( 404 ).json( { error: "selected item not found" } );
288
323
  break;
289
324
  }
325
+
290
326
  default : {
291
327
  res.status( 500 ).json( { error: error.message } );
292
328
  }
@@ -302,17 +338,22 @@ module.exports = function() {
302
338
  * @param {Hitchy.Core.ServerResponse} res response controller
303
339
  * @returns {Promise} promises response sent
304
340
  */
305
- function reqFetchItems( req, res ) {
341
+ async function reqFetchItems( req, res ) {
306
342
  logDebug( "got request fetching items" );
307
343
 
344
+ if ( api.plugins.authentication && !await Services.Authorization.mayAccess( req.user, `@hitchy.odem.model.${modelName}.list` ) ) {
345
+ resAccessForbidden( res );
346
+ return;
347
+ }
348
+
308
349
  if ( !Schema.mayBeExposed( req, Model ) ) {
309
350
  res.status( 403 ).json( { error: "access forbidden by model" } );
310
- return undefined;
351
+ return;
311
352
  }
312
353
 
313
354
  if ( req.headers["x-list-as-array"] ) {
314
355
  res.status( 400 ).json( { error: "fetching items as array is deprecated for security reasons" } );
315
- return undefined;
356
+ return;
316
357
  }
317
358
 
318
359
  let handler = reqListAll;
@@ -321,7 +362,7 @@ module.exports = function() {
321
362
  handler = reqListMatches;
322
363
  }
323
364
 
324
- return handler.call( this, req, res );
365
+ await handler.call( this, req, res );
325
366
  }
326
367
 
327
368
  /**
@@ -332,7 +373,6 @@ module.exports = function() {
332
373
  */
333
374
  function parseQuery( query ) {
334
375
  const simpleTernary = /^([^:\s]+):between:([^:]+):([^:]+)$/i.exec( query );
335
-
336
376
  if ( simpleTernary ) {
337
377
  const [ , name, lower, upper ] = simpleTernary;
338
378
 
@@ -422,13 +462,13 @@ module.exports = function() {
422
462
 
423
463
  const meta = count || req.headers["x-count"] ? {} : null;
424
464
 
425
- await Model.find( parsedQuery, { offset, limit, sortBy, sortAscendingly: !descending }, {
465
+ await Model.find( Schema.checkQuery( parsedQuery, req, Model ), { offset, limit, sortBy, sortAscendingly: !descending }, {
426
466
  metaCollector: meta,
427
467
  loadRecords
428
468
  } )
429
469
  .then( matches => {
430
470
  const result = {
431
- items: matches.map( m => m.toObject( { serialized: true } ) ),
471
+ items: matches.map( item => Schema.filterItem( item.toObject( { serialized: true } ), req, Model, "list" ) ),
432
472
  };
433
473
 
434
474
  if ( meta ) {
@@ -453,17 +493,18 @@ module.exports = function() {
453
493
  * @param {Hitchy.Core.ServerResponse} res API for creating response
454
494
  * @returns {Promise|undefined} promises request processed successfully
455
495
  */
456
- function reqListAll( req, res ) {
496
+ async function reqListAll( req, res ) {
457
497
  logDebug( "got request listing all items" );
458
498
 
459
499
  if ( !Schema.mayBeExposed( req, Model ) ) {
460
500
  res.status( 403 ).json( { error: "access forbidden by model" } );
461
- return undefined;
501
+ return;
462
502
  }
463
503
 
464
504
  const { offset = 0, limit = Infinity, sortBy = null, descending = false, loadRecords = true, count = false } = req.query;
465
505
  const meta = count || req.headers["x-count"] ? {} : null;
466
- return Model.list( {
506
+
507
+ await Model.list( {
467
508
  offset,
468
509
  limit,
469
510
  sortBy,
@@ -471,7 +512,7 @@ module.exports = function() {
471
512
  }, { loadRecords, metaCollector: meta } )
472
513
  .then( matches => {
473
514
  const result = {
474
- items: matches.map( m => m.toObject( { serialized: true } ) ),
515
+ items: matches.map( item => Schema.filterItem( item.toObject( { serialized: true } ), req, Model, "list" ) ),
475
516
  };
476
517
 
477
518
  if ( meta ) {
@@ -495,38 +536,37 @@ module.exports = function() {
495
536
  * @param {Hitchy.Core.ServerResponse} res API for creating response
496
537
  * @returns {Promise|undefined} promises request processed successfully
497
538
  */
498
- function reqCreateItem( req, res ) {
539
+ async function reqCreateItem( req, res ) {
499
540
  logDebug( "got request creating item" );
500
541
 
542
+ if ( api.plugins.authentication && !await Services.Authorization.mayAccess( req.user, `@hitchy.odem.model.${modelName}.create` ) ) {
543
+ resAccessForbidden( res );
544
+ return;
545
+ }
546
+
501
547
  if ( !Schema.mayBeExposed( req, Model ) ) {
502
548
  res.status( 403 ).json( { error: "access forbidden by model" } );
503
- return undefined;
549
+ return;
504
550
  }
505
551
 
506
552
  const item = new Model(); // eslint-disable-line new-cap
507
553
 
508
- return ( req.method === "GET" ? Promise.resolve( req.query ) : req.fetchBody() )
554
+ await ( req.method === "GET" ? Promise.resolve( req.query ) : req.fetchBody() )
509
555
  .then( record => {
510
- if ( record.uuid ) {
511
- logDebug( "creating %s:", routeName, "new entry can not be created with uuid" );
512
- res.status( 400 ).json( { error: "new entry can not be created with uuid" } );
513
- return undefined;
514
- }
515
-
516
556
  if ( record ) {
517
- const names = Object.keys( record );
518
- const numNames = names.length;
557
+ if ( record.uuid ) {
558
+ logDebug( "creating %s:", routeName, "new entry can not be created with uuid" );
559
+ res.status( 400 ).json( { error: "new entry can not be created with uuid" } );
560
+ return undefined;
561
+ }
519
562
 
563
+ const filtered = Schema.filterItem( record, req, Model, "create" );
520
564
  const definedProps = Model.schema.props;
521
565
  const definedComputed = Model.schema.computed;
522
566
 
523
- for ( let i = 0; i < numNames; i++ ) {
524
- const name = names[i];
525
-
526
- if ( definedProps[name] ) {
527
- item.$properties[name] = record[name];
528
- } else if ( definedComputed[name] ) {
529
- item[name] = record[name];
567
+ for ( const [ key, value ] of Object.entries( filtered ) ) {
568
+ if ( definedProps[key]?.$type || definedComputed[key] ) {
569
+ item[key] = value;
530
570
  }
531
571
  }
532
572
  }
@@ -549,23 +589,28 @@ module.exports = function() {
549
589
  * @param {Hitchy.Core.ServerResponse} res API for creating response
550
590
  * @returns {Promise|undefined} promises request processed successfully
551
591
  */
552
- function reqModifyItem( req, res ) {
592
+ async function reqModifyItem( req, res ) {
553
593
  logDebug( "got request to modify some item" );
554
594
 
595
+ if ( api.plugins.authentication && !await Services.Authorization.mayAccess( req.user, `@hitchy.odem.model.${modelName}.write` ) ) {
596
+ resAccessForbidden( res );
597
+ return;
598
+ }
599
+
555
600
  if ( !Schema.mayBeExposed( req, Model ) ) {
556
601
  res.status( 403 ).json( { error: "access forbidden by model" } );
557
- return undefined;
602
+ return;
558
603
  }
559
604
 
560
605
  const { uuid } = req.params;
561
606
  if ( !ptnUuid.test( uuid ) ) {
562
607
  res.status( 400 ).json( { error: "invalid UUID" } );
563
- return undefined;
608
+ return;
564
609
  }
565
610
 
566
611
  const item = new Model( uuid ); // eslint-disable-line new-cap
567
612
 
568
- return item.$exists
613
+ await item.$exists
569
614
  .then( exists => {
570
615
  if ( !exists ) {
571
616
  res.status( 404 ).json( { error: "selected item not found" } );
@@ -578,27 +623,20 @@ module.exports = function() {
578
623
  ] )
579
624
  .then( ( [ loaded, record ] ) => {
580
625
  if ( record ) {
581
- // FIXME consider using Odem's Model.fromObject() method here
582
- const names = Object.keys( record );
583
- const numNames = names.length;
584
-
626
+ const filtered = Schema.filterItem( record, req, Model, "write" );
585
627
  const definedProps = Model.schema.props;
586
628
  const definedComputed = Model.schema.computed;
587
629
 
588
- for ( let i = 0; i < numNames; i++ ) {
589
- const name = names[i];
590
-
591
- if ( definedProps[name] ) {
592
- loaded.$properties[name] = record[name]; // eslint-disable-line no-param-reassign
593
- } else if ( definedComputed[name] ) {
594
- loaded[name] = record[name]; // eslint-disable-line no-param-reassign
630
+ for ( const [ key, value ] of Object.entries( filtered ) ) {
631
+ if ( definedProps[key]?.$type || definedComputed[key] ) {
632
+ loaded[key] = value; // eslint-disable-line no-param-reassign
595
633
  }
596
634
  }
597
635
  }
598
636
 
599
637
  return loaded.save()
600
638
  .then( saved => {
601
- res.json( saved.toObject( { serialized: true } ) );
639
+ res.json( Schema.filterItem( saved.toObject( { serialized: true } ), req, Model, "read" ) );
602
640
  } );
603
641
  } );
604
642
  } )
@@ -616,23 +654,28 @@ module.exports = function() {
616
654
  * @param {Hitchy.Core.ServerResponse} res API for creating response
617
655
  * @returns {Promise|undefined} promises request processed successfully
618
656
  */
619
- function reqReplaceItem( req, res ) {
657
+ async function reqReplaceItem( req, res ) {
620
658
  logDebug( "got request replacing some item" );
621
659
 
660
+ if ( api.plugins.authentication && !await Services.Authorization.mayAccess( req.user, `@hitchy.odem.model.${modelName}.write` ) ) {
661
+ resAccessForbidden( res );
662
+ return;
663
+ }
664
+
622
665
  if ( !Schema.mayBeExposed( req, Model ) ) {
623
666
  res.status( 403 ).json( { error: "access forbidden by model" } );
624
- return undefined;
667
+ return;
625
668
  }
626
669
 
627
670
  const { uuid } = req.params;
628
671
  if ( !ptnUuid.test( uuid ) ) {
629
672
  res.status( 400 ).json( { error: "invalid UUID" } );
630
- return undefined;
673
+ return;
631
674
  }
632
675
 
633
676
  const item = new Model( uuid, { onUnsaved: false } ); // eslint-disable-line new-cap
634
677
 
635
- return Promise.all( [ item.$exists, req.method === "GET" ? Promise.resolve( req.query ) : req.fetchBody() ] )
678
+ await Promise.all( [ item.$exists, req.method === "GET" ? Promise.resolve( req.query ) : req.fetchBody() ] )
636
679
  .then( ( [ exists, record ] ) => {
637
680
  return ( exists ? item.load() : Promise.resolve() ).then( () => {
638
681
  const propNames = Object.keys( Model.schema.props );
@@ -640,30 +683,27 @@ module.exports = function() {
640
683
 
641
684
  const computedNames = Object.keys( Model.schema.computed );
642
685
  const numComputedNames = computedNames.length;
686
+ const filtered = Schema.filterItem( record, req, Model, "write" );
643
687
 
644
688
  // update properties, drop those missing in provided record
645
689
  for ( let i = 0; i < numPropNames; i++ ) {
646
690
  const propName = propNames[i];
647
- const value = record[propName];
691
+ const value = filtered[propName];
648
692
 
649
- if ( value == null ) {
650
- item.$properties[propName] = null;
651
- } else {
652
- item.$properties[propName] = value;
653
- }
693
+ item[propName] = value ?? null;
654
694
  }
655
695
 
656
696
  // assign all posted computed properties
657
697
  for ( let i = 0; i < numComputedNames; i++ ) {
658
698
  const computedName = computedNames[i];
659
- item[computedName] = record[computedName];
699
+ item[computedName] = filtered[computedName];
660
700
  }
661
701
 
662
702
  return item.save( { ignoreUnloaded: !exists } );
663
703
  } );
664
704
  } )
665
705
  .then( saved => {
666
- res.json( { uuid: saved.uuid } );
706
+ res.json( Schema.filterItem( saved.toObject( { serialized: true } ), req, Model, "read" ) );
667
707
  } )
668
708
  .catch( error => {
669
709
  logError( "updating %s:", routeName, error );
@@ -678,23 +718,28 @@ module.exports = function() {
678
718
  * @param {Hitchy.Core.ServerResponse} res API for creating response
679
719
  * @returns {Promise|undefined} promises request processed successfully
680
720
  */
681
- function reqRemoveItem( req, res ) {
721
+ async function reqRemoveItem( req, res ) {
682
722
  logDebug( "got request removing some item" );
683
723
 
724
+ if ( api.plugins.authentication && !await Services.Authorization.mayAccess( req.user, `@hitchy.odem.model.${modelName}.remove` ) ) {
725
+ resAccessForbidden( res );
726
+ return;
727
+ }
728
+
684
729
  if ( !Schema.mayBeExposed( req, Model ) ) {
685
730
  res.status( 403 ).json( { error: "access forbidden by model" } );
686
- return undefined;
731
+ return;
687
732
  }
688
733
 
689
734
  const { uuid } = req.params;
690
735
  if ( !ptnUuid.test( uuid ) ) {
691
736
  res.status( 400 ).json( { error: "invalid UUID" } );
692
- return undefined;
737
+ return;
693
738
  }
694
739
 
695
740
  const item = new Model( uuid ); // eslint-disable-line new-cap
696
741
 
697
- return item.$exists
742
+ await item.$exists
698
743
  .then( exists => {
699
744
  if ( exists ) {
700
745
  return item.remove()
package/package.json CHANGED
@@ -1,34 +1,33 @@
1
1
  {
2
2
  "name": "@hitchy/plugin-odem-rest",
3
- "version": "0.5.6",
4
- "description": "HTTP REST API for Hitchy's ODM",
3
+ "version": "0.7.0",
4
+ "description": "HTTP REST API for Hitchy's document-oriented database",
5
5
  "main": "index.js",
6
6
  "scripts": {
7
7
  "lint": "eslint .",
8
- "test": "hitchy-pm odem --exec mocha --ui=tdd 'test/scripts/**/*.js'",
9
- "coverage": "hitchy-pm odem --exec c8 mocha --ui=tdd 'test/scripts/**/*.js'"
8
+ "test": "hitchy-pm odem auth --exec c8 mocha --ui=tdd 'test/scripts/**/*.js'"
10
9
  },
11
10
  "repository": "https://gitlab.com/hitchy/plugin-odem-rest.git",
12
11
  "keywords": [
13
- "hitchy",
14
- "ODM"
12
+ "hitchy"
15
13
  ],
16
14
  "author": "Thomas Urban",
17
15
  "license": "MIT",
18
16
  "bugs": "https://gitlab.com/hitchy/plugin-odem-rest/-/issues",
19
17
  "homepage": "https://gitlab.com/hitchy/plugin-odem-rest#plugin-odem-rest",
20
18
  "peerDependencies": {
21
- "@hitchy/core": "^0.8.x",
22
- "@hitchy/plugin-odem": "^0.7.8"
19
+ "@hitchy/core": "0.8.x",
20
+ "@hitchy/plugin-odem": "0.9.x",
21
+ "@hitchy/plugin-auth": "0.4.x"
23
22
  },
24
23
  "devDependencies": {
25
24
  "@hitchy/types": "^0.1.3",
26
- "@hitchy/server-dev-tools": "^0.4.8",
27
- "c8": "^8.0.1",
28
- "eslint": "^8.47.0",
29
- "eslint-config-cepharum": "^1.0.13",
30
- "eslint-plugin-promise": "^6.1.1",
31
- "mocha": "^10.2.0",
25
+ "@hitchy/server-dev-tools": "^0.4.9",
26
+ "c8": "^10.1.2",
27
+ "eslint": "^8.57.0",
28
+ "eslint-config-cepharum": "^1.0.14",
29
+ "eslint-plugin-promise": "^6.6.0",
30
+ "mocha": "^10.7.3",
32
31
  "should": "^13.2.3",
33
32
  "should-http": "^0.1.1"
34
33
  }
package/readme.md CHANGED
@@ -1,10 +1,10 @@
1
1
  # plugin-odem-rest [![pipeline status](https://gitlab.com/hitchy/plugin-odem-rest/badges/master/pipeline.svg)](https://gitlab.com/hitchy/plugin-odem-rest/-/commits/master)
2
2
 
3
- HTTP REST API for [Hitchy's](https://core.hitchy.org/) [ODM](https://odem.hitchy.org/)
3
+ HTTP REST API for [Hitchy's](https://core.hitchy.org/) [document-oriented database](https://odem.hitchy.org/)
4
4
 
5
- [Hitchy](http://core.hitchy.org/) is a server-side framework for developing web applications with [Node.js](https://nodejs.org/). [Odem](https://www.npmjs.com/package/@hitchy/plugin-odem) is plugin for Hitchy implementing an object document management (ODM) using data backends like regular file systems, LevelDBs and temporary in-memory databases.
5
+ [Hitchy](http://core.hitchy.org/) is a server-side framework for developing web applications with [Node.js](https://nodejs.org/). [Odem](https://www.npmjs.com/package/@hitchy/plugin-odem) is a plugin for Hitchy implementing a document-oriented database using data backends like regular file systems, LevelDBs and temporary in-memory databases.
6
6
 
7
- This plugin is defining blueprint routes for accessing data managed in ODM using REST API.
7
+ This plugin is defining blueprint routes for accessing data managed in document-oriented database using REST API.
8
8
 
9
9
  ## License
10
10
 
@@ -21,15 +21,34 @@ npm i @hitchy/plugin-odem-rest @hitchy/plugin-odem
21
21
  The command is installing this plugin and the additionally required [@hitchy/plugin-odem](https://www.npmjs.com/package/@hitchy/plugin-odem).
22
22
 
23
23
  :::warning Compatibility
24
- Starting with version 0.4.0 the latter plugin must be installed
25
- explicitly.
26
- :::
24
+ Starting with version 0.4.0 the latter plugin must be installed explicitly.
25
+ :::
26
+
27
+ :::warning Compatibility
28
+ Starting with version 0.6.0 authorization is tightened as soon as [@hitchy/plugin-auth](https://auth.hitchy.org) is discovered in current project. You need an [additional configuration](#authorization) to keep the REST API as open as before.
29
+ :::
27
30
 
28
31
  ## Usage
29
32
 
30
33
  This package depends on [@hitchy/plugin-odem](https://odem.hitchy.org/) and its preparation of model definitions discovered by [Hitchy's core](https://core.hitchy.org/). See the linked manuals for additional information.
31
34
 
32
- For a quick start example create a file **api/models/local-employee.js** in your Hitchy-based application with the following content:
35
+ In addition, [@hitchy/plugin-auth](https://auth.hitchy.org) is supported resulting in a tightened access control. When using that plugin in a project, a file **config/auth.js** with following content is necessary to test-drive this plugin's REST API:
36
+
37
+ ```javascript
38
+ exports.auth = {
39
+ authorizations: {
40
+ "@hitchy.odem": "*"
41
+ }
42
+ };
43
+ ```
44
+
45
+ > **Do not use this in a production setup!**
46
+ >
47
+ > This example is granting permissions to create, adjust and remove items of your document-oriented database without any authentication. In addition, all _protected_ models and properties get exposed to everyone.
48
+ >
49
+ > See the [section on authorization](#authorization) for additional information!
50
+
51
+ Now, for a quick start example create a file **api/models/local-employee.js** in your Hitchy-based application with the following content:
33
52
 
34
53
  ```javascript
35
54
  module.exports = {
@@ -60,7 +79,7 @@ module.exports = {
60
79
  };
61
80
  ```
62
81
 
63
- When starting your Hitchy-based application it will discover a model named **LocalEmployee** and expose it via REST API using URLs like `/api/local-employee` just because of this package and its dependencies mentioned before.
82
+ When starting your Hitchy-based application it will discover a model named **LocalEmployee** and expose it via REST API using base URL `/api/local-employee` just because of this package and its dependencies mentioned before.
64
83
 
65
84
  #### Models of Hitchy plugins
66
85
 
@@ -69,7 +88,7 @@ Due to the way Hitchy is discovering plugins and compiling [components](https://
69
88
 
70
89
  ### How it works
71
90
 
72
- This plugin is defining a set of [blueprint routes](https://core.hitchy.org/internals/routing-basics.html#focusing-on-routes) implementing REST API for every model defined in file system as described before.
91
+ This plugin is defining a set of [blueprint routes](https://core.hitchy.org/internals/routing-basics.html#focusing-on-routes) implementing a REST API for every model defined in file system as described before.
73
92
 
74
93
  Those routes comply with this pattern:
75
94
 
@@ -86,7 +105,7 @@ exports.model = {
86
105
 
87
106
  The model's segment in URL `<model>` is derived as the kebab-case version of model's name which is given in PascalCase. Thus, a model definition in a file named **api/models/my-fancy-model.js** is assumed to describe a model named **MyFancyModel** by default, resulting in model's URL segment to be **my-fancy-model** again. So the URL path for the collection of items is `/api/my-fancy-model`.
88
107
 
89
- In Hitchy's ODM all model instances or items are uniquely addressable via UUIDs. By appending an item's UUID to the given URL path of a collection you get the URL path of that item, e.g. `/api/my-fancy-model/01234567-1234-1234-1234-56789abcdef0`.
108
+ In Hitchy's document-oriented database all model instances or items are uniquely addressable via UUIDs. By appending an item's UUID to the given URL path of a collection you get the URL path of that item, e.g. `/api/my-fancy-model/01234567-1234-1234-1234-56789abcdef0`.
90
109
 
91
110
 
92
111
  ### The REST API
@@ -201,9 +220,9 @@ For example, a GET-request for `/api/localEmployee?q=salary:between:2000:4000` w
201
220
 
202
221
  ##### Complex tests <Bade type=info text=v0.5.2+></Badge>
203
222
 
204
- Hitchy ODM supports more complex queries that can't be encoded as such simple queries as described above. Thus, a different way of querying has been added.
223
+ Hitchy's document-oriented database supports more complex queries that can't be encoded as such simple queries as described above. Thus, a different way of querying has been added.
205
224
 
206
- When using parameter `query` instead of `q`, its value is assumed to be a JSON-encoded query complying with query syntax supported by [Model.find() method of Hitchy ODM](https://odem.hitchy.org/api/model.html#model-find).
225
+ When using parameter `query` instead of `q`, its value is assumed to be a JSON-encoded query complying with query syntax supported by [Model.find() method of Hitchy's document-oriented database](https://odem.hitchy.org/api/model.html#model-find).
207
226
 
208
227
  ```http request
209
228
  GET /api/user?query={"in":{"name":["john","jane","jason"]}}
@@ -231,3 +250,73 @@ Using query parameter `sortBy=lastName` a fetched list of items is sorted by val
231
250
  Query parameter `limit=n` is requesting to fetch at most **n** items. Parameter `offset=n` is requesting to skip **n** items before starting retrieval. Slicing is applied after sorting items.
232
251
 
233
252
  When slicing this way only a subset of basically available items is fetched by intention. If you need to know the total number of available items when requesting a slice you can either set custom field `x-count` in request header or query parameter `count` to `1` or any other truthy value. This will have a slight negative impact on request performance, but causes delivery of the total number of matching items in a separate property `count` of response body as well as in response header named `x-count`.
253
+
254
+ #### Authorization
255
+
256
+ As soon as [@hitchy/plugin-auth](https://auth.hitchy.org) is included with your project, **@hitchy/plugin-odem-rest** is using it to check a requesting user's authorization by declaring named [resources](https://auth.hitchy.org/introduction.html#resources) available for setting up authorization rules in [database](https://auth.hitchy.org/api/model/authorization-rule.html) or in [configuration](https://auth.hitchy.org/api/config.html#config-auth-authorizations).
257
+
258
+ ##### Resource namespace
259
+
260
+ All declared resources share common prefix `@hitchy.odem`.
261
+
262
+ Despite the generic name, the authorization control described here does not apply to **@hitchy/plugin-odem** in general. Custom server-side code needs to stay capable of interacting with models and their private or protected properties. Instead, authorization control affects the REST API implemented by this plugin, only. Other plugins may implement APIs for accessing the same models by different means. Those plugins may share the resource naming pattern described here so that all APIs featuring remote interaction with the data can be controlled in the same way. Thus, resource names omit to refer to the REST API explicitly.
263
+
264
+ ##### Per-model resources
265
+
266
+ Resources are declared to control authorizations for basically interacting with either model:
267
+
268
+ * When finding or listing items, accessing the resource `@hitchy.odem.model.<ModelName>.list` must be granted.
269
+ * When checking an item, accessing the resource `@hitchy.odem.model.<ModelName>.check` must be granted.
270
+ * When reading an item, accessing the resource `@hitchy.odem.model.<ModelName>.read` must be granted.
271
+ * When patching or replacing an item, accessing the resource `@hitchy.odem.model.<ModelName>.write` must be granted.
272
+ * When creating a new item, accessing the resource `@hitchy.odem.model.<ModelName>.create` must be granted.
273
+ * When removing an item, accessing the resource `@hitchy.odem.model.<ModelName>.remove` must be granted.
274
+ * Accessing a model's schema requires the resource `@hitchy.odem.model.<ModelName>.schema` to be granted.
275
+ * Accessing the collection of all models' schemata requires the resource `@hitchy.odem.schema` to be granted. This implicitly causes models with their [promote option](https://odem.hitchy.org/guides/defining-models.html#options) being `protected` to be exposed in responses to that request unless resource `@hitchy.odem.model.<ModelName>.promote` isn't revoked from the user.
276
+
277
+ > In these examples, replace `<ModelName>` with a model's name in PascalCase.
278
+
279
+ ##### Per-property resources
280
+
281
+ In addition, resources are declared to control access on a model's protected properties. By default, protected properties are hidden from a user's REST request.
282
+
283
+ > Prior to supporting **@hitchy/plugin-auth** for authoriation control in v0.6.0, protected properties have been available to any authenticated user. The same behavior still applies if **@hitchy/plugin-auth** isn't available in a project.
284
+
285
+ * When finding or listing items, accessing the resource `@hitchy.odem.model.<ModelName>.property.<propertyName>.list` must be granted for the protected property to be supported in filter queries and to include it in resulting set of items.
286
+ * When reading an item, accessing the resource `@hitchy.odem.model.<ModelName>.property.<propertyName>.read` must be granted to include the property in the resulting record.
287
+ * When creating an item, accessing the resource `@hitchy.odem.model.<ModelName>.property.<propertyName>.create` must be granted to include the property in the resulting record.
288
+ * When patching or replacing an item, accessing the resource `@hitchy.odem.model.<ModelName>.property.<propertyName>.write` must be granted to consider an update for the property and to include it in the resulting record.
289
+
290
+ > In these examples, replace `<ModelName>` with a model's name in PascalCase and `<propertyName>` with its name as defined in the model.
291
+
292
+ Per-property authorization control is not considered for public and private properties. This is meant to limit performance penalties on processing requests.
293
+
294
+ * Public properties are always available via REST API as soon as the according [per-model authorization](#per-model-resources) is given.
295
+ * Private properties are never available.
296
+
297
+ ##### Examples
298
+
299
+ The following example for a file **config/auth.js** is effectively disabling all authorization control in context of **@hitchy/plugin-odem-rest** and thus should be used for evaluation purposes, only:
300
+
301
+ ```javascript
302
+ exports.auth = {
303
+ authorizations: {
304
+ "@hitchy.odem": "*"
305
+ }
306
+ };
307
+ ```
308
+
309
+ A slightly more sophisticated example could look like this:
310
+
311
+ ```javascript
312
+ exports.auth = {
313
+ authorizations: {
314
+ "@hitchy.odem": "*",
315
+ "@hitchy.odem.model.User": [ "-*", "@admin" ],
316
+ "@hitchy.odem.model.Role": [ "-*", "@admin" ],
317
+ "@hitchy.odem.model.User.property.password": [ "-*" ],
318
+ }
319
+ };
320
+ ```
321
+
322
+ This example is granting access to all features of **@hitchy/plugin-odem-rest** unless they address either model `User` or `Role`. Access on those models is limited to users with role `admin`. However, even those users must not access protected property `password`, though you might want to declare it as private anyway in which case last rule in given example does not have any effect.
@@ -1,208 +0,0 @@
1
- "use strict";
2
-
3
- const PtnAcceptsValue = /^\s*(?:function\s*(?:\s\S+\s*)?)?\(\s*[^\s)]/;
4
-
5
- module.exports = function() {
6
- /**
7
- * Handles model schemata in context of REST-API plugin.
8
- *
9
- * @name api.runtime.services.OdemRestSchema
10
- */
11
- class OdemRestSchema {
12
- /**
13
- * Detects if selected model's schema may be promoted to clients or not.
14
- *
15
- * @param {HitchyIncomingMessage} request request descriptor
16
- * @param {class<Model>} model class of model to check
17
- * @returns {boolean} true if model's schema may be promoted to clients, false otherwise
18
- */
19
- static mayBePromoted( request, model ) {
20
- const { schema: { options } } = model;
21
-
22
- const scope = String( options.promote || options.expose || "public" ).trim().toLowerCase();
23
-
24
- switch ( scope ) {
25
- case "public" :
26
- return true;
27
-
28
- case "protected" :
29
- return Boolean( request.user );
30
-
31
- default :
32
- return false;
33
- }
34
- }
35
-
36
- /**
37
- * Detects if selected model may be exposed to clients or not.
38
- *
39
- * @param {HitchyIncomingMessage} request request descriptor
40
- * @param {class<Model>} model class of model to check
41
- * @returns {boolean} true if model may be exposed to clients, false otherwise
42
- */
43
- static mayBeExposed( request, model ) {
44
- const { schema: { options } } = model;
45
-
46
- const scope = String( options.expose || "public" ).trim().toLowerCase();
47
-
48
- switch ( scope ) {
49
- case "public" :
50
- return true;
51
-
52
- case "protected" :
53
- return Boolean( request.user );
54
-
55
- default :
56
- return false;
57
- }
58
- }
59
-
60
- /**
61
- * Extracts information on selected model's schema for publishing.
62
- *
63
- * @param {class<Model>} model model to process
64
- * @param {boolean} omitComputed set true to omit computed properties
65
- * @returns {object} extracted public information on selected model's schema
66
- */
67
- static extractPublicData( model, { omitComputed = false } = {} ) {
68
- const { schema: { props, computed, options } } = model;
69
-
70
- const extracted = {
71
- name: model.name,
72
- props: {},
73
- };
74
-
75
- const propNames = Object.keys( props );
76
- const numProps = propNames.length;
77
-
78
- for ( let i = 0; i < numProps; i++ ) {
79
- const name = propNames[i];
80
- const prop = props[name];
81
-
82
- const copy = extracted.props[name] = {
83
- type: prop.type || "string",
84
- };
85
-
86
- const optionNames = Object.keys( prop );
87
- const numOptions = optionNames.length;
88
-
89
- for ( let j = 0; j < numOptions; j++ ) {
90
- const optionName = optionNames[j];
91
-
92
- switch ( optionName.toLowerCase() ) {
93
- case "type" :
94
- case "indexes" :
95
- case "indices" :
96
- case "index" :
97
- break;
98
-
99
- default : {
100
- const option = prop[optionName];
101
-
102
- if ( option != null && typeof option !== "function" ) {
103
- copy[optionName] = this.cloneSerializable( option );
104
- }
105
- }
106
- }
107
- }
108
- }
109
-
110
- if ( !omitComputed ) {
111
- const computedNames = Object.keys( props );
112
- const numComputed = computedNames.length;
113
- extracted.computed = {};
114
-
115
- for ( let i = 0; i < numComputed; i++ ) {
116
- const name = computedNames[i];
117
- const fn = computed[name];
118
-
119
- if ( typeof fn === "function" ) {
120
- extracted.computed[name] = PtnAcceptsValue.test( fn );
121
- }
122
- }
123
- }
124
-
125
- const optionsNames = Object.keys( options );
126
- const numOptions = optionsNames.length;
127
-
128
- for ( let i = 0; i < numOptions; i++ ) {
129
- const name = optionsNames[i];
130
-
131
- switch ( name ) {
132
- case "onUnsaved" :
133
- case "expose" :
134
- case "promote" :
135
- break;
136
-
137
- default : {
138
- if ( !extracted.options ) {
139
- extracted.options = {};
140
- }
141
-
142
- const option = options[name];
143
-
144
- if ( option != null && typeof option !== "function" ) {
145
- extracted.options[name] = this.cloneSerializable( option );
146
- }
147
- }
148
- }
149
- }
150
-
151
- return extracted;
152
- }
153
-
154
- /**
155
- * Creates deep clone of provided value omitting any data can't be or
156
- * mustn't be serialized.
157
- *
158
- * @param {*} input data to be cloned
159
- * @returns {*} (partial) clone of provided data
160
- */
161
- static cloneSerializable( input ) {
162
- switch ( typeof input ) {
163
- case "object" : {
164
- if ( Array.isArray( input ) ) {
165
- const numItems = input.length;
166
- const clone = new Array( numItems );
167
- let write = 0;
168
-
169
- for ( let read = 0; read < numItems; read++ ) {
170
- const value = this.cloneSerializable( input[read] );
171
-
172
- if ( value != null ) {
173
- clone[write++] = value;
174
- }
175
- }
176
-
177
- clone.splice( write );
178
-
179
- return clone;
180
- }
181
-
182
- const names = Object.keys( input );
183
- const numNames = names.length;
184
- const clone = {};
185
-
186
- for ( let i = 0; i < numNames; i++ ) {
187
- const name = names[i];
188
- const value = this.cloneSerializable( input[name] );
189
-
190
- if ( value != null ) {
191
- clone[name] = value;
192
- }
193
- }
194
-
195
- return clone;
196
- }
197
-
198
- case "function" :
199
- return null;
200
-
201
- default :
202
- return input;
203
- }
204
- }
205
- }
206
-
207
- return OdemRestSchema;
208
- };