Controllers

Actions

By convention there are 7 RESTful actions for a controller:

  1. index: Show all resources (GET)
  2. new: Build a new resource and render a form (GET)
  3. create: Create a new resource (POST)
  4. show: Find and show a resource by id (GET)
  5. edit: Find a resource by id and render it in a form to edit (GET)
  6. update: Update an existing resource (PUT)
  7. 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:

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

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

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/}