= 1; default 1. Mirrors WordPress's own * `paged` query var so the admin pagination links interoperate * with core conventions. * - `per_page` — integer 1..{@see AuditEventListQuery::MAX_PER_PAGE}; * clamped to the max, out-of-range below 1 falls back to the * default. * * Mirrors {@see \Affilun\Presentation\Admin\Page\LogsListQueryParser} * verbatim except for the per-axis vocabulary so the §6 Logs admin * page and the (Phase 2 follow-up) §10 Audit Events admin page * share the same parsing conventions and graceful-fallback * contract. * * @package Affilun\Presentation\Admin\Page */ declare(strict_types=1); namespace Affilun\Presentation\Admin\Page; use Affilun\Infrastructure\Persistence\AuditEventListQuery; use InvalidArgumentException; /** * Pure-function helper that converts the `$_GET` array of the §10 * Audit Events admin page into a normalized * {@see AuditEventListQuery}. * * See the file-level docblock for the per-axis parsing rules and * the graceful-fallback contract that catches construction errors * from a tampered or stale URL. */ final class AuditListQueryParser { /** * `$_GET` key carrying the §10.2 audit-kind filter. */ public const KEY_AUDIT_KIND = 'audit_kind'; /** * `$_GET` key carrying the §10.2 actor-id exact-match filter. */ public const KEY_ACTOR_ID = 'actor_id'; /** * `$_GET` key carrying the §10.2 target-type exact-match filter. */ public const KEY_TARGET_TYPE = 'target_type'; /** * `$_GET` key carrying the §10.2 target-id exact-match filter. */ public const KEY_TARGET_ID = 'target_id'; /** * `$_GET` key carrying the `context_json.$.request_id` * exact-match filter (Phase 2 KI-006 follow-up). */ public const KEY_REQUEST_ID = 'request_id'; /** * `$_GET` key carrying the `context_json.$.correlation_id` * exact-match filter (Phase 2 KI-006 follow-up). */ public const KEY_CORRELATION_ID = 'correlation_id'; /** * `$_GET` key carrying the `context_json.$.job_id` exact-match * filter (Phase 2 KI-006 follow-up). */ public const KEY_JOB_ID = 'job_id'; /** * `$_GET` key carrying the 1-based page number. * * Mirrors WordPress's own `paged` query var so the admin * pagination links interoperate with core conventions. */ public const KEY_PAGE = 'paged'; /** * `$_GET` key carrying the records-per-page count. */ public const KEY_PER_PAGE = 'per_page'; /** * Convert `$_GET`-shaped input into an * {@see AuditEventListQuery}. * * Construction failures fall back to * {@see AuditEventListQuery::defaults()} so a malformed URL * cannot break the admin page rendering. * * @param array $rawGet `$_GET`-shaped input (already unslashed). */ public function parse( array $rawGet ): AuditEventListQuery { $auditKind = self::parseNonEmptyString( $rawGet, self::KEY_AUDIT_KIND ); if ( $auditKind !== null && ! in_array( $auditKind, AuditEventListQuery::VALID_AUDIT_KINDS, true ) ) { $auditKind = null; } $actorId = self::parseNonEmptyString( $rawGet, self::KEY_ACTOR_ID ); $targetType = self::parseNonEmptyString( $rawGet, self::KEY_TARGET_TYPE ); $targetId = self::parseNonEmptyString( $rawGet, self::KEY_TARGET_ID ); $requestId = self::parseNonEmptyString( $rawGet, self::KEY_REQUEST_ID ); $correlationId = self::parseNonEmptyString( $rawGet, self::KEY_CORRELATION_ID ); $jobId = self::parseNonEmptyString( $rawGet, self::KEY_JOB_ID ); $page = self::parsePositiveInt( $rawGet, self::KEY_PAGE, 1 ); $perPage = self::parsePositiveInt( $rawGet, self::KEY_PER_PAGE, AuditEventListQuery::DEFAULT_PER_PAGE ); if ( $perPage > AuditEventListQuery::MAX_PER_PAGE ) { $perPage = AuditEventListQuery::MAX_PER_PAGE; } try { return new AuditEventListQuery( $auditKind, $actorId, $targetType, $targetId, $page, $perPage, $requestId, $correlationId, $jobId ); } catch ( InvalidArgumentException $e ) { unset( $e ); return AuditEventListQuery::defaults(); } } /** * Read a positive-integer axis from `$rawGet`, falling back to * `$fallback` when the key is missing, non-scalar, non-numeric, * or below 1. * * @param array $rawGet $_GET-shaped input. * @param string $key Axis key. * @param int $fallback Default value when absent. */ private static function parsePositiveInt( array $rawGet, string $key, int $fallback ): int { if ( ! array_key_exists( $key, $rawGet ) ) { return $fallback; } $raw = $rawGet[ $key ]; if ( ! is_scalar( $raw ) ) { return $fallback; } $asString = trim( (string) $raw ); if ( $asString === '' || preg_match( '/^-?\d+$/', $asString ) !== 1 ) { return $fallback; } $value = (int) $asString; if ( $value < 1 ) { return $fallback; } return $value; } /** * Read a non-empty (post-trim) string axis from `$rawGet`, * 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. The resulting string is also handed to * {@see AuditEventListQuery::__construct()}, which performs a * second `trim()`-and-null-if-empty pass via * {@see AuditEventListQuery normalizeOptional()}; the parser-side * pass is kept so the off-whitelist branch on `audit_kind` * compares against an already-trimmed value. * * @param array $rawGet $_GET-shaped input. * @param string $key Axis key. */ private static function parseNonEmptyString( array $rawGet, string $key ): ?string { if ( ! array_key_exists( $key, $rawGet ) ) { return null; } $raw = $rawGet[ $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; } }