Sending multipart data with PSR-18
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
$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 --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 o">. $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:
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.
Multipart form data отправка php
I think the way an array of attachments works is kind of cumbersome. Usually the PHP guys are right on the money, but this is just counter-intuitive. It should have been more like:
Array
(
[0] => Array
(
[name] => facepalm.jpg
[type] => image/jpeg
[tmp_name] => /tmp/phpn3FmFr
[error] => 0
[size] => 15476
)
Anyways, here is a fuller example than the sparce one in the documentation above:
foreach ( $_FILES [ «attachment» ][ «error» ] as $key => $error )
$tmp_name = $_FILES [ «attachment» ][ «tmp_name» ][ $key ];
if (! $tmp_name ) continue;
$name = basename ( $_FILES [ «attachment» ][ «name» ][ $key ]);
if ( $error == UPLOAD_ERR_OK )
if ( move_uploaded_file ( $tmp_name , «/tmp/» . $name ) )
$uploaded_array [] .= «Uploaded file ‘» . $name . «‘.
\n» ;
else
$errormsg .= «Could not move uploaded file ‘» . $tmp_name . «‘ to ‘» . $name . «‘
\n» ;
>
else $errormsg .= «Upload error. [» . $error . «] on file ‘» . $name . «‘
\n» ;
>
?>
Do not use Coreywelch or Daevid’s way, because their methods can handle only within two-dimensional structure. $_FILES can consist of any hierarchy, such as 3d or 4d structure.
The following example form breaks their codes:
As the solution, you should use PSR-7 based zendframework/zend-diactoros.
use Psr \ Http \ Message \ UploadedFileInterface ;
use Zend \ Diactoros \ ServerRequestFactory ;
$request = ServerRequestFactory :: fromGlobals ();
if ( $request -> getMethod () !== ‘POST’ ) http_response_code ( 405 );
exit( ‘Use POST method.’ );
>
$uploaded_files = $request -> getUploadedFiles ();
if (
!isset( $uploaded_files [ ‘files’ ][ ‘x’ ][ ‘y’ ][ ‘z’ ]) ||
! $uploaded_files [ ‘files’ ][ ‘x’ ][ ‘y’ ][ ‘z’ ] instanceof UploadedFileInterface
) http_response_code ( 400 );
exit( ‘Invalid request body.’ );
>
$file = $uploaded_files [ ‘files’ ][ ‘x’ ][ ‘y’ ][ ‘z’ ];
if ( $file -> getError () !== UPLOAD_ERR_OK ) http_response_code ( 400 );
exit( ‘File uploading failed.’ );
>
$file -> moveTo ( ‘/path/to/new/file’ );
The documentation doesn’t have any details about how the HTML array feature formats the $_FILES array.
Array
(
[document] => Array
(
[name] => sample-file.doc
[type] => application/msword
[tmp_name] => /tmp/path/phpVGCDAJ
[error] => 0
[size] => 0
)
)
Multi-files with HTML array feature —
Array
(
[documents] => Array
(
[name] => Array
(
[0] => sample-file.doc
[1] => sample-file.doc
)
(
[0] => application/msword
[1] => application/msword
) [tmp_name] => Array
(
[0] => /tmp/path/phpVGCDAJ
[1] => /tmp/path/phpVGCDAJ
)
The problem occurs when you have a form that uses both single file and HTML array feature. The array isn’t normalized and tends to make coding for it really sloppy. I have included a nice method to normalize the $_FILES array.
function normalize_files_array ( $files = [])
foreach( $files as $index => $file )
if (! is_array ( $file [ ‘name’ ])) $normalized_array [ $index ][] = $file ;
continue;
>
foreach( $file [ ‘name’ ] as $idx => $name ) $normalized_array [ $index ][ $idx ] = [
‘name’ => $name ,
‘type’ => $file [ ‘type’ ][ $idx ],
‘tmp_name’ => $file [ ‘tmp_name’ ][ $idx ],
‘error’ => $file [ ‘error’ ][ $idx ],
‘size’ => $file [ ‘size’ ][ $idx ]
];
>
?>
The following is the output from the above method.
Array
(
[document] => Array
(
[0] => Array
(
[name] => sample-file.doc
[type] => application/msword
[tmp_name] => /tmp/path/phpVGCDAJ
[error] => 0
[size] => 0
)
(
[0] => Array
(
[name] => sample-file.doc
[type] => application/msword
[tmp_name] => /tmp/path/phpVGCDAJ
[error] => 0
[size] => 0
) [1] => Array
(
[name] => sample-file.doc
[type] => application/msword
[tmp_name] => /tmp/path/phpVGCDAJ
[error] => 0
[size] => 0
)