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,
    ]);
  }

}

Главная | Обратная связь

drupal hosting | друпал хостинг | it patrol .inc