From 95b7c27e6cd40521eaab4e8fee285a834d092406 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=9F=D0=B0=D0=B2=D0=B5=D0=BB?= Date: Thu, 1 Oct 2020 10:34:42 +0300 Subject: [PATCH] correct deserialization for inline json, stick to json mode because implementing same for xml is not important --- .../JsonDeserializationVisitorFactory.php | 70 +++++ .../JsonDeserializationVisitor.php | 294 ++++++++++++++++++ .../Serializer/InlineJsonBodyHandler.php | 201 ------------ src/Factory/SerializerFactory.php | 6 +- .../HttpDnsGetResponseResult.php | 4 +- .../Response/Data/HttpDnsGetResponseData.php | 1 + src/Model/Response/Type/HttpDnsEnvEntry.php | 44 +++ src/TopClient/Client.php | 4 + .../RetailCrm/Tests/TopClient/ClientTest.php | 14 + 9 files changed, 433 insertions(+), 205 deletions(-) create mode 100644 src/Component/JMS/Factory/JsonDeserializationVisitorFactory.php create mode 100644 src/Component/JMS/Visitor/Deserialization/JsonDeserializationVisitor.php delete mode 100644 src/Component/Serializer/InlineJsonBodyHandler.php create mode 100644 src/Model/Response/Type/HttpDnsEnvEntry.php diff --git a/src/Component/JMS/Factory/JsonDeserializationVisitorFactory.php b/src/Component/JMS/Factory/JsonDeserializationVisitorFactory.php new file mode 100644 index 0000000..82cf600 --- /dev/null +++ b/src/Component/JMS/Factory/JsonDeserializationVisitorFactory.php @@ -0,0 +1,70 @@ + + * @license http://retailcrm.ru Proprietary + * @link http://retailcrm.ru + * @see http://help.retailcrm.ru + */ + +namespace RetailCrm\Component\JMS\Factory; + +use RetailCrm\Component\JMS\Visitor\Deserialization\JsonDeserializationVisitor; +use JMS\Serializer\Visitor\DeserializationVisitorInterface; +use JMS\Serializer\Visitor\Factory\DeserializationVisitorFactory; + +/** + * Class JsonDeserializationVisitorFactory + * + * @category JsonDeserializationVisitorFactory + * @package RetailCrm\Component\JMS\Factory + * @author RetailDriver LLC + * @license https://retailcrm.ru Proprietary + * @link http://retailcrm.ru + * @see https://help.retailcrm.ru + */ +class JsonDeserializationVisitorFactory implements DeserializationVisitorFactory +{ + /** + * @var int + */ + private $options = 0; + + /** + * @var int + */ + private $depth = 512; + + /** + * @return \JMS\Serializer\Visitor\DeserializationVisitorInterface + */ + public function getVisitor(): DeserializationVisitorInterface + { + return new JsonDeserializationVisitor($this->options, $this->depth); + } + + /** + * @param int $options + * + * @return $this + */ + public function setOptions(int $options): self + { + $this->options = $options; + return $this; + } + + /** + * @param int $depth + * + * @return $this + */ + public function setDepth(int $depth): self + { + $this->depth = $depth; + return $this; + } +} diff --git a/src/Component/JMS/Visitor/Deserialization/JsonDeserializationVisitor.php b/src/Component/JMS/Visitor/Deserialization/JsonDeserializationVisitor.php new file mode 100644 index 0000000..ff0606b --- /dev/null +++ b/src/Component/JMS/Visitor/Deserialization/JsonDeserializationVisitor.php @@ -0,0 +1,294 @@ + + * @license http://retailcrm.ru Proprietary + * @link http://retailcrm.ru + * @see http://help.retailcrm.ru + */ + +namespace RetailCrm\Component\JMS\Visitor\Deserialization; + +use JMS\Serializer\AbstractVisitor; +use JMS\Serializer\Exception\LogicException; +use JMS\Serializer\Exception\NotAcceptableException; +use JMS\Serializer\Exception\RuntimeException; +use JMS\Serializer\Metadata\ClassMetadata; +use JMS\Serializer\Metadata\PropertyMetadata; +use JMS\Serializer\Visitor\DeserializationVisitorInterface; +use SplStack; + +/** + * Class JsonDeserializationVisitor + * + * @category JsonDeserializationVisitor + * @package RetailCrm\Component\JMS\Visitor\Deserialization + * @author RetailDriver LLC + * @license https://retailcrm.ru Proprietary + * @link http://retailcrm.ru + * @see https://help.retailcrm.ru + */ +class JsonDeserializationVisitor extends AbstractVisitor implements DeserializationVisitorInterface +{ + /** + * @var int + */ + private $options = 0; + + /** + * @var int + */ + private $depth = 512; + + /** + * @var \SplStack + */ + private $objectStack; + + /** + * @var object|null + */ + private $currentObject; + + public function __construct( + int $options = 0, + int $depth = 512 + ) { + $this->objectStack = new SplStack(); + $this->options = $options; + $this->depth = $depth; + } + + /** + * {@inheritdoc} + */ + public function visitNull($data, array $type): void + { + } + + /** + * {@inheritdoc} + */ + public function visitString($data, array $type): string + { + return (string) $data; + } + + /** + * {@inheritdoc} + */ + public function visitBoolean($data, array $type): bool + { + return (bool) $data; + } + + /** + * {@inheritdoc} + */ + public function visitInteger($data, array $type): int + { + return (int) $data; + } + + /** + * {@inheritdoc} + */ + public function visitDouble($data, array $type): float + { + return (float) $data; + } + + /** + * {@inheritdoc} + */ + public function visitArray($data, array $type): array + { + if (!\is_array($data)) { + throw new RuntimeException(sprintf('Expected array, but got %s: %s', \gettype($data), json_encode($data))); + } + + // If no further parameters were given, keys/values are just passed as is. + if (!$type['params']) { + return $data; + } + + switch (\count($type['params'])) { + case 1: // Array is a list. + $listType = $type['params'][0]; + + $result = []; + + foreach ($data as $v) { + $result[] = $this->navigator->accept($v, $listType); + } + + return $result; + + case 2: // Array is a map. + [$keyType, $entryType] = $type['params']; + + $result = []; + + foreach ($data as $k => $v) { + $result[$this->navigator->accept($k, $keyType)] = $this->navigator->accept($v, $entryType); + } + + return $result; + + default: + throw new RuntimeException(sprintf( + 'Array type cannot have more than 2 parameters, but got %s.', + json_encode($type['params']) + )); + } + } + + /** + * {@inheritdoc} + */ + public function visitDiscriminatorMapProperty($data, ClassMetadata $metadata): string + { + if (isset($data[$metadata->discriminatorFieldName])) { + return (string) $data[$metadata->discriminatorFieldName]; + } + + throw new LogicException(sprintf( + 'The discriminator field name "%s" for base-class "%s" was not found in input data.', + $metadata->discriminatorFieldName, + $metadata->name + )); + } + + /** + * {@inheritdoc} + */ + public function startVisitingObject(ClassMetadata $metadata, object $object, array $type): void + { + $this->setCurrentObject($object); + } + + /** + * {@inheritdoc} + * @SuppressWarnings(PHPMD.CyclomaticComplexity) + * @SuppressWarnings(PHPMD.NPathComplexity) + */ + public function visitProperty(PropertyMetadata $metadata, $data) + { + $name = $metadata->serializedName; + + if (null === $data) { + return; + } + + if (!\is_array($data)) { + throw new RuntimeException(sprintf( + 'Invalid data %s (%s), expected "%s".', + json_encode($data), + $metadata->type['name'], + $metadata->class + )); + } + + if (in_array('InlineJsonBody', $metadata->groups ?? [])) { + if (!array_key_exists($metadata->serializedName, $data)) { + throw new RuntimeException(sprintf( + 'Cannot find expected key in the data: %s', + $metadata->serializedName + )); + } + + $data[$metadata->serializedName] = json_decode( + $data[$metadata->serializedName], + true, + 512, + JSON_THROW_ON_ERROR + ); + } + + if (true === $metadata->inline) { + if (!$metadata->type) { + throw RuntimeException::noMetadataForProperty($metadata->class, $metadata->name); + } + return $this->navigator->accept($data, $metadata->type); + } + + if (!array_key_exists($name, $data)) { + throw new NotAcceptableException(); + } + + if (!$metadata->type) { + throw RuntimeException::noMetadataForProperty($metadata->class, $metadata->name); + } + + return null !== $data[$name] ? $this->navigator->accept($data[$name], $metadata->type) : null; + } + + /** + * {@inheritdoc} + */ + public function endVisitingObject(ClassMetadata $metadata, $data, array $type): object + { + $obj = $this->currentObject; + $this->revertCurrentObject(); + + return $obj; + } + + /** + * {@inheritdoc} + */ + public function getResult($data) + { + return $data; + } + + public function setCurrentObject(object $object): void + { + $this->objectStack->push($this->currentObject); + $this->currentObject = $object; + } + + public function getCurrentObject(): ?object + { + return $this->currentObject; + } + + public function revertCurrentObject(): ?object + { + return $this->currentObject = $this->objectStack->pop(); + } + + /** + * {@inheritdoc} + */ + public function prepare($str) + { + $decoded = json_decode($str, true, $this->depth, $this->options); + + switch (json_last_error()) { + case JSON_ERROR_NONE: + return $decoded; + + case JSON_ERROR_DEPTH: + throw new RuntimeException('Could not decode JSON, maximum stack depth exceeded.'); + + case JSON_ERROR_STATE_MISMATCH: + throw new RuntimeException('Could not decode JSON, underflow or the nodes mismatch.'); + + case JSON_ERROR_CTRL_CHAR: + throw new RuntimeException('Could not decode JSON, unexpected control character found.'); + + case JSON_ERROR_SYNTAX: + throw new RuntimeException('Could not decode JSON, syntax error - malformed JSON.'); + + case JSON_ERROR_UTF8: + throw new RuntimeException('Could not decode JSON, malformed UTF-8 characters (incorrectly encoded?)'); + + default: + throw new RuntimeException('Could not decode JSON.'); + } + } +} diff --git a/src/Component/Serializer/InlineJsonBodyHandler.php b/src/Component/Serializer/InlineJsonBodyHandler.php deleted file mode 100644 index 3c65728..0000000 --- a/src/Component/Serializer/InlineJsonBodyHandler.php +++ /dev/null @@ -1,201 +0,0 @@ - - * @license MIT - * @link http://retailcrm.ru - * @see http://help.retailcrm.ru - */ -namespace RetailCrm\Component\Serializer; - -use JMS\Serializer\Context; -use JMS\Serializer\GraphNavigator; -use JMS\Serializer\GraphNavigatorInterface; -use JMS\Serializer\Handler\SubscribingHandlerInterface; -use JMS\Serializer\JsonSerializationVisitor; -use JMS\Serializer\Metadata\PropertyMetadata; -use JMS\Serializer\Metadata\StaticPropertyMetadata; -use JMS\Serializer\Visitor\DeserializationVisitorInterface; -use JMS\Serializer\Visitor\SerializationVisitorInterface; -use RecursiveDirectoryIterator; -use RecursiveIteratorIterator; -use ReflectionClass; - -/** - * Class InlineJsonBodyHandler - * - * @category InlineJsonBodyHandler - * @package RetailCrm\Component\Serializer - * @author RetailDriver LLC - * @license MIT - * @link http://retailcrm.ru - * @see https://help.retailcrm.ru - * @todo Doesn't work as expected. - */ -class InlineJsonBodyHandler implements SubscribingHandlerInterface -{ - /** - * @param \JMS\Serializer\Visitor\SerializationVisitorInterface $visitor - * @param mixed $item - * @param array $type - * @param \JMS\Serializer\Context $context - * - * @return false|string - * @throws \JsonException - * @throws \ReflectionException - */ - public function serialize(SerializationVisitorInterface $visitor, $item, array $type, Context $context) - { - $typeName = $type['name']; - $classMetadata = $context->getMetadataFactory()->getMetadataForClass($typeName); - $reflection = new ReflectionClass($type['name']); - - $visitor->startVisitingObject($classMetadata, $item, ['name' => $typeName]); - - foreach ($reflection->getProperties() as $property) { - if ($property->isStatic()) { - continue; - } - - if (!$property->isPublic()) { - $property->setAccessible(true); - } - - $value = $property->getValue($item); - $metadata = new StaticPropertyMetadata($type['name'], $property->getName(), $value); - - $visitor->visitProperty($metadata, $value); - } - - return json_encode( - $visitor->endVisitingObject($classMetadata, $item, ['name' => $typeName]), - JSON_THROW_ON_ERROR - ); - } - - /** - * @param \JMS\Serializer\Visitor\DeserializationVisitorInterface $visitor - * @param string $json - * @param array $type - * @param \JMS\Serializer\Context $context - * - * @return object - * @throws \JsonException - * @throws \ReflectionException - */ - public function deserialize(DeserializationVisitorInterface $visitor, $json, array $type, Context $context) - { - $typeName = $type['name']; - $instance = new $type['name']; - $classMetadata = $context->getMetadataFactory()->getMetadataForClass($typeName); - $reflection = new ReflectionClass($type['name']); - $jsonData = json_decode($json, true, 512, JSON_THROW_ON_ERROR); - - $visitor->startVisitingObject($classMetadata, $instance, ['name' => $typeName]); - - foreach ($reflection->getProperties() as $property) { - if ($property->isStatic()) { - continue; - } - - if (!$property->isPublic()) { - $property->setAccessible(true); - } - - $metadata = new PropertyMetadata($type['name'], $property->getName()); - $property->setValue($instance, $visitor->visitProperty($metadata, $jsonData)); - } - - $result = $visitor->endVisitingObject($classMetadata, $instance, ['name' => $typeName]); - - return $visitor->getResult($result); - } - - /** - * @return array - */ - public static function getSubscribingMethods() - { - $methods = []; - - foreach (self::getInlineJsonBodyModels() as $type) { - $methods[] = [ - 'direction' => GraphNavigatorInterface::DIRECTION_SERIALIZATION, - 'format' => 'json', - 'type' => $type, - 'method' => 'serialize', - ]; - $methods[] = [ - 'direction' => GraphNavigatorInterface::DIRECTION_DESERIALIZATION, - 'format' => 'json', - 'type' => $type, - 'method' => 'deserialize', - ]; - $methods[] = [ - 'direction' => GraphNavigatorInterface::DIRECTION_SERIALIZATION, - 'format' => 'xml', - 'type' => $type, - 'method' => 'serialize', - ]; - $methods[] = [ - 'direction' => GraphNavigatorInterface::DIRECTION_DESERIALIZATION, - 'format' => 'xml', - 'type' => $type, - 'method' => 'deserialize', - ]; - } - - return $methods; - } - - /** - * @return array - * @todo That's horrifying. Maybe find better solution? - */ - private static function getInlineJsonBodyModels(): array - { - $items = []; - $rootDir = realpath(dirname(__DIR__) . '/..'); - $directory = new RecursiveDirectoryIterator( - __DIR__ . '/../../Model/Response/Body/InlineJsonBody', - RecursiveDirectoryIterator::SKIP_DOTS - ); - $fileIterator = new RecursiveIteratorIterator($directory, RecursiveIteratorIterator::LEAVES_ONLY); - - /** @var \SplFileObject $file */ - foreach ($fileIterator as $file) { - if ('php' !== $file->getExtension()) { - continue; - } - - $items[] = self::pathToClasspath($rootDir, $file->getPath(), $file->getFilename()); - } - - return $items; - } - - /** - * @param string $root - * @param string $path - * @param string $fileName - * - * @return string - */ - private static function pathToClasspath(string $root, string $path, string $fileName): string - { - return 'RetailCrm\\' . - str_replace( - DIRECTORY_SEPARATOR, - '\\', - str_replace( - $root . DIRECTORY_SEPARATOR, - '', - realpath($path) - ) - ) . '\\' . trim(substr($fileName, 0, -4)); - } -} diff --git a/src/Factory/SerializerFactory.php b/src/Factory/SerializerFactory.php index af3cab2..8a1d956 100644 --- a/src/Factory/SerializerFactory.php +++ b/src/Factory/SerializerFactory.php @@ -17,9 +17,10 @@ use JMS\Serializer\Handler\HandlerRegistry; use JMS\Serializer\Serializer; use JMS\Serializer\SerializerBuilder; use JMS\Serializer\SerializerInterface; +use JMS\Serializer\Visitor\Factory\JsonSerializationVisitorFactory; use Psr\Container\ContainerInterface; use RetailCrm\Component\Constants; -use RetailCrm\Component\Serializer\InlineJsonBodyHandler; +use RetailCrm\Component\JMS\Factory\JsonDeserializationVisitorFactory; use RetailCrm\Interfaces\FactoryInterface; /** @@ -133,8 +134,9 @@ class SerializerFactory implements FactoryInterface 'xml', $returnNull ); - $registry->registerSubscribingHandler(new InlineJsonBodyHandler()); })->addDefaultHandlers() + ->setSerializationVisitor('json', new JsonSerializationVisitorFactory()) + ->setDeserializationVisitor('json', new JsonDeserializationVisitorFactory()) ->setSerializationContextFactory(new SerializationContextFactory()) ->build(); } diff --git a/src/Model/Response/Body/InlineJsonBody/HttpDnsGetResponseResult.php b/src/Model/Response/Body/InlineJsonBody/HttpDnsGetResponseResult.php index f496a04..35c1587 100644 --- a/src/Model/Response/Body/InlineJsonBody/HttpDnsGetResponseResult.php +++ b/src/Model/Response/Body/InlineJsonBody/HttpDnsGetResponseResult.php @@ -27,9 +27,9 @@ use JMS\Serializer\Annotation as JMS; class HttpDnsGetResponseResult { /** - * @var array $env + * @var array $env * - * @JMS\Type("int") + * @JMS\Type("array>") * @JMS\SerializedName("env") */ public $env; diff --git a/src/Model/Response/Data/HttpDnsGetResponseData.php b/src/Model/Response/Data/HttpDnsGetResponseData.php index 7936078..327ebc5 100644 --- a/src/Model/Response/Data/HttpDnsGetResponseData.php +++ b/src/Model/Response/Data/HttpDnsGetResponseData.php @@ -32,6 +32,7 @@ class HttpDnsGetResponseData * * @JMS\Type("RetailCrm\Model\Response\Body\InlineJsonBody\HttpDnsGetResponseResult") * @JMS\SerializedName("result") + * @JMS\Groups(groups={"InlineJsonBody"}) */ public $result; } diff --git a/src/Model/Response/Type/HttpDnsEnvEntry.php b/src/Model/Response/Type/HttpDnsEnvEntry.php new file mode 100644 index 0000000..db367b9 --- /dev/null +++ b/src/Model/Response/Type/HttpDnsEnvEntry.php @@ -0,0 +1,44 @@ + + * @license http://retailcrm.ru Proprietary + * @link http://retailcrm.ru + * @see http://help.retailcrm.ru + */ + +namespace RetailCrm\Model\Response\Type; + +use JMS\Serializer\Annotation as JMS; + +/** + * Class HttpDnsEnvEntry + * + * @category HttpDnsEnvEntry + * @package RetailCrm\Model\Response\Type + * @author RetailDriver LLC + * @license https://retailcrm.ru Proprietary + * @link http://retailcrm.ru + * @see https://help.retailcrm.ru + */ +class HttpDnsEnvEntry +{ + /** + * @var array + * + * @JMS\Type("array") + * @JMS\SerializedName("vip") + */ + public $vip; + + /** + * @var string + * + * @JMS\Type("string") + * @JMS\SerializedName("proto") + */ + public $proto; +} diff --git a/src/TopClient/Client.php b/src/TopClient/Client.php index 5deef6f..0502490 100644 --- a/src/TopClient/Client.php +++ b/src/TopClient/Client.php @@ -161,6 +161,10 @@ class Client */ public function sendRequest(BaseRequest $request): TopResponseInterface { + if ('json' !== $request->format) { + throw new TopClientException(sprintf('Client only supports JSON mode, got `%s` mode', $request->format)); + } + $this->processor->process($request, $this->appData); $httpRequest = $this->requestFactory->fromModel($request, $this->appData); diff --git a/tests/RetailCrm/Tests/TopClient/ClientTest.php b/tests/RetailCrm/Tests/TopClient/ClientTest.php index df1348a..fc289f9 100644 --- a/tests/RetailCrm/Tests/TopClient/ClientTest.php +++ b/tests/RetailCrm/Tests/TopClient/ClientTest.php @@ -60,4 +60,18 @@ class ClientTest extends TestCase $client->sendRequest(new HttpDnsGetRequest()); } + + public function testClientRequestXmlUnsupported() + { + $client = ClientBuilder::create() + ->setContainer($this->getContainer(self::getMockClient())) + ->setAppData(new AppData(AppData::OVERSEAS_ENDPOINT, 'appKey', 'appSecret')) + ->build(); + + $request = new HttpDnsGetRequest(); + $request->format = 'xml'; + + $this->expectExceptionMessage('Client only supports JSON mode, got `xml` mode'); + $client->sendRequest($request); + } }