diff --git a/.github/workflows/run-tests.yml b/.github/workflows/run-tests.yml index 0bdaf1f..1272433 100644 --- a/.github/workflows/run-tests.yml +++ b/.github/workflows/run-tests.yml @@ -9,7 +9,7 @@ jobs: fail-fast: true matrix: os: [ubuntu-latest, windows-latest] - php: [8.2, 8.1] + php: [8.4, 8.3, 8.2] stability: [prefer-lowest, prefer-stable] name: P${{ matrix.php }} - ${{ matrix.stability }} - ${{ matrix.os }} diff --git a/.gitignore b/.gitignore index 00403f7..b32743b 100644 --- a/.gitignore +++ b/.gitignore @@ -3,6 +3,7 @@ /.fleet .php_cs .php_cs.cache +.phpunit.cache .phpunit.result.cache build composer.lock diff --git a/composer.json b/composer.json index c376929..53881db 100644 --- a/composer.json +++ b/composer.json @@ -14,12 +14,13 @@ } ], "require": { - "php": "^8.1", + "php": "^8.2", "farzai/support": "^1.2", - "farzai/transport": "^1.2", + "farzai/transport": "^2.1", "phrity/websocket": "^3.0" }, "require-dev": { + "guzzlehttp/guzzle": "^7.8", "pestphp/pest": "^2.15", "laravel/pint": "^1.0", "spatie/ray": "^1.28" diff --git a/src/ClientBuilder.php b/src/ClientBuilder.php index 7372c8e..129970a 100644 --- a/src/ClientBuilder.php +++ b/src/ClientBuilder.php @@ -41,7 +41,7 @@ final class ClientBuilder */ public static function create() { - return new self(); + return new self; } /** @@ -98,17 +98,17 @@ public function build() { $this->ensureConfigIsValid(); - $logger = $this->logger ?? new NullLogger(); + $logger = $this->logger ?? new NullLogger; + + $builder = TransportBuilder::make() + ->withBaseUri(sprintf('https://%s', self::DEFAULT_HOST)) + ->setLogger($logger); - $builder = TransportBuilder::make(); if ($this->httpClient) { - $builder->setClient($this->httpClient); + $builder = $builder->setClient($this->httpClient); } - $builder->setLogger($logger); - $transport = $builder->build(); - $transport->setUri(sprintf('https://%s', self::DEFAULT_HOST)); $client = new Client( config: $this->config, @@ -130,8 +130,5 @@ private function ensureConfigIsValid(): void } } - final public function __construct() - { - - } + final public function __construct() {} } diff --git a/src/Endpoints/MarketEndpoint.php b/src/Endpoints/MarketEndpoint.php index 3c6e0ed..6abc414 100644 --- a/src/Endpoints/MarketEndpoint.php +++ b/src/Endpoints/MarketEndpoint.php @@ -35,7 +35,7 @@ public function symbols(): ResponseInterface /** * Get ticker information. * - * @param string $symbol (optional) The symbol (e.g. btc_thb) + * @param string $symbol (optional) The symbol (e.g. btc_thb) * * @response * { @@ -388,7 +388,7 @@ public function balances(): ResponseInterface * List all open orders of the given symbol. * Note : The client_id of this API response is the input body field name client_id , was inputted by the user of APIs. * - * @param string $sym The symbol (e.g. btc_thb) + * @param string $sym The symbol (e.g. btc_thb) * * @response * { diff --git a/src/Requests/PendingRequest.php b/src/Requests/PendingRequest.php index ac2f055..dfdfcfe 100644 --- a/src/Requests/PendingRequest.php +++ b/src/Requests/PendingRequest.php @@ -6,7 +6,7 @@ use Farzai\Bitkub\Contracts\RequestInterceptor; use Farzai\Bitkub\Responses\ResponseWithValidateErrorCode; use Farzai\Transport\Contracts\ResponseInterface; -use Farzai\Transport\Request; +use Farzai\Transport\RequestBuilder; use Farzai\Transport\Response; use Psr\Http\Message\RequestInterface as PsrRequestInterface; use Psr\Http\Message\ResponseInterface as PsrResponseInterface; @@ -146,25 +146,27 @@ public function createRequest(string $method, string $path, array $options = []) // Normalize path $path = '/'.trim($path, '/'); + $builder = (new RequestBuilder)->method($method)->uri($path); + // Query if (isset($options['query']) && is_array($options['query']) && ! empty($options['query'])) { - $path .= '?'.http_build_query($options['query']); + $builder = $builder->withQuery($options['query']); } // Set body if (isset($options['body'])) { $body = $options['body']; - - // Convert array to json - if (is_array($body)) { - $body = json_encode($body); - } + $builder = is_array($body) + ? $builder->withJson($body) + : $builder->withBody($body); } // Set headers - $headers = $options['headers'] ?? []; + if (! empty($options['headers'])) { + $builder = $builder->withHeaders($options['headers']); + } - return new Request($method, $path, $headers, $body ?? null); + return $builder->build(); } /** diff --git a/src/Responses/AbstractResponseDecorator.php b/src/Responses/AbstractResponseDecorator.php index e9a86a5..5b0be0e 100644 --- a/src/Responses/AbstractResponseDecorator.php +++ b/src/Responses/AbstractResponseDecorator.php @@ -3,13 +3,11 @@ namespace Farzai\Bitkub\Responses; use Farzai\Transport\Contracts\ResponseInterface; -use Farzai\Transport\Traits\PsrResponseTrait; use Psr\Http\Message\RequestInterface as PsrRequestInterface; +use Psr\Http\Message\StreamInterface; abstract class AbstractResponseDecorator implements ResponseInterface { - use PsrResponseTrait; - protected ResponseInterface $response; /** @@ -45,11 +43,11 @@ public function headers(): array } /** - * Check if the response is successfull. + * Check if the response is successful. */ - public function isSuccessfull(): bool + public function isSuccessful(): bool { - return $this->response->isSuccessfull(); + return $this->response->isSuccessful(); } /** @@ -61,15 +59,31 @@ public function json(?string $key = null): mixed } /** - * Throw an exception if the response is not successfull. - * - * @param callable<\Farzai\Transport\Contracts\ResponseInterface> $callback Custom callback to throw an exception. + * Return the json decoded response or null. + */ + public function jsonOrNull(?string $key = null): mixed + { + return $this->response->jsonOrNull($key); + } + + /** + * Return the response as an array. + */ + public function toArray(): array + { + return $this->response->toArray(); + } + + /** + * Throw an exception if the response is not successful. * * @throws \Psr\Http\Client\ClientExceptionInterface */ - public function throw(?callable $callback = null) + public function throw(?callable $callback = null): static { - return $this->response->throw($callback); + $this->response->throw($callback); + + return $this; } /** @@ -79,4 +93,84 @@ public function getPsrRequest(): PsrRequestInterface { return $this->response->getPsrRequest(); } + + // PSR-7 ResponseInterface delegation + + public function getStatusCode(): int + { + return $this->response->getStatusCode(); + } + + public function withStatus(int $code, string $reasonPhrase = ''): static + { + return $this->cloneWithResponse($this->response->withStatus($code, $reasonPhrase)); + } + + public function getReasonPhrase(): string + { + return $this->response->getReasonPhrase(); + } + + public function getProtocolVersion(): string + { + return $this->response->getProtocolVersion(); + } + + public function withProtocolVersion(string $version): static + { + return $this->cloneWithResponse($this->response->withProtocolVersion($version)); + } + + public function getHeaders(): array + { + return $this->response->getHeaders(); + } + + public function hasHeader(string $name): bool + { + return $this->response->hasHeader($name); + } + + public function getHeader(string $name): array + { + return $this->response->getHeader($name); + } + + public function getHeaderLine(string $name): string + { + return $this->response->getHeaderLine($name); + } + + public function withHeader(string $name, $value): static + { + return $this->cloneWithResponse($this->response->withHeader($name, $value)); + } + + public function withAddedHeader(string $name, $value): static + { + return $this->cloneWithResponse($this->response->withAddedHeader($name, $value)); + } + + public function withoutHeader(string $name): static + { + return $this->cloneWithResponse($this->response->withoutHeader($name)); + } + + public function getBody(): StreamInterface + { + return $this->response->getBody(); + } + + public function withBody(StreamInterface $body): static + { + return $this->cloneWithResponse($this->response->withBody($body)); + } + + private function cloneWithResponse(ResponseInterface $response): static + { + $clone = clone $this; + $clone->response = $response; + + return $clone; + } } diff --git a/src/Responses/ResponseWithValidateErrorCode.php b/src/Responses/ResponseWithValidateErrorCode.php index 413ee63..3e5464b 100644 --- a/src/Responses/ResponseWithValidateErrorCode.php +++ b/src/Responses/ResponseWithValidateErrorCode.php @@ -8,19 +8,22 @@ class ResponseWithValidateErrorCode extends AbstractResponseDecorator { /** - * Throw an exception if the response is not successfull. + * Throw an exception if the response is not successful. * * * @throws \Psr\Http\Client\ClientExceptionInterface */ - public function throw(?callable $callback = null) + public function throw(?callable $callback = null): static { - return parent::throw($callback ?? function (ResponseInterface $response, ?\Exception $e) use ($callback) { - if ($this->json('error') !== null && $this->json('error') !== 0) { + parent::throw($callback ?? function (ResponseInterface $response, ?\Exception $e) use ($callback) { + $errorCode = $this->json('error'); + if ($errorCode !== null && $errorCode !== 0) { throw new BitkubResponseErrorCodeException($response); } return $callback ? $callback($response, $e) : $response; }); + + return $this; } } diff --git a/src/WebSocket/Endpoints/LiveOrderBookEndpoint.php b/src/WebSocket/Endpoints/LiveOrderBookEndpoint.php index ef9ac32..7900050 100644 --- a/src/WebSocket/Endpoints/LiveOrderBookEndpoint.php +++ b/src/WebSocket/Endpoints/LiveOrderBookEndpoint.php @@ -13,7 +13,7 @@ class LiveOrderBookEndpoint extends AbstractEndpoint * echo $message->json('sym'); * }); * - * @param string|int $symbol Symbol name or id. + * @param string|int $symbol Symbol name or id. * @param callable|array $listeners */ public function listen($symbol, $listeners) diff --git a/src/WebSocket/Engine.php b/src/WebSocket/Engine.php index 2076d9b..00feec9 100644 --- a/src/WebSocket/Engine.php +++ b/src/WebSocket/Engine.php @@ -14,8 +14,7 @@ class Engine implements WebSocketEngineInterface { public function __construct( private LoggerInterface $logger, - ) { - } + ) {} public function handle(array $listeners): void { @@ -28,8 +27,8 @@ public function handle(array $listeners): void $client = new WebSocketClient('wss://api.bitkub.com/websocket-api/'.implode(',', $events)); $client - ->addMiddleware(new WebSocketMiddleware\CloseHandler()) - ->addMiddleware(new WebSocketMiddleware\PingResponder()); + ->addMiddleware(new WebSocketMiddleware\CloseHandler) + ->addMiddleware(new WebSocketMiddleware\PingResponder); $client->onText(function (WebSocketClient $client, WebSocketConnection $connection, WebSocketMessage $message) use ($listeners) { $receivedAt = Carbon::now(); diff --git a/tests/ClientBuilderTest.php b/tests/ClientBuilderTest.php index 1b45b72..75142f0 100644 --- a/tests/ClientBuilderTest.php +++ b/tests/ClientBuilderTest.php @@ -26,3 +26,39 @@ $client->market()->balances(); })->throws(InvalidArgumentException::class, 'Secret key is required'); + +it('can set custom http client', function () { + $httpClient = $this->createMock(\Psr\Http\Client\ClientInterface::class); + + $client = ClientBuilder::create() + ->setCredentials('test', 'secret') + ->setHttpClient($httpClient) + ->build(); + + expect($client)->toBeInstanceOf(Client::class); +}); + +it('can set custom logger', function () { + $logger = $this->createMock(\Psr\Log\LoggerInterface::class); + + $client = ClientBuilder::create() + ->setCredentials('test', 'secret') + ->setLogger($logger) + ->build(); + + expect($client)->toBeInstanceOf(Client::class); +}); + +it('can set retries', function () { + $client = ClientBuilder::create() + ->setCredentials('test', 'secret') + ->setRetries(5) + ->build(); + + expect($client)->toBeInstanceOf(Client::class); +}); + +it('should throw exception when retries is negative', function () { + ClientBuilder::create() + ->setRetries(-1); +})->throws(\InvalidArgumentException::class, 'Retries must be greater than or equal to 0.'); diff --git a/tests/PendingRequestTest.php b/tests/PendingRequestTest.php index c88fc3f..06e3ca9 100644 --- a/tests/PendingRequestTest.php +++ b/tests/PendingRequestTest.php @@ -149,3 +149,62 @@ expect($request->getUri()->getQuery())->toBe('symbol=BTC'); expect($request->getHeaderLine('Content-Type'))->toBe('application/json'); }); + +it('can createRequest with string body', function () { + $client = ClientBuilder::create() + ->setCredentials('test', 'secret') + ->build(); + + $pending = new PendingRequest($client, 'POST', '/api/market/orders'); + + $request = $pending->createRequest('POST', '/api/market/orders', [ + 'body' => 'raw-string-body', + ]); + + expect($request)->toBeInstanceOf(\Psr\Http\Message\RequestInterface::class); + expect($request->getBody()->getContents())->toBe('raw-string-body'); +}); + +it('can createRequest without optional parameters', function () { + $client = ClientBuilder::create() + ->setCredentials('test', 'secret') + ->build(); + + $pending = new PendingRequest($client, 'GET', '/api/market/ticker'); + + $request = $pending->createRequest('GET', '/api/market/ticker'); + + expect($request)->toBeInstanceOf(\Psr\Http\Message\RequestInterface::class); + expect($request->getMethod())->toBe('GET'); + expect($request->getUri()->getPath())->toBe('/api/market/ticker'); + expect($request->getUri()->getQuery())->toBe(''); +}); + +it('normalizes path with leading slash', function () { + $client = ClientBuilder::create() + ->setCredentials('test', 'secret') + ->build(); + + $pending = new PendingRequest($client, 'GET', 'api/market/ticker'); + + $request = $pending->createRequest('GET', 'api/market/ticker'); + + expect($request->getUri()->getPath())->toBe('/api/market/ticker'); +}); + +it('can createResponse wrapping PSR response', function () { + $client = ClientBuilder::create() + ->setCredentials('test', 'secret') + ->build(); + + $pending = new PendingRequest($client, 'GET', '/api/market/ticker'); + + $psrRequest = $this->createMock(\Psr\Http\Message\RequestInterface::class); + $psrResponse = \Farzai\Bitkub\Tests\MockHttpClient::response(200, json_encode(['error' => 0])); + + $response = $pending->createResponse($psrRequest, $psrResponse); + + expect($response)->toBeInstanceOf(\Farzai\Transport\Contracts\ResponseInterface::class); + expect($response)->toBeInstanceOf(\Farzai\Bitkub\Responses\ResponseWithValidateErrorCode::class); + expect($response->statusCode())->toBe(200); +}); diff --git a/tests/ResponseErrorCodeTest.php b/tests/ResponseErrorCodeTest.php index 4a78d16..1328ffa 100644 --- a/tests/ResponseErrorCodeTest.php +++ b/tests/ResponseErrorCodeTest.php @@ -27,7 +27,7 @@ expect($response->headers())->toBe([ 'Content-Type' => 'application/json', ]); - expect($response->isSuccessfull())->toBeTrue(); + expect($response->isSuccessful())->toBeTrue(); expect($response->json('error'))->toBe(0); expect($response->getPsrRequest())->toBe($psrRequest); }); @@ -78,3 +78,78 @@ new BitkubResponseErrorCodeException($psrResponse); })->throws(\Exception::class, 'Error code not found.'); + +it('should not throw exception when error code is null', function () { + $psrRequest = $this->createMock(PsrRequestInterface::class); + $psrResponse = MockHttpClient::response(200, json_encode([ + 'result' => 'ok', + ])); + + $response = new Response($psrRequest, $psrResponse); + $response = new ResponseWithValidateErrorCode($response); + + $result = $response->throw(); + + expect($result)->toBeInstanceOf(ResponseWithValidateErrorCode::class); +}); + +it('throw returns static for chaining', function () { + $psrRequest = $this->createMock(PsrRequestInterface::class); + $psrResponse = MockHttpClient::response(200, json_encode([ + 'error' => 0, + ])); + + $response = new Response($psrRequest, $psrResponse); + $response = new ResponseWithValidateErrorCode($response); + + $result = $response->throw(); + + expect($result)->toBe($response); +}); + +it('decorator delegates jsonOrNull method', function () { + $psrRequest = $this->createMock(PsrRequestInterface::class); + $psrResponse = MockHttpClient::response(200, json_encode([ + 'error' => 0, + 'result' => ['balance' => 100], + ])); + + $response = new Response($psrRequest, $psrResponse); + $response = new ResponseWithValidateErrorCode($response); + + expect($response->jsonOrNull('result'))->toBe(['balance' => 100]); + expect($response->jsonOrNull('nonexistent'))->toBeNull(); +}); + +it('decorator delegates toArray method', function () { + $psrRequest = $this->createMock(PsrRequestInterface::class); + $psrResponse = MockHttpClient::response(200, json_encode([ + 'error' => 0, + 'result' => 'ok', + ])); + + $response = new Response($psrRequest, $psrResponse); + $response = new ResponseWithValidateErrorCode($response); + + expect($response->toArray())->toBe(['error' => 0, 'result' => 'ok']); +}); + +it('decorator delegates PSR-7 getStatusCode', function () { + $psrRequest = $this->createMock(PsrRequestInterface::class); + $psrResponse = MockHttpClient::response(201, json_encode(['error' => 0])); + + $response = new Response($psrRequest, $psrResponse); + $response = new ResponseWithValidateErrorCode($response); + + expect($response->getStatusCode())->toBe(201); +}); + +it('decorator delegates PSR-7 getBody', function () { + $psrRequest = $this->createMock(PsrRequestInterface::class); + $psrResponse = MockHttpClient::response(200, json_encode(['error' => 0])); + + $response = new Response($psrRequest, $psrResponse); + $response = new ResponseWithValidateErrorCode($response); + + expect($response->getBody())->toBeInstanceOf(\Psr\Http\Message\StreamInterface::class); +});