diff --git a/doc/customization/customization.md b/doc/customization/customization.md index f952d9d..746842e 100644 --- a/doc/customization/customization.md +++ b/doc/customization/customization.md @@ -1,12 +1,13 @@ ## Customization -* [Using different PSR-18, PSR-17 and PSR-7 implementations](different_psr_implementations.md) +* [Controlling HTTP abstraction layer](different_psr_implementations.md) * [Customizing request and response processing](pipelines/implementing_a_handler.md) + [Using a predefined handler](pipelines/using_a_predefined_handler.md) + [Built-in handlers](pipelines/using_a_predefined_handler.md#built-in-handlers) + [Modifying the default pipeline](pipelines/using_a_predefined_handler.md#modifying-the-default-pipeline) + [Constructing the pipeline from scratch](pipelines/using_a_predefined_handler.md#constructing-the-pipeline-from-scratch) + [Implementing a handler](pipelines/implementing_a_handler.md) +* [Implementing custom API methods](implementing_custom_api_methods.md) Both `ClientFactory` and `ClientBuilder` provide the necessary functionality to replace PSR dependencies with any other compatible implementation. By default, those dependencies will be detected via service discovery. But service discovery supports a limited amount of implementation. diff --git a/doc/customization/examples/custom-api-methods-with-dto/README.md b/doc/customization/examples/custom-api-methods-with-dto/README.md new file mode 100644 index 0000000..ed7fd42 --- /dev/null +++ b/doc/customization/examples/custom-api-methods-with-dto/README.md @@ -0,0 +1,28 @@ +# Custom API methods with DTO + +This example demonstrates how you can use your custom serializer with custom DTOs to implement API methods. + +## How to run the project + +1. Open `app.php` and change credentials and the site to your data. +2. Run these commands: +```sh +composer install +php app.php +``` + +You will see something like this: +```sh +Created customer using custom methods. ID: 5633 +``` + +This means that the project works as expected. + +## Navigation + +- [`app.php`](app.php) - entrypoint, calls the custom method and outputs the response data. +- [`src/Component/Adapter/SymfonyToLiipAdapter.php`](src/Component/Adapter/SymfonyToLiipAdapter.php) - adapter for using `symfony/serializer` inside `FormEncoder` component. +- [`src/Component/CustomApiMethod.php`](src/Component/CustomApiMethod.php) - `CustomApiMethod` that uses `SerializerInterface` from `liip/serializer` and `FormEncoder`. This component will handle marshaling. +- [`src/Dto`](src/Dto) - data models used in the project. +- [`src/Factory/SerializerFactory.php`](src/Factory/SerializerFactory.php) - builds `symfony/serializer`'s serializer and wraps it into the `SymfonyToLiipAdapter`. +- [`src/Factory/ClientFactory.php`](src/Factory/ClientFactory.php) - custom client factory that register the custom API method. diff --git a/doc/customization/examples/custom-api-methods-with-dto/app.php b/doc/customization/examples/custom-api-methods-with-dto/app.php new file mode 100644 index 0000000..665480f --- /dev/null +++ b/doc/customization/examples/custom-api-methods-with-dto/app.php @@ -0,0 +1,29 @@ +setSerializer($serializer)->build(); +$clientFactory = (new ClientFactory())->setCustomEncoder($encoder); + +// Replace API URL and API key with your data. +$client = $clientFactory->createClient('https://test.simla.com', 'apiKey'); + +$request = new CustomersCreateRequest(); +$request->customer = new Customer(); +$request->customer->firstName = 'Tester'; +$request->customer->lastName = 'User'; +$request->customer->patronymic = 'Patronymic'; +$request->site = 'site'; // Replace site with your data. + +/** @var \Retailcrm\Examples\CustomMethodsDto\Dto\Response\CustomersCreateResponse $response */ +$response = $client->customMethods->createCustomer($request); + +echo 'Created customer using custom methods. ID: ' . $response->id; diff --git a/doc/customization/examples/custom-api-methods-with-dto/composer.json b/doc/customization/examples/custom-api-methods-with-dto/composer.json new file mode 100644 index 0000000..1c4b2b9 --- /dev/null +++ b/doc/customization/examples/custom-api-methods-with-dto/composer.json @@ -0,0 +1,16 @@ +{ + "name": "retailcrm/custom-api-methods-with-dto-example", + "description": "This project demonstrates DTO usage with the custom methods.", + "type": "project", + "require": { + "retailcrm/api-client-php": "^6", + "symfony/serializer": "^5.3", + "symfony/property-access": "^5.3" + }, + "license": "MIT", + "autoload": { + "psr-4": { + "RetailCrm\\Examples\\CustomMethodsDto\\": "src/" + } + } +} diff --git a/doc/customization/examples/custom-api-methods-with-dto/src/Component/Adapter/SymfonyToLiipAdapter.php b/doc/customization/examples/custom-api-methods-with-dto/src/Component/Adapter/SymfonyToLiipAdapter.php new file mode 100644 index 0000000..e03d8fb --- /dev/null +++ b/doc/customization/examples/custom-api-methods-with-dto/src/Component/Adapter/SymfonyToLiipAdapter.php @@ -0,0 +1,65 @@ +serializer = $serializer; + } + + /** + * @inheritDoc + */ + public function serialize($data, string $format, Context $context = null): string + { + return $this->serializer->serialize($data, $format); + } + + /** + * @inheritDoc + */ + public function deserialize(string $data, string $type, string $format, Context $context = null) + { + return $this->serializer->deserialize($data, $type, $format); + } + + /** + * @inheritDoc + * @throws \Symfony\Component\Serializer\Exception\ExceptionInterface + */ + public function toArray($data, Context $context = null): array + { + return $this->serializer->normalize($data); + } + + /** + * @inheritDoc + * @throws \Symfony\Component\Serializer\Exception\ExceptionInterface + */ + public function fromArray(array $data, string $type, Context $context = null) + { + return $this->serializer->denormalize($data, $type); + } +} diff --git a/doc/customization/examples/custom-api-methods-with-dto/src/Component/CustomApiMethod.php b/doc/customization/examples/custom-api-methods-with-dto/src/Component/CustomApiMethod.php new file mode 100644 index 0000000..e0fd882 --- /dev/null +++ b/doc/customization/examples/custom-api-methods-with-dto/src/Component/CustomApiMethod.php @@ -0,0 +1,68 @@ +responseFqn = $responseFqn; + $this->encoder = $encoder; + } + + /** + * Sends the request, returns the response. + * + * @param \RetailCrm\Api\Interfaces\RequestSenderInterface $sender + * @param array|object $data + * + * @return object + * @throws \RetailCrm\Api\Exception\ApiException + * @throws \RetailCrm\Api\Exception\ClientException + * @throws \RetailCrm\Api\Exception\Client\HandlerException + * @throws \RetailCrm\Api\Interfaces\ApiExceptionInterface + */ + public function __invoke(RequestSenderInterface $sender, $data = []) + { + if (is_object($data)) { + $data = $this->encoder->encodeArray($data); + } + + $result = parent::__invoke($sender, $data); + + try { + return $this->encoder->getSerializer()->fromArray($result, $this->responseFqn); + } catch (\Throwable $throwable) { + throw new HandlerException( + 'Cannot deserialize body: ' . $throwable->getMessage(), + 0, + $throwable + ); + } + } +} diff --git a/doc/customization/examples/custom-api-methods-with-dto/src/Dto/Customer.php b/doc/customization/examples/custom-api-methods-with-dto/src/Dto/Customer.php new file mode 100644 index 0000000..ba1368f --- /dev/null +++ b/doc/customization/examples/custom-api-methods-with-dto/src/Dto/Customer.php @@ -0,0 +1,31 @@ +customEncoder = $customEncoder; + return $this; + } + + public function createClient(string $apiUrl, string $apiKey): Client + { + $client = parent::createClient($apiUrl, $apiKey); + $client->customMethods->register( + 'createCustomer', + $this->method( + RequestMethod::POST, + 'customers/create', + CustomersCreateResponse::class + ) + ); + + return $client; + } + + private function method(string $method, string $route, string $responseFqn): CustomApiMethod + { + return new CustomApiMethod($method, $route, $responseFqn, $this->customEncoder); + } +} diff --git a/doc/customization/examples/custom-api-methods-with-dto/src/Factory/SerializerFactory.php b/doc/customization/examples/custom-api-methods-with-dto/src/Factory/SerializerFactory.php new file mode 100644 index 0000000..5ca9308 --- /dev/null +++ b/doc/customization/examples/custom-api-methods-with-dto/src/Factory/SerializerFactory.php @@ -0,0 +1,30 @@ +customMethods->register('dialogs', new CustomApiMethod(RequestMethod::GET, 'dialogs')); + +try { + $dialogs = $client->customMethods->call('dialogs'); +} catch (ApiExceptionInterface $exception) { + echo sprintf( + 'Error from RetailCRM API (status code: %d): %s', + $exception->getStatusCode(), + $exception->getMessage() + ); + + if (count($exception->getErrorResponse()->errors) > 0) { + echo PHP_EOL . 'Errors: ' . implode(', ', $exception->getErrorResponse()->errors); + } + + return; +} + +echo 'Dialogs: ' . print_r($dialogs['dialogs'], true); +``` + +First, we need to register the custom method in the client. To do this we must give our method a name and an implementation. +The name will be used to call the method later using `call()` and the implementation is just a callable with the three +parameters: `RequestSenderInterface`, data array, and the context. Let's take a look at all three. + +1. `RequestSenderInterface` is implemented by the `RequestSender` and contains three methods: `send`, `route` and `host`. + `send` is used to send the request, `route` will append the base URL to your method name, and the latter, `host`, will return + hostname from the base URL. +2. The data array contains any data that has been provided to the callable during the method call. It will be encoded as + a query string and stored either as the URL params or as the form body (query string is used for GET and DELETE requests). +3. The context contains any information provided to the callable during the method call. It is not checked by the client. + However, it is always must be of array type. + +As you can see, we're using `CustomApiMethod` as a callable in the example. The `CustomApiMethod` is a simple invokable +wrapper that can be used to simplify the registration of the new methods. The boilerplate code in the previous example is +already implemented inside the `CustomApiMethod` component. + +It is easier to understand how the callable should work and how the `CustomApiMethod` works by looking at the registration example. +It works exactly like the previous example, but without the `CustomApiMethod` component: + +```php +use RetailCrm\Api\Enum\RequestMethod; +use RetailCrm\Api\Interfaces\RequestSenderInterface; + +$client->customMethods->register('dialogs', function (RequestSenderInterface $sender, $data, array $context) { + return $sender->send(RequestMethod::GET, $sender->route('dialogs'), $data); +}); +``` + +The data here is not defined as array because it can be anything you like. You can use any serializer to serialize the data +and deserialize the response. `CustomApiMethod` only supports array out-of-box, but your handlers can utilize any +data types. + +The base URL inside the client is always represented as a URL with the version suffix. It should be always kept in mind +while using the `route` method: + +```php +// This code is being executed inside the custom method callable. +// Base URL is http://test.simla.io/api/v5 + +$settingsRoute = $sender->route('settings'); +$host = $sender->host(); + +echo $settingsRoute; // prints "https://test.simla.io/api/v5/settings" - the slash is inserted by the route() method. +echo $host; // prints "test.simla.io" +``` + +Any registered custom method can be called using the `__call` magic method: + +```php +// Both calls below works the same. + +$client->customMethods->call('dialogs', ['param' => 'value'], ['contextParam' => 'contextValue']); +$client->customMethods->dialogs(['param' => 'value'], ['contextParam' => 'contextValue']); +``` + +The last notable difference from the regular methods lies inside events. You won't be able to get request or response models +from the events, but you can extract the response array using the `getResponseArray()` method. + +## Using DTOs with the custom methods + +You can use DTOs with the custom methods. To do that you just need to serialize the request models inside the method callback +before sending the request and deserialize the response before returning it. We already made a great example project that +demonstrates the DTO usage alongside custom methods. + +[**DTOs with the custom methods - sample project**](examples/custom-api-methods-with-dto) diff --git a/doc/index.md b/doc/index.md index 33e0862..5b43e43 100644 --- a/doc/index.md +++ b/doc/index.md @@ -26,4 +26,6 @@ + [Modifying the default pipeline](customization/pipelines/using_a_predefined_handler.md#modifying-the-default-pipeline) + [Constructing the pipeline from scratch](customization/pipelines/using_a_predefined_handler.md#constructing-the-pipeline-from-scratch) + [Implementing a handler](customization/pipelines/implementing_a_handler.md) + + [Implementing custom API methods](customization/implementing_custom_api_methods.md) * [Troubleshooting](troubleshooting.md) +* [PHPDoc](https://retailcrm.github.io/api-client-php/) diff --git a/src/Builder/FormEncoderBuilder.php b/src/Builder/FormEncoderBuilder.php index 2df5402..903a716 100644 --- a/src/Builder/FormEncoderBuilder.php +++ b/src/Builder/FormEncoderBuilder.php @@ -11,6 +11,7 @@ namespace RetailCrm\Api\Builder; use Doctrine\Common\Annotations\AnnotationReader; use Doctrine\Common\Annotations\PsrCachedReader; +use Liip\Serializer\SerializerInterface; use Psr\Cache\CacheItemPoolInterface; use RetailCrm\Api\Component\FormData\FormEncoder; use RetailCrm\Api\Factory\SerializerFactory; @@ -36,6 +37,9 @@ class FormEncoderBuilder implements BuilderInterface /** @var \RetailCrm\Api\Builder\FilesystemCacheBuilder */ private $fsCacheBuilder; + /** @var \Liip\Serializer\SerializerInterface */ + private $serializer; + /** * FormEncoderBuilder constructor. */ @@ -76,6 +80,21 @@ class FormEncoderBuilder implements BuilderInterface return $this; } + /** + * Sets serializer implementation. + * + * This serializer implementation will be used by FormEncoder component. + * + * @param \Liip\Serializer\SerializerInterface $serializer + * + * @return FormEncoderBuilder + */ + public function setSerializer(SerializerInterface $serializer): FormEncoderBuilder + { + $this->serializer = $serializer; + return $this; + } + /** * Builds FormEncoder. * @@ -88,10 +107,9 @@ class FormEncoderBuilder implements BuilderInterface { $this->buildCache(); $this->buildAnnotationReader(); + $this->buildSerializer(); - $serializer = SerializerFactory::create(); - - return new FormEncoder($serializer, $this->annotationReader); + return new FormEncoder($this->serializer, $this->annotationReader); } /** @@ -117,4 +135,14 @@ class FormEncoderBuilder implements BuilderInterface $this->annotationReader = new PsrCachedReader(new AnnotationReader(), $this->cache); } } + + /** + * Builds serializer. + */ + private function buildSerializer(): void + { + if (null === $this->serializer) { + $this->serializer = SerializerFactory::create(); + } + } } diff --git a/src/Client.php b/src/Client.php index 8ea9d13..5f39d4a 100644 --- a/src/Client.php +++ b/src/Client.php @@ -21,6 +21,7 @@ use RetailCrm\Api\ResourceGroup\Costs; use RetailCrm\Api\ResourceGroup\Customers; use RetailCrm\Api\ResourceGroup\CustomersCorporate; use RetailCrm\Api\ResourceGroup\CustomFields; +use RetailCrm\Api\ResourceGroup\CustomMethods; use RetailCrm\Api\ResourceGroup\Delivery; use RetailCrm\Api\ResourceGroup\Files; use RetailCrm\Api\ResourceGroup\Integration; @@ -117,6 +118,9 @@ class Client /** @var \RetailCrm\Api\ResourceGroup\Statistics */ public $statistics; + /** @var \RetailCrm\Api\ResourceGroup\CustomMethods */ + public $customMethods; + /** @var StreamFactoryInterface */ private $streamFactory; @@ -315,6 +319,14 @@ class Client $eventDispatcher, $logger ); + $this->customMethods = new CustomMethods( + $url, + $httpClient, + $requestTransformer, + $responseTransformer, + $eventDispatcher, + $logger + ); } /** diff --git a/src/Component/CustomApiMethod.php b/src/Component/CustomApiMethod.php new file mode 100644 index 0000000..dc201b6 --- /dev/null +++ b/src/Component/CustomApiMethod.php @@ -0,0 +1,80 @@ +method = $method; + $this->route = $route; + } + + /** + * Use provided route as if it was full URL. + * + * @return $this + */ + public function useRouteAsUri(): self + { + $this->rawRouteUri = true; + return $this; + } + + /** + * Sends the request, returns the response. + * + * @param \RetailCrm\Api\Interfaces\RequestSenderInterface $sender + * @param array|object $data + * + * @return array|mixed + * @throws \RetailCrm\Api\Exception\ApiException + * @throws \RetailCrm\Api\Exception\ClientException + * @throws \RetailCrm\Api\Exception\Client\HandlerException + * @throws \RetailCrm\Api\Interfaces\ApiExceptionInterface + */ + public function __invoke(RequestSenderInterface $sender, $data = []) + { + if (!is_array($data)) { + throw new HandlerException(__CLASS__ . ' only supports array data'); + } + + return $sender->send($this->method, $this->rawRouteUri ? $this->route : $sender->route($this->route), $data); + } +} diff --git a/src/Component/RequestSender.php b/src/Component/RequestSender.php new file mode 100644 index 0000000..9217bd5 --- /dev/null +++ b/src/Component/RequestSender.php @@ -0,0 +1,106 @@ +baseUrl, + $resourceGroup->httpClient, + $resourceGroup->requestTransformer, + $resourceGroup->responseTransformer, + $resourceGroup->eventDispatcher, + $resourceGroup->logger + ); + } + + /** + * Sends custom request to provided route with provided method and body, returns array response. + * Request will be put into GET parameters or into POST form-data (depends on method). + * + * Note: do not remove "useless" exceptions which are marked as "never thrown" by IDE. + * PSR-18's ClientInterface doesn't have them in the DocBlock, but, according to PSR-18, + * they can be thrown by clients, and therefore should be present here. + * + * @see https://www.php-fig.org/psr/psr-18/#error-handling + * + * @param string $method + * @param string $route + * @param array $requestForm + * + * @return array + * @throws \RetailCrm\Api\Exception\ApiException + * @throws \RetailCrm\Api\Exception\ClientException + * @throws \RetailCrm\Api\Exception\Client\HandlerException + * @throws \RetailCrm\Api\Interfaces\ApiExceptionInterface + * @SuppressWarnings(PHPMD.ElseExpression) + */ + public function send( + string $method, + string $route, + array $requestForm = [] + ): array { + $method = strtoupper($method); + $psrRequest = $this->requestTransformer->createCustomPsrRequest($method, $route, $requestForm); + + $this->logPsr7Request($psrRequest); + + try { + $psrResponse = $this->httpClient->sendRequest($psrRequest); + } catch (ClientExceptionInterface | NetworkExceptionInterface $exception) { + $this->processPsr18Exception($psrRequest, $exception); + } + + if (isset($psrResponse)) { + $this->logPsr7Response($psrResponse); + } else { + return []; + } + + return $this->responseTransformer->createCustomResponse($this->baseUrl, $psrRequest, $psrResponse); + } + + /** + * @inheritDoc + */ + public function route(string $route): string + { + return $this->makeRoute($route); + } + + /** + * @inheritDoc + */ + public function host(): string + { + return (string) parse_url($this->baseUrl, PHP_URL_HOST); + } +} diff --git a/src/Component/Transformer/RequestTransformer.php b/src/Component/Transformer/RequestTransformer.php index 83f093b..7c10ade 100644 --- a/src/Component/Transformer/RequestTransformer.php +++ b/src/Component/Transformer/RequestTransformer.php @@ -49,7 +49,7 @@ class RequestTransformer implements RequestTransformerInterface * @param \RetailCrm\Api\Interfaces\RequestInterface|null $request * * @return \Psr\Http\Message\RequestInterface - * @throws \RetailCrm\Api\Exception\Client\HandlerException + * @throws \RetailCrm\Api\Exception\Client\HandlerException|\RetailCrm\Api\Interfaces\ApiExceptionInterface */ public function createPsrRequest( string $method, @@ -59,11 +59,28 @@ class RequestTransformer implements RequestTransformerInterface $requestData = new RequestData($method, $uri, $request); $this->handler->handle($requestData); - if (null === $requestData->request) { - throw new HandlerException('Handlers should instantiate request in the ResponseData.'); - } + return $this->returnRequest($requestData); + } - return $requestData->request; + /** + * Transforms provided request data into PSR-7 request model. + * + * You can alter the results by providing your chain of handlers. + * + * @param string $method + * @param string $uri + * @param array $requestForm + * + * @return \Psr\Http\Message\RequestInterface + * @throws \RetailCrm\Api\Exception\Client\HandlerException + * @throws \RetailCrm\Api\Interfaces\ApiExceptionInterface + */ + public function createCustomPsrRequest(string $method, string $uri, array $requestForm = []): PsrRequestInterface + { + $requestData = new RequestData($method, $uri, null, $requestForm); + $this->handler->handle($requestData); + + return $this->returnRequest($requestData); } /** @@ -73,4 +90,19 @@ class RequestTransformer implements RequestTransformerInterface { return $this->handler; } + + /** + * @param \RetailCrm\Api\Model\RequestData $requestData + * + * @return \Psr\Http\Message\RequestInterface + * @throws \RetailCrm\Api\Exception\Client\HandlerException + */ + private function returnRequest(RequestData $requestData): PsrRequestInterface + { + if (null === $requestData->request) { + throw new HandlerException('Handlers should instantiate request in the ResponseData.'); + } + + return $requestData->request; + } } diff --git a/src/Component/Transformer/ResponseTransformer.php b/src/Component/Transformer/ResponseTransformer.php index cd43cf6..d837646 100644 --- a/src/Component/Transformer/ResponseTransformer.php +++ b/src/Component/Transformer/ResponseTransformer.php @@ -59,12 +59,36 @@ class ResponseTransformer implements ResponseTransformerInterface ResponseInterface $response, string $type ): RetailCrmResponse { - $responseData = new ResponseData($baseUrl, $request, $response, $type); + $responseData = new ResponseData($baseUrl, $request, $response, $type, false); $this->handler->handle($responseData); return $responseData->responseModel; } + /** + * Transforms PSR-7 response into response array. + * + * You can alter the results by providing your chain of handlers. + * + * @param string $baseUrl + * @param \Psr\Http\Message\RequestInterface $request + * @param \Psr\Http\Message\ResponseInterface $response + * + * @return array + * @throws \RetailCrm\Api\Exception\Client\HandlerException + * @throws \RetailCrm\Api\Interfaces\ApiExceptionInterface + */ + public function createCustomResponse( + string $baseUrl, + RequestInterface $request, + ResponseInterface $response + ): array { + $responseData = new ResponseData($baseUrl, $request, $response, '', true); + $this->handler->handle($responseData); + + return $responseData->responseArray; + } + /** * @inheritDoc */ diff --git a/src/Event/AbstractRequestEvent.php b/src/Event/AbstractRequestEvent.php index 2ae5b98..f3a7e19 100644 --- a/src/Event/AbstractRequestEvent.php +++ b/src/Event/AbstractRequestEvent.php @@ -35,19 +35,28 @@ abstract class AbstractRequestEvent /** @var \Psr\Http\Message\ResponseInterface|null */ private $response; + /** @var array */ + private $responseArray; + /** * AbstractRequestEvent constructor. * * @param string $baseUrl * @param \Psr\Http\Message\RequestInterface $request * @param \Psr\Http\Message\ResponseInterface|null $response + * @param array $responseArray */ - public function __construct(string $baseUrl, RequestInterface $request, ?ResponseInterface $response) - { + public function __construct( + string $baseUrl, + RequestInterface $request, + ?ResponseInterface $response, + array $responseArray = [] + ) { $this->requestScheme = (string) parse_url($baseUrl, PHP_URL_SCHEME); $this->crmDomain = (string) parse_url($baseUrl, PHP_URL_HOST); $this->request = $request; $this->response = $response; + $this->responseArray = $responseArray; } /** @@ -70,6 +79,16 @@ abstract class AbstractRequestEvent return $this->response; } + /** + * Returns a response array. It will be present only if custom request was used. + * + * @return array + */ + public function getResponseArray(): array + { + return $this->responseArray; + } + /** * Returns RetailCRM domain for request. * diff --git a/src/Event/FailureRequestEvent.php b/src/Event/FailureRequestEvent.php index b439fb3..1974e82 100644 --- a/src/Event/FailureRequestEvent.php +++ b/src/Event/FailureRequestEvent.php @@ -35,10 +35,16 @@ class FailureRequestEvent extends AbstractRequestEvent * @param \Psr\Http\Message\RequestInterface $request * @param \Psr\Http\Message\ResponseInterface|null $response * @param ApiException|ClientException $exception + * @param array $responseArray */ - public function __construct(string $baseUrl, RequestInterface $request, ?ResponseInterface $response, $exception) - { - parent::__construct($baseUrl, $request, $response); + public function __construct( + string $baseUrl, + RequestInterface $request, + ?ResponseInterface $response, + $exception, + array $responseArray = [] + ) { + parent::__construct($baseUrl, $request, $response, $responseArray); $this->exception = $exception; } diff --git a/src/Event/SuccessRequestEvent.php b/src/Event/SuccessRequestEvent.php index 7ddd0eb..3c9500f 100644 --- a/src/Event/SuccessRequestEvent.php +++ b/src/Event/SuccessRequestEvent.php @@ -21,32 +21,34 @@ use RetailCrm\Api\Interfaces\ResponseInterface; */ class SuccessRequestEvent extends AbstractRequestEvent { - /** @var \RetailCrm\Api\Interfaces\ResponseInterface */ + /** @var \RetailCrm\Api\Interfaces\ResponseInterface|null */ private $responseModel; /** - * FailureRequestEvent constructor. + * SuccessRequestEvent constructor. * - * @param string $baseUrl - * @param \Psr\Http\Message\RequestInterface $request - * @param \Psr\Http\Message\ResponseInterface $response - * @param \RetailCrm\Api\Interfaces\ResponseInterface $responseModel + * @param string $baseUrl + * @param \Psr\Http\Message\RequestInterface $request + * @param \Psr\Http\Message\ResponseInterface $response + * @param \RetailCrm\Api\Interfaces\ResponseInterface|null $responseModel + * @param array $responseArray */ public function __construct( string $baseUrl, RequestInterface $request, PsrResponseInterface $response, - ResponseInterface $responseModel + ?ResponseInterface $responseModel, + array $responseArray = [] ) { - parent::__construct($baseUrl, $request, $response); + parent::__construct($baseUrl, $request, $response, $responseArray); $this->responseModel = $responseModel; } /** - * @return \RetailCrm\Api\Interfaces\ResponseInterface + * @return \RetailCrm\Api\Interfaces\ResponseInterface|null */ - public function getResponseModel(): ResponseInterface + public function getResponseModel(): ?ResponseInterface { return $this->responseModel; } diff --git a/src/Factory/ApiExceptionFactory.php b/src/Factory/ApiExceptionFactory.php index 4c9240b..56b0562 100644 --- a/src/Factory/ApiExceptionFactory.php +++ b/src/Factory/ApiExceptionFactory.php @@ -54,6 +54,8 @@ class ApiExceptionFactory Throwable $previous = null ): ApiException { $response = $errorResponse instanceof ErrorResponse ? $errorResponse : new ErrorResponse(); + $response->errorMsg = $response->errorMsg ?? ''; + $response->errors = $response->errors ?? []; $errorFqn = self::getErrorClassByMessage($response->errorMsg ?? ''); if (class_exists($errorFqn) && is_subclass_of($errorFqn, ApiException::class)) { diff --git a/src/Handler/Request/RequestDataHandler.php b/src/Handler/Request/RequestDataHandler.php index db22b9a..a697a14 100644 --- a/src/Handler/Request/RequestDataHandler.php +++ b/src/Handler/Request/RequestDataHandler.php @@ -9,6 +9,7 @@ namespace RetailCrm\Api\Handler\Request; +use JsonException; use Psr\Http\Message\StreamFactoryInterface; use RetailCrm\Api\Enum\RequestMethod; use RetailCrm\Api\Exception\Client\HandlerException; @@ -59,28 +60,64 @@ class RequestDataHandler extends AbstractHandler */ public function handle($item) { - if ($item instanceof RequestData && null !== $item->requestModel && null !== $item->request) { - try { - $formData = $this->formEncoder->encode($item->requestModel); - } catch (Throwable $throwable) { - throw new HandlerException( - sprintf('Cannot encode request: %s', $throwable->getMessage()), - $throwable->getCode(), - $throwable - ); + if ($item instanceof RequestData && null !== $item->request) { + $formData = ''; + + if (null !== $item->requestModel) { + try { + $formData = $this->formEncoder->encode($item->requestModel); + } catch (Throwable $throwable) { + static::throwEncodeException($throwable); + } } - if (in_array(strtoupper($item->request->getMethod()), [RequestMethod::GET, RequestMethod::DELETE], true)) { - $item->request = $item->request->withUri( - $item->request->getUri()->withQuery($formData) - ); - } else { - $item->request = $item->request->withBody( - $this->streamFactory->createStream($formData) - ); + if (!empty($item->requestForm)) { + $formData = http_build_query($item->requestForm); + } + + if ('' !== $formData) { + if (static::queryShouldBeUsed($item->request->getMethod())) { + $item->request = $item->request->withUri( + $item->request->getUri()->withQuery($formData) + ); + } else { + $item->request = $item->request->withBody( + $this->streamFactory->createStream($formData) + ); + } } } return parent::handle($item); } + + /** + * @param \Throwable $throwable + * + * @throws \RetailCrm\Api\Exception\Client\HandlerException + */ + private static function throwEncodeException(Throwable $throwable): void + { + throw new HandlerException( + sprintf('Cannot encode request: %s', $throwable->getMessage()), + $throwable->getCode(), + $throwable + ); + } + + /** + * Returns true if query params should be used instead of JSON. + * + * @param string $method + * + * @return bool + */ + private static function queryShouldBeUsed(string $method): bool + { + return in_array( + strtoupper($method), + [RequestMethod::GET, RequestMethod::DELETE], + true + ); + } } diff --git a/src/Handler/Response/AbstractResponseHandler.php b/src/Handler/Response/AbstractResponseHandler.php index a31da03..e165d71 100644 --- a/src/Handler/Response/AbstractResponseHandler.php +++ b/src/Handler/Response/AbstractResponseHandler.php @@ -9,6 +9,7 @@ namespace RetailCrm\Api\Handler\Response; +use JsonException; use Liip\Serializer\SerializerInterface; use Psr\EventDispatcher\EventDispatcherInterface; use Psr\Http\Message\ResponseInterface; @@ -96,8 +97,27 @@ abstract class AbstractResponseHandler extends AbstractHandler implements try { return $this->serializer->deserialize(Utils::getBodyContents($response->getBody()), $type, 'json'); } catch (Throwable $throwable) { - throw new HandlerException('Cannot deserialize body: ' . $throwable->getMessage(), 0, $throwable); + static::throwUnmarshalError($throwable); } + + return new $type(); + } + + /** + * @param \Psr\Http\Message\ResponseInterface $response + * + * @return array + * @throws \RetailCrm\Api\Exception\Client\HandlerException + */ + protected function unmarshalBodyArray(ResponseInterface $response): array + { + try { + return json_decode(Utils::getBodyContents($response->getBody()), true, 512, JSON_THROW_ON_ERROR); + } catch (JsonException $exception) { + static::throwUnmarshalError($exception); + } + + return []; } /** @@ -111,4 +131,18 @@ abstract class AbstractResponseHandler extends AbstractHandler implements * @throws \RetailCrm\Api\Interfaces\ApiExceptionInterface */ abstract protected function handleResponse(ResponseData $responseData); + + /** + * @param \Throwable $throwable + * + * @throws \RetailCrm\Api\Exception\Client\HandlerException + */ + private static function throwUnmarshalError(Throwable $throwable): void + { + throw new HandlerException( + 'Cannot deserialize body: ' . $throwable->getMessage(), + 0, + $throwable + ); + } } diff --git a/src/Handler/Response/AccountNotFoundHandler.php b/src/Handler/Response/AccountNotFoundHandler.php index 1ee0c5b..9558732 100644 --- a/src/Handler/Response/AccountNotFoundHandler.php +++ b/src/Handler/Response/AccountNotFoundHandler.php @@ -37,6 +37,7 @@ class AccountNotFoundHandler extends AbstractResponseHandler ) { $errorResponse = new ErrorResponse(); $errorResponse->errorMsg = 'Account does not exist.'; + $errorResponse->errors = []; $event = new FailureRequestEvent( $responseData->baseUrl, diff --git a/src/Handler/Response/UnmarshalResponseHandler.php b/src/Handler/Response/UnmarshalResponseHandler.php index aae594a..87c5c76 100644 --- a/src/Handler/Response/UnmarshalResponseHandler.php +++ b/src/Handler/Response/UnmarshalResponseHandler.php @@ -17,6 +17,7 @@ use RetailCrm\Api\Model\ResponseData; * * @category UnmarshalResponseHandler * @package RetailCrm\Api\Handler\Response + * @SuppressWarnings(PHPMD.ElseExpression) */ class UnmarshalResponseHandler extends AbstractResponseHandler { @@ -25,12 +26,18 @@ class UnmarshalResponseHandler extends AbstractResponseHandler */ protected function handleResponse(ResponseData $responseData) { - $responseData->responseModel = $this->unmarshalBody($responseData->response, $responseData->type); + if ($responseData->custom) { + $responseData->responseArray = $this->unmarshalBodyArray($responseData->response); + } else { + $responseData->responseModel = $this->unmarshalBody($responseData->response, $responseData->type); + } + $this->dispatch(new SuccessRequestEvent( $responseData->baseUrl, $responseData->request, $responseData->response, - $responseData->responseModel + $responseData->responseModel, + $responseData->responseArray ?? [] )); } } diff --git a/src/Interfaces/RequestSenderInterface.php b/src/Interfaces/RequestSenderInterface.php new file mode 100644 index 0000000..a7f84b5 --- /dev/null +++ b/src/Interfaces/RequestSenderInterface.php @@ -0,0 +1,61 @@ + $requestForm + * + * @return array + * @throws \RetailCrm\Api\Exception\ApiException + * @throws \RetailCrm\Api\Exception\ClientException + * @throws \RetailCrm\Api\Exception\Client\HandlerException + * @throws \RetailCrm\Api\Interfaces\ApiExceptionInterface + */ + public function send( + string $method, + string $route, + array $requestForm = [] + ): array; + + /** + * Returns API routes with base URI prepended. + * + * @param string $route + * + * @return string + */ + public function route(string $route): string; + + /** + * Returns host from the base URL. + * + * @return string + */ + public function host(): string; +} diff --git a/src/Interfaces/RequestTransformerInterface.php b/src/Interfaces/RequestTransformerInterface.php index ad46ce7..b38a771 100644 --- a/src/Interfaces/RequestTransformerInterface.php +++ b/src/Interfaces/RequestTransformerInterface.php @@ -39,6 +39,26 @@ interface RequestTransformerInterface ?RequestInterface $request = null ): PsrRequestInterface; + /** + * Transforms provided custom request data into PSR-7 request model. + * + * This method should perform the following operations: + * - Transform request model into PSR-7 request. + * - Throw `HandlerException` instance if necessary. + * + * @param string $method + * @param string $uri + * @param array $requestForm + * + * @return \Psr\Http\Message\RequestInterface + * @throws \RetailCrm\Api\Exception\Client\HandlerException + */ + public function createCustomPsrRequest( + string $method, + string $uri, + array $requestForm = [] + ): PsrRequestInterface; + /** * Returns HandlerInterface. * diff --git a/src/Interfaces/ResponseTransformerInterface.php b/src/Interfaces/ResponseTransformerInterface.php index 7b30790..97fff9d 100644 --- a/src/Interfaces/ResponseTransformerInterface.php +++ b/src/Interfaces/ResponseTransformerInterface.php @@ -11,7 +11,6 @@ namespace RetailCrm\Api\Interfaces; use Psr\Http\Message\RequestInterface; use Psr\Http\Message\ResponseInterface; -use Psr\Http\Message\UriInterface; use RetailCrm\Api\Interfaces\ResponseInterface as RetailCrmResponse; /** @@ -46,6 +45,30 @@ interface ResponseTransformerInterface string $type ): RetailCrmResponse; + /** + * Transforms PSR-7 response into response array. + * + * This method should do the following operations: + * - It should convert PSR-7 response object into the response model of provided type. + * - It should automatically detect an API error and throw an `ApiExceptionInterface` instance. + * - It can throw a `HandlerException` instance if necessary. + * + * This method should be used only for custom requests. + * + * @param string $baseUrl + * @param \Psr\Http\Message\RequestInterface $request + * @param \Psr\Http\Message\ResponseInterface $response + * + * @return array + * @throws \RetailCrm\Api\Interfaces\ApiExceptionInterface + * @throws \RetailCrm\Api\Exception\Client\HandlerException + */ + public function createCustomResponse( + string $baseUrl, + RequestInterface $request, + ResponseInterface $response + ): array; + /** * Returns HandlerInterface. * diff --git a/src/Model/RequestData.php b/src/Model/RequestData.php index 82890d6..b556d5a 100644 --- a/src/Model/RequestData.php +++ b/src/Model/RequestData.php @@ -28,6 +28,9 @@ class RequestData /** @var \RetailCrm\Api\Interfaces\RequestInterface|null */ public $requestModel; + /** @var array */ + public $requestForm; + /** @var ?\Psr\Http\Message\RequestInterface */ public $request; @@ -37,11 +40,13 @@ class RequestData * @param string $method * @param string $uri * @param \RetailCrm\Api\Interfaces\RequestInterface|null $requestModel + * @param array $requestForm */ - public function __construct(string $method, string $uri, ?RequestModel $requestModel) + public function __construct(string $method, string $uri, ?RequestModel $requestModel, array $requestForm = []) { - $this->method = $method; - $this->uri = $uri; + $this->method = $method; + $this->uri = $uri; $this->requestModel = $requestModel; + $this->requestForm = $requestForm; } } diff --git a/src/Model/ResponseData.php b/src/Model/ResponseData.php index ca17417..572ae24 100644 --- a/src/Model/ResponseData.php +++ b/src/Model/ResponseData.php @@ -33,9 +33,15 @@ class ResponseData /** @var string */ public $type; + /** @var bool */ + public $custom; + /** @var \RetailCrm\Api\Interfaces\ResponseInterface */ public $responseModel; + /** @var array */ + public $responseArray; + /** * ResponseData constructor. * @@ -43,12 +49,21 @@ class ResponseData * @param \Psr\Http\Message\RequestInterface $request * @param \Psr\Http\Message\ResponseInterface $response * @param string $type + * @param bool $custom + * + * @SuppressWarnings(PHPMD.BooleanArgumentFlag) */ - public function __construct(string $baseUrl, RequestInterface $request, ResponseInterface $response, string $type) - { - $this->baseUrl = $baseUrl; - $this->request = $request; + public function __construct( + string $baseUrl, + RequestInterface $request, + ResponseInterface $response, + string $type, + bool $custom = false + ) { + $this->baseUrl = $baseUrl; + $this->request = $request; $this->response = $response; - $this->type = $type; + $this->type = $type; + $this->custom = $custom; } } diff --git a/src/ResourceGroup/AbstractApiResourceGroup.php b/src/ResourceGroup/AbstractApiResourceGroup.php index cd52ef3..a2694fe 100644 --- a/src/ResourceGroup/AbstractApiResourceGroup.php +++ b/src/ResourceGroup/AbstractApiResourceGroup.php @@ -13,6 +13,8 @@ use Psr\EventDispatcher\EventDispatcherInterface; use Psr\Http\Client\ClientExceptionInterface; use Psr\Http\Client\ClientInterface; use Psr\Http\Client\NetworkExceptionInterface; +use Psr\Http\Message\RequestInterface as PsrRequestInterface; +use Psr\Http\Message\ResponseInterface as PsrResponseInterface; use Psr\Log\LoggerInterface; use Psr\Log\NullLogger; use RetailCrm\Api\Component\Utils; @@ -23,8 +25,8 @@ use RetailCrm\Api\Interfaces\RequestInterface; use RetailCrm\Api\Interfaces\RequestTransformerInterface; use RetailCrm\Api\Interfaces\ResponseInterface; use RetailCrm\Api\Interfaces\ResponseTransformerInterface; -use RetailCrm\Api\Model\Response\SuccessResponse; use RetailCrm\Api\Traits\EventDispatcherAwareTrait; +use RetailCrm\Api\Traits\BaseUrlAwareTrait; /** * Class AbstractApiResourceGroup @@ -33,15 +35,14 @@ use RetailCrm\Api\Traits\EventDispatcherAwareTrait; * @package RetailCrm\Api\Modules * @internal * + * @SuppressWarnings(PHPMD.ElseExpression) * @SuppressWarnings(PHPMD.NumberOfChildren) * @SuppressWarnings(PHPMD.CouplingBetweenObjects) */ abstract class AbstractApiResourceGroup implements EventDispatcherAwareInterface { use EventDispatcherAwareTrait; - - /** @var string */ - protected $baseUrl; + use BaseUrlAwareTrait; /** @var ClientInterface */ protected $httpClient; @@ -73,12 +74,12 @@ abstract class AbstractApiResourceGroup implements EventDispatcherAwareInterface ?EventDispatcherInterface $eventDispatcher = null, ?LoggerInterface $logger = null ) { - $this->baseUrl = $baseUrl; - $this->httpClient = $httpClient; - $this->requestTransformer = $requestTransformer; + $this->baseUrl = $baseUrl; + $this->httpClient = $httpClient; + $this->requestTransformer = $requestTransformer; $this->responseTransformer = $responseTransformer; - $this->eventDispatcher = $eventDispatcher; - $this->logger = $logger; + $this->eventDispatcher = $eventDispatcher; + $this->logger = $logger; } /** @@ -97,15 +98,10 @@ abstract class AbstractApiResourceGroup implements EventDispatcherAwareInterface * @param string $type * * @return \RetailCrm\Api\Interfaces\ResponseInterface - * @throws \RetailCrm\Api\Interfaces\ApiExceptionInterface - * @throws \RetailCrm\Api\Interfaces\ClientExceptionInterface - * @throws \RetailCrm\Api\Exception\Api\AccountDoesNotExistException - * @throws \RetailCrm\Api\Exception\Api\ApiErrorException - * @throws \RetailCrm\Api\Exception\Api\MissingCredentialsException - * @throws \RetailCrm\Api\Exception\Api\MissingParameterException - * @throws \RetailCrm\Api\Exception\Api\ValidationException + * @throws \RetailCrm\Api\Exception\ApiException + * @throws \RetailCrm\Api\Exception\ClientException * @throws \RetailCrm\Api\Exception\Client\HandlerException - * @throws \RetailCrm\Api\Exception\Client\HttpClientException + * @throws \RetailCrm\Api\Interfaces\ApiExceptionInterface */ protected function sendRequest( string $method, @@ -113,13 +109,37 @@ abstract class AbstractApiResourceGroup implements EventDispatcherAwareInterface ?RequestInterface $request, string $type ): ResponseInterface { - $method = strtoupper($method); + $method = strtoupper($method); $psrRequest = $this->requestTransformer->createPsrRequest( $method, $this->route($route), $request ); + $this->logPsr7Request($psrRequest); + + try { + $psrResponse = $this->httpClient->sendRequest($psrRequest); + } catch (ClientExceptionInterface | NetworkExceptionInterface $exception) { + $this->processPsr18Exception($psrRequest, $exception); + } + + if (isset($psrResponse)) { + $this->logPsr7Response($psrResponse); + } else { + return new $type(); + } + + return $this->responseTransformer->createResponse($this->baseUrl, $psrRequest, $psrResponse, $type); + } + + /** + * Logs PSR-7 request data if possible. + * + * @param \Psr\Http\Message\RequestInterface $psrRequest + */ + protected function logPsr7Request(PsrRequestInterface $psrRequest): void + { if ($this->logger instanceof LoggerInterface && !($this->logger instanceof NullLogger)) { $this->logger->debug(sprintf( '[RetailCRM API Request]: %s URL: "%s", Headers: "%s", Body: "%s"', @@ -129,32 +149,18 @@ abstract class AbstractApiResourceGroup implements EventDispatcherAwareInterface Utils::getBodyContents($psrRequest->getBody()) )); } + } - try { - $psrResponse = $this->httpClient->sendRequest($psrRequest); - } catch (ClientExceptionInterface | NetworkExceptionInterface $exception) { - $event = new FailureRequestEvent( - $this->baseUrl, - $psrRequest, - null, - new HttpClientException( - sprintf('HTTP client error: %s', $exception->getMessage()), - $exception->getCode(), - $exception - ) - ); - - $this->dispatch($event); - - if (!$event->shouldSuppressThrow()) { - throw $event->getException(); - } - } - + /** + * Logs PSR-7 response data if possible. + * + * @param \Psr\Http\Message\ResponseInterface $psrResponse + */ + protected function logPsr7Response(PsrResponseInterface $psrResponse): void + { if ( $this->logger instanceof LoggerInterface && - !($this->logger instanceof NullLogger) && - isset($psrResponse) + !($this->logger instanceof NullLogger) ) { $this->logger->debug(sprintf( '[RetailCRM API Response]: Status: "%d", Body: "%s"', @@ -162,23 +168,34 @@ abstract class AbstractApiResourceGroup implements EventDispatcherAwareInterface Utils::getBodyContents($psrResponse->getBody()) )); } - - if (!isset($psrResponse)) { - return new $type(); - } - - return $this->responseTransformer->createResponse($this->baseUrl, $psrRequest, $psrResponse, $type); } /** - * Returns route with base URI. + * Processes PSR-18 client exception. * - * @param string $route + * @param \Psr\Http\Message\RequestInterface $psrRequest + * @param ClientExceptionInterface|NetworkExceptionInterface $exception * - * @return string + * @throws \RetailCrm\Api\Exception\ApiException + * @throws \RetailCrm\Api\Exception\ClientException */ - protected function route(string $route): string + protected function processPsr18Exception(PsrRequestInterface $psrRequest, $exception): void { - return sprintf('%s/%s', $this->baseUrl, $route); + $event = new FailureRequestEvent( + $this->baseUrl, + $psrRequest, + null, + new HttpClientException( + sprintf('HTTP client error: %s', $exception->getMessage()), + $exception->getCode(), + $exception + ) + ); + + $this->dispatch($event); + + if (!$event->shouldSuppressThrow()) { + throw $event->getException(); + } } } diff --git a/src/ResourceGroup/CustomMethods.php b/src/ResourceGroup/CustomMethods.php new file mode 100644 index 0000000..6c1b972 --- /dev/null +++ b/src/ResourceGroup/CustomMethods.php @@ -0,0 +1,217 @@ + */ + private $methods = []; + + /** + * Register custom method in the resource group. You can use invokable class for easier implementation. + * + * Registered callable should accept three arguments: + * - `RequestSenderInterface` + * - Array with the request data (may be null or empty if not provided - this is the case for get requests). + * - Context data - may contain any data. You can use the context for anything you like. + * + * Example with the `CustomApiMethod` wrapper: + * + * ```php + * use RetailCrm\Api\Component\CustomApiMethod; + * use RetailCrm\Api\Enum\RequestMethod; + * use RetailCrm\Api\Factory\SimpleClientFactory; + * use RetailCrm\Api\Interfaces\ApiExceptionInterface; + * + * $client = SimpleClientFactory::createClient('https://test.simla.com', 'apiKey'); + * $client->customMethods->register('settings', new CustomApiMethod(RequestMethod::GET, 'settings')); + * ``` + * + * Sometimes `CustomApiMethod` may feel too simple to do certain tasks in the methods. That's why it is possible + * to register custom callable. Let's register a custom method that returns array of available scopes: + * + * use RetailCrm\Api\Enum\RequestMethod; + * use RetailCrm\Api\Factory\SimpleClientFactory; + * use RetailCrm\Api\Interfaces\ApiExceptionInterface; + * use RetailCrm\Api\Interfaces\RequestSenderInterface; + * + * ```php + * $client = SimpleClientFactory::createClient('https://test.simla.com', 'apiKey'); + * $client->customMethods->register( + * 'scopes', + * static function (RequestSenderInterface $sender, $data, array $context) { + * return $sender->send( + * RequestMethod::GET, + * sprintf('https://%s/api/credentials', $sender->host()), + * $data + * )['scopes']; + * } + * ); + * ``` + * + * Check `call()` method to learn how to call registered methods. + * + * @see self::call + * @see \RetailCrm\Api\Interfaces\RequestSenderInterface + * @see RequestSender + * @see \RetailCrm\Api\Component\CustomApiMethod + * + * @param string $name + * @param callable|\RetailCrm\Api\Component\CustomApiMethod $sender + */ + public function register(string $name, callable $sender): void + { + if (null === $this->requestSender) { + $this->requestSender = new RequestSender($this); + } + + $this->methods[$name] = $sender; + } + + /** + * Calls custom method, returns array response. + * + * Usage: + * + * ```php + * $client->customMethods->call('scopes'); + * ``` + * + * Second parameter should be provided for POST requests or query data. Third parameter is used to pass anything + * you like to the callable during the method call (logger, for example). + * + * This implementation is also used in the `__call` magic method. It works like this: + * + * ```php + * $client->customMethods->scopes($data, 'any', 'other', 'params'); + * ``` + * will make this call: + * ```php + * $client->customMethods->call('scopes', $data, ['any', 'other', 'params']); + * ``` + * + * Full example for the settings method: + * + * ```php + * use RetailCrm\Api\Component\CustomApiMethod; + * use RetailCrm\Api\Enum\RequestMethod; + * use RetailCrm\Api\Factory\SimpleClientFactory; + * use RetailCrm\Api\Interfaces\ApiExceptionInterface; + * + * $client = SimpleClientFactory::createClient('https://azgalot.retailcrm.ru', '9y3e3ohX1NGqAausgg5ACMWPv5Z4iXQF'); + * $client->customMethods->register('settings', new CustomApiMethod(RequestMethod::GET, 'settings')); + * + * try { + * // It will work because 'settings' method was registered before + * $settings = $client->customMethods->settings(); + * } catch (ApiExceptionInterface $exception) { + * echo sprintf( + * 'Error from RetailCRM API (status code: %d): %s', + * $exception->getStatusCode(), + * $exception->getMessage() + * ); + * + * if (count($exception->getErrorResponse()->errors) > 0) { + * echo PHP_EOL . 'Errors: ' . implode(', ', $exception->getErrorResponse()->errors); + * } + * + * return; + * } + * + * echo 'Timezone: ' . $settings['settings']['timezone']['value']; + * ``` + * + * @param string $name + * @param array|object $data + * @param array $context + * + * @return array|object + * @throws \RetailCrm\Api\Interfaces\ApiExceptionInterface + * @throws \RetailCrm\Api\Interfaces\ClientExceptionInterface + * @throws \RetailCrm\Api\Exception\Api\AccountDoesNotExistException + * @throws \RetailCrm\Api\Exception\Api\ApiErrorException + * @throws \RetailCrm\Api\Exception\Api\MissingCredentialsException + * @throws \RetailCrm\Api\Exception\Api\MissingParameterException + * @throws \RetailCrm\Api\Exception\Api\ValidationException + * @throws \RetailCrm\Api\Exception\Client\HandlerException + * @throws \RetailCrm\Api\Exception\Client\HttpClientException + */ + public function call(string $name, $data = [], array $context = []) + { + if (null === $this->requestSender || !array_key_exists($name, $this->methods)) { + throw new HandlerException(sprintf('Cannot find custom method with name "%s"', $name)); + } + + return $this->methods[$name]($this->requestSender, $data, $context); + } + + /** + * Calls custom method, returns array response. + * + * @param string $name + * @param array $arguments + * + * @return array|object + * @throws \RetailCrm\Api\Interfaces\ApiExceptionInterface + * @throws \RetailCrm\Api\Interfaces\ClientExceptionInterface + * @throws \RetailCrm\Api\Exception\Api\AccountDoesNotExistException + * @throws \RetailCrm\Api\Exception\Api\ApiErrorException + * @throws \RetailCrm\Api\Exception\Api\MissingCredentialsException + * @throws \RetailCrm\Api\Exception\Api\MissingParameterException + * @throws \RetailCrm\Api\Exception\Api\ValidationException + * @throws \RetailCrm\Api\Exception\Client\HandlerException + * @throws \RetailCrm\Api\Exception\Client\HttpClientException + *@see \RetailCrm\Api\ResourceGroup\CustomMethods::call() + * + */ + public function __call(string $name, array $arguments) + { + $data = []; + $context = []; + + if (count($arguments) > 0) { + if (!is_array($arguments[0]) && !is_object($arguments[0])) { + throw new HandlerException(sprintf( + '$data must be of type array or object, %s given', + gettype($arguments[0]) + )); + } + + $data = $arguments[0]; + } + + if (count($arguments) > 1) { + if (!is_array($arguments[1])) { + throw new HandlerException(sprintf( + '$context must be of type array, %s given', + gettype($arguments[1]) + )); + } + + $context = $arguments[1]; + } + + return $this->call($name, $data, $context); + } +} diff --git a/src/Traits/BaseUrlAwareTrait.php b/src/Traits/BaseUrlAwareTrait.php new file mode 100644 index 0000000..e9384ce --- /dev/null +++ b/src/Traits/BaseUrlAwareTrait.php @@ -0,0 +1,34 @@ +baseUrl, $route); + } +} diff --git a/tests/src/Component/CustomApiMethodTest.php b/tests/src/Component/CustomApiMethodTest.php new file mode 100644 index 0000000..2c6c1e5 --- /dev/null +++ b/tests/src/Component/CustomApiMethodTest.php @@ -0,0 +1,64 @@ +getMockBuilder(RequestSenderInterface::class) + ->onlyMethods(['send', 'route', 'host']) + ->getMock(); + + $mock->method('send') + ->with(RequestMethod::GET, $baseUrl . '/method') + ->willReturn([$baseUrl]); + + $mock->method('route') + ->withAnyParameters() + ->willReturn($baseUrl . '/method'); + + static::assertEquals([$baseUrl], (new CustomApiMethod(RequestMethod::GET, 'method'))($mock)); + } + + public function testRawRoute(): void + { + $baseUrl = 'https://test.simla.io/api/v5'; + $mock = $this->getMockBuilder(RequestSenderInterface::class) + ->onlyMethods(['send', 'route', 'host']) + ->getMock(); + + $mock->method('send') + ->with(RequestMethod::GET, 'method') + ->willReturn([$baseUrl]); + + $mock->method('route') + ->withAnyParameters() + ->willReturn(''); + + static::assertEquals( + [$baseUrl], + (new CustomApiMethod(RequestMethod::GET, 'method'))->useRouteAsUri()($mock) + ); + } +} diff --git a/tests/src/ResourceGroup/AbstractApiResourceGroupTest.php b/tests/src/ResourceGroup/AbstractApiResourceGroupTest.php index 2cb123b..ff2a6ae 100644 --- a/tests/src/ResourceGroup/AbstractApiResourceGroupTest.php +++ b/tests/src/ResourceGroup/AbstractApiResourceGroupTest.php @@ -18,6 +18,7 @@ use RetailCrm\Api\Exception\Api\AccessDeniedException; use RetailCrm\Api\Handler\Request\GetParameterAuthenticatorHandler; use RetailCrm\Api\Interfaces\ApiExceptionInterface; use RetailCrm\Api\Interfaces\ClientExceptionInterface; +use RetailCrm\Api\Interfaces\RequestSenderInterface; use RetailCrm\Api\Model\Response\Api\ApiVersionsResponse; use RetailCrm\TestUtils\ArrayLogger; use RetailCrm\TestUtils\Factory\TestClientFactory; @@ -124,6 +125,63 @@ EOF; self::assertEquals(TestConfig::getApiKey(), $event->getApiKey()); } + public function testSuccessCustomRequestEvent(): void + { + /** @var SuccessRequestEvent|null $event */ + $event = null; + $json = <<<'EOF' +{ + "success": true, + "versions": [ + "3.0", + "4.0", + "5.0" + ] +} +EOF; + $dispatcher = new EventDispatcher(); + $dispatcher->subscribeTo(SuccessRequestEvent::class, static function (object $item) use (&$event) { + $event = $item; + }); + + $mock = static::createUnversionedApiMockBuilder('api-versions'); + $mock->matchMethod(RequestMethod::GET) + ->reply(200) + ->withBody($json); + + $client = TestClientFactory::createClient($mock->getClient(), null, $dispatcher); + $client->customMethods->register( + 'apiVersions', + static function (RequestSenderInterface $sender, array $data, array $context) { + return $sender->send( + RequestMethod::GET, + sprintf('https://%s/api/api-versions', $sender->host()) + )['versions']; + } + ); + $client->customMethods->call('apiVersions'); + + self::assertInstanceOf(SuccessRequestEvent::class, $event); + self::assertNotNull($event->getResponse()); + self::assertNotEmpty($event->getResponse()->getBody()->__toString()); + self::assertNull($event->getResponseModel()); + self::assertEquals([ + 'success' => true, + 'versions' => ["3.0", "4.0", "5.0"] + ], $event->getResponseArray()); + self::assertInstanceOf(RequestInterface::class, $event->getRequest()); + self::assertEmpty($event->getRequest()->getBody()->__toString()); + self::assertStringContainsString( + parse_url(TestConfig::getApiUrl(), PHP_URL_HOST), + $event->getApiUrl() + ); + self::assertStringContainsString( + parse_url(TestConfig::getApiUrl(), PHP_URL_HOST), + $event->getApiDomain() + ); + self::assertEquals(TestConfig::getApiKey(), $event->getApiKey()); + } + public function testFailureRequestEvent(): void { /** @var FailureRequestEvent|null $event */ @@ -209,6 +267,109 @@ EOF; ); } + public function testFailureCustomRequestEvent(): void + { + /** @var FailureRequestEvent|null $event */ + $event = null; + $json = <<<'EOF' +{ + "errorMsg": "Access denied.", + "success": false +} +EOF; + $dispatcher = new EventDispatcher(); + $dispatcher->subscribeTo(FailureRequestEvent::class, static function (object $item) use (&$event) { + $event = $item; + }); + + $mock = static::createUnversionedApiMockBuilder('api-versions'); + $mock->matchMethod(RequestMethod::GET) + ->reply(403) + ->withBody($json); + + $client = TestClientFactory::createClient($mock->getClient(), null, $dispatcher); + $client->customMethods->register( + 'apiVersions', + static function (RequestSenderInterface $sender, array $data, array $context) { + return $sender->send( + RequestMethod::GET, + sprintf('https://%s/api/api-versions', $sender->host()) + )['versions']; + } + ); + + try { + $client->customMethods->call('apiVersions'); + } catch (AccessDeniedException $exception) { + } + + self::assertInstanceOf(FailureRequestEvent::class, $event); + self::assertNotNull($event->getResponse()); + self::assertNotEmpty($event->getResponse()->getBody()->__toString()); + self::assertInstanceOf(AccessDeniedException::class, $event->getException()); + self::assertEquals('Access denied.', $event->getException()->getErrorResponse()->errorMsg); + self::assertEquals(403, $event->getException()->getStatusCode()); + self::assertStringContainsString( + parse_url(TestConfig::getApiUrl(), PHP_URL_HOST), + $event->getApiUrl() + ); + self::assertStringContainsString( + parse_url(TestConfig::getApiUrl(), PHP_URL_HOST), + $event->getApiDomain() + ); + self::assertEquals(TestConfig::getApiKey(), $event->getApiKey()); + } + + /** + * @dataProvider failureRequestEventSuppressThrow + */ + public function testFailureRequestEventCustomSuppressThrow(bool $useClientException): void + { + /** @var FailureRequestEvent $event */ + $event = null; + + $dispatcher = new EventDispatcher(); + $dispatcher->subscribeTo( + FailureRequestEvent::class, + static function (FailureRequestEvent $item) use (&$event) { + $item->suppressThrow(); + $event = $item; + } + ); + + $mock = static::createUnversionedApiMockBuilder('api-versions'); + + if ($useClientException) { + $mock->matchMethod(RequestMethod::GET) + ->throwClientException(); + } else { + $mock->matchMethod(RequestMethod::GET) + ->reply(403) + ->withJson([ + 'success' => false, + 'errorMsg' => 'Access denied.' + ]); + } + + $client = TestClientFactory::createClient($mock->getClient(), null, $dispatcher); + $client->customMethods->register( + 'apiVersions', + static function (RequestSenderInterface $sender, array $data, array $context) { + return $sender->send( + RequestMethod::GET, + sprintf('https://%s/api/api-versions', $sender->host()) + )['versions'] ?? []; + } + ); + $client->customMethods->call('apiVersions'); + + self::assertInstanceOf(FailureRequestEvent::class, $event); + self::assertInstanceOf( + $useClientException ? ClientExceptionInterface::class : ApiExceptionInterface::class, + $event->getException() + ); + } + public function failureRequestEventSuppressThrow(): array { return [[true], [false]]; diff --git a/tests/src/ResourceGroup/CustomMethodsTest.php b/tests/src/ResourceGroup/CustomMethodsTest.php new file mode 100644 index 0000000..707f6d2 --- /dev/null +++ b/tests/src/ResourceGroup/CustomMethodsTest.php @@ -0,0 +1,294 @@ +matchMethod(RequestMethod::GET) + ->reply(200) + ->withBody($json); + + $client = TestClientFactory::createClient($mock->getClient()); + $client->customMethods->register( + 'apiVersions', + static function (RequestSenderInterface $sender, array $data, array $context) { + return $sender->send( + RequestMethod::GET, + sprintf('https://%s/api/api-versions', $sender->host()) + )['versions']; + } + ); + $apiVersions = $client->customMethods->call('apiVersions'); + + self::assertEquals(["3.0", "4.0", "5.0"], $apiVersions); + } + + public function testCallDtos(): void + { + $json = <<<'EOF' +{ + "success": true, + "versions": [ + "3.0", + "4.0", + "5.0" + ] +} +EOF; + + $mock = static::createUnversionedApiMockBuilder('api-versions'); + $mock->matchMethod(RequestMethod::GET) + ->matchExactQuery(['param' => 'value']) + ->reply(200) + ->withBody($json); + + $client = TestClientFactory::createClient($mock->getClient()); + $client->customMethods->register( + 'apiVersions', + static function (RequestSenderInterface $sender, $data, array $context) { + $response = $sender->send( + RequestMethod::GET, + sprintf('https://%s/api/api-versions', $sender->host()), + json_decode($data, true, 512, JSON_THROW_ON_ERROR) + ); + + return new APIVersionsResponse( + $response['success'] ?? false, + $response['versions'] ?? [] + ); + } + ); + + /** @var \RetailCrm\TestUtils\APIVersionsResponse $apiVersions */ + $apiVersions = $client->customMethods->call('apiVersions', '{"param": "value"}'); + + self::assertEquals(["3.0", "4.0", "5.0"], $apiVersions->versions); + } + + public function testCallContext(): void + { + $json = <<<'EOF' +{ + "success": true, + "versions": [ + "3.0", + "4.0", + "5.0" + ] +} +EOF; + + $mock = static::createUnversionedApiMockBuilder('api-versions/v1'); + $mock->matchMethod(RequestMethod::GET) + ->reply(200) + ->withBody($json); + + $client = TestClientFactory::createClient($mock->getClient()); + $client->customMethods->register( + 'apiVersions', + static function (RequestSenderInterface $sender, array $data, array $context) { + return $sender->send( + RequestMethod::GET, + sprintf('https://%s/api/api-versions/v%s', $sender->host(), $context['v']) + )['versions']; + } + ); + $apiVersions = $client->customMethods->call('apiVersions', [], ['v' => '1']); + + self::assertEquals(["3.0", "4.0", "5.0"], $apiVersions); + } + + public function testCallMagicContext(): void + { + $json = <<<'EOF' +{ + "success": true, + "versions": [ + "3.0", + "4.0", + "5.0" + ] +} +EOF; + + $mock = static::createUnversionedApiMockBuilder('api-versions/v1'); + $mock->matchMethod(RequestMethod::GET) + ->reply(200) + ->withBody($json); + + $client = TestClientFactory::createClient($mock->getClient()); + $client->customMethods->register( + 'apiVersions', + static function (RequestSenderInterface $sender, array $data, array $context) { + return $sender->send( + RequestMethod::GET, + sprintf('https://%s/api/api-versions/v%s', $sender->host(), $context['v']) + )['versions']; + } + ); + $apiVersions = $client->customMethods->apiVersions([], ['v' => '1']); + + self::assertEquals(["3.0", "4.0", "5.0"], $apiVersions); + } + + public function testCallWithParamsGet(): void + { + $json = <<<'EOF' +{ + "success": true, + "versions": [ + "3.0", + "4.0", + "5.0" + ] +} +EOF; + + $mock = static::createUnversionedApiMockBuilder('api-versions'); + $mock->matchMethod(RequestMethod::GET) + ->matchExactQuery(['param' => 'value']) + ->reply(200) + ->withBody($json); + + $client = TestClientFactory::createClient($mock->getClient()); + $client->customMethods->register( + 'apiVersions', + static function (RequestSenderInterface $sender, array $data, array $context) { + return $sender->send( + RequestMethod::GET, + sprintf('https://%s/api/api-versions', $sender->host()), + $data + )['versions']; + } + ); + $apiVersions = $client->customMethods->call('apiVersions', ['param' => 'value']); + + self::assertEquals(["3.0", "4.0", "5.0"], $apiVersions); + } + + public function testCallWithParamsPost(): void + { + $json = <<<'EOF' +{ + "success": true +} +EOF; + + $mock = static::createApiMockBuilder('some-method'); + $mock->matchMethod(RequestMethod::POST) + ->matchExactFormData(['param' => 'value']) + ->reply(200) + ->withBody($json); + + $client = TestClientFactory::createClient($mock->getClient()); + $client->customMethods->register('someMethod', new CustomApiMethod(RequestMethod::POST, 'some-method')); + $response = $client->customMethods->call('someMethod', ['param' => 'value']); + + self::assertEquals(['success' => true], $response); + } + + public function testCallMagic(): void + { + $json = <<<'EOF' +{ + "success": true, + "versions": [ + "3.0", + "4.0", + "5.0" + ] +} +EOF; + + $mock = static::createUnversionedApiMockBuilder('api-versions'); + $mock->matchMethod(RequestMethod::GET) + ->reply(200) + ->withBody($json); + + $client = TestClientFactory::createClient($mock->getClient()); + $client->customMethods->register( + 'apiVersions', + static function (RequestSenderInterface $sender, array $data, array $context) { + return $sender->send( + RequestMethod::GET, + sprintf('https://%s/api/api-versions', $sender->host()) + )['versions']; + } + ); + $apiVersions = $client->customMethods->apiVersions(); + + self::assertEquals(["3.0", "4.0", "5.0"], $apiVersions); + } + + public function testCallNoMethod(): void + { + $this->expectException(HandlerException::class); + $client = TestClientFactory::createClient(static::noSendingMock()); + $client->customMethods->call('nonexistent'); + } + + public function testCallMagicNoMethod(): void + { + $this->expectException(HandlerException::class); + $client = TestClientFactory::createClient(static::noSendingMock()); + $client->customMethods->nonexistent(); + } + + public function testCallMagicInvalidData(): void + { + $this->expectException(HandlerException::class); + $client = TestClientFactory::createClient(static::noSendingMock()); + $client->customMethods->register('failure', new CustomApiMethod(RequestMethod::GET, 'failure')); + $client->customMethods->failure(0, []); + } + + public function testCallMagicInvalidContext(): void + { + $this->expectException(HandlerException::class); + $client = TestClientFactory::createClient(static::noSendingMock()); + $client->customMethods->register('failure', new CustomApiMethod(RequestMethod::GET, 'failure')); + $client->customMethods->failure([], 0); + } + + private static function noSendingMock(): ClientInterface + { + return (new PockBuilder())->always()->throwClientException('No requests should be sent.')->getClient(); + } +} diff --git a/tests/utils/APIVersionsResponse.php b/tests/utils/APIVersionsResponse.php new file mode 100644 index 0000000..5ec4c82 --- /dev/null +++ b/tests/utils/APIVersionsResponse.php @@ -0,0 +1,35 @@ +success = $success; + $this->versions = $versions; + } +}