webinale Blog

Composable Commerce mit Mezzio

Datenjongleur der Mittelschicht

Mar 16, 2023

In einer Composable Architecture spielen meist große Global Player mit. Um die Kommunikation unter den Systemen effizient zu gestalten, braucht es einen zuverlässigen Mittelsmann. Ob das Middleware-Microframework Mezzio den großen Spielern das Wasser reichen kann, wird in diesem Artikel untersucht.

Dass die Anforderungen mit einem klassischen Shop von der Stange nur sehr schwer zu bewältigen wären, wurde uns während der Workshops in der Konzeptionsphase für einen Onlineshop schnell bewusst.

Eine fertige Shoplösung bringt zwar bereits viele Funktionalitäten mit sich und der Initialaufwand fällt geringer aus, jedoch konnte keine der Standardlösungen die Anforderungen vollumfänglich abdecken. Anpassungen wären unumgänglich gewesen. Zudem soll eine global konstante Performance erreicht werden – egal ob der Käufer in New York, Zürich oder Tokyo sitzt. Das setzt eine entsprechend skalierende Infrastruktur voraus.

Composable Commerce und Headless

Hier konnte ein Composable-Commerce-System überzeugen: Trotz initial höherem Aufwand lassen sich Anpassungen wesentlich freier und dadurch mit geringerem Aufwand umsetzen. Das System ist bspw. bereits global verteilt. Damit werden Anfragen aus dem Frontend von der Region beantwortet, die dem Anfragenden am nächsten ist. Schnelle Antwortzeiten wirken sich positiv auf den Umsatz aus und erhöhen die Konversationsrate.

Composable Commerce

Anstatt standardmäßige „Out of the box“-E-Commerce-Funktionen an Bedürfnisse anzupassen, nutzt Composable Commerce moderne Technologien und Ansätze wie eine MACH-(Microservices-APIs-Cloud-und-Headless-)Architektur, um sich an die schnell verändernde Marktdynamik jetzt und in Zukunft flexibel anzupassen.

Nebst dem Composable-Commerce-System kommt ein Headless CMS für den Inhalt zum Einsatz. Headless bezieht sich auf die Fähigkeit, das Frontend vom Backend zu entkoppeln, in diesem Fall die Commerce- und CMS-Schnittstellen vom Backend-Betriebssystem und PIM zu trennen.

Die Architektur erlaubt es uns, durch verteilte Systeme rasend schnelle Applikationen zu entwickeln, deren Kommunikationsmittelpunkt eine schlanke Mezzio-Applikation ist.

In diesem Artikel werden nur die Schnittstellen zum ERP (SAP) und einem Headless Commerce (CommerceLayer) beschrieben. Der Fokus liegt auf der Kommunikation zwischen den APIs mittels einer Mezzio-Applikation. Welcher Hersteller angebunden wird, ist nicht von Belang. Für ein vollständig funktionierendes Shopsystem sind selbstverständlich weitere Systeme (Headless CMS, Payment Provider etc.) notwendig. Der beschriebene Code ist in einem öffentlichen Repository [1] abrufbar. Genug der Theorie – wie sieht eine solche Implementation nun aus?

NOCH MEHR ZU WEB DESIGN?

Der Web Design & Development Track

Das Mezzio Skeleton

Laminas bietet mit dem Mezzio Skeleton [2] ein Installationsprogramm an, welches ein neues Mezzio-Projekt auf Grundlage von Benutzerentscheidungen erstellt:

composer create-project mezzio/mezzio-skeleton composable-commerce-mit-mezzio

Wenn die Installation aktuell nicht unter PHP 7.4 ausgeführt wird, lässt ein Bug [3] die Installation fehlschlagen. Um diesen zu umgehen, wird das Projekt geklont, die composer.lock-Datei entfernt und anschließend ein composer install ausgeführt:

git clone [email protected]:mezzio/mezzio-skeleton.git composable-commerce- mit-mezzio
cd composable-commerce- mit-mezzio
rm composer.lock
composer install

Ein Assistent führt durch die Installation der neuen Applikation und gibt die Wahl der Applikationsstruktur, des Service-Containers usw. In der ersten Option wird die modulare Struktur ausgewählt. Die Modulstruktur ermöglich eine saubere Trennung der Applikationsteile bzw. -logik. Das wird im späteren Teil der Implementation ersichtlich. Die weiteren Optionen werden beim Standard belassen. Nach Abschluss der Installation steht eine fertige Mezzio-Applikation bereit.

Im Folgenden werden verschiedene PHP Standards Recommendations (PSRs) erwähnt. Interessierte erfahren auf den Seiten der Framework Interoperability Group [4] alles Wissenswerte.

Die wichtigsten Komponenten der eben erstellten Applikation in der Übersicht:

  • mezzio/mezzio: Die PSR-15 Kernkomponente zur Verarbeitung von Web-Requests
  • laminas/laminas-service-manager: PSR-11 Dependency Injection Container
  • mezzio/mezzio-fastroute: FastRoute [5] Router
  • laminas/laminas-diactoros: PSR-7 Implementation für die Darstellung von HTTP-Nachrichten
  • laminas/laminas-config-aggregator: Komponente zum Sammeln und Zusammenführen von Konfigurationen aus verschiedenen Quellen

Im nächsten Schritt werden die API-Clients umgesetzt. Hierfür wird ein HTTP-Client benötigt. Es kann eine beliebige PSR-18-Implementation verwendet werden. In diesem Projekt wird die beliebte Bibliothek von Guzzle eingesetzt:

composer require guzzlehttp/guzzle

CommerceLayer API

Mit der modularen Applikationsstruktur kann die Applikation in verschiedene Module organisiert werden. Ein Modul repräsentiert hier einen separaten Ordner innerhalb von src. Diesem wird ein eigener Namespace zugewiesen. Für alles Relevante bzgl. CommerceLayer wird nun ein Modul erstellt. Ebenso werden später Module für weitere Schnittstellen und den ApiConnector erstellt.

Mezzio bringt einen Console Command mit, der diese Arbeit erledigt. Standardmäßig wird ein Modul mit einem src– und template-Ordner erstellt. Da keine Templates eingesetzt werden, kann eine flache Struktur mit der Option -f erstellt werden. Dabei wird auf Unterordner verzichtet und der Namespace direkt im Modulordner registriert:

vendor/bin/laminas mezzio:module:create CommerceLayer -f

Im Modulordner wird eine ConfigProvider-Klasse erstellt. Diese ist dafür zuständig, alle Services innerhalb des Moduls zu verwalten. Wo die Services registriert werden, spielt letzten Endes keine Rolle. Es ist ebenfalls möglich, diese in einer großen, global gültigen Datei zu hinterlegen. Wesentlich übersichtlicher ist es jedoch, diese Dienste modulweise zu verwalten.

API-Client

CommerceLayer bietet eine mächtige Schnittstelle, die JSON-API-kompatibel ist und kontinuierlich weiterentwickelt wird. Für die Kommunikation mit der CommerceLayer-Schnittstelle wird der zuvor installierte Guzzle-HTTP-Client zum Versenden und Empfangen der HTTP-Nachrichten eingesetzt. Der HTTP-Client benötigt einen mandantenspezifischen Basis-URL sowie einen gültigen Access-Token. Die Authentifizierung erfolgt über OAuth 2.0. Da die Erstellung des Tokens über OAuth 2.0 den Rahmen des Artikels sprengt, wird lediglich ein TokenProvider-Stub verwendet, das diesen Prozess symbolisch darstellt.

In der Konfiguration wird ein Options-Array deklariert, das dem Guzzle-Client übergeben wird. Darin werden der Basis-URL und die für die JSON API notwendigen Header definiert (Listing 1).

<?php
 
declare(strict_types=1);
 
return [
  'commerce_layer' => [
    'client' => [
      'options' => [
        'base_uri' => 'https://my-app.commerce-layer.com/',
        'headers'  => [
          'Content-Type' => 'application/vnd.api+json',
          'Accept'       => 'application/vnd.api+json',
        ],
      ],
    ],
  ],
];

Um den Client später als Service abrufen zu können, wäre es möglich, eine Factory für Psr\Http\Client\ClientInterface zu erstellen. Jedoch kann mit einem Interface nur ein Service referenziert werden. Aus diesem Grund wird pro Schnittstelle ein eigenes ApiClientInterface erstellt. Das ApiClientInterface erweitert lediglich das Psr\Http\Client\ClientInterface (Listing 2).

<?php
 
declare(strict_types=1);
 
namespace CommerceLayer;
 
use Psr\Http\Client\ClientInterface;
 
interface ApiClientInterface extends ClientInterface
{
}

In einer Mezzio-Applikation wird die gesammelte Konfiguration dem Service Container unter dem Schlüssel config übergeben. Damit kann die Factory die Clientkonfiguration abrufen und dem Client-Constructor übergeben (Listing 3).

<?php
 
declare(strict_types=1);
 
namespace CommerceLayer;
 
use GuzzleHttp\Client;
use Psr\Container\ContainerInterface;
 
class ApiClientFactory
{
  public function __invoke(ContainerInterface $container): Client
  {
    return new Client(
      $container->get('config')['commerce_layer']['client']['options']
    );
  }
}

Im ConfigProvider werden die Services registriert. Damit weiß der Service-Container, wie ein angefragter Service instanziiert wird (Listing 4).

<?php
 
declare(strict_types=1);
 
namespace CommerceLayer;
 
class ConfigProvider
{
  public function __invoke(): array
  {
    return [
      'dependencies' => $this->getDependencies(),
    ];
  }
 
  public function getDependencies(): array
  {
    return [
      'invokables' => [],
      'factories'  => [
        ApiClientInterface::class => ApiClientFactory::class,
      ],
    ];
  }
}

Dieses Muster – die Verwendung einer Factory zur Instanziierung eines Service und die Registrierung des Service im ConfigProvider – illustriert, wie ein Service in einer Mezzio-Applikation erstellt wird. In diesem Artikel wird dieses Muster für sämtliche Services verwendet, jedoch aus Platzgründen nicht explizit erwähnt. Neben der Verwendung einer Factory gibt es einige weitere Möglichkeiten zur Verwendung von Services mit dem Laminas Service Manager. In der Dokumentation [6] sind diese ausführlich beschrieben.

Im nächsten Schritt wird ein CommerceLayer Client erstellt, der Anfragen entgegennimmt, diese mit einem Access-Token ergänzt und schließlich über den API-Client versendet (Listing 5). Wie zuvor erwähnt, ist die Implementation des TokenProviders nicht Bestandteil dieses Artikels.

<?php
 
declare(strict_types=1);
 
namespace CommerceLayer;
 
use CommerceLayer\Client\TokenProvider;
use Psr\Http\Client\ClientInterface as HttpClient;
use Psr\Http\Message\RequestInterface;
use Psr\Http\Message\ResponseInterface;
 
class Client
{
  public function __construct(
    private readonly HttpClient $httpClient,
    private readonly TokenProvider $tokenProvider
  ) {
  }
 
  public function sendRequest(RequestInterface $request): ResponseInterface
  {
    return $this->httpClient->sendRequest(
            $this->withToken($request)
    );
  }
 
  private function withToken(RequestInterface $request): RequestInterface
  {
    return $request->withHeader('Authorization', $this->tokenProvider->getToken());
  }
}

Datentransferobjekt

Um das Interpretieren von Antworten auf API-Anfragen nicht zu wiederholen und Daten einheitlich innerhalb der Applikation weitergeben zu können, wird ein Datentransferobjekt (DTO) eingesetzt (Listing 6). Ein DTO ist ein Objekt, das dazu dient, Daten zu kapseln und sie von einem Teilsystem einer Anwendung an ein anderes zu senden.

<?php
 
declare(strict_types=1);
 
namespace CommerceLayer\Sku;
 
use Psr\Http\Message\ResponseInterface;
 
use function json_decode;
 
use const JSON_THROW_ON_ERROR;
 
class Dto
{
  public function __construct(
    public readonly ?string $id,
    public readonly string $code,
    public readonly string $name,
  ) {
  }
 
  public static function fromResponse(ResponseInterface $response): self
  {
    $data = json_decode(
      $response->getBody()->getContents(),
      true,
      512,
      JSON_THROW_ON_ERROR
    );
 
    return new self(
      $data['data']['id'],
      $data['data']['attributes']['code'],
      $data['data']['attributes']['name'],
    );
  }
}

Obwohl eine SKU sehr viele Informationen enthalten kann, wird das DTO der Einfachheit halber hier mit nur drei Eigenschaften deklariert:

  • id: Repräsentation der CommerceLayer-ID. Diese kann leer (null) sein, z. B. für neue SKUs.
  • code: die SKU oder einfach Artikelnummer.
  • name: Name der SKU.

Nebst dem Constructor wird ein statischer Constructor bereitgestellt, welcher ein DTO anhand des Psr\Http\Message\ResponseInterface-Objekts instanziiert. Dieser ermöglicht die Erstellung einer DTO-Instanz aus dem Response-Objekt verschiedener Anfragen (wie Read, Create oder Update), ohne Logik duplizieren zu müssen.

Requests

Der Client nimmt für API-Anfragen Psr\Http\Message\RequestInterface-Instanzen entgegen. Die Requests werden in einer separaten Klasse instanziiert und später vom Repository verwendet.

Beispielhaft zeigt die folgende Klasse den Request zur Erstellung neuer SKUs (Listing 7).

<?php
 
declare(strict_types=1);
 
namespace CommerceLayer\Sku\Request;
 
use CommerceLayer\Sku\Dto;
use GuzzleHttp\Psr7\Request;
use Psr\Http\Message\RequestInterface;
 
use function json_encode;
 
use const JSON_THROW_ON_ERROR;
 
class Create
{
  public static function create(Dto $dto): RequestInterface
  {
    $data = [
      'data' => [
        'type'       => 'skus',
        'attributes' => [
          'code' => $dto->code,
          'name' => $dto->name,
        ],
      ],
    ];
 
    return new Request(
      'POST',
      '/api/skus',
      [],
      json_encode($data, JSON_THROW_ON_ERROR)
    );
  }
}

Repository

Mit dem umgesetzten Client, Request und DTO kann nun das Repository implementiert werden. Die Anwendung wird sich des Repositorys bedienen, ohne selbst die Requests zu definieren oder Access Tokens zu generieren etc.

Das Repository bietet zunächst drei Methoden an: readOneByCode, create und update. Alle Methoden geben ein DTO-Objekt zurück, sodass eine einheitliche Signatur angeboten und die Interpretation der HTTP-Antwort nicht wiederholt wird. Das Repository kann erweitert werden, um weitere Anwendungsfälle wie das Löschen einer SKU abzudecken (Listing 8).

<?php
 
declare(strict_types=1);
 
namespace CommerceLayer\Sku;
 
use CommerceLayer\Client;
use CommerceLayer\Sku\Request\Create;
use CommerceLayer\Sku\Request\ReadOneByCode;
use CommerceLayer\Sku\Request\Update;
 
class Repository
{
  public function __construct(
    private readonly Client $client,
  ) {
  }
 
  public function readOneByCode(string $code): Dto
  {
    $request  = ReadOneByCode::create($code);
    $response = $this->client->sendRequest($request);
 
    return Dto::fromResponse($response);
  }
 
  public function create(Dto $dto): Dto
  {
    $request  = Create::create($dto);
    $response = $this->client->sendRequest($request);
 
    return Dto::fromResponse($response);
  }
 
  public function update(Dto $sku): Dto
  {
    $request  = Update::create($sku);
    $response = $this->client->sendRequest($request);
 
    return Dto::fromResponse($response);
  }
}

Mit diesem Stand kann nun mit der CommerceLayer-API kommuniziert werden. SKUs können erstellt, ausgelesen und aktualisiert werden.

Im gleichen Verfahren werden weitere Schnittstellen angebunden. Im Beispielprojekt wird die SAP-Schnittstelle angebunden. Obwohl diese im weiteren Verlauf verwendet wird, ist sie hier nicht explizit beschrieben.

NEWSLETTER

Alle News zu Web Design, UX und Digital Marketing

Events

Um Daten auf dem aktuellen Stand zu behalten, gibt es unterschiedliche Ansätze. Klassisch vertretend sind Cronjobs, die Daten von Zeit zu Zeit selbst zuziehen.

Dieses Projekt baut auf eine ereignisgesteuerte Architektur. Das ist ein Softwarearchitekturparadigma, das die Erzeugung, Erkennung und Nutzung von Ereignissen sowie die Reaktion darauf fördert. Das bedeutet, dass Drittsysteme unsere Applikation über ein Ereignis, wie z. B. eine Preisänderung oder Bestellaufgabe, informiert. Auf dieses Ereignis kann nun reagiert und Prozesse können abgebildet werden, wie sie benötigt werden.

Product Update

Als Beispiel wird die Implementierung der Produktaktualisierung aufgezeigt. Unser System wird über eine Produktänderung benachrichtigt. Es holt sich die Daten aus dem Quellsystem und schreibt sie ins Zielsystem. Für die Kommunikation zwischen den Schnittstellen wird ein ApiConnector-Modul erstellt.

Transferrer

Die Transferrer-Klasse hat die Aufgabe, Daten aus dem Quellsystem abzurufen und im Zielsystem zu speichern. Dadurch bedient sie sich der zuvor erstellten Repositoryobjekte. Als Parameter wird ein Wert verwendet, anhand dessen der SKU in beiden Systemen referenziert werden kann – der SKU-Code.

Sollte der SKU in CommerceLayer (SkuRepository) nicht vorhanden sein, generiert der Guzzle-Client aus einer Antwort mit HTTP-Status 404 eine ClientException. Diese wird gefangen und anstelle eines Updates ein Create ausgelöst. Die daraus resultierende Klasse sieht wie in Listing 9 aus.

<?php
 
declare(strict_types=1);
 
namespace ApiConnector\Material\DataToCommerce;
 
use CommerceLayer\Sku\Dto;
use CommerceLayer\Sku\Repository as SkuRepository;
use GuzzleHttp\Exception\ClientException;
use Sap\Material\Repository as MaterialRepository;
 
class Transferrer
{
  public function __construct(
    private readonly MaterialRepository $materialRepository,
    private readonly SkuRepository $skuRepository,
  ) {
  }
 
  public function __invoke(string $skuCode): void
  {
    $material = $this->materialRepository->readOneBySku($skuCode);
 
    try {
      $sku = $this->skuRepository->readOneByCode($material->sku);
 
      $updatedSku = new Dto(
        $sku->id,
        $material->sku,
        $material->name,
      );
 
      $this->skuRepository->update($updatedSku);
    } catch (ClientException) {
      $this->skuRepository->create(Dto::fromMaterialDto($material));
    }
  }
}

Dass das Ereignis „Produktaktualisierung“ den Transferrer aufrufen kann, muss dieser über eine Route exponiert werden. Die Route bzw. die darüber eingehenden Anfragen werden von einem RequestHandler abgearbeitet, welcher wiederum den Transferrer aufrufen.

In einer Mezzio-Applikation werden die Routen an der Applikationsinstanz in config/routes.php definiert [7] (Listing 10). Der erste Parameter definiert den Pfad und optional mögliche Argumente. Der zweite Parameter gibt die RequestHandler-Klasse an. Als dritten Parameter kann man einen Namen angeben. Dieser spielt später noch eine wichtige Rolle.

$app->get(
  '/api/event/material-change/{sku-code}',
  Api\Event\MaterialChange\RequestHandler::class,
  'api.event.material-change'
);

Dem RequestHandler wird ein Request-Objekt übergeben, das vom Router mit Argumenten angereichert werden kann (Listing 11). In diesem Fall wird der SKU-Code ausgelesen. Es folgt eine einfache Validierung, welche im Fehlerfall eine Antwort mit „Status 400 – Bad Response“ zurückgibt.

Anschließend wird der Transferrer aufgerufen. Mögliche Fehler resultieren in einer Antwort mit „Code 500 – Internal Server Error“. Im Erfolgsfall wird eine einfache „204 – No Content“-Antwort geliefert.

<?php
 
declare(strict_types=1);
 
namespace Api\Event\MaterialChange;
 
use ApiConnector\Material\DataToCommerce\Transferrer;
use Laminas\Diactoros\Response\EmptyResponse;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Server\RequestHandlerInterface;
use Throwable;
 
use function is_string;
 
class RequestHandler implements RequestHandlerInterface
{
  public function __construct(
    private readonly Transferrer $transferrer
  ) {
  }
 
  public function handle(ServerRequestInterface $request): ResponseInterface
  {
    $skuCode = $request->getAttribute('sku-code');
 
    if (
      ! is_string($skuCode)
      || $skuCode === ''
    ) {
      return new EmptyResponse(400);
    }
 
    try {
      ($this->transferrer)($skuCode);
    } catch (Throwable) {
      return new EmptyResponse(500);
    }
 
    return new EmptyResponse();
  }
}

Endpoint-spezifische HMAC-Validierung

Mittels HMAC signierte Payloads bieten die Möglichkeit einer Authentifizierung unter Verwendung eines gemeinsamen Geheimnisses anstelle von digitalen Signaturen mit asymmetrischer Kryptografie (Public-Key-Verschlüsselungsverfahren).

Die CommerceLayer API signiert Event Payloads mit HMAC. Um die Echtheit des Senders sicherzustellen, wird empfohlen, diese Signatur mit einem geteilten Geheimnis zu verifizieren. CommerceLayer generiert für jeden WebHook ein eigenes geteiltes Geheimnis. Dieses wird in der Konfiguration mit dem Namen der entsprechenden Route hinterlegt (Listing 12).

<?php
 
declare(strict_types=1);
 
return [
  'commerce_layer' => [
    // [..]
    'webhooks' => [
      'shared_secrets' => [
        'api.event.customer_create' => 'my-secret',
      ],
    ],
  ],
  // [..]
];

SharedSecretStore

Die geteilten Geheimnisse werden zur Laufzeit in einen SharedSecretStore gelesen, welcher die Aufgabe hat, ein SharedSecret anhand des Route-Namens auszugeben (Listing 13 und 14).

<?php
 
declare(strict_types=1);
 
namespace CommerceLayer\Authentication\Hmac;
 
class SharedSecret
{
  public function __construct(
    public readonly string $route,
    public readonly string $secret,
  ) {
  }
}
<?php
 
declare(strict_types=1);
 
namespace CommerceLayer\Authentication\Hmac;
 
use CommerceLayer\Authentication\Hmac\Exception\SharedSecretNotFoundException;
 
class SharedSecretStore
{
  /** @var SharedSecret[] */
  private array $sharedSecrets;
 
  public function __construct(SharedSecret ...$sharedSecrets)
  {
    $this->sharedSecrets = $sharedSecrets;
  }
 
  public function has(string $route): bool
  {
    foreach ($this->sharedSecrets as $sharedSecret) {
      if ($route === $sharedSecret->route) {
        return true;
      }
    }
 
    return false;
  }
 
  public function get(string $route): SharedSecret
  {
    foreach ($this->sharedSecrets as $sharedSecret) {
      if ($route === $sharedSecret->route) {
        return $sharedSecret;
      }
    }
 
    throw SharedSecretNotFoundException::fromRoute($route);
  }
}

Validator

Der Validator ist dafür zuständig, anhand von SharedSecret und Payload die Signatur zu berechnen und überprüfen, ob diese mit der übergebenen Signatur übereinstimmt (Listing 15).

<?php
 
declare(strict_types=1);
 
namespace CommerceLayer\Authentication\Hmac;
 
use ErrorException;
use Throwable;
 
use function base64_encode;
use function hash_hmac;
use function restore_error_handler;
use function set_error_handler;
 
use const E_WARNING;
 
class Validator
{
  private const ALGORITHM = 'sha256';
 
  public function invoke(
    string $sharedSecret,
    string $signature,
    string $payload
  ): bool {
    try {
      set_error_handler(static function (
        $level,
        $message
      ) {
        throw new ErrorException($message);
      }, E_WARNING);
 
      $message = hash_hmac(
        self::ALGORITHM,
        $payload,
        $sharedSecret,
        true
      );
    } catch (Throwable) {
      restore_error_handler();
 
      return false;
    }
 
    restore_error_handler();
 
    if (false === $message) {
      return false;
    }
 
    $calculatedSignature = base64_encode($message);
 
    return $calculatedSignature === $signature;
  }
}

Middleware

Der Validator kann die Signaturen überprüfen. Nun wäre es sehr mühsam, diesen in jedem RequestHandler manuell aufzurufen. An einer Route können eine oder mehrere Middleware-Klassen hinterlegt werden. Diese werden in definierter Reihenfolge abgearbeitet. Eine Middleware hat die Möglichkeit, einen Request frühzeitig zu beenden, wenn bspw. die anfragende IP-Adresse nicht gestattet ist oder ein API-Rate-Limit überstiegen wurde. Dann wird ein Response-Objekt zurückgeliefert. Ist die Überprüfung positiv verlaufen, wird die Anfrage an die nächste Middleware in der Kette weitergegeben, bis schließlich mit einem Response-Objekt geantwortet wird. Die Middleware für die HMAC-Validierung setzt die zuvor erstellten Komponenten ein (Listing 16).

<?php
 
declare(strict_types=1);
 
namespace CommerceLayer\Authentication\Hmac;
 
use Laminas\Diactoros\Response\EmptyResponse;
use Mezzio\Router\RouteResult;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Server\MiddlewareInterface;
use Psr\Http\Server\RequestHandlerInterface;
 
use function assert;
use function count;
 
class Middleware implements MiddlewareInterface
{
  public function __construct(
    private readonly SharedSecretStore $secretStore,
    private readonly Validator $hmacValidator,
  ) {
  }
 
  public function process(
    ServerRequestInterface $request,
    RequestHandlerInterface $handler
  ): ResponseInterface {
    $route = $request->getAttribute(RouteResult::class);
    assert($route instanceof RouteResult);
    $routeName = $route->getMatchedRouteName();
 
    if (false === $routeName) {
      return new EmptyResponse(401);
    }
 
    if (! $this->secretStore->has($routeName)) {
      return new EmptyResponse(401);
    }
 
    $signature          = $request->getHeader('x-commercelayer-signature');
    $numberOfSignatures = count($signature);
 
    if (1 !== $numberOfSignatures) {
      return new EmptyResponse(401);
    }
 
    $payload = (string) $request->getBody();
 
    if (
      $this->hmacValidator->invoke(
        $this->secretStore->get($routeName)->secret,
        $signature[0],
        $payload
      )
    ) {
      return $handler->handle($request);
    }
 
    return new EmptyResponse(401);
  }
}

Middleware zur Route hinzufügen

Diese wird nun dem RequestHandler in der Route vorangestellt. Anfragen mit ungültigen Signaturen werden von der Middleware frühzeitig beendet und erreichen den RequestHandler nicht. Auf die gleiche Weise können alle anderen Endpunkte, die eine HMAC-Signatur überprüfen sollen, ergänzt werden, ohne jeden RequestHandler erweitern zu müssen. Mit dem in Listing 17 gezeigten Code ist die Middleware auch schon einsatzbereit.

$app->get(
  '/api/event/customer-create',
  [
    CommerceLayer\Authentication\Hmac\Middleware::class,
    Api\Event\MaterialChange\RequestHandler::class,
  ],
  'api.event.customer_create'
);

Viele Wege führen nach Rom

Dem aufmerksamen Leser ist nicht entgangen, dass die Codebeispiele stark vom eigentlichen Framework entkoppelt sind und wo immer möglich auf PSRs gesetzt wird. Das bedeutet, dass das Framework selbst in den Hintergrund gerückt und ersetzbar(er) geworden ist. Das ist erfreulich, da das Framework oder auch einzelne Komponenten leichter abgelöst werden können, wenn sich bspw. die Anforderungen ändern oder einzelne Bibliotheken nicht mehr gewartet werden. Wahrscheinlich ist es möglich, den gezeigten Fall mit nahezu jedem anderen modernen PHP-Framework umzusetzen.

NOCH MEHR ZU WEB DESIGN?

Der Web Design & Development Track

Fazit

Im Team haben wir vor wenigen Jahren diverse Frameworks evaluiert und uns einstimmig für Mezzio entschieden, um das vorherige Framework abzulösen. Mit unserer Entscheidung sind wir voll zufrieden, weil Mezzio

  • es uns erlaubt, das Framework so einzusetzen, wie es die jeweiligen Projektanforderungen erfordern, ohne auf unflexible Vorschriften des Frameworks Rücksicht nehmen zu müssen,
  • es einem leicht macht, testbaren Code zu schreiben, der sauber vom Framework getrennt ist und
  • eine sehr hilfreiche Community hat, die schnelle Unterstützung im Forum [8] und Chat [9] anbietet.

Wenn wir etwas an Mezzio verbessern könnten, wäre das die Größe der aktiven Community – das ist eine Einladung an alle Interessierten, sich am Laminas-Projekt zu beteiligen. Es wird von wenigen Schultern getragen und hat eine vergleichsweise große Anzahl stiller Benutzer. Mezzio kann von Mitwirkenden profitieren, die Dokumentationen schreiben, Issues bearbeiten, sich als Maintainer einer Komponente begeistern oder finanziell unterstützen [10].

Das beschriebene Projekt ist im Sommer 2021 live gegangen. Eine vollständige Fallstudie kann auf der Seite der Intelliact AG [11] abgerufen werden. Ein rückwärts gerichteter Blick bestätigt, dass mit dieser Architektur die richtige Entscheidung getroffen wurde. Kunden und Endkunden sind von der Performance begeistert. Beim Best of Swiss Web Award wurde das Projekt aus 351 Einreichungen zum Masterkandidat [12] nominiert und gewann u. a. den goldenen Preis in der Kategorie Technologie [13].

Zusammengefasst: Mezzio kann den Global Playern definitiv das Wasser reichen!


Links & Literatur

[1] https://github.com/arueckauer/composable-commerce-mit-mezzio

[2] https://docs.mezzio.dev/mezzio/#get-started-now

[3] https://github.com/mezzio/mezzio-skeleton/issues/46

[4] https://www.php-fig.org/

[5] https://github.com/nikic/FastRoute

[6] https://docs.laminas.dev/laminas-servicemanager/configuring-the-service-manager/

[7] https://docs.mezzio.dev/mezzio/v3/getting-started/quick-start/#routing

[8] https://discourse.laminas.dev/

[9] https://laminas.slack.com/

[10] https://crowdfunding.lfx.linuxfoundation.org/projects/laminas-project

[11] https://intelliact.ch/referenzen/case-study-thommen

[12] https://www.netzwoche.ch/news/2022-03-10/master-kandidat-thommen-medical-b2b-webshop

[13] https://www.netzwoche.ch/news/2022-04-07/gold-fuer-thommen-medical-b2b-webshop-in-der-kategorie-technology

MEHR INFOS ZUR WEBINALE?

JETZT NEWSLETTER ABONNIEREN