xero-8.x-2.x-dev/src/XeroQuery.php
src/XeroQuery.php
<?php
namespace Drupal\xero;
use Drupal\Component\Plugin\Exception\PluginNotFoundException;
use Drupal\Component\Uuid\Uuid;
use Drupal\Core\Cache\CacheBackendInterface;
use Drupal\Core\Logger\LoggerChannelFactoryInterface;
use Drupal\Core\TypedData\ListInterface;
use Drupal\Core\TypedData\TypedDataManagerInterface;
use GuzzleHttp\Exception\RequestException;
use Radcliffe\Xero\XeroClientInterface;
use Symfony\Component\Serializer\Serializer;
/**
* Provides a query builder service for HTTP requests to Xero.
*
* This matches functionality provided by Drupal 7 xero module and the old
* PHP-Xero library.
*/
class XeroQuery /*implements XeroQueryInterface */ {
/**
* Available Xero query string operators.
*
* @var string[]
*/
protected static $operators = [
'==',
'!=',
'StartsWith',
'EndsWith',
'Contains',
'ToLower().Contains',
'guid',
'NULL',
'NOT NULL',
];
/**
* The options to pass into guzzle.
*
* @var array<string, mixed>
*/
protected $options;
/**
* The conditions for the where query parameter.
*
* @var array
*/
protected $conditions;
/**
* The output format. Either json or pdf.
*
* @var string
*/
protected $format = 'json';
/**
* The xero method to use.
*
* @var string
*/
protected $method = 'get';
/**
* The xero UUID to use for a quick filter in get queries.
*
* @var string
*/
protected $uuid;
/**
* The xero type plugin id.
*
* @var string|null
*/
protected $type = NULL;
/**
* The xero data type, type definition.
*
* @var \Drupal\xero\TypedData\Definition\XeroDefinitionInterface
*/
protected $type_definition;
/**
* The xero data object.
*
* @var \Drupal\Core\TypedData\ListInterface|null
*/
protected $data = NULL;
/**
* The xero client.
*
* @var \Radcliffe\Xero\XeroClientInterface|\stdClass|null
*/
protected $client;
/**
* The serializer.
*
* @var \Symfony\Component\Serializer\Serializer
*/
protected $serializer;
/**
* The typed data manager.
*
* @var \Drupal\Core\TypedData\TypedDataManagerInterface
*/
protected $typed_data;
/**
* A logger channel for this module.
*
* @var \Drupal\Core\Logger\LoggerChannelInterface
*/
protected $logger;
/**
* The cache backend interface for 'xero_query' bin.
*
* @var \Drupal\Core\Cache\CacheBackendInterface
*/
protected $cache;
/**
* Whether to throw errors generated when the query is executed.
*
* @var bool
*/
protected $throwErrors = FALSE;
/**
* Construct a Xero Query object.
*
* @param \Radcliffe\Xero\XeroClientInterface|\stdClass|null $client
* The xero client object to make requests.
* @param \Symfony\Component\Serializer\Serializer $serializer
* The serialization service to handle normalization and denormalization.
* @param \Drupal\Core\TypedData\TypedDataManagerInterface $typed_data
* The Typed Data manager for retrieving definitions of xero types.
* @param \Drupal\Core\Logger\LoggerChannelFactoryInterface $logger_factory
* The logger factory service for error logging.
* @param \Drupal\Core\Cache\CacheBackendInterface $cache
* The cache backend for Xero Query cache.
*/
public function __construct(XeroClientInterface|\stdClass|null $client, Serializer $serializer, TypedDataManagerInterface $typed_data, LoggerChannelFactoryInterface $logger_factory, CacheBackendInterface $cache) {
$this->client = $client;
$this->serializer = $serializer;
$this->typed_data = $typed_data;
$this->logger = $logger_factory->get('xero');
$this->cache = $cache;
// Ensure the format header is always set, but can be modified.
$this->setFormat($this->format);
}
/**
* Get the xero type. Useful for unit tests.
*
* @return string
* The xero type set on this object.
*/
public function getType() {
return $this->type;
}
/**
* Set the xero type by plugin id.
*
* @param string $type
* The plugin id corresponding to a xero type i.e. xero_account.
*
* @return self
* The query object for chaining.
*
* @throws \Drupal\Component\Plugin\Exception\PluginNotFoundException
*/
public function setType($type) {
try {
$this->type_definition = $this->typed_data->getDefinition($type);
$this->type = $type;
}
catch (PluginNotFoundException $e) {
throw $e;
}
return $this;
}
/**
* Get the HTTP method to use. Useful for unit tests.
*
* @return string
* The HTTP Method: get or post.
*/
public function getMethod() {
return $this->method;
}
/**
* Set which http method to use for the query.
*
* This is "type" from xero_query().
*
* @param string $method
* The method to use, which is one of "get" or "post". The HTTP PUT method
* will be automatically used for updating records.
*
* @return self
* The query object for chaining.
*/
public function setMethod($method) {
if (!in_array($method, ['get', 'post', 'put'])) {
throw new \InvalidArgumentException('Invalid method given.');
}
$this->method = $method;
if ($this->method === 'post' || $this->method === 'put') {
$this->setFormat('json');
}
return $this;
}
/**
* Get the format to return. Useful for unit tests.
*
* @return string
* The format to return: json or pdf.
*/
public function getFormat() {
return $this->format;
}
/**
* Set the format to use for the query. This is "method" from xero_query().
*
* @todo support pdf format.
*
* @param string $format
* The format ot use, which is one of "json" or "pdf".
*
* @return self
* The query object for chaining.
*/
public function setFormat($format) {
if (!in_array($format, ['json', 'pdf'])) {
throw new \InvalidArgumentException('Invalid format given.');
}
$this->format = $format;
$this->addHeader('Accept', 'application/' . $this->format);
return $this;
}
/**
* Get the UUID that is set for the query. Useful for unit tests.
*
* @return string
* The Universally-Unique ID that is set on the object.
*/
public function getId() {
return $this->uuid;
}
/**
* Set the Xero UUID for the request. Useful only in get method.
*
* @param string $uuid
* The universally-unique ID.
*
* @return self
* The query object for chaining.
*/
public function setId($uuid) {
if (!Uuid::isValid($uuid)) {
throw new \InvalidArgumentException('UUID is not valid');
}
$this->uuid = $uuid;
return $this;
}
/**
* Set the modified-after filter.
*
* @param int $timestamp
* A UNIX timestamp to use. Should be UTC.
*
* @return self
* The query object for chaining.
*/
public function setModifiedAfter($timestamp) {
$this->addHeader('If-Modified-Since', $timestamp);
return $this;
}
/**
* Get the data object that was set.
*
* @return \Drupal\Core\TypedData\ListInterface
* A xero data type or NULL.
*/
public function getData() {
return $this->data;
}
/**
* Set data to null to reset query.
*/
public function clearData() {
$this->data = NULL;
}
/**
* Set the data object to send in the request.
*
* @param \Drupal\Core\TypedData\ListInterface $data
* The xero data object wrapped in a list interface.
*
* @return self
* The query object for chaining.
*/
public function setData(ListInterface $data) {
if (isset($this->type) && $this->type <> $data->getItemDefinition()->getDataType()) {
throw new \InvalidArgumentException('The xero data type set for this query does not match the data.');
}
elseif (!isset($this->type)) {
$this->type = $data->getItemDefinition()->getDataType();
}
$this->data = $data;
return $this;
}
/**
* Get the xero query options. Useful for unit tests.
*
* @return array<string, mixed>
* An associative array of options to pass to Guzzle.
*/
public function getOptions() {
return $this->options;
}
/**
* Get the type data definition property.
*
* @return \Drupal\Core\TypedData\DataDefinitionInterface
* The data definition class or NULL if not set.
*/
public function getDefinition() {
return $this->type_definition;
}
/**
* Add a condition to the query.
*
* @param string $field
* The Xero API property that can be used in a condition.
* @param mixed $value
* The property value.
* @param string $op
* The operation to use, which is one of the following operators.
* - ==: Equal to the value
* - !=: Not equal to the value
* - StartsWith: Starts with the value
* - EndsWith: Ends with the value
* - Contains: Contains the value
* - guid: Equality for guid values. See Xero API.
* - NOT NULL: Not empty.
*
* @return self
* The query object for chaining.
*/
public function addCondition($field, $value = '', $op = '==') {
if (!in_array($op, self::$operators)) {
throw new \InvalidArgumentException('Invalid operator');
}
// Change boolean into a string value of the same name.
if (is_bool($value)) {
$value = $value ? 'true' : 'false';
}
// Construction condition statement based on operator.
if (in_array($op, ['==', '!='])) {
$this->conditions[] = $field . $op . '"' . $value . '"';
}
elseif ($op == 'guid') {
$this->conditions[] = $field . '= Guid("' . $value . '")';
}
elseif ($op == 'NULL') {
$this->conditions[] = $field . '==null';
}
elseif ($op == 'NOT NULL') {
$this->conditions[] = $field . '!=null';
}
elseif ($op == 'ToLower().Contains') {
$this->conditions[] = $field . '.' . $op . '("' . strtolower($value) . '")';
}
else {
$this->conditions[] = $field . '.' . $op . '("' . $value . '")';
}
return $this;
}
/**
* Add a logical operator AND or OR to the conditions array.
*
* @param string $op
* Operator AND or OR.
*
* @return self
* The query object for chaining.
*/
public function addOperator($op = 'AND') {
if (!in_array($op, ['AND', 'OR'])) {
throw new \InvalidArgumentException('Invalid logical operator.');
}
$this->conditions[] = $op;
return $this;
}
/**
* Add an order by to the query.
*
* @param string $field
* The full field name to use. See Xero API.
* @param string $dir
* The sort direction. either ASC or DESC.
*
* @return self
* The query object for chaining.
*/
public function orderBy($field, $dir = 'ASC') {
if ($dir == 'DESC') {
$field .= ' ' . $dir;
}
$this->addQuery('order', $field);
return $this;
}
/**
* Add a query parameter.
*
* This will overwrite any other value set for the key.
*
* @param string $key
* The query parameter key.
* @param string $value
* The query parameter value.
*/
protected function addQuery($key, $value): void {
if (!isset($this->options['query'])) {
$this->options['query'] = [];
}
$this->options['query'][$key] = $value;
}
/**
* Set a header option. This will overwrite any other value set.
*
* @param string $name
* The header option name.
* @param mixed $value
* The header option value.
*/
public function addHeader($name, $value) {
if (!isset($this->options['headers'])) {
$this->options['headers'] = [];
}
$this->options['headers'][$name] = $value;
}
/**
* Explode the conditions into a query parameter.
*
* @todo Support query OR groups.
*/
protected function explodeConditions() {
if (!empty($this->conditions)) {
$value = implode(' ', $this->conditions);
$this->addQuery('where', $value);
}
}
/**
* Get the conditions array. Useful for unit tests.
*
* @return string[]
* An array of conditions.
*/
public function getConditions() {
return $this->conditions;
}
/**
* Set the conditions array. Useful for unit tests.
*
* @param string[] $conditions
* The conditions to set.
*/
public function setConditions($conditions) {
$this->conditions = $conditions;
}
/**
* Validates the query before execution.
*
* Ensures that query parameters make sense for the method being called, for
* example.
*
* @return bool
* TRUE if the query should be validated.
*
* @throws \InvalidArgumentException
*/
public function validate() {
if ($this->type === NULL) {
throw new \InvalidArgumentException('The query must have a type set.');
}
if (in_array($this->method, ['post', 'put']) && $this->format <> 'json') {
throw new \InvalidArgumentException('The format must be JSON for creating or updating data.');
}
if ($this->method == 'get' && $this->data !== NULL) {
throw new \InvalidArgumentException('Invalid use of data object for fetching data.');
}
if ($this->format == 'pdf' &&
!in_array($this->type, ['xero_invoice', 'xero_credit_note'])) {
throw new \InvalidArgumentException('PDF format may only be used for invoices or credit notes.');
}
return TRUE;
}
/**
* Execute the Xero query.
*
* @return bool|\Drupal\Core\TypedData\Plugin\DataType\ItemList
* The TypedData object in the response.
*
* @throws \Throwable
*/
public function execute() {
try {
$this->validate();
// @todo Add summarizeErrors for post if posting multiple objects.
$this->explodeConditions();
$data_class = $this->type_definition['class'];
$endpoint = $this->uuid ? $data_class::$plural_name . '/' . (string) $this->uuid : $data_class::$plural_name;
$context = [
'plugin_id' => $this->type,
];
if ($this->data !== NULL) {
$this->options['body'] = $this->serializer->serialize($this->data, $this->format, $context);
}
/** @var \Psr\Http\Message\ResponseInterface $response */
$response = $this->client->request($this->method, $endpoint, $this->options);
/** @var \Drupal\xero\Plugin\DataType\XeroItemList $data */
$data = $this->serializer->deserialize($response->getBody()->getContents(), $data_class, $this->format, $context);
return $data;
}
catch (\Throwable $e) {
if ($e instanceof RequestException) {
$this->logRequestError($e);
}
else {
$this->logger->error('%message', ['%message' => $e->getMessage()]);
}
if ($this->throwErrors) {
throw $e;
}
return FALSE;
}
}
/**
* Fetch a given data type from cache, if it exists, or fetch it from Xero.
*
* @param string $type
* The Xero data type plugin id.
*
* @return \Drupal\xero\Plugin\DataType\XeroItemList|bool
* The cached data normalized into a list data type.
*
* @throws \Drupal\Component\Plugin\Exception\PluginNotFoundException
* @throws \Throwable
*
* @todo Support filters.
*/
public function getCache($type) {
$cid = $type;
// Get the cached data.
if ($cached = $this->cache->get($cid)) {
return $cached->data;
}
$this->setType($type);
$data = $this->execute();
if ($data) {
$this->setCache($cid, $data);
}
return $data;
}
/**
* Store the typed data into cache based on the plugin id.
*
* @param string $cid
* The cache identifier to store the data as.
* @param \Drupal\Core\TypedData\ListInterface $data
* The typed data to sets in cache.
*/
protected function setCache($cid, ListInterface $data) {
$tags = $this->getCacheTags($data);
$this->cache->set($cid, $data, CacheBackendInterface::CACHE_PERMANENT, $tags);
// Invalidate the cache right away because there really is not a good time
// to do this for 3rd party data. This will keep it in cache until the next
// garbage collection period.
$this->cache->invalidate($cid);
}
/**
* Get the cache tag for the query.
*
* @param \Drupal\Core\TypedData\ListInterface $data
* The item list to extract type information from.
*
* @return string[]
* Return the cache tags to use for the cache.
*/
protected function getCacheTags(ListInterface $data) {
/** @var \Drupal\xero\TypedData\Definition\XeroDefinitionInterface $definition */
$definition = $data->getItemDefinition();
/** @var \Drupal\xero\TypedData\XeroItemInterface $type_class */
$type_class = $definition->getClass();
return [$type_class::$plural_name];
}
/**
* Confirm that the Xero Query object can make queries.
*
* @return bool
* TRUE if the Xero Client is ready to go.
*
* @deprecated in xero:3.1.0-alpha1 and is removed from xero:4.0.0. The client
* will always be available even if it is not properly configured.
*/
public function hasClient() {
if ($this->client instanceof XeroClientInterface &&
!is_a($this->client, '\Drupal\xero\XeroNullClient')) {
return TRUE;
}
return FALSE;
}
/**
* Whether to throw errors generated when the query is executed.
*
* @param bool $shouldThrow
* Whether errors should be thrown.
*
* @return $this
* The query object for chaining.
*/
public function throwErrors($shouldThrow = TRUE) {
$this->throwErrors = $shouldThrow;
return $this;
}
/**
* Gets the connections allowed by this client.
*
* This decodes the response into a simple indexed array rather than a typed
* data type.
*
* @return array<string, mixed>[]
* An indexed array of connections with the following keys: clientId,
* tenantId, tenantType, createdDateUtc, updatedDateUtc.
*/
public function getConnections() {
try {
return $this->client->getTenantIds();
}
catch (RequestException $e) {
$this->logRequestError($e);
}
return [];
}
/**
* Log a failed request to assist in troubleshooting.
*
* @param \GuzzleHttp\Exception\RequestException $e
* The caught exception.
*/
protected function logRequestError(RequestException $e) {
// Truncate the authorization token to make the log easier to read.
$requestHeaders = $e->getRequest()->getHeaders();
if (isset($requestHeaders['Authorization']) &&
is_array($requestHeaders['Authorization']) &&
count($requestHeaders['Authorization']) === 1) {
$requestHeaders['Authorization'] = [
substr($requestHeaders['Authorization'][0], 0, 15) . '(truncated...)',
];
}
$this->logger->error("%message \nUri:%uri \nRequest headers:@request_headers \nRequest Body:@request_body \nResponse body:@response", [
'%message' => $e->getMessage(),
'%uri' => $e->getRequest()->getUri(),
'@request_headers' => print_r($requestHeaders, TRUE),
'@request_body' => $e->getRequest()->getBody()->getContents(),
'@response' => $e->getResponse()?->getBody()->getContents(),
'exception' => $e,
]);
}
}
