diff --git a/app/controllers/api/functions.php b/app/controllers/api/functions.php index 04654c671..034ab79a3 100644 --- a/app/controllers/api/functions.php +++ b/app/controllers/api/functions.php @@ -759,6 +759,13 @@ App::post('/v1/functions/:functionId/executions') throw new Exception('Function not found', 404); } + $runtimes = Config::getParam('runtimes', []); + $key = $function->getAttribute('runtime', ''); + $runtime = isset($runtimes[$key]) ? $runtimes[$key] : null; + if (\is_null($runtime)) { + throw new Exception('Runtime "' . $function->getAttribute('runtime', '') . '" is not supported', 400); + } + $deployment = Authorization::skip(fn() => $dbForProject->getDocument('deployments', $function->getAttribute('deployment'))); if ($deployment->getAttribute('resourceId') !== $function->getId()) { @@ -834,7 +841,7 @@ App::post('/v1/functions/:functionId/executions') 'data' => $data, 'runtime' => $function->getAttribute('runtime', ''), 'timeout' => $function->getAttribute('timeout', 0), - 'baseImage' => '', + 'baseImage' => $runtime['image'], 'webhooks' => $project->getAttribute('webhooks', []), 'userId' => $user->getId(), 'functionId' => $function->getId(), @@ -849,21 +856,19 @@ App::post('/v1/functions/:functionId/executions') } /** Send variables */ - // $vars = \array_merge($function->getAttribute('vars', []), [ - // 'ENTRYPOINT_NAME' => $deployment->getAttribute('entrypoint', ''), - // 'APPWRITE_FUNCTION_ID' => $function->getId(), - // 'APPWRITE_FUNCTION_NAME' => $function->getAttribute('name', ''), - // 'APPWRITE_FUNCTION_DEPLOYMENT' => $deployment->getId(), - // 'APPWRITE_FUNCTION_TRIGGER' => 'http', - // 'APPWRITE_FUNCTION_RUNTIME_NAME' => $runtime['name'], - // 'APPWRITE_FUNCTION_RUNTIME_VERSION' => $runtime['version'], - // 'APPWRITE_FUNCTION_EVENT' => $event, - // 'APPWRITE_FUNCTION_EVENT_DATA' => $eventData, - // 'APPWRITE_FUNCTION_DATA' => $data, - // 'APPWRITE_FUNCTION_USER_ID' => $userId, - // 'APPWRITE_FUNCTION_JWT' => $jwt, - // 'APPWRITE_FUNCTION_PROJECT_ID' => $projectId - // ]); + $vars = \array_merge($function->getAttribute('vars', []), [ + 'ENTRYPOINT_NAME' => $deployment->getAttribute('entrypoint', ''), + 'APPWRITE_FUNCTION_ID' => $function->getId(), + 'APPWRITE_FUNCTION_NAME' => $function->getAttribute('name', ''), + 'APPWRITE_FUNCTION_DEPLOYMENT' => $deployment->getId(), + 'APPWRITE_FUNCTION_TRIGGER' => 'http', + 'APPWRITE_FUNCTION_RUNTIME_NAME' => $runtime['name'], + 'APPWRITE_FUNCTION_RUNTIME_VERSION' => $runtime['version'], + 'APPWRITE_FUNCTION_DATA' => $data, + 'APPWRITE_FUNCTION_USER_ID' => $user->getId(), + 'APPWRITE_FUNCTION_JWT' => $jwt, + 'APPWRITE_FUNCTION_PROJECT_ID' => $project->getId() + ]); // Directly execute function. $ch = \curl_init(); @@ -873,11 +878,11 @@ App::post('/v1/functions/:functionId/executions') 'deploymentId' => $deployment->getId(), 'buildId' => $deployment->getAttribute('buildId', ''), 'path' => $build->getAttribute('outputPath', ''), - 'vars' => $function->getAttribute('vars', []), + 'vars' => $vars, 'data' => $data, 'runtime' => $function->getAttribute('runtime', ''), 'timeout' => $function->getAttribute('timeout', 0), - 'baseImage' => '', + 'baseImage' => $runtime['image'], 'webhooks' => $project->getAttribute('webhooks', []), 'userId' => $user->getId(), ])); @@ -896,12 +901,21 @@ App::post('/v1/functions/:functionId/executions') if (!empty($error)) { Console::error('Curl error: '.$error); } - \curl_close($ch); + $responseExecute = json_decode($responseExecute, true); + $execution->setAttribute('status', $responseExecute['status']); + $execution->setAttribute('statusCode', $responseExecute['statusCode']); + $execution->setAttribute('stdout', $responseExecute['stdout']); + $execution->setAttribute('stderr', $responseExecute['stderr']); + $execution->setAttribute('time', $responseExecute['time']); + Authorization::skip(fn() => $dbForProject->updateDocument('executions', $executionId, $execution)); + + $responseExecute['response'] = ($responseExecute['status'] !== 'completed') ? $responseExecute['stderr'] : $responseExecute['stdout']; + $response ->setStatusCode(Response::STATUS_CODE_CREATED) - ->dynamic(new Document(json_decode($responseExecute, true)), Response::MODEL_SYNC_EXECUTION); + ->dynamic(new Document($responseExecute), Response::MODEL_SYNC_EXECUTION); }); App::get('/v1/functions/:functionId/executions') diff --git a/app/executor.php b/app/executor.php index 76f3bc4ff..2e923479f 100644 --- a/app/executor.php +++ b/app/executor.php @@ -276,23 +276,6 @@ function execute(string $projectId, string $functionId, string $deploymentId, ar $key = $activeFunctions->get('appwrite-function-' . $deploymentId, 'key'); - // Process environment variables - // $vars = \array_merge($function->getAttribute('vars', []), [ - // 'ENTRYPOINT_NAME' => $deployment->getAttribute('entrypoint', ''), - // 'APPWRITE_FUNCTION_ID' => $function->getId(), - // 'APPWRITE_FUNCTION_NAME' => $function->getAttribute('name', ''), - // 'APPWRITE_FUNCTION_DEPLOYMENT' => $deployment->getId(), - // 'APPWRITE_FUNCTION_TRIGGER' => $trigger, - // 'APPWRITE_FUNCTION_RUNTIME_NAME' => $runtime['name'], - // 'APPWRITE_FUNCTION_RUNTIME_VERSION' => $runtime['version'], - // 'APPWRITE_FUNCTION_EVENT' => $event, - // 'APPWRITE_FUNCTION_EVENT_DATA' => $eventData, - // 'APPWRITE_FUNCTION_DATA' => $data, - // 'APPWRITE_FUNCTION_USER_ID' => $userId, - // 'APPWRITE_FUNCTION_JWT' => $jwt, - // 'APPWRITE_FUNCTION_PROJECT_ID' => $projectId - // ]); - $stdout = ''; $stderr = ''; @@ -365,6 +348,8 @@ function execute(string $projectId, string $functionId, string $deploymentId, ar throw new Exception('An internal curl error has occurred within the executor! Error Msg: ' . $error, 500); } + var_dump($executorResponse); + $executionData = []; if (!empty($executorResponse)) { diff --git a/app/workers/builds.php b/app/workers/builds.php index c754f49d2..9ebfb0518 100644 --- a/app/workers/builds.php +++ b/app/workers/builds.php @@ -2,6 +2,7 @@ use Appwrite\Resque\Worker; use Cron\CronExpression; +use Executor\Executor; use Utopia\Database\Validator\Authorization; use Utopia\App; use Utopia\CLI\Console; @@ -20,28 +21,19 @@ Console::success(APP_NAME.' build worker v1 has started'); // TODO: Executor should return appropriate response codes. class BuildsV1 extends Worker { - const METHOD_GET = 'GET'; - const METHOD_POST = 'POST'; - const METHOD_PUT = 'PUT'; - const METHOD_PATCH = 'PATCH'; - const METHOD_DELETE = 'DELETE'; - const METHOD_HEAD = 'HEAD'; - const METHOD_OPTIONS = 'OPTIONS'; - const METHOD_CONNECT = 'CONNECT'; - const METHOD_TRACE = 'TRACE'; - - protected $selfSigned = false; - private $endpoint = 'http://appwrite-executor/v1'; - protected $headers = [ - 'content-type' => '', - ]; + /** + * @var Executor + */ + private $executor = null; public function getName(): string { return "builds"; } - public function init(): void {} + public function init(): void { + $this->executor = new Executor(); + } public function run(): void { @@ -70,32 +62,6 @@ class BuildsV1 extends Worker } } - - protected function createBuild(string $projectId, string $functionId, string $deploymentId, string $buildId, string $path, array $vars, string $runtime, string $baseImage) - { - $route = "/functions/$functionId/deployments/$deploymentId/builds/$buildId"; - $headers = [ - 'content-type' => 'application/json', - 'x-appwrite-project' => $projectId, - 'x-appwrite-executor-key' => App::getEnv('_APP_EXECUTOR_SECRET', '') - ]; - $params = [ - 'path' => $path, - 'vars' => $vars, - 'runtime' => $runtime, - 'baseImage' => $baseImage - ]; - - $response = $this->call(self::METHOD_POST, $route, $headers, $params, true, 30); - - $status = $response['headers']['status-code']; - if ($status >= 400) { - throw new \Exception('Error creating build: ', $status); - } - - return $response['body']; - } - protected function buildDeployment(string $projectId, string $functionId, string $deploymentId) { $dbForProject = $this->getProjectDB($projectId); @@ -150,8 +116,17 @@ class BuildsV1 extends Worker $path = $deployment->getAttribute('path'); $vars = $function->getAttribute('vars', []); $baseImage = $runtime['image']; - $response = $this->createBuild($projectId, $functionId, $deploymentId, $buildId, $path, $vars, $key, $baseImage); - + $response = $this->executor->createRuntime( + projectId: $projectId, + functionId: $functionId, + deploymentId: $deploymentId, + buildId: $buildId, + path: $path, + vars: $vars, + runtime: $key, + baseImage: $baseImage + ); + /** Update the build document */ $build->setAttribute('endTime', $response['endTime']); $build->setAttribute('duration', $response['duration']); @@ -181,151 +156,4 @@ class BuildsV1 extends Worker } public function shutdown(): void {} - - /** - * Call - * - * Make an API call - * - * @param string $method - * @param string $path - * @param array $params - * @param array $headers - * @param bool $decode - * @return array|string - * @throws Exception - */ - public function call(string $method, string $path = '', array $headers = [], array $params = [], bool $decode = true, int $timeout = 15) - { - $headers = array_merge($this->headers, $headers); - $ch = curl_init($this->endpoint . $path . (($method == self::METHOD_GET && !empty($params)) ? '?' . http_build_query($params) : '')); - $responseHeaders = []; - $responseStatus = -1; - $responseType = ''; - $responseBody = ''; - - switch ($headers['content-type']) { - case 'application/json': - $query = json_encode($params); - break; - - case 'multipart/form-data': - $query = $this->flatten($params); - break; - - default: - $query = http_build_query($params); - break; - } - - foreach ($headers as $i => $header) { - $headers[] = $i . ':' . $header; - unset($headers[$i]); - } - - curl_setopt($ch, CURLOPT_CUSTOMREQUEST, $method); - curl_setopt($ch, CURLOPT_RETURNTRANSFER, 1); - curl_setopt($ch, CURLOPT_FOLLOWLOCATION, true); - curl_setopt($ch, CURLOPT_USERAGENT, 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/70.0.3538.77 Safari/537.36'); - curl_setopt($ch, CURLOPT_HTTPHEADER, $headers); - curl_setopt($ch, CURLOPT_CONNECTTIMEOUT, 0); - curl_setopt($ch, CURLOPT_TIMEOUT, $timeout); - curl_setopt($ch, CURLOPT_HEADERFUNCTION, function ($curl, $header) use (&$responseHeaders) { - $len = strlen($header); - $header = explode(':', $header, 2); - - if (count($header) < 2) { // ignore invalid headers - return $len; - } - - $responseHeaders[strtolower(trim($header[0]))] = trim($header[1]); - - return $len; - }); - - if ($method != self::METHOD_GET) { - curl_setopt($ch, CURLOPT_POSTFIELDS, $query); - } - - // Allow self signed certificates - if ($this->selfSigned) { - curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, false); - curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false); - } - - $responseBody = curl_exec($ch); - $responseType = $responseHeaders['content-type'] ?? ''; - $responseStatus = curl_getinfo($ch, CURLINFO_HTTP_CODE); - - if($decode) { - switch (substr($responseType, 0, strpos($responseType, ';'))) { - case 'application/json': - $json = json_decode($responseBody, true); - - if ($json === null) { - throw new Exception('Failed to parse response: '.$responseBody); - } - - $responseBody = $json; - $json = null; - break; - } - } - - if ((curl_errno($ch)/* || 200 != $responseStatus*/)) { - throw new Exception(curl_error($ch) . ' with status code ' . $responseStatus, $responseStatus); - } - - curl_close($ch); - - $responseHeaders['status-code'] = $responseStatus; - - if ($responseStatus === 500) { - echo 'Server error('.$method.': '.$path.'. Params: '.json_encode($params).'): '.json_encode($responseBody)."\n"; - } - - return [ - 'headers' => $responseHeaders, - 'body' => $responseBody - ]; - } - - /** - * Parse Cookie String - * - * @param string $cookie - * @return array - */ - public function parseCookie(string $cookie): array - { - $cookies = []; - - parse_str(strtr($cookie, array('&' => '%26', '+' => '%2B', ';' => '&')), $cookies); - - return $cookies; - } - - /** - * Flatten params array to PHP multiple format - * - * @param array $data - * @param string $prefix - * @return array - */ - protected function flatten(array $data, string $prefix = ''): array - { - $output = []; - - foreach ($data as $key => $value) { - $finalKey = $prefix ? "{$prefix}[{$key}]" : $key; - - if (is_array($value)) { - $output += $this->flatten($value, $finalKey); // @todo: handle name collision here if needed - } else { - $output[$finalKey] = $value; - } - } - - return $output; - } } diff --git a/app/workers/deletes.php b/app/workers/deletes.php index 0341eae2c..0760a6788 100644 --- a/app/workers/deletes.php +++ b/app/workers/deletes.php @@ -5,6 +5,7 @@ use Utopia\Database\Document; use Utopia\Database\Query; use Utopia\Database\Validator\Authorization; use Appwrite\Resque\Worker; +use Executor\Executor; use Utopia\Storage\Device\Local; use Utopia\Abuse\Abuse; use Utopia\Abuse\Adapters\TimeLimit; @@ -354,22 +355,10 @@ class DeletesV1 extends Worker /** * Request executor to delete all deployment containers */ + $executor = new Executor(); foreach ($deploymentIds as $deploymentId) { try { - $route = "/deployments/$deploymentId"; - $headers = [ - 'content-Type' => 'application/json', - 'x-appwrite-project' => $projectId, - 'x-appwrite-executor-key' => App::getEnv('_APP_EXECUTOR_SECRET', '') - ]; - $params = [ - 'buildIds' => $buildIds[$deploymentId] ?? [], - ]; - $response = $this->call(self::METHOD_DELETE, $route, $headers, $params, true, 30); - $status = $response['headers']['status-code']; - if ($status >= 400) { - throw new \Exception('Error deleting deplyoment: ' . $document->getId() , $status); - } + $executor->deleteRuntime($deploymentId, $projectId); } catch (Throwable $th) { Console::error($th->getMessage()); } @@ -415,21 +404,8 @@ class DeletesV1 extends Worker * Request executor to delete the deployment container */ try { - $route = "/deployments/{$document->getId()}"; - $headers = [ - 'content-Type' => 'application/json', - 'x-appwrite-project' => $projectId, - 'x-appwrite-executor-key' => App::getEnv('_APP_EXECUTOR_SECRET', '') - ]; - $params = [ - 'buildIds' => $buildIds ?? [] - ]; - - $response = $this->call(self::METHOD_DELETE, $route, $headers, $params, true, 30); - $status = $response['headers']['status-code']; - if ($status >= 400) { - throw new \Exception('Error deleting deplyoment: ' . $document->getId() , $status); - } + $executor = new Executor(); + $executor->deleteRuntime($document->getId(), $projectId); } catch (Throwable $th) { Console::error($th->getMessage()); } @@ -554,167 +530,4 @@ class DeletesV1 extends Worker Console::info("No certificate files found for {$domain}"); } } - - const METHOD_GET = 'GET'; - const METHOD_POST = 'POST'; - const METHOD_PUT = 'PUT'; - const METHOD_PATCH = 'PATCH'; - const METHOD_DELETE = 'DELETE'; - const METHOD_HEAD = 'HEAD'; - const METHOD_OPTIONS = 'OPTIONS'; - const METHOD_CONNECT = 'CONNECT'; - const METHOD_TRACE = 'TRACE'; - - protected $selfSigned = false; - private $endpoint = 'http://appwrite-executor/v1'; - protected $headers = [ - 'content-type' => '', - ]; - - /** - * Call - * - * Make an API call - * - * @param string $method - * @param string $path - * @param array $params - * @param array $headers - * @param bool $decode - * @return array|string - * @throws Exception - */ - public function call(string $method, string $path = '', array $headers = [], array $params = [], bool $decode = true, int $timeout = 15) - { - $headers = array_merge($this->headers, $headers); - $ch = curl_init($this->endpoint . $path . (($method == self::METHOD_GET && !empty($params)) ? '?' . http_build_query($params) : '')); - $responseHeaders = []; - $responseStatus = -1; - $responseType = ''; - $responseBody = ''; - - switch ($headers['content-type']) { - case 'application/json': - $query = json_encode($params); - break; - - case 'multipart/form-data': - $query = $this->flatten($params); - break; - - default: - $query = http_build_query($params); - break; - } - - foreach ($headers as $i => $header) { - $headers[] = $i . ':' . $header; - unset($headers[$i]); - } - - curl_setopt($ch, CURLOPT_CUSTOMREQUEST, $method); - curl_setopt($ch, CURLOPT_RETURNTRANSFER, 1); - curl_setopt($ch, CURLOPT_FOLLOWLOCATION, true); - curl_setopt($ch, CURLOPT_USERAGENT, 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/70.0.3538.77 Safari/537.36'); - curl_setopt($ch, CURLOPT_HTTPHEADER, $headers); - curl_setopt($ch, CURLOPT_CONNECTTIMEOUT, 0); - curl_setopt($ch, CURLOPT_TIMEOUT, $timeout); - curl_setopt($ch, CURLOPT_HEADERFUNCTION, function ($curl, $header) use (&$responseHeaders) { - $len = strlen($header); - $header = explode(':', $header, 2); - - if (count($header) < 2) { // ignore invalid headers - return $len; - } - - $responseHeaders[strtolower(trim($header[0]))] = trim($header[1]); - - return $len; - }); - - if ($method != self::METHOD_GET) { - curl_setopt($ch, CURLOPT_POSTFIELDS, $query); - } - - // Allow self signed certificates - if ($this->selfSigned) { - curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, false); - curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false); - } - - $responseBody = curl_exec($ch); - $responseType = $responseHeaders['content-type'] ?? ''; - $responseStatus = curl_getinfo($ch, CURLINFO_HTTP_CODE); - - if($decode) { - switch (substr($responseType, 0, strpos($responseType, ';'))) { - case 'application/json': - $json = json_decode($responseBody, true); - - if ($json === null) { - throw new Exception('Failed to parse response: '.$responseBody); - } - - $responseBody = $json; - $json = null; - break; - } - } - - if ((curl_errno($ch)/* || 200 != $responseStatus*/)) { - throw new Exception(curl_error($ch) . ' with status code ' . $responseStatus, $responseStatus); - } - - curl_close($ch); - - $responseHeaders['status-code'] = $responseStatus; - - if ($responseStatus === 500) { - echo 'Server error('.$method.': '.$path.'. Params: '.json_encode($params).'): '.json_encode($responseBody)."\n"; - } - - return [ - 'headers' => $responseHeaders, - 'body' => $responseBody - ]; - } - - /** - * Parse Cookie String - * - * @param string $cookie - * @return array - */ - public function parseCookie(string $cookie): array - { - $cookies = []; - - parse_str(strtr($cookie, array('&' => '%26', '+' => '%2B', ';' => '&')), $cookies); - - return $cookies; - } - - /** - * Flatten params array to PHP multiple format - * - * @param array $data - * @param string $prefix - * @return array - */ - protected function flatten(array $data, string $prefix = ''): array - { - $output = []; - - foreach ($data as $key => $value) { - $finalKey = $prefix ? "{$prefix}[{$key}]" : $key; - - if (is_array($value)) { - $output += $this->flatten($value, $finalKey); // @todo: handle name collision here if needed - } else { - $output[$finalKey] = $value; - } - } - - return $output; - } } diff --git a/composer.json b/composer.json index 02daadc7d..5a46b5758 100644 --- a/composer.json +++ b/composer.json @@ -11,7 +11,8 @@ ], "autoload": { "psr-4": { - "Appwrite\\": "src/Appwrite" + "Appwrite\\": "src/Appwrite", + "Executor\\": "src/Executor" } }, "autoload-dev": { diff --git a/src/Executor/Executor.php b/src/Executor/Executor.php new file mode 100644 index 000000000..91a34a11f --- /dev/null +++ b/src/Executor/Executor.php @@ -0,0 +1,278 @@ + '', + ]; + + public function __construct(string $endpoint = 'http://appwrite-executor/v1') + { + $this->endpoint = $endpoint; + } + + public function createRuntime( + string $functionId, + string $deploymentId, + string $buildId, + string $projectId, + string $path, + array $vars, + string $runtime, + string $baseImage) + { + $route = "/functions/$functionId/deployments/$deploymentId/builds/$buildId"; + $headers = [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + 'x-appwrite-executor-key' => App::getEnv('_APP_EXECUTOR_SECRET', '') + ]; + $params = [ + 'path' => $path, + 'vars' => $vars, + 'runtime' => $runtime, + 'baseImage' => $baseImage + ]; + + $response = $this->call(self::METHOD_POST, $route, $headers, $params, true, 30); + + $status = $response['headers']['status-code']; + if ($status >= 400) { + throw new \Exception('Error creating build: ', $status); + } + + return $response['body']; + } + + public function deleteRuntime(string $deploymentId, string $projectId) + { + $route = "/deployments/$deploymentId"; + $headers = [ + 'content-Type' => 'application/json', + 'x-appwrite-project' => $projectId, + 'x-appwrite-executor-key' => App::getEnv('_APP_EXECUTOR_SECRET', '') + ]; + $params = [ + 'buildIds' => $buildIds[$deploymentId] ?? [], + ]; + + $response = $this->call(self::METHOD_DELETE, $route, $headers, $params, true, 30); + + $status = $response['headers']['status-code']; + if ($status >= 400) { + throw new \Exception('Error deleting deplyoment: ' . $deploymentId , $status); + } + + return $response['body']; + } + + public function createExecution( + string $projectId, + string $functionId, + string $deploymentId, + string $buildId, + string $path, + array $vars, + string $data, + string $runtime, + string $baseImage, + $timeout, + $webhooks, + string $userId + ) + { + $route = "/functions/$functionId/executions"; + $headers = [ + 'content-Type' => 'application/json', + 'x-appwrite-project' => $projectId, + 'x-appwrite-executor-key' => App::getEnv('_APP_EXECUTOR_SECRET', '') + ]; + $params = [ + 'deploymentId' => $deploymentId, + 'buildId' => $buildId, + 'path' => $path, + 'vars' => $vars, + 'data' => $data, + 'runtime' => $runtime, + 'timeout' => $timeout, + 'baseImage' => $baseImage, + 'webhooks' => $webhooks, + 'userId' => $userId, + ]; + + $response = $this->call(self::METHOD_POST, $route, $headers, $params, true, 30); + + $status = $response['headers']['status-code']; + if ($status >= 400) { + throw new \Exception('Error creating execution: ', $status); + } + + return $response['body']; + } + + /** + * Call + * + * Make an API call + * + * @param string $method + * @param string $path + * @param array $params + * @param array $headers + * @param bool $decode + * @return array|string + * @throws Exception + */ + public function call(string $method, string $path = '', array $headers = [], array $params = [], bool $decode = true, int $timeout = 15) + { + $headers = array_merge($this->headers, $headers); + $ch = curl_init($this->endpoint . $path . (($method == self::METHOD_GET && !empty($params)) ? '?' . http_build_query($params) : '')); + $responseHeaders = []; + $responseStatus = -1; + $responseType = ''; + $responseBody = ''; + + switch ($headers['content-type']) { + case 'application/json': + $query = json_encode($params); + break; + + case 'multipart/form-data': + $query = $this->flatten($params); + break; + + default: + $query = http_build_query($params); + break; + } + + foreach ($headers as $i => $header) { + $headers[] = $i . ':' . $header; + unset($headers[$i]); + } + + curl_setopt($ch, CURLOPT_CUSTOMREQUEST, $method); + curl_setopt($ch, CURLOPT_RETURNTRANSFER, 1); + curl_setopt($ch, CURLOPT_FOLLOWLOCATION, true); + curl_setopt($ch, CURLOPT_USERAGENT, 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/70.0.3538.77 Safari/537.36'); + curl_setopt($ch, CURLOPT_HTTPHEADER, $headers); + curl_setopt($ch, CURLOPT_CONNECTTIMEOUT, 0); + curl_setopt($ch, CURLOPT_TIMEOUT, $timeout); + curl_setopt($ch, CURLOPT_HEADERFUNCTION, function ($curl, $header) use (&$responseHeaders) { + $len = strlen($header); + $header = explode(':', $header, 2); + + if (count($header) < 2) { // ignore invalid headers + return $len; + } + + $responseHeaders[strtolower(trim($header[0]))] = trim($header[1]); + + return $len; + }); + + if ($method != self::METHOD_GET) { + curl_setopt($ch, CURLOPT_POSTFIELDS, $query); + } + + // Allow self signed certificates + if ($this->selfSigned) { + curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, false); + curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false); + } + + $responseBody = curl_exec($ch); + $responseType = $responseHeaders['content-type'] ?? ''; + $responseStatus = curl_getinfo($ch, CURLINFO_HTTP_CODE); + + if($decode) { + switch (substr($responseType, 0, strpos($responseType, ';'))) { + case 'application/json': + $json = json_decode($responseBody, true); + + if ($json === null) { + throw new Exception('Failed to parse response: '.$responseBody); + } + + $responseBody = $json; + $json = null; + break; + } + } + + if ((curl_errno($ch)/* || 200 != $responseStatus*/)) { + throw new Exception(curl_error($ch) . ' with status code ' . $responseStatus, $responseStatus); + } + + curl_close($ch); + + $responseHeaders['status-code'] = $responseStatus; + + if ($responseStatus === 500) { + echo 'Server error('.$method.': '.$path.'. Params: '.json_encode($params).'): '.json_encode($responseBody)."\n"; + } + + return [ + 'headers' => $responseHeaders, + 'body' => $responseBody + ]; + } + + /** + * Parse Cookie String + * + * @param string $cookie + * @return array + */ + public function parseCookie(string $cookie): array + { + $cookies = []; + + parse_str(strtr($cookie, array('&' => '%26', '+' => '%2B', ';' => '&')), $cookies); + + return $cookies; + } + + /** + * Flatten params array to PHP multiple format + * + * @param array $data + * @param string $prefix + * @return array + */ + protected function flatten(array $data, string $prefix = ''): array + { + $output = []; + + foreach ($data as $key => $value) { + $finalKey = $prefix ? "{$prefix}[{$key}]" : $key; + + if (is_array($value)) { + $output += $this->flatten($value, $finalKey); // @todo: handle name collision here if needed + } else { + $output[$finalKey] = $value; + } + } + + return $output; + } +}