Eine eigene REST-API: webapi.xml und ACL für den Punktestand
Eine eigene REST-API: webapi.xml und ACL für den Punktestand
~7 Min. Lesezeit Zuletzt aktualisiert am 9. August 2026
Mit dem Repository-Fundament aus Kapitel 79 fertig, folgt jetzt die erste eigene REST-Route dieser Serie. Bewusster Einstieg: nicht die Prämien-Verwaltung selbst (die folgt in Kapitel 81), sondern der einfachere, lesende Punktestand - GET /V1/loyalty/points/mine, der genau zeigt, wie webapi.xml einen Service Contract auf eine URL abbildet und wie ACL dabei zwei völlig unterschiedliche Zugriffsmodelle für denselben Code ermöglicht.
Ein Aggregat statt eines rohen Attributs
loyalty_points_balance (Kapitel 21) ließe sich über den bereits existierenden Core-Endpunkt GET /V1/customers/me abfragen - Custom Attributes werden dort automatisch mitgeliefert. Der eigene Endpunkt lohnt sich trotzdem, weil er mehr liefert, als ein einzelnes Attribut hergibt: Punktestand, Treue-Stufe UND die zugehörige Ledger-Historie (Kapitel 6) in einer einzigen Antwort. Genau diese Aggregation übernimmt Api\PointsManagementInterface und Api\Data\PointsSummaryInterface:
<?php
declare(strict_types=1);
namespace Mironsoft\Loyalty\Api\Data;
/**
* Data interface for a customer's aggregated points summary - current
* balance, tier, and recent ledger history in a single call.
*/
interface PointsSummaryInterface
{
public const CUSTOMER_ID = 'customer_id';
public const POINTS_BALANCE = 'points_balance';
public const TIER = 'tier';
public const LEDGER_ENTRIES = 'ledger_entries';
/**
* @return int
*/
public function getCustomerId(): int;
/**
* @param int $customerId Customer entity ID.
* @return $this
*/
public function setCustomerId(int $customerId): self;
/**
* @return int
*/
public function getPointsBalance(): int;
/**
* @param int $pointsBalance Current points balance.
* @return $this
*/
public function setPointsBalance(int $pointsBalance): self;
/**
* @return string
*/
public function getTier(): string;
/**
* @param string $tier One of Model\Source\LoyaltyTier::TIER_*.
* @return $this
*/
public function setTier(string $tier): self;
/**
* @return \Mironsoft\Loyalty\Api\Data\PointsLedgerInterface[]
*/
public function getLedgerEntries(): array;
/**
* @param \Mironsoft\Loyalty\Api\Data\PointsLedgerInterface[] $ledgerEntries Recent ledger entries.
* @return $this
*/
public function setLedgerEntries(array $ledgerEntries): self;
}
<?php
declare(strict_types=1);
namespace Mironsoft\Loyalty\Api;
use Magento\Framework\Exception\NoSuchEntityException;
use Mironsoft\Loyalty\Api\Data\PointsSummaryInterface;
/**
* Service contract that aggregates a customer's points balance, tier, and
* ledger history - reused by both webapi.xml routes (chapter 80) and the
* GraphQL points query (chapter 82).
*/
interface PointsManagementInterface
{
/**
* Returns the points summary for the given customer.
*
* @param int $customerId Customer entity ID.
* @return \Mironsoft\Loyalty\Api\Data\PointsSummaryInterface
* @throws NoSuchEntityException
*/
public function getPointsSummary(int $customerId): PointsSummaryInterface;
}
Die Implementierung ruft ausschließlich bereits vorhandene Service Contracts auf - PointsLedgerRepositoryInterface::getListByCustomerId() aus Kapitel 6 und dieselbe getCustomAttribute()-Technik aus Kapitel 30 - und fügt keine einzige neue Zeile Geschäftslogik hinzu:
<?php
declare(strict_types=1);
namespace Mironsoft\Loyalty\Model;
use Magento\Customer\Api\CustomerRepositoryInterface;
use Mironsoft\Loyalty\Api\Data\PointsSummaryInterface;
use Mironsoft\Loyalty\Api\Data\PointsSummaryInterfaceFactory;
use Mironsoft\Loyalty\Api\PointsLedgerRepositoryInterface;
use Mironsoft\Loyalty\Api\PointsManagementInterface;
/**
* Aggregates a customer's points balance, tier, and ledger history from
* classes already introduced in block 1 - no new persistence, purely a
* read-side composition used by REST (chapter 80) and GraphQL (chapter 82).
*/
class PointsManagement implements PointsManagementInterface
{
/**
* @param CustomerRepositoryInterface $customerRepository Reads the customer's custom attributes.
* @param PointsLedgerRepositoryInterface $ledgerRepository Loads the customer's ledger entries (chapter 6).
* @param PointsSummaryInterfaceFactory $summaryFactory Factory for the PointsSummary DTO.
*/
public function __construct(
private readonly CustomerRepositoryInterface $customerRepository,
private readonly PointsLedgerRepositoryInterface $ledgerRepository,
private readonly PointsSummaryInterfaceFactory $summaryFactory,
) {
}
/**
* @inheritDoc
*/
public function getPointsSummary(int $customerId): PointsSummaryInterface
{
$customer = $this->customerRepository->getById($customerId);
$balanceAttribute = $customer->getCustomAttribute('loyalty_points_balance');
$pointsBalance = $balanceAttribute !== null ? (int) $balanceAttribute->getValue() : 0;
$tierAttribute = $customer->getCustomAttribute('loyalty_tier');
$tier = $tierAttribute !== null ? (string) $tierAttribute->getValue() : 'bronze';
$summary = $this->summaryFactory->create();
$summary->setCustomerId($customerId);
$summary->setPointsBalance($pointsBalance);
$summary->setTier($tier);
$summary->setLedgerEntries($this->ledgerRepository->getListByCustomerId($customerId));
return $summary;
}
}
webapi.xml: zwei Routen, eine Methode
webapi.xml bildet HTTP-Methode und URL auf Service/method ab. Absichtlich zwei Routen für dieselbe getPointsSummary()-Methode, um zwei grundverschiedene Sicherheitsmodelle nebeneinander zu zeigen, statt Logik zu duplizieren:
<?xml version="1.0"?>
<routes xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="urn:magento:module:Magento_Webapi:etc/webapi.xsd">
<route url="/V1/loyalty/points/mine" method="GET">
<service class="Mironsoft\Loyalty\Api\PointsManagementInterface" method="getPointsSummary"/>
<resources>
<resource ref="self"/>
</resources>
<data>
<parameter name="customerId" force="true">%customer_id%</parameter>
</data>
</route>
<route url="/V1/loyalty/points/customer/:customerId" method="GET">
<service class="Mironsoft\Loyalty\Api\PointsManagementInterface" method="getPointsSummary"/>
<resources>
<resource ref="Mironsoft_Loyalty::points_view"/>
</resources>
</route>
</routes>
/V1/loyalty/points/mine-<resource ref="self"/>ist Magentos eingebauter Selbstbezug (dasselbe Muster wie beim Core-Endpunkt/V1/customers/me): jeder eingeloggte Kunde darf zugreifen, aber ausschließlich auf die eigenen Daten.<data><parameter name="customerId" force="true">%customer_id%</parameter></data>- überschreibt dencustomerId-Parameter serverseitig mit der ID aus dem authentifizierten Token, bevorgetPointsSummary()überhaupt aufgerufen wird. Der Aufrufer kann diesen Wert nicht selbst setzen./V1/loyalty/points/customer/:customerId- keinforce, dafür die neue, eng geschnittene ACL-RessourceMironsoft_Loyalty::points_view: ein Support-Mitarbeiter mit dieser Rolle kann gezielt den Punktestand eines beliebigen Kunden nachschlagen, ohne dass die Route für normale Kunden erreichbar wäre.
Achtung: force="true" zu vergessen ist die klassische ACL-Lücke bei "mine"-artigen Endpunkten: Ohne dieses Attribut würde Magento den customerId-Parameter aus der Anfrage selbst übernehmen - ein authentifizierter Kunde könnte dann per einfachem Query-Parameter den Punktestand eines anderen Kunden abfragen, obwohl ref="self" Zugriff grundsätzlich gestattet. force ist genau der Mechanismus, der "self" tatsächlich auf "nur sich selbst" einschränkt.
Die neue ACL-Ressource
Mironsoft_Loyalty::points_view reiht sich unter der bereits in Kapitel 1 angelegten Elternressource Mironsoft_Loyalty::loyalty ein, als Geschwister von Mironsoft_Loyalty::rewards (Kapitel 2) und Mironsoft_Loyalty::config_section (Kapitel 7):
<acl>
<resources>
<resource id="Magento_Backend::admin">
<resource id="Mironsoft_Loyalty::loyalty" title="Mironsoft Loyalty" sortOrder="10">
<resource id="Mironsoft_Loyalty::rewards" title="Rewards" sortOrder="10"/>
<resource id="Mironsoft_Loyalty::config_section" title="Configuration" sortOrder="20"/>
<resource id="Mironsoft_Loyalty::points_view" title="View Customer Points (API)" sortOrder="30"/>
</resource>
</resource>
</resources>
</acl>
Achtung: Eine breite, bereits existierende Ressource wie Magento_Backend::admin wiederzuverwenden wäre die bequemere, aber falsche Abkürzung gewesen - jeder Admin-Benutzer hätte dann automatisch Zugriff auf fremde Punktestände gehabt, statt nur die Rollen, denen ein Administrator Mironsoft_Loyalty::points_view explizit zuweist. Eine eigene, eng geschnittene ACL-Ressource pro sensiblem Endpunkt ist der Standard, nicht die Ausnahme.
Den Endpunkt testen
# Kundentoken zuerst holen (Standard-Core-Endpunkt):
curl -s -X POST https://mironsoft.test/rest/V1/integration/customer/token \
-H 'Content-Type: application/json' \
-d '{"username":"kunde@example.com","password":"geheim123"}'
# Mit dem zurückgegebenen Token den eigenen Punktestand abrufen:
curl -s https://mironsoft.test/rest/V1/loyalty/points/mine \
-H 'Authorization: Bearer <token>'Tipp: Nach jeder Änderung an webapi.xml oder acl.xml ist ein expliziter Cache-Leerlauf Pflicht, da beide Dateien in den Cache-Typ config_webservice einfließen: bin/cache-clean config_webservice. Auch im developer-Modus bleibt eine geänderte Route sonst unsichtbar - ein häufiger, leicht zu übersehender Stolperstein, wenn ein frisch registrierter Endpunkt hartnäckig mit "404 - Requested resource not found" antwortet.
Kapitel 81 baut auf demselben Muster den zweiten, schreibenden Endpunkt: eine Prämie per REST einlösen - und legt dabei die Geschäftslogik an, die Kapitel 83 später unverändert für die GraphQL-Mutation wiederverwendet.