UNPKG

12.2 kBJavaScriptView Raw
1const path = require('path');
2const debugLogger = require('debug');
3const debugNamespace = Symbol('debugNamespace');
4const debug = Symbol('debug');
5const ControllerCache = require('./controller-cache');
6const { chalk: {info}} = require('../namespace/console');
7const object = require('../namespace/object');
8const types = require('../namespace/type');
9
10/**
11 * Controller Class.
12 * API:
13 * ---
14 * ```
15 * - cache Caching Class
16 * - actionCache Action Caching Store
17 * - cacheStore Current Cache Store
18 * - req Express request object
19 * - res Express response object
20 * - params Alias for `this.req.params`
21 * - body Alias for `this.req.body`
22 * - redisClient Redis Connection
23 * - permit A method that removes non-whitelisted objects. Nested Objects are not allowed without using dot notation to specify the nested keys.
24 * - deepPermit A method that removes non-whitelisted objects. Nested Objects are allowed.
25 * - permitted Boolean, has this action called permit. This may be useful to enforce permitting.
26 * - render Renders a view.
27 * ```
28 */
29class Controller {
30
31 constructor (req, res, redisClient) {
32 let { params, body } = req;
33 this.req = req;
34 this.res = res;
35 this.params = params;
36 this.body = body;
37 this.redisClient = redisClient;
38 this.cacheStore = {};
39 this.viewPath = req.controller.replace('Controller', '').toLowerCase();
40 this[debugNamespace] = debugLogger('glad');
41 this[debug]('Created Controller Instance');
42 }
43
44 [debug] (name) {
45 this[debugNamespace]("Controller: %s > %s", name, this.req.id);
46 }
47
48 /**
49 * Whitelist allowable data in a request body. This is a good idea if you mass assigning things.
50 * Ex: Shallow
51 * ```javascript
52 * this.req.body = { name: 'Fooze', admin: true };
53 * this.permit('name', 'email', 'phone');
54 * // this.req.body === { name: 'Fooze' }
55 * ```
56 *
57 * Ex: Deep
58 * ```javascript
59 * this.req.body = { user: { name: 'Fooze', admin: true } };
60 * this.permit('user.name', 'user.email', 'user.phone');
61 * // this.req.body === { user: { name: 'Fooze' } }
62 * ```
63 * You must be specific when the key contains an object.
64 * You can not permit the whole user object at once. In order to permit "sub documents"
65 * you need to use the `deepPermit` method instead. This is intentional because it can defeat
66 * the purpose of `permit` when you permit a subdocument that could potentially contain things
67 * that shouldnt be allowed. For example: { user : { admin: true } }. Assume that admin was injected by a malicious
68 * user trying to gain admin access to the site, and admin access is determined by the admin key.
69 */
70 permit (...keys) {
71 this[debug]('permit');
72 let i = 0;
73 let len = keys.length;
74 let ref = {};
75
76 for (i; i < len; i += 1) {
77 let val = object.get(this.body, keys[i]);
78
79 if (types.isNotObject(val)) {
80 if (val) {
81 object.set(ref, keys[i], val);
82 }
83 }
84 }
85 this.body = this.req.body = ref;
86 this.permitted = true;
87 }
88
89 /**
90 * This method is similar to permit, only it also permits subdocuments.
91 */
92 deepPermit(...keys) {
93 this[debug]('deepPermit');
94 let i = 0;
95 let len = keys.length;
96 let ref = {};
97
98 for (i; i < len; i += 1) {
99 let val = object.get(this.body, keys[i]);
100 if (val) {
101 object.set(ref, keys[i], val);
102 }
103 }
104 this.body = this.req.body = ref;
105 this.permitted = true;
106 }
107
108 /**
109 * The default error response for the controller
110 * @param {object} error - The error that occured. Providing `status` on the error object will set the HTTP Status code on the respone.
111 */
112 error (err = {}) {
113 this[debug]('error');
114 this.res.status(err.status || 500).json(err)
115 }
116
117 /**
118 * ## The controller level cache.
119 * ---
120 * All options are optional.
121 *
122 * [ *Number* ] `max`: Maximum amount of items the cache can hold. This option is required; if no
123 * other option is needed, it can be passed directly as the second parameter when creating
124 * the cache.
125 *
126 * [ *String* ] `namespace`: Prefix appended to all keys saved in Redis, to avoid clashes with other applications
127 * and to allow multiple instances of the cache. Given a controller called "Products" and an action called "find", the default
128 * namespace is `Products#find`. By setting this value to 'server007' it would be `Products#find-server007`.
129 *
130 * [ *Number* ] `maxAge`: Maximum amount of milliseconds the key will be kept in the cache; after that getting/peeking will
131 * resolve to `null`. Note that the value will be removed from Redis after `maxAge`, but the key will
132 * be kept in the cache until next time it's accessed (i.e. it will be included in `count`, `keys`, etc., although not in `has`.).
133 *
134 * [ *Function* ] `score`: function to customize the score used to order the elements in the cache. Defaults to `() => new Date().getTime()`
135 *
136 * [ *Boolean* ] `increment`: if `true`, on each access the result of the `score` function is added to the previous one,
137 * rather than replacing it.
138 *
139 * [ *String* ] `strategy`: If 'LRU', the scoring function will be set to LRU Cache.
140 * If 'LFU', the scoring function will be set to 'LFU'. The default strategy is 'LFU'
141 *
142 * [ *String* ] `type`: Overrides the default response type (json). Set this to any mime type express supports. [Docs Here](https://expressjs.com/en/api.html#res.type)
143 *
144 * ---
145 *
146 * #### Call back method
147 * ```javascript
148 * this.cache({ max: 100, strategy: 'LFU' }, cache => {
149 * Products.find({category: 'widgets'}).exec( (err, widgets) => {
150 * this.res.status(200).json(widgets);
151 * cache(doc);
152 * });
153 * });
154 * ```
155 * Using the call back method using LFU cache. In the event of a cache miss, The callback function recieves a cache method
156 * that you can call to set the cache for identical requests that may happen in the future. Once this is set, the call back
157 * will be skipped and the default response method will be called automatically.
158 *
159 * #### Chained Methods offer more control.
160 * ```javascript
161 * this.cache({ max: 100, strategy: 'LRU' })
162 * .miss(cache => {
163 * Products.find({category: 'widgets'}).exec( (err, widgets) => {
164 * this.res.status(200).json(widgets);
165 * cache(widgets);
166 * });
167 * })
168 * .hit(data => this.res.status(200).json(data))
169 * .exec();
170 * ```
171 * In the above example, we don't pass in a callback. Instead we use chaining to express
172 * how we want to handle the control flow.
173 *
174 * - `miss` registers a callback to allow you to store the item in the cache
175 *
176 * - `hit` registers a callback to allow you to handle your own response
177 *
178 * - `exec` runs the function and is a required function call.
179 *
180 * @param {object} options - (optional) Caching options. See options in the description.
181 * @param {function} fn - (optional) the method to call for a cache miss.
182 */
183 cache (options, fn) {
184 this[debug]('cache');
185 var cache = new ControllerCache(this.redisClient, this.req.controller, this.req.action, options);
186 var hitFn, missFn;
187
188 this.res.cache = function (content) {
189 return cache.cache.set(this.req.url, content);
190 }
191
192 this.cacheStore = cache.cache;
193
194 if (fn) {
195 return cache.cachedVersion(this.req).then(result => {
196 if (result) {
197 this[debug]('cache:callback:hit');
198 this.res.set('X-GLAD-CACHE-HIT', 'true');
199 return this.res.type(options.type || 'json').send(result);
200 } else {
201 this[debug]('cache:callback:miss');
202 return fn.call(this, (content) => {
203 return cache.cache.set(this.req.url, content);
204 });
205 }
206 });
207 } else {
208 return {
209 hit (func) { hitFn = func; return this;},
210 miss (func) { missFn = func; return this;},
211 exec : () => {
212 return new Promise( (resolve, reject) => {
213 cache.cachedVersion(this.req).then(result => {
214
215 if (result) {
216 this[debug]('cache:exec:hit');
217 this.res.set('X-Glad-Cache-Hit', 'true');
218 }
219
220 if (result && hitFn) {
221 hitFn(result);
222 resolve(result);
223 } else if (result) {
224 this.res.type(options.type || 'json').send(result);
225 resolve(result);
226 } else if (missFn) {
227 this[debug]('cache:exec:miss');
228 missFn(data => {
229 cache.cache.set(this.req.url, data);
230 resolve(data);
231 });
232 } else {
233 this[debug]('cache:exec:error');
234 reject({
235 err: `
236 Missing method Error.
237 Please provide the '.miss' portion of your cache chain.
238 this.cache({..opts}).miss((cache) => { ..do stuff >> res.json(stuff) >> cache(stuff) })
239 `
240 });
241 }
242 });
243 });
244 }
245 };
246 }
247 }
248
249 /**
250 * Returns a Cache instance for a give controller action.
251 * This makes the following cache methods available.
252 * This is needed when you need to manage a cache for another action on the same controller.
253 *
254 * <strong>Example:</strong> You may want to expire individual items from cache.
255 * ```javascript
256 * this.actionCache('FindOne').del('/widgets/12').then(function () {});
257 *
258 * ```
259 * <strong>Example:</strong> A DELETE request to `/api/widgets/12` might delete the 12th widget in your database.
260 * After removing the item from your database, you may want to reset some caches. The simplest form of this might be to
261 * clear the cache for the `Get` action, and remove it from the `FindOne` cache as well. This would ensure that (starting immediately)
262 * future requests for widgets will not include this data.
263 *
264 * ```javascript
265 * this.actionCache('Get').reset().then( () => {
266 * this.actionCache('FindOne').del('/widgets/12').then(res);
267 * });
268 * ```
269 *
270 * TODO:
271 * -----------
272 * <strong>Example:</strong> You may have objects in your cache that will change over time, but always need to live in cache.
273 * ```javascript
274 * this.actionCache('Get').find({url : '/widgets/100'}).update(cache => {
275 * //..do stuff then call cache(widget);
276 * });
277 * ```
278 *
279 * TODO:
280 * -----------
281 * <strong>Example:</strong> You may want to create cache re-warmers for actions.
282 * Furthermore after your data changes, you may need to clear a cache and rewarm it.
283 * the forEach method will iterate over every item in your action's cache, and provide
284 * the item and a cacher specific to the item which you can call to update that item.
285 * ```javascript
286 * this.actionCache('Get').forEach((item, cache, next) => {
287 * //..do stuff then call cache(updatedItem);
288 * });
289 * ```
290 */
291 actionCache (action) {
292 this[debug]('actionCache');
293 let _cache = new ControllerCache(this.redisClient, this.req.controller, action);
294 let { cache } = _cache;
295 return cache;
296 }
297
298 /**
299 * Renders a view. In the end, this maps to express res.render. By default it looks for a view residing in views/:controller/:action.pug.
300 * This can be overridden by passing in the path to the view to render.
301 *
302 * A controller named User would look for views in the `view/user` folder. If you were rendering from an action called `findOne`, the complete
303 * view path would be `views/user/findone`. If you would prefer to specify your own viewpath, there are a two options for you.
304 *
305 * #### From a controller method:
306 * ```
307 * // Specify the viewPath on the controller instance. This applies to a request instance.
308 * this.viewPath = 'my-views/wigets/widget.pug';
309 *
310 * // Use this.res.render (from express)
311 * this.res.render('path/to/view', data);
312 * ```
313 */
314 render (...args) {
315 this[debug]('render');
316 args[0] = path.join(this.viewPath, args[0]);
317 this.res.render.apply(this.res, args);
318 }
319
320}
321
322module.exports = Controller;