├── .editorconfig ├── .github ├── dependabot.yml └── workflows │ ├── php-cs-fixer.yml │ └── run-tests.yml ├── .gitignore ├── .php_cs.dist.php ├── LICENSE.md ├── README.md ├── composer.json ├── phpunit.xml.dist ├── src ├── Client.php └── Response.php └── tests ├── .gitkeep ├── FixtureProcedure.php ├── HttpClientTest.php └── TestCase.php /.editorconfig: -------------------------------------------------------------------------------- 1 | ; This file is for unifying the coding style for different editors and IDEs. 2 | ; More information at https://editorconfig.org 3 | 4 | root = true 5 | 6 | [*] 7 | charset = utf-8 8 | indent_size = 4 9 | indent_style = space 10 | end_of_line = lf 11 | insert_final_newline = true 12 | trim_trailing_whitespace = true 13 | 14 | [*.md] 15 | trim_trailing_whitespace = false 16 | 17 | [*.yml] 18 | indent_size = 2 19 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | # To get started with Dependabot version updates, you'll need to specify which 2 | # package ecosystems to update and where the package manifests are located. 3 | # Please see the documentation for all configuration options: 4 | # https://docs.github.com/github/administering-a-repository/configuration-options-for-dependency-updates 5 | 6 | version: 2 7 | updates: 8 | 9 | # Maintain dependencies for GitHub Actions 10 | - package-ecosystem: "github-actions" 11 | directory: "/" 12 | schedule: 13 | interval: "weekly" 14 | 15 | # Maintain dependencies for Composer 16 | - package-ecosystem: "composer" 17 | directory: "/" 18 | schedule: 19 | interval: "weekly" 20 | -------------------------------------------------------------------------------- /.github/workflows/php-cs-fixer.yml: -------------------------------------------------------------------------------- 1 | name: Check & fix styling 2 | 3 | on: [ push ] 4 | 5 | jobs: 6 | php-cs-fixer: 7 | runs-on: ubuntu-latest 8 | 9 | steps: 10 | - name: Checkout code 11 | uses: actions/checkout@v4 12 | with: 13 | ref: ${{ github.head_ref }} 14 | 15 | - name: Run PHP CS Fixer 16 | uses: docker://oskarstark/php-cs-fixer-ga 17 | with: 18 | args: --config=.php_cs.dist.php --allow-risky=yes 19 | 20 | - name: Commit changes 21 | uses: stefanzweifel/git-auto-commit-action@v5 22 | with: 23 | commit_message: Fix styling 24 | -------------------------------------------------------------------------------- /.github/workflows/run-tests.yml: -------------------------------------------------------------------------------- 1 | name: run-tests 2 | 3 | on: 4 | # Run action on every push and PR 5 | push: 6 | pull_request: 7 | 8 | # Run action at midnight to test against any updated dependencies 9 | schedule: 10 | - cron: '0 0 * * *' 11 | 12 | jobs: 13 | test: 14 | runs-on: ${{ matrix.os }} 15 | strategy: 16 | fail-fast: false 17 | matrix: 18 | os: [ ubuntu-latest, windows-latest, macos-latest ] 19 | php: [8.4, 8.3, 8.2, 8.1, 8.0 ] 20 | laravel: [ "12.*", "11.*", "10.*", "9.*" ] 21 | dependency-version: [prefer-lowest, prefer-stable] 22 | include: 23 | - laravel: 12.* 24 | testbench: 10.* 25 | - laravel: 11.* 26 | testbench: 9.* 27 | - laravel: 10.* 28 | testbench: 8.* 29 | - laravel: 9.* 30 | testbench: 7.* 31 | exclude: 32 | - laravel: 10.* 33 | php: 8.0 34 | - laravel: 11.* 35 | php: 8.1 36 | - laravel: 12.* 37 | php: 8.1 38 | 39 | name: P${{ matrix.php }} - ${{ matrix.os }} - ${{ matrix.dependency-version }} 40 | 41 | steps: 42 | - name: Checkout code 43 | uses: actions/checkout@v4 44 | 45 | - name: Cache dependencies 46 | uses: actions/cache@v4.2.3 47 | with: 48 | path: ~/.composer/cache/files 49 | key: dependencies-php-${{ matrix.php }}-composer-${{ hashFiles('composer.json') }} 50 | 51 | - name: Setup PHP 52 | uses: shivammathur/setup-php@v2 53 | with: 54 | php-version: ${{ matrix.php }} 55 | extensions: dom, curl, libxml, mbstring, zip, pcntl, pdo, sqlite, pdo_sqlite, bcmath, soap, intl, gd, exif, iconv, imagick, zlib, fileinfo 56 | coverage: none 57 | tools: composer:v2 58 | 59 | - name: Install dependencies 60 | run: | 61 | composer update --${{ matrix.dependency-version }} --prefer-dist --no-interaction 62 | 63 | - name: Execute tests 64 | run: vendor/bin/phpunit 65 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | build 2 | composer.lock 3 | docs 4 | vendor 5 | coverage 6 | .phpunit.result.cache 7 | .idea 8 | .php_cs 9 | .php_cs.cache 10 | .php-cs-fixer.cache 11 | -------------------------------------------------------------------------------- /.php_cs.dist.php: -------------------------------------------------------------------------------- 1 | in([ 5 | __DIR__ . '/src', 6 | __DIR__ . '/tests', 7 | ]) 8 | ->name('*.php') 9 | ->notName('*.blade.php') 10 | ->ignoreDotFiles(true) 11 | ->ignoreVCS(true); 12 | 13 | return (new PhpCsFixer\Config()) 14 | ->setRules([ 15 | '@PSR2' => true, 16 | 'array_syntax' => ['syntax' => 'short'], 17 | 'ordered_imports' => ['sort_algorithm' => 'alpha'], 18 | 'no_unused_imports' => true, 19 | 'no_whitespace_before_comma_in_array' => true, 20 | 'not_operator_with_successor_space' => true, 21 | 'trailing_comma_in_multiline' => ['elements' => ['arrays']], 22 | 'phpdoc_scalar' => true, 23 | 'unary_operator_spaces' => true, 24 | 'binary_operator_spaces' => [ 25 | 'operators' => [ 26 | '=>' => 'align_single_space', 27 | ], 28 | ], 29 | 'blank_line_before_statement' => [ 30 | 'statements' => ['continue', 'declare', 'return', 'throw', 'try'], 31 | ], 32 | 'phpdoc_separation' => true, 33 | 'phpdoc_align' => true, 34 | 'phpdoc_order' => true, 35 | 'phpdoc_single_line_var_spacing' => true, 36 | 'phpdoc_var_without_name' => true, 37 | 'class_attributes_separation' => [ 38 | 'elements' => [ 39 | 'method' => 'one', 40 | ], 41 | ], 42 | 'method_argument_space' => [ 43 | 'on_multiline' => 'ignore', 44 | ], 45 | 'trim_array_spaces' => true, 46 | 'single_trait_insert_per_statement' => false, 47 | ]) 48 | ->setFinder($finder); 49 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) Alexandr Chernyaev 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ## Introduction 2 | 3 | [![run-tests](https://github.com/sajya/client/actions/workflows/run-tests.yml/badge.svg)](https://github.com/sajya/client/actions/workflows/run-tests.yml) 4 | 5 | This package lets you set up a JSON-RPC client over HTTP(S), using your PHP code to make the requests. Built 6 | around [Laravel](https://laravel.com/docs/8.x/http-client#introduction) (Doesn't require the entire framework, just its component) expressive HTTP wrapper, it allows you to 7 | customize things like authorization, retries, and more. 8 | 9 | 10 | ## Install 11 | 12 | Go to the project directory and run the command: 13 | 14 | ```php 15 | $ composer require sajya/client 16 | ``` 17 | 18 | 19 | ## Usage 20 | 21 | ```php 22 | use Illuminate\Support\Facades\Http; 23 | use Sajya\Client\Client; 24 | 25 | $client = new Client(Http::baseUrl('http://localhost:8000/api/v1/endpoint')); 26 | 27 | $response = $client->execute('tennis@ping'); 28 | 29 | $response->result(); // pong 30 | ``` 31 | 32 | By default, the request identifier will be generated using the [UUID](https://en.wikipedia.org/wiki/Universally_unique_identifier), you can get it by calling the `id()` method 33 | 34 | ```php 35 | $response->id(); 36 | ``` 37 | 38 | To get the result of an error, you need to call the `error()` method 39 | 40 | ```php 41 | $response->error(); 42 | ``` 43 | 44 | ### Parameters 45 | 46 | Example with positional parameters: 47 | 48 | ```php 49 | $response = $client->execute('tennis@ping', [3, 5]); 50 | ``` 51 | 52 | Example with named arguments: 53 | 54 | ```php 55 | $response = $client->execute('tennis@ping', ['end' => 10, 'start' => 1]); 56 | ``` 57 | 58 | ### Batch requests 59 | 60 | Call several procedures in a single HTTP request: 61 | 62 | ```php 63 | $batchData = $client->batch(function (Client $client) { 64 | $client->execute('tennis@ping'); 65 | $client->execute('tennis@ping'); 66 | }); 67 | ``` 68 | 69 | ### Notify requests 70 | 71 | ```php 72 | $client->notify('procedure@method'); 73 | ``` 74 | 75 | ## License 76 | 77 | The MIT License (MIT). Please see [License File](LICENSE.md) for more information. 78 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "sajya/client", 3 | "type": "library", 4 | "description": "HTTP client and server for JSON-RPC 2.0", 5 | "keywords": [ 6 | "rpc", 7 | "json-prc", 8 | "api" 9 | ], 10 | "homepage": "https://sajya.github.io", 11 | "license": "MIT", 12 | "authors": [ 13 | { 14 | "name": "tabuna", 15 | "email": "bliz48rus@gmail.com" 16 | } 17 | ], 18 | "require": { 19 | "guzzlehttp/guzzle": "^7.4", 20 | "illuminate/http": "^8.0|^9.0|^10.0|^11.0|^12.0", 21 | "illuminate/support": "8.0|^9.0|^10.0|^11.0|^12.0" 22 | }, 23 | "require-dev": { 24 | "orchestra/testbench": "^7.0|^8.0|^9.0|^10.0", 25 | "sajya/server": "^5.2|^6.0|^7.0" 26 | }, 27 | "autoload": { 28 | "psr-4": { 29 | "Sajya\\Client\\": "src/" 30 | } 31 | }, 32 | "autoload-dev": { 33 | "psr-4": { 34 | "Sajya\\Client\\Tests\\": "tests" 35 | } 36 | }, 37 | "scripts": { 38 | "test": "vendor/bin/phpunit" 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /phpunit.xml.dist: -------------------------------------------------------------------------------- 1 | 2 | 6 | 7 | 8 | ./tests 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | -------------------------------------------------------------------------------- /src/Client.php: -------------------------------------------------------------------------------- 1 | http = $http->acceptJson()->asJson(); 34 | } 35 | 36 | /** 37 | * @param callable $callback 38 | * 39 | * @return Collection 40 | */ 41 | public function batch(callable $callback) 42 | { 43 | $this->isBatch = true; 44 | 45 | $callback($this); 46 | 47 | $response = $this->http->post('', $this->batch); 48 | 49 | $this->isBatch = false; 50 | $this->batch = []; 51 | 52 | return $response->collect()->mapWithKeys(function ($content) use ($response) { 53 | $rpcResponse = $this->prepareResponse(collect($content), $response); 54 | 55 | return [$rpcResponse->id() => $rpcResponse]; 56 | }); 57 | } 58 | 59 | /** 60 | * @param \Illuminate\Support\Collection $data 61 | * @param \GuzzleHttp\Promise\PromiseInterface|\Illuminate\Http\Client\Response $response 62 | * 63 | * @return \Sajya\Client\Response 64 | */ 65 | protected function prepareResponse(Collection $data, $response) 66 | { 67 | return new Response( 68 | $data->get('id'), 69 | $data->get('result'), 70 | $data->get('error'), 71 | $response 72 | ); 73 | } 74 | 75 | /** 76 | * @param string $method 77 | * @param array|null $params 78 | * @param string|null $id 79 | * 80 | * @return \Sajya\Client\Response|void 81 | */ 82 | public function execute(string $method, array $params = null, string $id = null) 83 | { 84 | return $this->request($method, $params, $id ?? Str::uuid()->toString()); 85 | } 86 | 87 | /** 88 | * @param string $method 89 | * @param array $params 90 | * @param string|null $id 91 | * 92 | * @return $this|\GuzzleHttp\Promise\PromiseInterface|\Illuminate\Http\Client\Response 93 | */ 94 | protected function request(string $method, ?array $params, string $id = null) 95 | { 96 | $data = collect([ 97 | 'jsonrpc' => '2.0', 98 | 'id' => $id, 99 | 'params' => $params, 100 | 'method' => $method, 101 | ])->filter()->toArray(); 102 | 103 | if ($this->isBatch) { 104 | $this->batch[] = $data; 105 | 106 | return $this; 107 | } 108 | 109 | return $this->call($data); 110 | } 111 | 112 | /** 113 | * @param array $data 114 | * 115 | * @return \Sajya\Client\Response 116 | */ 117 | protected function call(array $data) 118 | { 119 | $response = $this->http->post('', $data); 120 | 121 | return $this->prepareResponse($response->collect(), $response); 122 | } 123 | 124 | /** 125 | * @param string $method 126 | * @param array $params 127 | * 128 | * @return static 129 | */ 130 | public function notify(string $method, array $params = []) 131 | { 132 | return $this->request($method, $params); 133 | } 134 | } 135 | -------------------------------------------------------------------------------- /src/Response.php: -------------------------------------------------------------------------------- 1 | id = $id; 25 | $this->result = $result; 26 | $this->error = $error; 27 | $this->response = $response; 28 | } 29 | 30 | /** 31 | * @return \GuzzleHttp\Promise\PromiseInterface|\Illuminate\Http\Client\Response 32 | */ 33 | public function response() 34 | { 35 | return $this->response; 36 | } 37 | 38 | /** 39 | * @return mixed 40 | */ 41 | public function result() 42 | { 43 | return $this->result; 44 | } 45 | 46 | /** 47 | * @return mixed 48 | */ 49 | public function error() 50 | { 51 | return $this->error; 52 | } 53 | 54 | /** 55 | * @return string|null 56 | */ 57 | public function id(): ?string 58 | { 59 | return $this->id; 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /tests/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sajya/client/d0606f39c0ce90889552722ead567e7630f92ec8/tests/.gitkeep -------------------------------------------------------------------------------- /tests/FixtureProcedure.php: -------------------------------------------------------------------------------- 1 | validate([ 37 | 'a' => 'integer|required', 38 | 'b' => 'integer|required', 39 | ]); 40 | 41 | return $request->get('a') + $request->get('b'); 42 | } 43 | 44 | /** 45 | * @return mixed 46 | */ 47 | public function runtimeError() 48 | { 49 | throw new RuntimeRpcException(); 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /tests/HttpClientTest.php: -------------------------------------------------------------------------------- 1 | getClient()->execute('fixture@ok'); 16 | 17 | $this->assertEquals('Ok', $response->result()); 18 | $this->assertNotNull($response->id()); 19 | $this->assertNull($response->error()); 20 | } 21 | 22 | public function testExecuteId(): void 23 | { 24 | $response = $this->getClient()->execute('fixture@ok', null, 'any-id'); 25 | 26 | $this->assertEquals('any-id', $response->id()); 27 | } 28 | 29 | public function testNotify(): void 30 | { 31 | $response = $this->getClient()->notify('fixture@ok'); 32 | 33 | $this->assertEmpty($response->id()); 34 | } 35 | 36 | public function testExecuteArgument(): void 37 | { 38 | $response = $this->getClient()->execute('fixture@sum', ['a' => 1, 'b' => 2]); 39 | 40 | $this->assertEquals(3, $response->result()); 41 | $this->assertNotNull($response->id()); 42 | $this->assertNull($response->error()); 43 | } 44 | 45 | public function testExecuteError(): void 46 | { 47 | $response = $this->getClient()->execute('fixture@runtimeError'); 48 | 49 | $this->assertNull($response->result()); 50 | $this->assertNotNull($response->id()); 51 | $this->assertNotNull($response->error()); 52 | } 53 | 54 | public function testBatchRequest(): void 55 | { 56 | $batch = $this->getClient()->batch(function (Client $client) { 57 | $client->execute('fixture@sum', ['a' => 100, 'b' => 100], 'first'); 58 | $client->execute('fixture@sum', ['a' => 50, 'b' => 50], 'second'); 59 | $client->execute('fixture@runtimeError', null, 'third'); 60 | $client->notify('fixture@ok'); 61 | }); 62 | 63 | $this->assertCount(3, $batch); 64 | 65 | $this->assertEquals(200, $batch->get('first')->result()); 66 | $this->assertEquals(100, $batch->get('second')->result()); 67 | 68 | $this->assertNotNull($batch->get('third')->error()); 69 | Http::assertSentCount(1); 70 | } 71 | 72 | /** 73 | * @return Client 74 | */ 75 | protected function getClient(): Client 76 | { 77 | return new Client(Http::baseUrl('http://localhost:8000/api/v1/endpoint')); 78 | } 79 | 80 | /** 81 | * Setup the test environment. 82 | * 83 | * @return void 84 | */ 85 | protected function setUp(): void 86 | { 87 | parent::setUp(); 88 | 89 | Http::fake(function (Request $request) { 90 | 91 | $app = class_exists(\Sajya\Server\App::class) 92 | ? \Sajya\Server\App::class 93 | : \Sajya\Server\Guide::class; 94 | 95 | $guide = new $app([FixtureProcedure::class]); 96 | $response = $guide->terminate($request->body()); 97 | 98 | return Http::response(json_encode($response, JSON_THROW_ON_ERROR)); 99 | }); 100 | } 101 | } 102 | -------------------------------------------------------------------------------- /tests/TestCase.php: -------------------------------------------------------------------------------- 1 |