« HE:labs
HE:labs

Construindo um cliente de reddit com emberjs

Postado por Marcio Junior em 29/08/2014

Ember.js é um framework javascript MVC que atua do lado cliente. Seus criadores foram Yehuda Katz e Tom Dale, mas atualmente recebe contribuições de diversos desenvolvedores no github.

Nesse post iremos aprender a criar um cliente com base na api do reddit.

Pré-requisitos

Para esse tutorial usaremos o ember-cli, que é a interface de linha de comando que te dá todo o ferramental necessário para usar geradores, rodar o servidor, executar testes, minificar o javascript, etc. O ember-cli é feito em node e usa o bower pra resolver algumas dependências então será necessário ter essas duas ferramentas instaladas também.

Instalando o ember-cli

Utilizaremos nesse tutorial a versão 0.0.40 que é a mais atual no tempo que escrevo esse post.

1 npm install -g ember-cli@0.0.40
2 # npm noise ...
3 ember --version
4 # version: 0.0.40
5 # node: 0.10.26
6 # npm: 1.4.21

Criando o projeto

Uma vez instalado o ember-cli você terá disponível na sua linha de comando o comando ember. Para cria uma nova aplicação utilizaremos:

1 ember new ember-reddit && cd ember-reddit
2 # installing
3 #   create several files
4 # Installed packages for tooling via npm.
5 # Installed browser packages via Bower.
6 # Sucessfully initialized git

Uma vez dentro do diretório da aplicação podemos levantar o servidor usando ember server ou ember s e visitar a url http://localhost:4200. Se tudo ocorrer bem, você verá um título com Welcome to Ember.js.

image

Para dar uma cara melhor ao projeto iremos utilizar o twitter boostrap, para instala-lo utilize bower install bootstrap --save. E para adiciona-lo na asset pipeline do ember-cli mude o arquivo Brocfile.js para:

1 var EmberApp = require('ember-cli/lib/broccoli/ember-app');
2 
3 var app = new EmberApp();
4 app.import('vendor/bootstrap/dist/css/bootstrap.css');
5 
6 module.exports = app.toTree();

Obs: o arquivo Brocfile.js não é recarregado automaticamente então você terá que reiniciar o servidor para pegar as alterações.

Ao atualizar a página você verá que o título Welcome to Ember.js está um pouco maior, o que indica que os estilos do boostrap estão sendo usados.

image

Criando nosso primeiro teste

O ember.js vem com uma biblioteca de testes que usa uma dsl com uma sintaxe bem intuitiva e que tenta abstrair ao máximo possível as execuções assíncronas, já que ajax é muito comum em SPA(Single page application)'s. Você pode encontrar mais informações a respeito dela no guia oficial de testes. Essa biblioteca vem integrada por padrão no qunit então você não precisa ficar fazendo asyncStart, start e stop, velhos conhecidos quando precisamos testar códigos assíncronos.

Vamos criar um teste simples para verificar se o título principal do site, o h1, é igual a 'Ember reddit'. Para isso vamos usar os geradores do ember-cli para criar um teste te aceitação:

1 ember generate acceptance-test index
2 # version: 0.0.40
3 # installing
4 #   create tests/acceptance/index-test.js

Vamos alterar o teste gerado pelo ember-cli e adicionar o cenário esperado para a nossa app:

1 test('visiting /', function() {
2   visit('/');
3 
4   andThen(function() {
5     equal(find('h1').text(), 'Ember reddit');
6   });
7 });

Algumas explicações sobre o que o teste acima está fazendo: O visit('/') simula uma requisição para a raiz da aplicação. O andThen executa uma função assim que o visit carrega a página. No nosso caso quando a página for carregada queremos verificar que o texto do elemento h1 é igual a 'Ember reddit'. O find('h1').text() nada mais é que uma chamada ao jQuery. É quase igual a $('h1').text().

Para ver os testes executando visite http://localhost:4200/tests. Você verá que esse teste vai falhar, faremos ele passar mas primeiro vamos entender como o ember renderiza os templates.

O ember.js usa convenção sobre configuração, então caso editássemos o arquivo app/router.js e criássemos uma rota chamada 'foo':

1 Router.map(function() {
2   this.route('foo');
3 });

Ao acessar '/foo' seria renderizado o template app/templates/foo.hbs usando como layout o template app/templates/application.hbs.

No nosso caso mesmo não tendo nenhuma rota criada ainda, o ember cria o seguinte mapeamento por padrão:

1 Router.map(function() {
2   // rota criada por padrão
3   // this.route('index', { path: '/' });
4 });

Ou seja, uma rota chamada index que mapea para a url raiz da nossa aplicação. Logo ele está renderizando o template app/templates/index.hbs usando o layout app/templates/application.hbs. Como não temos o arquivo index.hbs ele não é renderizado, apenas o seu layout.

Agora que entendemos o que está acontecendo, podemos alterar o nosso layout para o seguinte código:

1 <div class="container">
2   <h1>Ember reddit</h1>
3   {{outlet}}
4 </div>

O {{outlet}} é um helper do handlebars.js onde cada template será renderizado.

Após essa alteração você verá que todos os testes estão passando.

Coletando dados da api do reddit

Listaremos os tópicos populares do reddit, para isso precisamos consumir a api rest deles. Utilizaremos ajax para realizar essa chamada, e é interessante colocarmos num serviço para manter as chamadas da api num lugar só. Vamos criar esse serviço usando o seguinte comando:

1 ember generate service reddit
2 # version: 0.0.40
3 # installing
4 #   create app/initializers/reddit.js
5 #   create app/services/reddit.js
6 #   create tests/unit/services/reddit-test.js

O endpoint a ser consumido é http://www.reddit.com/hot.json que retorna um json. Esse json vai mudar de tempos em tempos porque o conteúdo retornado por ele é atualizado periodicamente, para nossos testes sempre trabalharem com o mesmo conteúdo vamos mockar essa chamada.

tests/helper/hot-fixture.js

1 import { defineFixture } from 'ic-ajax';
2 
3 var data = [...]; // adicione o json retornado pela api aqui
4 
5 export default defineFixture('http://www.reddit.com/hot.json', {
6   response: data,
7   jqXHR: {},
8   textStatus: 'success'
9 });

O ember-cli já vem com o ic-ajax, que é uma versão do jQuery.ajax mais integrada com o ember.js. Além disso o ic-ajax possui um método para simularmos requisições ajax que é o defineFixture que estamos usando acima. Se você está curioso a respeito da sintaxe import { defineFixture } from 'ic-ajax'; ela nada mais é do que a forma de utilizar módulos que está sobre aprovação na próxima versão do javascript. É seguro usa-la no ember-cli porque ela é transpilada usando o es6-module-transpiler.

Agora que temos a chamada da api mockada, vamos criar um teste:

tests/unit/services/reddit-test.js

 1 import { test, moduleFor } from 'ember-qunit';
 2 import { defineFixture } from 'ic-ajax';
 3 import { hotFixture } from '../../helpers/hot-fixture';
 4 
 5 
 6 moduleFor('service:reddit', 'RedditService', {
 7   // Specify the other units that are required for this test.
 8   // needs: ['service:foo']
 9 });
10 
11 // Replace this with your real tests.
12 test('it exists', function() {
13   var service = this.subject();
14   ok(service);
15 });
16 
17 test('it fetch the data', function() {
18   expect(2);
19   var service = this.subject();
20   service.hot().then(function(data) {
21     equal(data.kind, 'Listing');
22     equal(data.data.children.length, 25);
23   });
24 });

Se você verificar o código acima, moduleFor e this.subject não são métodos do qunit. Esses métodos são adicionados por outra biblioteca chamada ember-qunit, que facilita a criação de testes unitários no ember.js. O moduleFor serve para especificar qual objeto queremos testar e suas dependências. O this.subject() cria uma nova instância desse objeto e resolve as suas dependências.

Ao rodar esses testes, como esperado eles falham pois não criamos o método hot ainda. Então vamos cria-lo:

app/services/reddit.js

 1 import Ember from 'ember';
 2 import ajax from 'ic-ajax';
 3 
 4 export default Ember.Object.extend({
 5   host: 'http://www.reddit.com/',
 6   request: function(path) {
 7     return ajax(this.host + path + '.json');
 8   },
 9   hot: function() {
10     return this.request('hot');
11   }
12 });

No serviço criado acima foi feito um método chamado hot() que realizada uma requisição ajax para o endpoint http://www.reddit.com/hot.json

Criando nossa primeira rota

Agora que temos o nosso serviço funcionando e testado, é hora de usa-lo no sistema. Para isso vamos criar uma rota chamada hot:

1 ember generate route hot
2 # version: 0.0.40
3 # installing
4 #   create app/routes/hot.js
5 #   create app/templates/hot.hbs
6 #   create tests/unit/routes/hot-test.js

Além disso vamos criar um teste de aceitação para certificar que o conteúdo vindo da api do reddit realmente está sendo mostrado para o usuário.

1 ember generate acceptance-test hot
2 # version: 0.0.40
3 # installing
4 #   create tests/acceptance/hot-test.js

tests/acceptance/hot-test.js

 1 import Ember from 'ember';
 2 import startApp from '../helpers/start-app';
 3 import { hotFixture } from '../helpers/hot-fixture';
 4 
 5 var App;
 6 
 7 module('Acceptance: Hot', {
 8   setup: function() {
 9     App = startApp();
10   },
11   teardown: function() {
12     Ember.run(App, 'destroy');
13   }
14 });
15 
16 test('visiting /hot', function() {
17   visit('/hot');
18 
19   andThen(function() {
20     equal(find('h4').eq(0).text(), 'My buddy is an NFL running back. His kids dressed as Flash and Batman to fight him dressed as Bane.');
21     equal(find('h4').eq(1).text(), 'More selfies drawn by strangers');
22   });
23 });

No teste acima é verificado os dois primeiros h4's. Esse elemento será usado para o título de cada item da lista vinda do reddit. Esse exemplo ficará diferente na sua applicação se você estiver realizando passo a passo esse tutorial, pois esses dados vem da fixture gerada anteriormente.

O ember.js usa muito convenção sobre configuração tanto na organização dos arquivos quanto para resolver problemas comuns. Nesse caso o que queremos fazer é: carregar os dados iniciais, a.k.a model, de uma determinada tela. Para isso existe um método chamado model, ele é chamado pelo framework e espera algum objeto ou uma promise. Se for retornado uma promise o framework irá esperar ela ser finalizada, pegar o conteúdo dela e utilizar como model. No nosso caso como o reddit.hot() executa uma requisição ajax incapsulada numa promise o json retornado pela api será o nosso model. O código ficará assim:

app/routes/hot.js

1 import Ember from 'ember';
2 
3 export default Ember.Route.extend({
4   model: function() {
5     return this.reddit.hot();
6   }
7 });

É importante notar que o nosso serviço reddit é magicamente injetado na nossa rota. Na verdade essa mágica acontece quando geramos o serviço via ember generate service reddit nesse momento além de serem criados a rota e o teste unitário dela também é criado um initializer em app/initializers/reddit.js. Arquivos dentro dessa pasta são executados quando o framework inicializa, sendo uma boa oportunidade para registrar injeções de dependências.

Vamos adicionar o template que será responsável por renderizar o conteúdo do endpoint /hot

app/templates/hot.hbs

 1 {{#each model.data.children}}
 2   <div class="media" >
 3     <div class="pull-left">
 4       {{data.score}}
 5     </div>
 6     {{#if data.thumbnail}}
 7       <a class="pull-left" {{bind-attr href=data.url}}>
 8         <img class="media-object" {{bind-attr src=data.thumbnail}}>
 9       </a>
10     {{/if}}
11     <div class="media-body">
12       <a {{bind-attr href=data.url}}>
13         <h4 class="media-heading">{{data.title}}</h4>
14       </a>
15     </div>
16   </div>
17 {{/each}}

O ember.js usa a biblioteca handlebars.js para renderizar seu conteúdo. Ela é bem interessante porque possibilita que escrevamos:

1 {{#each people}}
2   <p>Hello {{name}}</p>
3 {{/each}}

ao invéz de:

1 var html = "";
2 for(var i = 0; i < people.length; i++) {
3   var person = people[i];
4   html += "<p>" + person.name + "</p>";
5 }
6 // adiciona o html

O {{#each}} é um helper do handlebars que renderiza os elementos de um array. Existem outros, e é possível criar o seu prórpio caso necessário.

Um pouco de explicação sobre o que está acontecendo no hot.hbs: O {{#each model.data.children}} executa o bloco passando como contexto cada item do array. O {{#if data.thumbnail}} verifica se existe uma url pro thumbnail para só assim renderizar a imagem. E por fim o {{bind-attr src=data.thumbnail}} é usado para vincular um atributo do html a um propriedade de um objeto, nesse caso o atributo src a propriedade data.thumbnail. O ideal é que fosse apenas <img src={{data.thumbnail}}/> mas isso ainda não é possível e deve ser usado o bind-attr. Após criar esse template, provavelmente agora os testes estão passando. Abaixo está uma imagem de como está a aplicação:

image

Podemos ver que as vezes aparecem imagens quebradas, eu não sei porque mas as vezes a api do reddit retorna os valores: "self", "default", "nsfw" para o campo thumbnail. E no momento só verificamos a presença da imagem usando {{#if data.thumbnail}}. Existem algumas maneiras de resolver esse problema, mas pra manter as coisas simples vamos ignorar imagens que não comecem com http ou https. A primeira coisa que vem a mente é adicionar um if no template verificando isso com uma regex. Por exemplo:

1 ...
2 {{#if /^http[s]?:\/\//.test(data.thumbnail)}}
3   <a class="pull-left" {{bind-attr href=data.url}}>
4     <img class="media-object" {{bind-attr src=data.thumbnail}}>
5   </a>
6 {{/if}}
7 ...

Mas isso não funciona, e nesse caso não funciona de próposito. Por ser muito comum existirem templates com um monte de código jogado, que com o tempo acaba sendo díficil dar manutenção, pois não se entende do que se trata. Decidiram limitar o que o handlebars processa e ele não executa expressões. O que funcionaria seria o seguinte:

1 ...
2 {{#if validThumbnailUrl}}
3   <a class="pull-left" {{bind-attr href=data.url}}>
4     <img class="media-object" {{bind-attr src=data.thumbnail}}>
5   </a>
6 {{/if}}
7 ...

Mas daí teríamos que alterar o model da rota, e adicionar uma propriedade chamada validThumbnailUrl em cada dado:

 1 export default Ember.Route.extend({
 2   model: function() {
 3     return this.reddit.hot().then(function(result) {
 4       result.data.children.forEach(function(child) {
 5         child.validThumbnailUrl = /^http[s]?:\/\//.test(child.data.thumbnail);
 6       });
 7       return result;
 8     })
 9   }
10 });

Isso funciona, mas podemos fazer melhor.

Controllers e propriedades computadas

No ember existe uma camada de controller mas ela se comporta um pouco diferente do padrão MVC que estamos habituados. Os controllers no ember se comportam muito mais como presenters ou decorators. Cada controller é vínculado a um model, e quando existe uma propriedade referenciada no template, ela é primeiro procurada no controller e caso não exista é delegada ao model. Isso é bem interessante pois podemos mover as lógicas para esses controllers, deixando os models apenas com os dados.

Vamos criar um controller e chama-lo de entry usando o seguinte comando:

1 ember generate controller entry
2 # version: 0.0.40
3 # installing
4 #   create app/controllers/entry.js
5 #   create tests/unit/controllers/entry-test.js

No nosso teste queremos que ao fazer um this.get('validThumbnailUrl') ele seja true se this.get('data.thumbnail') retornar uma url válida.

tests/unit/controllers/entry-test.js

 1 test('#validThumbnailUrl returns true for http urls', function() {
 2   var controller = this.subject();
 3   controller.set('model', { data: {} });
 4   controller.set('data.thumbnail', 'http://foo.com');
 5   ok(controller.get('validThumbnailUrl'));
 6 });
 7 
 8 test('#validThumbnailUrl returns true for https urls', function() {
 9   var controller = this.subject();
10   controller.set('model', { data: {} });
11   controller.set('data.thumbnail', 'http://foo.com');
12   ok(controller.get('validThumbnailUrl'));
13 });
14 
15 test('#validThumbnailUrl returns false for empty urls', function() {
16   var controller = this.subject();
17   controller.set('model', { data: {} });
18   controller.set('data.thumbnail', '');
19   ok(!controller.get('validThumbnailUrl'));
20 });
21 
22 test('#validThumbnailUrl returns false for not valid urls', function() {
23   var controller = this.subject();
24   controller.set('model', { data: {} });
25   controller.set('data.thumbnail', 'hue');
26   ok(!controller.get('validThumbnailUrl'));
27 });

Perceba que estamos usando this.get('prop') e this.set('prop', value) ao invéz de this.prop e this.prop = value. Usa-se o get porque o controller é um proxy do model, ou seja, se a propriedade não existir no controller ele tenta achar no model. E o set é necessário para o controller mudar o valor do model.

Para implementar o validThumbnailUrl no nosso controller vamos usar uma propriedade computada

app/controllers/entry.js

1 import Ember from 'ember';
2 
3 export default Ember.ObjectController.extend({
4   validThumbnailUrl: function() {
5     return /^http[s]?:\/\//.test(this.get('data.thumbnail'));
6   }.property('data.thumbnail')
7 });

Após adicionar esse controller os testes agora passam.

Uma vez criado esse controller precisamos fazer com que o framework use ele para cada item renderizado pelo {{#each}}. Para isso podemos usar o atributo itemController passando o nome do nosso controller, que nesse caso é entry.

app/templates/hot.hbs

1 {{#each model.data.children itemController="entry"}}
2   ...
3 {{/each}}

Depois de adicionar o nosso controller, agora aquelas imagens inválidas não aparecem mais e os dados são exibidos corretamente. Cada item renderizado agora não é um objeto do array model.data.children mas sim uma instância de entry controller com cada objeto sendo o seu model. Como o nosso entry controller atua como um proxy, o validThumbnailUrl é pego dele, e os outros dados que estavam sendo referenciados no template são delegados ao model.

O código completo deste cliente do reddit está na minha conta do github. E você pode vê-lo online no heroku através da url http://ember-reddit-client.herokuapp.com/hot

Fique a vontade pra deixar dúvidas ou sugestões. Abraços!

Compartilhe

Sabia que nosso blog agora está no Medium? Confira Aqui!