= 1; non-positive values fall back * to the defaults envelope. * - `affilun_per_page` — integer 1..50; out-of-range values fall * back to defaults. * - `affilun_provider` — non-empty string; whitespace-only / missing * coerced to null. * - `affilun_sync_state`— closed-set string; invalid values fall back * to null (the `ProductListQuery` constructor * enforces the whitelist). * - `affilun_search` — non-empty (post-trim) string; whitespace-only * coerced to null. * - `affilun_sort` — closed-set string; default `'updated_at'`. * - `affilun_order` — closed-set string; default `'desc'`. * * The parser routes every string axis through `sanitize_text_field()` * when available so a malicious URL parameter cannot smuggle an * executable payload into the rendered filter form via a `selected` * attribute. The function-exists guard keeps the parser usable in * pure-unit harnesses where WordPress is not loaded. * * See `specifications/admin-ui-spec.md` §6.4 (filters) and * `specifications/rest-api-spec.md` §5.1 / §5.3 (pagination, * filters). * * @package Affilun\Presentation\Admin\Page */ declare(strict_types=1); namespace Affilun\Presentation\Admin\Page; use Affilun\Infrastructure\Persistence\ProductListQuery; use InvalidArgumentException; /** * Pure-function helper that converts the `$_GET` array of the §6 * Products list page into a normalized {@see ProductListQuery}. * * See the file-level docblock for the per-axis parsing rules and * the graceful-fallback contract that catches construction errors * from a tampered URL. */ final class ProductsListQueryParser { /** * `$_GET` key carrying the 1-based page number. */ public const KEY_PAGE = 'affilun_page'; /** * `$_GET` key carrying the records-per-page count. */ public const KEY_PER_PAGE = 'affilun_per_page'; /** * `$_GET` key carrying the provider exact-match filter. */ public const KEY_PROVIDER = 'affilun_provider'; /** * `$_GET` key carrying the sync-state filter. */ public const KEY_SYNC_STATE = 'affilun_sync_state'; /** * `$_GET` key carrying the title substring filter. */ public const KEY_SEARCH = 'affilun_search'; /** * `$_GET` key carrying the user-side state filter. */ public const KEY_USER_STATE = 'affilun_user_state'; /** * Legacy `$_GET` key that used to toggle deleted products into the list. */ public const KEY_SHOW_DELETED = 'affilun_show_deleted'; /** * `$_GET` key carrying the sort-axis literal. */ public const KEY_SORT = 'affilun_sort'; /** * `$_GET` key carrying the sort-order literal. */ public const KEY_ORDER = 'affilun_order'; /** * Default records-per-page when `$_GET` omits the axis. */ private const DEFAULT_PER_PAGE = 20; /** * Convert `$_GET`-shaped input into a {@see ProductListQuery}. * * Construction failures (e.g. a tampered `affilun_sort` token that * survives every per-axis trim but trips the closed-set check * inside {@see ProductListQuery::__construct()}) are caught and * mapped to {@see ProductListQuery::defaults()} so a malformed * URL cannot break the admin page rendering. * * @param array $getData `$_GET`-shaped input * (already unslashed). */ public function parse( array $getData ): ProductListQuery { $page = self::parsePositiveInt( $getData, self::KEY_PAGE, 1 ); $perPage = self::parsePositiveInt( $getData, self::KEY_PER_PAGE, self::DEFAULT_PER_PAGE ); $provider = self::parseNonEmptyString( $getData, self::KEY_PROVIDER ); $syncState = self::parseNonEmptyString( $getData, self::KEY_SYNC_STATE ); $search = self::parseNonEmptyString( $getData, self::KEY_SEARCH ); $userState = self::parseNonEmptyString( $getData, self::KEY_USER_STATE ); $lifecycle = self::lifecycleFromUserState( $userState ); $sortKey = self::parseNonEmptyString( $getData, self::KEY_SORT ) ?? ProductListQuery::SORT_KEY_UPDATED_AT; $sortOrder = self::parseNonEmptyString( $getData, self::KEY_ORDER ) ?? ProductListQuery::SORT_ORDER_DESC; try { return new ProductListQuery( $page, $perPage, $provider, $search, $syncState, null, $lifecycle, $sortKey, $sortOrder ); } catch ( InvalidArgumentException $e ) { unset( $e ); return ProductListQuery::defaults(); } } /** * Read a positive-integer axis from `$getData`, falling back to * `$default` when the key is missing, non-scalar, non-numeric, * or below 1. * * The parser does NOT clamp the value to the * {@see ProductListQuery} per-page maximum; out-of-range values * are forwarded so the constructor's exhaustive validation * triggers the parser's defaults() fallback in {@see self::parse()}. * * @param array $getData $_GET-shaped input. * @param string $key Axis key. * @param int $fallback Default value when absent. */ private static function parsePositiveInt( array $getData, string $key, int $fallback ): int { if ( ! array_key_exists( $key, $getData ) ) { return $fallback; } $raw = $getData[ $key ]; if ( ! is_scalar( $raw ) ) { return $fallback; } $asString = trim( (string) $raw ); if ( $asString === '' || ! preg_match( '/^-?\d+$/', $asString ) ) { return $fallback; } $value = (int) $asString; if ( $value < 1 ) { return $fallback; } return $value; } /** * Convert the user-side state token into the stored lifecycle state. * * @param string|null $userState User-side state token. */ private static function lifecycleFromUserState( ?string $userState ): ?string { return match ( $userState ) { 'publish' => ProductListQuery::LIFECYCLE_STATE_ACTIVE, 'unused' => ProductListQuery::LIFECYCLE_STATE_ARCHIVED, 'trash' => ProductListQuery::LIFECYCLE_STATE_DELETED_PENDING, default => null, }; } /** * Read a non-empty (post-trim) string axis from `$getData`, * returning null when the key is missing, non-scalar, or * whitespace-only. * * Routes through `sanitize_text_field()` when available so a * malicious URL parameter cannot smuggle markup into the * rendered filter form's `selected` / `value` attributes. * * @param array $getData $_GET-shaped input. * @param string $key Axis key. */ private static function parseNonEmptyString( array $getData, string $key ): ?string { if ( ! array_key_exists( $key, $getData ) ) { return null; } $raw = $getData[ $key ]; if ( ! is_scalar( $raw ) ) { return null; } $asString = (string) $raw; if ( function_exists( 'sanitize_text_field' ) ) { $asString = (string) sanitize_text_field( $asString ); } $trimmed = trim( $asString ); if ( $trimmed === '' ) { return null; } return $trimmed; } }