Multiple files uploaded via Guzzle multipart/form-data request are not recognized by Symfony

Worp picture Worp · Nov 29, 2017 · Viewed 9.1k times · Source

Environment: Guzzle 6 Symfony 2.3

Uploading multiple files via a Guzzle POST request should be done with a multipart request. So I configure my $options array like so:

Array
(
[multipart] => Array
    (
        [0] => Array
            (
                [name] => filename-0
                [contents] => Resource id #440
                [filename] => filename-0
            )

        [1] => Array
            (
                [name] => filename-1
                [contents] => Resource id #441
                [filename] => filename-1
            )

        [2] => Array
            (
                [name] => filename-2
                [contents] => Resource id #442
                [filename] => filename-2
            )

    )

[headers] => Array
    (
        [Accept] => application/json
        [Content-Type] => multipart/form-data
        [Accept-Language] => de
    )

[allow_redirects] =>
[http_errors] =>

)

The resources in the multipart-array are the results of an fopen().

And send the request using

$response = $this->client->post(
    '/someUrl/someAction',
    $options
);

Using an already created client.

On the accepting Symfony-Controllers side, I can't get the files sent:

var_dump($_FILES); // array(0) {}
var_dump($_POST);  // array(0) {}
var_dump(count($request->files->all())); // int(0)

However, both of these:

var_dump(file_get_contents("php://input"));
var_dump($request->getContent());

Return data on the input stream:

/myPath/FileController.php:xx:
string(601) "--e55f849feb078da4a9e35ba77da3ded02ec813a7
Content-Disposition: form-data; name="filename-0"; filename="filename-0"
Content-Length: 43

This is a testfile...
--e55f849feb078da4a9e35ba77da3ded02ec813a7
Content-Disposition: form-data; name="filename-1"; filename="filename-1"
Content-Length: 43

This is a testfile...
--e55f849feb078da4a9e35ba77da3ded02ec813a7
Content-Disposition: form-data; name="filename-2"; filename="filename-2"
Content-Length: 43

This is a testfile...
--e55f849feb078da4a9e35ba77da3ded02ec813a7--
"

How might I get in the receiving controller in a Symfony-way?

A curiosity to consider: The Controller reports

var_dump($request->getContentType()); // NULL

My gut feeling says this might be important.

Answer

Philipp Rieber picture Philipp Rieber · Nov 29, 2017

When using the multipart option, you must not specify the Content-Type header yourself. Guzzle will take care of this and - more important for this issue - of the boundary that separates the content parts in the raw request body. The main Content-Type header defines that boundary like so:

Content-Type: multipart/form-data; boundary=unique-string-that-is-hopefully-not-used-in-the-real-content-because-it-separates-the-content-parts`

Check Client.php#L308: Guzzle creates the request body as GuzzleHttp\Psr7\MultipartStream object when multipart option was used.

Then check the MultipartStream constructor at MultipartStream.php#L30: A random value is created to be used as the boundary: sha1(uniqid('', true))

Guzzle will then use the boundary to set the correct content type on its own at Client.php#L383.

But as Guzzle does not override an option that you have specified explicitly, the main header specifies an empty boundary and the raw body parts will be separated by the one created by the MultpartStream constructor. On the receiving end PHP cannot separate the content parts any more.

This code works for me:

(new GuzzleHttp\Client())->request(
    'POST',
    'http://localhost/upload',
    [
        'multipart' => [
            [
                'name' => 'filename-0',
                'contents' => fopen(__DIR__.'/sample-0.txt', 'rb'),
                'filename' => 'filename-0',
            ],
            [
                'name' => 'filename-1',
                'contents' => fopen(__DIR__.'/sample-1.txt', 'rb'),
                'filename' => 'filename-1',
            ],
            [
                'name' => 'filename-2',
                'contents' => fopen(__DIR__.'/sample-2.txt', 'rb'),
                'filename' => 'filename-2',
            ],
        ],
        'headers' => [
            # Do not override the Content-Type header here, Guzzle takes care of it
        ] ,
    ]
);

Please note that all of this is not Symfony related, i.e. Symfony just creates it Request object from what it can get via PHP's native sources like $_FILES, $_POST etc. You were able to see all the content in the raw request body ($request->getContent()) because it was sent like this, but PHP could not split the content parts because it didn't knew of the correct boundary.