Wiki

Clone wiki

asd-2016 / Lecture10

Connecting the ends

In this session, we are going to connect an AngularJS front-end with a Rails-based backend. The application that we will start implementing will be used to introduce several concepts: request/response (or synchronous) communication (this session), push-oriented (or asynchronous) communication (next session), hybrid mobile application development, so on and so forth.

We will start with a skeleton AngularJS application (frontend). The same skeleton application can be built by following the steps we used in the previous week's session. However, this time I will provide you with the skeleton in a zip file that you can download here. Download the file and uncompress it in your working directory. The frontend application will be contained in a folder called taksi_client. Open the project in your editor and analyze the organization.

For the backend application, let us create a new brand rails application.

rails new taksi_server -T --database=postgresql
cd taksi_server

Please don't forget to add RSpec, Cucumber and to complete the corresponding setup steps. Well, we will modify a little the configuration:

  • Do not install training-wheels (by this time, you should be ready to start riding by yourself).
  • Add the gems selenium-webdriver and poltergeist to your Gemfile, both within a test group.

Please note that we will be using Cucumber for steering our acceptance tests, even though the front-end is not implemented in Rails. This is the reason why we are adding selenium-webdriver and poltergeist to our ecosystem. In particular, we need to understand that poltergeist is a driver that enables Capybara to interact with a Javascript application using a headless browser (cf. a invisible browser that understands Javascript) via PhantomJS.

To complete the configuration of Capybara/Cucumber/Poltergeist add the following lines into file features/support/env.rb:

require 'capybara/poltergeist'
Capybara.javascript_driver = :poltergeist

You will need to install PhantomJS driver (the middleware that simulates the user and his/her interaction with the application via the browser). To that end, in a terminal run npm install -g phantomjs.

As a running example, I will use a scenario extracted from the Taxi Information System. The idea is the following: We will assume that the customer enters its coordinates and submits that information to the system (a bit unrealistic, but enough for illustration purposes). In the backend, the application will receive the request and will immediately reply with a message "Request is being processed". This reply completes the synchronous interaction (there is a pair request/response). Since the processing of the request may take long (maybe not in this case, but my goal is to illustrate asynchronous communication), in the backend a new process will be forked that will perform the information processing. When the processing is complete, the backend should send back the results to the front-end. Note that the second interaction is initiated by the backend (push-oriented communication).

As you can see, the scenario above is part of the taxi booking feature in our project. However, in this handout I will simplify the scenario the asynchronous callback will only return the address corresponding to the coordinates provided by the customer. The following Gherkin user story captures the behavior described above. Copy the user story to the file async_callback.feature (this goes within the Rails project).

Feature: Taxi booking system
  As a customer
  So that I can get a taxi ride
  I want to send my coordinates and booking request

  @javascript
  Scenario: Asynchronous callback
    Given I am in the booking page
    When I provide my coordinates
    And I submit this information
    Then I should be notified that my information is being processed
    And I should eventually receive an asynchronous message with my address

Please note that we added the @javascript annotation to our scenario. This annotation instructs cucumber to start a webdriver instance to interact with the javascript frontend application. We are now ready to start with our BDD cycle.

Luckily, cucumber provides us with hints on how to implement the corresponding steps. As usual, we will be implementing one cucumber step at a time. The first step should look like the following.

Given(/^I am in the booking page$/) do
  visit "http://localhost:8080/#/bookings"
end

If you run cucumber you will notice that everything looks fine (although this cannot be true, because we don't event have the front-end application running). Let's first correct the routing information. To that end, add the following to taksi_client file dist/js/main.js:

.when('/bookings', {
  templateUrl: 'views/bookings/new.html',
  controller: 'BookingsCtrl'
})

Copy also the following snippet into file dist/js/controllers/bookings_controllers.js:

var app = angular.module('taksi_client');

app.controller('BookingsCtrl', function (){

});

For the next step, we want to be sure that the application provides a form to enter the coordinates. That is exactly what the following snippet specifies, so you can replace the current spec with the snippet:

When(/^I provide my coordinates$/) do
  fill_in 'lat-input', with: '58.3782485'
  fill_in 'lng-input', with: '26.7146733'
end

Of course, if you run cucumber, the test will fail because there is not yet any application running. Launch the application as usual (i.e. use node index.js in the corresponding folder). Add the following html snippet:

<form>
  <input id="lat-input" type="text" placeholder="Latitude" ng-model="latitude"/>
  <input id="lng-input" type="text" placeholder="Longitude" ng-model="longitude"/>
  <button id="submit-coord" ng-click="submit()">Submit</button>
</form>

We can now complete the third cucumber step:

When(/^I submit this information$/) do
  click_on 'submit-coord'
end

At this point, it is time to switch to the backend application.

Setting up the backend

As expected, we are going to use Ruby on Rails as the platform for developing the backend. In a first iteration, we are going to implement the synchronous interaction part. The expected solution includes a controller that exposes a single action that performs the transation of a pair of coordinates to the corresponding address. Indeed, this corresponds to a RESTful service. I decided to call the underlying resource bookings and, I hope you agree with me, the operation corresponds somehow with the creation of a booking (cf. POST /bookings).

We should have been using TDD to guide the implementation of this RESTful service. However, I will leave this as an exercise for you. So, we will manually add a controller BookingsController with an action create . For this moment, we are not to process anything and we will reply to the request with a HTTP status code 201 (Created) and a small informative text. Since the front-end is written in Javascript (at least under the hood), we are going to use JSON for encoding the messages exchanged between the front-end and backend. Copy the following snippet to the file app/controllers/bookings_controller.rb:

class BookingsController < ApplicationController
  def create
    render :text => {:message => "Booking is being processed"}.to_json, :status => :created
  end
end
You also need to configure the corresponding route. To this end, add post "/bookings", :to => "bookings#create" to your config/routes.rb file.

That’s it! Hum! ... unfortunately, this is not the case. Before we need to complete the steps described in the next section.

Setting up CORS

When it comes to connect a Javascript front-end application with a Rails application, we will be required to properly configure some security-related stuff. In this context, the industry has proposed a standard, referred to as CORS (Cross- origin resource sharing), and we have to ensure that Rails includes some information in the HTTP headers as specified in CORS. Fortunately, the setup is not that complex as it only requires two steps.

First, we need to add one gem to our Gemfile. Use the following snippet:

gem 'rack-cors', :require => 'rack/cors'

Then, we will need to modify the application configuration. This time we will modify the file config/application.rb. Copy the following snippet just after the line config.active_record.raise_in_transactional_callbacks = true:

    config.middleware.insert_before 0, "Rack::Cors" do
      allow do
        origins '*'
        resource '*', :headers => :any, :methods => :any
      end
    end
Please note that the configuration opens the access on all resources, using any HTTP method, from any computer connected to the Internet. You can modify the configuration to be a more restrictive (e.g. listing the subset of methods that you are opening to the world, restricting the set of resources that can be accessed and even listing a set of IP addresses or DNS names from which you will allow people to connect). You are referred to the documentation find out how.

If you want to try the backend, don't forget to start the server!

Adjusting CSRF settings

Before moving forward, we need to adjust the default configuration for CSRF. CSRF stands for "Cross-site request forgery" (a.k.a. one-click attack or session riding) and refers to a type of malicious exploit of a website where unauthorized commands are transmitted from a user that the website trusts. By default, Rails configures the application to check the basic information (e.g. CSRF token) because it is intended to be used with HTML forms. In the architecture for our application, we have to loosen a bit the settings CSRF because the Rails backend will be accessed via a REST-API and that from a separate application (i.e. the AngularJS-based front-end).

Changing the CSRF settings is straightforward. Just change the file app/controllers/application_controller.rb to the following:

protect_from_forgery with: :null_session

Unit testing the AngularJS Controller

We are now ready to start implementing the AngularJS controller. Recall that an AngularJS is the means by which we can interact with views and that we need an AngularJS service to interact with the rest of the world. In the previous handouts, we used an in-memory service: all the movies were stored in one array. In this handout, however, I will illustrate the procedure to connect to our Rails-based backend.

npm install karma-jasmine karma-phantomjs-launcher --save-dev
npm install -g karma-cli

We have to add one of angularjs' testing libraries to our development environment. To that end, run the command bower install angular-mocks --save-dev.

To set up Karma, run the command karma init and select all the defaults. The result of the previous step will be a configuration file named karma.conf.js. Replace in such a file there the following snippet where it corresponds:

    files: [
      'components/angular/angular.js',
      'components/angular-mocks/angular-mocks.js',
      'components/angular-route/angular-route.js',
      'components/angular-resource/angular-resource.js',
      'js/*.js',
      'js/controllers/*.js',
      'js/services/*.js',
      '../test/spec/**/*.js'
    ],

    plugins: [
      'karma-jasmine',
      'karma-phantomjs-launcher'
    ],

Update also the following settings: basePath must be set to dist/, autoWatch must be set to true and browsers: ['PhantomJS'],.

In one separate terminal, launch the karma runner with the following command: karma start.

As a starting point, copy the following snippet to the file test/spec/bookings_controllers_spec.js.

'use strict';

describe('BookingsCtrl', function () {

  beforeEach(module('taksi_client'));

  var BookingsCtrl,
    scope;

  beforeEach(inject(function ($controller, $rootScope) {
    scope = $rootScope.$new();
    BookingsCtrl = $controller('BookingsCtrl', {
      $scope: scope
    });
  }));

  it('should always fail', function () {
    expect(true).toBe(false);
  });
});

For the time being, our controller BookingsCtrl is empty. Don't pay attention to this fact, because for the time being Jasmine (by means of Karma) is reporting a test failure: Expected true to be false. But this is normal because we added a test to checkout whether our test platform was properly configure. Remove the failing test and copy the following snippet:

it('should be connected to its view', function () {
  expect(scope.latitude).toBeDefined();
  expect(scope.longitude).toBeDefined();
  expect(scope.submit).toBeDefined();
});

The above test checks if the controller defines the variables $scope.latitude, $scope.longitude and the function $scope.submit. Recall that those variables/function are already referred from inside the view views/bookings/new.html. Not surprisingly, the test above fails, because our controller is empty at this moment. The following snippet fixes the problems encountered by the test.

app.controller('BookingsCtrl', function ($scope) {
  $scope.latitude = 0;
  $scope.longitude = 0;
  $scope.submit = function() {};
});

Mocking the backend

Although our backend is up and running, it is common practice to use test mocks instead of the actual backend during the TDD phase.

The following snippet illustrates how we can create a mock service for an HTTP or REST-based backend. Note that $httpBackend is a service provided by AngularJS and allows one to mock backend services. To avoid name clashes, AngularJS would provide a handle to the mock service as _$httpBackend_. Copy the following Javascript snippet to the end of your current test.

var $httpBackend = {};

beforeEach(inject(function (_$httpBackend_) {
  $httpBackend = _$httpBackend_;
}));

afterEach(function () {
  $httpBackend.verifyNoOutstandingExpectation();
  $httpBackend.verifyNoOutstandingRequest();
});

it('should submit a request to the backend service', function () {
  $httpBackend
    .expectPOST('http://localhost:3000/bookings')
    .respond(201);
  scope.submit();
  $httpBackend.flush();
});

The $httpBackend mock service provides two families of functions: expectVERB where VERB corresponds to any HTTP verb and that specifies a test expectation and the typical response (e.g., status code), and whenVERB which specifies only the return values to be used given some input parameters. $httpBackend.flush() should be called at the point we want to simulate the completion of one interaction with the backend.

With this in mind, we can see that the test above requires that an HTTP POST on http://localhost:3000/bookings is preformed under the hood whenever the function $scope.submit() from BookingsCtrl is called.

Please note that it is not in the controller that we are going to implement the interactions with the backend. To this end, we have to introduce a special kind of AngularJS service: a resource. Roughly, and AngularJS resource is a convenient, high-level interface that implements interactions with RESTful web services. Let’s go straight to the poing: copy the following snippet to the file js/services/bookings_service.js.

'use strict';

var app = angular.module('taksi_client');

app.service('BookingsService', function ($resource) {
  return $resource('http://localhost:3000/bookings', {});
});

Don't forget to add bookings_service.js to index.html.

That is ALL!!!! After adding the lines above, you will have access all the typical REST operations on the resource /bookings, namely get (a single resource instance), save (a new resource instance), query (a list of resource instances), and remove/delete (a single resource instance).

So, now we can implement the interaction with the backend by changing the controller as follows:

app.controller('BookingsCtrl', function ($scope, BookingsService) {
  $scope.latitude = 0;
  $scope.longitude = 0;
  $scope.submit = function() {
    BookingsService.save();
  };
});

Note that the above is not the full implementation. In this case I want to follow the TDD approach: write the simplest code that allows one to pass the current test.

Let's write a more specific test:

it('should submit a request to the backend service', function () {
  $httpBackend
    .expectPOST('http://localhost:3000/bookings')
    .respond(201);
  scope.latitude = 58.37824850000001;
  scope.longitude = 26.7146733;
  scope.submit();
  $httpBackend.flush();
});

It should be easy to see that we are simulating the case where the customer has entered a pair of coordinates. In that case, we would expect that those coordinates are sent to the backend service for processing.

In order to pass this test, we have to modify a little the implementation of the controller, and more specifically, the $scope.submit function. Replace the current implementation with the following snippet:

  $scope.submit = function() {
    BookingsService.save({latitude: $scope.latitude, longitude: $scope.longitude});
  };

To wrap up our implementation, we should ensure that we copy the message sent by the backend back to the front-end. Add the following test case:

it('should submit a request to the backend service', function () {
  $httpBackend
    .expectPOST('http://localhost:3000/bookings')
    .respond(201, {message: 'Booking is being processed'});
  scope.latitude = 58.37824850000001;
  scope.longitude = 26.7146733;
  scope.submit();
  $httpBackend.flush();
  expect(scope.syncNotification).toEqual('Booking is being processed');
});

Note that for this test, I am using $httpBackend.whenPOST rather than $httpBackend.expectPOST. This is because in this case I am just specifying the behavior of the mock and not an expectation directly on the $httpBackend. Actually, for this test both variants (whenPOST and expectPOST) would seem the same, provided that you keep the previous test cases. However, in this case we want to specify an expectation not on the communication with the backend. Instead, we want to check that at the end of the interaction with the backend, the message sent by the server is copied to the variable $scope.syncNotification. The code to complete the implementation is as follows:

'use strict'

var app = angular.module('taksi_client');

app.controller('BookingsCtrl', function ($scope, BookingsService) {
  $scope.latitude = 0;
  $scope.longitude = 0;
  $scope.syncNotification = '';
  $scope.submit = function() {
    BookingsService.save({latitude: $scope.latitude, longitude: $scope.longitude}, function (response) {
      $scope.syncNotification = response.message;
    });
  };
});

The above completes the implementation (TDD phase) of synchronous interaction outlined in the user story. We can therefore move back to our acceptance test (BDD phase). This is left to you ;) To that end, add the following html template to the end of views/bookings/new.html.

<div id="sync-notification">
  {{syncNotification}}
</div>

Updated