From ee1d48989e8e3270c5f3f7b21f24ef3028e86d86 Mon Sep 17 00:00:00 2001 From: Manuel Reinhard Date: Thu, 21 Sep 2023 13:59:34 +0200 Subject: [PATCH 01/21] [wip] Refactor to use Guzzle and custom Response class --- composer.json | 2 +- lib/ApiClient/Http/Client.php | 83 ++++++++++++ lib/ApiClient/Http/HttpRequestException.php | 9 ++ lib/ApiClient/Http/Response.php | 36 +++++ lib/ApiClient/TicketparkApiClient.php | 143 ++++++++++---------- 5 files changed, 204 insertions(+), 69 deletions(-) create mode 100644 lib/ApiClient/Http/Client.php create mode 100644 lib/ApiClient/Http/HttpRequestException.php create mode 100644 lib/ApiClient/Http/Response.php diff --git a/composer.json b/composer.json index eb654b9..b71fcce 100644 --- a/composer.json +++ b/composer.json @@ -4,7 +4,7 @@ "type": "library", "require": { "php": ">=8.1", - "kriswallsmith/buzz": "^0.15.0" + "guzzlehttp/guzzle": "^7.8" }, "license": "MIT", "authors": [ diff --git a/lib/ApiClient/Http/Client.php b/lib/ApiClient/Http/Client.php new file mode 100644 index 0000000..33a8158 --- /dev/null +++ b/lib/ApiClient/Http/Client.php @@ -0,0 +1,83 @@ +guzzle = new GuzzleClient(); + } + + public function head(string $url, array $headers): Response + { + return $this->execute('head', $url, $headers); + } + + public function get(string $url, array $headers): Response + { + return $this->execute('get', $url, $headers); + } + + public function post(string $url, string $content, array $headers): Response + { + return $this->execute('post', $url, $headers, $content); + } + + public function postForm(string $url, array $formData, array $headers): Response + { + return $this->execute('post', $url, $headers, null, $formData); + } + + public function patch(string $url, string $content, array $headers): Response + { + return $this->execute('patch', $url, $headers, $content); + } + + public function delete(string $url, array $headers): Response + { + return $this->execute('delete', $url, $headers); + } + + private function execute( + string $method, + string $url, + array $headers = [], + string $content = null, + array $formData = [] + ): Response { + try { + /** @var GuzzleResponse $response */ + $guzzleResponse = $this->guzzle->request( + $method, + $url, + [ + 'headers' => $headers, + 'body' => $content, + 'form_params' => $formData + ] + ); + } catch (\Exception $e) { + if (!$e instanceof ClientException) { + throw new HttpRequestException($e->getMessage()); + } + + /** @var GuzzleResponse $response */ + $guzzleResponse = $e->getResponse(); + } + + return new Response( + $guzzleResponse->getStatusCode(), + (string) $guzzleResponse->getBody(), + $guzzleResponse->getHeaders() + ); + } +} \ No newline at end of file diff --git a/lib/ApiClient/Http/HttpRequestException.php b/lib/ApiClient/Http/HttpRequestException.php new file mode 100644 index 0000000..f71c484 --- /dev/null +++ b/lib/ApiClient/Http/HttpRequestException.php @@ -0,0 +1,9 @@ +statusCode; + } + + public function getContent(): array + { + return json_decode($this->content, true); + } + + public function getHeaders(): array + { + return $this->headers; + } + + public function isSuccessful(): bool + { + return ($this->statusCode >= 200 && $this->statusCode <= 204); + } +} \ No newline at end of file diff --git a/lib/ApiClient/TicketparkApiClient.php b/lib/ApiClient/TicketparkApiClient.php index 5435655..9e2cbc7 100644 --- a/lib/ApiClient/TicketparkApiClient.php +++ b/lib/ApiClient/TicketparkApiClient.php @@ -2,9 +2,8 @@ namespace Ticketpark\ApiClient; -use Buzz\Browser; -use Buzz\Client\Curl; -use Buzz\Message\Response; +use Ticketpark\ApiClient\Http\Client; +use Ticketpark\ApiClient\Http\Response; use Ticketpark\ApiClient\Exception\TokenGenerationException; use Ticketpark\ApiClient\Token\AccessToken; use Ticketpark\ApiClient\Token\RefreshToken; @@ -14,9 +13,9 @@ class TicketparkApiClient private const ROOT_URL = 'https://api.ticketpark.ch'; private const REFRESH_TOKEN_LIFETIME = 30 * 86400; + private ?Client $client = null; private ?string $username = null; private ?string $password = null; - private ?Browser $browser = null; private ?RefreshToken $refreshToken = null; private ?AccessToken $accessToken = null; @@ -26,20 +25,6 @@ public function __construct( ) { } - public function setBrowser(Browser $browser = null): void - { - $this->browser = $browser; - } - - public function getBrowser(): Browser - { - if (null === $this->browser) { - $this->browser = new Browser(new Curl()); - } - - return $this->browser; - } - public function setUserCredentials(string $username, string $password): void { $this->username = $username; @@ -51,11 +36,6 @@ public function getAccessToken(): ?AccessToken return $this->accessToken; } - public function setAccessTokenInstance(AccessToken $accessToken): void - { - $this->accessToken = $accessToken; - } - public function setAccessToken(string $accessToken): void { $this->accessToken = new AccessToken($accessToken); @@ -66,49 +46,51 @@ public function getRefreshToken(): ?RefreshToken return $this->refreshToken; } - public function setRefreshTokenInstance(RefreshToken $refreshToken): void - { - $this->refreshToken = $refreshToken; - } - public function setRefreshToken(string $refreshToken): void { $this->refreshToken = new RefreshToken($refreshToken); } - public function get(string $path, array $parameters = [], array $headers = []): Response + public function head(string $path, array $parameters = []): Response { - $params = ''; - if (count($parameters)) { - $params = '?' . http_build_query($parameters); - } - - return $this->getBrowser()->get(self::ROOT_URL . $path . $params, $this->getDefaultHeaders($headers)); + return $this->getClient()->head( + $this->getUrl($path, $parameters), + $this->getHeaders() + ); } - public function post(string $path, mixed $content = '', array $headers = []): Response + public function get(string $path, array $parameters = []): Response { - return $this->getBrowser()->post(self::ROOT_URL . $path, $this->getDefaultHeaders($headers), json_encode($content, JSON_THROW_ON_ERROR)); + return $this->getClient()->get( + $this->getUrl($path, $parameters), + $this->getHeaders() + ); } - public function head($path, array $parameters = [], array $headers = []): Response + public function post(string $path, array $data = []): Response { - $params = ''; - if (count($parameters)) { - $params = '?' . http_build_query($parameters); - } - - return $this->getBrowser()->head(self::ROOT_URL . $path . $params, $this->getDefaultHeaders($headers)); + return $this->getClient()->post( + $this->getUrl($path), + json_encode($data, JSON_THROW_ON_ERROR), + $this->getHeaders() + ); } - public function patch(string $path, mixed $content = '', array $headers = []): Response + public function patch(string $path, array $data = []): Response { - return $this->getBrowser()->patch(self::ROOT_URL . $path, $this->getDefaultHeaders($headers), json_encode($content, JSON_THROW_ON_ERROR)); + return $this->getClient()->patch( + $this->getUrl($path), + json_encode($data, JSON_THROW_ON_ERROR), + $this->getHeaders() + ); } - public function delete(string $path, array $headers = []): Response + public function delete(string $path): Response { - return $this->getBrowser()->delete(self::ROOT_URL . $path, $this->getDefaultHeaders($headers)); + return $this->getClient()->delete( + $this->getUrl($path), + $this->getHeaders() + ); } public function generateTokens(): void @@ -127,7 +109,7 @@ public function generateTokens(): void } // Try with user credentials - if (!isset($data) && $this->username) { + if ($this->username) { $data = [ 'username' => $this->username, 'password' => $this->password, @@ -142,18 +124,30 @@ public function generateTokens(): void throw new TokenGenerationException('Failed to generate a access tokens. Make sure to provide a valid refresh token or user credentials.'); } - private function getDefaultHeaders(array $customHeaders = []): array + private function getClient(): Client { - $headers = ['Content-Type' => 'application/json', 'Accept' => 'application/json', 'Authorization' => 'Bearer ' . $this->getValidAccessToken()]; + if (null === $this->client) { + $this->client = new Client(); + } - return array_merge($customHeaders, $headers); + return $this->client; + } + + private function getUrl(string $path, array $parameters = []): string + { + $params = ''; + if (count($parameters)) { + $params = '?' . http_build_query($parameters); + } + + return self::ROOT_URL . $path . $params; } private function getValidAccessToken(): string { $accessToken = $this->getAccessToken(); - if (!$accessToken || $accessToken->hasExpired()) { + if (null === $accessToken || $accessToken->hasExpired()) { $this->generateTokens(); $accessToken = $this->getAccessToken(); } @@ -161,7 +155,7 @@ private function getValidAccessToken(): string return $accessToken->getToken(); } - protected function doGenerateTokens(array $data): bool + private function doGenerateTokens(array $data): bool { $headers = [ 'Content-Type' => 'application/x-www-form-urlencoded', @@ -169,24 +163,37 @@ protected function doGenerateTokens(array $data): bool 'Authorization' => 'Basic '.base64_encode($this->apiKey . ':' . $this->apiSecret) ]; - $response = $this->getBrowser()->post(self::ROOT_URL . '/oauth/v2/token', $headers, $data); + $response = $this->getClient()->postForm( + $this->getUrl('/oauth/v2/token'), + $data, + $headers, + ); - if (200 == $response->getStatusCode()) { - $response = json_decode((string) $response->getContent(), true, 512, JSON_THROW_ON_ERROR); + if (!$response->isSuccessful()) { + return false; + } - $this->accessToken = new AccessToken( - $response['access_token'], - (new \DateTime())->setTimestamp(time() + $response['expires_in']) - ); + $content = $response->getContent(); - $this->refreshToken = new RefreshToken( - $response['refresh_token'], - (new \DateTime())->setTimestamp(time() + self::REFRESH_TOKEN_LIFETIME) - ); + $this->accessToken = new AccessToken( + $content['access_token'], + (new \DateTime())->setTimestamp(time() + $content['expires_in']) + ); - return true; - } + $this->refreshToken = new RefreshToken( + $content['refresh_token'], + (new \DateTime())->setTimestamp(time() + self::REFRESH_TOKEN_LIFETIME) + ); - return false; + return true; + } + + private function getHeaders(): array + { + return [ + 'Content-Type' => 'application/json', + 'Accept' => 'application/json', + 'Authorization' => 'Bearer ' . $this->getValidAccessToken() + ]; } } From fb753f3332d30fa3ca98eeb1488cdb7c0de22cf4 Mon Sep 17 00:00:00 2001 From: Manuel Reinhard Date: Wed, 4 Oct 2023 16:50:26 +0200 Subject: [PATCH 02/21] Remove token instance setters from example --- example.php | 2 -- 1 file changed, 2 deletions(-) diff --git a/example.php b/example.php index 9cbd87a..ecf8f53 100644 --- a/example.php +++ b/example.php @@ -12,9 +12,7 @@ // With frequent requests, re-using tokens results in less api requests than using user credentials only. // // $client->setAccessToken('someAccessTokenString'); -// or $client->setAccessTokenInstance(new AccessToken($string, $expiration)); // $client->setRefreshToken('someRefreshToken'); -// or $client->setRefreshTokenInstance($string, $expiration); // 3. Execute the desired command $response = $client->get('/events/', array('maxResults' => 2)); From 6650777e2a0a2ec263a705def4bbb59b8b4edb1a Mon Sep 17 00:00:00 2001 From: Manuel Reinhard Date: Wed, 4 Oct 2023 16:50:44 +0200 Subject: [PATCH 03/21] Remove token instance tests from example --- test/ApiClient/TicketparkApiClientTest.php | 29 +--------------------- 1 file changed, 1 insertion(+), 28 deletions(-) diff --git a/test/ApiClient/TicketparkApiClientTest.php b/test/ApiClient/TicketparkApiClientTest.php index 7cbe205..c970b38 100644 --- a/test/ApiClient/TicketparkApiClientTest.php +++ b/test/ApiClient/TicketparkApiClientTest.php @@ -2,8 +2,6 @@ namespace Ticketpark\ApiClient\Test; -use Buzz\Browser; -use Buzz\Message\Response; use PHPUnit\Framework\TestCase; use Ticketpark\ApiClient\Exception\TokenGenerationException; use Ticketpark\ApiClient\TicketparkApiClient; @@ -18,28 +16,12 @@ public function setUp(): void { $this->apiClient = new TicketparkApiClient('apiKey', 'apiSecret'); } - - public function testDefaultBrowser() - { - $this->assertInstanceOf(Browser::class, $this->apiClient->getBrowser()); - } - public function testSetAccessToken() { $this->apiClient->setAccessToken('foo'); $this->assertInstanceOf(AccessToken::class, $this->apiClient->getAccessToken()); $this->assertEquals('foo', $this->apiClient->getAccessToken()->getToken()); } - - public function testSetAccessTokenInstance() - { - $accessToken = new AccessToken('bar'); - $this->apiClient->setAccessTokenInstance($accessToken); - - $this->assertInstanceOf(AccessToken::class, $this->apiClient->getAccessToken()); - $this->assertEquals('bar', $this->apiClient->getAccessToken()->getToken()); - } - public function testSetRefreshToken() { $this->apiClient->setRefreshToken('foo'); @@ -47,16 +29,7 @@ public function testSetRefreshToken() $this->assertEquals('foo', $this->apiClient->getRefreshToken()->getToken()); } - public function testSetRefreshTokenInstance() - { - $refreshToken = new RefreshToken('bar'); - $this->apiClient->setRefreshTokenInstance($refreshToken); - - $this->assertInstanceOf(RefreshToken::class, $this->apiClient->getRefreshToken()); - $this->assertEquals('bar', $this->apiClient->getRefreshToken()->getToken()); - } - - public function testGenerateTokensWithoutData() + public function testGenerateTokensWithoutDataThrowsException() { $this->expectException(TokenGenerationException::class); From fde6dfa0e4ea90e419321971d695082832da24a6 Mon Sep 17 00:00:00 2001 From: Manuel Reinhard Date: Wed, 11 Oct 2023 12:44:07 +0200 Subject: [PATCH 04/21] Adjust TicketparkApiClientTest --- composer.json | 3 +- lib/ApiClient/Http/Client.php | 2 +- lib/ApiClient/Http/ClientInterface.php | 20 ++ lib/ApiClient/TicketparkApiClient.php | 12 +- test/ApiClient/TicketparkApiClientTest.php | 380 +++++++-------------- 5 files changed, 159 insertions(+), 258 deletions(-) create mode 100644 lib/ApiClient/Http/ClientInterface.php diff --git a/composer.json b/composer.json index b71fcce..de64488 100644 --- a/composer.json +++ b/composer.json @@ -23,6 +23,7 @@ "require-dev": { "phpunit/phpunit": "^9.6", "rector/rector": "^0.18.3", - "friendsofphp/php-cs-fixer": "^3.27" + "friendsofphp/php-cs-fixer": "^3.27", + "phpspec/prophecy": "^1.17" } } diff --git a/lib/ApiClient/Http/Client.php b/lib/ApiClient/Http/Client.php index 33a8158..92a2974 100644 --- a/lib/ApiClient/Http/Client.php +++ b/lib/ApiClient/Http/Client.php @@ -8,7 +8,7 @@ use GuzzleHttp\Exception\ClientException; use GuzzleHttp\Psr7\Response as GuzzleResponse; -final class Client +final class Client implements ClientInterface { private GuzzleClient $guzzle; diff --git a/lib/ApiClient/Http/ClientInterface.php b/lib/ApiClient/Http/ClientInterface.php new file mode 100644 index 0000000..585343d --- /dev/null +++ b/lib/ApiClient/Http/ClientInterface.php @@ -0,0 +1,20 @@ +client = $client; + } + + private function getClient(): ClientInterface { if (null === $this->client) { $this->client = new Client(); @@ -160,7 +166,7 @@ private function doGenerateTokens(array $data): bool $headers = [ 'Content-Type' => 'application/x-www-form-urlencoded', 'Accept' => 'application/json', - 'Authorization' => 'Basic '.base64_encode($this->apiKey . ':' . $this->apiSecret) + 'Authorization' => 'Basic ' . base64_encode($this->apiKey . ':' . $this->apiSecret) ]; $response = $this->getClient()->postForm( diff --git a/test/ApiClient/TicketparkApiClientTest.php b/test/ApiClient/TicketparkApiClientTest.php index c970b38..594d901 100644 --- a/test/ApiClient/TicketparkApiClientTest.php +++ b/test/ApiClient/TicketparkApiClientTest.php @@ -3,30 +3,42 @@ namespace Ticketpark\ApiClient\Test; use PHPUnit\Framework\TestCase; +use Prophecy\Prophet; use Ticketpark\ApiClient\Exception\TokenGenerationException; +use Ticketpark\ApiClient\Http\ClientInterface; +use Ticketpark\ApiClient\Http\Response; use Ticketpark\ApiClient\TicketparkApiClient; use Ticketpark\ApiClient\Token\AccessToken; use Ticketpark\ApiClient\Token\RefreshToken; class TicketparkApiClientTest extends TestCase { - protected $apiClient; + private TicketparkApiClient $apiClient; + private Prophet $prophet; public function setUp(): void { $this->apiClient = new TicketparkApiClient('apiKey', 'apiSecret'); + $this->prophet = new Prophet(); } + + protected function tearDown(): void + { + $this->addToAssertionCount(count($this->prophet->getProphecies())); + } + public function testSetAccessToken() { - $this->apiClient->setAccessToken('foo'); + $this->apiClient->setAccessToken('some-token'); $this->assertInstanceOf(AccessToken::class, $this->apiClient->getAccessToken()); - $this->assertEquals('foo', $this->apiClient->getAccessToken()->getToken()); + $this->assertEquals('some-token', $this->apiClient->getAccessToken()->getToken()); } + public function testSetRefreshToken() { - $this->apiClient->setRefreshToken('foo'); + $this->apiClient->setRefreshToken('some-token'); $this->assertInstanceOf(RefreshToken::class, $this->apiClient->getRefreshToken()); - $this->assertEquals('foo', $this->apiClient->getRefreshToken()->getToken()); + $this->assertEquals('some-token', $this->apiClient->getRefreshToken()->getToken()); } public function testGenerateTokensWithoutDataThrowsException() @@ -36,285 +48,147 @@ public function testGenerateTokensWithoutDataThrowsException() $this->apiClient->generateTokens(); } - public function testGenerateTokensWithUserCredentials() + public function testHead() { - $this->apiClient->setBrowser($this->getBrowserMock([ - 'post' => [ - 'expects' => $this->once(), - 'with' => [ - $this->equalTo('https://api.ticketpark.ch/oauth/v2/token'), - $this->anything(), - $this->equalTo([ - 'username' => 'username', - 'password' => 'password', - 'grant_type' => 'password' - ]), - ], - 'response' => [ - 'status' => 200, - 'content' => [ - 'access_token' => 'accessToken', - 'refresh_token' => 'refreshToken', - 'expires_in' => 60 - ] - ] + $httpClient = $this->prophet->prophesize(ClientInterface::class); + $httpClient->head( + 'https://api.ticketpark.ch/path?foo=bar', + [ + 'Content-Type' => 'application/json', + 'Accept' => 'application/json', + 'Authorization' => 'Bearer some-token' ] - ])); - - $this->apiClient->setUserCredentials('username', 'password'); - $this->apiClient->generateTokens(); + ) + ->willReturn(new Response(200, '', [])) + ->shouldBeCalledOnce(); - $this->assertEquals('accessToken', $this->apiClient->getAccessToken()->getToken()); - $this->assertEquals('refreshToken', $this->apiClient->getRefreshToken()->getToken()); + $this->apiClient->setClient($httpClient->reveal()); + $this->apiClient->setAccessToken('some-token'); + $this->apiClient->head('/path', ['foo' => 'bar']); } - public function testGenerateTokensWithUserCredentialsFails() + public function testGet() { - $this->expectException(TokenGenerationException::class); - - $this->apiClient->setBrowser($this->getBrowserMock([ - 'post' => [ - 'expects' => $this->once(), - 'with' => [ - $this->equalTo('https://api.ticketpark.ch/oauth/v2/token'), - $this->anything(), - $this->equalTo([ - 'username' => 'username', - 'password' => 'password', - 'grant_type' => 'password' - ]), - ], - 'response' => [ - 'status' => 400, - 'content' => '' - ] + $httpClient = $this->prophet->prophesize(ClientInterface::class); + $httpClient->get( + 'https://api.ticketpark.ch/path?foo=bar', + [ + 'Content-Type' => 'application/json', + 'Accept' => 'application/json', + 'Authorization' => 'Bearer some-token' ] - ])); + ) + ->willReturn(new Response(200, '', [])) + ->shouldBeCalledOnce(); - $this->apiClient->setUserCredentials('username', 'password'); - $this->apiClient->generateTokens(); + $this->apiClient->setClient($httpClient->reveal()); + $this->apiClient->setAccessToken('some-token'); + $this->apiClient->get('/path', ['foo' => 'bar']); } - public function testGenerateTokensWithRefreshToken() + public function testPost() { - $this->apiClient->setBrowser($this->getBrowserMock([ - 'post' => [ - 'expects' => $this->once(), - 'with' => [ - $this->equalTo('https://api.ticketpark.ch/oauth/v2/token'), - $this->anything(), - $this->equalTo([ - 'refresh_token' => 'mySavedRefreshToken', - 'grant_type' => 'refresh_token' - ]), - ], - 'response' => [ - 'status' => 200, - 'content' => [ - 'access_token' => 'accessToken', - 'refresh_token' => 'refreshToken', - 'expires_in' => 60 - ] - ] + $httpClient = $this->prophet->prophesize(ClientInterface::class); + $httpClient->post( + 'https://api.ticketpark.ch/path', + '{"foo":"bar"}', + [ + 'Content-Type' => 'application/json', + 'Accept' => 'application/json', + 'Authorization' => 'Bearer some-token' ] - ])); - - $this->apiClient->setRefreshToken('mySavedRefreshToken'); - $this->apiClient->generateTokens(); + ) + ->willReturn(new Response(204, '', [])) + ->shouldBeCalledOnce(); - $this->assertEquals('accessToken', $this->apiClient->getAccessToken()->getToken()); - $this->assertEquals('refreshToken', $this->apiClient->getRefreshToken()->getToken()); + $this->apiClient->setClient($httpClient->reveal()); + $this->apiClient->setAccessToken('some-token'); + $this->apiClient->post('/path', ['foo' => 'bar']); } - public function testGenerateTokensWithRefreshTokenFails() + public function testPatch() { - $this->expectException(TokenGenerationException::class); - - $this->apiClient->setBrowser($this->getBrowserMock([ - 'post' => [ - 'expects' => $this->once(), - 'with' => [ - $this->equalTo('https://api.ticketpark.ch/oauth/v2/token'), - $this->anything(), - $this->equalTo([ - 'refresh_token' => 'mySavedRefreshToken', - 'grant_type' => 'refresh_token' - ]), - ], - 'response' => [ - 'status' => 400, - 'content' => '' - ] + $httpClient = $this->prophet->prophesize(ClientInterface::class); + $httpClient->patch( + 'https://api.ticketpark.ch/path', + '{"foo":"bar"}', + [ + 'Content-Type' => 'application/json', + 'Accept' => 'application/json', + 'Authorization' => 'Bearer some-token' ] - ])); + ) + ->willReturn(new Response(204, '', [])) + ->shouldBeCalledOnce(); - $this->apiClient->setRefreshToken('mySavedRefreshToken'); - $this->apiClient->generateTokens(); + $this->apiClient->setClient($httpClient->reveal()); + $this->apiClient->setAccessToken('some-token'); + $this->apiClient->patch('/path', ['foo' => 'bar']); } - public function testGet() - { - $this->apiClient->setBrowser($this->getBrowserMock([ - 'get' => [ - 'expects' => $this->once(), - 'with' => [ - $this->equalTo('https://api.ticketpark.ch/shows?a=1&b=2&c%5Bd%5D=3'), - $this->equalTo([ - 'Content-Type' => 'application/json', - 'Accept' => 'application/json', - 'Authorization' => 'Bearer myAccessToken', - 'CustomHeader' => 'foo' - ]) - ], - 'response' => [ - 'status' => 200, - 'content' => '' - ] - ], - - ])); - - $this->apiClient->setAccessToken('myAccessToken'); - $this->apiClient->get('/shows', ['a' => 1, 'b' => 2, 'c' => ['d' => 3]], ['CustomHeader' => 'foo']); - } - - public function testHead() - { - $this->apiClient->setBrowser($this->getBrowserMock([ - 'head' => [ - 'expects' => $this->once(), - 'with' => [ - $this->equalTo('https://api.ticketpark.ch/shows?a=1&b=2&c%5Bd%5D=3'), - $this->equalTo([ - 'Content-Type' => 'application/json', - 'Accept' => 'application/json', - 'Authorization' => 'Bearer myAccessToken', - 'CustomHeader' => 'foo' - ]) - ], - 'response' => [ - 'status' => 200, - 'content' => '' - ] - ], - - ])); - - $this->apiClient->setAccessToken('myAccessToken'); - $this->apiClient->head('/shows', ['a' => 1, 'b' => 2, 'c' => ['d' => 3]], ['CustomHeader' => 'foo']); - } - - public function testPatch() + public function testDelete() { - $this->apiClient->setBrowser($this->getBrowserMock([ - 'patch' => [ - 'expects' => $this->once(), - 'with' => [ - $this->equalTo('https://api.ticketpark.ch/shows/foo'), - $this->equalTo([ - 'Content-Type' => 'application/json', - 'Accept' => 'application/json', - 'Authorization' => 'Bearer myAccessToken', - 'CustomHeader' => 'foo' - ]), - $this->equalTo('"content"') - ], - 'response' => [ - 'status' => 200, - 'content' => '' - ] - ], - - ])); + $httpClient = $this->prophet->prophesize(ClientInterface::class); + $httpClient->delete( + 'https://api.ticketpark.ch/path', + [ + 'Content-Type' => 'application/json', + 'Accept' => 'application/json', + 'Authorization' => 'Bearer some-token' + ] + ) + ->willReturn(new Response(204, '', [])) + ->shouldBeCalledOnce(); - $this->apiClient->setAccessToken('myAccessToken'); - $this->apiClient->patch('/shows/foo', 'content', ['CustomHeader' => 'foo']); + $this->apiClient->setClient($httpClient->reveal()); + $this->apiClient->setAccessToken('some-token'); + $this->apiClient->delete('/path'); } - public function testPost() + public function testGenerateTokensWithUsername() { - $this->apiClient->setBrowser($this->getBrowserMock([ - 'post' => [ - 'expects' => $this->once(), - 'with' => [ - $this->equalTo('https://api.ticketpark.ch/shows/foo'), - $this->equalTo([ - 'Content-Type' => 'application/json', - 'Accept' => 'application/json', - 'Authorization' => 'Bearer myAccessToken', - 'CustomHeader' => 'foo' - ]), - $this->equalTo('"content"') - ], - 'response' => [ - 'status' => 200, - 'content' => '' - ] + $httpClient = $this->prophet->prophesize(ClientInterface::class); + $httpClient->postForm( + 'https://api.ticketpark.ch/oauth/v2/token', + [ + 'username' => 'username', + 'password' => 'secret', + 'grant_type' => 'password' ], + [ + 'Content-Type' => 'application/x-www-form-urlencoded', + 'Accept' => 'application/json', + 'Authorization' => 'Basic ' . base64_encode('apiKey:apiSecret') + ] + ) + ->willReturn(new Response(204, '{"access_token": "some-token", "refresh_token": "some-other-token", "expires_in": 600}', [])) + ->shouldBeCalledOnce(); - ])); - - $this->apiClient->setAccessToken('myAccessToken'); - $this->apiClient->post('/shows/foo', 'content', ['CustomHeader' => 'foo']); + $this->apiClient->setClient($httpClient->reveal()); + $this->apiClient->setUserCredentials('username', 'secret'); + $this->apiClient->generateTokens(); } - public function testDelete() + public function testGenerateTokensWithRefreshToken() { - $this->apiClient->setBrowser($this->getBrowserMock([ - 'delete' => [ - 'expects' => $this->once(), - 'with' => [ - $this->equalTo('https://api.ticketpark.ch/shows/foo'), - $this->equalTo([ - 'Content-Type' => 'application/json', - 'Accept' => 'application/json', - 'Authorization' => 'Bearer myAccessToken', - 'CustomHeader' => 'foo' - ]) - ], - 'response' => [ - 'status' => 200, - 'content' => '' - ] + $httpClient = $this->prophet->prophesize(ClientInterface::class); + $httpClient->postForm( + 'https://api.ticketpark.ch/oauth/v2/token', + [ + 'refresh_token' => 'some-refresh-token', + 'grant_type' => 'refresh_token' ], + [ + 'Content-Type' => 'application/x-www-form-urlencoded', + 'Accept' => 'application/json', + 'Authorization' => 'Basic ' . base64_encode('apiKey:apiSecret') + ] + ) + ->willReturn(new Response(204, '{"access_token": "some-token", "refresh_token": "some-other-token", "expires_in": 600}', [])) + ->shouldBeCalledOnce(); - ])); - - $this->apiClient->setAccessToken('myAccessToken'); - $this->apiClient->delete('/shows/foo', ['CustomHeader' => 'foo']); - } - - protected function getBrowserMock($data = []) - { - $browser = $this->getMockBuilder(Browser::class) - ->onlyMethods(['head', 'get', 'post', 'patch', 'delete']) - ->getMock(); - - foreach($data as $method => $params) { - $browser - ->expects($params['expects']) - ->method($method) - ->withConsecutive($params['with']) - ->willReturn($this->getResponseMock($params['response']['status'], $params['response']['content'])); - } - - return $browser; - } - - protected function getResponseMock($status, $content) - { - $response = $this->getMockBuilder(Response::class) - ->onlyMethods(['getStatusCode', 'getContent']) - ->getMock(); - - $response - ->method('getStatusCode') - ->willReturn($status); - - $response - ->method('getContent') - ->willReturn(json_encode($content, JSON_THROW_ON_ERROR)); - - return $response; + $this->apiClient->setClient($httpClient->reveal()); + $this->apiClient->setRefreshToken('some-refresh-token'); + $this->apiClient->generateTokens(); } } From a6e7d302d3ef5bddf09e5485314b642dcee6e168 Mon Sep 17 00:00:00 2001 From: Manuel Reinhard Date: Wed, 11 Oct 2023 13:27:05 +0200 Subject: [PATCH 05/21] Add ResponseTest --- test/ApiClient/Http/ResponseTest.php | 63 ++++++++++++++++++++++++++++ 1 file changed, 63 insertions(+) create mode 100644 test/ApiClient/Http/ResponseTest.php diff --git a/test/ApiClient/Http/ResponseTest.php b/test/ApiClient/Http/ResponseTest.php new file mode 100644 index 0000000..d0cc803 --- /dev/null +++ b/test/ApiClient/Http/ResponseTest.php @@ -0,0 +1,63 @@ +assertSame(200, $response->getStatusCode()); + } + + public function testItReturnsContentAsArray() + { + $response = new Response( + 200, + '{"foo": "bar"}', + [] + ); + + $this->assertSame(['foo' => 'bar'], $response->getContent()); + } + + public function testItReturnsTrueOnSuccessfulStatusCode() + { + $statusCode = 200; + while ($statusCode <= 204) { + + $response = new Response( + $statusCode, + '', + [] + ); + + $this->assertTrue($response->isSuccessful()); + $statusCode++; + } + } + + public function testItReturnsFalseOnUnsuccessfulStatusCode() + { + $statusCode = 100; + while ($statusCode <= 999) { + if ($statusCode < 200 || $statusCode > 204) { + $response = new Response( + $statusCode, + '', + [] + ); + $this->assertFalse($response->isSuccessful()); + } + $statusCode++; + } + } +} \ No newline at end of file From 929bf77a21913c158e63dc267759d74b7e320787 Mon Sep 17 00:00:00 2001 From: Manuel Reinhard Date: Wed, 11 Oct 2023 13:30:25 +0200 Subject: [PATCH 06/21] Add UnexpectedResponseException --- .../Http/UnexpectedResponseException.php | 9 +++++++ lib/ApiClient/TicketparkApiClient.php | 5 ++++ test/ApiClient/TicketparkApiClientTest.php | 27 +++++++++++++++++++ 3 files changed, 41 insertions(+) create mode 100644 lib/ApiClient/Http/UnexpectedResponseException.php diff --git a/lib/ApiClient/Http/UnexpectedResponseException.php b/lib/ApiClient/Http/UnexpectedResponseException.php new file mode 100644 index 0000000..e7db5d7 --- /dev/null +++ b/lib/ApiClient/Http/UnexpectedResponseException.php @@ -0,0 +1,9 @@ +getContent(); + if (!isset($content['access_token']) || !isset($content['refresh_token']) || !isset($content['expires_in'])) { + throw new UnexpectedResponseException('Generating tokens did not receive the expected http response.'); + } + $this->accessToken = new AccessToken( $content['access_token'], (new \DateTime())->setTimestamp(time() + $content['expires_in']) diff --git a/test/ApiClient/TicketparkApiClientTest.php b/test/ApiClient/TicketparkApiClientTest.php index 594d901..a3e9a29 100644 --- a/test/ApiClient/TicketparkApiClientTest.php +++ b/test/ApiClient/TicketparkApiClientTest.php @@ -7,6 +7,7 @@ use Ticketpark\ApiClient\Exception\TokenGenerationException; use Ticketpark\ApiClient\Http\ClientInterface; use Ticketpark\ApiClient\Http\Response; +use Ticketpark\ApiClient\Http\UnexpectedResponseException; use Ticketpark\ApiClient\TicketparkApiClient; use Ticketpark\ApiClient\Token\AccessToken; use Ticketpark\ApiClient\Token\RefreshToken; @@ -191,4 +192,30 @@ public function testGenerateTokensWithRefreshToken() $this->apiClient->setRefreshToken('some-refresh-token'); $this->apiClient->generateTokens(); } + + public function testGenerateTokensThrowsExceptionOnUnexpectedResponse() + { + $this->expectException(UnexpectedResponseException::class); + + $httpClient = $this->prophet->prophesize(ClientInterface::class); + $httpClient->postForm( + 'https://api.ticketpark.ch/oauth/v2/token', + [ + 'username' => 'username', + 'password' => 'secret', + 'grant_type' => 'password' + ], + [ + 'Content-Type' => 'application/x-www-form-urlencoded', + 'Accept' => 'application/json', + 'Authorization' => 'Basic ' . base64_encode('apiKey:apiSecret') + ] + ) + ->willReturn(new Response(204, '{}', [])) + ->shouldBeCalledOnce(); + + $this->apiClient->setClient($httpClient->reveal()); + $this->apiClient->setUserCredentials('username', 'secret'); + $this->apiClient->generateTokens(); + } } From 983299171bb75f3a172a3b4a5cf89e2475b6e0c1 Mon Sep 17 00:00:00 2001 From: Manuel Reinhard Date: Wed, 11 Oct 2023 13:33:52 +0200 Subject: [PATCH 07/21] Move exceptions --- lib/ApiClient/{Http => Exception}/HttpRequestException.php | 2 +- .../{Http => Exception}/UnexpectedResponseException.php | 2 +- lib/ApiClient/Http/Client.php | 1 + lib/ApiClient/TicketparkApiClient.php | 4 ++-- test/ApiClient/TicketparkApiClientTest.php | 2 +- 5 files changed, 6 insertions(+), 5 deletions(-) rename lib/ApiClient/{Http => Exception}/HttpRequestException.php (66%) rename lib/ApiClient/{Http => Exception}/UnexpectedResponseException.php (68%) diff --git a/lib/ApiClient/Http/HttpRequestException.php b/lib/ApiClient/Exception/HttpRequestException.php similarity index 66% rename from lib/ApiClient/Http/HttpRequestException.php rename to lib/ApiClient/Exception/HttpRequestException.php index f71c484..ada8d50 100644 --- a/lib/ApiClient/Http/HttpRequestException.php +++ b/lib/ApiClient/Exception/HttpRequestException.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Ticketpark\ApiClient\Http; +namespace Ticketpark\ApiClient\Exception; class HttpRequestException extends \Exception { diff --git a/lib/ApiClient/Http/UnexpectedResponseException.php b/lib/ApiClient/Exception/UnexpectedResponseException.php similarity index 68% rename from lib/ApiClient/Http/UnexpectedResponseException.php rename to lib/ApiClient/Exception/UnexpectedResponseException.php index e7db5d7..2d33252 100644 --- a/lib/ApiClient/Http/UnexpectedResponseException.php +++ b/lib/ApiClient/Exception/UnexpectedResponseException.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Ticketpark\ApiClient\Http; +namespace Ticketpark\ApiClient\Exception; class UnexpectedResponseException extends \Exception { diff --git a/lib/ApiClient/Http/Client.php b/lib/ApiClient/Http/Client.php index 92a2974..5f49b9b 100644 --- a/lib/ApiClient/Http/Client.php +++ b/lib/ApiClient/Http/Client.php @@ -7,6 +7,7 @@ use GuzzleHttp\Client as GuzzleClient; use GuzzleHttp\Exception\ClientException; use GuzzleHttp\Psr7\Response as GuzzleResponse; +use Ticketpark\ApiClient\Exception\HttpRequestException; final class Client implements ClientInterface { diff --git a/lib/ApiClient/TicketparkApiClient.php b/lib/ApiClient/TicketparkApiClient.php index 4b17145..cafdd4f 100644 --- a/lib/ApiClient/TicketparkApiClient.php +++ b/lib/ApiClient/TicketparkApiClient.php @@ -2,11 +2,11 @@ namespace Ticketpark\ApiClient; +use Ticketpark\ApiClient\Exception\TokenGenerationException; +use Ticketpark\ApiClient\Exception\UnexpectedResponseException; use Ticketpark\ApiClient\Http\Client; use Ticketpark\ApiClient\Http\ClientInterface; use Ticketpark\ApiClient\Http\Response; -use Ticketpark\ApiClient\Exception\TokenGenerationException; -use Ticketpark\ApiClient\Http\UnexpectedResponseException; use Ticketpark\ApiClient\Token\AccessToken; use Ticketpark\ApiClient\Token\RefreshToken; diff --git a/test/ApiClient/TicketparkApiClientTest.php b/test/ApiClient/TicketparkApiClientTest.php index a3e9a29..be4a6d7 100644 --- a/test/ApiClient/TicketparkApiClientTest.php +++ b/test/ApiClient/TicketparkApiClientTest.php @@ -5,9 +5,9 @@ use PHPUnit\Framework\TestCase; use Prophecy\Prophet; use Ticketpark\ApiClient\Exception\TokenGenerationException; +use Ticketpark\ApiClient\Exception\UnexpectedResponseException; use Ticketpark\ApiClient\Http\ClientInterface; use Ticketpark\ApiClient\Http\Response; -use Ticketpark\ApiClient\Http\UnexpectedResponseException; use Ticketpark\ApiClient\TicketparkApiClient; use Ticketpark\ApiClient\Token\AccessToken; use Ticketpark\ApiClient\Token\RefreshToken; From cdb9a87809cfce6426ba852722cb8f37899118a4 Mon Sep 17 00:00:00 2001 From: Manuel Reinhard Date: Wed, 11 Oct 2023 13:55:52 +0200 Subject: [PATCH 08/21] Add getting pid and links of generated elements from Response --- lib/ApiClient/Http/Response.php | 61 +++++++++++++++++++- lib/ApiClient/TicketparkApiClient.php | 2 +- test/ApiClient/Http/ResponseTest.php | 80 +++++++++++++++++++++++++++ 3 files changed, 139 insertions(+), 4 deletions(-) diff --git a/lib/ApiClient/Http/Response.php b/lib/ApiClient/Http/Response.php index e773f72..51e9605 100644 --- a/lib/ApiClient/Http/Response.php +++ b/lib/ApiClient/Http/Response.php @@ -4,12 +4,14 @@ namespace Ticketpark\ApiClient\Http; +use Ticketpark\ApiClient\TicketparkApiClient; + class Response { public function __construct( - private int $statusCode, - private string $content, - private array $headers + private readonly int $statusCode, + private readonly string $content, + private readonly array $headers ) { } @@ -33,4 +35,57 @@ public function isSuccessful(): bool { return ($this->statusCode >= 200 && $this->statusCode <= 204); } + + /** + * After creating a single record with POST, use this method + * to get the PID of the newly created record. + */ + function getGeneratedPid(): ?string + { + $lastElement = $this->getLastElementOfLocationHeader(); + + if ($lastElement && !str_contains($lastElement, 'batchId')) { + return $lastElement; + } + + return null; + } + + /** + * After creating multiple records with POST, use this method + * to get the URL where the newly created elements can be fetched with a new GET request. + */ + function getGeneratedListLink(): ?string + { + $lastElement = $this->getLastElementOfLocationHeader(); + + if ($lastElement && str_contains($lastElement, 'batchId')) { + + return str_replace(TicketparkApiClient::ROOT_URL, '', $this->getLocationHeaderContent()); + } + + return null; + } + + private function getLastElementOfLocationHeader(): ?string + { + $location = $this->getLocationHeaderContent(); + + if ($location) { + return trim(preg_replace('/^.*\//', '', $location)); + } + + return null; + } + + private function getLocationHeaderContent(): ?string + { + foreach($this->getHeaders() as $header){ + if (str_starts_with(strtolower($header), 'location:')) { + return trim(preg_replace('/^.+?:/', '', $header)); + } + } + + return null; + } } \ No newline at end of file diff --git a/lib/ApiClient/TicketparkApiClient.php b/lib/ApiClient/TicketparkApiClient.php index cafdd4f..93bcf3b 100644 --- a/lib/ApiClient/TicketparkApiClient.php +++ b/lib/ApiClient/TicketparkApiClient.php @@ -12,7 +12,7 @@ class TicketparkApiClient { - private const ROOT_URL = 'https://api.ticketpark.ch'; + public const ROOT_URL = 'https://api.ticketpark.ch'; private const REFRESH_TOKEN_LIFETIME = 30 * 86400; private ?ClientInterface $client = null; diff --git a/test/ApiClient/Http/ResponseTest.php b/test/ApiClient/Http/ResponseTest.php index d0cc803..84282f9 100644 --- a/test/ApiClient/Http/ResponseTest.php +++ b/test/ApiClient/Http/ResponseTest.php @@ -60,4 +60,84 @@ public function testItReturnsFalseOnUnsuccessfulStatusCode() $statusCode++; } } + + public function testGettingGeneratedPid() + { + $response = new Response( + 204, + '', + [ 'Some-Header: something', + 'Location: https://api.ticketpark.ch/some-entity/some-uuid' + ] + ); + + $this->assertSame('some-uuid', $response->getGeneratedPid()); + } + + public function testGettingGeneratedPidReturnsNullIfInexistent() + { + $response = new Response( + 204, + '', + [ + 'Some-Header: something' + ] + ); + + $this->assertNull($response->getGeneratedPid()); + } + + public function testGettingGeneratedPidReturnsNullIfListLinkAvailable() + { + $response = new Response( + 204, + '', + [ + 'Some-Header: something', + 'Location: https://api.ticketpark.ch/some-entity/filters[batchId]=some-uuid&orderBy[batchOrder]=asc' + ] + ); + + $this->assertNull($response->getGeneratedPid()); + } + + public function testGettingGeneratedListLink() + { + $response = new Response( + 204, + '', + [ 'Some-Header: something', + 'Location: https://api.ticketpark.ch/some-entity/filters[batchId]=some-uuid&orderBy[batchOrder]=asc' + ] + ); + + $this->assertSame('/some-entity/filters[batchId]=some-uuid&orderBy[batchOrder]=asc', $response->getGeneratedListLink()); + } + + public function testGettingGeneratedListLinkReturnsNullIfInexistent() + { + $response = new Response( + 204, + '', + [ + 'Some-Header: something', + ] + ); + + $this->assertNull($response->getGeneratedListLink()); + } + + public function testGettingGeneratedListLinkReturnsNullIfPidAvailable() + { + $response = new Response( + 204, + '', + [ + 'Some-Header: something', + 'Location: https://api.ticketpark.ch/some-entity/some-uuid' + ] + ); + + $this->assertNull($response->getGeneratedListLink()); + } } \ No newline at end of file From 82fb68ba36ab7d64b8fc1a57779294ba67546450 Mon Sep 17 00:00:00 2001 From: Manuel Reinhard Date: Wed, 11 Oct 2023 14:16:48 +0200 Subject: [PATCH 09/21] Add HttpTimeOutException --- lib/ApiClient/Exception/HttpTimeOutException.php | 9 +++++++++ lib/ApiClient/Http/Client.php | 15 +++++++++++---- 2 files changed, 20 insertions(+), 4 deletions(-) create mode 100644 lib/ApiClient/Exception/HttpTimeOutException.php diff --git a/lib/ApiClient/Exception/HttpTimeOutException.php b/lib/ApiClient/Exception/HttpTimeOutException.php new file mode 100644 index 0000000..5ffce71 --- /dev/null +++ b/lib/ApiClient/Exception/HttpTimeOutException.php @@ -0,0 +1,9 @@ + $headers, 'body' => $content, - 'form_params' => $formData + 'form_params' => $formData, + 'timeout' => 30 ] ); - } catch (\Exception $e) { - if (!$e instanceof ClientException) { - throw new HttpRequestException($e->getMessage()); + } catch (ConnectException $e) { + if (str_contains($e->getMessage(), 'cURL error 28')) { + throw new HttpTimeOutException(); } + } catch (ClientException $e) { /** @var GuzzleResponse $response */ $guzzleResponse = $e->getResponse(); + + } catch (\Exception $e) { + throw new HttpRequestException($e->getMessage()); } return new Response( From 0937fc93b203a4606a1aa734563f4bcaf2017b82 Mon Sep 17 00:00:00 2001 From: Manuel Reinhard Date: Wed, 11 Oct 2023 14:33:19 +0200 Subject: [PATCH 10/21] Update example --- example.php | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/example.php b/example.php index ecf8f53..91e98e5 100644 --- a/example.php +++ b/example.php @@ -15,19 +15,18 @@ // $client->setRefreshToken('someRefreshToken'); // 3. Execute the desired command -$response = $client->get('/events/', array('maxResults' => 2)); +$response = $client->get('/events/', ['maxResults' => 2]); // 4. Handle the response -// It is an instance of Buzz\Message\Response if ($response->isSuccessful()) { - print "Request successful!
"; - $events = json_decode($response->getContent(), true); + print "Request successful!\n\n"; + $events = $response->getContent(); foreach($events as $event) { - print $event['name']."
"; + print $event['name']."\n"; } } -// 5. Get the tokens and store them to use them again later on +// 5. Recommended: Get the tokens and store them to use them again later on $myAccessToken = $client->getAccessToken(); $myRefreshToken = $client->getRefreshToken(); \ No newline at end of file From c7f27fa09443759f4b7ed765e0b8725784526615 Mon Sep 17 00:00:00 2001 From: Manuel Reinhard Date: Wed, 11 Oct 2023 14:33:31 +0200 Subject: [PATCH 11/21] Adjust dependencies --- README.md | 65 ++++++++++++++++++++++++++++++++++++++++++++++++++- composer.json | 4 ++-- 2 files changed, 66 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 67606f5..46f8bdd 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,70 @@ composer require ticketpark/php-api-client ``` ## Usage -See example.php + +### Getting data (GET) + +```php +setUserCredentials('your@username.com', 'yourPassword'); + +$response = $client->get('/events/', ['maxResults' => 2]); + +if ($response->isSuccessful()) { + $data = $response->getContent(); +} +``` + +### Creating data (POST) + +```php +setUserCredentials('your@username.com', 'yourPassword'); + +$response = $client->post('/events/', [ + 'host' => 'yourHostPid', + 'name' => 'Some great event', + 'currency' => 'CHF' +]); + +if ($response->isSuccessful()) { + $pidOfNewEvent = $response->getGeneratedPid(); + + // if you created a collection of records, the response will contain a link instead + // that can be used to fetch the data of the newly generated records. + // + // $path = $response->getGeneratedListLink(); + // $newResponse = $client->get($path); +} +``` + +### Updating data (PATCH) + +```php +setUserCredentials('your@username.com', 'yourPassword'); + +$response = $client->patch('/events/yourEventPid', [ + 'name' => 'Some changed event name' +] + +if ($response->isSuccessful()) { + // Data was successfully updated +} +``` + ## User credentials Get in touch with us to get your user credentials: diff --git a/composer.json b/composer.json index de64488..842504d 100644 --- a/composer.json +++ b/composer.json @@ -3,8 +3,8 @@ "description": "A PHP client to use the Ticketpark API", "type": "library", "require": { - "php": ">=8.1", - "guzzlehttp/guzzle": "^7.8" + "php": "^8.1|^8.2", + "guzzlehttp/guzzle": "^6.5|^7.5" }, "license": "MIT", "authors": [ From e5008bd921cb95aceb4974e45ec955ce268691f6 Mon Sep 17 00:00:00 2001 From: Manuel Reinhard Date: Wed, 11 Oct 2023 14:34:58 +0200 Subject: [PATCH 12/21] Fix codestyle --- lib/ApiClient/Http/Client.php | 2 +- lib/ApiClient/Http/ClientInterface.php | 2 +- lib/ApiClient/Http/Response.php | 11 +++++------ 3 files changed, 7 insertions(+), 8 deletions(-) diff --git a/lib/ApiClient/Http/Client.php b/lib/ApiClient/Http/Client.php index c402c4f..bcaccc5 100644 --- a/lib/ApiClient/Http/Client.php +++ b/lib/ApiClient/Http/Client.php @@ -88,4 +88,4 @@ private function execute( $guzzleResponse->getHeaders() ); } -} \ No newline at end of file +} diff --git a/lib/ApiClient/Http/ClientInterface.php b/lib/ApiClient/Http/ClientInterface.php index 585343d..3da2002 100644 --- a/lib/ApiClient/Http/ClientInterface.php +++ b/lib/ApiClient/Http/ClientInterface.php @@ -17,4 +17,4 @@ public function postForm(string $url, array $formData, array $headers): Response public function patch(string $url, string $content, array $headers): Response; public function delete(string $url, array $headers): Response; -} \ No newline at end of file +} diff --git a/lib/ApiClient/Http/Response.php b/lib/ApiClient/Http/Response.php index 51e9605..77e1802 100644 --- a/lib/ApiClient/Http/Response.php +++ b/lib/ApiClient/Http/Response.php @@ -12,8 +12,7 @@ public function __construct( private readonly int $statusCode, private readonly string $content, private readonly array $headers - ) - { + ) { } public function getStatusCode(): int @@ -40,7 +39,7 @@ public function isSuccessful(): bool * After creating a single record with POST, use this method * to get the PID of the newly created record. */ - function getGeneratedPid(): ?string + public function getGeneratedPid(): ?string { $lastElement = $this->getLastElementOfLocationHeader(); @@ -55,7 +54,7 @@ function getGeneratedPid(): ?string * After creating multiple records with POST, use this method * to get the URL where the newly created elements can be fetched with a new GET request. */ - function getGeneratedListLink(): ?string + public function getGeneratedListLink(): ?string { $lastElement = $this->getLastElementOfLocationHeader(); @@ -80,7 +79,7 @@ private function getLastElementOfLocationHeader(): ?string private function getLocationHeaderContent(): ?string { - foreach($this->getHeaders() as $header){ + foreach($this->getHeaders() as $header) { if (str_starts_with(strtolower($header), 'location:')) { return trim(preg_replace('/^.+?:/', '', $header)); } @@ -88,4 +87,4 @@ private function getLocationHeaderContent(): ?string return null; } -} \ No newline at end of file +} From f180459881e5439e1be8218fb8e6bc772e935f7b Mon Sep 17 00:00:00 2001 From: Manuel Reinhard Date: Wed, 11 Oct 2023 14:38:13 +0200 Subject: [PATCH 13/21] Remove obsolete .travis.yml --- .travis.yml | 11 ----------- 1 file changed, 11 deletions(-) delete mode 100644 .travis.yml diff --git a/.travis.yml b/.travis.yml deleted file mode 100644 index 77e119c..0000000 --- a/.travis.yml +++ /dev/null @@ -1,11 +0,0 @@ -language: php - -php: - - 5.6 - - 7.0 - -before_script: - - composer install --prefer-source - -script: - - phpunit \ No newline at end of file From 9a436a8f68f544f8d75be1dc51fa83ddfa942ebe Mon Sep 17 00:00:00 2001 From: Manuel Reinhard Date: Wed, 11 Oct 2023 14:39:53 +0200 Subject: [PATCH 14/21] Apply code improvements by Rector --- lib/ApiClient/Http/Client.php | 2 +- lib/ApiClient/Http/Response.php | 6 +++--- lib/ApiClient/TicketparkApiClient.php | 2 +- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/lib/ApiClient/Http/Client.php b/lib/ApiClient/Http/Client.php index bcaccc5..fcfef2f 100644 --- a/lib/ApiClient/Http/Client.php +++ b/lib/ApiClient/Http/Client.php @@ -13,7 +13,7 @@ final class Client implements ClientInterface { - private GuzzleClient $guzzle; + private readonly GuzzleClient $guzzle; public function __construct() { diff --git a/lib/ApiClient/Http/Response.php b/lib/ApiClient/Http/Response.php index 77e1802..b76d49e 100644 --- a/lib/ApiClient/Http/Response.php +++ b/lib/ApiClient/Http/Response.php @@ -22,7 +22,7 @@ public function getStatusCode(): int public function getContent(): array { - return json_decode($this->content, true); + return json_decode($this->content, true, 512, JSON_THROW_ON_ERROR); } public function getHeaders(): array @@ -80,8 +80,8 @@ private function getLastElementOfLocationHeader(): ?string private function getLocationHeaderContent(): ?string { foreach($this->getHeaders() as $header) { - if (str_starts_with(strtolower($header), 'location:')) { - return trim(preg_replace('/^.+?:/', '', $header)); + if (str_starts_with(strtolower((string) $header), 'location:')) { + return trim(preg_replace('/^.+?:/', '', (string) $header)); } } diff --git a/lib/ApiClient/TicketparkApiClient.php b/lib/ApiClient/TicketparkApiClient.php index 93bcf3b..c05e48f 100644 --- a/lib/ApiClient/TicketparkApiClient.php +++ b/lib/ApiClient/TicketparkApiClient.php @@ -12,7 +12,7 @@ class TicketparkApiClient { - public const ROOT_URL = 'https://api.ticketpark.ch'; + final public const ROOT_URL = 'https://api.ticketpark.ch'; private const REFRESH_TOKEN_LIFETIME = 30 * 86400; private ?ClientInterface $client = null; From 24d58facc259d29968adb86388f3718b18f8ab0f Mon Sep 17 00:00:00 2001 From: Manuel Reinhard Date: Wed, 11 Oct 2023 14:42:13 +0200 Subject: [PATCH 15/21] Update README --- README.md | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 46f8bdd..58dc306 100644 --- a/README.md +++ b/README.md @@ -1,10 +1,10 @@ # Ticketpark PHP API Client -A basic api client to consume the Ticketpark REST API. +A PHP client to consume the Ticketpark REST API. ## Installation -Simply add this library to your composer.json: +Add this library to your composer.json: ``` composer require ticketpark/php-api-client @@ -12,6 +12,8 @@ composer require ticketpark/php-api-client ## Usage +Also see `example.php`. + ### Getting data (GET) ```php @@ -77,5 +79,5 @@ if ($response->isSuccessful()) { ## User credentials -Get in touch with us to get your user credentials: -[tech@ticketpark.ch](mailto:tech@ticketpark.ch), [www.ticketpark.ch](http://www.ticketpark.ch) +Get in touch with us to get your user credentials:
+[support@ticketpark.ch](mailto:support@ticketpark.ch) From 59d1f6880749035b5b43fe301357f73e0f9ffe3f Mon Sep 17 00:00:00 2001 From: Manuel Reinhard Date: Wed, 11 Oct 2023 15:48:53 +0200 Subject: [PATCH 16/21] Fix client to distinguish between forms and other contents --- lib/ApiClient/Http/Client.php | 34 +++++++++++++++++++++++----------- 1 file changed, 23 insertions(+), 11 deletions(-) diff --git a/lib/ApiClient/Http/Client.php b/lib/ApiClient/Http/Client.php index fcfef2f..3f1d90b 100644 --- a/lib/ApiClient/Http/Client.php +++ b/lib/ApiClient/Http/Client.php @@ -58,17 +58,29 @@ private function execute( array $formData = [] ): Response { try { - /** @var GuzzleResponse $response */ - $guzzleResponse = $this->guzzle->request( - $method, - $url, - [ - 'headers' => $headers, - 'body' => $content, - 'form_params' => $formData, - 'timeout' => 30 - ] - ); + if ($formData) { + /** @var GuzzleResponse $response */ + $guzzleResponse = $this->guzzle->request( + $method, + $url, + [ + 'headers' => $headers, + 'form_params' => $formData, + 'timeout' => 30 + ] + ); + } else { + /** @var GuzzleResponse $response */ + $guzzleResponse = $this->guzzle->request( + $method, + $url, + [ + 'headers' => $headers, + 'body' => $content, + 'timeout' => 30 + ] + ); + } } catch (ConnectException $e) { if (str_contains($e->getMessage(), 'cURL error 28')) { throw new HttpTimeOutException(); From 1e7e999a8aea79fb3bdf6ed3364a59c4118874d0 Mon Sep 17 00:00:00 2001 From: Manuel Reinhard Date: Thu, 12 Oct 2023 12:43:07 +0200 Subject: [PATCH 17/21] Add test badge --- .github/workflows/{tests.yml => ci.yml} | 0 README.md | 3 +++ 2 files changed, 3 insertions(+) rename .github/workflows/{tests.yml => ci.yml} (100%) diff --git a/.github/workflows/tests.yml b/.github/workflows/ci.yml similarity index 100% rename from .github/workflows/tests.yml rename to .github/workflows/ci.yml diff --git a/README.md b/README.md index 58dc306..389925c 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,8 @@ # Ticketpark PHP API Client +[![Build Status](https://github.com/Ticketpark/php-api-client/actions/workflows/ci.yml/badge.svg)](https://github.com/sprain/php-swiss-qr-bill/actions) + + A PHP client to consume the Ticketpark REST API. ## Installation From 8eece4e7b519505c2f14c75f501ac78efe829b4b Mon Sep 17 00:00:00 2001 From: Manuel Reinhard Date: Thu, 12 Oct 2023 12:56:08 +0200 Subject: [PATCH 18/21] Allow to set token expiration --- lib/ApiClient/TicketparkApiClient.php | 8 ++++---- test/ApiClient/TicketparkApiClientTest.php | 10 ++++++++-- 2 files changed, 12 insertions(+), 6 deletions(-) diff --git a/lib/ApiClient/TicketparkApiClient.php b/lib/ApiClient/TicketparkApiClient.php index c05e48f..dff9485 100644 --- a/lib/ApiClient/TicketparkApiClient.php +++ b/lib/ApiClient/TicketparkApiClient.php @@ -38,9 +38,9 @@ public function getAccessToken(): ?AccessToken return $this->accessToken; } - public function setAccessToken(string $accessToken): void + public function setAccessToken(string $accessToken, ?\DateTime $expiration = null): void { - $this->accessToken = new AccessToken($accessToken); + $this->accessToken = new AccessToken($accessToken, $expiration); } public function getRefreshToken(): ?RefreshToken @@ -48,9 +48,9 @@ public function getRefreshToken(): ?RefreshToken return $this->refreshToken; } - public function setRefreshToken(string $refreshToken): void + public function setRefreshToken(string $refreshToken, ?\DateTime $expiration = null): void { - $this->refreshToken = new RefreshToken($refreshToken); + $this->refreshToken = new RefreshToken($refreshToken, $expiration); } public function head(string $path, array $parameters = []): Response diff --git a/test/ApiClient/TicketparkApiClientTest.php b/test/ApiClient/TicketparkApiClientTest.php index be4a6d7..24fe2da 100644 --- a/test/ApiClient/TicketparkApiClientTest.php +++ b/test/ApiClient/TicketparkApiClientTest.php @@ -30,16 +30,22 @@ protected function tearDown(): void public function testSetAccessToken() { - $this->apiClient->setAccessToken('some-token'); + $expiration = new \DateTime('2035-01-01 12:00:00'); + $this->apiClient->setAccessToken('some-token', $expiration); + $this->assertInstanceOf(AccessToken::class, $this->apiClient->getAccessToken()); $this->assertEquals('some-token', $this->apiClient->getAccessToken()->getToken()); + $this->assertEquals($expiration, $this->apiClient->getAccessToken()->getExpiration()); } public function testSetRefreshToken() { - $this->apiClient->setRefreshToken('some-token'); + $expiration = new \DateTime('2035-01-01 12:00:00'); + $this->apiClient->setRefreshToken('some-token', $expiration); + $this->assertInstanceOf(RefreshToken::class, $this->apiClient->getRefreshToken()); $this->assertEquals('some-token', $this->apiClient->getRefreshToken()->getToken()); + $this->assertEquals($expiration, $this->apiClient->getRefreshToken()->getExpiration()); } public function testGenerateTokensWithoutDataThrowsException() From 9bc166939d8dc52539cf29a38da22e7399a82bb5 Mon Sep 17 00:00:00 2001 From: Manuel Reinhard Date: Thu, 12 Oct 2023 12:56:17 +0200 Subject: [PATCH 19/21] Remove unneeded whitespace --- lib/ApiClient/Http/Response.php | 1 - 1 file changed, 1 deletion(-) diff --git a/lib/ApiClient/Http/Response.php b/lib/ApiClient/Http/Response.php index b76d49e..ad5c265 100644 --- a/lib/ApiClient/Http/Response.php +++ b/lib/ApiClient/Http/Response.php @@ -59,7 +59,6 @@ public function getGeneratedListLink(): ?string $lastElement = $this->getLastElementOfLocationHeader(); if ($lastElement && str_contains($lastElement, 'batchId')) { - return str_replace(TicketparkApiClient::ROOT_URL, '', $this->getLocationHeaderContent()); } From 422418a6f0f461436b4127acaef93096db6d1084 Mon Sep 17 00:00:00 2001 From: Manuel Reinhard Date: Thu, 12 Oct 2023 13:11:53 +0200 Subject: [PATCH 20/21] Refactor Client --- lib/ApiClient/Http/Client.php | 59 +++++++++++++++++++++-------------- 1 file changed, 36 insertions(+), 23 deletions(-) diff --git a/lib/ApiClient/Http/Client.php b/lib/ApiClient/Http/Client.php index 3f1d90b..d66000d 100644 --- a/lib/ApiClient/Http/Client.php +++ b/lib/ApiClient/Http/Client.php @@ -58,29 +58,14 @@ private function execute( array $formData = [] ): Response { try { - if ($formData) { - /** @var GuzzleResponse $response */ - $guzzleResponse = $this->guzzle->request( - $method, - $url, - [ - 'headers' => $headers, - 'form_params' => $formData, - 'timeout' => 30 - ] - ); - } else { - /** @var GuzzleResponse $response */ - $guzzleResponse = $this->guzzle->request( - $method, - $url, - [ - 'headers' => $headers, - 'body' => $content, - 'timeout' => 30 - ] - ); - } + $guzzleResponse = $this->doExecute( + $method, + $url, + $headers, + $content, + $formData + ); + } catch (ConnectException $e) { if (str_contains($e->getMessage(), 'cURL error 28')) { throw new HttpTimeOutException(); @@ -100,4 +85,32 @@ private function execute( $guzzleResponse->getHeaders() ); } + + private function doExecute( + string $method, + string $url, + array $headers, + ?string $content, + array $formData + ): GuzzleResponse { + $requestData = [ + 'headers' => $headers, + 'timeout' => 30 + ]; + + if ($formData) { + $requestData['form_params'] = $formData; + } else { + $requestData['body'] = $content; + } + + /** @var GuzzleResponse $response */ + $guzzleResponse = $this->guzzle->request( + $method, + $url, + $requestData + ); + + return $guzzleResponse; + } } From e13f765b02089ea728b8259f480e2db0fde51f2d Mon Sep 17 00:00:00 2001 From: Manuel Reinhard Date: Thu, 12 Oct 2023 14:03:16 +0200 Subject: [PATCH 21/21] Fix getting http headers --- lib/ApiClient/Http/Response.php | 6 +++--- test/ApiClient/Http/ResponseTest.php | 22 ++++++++++++---------- 2 files changed, 15 insertions(+), 13 deletions(-) diff --git a/lib/ApiClient/Http/Response.php b/lib/ApiClient/Http/Response.php index ad5c265..e915593 100644 --- a/lib/ApiClient/Http/Response.php +++ b/lib/ApiClient/Http/Response.php @@ -78,9 +78,9 @@ private function getLastElementOfLocationHeader(): ?string private function getLocationHeaderContent(): ?string { - foreach($this->getHeaders() as $header) { - if (str_starts_with(strtolower((string) $header), 'location:')) { - return trim(preg_replace('/^.+?:/', '', (string) $header)); + foreach($this->getHeaders() as $key => $content) { + if (strtolower($key) === 'location') { + return trim($content[0]); } } diff --git a/test/ApiClient/Http/ResponseTest.php b/test/ApiClient/Http/ResponseTest.php index 84282f9..ab65970 100644 --- a/test/ApiClient/Http/ResponseTest.php +++ b/test/ApiClient/Http/ResponseTest.php @@ -66,8 +66,9 @@ public function testGettingGeneratedPid() $response = new Response( 204, '', - [ 'Some-Header: something', - 'Location: https://api.ticketpark.ch/some-entity/some-uuid' + [ + 'Some-Header' => ['something'], + 'Location' => ['https://api.ticketpark.ch/some-entity/some-uuid'] ] ); @@ -80,7 +81,7 @@ public function testGettingGeneratedPidReturnsNullIfInexistent() 204, '', [ - 'Some-Header: something' + 'Some-Header' => ['something'] ] ); @@ -93,8 +94,8 @@ public function testGettingGeneratedPidReturnsNullIfListLinkAvailable() 204, '', [ - 'Some-Header: something', - 'Location: https://api.ticketpark.ch/some-entity/filters[batchId]=some-uuid&orderBy[batchOrder]=asc' + 'Some-Header' => ['something'], + 'Location' => ['https://api.ticketpark.ch/some-entity/filters[batchId]=some-uuid&orderBy[batchOrder]=asc'] ] ); @@ -106,8 +107,9 @@ public function testGettingGeneratedListLink() $response = new Response( 204, '', - [ 'Some-Header: something', - 'Location: https://api.ticketpark.ch/some-entity/filters[batchId]=some-uuid&orderBy[batchOrder]=asc' + [ + 'Some-Header' => ['something'], + 'Location' => ['https://api.ticketpark.ch/some-entity/filters[batchId]=some-uuid&orderBy[batchOrder]=asc'] ] ); @@ -120,7 +122,7 @@ public function testGettingGeneratedListLinkReturnsNullIfInexistent() 204, '', [ - 'Some-Header: something', + 'Some-Header' => ['something'] ] ); @@ -133,8 +135,8 @@ public function testGettingGeneratedListLinkReturnsNullIfPidAvailable() 204, '', [ - 'Some-Header: something', - 'Location: https://api.ticketpark.ch/some-entity/some-uuid' + 'Some-Header' => ['something'], + 'Location' => ['https://api.ticketpark.ch/some-entity/some-uuid'] ] );