JezK
Edit File: AbstractListingEndpoint.php
<?php declare(strict_types = 1); namespace MailPoet\API\REST; if (!defined('ABSPATH')) exit; use MailPoet\Listing\Handler as ListingHandler; use MailPoet\Listing\ListingDefinition; use MailPoet\Listing\ListingRepository; use MailPoet\Validator\Builder; /** * Base class for REST endpoints that expose a MailPoet listing * (search, sort, filter, pagination) via the shared * {@see ListingRepository} infrastructure. * * Concrete subclasses only need to supply the repository and the * per-row response mapper. The request schema, parameter parsing, * and response shape (`{items, meta:{count, pages}, filters, groups}`) * are standardized so all DataViews-backed listings in the admin UI * consume the same contract. */ abstract class AbstractListingEndpoint extends Endpoint { public const DEFAULT_PER_PAGE = 20; public const MAX_PER_PAGE = 100; // Sentinel cap on `page`. Keeps `(page-1) * per_page` from producing // huge OFFSETs that would push MySQL through millions of skipped rows // when a client (or fuzzer) sends `page=999999999`. public const MAX_PAGE = 100000; /** @var ListingHandler */ private $listingHandler; public function __construct( ListingHandler $listingHandler ) { $this->listingHandler = $listingHandler; } /** * Subclasses MUST override `checkPermissions()` to declare a real * capability. The inherited default from `Endpoint::checkPermissions()` * checks a non-existent `admin` capability and would fail closed but * silently. PHP does not let us redeclare the parent method as `abstract` * here without breaking other consumers, so this is enforced by review + * tests instead of the type system. */ public function handle(Request $request): Response { $definition = $this->buildDefinition($request); $repository = $this->getListingRepository(); $rows = $repository->getData($definition); $countAndGroups = $this->getCountAndGroups($repository, $definition); $count = $countAndGroups['count']; $perPage = $definition->getLimit() ?: self::DEFAULT_PER_PAGE; $pages = $count === 0 ? 0 : (int)ceil($count / max(1, $perPage)); return new Response([ 'items' => $this->buildItems($rows, $definition), 'meta' => [ 'count' => $count, 'pages' => $pages, ], 'filters' => $repository->getFilters($definition), 'groups' => $countAndGroups['groups'], ]); } /** * Returns `['count' => int, 'groups' => array]`. Split into a hook so an * endpoint whose group counts already include the current group's count can * compute both from a single query and skip the separate getCount(). The * default keeps the two independent queries, unchanged for every other * listing. * * @return array{count: int, groups: array<int, array<string, mixed>>} */ protected function getCountAndGroups(ListingRepository $repository, ListingDefinition $definition): array { return [ 'count' => $repository->getCount($definition), 'groups' => $repository->getGroups($definition), ]; } public static function getRequestSchema(): array { return [ 'page' => Builder::integer(), 'per_page' => Builder::integer(), 'orderby' => Builder::string(), 'order' => Builder::string(), 'sort_by' => Builder::string(), 'sort_order' => Builder::string(), 'search' => Builder::string(), 'group' => Builder::string(), 'filter' => Builder::object(), ]; } abstract protected function getListingRepository(): ListingRepository; /** * @param mixed[] $rows Rows returned by {@see ListingRepository::getData()}. * @param ListingDefinition $definition Parsed request — exposed so subclasses * can branch on filter/group when shaping items without stashing per-request * state on the (shared) endpoint instance. * @return array<int, array<string, mixed>> Items ready to be serialized. */ abstract protected function buildItems(array $rows, ListingDefinition $definition): array; protected function getDefaultSortBy(): string { return 'id'; } protected function getDefaultSortOrder(): string { return 'desc'; } /** * Default group applied when the client does not send one. Useful when a * listing's repository uses groups to gate "all" vs "trash" (or similar) * and would otherwise return mixed results. */ protected function getDefaultGroup(): ?string { return null; } protected function getDefaultPerPage(): int { return self::DEFAULT_PER_PAGE; } protected function getDefaultParameters(): array { return []; } /** * Subclasses may override to derive the listing definition's free-form * `params` array from the request (e.g. a `?type=standard` query arg that * routes to the same underlying repository). The default reuses * {@see getDefaultParameters()} so callers without per-request params keep * the existing behavior. * * @return array<string, mixed> */ protected function getRequestParameters(Request $request): array { return $this->getDefaultParameters(); } private function buildDefinition(Request $request): ListingDefinition { $perPageParam = $request->getParam('per_page') ?? $request->getParam('limit'); $perPage = is_numeric($perPageParam) ? max(1, min(self::MAX_PER_PAGE, (int)$perPageParam)) : $this->getDefaultPerPage(); $pageParam = $request->getParam('page'); $offsetParam = $request->getParam('offset'); $offset = is_numeric($pageParam) ? (min(self::MAX_PAGE, max(1, (int)$pageParam)) - 1) * $perPage : (is_numeric($offsetParam) ? (int)$offsetParam : 0); $orderByParam = $request->getParam('orderby') ?? $request->getParam('sort_by'); $sortBy = is_string($orderByParam) && $orderByParam !== '' ? $orderByParam : $this->getDefaultSortBy(); $orderParam = $request->getParam('order') ?? $request->getParam('sort_order'); $sortOrder = is_string($orderParam) && $orderParam !== '' ? strtolower($orderParam) : $this->getDefaultSortOrder(); $searchParam = $request->getParam('search'); $search = is_string($searchParam) ? $searchParam : null; $groupParam = $request->getParam('group'); $group = is_string($groupParam) && $groupParam !== '' ? $groupParam : $this->getDefaultGroup(); $filterParam = $request->getParam('filter'); $filters = is_array($filterParam) ? $filterParam : []; return $this->listingHandler->getListingDefinition([ 'offset' => $offset, 'limit' => $perPage, 'sort_by' => $sortBy, 'sort_order' => $sortOrder, 'search' => $search, 'group' => $group, 'filter' => $filters, 'params' => $this->getRequestParameters($request), ]); } }