Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
<?php

/*
* This file is part of the auto1-oss/service-api-handler-bundle.
*
* (c) AUTO1 Group SE https://www.auto1-group.com
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/

declare(strict_types=1);

namespace Auto1\ServiceAPIHandlerBundle\ArgumentResolver\RequestDataExtractor;

use Auto1\ServiceAPIComponentsBundle\Service\Endpoint\EndpointInterface;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Serializer\Encoder\DecoderInterface;
use Symfony\Component\Serializer\Exception\UnexpectedValueException;

use function array_merge;
use function get_debug_type;
use function is_array;
use function sprintf;

class DefaultRequestDataExtractor implements RequestDataExtractorInterface
{
private DecoderInterface $decoder;

public function __construct(DecoderInterface $decoder)
{
$this->decoder = $decoder;
}

public function supports(EndpointInterface $endpoint): bool
{
return true;
}

public function extract(Request $request, EndpointInterface $endpoint): array
{
$decoded = [];

$body = $request->getContent();
if ('' !== $body) {
$decoded = $this->decoder->decode($body, $endpoint->getRequestFormat());

// A scalar body (e.g. JSON `0` or `"foo"`) decodes without error but cannot feed
// array_merge(); UnexpectedValueException is a Serializer ExceptionInterface, so
// ServiceRequestResolver converts it to a 400 instead of a TypeError-driven 500.
if (!is_array($decoded)) {
throw new UnexpectedValueException(
sprintf('Request body must decode to an array, got "%s".', get_debug_type($decoded))
);
}
}

return array_merge(
$decoded,
$request->attributes->all(),
$request->query->all()
);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
<?php

/*
* This file is part of the auto1-oss/service-api-handler-bundle.
*
* (c) AUTO1 Group SE https://www.auto1-group.com
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/

declare(strict_types=1);

namespace Auto1\ServiceAPIHandlerBundle\ArgumentResolver\RequestDataExtractor;

use Auto1\ServiceAPIComponentsBundle\Service\Endpoint\EndpointInterface;
use Auto1\ServiceAPIComponentsBundle\Multipart\UploadedFileStream;
use LogicException;
use Psr\Http\Message\StreamFactoryInterface;
use Symfony\Component\HttpFoundation\File\UploadedFile;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;

use function array_merge;
use function explode;
use function in_array;
use function is_array;
use function sprintf;
use function strtolower;
use function trim;

class MultipartRequestDataExtractor implements RequestDataExtractorInterface
{
private const MIME_TYPE_MULTIPART = 'multipart/form-data';

// Request::create()/BrowserKit default POST bodies to urlencoded even when files are
// attached, and PHP parses both form mime types into the same superglobals — accept either.
private const FORM_MIME_TYPES = [
self::MIME_TYPE_MULTIPART,
'application/x-www-form-urlencoded',
];

private ?StreamFactoryInterface $streamFactory;

public function __construct(?StreamFactoryInterface $streamFactory = null)
{
$this->streamFactory = $streamFactory;
}

public function supports(EndpointInterface $endpoint): bool
{
return EndpointInterface::FORMAT_MULTIPART === $endpoint->getRequestFormat();
}

public function extract(Request $request, EndpointInterface $endpoint): array
{
// PHP populates $_POST / $_FILES only when the wire method is POST; method overrides
// (_method / X-HTTP-METHOD-OVERRIDE) are applied after parsing, so the real method
// decides whether the body was parsed — an overridden wire POST is fine.
if (Request::METHOD_POST !== $request->getRealMethod()) {
throw new BadRequestHttpException(
sprintf(
'multipart/form-data endpoints must be sent as POST, got "%s".',
$request->getRealMethod()
)
);
}

// Media types are case-insensitive (RFC 9110) and may carry parameters (boundary).
$contentType = (string) $request->headers->get('CONTENT_TYPE');
$mimeTypeParts = explode(';', $contentType, 2);
$mimeType = strtolower(trim($mimeTypeParts[0]));
if (!in_array($mimeType, self::FORM_MIME_TYPES, true)) {
throw new BadRequestHttpException(
sprintf(
'Expected "%s" content type, got "%s".',
self::MIME_TYPE_MULTIPART,
$contentType
)
);
}

// A body PHP failed to parse (e.g. post_max_size exceeded) leaves both bags empty
// while the body itself is non-empty — reject it instead of letting the request
// through with a silently empty payload.
if (0 === $request->request->count()
&& 0 === $request->files->count()
&& 0 < (int) $request->headers->get('CONTENT_LENGTH')
) {
throw new BadRequestHttpException(
'Form body could not be parsed — the "post_max_size" limit may be exceeded.'
);
}

return array_merge(
$request->request->all(),
$this->wrapFiles($request->files->all()),
Comment on lines +96 to +97

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Multipart PUT/PATCH will silently produce an empty payload.

PHP only populates $_POST / $_FILES for POST requests. For a multipart PUT or PATCH, $request->request->all() and $request->files->all() are both empty, and the body is never parsed — so the endpoint denormalizes [] + attributes + query and the caller gets a confusingly empty DTO instead of an error.

If the bundle is expected to support non-POST multipart endpoints, this needs handling (parse the raw body, or reject explicitly). If not, at minimum throw on !$request->isMethod('POST') here so the failure is loud, and document the constraint in the README.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Went with the loud failure: extract() now rejects any non-POST method with a 400 explaining that PHP doesn't parse multipart bodies for other methods, and the README documents the POST-only constraint in the new multipart section.

$request->attributes->all(),
$request->query->all()
);
}

private function wrapFiles(array $files): array
{
$wrapped = [];
foreach ($files as $key => $value) {
if (is_array($value)) {
$wrapped[$key] = $this->wrapFiles($value);
continue;
}

// An unfilled optional file input arrives as null and is intentionally
// omitted, so the request DTO keeps its default value.
if (!$value instanceof UploadedFile) {
continue;
}

if (!$value->isValid()) {
throw new BadRequestHttpException(
sprintf('Upload failed for field "%s": %s', $key, $value->getErrorMessage())
);
}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There's no else branch, so anything that isn't an UploadedFile is silently dropped from the result. In practice that's null for an optional file input the client didn't fill — the key vanishes from the payload entirely rather than arriving as null.

That's probably what you want (the DTO keeps its default), but it's implicit. Either make it explicit:

$wrapped[$key] = $value instanceof UploadedFile
    ? new UploadedFileStream(..., $value)
    : null;

or leave the drop and add a one-line comment saying unfilled file inputs are intentionally omitted.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Kept the drop (an explicit null would overwrite DTO defaults during denormalization) and added the one-line comment stating that unfilled optional file inputs are intentionally omitted.


if (null === $this->streamFactory) {
throw new LogicException(
sprintf(
'A PSR-17 "%s" must be wired to handle multipart/form-data file uploads. '
. 'Install a PSR-7 implementation (e.g. guzzlehttp/psr7, nyholm/psr7) '
. 'and register its stream factory.',
StreamFactoryInterface::class
)
);
}

$stream = $this->streamFactory->createStreamFromFile($value->getPathname(), 'r');
$wrapped[$key] = new UploadedFileStream($stream, $value);
}

return $wrapped;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
<?php

/*
* This file is part of the auto1-oss/service-api-handler-bundle.
*
* (c) AUTO1 Group SE https://www.auto1-group.com
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/

declare(strict_types=1);

namespace Auto1\ServiceAPIHandlerBundle\ArgumentResolver\RequestDataExtractor;

use Auto1\ServiceAPIComponentsBundle\Service\Endpoint\EndpointInterface;
use Symfony\Component\HttpFoundation\Request;

interface RequestDataExtractorInterface
{
public function supports(EndpointInterface $endpoint): bool;

/**
* Returns the merged payload array to be passed to the serializer's denormalize() call
* for the endpoint's request class.
*/
public function extract(Request $request, EndpointInterface $endpoint): array;
}
Loading