DEV Community

loading...

Sending multipart data with PSR-18

timoschinkel profile image Timo Schinkel ・5 min read

While migrating our large codebase from Guzzle - and a little bit of cURL here and there - to PSR-18 I came across the following snippet:

$file = new \GuzzleHttp\Post\PostFile('file', $screenshotData, 'screenshot.png');

$this->httpClient->post(
    "https://...",
    ['body' => 
        'name' => $name,
        'file' => $file
    ]
);

This snippet creates a POST request as multipart/form-data with a parameter file that contains the data of the file that is uploaded. This effectively emulates a simple HTML form:

<form method="post" action="https://..." enctype="multipart/form-data">
    <input type="text" name="name" />
    <input type="file" name="file" />
</form>

Although we implement PSR-18 using Guzzle - via HttPlug - I want to refrain from using Guzzle specific classes in our codebase, since that might prevent us from having a smooth migration to a different HTTP abstraction. And it is not compliant with PSR-18.

Sending a post request

There are two methods of sending data with a POST request; application/x-www-form-urlencoded or multipart/form-data. The first is a translation of the GET structure to a POST body:

POST /a/post/url HTTP/1.1
Host: www.domain.ext
Content-Type: application/x-www-form-urlencoded

field=value&another+field=another+value

This body is achievable via PHP's http_build_query:

$body = http_build_query(
    ['field' => 'value', 'another field' => 'another value'], 
    '', 
    '&', 
    PHP_QUERY_RFC1738
);

But using this method of creating a POST request does not allow for the submission of attachments. For those situations you'll need to resort to multipart/form-data.

multipart/form-data

An attachment is not just the (binary) data of a file, it is a filename, a content type and possible other meta information. As this needs some form of structure the multipart/form-data content type was introduced in RFC 2388.

Effectively what the code above does is create a request that looks like this:

POST /a/post/url HTTP/1.1
Host: www.domain.ext
Content-Type: multipart/format-data; boundary="a.random.boundary"

--a.random.boundary
Content-Disposition: form-data; name="name"
Content-Length: 4

name
--a.random.boundary
Content-Disposition: form-data; name="file"; filename="screenshot.png"
Content-Type: image.png

{$screenshotData}
--a.random.boundary--

And although this is doable using the PSR-18 and PSR-15 interfaces, it requires some knowledge about how this part of the HTTP spec works:

$boundary = 'a.random.boundary';
$request = (new RequestFactory())
    ->createRequest('POST', 'https://www.domain.ext/a/post/url')
    ->withHeader('Content-Type', "multipart/form-data; boundary=\"{$boundary}\"")
    ->withBody((new StreamFactory())->createStream("--{$boundary}" . "\r\n".
"Content-Disposition: form-data; name=\"name\"" . "\r\n" .
"\r\n" . 
"{$name}" . "\r\n" . 
"--{$boundary}" . "\r\n" .    
"Content-Disposition: form-data; name=\"file\"; filename=\"screenshot.php\"" . "\r\n" .
"Content-Type: image/png" . "\r\n" .
"\r\n" . 
"{$screenShotData}" . "\r\n" .
"--{$boundary}--"));

$this->httpClient->sendRequest($request);

This is a lot of work and it has a few pitfalls; while trying this I used a PHP server as receiving party and the requests were handled properly. But when I tried to send a file to JIRA I got an error back saying Header section has more than 10240 bytes (maybe it is not properly terminated). After a fair amount of debugging I found that JIRA (or Java) requires \r\n as new line character.

Now I can create a nice library for, but I am a bit lazy, so I typically turn to Packagist first to see if someone has done this already. When searching Packagist for packages to handle all of this for me you'll find a number of packages, but ons stands out downloads and stars wise: php-http/multipart-stream-builder.

Multipart stream builder

The Multipart stream builder is a package authored by the team that is also responsible for HttPlug. It is - as the name suggests - actually meant to construct multipart streams:

A builder for Multipart PSR-7 Streams. The builder create streams independently form any PSR-7 implementation.

In order to create a PSR-7 compliant request with a multipart body we need to create an instance of the multipart stream builder:

use Http\Message\MultipartStream\MultipartStreamBuilder;

$builder = new MultipartStreamBuilder($streamFactory);
$builder->addResource(
    'file', 
    fopen('/path/to/uploaded/file', 'r'), 
    [
        'filename' => 'filename.ext', 
        'headers' => ['Content-Type' => 'application/octet-stream']
    ]
);

$request = $requestFactory
    ->createRequest('POST', 'https://...')
    ->withHeader('Content-Type', 'multipart/form-data; boundary="' . $builder->getBoundary() . '"')
    ->withBody($builder->build());

$response = $client->sendRequest($request);    

The nice thing about this library is that it returns an instance of \Psr\Http\Message\StreamInterface. Incidentally the same type of object \Psr\Http\Message\MessageInterface::withBody() expects.

A downside of this library is that the stream builder is not immutable. Injecting it into your application can be a bit tricky as your DI container should return a new instance of the stream builder every time it is injected. Next to that I like to inject based on an interface, which is a personal preference. A small workaround for this is easily created via a factory:

<?php
declare(strict_types=1);

use Http\Message\MultipartStream\MultipartStreamBuilder;
use Psr\Http\Message\StreamFactoryInterface;

final class MultipartStreamBuilderFactory implements MultipartStreamBuilderFactoryInterface
{
    /** @var StreamFactoryInterface */
    private $streamFactory;

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

    public function build(): MultipartStreamBuilder
    {
        return new MultipartStreamBuilder($this->streamFactory);
    }
}

Now you don't have to worry about the factory being shared between different resources; you can always get a new instance of the stream builder.

Symfony Mime

Although it did not show up on the Packagist search Symfony has a component to do this for you as well. The primary goal of this package is aimed to be used to create MIME messages and is primarily focused on email messages - MIME is an acronym for Multipurpose Internet Mail Extensions. But it also has a feature - currently marked as experimental - that can be used to create multipart messages; FormDataPart. This feature is described in the documentation of the Symfony Http Client:

To submit a form with file uploads, it is your responsibility to encode the body according to the multipart/form-data content-type. The Symfony Mime component makes it a few lines of code:

[...]

This statement is partly true; if you're using the Symfony Http Client only a few lines of code are needed. But if you're using a PSR-18 compliant client a few more lines are needed:

use Symfony\Component\Mime\Part\DataPart;
use Symfony\Component\Mime\Part\Multipart\FormDataPart;

$formFields = [
    'regular_field' => 'some value',
    'file_field' => DataPart::fromPath('/path/to/uploaded/file'),
];

$request = $requestFactory
    ->createRequest('POST', 'https://...');

$formData = new FormDataPart($formFields);
$preparedHeaders = $formData->getPreparedHeaders();
foreach ($preparedHeaders->getNames() as $header) {
    $request = $request->withHeader(
        $header, 
        $preparedHeaders->get($header)->getBodyAsString()
    );
}

$request = $request->withBody(
    $streamFactory->createStream($formData->bodyToString())
);

$response = $client->sendRequest($request);

Conclusion

You can build up a multipart message yourself, but you might run into unexpected issues - like new line character incompatibilities. Chances are you are not the first that is facing such a situation and chances are that it is already a solved issue. An example of this is php-http/multipart-stream-builder.

The two major HTTP client abstraction libraries - Guzzle and Symfony HttpClient - have a lot built-in functionalities that PSR-18 does not offer. This makes that when using PSR-18 you might need to have more knowledge of how HTTP actually works. I personally don't think this is a bad thing. Here lie new opportunities as we can now use clients that only perform requests and move all additional functionalities to separate packages that have a single responsibility and you only need to add to your codebase if you actually use them.

Discussion (0)

pic
Editor guide