GraphQL-Endpoint: Punktestand und Prämienkatalog abfragen
GraphQL-Endpoint: Punktestand und Prämienkatalog abfragen
~8 Min. Lesezeit Zuletzt aktualisiert am 9. August 2026
Kapitel 79-81 haben die REST-Seite fertiggestellt. Dieses Kapitel bringt dieselben Daten - Punktestand und Prämienkatalog - als GraphQL-Query, nach exakt demselben schema.graphqls/Resolver/DataProvider-Muster, das die separate GraphQL-Tutorial-Serie dieses Katalogs im Detail einführt (Skript tools/generate-tutorial-graphql-csv.py, hier vorausgesetzt).
Zwei Felder, zwei Sicherheitsmodelle
Genau wie bei den beiden REST-Routen aus Kapitel 80 stehen sich hier zwei grundverschiedene Zugriffsmodelle gegenüber: loyaltyPointsSummary ist personenbezogen und erfordert einen eingeloggten Kunden, loyaltyRewards ist der öffentliche, gästefähige Katalog (dieselbe Unterscheidung wie zwischen den Controllern History\Index und Catalog\Index aus Kapitel 45/49).
type Query {
loyaltyPointsSummary: LoyaltyPointsSummary
@resolver(class: "Mironsoft\\Loyalty\\Model\\Resolver\\PointsSummary")
@doc(description: "Returns the current customer's points balance, tier, and ledger history")
loyaltyRewards(
rewardType: String
maxPoints: Int
): [LoyaltyReward]
@resolver(class: "Mironsoft\\Loyalty\\Model\\Resolver\\RewardCatalog")
@doc(description: "Returns the active reward catalog, optionally filtered")
}
type LoyaltyPointsSummary @doc(description: "A customer's aggregated points summary") {
points_balance: Int! @doc(description: "Current points balance")
tier: String! @doc(description: "One of bronze, silver, gold")
ledger_entries: [LoyaltyPointsLedgerEntry] @doc(description: "Recent ledger entries")
}
type LoyaltyPointsLedgerEntry @doc(description: "A single points ledger entry") {
points: Int! @doc(description: "Positive for a credit, negative for a debit")
type: String! @doc(description: "One of earn, redeem, expire, adjust")
balance_after: Int! @doc(description: "Balance immediately after this entry")
created_at: String @doc(description: "When this entry was booked")
}
type LoyaltyReward @doc(description: "A single redeemable reward") {
reward_id: Int! @doc(description: "Reward entity ID")
identifier: String! @doc(description: "URL-safe reward identifier")
title: String! @doc(description: "Reward title")
description: String @doc(description: "Reward description")
points_cost: Int! @doc(description: "Points required to redeem this reward")
discount_value: Float @doc(description: "Discount value, for discount-type rewards")
reward_type: String! @doc(description: "One of discount, free_product, free_shipping")
is_active: Boolean! @doc(description: "Whether the reward is currently redeemable")
}
Der PointsSummary-Resolver
Statt eigener Geschäftslogik injiziert der Resolver direkt Api\PointsManagementInterface aus Kapitel 80 - dasselbe Service-Contract-Wiederverwendungsprinzip, das Kapitel 79 als Kapitelziel formuliert hat, hier zum ersten Mal ganz konkret.
<?php
declare(strict_types=1);
namespace Mironsoft\Loyalty\Model\Resolver;
use Magento\Framework\GraphQl\Config\Element\Field;
use Magento\Framework\GraphQl\Exception\GraphQlAuthorizationException;
use Magento\Framework\GraphQl\Query\ResolverInterface;
use Magento\Framework\GraphQl\Schema\Type\ResolveInfo;
use Mironsoft\Loyalty\Api\Data\PointsLedgerInterface;
use Mironsoft\Loyalty\Api\PointsManagementInterface;
/**
* Resolves the loyaltyPointsSummary query field - deliberately delegates to
* the same PointsManagementInterface the REST endpoint uses (chapter 80),
* no separate GraphQL-only business logic.
*/
class PointsSummary implements ResolverInterface
{
/**
* @param PointsManagementInterface $pointsManagement Aggregates balance, tier, and ledger history.
*/
public function __construct(
private readonly PointsManagementInterface $pointsManagement,
) {
}
/**
* @param Field $field Resolved GraphQL field configuration.
* @param mixed $context Resolver context, carries the customer ID (see graphql-magento series, chapter 17).
* @param ResolveInfo $info GraphQL resolve tree info.
* @param array|null $value Parent resolver's value, unused for a top-level field.
* @param array|null $args Arguments passed to the loyaltyPointsSummary field, unused - no arguments declared.
* @return array<string, mixed>
* @throws GraphQlAuthorizationException
*/
public function resolve(
Field $field,
$context,
ResolveInfo $info,
?array $value = null,
?array $args = null
): array {
if (!$context->getExtensionAttributes()->getIsCustomer()) {
throw new GraphQlAuthorizationException(
__('The current customer isn\'t authorized.')
);
}
$customerId = (int) $context->getUserId();
$summary = $this->pointsManagement->getPointsSummary($customerId);
return [
'points_balance' => $summary->getPointsBalance(),
'tier' => $summary->getTier(),
'ledger_entries' => array_map(
static fn (PointsLedgerInterface $entry): array => [
'points' => $entry->getPoints(),
'type' => $entry->getType(),
'balance_after' => $entry->getBalanceAfter(),
'created_at' => $entry->getCreatedAt(),
],
$summary->getLedgerEntries()
),
];
}
}
Achtung: $context->getExtensionAttributes()->getIsCustomer() ist hier von Anfang an vorhanden, nicht - wie im Einstiegsbeispiel der GraphQL-Serie - nachträglich ergänzt: Ein Punktestand ist per Definition personenbezogen, ein Gastzugriff darf niemals stillschweigend customerId = 0 auflösen. GraphQlAuthorizationException stoppt die Anfrage sauber, statt Daten eines zufälligen Kunden zurückzugeben.
Caching: die FPC-Authorization-Regel greift automatisch
Da loyaltyPointsSummary immer einen Authorization-Header mit Kunden-Token voraussetzt, markiert Magento die Antwort automatisch als nicht öffentlich cachefähig - derselbe Mechanismus, den die GraphQL-Serie an is_favorite zeigt. Kein eigener Cache-Code nötig, keine Gefahr, dass ein Kunde versehentlich den im FPC gespeicherten Punktestand eines anderen Kunden sieht.
Der RewardCatalog-Resolver und Datenprovider
loyaltyRewards ist dagegen öffentlich cachefähig - hier lohnt sich ein eigenes Cache-Tag über IdentityInterface, damit eine im Admin geänderte Prämie den zugehörigen FPC-Eintrag zuverlässig invalidiert:
<?php
declare(strict_types=1);
namespace Mironsoft\Loyalty\Model\Resolver;
use Magento\Framework\GraphQl\Config\Element\Field;
use Magento\Framework\GraphQl\Query\Resolver\IdentityInterface;
use Magento\Framework\GraphQl\Query\ResolverInterface;
use Magento\Framework\GraphQl\Schema\Type\ResolveInfo;
use Mironsoft\Loyalty\Model\Resolver\DataProvider\RewardCatalog as RewardCatalogDataProvider;
/**
* Resolves the loyaltyRewards query field - kept thin, all data access lives
* in the DataProvider (same split as the graphql-magento series' Events resolver).
*/
class RewardCatalog implements ResolverInterface, IdentityInterface
{
private const CACHE_TAG = 'mironsoft_loyalty_reward';
/**
* @param RewardCatalogDataProvider $dataProvider Loads and shapes the reward catalog.
*/
public function __construct(
private readonly RewardCatalogDataProvider $dataProvider,
) {
}
/**
* @param Field $field Resolved GraphQL field configuration.
* @param mixed $context Resolver context, unused - the catalog is guest-accessible.
* @param ResolveInfo $info GraphQL resolve tree info.
* @param array|null $value Parent resolver's value, unused for a top-level field.
* @param array|null $args Arguments passed to the loyaltyRewards field (rewardType, maxPoints).
* @return array<int, array<string, mixed>>
*/
public function resolve(
Field $field,
$context,
ResolveInfo $info,
?array $value = null,
?array $args = null
): array {
return $this->dataProvider->getRewards(
isset($args['rewardType']) ? (string) $args['rewardType'] : null,
isset($args['maxPoints']) ? (int) $args['maxPoints'] : null
);
}
/**
* Returns the cache tags this resolved data should be invalidated by.
*
* @param array<int, array<string, mixed>> $resolvedData The array returned by resolve().
* @return string[]
*/
public function getIdentities(array $resolvedData): array
{
$tags = [self::CACHE_TAG];
foreach ($resolvedData as $reward) {
if (isset($reward['reward_id'])) {
$tags[] = self::CACHE_TAG . '_' . $reward['reward_id'];
}
}
return $tags;
}
}
<?php
declare(strict_types=1);
namespace Mironsoft\Loyalty\Model\Resolver\DataProvider;
use Mironsoft\Loyalty\Model\ResourceModel\Reward;
use Mironsoft\Loyalty\Model\ResourceModel\Reward\CollectionFactory;
/**
* Loads and shapes the active reward catalog for the loyaltyRewards GraphQL
* field, reusing the same EAV collection filters the storefront catalog
* page already uses (chapter 15/49).
*/
class RewardCatalog
{
/**
* @param CollectionFactory $collectionFactory Factory for the EAV reward collection (chapter 15).
*/
public function __construct(
private readonly CollectionFactory $collectionFactory,
) {
}
/**
* Returns the active reward catalog, optionally filtered.
*
* @param string|null $rewardType One of Model\Reward\Source\RewardType::TYPE_*, or null for all.
* @param int|null $maxPoints Maximum points cost, or null for no limit.
* @return array<int, array<string, mixed>>
*/
public function getRewards(?string $rewardType, ?int $maxPoints): array
{
$collection = $this->collectionFactory->create();
$collection->addActiveFilter();
if ($rewardType !== null) {
$collection->addRewardTypeFilter($rewardType);
}
if ($maxPoints !== null) {
$collection->addMaxPointsCostFilter($maxPoints);
}
$rewards = [];
/** @var Reward $reward */
foreach ($collection as $reward) {
$rewards[] = [
'reward_id' => (int) $reward->getId(),
'identifier' => (string) $reward->getData('identifier'),
'title' => (string) $reward->getData('title'),
'description' => $reward->getData('description'),
'points_cost' => (int) $reward->getData('points_cost'),
'discount_value' => $reward->getData('discount_value'),
'reward_type' => (string) $reward->getData('reward_type'),
'is_active' => (bool) $reward->getData('is_active'),
];
}
return $rewards;
}
}
Der DataProvider ruft ausschließlich die bereits in Kapitel 15 gebauten Collection-Filtermethoden auf (addActiveFilter(), addRewardTypeFilter(), addMaxPointsCostFilter()) - identisch zu ViewModel\RewardCatalog aus Kapitel 49, nur als GraphQL- statt als Storefront-Aufrufer.
Beide Queries testen
query MyPoints {
loyaltyPointsSummary {
points_balance
tier
ledger_entries {
points
type
created_at
}
}
}
query ActiveDiscountRewards {
loyaltyRewards(rewardType: "discount", maxPoints: 500) {
reward_id
title
points_cost
discount_value
}
}Tipp: Genau wie webapi.xml/acl.xml (Kapitel 80) fließt auch schema.graphqls in den config_webservice-Cache - nach jeder Änderung an dieser Datei ist bin/cache-clean config_webservice Pflicht. Bleibt dieser Schritt aus, meldet /graphql hartnäckig "Cannot query field ... on type Query", obwohl das Feld längst im Schema steht.
Kapitel 83 ergänzt die fehlende Gegenseite: eine Mutation, die Kapitel 81s Einlöse-Logik ohne jede Duplizierung wiederverwendet.