Commerce digital

Importer des ressources depuis Microsoft Sharepoint dans le PIM Akeneo

by Benoit Wannepain 29 mai 2020

Akeneo PIM est une puissante solution de gestion d’information produit (Product Information Management) aujourd’hui plébiscitée par nombre d’acteurs du e-commerce pour sa simplicité d’utilisation et les services rendus. Akeneo PIM permet de collecter, gérer, enrichir l’information produit, créer des catalogues, afin de les distribuer sur différents canaux de diffusion (web, print, etc…).

Dans un précédent article, j’expliquais comment des ressources stockées sur Dropbox pouvaient être importées dans le PIM Akeneo. La méthode s’appuyait sur l’excellente librairie Flysystem qui propose une couche d’abstraction pour accéder à des systèmes de fichiers. Cette librairie introduit une interface (adaptateur) qui nous permet de basculer d’un système de fichiers Dropbox à un système de fichiers Sharepoint sans rien avoir à changer à la logique d’import déjà en place.

Pour accéder aux ressources sur Dropbox, nous avions pu compter sur un adaptateur déjà disponible (spatie/flysystem-dropbox). On peut également trouver sur Github des adaptateurs pour Sharepoint mais ils pourraient ne pas convenir en fonction de la version de Sharepoint installée. Heureusement, il est relativement simple d’implémenter un adaptateur répondant à des besoins spécifiques.

Mise en place du client SharePoint

Dans un premier temps, nous mettons en place un client capable de communiquer avec le serveur SharePoint. L’adaptateur exploitera ce client pour communiquer avec le serveur SharePoint.

<?php

namespace AppBundle\Sharepoint;

use GuzzleHttp\Psr7\StreamWrapper;
use Symfony\Component\Security\Core\Exception\AuthenticationException;

class SPClient
{
    /** @var \GuzzleHttp\Client */
    protected $http;
    /** @var SPAuthenticate */
    protected $authenticate;
    /** @var string */
    protected $siteName;
    /** @var string */
    protected $baseUrl;
    /** @var string */
    protected $baseUri;

    /**
     * SPClient constructor.
     * @param SPAuthenticate $authenticate
     * @param string $siteName
     * @param string $baseUrl
     * @param string $baseUri
     */
    public function __construct(
        SPAuthenticate $authenticate,
        string $siteName,
        string $baseUrl,
        string $baseUri
    ) {
        $this->authenticate = $authenticate;
        $this->siteName = $siteName;
        $this->baseUrl = $baseUrl;
        $this->baseUri = $baseUri;

        $this->buildClient();
    }

    /**
     * @param string $serverRelativeUrl
     * @return string
     */
    public function getFolders(string $serverRelativeUrl)
    {
        $uri = sprintf(
            "%s/GetFolderByServerRelativeUrl('%s')/Folders",
            $this->baseUri,
            str_replace("'", "''", $serverRelativeUrl)
        );

        $res = $this->http->get($uri);

        return $res->getBody()->getContents();
    }

    /**
     * @param string $serverRelativeUrl
     * @return string
     */
    public function getFiles(string $serverRelativeUrl)
    {
        $uri = sprintf(
            "%s/GetFolderByServerRelativeUrl('%s')/Files",
            $this->baseUri,
            str_replace("'", "''", $serverRelativeUrl)
        );

        $res = $this->http->get($uri);

        return $res->getBody()->getContents();
    }

    /**
     * @param string $serverRelativeUrl
     * @return resource
     */
    public function download(string $serverRelativeUrl)
    {
        $uri = sprintf(
            '%s/GetFileByServerRelativeUrl(\'%s\')/$value',
            $this->baseUri,
            str_replace("'", "''", $serverRelativeUrl)
        );

        $res = $this->http->get($uri);

        return StreamWrapper::getResource($res->getBody());
    }

    /**
     * @param array $headers
     */
    private function buildClient(array $headers = [])
    {
        $headers = array_merge([
            'Accept' => 'application/json;odata=nometadata',
            'Authorization' => 'Bearer ' . ($this->authenticate)(),
        ], $headers);

        $this->http = new \GuzzleHttp\Client([
            'base_uri' => $this->baseUrl,
            'headers' => $headers,
        ]);
    }
}

L’authentification sur le serveur SharePoint se fait dans un premier temps en récupérant un token, qui est ensuite passé dans l’entête Authorization de la requête (Bearer authentication). Ici cette tâche de récupération du token incombe à une classe spécialisée :

<?php

namespace AppBundle\Sharepoint;

use Symfony\Component\Security\Core\Exception\AuthenticationException;

class SPAuthenticate
{
    /** @var string */
    protected $clientId;
    /** @var string */
    protected $clientSecret;
    /** @var string */
    protected $realm;
    /** @var string */
    protected $principal;
    /** @var string */
    protected $targetHost;

    /**
     * SPAuthenticate constructor.
     * @param string $clientId
     * @param string $clientSecret
     * @param string $realm
     * @param string $principal
     * @param string $targetHost
     */
    public function __construct(
        string $clientId,
        string $clientSecret,
        string $realm,
        string $principal,
        string $targetHost
    ) {
        $this->clientSecret = $clientSecret;
        $this->clientId = $clientId;
        $this->realm = $realm;
        $this->principal = $principal;
        $this->targetHost = $targetHost;
    }

    /**
     * @return string
     */
    public function __invoke(): string
    {
        $authClient = new \GuzzleHttp\Client([
            'base_uri' => $this->getAccessControlUrl($this->realm),
        ]);
        $res = $authClient->post('', [
            'headers' => [
                'Content-Type' => 'application/x-www-form-urlencoded',
            ],
            'form_params' => [
                'grant_type' => 'client_credentials',
                'client_id' => sprintf('%s@%s', $this->clientId, $this->realm),
                'client_secret' => $this->clientSecret,
                'resource' => sprintf(
                    '%s/%s@%s',
                    $this->principal,
                    $this->targetHost,
                    $this->realm
                ),
            ],
        ]);

        if ($res->getStatusCode() === 200) {
            $responseData = json_decode($res->getBody()->getContents(), true);

            return $responseData['access_token'];
        }

        throw new AuthenticationException('No access token granted');
    }

    /**
     * @param string $realm
     * @return string
     */
    protected function getAccessControlUrl(string $realm): string
    {
        return sprintf(
            'https://accounts.accesscontrol.windows.net/%s/tokens/oAuth/2',
            $realm
        );
    }
}

Il est à noter qu’avec SharePoint, contrairement à Dropbox, lister le contenu d’un dossier n’est pas récursif (l’API ne liste pas le contenu des sous-dossiers). C’est un point important à prendre en compte pour la suite, car il faudra implémenter la récursivité dans l’adaptateur.

Mise en place de l’adaptateur

Un adaptateur Flysystem implémente l’interface League\Flysystem\AdapterInterface. Il faut donc en principe redéfinir toutes les méthodes de l’interface. Pour nos besoins nous n’avons réellement besoin de n’implémenter que deux méthodes de l’interface : les méthodes listContents et readStream.

Pour simplifier la tâche, Flysystem met à disposition une classe abstraite League\Flysystem\Adapter\AbstractAdapter qui implémente déjà League\Flysystem\AdapterInterface et qu’il nous suffit donc d’étendre.

<?php

namespace AppBundle\Sharepoint;

use Akeneo\Test\Acceptance\Common\NotImplementedException;
use AppBundle\Filesystem\Filesystem;
use GuzzleHttp\Client as GuzzleClient;
use League\Flysystem\Adapter\AbstractAdapter;
use League\Flysystem\AdapterInterface;
use League\Flysystem\Config;

class SPAdapter extends AbstractAdapter
{
    /** @var SPClient */
    protected $client;

    /**
    * SPAdapter constructor.
    * @param SPClient $client
    */
    public function __construct(SPClient $client)
    {
        $this->client = $client;
    }

    /**
    * @inheritDoc
    */
    public function listContents($path = '', $recursive = false): array
    {
        $contents = [];
        $this->listFolders($path, $recursive, $contents);

        return $contents;
    }

    public function readStream($path)
    {
        if (substr($path, 0, 1) !== '/') {
            $path = '/' . $path;
        }

        $stream = $this->client->download($path);

        return compact('stream');
    }  

    /**
     * @param string $path
     * @param bool $recursive
     * @param array $contents
     */
    protected function listFolders(string $path, bool $recursive, array &$contents): void
    {
        if (substr($path, 0, 1) !== '/') {
            $path = '/' . $path;
        }

        $this->listFiles($path, $contents);
        $folders = current(json_decode($this->client->getFolders($path), true));
        foreach ($folders as $folder) {
            if (substr($folder['ServerRelativeUrl'], 0, 1) === '/') {
                $folder['ServerRelativeUrl'] = substr($folder['ServerRelativeUrl'], 1);
            }

            $d = [
                'type' => Filesystem::TYPE_DIR,
                'path' => $folder['ServerRelativeUrl'],
                'timestamp' => strtotime($folder['TimeLastModified']),
                'visibility' => AdapterInterface::VISIBILITY_PUBLIC,
                'contents' => '',
                'stream' => null,
            ];
            $contents[] = $d;
            if ($recursive) {
                $this->listFolders($folder['ServerRelativeUrl'], true, $contents);
            }
        }
    }

    /**
     * @param string $path
     * @param array $contents
     */
    protected function listFiles(string $path, array &$contents)
    {
        $files = current(json_decode($this->client->getFiles($path), true));
        foreach ($files as $file) {
            if (substr($file['ServerRelativeUrl'], 0, 1) === '/') {
                $file['ServerRelativeUrl'] = substr($file['ServerRelativeUrl'], 1);
            }
            $contents[] = [
                'type' => Filesystem::TYPE_FILE,
                'path' => $file['ServerRelativeUrl'],
                'timestamp' => strtotime($file['TimeLastModified']),
                'visibility' => AdapterInterface::VISIBILITY_PUBLIC,
                'contents' => '',
                'stream' => null,
            ];
        }
    }
}

Les méthodes listFiles et listFolders récupèrent respectivement la liste des fichiers et la liste des dossiers pour le chemin passé en paramètre. La méthode listFolders prend également un paramètre permettant de gérer la récursivité, car comme indiqué plus haut, ce n’est pas automatique avec l’API de SharePoint. De ce fait, récupérer l’ensemble des dossiers et des fichiers est sensiblement plus lent avec SharePoint qu’avec Dropbox, car il est nécessaire de faire de nombreux appels à l’API, surtout si l’arborescence des dossiers est complexe.

Conclusion

Grâce à l’utilisation des interfaces League\Flysystem\FilesystemInterface, il est relativement simple de passer d’un système de fichiers à un autre, sans avoir à modifier une seule ligne de code métier. Notez que Flysystem propose déjà un certain nombre d’adaptateurs pour les systèmes de fichiers les plus courants : système de fichiers local, Microsoft Azure, AWS S3, DigitalOcean Spaces, Dropbox, FTP, Gitlab, Google Cloud Storage, Memory, Rackspace, SFTP, WebDAV, PHPCR, ZipArchive… Si votre système de fichiers n’est pas dans la liste, nous venons de voir qu’il est relativement simple d’implémenter un nouvel adaptateur.

Benoit Wannepain

Benoit Wannepain

Lead Developer

J'ai découvert l'informatique et la programmation dans les années 80. L'essor de l'Internet depuis les années 90 m'a progressivement amené au développement d'applications web backend et à la mise en place des infrastructures de serveurs nécessaires au bon fonctionnement de celles-ci. Aujourd'hui, je m'intéresse particulièrement au framework PHP Symfony et à son écosystème (Akeneo, EzPublish, Oro, ...).

Commentaires

Ajouter un commentaire

Votre commentaire sera modéré par nos administrateurs

Vous avez un projet ? Nos équipes répondent à vos questions

Contactez-nous