Skip to content

Commit b766dd0

Browse files
committed
feat(utils): #45 - Enforce type for custom remote, and add a jsonapi option on custom remote to override include/exclude
feat(options): Add a `handleCustomRemote: boolean` option to component config
1 parent cd6ab2d commit b766dd0

File tree

3 files changed

+276
-14
lines changed

3 files changed

+276
-14
lines changed

README.md

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -119,6 +119,7 @@ Example:
119119
"enable": true,
120120
"handleErrors": true,
121121
"errorStackInResponse": false,
122+
"handleCustomRemote": false,
122123
"exclude": [
123124
{"model": "comment"},
124125
{"methods": "find"},
@@ -220,6 +221,26 @@ response. It will be stored under the `source.stack` key.
220221
- Type: `boolean`
221222
- Default: `false`
222223

224+
### handleCustomRemote
225+
Allow all (custom) remotes to be serialized by default.
226+
227+
This option can be overrided with:
228+
1. `jsonapi` remote option (have highest priority)
229+
2. `exclude` component option
230+
3. `include` component option
231+
232+
#### example
233+
```js
234+
{
235+
...
236+
"handleCustomRemote": true,
237+
...
238+
}
239+
```
240+
241+
- Type: `boolean`
242+
- Default: `false`
243+
223244
### exclude
224245
Allows blacklisting of models and methods.
225246
Define an array of blacklist objects. Blacklist objects can contain "model" key
@@ -388,6 +409,55 @@ Only expose foreign keys for the comment model findById method. eg. `GET /api/co
388409
- Type: `boolean|array`
389410
- Default: `false`
390411

412+
## Custom remote
413+
414+
### `jsonapi` remote options
415+
Sometime you need to control if custom remote should be serialized or not.
416+
**By default a custom remote will NOT be handled by JSONApi.**
417+
418+
To enabled/disable JSONApi for specific remotes, you have multiple solutions:
419+
420+
1. Use `jsonapi` remote option
421+
2. Use `exlude` on component options (see above)
422+
3. Use `include` on component options (see above)
423+
4. Use `handleCustomRemote` on component options (see above)
424+
425+
**This option has precedence** and it forces the remote to BE or to NOT BE deserialized/serialized by JSONApi.
426+
427+
#### examples
428+
```js
429+
Post.remoteMethod('greet', {
430+
jsonapi: true
431+
returns: { root: true }
432+
})
433+
```
434+
Ensure the response of `Post.greet` will follow the JSONApi format.
435+
436+
```js
437+
Post.remoteMethod('greet', {
438+
jsonapi: false
439+
returns: { arg: 'greeting', type: 'string' }
440+
})
441+
```
442+
Ensure the response of `Post.greet` will not follow the JSONApi format.
443+
444+
#### Note
445+
You should always pass `root: true` to the `returns` object when you use JSONApi, especialy when you expect to respond with an array.
446+
447+
### Override serialization type
448+
You may want for a custom method of a given model to return instance(s) from another model. When you do so, you need to enforce the return `type` of this other model in the definition of the remote method.
449+
450+
*If an unknown type or no type are given, the model name will be used.*
451+
452+
#### example
453+
454+
```js
455+
Post.remoteMethod('prototype.ownComments', {
456+
jsonapi: true
457+
returns: { root: true, type: 'comment' }
458+
})
459+
```
460+
391461
## Custom Serialization
392462
For occasions where you need greater control over the serialization process, you can implement a custom serialization function for each model as needed. This function will be used instead of the regular serialization process.
393463

lib/utils.js

Lines changed: 42 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ module.exports = {
1111
buildModelUrl: buildModelUrl,
1212
clone: clone,
1313
getModelFromContext: getModelFromContext,
14+
getTypeFromContext: getTypeFromContext,
1415
getRelationsFromContext: getRelationsFromContext,
1516
hostFromContext: hostFromContext,
1617
modelNameFromContext: modelNameFromContext,
@@ -126,10 +127,32 @@ function urlFromContext (context) {
126127
* @return {Object}
127128
*/
128129
function getModelFromContext (context, app) {
130+
var type = getTypeFromContext(context)
131+
if (app.models[type]) return app.models[type]
132+
129133
var name = modelNameFromContext(context)
130134
return app.models[name]
131135
}
132136

137+
/**
138+
* Returns a model type from the context object.
139+
* Infer the type from the `root` returns in the remote.
140+
* @public
141+
* @memberOf {Utils}
142+
* @param {Object} context
143+
* @param {Object} app
144+
* @return {String}
145+
*/
146+
function getTypeFromContext (context) {
147+
if (!context.method.returns) return undefined
148+
149+
const returns = [].concat(context.method.returns)
150+
for (var i = 0, l = returns.length; i < l; i++) {
151+
if (typeof returns[i] === 'object' && returns[i].root === true) continue
152+
return returns[i].type
153+
}
154+
}
155+
133156
/**
134157
* Gets the relations from a context.
135158
* @public
@@ -192,31 +215,40 @@ function buildModelUrl (protocol, host, apiRoot, modelName, id) {
192215
}
193216

194217
function shouldApplyJsonApi (ctx, options) {
195-
if (!options.include) return false
218+
// include on remote have higher priority
219+
if (ctx.method.jsonapi) return !!ctx.method.jsonapi
196220

197221
var modelName = ctx.method.sharedClass.name
198222
var methodName = ctx.method.name
199223
var model
200224
var methods
201-
for (var i = 0; i < options.include.length; i++) {
202-
model = options.include[i].model
203-
methods = options.include[i].methods
204-
if (model === modelName && !methods) return true
205-
if (!model && methods === methodName) return true
206-
if (model === modelName && methods === methodName) return true
207-
if (model === modelName && _.includes(methods, methodName)) return true
225+
if (options.include) {
226+
for (var i = 0; i < options.include.length; i++) {
227+
model = options.include[i].model
228+
methods = options.include[i].methods
229+
if (model === modelName && !methods) return true
230+
if (!model && methods === methodName) return true
231+
if (model === modelName && methods === methodName) return true
232+
if (model === modelName && _.includes(methods, methodName)) return true
233+
}
208234
}
209-
return false
235+
236+
// a default option can be set in component-config
237+
return !!options.handleCustomRemote
210238
}
211239

212240
function shouldNotApplyJsonApi (ctx, options) {
241+
// exclude on remote have higher priority
242+
if (ctx.method.jsonapi === false) return true
243+
213244
// handle options.exclude
214245
if (!options.exclude) return false
215246

216247
var modelName = ctx.method.sharedClass.name
217248
var methodName = ctx.method.name
218249
var model
219250
var methods
251+
220252
for (var i = 0; i < options.exclude.length; i++) {
221253
model = options.exclude[i].model
222254
methods = options.exclude[i].methods
@@ -225,6 +257,7 @@ function shouldNotApplyJsonApi (ctx, options) {
225257
if (model === modelName && methods === methodName) return true
226258
if (model === modelName && _.includes(methods, methodName)) return true
227259
}
260+
228261
return false
229262
}
230263

test/remoteMethods.test.js

Lines changed: 164 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -4,31 +4,98 @@ var request = require('supertest')
44
var loopback = require('loopback')
55
var expect = require('chai').expect
66
var JSONAPIComponent = require('../')
7-
var app, Post
7+
var app, Post, Archive
88

9-
describe('loopback json api remote methods', function () {
10-
beforeEach(function () {
9+
describe.only('loopback json api remote methods', function () {
10+
var autocompleteTitleData = ['Post 1', 'Post 2']
11+
12+
var archiveData = {
13+
id: 10,
14+
raw: {
15+
id: 1,
16+
title: 'Ancient Post 1',
17+
content: 'Ancient Content of Post 1'
18+
},
19+
createdAt: Date.now()
20+
}
21+
22+
var postData = {
23+
id: 1,
24+
title: 'Post 1',
25+
content: 'Content of Post 1'
26+
}
27+
28+
beforeEach(function (done) {
1129
app = loopback()
1230
app.set('legacyExplorer', false)
1331
var ds = loopback.createDataSource('memory')
32+
33+
Archive = ds.createModel('archive', {
34+
id: { type: Number, id: true },
35+
raw: Object,
36+
createAt: Date
37+
})
38+
1439
Post = ds.createModel('post', {
1540
id: { type: Number, id: true },
1641
title: String,
1742
content: String
1843
})
44+
1945
Post.greet = function (msg, cb) {
2046
cb(null, 'Greetings... ' + msg)
2147
}
48+
49+
Post.last = function (cb) {
50+
Post.findOne({}, cb)
51+
}
52+
53+
Post.autocomplete = function (q, cb) {
54+
cb(null, autocompleteTitleData)
55+
}
56+
57+
Post.prototype.findArchives = function (cb) {
58+
Archive.find({}, cb)
59+
}
60+
2261
Post.remoteMethod('greet', {
2362
accepts: { arg: 'msg', type: 'string' },
2463
returns: { arg: 'greeting', type: 'string' }
2564
})
65+
66+
Post.remoteMethod('last', {
67+
returns: { root: true },
68+
http: { verb: 'get' }
69+
})
70+
71+
Post.remoteMethod('autocomplete', {
72+
jsonapi: false,
73+
accepts: { arg: 'q', type: 'string', http: { source: 'query' } },
74+
returns: { root: true, type: 'array' },
75+
http: { path: '/autocomplete', verb: 'get' }
76+
})
77+
78+
Post.remoteMethod('prototype.findArchives', {
79+
jsonapi: true,
80+
returns: { root: true, type: 'archive' },
81+
http: { path: '/archives' }
82+
})
83+
2684
app.model(Post)
85+
app.model(Archive)
2786
app.use(loopback.rest())
28-
JSONAPIComponent(app)
87+
88+
Promise.all([Archive.create(archiveData), Post.create(postData)])
89+
.then(function () {
90+
done()
91+
})
2992
})
3093

31-
describe('remote method for application/json', function () {
94+
describe('for application/json', function () {
95+
beforeEach(function () {
96+
JSONAPIComponent(app)
97+
})
98+
3299
it('POST /posts/greet should return remote method message', function (done) {
33100
request(app)
34101
.post('/posts/greet')
@@ -42,4 +109,96 @@ describe('loopback json api remote methods', function () {
42109
})
43110
})
44111
})
112+
113+
describe('should serialize with `jsonapi: true`', function () {
114+
beforeEach(function () {
115+
JSONAPIComponent(app)
116+
})
117+
118+
testAutocompleteJsonAPI()
119+
testArchivesJsonAPI()
120+
})
121+
122+
describe('when `serializeCustomRemote` is set', function (done) {
123+
beforeEach(function () {
124+
JSONAPIComponent(app, { handleCustomRemote: true })
125+
})
126+
127+
testAutocompleteJsonAPI()
128+
testArchivesJsonAPI()
129+
testLastJsonAPI()
130+
})
131+
132+
/* Static test */
133+
function testAutocompleteJsonAPI () {
134+
it(
135+
'GET /posts/autocomplete should return an array with raw format (`jsonapi: false` has precedence)',
136+
function (done) {
137+
request(app)
138+
.get('/posts/autocomplete')
139+
.expect(200)
140+
.end(function (err, res) {
141+
expect(err).to.equal(null)
142+
expect(res.body).to.deep.equal(autocompleteTitleData)
143+
done()
144+
})
145+
}
146+
)
147+
}
148+
149+
/* Static test */
150+
function testArchivesJsonAPI () {
151+
it(
152+
'GET /posts/1/archives should return a JSONApi list of Archive (`jsonapi: true` has precedence)',
153+
function (done) {
154+
request(app)
155+
.get('/posts/1/archives')
156+
.expect(200)
157+
.end(function (err, res) {
158+
expect(err).to.equal(null)
159+
expect(res.body).to.be.an('object')
160+
expect(res.body.data).to.be.an('array').with.lengthOf(1)
161+
expect(res.body.data[0].id).to.equal(archiveData.id + '')
162+
expect(res.body.data[0].attributes).to.deep.equal({
163+
raw: archiveData.raw,
164+
createdAt: archiveData.createdAt
165+
})
166+
167+
done()
168+
})
169+
}
170+
)
171+
}
172+
173+
/* Static test */
174+
function testLastJsonAPI () {
175+
it('GET /posts/last should return a JSONApi Post', function (done) {
176+
request(app).get('/posts/last').expect(200).end(function (err, res) {
177+
expect(err).to.equal(null)
178+
179+
expect(res.body.data).to.be.an('object')
180+
expect(res.body.data.id).to.equal(postData.id + '')
181+
expect(res.body.data.attributes).to.deep.equal({
182+
title: postData.title,
183+
content: postData.content
184+
})
185+
186+
done()
187+
})
188+
})
189+
}
190+
191+
/* Static test */
192+
function testLastRaw () {
193+
it(
194+
'GET /posts/last should return a raw Post (exclude is more important than include and handleCustomRemote)',
195+
function (done) {
196+
request(app).get('/posts/last').expect(200).end(function (err, res) {
197+
expect(err).to.equal(null)
198+
expect(res.body).to.deep.equal(postData)
199+
done()
200+
})
201+
}
202+
)
203+
}
45204
})

0 commit comments

Comments
 (0)