1
0
mirror of synced 2024-11-21 12:56:08 +03:00

Сustom methods support

This commit is contained in:
Pavel 2021-11-25 11:02:42 +03:00 committed by GitHub
parent f0a1adfb3d
commit 75fd10d40e
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
39 changed files with 1933 additions and 106 deletions

View File

@ -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.

View File

@ -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.

View File

@ -0,0 +1,29 @@
<?php
use RetailCrm\Api\Builder\FormEncoderBuilder;
use RetailCrm\Examples\CustomMethodsDto\Dto\Customer;
use RetailCrm\Examples\CustomMethodsDto\Dto\Request\CustomersCreateRequest;
use RetailCrm\Examples\CustomMethodsDto\Factory\ClientFactory;
use RetailCrm\Examples\CustomMethodsDto\Factory\SerializerFactory;
require __DIR__ . '/vendor/autoload.php';
// Three lines below will be usually called during DI container building or hidden by other means.
$serializer = SerializerFactory::create();
$encoder = (new FormEncoderBuilder())->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;

View File

@ -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/"
}
}
}

View File

@ -0,0 +1,65 @@
<?php
/**
* PHP version 7.3
*
* @category SymfonyToLiipAdapter
* @package Retailcrm\Examples\CustomMethodsDto\Component\Adapter
*/
namespace Retailcrm\Examples\CustomMethodsDto\Component\Adapter;
use Liip\Serializer\Context;
use Liip\Serializer\SerializerInterface;
use Symfony\Component\Serializer\Serializer as SymfonySerializer;
/**
* Class SymfonyToLiipAdapter
*
* @category SymfonyToLiipAdapter
* @package Retailcrm\Examples\CustomMethodsDto\Component\Adapter
*/
class SymfonyToLiipAdapter implements SerializerInterface
{
/** @var SymfonySerializer */
private $serializer;
public function __construct(SymfonySerializer $serializer)
{
$this->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);
}
}

View File

@ -0,0 +1,68 @@
<?php
/**
* PHP version 7.3
*
* @category CustomApiMethod
* @package Retailcrm\Examples\CustomMethodsDto\Component
*/
namespace Retailcrm\Examples\CustomMethodsDto\Component;
use RetailCrm\Api\Exception\Client\HandlerException;
use RetailCrm\Api\Interfaces\FormEncoderInterface;
use RetailCrm\Api\Interfaces\RequestSenderInterface;
/**
* Class CustomApiMethod
*
* @category CustomApiMethod
* @package Retailcrm\Examples\CustomMethodsDto\Component
*/
class CustomApiMethod extends \RetailCrm\Api\Component\CustomApiMethod
{
/** @var string */
private $responseFqn;
/** @var FormEncoderInterface */
private $encoder;
public function __construct(string $method, string $route, string $responseFqn, FormEncoderInterface $encoder)
{
parent::__construct($method, $route);
$this->responseFqn = $responseFqn;
$this->encoder = $encoder;
}
/**
* Sends the request, returns the response.
*
* @param \RetailCrm\Api\Interfaces\RequestSenderInterface $sender
* @param array<int|string, mixed>|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
);
}
}
}

View File

@ -0,0 +1,31 @@
<?php
/**
* PHP version 7.3
*
* @category Customer
* @package Retailcrm\Examples\CustomMethodsDto\Dto
*/
namespace Retailcrm\Examples\CustomMethodsDto\Dto;
/**
* Class Customer
*
* @category Customer
* @package Retailcrm\Examples\CustomMethodsDto\Dto
*/
class Customer
{
/** @var string */
public $firstName;
/** @var string */
public $lastName;
/** @var string */
public $patronymic;
/** @var string */
public $email;
}

View File

@ -0,0 +1,38 @@
<?php
/**
* PHP version 7.3
*
* @category CustomersCreateRequest
* @package Retailcrm\Examples\CustomMethodsDto\Dto\Request
*/
namespace Retailcrm\Examples\CustomMethodsDto\Dto\Request;
use RetailCrm\Api\Component\FormData\Mapping as Form;
/**
* Class CustomersCreateRequest
*
* @category CustomersCreateRequest
* @package Retailcrm\Examples\CustomMethodsDto\Dto\Request
*/
class CustomersCreateRequest
{
/**
* @var string
*
* @Form\Type("string")
* @Form\SerializedName("site")
*/
public $site;
/**
* @var \Retailcrm\Examples\CustomMethodsDto\Dto\Customer
*
* @Form\Type("Retailcrm\Examples\CustomMethodsDto\Dto\Customer")
* @Form\SerializedName("customer")
* @Form\JsonField()
*/
public $customer;
}

View File

@ -0,0 +1,22 @@
<?php
/**
* PHP version 7.3
*
* @category CustomersCreateResponse
* @package Retailcrm\Examples\CustomMethodsDto\Dto\Response
*/
namespace Retailcrm\Examples\CustomMethodsDto\Dto\Response;
/**
* Class CustomersCreateResponse
*
* @category CustomersCreateResponse
* @package Retailcrm\Examples\CustomMethodsDto\Dto\Response
*/
class CustomersCreateResponse
{
/** @var int */
public $id;
}

View File

@ -0,0 +1,55 @@
<?php
/**
* PHP version 7.3
*
* @category ClientFactory
* @package RetailCrm\Examples\CustomMethodsDto\Factory
*/
namespace RetailCrm\Examples\CustomMethodsDto\Factory;
use RetailCrm\Api\Client;
use RetailCrm\Api\Enum\RequestMethod;
use RetailCrm\Api\Factory\ClientFactory as Base;
use RetailCrm\Api\Interfaces\FormEncoderInterface;
use RetailCrm\Examples\CustomMethodsDto\Component\CustomApiMethod;
use RetailCrm\Examples\CustomMethodsDto\Dto\Response\CustomersCreateResponse;
/**
* Class ClientFactory
*
* @category ClientFactory
* @package RetailCrm\Examples\CustomMethodsDto\Factory
*/
class ClientFactory extends Base
{
/** @var \RetailCrm\Api\Interfaces\FormEncoderInterface */
private $customEncoder;
public function setCustomEncoder(FormEncoderInterface $customEncoder): ClientFactory
{
$this->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);
}
}

View File

@ -0,0 +1,30 @@
<?php
/**
* PHP version 7.3
*
* @category SerializerFactory
* @package Retailcrm\Examples\CustomMethodsDto\Factory
*/
namespace Retailcrm\Examples\CustomMethodsDto\Factory;
use Liip\Serializer\SerializerInterface;
use RetailCrm\Examples\CustomMethodsDto\Component\Adapter\SymfonyToLiipAdapter;
use Symfony\Component\Serializer\Serializer;
use Symfony\Component\Serializer\Encoder\JsonEncoder;
use Symfony\Component\Serializer\Normalizer\ObjectNormalizer;
/**
* Class SerializerFactory
*
* @category SerializerFactory
* @package Retailcrm\Examples\CustomMethodsDto\Factory
*/
class SerializerFactory
{
public static function create(): SerializerInterface
{
return new SymfonyToLiipAdapter(new Serializer([new ObjectNormalizer()], [new JsonEncoder()]));
}
}

View File

@ -0,0 +1,106 @@
## Implementing custom API methods
You can use this feature if you need to group multiple API calls, or return something custom - not the API response as-is,
or, for example, to use new API methods without waiting for the implementation. For all of those cases, we provide a special
resource group in the client that will make it easy to implement a custom API method.
The main limitation of this mechanism is the lack of DTO. You will be limited to arrays inside custom methods. It is
_possible_ to use custom DTO's for the custom methods, but it is a little tricky - check the bottom of this page for the example.
Let's imagine that we have a special method with this route: `/api/v5/dialogs`. This method returns a list of the dialogs present in the system.
We cannot use it directly because it is not implemented in the client. However, we can implement it by ourselves using custom methods.
It will look like this:
```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.io', 'key');
$client->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)

View File

@ -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/)

View File

@ -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();
}
}
}

View File

@ -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
);
}
/**

View File

@ -0,0 +1,80 @@
<?php
/**
* PHP version 7.3
*
* @category CustomApiMethod
* @package RetailCrm\Api\Component
*/
namespace RetailCrm\Api\Component;
use RetailCrm\Api\Exception\Client\HandlerException;
use RetailCrm\Api\Interfaces\RequestSenderInterface;
/**
* Class CustomApiMethod
*
* This class can be used to implement custom methods without any hassle. It is useful if you don't want to
* do anything besides sending the request and reading the response.
*
* @see \RetailCrm\Api\ResourceGroup\CustomMethods::register() for the usage example.
*
* @category CustomApiMethod
* @package RetailCrm\Api\Component
*/
class CustomApiMethod
{
/** @var string */
protected $method;
/** @var string */
protected $route;
/** @var bool */
protected $rawRouteUri;
/**
* Instantiates new instance of the CustomApiMethod.
*
* @param string $method
* @param string $route
*/
public function __construct(string $method, string $route)
{
$this->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<int|string, mixed>|object $data
*
* @return array<int|string, mixed>|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);
}
}

View File

@ -0,0 +1,106 @@
<?php
/**
* PHP version 7.3
*
* @category RequestSender
* @package RetailCrm\Api\Component
*/
namespace RetailCrm\Api\Component;
use Psr\Http\Client\ClientExceptionInterface;
use Psr\Http\Client\NetworkExceptionInterface;
use RetailCrm\Api\Interfaces\RequestSenderInterface;
use RetailCrm\Api\ResourceGroup\AbstractApiResourceGroup;
use RetailCrm\Api\Traits\BaseUrlAwareTrait;
/**
* Class RequestSender
*
* @category RequestSender
* @package RetailCrm\Api\Component
*/
class RequestSender extends AbstractApiResourceGroup implements RequestSenderInterface
{
use BaseUrlAwareTrait {
route as private makeRoute;
}
/**
* @param \RetailCrm\Api\ResourceGroup\AbstractApiResourceGroup $resourceGroup
*/
public function __construct(AbstractApiResourceGroup $resourceGroup)
{
parent::__construct(
$resourceGroup->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<int|string, mixed> $requestForm
*
* @return array<int|string, mixed>
* @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);
}
}

View File

@ -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<int|string, mixed> $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;
}
}

View File

@ -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<int|string, mixed>
* @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
*/

View File

@ -35,19 +35,28 @@ abstract class AbstractRequestEvent
/** @var \Psr\Http\Message\ResponseInterface|null */
private $response;
/** @var array<int|string, mixed> */
private $responseArray;
/**
* AbstractRequestEvent constructor.
*
* @param string $baseUrl
* @param \Psr\Http\Message\RequestInterface $request
* @param \Psr\Http\Message\ResponseInterface|null $response
* @param array<int|string, mixed> $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<int|string, mixed>
*/
public function getResponseArray(): array
{
return $this->responseArray;
}
/**
* Returns RetailCRM domain for request.
*

View File

@ -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<int|string, mixed> $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;
}

View File

@ -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<int|string, mixed> $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;
}

View File

@ -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)) {

View File

@ -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
);
}
}

View File

@ -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<int|string, mixed>
* @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
);
}
}

View File

@ -37,6 +37,7 @@ class AccountNotFoundHandler extends AbstractResponseHandler
) {
$errorResponse = new ErrorResponse();
$errorResponse->errorMsg = 'Account does not exist.';
$errorResponse->errors = [];
$event = new FailureRequestEvent(
$responseData->baseUrl,

View File

@ -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 ?? []
));
}
}

View File

@ -0,0 +1,61 @@
<?php
/**
* PHP version 7.3
*
* @category RequestTransformerInterface
* @package RetailCrm\Api\Interfaces
*/
namespace RetailCrm\Api\Interfaces;
/**
* Interface RequestSenderInterface
*
* @category RequestTransformerInterface
* @package RetailCrm\Api\Interfaces
*/
interface RequestSenderInterface
{
/**
* Sends 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<int|string, mixed> $requestForm
*
* @return array<int|string, mixed>
* @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;
}

View File

@ -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<int|string, mixed> $requestForm
*
* @return \Psr\Http\Message\RequestInterface
* @throws \RetailCrm\Api\Exception\Client\HandlerException
*/
public function createCustomPsrRequest(
string $method,
string $uri,
array $requestForm = []
): PsrRequestInterface;
/**
* Returns HandlerInterface.
*

View File

@ -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<int|string, mixed>
* @throws \RetailCrm\Api\Interfaces\ApiExceptionInterface
* @throws \RetailCrm\Api\Exception\Client\HandlerException
*/
public function createCustomResponse(
string $baseUrl,
RequestInterface $request,
ResponseInterface $response
): array;
/**
* Returns HandlerInterface.
*

View File

@ -28,6 +28,9 @@ class RequestData
/** @var \RetailCrm\Api\Interfaces\RequestInterface|null */
public $requestModel;
/** @var array<int|string, mixed> */
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<int|string, mixed> $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;
}
}

View File

@ -33,9 +33,15 @@ class ResponseData
/** @var string */
public $type;
/** @var bool */
public $custom;
/** @var \RetailCrm\Api\Interfaces\ResponseInterface */
public $responseModel;
/** @var array<int|string, mixed> */
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;
}
}

View File

@ -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();
}
}
}

View File

@ -0,0 +1,217 @@
<?php
/**
* PHP version 7.3
*
* @category CustomMethods
* @package RetailCrm\Api\ResourceGroup
*/
namespace RetailCrm\Api\ResourceGroup;
use RetailCrm\Api\Component\RequestSender;
use RetailCrm\Api\Exception\Client\HandlerException;
/**
* Class CustomMethods
*
* @category CustomMethods
* @package RetailCrm\Api\ResourceGroup
*
* @SuppressWarnings(PHPMD.CouplingBetweenObjects)
*/
class CustomMethods extends AbstractApiResourceGroup
{
/** @var \RetailCrm\Api\Interfaces\RequestSenderInterface|null */
private $requestSender;
/** @var array<string, callable|\RetailCrm\Api\Component\CustomApiMethod> */
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<int|string, mixed>|object $data
* @param array<int|string, mixed> $context
*
* @return array<int|string, mixed>|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<int|string, mixed> $arguments
*
* @return array<int|string, mixed>|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);
}
}

View File

@ -0,0 +1,34 @@
<?php
/**
* PHP version 7.3
*
* @category BaseUrlAwareTrait
* @package RetailCrm\Api\Traits
*/
namespace RetailCrm\Api\Traits;
/**
* Trait BaseUrlAwareTrait
*
* @category BaseUrlAwareTrait
* @package RetailCrm\Api\Traits
*/
trait BaseUrlAwareTrait
{
/** @var string */
protected $baseUrl;
/**
* Returns API routes with base URI prepended.
*
* @param string $route
*
* @return string
*/
protected function route(string $route): string
{
return sprintf('%s/%s', $this->baseUrl, $route);
}
}

View File

@ -0,0 +1,64 @@
<?php
/**
* PHP version 7.3
*
* @category CustomApiMethodTest
* @package RetailCrm\Tests\Component
*/
namespace RetailCrm\Tests\Component;
use http\Env\Request;
use PHPUnit\Framework\TestCase;
use RetailCrm\Api\Component\CustomApiMethod;
use RetailCrm\Api\Enum\RequestMethod;
use RetailCrm\Api\Interfaces\RequestSenderInterface;
/**
* Class CustomApiMethodTest
*
* @category CustomApiMethodTest
* @package RetailCrm\Tests\Component
*/
class CustomApiMethodTest extends TestCase
{
public function testDefault(): void
{
$baseUrl = 'https://test.simla.io/api/v5';
$mock = $this->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)
);
}
}

View File

@ -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]];

View File

@ -0,0 +1,294 @@
<?php
/**
* PHP version 7.3
*
* @category CustomMethodsTest
* @package RetailCrm\Tests\ResourceGroup
*/
namespace RetailCrm\Tests\ResourceGroup;
use Psr\Http\Client\ClientInterface;
use RetailCrm\Api\Component\CustomApiMethod;
use RetailCrm\Api\Enum\RequestMethod;
use RetailCrm\Api\Exception\Client\HandlerException;
use RetailCrm\Api\Interfaces\RequestSenderInterface;
use RetailCrm\TestUtils\APIVersionsResponse;
use RetailCrm\TestUtils\Factory\TestClientFactory;
use RetailCrm\TestUtils\PockBuilder;
use RetailCrm\TestUtils\TestCase\AbstractApiResourceGroupTestCase;
/**
* Class CustomMethodsTest
*
* @category CustomMethodsTest
* @package RetailCrm\Tests\ResourceGroup
*/
class CustomMethodsTest extends AbstractApiResourceGroupTestCase
{
public function testCall(): 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->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();
}
}

View File

@ -0,0 +1,35 @@
<?php
/**
* PHP version 7.3
*
* @category APIVersionsResponse
* @package RetailCrm\TestUtils
*/
namespace RetailCrm\TestUtils;
/**
* Class APIVersionsResponse
*
* @category APIVersionsResponse
* @package RetailCrm\TestUtils
*/
class APIVersionsResponse
{
/** @var bool */
public $success;
/** @var string[] */
public $versions;
/**
* @param bool $success
* @param string[] $versions
*/
public function __construct(bool $success, array $versions)
{
$this->success = $success;
$this->versions = $versions;
}
}