` so the on-the-wire JSON renders as * an array, not an object — and so a programming error that * accidentally produces a sparse array surfaces here rather * than at the REST boundary. * * Both fields are validated as `http://` or `https://` URLs at * construction time; "present but malformed" is treated as a * programming error rather than a silently-passed value. * * @package Affilun\Presentation\Rest\Mapper */ declare(strict_types=1); namespace Affilun\Presentation\Rest\Mapper; use InvalidArgumentException; /** * Immutable image-hints carrier consumed by the §6.1 / §6.2 * REST mappers. * * See the file-level docblock for the mapping to * `rest-api-spec.md` §6.1 / §6.2 and the per-field validation * rules. */ final class ImageHints { /** * §6.1 `image_primary_url` / §6.2 `images.primary` value, or * null when the controller could not resolve one. */ private ?string $primaryUrl; /** * §6.2 `images.gallery` list, in resolution order. * * @var list */ private array $galleryUrls; /** * Construct an image-hints VO with explicit values for every * field. * * @param string|null $primaryUrl Primary image URL, or null. * @param list $galleryUrls Gallery image URLs, in order. * * @throws InvalidArgumentException When `$primaryUrl` is non-null * but not an `http(s)://` URL, * when `$galleryUrls` is not a * sequential list, or when any * gallery entry is not a string * or not an `http(s)://` URL. */ public function __construct( ?string $primaryUrl, array $galleryUrls = array() ) { if ( $primaryUrl !== null ) { self::assertHttpUrl( 'primaryUrl', $primaryUrl ); } $expectedIndex = 0; foreach ( $galleryUrls as $key => $value ) { if ( $key !== $expectedIndex ) { $message = 'ImageHints galleryUrls must be a sequential list (0-indexed, contiguous).'; // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML. throw new InvalidArgumentException( $message ); } if ( ! is_string( $value ) ) { $message = sprintf( 'ImageHints galleryUrls entry at index %d must be a string.', $expectedIndex ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML. throw new InvalidArgumentException( $message ); } self::assertHttpUrl( sprintf( 'galleryUrls[%d]', $expectedIndex ), $value ); ++$expectedIndex; } $this->primaryUrl = $primaryUrl; /** @var list $galleryUrls */ $this->galleryUrls = $galleryUrls; } /** * Construct an empty image-hints VO with no primary image and * no gallery entries. Used by controllers that have not yet * resolved any image data. */ public static function none(): self { return new self( null, array() ); } /** * §6.1 `image_primary_url` / §6.2 `images.primary` value, or * null when not resolved. */ public function primaryUrl(): ?string { return $this->primaryUrl; } /** * §6.2 `images.gallery` list, in resolution order. * * @return list */ public function galleryUrls(): array { return $this->galleryUrls; } /** * Structural equality: two hints are equal when both the * primary URL and the gallery list match exactly (including * order). * * @param self $other Other hints to compare with. */ public function equals( self $other ): bool { return $this->primaryUrl === $other->primaryUrl && $this->galleryUrls === $other->galleryUrls; } /** * Validate that `$value` is a non-empty `http://` or * `https://` URL. * * @param string $field Field name for the error message. * @param string $value Candidate URL. * * @throws InvalidArgumentException When `$value` is not a valid * `http(s)://` URL. */ private static function assertHttpUrl( string $field, string $value ): void { $trimmed = trim( $value ); if ( $trimmed === '' ) { $message = sprintf( 'ImageHints %s must be a non-empty http(s):// URL.', $field ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML. throw new InvalidArgumentException( $message ); } if ( filter_var( $trimmed, FILTER_VALIDATE_URL ) === false ) { $message = sprintf( 'ImageHints %s must be a valid http(s):// URL; got "%s".', $field, $value ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML. throw new InvalidArgumentException( $message ); } // phpcs:ignore WordPress.WP.AlternativeFunctions.parse_url_parse_url -- Pure VO validation; wp_parse_url() is unavailable in unit-test contexts that bypass the WP shim. $scheme = strtolower( (string) parse_url( $trimmed, PHP_URL_SCHEME ) ); if ( $scheme !== 'http' && $scheme !== 'https' ) { $message = sprintf( 'ImageHints %s must use the http or https scheme; got "%s".', $field, $value ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML. throw new InvalidArgumentException( $message ); } } }