This article expects that you already have done the 101 AngularJS journey. If you're new to AngularJS then you'd best checkout the AngularJS Tutorial or Code Academy - AngularJS Patterns Tutorial(NB: this is for Angular 1.0.6) before continuing with this article. What we're really focusing on here are the decisions I've made and nuances discovered during the transition of a Rails app with the least resistance using the following stack:
- Rails 4.0.x
- Haml
- Sass
- Coffee
- Devise with CanCan
- jQuery
- Twitter Bootstrap
- PostgeSQL
- Unicorn
As a part of the transition I wanted to reuse much of the work I had already done. So reuse of items such as markup and client side code in CoffeeScript were important. I didn't want to go down the track of having to rewrite large chunks of code. Ideally it would be good to transition all of those items releasing my new Angular application form the Rails asset pipeline but Rome wasn't built in a day: small steps.
You can follow the bootstrapping steps however as with most things in the ruby world: there's a gem for that :) With the path of least resistance in mind I chose to use the angularjs-rails gem. The maintainers of the gem have implemented an auto update mechanism that updates the gem as new versions of AngularJS become available with gem version bumps match the AngularJS version where practical. The world of JS frameworks is a fast moving one so be sure to include the version when adding the gem to your Gemfile
otherwise you'll be in for a world of hurt.
gem 'angular-rails', '1.2.21'
The beauty of AngularJS is you can choose to apply it where you need it. So if you've got a large application that you what to incrementally move over to AngularJS then it's totally possible. Using the ngApp directive you can choose place in rendered HTML where the Angular application will live. In the sample below we have a single page application with the ngApp directive being applied to the html
element (see %html{ 'ng-app' => 'MoreBeerOnline' }
). This could as easily be applied to a div
on a particular view giving you the option to angularise one page of your Rails app.
app/views/layouts/application.html.haml
!!!
%html{ 'ng-app' => 'AngularMusic' }
%head
%meta{:name => "viewport", :content => "width=device-width, initial-scale=1.0"}
%title= content_for?(:title) ? yield(:title) : 'Angular Music'
%meta{:name => "description", :content => "#{content_for?(:description) ? yield(:description) : 'Angular Music'}"}
= favicon_link_tag 'favicon.ico'
= stylesheet_link_tag "application", media: "all", "data-turbolinks-track" => true
= javascript_include_tag "application", "data-turbolinks-track" => true
= csrf_meta_tags
%body
#container
%ng-view
For my project making the transition to AngularJS didn't happen overnight and it's still ongoing. To maintain a functional application during the transition I took an incremental approach. I identified discrete sections of my application (e.g. User Management, Fiscal Statements, etc.) then converted each of them to AngularJS.
Redoing all of the markup back into plain old HTML was looking to be a fair bit work. So I did a bit of searching and I found I could use the tilt gem for Haml in my Angular views. The meant making sure my view files were in the assets directory so they were included in the asset pipeline. Tilt isn't just for Haml, it supports many other template engines like Slim, Haml, Erb, CoffeeScript, etc. To enable it all you need to do is to create the following initialiser:
config/initializers/angular_assets.rb
Rails.application.assets.register_engine('.haml', Tilt::HamlTemplate)
If you have a mix of template engines then you can add more. For my project all of the markup was in HAML so all I needed was the .haml
to be registered. Once in place my views compiled down to HTML as part of the asset pipeline.
This isn't a free ride you will still need to covert all of the reader
s partials to ng-include
s and change references to Rails controller attributes to Angular controller attributes.
With Angular you have a free hand to organise your application files where you want them. This can be a blessing and a curse at the same time. While you can craft a beatify file structure it will be particular to your style application and you will have to educate new developers as they come to work on your codebase. For my application I chose to replicate something fairly similar to Rails but living in the app/javascript
directory. So my resulting structure looks like the following:
app/
assets/
javascripts/
models/
songs.js.coffee
...
controllers/
songs_controller.js.coffee
authentication_controller.js.coffee
...
views/
login.html.haml
password_reset.html.haml
...
common/
application.js.coffee
router.js.coffee
You'll all be familiar with models
,controllers
, views
and application.js.coffee
. There are some extras I've added:
-
common/
: This directory is where I'm keeping extentions to AngularJS that are used throughout the application. -
router.js.coffee
: Think of this as the AngularJS version ofconfig/routes.rb
. Here's what it might look like@AngularMusic.config [ "$routeProvider" "$locationProvider" ($routeProvider, $locationProvider) -> $locationProvider.html5Mode(true) $routeProvider .when("/login", templateUrl: "/assets/views/login.html" controller: 'AuthenticationController' ) .when("/password_reset", templateUrl: "/assets/views/password_reset.html" controller: 'PasswordController' ) .when("/change_password", templateUrl: "/assets/views/change_password.html" controller: 'PasswordController' ) .when('/home', templateUrl: "/assets/views/home.html" ) .when('/balances', templateUrl: "/assets/views/songs.html" controller: "SongsController" ) .otherwise(redirectTo: "/login") ]
As Rails developers we're very used to seeing the inclusion of javascripts in the assets:
app/assets/javascripts/application.js.coffee
#= require jquery
#= require jquery_ujs
#= require turbolinks
#= require bootstrap-sprockets
#= require_tree .
Unfortunately we can just rely on the require_tree .
to include all of our javascript files for angular. The problem with that method is that it will include everything in alphabetical order not the dependency order that Angular requires. So it becomes a manual task where I had to handcraft the order as follows:
app/assets/javascripts/application.js.coffee
#= require jquery
#= require jquery_ujs
#= require turbolinks
#= require bootstrap-sprockets
#= require angular
#= require angular-mocks
#= require angular-resource
#= require angular-route
#= require_self
#= require_tree ./common
#= require_tree ./models
#= require_tree ./controllers
#= require router
@AngularMusicControllers = angular.module("AngularMusic.Controllers", [])
@CommonForms = angular.module("common.forms", [])
@AngularMusic = angular.module("AngularMusic", [
"ngRoute"
"ngResource"
"ngDialog"
"common.authentication"
"common.error_handling"
"common.forms"
"AngularMusic.Controllers"
])
You'll notice directly after all of the require
s there's the creation of the AngularJS application via using Angular's modules. This may seem like it's in the wrong place but that's sorted out by the require_sef
.
With the separation of the the UI control from Rails it's now possible to have many versions your application running while you do updates. To allow for this the API provided by Rails needs to be versioned. Following is a sample for routing:
config/routes.rb
AngularMusic::Application.routes.draw do
namespace :api do
namespace :v1 do
resources :songs
end
end
root to: 'home#index'
devise_for :users, controllers: {sessions: 'sessions', passwords: 'passwords'}
get 'home' => 'home#index'
get '*path', to: 'home#index'
end
Note the routing outside of the API all heads to one place: home#index
. This geared for a one page application. You do variations on this depending on how you want to transition your application leaving old routes in place and not including get '*path', to: 'home#index'
which is a catch all for any unknown routes.
You can use $http.get, $http.post like the following:
AngularMusic.factory "Song", ($http) ->
index: ->
$http.get "/api/v1/songs"
song: (id) ->
$http.get "/api/v1/songs/:id"
#... etc
However to make the flow easy for development with a RESTful API like that Rails provides it's best to use ngResource.
@AngularMusic = angular.module("AngularMusic", ["ngResource"])
AngularMusic.factory 'Song', ($resource) ->
Song = $resource "/api/v1/songs/:id",
{ id: "@id" }
return Song
It implements RESTful methods making for less code, provides a clear standard and fits in well with Rails:
Song.get # HTTP GET
Song.query # HTTP GET
Song.save # HTTP PUT
Song.delete # HTTP DELETE
Song.remove # HTTP REMOVE
Angular's focus on testing goes hand and hand with the very active Ruby testing community. There are a number of testing frameworks to choose from jasmine, mocha and QUint to name a few. The one that stood out for me is Jasmine 2.0 which closely resembles RSpec. It's relatively easy to setup however I had use a gem to run the tests due to the need for asset compilation during testing. Finding a gem that supports Jasmine 2.0 rather than the older Jasmine 1.3 proved difficult. Both jasminerice and teaspoon didn't support it so I decided to upgrade one them. I chose jasminerice as it was by far the simpler of the two. Since the pull request hasn't been accepted yet you can check out my Jasmine 2.0 support at https://github.com/michael-harrison/jasminerice which has full details on installation.
Full stack testing can be done with your new Angular app however you will get intermittent failures due to Angular calls to your Rails API not being completed. There is a gem to resolve this Capybara::Angular but I was unable to get it working so I had a look at the code worked then came up with the following solution which is more explicit. I hope to do pull request in the future to resolve the gems issues.
spec/support/angular_waiter.rb
This is the core of the waiter doing the waiting on Angular to complete its requests
module Angular
class Waiter
attr_accessor :page
def initialize(page)
@page = page
end
def wait_until_ready
return unless angular_app?
setup_ready
start = Time.now
until ready?
timeout! if timeout?(start)
setup_ready if page_reloaded_on_wait?
sleep(0.01)
end
end
private
def timeout?(start)
Time.now - start > Capybara.default_wait_time
end
def timeout!
raise TimeoutError.new("timeout while waiting for angular")
end
def ready?
page.evaluate_script("window.angularReady")
end
def angular_app?
begin
js = "(typeof angular !== 'undefined') && "
js += "angular.element(document.querySelector('[ng-app]')).length > 0"
page.evaluate_script js
rescue Capybara::NotSupportedByDriverError
false
end
end
def setup_ready
page.execute_script <<-JS
window.angularReady = false;
var app = angular.element(document.querySelector('[ng-app]'));
var injector = app.injector();
injector.invoke(function($browser) {
$browser.notifyWhenNoOutstandingRequests(function() {
window.angularReady = true;
});
});
JS
end
def page_reloaded_on_wait?
page.evaluate_script("window.angularReady === undefined")
end
end
end
spec/support/wait_for_angular.rb
This provides a method for explicitly waiting on Angular
module WaitForAngular
def wait_for_angular
Angular::Waiter.new(Capybara.current_session).wait_until_ready
end
end
spec/support/wait_for_ajax.rb
This provides a method for explicitly waiting on jQuery which is used by Angular under covers.
module WaitForAjax
def wait_for_ajax
Timeout.timeout(Capybara.default_wait_time) do
active = page.evaluate_script('jQuery.active')
until active == 0
active = page.evaluate_script('jQuery.active')
end
end
end
end
To make the two methods available simply add the two following lines
RSpec.configure do |config|
config.include WaitForAjax, type: :feature
config.include WaitForAngular, type: :feature
end
Then in your spec use it like the following example:
require 'spec_helper'
describe 'login', :type => :feature, :js => true do
context 'for customers' do
let(:customer) { FactoryGirl.create :customer }
context 'with user name and password' do
it 'will allow the visitor to login' do
visit root_path
fill_in 'user name', with: customer.username
fill_in 'password', with: 'passwordpassword'
click_button 'enter'
wait_for_angular
expect(page).to have_content 'You have signed in successfully'
expect(last_activity.key).to eq 'user.successful_login'
end
end
end
end
When the site visitor doesn't have a session I wanted to redirected them to a page that's appropriate. In the case of my application redirection to the 'login' page was required. The following module intercepts the http request and check to see if it's a 401
(Unauthorised) then redirects the visitor to the login page.
app/assets/javascripts/common/authentication.js
/**
* Authentication module, redirects to homepage if not logged in
*/
angular.module('common.authentication', [])
.config(function($httpProvider){
// Intercepts every http request. If the response is success, pass it through. If the response is an
// error, and that error is 401 (unauthorised) then the user isn't logged in, redirect to the login page
var interceptor = function($q, $location, $rootScope) {
return {
'responseError': function(rejection) {
if (rejection.status == 401) {
$rootScope.$broadcast('event:unauthorized');
$location.path('/login');
return rejection;
}
return $q.reject(rejection);
}
};
};
$httpProvider.interceptors.push(interceptor);
});
Devise will work with Angular however with protect_from_forgery
enabled on your Rails application, authentication will fail due to the lack of protection tokens not being passed back to Rails on calls. In fact this will case problems with all API calls. The following additions to the application_controller.rb
and sessions_controller.rb
allow for tokens to be transmitted to the browser and received on requests with the token being reset on each request.
app/controllers/application_controller.rb
class ApplicationController < ActionController::Base
protect_from_forgery
after_filter :set_csrf_cookie_for_ng
def set_csrf_cookie_for_ng
cookies['XSRF-TOKEN'] = form_authenticity_token if protect_against_forgery?
end
protected
def verified_request?
super || form_authenticity_token == request.headers['X-XSRF-TOKEN']
end
end
app/controllers/sessions_controller.rb
class SessionsController < Devise::SessionsController
after_filter :set_csrf_headers, only: [:create, :destroy]
respond_to :json
protected
def set_csrf_headers
cookies['XSRF-TOKEN'] = form_authenticity_token if protect_against_forgery?
end
end
While the code below is not necessary for getting Devise working with Angular I like to add this to ensure unauthenticated CSRF attempts cause exceptions.
app/controllers/application_controller.rb
protected
# Unauthenticated CSRF protection
def handle_unverified_request
super
raise(ActionController::InvalidAuthenticityToken)
end
With AngularJS we're calling back to an API served up by application who's code is hidden from the unscrupulous. The code for the Angular application however is available. Even though we may obfuscate the javascript code through gems like uglifier
it's still relatively easy to reverse engineer the code to identify what the full API is for your Rails. So it's important to take measures to minimize attacks using this information. I implemented Rack::Attack!!! which give me a high level of configurability to craft something specific to my needs. I also used secure_headers to catch some of the more common security issues. Another gem to check, which is very simple to implement is Rack::Protection and may suffice for what you need; in my case it didn't.
-
Pure Javascript Testing Frameworks
-
Gems for Testing