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