diff --git a/src/Http/BaseClient.php b/src/Http/BaseClient.php new file mode 100644 index 0000000..dadcd55 --- /dev/null +++ b/src/Http/BaseClient.php @@ -0,0 +1,278 @@ + for more details. + */ + +namespace BEdita\WebTools\Http; + +use Cake\Core\App; +use Cake\Core\Configure; +use Cake\Core\InstanceConfigTrait; +use Cake\Http\Client; +use Cake\Http\Client\Response; +use Cake\Log\LogTrait; +use Cake\Validation\Validator; + +/** + * Base class for clients. + */ +abstract class BaseClient +{ + use InstanceConfigTrait; + use LogTrait; + + /** + * Default configuration. + * + * @var array + */ + protected $_defaultConfig = [ + 'auth' => null, + 'logLevel' => 'error', + 'url' => null, + ]; + + /** + * The HTTP client. + * + * @var \Cake\Http\Client + */ + protected $client; + + /** + * Constructor. Initialize HTTP client. + * + * @param array $config The configuration + */ + public function __construct(array $config = []) + { + $config += (array)Configure::read($this->defaultConfigName(), []); + $this->setConfig($config); + $this->validateConf($this->getValidator()); + $this->createClient(); + } + + /** + * Get default config name. + * It's the name of the client class without `Client` suffix. + * + * @return string + */ + protected function defaultConfigName(): string + { + $shortName = App::shortName(static::class, 'Http', 'Client'); + [$plugin, $name] = pluginSplit($shortName); + + return $name; + } + + /** + * Return the Validator object + * + * @return \Cake\Validation\Validator + */ + protected function getValidator(): Validator + { + $validator = new Validator(); + + return $validator + ->requirePresence('url') + ->notEmptyString('url'); + } + + /** + * Validate configuration data. + * + * @param \Cake\Validation\Validator $validator The validator object + * @return void + */ + protected function validateConf(Validator $validator): void + { + $errors = $validator->validate($this->getConfig()); + if (!empty($errors)) { + throw new \InvalidArgumentException(sprintf('%s client config not valid: %s', static::class, json_encode($errors))); + } + } + + /** + * Create JSON HTTP client. + * + * @return void + */ + protected function createClient(): void + { + $parsedUrl = parse_url($this->getConfig('url')); + $options = [ + 'host' => $parsedUrl['host'], + 'scheme' => $parsedUrl['scheme'], + 'path' => $parsedUrl['path'] ?? '', + 'headers' => [ + 'Accept' => 'application/json', + 'Content-Type' => 'application/json', + ], + ] + $this->getConfig(); + $this->client = new Client($options); + } + + /** + * Get the correct relative url. + * + * @param string $url The relative url + * @return string + */ + protected function getUrl(string $url): string + { + if (strpos($url, 'https://') === 0) { + return $url; + } + $base = trim($this->client->getConfig('path'), '/'); + $url = trim($url, '/'); + + return sprintf('%s/%s', $base, $url); + } + + /** + * Log API call. + * + * @param string $call The API call + * @param string $url The API url + * @param string $payload The json payload + * @param \Cake\Http\Client\Response $response The API response + * @return ?string + */ + protected function logCall(string $call, string $url, string $payload, Response $response): ?string + { + $level = $this->getConfig('logLevel') ?? 'error'; + if (!in_array($level, ['error', 'debug'])) { + return null; + } + $result = $response->isOk() ? '' : 'error'; + if ($level === 'error' && empty($result)) { + return null; + } + $level = $result === 'error' ? 'error' : $level; + $message = sprintf( + '%s API %s | %s %s | with status %s: %s - Payload: %s', + $response->isOk() ? '[OK]' : '[ERROR]', + $this->defaultConfigName(), + $call, + $url, + $response->getStatusCode(), + (string)$response->getBody(), + $payload + ); + $message = trim($message); + $this->log($message, $level); + + return $message; + } + + /** + * Get http client + * + * @return \Cake\Http\Client The client + */ + public function getHttpClient(): Client + { + return $this->client; + } + + /** + * Get request. + * + * @param string $url The request url + * @param array $data The query data + * @param array $options Request options + * @return \Cake\Http\Client\Response + */ + public function get(string $url, array $data = [], array $options = []): Response + { + $apiUrl = $this->getUrl($url); + $response = $this->client->get($apiUrl, $data, $options); + $this->logCall('/GET', $apiUrl, json_encode($data), $response); + + return $response; + } + + /** + * Post request. + * + * @param string $url The request url + * @param array $data The post data + * @param array $options Request options + * @return \Cake\Http\Client\Response + */ + public function post(string $url, array $data = [], array $options = []): Response + { + $data = json_encode($data); + $apiUrl = $this->getUrl($url); + $response = $this->client->post($apiUrl, $data, $options); + $this->logCall('/POST', $apiUrl, $data, $response); + + return $response; + } + + /** + * Patch request. + * + * @param string $url The request url + * @param array $data The post data + * @param array $options Request options + * @return \Cake\Http\Client\Response + */ + public function patch(string $url, array $data = [], array $options = []): Response + { + $apiUrl = $this->getUrl($url); + $data = json_encode($data); + $response = $this->client->patch($apiUrl, $data, $options); + $this->logCall('/PATCH', $apiUrl, $data, $response); + + return $response; + } + + /** + * Put request. + * + * @param string $url The request url + * @param array $data The post data + * @param array $options Request options + * @return \Cake\Http\Client\Response + */ + public function put(string $url, array $data = [], array $options = []): Response + { + $apiUrl = $this->getUrl($url); + $data = json_encode($data); + $response = $this->client->put($this->getUrl($url), $data, $options); + $this->logCall('/PUT', $apiUrl, $data, $response); + + return $response; + } + + /** + * Delete request. + * + * @param string $url The request url + * @param array $data The post data + * @param array $options Request options + * @return \Cake\Http\Client\Response + */ + public function delete(string $url, array $data = [], array $options = []): Response + { + $apiUrl = $this->getUrl($url); + $data = json_encode($data); + $response = $this->client->delete($this->getUrl($url), $data, $options); + $this->logCall('/DELETE', $apiUrl, $data, $response); + + return $response; + } +} diff --git a/tests/TestCase/Http/BaseClientTest.php b/tests/TestCase/Http/BaseClientTest.php new file mode 100644 index 0000000..fc2a630 --- /dev/null +++ b/tests/TestCase/Http/BaseClientTest.php @@ -0,0 +1,304 @@ + for more details. + */ + +namespace BEdita\WebTools\Test\TestCase\Identifier; + +use BEdita\WebTools\Http\BaseClient; +use Cake\Core\Configure; +use Cake\Http\Client; +use Cake\Http\Client\Response; +use Cake\TestSuite\TestCase; +use Cake\Validation\Validator; +use Laminas\Diactoros\Stream; + +/** + * {@see \BEdita\WebTools\Http\BaseClient} Test Case + * + * @coversDefaultClass \BEdita\WebTools\Http\BaseClient + */ +class BaseClientTest extends TestCase +{ + /** + * Test constructor against invalid configuration. + * + * @return void + * @covers ::__construct() + * @covers ::validateConf() + * @covers ::getValidator() + * @covers ::createClient() + */ + public function testInvalidConfig(): void + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('client config not valid: {"url":{"_empty":"This field cannot be left empty"}}'); + $config = [ + 'auth' => [ + 'type' => 'BearerAccessToken', + ], + 'logLevel' => 'error', + ]; + new class ($config) extends BaseClient { + public function getValidator(): Validator + { + return parent::getValidator(); + } + }; + } + + /** + * Basic test. + * + * @return void + * @covers ::__construct() + * @covers ::validateConf() + * @covers ::getValidator() + * @covers ::createClient() + * @covers ::defaultConfigName() + * @covers ::getHttpClient() + */ + public function testBase(): void + { + Configure::write('BaseClientTest.php', ['url' => 'https://example.com']); + $config = [ + 'auth' => [ + 'type' => 'BearerAccessToken', + ], + 'logLevel' => 'error', + ]; + $client = new class ($config) extends BaseClient { + public function getValidator(): Validator + { + return parent::getValidator(); + } + }; + static::assertInstanceOf(BaseClient::class, $client); + $test = $client->getHttpClient(); + static::assertInstanceOf(Client::class, $test); + } + + /** + * Test `getUrl` method. + * + * @return void + * @covers ::getUrl() + */ + public function testGetUrl(): void + { + $config = [ + 'auth' => [ + 'type' => 'BearerAccessToken', + ], + 'logLevel' => 'error', + 'url' => 'https://example.com/api/v2', + ]; + $client = new class ($config) extends BaseClient { + public function getUrl(string $url): string + { + return parent::getUrl($url); + } + }; + $url = $client->getUrl('https://example.com/api/objects'); + static::assertSame('https://example.com/api/objects', $url); + $url = $client->getUrl('/objects'); + static::assertSame('api/v2/objects', $url); + } + + /** + * Test `logCall` method. + * + * @return void + * @covers ::logCall() + */ + public function testLogCall(): void + { + $config = [ + 'auth' => [ + 'type' => 'BearerAccessToken', + ], + 'logLevel' => 'info', + 'url' => 'https://example.com/api/v2', + ]; + $client = new class ($config) extends BaseClient { + public function logCall(string $call, string $url, string $payload, Response $response): ?string + { + return parent::logCall($call, $url, $payload, $response); + } + }; + $response = new Response(); + $payload = '{"data": "test"}'; + $log = $client->logCall('/GET', 'https://example.com', $payload, $response); + static::assertNull($log); + + // log level error, response + $config['logLevel'] = 'error'; + $client = new class ($config) extends BaseClient { + public function logCall(string $call, string $url, string $payload, Response $response): ?string + { + return parent::logCall($call, $url, $payload, $response); + } + }; + $response = $response->withStatus(200); + $payload = '{"data": "test"}'; + $log = $client->logCall('/GET', 'https://example.com', $payload, $response); + static::assertNull($log); + + // log level debug, response ok + $config['logLevel'] = 'debug'; + $client = new class ($config) extends BaseClient { + public function logCall(string $call, string $url, string $payload, Response $response): ?string + { + return parent::logCall($call, $url, $payload, $response); + } + }; + $stream = new Stream('php://memory', 'wb+'); + $stream->write('this is a response body'); + $stream->rewind(); + $response = $response->withStatus(200)->withBody($stream); + $payload = '{"data": "test"}'; + $log = $client->logCall('/GET', 'https://example.com', $payload, $response); + static::assertEquals('[OK] API BaseClientTest.php: | /GET https://example.com | with status 200: this is a response body - Payload: {"data": "test"}', $log); + + // log level debug, response with error + $config['logLevel'] = 'debug'; + $client = new class ($config) extends BaseClient { + public function logCall(string $call, string $url, string $payload, Response $response): ?string + { + return parent::logCall($call, $url, $payload, $response); + } + }; + $stream = new Stream('php://memory', 'wb+'); + $stream->write('this is a response body for error'); + $stream->rewind(); + $response = $response->withStatus(400)->withBody($stream); + $payload = '{"data": "test"}'; + $log = $client->logCall('/GET', 'https://example.com', $payload, $response); + static::assertEquals('[ERROR] API BaseClientTest.php: | /GET https://example.com | with status 400: this is a response body for error - Payload: {"data": "test"}', $log); + } + + /** + * Data provider for `testGetPostPatchPutDelete` test case. + * + * @return array + */ + public function getPostPatchPutDeleteProvider(): array + { + return [ + 'get call' => ['get'], + 'post call' => ['post'], + 'patch call' => ['patch'], + 'put call' => ['put'], + 'delete call' => ['delete'], + ]; + } + + /** + * Test `get`, `post`, `patch`, `put`, `delete` methods. + * + * @return void + * @covers ::get() + * @covers ::post() + * @covers ::patch() + * @covers ::put() + * @covers ::delete() + * @covers ::logCall() + * @dataProvider getPostPatchPutDeleteProvider + */ + public function testGetPostPatchPutDelete(string $method): void + { + $config = [ + 'auth' => [ + 'type' => 'BearerAccessToken', + ], + 'logLevel' => 'debug', + 'url' => 'https://example.com/api/v2', + ]; + $client = new class ($config) extends BaseClient { + public string $lastLog = ''; + + public function createClient(): void + { + $options = [ + 'host' => 'example.com', + 'scheme' => 'https', + 'port' => 443, + 'path' => '/api/v2', + ]; + $this->client = new class ($options) extends Client { + public function get(string $url, $data = [], array $options = []): Response + { + $response = new Response(); + $stream = new Stream('php://memory', 'wb+'); + $stream->write('this is a response body'); + $stream->rewind(); + + return $response->withStatus(200)->withBody($stream); + } + + public function post(string $url, $data = [], array $options = []): Response + { + $response = new Response(); + $stream = new Stream('php://memory', 'wb+'); + $stream->write('this is a response body'); + $stream->rewind(); + + return $response->withStatus(200)->withBody($stream); + } + + public function patch(string $url, $data = [], array $options = []): Response + { + $response = new Response(); + $stream = new Stream('php://memory', 'wb+'); + $stream->write('this is a response body'); + $stream->rewind(); + + return $response->withStatus(200)->withBody($stream); + } + + public function put(string $url, $data = [], array $options = []): Response + { + $response = new Response(); + $stream = new Stream('php://memory', 'wb+'); + $stream->write('this is a response body'); + $stream->rewind(); + + return $response->withStatus(200)->withBody($stream); + } + + public function delete(string $url, $data = [], array $options = []): Response + { + $response = new Response(); + $stream = new Stream('php://memory', 'wb+'); + $stream->write('this is a response body'); + $stream->rewind(); + + return $response->withStatus(200)->withBody($stream); + } + }; + } + + public function logCall(string $call, string $url, string $payload, Response $response): ?string + { + $this->lastLog = parent::logCall($call, $url, $payload, $response); + + return $this->lastLog; + } + }; + $response = $client->$method('/whatever', ['data' => 'test']); + static::assertInstanceOf(Response::class, $response); + static::assertSame(200, $response->getStatusCode()); + $expected = sprintf('[OK] API BaseClientTest.php: | /%s api/v2/whatever | with status 200: this is a response body - Payload: {"data":"test"}', strtoupper($method)); + static::assertSame($expected, $client->lastLog); + } +}