HEX
Server: LiteSpeed
System: Linux s1049.use1.mysecurecloudhost.com 4.18.0-477.27.2.lve.el8.x86_64 #1 SMP Wed Oct 11 12:32:56 UTC 2023 x86_64
User: xedaptot (3356)
PHP: 8.3.31
Disabled: NONE
Upload Files
File: /home/xedaptot/ai.naniguide.com/app/Services/Ads/ProductImageValidator.php
<?php

namespace App\Services\Ads;

use Illuminate\Http\Client\ConnectionException;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;

/**
 * Validates AdProduct image URLs before catalog upload.  Every catalog
 * platform (Meta, Google MC, TikTok, LinkedIn) rejects products with broken
 * image URLs — sometimes silently, sometimes with cryptic batch errors that
 * blame the wrong product.  Pre-flight HEAD-checking lets us surface
 * per-product errors with a clear message and skip bad rows instead of
 * hiding behind a single-batch failure.
 *
 * Rules per platform spec (R15):
 *  - Scheme MUST be http or https.
 *  - URL must respond to HEAD with 2xx within timeout (default 3s).
 *  - 4xx / 5xx / connection error / unsupported scheme → marked invalid.
 *
 * Validation is opt-in per `ads.catalog.validate_images` config flag.  When
 * disabled, `validate()` is a passthrough (still useful for staging where the
 * sync runs against fixtures without real image hosts).  Default ON in
 * production because the per-platform error message you get from a real
 * sync is "image_url could not be retrieved" — useless for triage without
 * the URL that failed.
 */
class ProductImageValidator
{
    public function __construct(
        private int $timeoutMs = 3000,
        private bool $enabled = true,
    ) {}

    public static function fromConfig(): self
    {
        return new self(
            timeoutMs: (int) config('ads.catalog.image_timeout_ms', 3000),
            enabled: (bool) config('ads.catalog.validate_images', true),
        );
    }

    /**
     * Walk a product list and tag invalid rows with `_image_error`.  Caller
     * decides whether to skip them (pass to adapter without invalids) or
     * surface them in the catalog's error_log column.
     *
     * @param  array<int,array<string,mixed>>  $products
     * @return array<int,array<string,mixed>>
     */
    public function validate(array $products): array
    {
        if (!$this->enabled) {
            return $products;
        }

        return array_map(function (array $p): array {
            $url = $p['image_url'] ?? null;
            if ($url === null || $url === '') {
                $p['_image_error'] = 'missing_image_url';
                return $p;
            }
            $url = (string) $url;
            $err = $this->checkUrl($url);
            if ($err !== null) {
                $p['_image_error'] = $err;
            }
            return $p;
        }, $products);
    }

    /**
     * Returns null when reachable, error code string otherwise.  Codes are
     * stable contract for callers that want to surface translated messages.
     */
    public function checkUrl(string $url): ?string
    {
        $scheme = parse_url($url, PHP_URL_SCHEME);
        if ($scheme !== 'http' && $scheme !== 'https') {
            return 'invalid_scheme';
        }
        try {
            $response = Http::timeout($this->timeoutMs / 1000)
                ->withOptions(['allow_redirects' => true])
                ->head($url);
        } catch (ConnectionException $e) {
            Log::channel(config('ads.observability.log_channel', 'stack'))
                ->warning('catalog.image_unreachable', ['url' => $url, 'error' => $e->getMessage()]);
            return 'unreachable';
        } catch (\Throwable $e) {
            // Any other HTTP-client failure (DNS, TLS, etc.) — bucket as
            // unreachable so the per-product UI message stays clean.
            Log::channel(config('ads.observability.log_channel', 'stack'))
                ->warning('catalog.image_unreachable', ['url' => $url, 'error' => $e->getMessage()]);
            return 'unreachable';
        }
        if ($response->status() < 200 || $response->status() >= 400) {
            return 'http_' . $response->status();
        }
        return null;
    }
}