diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..1c84dad --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +/.idea +*.DS_Store +/vendor +composer.lock +tmp diff --git a/.travis.yml b/.travis.yml new file mode 100644 index 0000000..4e4671f --- /dev/null +++ b/.travis.yml @@ -0,0 +1,15 @@ +language: php + +php: + - 5.5 + - 5.6 + +install: + - composer self-update + - composer install + +script: + - vendor/bin/phpunit --coverage-clover=coverage.xml + +after_success: + - bash <(curl -s https://codecov.io/bash) diff --git a/README.md b/README.md new file mode 100644 index 0000000..8081ebd --- /dev/null +++ b/README.md @@ -0,0 +1,89 @@ +# Classy PHP SDK [![Build Status](https://travis-ci.org/classy-org/classy-php-sdk.png?branch=master)](https://travis-ci.org/classy-org/classy-php-sdk) [![codecov.io](https://codecov.io/github/classy-org/classy-php-sdk/coverage.svg?branch=master)](https://codecov.io/github/classy-org/classy-php-sdk?branch=master) + +This repository contains a php HTTP client library to let your php App interact with Classy's API. + +## Installation + +The Classy PHP SDK can be installed with [Composer](https://getcomposer.org/): + +```sh +composer require classy-org/classy-php-sdk +``` + +Be sure you included composer autoloader in your app: + +```php +require_once '/path/to/your/project/vendor/autoload.php'; +``` + +## Usage + +### Basic example + +```php +$client = new \Classy\Client([ + 'client_id' => 'your_client_id', + 'client_secret' => 'your_client_secret', + 'version' => '2.0' // version of the API to be used +]); + +$session = $client->newAppSession(); + +// Get information regarding the campaign #1234 +$campaign = $client->get('/campaigns/1234', $session); + +// Access the campaign goal: $campaign->goal + +// Unpublish the campaign +$client->post('/campaign/1234/deactivate', $session); +``` + +### Sessions handling + +Sessions have an expiration date. It is possible to refresh a session: + +```php +if ($session->expired()) { + $client->refresh($session) +} + +// $session->expired() is now false. +``` + +Sessions are serializable, they can be saved an reused to reduce the amount of API calls: + +```php +$client = new \Classy\Client([ + 'client_id' => 'your_client_id', + 'client_secret' => 'your_client_secret', + 'version' => '2.0' // version of the API to be used +]); + +// Retrieve the session from a file +$session = unserialize(file_get_contents("path/to/a/cache/file")); + +// ... work with the API... + +// Save the session for later +file_put_contents("path/to/a/cache/file", serialize($session)); +``` + +### Errors handling + +This client can throw two types of Exceptions: + +* Classy\Exceptions\SDKException when the SDK is misused +* Classy\Exceptions\APIResponseException when the API is not returning an OK response + +```php +try { + $response = $client->get('/endpoint', $session); +} catch (\Classy\Exceptions\APIResponseException $e) { + // Get the HTTP response code + $code = $e->getCode(); + // Get the response content + $content = $e->getResponseData(); + // Get the response headers + $headers = $e->getResponseHeaders(); +} +``` diff --git a/composer.json b/composer.json new file mode 100644 index 0000000..3a13453 --- /dev/null +++ b/composer.json @@ -0,0 +1,22 @@ +{ + "name": "classy-org/classy-php-sdk", + "type": "library", + "description": "Php HTTP client library for Classy API", + "require": { + "guzzlehttp/guzzle": "^6.1" + }, + "require-dev": { + "phpunit/phpunit": "~4.0", + "mockery/mockery": "^0.9.4" + }, + "autoload": { + "psr-4": { + "Classy\\": "src" + } + }, + "autoload-dev": { + "psr-4": { + "Classy\\Tests\\": "tests" + } + } +} diff --git a/phpunit.xml b/phpunit.xml new file mode 100644 index 0000000..9e0577e --- /dev/null +++ b/phpunit.xml @@ -0,0 +1,13 @@ + + + + + ./tests + + + + + ./src + + + diff --git a/src/Client.php b/src/Client.php new file mode 100644 index 0000000..c938c99 --- /dev/null +++ b/src/Client.php @@ -0,0 +1,257 @@ + 'https://api.classy.org', + 'check_ssl_cert' => true + ]; + + $this->httpClient = new GuzzleClient([ + 'base_uri' => $config['base_uri'], + 'verify' => (boolean) $config['check_ssl_cert'] + ]); + + if (!isset($config['version'])) { + throw new SDKException("You must define the version of Classy API you want to use"); + } + if (!isset($config['client_id'])) { + throw new SDKException("client_id is missing"); + } + if (!isset($config['client_secret'])) { + throw new SDKException("client_secret is missing"); + } + + $this->version = urlencode($config['version']); + $this->client_id = $config['client_id']; + $this->client_secret = $config['client_secret']; + } + + /** + * @param $httpClient + * @codeCoverageIgnore + */ + public function setHttpClient($httpClient) + { + $this->httpClient = $httpClient; + } + + /** + * @return GuzzleClient + * @codeCoverageIgnore + */ + public function getHttpClient() + { + return $this->httpClient; + } + + /** + * @return Session + */ + public function newAppSession() + { + $session = new Session(); + $this->refresh($session); + return $session; + } + + /** + * @param $code + * @return Session + */ + public function newMemberSessionFromCode($code) + { + $response = $this->request('POST', '/oauth2/auth', null, [ + 'form_params' => [ + 'grant_type' => 'authorization_code', + 'client_id' => $this->client_id, + 'client_secret' => $this->client_secret, + 'code' => $code + ] + ]); + return new Session($response); + } + + /** + * @param $username + * @param $password + * @return Session + */ + public function newMemberSessionFromCredentials($username, $password) + { + $response = $this->request('POST', '/oauth2/auth', null, [ + 'form_params' => [ + 'grant_type' => 'password', + 'client_id' => $this->client_id, + 'client_secret' => $this->client_secret, + 'username' => $username, + 'password' => $password + ] + ]); + return new Session($response); + } + + /** + * @param string $refresh_token + * @return Session + */ + public function newMemberSessionFromRefreshToken($refresh_token) + { + $response = $this->request('POST', '/oauth2/auth', null, [ + 'form_params' => [ + 'grant_type' => 'refresh_token', + 'client_id' => $this->client_id, + 'client_secret' => $this->client_secret, + 'refresh_token' => $refresh_token + ] + ]); + return new Session($response); + } + + /** + * @param Session $session + */ + public function refresh(Session $session) + { + if (!is_null($session->getRefreshToken())) { + $response = $this->request('POST', '/oauth2/auth', null, [ + 'form_params' => [ + 'grant_type' => 'refresh_token', + 'client_id' => $this->client_id, + 'client_secret' => $this->client_secret, + 'refresh_token' => $session->getRefreshToken() + ] + ]); + } else { + $response = $this->request('POST', '/oauth2/auth', null, [ + 'form_params' => [ + 'grant_type' => 'client_credentials', + 'client_id' => $this->client_id, + 'client_secret' => $this->client_secret, + ] + ]); + } + $session->set($response); + } + + /** + * @param $endpoint + * @param Session|null $session + * @return \Psr\Http\Message\ResponseInterface + */ + public function get($endpoint, Session $session = null) + { + $endpoint = $this->applyVersion($this->version, $endpoint); + return $this->request('GET', $endpoint, $session); + } + + /** + * @param $endpoint + * @param Session|null $session + * @param array $payload + * @return \Psr\Http\Message\ResponseInterface + */ + public function post($endpoint, Session $session = null, $payload = []) + { + $endpoint = $this->applyVersion($this->version, $endpoint); + return $this->request('POST', $endpoint, $session, [ + 'json' => $payload + ]); + } + + /** + * @param $endpoint + * @param Session|null $session + * @param array $payload + * @return \Psr\Http\Message\ResponseInterface + */ + public function put($endpoint, Session $session = null, $payload = []) + { + $endpoint = $this->applyVersion($this->version, $endpoint); + return $this->request('PUT', $endpoint, $session, [ + 'json' => $payload + ]); + } + + /** + * @param $endpoint + * @param Session|null $session + * @return \Psr\Http\Message\ResponseInterface + */ + public function delete($endpoint, Session $session = null) + { + $endpoint = $this->applyVersion($this->version, $endpoint); + return $this->request('DELETE', $endpoint, $session); + } + + /** + * @param $verb + * @param $endpoint + * @param Session|null $session + * @param array $options + * @return array + */ + public function request($verb, $endpoint, Session $session = null, $options = []) + { + if (!is_null($session)) { + if ($session->expired()) { + $this->refresh($session); + } + if (!isset($options['headers'])) { + $options['headers'] = []; + } + $options['headers']['Authorization'] = "Bearer {$session->getAccessToken()}"; + } + + try { + $content = $this->httpClient + ->request($verb, $endpoint, $options) + ->getBody() + ->getContents(); + } catch (BadResponseException $e) { + throw new APIResponseException($e->getMessage(), $e->getCode(), $e); + } + + return json_decode($content); + } + + private function applyVersion($version, $endpoint) + { + $version = trim($version, "/ \t\n\r\0\x0B"); + $endpoint = trim($endpoint, "/ \t\n\r\0\x0B"); + return "/$version/$endpoint"; + } +} diff --git a/src/Exceptions/APIResponseException.php b/src/Exceptions/APIResponseException.php new file mode 100644 index 0000000..1a5f60e --- /dev/null +++ b/src/Exceptions/APIResponseException.php @@ -0,0 +1,23 @@ +getPrevious()->getResponse()->getBody()->getContents()); + } + + public function getResponseHeaders() + { + return $this->getPrevious()->getResponse()->getHeaders(); + } +} diff --git a/src/Exceptions/SDKException.php b/src/Exceptions/SDKException.php new file mode 100644 index 0000000..1f30137 --- /dev/null +++ b/src/Exceptions/SDKException.php @@ -0,0 +1,8 @@ +set($attributes); + } + } + + public function set($attributes) + { + foreach ($attributes as $key => $value) { + $this->$key = $value; + } + $this->expires_at = time() + $this->expires_in; + } + + /** + * @return string + * @codeCoverageIgnore + */ + public function getAccessToken() + { + return $this->access_token; + } + + /** + * @return string + * @codeCoverageIgnore + */ + public function getRefreshToken() + { + return $this->refresh_token; + } + + /** + * @return bool + */ + public function expired() + { + return time() > $this->expires_at; + } + + + /** + * String representation of object + * @link http://php.net/manual/en/serializable.serialize.php + * @return string the string representation of the object or null + * @since 5.1.0 + */ + public function serialize() + { + return serialize(get_object_vars($this)); + } + + /** + * Constructs the object + * @link http://php.net/manual/en/serializable.unserialize.php + * @param string $serialized

+ * The string representation of the object. + *

+ * @return void + * @since 5.1.0 + */ + public function unserialize($serialized) + { + $values = unserialize($serialized); + foreach ($values as $key => $value) { + $this->$key = $value; + } + } +} diff --git a/tests/ClientTest.php b/tests/ClientTest.php new file mode 100644 index 0000000..b6af83b --- /dev/null +++ b/tests/ClientTest.php @@ -0,0 +1,356 @@ + '2.0'], "client_id is missing"], + [['version' => '2.0', 'client_id' => '123'], "client_secret is missing"], + ]; + + } + + /** + * @dataProvider constructProvider + * @covers Classy\Client::__construct + */ + public function testConstructFailure($inputs, $error) + { + try { + new Client($inputs); + $this->fail("Exception expected"); + } catch (SDKException $e) { + $this->assertEquals($error, $e->getMessage()); + } + } + + /** + * @covers Classy\Client::__construct + */ + public function testConstructSuccess() + { + $client = new Client([ + 'version' => '2.0', + 'base_uri' => 'https://classy.org', + 'client_id' => '123', + 'client_secret' => '456' + ]); + $this->assertEquals('https://classy.org', $client->getHttpClient()->getConfig('base_uri')->__toString()); + } + + /** + * @covers Classy\Client::newAppSession + * @covers Classy\Session::expired + */ + public function testNewAppSession() + { + $this->guzzleMock->shouldReceive('request') + ->once() + ->with('POST', '/oauth2/auth', Mockery::on(function($args) { + return $args['form_params'] === [ + 'grant_type' => 'client_credentials', + 'client_id' => '123', + 'client_secret' => '456', + ]; + })) + ->andReturn(new Response(200, [], json_encode([ + "access_token" => 'access_token', + "expires_in" => 3600 + ]))); + + $session = $this->client->newAppSession(); + $this->assertInstanceOf(Session::class, $session); + $this->assertEquals("access_token", $session->getAccessToken()); + $this->assertFalse($session->expired()); + } + + /** + * @covers Classy\Client::newMemberSessionFromCode + */ + public function testNewMemberSessionFromCode() + { + $this->guzzleMock->shouldReceive('request') + ->once() + ->with('POST', '/oauth2/auth', Mockery::on(function($args) { + return $args['form_params'] === [ + 'grant_type' => 'authorization_code', + 'client_id' => '123', + 'client_secret' => '456', + 'code' => '789' + ]; + })) + ->andReturn(new Response(200, [], "{}")); + + $session = $this->client->newMemberSessionFromCode("789"); + $this->assertInstanceOf(Session::class, $session); + } + + /** + * @covers Classy\Client::newMemberSessionFromCredentials + */ + public function testNewMemberSessionFromCredentials() + { + $this->guzzleMock->shouldReceive('request') + ->once() + ->with('POST', '/oauth2/auth', Mockery::on(function($args) { + return $args['form_params'] === [ + 'grant_type' => 'password', + 'client_id' => '123', + 'client_secret' => '456', + 'username' => 'email@domain.tld', + 'password' => 'pass' + ]; + })) + ->andReturn(new Response(200, [], "{}")); + + $session = $this->client->newMemberSessionFromCredentials("email@domain.tld", "pass"); + $this->assertInstanceOf(Session::class, $session); + } + + /** + * @covers Classy\Client::newMemberSessionFromRefreshToken + */ + public function testNewMemberSessionFromRefreshToken() + { + $this->guzzleMock->shouldReceive('request') + ->once() + ->with('POST', '/oauth2/auth', Mockery::on(function($args) { + return $args['form_params'] === [ + 'grant_type' => 'refresh_token', + 'client_id' => '123', + 'client_secret' => '456', + 'refresh_token' => 'token', + ]; + })) + ->andReturn(new Response(200, [], "{}")); + + $session = $this->client->newMemberSessionFromRefreshToken("token"); + $this->assertInstanceOf(Session::class, $session); + } + + /** + * @covers Classy\Client::refresh + */ + public function testRefreshAppToken() + { + $session = new Session([ + 'access_token' => '12345', + 'expires_in' => -100 + ]); + $this->assertTrue($session->expired()); + + $this->guzzleMock->shouldReceive('request') + ->once() + ->with('POST', '/oauth2/auth', Mockery::on(function($args) { + return $args['form_params'] === [ + 'grant_type' => 'client_credentials', + 'client_id' => '123', + 'client_secret' => '456', + ]; + })) + ->andReturn(new Response(200, [], json_encode([ + "access_token" => '56789', + "expires_in" => 3600 + ]))); + + $this->client->refresh($session); + + $this->assertEquals('56789', $session->getAccessToken()); + $this->assertFalse($session->expired()); + } + + /** + * @covers Classy\Client::refresh + */ + public function testRefreshMemberToken() + { + $session = new Session([ + 'access_token' => '12345', + 'refresh_token' => '55555', + 'expires_in' => 3600 + ]); + + $this->guzzleMock->shouldReceive('request') + ->once() + ->with('POST', '/oauth2/auth', Mockery::on(function($args) { + return $args['form_params'] === [ + 'grant_type' => 'refresh_token', + 'client_id' => '123', + 'client_secret' => '456', + 'refresh_token' => '55555' + ]; + })) + ->andReturn(new Response(200, [], json_encode([ + "access_token" => '56789', + "refresh_token" => '6666', + "expires_in" => 3600 + ]))); + + $this->client->refresh($session); + + $this->assertEquals('56789', $session->getAccessToken()); + $this->assertEquals('6666', $session->getRefreshToken()); + $this->assertFalse($session->expired()); + } + + + public function testRESTVerbsProvider() + { + return [ + ['get', null], + ['delete', null], + ['post', ['payload' => 'content']], + ['put', ['payload' => 'content']], + ]; + + } + + /** + * @dataProvider testRESTVerbsProvider + * @covers Classy\Client::get + * @covers Classy\Client::post + * @covers Classy\Client::delete + * @covers Classy\Client::put + */ + public function testRESTVerbs($verb, array $payload = null) + { + $clientMock = Mockery::mock(Client::class . "[request]", [[ + 'client_id' => '123', + 'client_secret' => '456', + 'version' => '2.0' + ]]); + + $expectation = $clientMock->shouldReceive('request')->once(); + if (is_null($payload)) { + $expectation->with(mb_strtoupper($verb), '/2.0/endpoint', null); + $clientMock->$verb('endpoint'); + } else { + $expectation->with(mb_strtoupper($verb), '/2.0/endpoint', null, Mockery::on(function($args) { + return $args['json'] === ['payload' => 'content']; + })); + $clientMock->$verb('endpoint', null, $payload); + } + } + + /** + * @covers Classy\Client::request + */ + public function testRequest() + { + $session = new Session([ + 'access_token' => 'abcdef', + 'expires_in' => '3600' + ]); + + $this->guzzleMock->shouldReceive('request') + ->once() + ->with( + 'POST', + '/3.0/endpoint', + Mockery::on(function($args) { + return $args === [ + 'json' => ['payload' => 'content'], + 'headers' => ['Authorization' => 'Bearer abcdef'] + ]; + })) + ->andReturn(new Response(200, [], "{}")); + + $this->client->request('POST', '/3.0/endpoint', $session, ['json' => ['payload' => 'content']]); + } + + /** + * @covers Classy\Client::request + */ + public function testRequestWithExpiredSession() + { + $session = new Session([ + 'access_token' => 'abcdef', + 'expires_in' => '-1000' + ]); + + $this->guzzleMock->shouldReceive('request') + ->once() + ->with('POST', '/oauth2/auth', Mockery::on(function($args) { + return $args['form_params'] === [ + 'grant_type' => 'client_credentials', + 'client_id' => '123', + 'client_secret' => '456', + ]; + })) + ->andReturn(new Response(200, [], json_encode([ + "access_token" => '56789', + "expires_in" => 3600 + ]))); + + + $this->guzzleMock->shouldReceive('request') + ->once() + ->with( + 'GET', + '/3.0/endpoint', + Mockery::on(function($args) { + return $args === ['headers' => ['Authorization' => 'Bearer 56789']]; + })) + ->andReturn(new Response(200, [], "{}")); + + $this->client->request('GET', '/3.0/endpoint', $session); + $this->assertFalse($session->expired()); + } + + /** + * @covers Classy\Client::applyVersion + */ + public function testApplyVersion() + { + $testSets = [ + ['2.0', 'endpoint'], + ['2.0 /', '/endpoint'], + [' /2.0/ ', '/endpoint'], + ['/2.0 ', '/ endpoint'], + [' /2.0 ', '/endpoint '], + ['/2.0/ ', '/endpoint / '], + ]; + $method = new ReflectionMethod(Client::class, 'applyVersion'); + $method->setAccessible(true); + + foreach ($testSets as $inputs) { + $result = $method->invoke($this->client, $inputs[0], $inputs[1]); + $this->assertEquals('/2.0/endpoint', $result); + } + } + + /** + * @covers Classy\Client::request + * @covers Classy\Exceptions\APIResponseException + */ + public function testErrorHandling() + { + $client = new Client([ + 'version' => '2.0', + 'client_id' => 'aze', + 'client_secret' => 'aze' + ]); + try { + $client->newAppSession(); + $this->fail('Exception expected'); + } catch (APIResponseException $e) { + $this->assertEquals(400, $e->getCode()); + $this->assertEquals('invalid_request', $e->getResponseData()->error); + $this->assertEquals('application/json; charset=utf-8', $e->getResponseHeaders()['Content-Type'][0]); + } + } +} diff --git a/tests/SessionTest.php b/tests/SessionTest.php new file mode 100644 index 0000000..01406ed --- /dev/null +++ b/tests/SessionTest.php @@ -0,0 +1,41 @@ + 'abc', + 'refresh_token' => 'def', + 'expires_in' => 3600 + ]); + $this->assertEquals('abc', $session->getAccessToken()); + $this->assertEquals('def', $session->getRefreshToken()); + } + + /** + * @covers Classy\Session::serialize + * @covers Classy\Session::unserialize + */ + public function testSerialize() + { + $session = new Session([ + 'access_token' => 'abc', + 'refresh_token' => 'def', + 'expires_in' => 3600 + ]); + + $session2 = unserialize(serialize($session)); + $this->assertEquals($session->getAccessToken(), $session2->getAccessToken()); + $this->assertEquals($session->getRefreshToken(), $session2->getRefreshToken()); + } +} diff --git a/tests/TestCase.php b/tests/TestCase.php new file mode 100644 index 0000000..dd79429 --- /dev/null +++ b/tests/TestCase.php @@ -0,0 +1,33 @@ +guzzleMock = Mockery::mock(\GuzzleHttp\Client::class); + $this->client = new Client([ + 'client_id' => '123', + 'client_secret' => '456', + 'version' => '2.0' + ]); + $this->client->setHttpClient($this->guzzleMock); + parent::setUp(); + } + +}