src/Query.php
<?php
namespace chsergey\rest;
use GuzzleHttp\Client;
use GuzzleHttp\Exception\ClientException;
use GuzzleHttp\Exception\ConnectException;
use GuzzleHttp\Exception\RequestException;
use Psr\Http\Message\ResponseInterface;
use yii\base\Component;
use yii\base\InvalidCallException;
use yii\base\InvalidParamException;
use yii\web\HttpException;
use yii\web\ServerErrorHttpException;
/**
* Class Query
* HTTP transport by GuzzleHTTP
*
* @package chsergey\rest
*/
class Query extends Component implements QueryInterface {
/**
* Data type for requests and responses
* Required.
* @var string
*/
public $dataType = self::JSON_TYPE;
/**
* Headers for requests
* @var array
*/
public $requestHeaders = [];
/**
* Wildcard for response headers object
* @see Query::count()
* @var array
*/
public $responseHeaders = [
'totalCount' => 'X-Pagination-Total-Count',
'pageCount' => 'X-Pagination-Page-Count',
'currPage' => 'X-Pagination-Current-Page',
'perPageCount' => 'X-Pagination-Per-Page',
'links' => 'Link',
];
/**
* Response unserializer class
* @var array|object
*/
public $unserializers = [
self::JSON_TYPE => [
'class' => 'chsergey\rest\JsonUnserializer'
]
];
/**
* HTTP client that performs HTTP requests
* @var object
*/
public $httpClient;
/**
* Configuration to be supplied to the HTTP client
* @var array
*/
public $httpClientExtraConfig = [];
/**
* Model class
* @var Model
*/
public $modelClass;
/**
* Get param name for select fields
* @var string
*/
public $selectFieldsKey = 'fields';
/**
* Request LIMIT param name
* @see chsergey\rest\Model::$limitKey
* @var string
*/
public $limitKey;
/**
* Request OFFSET param name
* @see chsergey\rest\Model::$offsetKey
* @var string
*/
public $offsetKey;
/**
* Model class envelope
* @see chsergey\rest\Model::$collectionEnvelope
* @var string
*/
protected $_collectionEnvelope;
/**
* Model class pagination envelope
* @see chsergey\rest\Model::$paginationEnvelope
* @var string
*/
protected $_paginationEnvelope;
/**
* Model class pagination envelope keys mapping
* @see chsergey\rest\Model::$paginationEnvelopeKeys
* @var array
*/
private $_paginationEnvelopeKeys;
/**
* Pagination data from pagination envelope in GET request
* @var array
*/
private $_pagination;
/**
* Array of fields to select from REST
* @var array
*/
private $_select = [];
/**
* Conditions
* @var array
*/
private $_where;
/**
* Query limit
* @var int
*/
private $_limit;
/**
* Query offset
* @var int
*/
private $_offset;
/**
* Flag Is this query is sub-query
* to prevent recursive requests
* for get enveloped pagination
* @see Query::count()
* @var bool
*/
private $_subQuery = false;
/**
* Constructor. Really.
* @param Model $modelClass
* @param array $config
*/
public function __construct($modelClass, $config = []) {
$modelClass::staticInit();
$this->modelClass = $modelClass;
$this->_collectionEnvelope = $modelClass::$collectionEnvelope;
$this->_paginationEnvelope = $modelClass::$paginationEnvelope;
$this->_paginationEnvelopeKeys = $modelClass::$paginationEnvelopeKeys;
$this->offsetKey = $modelClass::$offsetKey;
$this->limitKey = $modelClass::$limitKey;
$httpClientConfig = array_merge(
[
/* @link http://docs.guzzlephp.org/en/latest/quickstart.html */
'base_uri' => $this->_getUrl('api'),
/* @link http://docs.guzzlephp.org/en/latest/request-options.html#headers */
'headers' => $this->_getRequestHeaders(),
],
$this->httpClientExtraConfig
);
$this->httpClient = new Client($httpClientConfig);
parent::__construct($config);
}
/**
* GET resource collection request
* @inheritdoc
*/
public function all() {
return $this->_populate(
$this->_request('get',
$this->_getUrl('collection'),
[
'query' => $this->_buildQueryParams()
]
)
);
}
/**
* Get collection count
* If $this->_pagination isset (from get request before call this method) return count from it
* else execute HEAD request to collection and get count from X-Pagination-Total-Count(default) response header
* If header is empty and isset pagination envelope - do get collection request with limit 1 to get pagination data
* @see Query::$_subQuery
* @inheritdoc
*/
public function count() {
if($this->_pagination) {
return isset($this->_pagination['totalCount']) ? (int) $this->_pagination['totalCount'] : 0;
}
if($this->_subQuery) {
return 0;
}
// try to get count by HEAD request
$count = $this->_request('head', $this->_getUrl('collection'), ['query' => $this->_buildQueryParams()])
->getHeaderLine($this->responseHeaders['totalCount']);
// REST server not allow HEAD query and X-Total header is empty
if($count === '' && $this->_paginationEnvelope) {
$query = clone $this;
$query->_setSubQueryFlag()->offset(0)->limit(1)->all();
return $query->count();
}
return (int) $count;
}
/**
* GET resource element request
* @inheritdoc
*/
public function one($id) {
if($this->_where) {
throw new InvalidCallException(__METHOD__.'() can not be called with "where" clause');
}
$model = $this->_populate(
$this->_request('get',
$this->_getUrl('element', $id),
[
'query' => $this->_buildQueryParams()
]
),
false
);
return $model;
}
/**
* POST request
* @inheritdoc
*/
public function create(Model $model) {
return $this->_populate(
$this->_request('post', $this->_getUrl('collection'), [
'json' => $model->getAttributes()
]),
false
);
}
/**
* PUT request
* // TODO non-json (i.e. form-data) payload
* @inheritdoc
*/
public function update(Model $model) {
return $this->_populate(
$this->_request('put', $this->_getUrl('element', $model->getPrimaryKey()), [
'json' => $model->getAttributes()
]),
false
);
}
/**
* @inheritdoc
*/
public function select(array $fields) {
$this->_select = $fields;
return $this;
}
/**
* @inheritdoc
*/
public function where(array $conditions) {
$this->_where = $conditions;
return $this;
}
/**
* @inheritdoc
*/
public function limit($limit) {
$this->_limit = (int) $limit;
return $this;
}
/**
* @inheritdoc
*/
public function offset($offset) {
$this->_offset = (int) $offset;
return $this;
}
/**
* HTTP request
* @param string $method
* @param string $url
* @param array $options
* @return ResponseInterface
* @throws ServerErrorHttpException
*/
private function _request($method, $url, array $options) {
try {
$response = $this->httpClient->{$method}($url, $options);
} catch(ClientException $e) {
$response = $e->getResponse();
} catch(ConnectException $e) {
$this->_throwServerError($e);
} catch (RequestException $e) {
$this->_throwServerError($e);
}
return $response;
}
/**
* Throw 500 error exception
* @param \Exception $e
* @throws ServerErrorHttpException
*/
private function _throwServerError(\Exception $e) {
$uri = (string) $this->httpClient->getConfig('base_uri');
throw new ServerErrorHttpException(get_class($e).': url='.$uri .' '. $e->getMessage(), 500);
}
/**
* Unserialize and create models
* @param ResponseInterface $response
* @param bool $asCollection
* @return $this|Model|array|void
* @throws HttpException
*/
protected function _populate(ResponseInterface $response, $asCollection = true) {
$models = [];
$statusCode = $response->getStatusCode();
$data = $this->_unserializeResponseBody($response);
// errors
if($statusCode >= 400) {
throw new HttpException(
$statusCode,
is_string($data) ? $data : $data->message,
$statusCode
);
}
// array of objects or arrays - probably resource collection
if(is_array($data)) {
$models = $this->_createModels($data);
}
// collection with data envelope or single element
if(is_object($data)) {
if($asCollection) {
if($this->_collectionEnvelope) {
$elements = isset($data->{$this->_collectionEnvelope})
? $data->{$this->_collectionEnvelope}
: [];
} else {
$elements = [];
}
if($this->_paginationEnvelope && isset($data->{$this->_paginationEnvelope})) {
$this->_setPagination(
$this->_getProps($data->{$this->_paginationEnvelope})
);
}
$models = $this->_createModels($elements);
} else {
$models = $this->_createModels([$data])[0];
}
}
return $models;
}
/**
* Create models from array of elements
* @param array $elements
* @return array
*/
protected function _createModels(array $elements) {
$modelClass = $this->modelClass;
$models = [];
foreach ($elements as $element) {
$model = $modelClass::instantiate()->setAttributes($this->_getProps($element));
$models[] = $model->setId(
$model->getAttribute($modelClass::primaryKey()[0])
);
}
return $models;
}
/**
* Try to unserialize response body data
* @param ResponseInterface $response
* @return object[]|object|string
* @throws \yii\base\InvalidConfigException
*/
protected function _unserializeResponseBody(ResponseInterface $response) {
$body = (string) $response->getBody();
$contentType = $response->getHeaderLine('Content-type');
try {
if(false !== stripos($contentType, $this->dataType)
&& isset($this->unserializers[$this->dataType])) {
/** @var UnserializerInterface $unserializer */
$unserializer = \Yii::createObject($this->unserializers[$this->dataType]);
if($unserializer instanceof UnserializerInterface) {
return $unserializer->unserialize($body, false);
}
}
return $body;
} catch(InvalidParamException $e) {
return $body;
}
}
/**
* Pagination data setter
* If pagination data isset in GET request result
* @param array $pagination
* @return $this
*/
private function _setPagination(array $pagination) {
foreach ($this->_paginationEnvelopeKeys as $key => $name) {
$this->_pagination[$key] = isset($pagination[$name])
? $pagination[$name]
: null;
}
return $this;
}
/**
* Get array of properties from object
* @param $object
* @return array
*/
private function _getProps($object) {
return is_object($object) ? get_object_vars($object) : $object;
}
/**
* Build query params
* @return array
*/
private function _buildQueryParams() {
$query = [];
$this->_where = is_array($this->_where) ? $this->_where : [];
foreach ($this->_where as $key => $val) {
$query[$key] = is_numeric($val) ? (int) $val : $val;
}
if(count($this->_select)) {
$query[$this->selectFieldsKey] = implode(',', $this->_select);
}
if($this->_limit !== null) {
$query[$this->limitKey] = $this->_limit;
}
if($this->_offset !== null) {
$query[$this->offsetKey] = $this->_offset;
}
return $query;
}
/**
* Get headers for request
* @return array
*/
private function _getRequestHeaders() {
return $this->requestHeaders ?: ['Accept' => $this->dataType];
}
/**
* Get url to collection or element of resource
* with check base url trailing slash
* @param string $type api|collection|element
* @param string $id
* @return string
*/
private function _getUrl($type = 'base', $id = 'null') {
$modelClass = $this->modelClass;
$collection = $modelClass::getResourceName();
switch($type) {
case 'api':
return $this->_trailingSlash($modelClass::getApiUrl());
break;
case 'collection':
return $this->_trailingSlash($collection, false);
break;
case 'element':
return $this->_trailingSlash($collection) . $this->_trailingSlash($id, false);
break;
}
return '';
}
/**
* Check trailing slash
* if $add - add trailing slash
* if not $add - remove trailing slash
* @param $string
* @param bool $add
* @return string
*/
private function _trailingSlash($string, $add = true) {
return substr($string, -1) === '/'
? ($add ? $string : substr($string, 0, strlen($string) - 1))
: ($add ? $string . '/' : $string);
}
/**
* Mark query as subquery to prevent queries recursion
* @see count()
* @return Query
*/
private function _setSubQueryFlag() {
$this->_subQuery = true;
return $this;
}
}