diff --git a/src/Component/Exception/FactoryException.php b/src/Component/Exception/FactoryException.php new file mode 100644 index 0000000..7605122 --- /dev/null +++ b/src/Component/Exception/FactoryException.php @@ -0,0 +1,27 @@ + + * @license MIT + * @link http://retailcrm.ru + * @see http://help.retailcrm.ru + */ +namespace RetailCrm\Component\Exception; + +/** + * Class FactoryException + * + * @category FactoryException + * @package RetailCrm\Component\Exception + * @author RetailDriver LLC + * @license MIT + * @link http://retailcrm.ru + * @see https://help.retailcrm.ru + */ +class FactoryException extends \Exception +{ +} diff --git a/src/Component/Psr7/AppendStream.php b/src/Component/Psr7/AppendStream.php new file mode 100644 index 0000000..99796c1 --- /dev/null +++ b/src/Component/Psr7/AppendStream.php @@ -0,0 +1,309 @@ + + * @license MIT + * @link http://retailcrm.ru + * @see http://help.retailcrm.ru + */ +namespace RetailCrm\Component\Psr7; + +use Psr\Http\Message\StreamInterface; + +/** + * Class AppendStream + * + * @category AppendStream + * @package RetailCrm\Component\Psr7 + * @author Michael Dowling + * @author RetailDriver LLC + * @license MIT + * @link http://retailcrm.ru + * @see https://help.retailcrm.ru + */ +class AppendStream implements StreamInterface +{ + /** @var StreamInterface[] Streams being decorated */ + private $streams = []; + + /** @var bool */ + private $seekable = true; + + /** @var int */ + private $current = 0; + + /** @var int */ + private $pos = 0; + + /** + * AppendStream constructor. + * + * @param StreamInterface[] $streams Streams to decorate. Each stream must + * be readable. + */ + public function __construct(array $streams = []) + { + foreach ($streams as $stream) { + $this->addStream($stream); + } + } + + /** + * @return string + * @throws \Throwable + */ + public function __toString(): string + { + try { + $this->rewind(); + + return $this->getContents(); + } catch (\Throwable $e) { + if (\PHP_VERSION_ID >= 70400) { + throw $e; + } + + trigger_error(sprintf('%s::__toString exception: %s', self::class, (string) $e), E_USER_ERROR); + } + } + + /** + * Add a stream to the AppendStream + * + * @param StreamInterface $stream Stream to append. Must be readable. + * + * @throws \InvalidArgumentException if the stream is not readable + */ + public function addStream(StreamInterface $stream): void + { + if (!$stream->isReadable()) { + throw new \InvalidArgumentException('Each stream must be readable'); + } + + // The stream is only seekable if all streams are seekable + if (!$stream->isSeekable()) { + $this->seekable = false; + } + + $this->streams[] = $stream; + } + + /** + * @return string + */ + public function getContents(): string + { + return Utils::copyToString($this); + } + + /** + * Closes each attached stream. + */ + public function close(): void + { + $this->pos = 0; + $this->current = 0; + $this->seekable = true; + + foreach ($this->streams as $stream) { + $stream->close(); + } + + $this->streams = []; + } + + /** + * Detaches each attached stream. + * + * Returns null as it's not clear which underlying stream resource to return. + */ + public function detach() + { + $this->pos = $this->current = 0; + $this->seekable = true; + + foreach ($this->streams as $stream) { + $stream->detach(); + } + + $this->streams = []; + + return null; + } + + /** + * @return int + */ + public function tell(): int + { + return $this->pos; + } + + /** + * Tries to calculate the size by adding the size of each stream. + * + * If any of the streams do not return a valid number, then the size of the + * append stream cannot be determined and null is returned. + */ + public function getSize(): ?int + { + $size = 0; + + foreach ($this->streams as $stream) { + $s = $stream->getSize(); + + if ($s === null) { + return null; + } + + $size += $s; + } + + return $size; + } + + /** + * @return bool + */ + public function eof(): bool + { + return !$this->streams || + ($this->current >= count($this->streams) - 1 && + $this->streams[$this->current]->eof()); + } + + /** + * + */ + public function rewind(): void + { + $this->seek(0); + } + + /** + * Attempts to seek to the given position. Only supports SEEK_SET. + * + * @param int $offset + * @param int $whence + */ + public function seek($offset, $whence = SEEK_SET): void + { + if (!$this->seekable) { + throw new \RuntimeException('This AppendStream is not seekable'); + } + + if ($whence !== SEEK_SET) { + throw new \RuntimeException('The AppendStream can only seek with SEEK_SET'); + } + + $this->pos = $this->current = 0; + + // Rewind each stream + foreach ($this->streams as $i => $stream) { + try { + $stream->rewind(); + } catch (\Exception $e) { + throw new \RuntimeException('Unable to seek stream ' + . $i . ' of the AppendStream', 0, $e); + } + } + + // Seek to the actual position by reading from each stream + while ($this->pos < $offset && !$this->eof()) { + $result = $this->read(min(8096, $offset - $this->pos)); + if ($result === '') { + break; + } + } + } + + /** + * Reads from all of the appended streams until the length is met or EOF. + * + * @param int $length + * + * @return string + */ + public function read($length): string + { + $buffer = ''; + $total = count($this->streams) - 1; + $remaining = $length; + $progressToNext = false; + + while ($remaining > 0) { + if ($progressToNext || $this->streams[$this->current]->eof()) { + $progressToNext = false; + + if ($this->current === $total) { + break; + } + + $this->current++; + } + + $result = $this->streams[$this->current]->read($remaining); + + if ($result === '') { + $progressToNext = true; + + continue; + } + + $buffer .= $result; + $remaining = $length - strlen($buffer); + } + + $this->pos += strlen($buffer); + + return $buffer; + } + + /** + * @return bool + */ + public function isReadable(): bool + { + return true; + } + + /** + * @return bool + */ + public function isWritable(): bool + { + return false; + } + + /** + * @return bool + */ + public function isSeekable(): bool + { + return $this->seekable; + } + + /** + * @param string $string + * + * @return int + */ + public function write($string): int + { + throw new \RuntimeException('Cannot write to an AppendStream'); + } + + /** + * @param null $key + * + * @return array|mixed|null + */ + public function getMetadata($key = null) + { + return $key ? null : []; + } +} diff --git a/src/Component/Psr7/BufferStream.php b/src/Component/Psr7/BufferStream.php new file mode 100644 index 0000000..41e9a96 --- /dev/null +++ b/src/Component/Psr7/BufferStream.php @@ -0,0 +1,197 @@ + + * @license MIT + * @link http://retailcrm.ru + * @see http://help.retailcrm.ru + */ +namespace RetailCrm\Component\Psr7; + +use Psr\Http\Message\StreamInterface; + +/** + * Class BufferStream + * + * @category BufferStream + * @package RetailCrm\Component\Psr7 + * @author Michael Dowling + * @author RetailDriver LLC + * @license MIT + * @link http://retailcrm.ru + * @see https://help.retailcrm.ru + */ +class BufferStream implements StreamInterface +{ + /** @var int */ + private $hwm; + + /** @var string */ + private $buffer = ''; + + /** + * @param int $hwm High water mark, representing the preferred maximum + * buffer size. If the size of the buffer exceeds the high + * water mark, then calls to write will continue to succeed + * but will return 0 to inform writers to slow down + * until the buffer has been drained by reading from it. + */ + public function __construct(int $hwm = 16384) + { + $this->hwm = $hwm; + } + + /** + * @return string + */ + public function __toString(): string + { + return $this->getContents(); + } + + /** + * @return string + */ + public function getContents(): string + { + $buffer = $this->buffer; + $this->buffer = ''; + + return $buffer; + } + + /** + * + */ + public function close(): void + { + $this->buffer = ''; + } + + /** + * @return |null + */ + public function detach() + { + $this->close(); + + return null; + } + + /** + * @return int|null + */ + public function getSize(): ?int + { + return strlen($this->buffer); + } + + /** + * @return bool + */ + public function isReadable(): bool + { + return true; + } + + /** + * @return bool + */ + public function isWritable(): bool + { + return true; + } + + /** + * @return bool + */ + public function isSeekable(): bool + { + return false; + } + + /** + * + */ + public function rewind(): void + { + $this->seek(0); + } + + /** + * @param $offset + * @param int $whence + */ + public function seek($offset, $whence = SEEK_SET): void + { + throw new \RuntimeException('Cannot seek a BufferStream'); + } + + /** + * @return bool + */ + public function eof(): bool + { + return strlen($this->buffer) === 0; + } + + /** + * @return int + */ + public function tell(): int + { + throw new \RuntimeException('Cannot determine the position of a BufferStream'); + } + + /** + * Reads data from the buffer. + */ + public function read($length): string + { + $currentLength = strlen($this->buffer); + + if ($length >= $currentLength) { + // No need to slice the buffer because we don't have enough data. + $result = $this->buffer; + $this->buffer = ''; + } else { + // Slice up the result to provide a subset of the buffer. + $result = substr($this->buffer, 0, $length); + $this->buffer = substr($this->buffer, $length); + } + + return $result; + } + + /** + * Writes data to the buffer. + */ + public function write($string): int + { + $this->buffer .= $string; + + if (strlen($this->buffer) >= $this->hwm) { + return 0; + } + + return strlen($string); + } + + /** + * @param null $key + * + * @return array|int|null + */ + public function getMetadata($key = null) + { + if ($key === 'hwm') { + return $this->hwm; + } + + return $key ? null : []; + } +} diff --git a/src/Component/Psr7/MultipartStream.php b/src/Component/Psr7/MultipartStream.php new file mode 100644 index 0000000..d6f41d4 --- /dev/null +++ b/src/Component/Psr7/MultipartStream.php @@ -0,0 +1,386 @@ + + * @license MIT + * @link http://retailcrm.ru + * @see http://help.retailcrm.ru + */ +namespace RetailCrm\Component\Psr7; + +use Psr\Http\Message\StreamInterface; + +/** + * Class MultipartStream + * + * @category MultipartStream + * @package RetailCrm\Component\Psr7 + * @author Michael Dowling + * @author RetailDriver LLC + * @license MIT + * @link http://retailcrm.ru + * @see https://help.retailcrm.ru + */ +class MultipartStream implements StreamInterface +{ + /** + * @var \Psr\Http\Message\StreamInterface $stream + */ + private $stream; + + /** @var string */ + private $boundary; + + /** + * @param array $elements Array of associative arrays, each containing a + * required "name" key mapping to the form field, + * name, a required "contents" key mapping to a + * StreamInterface/resource/string, an optional + * "headers" associative array of custom headers, + * and an optional "filename" key mapping to a + * string to send as the filename in the part. + * @param string|null $boundary You can optionally provide a specific boundary + * + */ + public function __construct(array $elements = [], string $boundary = null) + { + $this->boundary = $boundary ?: sha1(uniqid('', true)); + $this->stream = $this->createStream($elements); + } + + /** + * Magic method used to create a new stream if streams are not added in + * the constructor of a decorator. + * + * @param string $name + * + * @return StreamInterface + */ + public function __get(string $name) + { + if ($name === 'stream') { + $this->stream = $this->createStream(); + + return $this->stream; + } + + throw new \UnexpectedValueException("$name not found on class"); + } + + /** + * @param string $name + * @param $value + */ + public function __set(string $name, $value) + { + throw new \RuntimeException('Not implemented'); + } + + /** + * @param string $name + */ + public function __isset(string $name) + { + throw new \RuntimeException('Not implemented'); + } + + /** + * @return string + * @throws \Throwable + */ + public function __toString(): string + { + try { + if ($this->isSeekable()) { + $this->seek(0); + } + + return $this->getContents(); + } catch (\Throwable $e) { + if (\PHP_VERSION_ID >= 70400) { + throw $e; + } + + trigger_error(sprintf('%s::__toString exception: %s', self::class, (string) $e), E_USER_ERROR); + } + } + + /** + * Allow decorators to implement custom methods + * + * @param string $method + * @param array $args + * + * @return mixed + */ + public function __call(string $method, array $args) + { + /** @var callable $callable */ + $callable = [$this->stream, $method]; + $result = call_user_func_array($callable, $args); + + return $result === $this->stream ? $this : $result; + } + + /** + * @return string + */ + public function getContents(): string + { + return Utils::copyToString($this); + } + + /** + * close + */ + public function close(): void + { + $this->stream->close(); + } + + /** + * @param null $key + * + * @return array|mixed|null + */ + public function getMetadata($key = null) + { + return $this->stream->getMetadata($key); + } + + /** + * @return resource|null + */ + public function detach() + { + return $this->stream->detach(); + } + + /** + * @return int|null + */ + public function getSize(): ?int + { + return $this->stream->getSize(); + } + + /** + * @return bool + */ + public function eof(): bool + { + return $this->stream->eof(); + } + + /** + * @return int + */ + public function tell(): int + { + return $this->stream->tell(); + } + + /** + * @return bool + */ + public function isReadable(): bool + { + return $this->stream->isReadable(); + } + + /** + * @return bool + */ + public function isWritable(): bool + { + return $this->stream->isWritable(); + } + + /** + * @return bool + */ + public function isSeekable(): bool + { + return $this->stream->isSeekable(); + } + + /** + * rewind + */ + public function rewind(): void + { + $this->seek(0); + } + + /** + * @param int $offset + * @param int $whence + */ + public function seek($offset, $whence = SEEK_SET): void + { + $this->stream->seek($offset, $whence); + } + + /** + * @param int $length + * + * @return string + */ + public function read($length): string + { + return $this->stream->read($length); + } + + /** + * @param string $string + * + * @return int + */ + public function write($string): int + { + return $this->stream->write($string); + } + + /** + * @return string + */ + public function getBoundary(): string + { + return $this->boundary; + } + + /** + * Create the aggregate stream that will be used to upload the POST data + * + * @param array $elements + * + * @return \Psr\Http\Message\StreamInterface + */ + protected function createStream(array $elements = []): StreamInterface + { + $stream = new AppendStream(); + + foreach ($elements as $element) { + $this->addElement($stream, $element); + } + + // Add the trailing boundary with CRLF + $stream->addStream(Utils::streamFor("--{$this->boundary}--\r\n")); + + return $stream; + } + + /** + * Get the headers needed before transferring the content of a POST file + * + * @param array $headers + * + * @return string + */ + private function getHeaders(array $headers): string + { + $str = ''; + + foreach ($headers as $key => $value) { + $str .= "{$key}: {$value}\r\n"; + } + + return "--{$this->boundary}\r\n" . trim($str) . "\r\n\r\n"; + } + + /** + * @param \RetailCrm\Component\Psr7\AppendStream $stream + * @param array $element + */ + private function addElement(AppendStream $stream, array $element): void + { + foreach (['contents', 'name'] as $key) { + if (!array_key_exists($key, $element)) { + throw new \InvalidArgumentException("A '{$key}' key is required"); + } + } + + $element['contents'] = Utils::streamFor($element['contents']); + + if (empty($element['filename'])) { + $uri = $element['contents']->getMetadata('uri'); + + if (substr($uri, 0, 6) !== 'php://') { + $element['filename'] = $uri; + } + } + + [$body, $headers] = $this->createElement( + $element['name'], + $element['contents'], + $element['filename'] ?? null, + $element['headers'] ?? [] + ); + + $stream->addStream(Utils::streamFor($this->getHeaders($headers))); + $stream->addStream($body); + $stream->addStream(Utils::streamFor("\r\n")); + } + + /** + * @param string $name + * @param \Psr\Http\Message\StreamInterface $stream + * @param string|null $filename + * @param array $headers + * + * @return array + */ + private function createElement(string $name, StreamInterface $stream, ?string $filename, array $headers): array + { + // Set a default content-disposition header if one was no provided + $disposition = $this->getHeader($headers, 'content-disposition'); + + if (!$disposition) { + $headers['Content-Disposition'] = ($filename === '0' || $filename) + ? sprintf( + 'form-data; name="%s"; filename="%s"', + $name, + basename($filename) + ) + : "form-data; name=\"{$name}\""; + } + + // Set a default content-length header if one was no provided + $length = $this->getHeader($headers, 'content-length'); + + if (!$length && $length = $stream->getSize()) { + $headers['Content-Length'] = (string) $length; + } + + // Set a default Content-Type if one was not supplied + $type = $this->getHeader($headers, 'content-type'); + + if (!$type && ($filename === '0' || $filename) && $type = Utils::mimetypeFromFilename($filename)) { + $headers['Content-Type'] = $type; + } + + return [$stream, $headers]; + } + + /** + * @param array $headers + * @param string $key + * + * @return mixed|null + */ + private function getHeader(array $headers, string $key) + { + $lowercaseHeader = strtolower($key); + foreach ($headers as $k => $v) { + if (strtolower($k) === $lowercaseHeader) { + return $v; + } + } + + return null; + } +} diff --git a/src/Component/Psr7/PumpStream.php b/src/Component/Psr7/PumpStream.php new file mode 100644 index 0000000..978eb0a --- /dev/null +++ b/src/Component/Psr7/PumpStream.php @@ -0,0 +1,241 @@ + + * @license MIT + * @link http://retailcrm.ru + * @see http://help.retailcrm.ru + */ +namespace RetailCrm\Component\Psr7; + +use Psr\Http\Message\StreamInterface; + +/** + * Class PumpStream + * + * @category PumpStream + * @package RetailCrm\Component\Psr7 + * @author Michael Dowling + * @author RetailDriver LLC + * @license MIT + * @link http://retailcrm.ru + * @see https://help.retailcrm.ru + */ +class PumpStream implements StreamInterface +{ + /** @var callable|null */ + private $source; + + /** @var int|null */ + private $size; + + /** @var int */ + private $tellPos = 0; + + /** @var array */ + private $metadata; + + /** @var BufferStream */ + private $buffer; + + /** + * @param callable(int): (string|null|false) $source Source of the stream data. The callable MAY + * accept an integer argument used to control the + * amount of data to return. The callable MUST + * return a string when called, or false|null on error + * or EOF. + * @param array{size?: int, metadata?: array} $options Stream options: + * - metadata: Hash of metadata to use with stream. + * - size: Size of the stream, if known. + */ + public function __construct(callable $source, array $options = []) + { + $this->source = $source; + $this->size = $options['size'] ?? null; + $this->metadata = $options['metadata'] ?? []; + $this->buffer = new BufferStream(); + } + + /** + * @return string + * @throws \Throwable + */ + public function __toString(): string + { + try { + return Utils::copyToString($this); + } catch (\Throwable $e) { + if (\PHP_VERSION_ID >= 70400) { + throw $e; + } + + trigger_error(sprintf('%s::__toString exception: %s', self::class, (string) $e), E_USER_ERROR); + } + } + + /** + * @return void + */ + public function close(): void + { + $this->detach(); + } + + /** + * @return |null + */ + public function detach() + { + $this->tellPos = 0; + $this->source = null; + + return null; + } + + /** + * @return int|null + */ + public function getSize(): ?int + { + return $this->size; + } + + /** + * @return int + */ + public function tell(): int + { + return $this->tellPos; + } + + /** + * @return bool + */ + public function eof(): bool + { + return $this->source === null; + } + + /** + * @return bool + */ + public function isSeekable(): bool + { + return false; + } + + /** + * + */ + public function rewind(): void + { + $this->seek(0); + } + + /** + * @param $offset + * @param int $whence + */ + public function seek($offset, $whence = SEEK_SET): void + { + throw new \RuntimeException('Cannot seek a PumpStream'); + } + + /** + * @return bool + */ + public function isWritable(): bool + { + return false; + } + + /** + * @param $string + * + * @return int + */ + public function write($string): int + { + throw new \RuntimeException('Cannot write to a PumpStream'); + } + + /** + * @return bool + */ + public function isReadable(): bool + { + return true; + } + + /** + * @param $length + * + * @return string + */ + public function read($length): string + { + $data = $this->buffer->read($length); + $readLen = strlen($data); + $this->tellPos += $readLen; + $remaining = $length - $readLen; + + if ($remaining) { + $this->pump($remaining); + $data .= $this->buffer->read($remaining); + $this->tellPos += strlen($data) - $readLen; + } + + return $data; + } + + /** + * @return string + */ + public function getContents(): string + { + $result = ''; + while (!$this->eof()) { + $result .= $this->read(1000000); + } + + return $result; + } + + /** + * @param null $key + * + * @return array|mixed|null + */ + public function getMetadata($key = null) + { + if (!$key) { + return $this->metadata; + } + + return $this->metadata[$key] ?? null; + } + + /** + * @param int $length + */ + private function pump(int $length): void + { + if ($this->source) { + do { + $data = call_user_func($this->source, $length); + + if ($data === false || $data === null) { + $this->source = null; + return; + } + + $this->buffer->write($data); + $length -= strlen($data); + } while ($length > 0); + } + } +} diff --git a/src/Component/Psr7/Stream.php b/src/Component/Psr7/Stream.php new file mode 100644 index 0000000..c9336ce --- /dev/null +++ b/src/Component/Psr7/Stream.php @@ -0,0 +1,362 @@ + + * @license MIT + * @link http://retailcrm.ru + * @see http://help.retailcrm.ru + */ +namespace RetailCrm\Component\Psr7; + +use Psr\Http\Message\StreamInterface; + +/** + * Class Stream + * + * @category Stream + * @package RetailCrm\Component\Psr7 + * @author Michael Dowling + * @author RetailDriver LLC + * @license MIT + * @link http://retailcrm.ru + * @see https://help.retailcrm.ru + */ +class Stream implements StreamInterface +{ + /** + * @see http://php.net/manual/function.fopen.php + * @see http://php.net/manual/en/function.gzopen.php + */ + private const READABLE_MODES = '/r|a\+|ab\+|w\+|wb\+|x\+|xb\+|c\+|cb\+/'; + /** + * + */ + private const WRITABLE_MODES = '/a|w|r\+|rb\+|rw|x|c/'; + + /** @var resource */ + private $stream; + + /** @var int|null */ + private $size; + + /** @var bool */ + private $seekable; + + /** @var bool */ + private $readable; + + /** @var bool */ + private $writable; + + /** @var string|null */ + private $uri; + + /** @var mixed[] */ + private $customMetadata; + + /** + * This constructor accepts an associative array of options. + * + * - size: (int) If a read stream would otherwise have an indeterminate + * size, but the size is known due to foreknowledge, then you can + * provide that size, in bytes. + * - metadata: (array) Any additional metadata to return when the metadata + * of the stream is accessed. + * + * @param resource $stream Stream resource to wrap. + * @param array{size?: int, metadata?: array} $options Associative array of options. + * + * @throws \InvalidArgumentException if the stream is not a stream resource + */ + public function __construct($stream, array $options = []) + { + if (!is_resource($stream)) { + throw new \InvalidArgumentException('Stream must be a resource'); + } + + if (isset($options['size'])) { + $this->size = $options['size']; + } + + $this->customMetadata = $options['metadata'] ?? []; + $this->stream = $stream; + $meta = stream_get_meta_data($this->stream); + $this->seekable = $meta['seekable']; + $this->readable = (bool)preg_match(self::READABLE_MODES, $meta['mode']); + $this->writable = (bool)preg_match(self::WRITABLE_MODES, $meta['mode']); + $this->uri = $this->getMetadata('uri'); + } + + /** + * Closes the stream when the destructed + */ + public function __destruct() + { + $this->close(); + } + + /** + * @return string + * @throws \Throwable + */ + public function __toString(): string + { + try { + if ($this->isSeekable()) { + $this->seek(0); + } + + return $this->getContents(); + } catch (\Throwable $e) { + if (\PHP_VERSION_ID >= 70400) { + throw $e; + } + + trigger_error(sprintf('%s::__toString exception: %s', self::class, (string) $e), E_USER_ERROR); + } + } + + /** + * @return string + */ + public function getContents(): string + { + if (!isset($this->stream)) { + throw new \RuntimeException('Stream is detached'); + } + + $contents = stream_get_contents($this->stream); + + if ($contents === false) { + throw new \RuntimeException('Unable to read stream contents'); + } + + return $contents; + } + + /** + * + */ + public function close(): void + { + if (isset($this->stream)) { + if (is_resource($this->stream)) { + fclose($this->stream); + } + + $this->detach(); + } + } + + /** + * @return resource|null + */ + public function detach() + { + if (!isset($this->stream)) { + return null; + } + + $result = $this->stream; + unset($this->stream); + $this->size = $this->uri = null; + $this->readable = false; + $this->writable = false; + $this->seekable = false; + + return $result; + } + + /** + * @return int|null + */ + public function getSize(): ?int + { + if ($this->size !== null) { + return $this->size; + } + + if (!isset($this->stream)) { + return null; + } + + // Clear the stat cache if the stream has a URI + if ($this->uri) { + clearstatcache(true, $this->uri); + } + + $stats = fstat($this->stream); + if (is_array($stats) && isset($stats['size'])) { + $this->size = $stats['size']; + return $this->size; + } + + return null; + } + + /** + * @return bool + */ + public function isReadable(): bool + { + return $this->readable; + } + + /** + * @return bool + */ + public function isWritable(): bool + { + return $this->writable; + } + + /** + * @return bool + */ + public function isSeekable(): bool + { + return $this->seekable; + } + + /** + * @return bool + */ + public function eof(): bool + { + if (!isset($this->stream)) { + throw new \RuntimeException('Stream is detached'); + } + + return feof($this->stream); + } + + /** + * @return int + */ + public function tell(): int + { + if (!isset($this->stream)) { + throw new \RuntimeException('Stream is detached'); + } + + $result = ftell($this->stream); + + if ($result === false) { + throw new \RuntimeException('Unable to determine stream position'); + } + + return $result; + } + + /** + * + */ + public function rewind(): void + { + $this->seek(0); + } + + /** + * @param int $offset + * @param int $whence + */ + public function seek($offset, $whence = SEEK_SET): void + { + $whence = (int) $whence; + + if (!isset($this->stream)) { + throw new \RuntimeException('Stream is detached'); + } + + if (!$this->seekable) { + throw new \RuntimeException('Stream is not seekable'); + } + + if (fseek($this->stream, $offset, $whence) === -1) { + throw new \RuntimeException('Unable to seek to stream position ' + . $offset . ' with whence ' . var_export($whence, true)); + } + } + + /** + * @param int $length + * + * @return string + */ + public function read($length): string + { + if (!isset($this->stream)) { + throw new \RuntimeException('Stream is detached'); + } + + if (!$this->readable) { + throw new \RuntimeException('Cannot read from non-readable stream'); + } + + if ($length < 0) { + throw new \RuntimeException('Length parameter cannot be negative'); + } + + if (0 === $length) { + return ''; + } + + $string = fread($this->stream, $length); + + if (false === $string) { + throw new \RuntimeException('Unable to read from stream'); + } + + return $string; + } + + /** + * @param string $string + * + * @return int + */ + public function write($string): int + { + if (!isset($this->stream)) { + throw new \RuntimeException('Stream is detached'); + } + + if (!$this->writable) { + throw new \RuntimeException('Cannot write to a non-writable stream'); + } + + // We can't know the size after writing anything + $this->size = null; + $result = fwrite($this->stream, $string); + + if ($result === false) { + throw new \RuntimeException('Unable to write to stream'); + } + + return $result; + } + + /** + * @param null $key + * + * @return array|mixed|mixed[]|null + */ + public function getMetadata($key = null) + { + if (!isset($this->stream)) { + return $key ? null : []; + } elseif (!$key) { + return $this->customMetadata + stream_get_meta_data($this->stream); + } elseif (isset($this->customMetadata[$key])) { + return $this->customMetadata[$key]; + } + + $meta = stream_get_meta_data($this->stream); + + return $meta[$key] ?? null; + } +} diff --git a/src/Component/Psr7/Utils.php b/src/Component/Psr7/Utils.php new file mode 100644 index 0000000..d6a220c --- /dev/null +++ b/src/Component/Psr7/Utils.php @@ -0,0 +1,304 @@ + + * @license MIT + * @link http://retailcrm.ru + * @see http://help.retailcrm.ru + */ +namespace RetailCrm\Component\Psr7; + +use Psr\Http\Message\StreamInterface; + +/** + * Class Utils + * + * @category Utils + * @package RetailCrm\Component\Psr7 + * @author Michael Dowling + * @author RetailDriver LLC + * @license MIT + * @link http://retailcrm.ru + * @see https://help.retailcrm.ru + */ +class Utils +{ + /** + * Copy the contents of a stream into a string until the given number of + * bytes have been read. + * + * @param StreamInterface $stream Stream to read + * @param int $maxLen Maximum number of bytes to read. Pass -1 + * to read the entire stream. + * + * @return string + * @throws \RuntimeException on error. + */ + public static function copyToString(StreamInterface $stream, int $maxLen = -1): string + { + $buffer = ''; + + if ($maxLen === -1) { + while (!$stream->eof()) { + $buf = $stream->read(1048576); + + if ($buf === '') { + break; + } + + $buffer .= $buf; + } + return $buffer; + } + + $len = 0; + + while (!$stream->eof() && $len < $maxLen) { + $buf = $stream->read($maxLen - $len); + if ($buf === '') { + break; + } + $buffer .= $buf; + $len = strlen($buffer); + } + + return $buffer; + } + + /** + * Safely opens a PHP stream resource using a filename. + * + * When fopen fails, PHP normally raises a warning. This function adds an + * error handler that checks for errors and throws an exception instead. + * + * @param string $filename File to open + * @param string $mode Mode used to open the file + * + * @return resource + * + * @throws \RuntimeException if the file cannot be opened + */ + public static function tryFopen(string $filename, string $mode) + { + $ex = null; + + set_error_handler(static function (int $errno, string $errstr) use ($filename, $mode, &$ex): bool { + $ex = new \RuntimeException(sprintf( + 'Unable to open %s using mode %s: %s', + $filename, + $mode, + $errstr + )); + + return false; + }); + + /** @var resource $handle */ + $handle = fopen($filename, $mode); + + restore_error_handler(); + + if ($ex) { + throw $ex; + } + + return $handle; + } + + /** + * Create a new stream based on the input type. + * + * Options is an associative array that can contain the following keys: + * - metadata: Array of custom metadata. + * - size: Size of the stream. + * + * @param resource|string|int|float|bool|StreamInterface|callable|\Iterator|null $resource Entity body data + * @param array{size?: int, metadata?: array} $options Additional options + * + * @return \Psr\Http\Message\StreamInterface + * @throws \InvalidArgumentException if the $resource arg is not valid. + */ + public static function streamFor($resource = '', array $options = []): StreamInterface + { + if (is_scalar($resource)) { + $stream = self::tryFopen('php://temp', 'r+'); + + if ($resource !== '') { + fwrite($stream, (string) $resource); + fseek($stream, 0); + } + + return new Stream($stream, $options); + } + + switch (gettype($resource)) { + case 'resource': + /** @var resource $resource */ + return new Stream($resource, $options); + case 'object': + /** @var object $resource */ + if ($resource instanceof StreamInterface) { + return $resource; + } elseif ($resource instanceof \Iterator) { + return new PumpStream(function () use ($resource) { + if (!$resource->valid()) { + return false; + } + + $result = $resource->current(); + $resource->next(); + + return $result; + }, $options); + } elseif (method_exists($resource, '__toString')) { + return self::streamFor((string) $resource, $options); + } + + break; + case 'NULL': + return new Stream(self::tryFopen('php://temp', 'r+'), $options); + } + + if (is_callable($resource)) { + return new PumpStream($resource, $options); + } + + throw new \InvalidArgumentException('Invalid resource type: ' . gettype($resource)); + } + + /** + * Determines the mimetype of a file by looking at its extension. + * + * @param string $filename + * + * @return string|null + */ + public static function mimetypeFromFilename(string $filename): ?string + { + return self::mimetypeFromExtension(pathinfo($filename, PATHINFO_EXTENSION)); + } + + /** + * Maps a file extensions to a mimetype. + * + * @link http://svn.apache.org/repos/asf/httpd/httpd/branches/1.3.x/conf/mime.types + * + * @param string $extension + * + * @return string|null + */ + public static function mimetypeFromExtension(string $extension): ?string + { + static $mimetypes = [ + '3gp' => 'video/3gpp', + '7z' => 'application/x-7z-compressed', + 'aac' => 'audio/x-aac', + 'ai' => 'application/postscript', + 'aif' => 'audio/x-aiff', + 'asc' => 'text/plain', + 'asf' => 'video/x-ms-asf', + 'atom' => 'application/atom+xml', + 'avi' => 'video/x-msvideo', + 'bmp' => 'image/bmp', + 'bz2' => 'application/x-bzip2', + 'cer' => 'application/pkix-cert', + 'crl' => 'application/pkix-crl', + 'crt' => 'application/x-x509-ca-cert', + 'css' => 'text/css', + 'csv' => 'text/csv', + 'cu' => 'application/cu-seeme', + 'deb' => 'application/x-debian-package', + 'doc' => 'application/msword', + 'docx' => 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', + 'dvi' => 'application/x-dvi', + 'eot' => 'application/vnd.ms-fontobject', + 'eps' => 'application/postscript', + 'epub' => 'application/epub+zip', + 'etx' => 'text/x-setext', + 'flac' => 'audio/flac', + 'flv' => 'video/x-flv', + 'gif' => 'image/gif', + 'gz' => 'application/gzip', + 'htm' => 'text/html', + 'html' => 'text/html', + 'ico' => 'image/x-icon', + 'ics' => 'text/calendar', + 'ini' => 'text/plain', + 'iso' => 'application/x-iso9660-image', + 'jar' => 'application/java-archive', + 'jpe' => 'image/jpeg', + 'jpeg' => 'image/jpeg', + 'jpg' => 'image/jpeg', + 'js' => 'text/javascript', + 'json' => 'application/json', + 'latex' => 'application/x-latex', + 'log' => 'text/plain', + 'm4a' => 'audio/mp4', + 'm4v' => 'video/mp4', + 'mid' => 'audio/midi', + 'midi' => 'audio/midi', + 'mov' => 'video/quicktime', + 'mkv' => 'video/x-matroska', + 'mp3' => 'audio/mpeg', + 'mp4' => 'video/mp4', + 'mp4a' => 'audio/mp4', + 'mp4v' => 'video/mp4', + 'mpe' => 'video/mpeg', + 'mpeg' => 'video/mpeg', + 'mpg' => 'video/mpeg', + 'mpg4' => 'video/mp4', + 'oga' => 'audio/ogg', + 'ogg' => 'audio/ogg', + 'ogv' => 'video/ogg', + 'ogx' => 'application/ogg', + 'pbm' => 'image/x-portable-bitmap', + 'pdf' => 'application/pdf', + 'pgm' => 'image/x-portable-graymap', + 'png' => 'image/png', + 'pnm' => 'image/x-portable-anymap', + 'ppm' => 'image/x-portable-pixmap', + 'ppt' => 'application/vnd.ms-powerpoint', + 'pptx' => 'application/vnd.openxmlformats-officedocument.presentationml.presentation', + 'ps' => 'application/postscript', + 'qt' => 'video/quicktime', + 'rar' => 'application/x-rar-compressed', + 'ras' => 'image/x-cmu-raster', + 'rss' => 'application/rss+xml', + 'rtf' => 'application/rtf', + 'sgm' => 'text/sgml', + 'sgml' => 'text/sgml', + 'svg' => 'image/svg+xml', + 'swf' => 'application/x-shockwave-flash', + 'tar' => 'application/x-tar', + 'tif' => 'image/tiff', + 'tiff' => 'image/tiff', + 'torrent' => 'application/x-bittorrent', + 'ttf' => 'application/x-font-ttf', + 'txt' => 'text/plain', + 'wav' => 'audio/x-wav', + 'webm' => 'video/webm', + 'webp' => 'image/webp', + 'wma' => 'audio/x-ms-wma', + 'wmv' => 'video/x-ms-wmv', + 'woff' => 'application/x-font-woff', + 'wsdl' => 'application/wsdl+xml', + 'xbm' => 'image/x-xbitmap', + 'xls' => 'application/vnd.ms-excel', + 'xlsx' => 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + 'xml' => 'application/xml', + 'xpm' => 'image/x-xpixmap', + 'xwd' => 'image/x-xwindowdump', + 'yaml' => 'text/yaml', + 'yml' => 'text/yaml', + 'zip' => 'application/zip', + ]; + + $extension = strtolower($extension); + + return $mimetypes[$extension] ?? null; + } +} diff --git a/src/Component/ServiceLocator.php b/src/Component/ServiceLocator.php new file mode 100644 index 0000000..24ee05b --- /dev/null +++ b/src/Component/ServiceLocator.php @@ -0,0 +1,41 @@ + + * @license MIT + * @link http://retailcrm.ru + * @see http://help.retailcrm.ru + */ +namespace RetailCrm\Component; + +use RetailCrm\Factory\FileItemFactory; +use RetailCrm\Interfaces\ContainerAwareInterface; +use RetailCrm\Interfaces\FileItemFactoryInterface; +use RetailCrm\Traits\ContainerAwareTrait; + +/** + * Class ServiceLocator + * + * @category ServiceLocator + * @package RetailCrm\Component + * @author RetailDriver LLC + * @license MIT + * @link http://retailcrm.ru + * @see https://help.retailcrm.ru + */ +class ServiceLocator implements ContainerAwareInterface +{ + use ContainerAwareTrait; + + /** + * @return \RetailCrm\Interfaces\FileItemFactoryInterface + */ + public function getFileItemFactory(): FileItemFactoryInterface + { + return $this->getContainer()->get(FileItemFactory::class); + } +} diff --git a/src/Factory/ClientFactory.php b/src/Factory/ClientFactory.php index 01c5472..f3b73a6 100644 --- a/src/Factory/ClientFactory.php +++ b/src/Factory/ClientFactory.php @@ -14,6 +14,7 @@ namespace RetailCrm\Factory; use Psr\Container\ContainerInterface; use RetailCrm\Component\Constants; +use RetailCrm\Component\ServiceLocator; use RetailCrm\Interfaces\AppDataInterface; use RetailCrm\Interfaces\AuthenticatorInterface; use RetailCrm\Interfaces\ContainerAwareInterface; @@ -87,6 +88,7 @@ class ClientFactory implements ContainerAwareInterface, FactoryInterface $client->setSerializer($this->container->get(Constants::SERIALIZER)); $client->setValidator($this->container->get(Constants::VALIDATOR)); $client->setRequestFactory($this->container->get(RequestFactory::class)); + $client->setServiceLocator($this->container->get(ServiceLocator::class)); $client->validateSelf(); return $client; diff --git a/src/Factory/ContainerFactory.php b/src/Factory/ContainerFactory.php index 893bea1..d24115a 100644 --- a/src/Factory/ContainerFactory.php +++ b/src/Factory/ContainerFactory.php @@ -13,22 +13,23 @@ namespace RetailCrm\Factory; use JMS\Serializer\GraphNavigatorInterface; +use JMS\Serializer\Handler\HandlerRegistry; use JMS\Serializer\Serializer; -use RetailCrm\Component\Constants; -use RetailCrm\Service\RequestSigner; -use Shieldon\Psr17\StreamFactory; -use Shieldon\Psr17\UploadedFileFactory as BaseUploadedFileFactory; -use Shieldon\Psr17\RequestFactory as BaseRequestFactory; use JMS\Serializer\SerializerBuilder; +use JMS\Serializer\SerializerInterface; use Psr\Container\ContainerInterface; use Psr\Http\Client\ClientInterface; +use RetailCrm\Component\Constants; use RetailCrm\Component\DependencyInjection\Container; use RetailCrm\Component\Environment; +use RetailCrm\Component\ServiceLocator; use RetailCrm\Interfaces\FactoryInterface; +use RetailCrm\Service\RequestDataFilter; +use RetailCrm\Service\RequestSigner; +use Shieldon\Psr17\StreamFactory; use Symfony\Component\Validator\Validation; use Symfony\Component\Validator\Validator\TraceableValidator; use Symfony\Component\Validator\Validator\ValidatorInterface; -use JMS\Serializer\Handler\HandlerRegistry; /** * Class EnvironmentFactory @@ -109,17 +110,29 @@ class ContainerFactory implements FactoryInterface Constants::VALIDATOR, Validation::createValidatorBuilder()->enableAnnotationMapping()->getValidator() ); - $container->set(Constants::SERIALIZER, $this->getSerializer()); - $container->set(UploadedFileFactory::class, new UploadedFileFactory( - new BaseUploadedFileFactory(), - new StreamFactory()) - ); - $container->set(BaseRequestFactory::class, new BaseRequestFactory()); + $container->set(Constants::SERIALIZER, function (ContainerInterface $container) { + return SerializerFactory::withContainer($container)->create(); + }); + $container->set(FileItemFactory::class, new FileItemFactory(new StreamFactory())); + $container->set(RequestDataFilter::class, new RequestDataFilter()); $container->set(RequestSigner::class, function (ContainerInterface $container) { - return new RequestSigner($container->get(Constants::SERIALIZER)); + return new RequestSigner( + $container->get(Constants::SERIALIZER), + $container->get(RequestDataFilter::class) + ); }); $container->set(RequestFactory::class, function (ContainerInterface $container) { - return new RequestFactory($container->get(RequestSigner::class), $container->get(BaseRequestFactory::class)); + return new RequestFactory( + $container->get(RequestSigner::class), + $container->get(RequestDataFilter::class), + $container->get(Constants::SERIALIZER) + ); + }); + $container->set(ServiceLocator::class, function (ContainerInterface $container) { + $locator = new ServiceLocator(); + $locator->setContainer($container); + + return $locator; }); } @@ -134,43 +147,4 @@ class ContainerFactory implements FactoryInterface $container->set('validator', new TraceableValidator($validator)); } } - - /** - * @return \JMS\Serializer\Serializer - */ - protected function getSerializer(): Serializer - { - return SerializerBuilder::create() - ->configureHandlers(function(HandlerRegistry $registry) { - $returnNull = function($visitor, $obj, array $type) { - return null; - }; - - $registry->registerHandler( - GraphNavigatorInterface::DIRECTION_SERIALIZATION, - 'UploadedFileInterface', - 'json', - $returnNull - ); - $registry->registerHandler( - GraphNavigatorInterface::DIRECTION_DESERIALIZATION, - 'UploadedFileInterface', - 'json', - $returnNull - ); - $registry->registerHandler( - GraphNavigatorInterface::DIRECTION_SERIALIZATION, - 'UploadedFileInterface', - 'xml', - $returnNull - ); - $registry->registerHandler( - GraphNavigatorInterface::DIRECTION_DESERIALIZATION, - 'UploadedFileInterface', - 'xml', - $returnNull - ); - })->addDefaultHandlers() - ->build(); - } } diff --git a/src/Factory/FileItemFactory.php b/src/Factory/FileItemFactory.php new file mode 100644 index 0000000..41fa959 --- /dev/null +++ b/src/Factory/FileItemFactory.php @@ -0,0 +1,65 @@ + + * @license MIT + * @link http://retailcrm.ru + * @see http://help.retailcrm.ru + */ +namespace RetailCrm\Factory; + +use Psr\Http\Message\StreamFactoryInterface; +use RetailCrm\Interfaces\FileItemFactoryInterface; +use RetailCrm\Interfaces\FileItemInterface; +use RetailCrm\Model\FileItem; + +/** + * Class FileItemFactory + * + * @category FileItemFactory + * @package RetailCrm\Factory + * @author RetailDriver LLC + * @license MIT + * @link http://retailcrm.ru + * @see https://help.retailcrm.ru + */ +class FileItemFactory implements FileItemFactoryInterface +{ + /** @var StreamFactoryInterface $streamFactory */ + private $streamFactory; + + /** + * FileItemFactory constructor. + * + * @param \Psr\Http\Message\StreamFactoryInterface $streamFactory + */ + public function __construct(StreamFactoryInterface $streamFactory) + { + $this->streamFactory = $streamFactory; + } + + /** + * @param string $fileName Name without path + * @param string $contents + * + * @return FileItemInterface + */ + public function fromString(string $fileName, string $contents): FileItemInterface + { + return new FileItem($fileName, $this->streamFactory->createStream($contents)); + } + + /** + * @param string $fileName Name with or without path + * + * @return FileItemInterface + */ + public function fromFile(string $fileName): FileItemInterface + { + return new FileItem(basename($fileName), $this->streamFactory->createStreamFromFile($fileName)); + } +} diff --git a/src/Factory/RequestFactory.php b/src/Factory/RequestFactory.php index 101c32d..9da562e 100644 --- a/src/Factory/RequestFactory.php +++ b/src/Factory/RequestFactory.php @@ -12,11 +12,19 @@ */ namespace RetailCrm\Factory; -use Psr\Http\Message\RequestFactoryInterface as BaseFactoryInterface; +use JMS\Serializer\SerializerInterface; +use Psr\Http\Message\RequestInterface; +use RetailCrm\Component\Exception\FactoryException; +use RetailCrm\Component\Psr7\MultipartStream; use RetailCrm\Interfaces\AppDataInterface; +use RetailCrm\Interfaces\AuthenticatorInterface; +use RetailCrm\Interfaces\FileItemInterface; use RetailCrm\Interfaces\RequestFactoryInterface; use RetailCrm\Interfaces\RequestSignerInterface; use RetailCrm\Model\Request\BaseRequest; +use RetailCrm\Service\RequestDataFilter; +use Shieldon\Psr7\Request; +use Shieldon\Psr7\Uri; /** * Class RequestFactory @@ -36,29 +44,120 @@ class RequestFactory implements RequestFactoryInterface private $signer; /** - * @var BaseFactoryInterface $requestFactory + * @var RequestDataFilter $filter */ - private $requestFactory; + private $filter; + + /** + * @var SerializerInterface|\JMS\Serializer\Serializer $serializer + */ + private $serializer; /** * RequestFactory constructor. * * @param \RetailCrm\Interfaces\RequestSignerInterface $signer - * @param \Psr\Http\Message\RequestFactoryInterface $requestFactory + * @param \RetailCrm\Service\RequestDataFilter $filter + * @param \JMS\Serializer\SerializerInterface $serializer */ - public function __construct(RequestSignerInterface $signer, BaseFactoryInterface $requestFactory) - { + public function __construct( + RequestSignerInterface $signer, + RequestDataFilter $filter, + SerializerInterface $serializer + ) { $this->signer = $signer; - $this->requestFactory = $requestFactory; + $this->filter = $filter; + $this->serializer = $serializer; } /** - * @param \RetailCrm\Model\Request\BaseRequest $request - * @param \RetailCrm\Interfaces\AppDataInterface $appData + * @param string $endpoint + * @param \RetailCrm\Model\Request\BaseRequest $request + * @param \RetailCrm\Interfaces\AppDataInterface $appData + * @param \RetailCrm\Interfaces\AuthenticatorInterface $authenticator + * + * @return \Psr\Http\Message\RequestInterface + * @throws \RetailCrm\Component\Exception\FactoryException */ - public function fromModel(BaseRequest $request, AppDataInterface $appData) - { + public function fromModel( + string $endpoint, + BaseRequest $request, + AppDataInterface $appData, + AuthenticatorInterface $authenticator + ): RequestInterface { + $authenticator->authenticate($request); $this->signer->sign($request, $appData); - // TODO: Implement this + $requestData = $this->serializer->toArray($request); + $requestHasBinaryData = $this->filter->hasBinaryFromRequestData($requestData); + + if (empty($requestData)) { + throw new FactoryException('Empty request data'); + } + + if ($requestHasBinaryData) { + return $this->makeMultipartRequest($endpoint, $requestData); + } + + return new Request( + 'GET', + new Uri($endpoint . '?' . http_build_query($requestData)), + '', + self::defaultHeaders() + ); + } + + /** + * @param string $endpoint + * @param array $contents + * + * @return \Psr\Http\Message\RequestInterface + */ + private function makeMultipartRequest(string $endpoint, array $contents): RequestInterface + { + $prepared = []; + + foreach ($contents as $param => $value) { + if ($value instanceof FileItemInterface) { + $prepared[] = [ + 'name' => $param, + 'contents' => $value->getStream(), + 'filename' => $value->getFileName() + ]; + } else { + $prepared[] = [ + 'name' => $param, + 'contents' => $value + ]; + } + } + + return new Request( + 'POST', + new Uri($endpoint), + new MultipartStream($prepared), + self::defaultHeaders() + ); + } + + private static function defaultHeaders(): array + { + return [ + 'HTTP_ACCEPT' => 'application/json,application/xml;q=0.9', + 'HTTP_ACCEPT_CHARSET' => 'utf-8;q=0.7,*;q=0.3', + 'HTTP_ACCEPT_LANGUAGE' => 'en-US,en;q=0.9,zh-TW;q=0.8,zh;q=0.7', + 'HTTP_USER_AGENT' => 'Mozilla/5.0 (compatible; TopSdk; +http://retailcrm.pro)', + 'HTTP_HOST' => '127.0.0.1', + 'QUERY_STRING' => '', + 'REMOTE_ADDR' => '127.0.0.1', + 'REQUEST_METHOD' => 'GET', + 'REQUEST_SCHEME' => 'http', + 'REQUEST_TIME' => time(), + 'REQUEST_TIME_FLOAT' => microtime(true), + 'REQUEST_URI' => '', + 'SCRIPT_NAME' => '', + 'SERVER_NAME' => 'localhost', + 'SERVER_PORT' => 80, + 'SERVER_PROTOCOL' => 'HTTP/1.1', + ]; } } diff --git a/src/Factory/SerializationContextFactory.php b/src/Factory/SerializationContextFactory.php new file mode 100644 index 0000000..cdd8d4e --- /dev/null +++ b/src/Factory/SerializationContextFactory.php @@ -0,0 +1,37 @@ + + * @license MIT + * @link http://retailcrm.ru + * @see http://help.retailcrm.ru + */ +namespace RetailCrm\Factory; + +use JMS\Serializer\ContextFactory\SerializationContextFactoryInterface; +use JMS\Serializer\SerializationContext; + +/** + * Class SerializationContextFactory + * + * @category SerializationContextFactory + * @package RetailCrm\Factory + * @author RetailDriver LLC + * @license MIT + * @link http://retailcrm.ru + * @see https://help.retailcrm.ru + */ +class SerializationContextFactory implements SerializationContextFactoryInterface +{ + /** + * @return \JMS\Serializer\SerializationContext + */ + public function createSerializationContext(): SerializationContext + { + return SerializationContext::create()->setSerializeNull(false); + } +} diff --git a/src/Factory/SerializerFactory.php b/src/Factory/SerializerFactory.php new file mode 100644 index 0000000..f757f41 --- /dev/null +++ b/src/Factory/SerializerFactory.php @@ -0,0 +1,139 @@ + + * @license MIT + * @link http://retailcrm.ru + * @see http://help.retailcrm.ru + */ +namespace RetailCrm\Factory; + +use JMS\Serializer\GraphNavigatorInterface; +use JMS\Serializer\Handler\HandlerRegistry; +use JMS\Serializer\Serializer; +use JMS\Serializer\SerializerBuilder; +use JMS\Serializer\SerializerInterface; +use Psr\Container\ContainerInterface; +use RetailCrm\Component\Constants; +use RetailCrm\Interfaces\FactoryInterface; + +/** + * Class SerializerFactory + * + * @category SerializerFactory + * @package RetailCrm\Factory + * @author RetailDriver LLC + * @license MIT + * @link http://retailcrm.ru + * @see https://help.retailcrm.ru + */ +class SerializerFactory implements FactoryInterface +{ + /** + * @var \Psr\Container\ContainerInterface $container + */ + private $container; + + /** + * SerializerFactory constructor. + * + * @param \Psr\Container\ContainerInterface $container + */ + public function __construct(ContainerInterface $container) + { + $this->container = $container; + } + + /** + * @param \Psr\Container\ContainerInterface $container + * + * @return \RetailCrm\Factory\SerializerFactory + */ + public static function withContainer(ContainerInterface $container): FactoryInterface + { + return new self($container); + } + + /** + * @return \JMS\Serializer\Serializer + */ + public function create(): Serializer + { + $container = $this->container; + + return SerializerBuilder::create() + ->configureHandlers(function(HandlerRegistry $registry) use ($container) { + $returnNull = function($visitor, $obj, array $type) { + return null; + }; + $returnSame = function($visitor, $obj, array $type) { + return $obj; + }; + + $registry->registerHandler( + GraphNavigatorInterface::DIRECTION_SERIALIZATION, + 'RequestDtoInterface', + 'json', + function($visitor, $obj, array $type) use ($container) { + /** @var SerializerInterface $serializer */ + $serializer = $container->get(Constants::SERIALIZER); + + return $serializer->serialize($obj, 'json'); + } + ); + $registry->registerHandler( + GraphNavigatorInterface::DIRECTION_DESERIALIZATION, + 'RequestDtoInterface', + 'json', + $returnNull + ); + $registry->registerHandler( + GraphNavigatorInterface::DIRECTION_SERIALIZATION, + 'RequestDtoInterface', + 'xml', + function($visitor, $obj, array $type) use ($container) { + /** @var SerializerInterface $serializer */ + $serializer = $container->get(Constants::SERIALIZER); + + return $serializer->serialize($obj, 'xml'); + } + ); + $registry->registerHandler( + GraphNavigatorInterface::DIRECTION_DESERIALIZATION, + 'RequestDtoInterface', + 'xml', + $returnNull + ); + $registry->registerHandler( + GraphNavigatorInterface::DIRECTION_SERIALIZATION, + 'FileItemInterface', + 'json', + $returnSame + ); + $registry->registerHandler( + GraphNavigatorInterface::DIRECTION_DESERIALIZATION, + 'FileItemInterface', + 'json', + $returnNull + ); + $registry->registerHandler( + GraphNavigatorInterface::DIRECTION_SERIALIZATION, + 'FileItemInterface', + 'xml', + $returnSame + ); + $registry->registerHandler( + GraphNavigatorInterface::DIRECTION_DESERIALIZATION, + 'FileItemInterface', + 'xml', + $returnNull + ); + })->addDefaultHandlers() + ->setSerializationContextFactory(new SerializationContextFactory()) + ->build(); + } +} diff --git a/src/Factory/UploadedFileFactory.php b/src/Factory/UploadedFileFactory.php deleted file mode 100644 index e529aca..0000000 --- a/src/Factory/UploadedFileFactory.php +++ /dev/null @@ -1,59 +0,0 @@ - - * @license MIT - * @link http://retailcrm.ru - * @see http://help.retailcrm.ru - */ -namespace RetailCrm\Factory; - -use Psr\Http\Message\StreamFactoryInterface; -use Psr\Http\Message\UploadedFileFactoryInterface as BaseUploadedFileFactoryInterface; -use Psr\Http\Message\UploadedFileInterface; -use RetailCrm\Interfaces\UploadedFileFactoryInterface; - -/** - * Class UploadedFileFactory - * - * @category UploadedFileFactory - * @package RetailCrm\Factory - * @author RetailDriver LLC - * @license MIT - * @link http://retailcrm.ru - * @see https://help.retailcrm.ru - */ -class UploadedFileFactory implements UploadedFileFactoryInterface -{ - /** @var BaseUploadedFileFactoryInterface $baseFactory */ - private $baseFactory; - - /** @var StreamFactoryInterface $streamFactory */ - private $streamFactory; - - /** - * UploadedFileFactory constructor. - * - * @param \Psr\Http\Message\UploadedFileFactoryInterface $baseFactory - * @param \Psr\Http\Message\StreamFactoryInterface $streamFactory - */ - public function __construct(BaseUploadedFileFactoryInterface $baseFactory, StreamFactoryInterface $streamFactory) - { - $this->baseFactory = $baseFactory; - $this->streamFactory = $streamFactory; - } - - /** - * @param string $fileName - * - * @return \Psr\Http\Message\UploadedFileInterface - */ - public function create(string $fileName): UploadedFileInterface - { - return $this->baseFactory->createUploadedFile($this->streamFactory->createStreamFromFile($fileName)); - } -} diff --git a/src/Interfaces/FileItemFactoryInterface.php b/src/Interfaces/FileItemFactoryInterface.php new file mode 100644 index 0000000..7495953 --- /dev/null +++ b/src/Interfaces/FileItemFactoryInterface.php @@ -0,0 +1,42 @@ + + * @license MIT + * @link http://retailcrm.ru + * @see http://help.retailcrm.ru + */ + +namespace RetailCrm\Interfaces; + +/** + * Interface FileItemFactoryInterface + * + * @category FileItemFactoryInterface + * @package RetailCrm\Interfaces + * @author RetailDriver LLC + * @license MIT + * @link http://retailcrm.ru + * @see https://help.retailcrm.ru + */ +interface FileItemFactoryInterface +{ + /** + * @param string $fileName Name without path + * @param string $contents + * + * @return FileItemInterface + */ + public function fromString(string $fileName, string $contents): FileItemInterface; + + /** + * @param string $fileName Name with or without path + * + * @return FileItemInterface + */ + public function fromFile(string $fileName): FileItemInterface; +} diff --git a/src/Interfaces/UploadedFileFactoryInterface.php b/src/Interfaces/FileItemInterface.php similarity index 55% rename from src/Interfaces/UploadedFileFactoryInterface.php rename to src/Interfaces/FileItemInterface.php index ff77cca..1e24273 100644 --- a/src/Interfaces/UploadedFileFactoryInterface.php +++ b/src/Interfaces/FileItemInterface.php @@ -3,7 +3,7 @@ /** * PHP version 7.3 * - * @category UploadedFileFactoryInterface + * @category FileItemInterface * @package RetailCrm\Interfaces * @author RetailCRM * @license MIT @@ -13,24 +13,27 @@ namespace RetailCrm\Interfaces; -use Psr\Http\Message\UploadedFileInterface; +use Psr\Http\Message\StreamInterface; /** - * Interface UploadedFileFactoryInterface + * Interface FileItemInterface * - * @category UploadedFileFactoryInterface + * @category FileItemInterface * @package RetailCrm\Interfaces * @author RetailDriver LLC * @license MIT * @link http://retailcrm.ru * @see https://help.retailcrm.ru */ -interface UploadedFileFactoryInterface +interface FileItemInterface { /** - * @param string $fileName - * - * @return \Psr\Http\Message\UploadedFileInterface + * @return string */ - public function create(string $fileName): UploadedFileInterface; + public function getFileName(): string; + + /** + * @return \Psr\Http\Message\StreamInterface + */ + public function getStream(): StreamInterface; } diff --git a/src/Interfaces/RequestDtoInterface.php b/src/Interfaces/RequestDtoInterface.php new file mode 100644 index 0000000..0808548 --- /dev/null +++ b/src/Interfaces/RequestDtoInterface.php @@ -0,0 +1,28 @@ + + * @license MIT + * @link http://retailcrm.ru + * @see http://help.retailcrm.ru + */ + +namespace RetailCrm\Interfaces; + +/** + * Interface RequestDtoInterface + * + * @category RequestDtoInterface + * @package RetailCrm\Interfaces + * @author RetailDriver LLC + * @license MIT + * @link http://retailcrm.ru + * @see https://help.retailcrm.ru + */ +interface RequestDtoInterface +{ +} diff --git a/src/Interfaces/RequestFactoryInterface.php b/src/Interfaces/RequestFactoryInterface.php index 407957d..b5fffc5 100644 --- a/src/Interfaces/RequestFactoryInterface.php +++ b/src/Interfaces/RequestFactoryInterface.php @@ -13,6 +13,7 @@ namespace RetailCrm\Interfaces; +use Psr\Http\Message\RequestInterface; use RetailCrm\Model\Request\BaseRequest; /** @@ -28,10 +29,17 @@ use RetailCrm\Model\Request\BaseRequest; interface RequestFactoryInterface { /** - * @param \RetailCrm\Model\Request\BaseRequest $request - * @param \RetailCrm\Interfaces\AppDataInterface $appData + * @param string $endpoint + * @param \RetailCrm\Model\Request\BaseRequest $request + * @param \RetailCrm\Interfaces\AppDataInterface $appData + * @param \RetailCrm\Interfaces\AuthenticatorInterface $authenticator * - * @return mixed + * @return RequestInterface */ - public function fromModel(BaseRequest $request, AppDataInterface $appData); + public function fromModel( + string $endpoint, + BaseRequest $request, + AppDataInterface $appData, + AuthenticatorInterface $authenticator + ): RequestInterface; } diff --git a/src/Model/FileItem.php b/src/Model/FileItem.php new file mode 100644 index 0000000..aa8f271 --- /dev/null +++ b/src/Model/FileItem.php @@ -0,0 +1,63 @@ + + * @license MIT + * @link http://retailcrm.ru + * @see http://help.retailcrm.ru + */ +namespace RetailCrm\Model; + +use Psr\Http\Message\StreamInterface; +use RetailCrm\Interfaces\FileItemInterface; + +/** + * Class FileItem + * + * @category FileItem + * @package RetailCrm\Model + * @author RetailDriver LLC + * @license MIT + * @link http://retailcrm.ru + * @see https://help.retailcrm.ru + */ +class FileItem implements FileItemInterface +{ + /** @var string $fileName */ + private $fileName; + + /** @var StreamInterface */ + private $stream; + + /** + * FileItem constructor. + * + * @param string $fileName + * @param \Psr\Http\Message\StreamInterface $stream + */ + public function __construct(string $fileName, StreamInterface $stream) + { + $this->fileName = $fileName; + $this->stream = $stream; + } + + /** + * @return string + */ + public function getFileName(): string + { + return $this->fileName; + } + + /** + * @return \Psr\Http\Message\StreamInterface + */ + public function getStream(): StreamInterface + { + return $this->stream; + } +} diff --git a/src/Service/RequestDataFilter.php b/src/Service/RequestDataFilter.php new file mode 100644 index 0000000..241343d --- /dev/null +++ b/src/Service/RequestDataFilter.php @@ -0,0 +1,59 @@ + + * @license MIT + * @link http://retailcrm.ru + * @see http://help.retailcrm.ru + */ +namespace RetailCrm\Service; + +use RetailCrm\Interfaces\FileItemInterface; + +/** + * Class RequestDataFilter + * + * @category RequestDataFilter + * @package RetailCrm\Service + * @author RetailDriver LLC + * @license MIT + * @link http://retailcrm.ru + * @see https://help.retailcrm.ru + */ +class RequestDataFilter +{ + /** + * @param array $params + * + * @return array + */ + public function filterBinaryFromRequestData(array $params): array + { + return array_filter(array_filter( + $params, + static function ($item) { + return !($item instanceof FileItemInterface); + } + )); + } + + /** + * @param array $params + * + * @return bool + */ + public function hasBinaryFromRequestData(array $params): bool + { + foreach ($params as $item) { + if ($item instanceof FileItemInterface) { + return true; + } + } + + return false; + } +} diff --git a/src/Service/RequestSigner.php b/src/Service/RequestSigner.php index 36c4deb..ffbb579 100644 --- a/src/Service/RequestSigner.php +++ b/src/Service/RequestSigner.php @@ -34,15 +34,22 @@ class RequestSigner implements RequestSignerInterface /** * @var SerializerInterface|\JMS\Serializer\Serializer $serializer */ - public $serializer; + private $serializer; + + /** + * @var RequestDataFilter $filter + */ + private $filter; /** * RequestSigner constructor. * - * @param \JMS\Serializer\SerializerInterface $serializer + * @param \JMS\Serializer\SerializerInterface $serializer + * @param \RetailCrm\Service\RequestDataFilter $filter */ - public function __construct(SerializerInterface $serializer) + public function __construct(SerializerInterface $serializer, RequestDataFilter $filter) { + $this->filter = $filter; $this->serializer = $serializer; } @@ -55,7 +62,7 @@ class RequestSigner implements RequestSignerInterface public function sign(BaseRequest $request, AppDataInterface $appData): void { $stringToBeSigned = ''; - $params = $this->getRequestData($request); + $params = $this->getDataForSigning($request); foreach ($params as $param => $value) { $stringToBeSigned .= $param . $value; @@ -81,9 +88,9 @@ class RequestSigner implements RequestSignerInterface * * @return array */ - private function getRequestData(BaseRequest $request): array + private function getDataForSigning(BaseRequest $request): array { - $params = array_filter($this->serializer->toArray($request)); + $params = $this->filter->filterBinaryFromRequestData($this->serializer->toArray($request)); unset($params['sign']); ksort($params); diff --git a/src/TopClient/Client.php b/src/TopClient/Client.php index 2fa8ea6..d29900d 100644 --- a/src/TopClient/Client.php +++ b/src/TopClient/Client.php @@ -14,6 +14,7 @@ namespace RetailCrm\TopClient; use JMS\Serializer\SerializerInterface; use Psr\Http\Client\ClientInterface; +use RetailCrm\Component\ServiceLocator; use RetailCrm\Interfaces\AppDataInterface; use RetailCrm\Interfaces\AuthenticatorInterface; use RetailCrm\Interfaces\RequestFactoryInterface; @@ -63,6 +64,11 @@ class Client */ protected $serializer; + /** + * @var \RetailCrm\Component\ServiceLocator $serviceLocator + */ + protected $serviceLocator; + /** * Client constructor. * @@ -106,4 +112,20 @@ class Client { $this->requestFactory = $requestFactory; } + + /** + * @param \RetailCrm\Component\ServiceLocator $serviceLocator + */ + public function setServiceLocator(ServiceLocator $serviceLocator): void + { + $this->serviceLocator = $serviceLocator; + } + + /** + * @return \RetailCrm\Component\ServiceLocator + */ + public function getServiceLocator(): ServiceLocator + { + return $this->serviceLocator; + } } diff --git a/tests/RetailCrm/Test/TestCase.php b/tests/RetailCrm/Test/TestCase.php index 7a89a99..4fd38ff 100644 --- a/tests/RetailCrm/Test/TestCase.php +++ b/tests/RetailCrm/Test/TestCase.php @@ -2,9 +2,15 @@ namespace RetailCrm\Test; +use DateTime; use Psr\Container\ContainerInterface; +use RetailCrm\Component\AppData; +use RetailCrm\Component\Authenticator\TokenAuthenticator; use RetailCrm\Component\Environment; use RetailCrm\Factory\ContainerFactory; +use RetailCrm\Factory\FileItemFactory; +use RetailCrm\Interfaces\AppDataInterface; +use RetailCrm\Interfaces\AuthenticatorInterface; /** * Class TestCase @@ -20,7 +26,12 @@ class TestCase extends \PHPUnit\Framework\TestCase { private $container; - public function getContainer($recreate = false): ContainerInterface + /** + * @param bool $recreate + * + * @return \Psr\Container\ContainerInterface + */ + protected function getContainer($recreate = false): ContainerInterface { if (null === $this->container || $recreate) { $this->container = ContainerFactory::withEnv(Environment::TEST) @@ -30,4 +41,55 @@ class TestCase extends \PHPUnit\Framework\TestCase return $this->container; } + + /** + * @return \RetailCrm\Interfaces\AppDataInterface + */ + protected function getAppData(): AppDataInterface + { + return AppData::create(AppData::OVERSEAS_ENDPOINT, 'appKey', 'helloworld'); + } + + /** + * @param string $appKey + * @param string $token + * + * @return \RetailCrm\Interfaces\AuthenticatorInterface + */ + protected function getAuthenticator(string $appKey = 'appKey', string $token = 'token'): AuthenticatorInterface + { + return new TokenAuthenticator($appKey, $token); + } + + /** + * @param string $signMethod + * + * @param bool $withFile + * + * @return \RetailCrm\Test\TestSignerRequest + */ + protected function getTestRequest(string $signMethod, bool $withFile = false): TestSignerRequest + { + $request = new TestSignerRequest(); + $request->method = 'aliexpress.solution.order.fulfill'; + $request->appKey = '12345678'; + $request->session = 'test'; + $request->timestamp = DateTime::createFromFormat('Y-m-d H:i:s', '2016-01-01 12:00:00'); + $request->signMethod = $signMethod; + $request->serviceName = 'SPAIN_LOCAL_CORREOS'; + $request->outRef = '1000006270175804'; + $request->sendType = 'all'; + $request->logisticsNo = 'ES2019COM0000123456'; + + if ($withFile) { + /** @var FileItemFactory $factory */ + $factory = $this->getContainer()->get(FileItemFactory::class); + $request->document = $factory->fromString( + 'file.txt', + 'The quick brown fox jumps over the lazy dog' + ); + } + + return $request; + } } diff --git a/tests/RetailCrm/Test/TestSignerRequest.php b/tests/RetailCrm/Test/TestSignerRequest.php index 90711cd..ae085b5 100644 --- a/tests/RetailCrm/Test/TestSignerRequest.php +++ b/tests/RetailCrm/Test/TestSignerRequest.php @@ -13,6 +13,8 @@ namespace RetailCrm\Test; use JMS\Serializer\Annotation as JMS; +use Psr\Http\Message\UploadedFileInterface; +use RetailCrm\Interfaces\FileItemInterface; use RetailCrm\Model\Request\BaseRequest; use Symfony\Component\Validator\Constraints as Assert; @@ -63,4 +65,12 @@ class TestSignerRequest extends BaseRequest * @Assert\NotBlank() */ public $logisticsNo; + + /** + * @var FileItemInterface $document + * + * @JMS\Type("FileItemInterface") + * @JMS\SerializedName("document") + */ + public $document; } diff --git a/tests/RetailCrm/Tests/Component/ClientFactoryTest.php b/tests/RetailCrm/Tests/Component/ClientFactoryTest.php index bc61203..8a2f7c5 100644 --- a/tests/RetailCrm/Tests/Component/ClientFactoryTest.php +++ b/tests/RetailCrm/Tests/Component/ClientFactoryTest.php @@ -14,6 +14,7 @@ namespace RetailCrm\Tests\Component; use RetailCrm\Component\AppData; use RetailCrm\Component\Authenticator\TokenAuthenticator; +use RetailCrm\Component\ServiceLocator; use RetailCrm\Factory\ClientFactory; use RetailCrm\Test\TestCase; use RetailCrm\TopClient\Client; @@ -38,5 +39,6 @@ class ClientFactoryTest extends TestCase ->create(); self::assertInstanceOf(Client::class, $client); + self::assertInstanceOf(ServiceLocator::class, $client->getServiceLocator()); } } diff --git a/tests/RetailCrm/Tests/Factory/RequestFactoryTest.php b/tests/RetailCrm/Tests/Factory/RequestFactoryTest.php new file mode 100644 index 0000000..7729222 --- /dev/null +++ b/tests/RetailCrm/Tests/Factory/RequestFactoryTest.php @@ -0,0 +1,67 @@ + + * @license MIT + * @link http://retailcrm.ru + * @see http://help.retailcrm.ru + */ +namespace RetailCrm\Tests\Factory; + +use Psr\Http\Message\RequestInterface; +use RetailCrm\Component\Constants; +use RetailCrm\Factory\RequestFactory; +use RetailCrm\Component\AppData; +use RetailCrm\Test\TestCase; + +/** + * Class RequestFactoryTest + * + * @category RequestFactoryTest + * @package RetailCrm\Tests\Factory + * @author RetailDriver LLC + * @license MIT + * @link http://retailcrm.ru + * @see https://help.retailcrm.ru + */ +class RequestFactoryTest extends TestCase +{ + public function testFromModelGet(): void + { + /** @var RequestFactory $factory */ + $factory = $this->getContainer()->get(RequestFactory::class); + $request = $factory->fromModel( + AppData::OVERSEAS_ENDPOINT, + $this->getTestRequest(Constants::SIGN_TYPE_HMAC), + $this->getAppData(), + $this->getAuthenticator() + ); + $uri = $request->getUri(); + $contents = $request->getBody()->getContents(); + + self::assertEmpty($contents); + self::assertNotEmpty($uri->getQuery()); + self::assertNotFalse(stripos($uri->getQuery(), 'SPAIN_LOCAL_CORREOS')); + } + + public function testFromModelPost(): void + { + /** @var RequestFactory $factory */ + $factory = $this->getContainer()->get(RequestFactory::class); + $request = $factory->fromModel( + AppData::OVERSEAS_ENDPOINT, + $this->getTestRequest(Constants::SIGN_TYPE_HMAC, true), + $this->getAppData(), + $this->getAuthenticator() + ); + $uri = $request->getUri(); + $contents = $request->getBody()->getContents(); + + self::assertEmpty($uri->getQuery()); + self::assertNotFalse(stripos($contents, 'The quick brown fox jumps over the lazy dog')); + } +} diff --git a/tests/RetailCrm/Tests/Service/RequestSignerTest.php b/tests/RetailCrm/Tests/Service/RequestSignerTest.php index 75cd3f9..c9560c1 100644 --- a/tests/RetailCrm/Tests/Service/RequestSignerTest.php +++ b/tests/RetailCrm/Tests/Service/RequestSignerTest.php @@ -12,9 +12,7 @@ */ namespace RetailCrm\Tests\Service; -use DateTime; use RetailCrm\Component\AppData; -use RetailCrm\Component\Authenticator\TokenAuthenticator; use RetailCrm\Component\Constants; use RetailCrm\Interfaces\AppDataInterface; use RetailCrm\Interfaces\RequestSignerInterface; @@ -52,40 +50,29 @@ class RequestSignerTest extends TestCase public function signDataProvider(): array { - $appData = AppData::create(AppData::OVERSEAS_ENDPOINT, 'appKey', 'helloworld'); + $appData = $this->getAppData(); return [ [ - $this->getRequestWithSignMethod(Constants::SIGN_TYPE_MD5), + $this->getTestRequest(Constants::SIGN_TYPE_MD5), $appData, '4BC79C5FAA1B5E254E95A97E65BACEAB' ], [ - $this->getRequestWithSignMethod(Constants::SIGN_TYPE_HMAC), + $this->getTestRequest(Constants::SIGN_TYPE_HMAC), + $appData, + '497FA7FCAD98F4F335EFAE2451F8291D' + ], + [ + $this->getTestRequest(Constants::SIGN_TYPE_MD5, true), + $appData, + '4BC79C5FAA1B5E254E95A97E65BACEAB' + ], + [ + $this->getTestRequest(Constants::SIGN_TYPE_HMAC, true), $appData, '497FA7FCAD98F4F335EFAE2451F8291D' ] ]; } - - /** - * @param string $signMethod - * - * @return \RetailCrm\Test\TestSignerRequest - */ - private function getRequestWithSignMethod(string $signMethod): TestSignerRequest - { - $request = new TestSignerRequest(); - $request->method = 'aliexpress.solution.order.fulfill'; - $request->appKey = '12345678'; - $request->session = 'test'; - $request->timestamp = DateTime::createFromFormat('Y-m-d H:i:s', '2016-01-01 12:00:00'); - $request->signMethod = $signMethod; - $request->serviceName = 'SPAIN_LOCAL_CORREOS'; - $request->outRef = '1000006270175804'; - $request->sendType = 'all'; - $request->logisticsNo = 'ES2019COM0000123456'; - - return $request; - } }