src/Client.php
<?php
/**
* @package gooddata-php-client
* @copyright Keboola
* @author Jakub Matejka <jakub@keboola.com>
*/
namespace Keboola\GoodData;
use Guzzle\Common\Exception\RuntimeException;
use GuzzleHttp\Exception\RequestException;
use GuzzleHttp\Exception\ServerException;
use GuzzleHttp\HandlerStack;
use GuzzleHttp\MessageFormatter;
use GuzzleHttp\Middleware;
use GuzzleHttp\Psr7\Response;
use Psr\Http\Message\RequestInterface;
use Psr\Http\Message\ResponseInterface;
use Psr\Log\LoggerInterface;
class Client
{
/**
* Number of retries for one API call
*/
const RETRIES_COUNT = 5;
/**
* Back off time before retrying API call
*/
const BACKOFF_INTERVAL = 1;
/**
* Back off time before polling of async tasks
*/
const WAIT_INTERVAL = 10;
const API_URL = 'https://secure.gooddata.com';
const DEFAULT_CLIENT_SETTINGS = [
'timeout' => 600,
'headers' => [
'accept' => 'application/json',
'content-type' => 'application/json; charset=utf-8'
]
];
/** @var \GuzzleHttp\Client */
protected $guzzle;
protected $guzzleOptions;
/** @var LoggerInterface */
protected $logger;
/** @var null MessageFormatter */
protected $loggerFormatter;
/** @var array */
protected $logData = [];
protected $username;
protected $password;
/** @var Datasets */
protected $datasets;
/** @var DateDimensions */
protected $dateDimensions;
/** @var Filters */
protected $filters;
/** @var ProjectModel */
protected $projectModel;
/** @var Projects */
protected $projects;
/** @var Reports */
protected $reports;
/** @var Users */
protected $users;
public function __construct($url = null, $logger = null, $loggerFormatter = null, array $options = [])
{
$options = array_replace_recursive(self::DEFAULT_CLIENT_SETTINGS, $options);
$options['base_uri'] = $url ?: self::API_URL;
$this->guzzleOptions = $options;
if ($logger) {
$this->logger = $logger;
}
$this->loggerFormatter = $loggerFormatter ?: new MessageFormatter("{hostname} {req_header_User-Agent} - [{ts}] "
. "\"{method} {resource} {protocol}/{version}\" {code} {res_header_Content-Length}");
$this->initClient();
}
protected function initClient()
{
$handlerStack = HandlerStack::create();
/** @noinspection PhpUnusedParameterInspection */
$handlerStack->push(Middleware::retry(
function ($retries, RequestInterface $request, ResponseInterface $response = null, $error = null) {
return $response && $response->getStatusCode() == 503;
},
function ($retries) {
return rand(60, 600) * 1000;
}
));
/** @noinspection PhpUnusedParameterInspection */
$handlerStack->push(Middleware::retry(
function ($retries, RequestInterface $request, ResponseInterface $response = null, $error = null) {
if ($retries >= self::RETRIES_COUNT) {
return false;
} elseif ($response && $response->getStatusCode() > 499) {
return true;
} elseif ($error) {
return true;
} else {
return false;
}
},
function ($retries) {
return (int) pow(2, $retries - 1) * 1000;
}
));
$handlerStack->push(Middleware::cookies());
$this->guzzle = new \GuzzleHttp\Client(array_merge([
'handler' => $handlerStack,
'cookies' => true
], $this->guzzleOptions));
}
public function getBaseUri()
{
return $this->guzzleOptions['base_uri'];
}
public function getUsername()
{
return $this->username;
}
public function getPassword()
{
return $this->password;
}
public function setLogData($data)
{
$this->logData = $data;
}
public function setLogger(LoggerInterface $logger)
{
$this->logger = $logger;
}
public function setApiUrl($url, $verify = true)
{
if (!$verify) {
$this->guzzleOptions['verify'] = false;
}
$this->guzzleOptions['base_uri'] = $url;
$this->initClient();
}
public function setUserAgent($product, $version)
{
$this->guzzleOptions['headers']['User-Agent'] = "$product/$version";
}
public function setDebug($debug)
{
$this->guzzleOptions['debug'] = $debug;
}
public function disableCheckDomain()
{
$this->guzzleOptions['headers']['X-GDC-CHECK-DOMAIN'] = 'false';
}
public function getProjects()
{
if (!$this->projects) {
$this->projects = new Projects($this);
}
return $this->projects;
}
public function getProjectModel()
{
if (!$this->projectModel) {
$this->projectModel = new ProjectModel($this);
}
return $this->projectModel;
}
public function getUsers()
{
if (!$this->users) {
$this->users = new Users($this);
}
return $this->users;
}
public function getDatasets()
{
if (!$this->datasets) {
$this->datasets = new Datasets($this);
}
return $this->datasets;
}
public function getFilters()
{
if (!$this->filters) {
$this->filters = new Filters($this);
}
return $this->filters;
}
public function getReports()
{
if (!$this->reports) {
$this->reports = new Reports($this);
}
return $this->reports;
}
public function getDateDimensions()
{
if (!$this->dateDimensions) {
$this->dateDimensions = new DateDimensions($this);
}
return $this->dateDimensions;
}
public function get($uri, $params = [])
{
return $this->jsonRequest('GET', $uri, $params);
}
public function post($uri, $params = [])
{
return $this->jsonRequest('POST', $uri, $params);
}
public function put($uri, $params = [])
{
return $this->jsonRequest('PUT', $uri, $params);
}
public function delete($uri, $params = [])
{
return $this->jsonRequest('DELETE', $uri, $params);
}
public function jsonRequest($method, $uri, $params = [])
{
$this->refreshToken();
$body = $this->request($method, $uri, $params)->getBody();
return json_decode($body, true);
}
public function pollTask($uri)
{
$repeat = true;
$i = 0;
do {
sleep(self::WAIT_INTERVAL * ($i + 1));
$result = $this->get($uri);
if (isset($result['taskState']['status'])) {
if (in_array($result['taskState']['status'], ['OK', 'ERROR', 'WARNING'])) {
$repeat = false;
}
} else {
throw Exception::unexpectedResponseError('Task polling failed', 'GET', $uri, $result);
}
$i++;
} while ($repeat);
if ($result['taskState']['status'] != 'OK') {
throw Exception::error($uri, $result);
}
}
public function pollMaqlTask($uri)
{
$try = 1;
do {
sleep(10 * $try);
$result = $this->get($uri);
if (!isset($result['wTaskStatus']['status'])) {
throw Exception::unexpectedResponseError('Task polling failed', 'GET', $uri, $result);
}
$try++;
} while (in_array($result['wTaskStatus']['status'], ['PREPARED', 'RUNNING']));
if ($result['wTaskStatus']['status'] == 'ERROR') {
throw Exception::error($uri, $result);
}
}
public function ping()
{
$errorCount = 0;
do {
try {
$guzzle = new \GuzzleHttp\Client($this->guzzleOptions);
$response = $guzzle->request('GET', '/gdc/ping');
return $response->getStatusCode() != 503;
} catch (ServerException $e) {
return false;
} catch (\Exception $e) {
if ($e instanceof RuntimeException && strpos($e->getMessage(), 'No headers') === 0) {
return false;
}
$errorCount++;
sleep(rand(1, 5));
}
} while ($errorCount <= 5);
return false;
}
public function getUserUploadUrlFromGdcResponse($data)
{
foreach ($data['about']['links'] as $r) {
if ($r['category'] == 'uploads') {
return ($r['link'][0] === '/') ? "{$this->guzzle->getConfig('base_uri')}{$r['link']}" : $r['link'];
}
}
return false;
}
public function getUserUploadUrl()
{
$data = $this->get('/gdc');
return $this->getUserUploadUrlFromGdcResponse($data);
}
public function createDateDimension($params)
{
foreach (['pid', 'name'] as $param) {
if (!isset($params[$param])) {
throw new \Exception("Parameter $param is required");
}
}
$customIdentifier = !empty($params['identifier']) ? $params['identifier'] : null;
$identifier = $customIdentifier ?: $this->getDateDimensions()->getDefaultIdentifier($params['name']);
$template = !empty($params['template']) ? $params['template'] : null;
try {
$this->getDateDimensions()->executeCreateMaql($params['pid'], $params['name'], $identifier, $template);
} catch (Exception $e) {
// Ignore exception if the dimension already exists in GoodData
if (strpos($e->getMessage(), 'already exist in metadata ') === false) {
throw $e;
}
}
if (!empty($params['includeTime'])) {
$td = new \Keboola\GoodData\TimeDimension($this);
if (!$td->exists($params['pid'], $params['name'], $identifier)) {
$td->executeCreateMaql($params['pid'], $params['name'], $identifier);
$td->loadData($params['pid'], $params['name'], sys_get_temp_dir());
}
}
}
/**
* @return ResponseInterface
*/
public function request($method, $uri, $params = [], $retries = 5)
{
$startTime = time();
$options = $this->guzzleOptions;
if ($params) {
if ($method == 'GET' || $method == 'DELETE') {
$options['query'] = $params;
} else {
$options['json'] = $params;
}
}
try {
$response = $this->guzzle->request($method, $uri, $options);
$this->log($uri, $method, $params, $response, time() - $startTime);
return $response;
} catch (\Exception $e) {
$response = $e instanceof RequestException && $e->hasResponse() ? $e->getResponse() : null;
$this->log($uri, $method, $params, $response, time() - $startTime);
if ($response) {
$responseJson = json_decode($response->getBody(), true);
if ($response->getStatusCode() == 401) {
if ($uri == '/gdc/account/login') {
throw Exception::error($uri, $responseJson, 401, $e);
}
if ($retries <= 0) {
throw $e;
}
$this->login($this->username, $this->password);
return $this->request($method, $uri, $params, $retries-1);
}
throw Exception::error($uri, $responseJson, $response->getStatusCode(), $e);
}
throw $e;
}
}
public function getToFile($uri, $filename, $retries = 20)
{
$this->refreshToken();
$startTime = time();
$options = array_replace_recursive($this->guzzleOptions, [
'timeout' => 0,
'sink' => $filename,
'headers' => [
'accept' => 'text/csv',
'accept-charset' => 'utf-8'
]
]);
try {
$response = $this->guzzle->get($uri, $options);
$this->log($uri, 'GET', ['filename' => $filename], new Response($response->getStatusCode()), time() - $startTime);
if ($response->getStatusCode() == 200) {
return $filename;
} elseif ($response->getStatusCode() == 202) {
if ($retries <= 0) {
throw new Exception("Downloading of report $uri timed out");
}
sleep(self::BACKOFF_INTERVAL * (21 - $retries));
return $this->getToFile($uri, $filename, $retries-1);
}
} catch (\Exception $e) {
$response = $e instanceof RequestException && $e->hasResponse() ? $e->getResponse() : null;
$this->log($uri, 'GET', ['file' => $filename], $response, time() - $startTime);
if ($response) {
$responseJson = json_decode($response->getBody(), true);
if ($response->getStatusCode() == 401) {
if ($uri == '/gdc/account/login') {
throw Exception::error($uri, $responseJson, 401, $e);
}
if ($retries <= 0) {
throw $e;
}
$this->login($this->username, $this->password);
return $this->getToFile($uri, $filename, $retries-1);
}
throw Exception::error($uri, $responseJson, $response->getStatusCode(), $e);
}
throw $e;
}
}
public function login($username, $password)
{
$this->username = $username;
$this->password = $password;
try {
$this->request('POST', '/gdc/account/login', [
'postUserLogin' => [
'login' => $this->username,
'password' => $this->password,
'remember' => 0
]
]);
} catch (Exception $e) {
throw Exception::loginError($e);
}
$this->refreshToken();
}
public function refreshToken()
{
try {
$this->request('GET', '/gdc/account/token');
} catch (Exception $e) {
throw Exception::loginError($e);
}
}
protected function log($uri, $method, $params, Response $response = null, $duration = 0)
{
if (!$this->logger) {
return;
}
$params = $this->cleanForLog(['password', 'authorizationToken'], $params);
$data = [
'request' => "$method {$this->guzzle->getConfig('base_uri')}$uri",
'params' => $params,
'response' => null
];
if ($response) {
try {
$body = \GuzzleHttp\json_decode($response->getBody(), true);
$body = $this->cleanForLog(['password', 'authorizationToken'], $body);
} catch (\InvalidArgumentException $e) {
$body = (string)$response->getBody();
}
$data['response'] = [
'status' => $response->getStatusCode(),
'body' => $body
];
}
if ($duration) {
$data['duration'] = $duration;
}
$this->logger->debug(json_encode(array_merge($this->logData, $data)));
}
private function cleanForLog($find, $array)
{
if (is_array($array)) {
foreach ($array as $key => $val) {
if (in_array($key, $find)) {
$array[$key] = '***';
} elseif (is_array($val)) {
return $this->cleanForLog($find, $val);
}
}
}
return $array;
}
}