requireString( $result, 'keywords', $kw, 1, 200 ); * $result = $validator->requireOneOf( $result, 'provider', $p, array( 'amazon', 'rakuten' ) ); * $result = $validator->requirePagination( $result, $page, $perPage ); * if ( ! $result->isValid() ) { * return $result->toEnvelope( $ctx )->toArray(); * } * ``` * * Validation policy: * * - Each method appends at most ONE message per failure mode it * detects on the value. Methods that combine several checks * (for example {@see self::requireString()} which validates * presence + type + length) short-circuit on the first failure * so the §4.4 envelope is not noisier than necessary, but * callers may invoke multiple methods on the same field to * accumulate independent failures. * * - Messages are concise English strings (the admin UI is * responsible for translating). The grammar is "Sentence * case ending with a period." * * - The `optionalString` / `optionalInt` variants accept null / * missing without recording an error; non-null values are * subject to the same checks as the required variants. * * - The §5.1 / §5.2 helpers ({@see self::requirePagination()}, * {@see self::requireSort()}) encode the common-query * boundaries so individual controllers do not re-implement * the page≥1 / per_page∈[1,50] / order∈{asc,desc} rules. * * The validator carries no state and is safe to share as a * singleton across controllers. * * @package Affilun\Presentation\Rest\Validation */ declare(strict_types=1); namespace Affilun\Presentation\Rest\Validation; /** * Stateless §17.1 / §5.1 / §5.2 input-validation helpers. * * See the file-level docblock for the chaining contract used with * {@see ValidationResult} and the per-method validation policy. */ final class RequestValidator { /** * Validate that `$value` is a non-empty string within the given * length bounds and append a message to `$r` when not. * * Failure modes (short-circuited in this order): * * - `$value` is not a string → "Must be a string." * - Trimmed length below `$minLen` → "Too short (minimum N characters)." * - Length above `$maxLen` → "Too long (maximum N characters)." * * @param ValidationResult $r Running accumulator. * @param string $field Field name. * @param mixed $value Value to inspect. * @param int $minLen Minimum length, default 1. * @param int $maxLen Maximum length, default 255. */ public function requireString( ValidationResult $r, string $field, mixed $value, int $minLen = 1, int $maxLen = 255 ): ValidationResult { if ( ! is_string( $value ) ) { return $r->withField( $field, 'Must be a string.' ); } $length = strlen( $value ); if ( $length < $minLen ) { return $r->withField( $field, sprintf( 'Too short (minimum %d characters).', $minLen ) ); } if ( $length > $maxLen ) { return $r->withField( $field, sprintf( 'Too long (maximum %d characters).', $maxLen ) ); } return $r; } /** * Validate that `$value` is an integer within the given bounds. * * Numeric strings are accepted and coerced; non-numeric values * fail the type check. * * @param ValidationResult $r Running accumulator. * @param string $field Field name. * @param mixed $value Value to inspect. * @param int $min Minimum allowed value. * @param int $max Maximum allowed value. */ public function requireInt( ValidationResult $r, string $field, mixed $value, int $min = PHP_INT_MIN, int $max = PHP_INT_MAX ): ValidationResult { if ( ! $this->isIntegerLike( $value ) ) { return $r->withField( $field, 'Must be an integer.' ); } $intValue = (int) $value; if ( $intValue < $min ) { return $r->withField( $field, sprintf( 'Must be at least %d.', $min ) ); } if ( $intValue > $max ) { return $r->withField( $field, sprintf( 'Must be at most %d.', $max ) ); } return $r; } /** * Validate that `$value` is a boolean (or boolean-equivalent * scalar accepted by `filter_var(FILTER_VALIDATE_BOOLEAN)`). * * @param ValidationResult $r Running accumulator. * @param string $field Field name. * @param mixed $value Value to inspect. */ public function requireBool( ValidationResult $r, string $field, mixed $value ): ValidationResult { if ( is_bool( $value ) ) { return $r; } $filtered = filter_var( $value, FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE ); if ( $filtered === null ) { return $r->withField( $field, 'Must be a boolean.' ); } return $r; } /** * Validate that `$value` is exactly one of the allowed strings. * * @param ValidationResult $r Running accumulator. * @param string $field Field name. * @param mixed $value Value to inspect. * @param list $allowed Whitelist of allowed values. */ public function requireOneOf( ValidationResult $r, string $field, mixed $value, array $allowed ): ValidationResult { if ( ! is_string( $value ) || ! in_array( $value, $allowed, true ) ) { return $r->withField( $field, sprintf( 'Must be one of: %s.', implode( ', ', $allowed ) ) ); } return $r; } /** * Validate that `$value` is a list (zero-indexed sequential * array) with no more than `$maxItems` entries. * * Non-list arrays (associative or sparse-keyed) are rejected * because the §17.1 / §5.3 batch-input contracts uniformly * expect list-shaped input. * * @param ValidationResult $r Running accumulator. * @param string $field Field name. * @param mixed $value Value to inspect. * @param int $maxItems Maximum item count, default 100. */ public function requireArray( ValidationResult $r, string $field, mixed $value, int $maxItems = 100 ): ValidationResult { if ( ! is_array( $value ) || ! array_is_list( $value ) ) { return $r->withField( $field, 'Must be a list.' ); } if ( count( $value ) > $maxItems ) { return $r->withField( $field, sprintf( 'Too many items (maximum %d).', $maxItems ) ); } return $r; } /** * Optional-string variant of {@see self::requireString()}. * * Null is accepted without recording an error; non-null values * are subject to the same checks as the required variant. * * @param ValidationResult $r Running accumulator. * @param string $field Field name. * @param mixed $value Value to inspect. * @param int $minLen Minimum length when non-null. * @param int $maxLen Maximum length when non-null. */ public function optionalString( ValidationResult $r, string $field, mixed $value, int $minLen = 1, int $maxLen = 255 ): ValidationResult { if ( $value === null ) { return $r; } return $this->requireString( $r, $field, $value, $minLen, $maxLen ); } /** * Optional-integer variant of {@see self::requireInt()}. * * Null is accepted without recording an error; non-null values * are subject to the same checks as the required variant. * * @param ValidationResult $r Running accumulator. * @param string $field Field name. * @param mixed $value Value to inspect. * @param int $min Minimum allowed value. * @param int $max Maximum allowed value. */ public function optionalInt( ValidationResult $r, string $field, mixed $value, int $min = PHP_INT_MIN, int $max = PHP_INT_MAX ): ValidationResult { if ( $value === null ) { return $r; } return $this->requireInt( $r, $field, $value, $min, $max ); } /** * Validate the §5.1 pagination tuple. * * Encodes the §5.1 boundaries: * * - `page` is an integer ≥ 1. * - `per_page` is an integer in [1, 50]. * * Errors are appended under the `page` / `per_page` field * names so the §4.4 envelope's `fields` map matches the * documented query parameter names. * * @param ValidationResult $r Running accumulator. * @param mixed $page `page` query value. * @param mixed $perPage `per_page` query value. */ public function requirePagination( ValidationResult $r, mixed $page, mixed $perPage ): ValidationResult { $result = $this->requireInt( $r, 'page', $page, 1, PHP_INT_MAX ); $result = $this->requireInt( $result, 'per_page', $perPage, 1, 50 ); return $result; } /** * Validate the §5.2 sort tuple. * * Encodes the §5.2 boundaries: * * - `sort` is a string in `$allowedSortKeys`. * - `order` is one of `'asc'` / `'desc'`. * * Errors are appended under the `sort` / `order` field names * so the §4.4 envelope's `fields` map matches the documented * query parameter names. * * @param ValidationResult $r Running accumulator. * @param mixed $sort `sort` query value. * @param mixed $order `order` query value. * @param list $allowedSortKeys Whitelist of sort keys. */ public function requireSort( ValidationResult $r, mixed $sort, mixed $order, array $allowedSortKeys ): ValidationResult { $result = $this->requireOneOf( $r, 'sort', $sort, $allowedSortKeys ); $result = $this->requireOneOf( $result, 'order', $order, array( 'asc', 'desc' ) ); return $result; } /** * Report whether `$value` parses as an integer. * * Accepts `int` directly and numeric strings whose decoded * representation round-trips losslessly to `int` (so `'1'` and * `'1000'` pass but `'1.5'` and `'1e3'` do not). * * @param mixed $value Value to inspect. */ private function isIntegerLike( mixed $value ): bool { if ( is_int( $value ) ) { return true; } if ( is_string( $value ) && $value !== '' ) { if ( $value[0] === '-' ) { $digits = substr( $value, 1 ); return $digits !== '' && ctype_digit( $digits ); } return ctype_digit( $value ); } return false; } }