Blog > Calendrier de l'avent > Headless is more

Headless is more

headless is more

Introduction

Il existe bon nombre de CMS basés ou connectables avec un projet Symfony mais il existe un genre relativement récent de CMS dont l’approche as a service promet rapidité, productivité, ergonomie, scalabilité, inter-opérabilité et la parallelisation des canaux de communication.

La philosophie de base des CMS headless est de décentraliser la gestion du contenu afin d’être capable de consommer celui-ci via les différents canaux qui peuvent en avoir besoin. On se rapproche ainsi du principe de séparation des responsabilités, en séparant le contenu, qui relève de la responsabilité du marketing, de sa représentation, responsabilité des designers et développeurs.

Contentful est probablement le cms headless le plus cher du marché mais c'est également d'après moi le plus complet en cette fin d'année 2017. D'ailleurs, hasard du calendrier je vous le jure, ils viennent d'annoncer une nouvelle levée de fonds de 28M$, voilà de quoi distancer encore plus les concurrents.

Pour cette raison, j'ai décidé de vous le présenter aujourd'hui néanmoins, si vous voulez continuer le voyage après cet article, je vous conseille d'aller voir du côté de Directus, de Prismic, Cockpit, GraphCMS que j'ai découvert dernièrement et allez voir l'annuaire des CMS Headless: https://headlesscms.org.

Alors c'est parti, je vous emmène avec moi, on va refondre le site de l’AFSY afin de lui offrir un système puissant de gestion de contenu (ux, versionnement, relecture, publication, rss, édition simultanée/collaborative des éditeurs...).

A tout moment, vous pourrez aller voir le dépôt compagnon sur lequel j'ai fait mes commits en préparant cet article ! De plus, vous pouvez voir ce que ca donne ici: https://afsy.troopers.agency !

Tout d’abord, définissons brièvement les fonctionnalités attendues et qui nous serviront de sommaire:

  • une page d’accueil pour présenter l’afsy, les objectifs, les événements (passés et à venir)
  • un blog de qualité filtrable par catégorie avec une catégorie spéciale “calendrier de l’avent” (un article par jour et template spécial)
  • un formulaire pour proposer un événement

TL;DR / Sommaire

1. Afficher une page d'accueil

1.1 Création d'un projet vide

Pour se lancer, je vais partir d’un projet vide mais vous pouvez partir d’un projet existant tant qu’il est en 2.7+. En dessous, il sera difficile d’utiliser le bundle aidant à la connexion avec le cms.

symfony new afsy 3.4

et on va enchainer directement sur l'installation du bundle officiel:

composer require contentful/contentful-bundle

avec la déclaration du bundle dans le Kernel:

class AppKernel extends Kernel
{
    public function registerBundles()
    {
        $bundles = [
            //...
            new Contentful\ContentfulBundle\ContentfulBundle(),
        ];
    }
}

Assurez-vous qu'au moins un moteur de template est défini dans la configuration framework.templating pour permettre au bundle de fonctionner:

#config.yml
framework:
  templating: { engines: ['twig'] }

1.2 Création de l'espace Contenful

Désormais, destination https://contentful.com ! On va créer un compte si ce n'est pas déjà fait et ensuite on va créer un espace. Dans le jargon de Contentful, un espace se résumera très souvent à un projet mais il est tout à fait possible d'imaginer d'autres organisation. La notre se nommera afsy et partira sur un projet vide:

nouvel espace

Le champ language est là pour définir la langue par défaut des contenus qui vont être créés, il sera possible d'en ajouter par la suite mais comme c'est pour l'AFSY, on va choisir Francais 🇫🇷 .

La deuxième étape est alors de définir le modèle de notre contenu, on y est d'ailleurs invité dès le début:

Créer un type de contenu

On va commencer tranquille avec le type Page qui permettra de faire une première intégration avec notre projet Symfony de manière facile et rapide:

Content type Page

Je vous conseille de jouer un peu avec le système, pour ma part, j'ai trouvé l'interface très bien faite et adaptée à un profil technique comme le notre mais je vous laisse vous faire votre propre avis :)

Ensuite, si vous n'êtes pas allergiques à npm, je vous conseille d'installer le paquet contentful-import qui va vous permettre d'importer facilement schémas, contenus et assets, c'est idéal pour potentiellement capitaliser entre des projets ou dans le cadre de tests, cela fait d'excellentes données de test (fixtures):

npm install -g contentful-import

pour vous générer les fichiers json, j'ai utilisé son collègue contentful-export

Une fois que c'est fait, vous pouvez alors télécharger le fichier page.json pour l'importer dans votre space (ou dans un nouvel espace, vous faites bien comme c'est le plus pratique pour vous cher ami):

contentful-import --space-id YOUR_SPACE_ID --management-token YOUR-MGTOKEN --content-file page.json

Pour récupérer votre space-id ainsi que votre delivery_token (qu'il vous faudra dans quelques minutes), ça se passe dans le menu API > Content delivery / preview tokens > Website key

Pour récupérer/générer votre management-token, ça se passe dans le menu API > Content management tokens

Allez voir dans la partie Content, vous devriez avoir la page d'accueil, cela suffira pour commencer à jouer avec cette partie de notre cms.

1.3. Retour à Symfony

configuration

Pour faire le lien avec votre espace contenful, il faut définir 2 choses relatives à configuration de Contentful: le spaceId et le token.

#parameters.yml.dist
parameters.yml:
    contentful_delivery_space: spaceID
    contentful_delivery_token: token

#config.yml
contentful:
  delivery:
    space: '%contentful_delivery_space%'
    token: '%contentful_delivery_token%'

Contrôleur + Vue

Pour afficher cette belle page dans notre site, on va avoir besoin d'un controller PageController avec une action showAction qui sera chargée d'aller chercher dans l'api de Contentful la page relative au slug passé en request (homepage par défaut) et d'afficher son contenu. Avant de faire la vue et l'action, on va installer un bundle nous permettant de convertir du markdown en html pour pouvoir ensuite l'interpréter, le gros classique est le KnpMarkdownBundle donc:

composer require knplabs/knp-markdown-bundle

et puis l'immanquable ajout dans l'AppKernel (on tient bon, Flex sera bientôt partout ✊):

//src/AppKernel.php
$bundles = [
    //...
    new Knp\Bundle\MarkdownBundle\KnpMarkdownBundle(),
];

Allons-y pour le controller et l'action showAction:

<?php
//src/AppBundle/Controller/CMS/PageController.php
namespace AppBundle\Controller\CMS;

use Sensio\Bundle\FrameworkExtraBundle\Configuration\Route;
use Symfony\Bundle\FrameworkBundle\Controller\Controller;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;

class PageController extends Controller
{
    /**
     * @Route("/", name="homepage")
     * @Route("/{slug}", name="app_cms_page_show")
     */
    public function showAction($slug = 'homepage')
    {
        $client = $this->get('contentful.delivery');
        $query = new \Contentful\Delivery\Query;
        $query->setContentType('page')
            ->where('fields.slug', $slug)
            ->setLimit(1);
        $entry = $client->getEntries($query)[0];

        if (!$entry) {
            throw new NotFoundHttpException;
        }

        return $this->render('cms/page/show.html.twig', [
            'page' => $entry,
        ]);
    }
}

et voici une vue qui va faire honneur à notre contenu:

{# app/Resources/views/cms/page/show.html.twig #}
{% extends "::base.html.twig" %}

{% block body %}
    <h1>{{ page.getName() }}</h1>
    {{ page.getText()|markdown|raw }}
{% endblock %}

Si on va sur la page d'accueil... 🎊 ça communique avec Contentful et les modifications se répercuteront bien sur dans le site lorsqu'on décidera de les publier (avec quelques petits secondes de délais, cache oblige) !

C'est un peu minimaliste comme page d'accueil mais on ne va pas en rester là; je veux y afficher une liste d'événements et j'ai envie d'avoir un design et un template spécifique pour ma page d'accueil et ce n'est pas en markdown qu'on va le faire, rassurez-vous !

Je vous laisse créer une autre page, par exemple la page a-propos pour tester que la route app_cms_page_show fonctionne bien sur les autres pages (que la page homepage).

L'organisation du contenu dans les pages

Pour rendre la page d'accueil attractive, on m'a demandé d'avoir 3 parties:

  1. une cover permettant de mettre en avant le logo, la base ligne de l'asso et le bouton d'inscription au google group

  2. les 3 objectifs de l'asso:

objectifs 3. la liste des événements passés ou à venir

événements

2 réflexions: - en l'état, ma page d'accueil a la même structure que ma page a-propos, je vais avoir besoin de flexibilité, il faut réussir à le faire sans trop complexifier le mécanisme - il peut être tentant de mettre les objectifs en dur dans le twig car c'est pas grand chose à changer et ça ne prend pas longtemps si besoin... ma philosophie est que si c'est du contenu, c'est le gestionnaire de contenu qui en est le responsable et sa place est donc dans le cms (et créer un nouveau modèle est fun, prend 3 minutes et fais gagner du temps le jour où on veut les changer).

Les objectifs

Pour créer le modèle Goal et ajouter le contenu (3 objectifs + 3 images), 2 possibilités pour vous:

  • la première, très rapide, utilise le cli et ce fichier:
contentful-import --space-id SPACE_ID --management-token MGT_TOKEN --content-file goals.json
  • la deuxième est un peu plus longue (10 min) puisqu'il faut créer soit-même le content-model et peupler le modèle: model goals

Une fois que c'est fait, on va faire en sorte de les afficher dans la page d'accueil et pour commencer je vous propose cette petite astuce qui vous permettra de customiser les vues de certaines pages spéciales comme la page d'accueil tout en gardant une vue par défaut pour les pages dites classiques:

<?php
use Symfony\Component\Templating\EngineInterface;

class PageController extends Controller
{
    /**
     * @Route("/", name="homepage")
     * @Route("/{slug}", name="page_show")
     */
    public function showAction(EngineInterface $twigEngine, $slug = 'homepage')
    {
        //...

        //seek for custom template
        $template = sprintf('cms/page/custom/%s.html.twig', $slug);
        if (!$twigEngine->exists($template) ) {
            $template = 'cms/page/show.html.twig';
        }
        // replace this example code with whatever you need
        return $this->render($template, [
            'page' => $entry,
        ]);
    }
}

ℹ ce petit bout de code très simple va permettre d'aller d'abord voir s'il n'existe pas une template spécial pour la page qu'on essaye de charger (app/Resources/views/cms/page/custom/homepage.html.twig) et va revenir sinon sur la vue par défaut (app/Resources/views/cms/page/show.html.twig)

On va donc pouvoir commencer à personnaliser la page d'accueil:

{# app/Resources/views/cms/page/custom/homepage.html.twig #}
{% extends "::base.html.twig" %}

{% block body %}
    <section id="cover-section">
        {{ page.getText()|markdown|raw }} {# here will stand the name, baseline and call to action #}
    </section>
    <section id="goals-section">
        <h3>
            Nos objectifs
        </h3>
        <!-- Add goals section here -->
    </section>
    <section id="events-section">
        <h3>
            Les événements
        </h3>
        <!-- Add events section here -->
    </section>
{% endblock %}

On voit ici qu'on a maintenant 3 sections (dont 2 vides): - la cover qui va simplement afficher le contenu présent dans le champ text de la page d'accueil (par exemple le h1, h2 et un bouton) - les objectifs - les événements

Pour les objectifs et les événements, on peut utiliser la méthode render (ou render_esi):

{{ render(path('app_cms_event_rendergoals')) }}

Plus d'info sur la fonction render / render_esi

et voici les actions de contrôleurs associés:

<?php

namespace AppBundle\Controller\CMS;

use Symfony\Bundle\FrameworkBundle\Controller\Controller;
use Sensio\Bundle\FrameworkExtraBundle\Configuration\Route;
use Symfony\Component\HttpFoundation\Response;

/**
 * @Route("/goals")
 */
class GoalsController extends Controller
{
    /**
     * @Route("/")
     */
    public function renderListAction()
    {
        $client = $this->get('contentful.delivery');
        $query = new \Contentful\Delivery\Query;
        $query->setContentType('goal');

        $response = '';
        foreach ($client->getEntries($query) as $goal) {
            $response.= $this->renderView('cms/goals/_item.html.twig', [
                'goal' => $goal
            ]);
        }

        return new Response($response);
    }
}

et la vue d'un objectif:

{# app/Resources/views/cms/goals/_item.html.twig #}
<div>
    <h3>{{ goal.getName() }}</h3>
    {% if goal.getPicture() %}
        <img src="{{ goal.getPicture().file.url ~ "?fm=jpg&w=350&h=350" }}"/>
    {% endif %}
    {{ goal.getDescription()|markdown|raw }}
</div>

Les événements

Pour les événements, on peut s'y prendre exactement de la même manière avec la création d'un modèle de données côté Contentful et avec l'utilisation de la fonction render...

Voici le fichier pour définir le modèle Event comme je l'ai fait, il y a 2 événéments inclus: events.json.

Voici le controller EventController:

<?php

namespace AppBundle\Controller\CMS;

use Symfony\Bundle\FrameworkBundle\Controller\Controller;
use Sensio\Bundle\FrameworkExtraBundle\Configuration\Route;
use Symfony\Component\HttpFoundation\Response;

/**
 * @Route("/event")
 */
class EventController extends Controller
{
    /**
     * @Route("/")
     */
    public function renderListAction($max = 10)
    {
        $client = $this->get('contentful.delivery');
        $query = new \Contentful\Delivery\Query;
        $query->setContentType('event')
            ->orderBy('fields.date', true)
            ->setLimit($max);

        $response = '';
        foreach ($client->getEntries($query) as $event) {
            $response.= $this->renderView('cms/event/_item.html.twig', [
                'event' => $event
            ]);
        }

        return new Response($response);
    }
}

et la vue associée:

<section>
    <div id="{{ event.getId() }}-map" style="width: 300px; height: 300px;"></div>
    <div>
        {% if event.getDate() < date('now') %}
            <span style="right: 20px; top: 20px; position: absolute;">Évenement passé</span>
        {% endif %}
        <div>
            <h4>{{ event.getTitle() }}</h4>
            <small>{{ event.getDate()|date('d/m/Y') }}</small>
            {% for tag in event.getTags() %}
                <strong>{{ tag }}</strong>
            {% endfor %}
            {{ event.getDescription() }}
            <a href="{{ event.getLink() }}" class="mdl-button">Plus d'info</a>
        </div>
    </div>
</section>
<br/>
<script>
    function r(f){/in/.test(document.readyState)?setTimeout('r('+f+')',9):f()}
    r(function(){
        initMap{{ event.getId() }}();
    });
    function initMap{{ event.getId() }}() {
        var eventLocation{{ event.getId() }} = {lat: {{ event.getLocation().latitude }}, lng: {{ event.getLocation().longitude }} };
        var map{{ event.getId() }} = new google.maps.Map(document.getElementById('{{ event.getId() }}-map'), {
            zoom: 13,
            center: eventLocation{{ event.getId() }},
            disableDefaultUI: true
        });
        var marker{{ event.getId() }} = new google.maps.Marker({
            position: eventLocation{{ event.getId() }},
            map: map{{ event.getId() }}
        });
    }
</script>

Vous noterez qu'il y a des cartes gmap embarquées donc il faut rajouter la lib dans le layout de base:

{# app/Resources/views/base.html.twig #}

{% block javascripts %}
    <script async defer src="https://maps.googleapis.com/maps/api/js?key={{ googleMapAPIKEY }}{% block gmapExtraAttributes %}{% endblock %}"></script>
{% endblock %}

et bien-sur, ajoutez en parameter la clé (que vous aurez généré ici) et passez-là en global:

#parameters.yml.dist
parameters:
    googleMapsApiKey: key

#config.yml
twig:
    #...
    globals:
        googleMapAPIKEY: '%googleMapsApiKey%'

2. Le blog

Créer un blog simple dans un site Symfony n'est pas très compliqué, mais Contentful va nous nous permettre d’éviter de réinventer la roue tout en solidifiant et professionnalisant notre blog.

Lorsqu'on créé un nouvel espace dans Contentful, plutôt que de partir avec le squelette vide, on peut choisir le template Blog (ainsi que catalogue de produit et galerie photo) qui nous amènera un modèle éprouvé, des données de test ainsi que des exemples de consommations dans beaucoup de langages. D'ailleurs, si vous voulez voir d'autres implémentation, vous pouvez aller voir les projets bac à sable: un catalogue de produit avec Symfony ou un blog avec Laravel.

💾 Voici le fichier qu'il vous faudra importer comme précédemment avec contentful-import pour récupérer dans votre espace un blog prêt à utiliser blog.json

2.1 Un blog de base

Le blog apporté par Contentful est minimaliste et c'est tant mieux, pas de fioriture et libre à nous d'ajouter ce que l'on souhaite. De base, on a:

  • des articles
  • des catégories
  • des auteurs

On va donc faire une vue Blog qui va lister tous les articles puis une vue pour afficher chaque article et ouvrir un système de commentaires sans effort. On ajoutera aussi une vue pour lister uniquement les articles d'une catégorie.

Index du blog

Tout d'abord, on va commencer par créer le PostController comme ceci:

<?php

namespace AppBundle\Controller\CMS;

use Symfony\Bundle\FrameworkBundle\Controller\Controller;

class PostController extends Controller
{
    const CONTENT_TYPE_POST = '2wKn6yEnZewu2SCCkus4as';
    const CONTENT_TYPE_CATEGORY = '5KMiN6YPvi42icqAUQMCQe';
    const CONTENT_TYPE_AUTHOR = '1kUEViTN4EmGiEaaeC6ouY';
}

Notez la définition des constants CONTENT_TYPE_*. Le système génère par défaut un identifiant unique lorsqu'on créé un Modèle (content-type), afin d’éviter d'avoir un conflit lors d'un import avec un modèle ayant le même identifiant. Si vous êtes sûrs de ne pas générer de conflit, il est possible de définir un identifiant non obscurci comme dans l'exemple précédent sur le modèle Page qui a page comme identifiant.

On va rajouter ensuite la méthode permettant de lister les articles par ordre antéchronologique:

    /**
     * @Route("/blog")
     */
    public function indexAction()
    {
        $client = $this->get('contentful.delivery');
        $query = new \Contentful\Delivery\Query;
        $query->setContentType(self::CONTENT_TYPE_POST)->orderBy('fields.date', true);

        return $this->render('cms/post/index.html.twig', [
            'entries' => $client->getEntries($query)
        ]);
    }

et la vue associée:

{% extends "base.html.twig" %}

{% block title %}Blog - {{ parent() }}{% endblock %}
{% block body %}
<section id="section-blog">
    <h1>{{ title|default('Blog') }}</h1>
</section>

<main>
    {% for post in entries %}
        <section>
            <h2>{{ post.title }}
                <small>par
                    {% for post.getAuthor() %}
                        {{ post.author.name }}
                        {% if loop.index == post.getAuthor()|length - 1 %}
                            et
                        {% else if not loop.last %}
                            ,
                        {% endif %}
                    {% endfor %}
                </small> le {{ post.getDate()|date('d/m/Y') }}
            </h2>
            <img src="{{ post.getFeaturedImage().file.url ~ "?fm=jpg&w=215&h=215" }})"/>
            {% for category in post.getCategory() %}
                <strong>
                    {{ category.getTitle() }}
                </strong>
            {% endfor %}
            {{ post.getBody()|markdown|striptags|truncate(150)|raw }}
        </section>
    {% endfor %}
</main>
{% endblock %}

Si on se rend sur /blog, on va désormais avoir l'affichage certes rudimentaire mais non moins opérationnel des articles de notre blog. Pour l'instant, il manque l'action de visualisation d'un article, on veut lire le contenu complet, pas juste l'extrait.

Vue d'un article

Implémentons tout ça:

    //src/AppBundle/Controller/CMS/PostController.php

    /**
     * @Route("/blog/{slug}")
     */
    public function showAction($slug)
    {
        $client = $this->get('contentful.delivery');
        $query = new \Contentful\Delivery\Query;
        $query->setContentType(self::CONTENT_TYPE_POST)
            ->where('fields.slug', $slug, 'match')
            ->setLimit(1);

        return $this->render('cms/post/show.html.twig', [
            'post' => $client->getEntries($query)[0]
        ]);
    }

et la vue associée:

{# app/Resources/views/cms/post/show.html.twig #}
{% extends "::base.html.twig" %}

{% block title %}{{ post.getTitle() }} - {{ parent() }}{% endblock %}
{% block body %}
    <h1>{{ post.getTitle() }}</h1>
    <ul>
        {% for category in post.getCategory() %}
            <li>
                {{ category.getTitle() }}
            </li>
        {% endfor %}
    </ul>
    {{ post.getBody()|markdown|raw }}
{% endblock %}

Ne pas oublier de rajouter un petit lien dans l'index du blog pour pouvoir naviguer sur notre article:

{# app/Resources/views/cms/post/index.html.twig #}

...
{% block body %}
    ...

    {% for post in entries %}
        ...
        <a href="{{ path('app_cms_post_show', {slug: post.getSlug()}) }}">
            Lire
        </a>
    {% endfor %}
    ...
{% endblock %}
...

Liste des articles par catégorie + calendrier de l'avent

Attaquons-nous aux catégories désormais. La liste des articles d'une catégorie n'est finalement qu'une liste d'articles filtrée sur la catégorie, on va donc avoir besoin d'ajouter un action et une route particulière pour faire cette action mais on va pouvoir utiliser la même vue que l'index général:

    //src/AppBundle/Controller/CMS/PostController
    /**
     * @Route("/blog/category/{slug}")
     */
    public function listByCategoryAction($slug, EngineInterface $twigEngine)
    {
        $client = $this->get('contentful.delivery');
        //find first the category
        $query = new \Contentful\Delivery\Query;
        $query->setContentType(self::CONTENT_TYPE_CATEGORY)
            ->where('fields.slug', $slug)
            ->setLimit(1);
        $category = $client->getEntries($query)[0];

        //find posts by category
        $query = new \Contentful\Delivery\Query;
        $query->setContentType(self::CONTENT_TYPE_POST)
            ->where('fields.category.sys.id', $category->getId())
            ->orderBy('fields.date');

        //seek for category custom template
        $template = sprintf('cms/category/custom/%s.html.twig', $slug);
        if (!$twigEngine->exists($template) ) {
            $template = 'cms/post/index.html.twig';
        }

        return $this->render($template, [
            'title' => $category->getTitle(),
            'entries' => $client->getEntries($query)
        ]);
    }

En dehors du code du client Contentful qui nous est maintenant presque familier, on voit qu'on fait 2 requêtes. Une première pour aller chercher la catégorie en fonction du slug, une deuxième pour aller chercher les articles associés à cette catégorie.

Comme vous l'avez peut-être remarqué j'ai utilisé la même mécanique que sur la homepage pour avoir le droit de surcharger le template de la page d'une collection. Ça nous sera utile par exemple pour mettre en forme le calendrier de l'avent qui nous est demandé, qui est une catégorie du côté du cms.

On est libre de faire des choses évoluées très rapidement. Par exemple, Pour ce besoin très précis de calendrier de l'avent, je me suis amusé à faire une petite veille et j'ai trouvé chez les amis de Codrops une expérimentation Cubes Advent Calendar qui correspondait tout à fait à mes attentes:

calendrier de l'avent par codrops

C'est un peu long et pas si fou que ça donc je préfères détailler d'autres points mais si ca vous intéresse, retrouvez l'implémentation du calendrier de l'avent ici.

3 Soumettre des événements depuis le site

Jusqu'à présent, on est resté dans une démarche de consommation du contenu situé dans le cms mais pour ce nouveau besoin, c'est un internaute qui doit créer, en passant par le site, un nouvel événement. Cet événement devra avoir un statut particulier "En attente" et ne s'affichera sur le site que lorsqu'il aura été validé.

image preview

Pour ce faire, on va passer par la Content Management API (contrairement à précédemment où nous utilisions la Content Delivery API). D'ailleurs, vous l'avez déjà utilisé sans forcément vous en rendre compte car c'est l'API qu'utilise le script contentful-import pour ajouter du contenu dans l'espace.

C'est là que ça se gâte (un tout petit peu)

Et oui, hélas au jour où j'écris cet article, il semble qu'on ait quelques semaines d'avance car le bundle attend la version stable de la lib contentful/contentful-management pour y implémenter les fonctions visant à faciliter la communication avec l'API de management.

En fait, c'est pas si grave car la solution existe ! Donc avant de faire une PR ou au lieu d'attendre une mise à jour du bundle, on peut déjà bricoler quelque chose de propre pour avoir accès à un petit service de Management qui nous permettra de créer nos événements en un claquement de doigts ou presque.

3.1 Architecture:

image archi

Tout commence avec composer, on va installer contentful-management (il n'y a pas de version stable encore):

Installation du sdk contentful-management
composer require contentful/contentful-management:@dev

Ensuite, en suivant la documentation, on comprend qu'il va falloir instancier le client Contentful\Management\Client en lui passant le content_management_api_key et le space_id:


use Contentful\Management\Client; use Contentful\Management\Resource\Entry; $client = new Client('<content_management_api_key>', '<space_id>'); $entry = new Entry('<content_type_id>'); $entry->setField('title', 'en-US', 'Entry title'); $client->entry->create($entry);

Déclaration du service

On va créer un service pour s'affranchir de cette instanciation:

#parameters.yml
parameters:
    contentful_management_token: token

#app/config/services.yml
services:
    Contentful\Management\Client:
        arguments:
            $token: '%contentful_management_token%'
            $currentSpaceId: '%contentful_delivery_space%'

Le client va nous permettre de créer notre nouvel évenement.

Voici maintenant les quelques étapes pour développer cette fonctionnalité: - un model AppBundle\Domain\Model\Event - un formulaire EventType (+ 1 formulaire LocationType car c'est un peu spécifique) - un transformer AppBundle\Domain\Transformer\EventToEntryTransformer pour changer notre Event en Contentful\Management\Resource\Entry - un handler AppBundle\Domain\Handler\AddEventHandler pour centraliser la logique propre à la soumission d'un événement

Création du model

On va créer le model Event qui sera un miroir du content-type défini dans Contentful:

<?php
namespace AppBundle\Domain\Model;

use Symfony\Component\Validator\Constraints as Assert;

class Event
{
    /**
     * @var string
     */
    private $title;
    /**
     * @var \DateTime
     */
    private $date;
    /**
     * @var float
     */
    private $latitude;
    /**
     * @var float
     */
    private $longitude;
    /**
     * @var string
     */
    private $description;
    /**
     * @var array
     */
    private $tags = [];
    /**
     * @var string
     * @Assert\Url()
     */
    private $link;

    /**
     * @return string
     */
    public function getTitle(): ?string
    {
        return $this->title;
    }

    /**
     * @param string $title
     * @return Event
     */
    public function setTitle(string $title): Event
    {
        $this->title = $title;
        return $this;
    }

    /**
     * @return \DateTime
     */
    public function getDate(): ?\DateTime
    {
        return $this->date;
    }

    /**
     * @param \DateTime $date
     * @return Event
     */
    public function setDate(\DateTime $date): Event
    {
        $this->date = $date;
        return $this;
    }

    /**
     * @return string
     */
    public function getDescription(): ?string
    {
        return $this->description;
    }

    /**
     * @param string $description
     * @return Event
     */
    public function setDescription(string $description): Event
    {
        $this->description = $description;
        return $this;
    }

    /**
     * @return array
     */
    public function getTags(): ?array
    {
        return $this->tags;
    }

    /**
     * @param array $tags
     * @return Event
     */
    public function setTags(array $tags): Event
    {
        $this->tags = $tags;
        return $this;
    }

    /**
     * @return string
     */
    public function getLink(): ?string
    {
        return $this->link;
    }

    /**
     * @param string $link
     * @return Event
     */
    public function setLink(string $link): Event
    {
        $this->link = $link;
        return $this;
    }

    /**
     * @param string $latitude
     * @return Event
     */
    public function setLatitude(string $latitude): Event
    {
        $this->latitude = $latitude;
        return $this;
    }

    /**
     * @return float
     */
    public function getLatitude(): ?float
    {
        return $this->latitude;
    }

    /**
     * @param float $longitude
     * @return Event
     */
    public function setLongitude(float $longitude): Event
    {
        $this->longitude = $longitude;
        return $this;
    }

    /**
     * @return float
     */
    public function getLongitude(): ?float
    {
        return $this->longitude;
    }
}

Création du formulaire

Puis on va faire la formulaire EventType:

<?php
namespace AppBundle\Form;

use AppBundle\Domain\Model\Event;
use Contentful\Location;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\CallbackTransformer;
use Symfony\Component\Form\Extension\Core\Type\DateTimeType;
use Symfony\Component\Form\Extension\Core\Type\TextareaType;
use Symfony\Component\Form\Extension\Core\Type\TextType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;

class EventType extends AbstractType
{
    public function buildForm(FormBuilderInterface $builder, array $options)
    {

        $builder
            ->add('title')
            ->add('date', DateTimeType::class, [
                'html5' => true,
                'years' => range(date('Y'), date('Y') + 5)
            ])
            ->add('location', LocationType::class, [
                'inherit_data' => true,
            ])
            ->add('description', TextareaType::class)
            ->add('tags', TextType::class, [
                'attr' => [
                    'placeholder' => 'SFPot, Nantes, Pizza'
                ]
            ])
            ->add('link')
        ;

        $builder->get('tags')
            ->addModelTransformer(new CallbackTransformer(
                function ($tagsAsArray) {
                    return implode(', ', $tagsAsArray);
                },
                function ($tagsAsString) {
                    return explode(', ', $tagsAsString);
                }
            ));
    }

    public function configureOptions(OptionsResolver $resolver)
    {
        $resolver->setDefault('data_class', Event::class);
    }
}

ainsi que le LocationType:

<?php
namespace AppBundle\Form;

use Contentful\Location;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\HiddenType;
use Symfony\Component\Form\Extension\Core\Type\TextType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;

class LocationType extends AbstractType
{
    public function buildForm(FormBuilderInterface $builder, array $options)
    {
        $builder
            ->add('address', TextType::class, [
                'mapped' => false,
            ])
            ->add('latitude', HiddenType::class)
            ->add('longitude', HiddenType::class)
        ;
    }
}

Création de l'action de controller + Handler + DataTransformer

On va ensuite ajouter l'action d'ajout dans le controller EventController:


/** * @Route("/new") * @Method(methods={"GET", "POST"}) * @param Request $request * @return Response */ public function newAction(Request $request) { $event = new Event(); $form = $this->createForm(EventType::class, $event); if ($request->isMethod(Request::METHOD_POST)) { $form->handleRequest($request); if ($form->isValid()) { //@todo Do something } } return $this->render('cms/event/new.html.twig', [ 'form' => $form->createView() ]); }

et la vue associée:

{# app/Resources/views/cms/event/new.html.twig #}
{% extends "::base.html.twig" %}

{% block body %}
    <h1>Proposez un événement</h1>
    {{ form_start(form) }}
        {{ form_row(form.title) }}
        {{ form_row(form.date) }}
        {{ form_widget(form.location) }}
        <div id="previewMap" style="width: 300px; height: 200px;"></div>
        {{ form_rest(form) }}
        <input type="submit"/>
    {{ form_end(form) }}
{% endblock %}

{% block gmapExtraAttributes %}&libraries=places&callback=initMap{% endblock %}
{% block javascripts %}
    {{ parent() }}
    <script>
        function initMap() {
            var map = new google.maps.Map(document.getElementById('previewMap'), {
                center: {lat: 47.212205, lng: -1.550555},
                zoom: 13
            });
            var input = /** @type {!HTMLInputElement} */(
                document.getElementById('event_location_address'));
            var latInput = /** @type {!HTMLInputElement} */(
                document.getElementById('event_location_latitude'));
            var longInput = /** @type {!HTMLInputElement} */(
                document.getElementById('event_location_longitude'));

            map.controls[google.maps.ControlPosition.TOP_LEFT].push(input);

            var autocomplete = new google.maps.places.Autocomplete(input);
            autocomplete.bindTo('bounds', map);

            var infowindow = new google.maps.InfoWindow();
            var marker = new google.maps.Marker({
                map: map,
                anchorPoint: new google.maps.Point(0, -29)
            });

            autocomplete.addListener('place_changed', function() {
                infowindow.close();
                var place = autocomplete.getPlace();
                if (!place.geometry) {
                    // User entered the name of a Place that was not suggested and
                    // pressed the Enter key, or the Place Details request failed.
                    window.alert("No details available for input: '" + place.name + "'");
                    return;
                }

                latInput.value = place.geometry.location.lat();
                longInput.value = place.geometry.location.lng();

                // If the place has a geometry, then present it on a map.
                if (place.geometry.viewport) {
                    map.fitBounds(place.geometry.viewport);
                } else {
                    map.setCenter(place.geometry.location);
                    map.setZoom(17);  // Why 17? Because it looks good.
                }

                var address = '';
                if (place.address_components) {
                    address = [
                        (place.address_components[0] && place.address_components[0].short_name || ''),
                        (place.address_components[1] && place.address_components[1].short_name || ''),
                        (place.address_components[2] && place.address_components[2].short_name || '')
                    ].join(' ');
                }

                infowindow.setContent('<div><strong>' + place.name + '</strong><br>' + address);
                infowindow.open(map, marker);
            });
        }
    </script>
{% endblock %}

Pour traiter le formulaire et ne pas outrepasser la responsabilité du controller, on va créer un service responsable de créer un événement à partir d'un formulaire: AddEventHandler.

Ce service utilisera un DataTransformer qu'il faudra aussi créer pour transformer l'Événement en Entry et utilisera ensuite le service Contentful\Management\Client pour envoyer l'Entry à Contentful:

#src/AppBundle/Domain/Transformer
<?php
namespace AppBundle\Domain\Transformer;

use AppBundle\Domain\Model\Event;
use Contentful\Management\Resource\Entry;
use Symfony\Component\Form\DataTransformerInterface;
use Symfony\Component\HttpFoundation\RequestStack;

class EventToEntryTransformer implements DataTransformerInterface
{
    /**
     * @var RequestStack
     */
    private $requestStack;

    /**
     * EventToEntryTransformer constructor.
     * @param RequestStack $requestStack
     */
    public function __construct(RequestStack $requestStack)
    {
        $this->requestStack = $requestStack;
    }

    /**
     * @param Event $event
     * @return Entry
     */
    public function transform($event)
    {
        $locale = $this->requestStack->getCurrentRequest()->getLocale();
        $entry = new Entry('event');
        $entry->setField('title', $locale, $event->getTitle());
        $entry->setField('date', $locale, $event->getDate()->format('c'));
        $entry->setField('location', $locale, [
            "lat" => $event->getLatitude(),
            "lon" => $event->getLongitude()
        ]);
        $entry->setField('description', $locale, $event->getDescription());
        $entry->setField('tags', $locale, $event->getTags());
        $entry->setField('link', $locale, $event->getLink());
        $entry->setField('slug', $locale, uniqid('event_', true));

        return $entry;
    }

    /**
     * @param Entry $entry
     * @return Event
     */
    public function reverseTransform($entry)
    {
        $locale = $this->requestStack->getCurrentRequest()->getLocale();
        $event = new Event();
        $event->setTitle($entry->getField('title'), $locale);
        $event->setDate($entry->getField('date'), $locale);
        $event->setLatitude($entry->getField('location')->getField('latitude'), $locale);
        $event->setLongitude($entry->getField('location')->getField('longitude'), $locale);
        $event->setDescription($entry->getField('description'), $locale);
        $event->setTags($entry->getField('tags'), $locale);
        $event->setLink($entry->getField('link'), $locale);

        return $event;
    }
}

https://www.contentful.com/developers/docs/concepts/data-model/

#src/AppBundle/Domain/Handler
<?php
namespace AppBundle\Domain\Handler;

use AppBundle\Domain\Model\Event;
use AppBundle\Domain\Transformer\EventToEntryTransformer;
use Contentful\Management\Client;

class AddEventHandler
{
    /**
     * @var Client
     */
    private $client;
    /**
     * @var EventToEntryTransformer
     */
    private $transformer;

    public function __construct(Client $client, EventToEntryTransformer $transformer)
    {
        $this->client = $client;
        $this->transformer = $transformer;
    }

    public function handle(Event $event) {
        $entry = $this->transformer->transform($event);
        $this->client->entry->create($entry);
    }
}

Dernière étape, appeler ce handler lorsque le formulaire est valide:

...
use AppBundle\Domain\Handler\AddEventHandler;
...
    /**
     * @Route("/new")
     * @Method(methods={"GET", "POST"})
     * @param Request $request
     * @param AddEventHandler $addEventHandler
     * @return Response
     */
    public function newAction(Request $request, AddEventHandler $addEventHandler)
    {
        $event = new Event();
        $form = $this->createForm(EventType::class, $event);
        if ($request->isMethod(Request::METHOD_POST)) {
            $form->handleRequest($request);
            if ($form->isValid()) {
                $addEventHandler->handle($form);
                $this->addFlash('success', 'Votre événement a bien été enregistré, il sera visible après validation !');

                return $this->redirectToRoute('homepage');
            }
        }

        return $this->render('cms/event/new.html.twig', [
            'form' => $form->createView()
        ]);
    }

Désormais, si on se rend sur /event/new, on a accès au formulaire de dépôt d'un événement et lorsqu'on le soumet, un événement est ajouté dans Contentful en brouillon.

Il ne reste plus qu'a le valider. On peut imaginer que le AddEventHandler pourrait s'occuper de notifier la room Event dans le Slack de l'AFSY afin d'avoir une bonne réactivité mais c'est hors sujet ;)

Conclusion

Nous ne sommes qu'au début des CMS Headless mais on sent déjà qu'il s'agit d'une solution plus adaptée au développement applicatif moderne.

Le bundle ContentfulBundle

Fonctionnel mais il semble être encore un peu jeune. L'utilisation du client n'est pas des plus élégante et même si le sdk fait un travail conséquent notamment avec l'objet DynamicEntry, il faut l'abstraire dans des services métiers, spécialisés dans la récupération et la préparation du contenu pour ne pas surcharger les Controllers. De plus, le bundle vient avec un Collector pour la WDT:

debug toolbar profiler

C'est plutôt sympa pour garder un oeil sur le nombre de requêtes effectuées pour chaque page.

Et puis comme expliqué dans l'article, des évolutions devraient arriver dans les semaines à venir pour faciliter encore l'intégration des models avec Contentful.

Le prix

Cela ne vous aura pas échappé, en dehors de l'édition Developper, contentful n'est pas donné, loin de là:

pricing

Cependant, même l'offre Developper peut fonctionner pour des petits sites car le sdk PHP offre un système de cache permettant d'éviter les appels trop fréquents en prod: https://www.contentful.com/developers/docs/php/tutorials/caching-in-the-php-cda-sdk/

Et mes tests Behat 😰

La réponse apportée par la core team du sdk php est d'utiliser, comme eux, la librairie PHP-VCR qui permet d'enregistrer les appels API et de les enregistrer sur des cassettes (oui oui sérieux) afin de les rejouer dans les tests futurs en simulant les appels à l'API contentful.

Plus d'info ici: Mock Client Call #170.

Allez sur ce, je vous souhaite de belles fêtes de fin d'année pleines de contenu (content full hum...) et je souhaite une bonne fête à tous les Nicolas :).