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;
}
}