From f79a440c70e5f620766a79acb4fa6d958487f59f Mon Sep 17 00:00:00 2001 From: Spencer Mortensen Date: Fri, 22 May 2015 11:54:13 -0400 Subject: [PATCH] Initial files --- COPYING | 165 +++++++++++ README.md | 70 +++++ composer.json | 32 ++ examples/client.php | 13 + examples/server.php | 15 + phpunit.xml.dist | 18 ++ src/Client.php | 125 ++++++++ src/Server.php | 395 +++++++++++++++++++++++++ src/Translator.php | 51 ++++ tests/ClientTest.php | 92 ++++++ tests/Example/Stateful/Math.php | 64 ++++ tests/Example/Stateful/Translator.php | 69 +++++ tests/Example/Stateless/Math.php | 64 ++++ tests/Example/Stateless/Translator.php | 67 +++++ tests/ServerTest.php | 234 +++++++++++++++ 15 files changed, 1474 insertions(+) create mode 100644 COPYING create mode 100644 README.md create mode 100644 composer.json create mode 100644 examples/client.php create mode 100644 examples/server.php create mode 100644 phpunit.xml.dist create mode 100644 src/Client.php create mode 100644 src/Server.php create mode 100644 src/Translator.php create mode 100644 tests/ClientTest.php create mode 100644 tests/Example/Stateful/Math.php create mode 100644 tests/Example/Stateful/Translator.php create mode 100644 tests/Example/Stateless/Math.php create mode 100644 tests/Example/Stateless/Translator.php create mode 100644 tests/ServerTest.php diff --git a/COPYING b/COPYING new file mode 100644 index 0000000..02bbb60 --- /dev/null +++ b/COPYING @@ -0,0 +1,165 @@ + GNU LESSER GENERAL PUBLIC LICENSE + Version 3, 29 June 2007 + + Copyright (C) 2007 Free Software Foundation, Inc. + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + + This version of the GNU Lesser General Public License incorporates +the terms and conditions of version 3 of the GNU General Public +License, supplemented by the additional permissions listed below. + + 0. Additional Definitions. + + As used herein, "this License" refers to version 3 of the GNU Lesser +General Public License, and the "GNU GPL" refers to version 3 of the GNU +General Public License. + + "The Library" refers to a covered work governed by this License, +other than an Application or a Combined Work as defined below. + + An "Application" is any work that makes use of an interface provided +by the Library, but which is not otherwise based on the Library. +Defining a subclass of a class defined by the Library is deemed a mode +of using an interface provided by the Library. + + A "Combined Work" is a work produced by combining or linking an +Application with the Library. The particular version of the Library +with which the Combined Work was made is also called the "Linked +Version". + + The "Minimal Corresponding Source" for a Combined Work means the +Corresponding Source for the Combined Work, excluding any source code +for portions of the Combined Work that, considered in isolation, are +based on the Application, and not on the Linked Version. + + The "Corresponding Application Code" for a Combined Work means the +object code and/or source code for the Application, including any data +and utility programs needed for reproducing the Combined Work from the +Application, but excluding the System Libraries of the Combined Work. + + 1. Exception to Section 3 of the GNU GPL. + + You may convey a covered work under sections 3 and 4 of this License +without being bound by section 3 of the GNU GPL. + + 2. Conveying Modified Versions. + + If you modify a copy of the Library, and, in your modifications, a +facility refers to a function or data to be supplied by an Application +that uses the facility (other than as an argument passed when the +facility is invoked), then you may convey a copy of the modified +version: + + a) under this License, provided that you make a good faith effort to + ensure that, in the event an Application does not supply the + function or data, the facility still operates, and performs + whatever part of its purpose remains meaningful, or + + b) under the GNU GPL, with none of the additional permissions of + this License applicable to that copy. + + 3. Object Code Incorporating Material from Library Header Files. + + The object code form of an Application may incorporate material from +a header file that is part of the Library. You may convey such object +code under terms of your choice, provided that, if the incorporated +material is not limited to numerical parameters, data structure +layouts and accessors, or small macros, inline functions and templates +(ten or fewer lines in length), you do both of the following: + + a) Give prominent notice with each copy of the object code that the + Library is used in it and that the Library and its use are + covered by this License. + + b) Accompany the object code with a copy of the GNU GPL and this license + document. + + 4. Combined Works. + + You may convey a Combined Work under terms of your choice that, +taken together, effectively do not restrict modification of the +portions of the Library contained in the Combined Work and reverse +engineering for debugging such modifications, if you also do each of +the following: + + a) Give prominent notice with each copy of the Combined Work that + the Library is used in it and that the Library and its use are + covered by this License. + + b) Accompany the Combined Work with a copy of the GNU GPL and this license + document. + + c) For a Combined Work that displays copyright notices during + execution, include the copyright notice for the Library among + these notices, as well as a reference directing the user to the + copies of the GNU GPL and this license document. + + d) Do one of the following: + + 0) Convey the Minimal Corresponding Source under the terms of this + License, and the Corresponding Application Code in a form + suitable for, and under terms that permit, the user to + recombine or relink the Application with a modified version of + the Linked Version to produce a modified Combined Work, in the + manner specified by section 6 of the GNU GPL for conveying + Corresponding Source. + + 1) Use a suitable shared library mechanism for linking with the + Library. A suitable mechanism is one that (a) uses at run time + a copy of the Library already present on the user's computer + system, and (b) will operate properly with a modified version + of the Library that is interface-compatible with the Linked + Version. + + e) Provide Installation Information, but only if you would otherwise + be required to provide such information under section 6 of the + GNU GPL, and only to the extent that such information is + necessary to install and execute a modified version of the + Combined Work produced by recombining or relinking the + Application with a modified version of the Linked Version. (If + you use option 4d0, the Installation Information must accompany + the Minimal Corresponding Source and Corresponding Application + Code. If you use option 4d1, you must provide the Installation + Information in the manner specified by section 6 of the GNU GPL + for conveying Corresponding Source.) + + 5. Combined Libraries. + + You may place library facilities that are a work based on the +Library side by side in a single library together with other library +facilities that are not Applications and are not covered by this +License, and convey such a combined library under terms of your +choice, if you do both of the following: + + a) Accompany the combined library with a copy of the same work based + on the Library, uncombined with any other library facilities, + conveyed under the terms of this License. + + b) Give prominent notice with the combined library that part of it + is a work based on the Library, and explaining where to find the + accompanying uncombined form of the same work. + + 6. Revised Versions of the GNU Lesser General Public License. + + The Free Software Foundation may publish revised and/or new versions +of the GNU Lesser General Public License from time to time. Such new +versions will be similar in spirit to the present version, but may +differ in detail to address new problems or concerns. + + Each version is given a distinguishing version number. If the +Library as you received it specifies that a certain numbered version +of the GNU Lesser General Public License "or any later version" +applies to it, you have the option of following the terms and +conditions either of that published version or of any later version +published by the Free Software Foundation. If the Library as you +received it does not specify a version number of the GNU Lesser +General Public License, you may choose any version of the GNU Lesser +General Public License ever published by the Free Software Foundation. + + If the Library as you received it specifies that a proxy can decide +whether future versions of the GNU Lesser General Public License shall +apply, that proxy's public statement of acceptance of any version is +permanent authorization for you to choose that version for the +Library. \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..bd1e168 --- /dev/null +++ b/README.md @@ -0,0 +1,70 @@ +# JSON-RPC for PHP + +## Features + +* Fully compliant with the [JSON-RPC 2.0 specifications](http://www.jsonrpc.org/specification) (with 100% unit-test coverage) +* Flexible: you can choose your own system for interpreting the JSON-RPC method strings +* Minimalistic: just two tiny files + +## Requirements + +* PHP >= 5.3 + +## License + +This package is released under an open-source license: [LGPL-3.0](https://www.gnu.org/licenses/lgpl-3.0.html) + +## Examples + +### Client + +```php +$client = new Client(); + +$client->query(1, 'Math/subtract', array(5, 3)); + +$request = $client->encode(); // {"jsonrpc":"2.0","id":1,"method":"Math\/subtract","params":[5,3]} +``` + +### Server + +```php +$translator = new Translator(); +$server = new Server($translator); + +$request = '{"jsonrpc":"2.0","id":1,"method":"Math\/subtract","params":[5,3]}'; + +$reply = $server->reply($request); // {"jsonrpc":"2.0","id":1,"result":2} +``` + +*See the "examples" folder for ready-to-use examples.* + +## Installation + +If you're using [Composer](https://getcomposer.org/), you can use this package +by inserting a line in the "require" section of your "composer.json" file: +``` + "datto/json-rpc": "1.0.*" +``` + +## Getting started + +1. Try the examples. You can run the examples from the project directory like this: + ``` + php examples/client.php + php examples/server.php + ``` + +2. Take a look at the examples in the "tests" directory, and then replace them with +your own code. + +## Unit tests + +You can run the suite of unit tests from the project directory like this: +``` +./vendor/bin/phpunit +``` + +## Author + +[Spencer Mortensen](http://spencermortensen.com/contact/) diff --git a/composer.json b/composer.json new file mode 100644 index 0000000..57513b9 --- /dev/null +++ b/composer.json @@ -0,0 +1,32 @@ +{ + "name": "datto/php-json-rpc", + "type": "library", + "description": "Fully unit-tested JSON-RPC 2.0 for PHP", + "keywords": ["php", "json", "rpc", "jsonrpc", "json-rpc", "php-json-rpc"], + "homepage": "http://datto.com", + "license": "LGPL-3.0", + "authors": [ + { + "name": "Spencer Mortensen", + "email": "smortensen@datto.com", + "homepage": "http://spencermortensen.com", + "role": "Developer" + } + ], + "require": { + "php": ">=5.3.0" + }, + "require-dev": { + "phpunit/phpunit": "3.7.*" + }, + "autoload": { + "psr-4": { + "Datto\\JsonRpc\\": "src" + } + }, + "autoload-dev": { + "psr-4": { + "Datto\\JsonRpc\\Tests\\": "tests" + } + } +} diff --git a/examples/client.php b/examples/client.php new file mode 100644 index 0000000..b8f4828 --- /dev/null +++ b/examples/client.php @@ -0,0 +1,13 @@ +query(1, 'Math/subtract', array(5, 3)); + +$request = $client->encode(); + +echo $request, "\n"; // {"jsonrpc":"2.0","id":1,"method":"Math\/subtract","params":[5,3]} diff --git a/examples/server.php b/examples/server.php new file mode 100644 index 0000000..d65d517 --- /dev/null +++ b/examples/server.php @@ -0,0 +1,15 @@ +reply($request); + +echo $reply, "\n"; // {"jsonrpc":"2.0","id":1,"result":2} diff --git a/phpunit.xml.dist b/phpunit.xml.dist new file mode 100644 index 0000000..fc5f184 --- /dev/null +++ b/phpunit.xml.dist @@ -0,0 +1,18 @@ + + + + + tests + + + + + + + + examples + tests/Example + vendor + + + diff --git a/src/Client.php b/src/Client.php new file mode 100644 index 0000000..cc598f6 --- /dev/null +++ b/src/Client.php @@ -0,0 +1,125 @@ +. + * + * @author Spencer Mortensen + * @license http://www.gnu.org/licenses/lgpl-3.0.html LGPL-3.0 + * @copyright 2015 Datto, Inc. + */ + +namespace Datto\JsonRpc; + +/** + * Class Client + * + * @link http://www.jsonrpc.org/specification JSON-RPC 2.0 Specifications + * + * @package Datto\JsonRpc + */ +class Client +{ + /** @var string */ + const VERSION = '2.0'; + + /** @var array */ + private $messages; + + public function __construct() + { + $this->messages = array(); + } + + /** + * @param mixed $id + * @param string $method + * @param array $arguments + */ + public function query($id, $method, $arguments) + { + $message = array( + 'jsonrpc' => self::VERSION, + 'id' => $id, + 'method' => $method + ); + + if ($arguments !== null) { + $message['params'] = $arguments; + } + + $this->messages[] = $message; + } + + /** + * @param string $method + * @param array $arguments + */ + public function notify($method, $arguments) + { + $message = array( + 'jsonrpc' => self::VERSION, + 'method' => $method + ); + + if ($arguments !== null) { + $message['params'] = $arguments; + } + + $this->messages[] = $message; + } + + /** + * Encodes the requests as a valid JSON-RPC 2.0 string + * + * @return null|string + * Returns a valid JSON-RPC 2.0 message string + * Returns null if there is nothing to encode + */ + public function encode() + { + $count = count($this->messages); + + if ($count === 0) { + return null; + } + + if ($count === 1) { + $output = array_shift($this->messages); + } else { + $output = $this->messages; + } + + $this->messages = array(); + + return json_encode($output); + } + + /** + * Translates a JSON-RPC 2.0 server response string into an associative array + * + * @param string $reply + * Text reply from a JSON-RPC 2.0 server + * + * @return mixed + * Returns an associative array containing the decoded server response + * Returns null on error + */ + public function decode($reply) + { + return @json_decode($reply, true); + } +} diff --git a/src/Server.php b/src/Server.php new file mode 100644 index 0000000..40e78d8 --- /dev/null +++ b/src/Server.php @@ -0,0 +1,395 @@ +. + * + * @author Spencer Mortensen + * @license http://www.gnu.org/licenses/lgpl-3.0.html LGPL-3.0 + * @copyright 2015 Datto, Inc. + */ + +namespace Datto\JsonRpc; + +/** + * Class Server + * + * @link http://www.jsonrpc.org/specification JSON-RPC 2.0 Specifications + * + * @package Datto\JsonRpc + */ +class Server +{ + const VERSION = '2.0'; + + /** @var Translator */ + private $translator; + + /** + * @param Translator $translator + */ + public function __construct(Translator $translator) + { + $this->translator = $translator; + } + + /** + * Processes the user input, and prepares a response (if necessary). + * + * @param string $json + * Single request object, or an array of request objects, as a JSON string. + * + * @return string|null + * Returns a response object (or an error object) as a JSON string, when a query is made. + * Returns an array of response/error objects as a JSON string, when multiple queries are made. + * Returns null, when no response is necessary. + */ + public function reply($json) + { + $input = @json_decode($json, true); + + $output = $this->processInput($input); + + if ($output === null) { + return null; + } + + return json_encode($output); + } + + /** + * Processes the user input, and prepares a response (if necessary). + * + * @param array $input + * Single request object, or an array of request objects. + * + * @return array|null + * Returns a response object (or an error object) when a query is made. + * Returns an array of response/error objects when multiple queries are made. + * Returns null when no response is necessary. + */ + private function processInput($input) + { + if (!is_array($input)) { + return self::errorJson(); + } + + if (count($input) === 0) { + return self::errorRequest(); + } + + if (isset($input['jsonrpc'])) { + return $this->processRequest($input); + } + + return $this->processBatchRequests($input); + } + + /** + * Processes a batch of user requests, and prepares the response. + * + * @param array $input + * Array of request objects. + * + * @return array|null + * Returns a response/error object when a query is made. + * Returns an array of response/error objects when multiple queries are made. + * Returns null when no response is necessary. + */ + private function processBatchRequests($input) + { + $replies = array(); + + foreach ($input as $request) { + $reply = $this->processRequest($request); + + if ($reply !== null) { + $replies[] = $reply; + } + } + + if (count($replies) === 0) { + return null; + } + + return $replies; + } + + /** + * Processes an individual request, and prepares the response. + * + * @param array $request + * Single request object to be processed. + * + * @return array|null + * Returns a response object or an error object. + * Returns null when no response is necessary. + */ + private function processRequest($request) + { + if (!is_array($request)) { + return self::errorRequest(); + } + + $version = @$request['jsonrpc']; + + if (@$version !== self::VERSION) { + return self::errorRequest(); + } + + $method = @$request['method']; + + if (!is_string($method)) { + return self::errorRequest(); + } + + // The 'params' key is optional, but must be non-null when provided + if (array_key_exists('params', $request)) { + $arguments = $request['params']; + + if (!is_array($arguments)) { + return self::errorRequest(); + } + } else { + $arguments = array(); + } + + // The presence of the 'id' key indicates that a response is expected + if (array_key_exists('id', $request)) { + $id = $request['id']; + + if (!is_int($id) && !is_float($id) && !is_string($id) && ($id !== null)) { + return self::errorRequest(); + } + + return $this->processQuery($id, $method, $arguments); + } + + $this->processNotification($method, $arguments); + return null; + } + + /** + * Processes a query request and prepares the response. + * + * @param mixed $id + * Client-supplied value that allows the client to associate the server response + * with the original query. + * + * @param string $method + * String value representing a method to invoke on the server: + * JSON-RPC intentionally leaves the internal format of this string unspecified. + * + * @param array $arguments + * Array of arguments that will be passed to the server method. + * This arguments array can be either a zero-indexed or an associative array: + * + * If the array is a zero-indexed array, then the elements of the array + * are passed to the method as sequential, positional arguments. + * + * If the array is an associative array, then the entire array is passed + * as a single argument to the server method. + * + * @return array + * Returns a response object or an error object. + */ + private function processQuery($id, $method, $arguments) + { + $callable = $this->translator->getCallable($method); + + if (!is_callable($callable)) { + return self::errorMethod($id); + } + + $result = self::run($callable, $arguments); + + // A callable must return null when invoked with invalid arguments + if ($result === null) { + return self::errorArguments($id); + } + + return self::response($id, $result); + } + + /** + * Processes a notification. No response is necessary. + * + * @param string $method + * String value representing a method to invoke on the server + * + * @param array $arguments + * Array of arguments that will be passed to the method. + */ + private function processNotification($method, $arguments) + { + $callable = $this->translator->getCallable($method); + + if (is_callable($callable)) { + self::run($callable, $arguments); + } + } + + /** + * Executes a callable with the supplied arguments, and returns the result. + * + * @param callable $callable + * A callable that will be executed. + * + * @param array $arguments + * Array of arguments that will be passed to the callable. + * + * @return mixed + * Returns the return value from the callable. + * Returns null on error. + */ + private static function run($callable, $arguments) + { + if (self::isPositionalArguments($arguments)) { + return call_user_func_array($callable, $arguments); + } + + return call_user_func($callable, $arguments); + } + + /** + * Returns true if the argument array is a zero-indexed list of positional + * arguments, or false if the argument array is a set of named arguments. + * + * @param array $arguments + * Array of arguments. + * + * @return bool + * Returns true iff the arguments array is zero-indexed. + */ + private static function isPositionalArguments($arguments) + { + $i = 0; + + foreach ($arguments as $key => $value) { + if ($key !== $i++) { + return false; + } + } + + return true; + } + + /** + * Returns an error object explaining that an error occurred while parsing + * the JSON text input. + * + * @return array + * Returns an error object. + */ + private static function errorJson() + { + return self::error(null, -32700, 'Parse error'); + } + + /** + * Returns an error object explaining that the JSON input is not a valid + * request object. + * + * @return array + * Returns an error object. + */ + private static function errorRequest() + { + return self::error(null, -32600, 'Invalid Request'); + } + + /** + * Returns an error object explaining that the requested method is unknown. + * + * @param mixed $id + * Client-supplied value that allows the client to associate the server response + * with the original query. + * + * @return array + * Returns an error object. + */ + private static function errorMethod($id) + { + return self::error($id, -32601, 'Method not found'); + } + + /** + * Returns an error object explaining that the method arguments were invalid. + * + * @param mixed $id + * Client-supplied value that allows the client to associate the server response + * with the original query. + * + * @return array + * Returns an error object. + */ + private static function errorArguments($id) + { + return self::error($id, -32602, 'Invalid params'); + } + + /** + * Returns a properly-formatted error object. + * + * @param mixed $id + * Client-supplied value that allows the client to associate the server response + * with the original query. + * + * @param int $code + * Integer value representing the general type of error encountered. + * + * @param string $message + * Concise description of the error (ideally a single sentence). + * + * @return array + * Returns an error object. + */ + private static function error($id, $code, $message) + { + $error = array( + 'code' => $code, + 'message' => $message + ); + + return array( + 'jsonrpc' => self::VERSION, + 'id' => $id, + 'error' => $error + ); + } + + /** + * Returns a properly-formatted response object. + * + * @param mixed $id + * Client-supplied value that allows the client to associate the server response + * with the original query. + * + * @param mixed $result + * Return value from the server method, which will now be delivered to the user. + * + * @return array + * Returns a response object. + */ + private static function response($id, $result) + { + return array( + 'jsonrpc' => self::VERSION, + 'id' => $id, + 'result' => $result + ); + } +} diff --git a/src/Translator.php b/src/Translator.php new file mode 100644 index 0000000..ca8dde3 --- /dev/null +++ b/src/Translator.php @@ -0,0 +1,51 @@ +. + * + * @author Spencer Mortensen + * @license http://www.gnu.org/licenses/lgpl-3.0.html LGPL-3.0 + * @copyright 2015 Datto, Inc. + */ + +namespace Datto\JsonRpc; + +/** + * Interface Translator + * + * A "Translator" object translates a string method name to an actual + * callable method. This allows the JSON-RPC server to interpret the + * "method" argument in a request. + * + * You should create your a "MethodTranslator" class that will invoke your own codebase. + * If you'd like an example, the "tests" directory has both stateful and stateless + * (REST-style) examples. + * + * @package Datto\JsonRpc + */ +interface Translator +{ + /** + * @param string $methodName + * String value representing the method to invoke on the server. + * + * @return callable | null + * Returns the corresponding callable method. + * Returns null on error. + */ + public function getCallable($methodName); +} diff --git a/tests/ClientTest.php b/tests/ClientTest.php new file mode 100644 index 0000000..26bb2fa --- /dev/null +++ b/tests/ClientTest.php @@ -0,0 +1,92 @@ +. + * + * @author Spencer Mortensen + * @license http://www.gnu.org/licenses/lgpl-3.0.html LGPL-3.0 + * @copyright 2015 Datto, Inc. + */ + +namespace Datto\JsonRpc; + +use PHPUnit_Framework_TestCase; + +class ClientTest extends PHPUnit_Framework_TestCase +{ + public function testNotification() + { + $client = new Client(); + $client->notify('subtract', array(3, 2)); + + $this->compare($client, '{"jsonrpc":"2.0","method":"subtract","params":[3,2]}'); + } + + public function testQuery() + { + $client = new Client(); + $client->query(1, 'subtract', array(3, 2)); + + $this->compare($client, '{"jsonrpc":"2.0","id":1,"method":"subtract","params":[3,2]}'); + } + + public function testBatch() + { + $client = new Client(); + $client->query(1, 'subtract', array(3, 2)); + $client->notify('subtract', array(4, 3)); + + $this->compare($client, '[{"jsonrpc":"2.0","id":1,"method":"subtract","params":[3,2]},{"jsonrpc":"2.0","method":"subtract","params":[4,3]}]'); + } + + public function testEmpty() + { + $client = new Client(); + + $this->compare($client, null); + } + + public function testReset() + { + $client = new Client(); + $client->notify('subtract', array(3, 2)); + $client->encode(); + + $this->compare($client, null); + } + + public function testDecode() + { + $reply = '{"jsonrpc": "2.0", "result": 1, "id": 1}'; + + $client = new Client(); + $actualOutput = $client->decode($reply); + $expectedOutput = @json_decode($reply, true); + + $this->assertSame($expectedOutput, $actualOutput); + } + + private function compare(Client $client, $expectedJsonOutput) + { + $actualJsonOutput = $client->encode(); + + $expectedOutput = @json_decode($expectedJsonOutput, true); + $actualOutput = @json_decode($actualJsonOutput, true); + + $this->assertEquals($expectedOutput, $actualOutput); + } +} diff --git a/tests/Example/Stateful/Math.php b/tests/Example/Stateful/Math.php new file mode 100644 index 0000000..476dba6 --- /dev/null +++ b/tests/Example/Stateful/Math.php @@ -0,0 +1,64 @@ +. + * + * @author Spencer Mortensen + * @license http://www.gnu.org/licenses/lgpl-3.0.html LGPL-3.0 + * @copyright 2015 Datto, Inc. + */ + +namespace Datto\JsonRpc\Tests\Example\Stateful; + +class Math +{ + public function subtract() + { + $arguments = func_get_args(); + + if (count($arguments) === 1) { + // Named arguments + $a = @$arguments[0]['minuend']; + $b = @$arguments[0]['subtrahend']; + } else { + // Positional arguments + $a = @$arguments[0]; + $b = @$arguments[1]; + } + + return self::sub($a, $b); + } + + /** + * Returns the value $a - $b + * + * @param mixed $a + * @param mixed $b + * + * @return int|null + * Returns $a - $b if both $a and $b are integers + * Returns null otherwise + */ + private static function sub($a, $b) + { + if (!is_int($a) || !is_int($b)) { + return null; + } + + return $a - $b; + } +} diff --git a/tests/Example/Stateful/Translator.php b/tests/Example/Stateful/Translator.php new file mode 100644 index 0000000..c70d416 --- /dev/null +++ b/tests/Example/Stateful/Translator.php @@ -0,0 +1,69 @@ +. + * + * @author Spencer Mortensen + * @license http://www.gnu.org/licenses/lgpl-3.0.html LGPL-3.0 + * @copyright 2015 Datto, Inc. + */ + +namespace Datto\JsonRpc\Tests\Example\Stateful; + +use Datto\JsonRpc; + +class Translator implements JsonRpc\Translator +{ + /** @var Math */ + private $math; + + public function __construct() + { + $this->math = new Math(); + } + + /** + * @param string $name + * + * @return callable|null + */ + public function getCallable($name) + { + if (!self::isValidName($name)) { + return null; + } + + return array($this->math, $name); + } + + /** + * @param mixed $input + * + * @return bool + * Returns true if and only if the input is a valid method name + */ + private static function isValidName($input) + { + if (!is_string($input)) { + return false; + } + + $validPattern = '~^[a-zA-Z0-9]+$~'; + + return preg_match($validPattern, $input) === 1; + } +} diff --git a/tests/Example/Stateless/Math.php b/tests/Example/Stateless/Math.php new file mode 100644 index 0000000..48e0acd --- /dev/null +++ b/tests/Example/Stateless/Math.php @@ -0,0 +1,64 @@ +. + * + * @author Spencer Mortensen + * @license http://www.gnu.org/licenses/lgpl-3.0.html LGPL-3.0 + * @copyright 2015 Datto, Inc. + */ + +namespace Datto\JsonRpc\Tests\Example\Stateless; + +class Math +{ + public static function subtract() + { + $arguments = func_get_args(); + + if (count($arguments) === 1) { + // Named arguments + $a = @$arguments[0]['minuend']; + $b = @$arguments[0]['subtrahend']; + } else { + // Positional arguments + $a = @$arguments[0]; + $b = @$arguments[1]; + } + + return self::sub($a, $b); + } + + /** + * Returns the value $a - $b + * + * @param mixed $a + * @param mixed $b + * + * @return int|null + * Returns $a - $b if both $a and $b are integers + * Returns null otherwise + */ + private static function sub($a, $b) + { + if (!is_int($a) || !is_int($b)) { + return null; + } + + return $a - $b; + } +} diff --git a/tests/Example/Stateless/Translator.php b/tests/Example/Stateless/Translator.php new file mode 100644 index 0000000..8644e31 --- /dev/null +++ b/tests/Example/Stateless/Translator.php @@ -0,0 +1,67 @@ +. + * + * @author Spencer Mortensen + * @license http://www.gnu.org/licenses/lgpl-3.0.html LGPL-3.0 + * @copyright 2015 Datto, Inc. + */ + +namespace Datto\JsonRpc\Tests\Example\Stateless; + +use Datto\JsonRpc; + +class Translator implements JsonRpc\Translator +{ + private static $namespace = '\\Datto\\JsonRpc\\Tests\\Example\\Stateless\\'; + + /** + * @param string $name + * + * @return callable|null + */ + public function getCallable($name) + { + if (!self::isValidName($name)) { + return null; + } + + $parts = explode('/', $name); + $method = array_pop($parts); + $class = self::$namespace . implode('\\', $parts); + + return array($class, $method); + } + + /** + * @param mixed $input + * + * @return bool + * Returns true if and only if the input is a valid method name + */ + private static function isValidName($input) + { + if (!is_string($input)) { + return false; + } + + $validPattern = '~^[a-zA-Z0-9]+(/[a-zA-Z0-9]+)+$~'; + + return preg_match($validPattern, $input) === 1; + } +} diff --git a/tests/ServerTest.php b/tests/ServerTest.php new file mode 100644 index 0000000..6d21e72 --- /dev/null +++ b/tests/ServerTest.php @@ -0,0 +1,234 @@ +. + * + * @author Spencer Mortensen + * @license http://www.gnu.org/licenses/lgpl-3.0.html LGPL-3.0 + * @copyright 2015 Datto, Inc. + */ + +namespace Datto\JsonRpc; + +use PHPUnit_Framework_TestCase; +use Datto\JsonRpc\Tests\Example\Stateless\Translator; + +class ServerTest extends PHPUnit_Framework_TestCase +{ + public function testArgumentsPositionalA() + { + $input = '{"jsonrpc": "2.0", "method": "Math/subtract", "params": [3, 2], "id": 1}'; + + $output = '{"jsonrpc": "2.0", "result": 1, "id": 1}'; + + $this->compare($input, $output); + } + + public function testArgumentsPositionalB() + { + $input = '{"jsonrpc": "2.0", "method": "Math/subtract", "params": [2, 3], "id": 1}'; + + $output = '{"jsonrpc": "2.0", "result": -1, "id": 1}'; + + $this->compare($input, $output); + } + + public function testArgumentsNamedA() + { + $input = '{"jsonrpc": "2.0", "method": "Math/subtract", "params": {"minuend": 3, "subtrahend": 2}, "id": 1}'; + + $output = '{"jsonrpc": "2.0", "result": 1, "id": 1}'; + + $this->compare($input, $output); + } + + public function testArgumentsInvalid() + { + $input = '{"jsonrpc": "2.0", "method": "Math/subtract", "params": [], "id": 1}'; + + $output = '{"jsonrpc": "2.0", "error": {"code": -32602, "message": "Invalid params"}, "id": "1"}'; + + $this->compare($input, $output); + } + + public function testArgumentsNamedB() + { + $input = '{"jsonrpc": "2.0", "method": "Math/subtract", "params": {"subtrahend": 2, "minuend": 3}, "id": 1}'; + + $output = '{"jsonrpc": "2.0", "result": 1, "id": 1}'; + + $this->compare($input, $output); + } + + public function testNotificationArguments() + { + $input = '{"jsonrpc": "2.0", "method": "Math/subtract", "params": [3, 2]}'; + + $output = 'null'; + + $this->compare($input, $output); + } + + public function testNotification() + { + $input = '{"jsonrpc": "2.0", "method": "Math/subtract"}'; + + $output = 'null'; + + $this->compare($input, $output); + } + + public function testUndefinedMethod() + { + $input ='{"jsonrpc": "2.0", "method": "Math/undefined", "id": "1"}'; + + $output = '{"jsonrpc": "2.0", "error": {"code": -32601, "message": "Method not found"}, "id": "1"}'; + + $this->compare($input, $output); + } + + public function testInvalidJson() + { + $input = '{"jsonrpc": "2.0", "method": "foobar", "params": "bar", "baz]'; + + $output = '{"jsonrpc": "2.0", "error": {"code": -32700, "message": "Parse error"}, "id": null}'; + + $this->compare($input, $output); + } + + public function testInvalidMethod() + { + $input = '{"jsonrpc": "2.0", "method": 1, "params": [1, 2]}'; + + $output = '{"jsonrpc": "2.0", "error": {"code": -32600, "message": "Invalid Request"}, "id": null}'; + + $this->compare($input, $output); + } + + public function testInvalidParams() + { + $input = '{"jsonrpc": "2.0", "method": "foobar", "params": "bar"}'; + + $output = '{"jsonrpc": "2.0", "error": {"code": -32600, "message": "Invalid Request"}, "id": null}'; + + $this->compare($input, $output); + } + + public function testInvalidId() + { + $input = '{"jsonrpc": "2.0", "method": "foobar", "params": [1, 2], "id": [1]}'; + + $output = '{"jsonrpc": "2.0", "error": {"code": -32600, "message": "Invalid Request"}, "id": null}'; + + $this->compare($input, $output); + } + + public function testBatchInvalidJson() + { + $input = ' [ + {"jsonrpc": "2.0", "method": "Math/subtract", "params": [1, 2, 4], "id": "1"}, + {"jsonrpc": "2.0", "method" + ]'; + + $output = '{"jsonrpc": "2.0", "error": {"code": -32700, "message": "Parse error"}, "id": null}'; + + $this->compare($input, $output); + } + + public function testBatchEmpty() + { + $input = '[ + ]'; + + $output = '{"jsonrpc": "2.0", "error": {"code": -32600, "message": "Invalid Request"}, "id": null}'; + + $this->compare($input, $output); + } + + public function testBatchInvalidElement() + { + $input = '[ + 1 + ]'; + + $output = '[ + {"jsonrpc": "2.0", "error": {"code": -32600, "message": "Invalid Request"}, "id": null} + ]'; + + $this->compare($input, $output); + } + + public function testBatchInvalidElements() + { + $input = '[ + 1, + 2, + 3 + ]'; + + $output = '[ + {"jsonrpc": "2.0", "error": {"code": -32600, "message": "Invalid Request"}, "id": null}, + {"jsonrpc": "2.0", "error": {"code": -32600, "message": "Invalid Request"}, "id": null}, + {"jsonrpc": "2.0", "error": {"code": -32600, "message": "Invalid Request"}, "id": null} + ]'; + + $this->compare($input, $output); + } + + public function testBatch() + { + $input = '[ + {"jsonrpc": "2.0", "method": "Math/subtract", "params": [1, -1], "id": "1"}, + {"jsonrpc": "2.0", "method": "Math/subtract", "params": [1, -1]}, + {"foo": "boo"}, + {"jsonrpc": "2.0", "method": "undefined", "params": {"name": "myself"}, "id": "5"} + ]'; + + $output = '[ + {"jsonrpc": "2.0", "result": 2, "id": "1"}, + {"jsonrpc": "2.0", "error": {"code": -32600, "message": "Invalid Request"}, "id": null}, + {"jsonrpc": "2.0", "error": {"code": -32601, "message": "Method not found"}, "id": "5"} + ]'; + + $this->compare($input, $output); + } + + public function testBatchNotifications() + { + $input = '[ + {"jsonrpc": "2.0", "method": "Math/subtract", "params": [4, 2]}, + {"jsonrpc": "2.0", "method": "Math/subtract", "params": [3, 7]} + ]'; + + $output = 'null'; + + $this->compare($input, $output); + } + + private function compare($input, $expectedJsonOutput) + { + $method = new Translator(); + $server = new Server($method); + + $actualJsonOutput = $server->reply($input); + + $expectedOutput = json_decode($expectedJsonOutput, true); + $actualOutput = json_decode($actualJsonOutput, true); + + $this->assertEquals($expectedOutput, $actualOutput); + } +}