Skip to content
85 changes: 81 additions & 4 deletions src/Twilio/Http/CurlClient.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
namespace Twilio\Http;


use Twilio\Exceptions\ConfigurationException;
use Twilio\Exceptions\EnvironmentException;

class CurlClient implements Client {
Expand Down Expand Up @@ -104,9 +105,9 @@ public function options(string $method, string $url,
$options[CURLOPT_HTTPHEADER][] = 'Authorization: Basic ' . \base64_encode("$user:$password");
}

$body = $this->buildQuery($params);
if ($body) {
$options[CURLOPT_URL] .= '?' . $body;
$query = $this->buildQuery($params);
if ($query) {
$options[CURLOPT_URL] .= '?' . $query;
}

switch (\strtolower(\trim($method))) {
Expand All @@ -115,10 +116,18 @@ public function options(string $method, string $url,
break;
case 'post':
$options[CURLOPT_POST] = true;
$options[CURLOPT_POSTFIELDS] = $this->buildQuery($data);
if ($this->hasFile($data)) {
[$headers, $body] = $this->buildMultipartOptions($data);
$options[CURLOPT_POSTFIELDS] = $body;
$options[CURLOPT_HTTPHEADER] = \array_merge($options[CURLOPT_HTTPHEADER], $headers);
} else {
$options[CURLOPT_POSTFIELDS] = $this->buildQuery($data);
$options[CURLOPT_HTTPHEADER][] = 'Content-Type: application/x-www-form-urlencoded';
}

break;
case 'put':
// TODO: PUT doesn't used anywhere and it has strange implementation. Must investigate later
$options[CURLOPT_PUT] = true;
if ($data) {
if ($buffer = \fopen('php://memory', 'w+')) {
Expand Down Expand Up @@ -158,4 +167,72 @@ public function buildQuery(?array $params): string {

return \implode('&', $parts);
}

private function hasFile(array $data): bool {
foreach ($data as $value) {
if ($value instanceof File) {
return true;
}
}

return false;
}

private function buildMultipartOptions(array $data): array {
$boundary = \uniqid('', true);
$delimiter = "-------------{$boundary}";
$body = '';

foreach ($data as $key => $value) {
if ($value instanceof File) {
$contents = $value->getContents();
if ($contents === null) {
$chunk = \file_get_contents($value->getFileName());
$filename = \basename($value->getFileName());
} elseif (\is_resource($contents)) {
$chunk = '';
while (!\feof($contents)) {
$chunk .= \fread($contents, 8096);
}

$filename = $value->getFileName();
} elseif (\is_string($contents)) {
$chunk = $contents;
$filename = $value->getFileName();
} else {
throw new \InvalidArgumentException('Unsupported content type');
}

$headers = '';
$contentType = $value->getContentType();
if ($contentType !== null) {
$headers .= "Content-Type: {$contentType}\r\n";
}

$body .= \vsprintf("--%s\r\nContent-Disposition: form-data; name=\"%s\"; filename=\"%s\"\r\n%s\r\n%s\r\n", [
$delimiter,
$key,
$filename,
$headers,
$chunk,
]);
} else {
$body .= \vsprintf("--%s\r\nContent-Disposition: form-data; name=\"%s\"\r\n\r\n%s\r\n", [
$delimiter,
$key,
$value,
]);
}
}

$body .= "--{$delimiter}--\r\n";

return [
[
"Content-Type: multipart/form-data; boundary={$delimiter}",
'Content-Length: ' . \strlen($body),
],
$body,
];
}
}
47 changes: 47 additions & 0 deletions src/Twilio/Http/File.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
<?php
declare(strict_types=1);

namespace Twilio\Http;

final class File {
/**
* @var string
*/
private $fileName;

/**
* @var resource|string|mixed|null
*/
private $contents;

/**
* @var string|null
*/
private $contentType;

/**
* @param string $fileName full file path or file name for passed $contents
* @param string|resource|mixed|null $contents
* @param string $contentType
*/
public function __construct(string $fileName, $contents = null, string $contentType = null) {
$this->fileName = $fileName;
$this->contents = $contents;
$this->contentType = $contentType;
}

/**
* @return resource|string|mixed|null
*/
public function getContents() {
return $this->contents;
}

public function getFileName(): string {
return $this->fileName;
}

public function getContentType(): ?string {
return $this->contentType;
}
}
53 changes: 50 additions & 3 deletions src/Twilio/Http/GuzzleClient.php
Original file line number Diff line number Diff line change
Expand Up @@ -25,19 +25,25 @@ public function request(string $method, string $url,
string $user = null, string $password = null,
int $timeout = null): Response {
try {
$body = Query::build($data, PHP_QUERY_RFC1738);

$options = [
'timeout' => $timeout,
'auth' => [$user, $password],
'body' => $body,
'allow_redirects' => false,
];

if ($params) {
$options['query'] = $params;
}

if ($method === 'POST') {
if ($this->hasFile($data)) {
$options['multipart'] = $this->buildMultipartParam($data);
} else {
$options['body'] = Query::build($data, PHP_QUERY_RFC1738);
$headers['Content-Type'] = 'application/x-www-form-urlencoded';
}
}

$response = $this->client->send(new Request($method, $url, $headers), $options);
} catch (BadResponseException $exception) {
$response = $exception->getResponse();
Expand All @@ -49,4 +55,45 @@ public function request(string $method, string $url,
// See https://stackoverflow.com/a/30549372/86696
return new Response($response->getStatusCode(), (string)$response->getBody(), $response->getHeaders());
}

private function hasFile(array $data): bool {
foreach ($data as $value) {
if ($value instanceof File) {
return true;
}
}

return false;
}

private function buildMultipartParam(array $data): array {
$multipart = [];
foreach ($data as $key => $value) {
if ($value instanceof File) {
$contents = $value->getContents();
if ($contents === null) {
$contents = fopen($value->getFileName(), 'rb');
}

$chunk = [
'name' => $key,
'contents' => $contents,
'filename' => $value->getFileName(),
];

if ($value->getContentType() !== null) {
$chunk['headers']['Content-Type'] = $value->getContentType();
}
} else {
$chunk = [
'name' => $key,
'contents' => $value,
];
}

$multipart[] = $chunk;
}

return $multipart;
}
}
6 changes: 1 addition & 5 deletions src/Twilio/Rest/Client.php
Original file line number Diff line number Diff line change
Expand Up @@ -229,10 +229,6 @@ public function request(string $method, string $uri, array $params = [], array $
$headers['User-Agent'] .= ' ' . implode(' ', $this->userAgentExtensions);
}

if ($method === 'POST' && !\array_key_exists('Content-Type', $headers)) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

FYI, this is a generated file (should be refactored so this particular part isn't generated) so we'll need to update the generator before merging.

$headers['Content-Type'] = 'application/x-www-form-urlencoded';
}

if (!\array_key_exists('Accept', $headers)) {
$headers['Accept'] = 'application/json';
}
Expand Down Expand Up @@ -1054,4 +1050,4 @@ public function validateSslCertificate(CurlClient $client): void {
throw new TwilioException('Failed to validate SSL certificate');
}
}
}
}
49 changes: 44 additions & 5 deletions tests/Twilio/Unit/Http/CurlClientTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@


use Twilio\Http\CurlClient;
use Twilio\Http\File;
use Twilio\Tests\Unit\UnitTest;

class CurlClientTest extends UnitTest {
Expand Down Expand Up @@ -168,34 +169,44 @@ public function queryStringProvider(): array {
/**
* @param array|string $params Parameters to post
* @param array|string $data Data to post
* @param string $expected Expected POSTFIELDS
* @param string $expectedContentType Excpected Content-Type header
* @param string $expectedBody Expected POSTFIELDS
* @dataProvider postFieldsProvider
* @throws \Twilio\Exceptions\EnvironmentException
*/
public function testPostFields($params, $data, string $expected): void {
public function testPostFields($params, $data, string $expectedContentType, string $expectedBody): void {
$client = new CurlClient();

$actual = $client->options('POST', 'url', $params, $data);
foreach ($actual[CURLOPT_HTTPHEADER] as $header) {
if (strpos($header, 'Content-Type: ') === 0) {
$this->assertStringMatchesFormat($expectedContentType, substr($header, 14));
break;
}
}

$this->assertEquals($expected, $actual[CURLOPT_POSTFIELDS]);
$this->assertStringMatchesFormat($expectedBody, $actual[CURLOPT_POSTFIELDS]);
}

public function postFieldsProvider(): array {
return [
[
[],
[],
'application/x-www-form-urlencoded',
'',
],
[
['a' => 'x'],
['a' => 'b'],
'a=b'
'application/x-www-form-urlencoded',
'a=b',
],
[
['a' => 'x'],
['a' => 'x'],
'a=x'
'application/x-www-form-urlencoded',
'a=x',
],
[
['a' => 'x'],
Expand All @@ -204,8 +215,36 @@ public function postFieldsProvider(): array {
'b' => 7,
'c' => [1, 2, 3],
],
'application/x-www-form-urlencoded',
'a=z&b=7&c=1&c=2&c=3',
],
'file by its path' => [
[],
[
'key' => 'value',
'file' => new File(__DIR__ . '/file.txt'),
],
'multipart/form-data; boundary=-------------%s',
"---------------%s\r\nContent-Disposition: form-data; name=\"key\"\r\n\r\nvalue\r\n---------------%s\r\nContent-Disposition: form-data; name=\"file\"; filename=\"file.txt\"\r\n\r\nMock contents\n\r\n---------------%s--\r\n",
],
'file as a resource' => [
[],
[
'key' => 'value',
'file' => new File('file.txt', fopen(__DIR__ . '/file.txt', 'rb')),
],
'multipart/form-data; boundary=-------------%s',
"---------------%s\r\nContent-Disposition: form-data; name=\"key\"\r\n\r\nvalue\r\n---------------%s\r\nContent-Disposition: form-data; name=\"file\"; filename=\"file.txt\"\r\n\r\nMock contents\n\r\n---------------%s--\r\n",
],
'file as a string' => [
[],
[
'key' => 'value',
'file' => new File('file.txt', file_get_contents(__DIR__ . '/file.txt', 'rb'), 'custom/content'),
],
'multipart/form-data; boundary=-------------%s',
"---------------%s\r\nContent-Disposition: form-data; name=\"key\"\r\n\r\nvalue\r\n---------------%s\r\nContent-Disposition: form-data; name=\"file\"; filename=\"file.txt\"\r\nContent-Type: custom/content\r\n\r\nMock contents\n\r\n---------------%s--\r\n",
],
];
}

Expand Down
Loading