Mes premiers pas avec cakephp 3
Avant propos
Profitant d’un peu de temps libre j’ai décidé de m’essayer à la dernière version en date de CakePHP, à savoir CakePHP 3. Je suis donc parti dans l’idée de pondre un Twitter-like en version allégée (très allégée).
Après avoir posé le contexte en présentant mon Twitter fait maison, je développerai cet article en apportant des précisions sur le code et les différentes fonctionnalités de CakePHP 3 que j’ai utilisées pour construire le site. A noter que l’objectif premier est de se concentrer sur les spécificités de CakePHP 3. Je suggère - afin de tirer meilleur parti de cette lecture - de bénéficier en amont d’une certaine expérience autour d’outils comme Composer, d’être à l’aise avec le modèle MVC, ou encore de savoir ce qu’est un ORM.
Au départ, mes objectifs étaient de comprendre comment un projet CakePHP 3 est structuré et de découvrir les fonctionnalités offertes par le framework. C’est dans cet esprit que je vais écrire, tâchant de rester dans une simple description. Le but n’est donc pas de comparer CakePHP 3 à d’autres frameworks, ni de répondre directement à des questions comme “Cake est-il adapté pour tel type d’application ?”. D’autant que la forme ne s’y prête pas dans la mesure où un projet comme celui-ci ne permet pas de couvrir tous ses aspects.
Cet article est un bilan sur les quelques journées que j’ai passées à jongler entre mon IDE et la documentation officielle de CakePHP 3. Je l’écris avant tout pour moi, afin qu’il puisse éventuellement me servir de point de départ si j’ai un jour besoin de travailler avec ce framework. Ceci étant dit, comme il semble qu’il n’existe encore (du moins à l’heure où j’écris) que relativement peu de ressources sur le sujet (en dehors de la documentation officielle et en français du moins), je serais content d’apprendre qu’il a pu servir à d’autres développeurs.
Contexte
L’application que j’ai réalisée s’inspire ouvertement du fonctionnement de Twitter. Voilà le contenu de ma check-list en début de projet :
- Les utilisateurs enregistrés peuvent poster des messages (des tweets) de moins de 140 caractères
- Tous les tweets apparaissent en page d’accueil dans l’ordre du plus récent au plus ancien
- La page d’un utilisateur affiche les détails de son profil et la liste de ses tweets
- Possibilité d’ajouter des #hashtags dans les tweets, cliquer sur un hashtag affiche la liste de tous les tweets qui le mentionnent
- Pas de pagination pour les tweets, charger les tweets suivants au défilement de la page
- Les utilisateurs peuvent modifier les détails de leur profil et télécharger une image pour personnaliser leur avatar
- Afficher un bloc listant les hashtags les plus populaires
N’ayant pas souhaité déployer sur un serveur, j’ai pris la peine de réaliser cette vidéo de présentation au cas où vous souhaiteriez voir l’application tourner.
Les sources sont disponibles sur Github. Je suggère de conserver l’onglet Github ouvert pendant la lecture afin de pouvoir facilement faire des parallèles entre les notions abordées et le code de l’application.
Le fichier database.sql contient les requêtes à exécuter pour ajouter les tables dans une base de données MySQL.
Généralités et organisation du code
A supposer que vous souhaitiez démarrer un projet CakePHP 3, la seule chose à faire après avoir installé les pré-requis nécessaires (PHP 5.3 et Composer) est de lancer cette commande :
composer create-project --prefer-dist -s dev cakephp/app my_app_name
Composer téléchargera CakePHP 3 et ses dépendances dans un nouveau dossier my_app_name
. Le
script d’installation
est lancé automatiquement et propose de configurer les droits des répertoires pour vous. Vous pourrez
alors
commencer à travailler sur le site en utilisant le serveur HTTP embarqué :
bin/cake server
Comme pour beaucoup de frameworks web, CakePHP 3 propose une implémentation du pattern composite MVC pour la gestion du cycle de vie des requêtes HTTP. Le code de l’application va donc être segmenté en trois couches, chacune pouvant tirer parti d’un certain nombre d’éléments : composants, comportements, helpers, etc … Il s’agit là de termes propres à CakePHP que je développerai plus tard.
Le fichier d’entrée de l’application est /webroot/index.php
. Son rôle est de
déclencher le
processus de démarrage de l’application, puis d’instancier le dispatcher
qui se
chargera de déléguer la requête au bon contrôleur. /webroot/
est le répertoire auquel doit
être
configuré le document root.index.php
devrait y être le seul fichier PHP
aux côtés d’autres
ressources web comme des images, des fichiers CSS ou Javascript.
Si vous avez besoin d’intervenir sur des étapes du démarrage de l’application, vous aurez
alors besoin d’éditer
un peu de code dans /config/
. Dans /config/app.php
sont notamment définis les
paramètres
de connexion à la base de données, le niveau de debug ou encore la gestion des sessions. Les routes sont
définies
programmatiquement dans /config/routes.php
.
Pour le reste, le répertoire /src/
se chargera d’héberger les sources de l’application.
Les
contrôleurs, les modèles ou encore les templates sont situés dans des sous-répertoires de
/src/
. Cette
même structure est reprise au travers des
plugins. Pratique pour packager une application dans le but de la réutiliser dans une autre
(conceptuellement proche des bundles de Symfony 2).
Les routes
Déclarer des routes
Les routes sont définies dans /config/routes.php
à l’intérieur de
scopes. Un
scope permet - entre autres - de factoriser plusieurs routes afin de leur attribuer un préfix.
Router::scope('/api/', function ($routes) {
$routes->connect('/tweets', [
'controller' => 'Tweets',
'action' => 'index'
], [
'_name' => 'tweets_index'
]);
});
Le code ci-dessus connecte la route /api/tweets
au dispatcher. Le dispatcher se chargera de
passer la
requête à la méthode TweetsController::index()
. Le tableau d’options en troisième
paramètre de la
méthode connect()
est facultatif. Définir l’option _name
permet de
générer les urls
plus facilement depuis les templates (vu plus tard).
Déclarer des ressources restful
Supposons maintenant qu’il s’agisse de mettre en place une API restful. CakePHP 3 offre la possibilité de s’affranchir de portions de code répétitives en tirant parti de quelques conventions sur lesquelles reposent des comportements par défaut du framework. Cette philosophie - sans doute héritée de Ruby on Rails - est omniprésente. Qu’il s’agisse de travailler avec les routes ou encore avec l’ORM, elle peut faire gagner un temps précieux.
Pour l’exemple, jetons un oeil sur ce tableau :
GET | /api/tweets.:format | TweetsController::index() |
GET | /api/tweets/:tweet_id.:format | TweetsController::view($tweet_id) |
POST | /api/tweets.:format | TweetsController::add() |
PUT | /api/tweets/:tweet_id.:format | TweetsController::edit($tweet_id) |
PATCH | /api/tweets/:tweet_id.:format | TweetsController::edit($tweet_id) |
DELETE | /api/tweets/:tweet_id.:format | TweetsController::delete($tweet_id) |
Ces routes peuvent être configurées automatiquement avec ce seul extrait de code :
Router::scope('/api/', function ($routes) {
$routes->extensions(['xml', 'json']);
$routes->resources('tweets');
});
Les routes auto-déclarées
Une chose à savoir à propos de CakePHP 3 est qu’il connecte automatiquement une route au dispatcher pour chaque nouvelle action de contrôleur. Le nom de ces routes est défini en fonction du nom du contrôleur et de la méthode. Si bien que le code si dessous :
class TweetsController
{
function index()
{
...
}
function add()
{
...
}
function load()
{
...
}
}
Connectera automatiquement les routes /tweets
, /tweets/add
et /tweets/load
.
Ce
comportement est induit par cette instruction du fichier /config/routes.php
:
$routes->fallbacks('InflectedRoute');
Naturellement, supprimer cette instruction supprimera ce comportement.
La couche Controller
Les classes de contrôleur
Les classes de contrôleur sont situées dans /src/Controller/
. Elles doivent étendre la
classe \Cake\Controller\Controller
et leur nom doit - par convention - se terminer par le suffixe Controller
.
L’application Twitthome utilise quatre classes de contrôleur : TweetsController
,
HashtagsController
,
UsersController
et AccountParametersController
. Comme suggéré dans la
documentation
officielle, ces classes étendent AppController
. Cette pratique est un moyen simple de
définir des
comportements globaux pour l’application, comme par exemple des règles liées à l’authentification.
L’instruction ci-dessous extraite de la classe AppController
autorise les accès
non-authentifiés
aux actions (i.e. aux méthodes) index
, view
et display
pour tous
les
contrôleurs.
$this->Auth->allow(['index', 'view', 'display']);
Le router mis à part, le contrôleur est le point d’entrée de l’application. Depuis le
contrôleur, CakePHP
3 permet de manipuler la requête et la réponse HTTP au moyen des attributs request
et response
.
Les paramètres des routes sont quant à eux injectés en tant que paramètres des méthodes (des actions).
Le contrôleur délègue la génération du contenu de la réponse à une vue. La méthode \Cake\Controller\Controller::render()
est automatiquement appelée et se charge d’invoquer le template correspondant à l’action (vu
plus tard).
Le contrôleur peut passer des données au template au moyen de
\Cake\View\ViewVarsTrait::set()
.
$this->set([
'tweets' => $tweets,
'hashtag_name' => $name
]);
Les composants (components)
Les composants sont des objets qui peuvent être invoqués par un contrôleur dans le but de remplir une tâche spécifique. Le core de CakePHP 3 embarque des composants pour l’authentification, la manipulation des cookies ou encore l’utilisation de messages flash.
Charger des composants dans un contrôleur peut se faire à l’intérieur du hook
initialize()
du
contrôleur.
public function initialize()
{
$this->loadComponent('Flash');
}
Une fois fait, le composant est accessible en tant que variable d’instance du contrôleur :
class UsersController extends Controller
{
public function add()
{
...
$this->Flash->success(__('Your account has been created.'));
...
}
}
Créer ses propres composants est une solution simple et ludique permettant d’isoler de la logique dans des classes utilisables à l’intérieur d’un ou plusieurs contrôleurs. “Où placer la logique ?” est une des premières questions que je me suis posées. Un cas pratique d’utilisation était la possibilité de télécharger une photo de profil pour les utilisateurs. Le téléchargement d’une image représente une portion de code susceptible de vouloir être ré-utilisée à différents emplacements de l’application. Comme CakePHP 3 ne semble pas embarquer de composant d’injection de dépendances qui permettrait de travailler avec des classes de service (à l’instar de Symfony 2 par exemple) et qu’avoir recours à l’héritage n’est pas toujours approprié, je me suis lancé de la construction de mon propre composant d’upload.
Le composant est une classe résident dans /src/Controller/Component/
dont le nom doit se
terminer par le
suffixe Component
. Si la méthode initialize()
du composant attend des
paramètres (comme c’est
le cas pour mon FileUploadComponent
dont hérite ImageUploadComponent
) :
class FileUploadComponent extends Component
{
public function initialize(array $config)
{
$this->upload_dir = $this->_getSystemPath($config['upload_dir']);
}
}
Les contrôleurs utilisant le composant fourniront ces paramètres lors du chargement de ce dernier.
Exemple dans mon
AccountParametersController
:
class AccountParametersController extends AppController
{
public function initialize()
{
parent::initialize();
$this->loadComponent('ImageUpload', [
'upload_dir' => 'webroot/img/avatars'
]);
}
}
La couche Model
Les tables (repositories)
Extraire des données
Utiliser l’ORM pour extraire les informations de la base de données est facile et ne requiert la
création d’aucune
classe personnalisée.
A l’intérieur de TweetsController
, l’instruction ci-dessous permet d’extraire
l’ensemble
des lignes de la table tweets
.
$tweets = $this->Tweets->find('all')->toArray();
Encore une fois, CakePHP 3 repose sur des conventions pour faire fonctionner cette instruction :
- Par soucis de performance (je suppose), les données des tweets ne sont chargées automatiquement que
dans le
TweetsController
. Le chargement de ce modèle de données devra être fait manuellement s’il s’agit d’un autre contrôleur. - Le nom de la table dans la base de données doit correspondre au nom du contrôleur transformé en
lower-case +
underscores - soit pour cet exemple :
tweets
.
CakePHP 3 matérialise l’interface entre l’application et une table de la base de données par
la création
d’un objet de type \Cake\ORM\Table
. Sorti des conventions listées plus haut, pour
créer des
règles de validation ou encore pour exploiter des relations avec d’autres tables, vous aurez
besoin de créer
une classe spécialisée pour matérialiser cette interface.
La classe TweetsTable
qui étend \Cake\ORM\Table
dans le fichier /src/Model/Table/TweetsTable.php
sert justement ce rôle. Le hook initialize()
est utilisé pour définir les relations avec
les autres
tables.
public function initialize(array $config)
{
$this->belongsTo('Users');
$this->belongsToMany('Hashtags');
}
La documentation officielle fournit les informations nécessaires pour utiliser les relations entre les tables.
Il est intéressant de noter que cet appel : $this->Tweets->find('all');
va - de
manière
transparente - exécuter la méthode \Cake\ORM\Table::findAll()
. Il est donc possible de
modifier le
comportement de cette méthode en la redéfinissant à l’intérieur de TweetsTable
. Voici
comment
demander à l’ORM de charger les données des modèles associés, et de trier les résultats du plus
récent au plus
ancien :
public function findAll(Query $query, array $options)
{
$query->contain(['Users', 'Users.AccountParameters']);
$query->order(['Tweets.created' => 'DESC']);
return $query;
}
Cette technique permet de garder les classes de contrôleur DRY tout en continuant d’exploiter toute la puissance de l’ORM. De la même manière il est possible de définir d’autres finders. Cette méthode est utilisée afin d’extraire les tweets pour un hashtag donné :
// Dans la classe TweetsTable
public function findTagged(Query $query, array $options)
{
$query->contain(['Users', 'Users.AccountParameters', 'Hashtags']);
$query->matching('Hashtags', function ($q) use ($options) {
return $q->where(['Hashtags.name' => $options['tag_name']]);
});
$query->order(['Tweets.created' => 'DESC']);
return $query;
}
// Dans la classe HashtagsController
$this->Tweets->find('tagged', [
'tag_name' => $tag_name
]);
Insérer de nouvelles lignes
Insérer de nouvelles lignes dans la base de données ne pose pas de problème particulier.
Pour l’exemple, mon application requiert de pouvoir enregistrer de nouveaux utilisateurs. A chaque
nouvel
utilisateur, une nouvelle entrée dans la table account_parameters
doit également être
ajoutée.
Le code ci-dessous permet d’accomplir cette tâche avec très peu de code :
class UsersController extends AppController
{
public function add()
{
...
$user = $this->Users->newEntity($user_data);
$user->set('account_parameter', $this->AccountParameters->newEntity());
$this->Users->save($user);
...
}
}
Valider des données
CakePHP 3 propose une double approche pour permettre de valider les données d’une entité.
De lors que des données de requête sont converties en entité, CakePHP 3 effectue automatiquement une
validation
basée sur les règles configurées dans le hook validationDefault()
. Il est possible à ce
niveau de s’assurer
qu’une chaine de caractères respecte un format pré-défini ou encore de vérifier qu’un
attribut reçoit
bien une valeur en s’inspirant de ce code :
class UsersTable extends Table
{
public function validationDefault(Validator $validator)
{
return $validator
->notEmpty('username', __('Username must not be empty'))
->notEmpty('password', __('Password must not be empty'))
->notEmpty('email', __('E-mail must not be empty'))
->add('email', 'validFormat', [
'rule' => 'email',
'message' => __('E-mail must be valid')
])
->notEmpty('first_name', __('First name must not be empty'))
->notEmpty('last_name', __('Last name must not be empty'));
}
}
D’autre part, lorsqu’une entité s’apprête à être persistée en base de données, CakePHP
3 s’assure
que les données respectent les contraintes définies dans le hook buildRules()
. Il s’agit
là de règles de domaine, elles sont relatives à un besoin métier
de l’application.
Vous pourriez par exemple vous assurer que le statut de ce ticket l’autorise à recevoir un
commentaire, ou
bien que ce produit est toujours disponible avant de l’ajouter au panier. L’exemple
ci-dessous est
extrait de Twitthome et montre comment s’assurer de l’unicité des champs
username
et email
de la table users
:
class UsersTable extends Table
{
public function buildRules(RulesChecker $rules)
{
$rules->add($rules->isUnique(['username']));
$rules->add($rules->isUnique(['email']));
return $rules;
}
}
Les comportements (behaviors)
Tout comme les composants permettent de factoriser de la logique des contrôleurs, les comportements
permettent de
réutiliser de la logique de la couche Model. La documentation officielle de CakePHP 3 les
présente comme étant “conceptuellement similaires aux traits”. Bien que n’ayant pas eu
besoin de
créer mes propres comportements, j’ai pu tirer parti de l’utilisation du TimestampBehavior
(défini dans le core du framework) pour mettre à jour automatiquement les champs created
et
modified
des tables tweets
et users
. Voici comment utiliser un comportement dans une
table :
class UsersTable extends Table
{
public function initialize(array $config)
{
$this->addBehavior('Timestamp');
}
}
Les entités
Les objets table manipulent des objets de type \Cake\ORM\Entity
. Chaque instance représente
une ligne d’une
table de la base de données. Comme pour les tables, il est possible de créer des classes spécialisées
qui seront
utilisées par l’ORM pour représenter les entités de l’application. Ces classes sont définies
dans des
fichiers à l’intérieur de /src/Model/Entity/
et leur nom (par convention) correspond
au nom de la
table ramené au singulier.
Un intérêt d’utiliser des classes spécialisées réside dans la possibilité de surcharger les
accesseurs et les
mutateurs des différents attributs. Pratique notamment dans le cas de l’entité User
pour crypter
le mot de passe de manière transparente :
class User extends Entity
{
protected function _setPassword($password)
{
return (new DefaultPasswordHasher)->hash($password);
}
}
J’ai utilisé cette même technique afin d’extraire des informations du contenu d’un tweet, comme les hashtags ou les liens externes.
La couche View
Les templates
Les templates sont des fichiers contenant essentiellement du code HTML. Ils sont situés dans
/src/Templates/
et portent l’extension .ctp
. Le répertoire contient les
templates
responsables du rendu d’une action spécifique d’un contrôleur, mais également des fichiers
responsables
du rendu des éléments,
des cellules
(vu un peu après), ou encore des layouts.
Par défaut, le rendu des actions des contrôleurs est encapsulé à l’intérieur du fichier /src/Template/Layout/default.ctp
.
C’est dans ce fichier que doit être inséré le code commun à tous les templates. Pour mieux
comprendre, partons
du principe que le layout par défaut devrait contenir au minimum le code suivant :
<!DOCTYPE html>
<html>
<head>
<title><?= $this->fetch('title') ?></title>
</head>
<body>
<?= $this->fetch('content') ?>
</body>
</html>
L’affichage généré par le contrôleur sera rendu à l’emplacement de <?= $this->fetch('content')
?>
. Pour fonctionner, les templates doivent être nommés en corrélation avec le nom des
méthodes des
contrôleurs. Ainsi la méthode TweetsController::index()
cherchera par default le fichier
/src/Template/Tweets/index.ctp
.
Le fonctionnement des layouts est basé sur la possibilité de travailler avec des blocks
de vue à
l’intérieur de vues étendues. Comme vu précédemment, le rendu de l’action sera positionné
dans le block
content
, mais il est possible de définir d’autres blocks de façon arbitraire.
Pour l’application Twitthome, je m’étais donné à faire une sidebar dont le contenu serait
susceptible de
changer d’une page à l’autre. Un cas typique d’utilisation des blocks de vue. J’ai
donc
modifié mon layout default.ctp
afin qu’il se rapproche de quelque chose comme ça :
...
<body>
<div class="row">
<aside class="col-md-4">
<?= $this->fetch('sidebar') ?>
</aside>
<div class="col-md-8">
<?= $this->fetch('content') ?>
</div>
</div>
</body>
...
Le contenu du block sidebar
peut maintenant être défini dans un autre template, dans /src/Template/Tweets/index.ctp
par exemple :
<?php $this->start('sidebar'); ?>
<p>Contenu de la sidebar !</p>
<?php $this->end(); ?>
<?php foreach($tweets as $tweet): ?>
...
<?php endforeach; ?>
Dans cet exemple, la variable $tweets
est issue de l’appel à la méthode \Cake\View\ViewVarsTrait::set()
dans le contrôleur (cf. partie sur les classes de contrôleur).
Les helpers
Les helpers sont ce qui facilite la création des templates et ce qui la rend plus ludique. A l’image des composants pour les contrôleurs ou des comportements pour les tables, les helpers permettent de ré-utiliser de la logique de vue. Le core de CakePHP 3 embarque une dizaine de classes helpers chargées par défaut dans les vues et qui permettent entre autres :
De générer des urls :
<a href="<?= $this->Url->build(['_name' => 'login']) ?>"><?=
__('Sign in') ?></a>
D’afficher des formulaires :
<?= $this->Form->create(new Tweet()); ?>
<?= $this->Form->input('content', [
'label' => false,
'class' => 'form-control',
'placeholder' => __('What\'s up ?')
]); ?>
<?= $this->Form->button(__('Tweeter')); ?>
<?= $this->Form->end(); ?>
Ou encore d’insérer une feuille de style :
<?= $this->Html->css('app.min.css') ?>
Des classes helpers personnalisées peuvent être ajoutées dans /src/View/Helper
, leur nom
doit se
terminer par le suffixe Helper
. L’exemple ci-dessous est utilisé dans l’application
Twitthome pour générer le code HTML correspondant à l’avatar d’un utilisateur.
class AvatarHelper extends Helper
{
public $helpers = ['Html'];
public function render($avatar_file_name)
{
$avatar_path = $avatar_file_name ?
'avatars/' . h($avatar_file_name) : 'no-avatar.jpg';
return $this->Html->image($avatar_path, [
'alt' => 'Avatar',
'class' => 'img-responsive thumbnail'
]);
}
}
// Dans un template ...
...
<?= $this->Avatar->render($avatar_file_name) ?>
...
Comme le montre cet exemple, un helper peut dépendre d’autres helpers. Les classes d’helper
correspondant
aux éléments du tableau public $helpers
seront automatiquement instanciées et ajoutées
comme attributs.
Si vous souhaitez charger vos helpers pour les rendre utilisables à l’échelle de votre application,
vous pouvez
demander à CakePHP 3 de les instancier dans AppView
via le hook
\Cake\View\View::initialize()
.
class AppView extends View
{
public function initialize()
{
$this->loadHelper('Avatar');
}
}
Les cellules (cells)
Il arrive que des fragments de page HTML dépendent de données qui n’ont pas de lien direct avec le contenu principale de la page. Par exemple : un nuage de tags, un feed Instagram ou une remontée des posts les plus récents d’un blog. Si ces fragments apparaissent dans plusieurs templates, cela implique que les données doivent être rassemblées et passées à la vue dans chaque action de contrôleur correspondant. En adoptant cette approche, le code des contrôleurs risque d’être rapidement pollué. Utiliser des cellules est une solution plus pratique pour répondre à ce genre de problématiques.
La documentation officielle du
framework définit les cellules comme “des mini-controllers qui peuvent invoquer de la logique
de vue et
afficher les templates”. Dans le cadre de Twitthome, j’ai utilisé une cellule pour afficher
le
bloc “Tendances”. La cellule existe au travers de deux fichiers. Le premier est une classe
définie dans
/src/View/Cell/PopularHashtagsCell.php
:
class PopularHashtagsCell extends Cell
{
public function display()
{
$this->loadModel('Hashtags');
$hashtags = $this->Hashtags->find('popular')->toArray();
$this->set('hashtags', $hashtags);
}
}
Le comportement de cette classe est similaire à celui d’un contrôleur. Celle-ci est capable de
charger un
modèle, dans le but d’extraire les informations nécessaires de la base de données. Le second
fichier est le
template responsable du rendu de la cellule. Ce template est définit dans /src/Template/Cell/PopularHashtags/display.ctp
.
Enfin la dernière étape consiste à afficher la cellule à l’intérieur d’un template. Une méthode est justement prévue pour tenir ce rôle.
<?= $this->cell('PopularHashtags'); ?>
Le mot de la fin
Il reste évidemment de nombreux points à aborder. Certains sur lesquels je me suis penchés sont volontairement passés sous silence (comme notamment la partie sur l’internationalisation) afin de ne pas trop alourdir la lecture de cet article. D’autres sujets mériteraient une attention particulière, comme l’outil en ligne de commande, la gestion du cache, les logs ou encore l’intégration des tests.
Ceci étant dit, si cet article ne peut pas prétendre couvrir (même de loin) tous les aspects de CakePHP 3, j’ai bon espoir qu’il aide à se forger un premier avis sur le framework et puisse éventuellement servir de support pour le démarrage d’un projet. Pour aller plus loin, la documentation officielle est plutôt bien fournie. Elle contient des exemples d’applications, un cookbook complet et une documentation soignée de l’API.
Si le coeur vous en dit, je vous encourage à commenter si vous pensez pouvoir souligner certains axes d’amélioration, autant sur le support (Twitthome) que sur la forme. Je vous remercie pour la lecture et happy coding à tous !