From 33058da77afcc76e667534125dff5dd33afc1655 Mon Sep 17 00:00:00 2001 From: Tim Otten Date: Wed, 15 Dec 2021 14:21:48 -0800 Subject: [PATCH] (REF) Civi::pipe - Extract `BasicPipeClient` from `CliRunnerTest`. Make it is easier to use in other tests. --- Civi/Pipe/BasicPipeClient.php | 166 +++++++++++++++++++++ Civi/Pipe/JsonRpcMethodException.php | 27 ++++ tests/phpunit/E2E/Extern/CliRunnerTest.php | 24 +-- 3 files changed, 199 insertions(+), 18 deletions(-) create mode 100644 Civi/Pipe/BasicPipeClient.php create mode 100644 Civi/Pipe/JsonRpcMethodException.php diff --git a/Civi/Pipe/BasicPipeClient.php b/Civi/Pipe/BasicPipeClient.php new file mode 100644 index 0000000000..8adbc99c12 --- /dev/null +++ b/Civi/Pipe/BasicPipeClient.php @@ -0,0 +1,166 @@ +call('login', ['contactId' => 202]); + * $contacts = $rpc->call('api4', ['Contact', 'get']); + * @endCode + * + * Failed method-calls will emit `JsonRpcMethodException`. + * Errors in protocol handling will emit `RuntimeExcpetion`. + */ +class BasicPipeClient { + + /** + * Maximum length of a requst + * + * @var int + */ + private $bufferSize; + + /** + * @var array + */ + private $pipes; + + /** + * @var resource|false|null + */ + private $process; + + /** + * @var array|null + */ + private $welcome; + + /** + * @param string|null $command + * The shell command to start the pipe. If given, auto-connect. + * If omitted, then you can call connect($command) later. + * Ex: `cv ev 'Civi::pipe();'`, `cv ev 'Civi::pipe("u");'`, `drush ev 'civicrm_initialize(); Civi::pipe("vt");'` + * @param int $bufferSize + */ + public function __construct(?string $command = NULL, int $bufferSize = 32767) { + $this->bufferSize = $bufferSize; + if ($command) { + $this->connect($command); + } + } + + public function __destruct() { + if ($this->process) { + $this->close(); + } + } + + /** + * Start a worker process. + * + * @param string $command + * The shell command to start the pipe. + * Ex: `cv ev 'Civi::pipe();'`, `cv ev 'Civi::pipe("u");'`, `drush ev 'civicrm_initialize(); Civi::pipe("vt");'` + * @return array + * Returns the header/welcome message for the connection. + */ + public function connect(string $command): array { + if ($this->process) { + throw new \RuntimeException("Client error: Already connected"); + } + + $desc = [['pipe', 'r'], ['pipe', 'w'], ['pipe', 'a']]; + $this->process = proc_open($command, $desc, $this->pipes); + if (!$this->process) { + throw new \RuntimeException("Client error: Failed to open process: $command"); + } + $line = stream_get_line($this->pipes[1], $this->bufferSize, "\n"); + $this->welcome = json_decode($line, TRUE); + if ($this->welcome === NULL || !isset($this->welcome['Civi::pipe'])) { + throw new \RuntimeException("Protocol error: Received malformed welcome"); + } + return $this->welcome['Civi::pipe']; + } + + public function close(): void { + proc_close($this->process); + $this->pipes = NULL; + $this->process = NULL; + } + + /** + * Call a method and return the result. + * + * @param string $method + * @param array $params + * @param string|int|null $id + * @return array{result: array, error: array, jsonrpc: string, id: string|int|null} + * The JSON-RPC response recrd. Contains `result` or `error`. + */ + public function call(string $method, array $params, $id = NULL): array { + if (!$this->process) { + throw new \RuntimeException("Client error: Connection was not been opened yet."); + } + + $requestLine = json_encode(['jsonrpc' => '2.0', 'method' => $method, 'params' => $params, 'id' => $id]); + fwrite($this->pipes[0], $requestLine . "\n"); + $responseLine = stream_get_line($this->pipes[1], $this->bufferSize, "\n"); + $decode = json_decode($responseLine, TRUE); + if (!isset($decode['jsonrpc']) || $decode['jsonrpc'] !== '2.0') { + throw new \RuntimeException("Protocol error: Response lacks JSON-RPC header."); + } + if (!array_key_exists('id', $decode) || $decode['id'] !== $id) { + throw new \RuntimeException("Protocol error: Received response for wrong request."); + } + + if (array_key_exists('error', $decode) && !array_key_exists('result', $decode)) { + throw new JsonRpcMethodException($decode); + } + if (array_key_exists('result', $decode) && !array_key_exists('error', $decode)) { + return $decode['result']; + } + throw new \RuntimeException("Protocol error: Response must include 'result' xor 'error'."); + } + + /** + * @param int $bufferSize + * @return $this + */ + public function setBufferSize(int $bufferSize) { + $this->bufferSize = $bufferSize; + if ($this->process) { + $this->call('options', ['bufferSize' => $bufferSize]); + } + return $this; + } + + /** + * @return int + */ + public function getBufferSize(): int { + return $this->bufferSize; + } + + /** + * @return array|NULL + */ + public function getWelcome(): ?array { + return $this->welcome['Civi::pipe'] ?? NULL; + } + +} diff --git a/Civi/Pipe/JsonRpcMethodException.php b/Civi/Pipe/JsonRpcMethodException.php new file mode 100644 index 0000000000..f2985a3ec2 --- /dev/null +++ b/Civi/Pipe/JsonRpcMethodException.php @@ -0,0 +1,27 @@ +raw = $jsonRpcError; + } + +} diff --git a/tests/phpunit/E2E/Extern/CliRunnerTest.php b/tests/phpunit/E2E/Extern/CliRunnerTest.php index b7c65915f5..75e1951a31 100644 --- a/tests/phpunit/E2E/Extern/CliRunnerTest.php +++ b/tests/phpunit/E2E/Extern/CliRunnerTest.php @@ -124,27 +124,15 @@ class E2E_Extern_CliRunnerTest extends CiviEndToEndTestCase { */ public function testPipe($name, $runner) { $cmd = strtr($runner, ['@PHP' => escapeshellarg('Civi::pipe("t");')]); - $desc = [['pipe', 'r'], ['pipe', 'w'], ['pipe', 'a']]; - $process = proc_open($cmd, $desc, $pipes); + $rpc = new \Civi\Pipe\BasicPipeClient($cmd); - $write = function(string $method, array $data = []) use (&$pipes) { - fwrite($pipes[0], json_encode(['jsonrpc' => '2.0', 'method' => $method, 'params' => $data, 'id' => NULL]) . "\n"); - }; - $read = function() use (&$pipes) { - $line = stream_get_line($pipes[1], 4096, "\n"); - $decode = json_decode($line, TRUE); - return $decode; - }; + $this->assertEquals('trusted', $rpc->getWelcome()['t'], "Expect standard Civi::pipe header when starting via $name"); - $this->assertEquals(['Civi::pipe' => ['t' => 'trusted']], $read(), "Expect standard Civi::pipe header when starting via $name"); + $r = $rpc->call('echo', ['a' => 123]); + $this->assertEquals(['a' => 123], $r); - $write('echo', ['a' => 123]); - $this->assertEquals(['a' => 123], $read()['result']); - - $write('echo', [4, 5, 6]); - $this->assertEquals([4, 5, 6], $read()['result']); - - proc_close($process); + $r = $rpc->call('echo', [4, 5, 6]); + $this->assertEquals([4, 5, 6], $r); } /** -- 2.25.1