An example ember application that communicates with a compatible HAL/JSON web service, inspired by my visit to Blendle one day.
For example, have a look at ruby-hal-server which I created in tandem to this project for use as a test harnass.
Built using the ember-cli framework.
You will need the following things properly installed on your computer.
In order to install and start this application, run the following commands.
$ git clone https://github.com/kgish/ember-hal-template hal-client
$ cd hal-client
$ npm install
$ bower install
Although the defaults work out of the box, you might have to configure certain parameters in order to get the application to work properly.
This is accomplished by editing the file config/environment.js
and making
the following changes.
module.exports = function(environment) {
var ENV = {
...
},
APP: {
RESTADAPTER_HOST: 'http://0.0.0.0:8080'
}
};
...
return ENV;
};
Start the client by running the following command.
$ ember server
version: 0.1.6
Livereload server on port 35729
Serving on http://0.0.0.0:4200/
After which you can fire up you favorite browser and point it to http://localhost:4200.
ember test
ember test --server
http://localhost:4200/tests
ember build
(development)ember build --environment production
(production)
In order to get this application running properly with the ember default
RESTAdapter
, I had to do some serious tweaking.
The server will return HAL/JSON payload which needs to be reformatted before passing on to the client.
For a single
resource, the incoming payload from the API Server looks
likes this:
payload = {
_links: {
self: {
href: '/products/5'
},
curies: [
{
name: 'ht',
href: 'http://0.0.0.0:8080:/rels/{rel}',
templated: true
}
]
},
name: 'horse',
category: 'animal',
price: 3021
}
Which needs to be converted to this:
payload = {
product: {
id: 5,
name: 'horse',
category: 'animal',
price: 3021
}
}
For a collection
of resources, the incoming payload from the API Server
will look like this:
payload = {
_links: {
self: {
href: '/products'
},
curies: [
{
name: 'ht',
href: 'http://localhost:8080:/rels/{rel}',
templated: true
}
],
ht:product: [
{
'href': '/products/1',
'name': 'dragon',
'category': 'health',
'price': 2241
},
...
]
}
}
Which needs to be converted to this:
payload = {
products: [
{
id: 5,
name: 'horse',
category: 'animal',
price: 3021
},
...
]
}
This is achieved by extending the default ProductSerializer
and redefining
the normalizePayload
hook like this:
App.ProductSerializer = DS.RESTSerializer.extend({
...
normalizePayload: function(payload) {
var normalizedPayload = {};
if (payload['_links']) {
var links = payload['_links'],
href = links['self']['href'];
if (href === '/products') {
normalizedPayload = this._normalizeCollection(payload)
} else {
normalizedPayload = this._normalizeResource(payload)
}
}
return normalizedPayload;
},
/* private */
_normalizeResource: function(payload) {
var links = payload['_links'],
href = links['self']['href'],
id = href.replace(/^\/[^\/]+\//, ''),
normalizedPayload = {
product: {
id: id,
name: payload['name'],
category: payload['category'],
price: payload['price']
}
};
return normalizedPayload;
},
_normalizeCollection: function(payload) {
var links = payload['_links'],
href = links['self']['href'],
products = links['ht:product'];
var list = [];
products.forEach(function(product){
var id = product.href.replace(/^\/[^\/]+\//, '');
list.push({
id: id,
name: product['name'],
category: product['category'],
price: product['price']
});
});
return { products: list };
}
});
The same will need to be done with the user
resource, or in the future any
newer resources that must be accessed by the client.
Of course, this is not very efficient having to copy code for each resource,
so a better more generic handling should be done centrally at the level of
the ApplicationSerializer
.
In order to achieve this, we generate an ember-cli blueprint:
$ ember-cli generate serializer application
Which creates the file app/serializers/application.js
and after proper
modification should look something like this:
import DS from 'ember-data';
export default DS.RESTSerializer.extend({
...
normalizePayload: function(payload) {
var normalizedPayload = {};
if (payload['_links']) {
var links = payload['_links'],
href = links['self']['href'],
m = href.match(/^\/([^\/]+)s(\/(.*))?$/); // See below.
var idn = m[3] || 'none';
if (m[3]) {
normalizedPayload = this._normalizeResource(payload, m[1], m[3])
} else {
normalizedPayload = this._normalizeCollection(payload, m[1], 'ht')
}
}
return normalizedPayload;
},
/* private */
_normalizeResource: function(payload, resource, id) {
var normalizedPayload = {};
normalizedPayload[resource]['id'] = id;
for (var key in payload) {
if (key === '_links') continue;
normalizedPayload[resource][key] = payload[key];
}
return normalizedPayload;
},
_normalizeCollection: function(payload, resource, name) {
var normalizedPayload = {};
var links = payload['_links'],
resources = links[name+':'+resource];
var list = [];
resources.forEach(function(resource) {
var id = resource.href.replace(/^\/[^\/]+\//, ''); // See below.
var next = {};
next['id'] = id;
for (var key in resource) {
if (key === 'href') continue;
next[key] = resource[key];
}
list.push(next);
});
normalizedPayload[resource+'s'] = list;
return normalizedPayload;
}
});
You are probably scratching your head trying to get to grips with those two mangled-looking regular expressions I used, so I will try and explain them.
m = href.match(/^\/([^\/]+)s(\/(.*))?$/);
The value of href is either '/{name}s' or '/{name}s/id'
- If href =
/products
thenm[1,2,3] = 'product', undefined, undefined
- If href =
/products/22
thenm[1,2,3] = 'product', '/22', '22'
Therefore if m[3]
is undefined then payload is a collection
otherwise
it's a plain-vanilla resource
with id = m[3]
.
For this demo application, resource = product
or user
but this generic
serializer should handle any other resource from the HAL/JSON.
id = resource.href.replace(/^\/[^\/]+\//, '');
Given a string like /products/23
strip off the beginning and return the
terminating string after the last backlash resulting in 23
.
All of activities related to authentication are handled in the sessions
controller defined in the app/controllers/sessions.js
file.
Two cookies are used for persistent session handling, namely:
access_token
(token)auth_user
(currentUser)
Logging into the server is done by sending a POST /sessions
with the
following payload included in the request body:
{ password: 'pindakaas', username_or_email: 'kiffin' }
Assuming success the server responds with a 201 Created
passing back the
access_token
as part of the following payload in the response body:
{ api_key: { user_id: 1, access_token: '26c06...342aa' } }
Failure to login results in a 401 Unauthorized
which means that either or
both of the username and password were incorrect.
The access_token
is stored for safe-keeping in the appropriate cookie:
Ember.$.cookie('access_token', access_token, { root: '/'});
and thereafter needs to be included in the authorization header of all following requests in order to gain access:
Ember.$.ajaxSetup({
contentType: 'application/json; charset=utf-8',
headers: {
'Authorization': 'Bearer '+access_token
}
});
At his point the attributes related to the current user can be acquired by
sending a GET /users/user_id
(together with the token authorization, of course).
The server send backs the the user data in HAL/JSON format in the response body which after being converted by the serializer (above) looks like this:
currentUser = {
id: 1,
name: 'Kiffin Gish',
username: 'kiffin',
email: 'kiffin.gish@planet.nl',
is_admin: true,
login_date: '2015-01-23T20:12:24.000Z'
}
This is also saved in a cookie for safe-keeping:
Ember.$.cookie('auth_user', currentUser, { root: '/'});
During page refresh or incoming url the cookies are first checked to see if an authenticated user is available, and if so use those cookies for further validation purposes.
After this you're in the ballpark and can proceed freely to your seat behind home plate.
There are still a number of minor issues which should be looked into, namely the following:
- Value of 'ht' is hardcoded, instead use autodiscovery on root resource.
- Need to implement better error handling.
- Message/error banner for generic handling of user info.
- Migrate to
ember-cli-simple-auth
(maybe). - The global flags
isAuthenticated
,isAdmin
andcurrentUser
are not DRY, need to be handled more elegantly. - Pagination for the products list (nice to have).
- Successful user registration should redircet to login page with username field prefilled.
I had a look at the Ember Data HAL Adapter written by Ricardo Mendes, but unfortunately could not get it to work.
However, reading through the code was very enlightening and gave me the opportunity to pull out some valuable code snippets, thanks.
Here is a list of important references which I found very useful.
- Authentication in ember.js
- (Better) Authentication in ember.js
- Ember Data HAL Adapter
- Ember Data Local Storage Adapter
- Emberjs Authentication the right way (example)
- HTTP 1.1 Headers Status
Kiffin Gish kiffin.gish@planet.nl