diff --git a/.github/workflows/code-quality.yml b/.github/workflows/code-quality.yml index b511a86..b679f86 100644 --- a/.github/workflows/code-quality.yml +++ b/.github/workflows/code-quality.yml @@ -14,7 +14,7 @@ jobs: continue-on-error: ${{ matrix.experimental }} strategy: matrix: - php-versions: [8.0, 8.1, 8.2] + php-versions: [8.1, 8.2, 8.3] experimental: [false] steps: - uses: actions/checkout@v2 diff --git a/.gitignore b/.gitignore index ec6c85a..da80ea4 100644 --- a/.gitignore +++ b/.gitignore @@ -7,4 +7,6 @@ Thumbs.db composer.lock /coverage -example.php \ No newline at end of file +example.php +.phpunit.result.cache +/.phpunit.cache/ diff --git a/README.md b/README.md index 796d74b..d05ab4b 100644 --- a/README.md +++ b/README.md @@ -15,8 +15,7 @@ Ultimately it is up to the software that uses xeroclient to deal with [serializa ## Dependencies -* PHP 8.0 or greater -* (Deprecated) [guzzlehttp/oauth-subscriber](https://packagist.org/packages/guzzlehttp/oauth-subscriber) +* PHP 8.1 or greater * [league/oauth2-client](https://packagist.org/packages/league/oauth2-client) * [guzzlehttp/guzzle](https://packagist.org/packages/guzzlehttp/guzzle) @@ -38,7 +37,7 @@ $provider = new \Radcliffe\Xero\XeroProvider([ $url = $provider->getAuthorizationUrl(); ``` -### Create a guzzle client from an authorization code (see above) +### Create with a guzzle client from an authorization code (see above) ```php $client = \Radcliffe\Xero\XeroClient::createFromToken('my consumer key', 'my consumer secret', $code, 'authorization_code', 'accounting'); @@ -46,7 +45,7 @@ $client = \Radcliffe\Xero\XeroClient::createFromToken('my consumer key', 'my con $tokens = $client->getRefreshedToken(); ``` -### Create a guzzle client with an access token +### Create with a guzzle client with an access token ```php $client = \Radcliffe\Xero\XeroClient::createFromToken( @@ -61,7 +60,7 @@ $client = \Radcliffe\Xero\XeroClient::createFromToken( ); ``` -### Create a guzzle client with a refresh token +### Create with a guzzle client with a refresh token Access tokens expire after 30 minutes so you can create a new client with a stored refresh token too. @@ -88,7 +87,7 @@ try { 'query' => ['where' => 'Name.StartsWith("John")'], 'headers' => ['Accept' => 'application/json'], ]; - $response = $client->get('Accounts', $options); + $response = $client->request('GET', 'Accounts', $options); // Or use something like Symfony Serializer component. $accounts = json_decode($response->getBody()->getContents()); @@ -98,6 +97,12 @@ try { ``` +### Error handling + +If the configured client does not have a valid Xero API URL or if an auth_token is not provided, then XeroRequestException is thrown as part of the Guzzle request. + +Previously XeroClient would throw an exception on instantiation, but this is no longer the case. If the initialize method is used directly, XeroClient will probably fail for other reasons. + ### Use with a legacy OAuth1 application Please see the 0.2 branch and versions < 0.3.0. diff --git a/composer.json b/composer.json index db8e519..fac5fa3 100644 --- a/composer.json +++ b/composer.json @@ -2,7 +2,10 @@ "name": "mradcliffe/xeroclient", "description": "Provides a Guzzle client for use with the Xero Accounting and Payroll APIs.", "minimum-stability": "stable", - "license": "MIT", + "license": [ + "MIT", + "GPL-2.0-or-later" + ], "authors": [ { "name": "Matthew Radcliffe", @@ -21,10 +24,10 @@ } }, "require": { - "php": "^8", - "league/oauth2-client": "^2.4", - "guzzlehttp/oauth-subscriber": "0.6.0", - "ext-json": "*" + "php": "^8.1", + "league/oauth2-client": "^2", + "ext-json": "*", + "guzzlehttp/guzzle": "^7" }, "require-dev": { "ext-openssl": "*", diff --git a/src/Exception/InvalidOptionsException.php b/src/Exception/InvalidOptionsException.php index 7e47c23..2a8bea4 100644 --- a/src/Exception/InvalidOptionsException.php +++ b/src/Exception/InvalidOptionsException.php @@ -2,6 +2,13 @@ namespace Radcliffe\Xero\Exception; +/** + * An exception to throw when the client has invalid options. + * + * @deprecated in 0.5.0 and removed in 0.6.0. Use a guzzle request exception + * instead. + * @see \Radcliffe\Xero\Exception\XeroRequestException + */ class InvalidOptionsException extends \Exception { } diff --git a/src/Exception/XeroRequestException.php b/src/Exception/XeroRequestException.php new file mode 100644 index 0000000..a7bb0a3 --- /dev/null +++ b/src/Exception/XeroRequestException.php @@ -0,0 +1,9 @@ + $config - * The guzzle options. - * - * @throws \Radcliffe\Xero\Exception\InvalidOptionsException - * @see \GuzzleHttp\Client::__construct(). + * @param \GuzzleHttp\ClientInterface $client + * The guzzle client that is already fully configured for use with Xero. + * This method of initializing XeroClient should never be used without a + * static method because the guzzle developers mark useful methods as + * derpecated for no good reason. */ - public function __construct(array $config = []) + public function __construct(ClientInterface $client) { - $options = $config['options'] ?? []; - $scheme = $config['scheme'] ?? 'oauth1'; - $auth = $scheme === 'oauth1' ? 'oauth' : null; - - if (!isset($config['base_uri']) || - !$config['base_uri'] || - !$this->isValidUrl($config['base_uri'])) { - throw new InvalidOptionsException('API URL is not valid.'); - } - - if ($scheme === 'oauth1') { - // Backwards-compatible with oauth1. - if (!isset($config['consumer_key']) || !$config['consumer_key']) { - throw new InvalidOptionsException('Missing required parameter consumer_key'); - } - - if (!isset($config['consumer_secret']) || !$config['consumer_secret']) { - throw new InvalidOptionsException('Missing required parameter consumer_secret'); - } - - if ($config['application'] === 'private') { - $config['token'] = $config['consumer_key']; - } - - if ($config['application'] === 'private') { - $config['token_secret'] = $config['consumer_secret']; - } - - if ($config['application'] === 'private' && - (!isset($config['private_key']) || !$this->isValidPrivateKey($config['private_key'])) - ) { - throw new InvalidOptionsException('Missing required parameter private_key'); - } - - if ($config['application'] === 'private') { - $middleware = $this->getPrivateApplicationMiddleware($config); - } else { - $middleware = $this->getPublicApplicationMiddleware($config); - } - } elseif ($scheme === 'oauth2') { - // Use OAuth2 work flow. - if (!isset($config['auth_token'])) { - throw new InvalidOptionsException('Missing required parameter auth_token'); - } - $options['headers']['Authorization'] = 'Bearer ' . $config['auth_token']; - - if (isset($config['tenant'])) { - $options['headers']['xero-tenant-id'] = $config['tenant']; - } - } else { - throw new InvalidOptionsException('Invalid scheme provided'); - } - - if (isset($config['handler']) && is_a($config['handler'], '\GuzzleHttp\HandlerStack')) { - $stack = $config['handler']; - } else { - $stack = HandlerStack::create(); - } - - if (isset($middleware)) { - $stack->push($middleware); - } - - parent::__construct($options + [ - 'base_uri' => $config['base_uri'], - 'handler' => $stack, - 'auth' => $auth, - ]); + $this->client = $client; } /** @@ -124,76 +66,25 @@ public function __construct(array $config = []) */ public function isValidUrl($base_uri): bool { - return in_array($base_uri, $this->getValidUrls()) || - str_starts_with($base_uri, 'https://api.xero.com/oauth'); + return in_array($base_uri, self::getValidUrls()); } /** * {@inheritdoc} */ - public function isValidPrivateKey($filename): bool - { - if ($filename && realpath($filename) && !is_dir($filename) && is_readable($filename)) { - return true; - } - return false; - } - - /** - * @param array $options - * The options passed into the constructor. - * - * @return \GuzzleHttp\Subscriber\Oauth\Oauth1 - * OAuth1 middleware. - * - * @deprecated Deprecated since 0.2.0. - */ - protected function getPublicApplicationMiddleware(array $options): Oauth1 + public function getConnections(): array { - $oauth_options = [ - 'consumer_key' => $options['consumer_key'], - 'consumer_secret' => $options['consumer_secret'], - ]; - - if (isset($options['token'])) { - $oauth_options['token'] = $options['token']; - } - - if (isset($options['token_secret'])) { - $oauth_options['token_secret'] = $options['token_secret']; - } - - if (isset($options['callback'])) { - $oauth_options['callback'] = $options['callback']; - } - - if (isset($options['verifier'])) { - $oauth_options['verifier'] = $options['verifier']; + try { + $response = $this->request('GET', 'https://api.xero.com/connections', [ + 'Content-Type' => 'application/json', + ]); + return json_decode($response->getBody()->getContents(), true); + } catch (RequestException $e) { + if ($e->getCode() >= 400) { + throw $e; + } + return []; } - - return new Oauth1($oauth_options); - } - - /** - * @param array $options - * The options passed into the constructor. - * - * @return \GuzzleHttp\Subscriber\Oauth\Oauth1 - * OAuth1 middleware. - * - * @deprecated Deprecated since 0.2.0 - */ - protected function getPrivateApplicationMiddleware(array $options): Oauth1 - { - return new Oauth1([ - 'consumer_key' => $options['consumer_key'], - 'consumer_secret' => $options['consumer_secret'], - 'token' => $options['token'], - 'token_secret' => $options['token_secret'], - 'private_key_file' => $options['private_key'], - 'private_key_passphrase' => null, - 'signature_method' => Oauth1::SIGNATURE_METHOD_RSA, - ]); } /** @@ -201,77 +92,34 @@ protected function getPrivateApplicationMiddleware(array $options): Oauth1 * * @throws \Radcliffe\Xero\Exception\InvalidOptionsException */ - public static function getRequestToken(string $consumer_key, string $consumer_secret, array $options = []): array + public static function createFromConfig(array $config, array $options = []): static { - $config = [ - 'base_uri' => 'https://api.xero.com/oauth/', - 'consumer_key' => $consumer_key, - 'consumer_secret' => $consumer_secret, - 'application' => 'public', - ] + $options; - $client = new static($config); - - $tokens = []; - $response = $client->post('/RequestToken'); - $pairs = explode('&', $response->getBody()->getContents()); - foreach ($pairs as $pair) { - $split = explode('=', $pair, 2); - $parameter = urldecode($split[0]); - $tokens[$parameter] = isset($split[1]) ? urldecode($split[1]) : ''; + if (isset($options['tenant'])) { + $config['headers']['xero-tenant-id'] = $options['tenant']; } - return $tokens; - } - - /** - * {@inheritdoc} - * - * @throws \Radcliffe\Xero\Exception\InvalidOptionsException - */ - public static function getAccessToken( - string $consumer_key, - string $consumer_secret, - string $token, - string $token_secret, - string $verifier, - array $options = [] - ): array { - $config = [ - 'base_uri' => 'https://api.xero.com/oauth/', - 'consumer_key' => $consumer_key, - 'consumer_secret' => $consumer_secret, - 'token' => $token, - 'token_secret' => $token_secret, - 'verifier' => $verifier, - 'application' => 'public', - ] + $options; - $client = new static($config); - - $tokens = []; - $response = $client->post('/AccessToken'); - $pairs = explode('&', $response->getBody()->getContents()); - foreach ($pairs as $pair) { - $split = explode('=', $pair, 2); - $parameter = urldecode($split[0]); - $tokens[$parameter] = isset($split[1]) ? urldecode($split[1]) : ''; + if (isset($config['handler']) && is_a($config['handler'], '\GuzzleHttp\HandlerStack')) { + $stack = $config['handler']; + } else { + $stack = HandlerStack::create(); } - return $tokens; - } - /** - * {@inheritdoc} - */ - public function getConnections(): array - { - try { - $response = $this->get('https://api.xero.com/connections', ['Content-Type' => 'application/json']); - return json_decode($response->getBody()->getContents(), true); - } catch (RequestException $e) { - if ($e->getCode() >= 400) { - throw $e; + $stack->push(Middleware::mapRequest(function (RequestInterface $request) use ($options) { + $validUrls = array_filter(self::getValidUrls(), fn ($url) => str_starts_with($request->getUri(), $url)); + if (empty($validUrls)) { + throw new XeroRequestException('API URL is not valid', $request); } - return []; - } + + if (!isset($options['auth_token'])) { + throw new XeroRequestException('Missing required parameter auth_token', $request); + } + return $request->withHeader('Authorization', 'Bearer ' . $options['auth_token']); + })); + + $client = new Client($config + [ + 'handler' => $stack, + ]); + return new static($client); } /** @@ -279,7 +127,6 @@ public function getConnections(): array * * @throws \League\OAuth2\Client\Provider\Exception\IdentityProviderException * @throws \Radcliffe\Xero\Exception\InvalidOptionsException - * @throws \GuzzleHttp\Exception\ClientException */ public static function createFromToken( string $id, @@ -315,13 +162,9 @@ public static function createFromToken( } // Create a new static instance. - $instance = new static($options + [ - 'scheme' => 'oauth2', - 'auth_token' => $token, - ]); + $instance = self::createFromConfig($options, ['auth_token' => $token]); - $response = $instance->get('https://api.xero.com/connections'); - $instance->tenantIds = json_decode($response->getBody()->getContents(), true); + $instance->tenantIds = $instance->getConnections(); if (isset($refreshedToken)) { $instance->refreshedToken = $refreshedToken; @@ -330,6 +173,46 @@ public static function createFromToken( return $instance; } + /** + * {@inheritdoc} + * + * @throws \GuzzleHttp\Exception\GuzzleException + */ + public function request(string $method, UriInterface|string $uri = '', array $options = []): ResponseInterface + { + return $this->client->request(strtoupper($method), $uri, $options); + } + + /** + * {@inheritdoc} + * + * @throws \GuzzleHttp\Exception\GuzzleException + */ + public function get(UriInterface|string $uri = '', array $options = []): ResponseInterface + { + return $this->request('GET', $uri, $options); + } + + /** + * {@inheritdoc} + * + * @throws \GuzzleHttp\Exception\GuzzleException + */ + public function post(UriInterface|string $uri = '', array $options = []): ResponseInterface + { + return $this->request('POST', $uri, $options); + } + + /** + * {@inheritdoc} + * + * @throws \GuzzleHttp\Exception\GuzzleException + */ + public function put(UriInterface|string $uri = '', array $options = []): ResponseInterface + { + return $this->request('PUT', $uri, $options); + } + /** * Access tokens refreshed when creating an instance from a refresh token. * diff --git a/src/XeroClientInterface.php b/src/XeroClientInterface.php index 7090a36..29bde59 100644 --- a/src/XeroClientInterface.php +++ b/src/XeroClientInterface.php @@ -2,9 +2,11 @@ namespace Radcliffe\Xero; +use Psr\Http\Message\ResponseInterface; +use Psr\Http\Message\UriInterface; + interface XeroClientInterface { - /** * Get a list of valid API URLs. * @@ -19,71 +21,13 @@ public static function getValidUrls(): array; * * @return bool * TRUE if the base uri is valid. - */ - public function isValidUrl(string $base_uri): bool; - - /** - * Check the private key file. - * - * @param string $filename - * The file name of the private key. - * @return bool - * TRUE if the private key is valid. - * - * @deprecated Deprecated since 0.2.0 - */ - public function isValidPrivateKey(string $filename): bool; - - /** - * Get an unauthorized request token from the API. - * - * @param string $consumer_key - * Consumer key. - * @param string $consumer_secret - * Consumer secret. - * @param array $options - * An array of request options including other OAuth1 required properties depending on the application type. * - * @return array - * An associative array consisting of the following keys: - * - oauth_token - * - oauth_secret + * @deprecated in 0.5.0 and removed in 0.6.0. The check of base_uri is now + * internal to XeroClient. * - * @deprecated Deprecated since 0.2.0 + * @see XeroClient::createFromConfig(). */ - public static function getRequestToken(string $consumer_key, string $consumer_secret, array $options = []): array; - - /** - * Get an access token from the API. - * - * @param string $consumer_key - * Consumer key. - * @param string $consumer_secret - * Consumer secret. - * @param string $token - * OAuth token. - * @param string $token_secret - * Token secret from the request token. - * @param string $verifier - * The CSRF token provided by the API. - * @param array $options - * An array of request options to provide to Guzzle. - * - * @return array - * An associative array consisting of the following keys: - * - oauth_token - * - oauth_secret - * - * @deprecated Deprecated since 0.2.0 - */ - public static function getAccessToken( - string $consumer_key, - string $consumer_secret, - string $token, - string $token_secret, - string $verifier, - array $options = [] - ): array; + public function isValidUrl(string $base_uri): bool; /** * Get connections authorized by the user. @@ -144,4 +88,80 @@ public static function createFromToken( array $collaborators = [], string $redirectUri = '' ): static; + + /** + * Creates an instance of XeroClient with guzzle configured from options. + * + * @param array $config + * The guzzle options. + * @param array $options + * The XeroClient Options: + * - auth_token: the access or refresh token. + * - tenant: an optional tenant id. + * + * @return static + * + * @see \GuzzleHttp\Client::__construct(). + */ + public static function createFromConfig(array $config, array $options = []): static; + + /** + * Makes a request to the Xero API. + * + * @param string $method + * The request methad. + * @param string|\Psr\Http\Message\UriInterface $uri + * The endpoint path. + * @param array $options + * Options to pass to the http client. + * + * @return \Psr\Http\Message\ResponseInterface + * The response from the http client. + */ + public function request(string $method, string|UriInterface $uri = '', array $options = []): ResponseInterface; + + /** + * Makes a GET request to the Xero API endpoint. + * + * @param string|\Psr\Http\Message\UriInterface $uri + * The endpoint path. + * @param array $options + * Options to pass to the http client. + * + * @return \Psr\Http\Message\ResponseInterface + * The response from the http client. + * + * @deprecated in 0.5.0 and removed in 0.6.0. Use the request method. + */ + public function get(string|UriInterface $uri = '', array $options = []): ResponseInterface; + + /** + * Makes a POST request to the Xero API endpoint. + * + * @param string|\Psr\Http\Message\UriInterface $uri + * The endpoint path. + * @param array $options + * Options to pass to the http client. + * + * @return \Psr\Http\Message\ResponseInterface + * The response from the http client. + * + * @deprecated in 0.5.0 and removed in 0.6.0. Use the request method. + */ + public function post(string|UriInterface $uri = '', array $options = []): ResponseInterface; + + /** + * Makes a PUT request to the Xero API endpoint. + * + * @param string|\Psr\Http\Message\UriInterface $uri + * The endpoint path. + * @param array $options + * Options to pass to the http client. + * + * @return \Psr\Http\Message\ResponseInterface + * The response from the http client. + * + * @deprecated in 0.5.0 and removed in 0.6.0. Use the request method. + */ + public function put(string|UriInterface $uri = '', array $options = []): ResponseInterface; } diff --git a/src/XeroHelperTrait.php b/src/XeroHelperTrait.php index cf1a803..d6df8f5 100644 --- a/src/XeroHelperTrait.php +++ b/src/XeroHelperTrait.php @@ -7,21 +7,21 @@ trait XeroHelperTrait /** * Valid condition operators. * - * @var array + * @var string[] */ protected static $conditionOperators = ['==', '!=', 'StartsWith', 'EndsWith', 'Contains', 'guid']; /** * The Xero API conditions for GET requests. * - * @var array + * @var string[] */ protected $conditions = []; /** * Get the conditions for the request. * - * @return array + * @return string[] * The conditions protected property. */ public function getConditions() @@ -34,7 +34,7 @@ public function getConditions() * * @param string $field * The field to add the condition for. - * @param string $value + * @param string|bool $value * The value to compare against. * @param string $operator * The operator to use in the condition: @@ -47,7 +47,7 @@ public function getConditions() * * @return $this */ - public function addCondition($field, $value = '', $operator = '==') + public function addCondition(string $field, string|bool $value = '', string $operator = '==') { if (!in_array($operator, self::$conditionOperators)) { throw new \InvalidArgumentException('Invalid operator'); @@ -78,7 +78,7 @@ public function addCondition($field, $value = '', $operator = '==') * * @return $this */ - public function addOperator($operator = 'AND') + public function addOperator(string $operator = 'AND') { if (!in_array($operator, ['AND', 'OR'])) { throw new \InvalidArgumentException('Invalid logical operator'); @@ -92,7 +92,7 @@ public function addOperator($operator = 'AND') /** * Compile the conditions array into a query parameter. * - * @return array + * @return array * An associative array that can be merged into the query options. */ public function compileConditions() @@ -112,10 +112,10 @@ public function compileConditions() * @param string $direction * An optional direction. * - * @return array + * @return array * An associative array that can be merged into the query options. */ - public function orderBy($field, $direction = 'ASC') + public function orderBy(string $field, string $direction = 'ASC') { $ret = ['order' => $field]; if ($direction === 'DESC') { @@ -130,10 +130,10 @@ public function orderBy($field, $direction = 'ASC') * @param string $request_parameters * The HTTP Request parameters from the API to the web server. * - * @return array + * @return array * An associative array keyed by the parameter key. */ - public function getRequestParameters($request_parameters) + public function getRequestParameters(string $request_parameters) { $ret = []; $parts = explode('&', $request_parameters); diff --git a/src/XeroProvider.php b/src/XeroProvider.php index c408b43..bb14730 100644 --- a/src/XeroProvider.php +++ b/src/XeroProvider.php @@ -154,7 +154,7 @@ public static function getValidScopes(?string $api = '', array $custom = []): ar $scopes[] = "accounting.$type.read"; } $scopes = array_merge($scopes, ['accounting.reports.read', 'accounting.journals.read']); - } elseif (str_starts_with($api, 'payroll')) { + } elseif (str_starts_with($api ?? '', 'payroll')) { // @todo Split the logic into au, uk, nz, and other sections as necessary. $types = ['employees', 'payruns', 'payslip', 'timesheets', 'settings']; foreach ($types as $type) { diff --git a/tests/fixtures/dummy.cer b/tests/fixtures/dummy.cer deleted file mode 100644 index ad8bbc9..0000000 --- a/tests/fixtures/dummy.cer +++ /dev/null @@ -1,15 +0,0 @@ ------BEGIN CERTIFICATE----- -MIICPjCCAaegAwIBAgIJAMxSkISNKDC5MA0GCSqGSIb3DQEBCwUAMDgxCzAJBgNV -BAYTAlVTMQ0wCwYDVQQIDARPaGlvMRowGAYDVQQKDBFNYXR0aGV3IHJhZGNsaWZm -ZTAeFw0xNjEwMjUwMDU5MTlaFw0yMTEwMjQwMDU5MTlaMDgxCzAJBgNVBAYTAlVT -MQ0wCwYDVQQIDARPaGlvMRowGAYDVQQKDBFNYXR0aGV3IHJhZGNsaWZmZTCBnzAN -BgkqhkiG9w0BAQEFAAOBjQAwgYkCgYEAsq7nWGCBaOEtGySpaVPFAkOvDTKcEb3s -STh9Xye7kK0LtE8xM1UJqoFd4sxMlPPjJAKrSqZHkZ7y1WlYe/Sqr0ZfAwb0O9za -/4m4Gk57Bcm29uWnlOh/iOdnZWZ73xpNDpeNthKzH+cWSVcm777uc1jIV61W0/nq -1qql2hin66cCAwEAAaNQME4wHQYDVR0OBBYEFCGkWrHmCOY62z53wmpeY0p9oR2D -MB8GA1UdIwQYMBaAFCGkWrHmCOY62z53wmpeY0p9oR2DMAwGA1UdEwQFMAMBAf8w -DQYJKoZIhvcNAQELBQADgYEAXxbCTW/4MP/yrCj4kam0Ey2CNWMfzD14Ot0Jrel5 -vqixxMV4127JbI9Z7KAwinVlw/to2pXJnKOBHHkX+i3r7vZD4usymhdkqvvkVeqX -JiynZ08E8iTMjGdFiF8pWeAzHyuJtkSsffu8PwVBC5Md3jL8wJY+pjyg1P4fbnIj -tr8= ------END CERTIFICATE----- diff --git a/tests/fixtures/dummy.pem b/tests/fixtures/dummy.pem deleted file mode 100644 index 1b9ce2b..0000000 --- a/tests/fixtures/dummy.pem +++ /dev/null @@ -1,15 +0,0 @@ ------BEGIN RSA PRIVATE KEY----- -MIICWwIBAAKBgQCyrudYYIFo4S0bJKlpU8UCQ68NMpwRvexJOH1fJ7uQrQu0TzEz -VQmqgV3izEyU8+MkAqtKpkeRnvLVaVh79KqvRl8DBvQ73Nr/ibgaTnsFybb25aeU -6H+I52dlZnvfGk0Ol422ErMf5xZJVybvvu5zWMhXrVbT+erWqqXaGKfrpwIDAQAB -AoGAXxW6IIqCcwhY03fn+xfL38+ayMAk00ApDDHWbZucMXoUn2gA+5sGIMjk6Drl -ieJa/h+5fWkM7s4R1UVRA+2IIgf7evGIp+ogtFRKbs5JV7o/MQbRo8eBw84+vGiV -WCGw7bFG/Kex4B2VACI6aAAYGJOPyboMNHGR3vCVR3NnuLECQQDZvfikwaFoXLEQ -nFyGq1s0or/bT082vQffkrqf+qeu2Oqj6Qtl8J9WCJ6hhwXdUrDBo5ooGuUknSwo -iET2sL35AkEA0hQRF7sxLd2p5EgMQodTWc/2BKaqFt8G5FP9mnEIY44K0PvV7WsZ -qgKfGFm29sF2nmSsiSWog0sBSqvP1kTenwJAS8gDNowxednwwiA5T6Fpz8rozRIw -NpPZnqU5kJ4zJ/ZUy5E7J1J0kgPT+dG9Z8b0v6AJ8fVSVMji6oRqxBTFOQJAPU7p -5hkVMyN7j0mPemLv6kgMrjLNdLtF0aDJZCcUoak0cuTr+8vDw5/cOkkEYmL3cSZE -TAoXcamUCOy3TPrwVQJAINW0AYXilqckLd5Fb53y84WVADHZxqnjQLr8qoB7ExJe -RAnAt8Bh6s1k7b9rtsCy/zr9G52ALWcfU0b3GVl0+g== ------END RSA PRIVATE KEY----- diff --git a/tests/fixtures/notreadable.pem b/tests/fixtures/notreadable.pem deleted file mode 100644 index 1b9ce2b..0000000 --- a/tests/fixtures/notreadable.pem +++ /dev/null @@ -1,15 +0,0 @@ ------BEGIN RSA PRIVATE KEY----- -MIICWwIBAAKBgQCyrudYYIFo4S0bJKlpU8UCQ68NMpwRvexJOH1fJ7uQrQu0TzEz -VQmqgV3izEyU8+MkAqtKpkeRnvLVaVh79KqvRl8DBvQ73Nr/ibgaTnsFybb25aeU -6H+I52dlZnvfGk0Ol422ErMf5xZJVybvvu5zWMhXrVbT+erWqqXaGKfrpwIDAQAB -AoGAXxW6IIqCcwhY03fn+xfL38+ayMAk00ApDDHWbZucMXoUn2gA+5sGIMjk6Drl -ieJa/h+5fWkM7s4R1UVRA+2IIgf7evGIp+ogtFRKbs5JV7o/MQbRo8eBw84+vGiV -WCGw7bFG/Kex4B2VACI6aAAYGJOPyboMNHGR3vCVR3NnuLECQQDZvfikwaFoXLEQ -nFyGq1s0or/bT082vQffkrqf+qeu2Oqj6Qtl8J9WCJ6hhwXdUrDBo5ooGuUknSwo -iET2sL35AkEA0hQRF7sxLd2p5EgMQodTWc/2BKaqFt8G5FP9mnEIY44K0PvV7WsZ -qgKfGFm29sF2nmSsiSWog0sBSqvP1kTenwJAS8gDNowxednwwiA5T6Fpz8rozRIw -NpPZnqU5kJ4zJ/ZUy5E7J1J0kgPT+dG9Z8b0v6AJ8fVSVMji6oRqxBTFOQJAPU7p -5hkVMyN7j0mPemLv6kgMrjLNdLtF0aDJZCcUoak0cuTr+8vDw5/cOkkEYmL3cSZE -TAoXcamUCOy3TPrwVQJAINW0AYXilqckLd5Fb53y84WVADHZxqnjQLr8qoB7ExJe -RAnAt8Bh6s1k7b9rtsCy/zr9G52ALWcfU0b3GVl0+g== ------END RSA PRIVATE KEY----- diff --git a/tests/src/Mocks/HelperTraitMock.php b/tests/src/Mocks/HelperTraitMock.php new file mode 100644 index 0000000..7daf07c --- /dev/null +++ b/tests/src/Mocks/HelperTraitMock.php @@ -0,0 +1,10 @@ + $options - * Invalid options to pass to the consructor. - * - * @dataProvider invalidOptionsExceptionProvider - */ - public function testInvalidOptionsException(array $options): void - { - $this->expectException(InvalidOptionsException::class); - $client = new XeroClient($options); - - $this->assertNull($client); - } - - /** - * Asserts private application instantiation. - */ - public function testPrivateApplication(): void - { - $options = $this->createConfiguration(); - $client = new XeroClient($options); - $this->assertNotNull($client); - } - /** * Asserts public application instantiation. */ public function testPublicApplication(): void { - $options = $this->createConfiguration('accounting', 'public'); - $client = new XeroClient($options); + $options = $this->createConfiguration(); + $client = XeroClient::createFromConfig($options + [ + 'auth_token' => self::createRandomString(), + ]); $this->assertNotNull($client); } - /** - * Asserts get request token. - */ - public function testGetRequestToken(): void - { - $options = $this->createConfiguration('accounting', 'public'); - $expected = ['oauth_token' => $options['token'], 'oauth_secret' => $options['token_secret']]; - $response = 'oauth_token=' . $options['token'] . '&oauth_secret=' . $options['token_secret']; - $mock = new MockHandler( - [ - new Response(200, ['Content-Type' => 'application/x-www-form-urlencoded'], $response) - ] - ); - $options['handler'] = new HandlerStack($mock); - - $tokens = XeroClient::getRequestToken($options['consumer_key'], $options['consumer_secret'], $options); - $this->assertEquals($expected, $tokens); - } - - /** - * Asserts getting an access token. - */ - public function testGetAccessToken(): void - { - $expected = [ - 'oauth_token' => $this->createRandomString(), - 'oauth_secret' => $this->createRandomString(), - ]; - - $options = $this->createConfiguration('accounting', 'public'); - $response = 'oauth_token=' . $expected['oauth_token'] . '&oauth_secret=' . $expected['oauth_secret']; - $mock = new MockHandler( - [ - new Response(200, ['Content-Type' => 'application/x-www-form-urlencoded'], $response) - ] - ); - $options['callback'] = 'https://example.com'; - $options['handler'] = new HandlerStack($mock); - - $tokens = XeroClient::getAccessToken( - $options['consumer_key'], - $options['consumer_secret'], - $options['token'], - $options['token_secret'], - $options['verifier'], - $options - ); - $this->assertEquals($expected, $tokens); - } - /** * @param int $statusCode * @param array $headers @@ -107,7 +34,7 @@ public function testGetAccessToken(): void * * @dataProvider providerGetTest * - * @throws \Radcliffe\Xero\Exception\InvalidOptionsException + * @throws \Radcliffe\Xero\Exception\InvalidOptionsException|\GuzzleHttp\Exception\GuzzleException */ public function testGet(int $statusCode, array $headers, string $body): void { @@ -119,34 +46,14 @@ public function testGet(int $statusCode, array $headers, string $body): void ); $options['handler'] = new HandlerStack($mock); - $client = new XeroClient($options); + $client = XeroClient::createFromConfig($options, [ + 'auth_token' => self::createRandomString(), + ]); - $response = $client->get('/BrandingThemes'); + $response = $client->request('GET', 'BrandingThemes'); $this->assertEquals(200, $response->getStatusCode()); } - /** - * Tests trying to use an unreadable file. - */ - public function testUnreadableFile(): void - { - $this->expectException(InvalidOptionsException::class); - $path = __DIR__ . '/../fixtures/notreadable.pem'; - $options = [ - 'base_uri' => 'https://api.xero.com/api.xro/2.0/', - 'consumer_key' => 'test', - 'consumer_secret' => 'test', - 'private_key' => $path, - 'application' => 'private', - ]; - chmod($path, 000); - $client = new XeroClient($options); - $this->assertNull($client); - - // Test cleanup. - chmod($path, 0644); - } - /** * Asserts that connections are returned and decoded. * @@ -167,98 +74,34 @@ public function testGetConnections(int $statusCode, array $response, int $expect 'Content-Type' => 'application/json', ], json_encode($response)), ]); - $client = new XeroClient([ + $client = XeroClient::createFromConfig([ 'base_uri' => 'https://api.xero.com/connections', - 'scheme' => 'oauth2', - 'auth_token' => $this->createRandomString(), 'handler' => new HandlerStack($mock), - ]); + ], ['auth_token' => self::createRandomString()]); $connections = $client->getConnections(); $this->assertEquals($expectedCount, count($connections)); } - /** - * @return array - */ - public function invalidOptionsExceptionProvider(): array + public function testWithInvalidUrl(): void { - return [ - [[]], - [['base_uri' => '', 'application' => 'private']], - [ - [ - 'base_uri' => 'https://api.xero.com/api.xro/2.0/', - 'application' => 'private', - ], - ], - [ - [ - 'base_uri' => 'https://api.xero.com/api.xro/2.0/', - 'consumer_key' => '', - 'application' => 'private', - ], - ], - [ - [ - 'base_uri' => 'https://api.xero.com/api.xro/2.0/', - 'consumer_key' => 'test', - 'application' => 'private', - ], - ], - [ - [ - 'base_uri' => 'https://api.xero.com/api.xro/2.0/', - 'consumer_key' => 'test', - 'consumer_secret' => '', - 'application' => 'private', - ], - ], - [ - [ - 'base_uri' => 'https://api.xero.com/api.xro/2.0/', - 'consumer_key' => 'test', - 'consumer_secret' => 'test', - 'application' => 'private', - ], - ], - [ - [ - 'base_uri' => 'https://api.xero.com/api.xro/2.0/', - 'consumer_key' => 'test', - 'consumer_secret' => 'test', - 'private_key' => '', - 'application' => 'private', - ], - ], - [ - [ - 'base_uri' => 'https://api.xero.com/api.xro/2.0/', - 'consumer_key' => 'test', - 'consumer_secret' => 'test', - 'private_key' => 'nofile.pem', - 'application' => 'private', - ], - ], - [ - [ - 'base_uri' => 'https://api.xero.com/api.xro/2.0/', - 'consumer_key' => 'test', - 'consumer_secret' => 'test', - 'private_key' => 'testfile.pem', - 'application' => 'private', - ], - ], - [ - [ - 'base_uri' => 'https://api.xero.com/api.xro/2.0/', - 'consumer_key' => 'test', - 'consumer_secret' => 'test', - 'private_key' => __DIR__, - 'application' => 'private', - ], - ], - ]; + $this->expectException('\Radcliffe\Xero\Exception\XeroRequestException'); + + $options = $this->createConfiguration(); + $options['base_uri'] = 'https://example.com/'; + $client = XeroClient::createFromConfig($options, [ + 'auth_token' => self::createRandomString(), + ]); + $client->request('GET', 'Accounts'); + } + + public function testWithoutAuthToken(): void + { + $this->expectException('\Radcliffe\Xero\Exception\XeroRequestException'); + + $options = $this->createConfiguration(); + $client = XeroClient::createFromConfig($options, []); + $client->request('GET', 'Accounts'); } /** @@ -266,14 +109,14 @@ public function invalidOptionsExceptionProvider(): array * * @return array */ - public function providerGetTest(): array + public static function providerGetTest(): array { return [ [ 200, ['Content-Type' => 'text/xml'], '' . - $this->createGuid() . + self::createGuid() . 'Standard0' . '2010-06-29T18:16:36.27', ] @@ -286,20 +129,20 @@ public function providerGetTest(): array * @return array * An array of test cases and arguments. */ - public function connectionsResponseProvider(): array + public static function connectionsResponseProvider(): array { return [ 'returns tenants' => [200, [ [ - 'id' => $this->createGuid(), - 'tenantId' => $this->createGuid(), + 'id' => self::createGuid(), + 'tenantId' => self::createGuid(), 'tenantType' => 'ORGANISATION', 'createdDateUtc' => '2020-02-02T19:17:58.1117990', 'updatedDateUtc' => '2020-02-02T19:17:58.1117990', ], [ - 'id' => $this->createGuid(), - 'tenantId' => $this->createGuid(), + 'id' => self::createGuid(), + 'tenantId' => self::createGuid(), 'tenantType' => 'ORGANISATION', 'createdDateUtc' => '2020-01-30T01:33:36.2717380', 'updatedDateUtc' => '2020-02-02T19:21:08.5739590', diff --git a/tests/src/XeroClientTestBase.php b/tests/src/XeroClientTestBase.php index 280a721..3139142 100644 --- a/tests/src/XeroClientTestBase.php +++ b/tests/src/XeroClientTestBase.php @@ -15,40 +15,25 @@ class XeroClientTestBase extends TestCase * * @param string $api * The API to use: accounting or payroll. - * @param string $application - * The application type: private, public or partner. * * @return array * An associative array of configuration options with the following keys: * - base_uri: An API URL. * - consumer_key: A 32-character long sring. * - consumer_secret: A 32-character long string. - * - private_key: File path to the private key. - * - application: private or public. */ - protected function createConfiguration(string $api = 'accounting', string $application = 'private'): array + protected function createConfiguration(string $api = 'accounting'): array { $base_uri = 'https://api.xero.com/payroll.xro/1.0/'; if ($api === 'accounting') { $base_uri = 'https://api.xero.com/api.xro/2.0/'; } - $options = [ + return [ 'base_uri' => $base_uri, 'consumer_key' => $this->createRandomString(), 'consumer_secret' => $this->createRandomString(), - 'application' => $application, ]; - - if ($application === 'private') { - $options['private_key'] = __DIR__ . DIRECTORY_SEPARATOR . '../fixtures/dummy.pem'; - } else { - $options['token'] = $this->createRandomString(); - $options['token_secret'] = $this->createRandomString(); - $options['verifier'] = $this->createRandomString(); - } - - return $options; } /** @@ -57,7 +42,7 @@ protected function createConfiguration(string $api = 'accounting', string $appli * @return string * A globally-unique identifier. */ - protected function createGuid(): string + public static function createGuid(): string { $hash = strtoupper(hash('ripemd128', md5(openssl_random_pseudo_bytes(100)))); $guid = substr($hash, 0, 8) . '-' . substr($hash, 8, 4) . '-' . substr($hash, 12, 4); @@ -74,7 +59,7 @@ protected function createGuid(): string * @return string * A random string of characters. */ - protected function createRandomString(int $length = 30): string + public static function createRandomString(int $length = 30): string { if ($length > 255) { throw new \InvalidArgumentException('Maximum number is 100.'); diff --git a/tests/src/XeroHelperTraitTest.php b/tests/src/XeroHelperTraitTest.php index 58084c1..d01dd5f 100644 --- a/tests/src/XeroHelperTraitTest.php +++ b/tests/src/XeroHelperTraitTest.php @@ -2,6 +2,8 @@ namespace Radcliffe\Tests\Xero; +use Radcliffe\Tests\Xero\Mocks\HelperTraitMock; + class XeroHelperTraitTest extends XeroClientTestBase { @@ -17,8 +19,7 @@ class XeroHelperTraitTest extends XeroClientTestBase */ public function testGetRequestParameters(string $parameters, array $expected): void { - /* @var $mock \Radcliffe\Xero\XeroHelperTrait */ - $mock = $this->getMockForTrait('\Radcliffe\Xero\XeroHelperTrait'); + $mock = new HelperTraitMock(); $this->assertEquals($expected, $mock->getRequestParameters($parameters)); } @@ -39,8 +40,7 @@ public function testGetRequestParameters(string $parameters, array $expected): v */ public function testAddCondition(string $field, bool|int|string $value, string $operator, array $expected): void { - /* @var $mock \Radcliffe\Xero\XeroHelperTrait */ - $mock = $this->getMockForTrait('\Radcliffe\Xero\XeroHelperTrait'); + $mock = new HelperTraitMock(); $mock->addCondition($field, $value, $operator); @@ -59,8 +59,7 @@ public function testAddCondition(string $field, bool|int|string $value, string $ */ public function testAddOperator(string $operator, array $expected): void { - /* @var $mock \Radcliffe\Xero\XeroHelperTrait */ - $mock = $this->getMockForTrait('\Radcliffe\Xero\XeroHelperTrait'); + $mock = new HelperTraitMock(); $mock->addOperator($operator); @@ -79,8 +78,7 @@ public function testAddOperator(string $operator, array $expected): void */ public function testCompileConditions(array $conditions, array $expected): void { - /* @var $mock \Radcliffe\Xero\XeroHelperTrait */ - $mock = $this->getMockForTrait('\Radcliffe\Xero\XeroHelperTrait'); + $mock = new HelperTraitMock(); foreach ($conditions as $condition) { $mock->addCondition($condition[0], $condition[1], $condition[2]); } @@ -100,8 +98,7 @@ public function testCompileConditions(array $conditions, array $expected): void */ public function testOrderBy(string $direction, array $expected): void { - /* @var $mock \Radcliffe\Xero\XeroHelperTrait */ - $mock = $this->getMockForTrait('\Radcliffe\Xero\XeroHelperTrait'); + $mock = new HelperTraitMock(); $this->assertEquals($expected, $mock->orderBy('Name', $direction)); } @@ -111,8 +108,7 @@ public function testOrderBy(string $direction, array $expected): void */ public function testInvalidAddCondition(): void { - /* @var $mock \Radcliffe\Xero\XeroHelperTrait */ - $mock = $this->getMockForTrait('\Radcliffe\Xero\XeroHelperTrait'); + $mock = new HelperTraitMock(); $this->expectException(\InvalidArgumentException::class); $mock->addCondition('Name', 'Value', '<>'); @@ -123,8 +119,7 @@ public function testInvalidAddCondition(): void */ public function testInvalidLogicalOperator(): void { - /* @var $mock \Radcliffe\Xero\XeroHelperTrait */ - $mock = $this->getMockForTrait('\Radcliffe\Xero\XeroHelperTrait'); + $mock = new HelperTraitMock(); $this->expectException(\InvalidArgumentException::class); $mock->addOperator('NOT'); @@ -136,11 +131,11 @@ public function testInvalidLogicalOperator(): void * @return array * An array of test method parameters. */ - public function requestParametersProvider(): array + public static function requestParametersProvider(): array { $test1_expected = [ - 'oauth_token' => $this->createRandomString(), - 'oauth_verifier' => $this->createRandomString() + 'oauth_token' => self::createRandomString(), + 'oauth_verifier' => self::createRandomString() ]; $test1_string = 'oauth_token=' . urlencode($test1_expected['oauth_token']) . '&' . 'oauth_verifier=' . urlencode($test1_expected['oauth_verifier']); @@ -155,9 +150,9 @@ public function requestParametersProvider(): array * * @return array */ - public function addConditionProvider(): array + public static function addConditionProvider(): array { - $guid = $this->createGuid(); + $guid = self::createGuid(); return [ ['Name', 'Test Value', '==', ['Name=="Test Value"']], ['Name', 'Test Value', '!=', ['Name!="Test Value"']], @@ -173,7 +168,7 @@ public function addConditionProvider(): array * * @return array */ - public function addOperatorProvider(): array + public static function addOperatorProvider(): array { return [['AND', ['AND']], ['OR', ['OR']]]; } @@ -183,7 +178,7 @@ public function addOperatorProvider(): array * * @return array */ - public function compileConditionsProvider(): array + public static function compileConditionsProvider(): array { return [ [[], []], @@ -208,7 +203,7 @@ public function compileConditionsProvider(): array * * @return array */ - public function orderByProvider(): array + public static function orderByProvider(): array { return [ ['ASC', ['order' => 'Name']], diff --git a/tests/src/XeroProviderTest.php b/tests/src/XeroProviderTest.php index 83ca7d7..055bb8a 100644 --- a/tests/src/XeroProviderTest.php +++ b/tests/src/XeroProviderTest.php @@ -112,20 +112,24 @@ public function validScopesProvider(): array * * @dataProvider provideResponseData * - * @throws \League\OAuth2\Client\Provider\Exception\IdentityProviderException + * @throws \League\OAuth2\Client\Provider\Exception\IdentityProviderException|\GuzzleHttp\Exception\GuzzleException */ public function testGetResponseMessage(array $data, string $expected): void { $json = json_encode($data); $this->expectExceptionMessage($expected); + $streamProphet = $this->prophet->prophesize('\Psr\Http\Message\StreamInterface'); + $streamProphet->getContents()->willReturn($json); + $streamProphet->__toString()->willReturn($json); + $requestProphet = $this->prophet->prophesize('\Psr\Http\Message\RequestInterface'); $responseProphet = $this->prophet->prophesize('\Psr\Http\Message\ResponseInterface'); $responseProphet->getStatusCode()->willReturn(400); - $responseProphet->getBody()->willReturn($json); + $responseProphet->getBody()->willReturn($streamProphet->reveal()); $responseProphet ->getHeader(Argument::containingString('content-type')) - ->willReturn('application/json'); + ->willReturn(['application/json']); $guzzleProphet = $this->prophet->prophesize('\GuzzleHttp\ClientInterface'); $guzzleProphet->send(Argument::any())->willReturn($responseProphet->reveal());