Controllers
Actions
By convention there are 7 RESTful actions for a controller:
index: Show all resources (GET)new: Build a new resource and render a form (GET)create: Create a new resource (POST)show: Find and show a resource by id (GET)edit: Find a resource by id and render it in a form to edit (GET)update: Update an existing resource (PUT)destroy: Destroy an existing resource (DELETE)
If you wanted to manually write out simple controller actions, this is how you might do it:
class App.PostsController extends Tower.Controller
index: ->
App.Post.all (error, posts) =>
@render "index", locals: posts: posts
new: ->
post = new App.Post
@render "new", locals: post: post
create: ->
App.Post.create @params.post, (error, post) =>
@redirectTo post
show: ->
App.Post.find @params.id (error, post) =>
@render "show", locals: post: post
edit: ->
App.Post.find @params.id (error, post) =>
@render "edit", locals: post: post
update: ->
App.Post.find @params.id (error, post) =>
post.updateAttributes @params.post, (error) =>
if error
@render "edit", locals: post: post
else
@redirectTo post
destroy: ->
App.Post.find @params.id (error, post) =>
post.destroy (error) =>
@redirectTo "index"
Note: The above actions won't respond to different content types differently (i.e. for json, give me a json string, for html give me some rendered html). But for some cases that's all you need.
In order for those actions to be accessible, routes must be defined. Routes to all 7 RESTful actions are generated with the following declaration in config/routes.coffee:
Tower.Route.draw ->
@resources "posts"
More on routes in the routes section.
Custom Actions
Sometimes RESTful actions aren't enough. To add custom actions, just add a route mapping to the method name for the controller.
Tower.Route.draw ->
@resources "posts", ->
@get "dashboard"
Note: If you start thinking you need to add a custom action to your controller, I personally recommend asking the community (stack overflow, twitter, github, etc.) if there's a way to fit it into the paradigm. Several times I thought "this is the one time I need a custom action", but 99% of the time I found a way to fit it in. This always simplified the system.
Events
All controller actions are just events. This means then that controllers handle events:
- DOM events
- socket messages
- url requests
Instead of having to create a controller for each type of message, why not just establish some conventions:
class App.PostsController extends Tower.Controller
# socket.io handler
@on "create", "syncCreate" # created by default... knows because it's named after an action
@on "notification", "flashMessage" # knows it's socket because 'notification' isn't an action or dom event keyword
@on "mousemove", "updateHeatMap", type: 'socket' # if you name a socket event after a keyword then pass the `type: 'socket'` option.
# dom event handler
@on "click", "click"
@on "click .item a", "clickItem"
# or as an object
@on "click .itemA a": "clickItemA",
"click .itemB a": "clickItemB",
"click .itemC a": "clickItemC"
@on "change #user-first-name-input", "enable", dependent: "#user-last-name-input"
@on "change #user-first-name-input", "enable #user-last-name-input" # enable: (selector)
@on "change #user-first-name-input", "validate"
@on "change #user-first-name-input", bind: "firstName"
@bind "change #user-first-name-input", "firstName"
@on "click #add-user-address", "addAddress"
@on "click #add-user-address", "add", object: "address"
@on "click #remove-user-address", "removeAddress"
# $(window).on('click', '#user-details', "toggleDetails");
@on "click #user-details", "toggleDetails"
# show or hide
toggleShowHide: ->
show: ->
hide: ->
toggleSelectDeselect: ->
select: ->
deselect: ->
toggleAddRemove: ->
add: ->
remove: ->
toggleEnableDisable: ->
if _.blank(value)
@disable()
else
@enable()
# enable or disable
enable: ->
$(options.dependent).attr("disabled", false)
disable: ->
$(options.dependent).attr("disabled", true)
validate: (element) ->
element
invalidate: ->
bind: ->
next: ->
prev: ->
Params
You can define parameters which should be parsed into a Tower.Model.Criteria. This allows you to build a very robust query api with minimal effort.
Tower uses a set of pseudo-ruby conventions for defining ranges and sets of values to query by. They are defined with the @param class method on a controller. Tower looks for the attribute definition on the model class associated with the controller, unless you have specified the type option.
class App.EventsController extends Tower.Controller
@param "title"
@param "createdAt"
@param "memberCount"
@param "tags", type: "Array"
@param "coordinates"
@param "admin", source: "admin.firstName"
index: ->
App.Event.where(@criteria()).all (error, events) =>
@render json: events
StringDateArrayNumberOrderCoordinates
String
Query
title=Hello+World
title=Hello+-World
title='Hello+World'
Criteria
{ "title" : { "$match" : ["Hello", "World"] } }
{ "title" : { "$match" : ["Hello"], "$notMatch" : ["World"] } }
{ "title" : { "$match" : ["Hello World"] } }
Date
Query
createdAt=12-31-2011
createdAt=12-31-2011..t
createdAt=t..12-31-2011
createdAt=12-21-2011..12-31-2011
createdAt=12-21-2011..12-31-2011,01-04-2012
Criteria
{ "createdAt" : Date(12-31-2011) }
{ "createdAt" : { "$gte": Date(12-31-2011) } }
{ "createdAt" : { "$lte": Date(12-31-2011) } }
{ "createdAt" : { "$gte": Date(12-21-2011), "$lte": Date(12-31-2011) } }
{ "$or": [ { "createdAt" : { "$gte": Date(12-21-2011), "$lte": Date(12-31-2011) } }, { "createdAt" : Date(01-04-2012) } ] }
Number
Query
likeCount=1
likeCount=1..n
likeCount=n..100
likeCount=1..100
likeCount=1..100,1000
likeCount=-10
likeCount=^10
likeCount=^10,1000
likeCount=^10..1000
likeCount=^-1000
Criteria
{ "likeCount" : 1 }
{ "likeCount" : { "$gte": 1 } }
{ "likeCount" : { "$lte": 1 } }
{ "likeCount" : { "$gte": 1, "$lte": 100 } }
{ "$or": [{ "likeCount" : { "$gte": 1, "$lte": 100 } }, { "likeCount" : 1000 }] }
{ "likeCount": -10 }
{ "likeCount": { "$neq": -10 } }
{ "likeCount": { "$neq": 10, "$in": [1000] } }
{ "nor": [ { "likeCount" : { "$gte": 10, "$lte": 1000 } } ] }
{ "likeCount": { "$neq": -1000 } }
Array
Query
tags=javascript
tags=^java
tags=ruby,^java
tags=[ruby,javascript]
Criteria
{ "tags" : { "$in": ["javascript"] } }
{ "tags" : { "$nin": ["java"] } }
{ "tags" : { "$in": ["ruby"], "$nin": ["java"] } }
{ "tags" : { "$all": ["ruby", "javascript"] } }
Coordinates
Query
coordinates=51.509981,-0.074704
coordinates=51.509981,-0.074704,10
Criteria
{ "coordinates" : { "$near": [51.509981, -0.074704] } }
{ "coordinates" : { "$near": [51.509981, -0.074704] , "$maxDistance" : 10 } }
Order
Query
sort=title
sort=title+
sort=title-
sort=createdAt-,title
sort=createdAt+,title+
Criteria
{ "sort" : [["title", "asc"]] }
{ "sort" : [["title", "asc"]] }
{ "sort" : [["title", "desc"]] }
{ "sort" : [["createdAt", "desc"], ["title", "asc"]] }
{ "sort" : [["createdAt", "asc"], ["title", "asc"]] }
Examples
http://events.towerjs.org/?createdAt=12-25-2011
http://events.towerjs.org/?createdAt=12-25-2011..12-31-2011
http://events.towerjs.org/?tags=javascript,ruby
http://events.towerjs.org/?memberCount=10..n
http://events.towerjs.org/?sort=createdAt-,title+
http://events.towerjs.org/?createdAt=12-25-2011..12-31-2011&tags=javascript,ruby
http://events.towerjs.org/?createdAt=12-25-2011..12-31-2011&tags=javascript,ruby&sort=createdAt-,title+
http://events.towerjs.org/?createdAt=12-25-2011..12-31-2011&tags=javascript,ruby&memberCount=10..n&sort=createdAt-,title+
http://events.towerjs.org/?createdAt=12-25-2011..12-31-2011&tags=javascript,ruby&memberCount=10..n&coordinates=51.509981,-0.074704,10&sort=createdAt-,title+
The last url above would generate the criteria:
{
"coordinates" : { "$near": [51.509981, -0.074704] , "$maxDistance" : 10 },
"createdAt" : { "$gte": Date(12-21-2011), "$lte": Date(12-31-2011) },
"memberCount" : { "$gte": 10 },
"sort" : [["createdAt", "desc"], ["title", "asc"]],
"tags" : { "$in": ["ruby", "javascript"] }
}
OR Queries Over Several Attributes
You can do OR searches over several attributes, i.e. "find all posts in the past 2 days OR those tagged with 'javascript'". Just prepend each OR block with an array index:
# find all posts between christmas and new years eve, or those tagged with "javascript" and "ruby", then sort by date and title
[0]createdAt=12-25-2011..12-31-2011&[1]tags=javascript,ruby&sort=createdAt-,title+
createdAt[0]=12-25-2011..12-31-2011&tags[1]=javascript,ruby&sort=createdAt-,title+
Nested
Rendering
- Templates
- JSON
- The Rendering Process
Templates
class App.PostsController extends Tower.Controller
index: ->
@render "index"
JSON
class App.PostsController extends Tower.Controller
show: ->
App.Post.find @params.id, (error, post) =>
@render json: post
The Rendering Process
respondWith
show: ->
App.Post.find @params.id, (error, post) =>
@respondWith post, (format) =>
format.html => @render "show"
This will perform content negotiation, i.e. it will figure out what the mime type the browser prefers and run the corresponding responder method (for html, json, csv, etc.). Those methods then call the render method.
render
show: ->
App.Post.find @params.id, (error, post) =>
@render "show"
Calling the render method directly forces a specific content type to be rendered. Here is the method signature:
render json: {hello: "world"}
render "show" # render action: "show"
render "posts/show" # render file: "posts/show"
render -> h1 "Hello World"
render text: "success", status: 200
_normalizeRender
This converts the render arguments into a normalized options hash.
_renderToBody
_renderOption
_renderTemplate
Resources
Tower goes by the convention that every controller represents one resource, one model.
A controller doesn't need to follow these conventions, for example with a DashboardController or SearchController. In those cases, overriding the methods starts you with a clean slate. However, you'll quickly see how powerful this is.
The Resource
You can customize the variable names and resource type:
class App.PostsController extends Tower.Controller
@resource type: "Article", collection: "articles", resource: "article", key: "data", id: "dataId"
Internals
The default implementation for a Tower.Controller looks like this:
class App.PostsController extends Tower.Controller
index: ->
new: ->
create: ->
show: ->
edit: ->
update: ->
destroy: ->
Routes
Tower routes are modeled after Rails' routes. Here's how you might write a few for a blogging app:
Tower.Route.draw ->
@match "/login", to: "sessions#new", via: "get", as: "login"
@resources "posts", ->
@resources "comments"
@namespace "admin", ->
@resources "posts", ->
@resources "comments", only: "index"
@resources "users", ->
@resources "comments", only: "index"
@namespace "mobile", constraints: {subdomain: /^mobile$/}, ->
@root "/", to: "mobile#index"
@match "(/*path)", to: "application#index"
Resourceful Routes
Tower.Route.draw ->
# this...
@resources "posts"
# is equivalent to this...
@match "/posts", to: "posts#index", via: "get"
@match "/posts/new", to: "posts#new", via: "get"
@match "/posts", to: "posts#create", via: "post"
@match "/posts/:id", to: "posts#show", via: "get"
@match "/posts/:id/edit", to: "posts#edit", via: "get"
@match "/posts/:id", to: "posts#update", via: "put"
@match "/posts/:id", to: "posts#destroy", via: "delete"
Nesting Routes
Hardcore Routing Example
Here is a complete example straight from the Rails 3.2 test suite, soon to be in the Tower test suite.
Tower.Route.draw ->
@defaultUrlOptions host: "towerjs.org"
@resourcesPathNames correlationIndexes: "infoAboutCorrelationIndexes"
@controller "sessions", ->
@get "login": "new"
@post "login": "create"
@destroy "logout": "destroy"
@resource "session", ->
@get "create"
@post "reset"
@resource "info"
@member, ->
@get "crush"
@scope "bookmark", controller: "bookmarks", as: "bookmark", ->
@get "new", path: "build"
@post "create", path: "create", as: ""
@put "update"
@get "remove", action: "destroy", as: "remove"
@scope "pagemark", controller: "pagemarks", as: "pagemark", ->
@get "new", path: "build"
@post "create", as: ""
@put "update"
@get "remove", action: "destroy", as: "remove"
@match "account/logout": redirect("/logout"), as: "logoutRedirect"
@match "account/login", to: redirect("/login")
@match "secure", to: redirect("/secure/login")
@match "mobile", to: redirect(subdomain: "mobile")
@match "superNewDocumentation", to: redirect(host: "super-docs.com")
@match "youtubeFavorites/"youtubeId"/"name"", to: redirect(YoutubeFavoritesRedirector)
@constraints ((request) -> true), ->
@match "account/overview"
@match "/account/nested/overview"
@match signIn: "sessions#new"
@match "account/modulo/:name", to: @redirect("/%{name}s")
@match "account/proc/:name", to: @redirect((params, request) -> "/#{params.name.pluralize}")
@match "account/procReq": @redirect((params, request) "/#{req.method}")
@match "account/google": @redirect("http://www.google.com/", status: 302)
@match "openid/login", via: ["get", "post"], to: "openid#login"
@controller "global", ->
@get "global/hideNotice"
@match "global/export", to: "export", as: "exportRequest"
@match "/export/"id"/"file"", to: "export", as: "exportDownload", constraints: { file: /.*/ }
@match "global/"action""
@match "/local/"action"", controller: "local"
@match "/projects/status(."format")"
@match "/404", to: lambda { |env| [404, {"Content-Type": "text/plain"}, ["NOT FOUND"]] }
@constraints(ip: /192\.168\.1\.\d\d\d/), ->
@get "admin": "queenbee#index"
@constraints :"TestRoutingMapper":"IpRestrictor", ->
@get "admin/accounts": "queenbee#accounts"
@get "admin/passwords": "queenbee#passwords", constraints: :"TestRoutingMapper":"IpRestrictor"
@scope "pt", as: "pt", ->
@resources "projects", pathNames: { edit: "editar", new: "novo" }, path: "projetos", ->
@post "preview", on: "new"
@put "close", on: "member", path: "fechar"
@get "open", on: "new", path: "abrir"
@resource "admin", pathNames: { new: "novo", activate: "ativar" }, path: "administrador", ->
@post "preview", on: "new"
@put "activate", on: "member"
@resources "products", pathNames: { new: "novo" }, ->
@new, ->
@post "preview"
@resources "projects", controller: "project", ->
@resources "involvements", "attachments"
@get "correlationIndexes", on: "collection"
@resources "participants", ->
@put "updateAll", on: "collection"
@resources "companies", ->
@resources "people"
@resource "avatar", controller: "avatar"
@resources "images", as: "funnyImages", ->
@post "revise", on: "member"
@resource "manager", as: "superManager", ->
@post "fire"
@resources "people", ->
@nested, ->
@scope "/"accessToken"", ->
@resource "avatar"
@member, ->
@get "somePathWithName"
@put "accessibleProjects"
@post "resend", "generateNewPassword"
@resources "posts", ->
@get "archive", "toggleView", on: "collection"
@post "preview", on: "member"
@resource "subscription"
@resources "comments", ->
@post "preview", on: "collection"
@resources "replies", ->
@collection, ->
@get "page/"page"": "replies#index", page: %r{\d+}
@get ""page"": "replies#index", page: %r{\d+}
@new, ->
@post "preview"
@member, ->
@put "answer", to: "markAsAnswer"
@delete "answer", to: "unmarkAsAnswer"
@resources "posts", only: ["index", "show"], ->
@namespace "admin", ->
@root to: "index#index"
@resources "comments", except: "destroy", ->
@get "views": "comments#views", as: "views"
@resource "past", only: "destroy"
@resource "present", only: "update"
@resource "future", only: "create"
@resources "relationships", only: ["create", "destroy"]
@resources "friendships", only: ["update"]
@shallow, ->
@namespace "api", ->
@resources "teams", ->
@resources "players"
@resource "captain"
@scope "/hello", ->
@shallow, ->
@resources "notes", ->
@resources "trackbacks"
@resources "threads", shallow: true, ->
@resource "owner"
@resources "messages", ->
@resources "comments", ->
@member, ->
@post "preview"
@resources "sheep", ->
@get "_it", on: "member"
@resources "clients", ->
@namespace "google", ->
@resource "account", ->
@namespace "secret", ->
@resource "info"
@resources "customers", ->
@get "recent", on: "collection"
@get "profile", on: "member"
@get "secret/profile": "customers#secret", on: "member"
@post "preview": "customers#preview", as: "anotherPreview", on: "new"
@resource "avatar", ->
@get "thumbnail": "avatars#thumbnail", as: "thumbnail", on: "member"
@resources "invoices", ->
@get "outstanding": "invoices#outstanding", on: "collection"
@get "overdue", to: "overdue", on: "collection"
@get "print": "invoices#print", as: "print", on: "member"
@post "preview": "invoices#preview", as: "preview", on: "new"
@get "aged/"months"", on: "collection", action: "aged", as: "aged"
@resources "notes", shallow: true, ->
@get "preview": "notes#preview", as: "preview", on: "new"
@get "print": "notes#print", as: "print", on: "member"
@get "inactive", on: "collection"
@post "deactivate", on: "member"
@get "old", on: "collection", as: "stale"
@get "export"
@namespace "api", ->
@resources "customers", ->
@get "recent": "customers#recent", as: "recent", on: "collection"
@get "profile": "customers#profile", as: "profile", on: "member"
@post "preview": "customers#preview", as: "preview", on: "new"
@scope(""version"", version: /.+/), ->
@resources "users", id: /.+?/, format: /json|xml/
@match "sprockets.js": :"TestRoutingMapper":"SprocketsApp"
@match "people/"id"/update", to: "people#update", as: "updatePerson"
@match "/projects/"projectId"/people/"id"/update", to: "people#update", as: "updateProjectPerson"
# misc
@match "articles/"year"/"month"/"day"/"title"", to: "articles#show", as: "article"
# default params
@match "inlinePages/("id")", to: "pages#show", id: "home"
@match "defaultPages/("id")", to: "pages#show", defaults: { id: "home" }
@defaults id: "home", ->
@match "scopedPages/("id")", to: "pages#show"
@namespace "account", ->
@match "shorthand"
@match "description", to: "description", as: "description"
@match ""action"/callback", action: /twitter|github/, to: "callbacks", as: "callback"
@resource "subscription", "credit", "creditCard"
@root to: "account#index"
@namespace "admin", ->
@resource "subscription"
@namespace "forum", ->
@resources "products", path: "", ->
@resources "questions"
@namespace "users", path: "usuarios", ->
@root to: "home#index"
@controller "articles", ->
@scope "/articles", as: "article", ->
@scope path: "/"title"", title: /[a-z]+/, as: "withTitle", ->
@match "/"id"", to: "withId", as: ""
@scope ""accessToken"", constraints: { accessToken: /\w{5,5}/ }, ->
@resources "rooms"
@match "/info": "projects#info", as: "info"
@namespace "admin", ->
@scope "("locale")", locale: /en|pl/, ->
@resources "descriptions"
@scope "("locale")", locale: /en|pl/, ->
@get "registrations/new"
@resources "descriptions"
@root to: "projects#index"
@scope only: ["index", "show"], ->
@resources "products", constraints: { id: /\d{4}/ }, ->
@root to: "products#root"
@get "favorite", on: "collection"
@resources "images"
@resource "account"
@resource "dashboard", constraints: { ip: /192\.168\.1\.\d{1,3}/ }
@resource "token", module: "api"
@scope module: "api", ->
@resources "errors", shallow: true, ->
@resources "notices"
@scope path: "api", ->
@resource "me"
@match "/": "mes#index"
@get "(/"username")/followers": "followers#index"
@get "/groups(/user/"username")": "groups#index"
@get "(/user/"username")/photos": "photos#index"
@scope "(groups)", ->
@scope "(discussions)", ->
@resources "messages"
@match "whatever/"controller"(/"action"(/"id"))", id: /\d+/
@resource "profile", ->
@get "settings"
@new, ->
@post "preview"
@resources "content"
@namespace "transport", ->
@resources "taxis"
@namespace "medical", ->
@resource "taxis"
@scope constraints: { id: /\d+/ }, ->
@get "/tickets", to: "tickets#index", as: "tickets"
@scope constraints: { id: /\d{4}/ }, ->
@resources "movies", ->
@resources "reviews"
@resource "trailer"
@namespace "private", ->
@root to: redirect("/private/index")
@match "index", to: "private#index"
@scope only: ["index", "show"], ->
@namespace "only", ->
@resources "clubs", ->
@resources "players"
@resource "chairman"
@scope except: ["new", "create", "edit", "update", "destroy"], ->
@namespace "except", ->
@resources "clubs", ->
@resources "players"
@resource "chairman"
@namespace "wiki", ->
@resources "articles", id: /[^\/]+/, ->
@resources "comments", only: ["create", "new"]
@resources "wikiPages", path: "pages"
@resource "wikiAccount", path: "myAccount"
@scope only: "show", ->
@namespace "only", ->
@resources "sectors", only: "index", ->
@resources "companies", ->
@scope only: "index", ->
@resources "divisions"
@scope except: ["show", "update", "destroy"], ->
@resources "departments"
@resource "leader"
@resources "managers", except: ["show", "update", "destroy"]
@scope except: "index", ->
@namespace "except", ->
@resources "sectors", except: ["show", "update", "destroy"], ->
@resources "companies", ->
@scope except: ["show", "update", "destroy"], ->
@resources "divisions"
@scope only: "index", ->
@resources "departments"
@resource "leader"
@resources "managers", only: "index"
@resources "sections", id: /.+/, ->
@get "preview", on: "member"
@scope as: "routes", ->
@get "/c/"id"", as: "collision", to: "collision#show"
@get "/collision", to: "collision#show"
@get "/noCollision", to: "collision#show", as: nil
@get "/fc/"id"", as: "forcedCollision", to: "forcedCollision#show"
@get "/forcedCollision", as: "forcedCollision", to: "forcedCollision#show"
@match "/purchases/"token"/"filename"",
to: "purchases#fetch",
token: /[["alnum":]]{10}/,
filename: /(.+)/,
as: "purchase"
@resources "lists", id: /([A-Za-z0-9]{25})|default/, ->
@resources "todos", id: /\d+/
@scope "/countries/"country"", constraints: lambda { |params, req| params["country"].in?(["all", "France"]) }, ->
@match "/", to: "countries#index"
@match "/cities", to: "countries#cities"
@match "/countries/"country"/(*other)", to: redirect{ |params, req| params["other"] ? "/countries/all/#{params["other"]}" : "/countries/all" }
@match "/"locale"/*file."format"", to: "files#show", file: /path\/to\/existing\/file/
@scope "/italians", ->
@match "/writers", to: "italians#writers", constraints: :"TestRoutingMapper":"IpRestrictor"
@match "/sculptors", to: "italians#sculptors"
@match "/painters/"painter"", to: "italians#painters", constraints: {painter: /michelangelo/}