De zéro à Heroku avec Elixir/Phoenix et EmberJS

En 2015, le langage Elixir a connu un engouement particulièrement fort, notamment grâce au framework web Phoenix qui propose une approche intéressante du développement web.

Dans l’univers JavaScript, un autre framework a le vent en poupe cette année : EmberJS.

EmberJS et Elixir Phoenix

De ces deux mondes, est issue une nouvelle stack de développement, PEEP pour Phoenix Elixir EmberJS PostgreSQL. Mêlant la puissance de chacun dans son domaine, l’idée de cette stack est d’avoir Phoenix servant de couche backend / API et EmberJS fournissant la couche frontend.

Mettre tout cela en place et déployer n’est pas toujours trivial, c’est pourquoi nous vous proposons ce petit guide pour faire vos premiers pas et commencer l’année 2016 en beauté.

Avant-propos

Afin de rester concis, ce guide ne couvre pas l’installation des outils liés à la stack comme Elixir, Phoenix, NodeJS ou PostgreSQL.

Des guides maintenus à jour sont déjà disponibles en ligne :

L’application

L’application utilisée en exemple ici sera assez simple mais contiendra tout de même quelques associations dans le but de vérifier leur sérialisation entre EmberJS et Phoenix.

Elle permettra de gérer une liste de séries TV et les dates de diffusion des épisodes.

Nous utiliserons Ember-Data pour communiquer avec le backend et choisirons donc le format de communication JSONAPI puisque c’est le format par défaut dans Ember-Data.

Les deux applications vont tourner sur des domaines différents, que ce soit en développement et en production, il faudra donc configurer CORS pour que cela fonctionne.

Nous utiliserons Ember CLI pour servir l’application EmberJS en local.

L’application Phoenix

Créer l’application

Commençons pas créer notre application Phoenix.

mix phoenix.new tv_shows --no-brunch

L’option --no-brunch permet de ne pas mettre en place de système de gestion des ressources frontend (CSS, JS) puisque nous utiliserons Ember CLI pour servir l’application EmberJS.

Modifiez le fichier config/dev.exs pour changer les réglages liés à la base de données en fonction de votre installation.

Chez Tinci par exemple, nous avons une installation de PostgreSQL pour chaque application. Notre configuration ressemble donc à ceci :

# Configure your database
config :tv_shows, TvShows.Repo,
  adapter: Ecto.Adapters.Postgres,
  database: "tv_shows_dev",
  hostname: "localhost",
  pool_size: 10

Une fois cela fait et que votre serveur PostgreSQL tourne, il vous suffit de lancer la commande suivante pour créer votre base de données :

mix ecto.create

Configurer le pipeline API

Phoenix va uniquement nous servir en tant qu’API, nous n’avons donc pas besoin des traitements spécifique à un fonctionnement lié à un navigateur web.

Il nous faut donc modifier la configuration de notre application Phoenix pour indiquer que nous souhaitez uniquement mettre à disposition une API.

Cela se fait dans le fichier web/router.ex :

defmodule TvShows.Router do
  use TvShows.Web, :router

  pipeline :api do
    plug :accepts, ["json"]
  end

  scope "/api", TvShows do
    pipe_through :api
  end
end

Ici nous avons conservé uniquement le pipeline :api qui indique que la communication se fait en JSON et le scope /api qui identifie sous quelle URL sera accessible notre API.

La communication se fait certes en JSON mais elle va suivre une spécification bien précise, JSONAPI. Cette dernière indique que le type MIME à utiliser n’est pas application/json mais application/vnd.api+json. Pour cela, il nous faut modifier légèrement notre routeur :

pipeline :api do
  plug :accepts, ["json-api"]
end

Il nous reste maintenant à indiquer à l’application que json-api correspond au type MIME application/vnd.api+json. Cela se fait dans le fichier config/config.exs :

# Ajoutez cette ligne en fin de fichier
config :plug, :mimes, %{"application/vnd.api+json" => ["json-api"]}

Elixir est un langage compilé, il est donc nécessaire de recompiler la dépendance Plug pour laquelle nous venons de configurer le type MIME :

touch deps/plug/mix.exs
mix deps.compile plug

Supporter CORS

Nos deux applications vont être hébergées sur des domaines différents. En développement une application Ember CLI tourne par défaut sur le port 4200 et une application Phoenix sur le port 4000 ; les domaines respectifs seront donc localhost:4200 et localhost:4000.

Sans configuration de CORS, notre navigateur ne nous laisserait pas interagir directement avec l’application Phoenix et nous donnerait simplement un message d’erreur du type :

XMLHttpRequest cannot load http://localhost:4000/shows.
No 'Access-Control-Allow-Origin' header is present on the requested resource.
Origin 'http://localhost:4200' is therefore not allowed access.

Il existe plusieurs outils liés à CORS dans l’écosystème Elixir, nous avons fait le choix de [Corsica][corsica_url] pour sa simplicité de mise en place.

Commencez par ajouter Corsica à la liste des dépendances dans le fichier mix.exs :

defp deps do
  [{:phoenix, "~> 1.1.1"},
   {  },
   {:corsica, "~> 0.4"}]
end

Puis lancez la commande suivante pour installer cette nouvelle dépendance :

mix deps.get

Une fois cela fait, il nous reste à ajouter la configuration Corsica pour notre endpoint, ajoutez la ligne suivante au fichier lib/tv_shows/endpoint.ex :

defmodule TvShows.Endpoint do
  # …

  # Ajoutez cette ligne avant l'appel au Router
  plug Corsica, origins: "*", allow_headers: ~w(content-type)

  # Appel au Router
  plug TvShows.Router
end

Ici nous autorisons toute origine à interroger notre API. Pour une application web standard c’est un réglage suffisant.

Parler JSONAPI

Communiquer via JSONAPI depuis Phoenix n’est pas particulièrement trivial. La structure des ressources est assez verbeuse et ne s’aligne pas particulièrement sur le fonctionnement des outils comme Ecto.

Heureusement, une librairie existe pour prendre en charge la sérialisation et le parsing du format JSONAPI : ja_serializer.

Tout comme Corsica, nous ajoutons ja_serializer à la liste des dépendances de notre application dans mix.exs :

{:ja_serializer, "~> 0.6.1"}

N’oubliez pas d’installer cette nouvelle dépendance :

mix deps.get

Une fois cela fait, ajoutez les deux plugs de ja_serializer à votre pipeline :api dans web/router.ex :

pipeline :api do
  plug :accepts, ["json-api"]
  plug JaSerializer.ContentTypeNegotiation
  plug JaSerializer.Deserializer
end

Afin d’indiquer à tous les objets View d’utiliser ja_serializer, nous allons l’ajouter à la définition de view dans web/web.ex :

def view do
  quote do
    use Phoenix.View, root: "web/templates"
    use JaSerializer.PhoenixView

    # …
  end
end

Créer nos ressources

Maintenant que notre application Phoenix est configurée, il est temps de créer nos ressources : Show et Episode.

Show

Un Show aura un titre et une liste d’épisodes. Un Episode aura un numéro et une date de diffusion.

Commençons par créer la ressource Show :

mix phoenix.gen.json Show shows title:string

N’oublions pas d’ajouter notre ressource au routeur dans web/router.ex. Ceci est à faire avant le reste ; sans cela les commandes suivantes peuvent ne pas fonctionner.

scope "/api", TvShows do
  pipe_through :api

  resources "/shows", ShowController, except: [:new, :edit]
end

Créons la table correspondante en base de données :

mix ecto.migrate

Modifions maintenant l’objet View qui vient d’être généré dans web/views/show_view.ex pour simplement utiliser ja_serializer :

defmodule TvShows.ShowView do
  use TvShows.Web, :view

  attributes [:title]
end

Nous devons également modifier notre contrôleur dans web/controllers/show_controller.ex pour l’adapter au format JSONAPI.

Plusieurs modifications sont à effectuer.

1/ Il nous faut remplacer l’appel à scrub_params/2 :

plug :scrub_params, "show" when action in [:create, :update]

par celui-ci :

plug :scrub_params, "data" when action in [:create, :update]

2/ Changer la gestion des paramètres pour create et update :

def create(conn, params) do
  changeset = Show.changeset(%Show{}, show_params(params))

  case Repo.insert(changeset) do
    # …
  end
end

def update(conn, params = %{ "id" => id }) do
  show = Repo.get!(Show, id)
  changeset = Show.changeset(show, show_params(params))

  case Repo.update(changeset) do
    # …
  end
end

defp show_params(params) do
  %{"data" => %{"attributes" => attributes}} = params
  attributes
end

Avec tout cela mis en place, nous pouvons commencer à utiliser notre API. Pas d’application EmberJS pour le moment, utilisons donc curl. Dans un terminal, lancez votre application Phoenix :

mix phoenix.server

Dans un autre terminal, utilisez curl pour créer quelques objets Show :

curl -X POST                                     \
     -H "Content-Type: application/vnd.api+json" \
     -H "Accept: application/vnd.api+json"       \
     -d '{
           "data": {
             "type": "shows",
             "attributes": {
               "title": "Breaking Bad"
             }
           }
         }'                                      \
     http://localhost:4000/api/shows

# {
#   "jsonapi": {
#     "version": "1.0"
#   },
#   "data": {
#     "type": "show",
#     "id": "3",
#     "attributes": {
#       "title": "Breaking Bad"
#     }
#   }
# }

Vérifiez également que la modification fonctionne correctement :

# Modification d'une Série
curl -X PATCH                                    \
     -H "Content-Type: application/vnd.api+json" \
     -H "Accept: application/vnd.api+json"       \
     -d '{
           "data": {
             "type": "shows",
             "attributes": {
               "title": "Breaking Bad For Realz"
             }
           }
         }'                                      \
     http://localhost:4000/api/shows/1

# {
#   "jsonapi": {
#     "version": "1.0"
#   },
#   "data": {
#     "type": "show",
#     "id": "1",
#     "attributes": {
#       "title": "Breaking Bad For Realz"
#     }
#   }
# }

Si tout fonctionne correctement, vous devriez être à même de lister les objets Show :

curl -H "Content-Type: application/vnd.api+json" \
     -H "Accept: application/vnd.api+json"       \
     http://localhost:4000/api/shows
# {
#   "jsonapi": {
#     "version": "1.0"
#   },
#   "data": [
#     {
#       "type": "show",
#       "id": "1",
#       "attributes": {
#         "title": "Breaking Bad For Realz"
#       }
#     },{
#       "type": "show",
#       "id":"2",
#       "attributes": {
#         "title":"Game of Thrones"
#       }
#     },{
#       "type": "show",
#       "id": "1",
#       "attributes":{
#         "title": "Breaking Bad For Realz"
#       }
#     }
#   ]
# }

Episode

Un Episode est lié à un Show et contient un numéro et une date de diffusion.

Comme pour Show nous allons générer notre ressource :

mix phoenix.gen.json Episode episodes number:string airing:date show_id:references:shows

Une fois cela fait, nous effectuons les mêmes modifications pour notre ressource Show.

Ajouter la route :

# web/router.ex
scope "/api", TvShows do
  pipe_through :api

  resources "/shows", ShowController, except: [:new, :edit]
  resources "/episodes", EpisodeController, except: [:new, :edit]
end

Créer la table en base de données :

mix ecto.migrate

Modifier le contrôleur. Notez cependant que la fonction episode_params/1 est légèrement différente car il faut également récupérer le show_id de notre épisode :

# web/controllers/show_controller.ex

# Remplacer
plug :scrub_params, "episode" when action in [:create, :update]
# Par
plug :scrub_params, "data" when action in [:create, :update]

def create(conn, params) do
  # …
end

def update(conn, params = %{ "id" => id }) do
  # …
end

defp episode_params(params) do
  %{
    "data" => %{
      "attributes" => attributes,
      "relationships" => relationships
    }
  } = params
  Dict.put_new(attributes, "show_id", relationships["show"]["data"]["id"])
end

Avant de pouvoir ajouter un Episode, nous devons modifier le modèle Episode pour ajouter show_id à la liste des champs obligatoires. Sans cela il serait impossible de lier un Episode à un Show.

Cela se fait dans le fichier web/models/episode.ex :

@required_fields ~w(number airing show_id)

Il faut également modifier notre objet View pour l’adapter comme il se doit dans web/views/episode_view.ex. On voit ici la différence avec la vue précédente, on peut voir une relation exprimée grâce à has_one.

defmodule TvShows.EpisodeView do
  use TvShows.Web, :view

  attributes [:number, :airing]

  has_one :show,
    field: :show_id,
    type: "show"
end

Vous pouvez maintenant jouer avec votre ressource Episode.

curl -X POST                                     \
     -H "Content-Type: application/vnd.api+json" \
     -H "Accept: application/vnd.api+json"       \
     -d '{
           "data": {
             "type": "episodes",
             "attributes": {
               "number": "s01e01",
               "airing": "2008-01-20"
             },
             "relationships": {
               "show": {
                 "data": {
                   "type": "show",
                   "id": 1
                 }
               }
             }
           }
         }'                                      \
     http://localhost:4000/api/episodes

# {
#   "jsonapi": {
#     "version":"1.0"
#   },
#   "data": {
#     "type": "episode",
#     "id": "1",
#     "attributes": {
#       "number": "s01e01",
#       "airing": "2008-01-20"
#     },
#     "relationships": {
#       "show": {
#         "type": "show",
#         "id": 1
#       }
#     }
#   }
# }

Un peu de ménage

Nos ressources fonctionnent. Il manque cependant quelques petites choses.

  1. Plusieurs fichiers sont maintenant inutiles
  2. Un Show ne liste pas ses épisodes
  3. La vue d’erreur ne respect pas JSONAPI

1/ Voyons ça dans l’ordre et commençons par faire un peu le vide dans les fichiers :

rm priv/static/css/app.css             \
   priv/static/favicon.ico             \
   priv/static/images/phoenix.png      \
   priv/static/js/app.js               \
   priv/static/js/phoenix.js           \
   web/controllers/page_controller.ex  \
   web/templates/layout/app.html.eex   \
   web/templates/page/index.html.eex   \
   web/views/layout_view.ex            \
   web/views/page_view.ex              \

2/ Pour faire en sorte qu’un Show liste ses épisodes, nous devons tout d’abord modifier le modèle dans web/models/show.ex :

defmodule TvShows.Show do
  use TvShows.Web, :model

  schema "shows" do
    field :title, :string
    # Ajoutez ici la relation
    has_many :episodes, TvShows.Episode

    timestamps
  end

  # …

  # Ajoutez une fonction pour obtenir tous les épisodes d'un Show
  def get_episodes(show) do
    TvShows.Repo.all assoc(show, :episodes)
  end
end

Nous devons également modifier la vue ShowView pour intégrer la liste des épisodes :

# web/views/show_view.ex
defmodule TvShows.ShowView do
  use TvShows.Web, :view

  attributes [:title]

  has_many :episodes,
    type: "episode"

  def episodes(show, _conn) do
    show
    |> TvShows.Show.get_episodes
    |> Enum.map(&(&1.id))
  end
end

3/ En ce qui concerne la vue d’erreur, nous vous laissons ça comme exercice, cela ne devrait pas être trop compliqué. Indice : regardez le fichier web/views/changeset_view.ex.

Petite pause

Après tout ce long travail, prenez le temps de faire une pause, prendre un café, un thé ou juste l’air. On se retrouve tout de suite pour l’application EmberJS !

L’application EmberJS

Créer l’application

Créons maintenant une application Ember CLI (attention de ne pas lancer cette commande dans le dossier du projet Phoenix) :

ember new tv_shows_front

Attention ! Au moment de la publication de cet article, Ember CLI ne crée pas encore d’application EmberJS 2 par défaut. Pensez donc à mettre à jour le fichier bower.json comme suit :

{
  "name": "tv-shows-front",
  "dependencies": {
    "ember": "~2.2",
    "ember-data": "~2.2",
    // …
  }
}

et à installer ces nouvelles versions :

bower install

Une fois cela fait, nous avons quelques modifications à faire pour pouvoir parler à notre backend Phoenix.

Commençons par régler notre environnement de développement. Nous allons ajouter une variable pour contenir l’URL à laquelle contacter notre API et désactiver Ember CLI Mirage qui ne nous est plus utile puisque nous avons cette API.

En passant, nous allons également configurer la politique de sécurité de notre application.

// config/environment.js
module.exports = function(environment) {
  // …

  if (environment === 'development') {
    ENV.apiHost = 'http://localhost:4000';

    ENV['ember-cli-mirage'] = {
      enabled: false
    }

    ENV.contentSecurityPolicy = {
      'connect-src': "'self' http://localhost:4000"
    }
  }

  // …
}

Utilisons maintenant notre variable pour configurer l’adapter de notre application, sans oublier de spécifier que tout se situe sous /api.

Pour cela, créons le fichier app/adapters/application.js :

import DS from "ember-data";
import ENV from "../config/environment";

export default DS.JSONAPIAdapter.extend({
  host: ENV.apiHost,
  namespace: 'api'
});

Créer nos ressources

Nous pouvons maintenant créer nos modèles, nos routes et nos templates.

Définissons tout d’abord nos deux modèles, Show et Episode :

// app/models/show.js
import DS from "ember-data";

export default DS.Model.extend({
  title: DS.attr(),

  episodes: DS.hasMany('episode')
});
// app/models/episode.js
import DS from "ember-data";

export default DS.Model.extend({
  number: DS.attr(),
  airing: DS.attr(),

  show: DS.belongsTo('show')
});

Ajoutons ensuite une route pour lister nos séries et leurs épisodes :

// app/router.js
Router.map(function() {
  this.route('shows');
});
// app/routes/shows.js
import Ember from "ember";

export default Ember.Route.extend({
  model() {
    return this.store.findAll('show');
  }
});

Reste à ajouter le template pour afficher tout ça dans app/templates/shows.hbs :

<h1>Tv Shows</h1>

{{#each model as |show|}}
  <h2>{{show.title}}</h2>

  {{#each show.episodes as |episode|}}
    <p>{{episode.number}} ({{episode.airing}})</p>
  {{/each}}
{{else}}
  <p>No TvShow yet!</p>
{{/each}}

Vous pouvez maintenant lancer votre application

ember serve

et vérifier que tout fonctionne en visitant l’URL http://localhost:4200/shows dans votre navigateur. Cela devrait ressembler à ceci :

Aperçu de l'application

Ajouter une ressource

Pour confirmer que tout fonctionne comme prévu, ajoutons un formulaire permettant d’ajouter une nouvelle série :

<h1>Tv Shows</h1>

{{#each model as |show|}}
  ...
{{/each}}

{{input enter="addShow"}}

et l’action correspondante dans notre route :

export default Ember.Route.extend({
  model() {  },

  actions: {
    addShow(title) {
      this.store.createRecord('show', { title: title }).save();
    }
  }
});

Nous pouvons maintenant utiliser notre formulaire et vérifier que tout fonctionne comme attendu.

Note: attention si vous testez sous Firefox, en effet ce dernier ajouter ; charset=UTF-8 dans le header Content-type ce qui a pour conséquence que la requête est rejetée par le serveur comme le demande la spécification JSONAPI.

Déploiement

Maintenant que nous avons une application fonctionnelle, il est temps de la déployer.

Comme indiqué dans le titre de cet article, nous allons déployer sur Heroku. Chaque application aura sa propre instance.

Déployer l’API

Dans cet article, nous supposons que vous connaissez déjà Git et Heroku. Dans le cas contraire, il est recommandé de lire quelques guides pour bien démarrer.

Commençons par initialiser un dépôt Git dans notre projet Phoenix :

git init
git add .
git commit -m "Initial commit"

Créez ensuite votre application sur Heroku :

heroku create --buildpack "https://github.com/HashNuke/heroku-buildpack-elixir.git"

Cela fait nous obtenons le nom de notre nouvelle application, par exemple mysterious-stream-3714.herokuapp.com. Notez ce nom, nous en aurons besoin pour configurer l’application EmberJS.

Ajoutons également l’addon Heroku Postgres pour la base de données :

heroku addons:create heroku-postgresql:hobby-dev

Et, pour être sûr que notre application tourne correctement, nous devons également ajouter le buildpack heroku-buildpack-phoenix-static :

heroku buildpacks:add https://github.com/gjaldon/heroku-buildpack-phoenix-static.git

Configuration

Nous devons maintenant faire en sorte que notre application puisse utiliser les variables d’environnement fournies par Heroku.

Dans le fichier config/prod.exs nous allons ajouter une ligne pour indiquer comment récupérer la clé secrète de l’application :

config :tv_shows, TvShows.Endpoint,
  http: [port: {:system, "PORT"}],
  url: [host: "example.com", port: 80],
  cache_static_manifest: "priv/static/manifest.json",
  secret_key_base: System.get_env("SECRET_KEY_BASE") # Ajoutez cette ligne

Faisons ensuite de même pour la base de données en ajoutant le code suivant :

# Configure your database
config :tv_shows, TvShows.Repo,
  adapter: Ecto.Adapters.Postgres,
  url: System.get_env("DATABASE_URL"),
  pool_size: 10

Dernière chose, nous devons configurer l’URL de l’application et activer SSL. Pour cela, effectuez le changement suivant :

- url: [host: "example.com", port: 80],
+ url: [
+   scheme: "https",
+   host: "mysterious-stream-3714.herokuapp.com",
+   port: 443
+ ],
+ force_ssl: [
+   rewrite_on: [:x_forwarded_proto]
+ ],

Nous pouvons maintenant supprimer l’appel au fichier prod.secret.exs dont nous n’avons plus besoin :

# Supprimez cette section du fichier config/prod.exs

# Finally import the config/prod.secret.exs
# which should be versioned separately.
import_config "prod.secret.exs"

Ainsi que la mise en cache du manifest des assets statiques :

  url: [
    scheme: "https",
    host: "mysterious-stream-3714.herokuapp.com",
    port: 443
  ],
  force_ssl: [
    rewrite_on: [:x_forwarded_proto]
  ],
- cache_static_manifest: "priv/static/manifest.json",
  secret_key_base: System.get_env("SECRET_KEY_BASE")

Reste à générer la variable d’environnement SECRET_KEY_BASE et à l’enregistrer sur Heroku. Commençons par générer sa valeur :

mix phoenix.gen.secret
# R/tvf…3zDZ

Cette commande génère une chaîne aléatoire que vous pouvez copier et entrer dans la configuration Heroku :

heroku config:set SECRET_KEY_BASE="R/tvf…3zDZ"

N’oublions pas de sauvegarder nos modifications dans Git :

git add .
git commit -m "Getting ready for Heroku"

Déploiement

Nous sommes maintenant prêts à déployer notre application. Comme pour toute application Heroku, une seule commande pour déployer :

git push heroku master

Une fois le déploiement effectué, nous devons tout de même créer les tables en base de données :

heroku run mix ecto.migrate

Nous en avons maintenant terminé avec l’API ! Vous pouvez comme précédemment jouer avec grâce à curl.

Déployer le frontend

Ember CLI initialise un dépôt git dès la création de l’application. Nous pouvons donc commencer par sauvegarder tout ce que nous avons fait jusque-là :

git add .
git commit -m "Initial commit"

Créons maintenant une application Heroku. Cette fois nous allons utiliser le buildpack Ember CLI :

heroku create --buildpack https://github.com/tonycoco/heroku-buildpack-ember-cli.git

Configuration

Comme nous l’avons fait précédemment pour l’environnement de développement, nous allons configurer notre application Ember pour qu’elle puisse communiquer avec son API.

Commençons par modifier le fichier config/environment.js :

if (environment === 'production') {
  ENV.apiHost = process.env.API_HOST;
}

Plutôt que d’indiquer l’adresse de l’application Phoenix en dur, nous allons une nouvelle fois tirer parti des variables d’environnement Heroku.

heroku config:set API_HOST="https://mysterious-stream-3714.herokuapp.com"

De nouveau, n’oublions pas de sauvegarder nos changements :

git add .
git commit -m "Getting ready for Heroku"

Déploiement

Cette fois encore, nous allons tout simplement pousser notre code sur Heroku pour déployer :

git push heroku master

Et voilà !

Le mot de la fin

Nous avons terminé ! Nos applications tournent et communiquent, nous pouvons maintenant avancer et itérer dessus.

Ces applications sont resté volontairement simplistes mais vous êtes maintenant à même de construire toutes les applications qui vous passent par la tête.

Si ce guide vous a été utile, parlez-nous-en dans les commentaires, partagez-le et surtout… amusez-vous bien !

Publié le 11 janvier 2016

Notre vision des choses vous correspond ? Vous avez envie de travailler avec nous ?