<?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);
$count = $repository->getCount($definition);
$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' => $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),
]);
}
}