From 54675f571d434e87034a82fa87cf20af59d9b599 Mon Sep 17 00:00:00 2001 From: Tim Otten Date: Tue, 14 Dec 2021 16:39:01 -0800 Subject: [PATCH] Civi::pipe - JSON-RPC 2.0 session --- Civi/Pipe/JsonRpcSession.php | 111 +++++++++++++ .../phpunit/Civi/Pipe/JsonRpcSessionTest.php | 155 ++++++++++++++++++ 2 files changed, 266 insertions(+) create mode 100644 Civi/Pipe/JsonRpcSession.php create mode 100644 tests/phpunit/Civi/Pipe/JsonRpcSessionTest.php diff --git a/Civi/Pipe/JsonRpcSession.php b/Civi/Pipe/JsonRpcSession.php new file mode 100644 index 0000000000..15c665e731 --- /dev/null +++ b/Civi/Pipe/JsonRpcSession.php @@ -0,0 +1,111 @@ +methods = new PublicMethods(); + return json_encode(["Civi::pipe" => ['jsonrpc20']]); + } + + /** + * @inheritDoc + */ + protected function onRequest(string $requestLine): ?string { + $request = \json_decode($requestLine, TRUE); + + if ($request === NULL) { + throw new \InvalidArgumentException('Parse error', -32700); + } + + if (!is_array($request)) { + throw new \InvalidArgumentException('Invalid Request', -32600); + } + + if (isset($request[0])) { + $response = array_map([$this, 'handleRequest'], $request); + } + else { + $response = $this->handleRequest($request); + } + + return \json_encode($response); + } + + protected function handleRequest($request): array { + try { + if ($request === NULL) { + throw new \InvalidArgumentException('Parse error', -32700); + } + if (($request['jsonrpc'] ?? '') !== '2.0') { + throw new \InvalidArgumentException('Invalid Request', -32600); + } + + $method = str_replace('.', '_', mb_strtolower($request['method'])); + if (!is_string($method) || !preg_match(self::METHOD_REGEX, $method)) { + throw new \InvalidArgumentException('Invalid Request', -32600); + } + + if (!is_callable([$this->methods, $method])) { + throw new \InvalidArgumentException('Method not found', -32601); + } + + $result = call_user_func([$this->methods, $method], $this, $request['params'] ?? []); + $id = array_key_exists('id', $request) ? ['id' => $request['id']] : []; + return [ + 'jsonrpc' => '2.0', + 'result' => $result, + ] + $id; + } + catch (\Throwable $t) { + return $this->createJsonError($request, $t); + } + } + + /** + * @inheritDoc + */ + protected function onException(string $requestLine, \Throwable $t): ?string { + return \json_encode($this->createJsonError(['jsonrpc' => '2.0'], $t)); + } + + protected function createJsonError(array $request, \Throwable $t): array { + $isJsonErrorCode = $t->getCode() >= -32999 && $t->getCode() <= -32000; + $errorData = \CRM_Core_Config::singleton()->debug + ? ['class' => get_class($t), 'trace' => $t->getTraceAsString()] + : NULL; + return \CRM_Utils_Array::subset($request, ['jsonrpc', 'id']) + [ + 'error' => [ + 'code' => $isJsonErrorCode ? $t->getCode() : -32099, + 'message' => $t->getMessage(), + 'data' => $errorData, + ], + ]; + } + +} diff --git a/tests/phpunit/Civi/Pipe/JsonRpcSessionTest.php b/tests/phpunit/Civi/Pipe/JsonRpcSessionTest.php new file mode 100644 index 0000000000..b1b2f34016 --- /dev/null +++ b/tests/phpunit/Civi/Pipe/JsonRpcSessionTest.php @@ -0,0 +1,155 @@ +input = fopen('php://memory', 'w'); + $this->output = fopen('php://memory', 'w'); + $this->server = new JsonRpcSession($this->input, $this->output); + } + + protected function tearDown(): void { + fclose($this->input); + fclose($this->output); + $this->input = $this->output = $this->server = NULL; + parent::tearDown(); + } + + public function testInvalid() { + $responseLines = $this->runLines([ + '{"jsonrpc":"2.0","method":"wiggum"}', + ]); + $decode = json_decode($responseLines[1], 1); + $this->assertEquals('2.0', $decode['jsonrpc']); + $this->assertEquals('Method not found', $decode['error']['message']); + $this->assertEquals(-32601, $decode['error']['code']); + } + + public function testEcho() { + $this->assertRequestResponse([ + '{"jsonrpc":"2.0","id":"a","method":"echo","params":123}' => '{"jsonrpc":"2.0","result":123,"id":"a"}', + '{"jsonrpc":"2.0","id":"a","method":"echo","params":true}' => '{"jsonrpc":"2.0","result":true,"id":"a"}', + '{"jsonrpc":"2.0","id":"a","method":"echo","params":[1,4,9]}' => '{"jsonrpc":"2.0","result":[1,4,9],"id":"a"}', + '{"jsonrpc":"2.0","id":null,"method":"echo","params":123}' => '{"jsonrpc":"2.0","result":123,"id":null}', + ]); + } + + public function testBatch() { + $batchLine = '[' . + '{"jsonrpc":"2.0","id":"a","method":"wiggum"},' . + '{"jsonrpc":"2.0","id":"b","method": "echo","params":123}' . + ']'; + $responseLines = $this->runLines([$batchLine]); + $decode = json_decode($responseLines[1], 1); + + $this->assertEquals('2.0', $decode[0]['jsonrpc']); + $this->assertEquals('a', $decode[0]['id']); + $this->assertEquals('Method not found', $decode[0]['error']['message']); + $this->assertEquals(-32601, $decode[0]['error']['code']); + + $this->assertEquals('2.0', $decode[1]['jsonrpc']); + $this->assertEquals('b', $decode[1]['id']); + $this->assertEquals(123, $decode[1]['result']); + } + + public function testInvalidControl() { + $responses = $this->runLines(['{"jsonrpc":"2.0","id":"b","method":"session.nope","params":[]}']); + $decode = json_decode($responses[1], 1); + $this->assertEquals('2.0', $decode['jsonrpc']); + $this->assertEquals('Method not found', $decode['error']['message']); + } + + public function testControl() { + $this->assertRequestResponse([ + '{"jsonrpc":"2.0","id":"c","method":"options"}' => '{"jsonrpc":"2.0","result":{"responsePrefix":null,"maxLine":524288},"id":"c"}', + '{"jsonrpc":"2.0","id":"c","method":"options","params":{"responsePrefix":"ZZ"}}' => 'ZZ{"jsonrpc":"2.0","result":{"responsePrefix":"ZZ"},"id":"c"}', + '{"jsonrpc":"2.0","id":"c","method": "echo","params":123}' => 'ZZ{"jsonrpc":"2.0","result":123,"id":"c"}', + ]); + } + + public function testApi3() { + $responses = $this->runLines(['{"jsonrpc":"2.0","id":"a3","method":"api3","params":["System","get"]}']); + + $this->assertEquals($this->standardHeader, $responses[0]); + + $decode = json_decode($responses[1], TRUE); + $this->assertEquals('2.0', $decode['jsonrpc']); + $this->assertEquals('a3', $decode['id']); + $this->assertEquals(\CRM_Utils_System::version(), $decode['result']['values'][0]['version']); + } + + public function testApi4() { + $responses = $this->runLines(['{"jsonrpc":"2.0","id":"a4","method":"api4","params":["Contact","getFields"]}']); + + $this->assertEquals($this->standardHeader, $responses[0]); + + $decode = json_decode($responses[1], TRUE); + $this->assertEquals('2.0', $decode['jsonrpc']); + $this->assertEquals('a4', $decode['id']); + $this->assertTrue(is_array($decode['result'])); + $fields = \CRM_Utils_Array::index(['name'], $decode['result']); + $this->assertEquals('Number', $fields['id']['input_type']); + } + + /** + * @param array $requestResponse + * List of requests and the corresponding responses. + * Requests are sent in the same order given. + * Ex: ['{"ECHO":1}' => '{"OK":1}'] + */ + protected function assertRequestResponse(array $requestResponse) { + $responses = $this->runLines(array_keys($requestResponse)); + $next = function() use (&$responses) { + return array_shift($responses); + }; + + $this->assertEquals($this->standardHeader, $next()); + $this->assertNotEmpty($requestResponse); + foreach ($requestResponse as $request => $expectResponse) { + $this->assertEquals($expectResponse, $next(), "The request ($request) should return expected response."); + } + } + + /** + * @param string[] $lines + * List of statements to send. (Does not include the line-delimiter.) + * @return string[] + * List of responses. (Does not include the line-delimiter.) + */ + protected function runLines(array $lines): array { + foreach ($lines as $line) { + fwrite($this->input, $line . "\n"); + } + fseek($this->input, 0); + + $this->server->run(); + + fseek($this->output, 0); + return explode("\n", stream_get_contents($this->output)); + } + + protected function getOutputLine() { + return stream_get_line($this->output, 10000, "\n"); + } + +} -- 2.25.1