<?php
declare(strict_types=1);
namespace VehicleIngestBundle\Controller;
use Pimcore\Controller\FrontendController;
use Psr\Log\LoggerInterface;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Messenger\MessageBusInterface;
use Symfony\Component\Routing\Annotation\Route;
use VehicleIngestBundle\Http\MultipartFormDataParser;
use VehicleIngestBundle\Message\IngestImageMessage;
use VehicleIngestBundle\Service\ErrorResponseFactory;
use VehicleIngestBundle\Service\ImageIngestService;
class ImageController extends FrontendController
{
/**
* Field name every image part carries, per the vendor documentation.
*/
private const PART_NAME = 'images';
public function __construct(
private MessageBusInterface $bus,
private ImageIngestService $images,
private ErrorResponseFactory $errors,
private MultipartFormDataParser $multipart,
private ?LoggerInterface $logger = null,
) {
}
/**
* @Route("/seller-api/sellers/{sellerId}/ads/{adId}/images", name="seller_api_ad_image_set", methods={"PUT"}, requirements={"adId"="\d+"})
*
* The image contract, as documented by automedia ("Endpoints used" / "Per-vehicle call
* sequence"): ONE call per vehicle carrying `multipart/form-data`, one part per image under
* the field name `images`, in display order - the first part is the title image - and the
* PUT replaces the whole set. There is no POST on this resource, and no JSON variant.
*
* Two properties of the older ref-based implementation are deliberately preserved:
*
* 1. The bytes are stored SYNCHRONOUSLY here and only the resulting content-hash refs are
* handed to the bus. `messenger_messages.body` is a TEXT column (~64KB), so inlining a
* dozen photos into the message would overflow it and lose the entire set. This is the
* reason the parsing happens in the request rather than in the handler.
* 2. It does NOT check whether the ad/vehicle already exists (unlike
* AdController::updateAction()): the image push can legitimately arrive before the ad
* upsert that creates the vehicle, and IngestImageMessageHandler resolves that ordering
* downstream via its retry story. A well-formed envelope is always accepted with 200.
*
* Rejected synchronously with a keyed error (400). Every rejection here is an envelope we
* could not read, so all of them are keyed `body` rather than `images`:
* - a body that is not multipart, or whose Content-Type carries no boundary
* - a truncated envelope (no closing delimiter): since the PUT replaces the whole set,
* applying half an upload would silently drop the images that never arrived
* - an envelope that parses but yields no usable `images` part
*
* Two reasons for `body` over `images`. It is what LeasingMatrixController already uses for an
* envelope-level problem. And the vendor guide says the exporter "removes the field named by
* the error key and retries once" - keyed `images`, that rule would have them retry the image
* push with the images removed. The rule is scoped to create/update in the guide, so this may
* be moot, but `body` names no field they could drop and costs nothing.
*
* The zero-part case became an error on 20.08.2026, when automedia confirmed that a vehicle
* without images produces no /images call at all
* (docs/api-integration/automedia-operational-facts-2026-08-20.md). Until then it was passed on
* as an empty set and answered 200, on the grounds that the documentation said nothing about
* it and wiping a gallery would be a destructive guess. The gallery is still not wiped - the
* request simply never reaches the bus - but a 200 was the wrong answer: three separate defects
* (LF-only line endings, a field name other than `images`, and `filename` written before `name`
* in the disposition) all surface as zero usable parts, and their exporter would log each as a
* success and never retry. The images would then be missing until something else about the
* vehicle changed. An error costs one export cycle.
*/
public function putImagesAction(Request $request, string $sellerId, string $adId): Response
{
$body = $request->getContent();
$boundary = MultipartFormDataParser::boundaryFrom($request->headers->get('Content-Type'));
if ($boundary === null) {
return $this->errors->keyed('body');
}
$parts = $this->multipart->parse($body, $boundary);
if ($parts === null) {
return $this->errors->keyed('body');
}
$refs = [];
$skipped = 0;
foreach ($parts as $part) {
if ($part['name'] !== self::PART_NAME || $part['bytes'] === '') {
$skipped++;
continue;
}
$refs[] = $this->images->storeImage($adId, $part['bytes'])['ref'];
}
if ($refs === []) {
// The one place where the request tells us nothing useful, so log what we saw: with
// zero parts the envelope was unreadable, with parts present the field name is wrong.
$this->logger?->warning(
'ImageController: image push carried no usable image part; gallery left untouched.',
[
'adId' => $adId,
'sellerId' => $sellerId,
'bodyBytes' => strlen($body),
'partsParsed' => count($parts),
'partsSkipped' => $skipped,
'partNames' => array_values(array_unique(array_column($parts, 'name'))),
]
);
return $this->errors->keyed('body');
}
$this->bus->dispatch(new IngestImageMessage($adId, $refs));
return new Response('', Response::HTTP_OK);
}
}