@hitchy/plugin-odem-rest 0.5.5 → 0.6.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/services/odem-rest/cors.js +4 -4
- package/index.js +83 -47
- package/package.json +12 -12
- package/readme.md +95 -6
- package/api/services/odem-rest/schema.js +0 -208
|
@@ -28,7 +28,7 @@ module.exports = function() {
|
|
|
28
28
|
/**
|
|
29
29
|
* Handles CORS-related behaviours.
|
|
30
30
|
*
|
|
31
|
-
* @name api.
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
@@ -23,6 +23,10 @@ module.exports = function() {
|
|
|
23
23
|
before.set( `ALL ${resolve( urlPrefix, ".schema" )}`, CORS.getRequestFilterForSchemata() );
|
|
24
24
|
after.set( `ALL ${resolve( urlPrefix, ".schema" )}`, reqNotSupported );
|
|
25
25
|
|
|
26
|
+
if ( api.plugins.authentication ) {
|
|
27
|
+
before.set( `GET ${resolve( urlPrefix, ".schema" )}`, Services.AuthorizationPolicyGenerator.mayAccess( "@hitchy.odem.schema" ) );
|
|
28
|
+
}
|
|
29
|
+
|
|
26
30
|
for ( let i = 0, numNames = modelNames.length; i < numNames; i++ ) {
|
|
27
31
|
const name = modelNames[i];
|
|
28
32
|
const routeName = Case.pascalToKebab( name );
|
|
@@ -32,6 +36,55 @@ module.exports = function() {
|
|
|
32
36
|
before.set( `ALL ${resolve( urlPrefix, routeName, ".schema" )}`, CORS.getRequestFilterForModelSchema( model ) );
|
|
33
37
|
before.set( `ALL ${resolve( urlPrefix, routeName, ":uuid" )}`, CORS.getRequestFilterForModelItem( model ) );
|
|
34
38
|
|
|
39
|
+
if ( api.plugins.authentication ) {
|
|
40
|
+
// add policies for additionally checking a requesting user's authorization
|
|
41
|
+
const modelUrl = resolve( urlPrefix, routeName );
|
|
42
|
+
const policy = action => Services.AuthorizationPolicyGenerator.mayAccess( `@hitchy.odem.model.${name}.${action}` );
|
|
43
|
+
|
|
44
|
+
const handlers = {
|
|
45
|
+
schema: policy( "schema" ),
|
|
46
|
+
create: policy( "create" ),
|
|
47
|
+
list: policy( "list" ),
|
|
48
|
+
read: policy( "read" ),
|
|
49
|
+
write: policy( "write" ),
|
|
50
|
+
check: policy( "check" ),
|
|
51
|
+
remove: policy( "remove" ),
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
// convenience routes
|
|
55
|
+
before.set( `GET ${resolve( modelUrl, "create" )}`, handlers.create );
|
|
56
|
+
before.set( `GET ${resolve( modelUrl, "write", ":uuid" )}`, handlers.write );
|
|
57
|
+
before.set( `GET ${resolve( modelUrl, "replace", ":uuid" )}`, handlers.write );
|
|
58
|
+
before.set( `GET ${resolve( modelUrl, "has", ":uuid" )}`, handlers.check );
|
|
59
|
+
before.set( `GET ${resolve( modelUrl, "remove", ":uuid" )}`, handlers.remove );
|
|
60
|
+
|
|
61
|
+
// extended routes
|
|
62
|
+
before.set( `GET ${resolve( modelUrl, ".schema" )}`, policy( "schema" ) );
|
|
63
|
+
|
|
64
|
+
// REST-compliant routes
|
|
65
|
+
before.set( `GET ${modelUrl}`, function( req, res, next ) {
|
|
66
|
+
const tail = req.url.substring( modelUrl.length ).trim();
|
|
67
|
+
|
|
68
|
+
if ( tail && tail !== "/" ) {
|
|
69
|
+
next();
|
|
70
|
+
} else {
|
|
71
|
+
handlers.list.call( this, req, res, next );
|
|
72
|
+
}
|
|
73
|
+
} );
|
|
74
|
+
before.set( `POST ${resolve( modelUrl )}`, handlers.create );
|
|
75
|
+
before.set( `GET ${resolve( modelUrl, ":uuid" )}`, ( req, res, next ) => {
|
|
76
|
+
if ( req.params.uuid === ".schema" ) {
|
|
77
|
+
next();
|
|
78
|
+
} else {
|
|
79
|
+
handlers.read.call( this, req, res, next );
|
|
80
|
+
}
|
|
81
|
+
} );
|
|
82
|
+
before.set( `PATCH ${resolve( modelUrl, ":uuid" )}`, handlers.write );
|
|
83
|
+
before.set( `PUT ${resolve( modelUrl, ":uuid" )}`, handlers.write );
|
|
84
|
+
before.set( `HEAD ${resolve( modelUrl, ":uuid" )}`, handlers.check );
|
|
85
|
+
before.set( `DELETE ${resolve( modelUrl, ":uuid" )}`, handlers.remove );
|
|
86
|
+
}
|
|
87
|
+
|
|
35
88
|
after.set( `ALL ${resolve( urlPrefix, routeName )}`, reqNotSupported );
|
|
36
89
|
}
|
|
37
90
|
|
|
@@ -90,7 +143,7 @@ module.exports = function() {
|
|
|
90
143
|
* @returns {void}
|
|
91
144
|
*/
|
|
92
145
|
function addGlobalRoutes( routes, urlPrefix, models ) {
|
|
93
|
-
const { runtime: { services: { Model: BaseModel,
|
|
146
|
+
const { runtime: { services: { Model: BaseModel, OdemSchema } }, utility: { case: { pascalToKebab } } } = api;
|
|
94
147
|
|
|
95
148
|
routes.set( `GET ${resolve( urlPrefix, ".schema" )}`, reqFetchSchemata );
|
|
96
149
|
|
|
@@ -111,11 +164,11 @@ module.exports = function() {
|
|
|
111
164
|
const model = models[modelKeys[i]];
|
|
112
165
|
|
|
113
166
|
if ( model.prototype instanceof BaseModel &&
|
|
114
|
-
|
|
115
|
-
|
|
167
|
+
OdemSchema.mayBeExposed( req, model ) &&
|
|
168
|
+
OdemSchema.mayBePromoted( req, model ) ) {
|
|
116
169
|
const slug = pascalToKebab( model.name );
|
|
117
170
|
|
|
118
|
-
result[slug] =
|
|
171
|
+
result[slug] = OdemSchema.extractPublicData( model );
|
|
119
172
|
}
|
|
120
173
|
}
|
|
121
174
|
|
|
@@ -135,7 +188,7 @@ module.exports = function() {
|
|
|
135
188
|
* @returns {void}
|
|
136
189
|
*/
|
|
137
190
|
function addRoutesOnModel( routes, urlPrefix, routeName, Model, includeConvenienceRoutes ) {
|
|
138
|
-
const { Model: BaseModel,
|
|
191
|
+
const { Model: BaseModel, OdemSchema: Schema, OdemUtilityUuid: { ptnUuid } } = Services;
|
|
139
192
|
|
|
140
193
|
const modelUrl = resolve( urlPrefix, routeName );
|
|
141
194
|
|
|
@@ -182,7 +235,7 @@ module.exports = function() {
|
|
|
182
235
|
* @returns {void}
|
|
183
236
|
*/
|
|
184
237
|
function reqSuccess( req, res ) {
|
|
185
|
-
if ( Services.
|
|
238
|
+
if ( Services.OdemSchema.mayBeExposed( req, Model ) ) {
|
|
186
239
|
res.status( 200 ).send();
|
|
187
240
|
} else {
|
|
188
241
|
res.status( 403 ).send();
|
|
@@ -212,7 +265,7 @@ module.exports = function() {
|
|
|
212
265
|
function reqFetchSchema( req, res ) {
|
|
213
266
|
logDebug( "got request fetching schema" );
|
|
214
267
|
|
|
215
|
-
if ( !Services.
|
|
268
|
+
if ( !Services.OdemSchema.mayBeExposed( req, Model ) ) {
|
|
216
269
|
res.status( 403 ).json( { error: "access forbidden by model" } );
|
|
217
270
|
return;
|
|
218
271
|
}
|
|
@@ -278,7 +331,7 @@ module.exports = function() {
|
|
|
278
331
|
const item = new Model( uuid ); // eslint-disable-line new-cap
|
|
279
332
|
|
|
280
333
|
return item.load()
|
|
281
|
-
.then( loaded => res.json( loaded.toObject( { serialized: true } ) ) )
|
|
334
|
+
.then( loaded => res.json( Schema.filterItem( loaded.toObject( { serialized: true } ), req, Model, "read" ) ) )
|
|
282
335
|
.catch( error => {
|
|
283
336
|
logError( "fetching %s:", routeName, error );
|
|
284
337
|
|
|
@@ -332,7 +385,6 @@ module.exports = function() {
|
|
|
332
385
|
*/
|
|
333
386
|
function parseQuery( query ) {
|
|
334
387
|
const simpleTernary = /^([^:\s]+):between:([^:]+):([^:]+)$/i.exec( query );
|
|
335
|
-
|
|
336
388
|
if ( simpleTernary ) {
|
|
337
389
|
const [ , name, lower, upper ] = simpleTernary;
|
|
338
390
|
|
|
@@ -422,13 +474,13 @@ module.exports = function() {
|
|
|
422
474
|
|
|
423
475
|
const meta = count || req.headers["x-count"] ? {} : null;
|
|
424
476
|
|
|
425
|
-
await Model.find( parsedQuery, { offset, limit, sortBy, sortAscendingly: !descending }, {
|
|
477
|
+
await Model.find( Schema.checkQuery( parsedQuery, req, Model ), { offset, limit, sortBy, sortAscendingly: !descending }, {
|
|
426
478
|
metaCollector: meta,
|
|
427
479
|
loadRecords
|
|
428
480
|
} )
|
|
429
481
|
.then( matches => {
|
|
430
482
|
const result = {
|
|
431
|
-
items: matches.map(
|
|
483
|
+
items: matches.map( item => Schema.filterItem( item.toObject( { serialized: true } ), req, Model, "list" ) ),
|
|
432
484
|
};
|
|
433
485
|
|
|
434
486
|
if ( meta ) {
|
|
@@ -471,7 +523,7 @@ module.exports = function() {
|
|
|
471
523
|
}, { loadRecords, metaCollector: meta } )
|
|
472
524
|
.then( matches => {
|
|
473
525
|
const result = {
|
|
474
|
-
items: matches.map(
|
|
526
|
+
items: matches.map( item => Schema.filterItem( item.toObject( { serialized: true } ), req, Model, "list" ) ),
|
|
475
527
|
};
|
|
476
528
|
|
|
477
529
|
if ( meta ) {
|
|
@@ -507,26 +559,20 @@ module.exports = function() {
|
|
|
507
559
|
|
|
508
560
|
return ( req.method === "GET" ? Promise.resolve( req.query ) : req.fetchBody() )
|
|
509
561
|
.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
562
|
if ( record ) {
|
|
517
|
-
|
|
518
|
-
|
|
563
|
+
if ( record.uuid ) {
|
|
564
|
+
logDebug( "creating %s:", routeName, "new entry can not be created with uuid" );
|
|
565
|
+
res.status( 400 ).json( { error: "new entry can not be created with uuid" } );
|
|
566
|
+
return undefined;
|
|
567
|
+
}
|
|
519
568
|
|
|
569
|
+
const filtered = Schema.filterItem( record, req, Model, "create" );
|
|
520
570
|
const definedProps = Model.schema.props;
|
|
521
571
|
const definedComputed = Model.schema.computed;
|
|
522
572
|
|
|
523
|
-
for (
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
if ( definedProps[name] ) {
|
|
527
|
-
item.$properties[name] = record[name];
|
|
528
|
-
} else if ( definedComputed[name] ) {
|
|
529
|
-
item[name] = record[name];
|
|
573
|
+
for ( const [ key, value ] of Object.entries( filtered ) ) {
|
|
574
|
+
if ( definedProps[key]?.$type || definedComputed[key] ) {
|
|
575
|
+
item[key] = value;
|
|
530
576
|
}
|
|
531
577
|
}
|
|
532
578
|
}
|
|
@@ -578,27 +624,20 @@ module.exports = function() {
|
|
|
578
624
|
] )
|
|
579
625
|
.then( ( [ loaded, record ] ) => {
|
|
580
626
|
if ( record ) {
|
|
581
|
-
|
|
582
|
-
const names = Object.keys( record );
|
|
583
|
-
const numNames = names.length;
|
|
584
|
-
|
|
627
|
+
const filtered = Schema.filterItem( record, req, Model, "write" );
|
|
585
628
|
const definedProps = Model.schema.props;
|
|
586
629
|
const definedComputed = Model.schema.computed;
|
|
587
630
|
|
|
588
|
-
for (
|
|
589
|
-
|
|
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
|
|
631
|
+
for ( const [ key, value ] of Object.entries( filtered ) ) {
|
|
632
|
+
if ( definedProps[key]?.$type || definedComputed[key] ) {
|
|
633
|
+
loaded[key] = value; // eslint-disable-line no-param-reassign
|
|
595
634
|
}
|
|
596
635
|
}
|
|
597
636
|
}
|
|
598
637
|
|
|
599
638
|
return loaded.save()
|
|
600
639
|
.then( saved => {
|
|
601
|
-
res.json( saved.toObject( { serialized: true } ) );
|
|
640
|
+
res.json( Schema.filterItem( saved.toObject( { serialized: true } ), req, Model, "read" ) );
|
|
602
641
|
} );
|
|
603
642
|
} );
|
|
604
643
|
} )
|
|
@@ -640,30 +679,27 @@ module.exports = function() {
|
|
|
640
679
|
|
|
641
680
|
const computedNames = Object.keys( Model.schema.computed );
|
|
642
681
|
const numComputedNames = computedNames.length;
|
|
682
|
+
const filtered = Schema.filterItem( record, req, Model, "write" );
|
|
643
683
|
|
|
644
684
|
// update properties, drop those missing in provided record
|
|
645
685
|
for ( let i = 0; i < numPropNames; i++ ) {
|
|
646
686
|
const propName = propNames[i];
|
|
647
|
-
const value =
|
|
687
|
+
const value = filtered[propName];
|
|
648
688
|
|
|
649
|
-
|
|
650
|
-
item.$properties[propName] = null;
|
|
651
|
-
} else {
|
|
652
|
-
item.$properties[propName] = value;
|
|
653
|
-
}
|
|
689
|
+
item[propName] = value ?? null;
|
|
654
690
|
}
|
|
655
691
|
|
|
656
692
|
// assign all posted computed properties
|
|
657
693
|
for ( let i = 0; i < numComputedNames; i++ ) {
|
|
658
694
|
const computedName = computedNames[i];
|
|
659
|
-
item[computedName] =
|
|
695
|
+
item[computedName] = filtered[computedName];
|
|
660
696
|
}
|
|
661
697
|
|
|
662
698
|
return item.save( { ignoreUnloaded: !exists } );
|
|
663
699
|
} );
|
|
664
700
|
} )
|
|
665
701
|
.then( saved => {
|
|
666
|
-
res.json( {
|
|
702
|
+
res.json( Schema.filterItem( saved.toObject( { serialized: true } ), req, Model, "read" ) );
|
|
667
703
|
} )
|
|
668
704
|
.catch( error => {
|
|
669
705
|
logError( "updating %s:", routeName, error );
|
package/package.json
CHANGED
|
@@ -1,12 +1,11 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hitchy/plugin-odem-rest",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.6.0",
|
|
4
4
|
"description": "HTTP REST API for Hitchy's ODM",
|
|
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": [
|
|
@@ -18,17 +17,18 @@
|
|
|
18
17
|
"bugs": "https://gitlab.com/hitchy/plugin-odem-rest/-/issues",
|
|
19
18
|
"homepage": "https://gitlab.com/hitchy/plugin-odem-rest#plugin-odem-rest",
|
|
20
19
|
"peerDependencies": {
|
|
21
|
-
"@hitchy/core": "
|
|
22
|
-
"@hitchy/plugin-odem": "
|
|
20
|
+
"@hitchy/core": "0.8.x",
|
|
21
|
+
"@hitchy/plugin-odem": "0.8.x",
|
|
22
|
+
"@hitchy/plugin-auth": "0.4.x"
|
|
23
23
|
},
|
|
24
24
|
"devDependencies": {
|
|
25
|
-
"@hitchy/types": "^0.1.
|
|
26
|
-
"@hitchy/server-dev-tools": "^0.4.
|
|
27
|
-
"c8": "^
|
|
28
|
-
"eslint": "^8.
|
|
29
|
-
"eslint-config-cepharum": "^1.0.
|
|
30
|
-
"eslint-plugin-promise": "^6.
|
|
31
|
-
"mocha": "^10.
|
|
25
|
+
"@hitchy/types": "^0.1.3",
|
|
26
|
+
"@hitchy/server-dev-tools": "^0.4.9",
|
|
27
|
+
"c8": "^10.1.2",
|
|
28
|
+
"eslint": "^8.57.0",
|
|
29
|
+
"eslint-config-cepharum": "^1.0.14",
|
|
30
|
+
"eslint-plugin-promise": "^6.1.1",
|
|
31
|
+
"mocha": "^10.7.0",
|
|
32
32
|
"should": "^13.2.3",
|
|
33
33
|
"should-http": "^0.1.1"
|
|
34
34
|
}
|
package/readme.md
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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 ODM 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
|
|
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
|
|
|
@@ -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 a more specific resource `@hitchy.odem.schema.model.<ModelName>` 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
|
-
};
|