Externe Schnittstellen wie Vimeo, Youtube oder Google Calendar können im Handumdrehen in Sulu CMS integriert werden. Am Beispiel der Vimeo API zeigt das folgende Hands-On, wie mit Hilfe von Symfony Services und Twig Extensions eine Liste persönlicher Videos im Sulu Backend auswählbar gemacht und innerhalb von Templates angezeigt werden kann.
Dank der Cache-Komponente von Symfony ist das (Zwischen-)Speichern externer Daten in einer benutzerdefinierten Entität hierbei nicht notwendig. Ebenso verzichten wir auf die Erstellung einer React-Komponente und verwenden stattdessen einen Expression-Parameter am "Single Select" content type, um Videos anhand von Key-Value Paaren auswählbar zu machen. Zuletzt erstellen wir einen eigenen Event Subscriber, um globale Events wie das Löschen des Caches aus dem Sulu Backend heraus mit zusätzlichen Aktionen anzureichern.
1. Konfiguration & Einrichtung VimeoService
Um auf die Vimeo API zugreifen zu können, sind zunächst einige Vorbereitungen notwendig. Dazu gehört die Registrierung eines Vimeo-Accounts (falls noch nicht geschehen) und das Anlegen einer neuen App im Vimeo Developer-Bereich inklusive Generierung der Access Tokens. All diese Schritte können im Detail in der Vimeo API Dokumentation nachgelesen werden.
Wurden die Access Tokens erfolgreich generiert und kopiert, installieren wir zunächst mit Composer das offizielle Vimeo PHP SDK:
composer require vimeo/vimeo-api
Danach erweitern wir die .env.local
im Projektverzeichnis um die soeben kopierten Vimeo API Secrets:
VIMEO_CLIENT_ID=“...“
VIMEO_CLIENT_SECRET=““
VIMEO_ACCESS_TOKEN=““
welche wir zuletzt in der services.yaml
global verfügbar machen:
services:
_defaults:
// ....
bind:
//...
$vimeoClientId: "%env(VIMEO_CLIENT_ID)%"
$vimeoClientSecret: "%env(VIMEO_CLIENT_SECRET)%"
$vimeoAccessToken: "%env(VIMEO_ACCESS_TOKEN)%"
Nun kann endlich der Service erstellt werden! Der VimeoService initialisiert das Vimeo SDK mit Hilfe der eben eingefügten API Secrets und abstrahiert den Zugriff auf die externen Endpunkte – in diesem Beispiel alle im eigenen Profil hochgeladenen Videos oder ein einzelnes Video, welches für die Detailausgabe innerhalb der Templates verwendet wird. Wir erstellen unseren Service unter src/Service/VimeoService.php
mit folgendem Inhalt:
<?php
namespace App\Service;
use Vimeo\Vimeo;
class VimeoService
{
const PER_PAGE = 100;
const FIELDS = 'uri,name,created_time';
private Vimeo $client;
public function __construct(
private readonly string $vimeoClientId,
private readonly string $vimeoClientSecret,
private readonly string $vimeoAccessToken,
) {
$this->client = new Vimeo(
$this->vimeoClientId,
$this->vimeoClientSecret,
$this->vimeoAccessToken,
);
}
public function fetchUserVideos($page = 1): array
{
return $this->client->request('/me/videos', [
'page' => $page,
'per_page' => self::PER_PAGE,
'sort' => 'alphabetical',
'direction' => 'asc',
'fields' => self::FIELDS,
'filter' => 'embeddable',
]);
}
public function fetchVideo(string|int $videoId)
{
return $this->client->request('/videos/' . $videoId);
}
}
Wir werden diesen Service an zwei Stellen implementieren: An der single_select Komponente im Sulu Backend sowie in unseren Twig Templates. Fangen wir mit dem Backend an!
2. VideoSelect-Service und -Content Type
Wie in der Sulu Dokumentation beschrieben, können Methoden in Services über den Parametertyp "expression" an einem single_select content type referenziert werden. Wie es für die meisten Select-Felder typisch ist, genügt als zurückgegebener Wert eine Liste von Key-Value Paaren. In unserem Seitentemplate verbinden wir zunächst die getUserVideo
-Methode des im nächsten Schritt zu erstellenden VideoSelect-Services:
<property name="preview_video" type="single_select">
<meta>
<title lang="en">Vimeo video</title>
</meta>
<params>
<param name="values" type="expression"
value="service('App\\Content\\Select\\VideoSelect').getUserVideos()" />
</params>
</property>
Daraufhin erstellen wir einen weiteren kleinen Service "VideoSelect" unter src/Content/Select/VideoSelect.php
mit dem folgenden Inhalt:
<?php
namespace App\Content\Select;
use App\Service\VimeoService;
use Psr\Log\LoggerInterface;
use Symfony\Contracts\Cache\CacheInterface;
use Symfony\Contracts\Cache\ItemInterface;
class VideoSelect
{
public function __construct(
private CacheInterface $cache,
private LoggerInterface $logger,
private VimeoService $vimeoService
) {
}
/**
* @return array<int, array{name: string, title: string}>
*/
public function getUserVideos(): array
{
return $this->cache->get('vimeo_user_videos', function (ItemInterface $item) {
$item->expiresAfter(60 * 60 * 6); // 6 hours
return $this->fetchUserVideos();
});
}
/**
* @return array<int, array{name: string, title: string}>
*/
private function fetchUserVideos(): array
{
$videos = $this->vimeoService->fetchUserVideos();
$defaultSelectOption = [
'name' => '',
'title' => 'Select Vimeo video'
];
if (!$videos || count($videos) === 0) {
return [$defaultSelectOption];
}
return array_merge(
[
$defaultSelectOption
],
array_map(function ($video) {
// $video['uri'] = e.g. "/videos/881719055"
$videoId = (int) str_replace("/videos/", "", $video['uri']);
$createdTime = new \DateTime($video['created_time']);
return [
'name' => $videoId,
'title' => $video['name'] . ' (' . $createdTime->format('d-m-Y') . ')'
];
}, $videos)
);
}
}
In diesem Beispiel werden die Videos aus unserem VimeoService angefordert und in ein für Sulu (und Endnutzer:innen!) verständliches Format gemappt, welches im Symfony Cache abgelegt wird. Die Symfony-Cache-Komponente ermöglicht das effiziente Zwischenspeichern von Daten, um die Performance der Anwendung durch beschleunigte Zugriffe auf diese zu optimieren. Dies ist zwingend notwendig, da Sulu die getUserVideos
-Methode bei jedem Aufruf der Sulu UI erneut aufruft. Übrigens: Wie der Cache direkt über das Backend dann doch mal geleert werden kann, sehen wir uns im letzten Schritt an!
3. Twig Extension
Wird ein im Backend ausgewähltes Video im Twig-Template ausgegeben, erscheint dort lediglich dessen ID und kein aufgelöstes (Video-)Objekt. Um auf das vollständige Video zugreifen zu können (etwa für Vorschaubilder oder Metadaten), müssen wir mit dieser ID die Methode fetchVideo unseres inital erstellten VimeoService aufrufen. Dazu erstellen wir eine benutzerdefinierte Twig Extension, die den Filter "vimeo_video" global zur Verfügung stellt (es könnte aber auch eine gleichnamige Twig-Funktion anstelle eines -Filters verwendet werden). Falls noch nicht vorhanden, erstellen wir dazu die Datei src/Twig/AppExtension.php
mit folgendem Inhalt:
<?php
namespace App\Twig;
use App\Twig\Filters\VimeoRuntime;
use Twig\Extension\AbstractExtension;
use Twig\TwigFilter;
class AppExtension extends AbstractExtension
{
public function getFilters(): array
{
return [
new TwigFilter('vimeo_video', [VimeoRuntime::class, 'getVimeoVideo']),
];
}
}
Der Filter kann nun in Templates verwendet werden ({{ preview_video|vimeo_video }}
), führt aber noch zu einem Fehler, da die im Callback verknüpfte "VimeoRuntime" fehlt. Das (optionale) Suffix "Runtime" impliziert hierbei, dass diese Klasse von Symfony erst instanziiert wird, sobald ein entsprechender Aufruf in Twig erfolgt. Dieses "lazy loading" Feature mag bei wenigen Twig Extensions kaum ins Gewicht fallen, kann aber bei größeren Anwendung sowie Services mit vielen Abhängigkeiten Ressourcen sparen. Die "VimeoRuntime", welche wir zuletzt unter src/Twig/Filters/VimeoRuntime.php
erstellen, hat genau zwei dieser Abhängigkeiten - den VimeoVideo Service und das Caching System:
<?php
namespace App\Twig\Filters;
use App\Service\VimeoService;
use Twig\Extension\RuntimeExtensionInterface;
use Symfony\Contracts\Cache\ItemInterface;
use Symfony\Contracts\Cache\TagAwareCacheInterface;
class VimeoRuntime implements RuntimeExtensionInterface
{
public function __construct(
private TagAwareCacheInterface $cache,
private VimeoService $vimeoService
) {
}
public function getVimeoVideo(string|int $videoId): mixed
{
return $this->cache->get('vimeo_video_' . $videoId, function (ItemInterface $item) use ($videoId) {
$item->expiresAfter(60 * 60 * 6); // 6 hours
$item->tag('vimeo_video');
$video = $this->vimeoService->fetchVideo($videoId);
if (isset($video['body'])) {
return $video['body'];
}
return null;
});
}
}
Wie schon zuvor im VideoSelect Service wird das (von Twig automatisch als ID übergebene) Video über den VimeoService angefordert und im Cache zwischengespeichert. Allerdings weisen wir dem Cache-Item diesmal das Tag "vimeo_video" zu – eine Art Gruppierung, um Cache-Einträge unterschiedlicher Natur später einheitlich zu entfernen.
4. Cache (manuell) löschbar machen
Videos aus unserem Vimeo-Account können nun ausgewählt und vollständig in Templates ausgegeben werden – cool! Einen Haken hat die Sache allerdings: Das Verfallsdatum unseres Caches erscheint mit sechs Stunden noch etwas willkürlich. Besser wäre eine benutzerseitige Möglichkeit, den Cache manuell zu leeren, um die Videobibliothek neu aufzubauen. Da es mit dem Button "Webseiten Cache löschen" in der Webspace-Übersicht unseres Sulu-Backends bereits eine ähnliche Möglichkeit gibt, möchten wir diese adaptieren. Da es sich beim Löschen des (Webseiten-)Caches um ein globales Event handelt, bietet sich ein eigener "EventSubscriber" als Einstiegspunkt an. Und tatsächlich: Mit ein wenig Code-Recherche finden wir in Sulu’s WebsiteBundle ein Event "CACHE_CLEAR", auf das wir lauschen wollen. Hierzu erstellen wir unter src/EventSubscriber
eine CacheClearEventSubscriber.php
mit folgenden Inhalt:
<?php
namespace App\EventSubscriber;
use Sulu\Bundle\WebsiteBundle\Events;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Contracts\Cache\TagAwareCacheInterface;
final class ClearCacheEventSubscriber implements EventSubscriberInterface
{
public function __construct(
private TagAwareCacheInterface $cache
) {
}
public static function getSubscribedEvents()
{
return [
Events::CACHE_CLEAR => 'onSuluCacheClear',
];
}
public function onSuluCacheClear()
{
$this->cache->delete('vimeo_user_videos'); // @see VideoSelect
$this->cache->invalidateTags(['vimeo_video']); // @see VimeoRuntime
}
}
Ein Klick auf den Button "Webseiten Cache löschen", oder die Ausführung von php bin/websiteconsole cache:clear
, löscht nun alle Inhalte, die mit dem entsprechenden Key bzw. Tag im Cache gespeichert sind, und baut die Select-Liste beim nächsten Aufruf des Sulu Backends neu auf. Da dies aus UX-Sicht nicht immer eine ideale Lösung sein könnte, werden wir uns abschließend mit einigen anderen Ansätzen beschäftigen.
Fazit: Putting it all together
Wie dieses Hands-on zeigt, gelingt die Integration und Zwischenspeicherung externer Schnittstellen in Sulu dank der Symfony-eigenen Bordmittel auf sehr modulare Art und Weise. Aber: Jeder Anwendungsfall muss trotzdem für sich betrachtet werden! So kann eine „mehrseitige“ oder umfangreiche API, zeitkritische Daten am externen Endpunkt, oder die aus Performance-Sicht "teure" Transformation von API-Inhalten für einen anderen Ansatz sprechen. Denkbar wäre z.B. die permanente Speicherung der Daten in der lokalen Datenbank um gänzlich auf das Caching zu verzichten. Ebenso könnte eine eigene React-Komponente erstellt werden, welche die Inhalte des single-select-Feldes von Sulu bei jedem Rendering im Backend neu von der Drittanbieter-API abruft. Wie letzteres beispielhaft umgesetzt werden kann, wird in einem weiteren Blogbeitrag beschrieben.
Sie planen einen Website-Launch auf Basis des benutzerfreundlichen Sulu CMS? Kontaktieren Sie uns, um von unserer langjährigen Expertise in den Bereichen Symfony, Sulu und (Web-)App zu profitieren!