DEV Community

Timo Schinkel
Timo Schinkel

Posted on

Sending multipart data with PSR-18

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
    ]
);
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

This body is achievable via PHP's http_build_query:

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

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--
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

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);    
Enter fullscreen mode Exit fullscreen mode

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);
    }
}

Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

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.

Top comments (1)

Collapse
 
amcintosh profile image
Andrew McIntosh

Thank you! This post made my day.