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/test_app/TestApp/Application.php b/tests/test_app/TestApp/Application.php index c0df963..6d51b0c 100644 --- a/tests/test_app/TestApp/Application.php +++ b/tests/test_app/TestApp/Application.php @@ -5,6 +5,8 @@ use Cake\Http\BaseApplication; use Cake\Http\MiddlewareQueue; +use Cake\Routing\Middleware\RoutingMiddleware; +use Cake\Routing\RouteBuilder; /** * Application setup class. @@ -14,11 +16,33 @@ */ class Application extends BaseApplication { + /** + * {@inheritDoc} + * + * Do not require config/bootstrap.php + */ + public function bootstrap(): void + { + } + + /** + * {@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'); + }); + } + /** * {@inheritDoc} */ public function middleware(MiddlewareQueue $middleware): MiddlewareQueue { + $middleware->add(new RoutingMiddleware($this)); + return $middleware; } } 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; +}