Using ES7 Async Functions In Ember

on
  • Ember.js
  • JavaScript

In my last Ember.js projects, I've began to use the new upcoming JavaScript async functions. While they are not yet officially included in the standard, they were recently moved to a "Stage 3" proposal on the tc39/ecma262 document, this means they are ready to be implemented by browser vendors and can be somewhat safely used with transpilers like Babel.

To see the exact meaning of the different stages, see the official process document.

Setup an Ember application:

$ ember new await-test
$ cd await-test

Include the polyfill, as async functions need facebook/regenerator to work.

var EmberApp = require('ember-cli/lib/broccoli/ember-app')

module.exports = function(defaults) {
  var app = new EmberApp(defaults, {
    babel: {
      includePolyfill: true
    },
    // You might want to disable jshint as it does
    // not support async/await yet.
    hinting: false
  })

  return app.toTree()
}

Nothing more is required as Babel enables any JavaScript features that are Stage 3 or above.

For our example app, we're going to implement a simple login with an acceptance test. The login logic will be handled by ember-simple-auth version 1.0 which is compatible with Ember.js 2.0 and above.

Before we do anything, we have to write an initializer, which sets the global Promise object to the Ember.RSVP.Promise. This change guaranties that async code runs with the Ember run-loop in mind.

app/initializers/promise.js:

import Ember from 'ember'

export function initialize() {
  window.Promise = Ember.RSVP.Promise
}

export default {
  name: 'promise',
  initialize
}
$ ember install simplabs/ember-simple-auth
$ ember generate acceptance-test login

Imagine a login acceptance that looks like this in plain ES2015:

tests/acceptance/login-test.js:

import Ember from 'ember'
import $ from 'jquery'
import { module, test } from 'qunit'
import startApp from 'await-test/tests/helpers/start-app'
import { authenticateSession } from 'await-test/tests/helpers/ember-simple-auth'

module('Acceptance | Login', {
  beforeEach() {
    this.application = startApp()
  },
  afterEach() {
    Ember.run(() => this.application.destroy())
  }
})

test('visiting /protected redirects to /login', assert => {
  assert.expect(1)

  visit('/protected')

  andThen(() => {
    assert.equal(currentURL(), '/login')
  })
})

test('visiting /login works', assert => {
  assert.expect(1)

  visit('/login')

  andThen(() => {
    assert.equal(currentURL(), '/login')
  })
})

test('login fails for invalid user', assert => {
  assert.expect(2)

  visit('/login')

  andThen(() => {
    fillIn('input[type=text]',     'topaxi')
    fillIn('input[type=password]', 'notmypassword')
    click('button[type=submit]')
  })

  andThen(() => {
    assert.equal(currentURL(), '/login')
    assert.equal($('.error-message').text(), 'Invalid credentials!')
  })
})

test('login works for valid user', assert => {
  assert.expect(2)

  visit('/login')

  andThen(() => {
    fillIn('input[type=text]',     'topaxi')
    fillIn('input[type=password]', '123456')
    click('button[type=submit]')
  })

  andThen(() => {
    assert.equal(currentURL(), '/')
    assert.equal($('.logged-in-user').text(), 'topaxi')
  })
})

test('a protected route is accessible when the session is authenticated', assert => {
  assert.expect(1)

  authenticateSession(this.application)
  visit('/protected')

  andThen(() => {
    assert.equal(currentRouteName(), 'protected.index')
  })
})

With ES7 async functions, we can get rid of all these andThen() function calls which wait for all unresolved Promises (this is one of the reasons we had to replace the global Promise object with Ember.RSVP.Promise).

import Ember from 'ember'
import $ from 'jquery'
import { module, test } from 'qunit'
import startApp from 'await-test/tests/helpers/start-app'
import { authenticateSession } from 'await-test/tests/helpers/ember-simple-auth'

module('Acceptance | Login', {
  beforeEach() {
    this.application = startApp()
  },
  afterEach() {
    Ember.run(() => this.application.destroy())
  }
})

test('visiting /protected redirects to /login', async assert => {
  assert.expect(1)
  await visit('/protected')
  assert.equal(currentURL(), '/login')
})

test('visiting /login works', async assert => {
  assert.expect(1)
  await visit('/login')
  assert.equal(currentURL(), '/login')
})

test('login fails for invalid user', async assert => {
  assert.expect(2)

  await visit('/login')
  fillIn('input[type=text]',     'topaxi')
  fillIn('input[type=password]', 'notmypassword')
  await click('button[type=submit]')

  assert.equal(currentURL(), '/login')
  assert.equal($('.error-message').text(), 'Invalid credentials!')
})

test('login works for valid user', async assert => {
  assert.expect(2)

  await visit('/login')
  fillIn('input[type=text]',     'topaxi')
  fillIn('input[type=password]', '123456')
  await click('button[type=submit]')

  assert.equal(currentURL(), '/')
  assert.equal($('.logged-in-user').text(), 'topaxi')
})

test('a protected route is accessible when the session is authenticated', async function(assert) {
  assert.expect(1)

  authenticateSession(this.application)
  await visit('/protected')

  assert.equal(currentRouteName(), 'protected.index')
})

Now on to implementing the whole login process.

The authenticator is responsible for to login an user and restore or invalidate the session.

app/authenticators/custom.js:

import Ember from 'ember'
//import ajax from 'ember-ajax'
import BaseAuthenticator from 'ember-simple-auth/authenticators/base'

export default BaseAuthenticator.extend({
  async authenticate(credentials) {
    // Example API call to login which would return a session token
    // in a JSON object.
    //let { token } = await ajax('/api/v1/login', credentials)
    let { token } = await mockAuth(credentials)

    return { token, username: credentials.identification }
  },
  async restore(properties) {
    if (isEmpty(properties.token)) {
      throw new Error('No token to restore found')
    }

    return properties
  },
  invalidate() {
    // Example API call to logout
    //return ajax('/api/v1/logout')
    return true
  }
})

Even though the restore() method does not need to await anything, the ember-simple-auth API expects a rejected promise, throwing in an async function rejects the returned promise of restore(). The used mockAuth function here is only intended for our example and fakes an actual login attempt. I've implemented it like this:

function mockAuth({ identification: user, password: pass }) {
  return new Promise((resolve, reject) => {
    if (user === 'topaxi' && pass === '123456') {
      resolve({ token: 'some random session token' })
    }
    else {
      reject(new Error('Invalid credentials!'))
    }
  })
}

Setup the router with all routes we're going to use for this example:

import Ember from 'ember'
import config from './config/environment'

const Router = Ember.Router.extend({
  location: config.locationType
})

Router.map(function() {
  this.route('login')
  this.route('protected', function() {
    // Put any route which needs authentication here or make sure
    // to extend the protected route.
    //this.route('user', { resetNamespace: true })
  })
})

export default Router

Add an invalidateSession action to the application controller, we may need to add logout links on any page so this is a good place to implement his.

app/application/route.js:

import Ember from 'ember'
import ApplicationRouteMixin from 'ember-simple-auth/mixins/application-route-mixin'

export default Ember.Route.extend(ApplicationRouteMixin, {
  actions: {
    invalidateSession() {
      this.get('session').invalidate()
    }
  }
})

Add some indication to the application route when we're logged in:

app/templates/application.hbs:

Welcome to Ember

{{#if session.isAuthenticated}}
Currently logged in as: {{session.session.content.authenticated.username}}
Logout
{{else}} {{/if}} {{outlet}}

As we need the session in our template, we need to inject the ember-simple-auth session service into our application controller:

app/controllers/application.js:

import Ember from 'ember'

export default Ember.Controller.extend({
  session: Ember.inject.service()
})

Implement the route, controller and template for our login.

app/routes/login.js:

import Ember from 'ember'
import UnauthenticatedRouteMixin from 'ember-simple-auth/mixins/unauthenticated-route-mixin'

export default Ember.Route.extend(UnauthenticatedRouteMixin, {
  session: Ember.inject.service(),

  afterModel() {
    if (this.get('session.isAuthenticated')) {
      this.replaceWith('/')
    }
  }
})

app/controller/login.js:

import Ember from 'ember'

export default Ember.Controller.extend({
  session: Ember.inject.service(),

  actions: {
    async authenticate() {
      this.set('loading', true)
      this.set('errorMessage', null)

      try {
        let credentials = this.getProperties('identification', 'password')

        await this.get('session').authenticate('authenticator:custom', credentials)
      }
      catch (e) {
        this.set('errorMessage', e.message)
      }
      finally {
        this.set('loading', false)
      }
    }
  }
})

app/templates/login.hbs:

{{#if errorMessage}}

{{errorMessage}}

{{/if}}

Use the AuthenticatedRouteMixin to indicate which route needs authentication.

app/routes/protected.js:

import Ember from 'ember'
import AuthenticatedRouteMixin from 'ember-simple-auth/mixins/authenticated-route-mixin'

export default Ember.Route.extend(AuthenticatedRouteMixin)

Now we're all set, lets run the acceptance test to check if everything is working as expected:

$ ember test
version: 1.13.8
Built project successfully. Stored in "/home/topaxi/await-test/tmp/class-tests_dist-hJ8RCDwV.tmp".
ok 1 PhantomJS 1.9 - Acceptance | Login: visiting /protected redirects to /login
ok 2 PhantomJS 1.9 - Acceptance | Login: visiting /login works
ok 3 PhantomJS 1.9 - Acceptance | Login: login fails for invalid user
ok 4 PhantomJS 1.9 - Acceptance | Login: login works for valid user
ok 5 PhantomJS 1.9 - Acceptance | Login: a protected route is accessible when the session is authenticated

1..5
# tests 5
# pass  5
# fail  0

# ok

This covered our login process, what's still missing is authorization for our potential API endpoints. This might be implemented like this, but won't be covered fully in this blog post.

app/authorizers/custom.js:

import BaseAuthorizer from 'ember-simple-auth/authorizers/base'

export default BaseAuthorizer.extend({
  authorize({ token }, setRequestHeader) {
    if (token) {
      setRequestHeader('Authorization', `Bearer ${token}`)
    }
  }
})

Now that you've learnt to build an authentication with the latest ember-simple-auth module and ES7 async functions, go build something awesome! :)