REST и пользовательские сервисы в AngularJS

На этом шаге мы изменим способ получения данных.

Мы определим пользовательский сервис, который представляет собой RESTful-клиент. Используя этот сервис мы можем намного проще получать данные с сервера, т.е. не используя низкоуровневый $http API, HTTP-методы и URL-ы.

Переключим состояние проекта на данный шаг:

git checkout -f step-11


Зависимости

RESTful-функциональность обеспечивается модулем ngResource, который распространяется отдельно от основного Angular-фреймворка. Мы используем Bower для установки клиентских зависимостей. Добавим в конфигурационный файл bower.json новую зависимость:
{
  "name": "angular-seed",
  "description": "A starter project for AngularJS",
  "version": "0.0.0",
  "homepage": "https://github.com/angular/angular-seed",
  "license": "MIT",
  "private": true,
  "dependencies": {
    "angular": "1.4.x",
    "angular-mocks": "1.4.x",
    "jquery": "~2.1.1",
    "bootstrap": "~3.1.1",
    "angular-route": "1.4.x",
    "angular-resource": "1.4.x"
  }
}

Новая зависимость "angular-resource": "1.4.x" предписывает bower-у установить версию компонента angular-resource, которая совместима с версией 1.4.x. Для того чтобы скачать и установить зависимость надо выполнить команду:
npm install

Если  с последнего раза, когда вы запускали npm install вышла новая версия Angular, то вы можете получить конфликт при выполнении команды bower install из-за версий angular.js которые должны быть установлены. Для решения этой проблемы надо перед командой npm install удалить папку app/bower_components.

Если bower установлен глобально, тогда вы можете установить зависимость командой bower install. Но в этом проекте эту команду за нас выполняет npm install.

Шаблон

Наш пользовательский сервис ресурсов будет определен в файле app/js/services.js. Поэтому его нужно подключить в наш layout template. Дополнительно нам нужно загружать файл angular-resource.js, который содержит модуль ngResource.
...
  <script src="bower_components/angular-resource/angular-resource.js"></script>
  <script src="js/services.js"></script>
...

Сервис

Мы создадим собственный сервис предоставляющий данные о телефонах с сервера:
app/js/services.js.
var phonecatServices = angular.module('phonecatServices', ['ngResource']);

phonecatServices.factory('Phone', ['$resource',
  function($resource){
    return $resource('phones/:phoneId.json', {}, {
      query: {method:'GET', params:{phoneId:'phones'}, isArray:true}
    });
  }]);

Мы использовали фабричную функцию из API модулей, чтобы зарегистрировать пользовательский сервис. В эту функцию мы передали имя сервиса 'Phone' и функцию. Фабричная функция похожа на конструктор контроллера тем что и там и там можно объявить зависимости, которые будут внедрены через аргументы функции. Phone-сервис зависит от сервиса $resource.

Сервис $resource позволяет легко создать RESTful-клиент. Этот клиент может быть использован в нашем приложении вместо низкоуровневого сервиса $http.
...
angular.module('phonecatApp', ['ngRoute', 'phonecatControllers','phonecatFilters', 'phonecatServices']).
...

Мы должны добавить модуль 'phonecatServices' как зависимость для приложения 'phonecatApp'.

Контроллер

Мы упростили наши дочерние контроллеры (PhoneListCtrl и PhoneDetailCtrl) путем того что заменили низкоуровневый $http-сервис новым сервисом Phone. Сервис $resource легче использовать чем $http для взаимодействия с источником данных в форме RESTful ресурса.
var phonecatControllers = angular.module('phonecatControllers', []);

...

phonecatControllers.controller('PhoneListCtrl', ['$scope', 'Phone', function($scope, Phone) {
  $scope.phones = Phone.query();
  $scope.orderProp = 'age';
}]);

phonecatControllers.controller('PhoneDetailCtrl', ['$scope', '$routeParams', 'Phone', function($scope, $routeParams, Phone) {
  $scope.phone = Phone.get({phoneId: $routeParams.phoneId}, function(phone) {
    $scope.mainImageUrl = phone.images[0];
  });

  $scope.setImage = function(imageUrl) {
    $scope.mainImageUrl = imageUrl;
  }
}]);

В PhoneListCtrl мы заменили:
$http.get('phones/phones.json').success(function(data) {
  $scope.phones = data;
});

на:
$scope.phones = Phone.query();

Этот простой запрос запрашивает все телефоны.

Когда мы вызываем методы сервиса Phone мы не передаем никаких callback-ов. Это выглядит так как будто результат запроса возвращается синхронно. На самом деле синхронно возвращается объект "future", который будет заполнен данными, когда придет XHR-ответ. Благодаря data-binding в Angular мы можем использовать этот "future" и привязать его к шаблону. И тогда, когда данные придут они будут автоматически обновлены в представлении.

В случаях когда этого недостаточно можно использовать callback для обработки ответа от сервера. Контроллер PhoneDetailCtrl иллюстрирует эту возможность устанавливая значение свойству mainImageUrl в callback-функции.

Тестирование

Т.к. теперь мы используем модуль ngResource, то надо обновить конфигурацию Karma с angular-resource.
files : [
  'app/bower_components/angular/angular.js',
  'app/bower_components/angular-route/angular-route.js',
  'app/bower_components/angular-resource/angular-resource.js',
  'app/bower_components/angular-mocks/angular-mocks.js',
  'app/js/**/*.js',
  'test/unit/**/*.js'
],

Нам надо изменить наши модульные тесты так чтобы проверить, что наш новый сервис выпускает HTTP-запросы и обрабатывает их так как ожидается. Тесты также должны проверить что контроллеры правильно взаимодействуют с новым сервисом.

Сервис $resource дополняет объект ответа методами для обновления и удаления ресурса. Если для сравнения результатов мы будем использовать toEqual, то тесты провалятся потому что тестовые значения не будут точно соответствовать фактическим. Чтобы решить эту проблему мы будем использовать Jasmine matcher - toEqualData. Когда toEqualData сравнивает два объекта сравниваются только свойства, а методы пропускаются.
describe('PhoneCat controllers', function() {

  beforeEach(function(){
    this.addMatchers({
      toEqualData: function(expected) {
        return angular.equals(this.actual, expected);
      }
    });
  });

  beforeEach(module('phonecatApp'));
  beforeEach(module('phonecatServices'));


  describe('PhoneListCtrl', function(){
    var scope, ctrl, $httpBackend;

    beforeEach(inject(function(_$httpBackend_, $rootScope, $controller) {
      $httpBackend = _$httpBackend_;
      $httpBackend.expectGET('phones/phones.json').
          respond([{name: 'Nexus S'}, {name: 'Motorola DROID'}]);

      scope = $rootScope.$new();
      ctrl = $controller('PhoneListCtrl', {$scope: scope});
    }));


    it('should create "phones" model with 2 phones fetched from xhr', function() {
      expect(scope.phones).toEqualData([]);
      $httpBackend.flush();

      expect(scope.phones).toEqualData(
          [{name: 'Nexus S'}, {name: 'Motorola DROID'}]);
    });


    it('should set the default value of orderProp model', function() {
      expect(scope.orderProp).toBe('age');
    });
  });


  describe('PhoneDetailCtrl', function(){
    var scope, $httpBackend, ctrl,
        xyzPhoneData = function() {
          return {
            name: 'phone xyz',
            images: ['image/url1.png', 'image/url2.png']
          }
        };


    beforeEach(inject(function(_$httpBackend_, $rootScope, $routeParams, $controller) {
      $httpBackend = _$httpBackend_;
      $httpBackend.expectGET('phones/xyz.json').respond(xyzPhoneData());

      $routeParams.phoneId = 'xyz';
      scope = $rootScope.$new();
      ctrl = $controller('PhoneDetailCtrl', {$scope: scope});
    }));


    it('should fetch phone detail', function() {
      expect(scope.phone).toEqualData({});
      $httpBackend.flush();

      expect(scope.phone).toEqualData(xyzPhoneData());
    });
  });
});


Вывод Karma:
Chrome 22.0: Executed 5 of 5 SUCCESS (0.038 secs / 0.01 secs)