bundles/VehicleIngestBundle/Controller/ImageController.php line 77

Open in your IDE?
  1. <?php
  2. declare(strict_types=1);
  3. namespace VehicleIngestBundle\Controller;
  4. use Pimcore\Controller\FrontendController;
  5. use Psr\Log\LoggerInterface;
  6. use Symfony\Component\HttpFoundation\Request;
  7. use Symfony\Component\HttpFoundation\Response;
  8. use Symfony\Component\Messenger\MessageBusInterface;
  9. use Symfony\Component\Routing\Annotation\Route;
  10. use VehicleIngestBundle\Http\MultipartFormDataParser;
  11. use VehicleIngestBundle\Message\IngestImageMessage;
  12. use VehicleIngestBundle\Service\ErrorResponseFactory;
  13. use VehicleIngestBundle\Service\ImageIngestService;
  14. class ImageController extends FrontendController
  15. {
  16.     /**
  17.      * Field name every image part carries, per the vendor documentation.
  18.      */
  19.     private const PART_NAME 'images';
  20.     public function __construct(
  21.         private MessageBusInterface $bus,
  22.         private ImageIngestService $images,
  23.         private ErrorResponseFactory $errors,
  24.         private MultipartFormDataParser $multipart,
  25.         private ?LoggerInterface $logger null,
  26.     ) {
  27.     }
  28.     /**
  29.      * @Route("/seller-api/sellers/{sellerId}/ads/{adId}/images", name="seller_api_ad_image_set", methods={"PUT"}, requirements={"adId"="\d+"})
  30.      *
  31.      * The image contract, as documented by automedia ("Endpoints used" / "Per-vehicle call
  32.      * sequence"): ONE call per vehicle carrying `multipart/form-data`, one part per image under
  33.      * the field name `images`, in display order - the first part is the title image - and the
  34.      * PUT replaces the whole set. There is no POST on this resource, and no JSON variant.
  35.      *
  36.      * Two properties of the older ref-based implementation are deliberately preserved:
  37.      *
  38.      * 1. The bytes are stored SYNCHRONOUSLY here and only the resulting content-hash refs are
  39.      *    handed to the bus. `messenger_messages.body` is a TEXT column (~64KB), so inlining a
  40.      *    dozen photos into the message would overflow it and lose the entire set. This is the
  41.      *    reason the parsing happens in the request rather than in the handler.
  42.      * 2. It does NOT check whether the ad/vehicle already exists (unlike
  43.      *    AdController::updateAction()): the image push can legitimately arrive before the ad
  44.      *    upsert that creates the vehicle, and IngestImageMessageHandler resolves that ordering
  45.      *    downstream via its retry story. A well-formed envelope is always accepted with 200.
  46.      *
  47.      * Rejected synchronously with a keyed error (400). Every rejection here is an envelope we
  48.      * could not read, so all of them are keyed `body` rather than `images`:
  49.      *   - a body that is not multipart, or whose Content-Type carries no boundary
  50.      *   - a truncated envelope (no closing delimiter): since the PUT replaces the whole set,
  51.      *     applying half an upload would silently drop the images that never arrived
  52.      *   - an envelope that parses but yields no usable `images` part
  53.      *
  54.      * Two reasons for `body` over `images`. It is what LeasingMatrixController already uses for an
  55.      * envelope-level problem. And the vendor guide says the exporter "removes the field named by
  56.      * the error key and retries once" - keyed `images`, that rule would have them retry the image
  57.      * push with the images removed. The rule is scoped to create/update in the guide, so this may
  58.      * be moot, but `body` names no field they could drop and costs nothing.
  59.      *
  60.      * The zero-part case became an error on 20.08.2026, when automedia confirmed that a vehicle
  61.      * without images produces no /images call at all
  62.      * (docs/api-integration/automedia-operational-facts-2026-08-20.md). Until then it was passed on
  63.      * as an empty set and answered 200, on the grounds that the documentation said nothing about
  64.      * it and wiping a gallery would be a destructive guess. The gallery is still not wiped - the
  65.      * request simply never reaches the bus - but a 200 was the wrong answer: three separate defects
  66.      * (LF-only line endings, a field name other than `images`, and `filename` written before `name`
  67.      * in the disposition) all surface as zero usable parts, and their exporter would log each as a
  68.      * success and never retry. The images would then be missing until something else about the
  69.      * vehicle changed. An error costs one export cycle.
  70.      */
  71.     public function putImagesAction(Request $requeststring $sellerIdstring $adId): Response
  72.     {
  73.         $body $request->getContent();
  74.         $boundary MultipartFormDataParser::boundaryFrom($request->headers->get('Content-Type'));
  75.         if ($boundary === null) {
  76.             return $this->errors->keyed('body');
  77.         }
  78.         $parts $this->multipart->parse($body$boundary);
  79.         if ($parts === null) {
  80.             return $this->errors->keyed('body');
  81.         }
  82.         $refs = [];
  83.         $skipped 0;
  84.         foreach ($parts as $part) {
  85.             if ($part['name'] !== self::PART_NAME || $part['bytes'] === '') {
  86.                 $skipped++;
  87.                 continue;
  88.             }
  89.             $refs[] = $this->images->storeImage($adId$part['bytes'])['ref'];
  90.         }
  91.         if ($refs === []) {
  92.             // The one place where the request tells us nothing useful, so log what we saw: with
  93.             // zero parts the envelope was unreadable, with parts present the field name is wrong.
  94.             $this->logger?->warning(
  95.                 'ImageController: image push carried no usable image part; gallery left untouched.',
  96.                 [
  97.                     'adId' => $adId,
  98.                     'sellerId' => $sellerId,
  99.                     'bodyBytes' => strlen($body),
  100.                     'partsParsed' => count($parts),
  101.                     'partsSkipped' => $skipped,
  102.                     'partNames' => array_values(array_unique(array_column($parts'name'))),
  103.                 ]
  104.             );
  105.             return $this->errors->keyed('body');
  106.         }
  107.         $this->bus->dispatch(new IngestImageMessage($adId$refs));
  108.         return new Response(''Response::HTTP_OK);
  109.     }
  110. }