--- /dev/null
+<?php
+/*
+ +--------------------------------------------------------------------+
+ | Copyright CiviCRM LLC. All rights reserved. |
+ | |
+ | This work is published under the GNU AGPLv3 license with some |
+ | permitted exceptions and without any warranty. For full license |
+ | and copyright information, see https://civicrm.org/licensing |
+ +--------------------------------------------------------------------+
+ */
+
+namespace Civi\Pipe;
+
+class JsonRpcSession {
+
+ use LineSessionTrait;
+
+ protected const METHOD_REGEX = ';^[a-z][a-zA-Z0-9_]*$;';
+
+ /**
+ * Open-ended object. Any public method will be available during this session.
+ *
+ * @var object
+ */
+ protected $methods;
+
+ /**
+ * @inheritDoc
+ */
+ protected function onConnect(): ?string {
+ \CRM_Core_Session::useFakeSession();
+ $this->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,
+ ],
+ ];
+ }
+
+}
--- /dev/null
+<?php
+/*
+ +--------------------------------------------------------------------+
+ | Copyright CiviCRM LLC. All rights reserved. |
+ | |
+ | This work is published under the GNU AGPLv3 license with some |
+ | permitted exceptions and without any warranty. For full license |
+ | and copyright information, see https://civicrm.org/licensing |
+ +--------------------------------------------------------------------+
+ */
+
+namespace Civi\Pipe;
+
+/**
+ * @group headless
+ */
+class JsonRpcSessionTest extends \CiviUnitTestCase {
+
+ protected $standardHeader = '{"Civi::pipe":["jsonrpc20"]}';
+ protected $input;
+ protected $output;
+ protected $server;
+
+ protected function setUp(): void {
+ parent::setUp();
+ $this->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");
+ }
+
+}