@tryghost/express-bookshelf-jsonapi 0.3.4

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/lib/ebja.js ADDED
@@ -0,0 +1,24 @@
1
+ 'use strict';
2
+ /**
3
+ * Heavily borrowed from express
4
+ */
5
+
6
+ var debug = require('ghost-ignition').debug('main');
7
+ var API = require('./api');
8
+ var mixin = require('merge-descriptors');
9
+
10
+ function createAPI(options) {
11
+ var api = function () {};
12
+
13
+ debug('createAPI');
14
+
15
+ mixin(api, API, false);
16
+
17
+ api.init(options);
18
+
19
+ return api;
20
+ }
21
+
22
+ exports = module.exports = createAPI;
23
+
24
+ exports.plugin = require('./plugin');
@@ -0,0 +1,67 @@
1
+ 'use strict';
2
+ var debug = require('ghost-ignition').debug('endpoint');
3
+ var _ = require('lodash');
4
+ var http = require('./http');
5
+ var IncomingMessage = require('http').IncomingMessage;
6
+ var stack = require('./stack');
7
+ var apiWare = require('./apiware');
8
+
9
+ // @TODO come up with a better way to do this
10
+ // Inspect the request inside the HTTP layer? SO if it is a real-world GET request
11
+ // Or maybe use a different way to define which "type", like manually set the queue type
12
+ var types = {
13
+ GET: 'query',
14
+ POST: 'action',
15
+ PUT: 'action',
16
+ PATCH: 'action',
17
+ DELETE: 'destroy'
18
+ };
19
+
20
+ function prepareStack(options) {
21
+ var type = types[options.method || 'GET'];
22
+ debug('STACK TYPE', type);
23
+ var stack = _.clone(apiWare[type]);
24
+
25
+ // Override the queue functions with any matching functions passed in as options
26
+ // Matches are done between queue keys and option keys
27
+ _.intersection(Object.keys(stack), Object.keys(options)).forEach(function handleMatch(optionsStackMatch) {
28
+ stack[optionsStackMatch] = options[optionsStackMatch];
29
+ });
30
+
31
+ return stack;
32
+ }
33
+
34
+ // @TODO how to keep track of the resource that an endpoint belongs to?
35
+ var Endpoint = function Endpoint(api, options) {
36
+ var self = this;
37
+ this.api = api;
38
+ this.options = _.merge({}, this.api.options, options);
39
+
40
+ this.stack = prepareStack(options);
41
+
42
+ function doEndpoint(apiReq, apiRes, cb) {
43
+ debug('Executing apiware stack');
44
+ stack.handle(_.values(self.stack), apiReq, apiRes, cb);
45
+ }
46
+
47
+ // @TODO move this somewhere else?
48
+ var handler = function handler() {
49
+ debug('handling endpoint');
50
+ var args = Array.from(arguments);
51
+
52
+ // @TODO make this adapter logic a bit nicer
53
+ if (args.length === 3 && args[0] instanceof IncomingMessage) {
54
+ args.unshift(self.options);
55
+ args.unshift(self.api);
56
+ debug('endpoint apply with adapter');
57
+
58
+ return doEndpoint.apply(self, http.adapter.apply(self, args));
59
+ }
60
+
61
+ throw new Error('Unimplemented!');
62
+ };
63
+
64
+ return handler;
65
+ };
66
+
67
+ module.exports = Endpoint;
package/lib/format.js ADDED
@@ -0,0 +1,33 @@
1
+ 'use strict';
2
+ var _ = require('lodash');
3
+ var debug = require('ghost-ignition').debug('format');
4
+ var Mapper = require('./vendor/jsonapi-mapper');
5
+
6
+ var defaultSerializerOptions = {};
7
+ var defaultMapperOptions = {enableLinks: true};
8
+
9
+ module.exports = function format(api, apiReq, apiRes) {
10
+ if (!apiRes || _.isEmpty(apiRes.model)) {
11
+ debug('Nothing to format :(');
12
+ // We don't throw an error, but return empty
13
+ // This allows us to support, empty delete responses etc
14
+ return;
15
+ }
16
+
17
+ debug('mapping to JSONAPI');
18
+ // Apply any defaults that weren't overridden
19
+ _.defaults(apiRes.serializerOptions, defaultSerializerOptions);
20
+ _.defaults(apiRes.mapperOptions, defaultMapperOptions);
21
+ var mapper = Mapper(api.baseUrl, apiRes.serializerOptions);
22
+ var json = mapper.map(apiRes.model, apiRes.type, apiRes.mapperOptions);
23
+ var includes = apiReq.query.options.include;
24
+
25
+ // Omit data attribute from related models that have not been included
26
+ // This is a workaround for jsonapi-mapper bug - https://github.com/scoutforpets/jsonapi-mapper/issues/69
27
+ _.each(json.data.relationships, function (v, k) {
28
+ json.data.relationships[k] = !_.includes(includes, k) ? _.omit(v, 'data') : v;
29
+ });
30
+
31
+ debug('returning');
32
+ return json;
33
+ };
package/lib/http.js ADDED
@@ -0,0 +1,71 @@
1
+ 'use strict';
2
+
3
+ var http = exports;
4
+ var _private = {};
5
+ var debug = require('ghost-ignition').debug('http');
6
+ var _ = require('lodash');
7
+ var queryParser = require('./vendor/jsonapi-query-parser');
8
+ var formatter = require('./format');
9
+ var APIRequest = require('./request');
10
+ var APIResponse = require('./response');
11
+
12
+ // TODO: do this based on req.method instead of apiReq.options.method?
13
+ var successCodes = {
14
+ GET: 200,
15
+ POST: 201,
16
+ DELETE: 204,
17
+ PATCH: 200
18
+ };
19
+
20
+ _private.toAPI = function toAPI(api, req, options) {
21
+ var params = queryParser.parseRequest(req.url);
22
+ var payload = req.body;
23
+ var source = options.request.sourceProperty ? req[options.request.sourceProperty] : req.user;
24
+ source.ip = req.headers['x-forwarded-for'] || req.connection.remoteAddress;
25
+ var custom = {};
26
+
27
+ _.each(options.request.customProperties, function (propName) {
28
+ custom[propName] = req[propName];
29
+ });
30
+
31
+ debug('CONVERT to API');
32
+ var apiRequest = new APIRequest(api.models[options.model], options, params, payload, source, custom);
33
+ var apiResponse = new APIResponse(apiRequest);
34
+
35
+ return [
36
+ apiRequest,
37
+ apiResponse
38
+ ];
39
+ };
40
+
41
+ _private.fromAPI = function fromAPI(api, apiReq, apiRes) {
42
+ debug('CONVERT to HTTP');
43
+ return {
44
+ data: formatter(api, apiReq, apiRes),
45
+ status: apiReq.options.response.successCode || successCodes[apiReq.options.method] || 200
46
+ };
47
+ };
48
+
49
+ http.adapter = function adapter(api, options, req, res, next) {
50
+ var args = _private.toAPI(api, req, options);
51
+ var callback = function callback(err, apiReq, apiRes) {
52
+ debug('HTTP callback');
53
+ if (err) {
54
+ debug('Got an error');
55
+ return next(err);
56
+ }
57
+
58
+ var json = _private.fromAPI(api, apiReq, apiRes);
59
+
60
+ if (json) {
61
+ debug('Returning JSON');
62
+ return res.status(json.status).json(json.data);
63
+ }
64
+
65
+ return next();
66
+ };
67
+
68
+ args.push(callback);
69
+ return args;
70
+ };
71
+
package/lib/plugin.js ADDED
@@ -0,0 +1,40 @@
1
+ 'use strict';
2
+ var debug = require('ghost-ignition').debug('plugin');
3
+ var jsonapiParams = require('bookshelf-jsonapi-params');
4
+
5
+ var jsonapi = function jsonapi(Bookshelf) {
6
+ if (!Bookshelf.Model.hasOwnProperty('fetchJsonApi')) {
7
+ Bookshelf.plugin(jsonapiParams);
8
+ }
9
+
10
+ // Load the pagination plugin if it doesn't already exist
11
+ if (!Bookshelf.Model.hasOwnProperty('fetchPage')) {
12
+ Bookshelf.plugin('pagination');
13
+ }
14
+
15
+ var Model = Bookshelf.Model.extend(
16
+ {
17
+ jsonapi: true
18
+ },
19
+ {
20
+ getOne: function getOne(query) {
21
+ debug('getOne', query);
22
+ var options = query.options || {};
23
+ // enforce require true for single resource fetches
24
+ options.require = options.require || true;
25
+
26
+ return this
27
+ // forge won't work here!
28
+ .where(query.data)
29
+ .fetchJsonApi(options, false);
30
+ },
31
+ getPage: function getPage(query) {
32
+ debug('getPage');
33
+ return this.fetchJsonApi(query.options);
34
+ }
35
+ });
36
+
37
+ Bookshelf.Model = Model;
38
+ };
39
+
40
+ module.exports = jsonapi;
package/lib/request.js ADDED
@@ -0,0 +1,35 @@
1
+ 'use strict';
2
+ var debug = require('ghost-ignition').debug('request');
3
+ var _ = require('lodash');
4
+
5
+ // @TODO resolve this into a sensible signature
6
+ class APIRequest {
7
+ constructor(model, options, params, payload, source, custom) {
8
+ debug('APIRequest');
9
+
10
+ // Ensure we have response options
11
+ options.response = options.response || {};
12
+
13
+ // The bookshelf model for which this request is being made
14
+ this.model = model;
15
+ // The model method that will be called to do a query
16
+ this.modelMethod = options.modelMethod;
17
+ // Any other options passed in
18
+ this.options = options;
19
+ // Parameters
20
+ this.params = params;
21
+ // Payload
22
+ this.payload = payload;
23
+ // The query that is going to be passed to the model method
24
+ this.query = {
25
+ data: {},
26
+ options: {}
27
+ };
28
+ // Who or what is making the request (usually some sort of user)
29
+ this.source = source;
30
+
31
+ _.extend(this, custom);
32
+ }
33
+ }
34
+
35
+ module.exports = APIRequest;
@@ -0,0 +1,16 @@
1
+ 'use strict';
2
+ var debug = require('ghost-ignition').debug('resource');
3
+ var _ = require('lodash');
4
+ var Endpoint = require('./endpoint');
5
+
6
+ // @TODO fixup the way this is created vs api and endpoint
7
+ var Resource = function Resource(api, resourceOpts) {
8
+ debug('Registering resource: ' + resourceOpts.model);
9
+
10
+ this.Endpoint = function registerEndpoint(options) {
11
+ debug('Registering resource endpoint: ' + options.queryMethod);
12
+ return new Endpoint(api, _.merge({}, resourceOpts, options))
13
+ };
14
+ };
15
+
16
+ module.exports = Resource;
@@ -0,0 +1,33 @@
1
+ 'use strict';
2
+ var debug = require('ghost-ignition').debug('response');
3
+ var _ = require('lodash');
4
+ var Mapper = require('./vendor/jsonapi-mapper');
5
+
6
+ function getType(request) {
7
+ if (request.options.response && request.options.response.type) {
8
+ return request.options.response.type;
9
+ }
10
+ return request.params.relationshipType || request.params.resourceType;
11
+ }
12
+
13
+ // API Response
14
+ // @TODO resolve this into a sensible signature
15
+ // This object is initially constructed from just the request object
16
+ class APIResponse {
17
+ constructor(request) {
18
+ debug('APIResponse');
19
+ // A reference to the request object
20
+ this.request = request;
21
+ // The result of the query (bookshelf model)
22
+ this.model = {};
23
+ // The type of object that we are returning
24
+ this.type = getType(request);
25
+ // Various convoluted settings/options that get passed to the jsonapi mapper when formatting
26
+ this.serializerOptions = _.pick(request.options.response, Mapper.knownSerializerOptions.concat(request.options.relations));
27
+ this.mapperOptions = _.pick(request.options.response, Mapper.knownMapperOptions);
28
+ // An array of executed model methods
29
+ this.exec = [];
30
+ }
31
+ }
32
+
33
+ module.exports = APIResponse;
package/lib/stack.js ADDED
@@ -0,0 +1,30 @@
1
+ 'use strict';
2
+ module.exports.handle = function handleStack(stack, apiReq, apiRes, cb) {
3
+ var idx = 0;
4
+
5
+ if (stack.length === 0) {
6
+ return cb(null, apiReq, apiRes);
7
+ }
8
+
9
+ apiNext();
10
+
11
+ function apiNext(err) {
12
+ var nextFunc = stack[idx];
13
+ idx += 1;
14
+
15
+ if (!nextFunc) {
16
+ return cb(err, apiReq, apiRes);
17
+ }
18
+
19
+ if (err) {
20
+ apiRes.err = err;
21
+ return cb(err, apiReq, apiRes);
22
+ }
23
+
24
+ try {
25
+ nextFunc(apiReq, apiRes, apiNext);
26
+ } catch (err) {
27
+ cb(err, apiReq, apiRes);
28
+ }
29
+ }
30
+ };
@@ -0,0 +1,14 @@
1
+ var Mapper = require('@tryghost/jsonapi-mapper');
2
+
3
+ module.exports = function mapper(url, options) {
4
+ return new Mapper.Bookshelf(url, options);
5
+ };
6
+
7
+ module.exports.knownSerializerOptions = [
8
+ 'attributes', 'ref', 'included', 'id', 'topLevelLinks', 'dataLinks', 'relationshipLinks', 'relationshipMeta',
9
+ 'ignoreRelationshipData', 'keyForAttribute', 'nullIfMissing', 'pluralizeType', 'typeForAttribute', 'meta'
10
+ ];
11
+ module.exports.knownMapperOptions = [
12
+ 'omitAttrs', 'idAttribute', 'keyForAttr', 'relations', 'typeForModel', 'enableLinks', 'query', 'pagination'
13
+ ];
14
+
@@ -0,0 +1,2 @@
1
+ var JsonApiQueryParser = require('jsonapi-query-parser');
2
+ module.exports = new JsonApiQueryParser();
package/package.json ADDED
@@ -0,0 +1,40 @@
1
+ {
2
+ "name": "@tryghost/express-bookshelf-jsonapi",
3
+ "version": "0.3.4",
4
+ "description": "Create JSON API endpoints from bookshelf",
5
+ "main": "index.js",
6
+ "alias": "ebja",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "ssh://git@github.com/TryGhost/Pro-Packages",
10
+ "directory": "packages/express-bookshelf-jsonapi"
11
+ },
12
+ "author": "Hannah Wolfe",
13
+ "license": "MIT",
14
+ "bugs": {
15
+ "url": "https://github.com/TryGhost/Pro-Packages/issues"
16
+ },
17
+ "homepage": "https://github.com/TryGhost/Pro-Packages/tree/main/packages/express-bookshelf-jsonapi",
18
+ "dependencies": {
19
+ "bookshelf-jsonapi-params": "1.5.3",
20
+ "debug": "2.2.0",
21
+ "express": "4.14.0",
22
+ "ghost-ignition": "2.3.0",
23
+ "jsonapi-query-parser": "1.3.1",
24
+ "lodash": "4.16.4",
25
+ "merge-descriptors": "1.0.1",
26
+ "@tryghost/jsonapi-mapper": "^1.0.0"
27
+ },
28
+ "devDependencies": {
29
+ "@vitest/coverage-v8": "4.1.6",
30
+ "create-error": "0.3.1",
31
+ "sinon": "21.0.3",
32
+ "vitest": "4.1.6"
33
+ },
34
+ "scripts": {
35
+ "dev": "echo \"Implement me!\"",
36
+ "test": "NODE_ENV=testing vitest run --coverage",
37
+ "lint": "oxlint -c ../../.oxlintrc.json .",
38
+ "posttest": "pnpm lint"
39
+ }
40
+ }
@@ -0,0 +1,230 @@
1
+ var sinon = require('sinon');
2
+ var utils = require('../utils');
3
+
4
+ var payload = require('../../lib/apiware/payload');
5
+
6
+ var sandbox = sinon.createSandbox();
7
+
8
+ describe('apiware: payload', function () {
9
+ var req, res;
10
+
11
+ afterEach(function () {
12
+ sandbox.restore();
13
+ });
14
+
15
+ beforeEach(function () {
16
+ req = {
17
+ options: {},
18
+ payload: {},
19
+ query: {
20
+ data: {},
21
+ options: {}
22
+ }
23
+ };
24
+
25
+ res = {
26
+ exec: []
27
+ };
28
+ });
29
+
30
+ it('does nothing for empty payloads', function () {
31
+ return new Promise(function (resolve, reject) {
32
+ payload(req, res, function next(err) {
33
+ if (err) {
34
+ return reject(err);
35
+ }
36
+
37
+ expect(typeof req.query.data).toBe('object');
38
+ expect(Object.keys(req.query.data)).toHaveLength(0);
39
+ resolve();
40
+ });
41
+ });
42
+ });
43
+
44
+ describe('data validation', function () {
45
+ it('errors for payloads without a data param', function () {
46
+ return new Promise(function (resolve, reject) {
47
+ req.payload = {id: 'a1b'};
48
+
49
+ payload(req, res, function next(err) {
50
+ if (err) {
51
+ utils.expectIgnitionError(err, 'BadRequestError', /Malformed payload/);
52
+
53
+ return resolve();
54
+ }
55
+
56
+ reject(new Error('This should have thrown an error'));
57
+ });
58
+ });
59
+ });
60
+
61
+ it('errors for payloads with a string data param', function () {
62
+ return new Promise(function (resolve, reject) {
63
+ req.payload = {data: 'a1b'};
64
+
65
+ payload(req, res, function next(err) {
66
+ if (err) {
67
+ utils.expectIgnitionError(err, 'BadRequestError', /Malformed payload/);
68
+
69
+ return resolve();
70
+ }
71
+
72
+ reject(new Error('This should have thrown an error'));
73
+ });
74
+ });
75
+ });
76
+
77
+ it('errors for payloads with an empty array data param', function () {
78
+ return new Promise(function (resolve, reject) {
79
+ req.payload = {data: []};
80
+
81
+ payload(req, res, function next(err) {
82
+ if (err) {
83
+ utils.expectIgnitionError(err, 'BadRequestError', /Malformed payload/);
84
+
85
+ return resolve();
86
+ }
87
+
88
+ reject(new Error('This should have thrown an error'));
89
+ });
90
+ });
91
+ });
92
+
93
+ it('errors for payloads with an empty object data param', function () {
94
+ return new Promise(function (resolve, reject) {
95
+ req.payload = {data: {}};
96
+
97
+ payload(req, res, function next(err) {
98
+ if (err) {
99
+ utils.expectIgnitionError(err, 'BadRequestError', /Malformed payload/);
100
+
101
+ return resolve();
102
+ }
103
+
104
+ reject(new Error('This should have thrown an error'));
105
+ });
106
+ });
107
+ });
108
+
109
+ it('errors for payloads with object and no type or attributes', function () {
110
+ return new Promise(function (resolve, reject) {
111
+ req.payload = {data: {id: 'a1b'}};
112
+
113
+ payload(req, res, function next(err) {
114
+ if (err) {
115
+ utils.expectIgnitionError(err, 'BadRequestError', /Malformed payload/);
116
+
117
+ return resolve();
118
+ }
119
+
120
+ reject(new Error('This should have thrown an error'));
121
+ });
122
+ });
123
+ });
124
+
125
+ it('errors for payloads with object, type, but no attributes', function () {
126
+ return new Promise(function (resolve, reject) {
127
+ req.payload = {data: {
128
+ id: 'a1b',
129
+ type: 'thing'
130
+ }};
131
+
132
+ payload(req, res, function next(err) {
133
+ if (err) {
134
+ utils.expectIgnitionError(err, 'BadRequestError', /Malformed payload/);
135
+
136
+ return resolve();
137
+ }
138
+
139
+ reject(new Error('This should have thrown an error'));
140
+ });
141
+ });
142
+ });
143
+
144
+ it('errors for payloads with object, attributes, but no type', function () {
145
+ return new Promise(function (resolve, reject) {
146
+ req.payload = {data: {
147
+ id: 'a1b',
148
+ attributes: {cat: 'hat'}
149
+ }};
150
+
151
+ payload(req, res, function next(err) {
152
+ if (err) {
153
+ utils.expectIgnitionError(err, 'BadRequestError', /Malformed payload/);
154
+
155
+ return resolve();
156
+ }
157
+
158
+ reject(new Error('This should have thrown an error'));
159
+ });
160
+ });
161
+ });
162
+
163
+ it('does not error for payload with data, attributes and type', function () {
164
+ return new Promise(function (resolve, reject) {
165
+ req.payload = {data: {
166
+ id: 'a1b',
167
+ type: 'thing',
168
+ attributes: {cat: 'hat'}
169
+ }};
170
+
171
+ payload(req, res, function next(err) {
172
+ if (err) {
173
+ return reject(err);
174
+ }
175
+
176
+ expect(typeof req.query.data).toBe('object');
177
+ expect(req.query.data).toHaveProperty('cat', 'hat');
178
+ resolve();
179
+ });
180
+ });
181
+ });
182
+
183
+ it('can handle a data array with length 1', function () {
184
+ return new Promise(function (resolve, reject) {
185
+ req.payload = {data: [{
186
+ id: 'a1b',
187
+ type: 'thing',
188
+ attributes: {cat: 'hat'}
189
+ }]};
190
+
191
+ payload(req, res, function next(err) {
192
+ if (err) {
193
+ return reject(err);
194
+ }
195
+
196
+ expect(typeof req.query.data).toBe('object');
197
+ expect(req.query.data).toHaveProperty('cat', 'hat');
198
+ resolve();
199
+ });
200
+ });
201
+ });
202
+
203
+ it('does not error for data array with multiple items, but also does nothing with it :(', function () {
204
+ return new Promise(function (resolve, reject) {
205
+ req.payload = {data: [
206
+ {
207
+ id: 'a1b',
208
+ type: 'thing1',
209
+ attributes: {cat: 'hat'}
210
+ },
211
+ {
212
+ id: 'a2z',
213
+ type: 'thing2',
214
+ attributes: {mad: 'hat'}
215
+ }
216
+ ]};
217
+
218
+ payload(req, res, function next(err) {
219
+ if (err) {
220
+ return reject(err);
221
+ }
222
+
223
+ expect(typeof req.query.data).toBe('object');
224
+ expect(Object.keys(req.query.data)).toHaveLength(0);
225
+ resolve();
226
+ });
227
+ });
228
+ });
229
+ });
230
+ });