From 64064dc0d1c6b24f2bfa395524d0a05f13aceed2 Mon Sep 17 00:00:00 2001 From: batopa Date: Wed, 3 Jun 2020 23:06:50 +0200 Subject: [PATCH 1/4] feat: backport ApiProxyTrait (cherry picked from commit 88e49cdf6a76006a4e616d41a80fe80e534e4ef9) --- src/Controller/ApiProxyTrait.php | 276 ++++++++++++++++++ .../TestCase/Controller/ApiProxyTraitTest.php | 235 +++++++++++++++ tests/bootstrap.php | 5 +- tests/test_app/{ => TestApp}/Application.php | 14 +- .../TestApp/Controller/ApiController.php | 23 ++ tests/test_app/config/bootstrap.php | 2 + 6 files changed, 550 insertions(+), 5 deletions(-) create mode 100644 src/Controller/ApiProxyTrait.php create mode 100644 tests/TestCase/Controller/ApiProxyTraitTest.php rename tests/test_app/{ => TestApp}/Application.php (55%) create mode 100644 tests/test_app/TestApp/Controller/ApiController.php create mode 100644 tests/test_app/config/bootstrap.php diff --git a/src/Controller/ApiProxyTrait.php b/src/Controller/ApiProxyTrait.php new file mode 100644 index 0000000..edd22e5 --- /dev/null +++ b/src/Controller/ApiProxyTrait.php @@ -0,0 +1,276 @@ + for more details. + */ +namespace BEdita\WebTools\Controller; + +use BEdita\SDK\BEditaClientException; +use BEdita\WebTools\ApiClientProvider; +use Cake\Http\Exception\MethodNotAllowedException; +use Cake\Routing\Router; +use Cake\Utility\Hash; +use Cake\View\ViewVarsTrait; + +/** + * Use this Trait in a controller to directly proxy requests to BE4 API. + * The response will be the same of the API itself with links masked. + * + * You need also to define routing rules configured as (for ApiController) + * + * ``` + * $builder->scope('/api', ['_namePrefix' => 'api:'], function (RouteBuilder $builder) { + * $builder->get('/**', ['controller' => 'Api', 'action' => 'get'], 'get'); + * $builder->post('/**', ['controller' => 'Api', 'action' => 'post'], 'post'); + * // and so on for patch, delete if you want to use it + * }); + * ``` + */ +trait ApiProxyTrait +{ + use ViewVarsTrait; + + /** + * An instance of a \Cake\Http\ServerRequest object that contains information about the current request. + * + * @var \Cake\Http\ServerRequest + */ + protected $request; + + /** + * An instance of a Response object that contains information about the impending response. + * + * @var \Cake\Http\Response + */ + protected $response; + + /** + * BEdita4 API client + * + * @var \BEdita\SDK\BEditaClient + */ + protected $apiClient = null; + + /** + * Base URL used for mask links. + * + * @var string + */ + protected $baseUrl = ''; + + /** + * {@inheritDoc} + */ + public function initialize(): void + { + parent::initialize(); + + if ($this->apiClient === null) { + $this->apiClient = ApiClientProvider::getApiClient(); + } + + $this->viewBuilder() + ->setClassName('Json') + ->setOption('serialize', true); + } + + /** + * Set base URL used for mask links removing trailing slashes. + * + * @param string $path The path on which build base URL + * @return void + */ + protected function setBaseUrl($path): void + { + $requestPath = $this->request->getPath(); + $basePath = substr($requestPath, 0, strpos($requestPath, $path)); + $this->baseUrl = Router::url(rtrim($basePath, '/'), true); + } + + /** + * Proxy for GET requests to BEdita4 API + * + * @param string $path The path for API request + * @return void + */ + public function get($path = '') + { + $this->setBaseUrl($path); + $this->apiRequest([ + 'method' => 'get', + 'path' => $path, + 'query' => $this->request->getQueryParams(), + ]); + } + + /** + * Routes a request to the API handling response and errors. + * + * `$options` are: + * - method => the HTTP request method + * - path => a string representing the complete endpoint path + * - query => an array of query strings + * - body => the body sent + * - headers => an array of headers + * + * @param array $options The request options + * @return void + */ + protected function apiRequest(array $options): void + { + $options += [ + 'method' => '', + 'path' => '', + 'query' => null, + 'body' => null, + 'headers' => null, + ]; + + try { + switch (strtolower($options['method'])) { + case 'get': + $response = $this->apiClient->get($options['path'], $options['query'], $options['headers']); + break; + // case 'post': + // $response = $this->apiClient->post($options['path'], $options['body'], $options['headers']); + // break; + // case 'patch': + // $response = $this->apiClient->patch($options['path'], $options['body'], $options['headers']); + // break; + // case 'delete': + // $response = $this->apiClient->delete($options['path'], $options['body'], $options['headers']); + // break; + default: + throw new MethodNotAllowedException(); + } + + if (empty($response) || !is_array($response)) { + return; + } + + $response = $this->maskResponseLinks($response); + $this->set($response); + } catch (\Throwable $e) { + $this->handleError($e); + } + } + + /** + * Handle error. + * Set error var for view. + * + * @param \Throwable $error The error thrown. + * @return void + */ + protected function handleError(\Throwable $error): void + { + $status = $error->getCode(); + if ($status < 100 || $status > 599) { + $status = 500; + } + $this->response = $this->response->withStatus($status); + $errorData = [ + 'status' => (string)$status, + 'title' => $error->getMessage(), + ]; + $this->set('error', $errorData); + + if (!$error instanceof BEditaClientException) { + return; + } + + $errorAttributes = $error->getAttributes(); + if (!empty($errorAttributes)) { + $this->set('error', $errorAttributes); + } + } + + /** + * Mask links of response to not expose API URL. + * + * @param array $response The response from API + * @return array + */ + protected function maskResponseLinks(array $response): array + { + $response = $this->maskLinks($response, '$id'); + $response = $this->maskLinks($response, 'links'); + $response = $this->maskLinks($response, 'meta.schema'); + + if (!empty($response['meta']['resources'])) { + $response = $this->maskMultiLinks($response, 'meta.resources', 'href'); + } + + $data = (array)Hash::get($response, 'data'); + if (empty($data)) { + return $response; + } + + if (Hash::numeric(array_keys($data))) { + foreach ($data as &$item) { + $item = $this->maskLinks($item, 'links'); + $item = $this->maskMultiLinks($item); + } + $response['data'] = $data; + } else { + $response['data']['relationships'] = $this->maskMultiLinks($data); + } + + return (array)$response; + } + + /** + * Mask links across multidimensional array. + * By default search for `relationships` and mask their `links`. + * + * @param array $data The data with links to mask + * @param string $path The path to search for + * @param string $key The key on which are the links + * @return array + */ + protected function maskMultiLinks(array $data, string $path = 'relationships', string $key = 'links'): array + { + $relationships = Hash::get($data, $path, []); + foreach ($relationships as &$rel) { + $rel = $this->maskLinks($rel, $key); + } + + return Hash::insert($data, $path, $relationships); + } + + /** + * Mask links found in `$path` + * + * @param array $data The data with links to mask + * @param string $path The path to search for + * @return array + */ + protected function maskLinks(array $data, string $path): array + { + $links = Hash::get($data, $path, []); + if (empty($links)) { + return $data; + } + + if (is_string($links)) { + $links = str_replace($this->apiClient->getApiBaseUrl(), $this->baseUrl, $links); + + return Hash::insert($data, $path, $links); + } + + foreach ($links as &$link) { + $link = str_replace($this->apiClient->getApiBaseUrl(), $this->baseUrl, $link); + } + + return Hash::insert($data, $path, $links); + } +} diff --git a/tests/TestCase/Controller/ApiProxyTraitTest.php b/tests/TestCase/Controller/ApiProxyTraitTest.php new file mode 100644 index 0000000..b2f4456 --- /dev/null +++ b/tests/TestCase/Controller/ApiProxyTraitTest.php @@ -0,0 +1,235 @@ + for more details. + */ +namespace BEdita\WebTools\Test\TestCase\Controller; + +use BEdita\SDK\BEditaClient; +use BEdita\WebTools\ApiClientProvider; +use BEdita\WebTools\Controller\ApiProxyTrait; +use Cake\Controller\Controller; +use Cake\Http\ServerRequest; +use Cake\Routing\Router; +use Cake\TestSuite\IntegrationTestTrait; +use Cake\TestSuite\TestCase; +use Cake\Utility\Hash; + +/** + * ApiProxyTraitTest class + * + * {@see \BEdita\WebTools\Controller\ApiProxyTrait} Test Case + * + * @coversDefaultClass \BEdita\WebTools\Controller\ApiProxyTrait + */ +class ApiProxyTraitTest extends TestCase +{ + use IntegrationTestTrait; + + /** + * Instance of BEditaClient + * + * @var \BEdita\SDK\BEditaClient + */ + protected $apiClient = null; + + /** + * {@inheritDoc} + */ + public function setUp(): void + { + parent::setUp(); + + $this->apiClient = ApiClientProvider::getApiClient(); + $response = $this->apiClient->authenticate(env('BEDITA_ADMIN_USR'), env('BEDITA_ADMIN_PWD')); + $this->apiClient->setupTokens($response['meta']); + } + + /** + * {@inheritDoc} + */ + public function tearDown(): void + { + parent::tearDown(); + + $this->apiClient = null; + } + + /** + * Get base URL. + * + * @return string + */ + protected function getBaseUrl(): string + { + return Router::url('/', true); + } + + /** + * Test get() method + * + * @return void + * + * @covers ::initialize() + * @covers ::get() + * @covers ::setBaseUrl() + * @covers ::apiRequest() + * @covers ::maskResponseLinks() + * @covers ::maskMultiLinks() + * @covers ::maskLinks() + */ + public function testGet(): void + { + $this->get('/api/users/1'); + $this->assertResponseOk(); + $this->assertContentType('application/json'); + $data = $this->viewVariable('data'); + $links = $this->viewVariable('links'); + $meta = $this->viewVariable('meta'); + static::assertNotEmpty($data); + static::assertNotEmpty($links); + static::assertNotEmpty($meta); + static::assertEquals('1', Hash::get($data, 'id')); + + $response = json_decode((string)$this->_response, true); + static::assertArrayHasKey('data', $response); + static::assertArrayHasKey('links', $response); + static::assertArrayHasKey('meta', $response); + + $baseUrl = $this->getBaseUrl(); + foreach ($response['links'] as $link) { + static::assertStringContainsString($baseUrl, $link); + } + + foreach (Hash::extract($response, 'data.relationships.{s}.links') as $link) { + static::assertStringContainsString($baseUrl, $link); + } + } + + /** + * Test non found error proxied from API. + * + * @return void + * + * @covers ::get() + * @covers ::apiRequest() + * @covers ::handleError() + */ + public function testNotFoundError(): void + { + $this->get('/api/users/1000'); + $this->assertResponseError(); + $this->assertContentType('application/json'); + $error = $this->viewVariable('error'); + static::assertNotEmpty($error); + + $response = json_decode((string)$this->_response, true); + static::assertArrayHasKey('error', $response); + static::assertArrayHasKey('status', $response['error']); + static::assertArrayHasKey('title', $response['error']); + } + + /** + * Test that masking links with value searched equal to string works. + * + * @return void + * + * @covers ::maskLinks() + */ + public function testMaskLinksString(): void + { + $this->get('/api/model/schema/users'); + $this->assertResponseOk(); + $this->assertContentType('application/json'); + $response = json_decode((string)$this->_response, true); + static::assertStringContainsString($this->getBaseUrl(), Hash::get($response, '$id')); + } + + /** + * Test that getting a list of objects the relationships links are masked. + * + * @return void + * + * @covers ::maskResponseLinks() + */ + public function testMaskRelationshipsLinksGettingList(): void + { + $this->get('/api/users'); + $this->assertResponseOk(); + $this->assertContentType('application/json'); + $response = json_decode((string)$this->_response, true); + + foreach (Hash::extract($response, 'data.{n}.relationships.{s}.links.{s}') as $link) { + static::assertStringContainsString($this->getBaseUrl(), $link); + } + } + + /** + * Test that getting /home the resources links are masked. + * + * @return void + * + * @covers ::maskResponseLinks() + */ + public function testMaskResourcesGettingHome(): void + { + $this->get('/api/home'); + $this->assertResponseOk(); + $this->assertContentType('application/json'); + $response = json_decode((string)$this->_response, true); + + foreach (Hash::extract($response, 'meta.resources.{s}.href') as $link) { + static::assertStringContainsString($this->getBaseUrl(), $link); + } + } + + /** + * Test that an exception different from BEditaClientException throws in BEditaClient request + * is correctly handled + * + * @return void + * + * @covers ::handleError() + */ + public function testNotBEditaClientException(): void + { + $controller = new class (new ServerRequest()) extends Controller { + use ApiProxyTrait; + + public function setApiCLient($apiClient) + { + $this->apiClient = $apiClient; + } + + protected function setBaseUrl($path): void + { + $this->baseUrl = '/'; + } + }; + + $apiClientMock = $this->getMockBuilder(BEditaClient::class) + ->disableOriginalConstructor() + ->setMethods(['get']) + ->getMock(); + + $apiClientMock->method('get')->willThrowException(new \LogicException('Broken')); + + $controller->setApiCLient($apiClientMock); + $controller->get('/gustavo'); + $error = $controller->viewBuilder()->getVar('error'); + + static::assertArrayHasKey('status', $error); + static::assertArrayHasKey('title', $error); + static::assertEquals('500', $error['status']); + static::assertEquals('Broken', $error['title']); + } +} diff --git a/tests/bootstrap.php b/tests/bootstrap.php index 03c2fb5..f9da655 100644 --- a/tests/bootstrap.php +++ b/tests/bootstrap.php @@ -101,8 +101,5 @@ ConnectionManager::setConfig('test', ['url' => getenv('db_dsn')]); Router::reload(); -require ROOT . 'Application.php'; -use TestApp\Application; - -$app = new Application(dirname(__DIR__) . '/config'); +$app = new TestApp\Application(dirname(__DIR__) . '/config'); $app->bootstrap(); diff --git a/tests/test_app/Application.php b/tests/test_app/TestApp/Application.php similarity index 55% rename from tests/test_app/Application.php rename to tests/test_app/TestApp/Application.php index 17970a9..60519b4 100644 --- a/tests/test_app/Application.php +++ b/tests/test_app/TestApp/Application.php @@ -3,6 +3,7 @@ namespace TestApp; use BEdita\WebTools\BaseApplication; +use Cake\Routing\RouteBuilder; /** * Application setup class. @@ -21,6 +22,17 @@ public function bootstrap() parent::bootstrap(); // Load WebTools plugin - $this->addPlugin('BEdita/WebTools', ['bootstrap' => true, 'path' => dirname(dirname(__DIR__)) . DS]); + $this->addPlugin('BEdita/WebTools', ['bootstrap' => true, 'path' => dirname(dirname(dirname(__DIR__))) . DS]); + } + + /** + * {@inheritDoc} + */ + public function routes(RouteBuilder $routes): void + { + // add rules for ApiProxyTrait + $routes->scope('/api', ['_namePrefix' => 'api:'], function (RouteBuilder $routes) { + $routes->get('/**', ['controller' => 'Api', 'action' => 'get'], 'get'); + }); } } diff --git a/tests/test_app/TestApp/Controller/ApiController.php b/tests/test_app/TestApp/Controller/ApiController.php new file mode 100644 index 0000000..415f9f1 --- /dev/null +++ b/tests/test_app/TestApp/Controller/ApiController.php @@ -0,0 +1,23 @@ + for more details. + */ +namespace TestApp\Controller; + +use BEdita\WebTools\Controller\ApiProxyTrait; +use Cake\Controller\Controller; + +class ApiController extends Controller +{ + use ApiProxyTrait; +} diff --git a/tests/test_app/config/bootstrap.php b/tests/test_app/config/bootstrap.php new file mode 100644 index 0000000..c836a10 --- /dev/null +++ b/tests/test_app/config/bootstrap.php @@ -0,0 +1,2 @@ + Date: Wed, 3 Jun 2020 23:14:03 +0200 Subject: [PATCH 2/4] chore: we are in 2020 [skip ci] (cherry picked from commit 88c27f197dcccc62ca769ea6078da992fa42819d) --- tests/test_app/TestApp/Controller/ApiController.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_app/TestApp/Controller/ApiController.php b/tests/test_app/TestApp/Controller/ApiController.php index 415f9f1..137cfe7 100644 --- a/tests/test_app/TestApp/Controller/ApiController.php +++ b/tests/test_app/TestApp/Controller/ApiController.php @@ -3,7 +3,7 @@ /** * BEdita, API-first content management framework - * Copyright 2018 ChannelWeb Srl, Chialab Srl + * Copyright 2020 ChannelWeb Srl, Chialab Srl * * This file is part of BEdita: you can redistribute it and/or modify * it under the terms of the GNU Lesser General Public License as published From 0ffc9b7292b1f97a3dd7e01b2b052e70b035bb5d Mon Sep 17 00:00:00 2001 From: batopa Date: Thu, 4 Jun 2020 08:53:07 +0200 Subject: [PATCH 3/4] fix: make ApiProxyTrait compatible for cake 3.8 --- src/Controller/ApiProxyTrait.php | 9 +++++---- tests/TestCase/Controller/ApiProxyTraitTest.php | 12 ++++++------ tests/test_app/TestApp/Application.php | 2 +- tests/test_app/config/routes.php | 2 ++ 4 files changed, 14 insertions(+), 11 deletions(-) create mode 100644 tests/test_app/config/routes.php diff --git a/src/Controller/ApiProxyTrait.php b/src/Controller/ApiProxyTrait.php index edd22e5..a14e3dc 100644 --- a/src/Controller/ApiProxyTrait.php +++ b/src/Controller/ApiProxyTrait.php @@ -44,14 +44,14 @@ trait ApiProxyTrait * * @var \Cake\Http\ServerRequest */ - protected $request; + public $request; /** * An instance of a Response object that contains information about the impending response. * * @var \Cake\Http\Response */ - protected $response; + public $response; /** * BEdita4 API client @@ -79,8 +79,9 @@ public function initialize(): void } $this->viewBuilder() - ->setClassName('Json') - ->setOption('serialize', true); + ->setClassName('Json'); + + $this->set('_serialize', true); } /** diff --git a/tests/TestCase/Controller/ApiProxyTraitTest.php b/tests/TestCase/Controller/ApiProxyTraitTest.php index b2f4456..c676e55 100644 --- a/tests/TestCase/Controller/ApiProxyTraitTest.php +++ b/tests/TestCase/Controller/ApiProxyTraitTest.php @@ -107,11 +107,11 @@ public function testGet(): void $baseUrl = $this->getBaseUrl(); foreach ($response['links'] as $link) { - static::assertStringContainsString($baseUrl, $link); + static::assertStringStartsWith($baseUrl, $link); } foreach (Hash::extract($response, 'data.relationships.{s}.links') as $link) { - static::assertStringContainsString($baseUrl, $link); + static::assertStringStartsWith($baseUrl, $link); } } @@ -151,7 +151,7 @@ public function testMaskLinksString(): void $this->assertResponseOk(); $this->assertContentType('application/json'); $response = json_decode((string)$this->_response, true); - static::assertStringContainsString($this->getBaseUrl(), Hash::get($response, '$id')); + static::assertStringStartsWith($this->getBaseUrl(), Hash::get($response, '$id')); } /** @@ -169,7 +169,7 @@ public function testMaskRelationshipsLinksGettingList(): void $response = json_decode((string)$this->_response, true); foreach (Hash::extract($response, 'data.{n}.relationships.{s}.links.{s}') as $link) { - static::assertStringContainsString($this->getBaseUrl(), $link); + static::assertStringStartsWith($this->getBaseUrl(), $link); } } @@ -188,7 +188,7 @@ public function testMaskResourcesGettingHome(): void $response = json_decode((string)$this->_response, true); foreach (Hash::extract($response, 'meta.resources.{s}.href') as $link) { - static::assertStringContainsString($this->getBaseUrl(), $link); + static::assertStringStartsWith($this->getBaseUrl(), $link); } } @@ -225,7 +225,7 @@ protected function setBaseUrl($path): void $controller->setApiCLient($apiClientMock); $controller->get('/gustavo'); - $error = $controller->viewBuilder()->getVar('error'); + $error = $controller->viewVars['error']; static::assertArrayHasKey('status', $error); static::assertArrayHasKey('title', $error); diff --git a/tests/test_app/TestApp/Application.php b/tests/test_app/TestApp/Application.php index 60519b4..c28a165 100644 --- a/tests/test_app/TestApp/Application.php +++ b/tests/test_app/TestApp/Application.php @@ -28,7 +28,7 @@ public function bootstrap() /** * {@inheritDoc} */ - public function routes(RouteBuilder $routes): void + public function routes($routes) { // add rules for ApiProxyTrait $routes->scope('/api', ['_namePrefix' => 'api:'], function (RouteBuilder $routes) { diff --git a/tests/test_app/config/routes.php b/tests/test_app/config/routes.php new file mode 100644 index 0000000..c836a10 --- /dev/null +++ b/tests/test_app/config/routes.php @@ -0,0 +1,2 @@ + Date: Thu, 4 Jun 2020 09:04:57 +0200 Subject: [PATCH 4/4] chore: make phpcs happy --- tests/TestCase/Controller/ApiProxyTraitTest.php | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/tests/TestCase/Controller/ApiProxyTraitTest.php b/tests/TestCase/Controller/ApiProxyTraitTest.php index c676e55..d963495 100644 --- a/tests/TestCase/Controller/ApiProxyTraitTest.php +++ b/tests/TestCase/Controller/ApiProxyTraitTest.php @@ -33,7 +33,9 @@ */ class ApiProxyTraitTest extends TestCase { + // @codingStandardsIgnoreStart use IntegrationTestTrait; + // @codingStandardsIgnoreEnd /** * Instance of BEditaClient @@ -203,7 +205,9 @@ public function testMaskResourcesGettingHome(): void public function testNotBEditaClientException(): void { $controller = new class (new ServerRequest()) extends Controller { + // @codingStandardsIgnoreStart use ApiProxyTrait; + // @codingStandardsIgnoreEnd public function setApiCLient($apiClient) {