From 5b15e9232b1fe909d7d7d6fc430328588270413f Mon Sep 17 00:00:00 2001 From: leightonthomas Date: Thu, 15 Nov 2018 09:52:54 +0000 Subject: [PATCH 01/11] [BRA-1811] Add HTTPlug dependency --- composer.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/composer.json b/composer.json index 858853e..7e308e4 100644 --- a/composer.json +++ b/composer.json @@ -14,7 +14,8 @@ "php": ">=7.1", "guzzlehttp/guzzle": "~5.0", "symfony/options-resolver": "^2.2|^3.4", - "doctrine/cache": "~1.4" + "doctrine/cache": "~1.4", + "php-http/httplug": "^2.0" }, "require-dev": { "mikey179/vfsStream": "^1.6", From 5639dea12996c8e0ac3c4ff8cdc887cbcfde3371 Mon Sep 17 00:00:00 2001 From: leightonthomas Date: Thu, 15 Nov 2018 13:04:31 +0000 Subject: [PATCH 02/11] [BRA-1811] Add HTTPlug functionality to DocBuild * Refactor DocBuild client * Remove old adapters * Remove PHPSpec * Add PHPUnit tests --- .travis.yml | 2 +- README.md | 55 +- composer.json | 14 +- phpunit.xml | 23 + spec/Vivait/DocBuild/DocBuildSpec.php | 375 ------------ .../DocBuild/Http/GuzzleAdapterSpec.php | 196 ------ src/Vivait/DocBuild/DocBuild.php | 561 ++++++++++++------ .../DocBuild/Exception/AdapterException.php | 12 - .../Exception/BadRequestException.php | 11 - .../DocBuild/Exception/CacheException.php | 8 +- .../DocBuild/Exception/FileException.php | 7 +- .../DocBuild/Exception/HttpException.php | 7 - .../Exception/TokenEmptyException.php | 11 - .../Exception/TokenExpiredException.php | 10 +- .../Exception/TokenInvalidException.php | 11 +- .../Exception/UnauthorizedException.php | 9 +- src/Vivait/DocBuild/Http/GuzzleAdapter.php | 138 ----- src/Vivait/DocBuild/Http/HttpAdapter.php | 53 -- src/Vivait/DocBuild/Model/Options.php | 56 ++ tests/Vivait/DocBuild/DocBuildTest.php | 450 ++++++++++++++ 20 files changed, 977 insertions(+), 1032 deletions(-) create mode 100644 phpunit.xml delete mode 100644 spec/Vivait/DocBuild/DocBuildSpec.php delete mode 100644 spec/Vivait/DocBuild/Http/GuzzleAdapterSpec.php delete mode 100644 src/Vivait/DocBuild/Exception/AdapterException.php delete mode 100644 src/Vivait/DocBuild/Exception/BadRequestException.php delete mode 100644 src/Vivait/DocBuild/Exception/HttpException.php delete mode 100644 src/Vivait/DocBuild/Exception/TokenEmptyException.php delete mode 100644 src/Vivait/DocBuild/Http/GuzzleAdapter.php delete mode 100644 src/Vivait/DocBuild/Http/HttpAdapter.php create mode 100644 src/Vivait/DocBuild/Model/Options.php create mode 100644 tests/Vivait/DocBuild/DocBuildTest.php diff --git a/.travis.yml b/.travis.yml index 8b64368..c8a3f30 100644 --- a/.travis.yml +++ b/.travis.yml @@ -24,4 +24,4 @@ before_script: - sh .travis.install.sh script: - - bin/phpspec run --format=pretty + - bin/phpunit diff --git a/README.md b/README.md index 1da9d58..18a1920 100644 --- a/README.md +++ b/README.md @@ -2,21 +2,38 @@ ## Installation +First, install the package itself + ``` composer require vivait/docbuild-php ``` +then install a [PSR-18](https://www.php-fig.org/psr/psr-18/) compliant HTTP client to use for requests such as: + +``` +composer require php-http/guzzle6-adapter +``` + +and finally install a message factory library that is compatible with [`php-http/message-factory`](https://packagist.org/packages/php-http/message-factory), e.g. + +``` +composer require php-http/message + +# also require guzzlehttp/psr7 which is used by php-http/message +composer require guzzlehttp/psr7 +``` + ## Usage See Doc.Build's Api documentation for detailed information on its methods. -Creating an instance of of `DocBuild` will use the `GuzzleAdapter` by default. -You can create your own adapter by implementing `HttpAdapter`. - The class requires your client id and client secret. ```php -$docBuild = new DocBuild($clientId, $clientSecret); +// Instantiate a HTTP client, in this example we use Guzzle 6 +$client = GuzzleAdapter::createWithConfig([]); + +$docBuild = new DocBuild($clientId, $clientSecret, $client); $docBuild->createDocument('ADocument', 'docx', '/path/to/file.docx'); @@ -26,23 +43,21 @@ $docBuild->convertToPdf('documentid', 'http://mycallback.url/api'); ``` -### Http Client -The guzzle library is used to interact the API. However, you can use your own -adapter implementing `Vivait\DocBuild\Http\HttpAdapter` and injecting it into -the constructor: - -```php -$docBuild = new DocBuild($clientId, $clientSecret, $options, new CustomHttpAdapter()); -``` - ### Caching This library uses the `doctrine/cache` library to cache `access_token` between -requestes. By default it will use the `Doctrine\Common\Cache\FilesystemCache`, +requests. By default it will use the `Doctrine\Common\Cache\FilesystemCache`, but this can be changed by injecting a cache that implements `Doctrine\Common\Cache\Cache` into the constructor: ```php -$docBuild = new DocBuild($clientId, $clientSecret, $options, null, new ArrayCache()); +$docBuild = new DocBuild( + $clientId, + $clientSecret, + GuzzleAdapter::createWithConfig([]), + $options, + null, + new ArrayCache() +); ``` ### Manually refresh access_token @@ -51,13 +66,15 @@ this behaviour can be changed by setting the following option, or passing this options array into the constructor on instantiation. ```php -$docBuild->setOptions([ - 'token_refresh' => false, //Default: true -]); +$docBuild->setOptions( + [ + 'token_refresh' => false, // Default: true + ] +); try { $docs = $docBuild->getDocuments(); } catch (TokenExpiredException $e) { - //Have another go + // Have another go } ``` diff --git a/composer.json b/composer.json index 7e308e4..1458316 100644 --- a/composer.json +++ b/composer.json @@ -12,22 +12,26 @@ "prefer-stable": true, "require": { "php": ">=7.1", - "guzzlehttp/guzzle": "~5.0", "symfony/options-resolver": "^2.2|^3.4", "doctrine/cache": "~1.4", - "php-http/httplug": "^2.0" + "php-http/httplug": "^2.0", + "php-http/discovery": "^1.4", + "php-http/message-factory": "^1.0" }, "require-dev": { "mikey179/vfsStream": "^1.6", - "sebastian/comparator": "^1.2.4", - "phpspec/phpspec": "^5.1" + "guzzlehttp/psr7": "^1.4", + "guzzlehttp/guzzle": "^6.3", + "phpunit/phpunit": "^7.4", + "php-http/message": "^1.7" }, "config": { "bin-dir": "bin" }, "autoload": { "psr-0": { - "Vivait\\DocBuild\\": "src/" + "Vivait\\DocBuild\\": "src/", + "Tests\\Vivait\\DocBuild\\": "tests/" } } } diff --git a/phpunit.xml b/phpunit.xml new file mode 100644 index 0000000..483de0c --- /dev/null +++ b/phpunit.xml @@ -0,0 +1,23 @@ + + + + + + tests + + + + + src + + + diff --git a/spec/Vivait/DocBuild/DocBuildSpec.php b/spec/Vivait/DocBuild/DocBuildSpec.php deleted file mode 100644 index 85fe7af..0000000 --- a/spec/Vivait/DocBuild/DocBuildSpec.php +++ /dev/null @@ -1,375 +0,0 @@ -shouldHaveType('Vivait\DocBuild\DocBuild'); - } - - function let(HttpAdapter $httpAdapter, Cache $cache) - { - $this->tempDir = vfsStream::setup('path'); - - $httpAdapter->setUrl('http://api.docbuild.vivait.co.uk/')->shouldBeCalled(); - - $cache->contains('token')->willReturn(true); - $cache->fetch('token')->willReturn('myapitoken'); - - $this->beConstructedWith('myid', 'mysecret', [], $httpAdapter, $cache); - } - - function it_authorizes_if_no_token_set(HttpAdapter $httpAdapter, Cache $cache) - { - $cache->contains('token')->willReturn(false); - - $response = ['access_token' => 'newtoken', 'expires_in' => 3600, 'token_type' => 'bearer', 'scope' => '']; - $httpAdapter->post( - 'oauth/token', - [ - 'client_id' => 'myid', - 'client_secret' => 'mysecret', - 'grant_type' => 'client_credentials' - ], - [], - HttpAdapter::RETURN_TYPE_JSON - )->willReturn($response); - - $httpAdapter->getResponseCode()->willReturn(200); - $cache->save('token', 'newtoken')->shouldBeCalled(); - - $httpAdapter->get('documents', ['access_token' => 'newtoken'], [], HttpAdapter::RETURN_TYPE_JSON)->shouldBeCalled(); - - $this->getDocuments(); - } - - - function it_can_get_a_list_of_documents(HttpAdapter $httpAdapter) - { - $expected = [ - [ - 'status' => 0, - 'id' => 'a1ec0371-966d-11e4-baee-08002730eb8a', - 'name' => 'Test Document 1', - 'extension' => 'docx', - ], - [ - 'status' => 0, - 'id' => 'ee572a33-43c9-45c2-939a-009d0d48241f', - 'name' => 'Test Document 2', - 'extension' => 'docx', - ], - ]; - - $httpAdapter->get('documents', ['access_token' => 'myapitoken'], [], HttpAdapter::RETURN_TYPE_JSON)->willReturn($expected); - $this->getDocuments()->shouldReturn($expected); - } - - function it_can_download_a_document(HttpAdapter $httpAdapter) - { - $expected = "Test Document"; - $id = 'a1ec0371-966d-11e4-baee-08002730eb8a'; - - $file = vfsStream::newFile('file'); - $this->tempDir->addChild($file); - - $expectedFile = vfsStream::newFile('expected_file'); - $this->tempDir->addChild($expectedFile); - - $expectedStream = fopen('vfs://path/expected_file', 'w+'); - fwrite($expectedStream, $expected, strlen($expected)); - fseek($expectedStream, 0); - - $fileStream = fopen('vfs://path/file', 'w+'); - - $httpAdapter->get('documents/' . $id . '/payload' , ['access_token' => 'myapitoken'], [], HttpAdapter::RETURN_TYPE_STREAM)->willReturn($expectedStream); - - $this->downloadDocument($id, $fileStream); - - if(($fileContents = $file->getContent()) != $expected) { - throw new \Exception("File steam contents of '" . $fileContents ."' does not equal expected '" . $expected . "'"); - } - } - - function it_can_get_document_info(HttpAdapter $httpAdapter) - { - $id = 'a1ec0371-966d-11e4-baee-08002730eb8a'; - - $expected = [ - 'status' => 0, - 'id' => 'a1ec0371-966d-11e4-baee-08002730eb8a', - 'name' => 'Test Document 2', - 'extension' => 'docx', - ]; - - $httpAdapter->get('documents/' . $id, ['access_token' => 'myapitoken'], [], HttpAdapter::RETURN_TYPE_JSON)->willReturn($expected); - $this->getDocument($id)->shouldReturn($expected); - } - - function it_can_create_a_document_with_a_payload(HttpAdapter $httpAdapter) - { - $file = vfsStream::newFile('file'); - $file->setContent('somecontent'); - $this->tempDir->addChild($file); - - $file = fopen('vfs://path/file', 'r'); - - $expected = [ - "status" => 0, - "id" => "a1ec0371-966d-11e4-baee-08002730eb8a", - "name" => "Test Document 1", - "extension" => "docx", - ]; - - $request = [ - 'document[name]' => 'Test File 1', - 'document[extension]' => 'docx', - 'document[file]'=> $file, - 'access_token' => 'myapitoken', - ]; - - $httpAdapter->post('documents', $request, [], HttpAdapter::RETURN_TYPE_JSON)->willReturn($expected); - - $this->createDocument('Test File 1', 'docx', $file)->shouldReturn($expected); - } - - function it_can_create_a_document_without_a_payload(HttpAdapter $httpAdapter) - { - $expected = [ - "status" => 0, - "id" => "a1ec0371-966d-11e4-baee-08002730eb8a", - "name" => "Test Document 1", - "extension" => "docx", - ]; - - $request = [ - 'document[name]' => 'Test File 1', - 'document[extension]' => 'docx', - 'access_token' => 'myapitoken', - ]; - - $httpAdapter->post('documents', $request, [], HttpAdapter::RETURN_TYPE_JSON)->willReturn($expected); - - $this->createDocument('Test File 1', 'docx', null)->shouldReturn($expected); - } - - - function it_can_upload_a_payload_to_an_existing_document(HttpAdapter $httpAdapter) - { - $file = vfsStream::newFile('file'); - $file->setContent('somecontent'); - $this->tempDir->addChild($file); - - $file = fopen('vfs://path/file', 'r'); - - $expected = []; - $request = [ - 'document[file]' => $file, - 'access_token' => 'myapitoken', - ]; - - $httpAdapter->post('documents/a1ec0371-966d-11e4-baee-08002730eb8a/payload', $request, [], HttpAdapter::RETURN_TYPE_JSON)->willReturn($expected); - - $this->uploadDocument('a1ec0371-966d-11e4-baee-08002730eb8a', $file)->shouldReturn($expected); - } - - function it_can_create_a_callback(HttpAdapter $httpAdapter) - { - $expected = []; - - $request = [ - 'source' => 'a1ec0371-966d-11e4-baee-08002730eb8a', - 'url' => 'http://localhost/test/callback?id=a1ec0371-966d-11e4-baee-08002730eb8a', - 'access_token' => 'myapitoken', - ]; - - $httpAdapter->post('callback', $request, [], HttpAdapter::RETURN_TYPE_JSON)->willReturn($expected); - - $this->createCallback('a1ec0371-966d-11e4-baee-08002730eb8a', 'http://localhost/test/callback?id=a1ec0371-966d-11e4-baee-08002730eb8a', null) - ->shouldReturn($expected); - } - - function it_can_combine_a_document(HttpAdapter $httpAdapter) - { - $expected = []; - - $request = [ - 'name' => 'Combined Document 2', - 'source' => [ - 'a1ec0371-966d-11e4-baee-08002730eb8a', - 'a1ec0371-966d-11e4-baee-08002730eb8b', - ], - 'callback' => 'http://localhost/test/callback?id=a1ec0371-966d-11e4-baee-08002730eb8a', - 'access_token' => 'myapitoken', - ]; - - $httpAdapter->post('combine', $request, [], HttpAdapter::RETURN_TYPE_JSON)->willReturn($expected); - - $this->combineDocument('Combined Document 2', ["a1ec0371-966d-11e4-baee-08002730eb8a", "a1ec0371-966d-11e4-baee-08002730eb8b"] , 'http://localhost/test/callback?id=a1ec0371-966d-11e4-baee-08002730eb8a') - ->shouldReturn($expected); - } - - function it_can_combine_a_document_without_a_callback(HttpAdapter $httpAdapter) - { - $expected = []; - - $request = [ - 'name' => 'Combined Document 2', - 'source' => [ - 'a1ec0371-966d-11e4-baee-08002730eb8a', - 'a1ec0371-966d-11e4-baee-08002730eb8b', - ], - 'callback' => null, - 'access_token' => 'myapitoken', - ]; - - $httpAdapter->post('combine', $request, [], HttpAdapter::RETURN_TYPE_JSON)->willReturn($expected); - - $this->combineDocument('Combined Document 2', ["a1ec0371-966d-11e4-baee-08002730eb8a", "a1ec0371-966d-11e4-baee-08002730eb8b"]) - ->shouldReturn($expected); - } - - - function it_can_convert_a_doc_to_pdf(HttpAdapter $httpAdapter, Cache $cache) - { - $expected = []; - - $request = [ - 'source' => 'a1ec0371-966d-11e4-baee-08002730eb8a', - 'callback' => 'http://localhost/test/callback?id=a1ec0371-966d-11e4-baee-08002730eb8a', - 'access_token' => 'myapitoken', - ]; - - $httpAdapter->post('pdf', $request, [], HttpAdapter::RETURN_TYPE_JSON)->willReturn($expected); - - $this->convertToPdf('a1ec0371-966d-11e4-baee-08002730eb8a', 'http://localhost/test/callback?id=a1ec0371-966d-11e4-baee-08002730eb8a') - ->shouldReturn($expected); - } - - function it_can_mail_merge_a_document(HttpAdapter $httpAdapter) - { - $expected = []; - - $request = [ - 'source' => 'a1ec0371-966d-11e4-baee-08002730eb8a', - 'fields' => ['firstName' => 'Milly', 'lastName' => 'Merged'], - 'callback' => 'http://localhost/test/callback?id=a1ec0371-966d-11e4-baee-08002730eb8a', - 'access_token' => 'myapitoken', - ]; - - $httpAdapter->post('mailmerge', $request, [], HttpAdapter::RETURN_TYPE_JSON)->willReturn($expected); - - $this->mailMergeDocument('a1ec0371-966d-11e4-baee-08002730eb8a', ['firstName' => 'Milly', 'lastName' => 'Merged'], 'http://localhost/test/callback?id=a1ec0371-966d-11e4-baee-08002730eb8a') - ->shouldReturn($expected); - } - - function it_can_mail_merge_a_v2_document(HttpAdapter $httpAdapter) - { - $expected = []; - - $request = [ - 'source' => 'a1ec0371-966d-11e4-baee-08002730eb8a', - 'fields' => ['firstName' => 'Milly', 'lastName' => 'Merged'], - 'callback' => 'http://localhost/test/callback?id=a1ec0371-966d-11e4-baee-08002730eb8a', - 'access_token' => 'myapitoken', - ]; - - $httpAdapter->post('v2/mailmerge', $request, [], HttpAdapter::RETURN_TYPE_JSON)->willReturn($expected); - - $this->v2MailMergeDocument('a1ec0371-966d-11e4-baee-08002730eb8a', ['firstName' => 'Milly', 'lastName' => 'Merged'], 'http://localhost/test/callback?id=a1ec0371-966d-11e4-baee-08002730eb8a') - ->shouldReturn($expected); - } - - function it_errors_with_invalid_credentials(HttpAdapter $httpAdapter, Cache $cache) - { - $this->setClientSecret('anincorrectsecret'); - $cache->contains('token')->willReturn(false); - - $httpAdapter->post( - 'oauth/token', - [ - 'client_id' => 'myid', - 'client_secret' => 'anincorrectsecret', - 'grant_type' => 'client_credentials' - ], - [], - HttpAdapter::RETURN_TYPE_JSON - )->willThrow(new UnauthorizedException()); - - $httpAdapter->getResponseCode()->willReturn(401); - - $this->shouldThrow(new UnauthorizedException())->duringGetDocuments(); - } - - function it_can_authorize_the_client(HttpAdapter $httpAdapter) - { - $response = ['access_token' => 'myapitoken', 'expires_in' => 3600, 'token_type' => 'bearer', 'scope' => '']; - $httpAdapter->post( - 'oauth/token', - [ - 'client_id' => 'myid', - 'client_secret' => 'mysecret', - 'grant_type' => 'client_credentials' - ], - [], - HttpAdapter::RETURN_TYPE_JSON - )->willReturn($response); - - $httpAdapter->getResponseCode()->willReturn(200); - - $httpAdapter->get('documents', ['access_token' => 'myapitoken',], [], HttpAdapter::RETURN_TYPE_JSON)->willReturn([]); - - $this->getDocuments(); - } - - function it_clears_the_cache_if_exception(HttpAdapter $httpAdapter, Cache $cache) - { - $this->setOptions(['token_refresh' => false]); - - $cache->contains('token')->willReturn(true); - $cache->fetch('token')->willReturn('expiredtoken'); - - $httpAdapter->get('documents', ['access_token' => 'expiredtoken'], [], HttpAdapter::RETURN_TYPE_JSON) - ->willThrow(new TokenExpiredException("The access token provided has expired.")); - - $cache->delete('token')->willReturn(true); - - $this->shouldThrow(new TokenExpiredException())->duringGetDocuments(); - } - - function it_throws_exception_if_cache_cant_be_cleared(HttpAdapter $httpAdapter, Cache $cache) - { - $this->setOptions(['token_refresh' => false]); - - $cache->contains('token')->willReturn(true); - $cache->fetch('token')->willReturn('expiredtoken'); - - $httpAdapter->get('documents', ['access_token' => 'expiredtoken'], [], HttpAdapter::RETURN_TYPE_JSON) - ->willThrow(new TokenExpiredException("The access token provided has expired.")); - - $cache->delete('token')->willReturn(false); - - $this->shouldThrow(new CacheException('Could not delete the key in the cache. Do you have permission?'))->duringGetDocuments(); - } -} diff --git a/spec/Vivait/DocBuild/Http/GuzzleAdapterSpec.php b/spec/Vivait/DocBuild/Http/GuzzleAdapterSpec.php deleted file mode 100644 index 800eda3..0000000 --- a/spec/Vivait/DocBuild/Http/GuzzleAdapterSpec.php +++ /dev/null @@ -1,196 +0,0 @@ -shouldHaveType('Vivait\DocBuild\Http\GuzzleAdapter'); - $this->shouldHaveType('Vivait\DocBuild\Http\HttpAdapter'); - } - - function let(ClientInterface $client) - { - $this->beConstructedWith($client); - $this->setUrl('http://doc.build/api/'); - } - - - function it_can_set_the_api_end_point() - { - $url = 'http://doc.build/api/'; - $this->setUrl($url)->shouldReturn($this); - } - - function it_can_get_the_response_headers(ClientInterface $client) - { - $url = 'http://doc.build/api/documents/someid/payload'; - - $response = new Response(200); - $response->setHeader('Content-Disposition', 'attachment'); - $response->setHeader('filename', 'TestDocument1.docx'); - $response->setBody(Stream::factory("")); - - $client->get($url, Argument::any())->willReturn($response); - - $this->get('documents/someid/payload'); - - $expected = [ - 'Content-Disposition' => ['attachment'], - 'filename' => ['TestDocument1.docx'] - ]; - - $this->getResponseHeaders()->shouldEqual($expected); - } - - function it_can_get_the_last_response_code(ClientInterface $client) - { - //First request - $url = 'http://doc.build/api/doesnotexist'; - $code = 404; - - $response = new Response($code); - $response->setBody(Stream::factory("")); - - $client->get($url, Argument::any())->willReturn($response); - $this->get('doesnotexist'); - - //Second request - $url = 'http://doc.build/api/exists'; - $code = 200; - - $response = new Response($code); - $response->setBody(Stream::factory("")); - - $client->get($url, Argument::any())->willReturn($response); - $this->get('exists'); - - //Result - $this->getResponseCode()->shouldBe(200); - $this->getResponseCode()->shouldNotBe(404); - } - - function it_can_perform_a_get_request(ClientInterface $client) - { - $url = 'http://doc.build/api/documents'; - - $response = new Response(200); - $response->setBody( - Stream::factory( - '[{"status":0, "id":"a1ec0371-966d-11e4-baee-08002730eb8a", "name":"Test Document 1", "extension":"docx" }]' - ) - ); - - $expected = [ - [ - 'status' => 0, - 'id' => 'a1ec0371-966d-11e4-baee-08002730eb8a', - 'name' => 'Test Document 1', - 'extension' => 'docx' - ] - ]; - - $client->get($url, Argument::any())->willReturn($response); - $this->get('documents')->shouldEqual($expected); - } - - function it_can_perform_a_get_request_with_headers(ClientInterface $client) - { - $url = 'http://doc.build/api/documents'; - $response = new Response(200); - $response->setBody(Stream::factory("")); - - $client->get( - $url, - [ - 'query' => [], - 'headers' => ['Auth' => 'myapikey'], - 'exceptions' => true, - ] - )->shouldBeCalled()->willReturn($response); - - $this->get('documents', [], ['Auth' => 'myapikey']); - } - - function it_can_perform_a_get_request_with_query(ClientInterface $client) - { - $url = 'http://doc.build/api/documents'; - $response = new Response(200); - $response->setBody(Stream::factory("")); - - $client->get( - $url, - [ - 'query' => ['id' => '1234'], - 'headers' => ['Auth' => 'myapikey'], - 'exceptions' => true, - ] - )->shouldBeCalled()->willReturn($response); - - $this->get('documents', ['id' => '1234'], ['Auth' => 'myapikey']); - } - - function it_can_perform_a_post_request(ClientInterface $client) - { - $url = 'http://doc.build/api/documents'; - $response = new Response(200); - $response->setBody( - Stream::factory( - '{"status":0, "id":"a1ec0371-966d-11e4-baee-08002730eb8a", "name":"Test Document 1", "extension":"docx" }' - ) - ); - - $client->post( - $url, - [ - 'body' => ['document[name]' => 'Test File 1', 'document[extension]' => 'docx'], - 'headers' => ['Auth' => 'myapikey'], - 'exceptions' => true, - ] - )->shouldBeCalled()->willReturn($response); - - $this->post('documents', ['document[name]' => 'Test File 1', 'document[extension]' => 'docx'], ['Auth' => 'myapikey']); - } - - function it_can_perform_a_post_request_with_parameters(ClientInterface $client) - { - $url = 'http://doc.build/api/documents'; - $response = new Response(200); - $response->setBody(Stream::factory("")); - - $client->post( - $url, - [ - 'body' => ['document[name]' => 'Test File 1', 'document[extension]' => 'docx'], - 'headers' => ['Auth' => 'myapikey'], - 'exceptions' => true, - ] - )->shouldBeCalled()->willReturn($response); - - $this->post( - 'documents', - ['document[name]' => 'Test File 1', 'document[extension]' => 'docx'], - ['Auth' => 'myapikey'] - ); - } - - function it_throws_exceptions_for_adapter_errors(ClientInterface $client) - { - $e = new TransferException(); - $client->get(Argument::any(), Argument::any())->willThrow($e); - - $adapterException = new AdapterException(null, $e); - $this->shouldThrow($adapterException)->duringGet(Argument::any()); - } -} diff --git a/src/Vivait/DocBuild/DocBuild.php b/src/Vivait/DocBuild/DocBuild.php index 29032e4..78bf8d7 100644 --- a/src/Vivait/DocBuild/DocBuild.php +++ b/src/Vivait/DocBuild/DocBuild.php @@ -4,42 +4,47 @@ use Doctrine\Common\Cache\Cache; use Doctrine\Common\Cache\FilesystemCache; +use Http\Client\Exception\HttpException; +use Http\Discovery\MessageFactoryDiscovery; +use Psr\Http\Client\ClientExceptionInterface; +use Psr\Http\Client\ClientInterface; use Symfony\Component\OptionsResolver\OptionsResolver; use Vivait\DocBuild\Exception\CacheException; use Vivait\DocBuild\Exception\FileException; -use Vivait\DocBuild\Exception\HttpException; use Vivait\DocBuild\Exception\TokenExpiredException; use Vivait\DocBuild\Exception\TokenInvalidException; use Vivait\DocBuild\Exception\UnauthorizedException; -use Vivait\DocBuild\Http\GuzzleAdapter; -use Vivait\DocBuild\Http\HttpAdapter; +use Vivait\DocBuild\Model\Options; class DocBuild { - /** - * @var OptionsResolver - */ - protected $optionsResolver; + + public const TOKEN_EXPIRED = 'The access token provided has expired.'; + public const TOKEN_INVALID = 'The access token provided is invalid.'; + + private const RETURN_TYPE_JSON = 0; + private const RETURN_TYPE_STRING = 1; + private const RETURN_TYPE_STREAM = 2; /** - * @var HttpAdapter + * @var ClientInterface */ - protected $http; + private $http; /** * @var string */ - protected $clientSecret; + private $oauthClientSecret; /** * @var string */ - protected $clientId; + private $oauthClientId; /** - * @var array + * @var Options */ - protected $options; + private $options; /** * @var Cache @@ -47,148 +52,80 @@ class DocBuild private $cache; /** - * @param null $clientId - * @param null $clientSecret - * @param array $options - * @param HttpAdapter $http - * @param Cache $cache + * @param string|null $clientId OAuth client ID. + * @param string|null $clientSecret OAuth client secret. + * @param array $options Options for the DocBuild client, + * @param ClientInterface $client The core HTTP client to use for making requests. + * @param Cache $cache An optional */ - public function __construct($clientId, $clientSecret, array $options = [], HttpAdapter $http = null, Cache $cache = null) - { - $this->optionsResolver = new OptionsResolver(); - $this->setOptions($options); + public function __construct( + $clientId, + $clientSecret, + array $options = [], + ClientInterface $client, + Cache $cache = null + ) { + $this->options = $this->transformOptions($options); - if ($http) { - $this->http = $http; - } else { - $this->http = new GuzzleAdapter(); - } + $this->http = $client; if ($cache) { $this->cache = $cache; } else { - $this->cache = new FilesystemCache(sys_get_temp_dir()); + $this->cache = new FilesystemCache(\sys_get_temp_dir()); } - $this->clientId = $clientId; - $this->clientSecret = $clientSecret; - - $this->http->setUrl($this->options['url']); - } - - public function setOptions(array $options = []) - { - $this->configureOptions($this->optionsResolver); - $this->options = $this->optionsResolver->resolve($options); - } - - protected function configureOptions(OptionsResolver $resolver) - { - $resolver->setDefaults([ - 'token_refresh' => true, - 'cache_key' => 'token', - 'url' => 'http://api.docbuild.vivait.co.uk/', - ]); - } - - /** - * @param $resource - * @param array $request - * @param array $headers - * @param int $returnType - * @return array|mixed|string|resource - */ - protected function get($resource, array $request = [], array $headers = [], $returnType = HttpAdapter::RETURN_TYPE_JSON) - { - return $this->performRequest('get', $resource, $request, $headers, $returnType); - } - - /** - * @param $resource - * @param array $request - * @param array $headers - * @param int $returnType - * @return array|mixed|string|resource - */ - protected function post($resource, array $request = [], array $headers = [], $returnType = HttpAdapter::RETURN_TYPE_JSON) - { - return $this->performRequest('post', $resource, $request, $headers, $returnType); - } - - /** - * @param $method - * @param $resource - * @param array $request - * @param array $headers - * @param int $returnType - * @return array|mixed|string|resource - */ - protected function performRequest($method, $resource, array $request, array $headers, $returnType = HttpAdapter::RETURN_TYPE_JSON) - { - if ($this->cache->contains($this->options['cache_key'])) { - $accessToken = $this->cache->fetch($this->options['cache_key']); - } else { - $accessToken = $this->authorize(); - $this->cache->save($this->options['cache_key'], $accessToken); - } - - try { - $request['access_token'] = $accessToken; - - return $this->http->$method($resource, $request, $headers, $returnType); - - } catch (UnauthorizedException $e) { - - if(!$this->cache->delete($this->options['cache_key'])){ - throw new CacheException('Could not delete the key in the cache. Do you have permission?'); - } - - if ($e instanceof TokenExpiredException || $e instanceof TokenInvalidException) { - if ($this->options['token_refresh']) { - return $this->$method($resource, $request, $headers, $returnType); - } - } - - throw $e; - } + $this->oauthClientId = $clientId; + $this->oauthClientSecret = $clientSecret; } /** + * @throws ClientExceptionInterface + * * @return string */ - public function authorize() + public function authorize(): string { - $response = $this->http->post( - 'oauth/token', [ - 'client_id' => $this->clientId, - 'client_secret' => $this->clientSecret, - 'grant_type' => 'client_credentials' - ], - [], - HttpAdapter::RETURN_TYPE_JSON + // We can't use $this->performRequest() because we could get caught in a loop + $response = $this->http->sendRequest( + MessageFactoryDiscovery::find()->createRequest( + 'post', + $this->constructUrl('oauth/token'), + [], + \json_encode( + [ + 'client_id' => $this->oauthClientId, + 'client_secret' => $this->oauthClientSecret, + 'grant_type' => 'client_credentials', + ] + ) + ) ); - $code = $this->http->getResponseCode(); + $data = \json_decode($response->getBody()->getContents(), true); - if ($code == 200 && array_key_exists('access_token', $response)) { - return $response['access_token']; + if (\array_key_exists('access_token', $data)) { + return $data['access_token']; } else { - throw new HttpException("No access token was provided in the response", $code); + throw new \RuntimeException("No access token was provided in the response"); } } /** - * @param $name - * @param $extension - * @param null $stream - * @return array|mixed|string + * @param string $name + * @param string $extension + * @param null|resource $stream + * + * @throws ClientExceptionInterface + * + * @return array The decoded JSON of the response. */ - public function createDocument($name, $extension, $stream = null) + public function createDocument(string $name, string $extension, $stream = null): array { $request = [ - 'document[name]' => $name, - 'document[extension]' => $extension + 'document[name]' => $name, + 'document[extension]' => $extension, ]; if ($stream) { @@ -200,123 +137,193 @@ public function createDocument($name, $extension, $stream = null) } /** - * @param $id - * @param $stream - * @return array|mixed|string + * @param string $id The document ID to upload the payload for. + * @param resource $stream The payload stream to be uploaded. + * + * @throws ClientExceptionInterface + * + * @return array The decoded JSON of the response. */ - public function uploadDocument($id, $stream) + public function uploadDocument(string $id, $stream): array { $file = $this->handleFileResource($stream); - return $this->post('documents/' . $id . '/payload', [ - 'document[file]' => $file - ]); + return $this->post( + 'documents/' . $id . '/payload', + [ + 'document[file]' => $file, + ] + ); } /** + * @throws ClientExceptionInterface + * * @return array */ - public function getDocuments() + public function getDocuments(): array { return $this->get('documents'); } /** - * @param $id - * @return array + * @param string $id + * + * @throws ClientExceptionInterface + * + * @return array The decoded JSON of the response. */ - public function getDocument($id) + public function getDocument(string $id): array { return $this->get('documents/' . $id); } /** - * @param $id - * @param $stream + * @param string $id The ID of the document to download. + * @param resource $stream The stream to copy the contents to. + * + * @throws ClientExceptionInterface + * * @return void */ - public function downloadDocument($id, $stream) + public function downloadDocument(string $id, $stream): void { - $documentContents = $this->get('documents/' . $id . '/payload', [], [], HttpAdapter::RETURN_TYPE_STREAM); + $documentContents = $this->get('documents/' . $id . '/payload', [], [], self::RETURN_TYPE_STREAM); - stream_copy_to_stream($documentContents, $stream); + \stream_copy_to_stream($documentContents, $stream); } - public function createCallback($source, $url) + /** + * @param string $source The source document ID to create the callback for. + * @param string $url The callback URL. + * + * @throws ClientExceptionInterface + * + * @return array The decoded JSON of the response. + */ + public function createCallback(string $source, string $url): array { - return $this->post('callback', [ - 'source' => $source, - 'url' => $url, - ]); + return $this->post( + 'callback', + [ + 'source' => $source, + 'url' => $url, + ] + ); } - public function combineDocument($name, array $source, $callback = null) + /** + * @param string $name The name of the new document. + * @param array $sources An array of document IDs that need combining. + * @param null|string $callback The callback URL. + * + * @throws ClientExceptionInterface + * + * @return array The decoded JSON of the response. + */ + public function combineDocument(string $name, array $sources, ?string $callback = null): array { - return $this->post('combine', [ - 'name' => $name, - 'source' => $source, - 'callback' => $callback, - ]); + return $this->post( + 'combine', + [ + 'name' => $name, + 'source' => $sources, + 'callback' => $callback, + ] + ); } - public function convertToPdf($source, $callback = null) + /** + * @param string $source The ID of the document to convert to a PDF. + * @param null|string $callback The callback URL. + * + * @throws ClientExceptionInterface + * + * @return array The decoded JSON of the response. + */ + public function convertToPdf(string $source, ?string $callback = null): array { - return $this->post('pdf', [ - 'source' => $source, - 'callback' => $callback, - ]); + return $this->post( + 'pdf', + [ + 'source' => $source, + 'callback' => $callback, + ] + ); } - public function mailMergeDocument($source, Array $fields, $callback = null) + /** + * @param string $source The ID of the document to mailmerge. + * @param array $fields The fields to mailmerge into the document. + * @param null|string $callback The callback URL. + * + * @throws ClientExceptionInterface + * + * @return array The decoded JSON of the response. + */ + public function mailMergeDocument(string $source, array $fields, $callback = null) { - return $this->post('mailmerge', [ - 'source' => $source, - 'fields' => $fields, - 'callback' => $callback, - ]); + return $this->post( + 'mailmerge', + [ + 'source' => $source, + 'fields' => $fields, + 'callback' => $callback, + ] + ); } + /** + * @param string $source The ID of the document to mailmerge. + * @param array $fields The fields to mailmerge into the document. + * @param null|string $callback The callback URL. + * + * @throws ClientExceptionInterface + * + * @return array The decoded JSON of the response. + */ public function v2MailMergeDocument($source, Array $fields, $callback = null) { - return $this->post('v2/mailmerge', [ - 'source' => $source, - 'fields' => $fields, - 'callback' => $callback, - ]); + return $this->post( + 'v2/mailmerge', + [ + 'source' => $source, + 'fields' => $fields, + 'callback' => $callback, + ] + ); } - public function getHttpAdapter() + /** + * @return ClientInterface + */ + public function getHttpClient(): ClientInterface { return $this->http; } /** - * @param $stream - * @return \SplFileObject + * @param string $oauthClientSecret */ - protected function handleFileResource($stream) + public function setOauthClientSecret(string $oauthClientSecret): void { - if (!is_resource($stream) && get_resource_type($stream) != 'stream') { - throw new FileException(); - } else { - return $stream; - } + $this->oauthClientSecret = $oauthClientSecret; } /** - * @param string $clientSecret + * @param string $oauthClientId */ - public function setClientSecret($clientSecret) + public function setOauthClientId(string $oauthClientId): void { - $this->clientSecret = $clientSecret; + $this->oauthClientId = $oauthClientId; } /** - * @param string $clientId + * @param array $options */ - public function setClientId($clientId) + public function setOptions(array $options): void { - $this->clientId = $clientId; + $this->options = $this->transformOptions($options); } /** @@ -328,6 +335,8 @@ public function setClientId($clientId) * @param string $adobeRefreshToken * @param null|string $callback * + * @throws ClientExceptionInterface + * * @return array|mixed|resource|string */ public function adobeSign( @@ -348,7 +357,7 @@ public function adobeSign( 'callback' => $callback, 'clientId' => $adobeClientId, 'clientSecret' => $adobeClientSecret, - 'token' => $adobeRefreshToken + 'token' => $adobeRefreshToken, ] ); } @@ -363,6 +372,8 @@ public function adobeSign( * @param array $recipients * @param null $callback * + * @throws ClientExceptionInterface + * * @return array|mixed|resource|string */ public function signable( @@ -406,6 +417,8 @@ public function signable( * @param string $source * @param string $signableKey * + * @throws ClientExceptionInterface + * * @return array|mixed|resource|string */ public function signableReminder($source, $signableKey) @@ -425,6 +438,8 @@ public function signableReminder($source, $signableKey) * @param string $source * @param string $signableKey * + * @throws \Psr\Http\Client\ClientExceptionInterface + * * @return array|mixed|resource|string */ public function signableCancel($source, $signableKey) @@ -437,4 +452,174 @@ public function signableCancel($source, $signableKey) ] ); } + + /** + * @param string $method The request method to use. + * @param string $resource The resource to access on the base URL. + * @param array $request Any parameters for the request (body). + * @param array $headers The request's headers. + * @param int $returnType The DocBuild::RETURN_TYPE_* type to return the response as. + * + * @throws ClientExceptionInterface + * + * @return array|string|resource|null Returns array if JSON was requested, string if string was requested, and + * resource|null if a stream was requested. + */ + private function performRequest( + string $method, + string $resource, + array $request, + array $headers, + $returnType = self::RETURN_TYPE_JSON + ) { + if ($this->cache->contains($this->options->getCacheKey())) { + $accessToken = $this->cache->fetch($this->options->getCacheKey()); + } else { + $accessToken = $this->authorize(); + + $this->cache->save($this->options->getCacheKey(), $accessToken); + } + + try { + $request['access_token'] = $accessToken; + + $response = $this->http->sendRequest( + MessageFactoryDiscovery::find()->createRequest( + $method, + $this->constructUrl($resource), + $headers, + \json_encode($request) + ) + ); + + switch ($returnType) { + case self::RETURN_TYPE_JSON: + return \json_decode($response->getBody()->getContents(), true); + case self::RETURN_TYPE_STREAM: + return $response->getBody()->detach(); + case self::RETURN_TYPE_STRING: + default: + return $response->getBody()->getContents(); + } + } catch (HttpException $e) { + $code = $e->getCode(); + $retryableStates = [400, 401, 403]; + + if ( ! \in_array($code, $retryableStates)) { + throw $e; + } + + $body = \json_decode($e->getResponse()->getBody()->getContents(), true); + + if ($body === null || ! \array_key_exists('error_description', $body)) { + return; + } + + $message = $body['error_description']; + + switch ($message) { + case self::TOKEN_EXPIRED: + throw new TokenExpiredException; + case self::TOKEN_INVALID: + throw new TokenInvalidException; + default: + // We're unauthorised, so get the token again if we need to or re-throw + if( ! $this->cache->delete($this->options->getCacheKey())){ + throw new CacheException('Could not delete the key in the cache. Do you have permission?'); + } + + if ($e instanceof TokenExpiredException || $e instanceof TokenInvalidException) { + if ($this->options->shouldTokenRefresh()) { + return $this->$method($resource, $request, $headers, $returnType); + } + } + + // Can't re-authenticate + throw new UnauthorizedException(\is_string($message) ? $message : null); + } + } + } + + /** + * @param resource $stream + * + * @return resource + */ + private function handleFileResource($stream) + { + if ( ! \is_resource($stream) || \get_resource_type($stream) != 'stream') { + throw new FileException(); + } else { + return $stream; + } + } + + /** + * @param string $resource The resource to access on the base URL. + * @param array $request Any parameters for the request (body). + * @param array $headers The request's headers. + * @param int $returnType The DocBuild::RETURN_TYPE_* type to return the response as. + * + * @throws ClientExceptionInterface + * + * @return array|string|resource|null Returns array if JSON was requested, string if string was requested, and + * resource|null if a stream was requested. + */ + private function get( + string $resource, + array $request = [], + array $headers = [], + $returnType = self::RETURN_TYPE_JSON + ) { + return $this->performRequest('get', $resource, $request, $headers, $returnType); + } + + /** + * @param string $resource The resource to access on the base URL. + * @param array $request Any parameters for the request (body). + * @param array $headers The request's headers. + * @param int $returnType The DocBuild::RETURN_TYPE_* type to return the response as. + * + * @throws ClientExceptionInterface + * + * @return array|string|resource|null Returns array if JSON was requested, string if string was requested, and + * resource|null if a stream was requested. + */ + private function post( + string $resource, + array $request = [], + array $headers = [], + $returnType = self::RETURN_TYPE_JSON + ) { + return $this->performRequest('post', $resource, $request, $headers, $returnType); + } + + /** + * @param string $resource The resource to access on the base URL. + * + * @return string The base URL combined with the resource. + */ + private function constructUrl(string $resource): string + { + return \sprintf("%s/%s", \rtrim($this->options->getUrl(), '/'), $resource); + } + + /** + * @param array $options The raw user options. + * + * @return Options + */ + private function transformOptions(array $options = []): Options + { + $resolver = new OptionsResolver; + $resolver->setDefaults( + [ + 'token_refresh' => true, + 'cache_key' => 'token', + 'url' => 'http://api.docbuild.vivait.co.uk/', + ] + ); + + return new Options($resolver->resolve($options)); + } } diff --git a/src/Vivait/DocBuild/Exception/AdapterException.php b/src/Vivait/DocBuild/Exception/AdapterException.php deleted file mode 100644 index e57ea43..0000000 --- a/src/Vivait/DocBuild/Exception/AdapterException.php +++ /dev/null @@ -1,12 +0,0 @@ -guzzle = new Client(); - } else { - $this->guzzle = $guzzle; - } - } - - /** - * @param $url - * @return $this - */ - public function setUrl($url) - { - $this->url = $url; - return $this; - } - - /** - * @param $method - * @param $resource - * @param $options - * @param int $returnType - * @return array|string - */ - private function sendRequest($method, $resource, $options, $returnType = self::RETURN_TYPE_JSON) - { - $this->response = null; - - $options['exceptions'] = true; - - try { - $this->response = $this->guzzle->$method($this->url . $resource, $options); - } catch (RequestException $e){ - if (($e->getCode() == 400 || $e->getCode() == 401 || $e->getCode() == 403) && $e->hasResponse()) { - $body = $e->getResponse()->json(); - - if (array_key_exists('error_description', $body)) { - $message = $body['error_description']; - - switch ($message) { - case self::TOKEN_EXPIRED : throw new TokenExpiredException(); - case self::TOKEN_INVALID : throw new TokenInvalidException(); - default: throw new UnauthorizedException($message); - } - } - } else{ - throw new HttpException($e->getMessage(), $e->getCode()); - } - } catch (TransferException $e) { - throw new AdapterException($e->getMessage()); - } - - switch($returnType) { - case self::RETURN_TYPE_STRING: - return $this->getResponseContent(); - - case self::RETURN_TYPE_JSON: - return json_decode($this->getResponseContent(), true); - - case self::RETURN_TYPE_STREAM: - return $this->response->getBody()->detach(); - } - } - - public function get($resource, $request = [], $headers = [], $returnType = self::RETURN_TYPE_JSON) - { - $options = [ - 'query' => $request, - 'headers' => $headers, - ]; - - return $this->sendRequest('get', $resource, $options, $returnType); - } - - public function post($resource, $request = [], $headers = [], $returnType = self::RETURN_TYPE_JSON) - { - $options = [ - 'body' => $request, - 'headers' => $headers, - ]; - - return $this->sendRequest('post', $resource, $options, $returnType); - } - - public function getResponseCode() - { - return (int) $this->response->getStatusCode(); - } - - public function getResponseHeaders() - { - return $this->response->getHeaders(); - } - - public function getResponseContent() - { - return (string) $this->response->getBody(); - } - - -} diff --git a/src/Vivait/DocBuild/Http/HttpAdapter.php b/src/Vivait/DocBuild/Http/HttpAdapter.php deleted file mode 100644 index 05c6a79..0000000 --- a/src/Vivait/DocBuild/Http/HttpAdapter.php +++ /dev/null @@ -1,53 +0,0 @@ -tokenRefresh = $options['token_refresh']; + $this->cacheKey = $options['cache_key']; + $this->url = $options['url']; + } + + /** + * @return bool + */ + public function shouldTokenRefresh(): bool + { + return $this->tokenRefresh; + } + + /** + * @return string + */ + public function getCacheKey(): string + { + return $this->cacheKey; + } + + /** + * @return string + */ + public function getUrl(): string + { + return $this->url; + } +} diff --git a/tests/Vivait/DocBuild/DocBuildTest.php b/tests/Vivait/DocBuild/DocBuildTest.php new file mode 100644 index 0000000..27ab3be --- /dev/null +++ b/tests/Vivait/DocBuild/DocBuildTest.php @@ -0,0 +1,450 @@ +client = $this->getMockBuilder(ClientInterface::class) + ->disableOriginalConstructor() + ->getMock() + ; + + $this->cache = $this->getMockBuilder(Cache::class) + ->disableOriginalConstructor() + ->getMock() + ; + + $this->filesystem = vfsStream::setup('path'); + + $this->docBuild = new DocBuild('id', 'secret', [], $this->client, $this->cache); + } + + /** + * @test + */ + public function itWillAuthoriseIfNoTokenIsSet(): void + { + $this->cache->expects(self::once()) + ->method('contains') + ->with('token') + ->willReturnOnConsecutiveCalls(false) + ; + + $this->cache->expects(self::once()) + ->method('save') + ->with('token', 'newtoken') + ; + + $responseData = [ + 'access_token' => 'newtoken', + 'expires_in' => 3600, + 'token_type' => 'bearer', + 'scope' => '', + ]; + + $this->client->expects(self::exactly(2)) + ->method('sendRequest') + ->willReturnOnConsecutiveCalls( + new Response(200, [], \json_encode($responseData)), + new Response(200, [], \json_encode($responseData)) + ) + ; + + $this->docBuild->getDocuments(); + } + + /** + * @test + */ + public function itWillCorrectlyDownloadADocument(): void + { + $this->cache->expects(self::once()) + ->method('contains') + ->willReturn(true) + ; + + $this->cache->expects(self::once()) + ->method('fetch') + ->willReturn('accessToken') + ; + + $actualFile = vfsStream::newFile('actualFile'); + $fakeExternalFile = vfsStream::newFile('expectedFile'); + $fakeExternalFile->setContent('Test Content'); + + $this->filesystem->addChild($actualFile); + $this->filesystem->addChild($fakeExternalFile); + + $fakeExternalStream = \fopen('vfs://path/expectedFile', 'r'); + + $this->client->expects(self::once()) + ->method('sendRequest') + ->willReturn(new Response(200, [], $fakeExternalStream)) + ; + + $actualFileStream = \fopen('vfs://path/actualFile', 'w+'); + + + $this->docBuild->downloadDocument('test', $actualFileStream); + + self::assertSame($fakeExternalFile->getContent(), $actualFile->getContent()); + } + + /** + * @test + * @dataProvider retryableStatusProvider + * + * @param int $status + */ + public function itErrorsWithInvalidCredentials(int $status): void + { + $this->expectException(UnauthorizedException::class); + + $this->cache->expects(self::once()) + ->method('contains') + ->with('token') + ->willReturn(true) + ; + + $this->cache->expects(self::once()) + ->method('fetch') + ->with('token') + ->willReturn('badToken') + ; + + $this->cache->expects(self::once()) + ->method('delete') + ->with('token') + ->willReturn(true) + ; + + $request = $this->getMockBuilder(RequestInterface::class) + ->disableOriginalConstructor() + ->getMock() + ; + + // Create an anonymous class instead of mocking the exception since we can't mock `getCode` as it's final + $exception = $this->createHttpException($status, $request, ['error_description' => 'unrecognised error']); + + $this->client->expects(self::once()) + ->method('sendRequest') + ->willThrowException($exception) + ; + + $this->docBuild->getDocuments(); + } + + /** + * @test + * @dataProvider retryableStatusProvider + * + * @param int $status + */ + public function itWillThrowAnExceptionIfTheCacheKeyCannotBeDeleted(int $status): void + { + $this->expectException(CacheException::class); + + $this->cache->expects(self::once()) + ->method('contains') + ->with('token') + ->willReturn(true) + ; + + $this->cache->expects(self::once()) + ->method('fetch') + ->with('token') + ->willReturn('badToken') + ; + + $this->cache->expects(self::once()) + ->method('delete') + ->with('token') + ->willReturn(false) + ; + + $request = $this->getMockBuilder(RequestInterface::class) + ->disableOriginalConstructor() + ->getMock() + ; + + // Create an anonymous class instead of mocking the exception since we can't mock `getCode` as it's final + $exception = $this->createHttpException($status, $request, ['error_description' => 'unrecognised error']); + + $this->client->expects(self::once()) + ->method('sendRequest') + ->willThrowException($exception) + ; + + $this->docBuild->getDocuments(); + } + + /** + * @test + * @dataProvider retryableStatusProvider + * + * @param int $status + */ + public function itWillThrowAnExceptionIfTheTokenIsExpired(int $status): void + { + $this->expectException(TokenExpiredException::class); + + $this->cache->expects(self::once()) + ->method('contains') + ->with('token') + ->willReturn(true) + ; + + $this->cache->expects(self::once()) + ->method('fetch') + ->with('token') + ->willReturn('badToken') + ; + + $request = $this->getMockBuilder(RequestInterface::class) + ->disableOriginalConstructor() + ->getMock() + ; + + // Create an anonymous class instead of mocking the exception since we can't mock `getCode` as it's final + $exception = $this->createHttpException($status, $request, ['error_description' => DocBuild::TOKEN_EXPIRED]); + + $this->client->expects(self::once()) + ->method('sendRequest') + ->willThrowException($exception) + ; + + $this->docBuild->getDocuments(); + } + + /** + * @test + * @dataProvider retryableStatusProvider + * + * @param int $status + */ + public function itWillThrowAnExceptionIfTheTokenIsInvalid(int $status): void + { + $this->expectException(TokenInvalidException::class); + + $this->cache->expects(self::once()) + ->method('contains') + ->with('token') + ->willReturn(true) + ; + + $this->cache->expects(self::once()) + ->method('fetch') + ->with('token') + ->willReturn('badToken') + ; + + $request = $this->getMockBuilder(RequestInterface::class) + ->disableOriginalConstructor() + ->getMock() + ; + + // Create an anonymous class instead of mocking the exception since we can't mock `getCode` as it's final + $exception = $this->createHttpException($status, $request, ['error_description' => DocBuild::TOKEN_INVALID]); + + $this->client->expects(self::once()) + ->method('sendRequest') + ->willThrowException($exception) + ; + + $this->docBuild->getDocuments(); + } + + /** + * @return array + */ + public function retryableStatusProvider(): array + { + return [[400], [401], [403]]; + } + + /** + * @test + */ + public function itWillThrowAnExceptionIfItDoesNotReceiveAStreamWhenCreatingADocument(): void + { + $this->expectException(FileException::class); + $this->docBuild->createDocument('name', 'pdf', 'not a stream'); + } + + /** + * @test + */ + public function itWillThrowAnExceptionIfSignableRecipientHasNoName(): void + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('Recipient is missing a name.'); + + $this->docBuild->signable( + '1', + '2', + '3', + '4', + [ + [ + 'name' => 'a', + 'email' => 'b', + 'templateId' => 'c', + ], + [ + 'email' => 'e', + 'templateId' => 'f', + ], + ] + ); + } + + /** + * @test + */ + public function itWillThrowAnExceptionIfSignableRecipientHasNoEmail(): void + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('Recipient is missing an email.'); + + $this->docBuild->signable( + '1', + '2', + '3', + '4', + [ + [ + 'name' => 'a', + 'email' => 'b', + 'templateId' => 'c', + ], + [ + 'name' => 'd', + 'templateId' => 'f', + ], + ] + ); + } + + /** + * @test + */ + public function itWillThrowAnExceptionIfSignableRecipientHasNoTemplateId(): void + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('Recipient is missing a templateId.'); + + $this->docBuild->signable( + '1', + '2', + '3', + '4', + [ + [ + 'name' => 'a', + 'email' => 'b', + 'templateId' => 'c', + ], + [ + 'name' => 'd', + 'email' => 'e', + ], + ] + ); + } + + /** + * @test + */ + public function itWillThrowAnExceptionIfNoAccessTokenIsProvidedDuringAuthorisation(): void + { + $this->expectException(\RuntimeException::class); + + $this->cache->expects(self::any()) + ->method('contains') + ->with('token') + ->willReturn(false) + ; + + $this->client->expects(self::once()) + ->method('sendRequest') + ->willReturn(new Response(200, [], \json_encode([]))) + ; + + $this->docBuild->getDocuments(); + } + + /** + * @param int $status + * @param RequestInterface $request + * @param array $responseData + * + * @return HttpException + */ + private function createHttpException( + int $status, + RequestInterface $request, + array $responseData + ): HttpException + { + return new class($status, $request, $responseData) extends HttpException { + + /** + * @param int $status + * @param RequestInterface $request + * @param array $responseData + */ + public function __construct(int $status, RequestInterface $request, array $responseData) + { + parent::__construct( + 'Test', + $request, + new Response($status, [], \json_encode($responseData)) + ); + + $this->code = $status; + } + }; + } +} From 807fade421ddc208015992fe25e91c076f33f93c Mon Sep 17 00:00:00 2001 From: leightonthomas Date: Thu, 15 Nov 2018 17:01:40 +0000 Subject: [PATCH 03/11] [BRA-1811] Remove usages of HTTPlug, replace with custom adapter HTTPlug ended up being some trouble with multipart stuff and was a bit of a rabbit-hole, so we've decided to just move back to the old adapter stuff with some tweaks. --- src/Vivait/DocBuild/DocBuild.php | 214 ++++++++---------- src/Vivait/DocBuild/Http/Adapter.php | 26 +++ src/Vivait/DocBuild/Http/Response.php | 63 ++++++ .../Model/Exception/HttpException.php | 33 +++ tests/Vivait/DocBuild/DocBuildTest.php | 92 +++++--- 5 files changed, 270 insertions(+), 158 deletions(-) create mode 100644 src/Vivait/DocBuild/Http/Adapter.php create mode 100644 src/Vivait/DocBuild/Http/Response.php create mode 100644 src/Vivait/DocBuild/Model/Exception/HttpException.php diff --git a/src/Vivait/DocBuild/DocBuild.php b/src/Vivait/DocBuild/DocBuild.php index 78bf8d7..22c567b 100644 --- a/src/Vivait/DocBuild/DocBuild.php +++ b/src/Vivait/DocBuild/DocBuild.php @@ -4,16 +4,15 @@ use Doctrine\Common\Cache\Cache; use Doctrine\Common\Cache\FilesystemCache; -use Http\Client\Exception\HttpException; -use Http\Discovery\MessageFactoryDiscovery; -use Psr\Http\Client\ClientExceptionInterface; -use Psr\Http\Client\ClientInterface; use Symfony\Component\OptionsResolver\OptionsResolver; use Vivait\DocBuild\Exception\CacheException; use Vivait\DocBuild\Exception\FileException; use Vivait\DocBuild\Exception\TokenExpiredException; use Vivait\DocBuild\Exception\TokenInvalidException; use Vivait\DocBuild\Exception\UnauthorizedException; +use Vivait\DocBuild\Http\Adapter; +use Vivait\DocBuild\Http\Response; +use Vivait\DocBuild\Model\Exception\HttpException; use Vivait\DocBuild\Model\Options; class DocBuild @@ -22,12 +21,8 @@ class DocBuild public const TOKEN_EXPIRED = 'The access token provided has expired.'; public const TOKEN_INVALID = 'The access token provided is invalid.'; - private const RETURN_TYPE_JSON = 0; - private const RETURN_TYPE_STRING = 1; - private const RETURN_TYPE_STREAM = 2; - /** - * @var ClientInterface + * @var Adapter */ private $http; @@ -52,17 +47,17 @@ class DocBuild private $cache; /** - * @param string|null $clientId OAuth client ID. - * @param string|null $clientSecret OAuth client secret. - * @param array $options Options for the DocBuild client, - * @param ClientInterface $client The core HTTP client to use for making requests. - * @param Cache $cache An optional + * @param string|null $clientId OAuth client ID. + * @param string|null $clientSecret OAuth client secret. + * @param array $options Options for the DocBuild client, + * @param Adapter $client The HTTP client adapter to use for making requests. + * @param Cache $cache An optional */ public function __construct( $clientId, $clientSecret, array $options = [], - ClientInterface $client, + Adapter $client, Cache $cache = null ) { $this->options = $this->transformOptions($options); @@ -80,29 +75,25 @@ public function __construct( } /** - * @throws ClientExceptionInterface - * + * @throws HttpException + * * @return string */ public function authorize(): string { // We can't use $this->performRequest() because we could get caught in a loop $response = $this->http->sendRequest( - MessageFactoryDiscovery::find()->createRequest( - 'post', - $this->constructUrl('oauth/token'), - [], - \json_encode( - [ - 'client_id' => $this->oauthClientId, - 'client_secret' => $this->oauthClientSecret, - 'grant_type' => 'client_credentials', - ] - ) - ) + 'post', + $this->constructUrl('oauth/token'), + [ + 'client_id' => $this->oauthClientId, + 'client_secret' => $this->oauthClientSecret, + 'grant_type' => 'client_credentials', + ], + [] ); - $data = \json_decode($response->getBody()->getContents(), true); + $data = $response->toJsonArray(); if (\array_key_exists('access_token', $data)) { return $data['access_token']; @@ -117,7 +108,7 @@ public function authorize(): string * @param string $extension * @param null|resource $stream * - * @throws ClientExceptionInterface + * @throws HttpException * * @return array The decoded JSON of the response. */ @@ -133,14 +124,14 @@ public function createDocument(string $name, string $extension, $stream = null): $request['document[file]'] = $file; } - return $this->post('documents', $request); + return $this->post('documents', $request)->toJsonArray(); } /** * @param string $id The document ID to upload the payload for. * @param resource $stream The payload stream to be uploaded. * - * @throws ClientExceptionInterface + * @throws HttpException * * @return array The decoded JSON of the response. */ @@ -153,42 +144,42 @@ public function uploadDocument(string $id, $stream): array [ 'document[file]' => $file, ] - ); + )->toJsonArray(); } /** - * @throws ClientExceptionInterface + * @throws HttpException * * @return array */ public function getDocuments(): array { - return $this->get('documents'); + return $this->get('documents')->toJsonArray(); } /** * @param string $id * - * @throws ClientExceptionInterface + * @throws HttpException * * @return array The decoded JSON of the response. */ public function getDocument(string $id): array { - return $this->get('documents/' . $id); + return $this->get('documents/' . $id)->toJsonArray(); } /** * @param string $id The ID of the document to download. * @param resource $stream The stream to copy the contents to. * - * @throws ClientExceptionInterface + * @throws HttpException * * @return void */ public function downloadDocument(string $id, $stream): void { - $documentContents = $this->get('documents/' . $id . '/payload', [], [], self::RETURN_TYPE_STREAM); + $documentContents = $this->get('documents/' . $id . '/payload', [], [])->getStream(); \stream_copy_to_stream($documentContents, $stream); } @@ -197,7 +188,7 @@ public function downloadDocument(string $id, $stream): void * @param string $source The source document ID to create the callback for. * @param string $url The callback URL. * - * @throws ClientExceptionInterface + * @throws HttpException * * @return array The decoded JSON of the response. */ @@ -209,7 +200,7 @@ public function createCallback(string $source, string $url): array 'source' => $source, 'url' => $url, ] - ); + )->toJsonArray(); } /** @@ -217,7 +208,7 @@ public function createCallback(string $source, string $url): array * @param array $sources An array of document IDs that need combining. * @param null|string $callback The callback URL. * - * @throws ClientExceptionInterface + * @throws HttpException * * @return array The decoded JSON of the response. */ @@ -230,14 +221,14 @@ public function combineDocument(string $name, array $sources, ?string $callback 'source' => $sources, 'callback' => $callback, ] - ); + )->toJsonArray(); } /** * @param string $source The ID of the document to convert to a PDF. * @param null|string $callback The callback URL. * - * @throws ClientExceptionInterface + * @throws HttpException * * @return array The decoded JSON of the response. */ @@ -249,7 +240,7 @@ public function convertToPdf(string $source, ?string $callback = null): array 'source' => $source, 'callback' => $callback, ] - ); + )->toJsonArray(); } /** @@ -257,7 +248,7 @@ public function convertToPdf(string $source, ?string $callback = null): array * @param array $fields The fields to mailmerge into the document. * @param null|string $callback The callback URL. * - * @throws ClientExceptionInterface + * @throws HttpException * * @return array The decoded JSON of the response. */ @@ -270,7 +261,7 @@ public function mailMergeDocument(string $source, array $fields, $callback = nul 'fields' => $fields, 'callback' => $callback, ] - ); + )->toJsonArray(); } /** @@ -278,7 +269,7 @@ public function mailMergeDocument(string $source, array $fields, $callback = nul * @param array $fields The fields to mailmerge into the document. * @param null|string $callback The callback URL. * - * @throws ClientExceptionInterface + * @throws HttpException * * @return array The decoded JSON of the response. */ @@ -291,13 +282,13 @@ public function v2MailMergeDocument($source, Array $fields, $callback = null) 'fields' => $fields, 'callback' => $callback, ] - ); + )->toJsonArray(); } /** - * @return ClientInterface + * @return Adapter */ - public function getHttpClient(): ClientInterface + public function getHttpClient(): Adapter { return $this->http; } @@ -335,9 +326,9 @@ public function setOptions(array $options): void * @param string $adobeRefreshToken * @param null|string $callback * - * @throws ClientExceptionInterface + * @throws HttpException * - * @return array|mixed|resource|string + * @return array */ public function adobeSign( $source, @@ -347,7 +338,8 @@ public function adobeSign( $adobeClientSecret, $adobeRefreshToken, $callback = null - ) { + ): array + { return $this->post( 'adobe-sign', [ @@ -359,7 +351,7 @@ public function adobeSign( 'clientSecret' => $adobeClientSecret, 'token' => $adobeRefreshToken, ] - ); + )->toJsonArray(); } /** @@ -372,9 +364,9 @@ public function adobeSign( * @param array $recipients * @param null $callback * - * @throws ClientExceptionInterface + * @throws HttpException * - * @return array|mixed|resource|string + * @return array */ public function signable( $source, @@ -383,7 +375,8 @@ public function signable( $documentTitle, array $recipients, $callback = null - ) { + ): array + { foreach ($recipients as $recipient) { if ( ! array_key_exists('name', $recipient)) { throw new \InvalidArgumentException("Recipient is missing a name."); @@ -408,7 +401,7 @@ public function signable( 'callback' => $callback, 'source' => $source, ] - ); + )->toJsonArray(); } /** @@ -417,11 +410,11 @@ public function signable( * @param string $source * @param string $signableKey * - * @throws ClientExceptionInterface + * @throws HttpException * - * @return array|mixed|resource|string + * @return array */ - public function signableReminder($source, $signableKey) + public function signableReminder($source, $signableKey): array { return $this->post( 'signable/remind', @@ -429,7 +422,7 @@ public function signableReminder($source, $signableKey) 'signableKey' => $signableKey, 'source' => $source, ] - ); + )->toJsonArray(); } /** @@ -438,11 +431,11 @@ public function signableReminder($source, $signableKey) * @param string $source * @param string $signableKey * - * @throws \Psr\Http\Client\ClientExceptionInterface + * @throws HttpException * - * @return array|mixed|resource|string + * @return array */ - public function signableCancel($source, $signableKey) + public function signableCancel($source, $signableKey): array { return $this->post( 'signable/cancel', @@ -450,7 +443,7 @@ public function signableCancel($source, $signableKey) 'signableKey' => $signableKey, 'source' => $source, ] - ); + )->toJsonArray(); } /** @@ -458,20 +451,18 @@ public function signableCancel($source, $signableKey) * @param string $resource The resource to access on the base URL. * @param array $request Any parameters for the request (body). * @param array $headers The request's headers. - * @param int $returnType The DocBuild::RETURN_TYPE_* type to return the response as. * - * @throws ClientExceptionInterface + * @throws HttpException * - * @return array|string|resource|null Returns array if JSON was requested, string if string was requested, and - * resource|null if a stream was requested. + * @return Response */ private function performRequest( string $method, string $resource, array $request, - array $headers, - $returnType = self::RETURN_TYPE_JSON - ) { + array $headers + ): Response + { if ($this->cache->contains($this->options->getCacheKey())) { $accessToken = $this->cache->fetch($this->options->getCacheKey()); } else { @@ -483,36 +474,27 @@ private function performRequest( try { $request['access_token'] = $accessToken; - $response = $this->http->sendRequest( - MessageFactoryDiscovery::find()->createRequest( - $method, - $this->constructUrl($resource), - $headers, - \json_encode($request) - ) + return $this->http->sendRequest( + $method, + $this->constructUrl($resource), + $request, + $headers ); - - switch ($returnType) { - case self::RETURN_TYPE_JSON: - return \json_decode($response->getBody()->getContents(), true); - case self::RETURN_TYPE_STREAM: - return $response->getBody()->detach(); - case self::RETURN_TYPE_STRING: - default: - return $response->getBody()->getContents(); - } } catch (HttpException $e) { $code = $e->getCode(); - $retryableStates = [400, 401, 403]; - if ( ! \in_array($code, $retryableStates)) { + if ( ! \in_array($code, [400, 401, 403])) { throw $e; } - $body = \json_decode($e->getResponse()->getBody()->getContents(), true); + if ($e->getResponse() === null) { + throw $e; + } + + $body = $e->getResponse()->toJsonArray(); if ($body === null || ! \array_key_exists('error_description', $body)) { - return; + throw $e; } $message = $body['error_description']; @@ -530,7 +512,7 @@ private function performRequest( if ($e instanceof TokenExpiredException || $e instanceof TokenInvalidException) { if ($this->options->shouldTokenRefresh()) { - return $this->$method($resource, $request, $headers, $returnType); + return $this->$method($resource, $request, $headers); } } @@ -555,43 +537,31 @@ private function handleFileResource($stream) } /** - * @param string $resource The resource to access on the base URL. - * @param array $request Any parameters for the request (body). - * @param array $headers The request's headers. - * @param int $returnType The DocBuild::RETURN_TYPE_* type to return the response as. + * @param string $resource The resource to access on the base URL. + * @param array $request Any parameters for the request (body). + * @param array $headers The request's headers. * - * @throws ClientExceptionInterface + * @throws HttpException * - * @return array|string|resource|null Returns array if JSON was requested, string if string was requested, and - * resource|null if a stream was requested. + * @return Response */ - private function get( - string $resource, - array $request = [], - array $headers = [], - $returnType = self::RETURN_TYPE_JSON - ) { - return $this->performRequest('get', $resource, $request, $headers, $returnType); + private function get(string $resource, array $request = [], array $headers = []): Response + { + return $this->performRequest('get', $resource, $request, $headers); } /** * @param string $resource The resource to access on the base URL. * @param array $request Any parameters for the request (body). * @param array $headers The request's headers. - * @param int $returnType The DocBuild::RETURN_TYPE_* type to return the response as. * - * @throws ClientExceptionInterface + * @throws HttpException * - * @return array|string|resource|null Returns array if JSON was requested, string if string was requested, and - * resource|null if a stream was requested. + * @return Response */ - private function post( - string $resource, - array $request = [], - array $headers = [], - $returnType = self::RETURN_TYPE_JSON - ) { - return $this->performRequest('post', $resource, $request, $headers, $returnType); + private function post(string $resource, array $request = [], array $headers = []): Response + { + return $this->performRequest('post', $resource, $request, $headers); } /** diff --git a/src/Vivait/DocBuild/Http/Adapter.php b/src/Vivait/DocBuild/Http/Adapter.php new file mode 100644 index 0000000..e265069 --- /dev/null +++ b/src/Vivait/DocBuild/Http/Adapter.php @@ -0,0 +1,26 @@ +stream = $stream; + $this->statusCode = $statusCode; + } + + /** + * @return int + */ + public function getStatusCode(): int + { + return $this->statusCode; + } + + /** + * @return string + */ + public function toString(): string + { + return \stream_get_contents($this->stream); + } + + /** + * @return array + */ + public function toJsonArray(): array + { + return \json_decode($this->toString(), true); + } + + /** + * @return resource + */ + public function getStream() + { + return $this->stream; + } +} diff --git a/src/Vivait/DocBuild/Model/Exception/HttpException.php b/src/Vivait/DocBuild/Model/Exception/HttpException.php new file mode 100644 index 0000000..17c1284 --- /dev/null +++ b/src/Vivait/DocBuild/Model/Exception/HttpException.php @@ -0,0 +1,33 @@ +response = $response; + } + + /** + * @return null|Response + */ + public function getResponse(): ?Response + { + return $this->response; + } +} diff --git a/tests/Vivait/DocBuild/DocBuildTest.php b/tests/Vivait/DocBuild/DocBuildTest.php index 27ab3be..840784d 100644 --- a/tests/Vivait/DocBuild/DocBuildTest.php +++ b/tests/Vivait/DocBuild/DocBuildTest.php @@ -3,13 +3,10 @@ namespace Tests\Vivait\DocBuild; use Doctrine\Common\Cache\Cache; -use GuzzleHttp\Psr7\Response; -use Http\Client\Exception\HttpException; use org\bovigo\vfs\vfsStream; use org\bovigo\vfs\vfsStreamDirectory; use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; -use Psr\Http\Client\ClientInterface; use Psr\Http\Message\RequestInterface; use Vivait\DocBuild\DocBuild; use Vivait\DocBuild\Exception\CacheException; @@ -17,6 +14,9 @@ use Vivait\DocBuild\Exception\TokenExpiredException; use Vivait\DocBuild\Exception\TokenInvalidException; use Vivait\DocBuild\Exception\UnauthorizedException; +use Vivait\DocBuild\Http\Adapter; +use Vivait\DocBuild\Http\Response; +use Vivait\DocBuild\Model\Exception\HttpException; class DocBuildTest extends TestCase { @@ -46,7 +46,7 @@ class DocBuildTest extends TestCase */ public function setUp(): void { - $this->client = $this->getMockBuilder(ClientInterface::class) + $this->client = $this->getMockBuilder(Adapter::class) ->disableOriginalConstructor() ->getMock() ; @@ -87,12 +87,15 @@ public function itWillAuthoriseIfNoTokenIsSet(): void $this->client->expects(self::exactly(2)) ->method('sendRequest') ->willReturnOnConsecutiveCalls( - new Response(200, [], \json_encode($responseData)), - new Response(200, [], \json_encode($responseData)) + $req1 = new Response(200, $this->asStream(\json_encode($responseData))), + $req2 = new Response(200, $this->asStream(\json_encode($responseData))) ) ; $this->docBuild->getDocuments(); + + \fclose($req1->getStream()); + \fclose($req2->getStream()); } /** @@ -121,7 +124,7 @@ public function itWillCorrectlyDownloadADocument(): void $this->client->expects(self::once()) ->method('sendRequest') - ->willReturn(new Response(200, [], $fakeExternalStream)) + ->willReturn($req1 = new Response(200, $fakeExternalStream)) ; $actualFileStream = \fopen('vfs://path/actualFile', 'w+'); @@ -130,6 +133,8 @@ public function itWillCorrectlyDownloadADocument(): void $this->docBuild->downloadDocument('test', $actualFileStream); self::assertSame($fakeExternalFile->getContent(), $actualFile->getContent()); + + \fclose($fakeExternalStream); } /** @@ -160,13 +165,13 @@ public function itErrorsWithInvalidCredentials(int $status): void ->willReturn(true) ; - $request = $this->getMockBuilder(RequestInterface::class) - ->disableOriginalConstructor() - ->getMock() - ; - - // Create an anonymous class instead of mocking the exception since we can't mock `getCode` as it's final - $exception = $this->createHttpException($status, $request, ['error_description' => 'unrecognised error']); + $exception = new HttpException( + $status, + $req1 = new Response( + 200, + $this->asStream(\json_encode(['error_description' => 'unrecognised error'])) + ) + ); $this->client->expects(self::once()) ->method('sendRequest') @@ -204,13 +209,13 @@ public function itWillThrowAnExceptionIfTheCacheKeyCannotBeDeleted(int $status): ->willReturn(false) ; - $request = $this->getMockBuilder(RequestInterface::class) - ->disableOriginalConstructor() - ->getMock() - ; - - // Create an anonymous class instead of mocking the exception since we can't mock `getCode` as it's final - $exception = $this->createHttpException($status, $request, ['error_description' => 'unrecognised error']); + $exception = new HttpException( + $status, + $req1 = new Response( + 200, + $this->asStream(\json_encode(['error_description' => 'unrecognised error'])) + ) + ); $this->client->expects(self::once()) ->method('sendRequest') @@ -242,13 +247,13 @@ public function itWillThrowAnExceptionIfTheTokenIsExpired(int $status): void ->willReturn('badToken') ; - $request = $this->getMockBuilder(RequestInterface::class) - ->disableOriginalConstructor() - ->getMock() - ; - - // Create an anonymous class instead of mocking the exception since we can't mock `getCode` as it's final - $exception = $this->createHttpException($status, $request, ['error_description' => DocBuild::TOKEN_EXPIRED]); + $exception = new HttpException( + $status, + $req1 = new Response( + 200, + $this->asStream(\json_encode(['error_description' => DocBuild::TOKEN_EXPIRED])) + ) + ); $this->client->expects(self::once()) ->method('sendRequest') @@ -280,13 +285,13 @@ public function itWillThrowAnExceptionIfTheTokenIsInvalid(int $status): void ->willReturn('badToken') ; - $request = $this->getMockBuilder(RequestInterface::class) - ->disableOriginalConstructor() - ->getMock() - ; - - // Create an anonymous class instead of mocking the exception since we can't mock `getCode` as it's final - $exception = $this->createHttpException($status, $request, ['error_description' => DocBuild::TOKEN_INVALID]); + $exception = new HttpException( + $status, + $req1 = new Response( + 200, + $this->asStream(\json_encode(['error_description' => DocBuild::TOKEN_INVALID])) + ) + ); $this->client->expects(self::once()) ->method('sendRequest') @@ -409,7 +414,7 @@ public function itWillThrowAnExceptionIfNoAccessTokenIsProvidedDuringAuthorisati $this->client->expects(self::once()) ->method('sendRequest') - ->willReturn(new Response(200, [], \json_encode([]))) + ->willReturn(new Response(200, $this->asStream(\json_encode([])))) ; $this->docBuild->getDocuments(); @@ -447,4 +452,19 @@ public function __construct(int $status, RequestInterface $request, array $respo } }; } + + /** + * @param string $input + * + * @return bool|resource + */ + private function asStream(string $input) + { + $stream = \fopen('php://memory', 'r+'); + + \fwrite($stream, $input); + \rewind($stream); + + return $stream; + } } From 1543b9f25674d701b0f12cf9cb56df984748c10d Mon Sep 17 00:00:00 2001 From: leightonthomas Date: Thu, 15 Nov 2018 17:02:53 +0000 Subject: [PATCH 04/11] [BRA-1811] Remove HTTPlug (and related) dependencies --- composer.json | 10 ++-------- 1 file changed, 2 insertions(+), 8 deletions(-) diff --git a/composer.json b/composer.json index 1458316..80bd9d8 100644 --- a/composer.json +++ b/composer.json @@ -13,17 +13,11 @@ "require": { "php": ">=7.1", "symfony/options-resolver": "^2.2|^3.4", - "doctrine/cache": "~1.4", - "php-http/httplug": "^2.0", - "php-http/discovery": "^1.4", - "php-http/message-factory": "^1.0" + "doctrine/cache": "~1.4" }, "require-dev": { "mikey179/vfsStream": "^1.6", - "guzzlehttp/psr7": "^1.4", - "guzzlehttp/guzzle": "^6.3", - "phpunit/phpunit": "^7.4", - "php-http/message": "^1.7" + "phpunit/phpunit": "^7.4" }, "config": { "bin-dir": "bin" From 43370b3d391908b5a0a3467bf5e62fdc9b59ff81 Mon Sep 17 00:00:00 2001 From: leightonthomas Date: Thu, 15 Nov 2018 17:05:23 +0000 Subject: [PATCH 05/11] [BRA-1811] Fix namespace & file location of HttpException --- src/Vivait/DocBuild/DocBuild.php | 2 +- src/Vivait/DocBuild/{Model => }/Exception/HttpException.php | 2 +- src/Vivait/DocBuild/Http/Adapter.php | 2 +- tests/Vivait/DocBuild/DocBuildTest.php | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) rename src/Vivait/DocBuild/{Model => }/Exception/HttpException.php (93%) diff --git a/src/Vivait/DocBuild/DocBuild.php b/src/Vivait/DocBuild/DocBuild.php index 22c567b..a15411f 100644 --- a/src/Vivait/DocBuild/DocBuild.php +++ b/src/Vivait/DocBuild/DocBuild.php @@ -12,7 +12,7 @@ use Vivait\DocBuild\Exception\UnauthorizedException; use Vivait\DocBuild\Http\Adapter; use Vivait\DocBuild\Http\Response; -use Vivait\DocBuild\Model\Exception\HttpException; +use Vivait\DocBuild\Exception\HttpException; use Vivait\DocBuild\Model\Options; class DocBuild diff --git a/src/Vivait/DocBuild/Model/Exception/HttpException.php b/src/Vivait/DocBuild/Exception/HttpException.php similarity index 93% rename from src/Vivait/DocBuild/Model/Exception/HttpException.php rename to src/Vivait/DocBuild/Exception/HttpException.php index 17c1284..6229728 100644 --- a/src/Vivait/DocBuild/Model/Exception/HttpException.php +++ b/src/Vivait/DocBuild/Exception/HttpException.php @@ -1,6 +1,6 @@ Date: Thu, 15 Nov 2018 17:05:38 +0000 Subject: [PATCH 06/11] [BRA-1811] Add psr/http-message dependency --- composer.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/composer.json b/composer.json index 80bd9d8..9ea1b64 100644 --- a/composer.json +++ b/composer.json @@ -13,7 +13,8 @@ "require": { "php": ">=7.1", "symfony/options-resolver": "^2.2|^3.4", - "doctrine/cache": "~1.4" + "doctrine/cache": "~1.4", + "psr/http-message": "^1.0" }, "require-dev": { "mikey179/vfsStream": "^1.6", From ceabf5defa599f0f10488bfb588311126cdedc92 Mon Sep 17 00:00:00 2001 From: leightonthomas Date: Fri, 16 Nov 2018 09:16:14 +0000 Subject: [PATCH 07/11] [BRA-1811] Add new information to the readme and remove the old stuff --- README.md | 23 ++++------------------- 1 file changed, 4 insertions(+), 19 deletions(-) diff --git a/README.md b/README.md index 18a1920..f320864 100644 --- a/README.md +++ b/README.md @@ -2,36 +2,21 @@ ## Installation -First, install the package itself - ``` composer require vivait/docbuild-php ``` -then install a [PSR-18](https://www.php-fig.org/psr/psr-18/) compliant HTTP client to use for requests such as: - -``` -composer require php-http/guzzle6-adapter -``` - -and finally install a message factory library that is compatible with [`php-http/message-factory`](https://packagist.org/packages/php-http/message-factory), e.g. - -``` -composer require php-http/message - -# also require guzzlehttp/psr7 which is used by php-http/message -composer require guzzlehttp/psr7 -``` +and then write an Adapter that's compatible with the [Adapter interface](src/Vivait/DocBuild/Http/Adapter.php). ## Usage See Doc.Build's Api documentation for detailed information on its methods. -The class requires your client id and client secret. +The class requires your client id, client secret and a compatible [Adapter](src/Vivait/DocBuild/Http/Adapter.php). ```php -// Instantiate a HTTP client, in this example we use Guzzle 6 -$client = GuzzleAdapter::createWithConfig([]); +// Instantiate your adapter +$client = new MyAdapter(); $docBuild = new DocBuild($clientId, $clientSecret, $client); From 2596ae9f1389f969f7ccb2de5051b86ea4a40088 Mon Sep 17 00:00:00 2001 From: leightonthomas Date: Fri, 16 Nov 2018 10:08:02 +0000 Subject: [PATCH 08/11] [BRA-1811] Add a message to HttpException --- src/Vivait/DocBuild/Exception/HttpException.php | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/Vivait/DocBuild/Exception/HttpException.php b/src/Vivait/DocBuild/Exception/HttpException.php index 6229728..1ba130d 100644 --- a/src/Vivait/DocBuild/Exception/HttpException.php +++ b/src/Vivait/DocBuild/Exception/HttpException.php @@ -14,11 +14,12 @@ class HttpException extends \RuntimeException /** * @param int $code + * @param string $message * @param null|Response $response */ - public function __construct(int $code, ?Response $response = null) + public function __construct(int $code, string $message, ?Response $response = null) { - parent::__construct("There was an issue with the HTTP request or response.", $code); + parent::__construct(\sprintf("There was an issue with the HTTP request or response: %s", $message), $code); $this->response = $response; } From 3b1c946cb530afe63190d9cae40ca329aef691fa Mon Sep 17 00:00:00 2001 From: leightonthomas Date: Fri, 16 Nov 2018 10:28:02 +0000 Subject: [PATCH 09/11] [BRA-1811] Add nullability to Response->toJsonArray() --- src/Vivait/DocBuild/DocBuild.php | 54 +++++++++++++-------------- src/Vivait/DocBuild/Http/Response.php | 33 ++++++++++++++-- 2 files changed, 56 insertions(+), 31 deletions(-) diff --git a/src/Vivait/DocBuild/DocBuild.php b/src/Vivait/DocBuild/DocBuild.php index a15411f..ad56dca 100644 --- a/src/Vivait/DocBuild/DocBuild.php +++ b/src/Vivait/DocBuild/DocBuild.php @@ -95,7 +95,7 @@ public function authorize(): string $data = $response->toJsonArray(); - if (\array_key_exists('access_token', $data)) { + if ($data !== null && \array_key_exists('access_token', $data)) { return $data['access_token']; } else { throw new \RuntimeException("No access token was provided in the response"); @@ -110,9 +110,9 @@ public function authorize(): string * * @throws HttpException * - * @return array The decoded JSON of the response. + * @return array|null The decoded JSON of the response. */ - public function createDocument(string $name, string $extension, $stream = null): array + public function createDocument(string $name, string $extension, $stream = null): ?array { $request = [ 'document[name]' => $name, @@ -133,9 +133,9 @@ public function createDocument(string $name, string $extension, $stream = null): * * @throws HttpException * - * @return array The decoded JSON of the response. + * @return array|null The decoded JSON of the response. */ - public function uploadDocument(string $id, $stream): array + public function uploadDocument(string $id, $stream): ?array { $file = $this->handleFileResource($stream); @@ -150,9 +150,9 @@ public function uploadDocument(string $id, $stream): array /** * @throws HttpException * - * @return array + * @return array|null The decoded JSON of the response. */ - public function getDocuments(): array + public function getDocuments(): ?array { return $this->get('documents')->toJsonArray(); } @@ -162,9 +162,9 @@ public function getDocuments(): array * * @throws HttpException * - * @return array The decoded JSON of the response. + * @return array|null The decoded JSON of the response. */ - public function getDocument(string $id): array + public function getDocument(string $id): ?array { return $this->get('documents/' . $id)->toJsonArray(); } @@ -190,9 +190,9 @@ public function downloadDocument(string $id, $stream): void * * @throws HttpException * - * @return array The decoded JSON of the response. + * @return array|null The decoded JSON of the response. */ - public function createCallback(string $source, string $url): array + public function createCallback(string $source, string $url): ?array { return $this->post( 'callback', @@ -210,9 +210,9 @@ public function createCallback(string $source, string $url): array * * @throws HttpException * - * @return array The decoded JSON of the response. + * @return array|null The decoded JSON of the response. */ - public function combineDocument(string $name, array $sources, ?string $callback = null): array + public function combineDocument(string $name, array $sources, ?string $callback = null): ?array { return $this->post( 'combine', @@ -230,9 +230,9 @@ public function combineDocument(string $name, array $sources, ?string $callback * * @throws HttpException * - * @return array The decoded JSON of the response. + * @return array|null The decoded JSON of the response. */ - public function convertToPdf(string $source, ?string $callback = null): array + public function convertToPdf(string $source, ?string $callback = null): ?array { return $this->post( 'pdf', @@ -250,9 +250,9 @@ public function convertToPdf(string $source, ?string $callback = null): array * * @throws HttpException * - * @return array The decoded JSON of the response. + * @return array|null The decoded JSON of the response. */ - public function mailMergeDocument(string $source, array $fields, $callback = null) + public function mailMergeDocument(string $source, array $fields, $callback = null): ?array { return $this->post( 'mailmerge', @@ -271,9 +271,9 @@ public function mailMergeDocument(string $source, array $fields, $callback = nul * * @throws HttpException * - * @return array The decoded JSON of the response. + * @return array|null The decoded JSON of the response. */ - public function v2MailMergeDocument($source, Array $fields, $callback = null) + public function v2MailMergeDocument($source, Array $fields, $callback = null): ?array { return $this->post( 'v2/mailmerge', @@ -328,7 +328,7 @@ public function setOptions(array $options): void * * @throws HttpException * - * @return array + * @return array|null The decoded JSON of the response. */ public function adobeSign( $source, @@ -338,7 +338,7 @@ public function adobeSign( $adobeClientSecret, $adobeRefreshToken, $callback = null - ): array + ): ?array { return $this->post( 'adobe-sign', @@ -366,7 +366,7 @@ public function adobeSign( * * @throws HttpException * - * @return array + * @return array|null The decoded JSON of the response. */ public function signable( $source, @@ -375,7 +375,7 @@ public function signable( $documentTitle, array $recipients, $callback = null - ): array + ): ?array { foreach ($recipients as $recipient) { if ( ! array_key_exists('name', $recipient)) { @@ -412,9 +412,9 @@ public function signable( * * @throws HttpException * - * @return array + * @return array|null The decoded JSON of the response. */ - public function signableReminder($source, $signableKey): array + public function signableReminder($source, $signableKey): ?array { return $this->post( 'signable/remind', @@ -433,9 +433,9 @@ public function signableReminder($source, $signableKey): array * * @throws HttpException * - * @return array + * @return array|null The decoded JSON of the response. */ - public function signableCancel($source, $signableKey): array + public function signableCancel($source, $signableKey): ?array { return $this->post( 'signable/cancel', diff --git a/src/Vivait/DocBuild/Http/Response.php b/src/Vivait/DocBuild/Http/Response.php index a1e08d4..f19ae95 100644 --- a/src/Vivait/DocBuild/Http/Response.php +++ b/src/Vivait/DocBuild/Http/Response.php @@ -25,8 +25,19 @@ public function __construct(int $statusCode, $stream) throw new \InvalidArgumentException("Responses can only be constructed with streams."); } - $this->stream = $stream; + // Copy the stream to an in-memory one that was fopen'd so that we can rewind it automatically on each method + // call + $memoryStream = \fopen('php://memory', 'r+'); + + \stream_copy_to_stream($stream, $memoryStream); + + $this->stream = $memoryStream; $this->statusCode = $statusCode; + + // Close the original stream + \fclose($stream); + + $this->rewindStream(); } /** @@ -42,15 +53,22 @@ public function getStatusCode(): int */ public function toString(): string { + $this->rewindStream(); + return \stream_get_contents($this->stream); } /** - * @return array + * @return array|null */ - public function toJsonArray(): array + public function toJsonArray(): ?array { - return \json_decode($this->toString(), true); + $this->rewindStream(); + + $data = $this->toString(); + $decoded = \json_decode($data, true); + + return $decoded; } /** @@ -58,6 +76,13 @@ public function toJsonArray(): array */ public function getStream() { + $this->rewindStream(); + return $this->stream; } + + private function rewindStream(): void + { + \rewind($this->stream); + } } From 7681a471adb8d97c774ce6e03eae5f0013974ef0 Mon Sep 17 00:00:00 2001 From: leightonthomas Date: Fri, 16 Nov 2018 10:28:55 +0000 Subject: [PATCH 10/11] [BRA-1811] Remove unused method in test --- tests/Vivait/DocBuild/DocBuildTest.php | 34 -------------------------- 1 file changed, 34 deletions(-) diff --git a/tests/Vivait/DocBuild/DocBuildTest.php b/tests/Vivait/DocBuild/DocBuildTest.php index da45abb..18d46a4 100644 --- a/tests/Vivait/DocBuild/DocBuildTest.php +++ b/tests/Vivait/DocBuild/DocBuildTest.php @@ -7,7 +7,6 @@ use org\bovigo\vfs\vfsStreamDirectory; use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; -use Psr\Http\Message\RequestInterface; use Vivait\DocBuild\DocBuild; use Vivait\DocBuild\Exception\CacheException; use Vivait\DocBuild\Exception\FileException; @@ -420,39 +419,6 @@ public function itWillThrowAnExceptionIfNoAccessTokenIsProvidedDuringAuthorisati $this->docBuild->getDocuments(); } - /** - * @param int $status - * @param RequestInterface $request - * @param array $responseData - * - * @return HttpException - */ - private function createHttpException( - int $status, - RequestInterface $request, - array $responseData - ): HttpException - { - return new class($status, $request, $responseData) extends HttpException { - - /** - * @param int $status - * @param RequestInterface $request - * @param array $responseData - */ - public function __construct(int $status, RequestInterface $request, array $responseData) - { - parent::__construct( - 'Test', - $request, - new Response($status, [], \json_encode($responseData)) - ); - - $this->code = $status; - } - }; - } - /** * @param string $input * From 99abb8b4f2efca137086c258e552707744c9848d Mon Sep 17 00:00:00 2001 From: leightonthomas Date: Fri, 16 Nov 2018 11:27:40 +0000 Subject: [PATCH 11/11] [BRA-1811] Fix failing tests --- tests/Vivait/DocBuild/DocBuildTest.php | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/tests/Vivait/DocBuild/DocBuildTest.php b/tests/Vivait/DocBuild/DocBuildTest.php index 18d46a4..453d25d 100644 --- a/tests/Vivait/DocBuild/DocBuildTest.php +++ b/tests/Vivait/DocBuild/DocBuildTest.php @@ -15,7 +15,7 @@ use Vivait\DocBuild\Exception\UnauthorizedException; use Vivait\DocBuild\Http\Adapter; use Vivait\DocBuild\Http\Response; -use Vivait\DocBuild\Model\HttpException; +use Vivait\DocBuild\Exception\HttpException; class DocBuildTest extends TestCase { @@ -133,7 +133,9 @@ public function itWillCorrectlyDownloadADocument(): void self::assertSame($fakeExternalFile->getContent(), $actualFile->getContent()); - \fclose($fakeExternalStream); + if (\is_resource($fakeExternalStream)) { + \fclose($fakeExternalStream); + } } /** @@ -166,6 +168,7 @@ public function itErrorsWithInvalidCredentials(int $status): void $exception = new HttpException( $status, + "Test", $req1 = new Response( 200, $this->asStream(\json_encode(['error_description' => 'unrecognised error'])) @@ -210,6 +213,7 @@ public function itWillThrowAnExceptionIfTheCacheKeyCannotBeDeleted(int $status): $exception = new HttpException( $status, + "Test", $req1 = new Response( 200, $this->asStream(\json_encode(['error_description' => 'unrecognised error'])) @@ -248,6 +252,7 @@ public function itWillThrowAnExceptionIfTheTokenIsExpired(int $status): void $exception = new HttpException( $status, + "Test", $req1 = new Response( 200, $this->asStream(\json_encode(['error_description' => DocBuild::TOKEN_EXPIRED])) @@ -286,6 +291,7 @@ public function itWillThrowAnExceptionIfTheTokenIsInvalid(int $status): void $exception = new HttpException( $status, + "Test", $req1 = new Response( 200, $this->asStream(\json_encode(['error_description' => DocBuild::TOKEN_INVALID]))