>` keyed by field * name; values are non-empty lists of human-readable error * messages (concise English; the admin UI is responsible for * translating). Field names are validated against the * `^[a-z][a-z0-9_]*$` grammar so a typo (`'kwords '`) fails * loudly here rather than silently producing an unparseable * §4.4 envelope. * * - {@see self::isValid()} returns true iff `fieldErrors` is the * empty map. The §17.1 mandate is satisfied by treating any * non-empty map as a hard refusal of the request. * * - {@see self::withField()} appends a single message to the list * for the given field, preserving ordering across chained * validator calls. The caller does not need to know whether the * field already has errors. * * - {@see self::toEnvelope()} delegates to * {@see ErrorEnvelope::validation()} so the §4.4 wrapping logic * ("`details.fields = $fieldErrors`", code * `affilun_validation_failed`, status 422) lives in exactly one * place. * * - {@see self::equals()} performs a deep comparison so tests can * assert validator output without leaking the internal * representation. * * Validation failures raise {@see InvalidArgumentException}; the * messages are developer-facing and never rendered to HTML. * * @package Affilun\Presentation\Rest\Validation */ declare(strict_types=1); namespace Affilun\Presentation\Rest\Validation; use Affilun\Presentation\Rest\RequestContext; use Affilun\Presentation\Rest\Response\ErrorEnvelope; use InvalidArgumentException; /** * Immutable per-field validation error accumulator. * * See the file-level docblock for the mapping to * `rest-api-spec.md` §4.4 / §17.1, the per-field validation rules, * and the chaining contract used by {@see RequestValidator}. */ final class ValidationResult { /** * Grammar for field names. * * Held as a private constant so the constructor and * {@see self::withField()} share the same regex literal. */ private const FIELD_GRAMMAR = '/^[a-z][a-z0-9_]*$/'; /** * Map of field name → list of error messages. * * Keys are field names matching {@see self::FIELD_GRAMMAR}; * values are non-empty lists of trimmed error messages. * * @var array> */ private array $fieldErrors; /** * Construct a validation result with explicit field errors. * * Empty per-field lists are rejected because they would render * an §4.4 envelope whose `fields` map carries an empty array * for a field name, which is meaningless. Callers that want an * empty result should use {@see self::valid()}. * * @param array> $fieldErrors Per-field error map. * * @throws InvalidArgumentException When any field name violates * the grammar, or when any * per-field message list is * empty / contains a non-string * / blank entry. */ public function __construct( array $fieldErrors ) { $normalized = array(); foreach ( $fieldErrors as $field => $messages ) { if ( ! is_string( $field ) || preg_match( self::FIELD_GRAMMAR, $field ) !== 1 ) { $message = sprintf( 'ValidationResult field names must match %s; got "%s".', self::FIELD_GRAMMAR, is_string( $field ) ? $field : gettype( $field ) ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML. throw new InvalidArgumentException( $message ); } if ( ! is_array( $messages ) || $messages === array() ) { $invalid = sprintf( 'ValidationResult per-field message list for "%s" must be a non-empty list.', $field ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML. throw new InvalidArgumentException( $invalid ); } $entries = array(); foreach ( $messages as $entry ) { if ( ! is_string( $entry ) ) { $invalid = sprintf( 'ValidationResult messages for "%s" must be strings; got %s.', $field, gettype( $entry ) ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML. throw new InvalidArgumentException( $invalid ); } $trimmed = trim( $entry ); if ( $trimmed === '' ) { $invalid = sprintf( 'ValidationResult messages for "%s" must be non-empty trimmed strings.', $field ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML. throw new InvalidArgumentException( $invalid ); } $entries[] = $trimmed; } $normalized[ $field ] = $entries; } $this->fieldErrors = $normalized; } /** * Report whether this result carries no field errors. */ public function isValid(): bool { return $this->fieldErrors === array(); } /** * Per-field error map accumulated by this result. * * @return array> */ public function fieldErrors(): array { return $this->fieldErrors; } /** * Return a copy of this result with `$message` appended to the * list for `$field`. * * If the field already has errors the new message is appended * to the existing list, preserving ordering. The caller does * not need to know whether the field already has errors. * * @param string $field Field name (validated against * {@see self::FIELD_GRAMMAR}). * @param string $message Concise English error message. * * @throws InvalidArgumentException When `$field` violates the * grammar or `$message` is * empty after trimming. */ public function withField( string $field, string $message ): self { if ( preg_match( self::FIELD_GRAMMAR, $field ) !== 1 ) { $invalid = sprintf( 'ValidationResult::withField() field must match %s; got "%s".', self::FIELD_GRAMMAR, $field ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML. throw new InvalidArgumentException( $invalid ); } $trimmed = trim( $message ); if ( $trimmed === '' ) { $invalid = sprintf( 'ValidationResult::withField() message for "%s" must be a non-empty trimmed string.', $field ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML. throw new InvalidArgumentException( $invalid ); } $next = $this->fieldErrors; $existing = $next[ $field ] ?? array(); $existing[] = $trimmed; $next[ $field ] = $existing; $copy = clone $this; $copy->fieldErrors = $next; return $copy; } /** * Convenience factory returning an empty (valid) result. */ public static function valid(): self { return new self( array() ); } /** * Convert the accumulated field errors into the §4.4 error * envelope shape. * * Delegates to {@see ErrorEnvelope::validation()} so the §4.4 * wrapping logic lives in exactly one place. * * @param RequestContext $ctx Per-request meta source. * @param string $message Human-readable summary. */ public function toEnvelope( RequestContext $ctx, string $message = 'Validation failed.' ): ErrorEnvelope { return ErrorEnvelope::validation( $this->fieldErrors, $ctx, $message ); } /** * Deep equality check against another result. * * Returns true when both results carry the same set of field * names and identical message lists in identical order. * * @param self $other Result to compare against. */ public function equals( self $other ): bool { return $this->fieldErrors === $other->fieldErrors; } }