1 /*
  2  * Geddy JavaScript Web development framework
  3  * Copyright 2112 Matthew Eernisse (mde@fleegix.org)
  4  *
  5  * Licensed under the Apache License, Version 2.0 (the "License");
  6  * you may not use this file except in compliance with the License.
  7  * You may obtain a copy of the License at
  8  *
  9  *         http://www.apache.org/licenses/LICENSE-2.0
 10  *
 11  * Unless required by applicable law or agreed to in writing, software
 12  * distributed under the License is distributed on an "AS IS" BASIS,
 13  * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 14  * See the License for the specific language governing permissions and
 15  * limitations under the License.
 16  *
 17 */
 18 var fs = require('fs')
 19  20   , errors = require('./errors')
 21   , response = require('./response')
 22   , Templater = require('./template/adapters/ejs').Templater
 23 
 24 /**
 25   @name controller
 26   @namespace controller
 27 */
 28 var controller = {};
 29 
 30 /**
 31   @name controller.BaseController
 32   @constructor
 33 */
 34 controller.BaseController = function () {
 35   /**
 36     @name controller.BaseController#request
 37     @public
 38     @type http.ServerRequest
 39     @description The raw http.ServerRequest object for this request/response
 40     cycle.
 41    */
 42   this.request = null;
 43   /**
 44     @name controller.BaseController#response
 45     @public
 46     @type http.ServerResponse
 47     @description The raw http.ServerResponse object for this request/response
 48     cycle.
 49    */
 50   this.response = null;
 51   /**
 52     @name controller.BaseController#params
 53     @public
 54     @type Object
 55     @description The parsed params for the request. Also passed as an arg
 56     to the action, added as an instance field for convenience.
 57    */
 58   this.params = null;
 59   /**
 60     @name controller.BaseController#cookies
 61     @public
 62     @type Object
 63     @description Cookies collection for the request
 64    */
 65   this.cookies = null;
 66   /**
 67     @name controller.BaseController#name
 68     @public
 69     @type String
 70     @description The name of the controller constructor function,
 71     in CamelCase with uppercase initial letter.
 72    */
 73   this.name = null;
 74   /**
 75     @name controller.BaseController#respondsWith
 76     @public
 77     @type Array
 78     @description Content-type the controller can respond with.
 79     @default ['txt']
 80    */
 81   this.respondsWith = ['txt'];
 82   /**
 83     @name controller.BaseController#content
 84     @public
 85     @type {Object|String}
 86     @description Content to use for the response.
 87    */
 88   this.content = '';
 89   /**
 90     @name controller.BaseController#format
 91     @public
 92     @type {String}
 93     @description Determined by what format the client requests, and if the
 94     controller/action supports it. Built-in formats can be found in the enum
 95     controller.formats
 96    */
 97   this.format = '';
 98   /**
 99     @name controller.BaseController#contentType
100     @public
101     @type {String}
102     @description Content-type of the response -- a result of the
103     content-negotiation process. Determined by the format, and by what
104     content-types the client accepts.
105    */
106   this.contentType = '';
107   /**
108     @name controller.BaseController#beforeFilters
109     @public
110     @type {Array}
111     @description Actions to be performed before rendering a repsonse.
112    */
113   this.beforeFilters = [];
114   /**
115     @name controller.BaseController#afterFilters
116     @public
117     @type {Array}
118     @description Actions to be performed after rendering a repsonse.
119    */
120   this.afterFilters = [];
121 
122   /*
123   // The template root to look in for partials when rendering templates
124   // Gets created programmatically based on controller name -- see renderTemplate
125   this.template = undefined;
126   // Should the layout be rendered
127   this.layout = true;
128   // The template layout directory to look in when rendering templates
129   // Gets created programmatically based on controller name -- see renderTemplate
130   this.layoutpath = undefined;
131   */
132 };
133 
134 /**
135   @name controller.formats
136   @enum
137   @description High-level set of options which can represent multiple
138   content-types.
139  */
140 controller.formats = {
141   /**
142     @name controller.formats.TXT
143     @constant
144     @type {String}
145     @description String constant with a value of 'txt'
146   */
147   TXT: 'txt'
148   /**
149     @name controller.formats.JSON
150     @constant
151     @type {String}
152     @description String constant with a value of 'json'
153   */
154 , JSON: 'json'
155   /**
156     @name controller.formats.XML
157     @constant
158     @type {String}
159     @description String constant with a value of 'xml'
160   */
161 , XML: 'xml'
162   /**
163     @name controller.formats.HTML
164     @constant
165     @type {String}
166     @description String constant with a value of 'html'
167   */
168 , HTML: 'html'
169 };
170 
171 /**
172   @name controller.formatters
173   @static
174   @namespace
175   @description Contains the static methods for returning data in a specific
176   format.
177  */
178 controller.formatters = new (function () {
179 
180   /**
181     @name controller.formatters.json
182     @static
183     @function
184     @description JSON-formats an object, by looking for toJson/toJSON methds
185     defined on it, and then falling back to JSON.stringify.
186 
187     @param {Object|String} content The content to be formatted.
188    */
189   this.json = function (content) {
190     var toJson = content.toJson || content.toJSON;
191     if (typeof toJson == 'function') {
192       return toJson.call(content);
193     }
194     return JSON.stringify(content);
195   };
196   /**
197     @name controller.formatters.js
198     @static
199     @function
200     @description Formats an object as JSONP, by looking for toJson/toJSON methds
201     defined on it, and then falling back to JSON.stringify, and wrapping it in a
202     callback.
203 
204     @param {Object|String} content The content to be formatted.
205     @param {controller.BaseController} [controller] The controller handling the
206     response to be formatted.
207    */
208   this.js = function (content, controller) {
209     var params = controller.params;
210     if (!params.callback) {
211       err = new errors.InternalServerError('JSONP callback not defined.');
212       controller.error(err);
213     }
214     return params.callback + '(' + JSON.stringify(content) + ');';
215   };
216   /**
217     @name controller.formatters.txt
218     @static
219     @function
220     @description Formats an object as plaintext, by looking for a toString
221     defined on it, and then falling back to JSON.stringify.
222 
223     @param {Object|String} content The content to be formatted.
224    */
225   this.txt = function (content) {
226     if (typeof content.toString == 'function') {
227       return content.toString();
228     }
229     return JSON.stringify(content);
230   };
231 
232 })();
233 
234 controller.BaseController.prototype = new (function () {
235 
236   // Private methods, utility methods
237   // -------------------
238   var _addFilter = function (phase, filter, opts) {
239         var obj = {def: filter};
240         obj.except = opts.except;
241         obj.only = opts.only;
242         obj.async = opts.async;
243         this[phase + 'Filters'].push(obj);
244       }
245 
246     , _execFilters = function (action, phase, callback) {
247         var _this = this;
248         var filters = this[phase + 'Filters'];
249         var filter;
250         var list = [];
251         var func;
252         var applyFilter;
253         for (var i = 0; i < filters.length; i++) {
254           filter = filters[i];
255           applyFilter = true;
256           if (filter.only && filter.only != action) {
257             applyFilter = false;
258           }
259           if (filter.except && filter.except == action) {
260             applyFilter = false;
261           }
262           if (applyFilter) {
263             // Create an async wrapper for any sync filters
264             if (!filter.async) {
265               func = function (callback) {
266                 filter.def.apply(_this, []);
267                 callback();
268               };
269 270             }
271             else {
272               func = filter.def
273             }
274             list.push({
275               func: func
276             , args: []
277             , callback: null
278             , context: _this
279             });
280           }
281         }
282         var chain = new geddy.async.AsyncChain(list);
283         chain.last = callback;
284         chain.run();
285       }
286 
287     , _negotiateContent = function (frmt) {
288         var format
289           , contentType
290           , types = []
291           , match
292           , params = this.params
293           , err
294           , accepts = this.request.headers.accept
295           , accept
296           , pat
297           , wildcard = false;
298 
299         // If the client provides an Accept header, split on comma
300         // Some user-agents may include whitespace with the comma
301         if (accepts) {
302           accepts = accepts.split(/\s*,\s*/);
303         }
304         // If the client doesn't provide an Accept header, assume
305         // it's happy with anything
306         else {
307           accepts = ['*/*'];
308         }
309 
310         if (frmt) {
311           types = [frmt];
312         }
313         else if (params.format) {
314           var f = params.format;
315           // See if we can actually respond with this format,
316           // i.e., that this one is in the list
317           if (f && ('|' + this.respondsWith.join('|') + '|').indexOf(
318               '|' + f + '|') > -1) {
319             types = [f];
320           }
321         }
322         else {
323           types = this.respondsWith;
324         }
325 
326         // Okay, we have some format-types, let's see if anything matches
327         if (types.length) {
328           for (var i = 0, ii = accepts.length; i < ii; i++) {
329             // Ignore quality factors for now
330             accept = accepts[i].split(';')[0];
331             if (accept == '*/*') {
332               wildcard = true;
333               break;
334             }
335           }
336 
337           // If agent accepts anything, respond with the controller's first choice
338           if (wildcard) {
339             var t = types[0];
340             format = t;
341             contentType = response.formatsPreferred[t];
342             if (!contentType) {
343               _throwUndefinedFormatError.call(this);
344             }
345           }
346           // Otherwise look through the acceptable formats and see if
347           // Geddy knows about any of them.
348           else {
349             for (var i = 0, ii = types.length; i < ii; i++) {
350               pat = response.formatPatterns[types[i]];
351               if (pat) {
352                 for (var j = 0, jj = accepts.length; j < jj; j++) {
353                   match = accepts[j].match(pat);
354                   if (match) {
355                     format = types[i];
356                     contentType = match;
357                     break;
358                   }
359                 }
360               }
361               // If respondsWith contains some random format that Geddy doesn't know
362               // TODO Make it easy for devs to add new formats
363               else {
364                 this.throwUndefinedFormatError();
365               }
366             self// Don't look at any more formats if there's a match
367               if (match) {
368                 break;
369               }
370             }
371           }
372         }
373         return {format: format, contentType: contentType};
374       }
375 
376     , _doResponse = function (stat, headers, content) {
377         var self = this
378           , r = new response.Response(this.response)
379           , action = this.action
380           , callback = function () {
381               // Set status and headers, can be overridden in after-filters
382               if (self.cookies) {
383                 headers['Set-Cookie'] = self.cookies.toArray();
384               }
385               r.setHeaders(stat, headers);
386 
387               // Run after-filters, then finish out the response
388               _execFilters.apply(self, [action, 'after', function () {
389                 r.finalize(self.content);
390               }]);
391             };
392 
393         if (this.session) {
394           this.session.close(callback);
395         }
396         else {
397           callback();
398         }
399       }
400 
401     , _throwUndefinedFormatError = function () {
402         err = new errors.InternalServerError(
403             'Format not defined in response.formats.');
404         this.error(err);
405       };
406 
407   // Pseudo-private, non-API
408   // -------------------
409   /**
410    * Primary entry point for calling the action on a controller
411    * Wraps the action so befores and afters can be run
412    */
413   this._handleAction = function (action) {
414     var self = this;
415     // Wrap the actual action-handling in a callback to use as the 'last'
416     // method in the async chain of before-filters
417     var callback = function () {
418       self[action].apply(self, [self.request, self.response, self.params]);
419     };
420     // Running filters asynchronously breaks handlers that depend on
421     // setting listeners on the request before the next tick -- only
422     // run them if necessary
423     if (this.beforeFilters.length) {
424       _execFilters.apply(this, [action, 'before', callback]);
425     }
426     else {
427       callback();
428     }
429   };
430 
431   // Public methods
432   // -------------------
433   /**
434     @name controller.BaseController#before
435     @public
436     @function
437     @description Adds an action to the beforeFilters list.
438     @param {Function} filter Action to add to the beforeFilter list of
439     actions to be performed before a response is rendered.
440     @param {Object} [opts]
441       @param {Array} [opts.except=null] List of actions where the
442       before-filter should not be performed.
443       @param {Array} [opts.only=null] This list of actions are the
444       only actions where this before-filter should be performed.
445    */
446   this.before = function (filter, options) {
447     _addFilter.apply(this, ['before', filter, options || {}]);
448   };
449 
450   /**
451     @name controller.BaseController#after
452     @public
453     @function
454     @description Adds an action to the afterFilters list of actions
455     to be performed after a response is rendered.
456     @param {Function} filter Action to add to the afterFilter list.
457     @param {Object} [opts]
458       @param {Array} [opts.except=null] List of actions where the
459       after-filter should not be performed.
460       @param {Array} [opts.only=null] This list of actions are the
461       only actions where this after-filter should be performed.
462    */
463   this.after = function (filter, options) {
464     _addFilter.apply(this, ['after', filter, options || {}]);
465   };
466 
467   /**
468     @name controller.BaseController#redirect
469     @public
470     @function
471     @description Sends a 302 redirect to the client, based on either a
472     simple string-URL, or a controller/action/format combination.
473     @param {String|Object} target Either an URL, or an object literal containing
474     controller/action/format attributes to base the redirect on.
475    */
476   this.redirect = function (target) {
477     var url;
478     if (typeof target == 'string') {
479       url = target;
480     }
481     else {
482       var contr = target.controller || this.name;
483       var act = target.action;
484       var ext = target.format || this.params.format;
485       var id = target.id;
486       contr = geddy.string.decamelize(contr);
487       url = '/' + contr;
488       url += act ? '/' + act : '';
489       url += id ? '/' + id : '';
490       if (ext) {
491         url += '.' + ext;
492       }
493     }
494 
495     this.content = '';
496 
497     _doResponse.apply(this, [302, {'Location': url}]);
498   };
499 
500   /**
501     @name controller.BaseController#error
502     @public
503     @function
504     @description Respond to a request with an appropriate HTTP error-code.
505     If a status-code is set on the error object, uses that as the error's
506     status-code. Otherwise, responds with a 500 for the status-code.
507     @param {Object} err The error to use as the basis for the response.
508    */
509   this.error = function (err) {
510     errors.respond(this.response, err);
511   };
512 
513   /**
514     @name controller.BaseController#transfer
515     @public
516     @function
517     @description Transfer a request from its original action to a new one. The
518     entire request cycle is repeated, including before-filters.
519     @param {Object} action The new action designated to handle the request.
520    */
521   this.transfer = function (action) {
522     this.params.action = action;
523     this._handleAction(action);
524   };
525 
526   /**
527     @name controller.BaseController#respond
528     @public
529     @function
530     @description Performs content-negotiation, and renders a response.
531     @param {Object|String} content The content to use in the response.
532     @param {Object} [opts] Options.
533       @param {String} [opts.format] The desired format for the response.
534       @param {String} [opts.template] The path (without file extensions)
535       to the template to use to render this response.
536       @param {String} [opts.layout] The path (without file extensions)
537       to the layout to use to render the template for this response.
538    */
539   this.respond = function (content, opts) {
540     var c = content
541       , r
542       , headers
543       , options = opts || {}
544       , format = typeof opts == 'string' ? options : options.format
545       , negotiated = _negotiateContent.call(this, format);
546 
547     this.format = negotiated.format;
548     this.contentType = negotiated.contentType;
549 
550     if (!this.contentType) {
551       var err = new errors.NotAcceptableError('Not an acceptable media type.');
552       this.error(err);
553     }
554 
555     if (options.template) {
556       this.template = options.template;
557     }
558     if (options.layout) {
559       this.layout = options.layout;
560     }
561 
562     // If content needs formatting
563     if (typeof c != 'string') {
564       if (this.format) {
565         // Special-case HTML -- will go out to template-rendering code,
566         // and then come back here with content as a string
567         if (this.format == 'html') {
568           this.renderTemplate(content);
569           return;
570         }
571         else {
572           c = controller.formatters[format](c, this);
573         }
574       }
575       // If we couldn't perform content-negotiaton successfully, bail
576       // with error
577       else {
578         _throwUndefinedFormatError.call(this);
579         return;
580       }
581     }
582 
583     this.content = c;
584     _doResponse.apply(this, [200, {'Content-Type': this.contentType}]);
585 
586   };
587 
588   this.render = this.respond;
589 
590   this.renderTemplate = function (data) {
591     var _this = this
592       , dirName;
593 
594     dirName = geddy.inflection.pluralize(this.name);
595     dirName = geddy.string.snakeize(dirName);
596 
597     // Calculate the template if not set
598     this.template = this.template ||
599     	'app/views/' + dirName + '/' + this.params.action;
600 
601     if (this.layout) {
602 	    // Calculate the layout if not set
603 	    this.layoutpath = this.layoutpath ||
604 	    	'app/views/layouts/' + dirName;
605     }
606 
607     var templater = new Templater();
608     var content = '';
609 
610     templater.addListener('data', function (d) {
611       // Buffer for now, but could stream
612       content += d;
613     });
614 
615     templater.addListener('end', function () {
616       _this.respond(content);
617     });
618 
619     templater.render(data, {
620       layout: this.layoutpath
621     , template: this.template
622     , controller: this.name
623     , action: this.params.action
624     });
625   };
626 
627 })();
628 
629 630 exports.BaseController = controller.BaseController;
631 
632