Civi::pipe - JSON-RPC 2.0 session
authorTim Otten <totten@civicrm.org>
Wed, 15 Dec 2021 00:39:01 +0000 (16:39 -0800)
committerTim Otten <totten@civicrm.org>
Thu, 13 Jan 2022 21:15:00 +0000 (13:15 -0800)
Civi/Pipe/JsonRpcSession.php [new file with mode: 0644]
tests/phpunit/Civi/Pipe/JsonRpcSessionTest.php [new file with mode: 0644]

diff --git a/Civi/Pipe/JsonRpcSession.php b/Civi/Pipe/JsonRpcSession.php
new file mode 100644 (file)
index 0000000..15c665e
--- /dev/null
@@ -0,0 +1,111 @@
+<?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,
+      ],
+    ];
+  }
+
+}
diff --git a/tests/phpunit/Civi/Pipe/JsonRpcSessionTest.php b/tests/phpunit/Civi/Pipe/JsonRpcSessionTest.php
new file mode 100644 (file)
index 0000000..b1b2f34
--- /dev/null
@@ -0,0 +1,155 @@
+<?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");
+  }
+
+}