SPA với AngularJS và Yeoman: part 2 - Zite Web Client

Trong phần trước, mình đã trình bày cách sử dụng Yeoman để tạo và chạy thử một project AngularJS đơn giản. Trong bài này, mình sẽ ứng dụng nó để làm một web client cho Zite.

I. Zite là gì?

Zite là ứng dụng giúp người dùng biết được những sự kiện, bài viết về chủ đề mình quan tâm.

Trên Internet hiện nay có vô vàn nội dung, hàng ngày có một lượng lớn thông tin lại được đưa lên. Do đó, càng ngày càng khó có thể tìm được những nội dung mà mình mong muốn. Từ đó, Zite đã hình thành.

Zite thu thập hàng triệu bản tin, bài viết hàng ngày, sắp xếp chúng vào các chủ đề theo nội dung, và đánh giá lượng chia sẻ của chúng. Từ các thông tin này, Zite chọn ra các bài viết phù hợp với sở thích cá nhân của từng người.

Zite có app cho iOS, Android cũng như Windows Phone nhưng rất tiếc là chưa thể đọc các bài tin tức do Zite gợi ý trực tiếp trên trình duyệt.

II. Zite API

Zite không công bố API cho developer sử dụng, tuy nhiên sau khi tìm kiếm thử trên Google, mình tìm thấy repo Zite-API. Tác giả repo có vẻ đã "reveserve engineering" app Zite trên iOS và viết lại bằng ngôn ngữ PHP.

Trong ứng dụng đơn giản này, mình chỉ quan tâm tới một số API sau:

1. Login

- URL = https://api.zite.com/api/v2/account/login/
- Method: POST
- params = {
    appver: '2.0',
    deviceType: 'ipad',
    email: '{{userEmail}}',
    password: '{{userPassword}}'
  }
- successResult = {
    userId = '{{user ID}}',
    accessToken = '{{access Token}}'
  }

2. Lấy các bài viết theo chủ đề

- URL = https://api.zite.com/api.zite.com.com/v2/news/
- Method = GET
- params = {
    appver: '2.0',
    deviceType: 'ipad',
    userId = '{{user ID}}',
    accessToken = '{{access Token}}',
    section = '{{section name}}'
  }
- result: tập các bài viết cùng thông tin kèm theo

3. Đánh dấu một bài viết là đã đọc

- URL = https://api.zite.com/api/v2/log/event/
- Method: POST
- params = {
    appver: '2.0',
    deviceType: 'ipad',
    userId = '{{user ID}}',
    accessToken = '{{access Token}}',
    section = '{{section name}}',
    url = '{{article url}}',
    event: 'ArticleView',
    orientation: 'portrait',
    source: 'section',
    webmode: false
  }

III. Bắt đầu viết web client cho Zite

Đầu tiên là khởi tạo ứng dụng:

yo angular zite --coffee

Tùy chọn ---coffee đằng sau là để Yeoman sinh cho ta project AngularJS viết bằng CoffeeScript.

1. Cho phép ứng dụng CORS tới Zite server

Mặc định, khi gửi request loại POST tới domain khác, trình duyệt sẽ gửi thêm một request với method là OPTION. Khi nhận được request này, Zite server sẽ báo lỗi, khiến request POST của ta không được gửi đi.

Để khắc phục vấn đề này, ta config $httpProvider xóa hết header khi gửi request, như vậy xóa luôn cả origin header.

Sửa trong file app/script/app.coffee

...

angular
  .module 'ziteApp', [
    'ngAnimate',

...

    'ngTouch'
  ]
  .config ($httpProvider) ->
    $httpProvider.defaults.headers.common = {}
    $httpProvider.defaults.headers.post = {}
    $httpProvider.defaults.headers.put = {}
    $httpProvider.defaults.headers.patch = {}
  .config ($routeProvider) ->

...

2. Viết service ZiteServer để gọi API

Ở đây mình sinh ZiteServer là một factory:

yo angular:factory ZiteServer

Yeoman sẽ sinh ra file app/script/service/ziteserver.coffee cho chúng ta.

Để gọi API tới Zite server, mình sẽ dùng $http của AngularJS.

Để có thể gửi request POST với dữ liệu được encode dạng form data thì cần phải tự biến đổi dữ liệu gửi đi sử dụng transformRequest

formPost = (url, data) ->
  $http {
    method: 'POST'
    url: url
    headers: {'Content-Type': 'application/x-www-form-urlencoded'}
    transformRequest: (obj) ->
      str = []
      for k, v of obj
        str.push(encodeURIComponent(k) + "=" + encodeURIComponent(v))
      str.join("&")
    data: data
  }

Sử dụng method trên, 3 API kể bên trên có thể được viết thành service như sau:

'use strict'

angular.module 'ziteApp'
  .factory 'ZiteServer', ($http) ->
    baseUrl = 'https://api.zite.com/api/v2'
    loginPath = '/account/login/'
    articlesPath = '/news/'
    logEventPath = '/log/event/'

    defaultParams =
      appver: '2.0'
      deviceType: 'ipad'

    formPost = (url, data) ->
      $http {
        method: 'POST'
        url: url
        headers: {'Content-Type': 'application/x-www-form-urlencoded'}
        transformRequest: (obj) ->
          str = []
          for k, v of obj
            str.push(encodeURIComponent(k) + "=" + encodeURIComponent(v))
          str.join("&")
        data: data
      }

    credential: {}

    login: (email, password) ->
      params =
        email: email
        password: password
      angular.merge(params, defaultParams)
      formPost(baseUrl + loginPath, params)

    getArticles: (section = "topstories") ->
      $http.get(baseUrl + articlesPath, { params: angular.merge({ section: section }, defaultParams, @credential) })

    markAsRead: (section, url) ->
      params =
        section: section
        url: url
        event: 'ArticleView'
        orientation: 'portrait'
        source: 'section'
        webmode: false
      angular.merge(params, defaultParams, @credential)
      formPost(baseUrl + logEventPath, params)

3. Một số thành phần của ứng dụng

Trong phần này, mình sẽ trình bày một số thành phần của Zite Web client, tất cả source code có thể được xem ở link Github cuối bài.

a. Auth

Dùng Yeoman để tạo Auth service:

yo angular:service Auth
'use strict'

angular.module 'ziteApp'
  .service 'Auth', ($location, $window, ZiteServer) ->
    @credential = angular.fromJson($window.sessionStorage.getItem('credential')) || {}
    auth = @

    @is_loggedin = ->
      logged = @credential.hasOwnProperty("userId")
      $location.path('/login') if !logged
      logged

    @log_in = (email, password, target) ->
      ZiteServer.login(email, password)
        .success (data) ->
          auth.credential = data
          $window.sessionStorage.setItem('credential', angular.toJson(auth.credential))
          $location.path(target)
        .error (data, status) ->
          auth.credential = data
    return

Method log_in dùng emailpassword được cung cấp gọi tới Zite API để lấy thông tin credential. Thông tin credential sẽ được lưu tại sesstionStorage thay vì cookie. Sau khi login, method sẽ redirect sang target.

Method is_loggedin sẽ redirect ngưởi dùng về trang login nếu chưa login.

b. News

Dùng Yeoman để tạo News service:

yo angular:service News
'use strict'

angular.module 'ziteApp'
  .service 'News', (ZiteServer, Auth, $q)->
    @articles = {}

    @load = (section = "topstories") ->
      deferred = $q.defer()

      ZiteServer.credential = Auth.credential
      that = @
      ZiteServer.getArticles(section)
        .success (data) ->
          that.articles[section] = data.documents
          deferred.resolve()
        .error ->
          deferred.reject()

      deferred.promise

    @fetch = (section = "topstories") ->
      deferred = $q.defer()
      if @articles.hasOwnProperty(section) && @articles[section] != null
        deferred.resolve(@articles[section])
      else
        that = @
        @load(section)
          .then -> deferred.resolve(that.articles[section])
          .catch -> deferred.reject()

      deferred.promise
    return

Service này dùng để fetch dữ liệu thông tin các bài viết theo chủ đề từ Zite và sau đó cache lại để cung cấp qua method fetch

Cả hai method đều trả về một promise

c. LogEvent

Dùng Yeoman để tạo LogEvent service:

yo angular:service LogEvent
'use strict'

angular.module 'ziteApp'
  .service 'LogEvent', (ZiteServer, Auth) ->
    @markAsRead = (section, url) ->
      ZiteServer.credential = Auth.credential
      ZiteServer.markAsRead(section, url)
    return

Service này cung cấp method để markAsRead một bài viết.

d. Article directive

Để sinh một directive dùng Yeoman:

yo angular:directive article

Yeoman sẽ sinh cho ta file app/script/directives/article.coffee

'use strict'

angular.module 'ziteApp'
  .directive 'article', (LogEvent) ->
    restrict: 'EA'
    templateUrl: 'views/templates/article.html'
    scope:
      document: '='
      section: '='
    controller: ($scope, $window, $location) ->
      for pic in $scope.document.images
        if pic.h == pic.w && pic.h >= 120
          $scope.document.cover_url = pic.url
          break
        if pic.w > 800 && pic.h < pic.w / 2
          $scope.document.cover_url = pic.url
          $scope.document.is_full_pic = true
          break

      $scope.visitLink = ->
        $scope.document.isread = 1
        LogEvent.markAsRead($scope.section, $scope.document.url)
        $window.open($scope.document.url, '_blank')
        return true

      $scope.clickTag = (topic) ->
        $location.path('/section/' + topic.id)
        return true

Zite trả về kèm theo mỗi bài viết là một mảng các hình ảnh có trong bài viết đó. Vì vậy, trong controller mình chọn ra các hình ảnh phù hợp để hiển thị.

Controller còn cung cấp hai method là visitLinkclickTag để ứng với sự kiện người dùng click vào đọc tin hay click vào tag của các chủ đề liên quan.

Kết

Trong bài này, mình đã trình bày cách mình viết web client cho Zite, một số khó khăn gặp phải và cách giải quyết. Tuy client chỉ mới dừng lại ở mức đơn giản, thậm chí chưa cho phép đăng kí mới, phải dăng kí trên ứng dụng mobile, nhưng hy vọng bài viết này hữu ích cho mọi người, nhất là những ai sử dụng Zite.

Do mới học AngularJS nên bài viết cũng như chương trình còn nhiều thiếu sót, có thể chưa theo best practice. Rất mong nhận được những ý kiến góp ý từ tất cả các bạn.

Links

All Rights Reserved