-
Notifications
You must be signed in to change notification settings - Fork 15
Add multipart/form-data support via request data extractors #21
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: master
Are you sure you want to change the base?
Changes from all commits
015b69f
04c77fc
b13427b
0cfbe7a
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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()), | ||
| $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()) | ||
| ); | ||
| } | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. There's no 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.
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Kept the drop (an explicit |
||
|
|
||
| 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; | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Multipart
PUT/PATCHwill silently produce an empty payload.PHP only populates
$_POST/$_FILESforPOSTrequests. For a multipartPUTorPATCH,$request->request->all()and$request->files->all()are both empty, and the body is never parsed — so the endpoint denormalizes[] + attributes + queryand 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.There was a problem hiding this comment.
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.