Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 0 additions & 1 deletion composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,6 @@
},
"require-dev": {
"laravel/pint": "1.*",
"utopia-php/fetch": "^1.1.2",
"phpstan/phpstan": "^2.1",
"phpunit/phpunit": "^13.1",
"rector/rector": "^2.4",
Expand Down
43 changes: 1 addition & 42 deletions composer.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

158 changes: 131 additions & 27 deletions tests/e2e/Client.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,36 @@

namespace Tests\E2E;

use Utopia\Fetch\Client as FetchClient;
use OpenRuntimes\Executor\BodyMultipart;
use Psr\Http\Message\RequestInterface;
use Psr\Http\Message\ResponseInterface;
use Utopia\Client\Adapter\Curl\Client as Curl;
use Utopia\Client\Client as HttpClient;
use Utopia\Psr7\ContentType;
use Utopia\Psr7\Method;
use Utopia\Psr7\Request\Factory as RequestFactory;
use Utopia\Psr7\Stream\Factory as StreamFactory;

class Client extends FetchClient
class Client
{
public const string METHOD_GET = Method::GET;

public const string METHOD_POST = Method::POST;

public const string METHOD_PUT = Method::PUT;

public const string METHOD_PATCH = Method::PATCH;

public const string METHOD_DELETE = Method::DELETE;

public const string METHOD_HEAD = Method::HEAD;

public const string METHOD_OPTIONS = Method::OPTIONS;

private const int MAX_REDIRECTS = 5;

private const int CONNECT_TIMEOUT_MS = 5000;

/**
* @param array<string, string> $baseHeaders
*/
Expand All @@ -30,62 +55,141 @@ public function setKey(string $key): void
*/
public function call(string $method, string $path = '', array $headers = [], array $params = [], bool $decode = true, ?callable $callback = null, int $timeout = 60000): array
{
$url = $this->endpoint . $path;
$client = new HttpClient(new Curl())
->withTimeout($timeout / 1000)
->withConnectTimeout(self::CONNECT_TIMEOUT_MS / 1000)
->withFollowRedirects(true, self::MAX_REDIRECTS);

$client = new FetchClient();
$client->setTimeout($timeout);
$request = $this->request($method, $this->endpoint . $path, $headers, $params);

foreach ($this->baseHeaders as $key => $value) {
$client->addHeader($key, $value);
if ($callback !== null) {
$response = $client->stream($request, function (string $chunk) use ($callback): void {
$callback($chunk);
});
} else {
$response = $client->sendRequest($request);
}

foreach ($headers as $key => $value) {
$client->addHeader($key, $value);
}

$response = $client->fetch(
url: $url,
method: $method,
body: $method !== FetchClient::METHOD_GET ? $params : [],
query: $method === FetchClient::METHOD_GET ? $params : [],
chunks: $callback ? function ($chunk) use ($callback): void {
$callback($chunk->getData());
} : null
);
$responseHeaders = $this->headers($response);

$body = null;
if ($callback === null) {
if ($decode) {
$contentType = $response->getHeaders()['content-type'] ?? '';
$contentType = $responseHeaders['content-type'] ?? '';
$strpos = strpos($contentType, ';');
$strpos = is_bool($strpos) ? strlen($contentType) : $strpos;
$contentType = substr($contentType, 0, $strpos);

switch ($contentType) {
case 'multipart/form-data':
$boundary = explode('boundary=', $response->getHeaders()['content-type'] ?? '')[1] ?? '';
$boundary = explode('boundary=', $responseHeaders['content-type'] ?? '')[1] ?? '';
$multipartResponse = new BodyMultipart($boundary);
$multipartResponse->load($response->text());
$multipartResponse->load((string) $response->getBody());
$body = $multipartResponse->getParts();
break;
case 'application/json':
$body = $response->json();
$body = json_decode((string) $response->getBody(), true, flags: JSON_THROW_ON_ERROR);
break;
default:
$body = $response->text();
$body = (string) $response->getBody();
break;
}
} else {
$body = $response->text();
$body = (string) $response->getBody();
}
}

return [
'headers' => array_merge(
$response->getHeaders(),
$responseHeaders,
['status-code' => $response->getStatusCode()]
),
'body' => $body
];
}

/**
* Header names are case-insensitive, so the last value sent under any casing wins.
* GET parameters go in the query string, anything else is a JSON, form or
* multipart body chosen by the request's content type. A multipart body keeps
* the content type the factory sets, since that one carries the boundary.
*
* @param array<string, string> $headers
* @param array<string, mixed> $params
*/
private function request(string $method, string $url, array $headers, array $params): RequestInterface
{
$merged = [];
foreach ([...$this->baseHeaders, ...$headers] as $key => $value) {
$merged[strtolower($key)] = $value;
}

if ($method === self::METHOD_GET && $params !== []) {
$url = rtrim($url, '?&');
$url .= (str_contains($url, '?') ? '&' : '?') . http_build_query($params);
}

$factory = new RequestFactory();
$request = $factory->createRequest($method, $url);

if ($method !== self::METHOD_GET && ($merged['content-type'] ?? '') === ContentType::MULTIPART_FORM_DATA) {
unset($merged['content-type']);
$request = $factory->multipart($method, $url, self::flatten($params));
} elseif ($method !== self::METHOD_GET) {
$body = match ($merged['content-type'] ?? '') {
ContentType::JSON => json_encode($params, JSON_THROW_ON_ERROR),
ContentType::FORM_URLENCODED => http_build_query($params),
default => $params === [] ? '' : throw new \InvalidArgumentException('Unsupported content type for a request body: ' . ($merged['content-type'] ?? '(none)')),
};
$request = $request->withBody(new StreamFactory()->createStream($body));
}

foreach ($merged as $key => $value) {
$request = $request->withHeader($key, $value);
}

return $request;
}

/**
* Nested parameters become `parent[child]` fields and null an empty field, as
* curl sent them.
*
* @param array<array-key, mixed> $data
* @return array<array-key, scalar>
*/
private static function flatten(array $data, string $prefix = ''): array
{
$output = [];
foreach ($data as $key => $value) {
$name = $prefix === '' ? (string) $key : $prefix . '[' . $key . ']';

if (is_array($value)) {
$output += self::flatten($value, $name);
} elseif (is_scalar($value)) {
$output[$name] = $value;
} elseif ($value === null) {
$output[$name] = '';
} else {
throw new \InvalidArgumentException('Unsupported multipart field value for ' . $name);
}
}

return $output;
}

/**
* Lowercased names, keeping the last value of a repeated header.
*
* @return array<string, string>
*/
private function headers(ResponseInterface $response): array
{
$headers = [];
foreach ($response->getHeaders() as $name => $values) {
$headers[strtolower((string) $name)] = (string) end($values);
}

return $headers;
}
}
Loading