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.
Top comments (1)
Thank you! This post made my day.