├── LICENSE.md ├── README.md ├── composer.json ├── config └── sajya.php ├── src ├── App.php ├── Attributes │ └── RpcMethod.php ├── Binding.php ├── Commands │ ├── DocsCommand.php │ └── ProcedureMakeCommand.php ├── Docs.php ├── Exceptions │ ├── InternalErrorException.php │ ├── InvalidParams.php │ ├── InvalidRequestException.php │ ├── MaxBatchSizeExceededException.php │ ├── MethodNotFound.php │ ├── ParseErrorException.php │ ├── RpcException.php │ └── RuntimeRpcException.php ├── Facades │ └── RPC.php ├── HandleProcedure.php ├── Http │ ├── Parser.php │ ├── Request.php │ └── Response.php ├── JsonRpcController.php ├── Middleware │ └── GzipCompress.php ├── Procedure.php ├── Proxy.php ├── Rules │ └── Identifier.php ├── ServerServiceProvider.php └── Testing │ └── ProceduralRequests.php ├── stubs └── procedure.stub └── views └── docs.blade.php /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 | [](https://github.com/sajya/server/actions/workflows/phpunit.yml) 4 | [](https://github.com/sajya/server/actions/workflows/quality.yml) 5 | [](https://github.com/sajya/server/actions/workflows/php-cs-fixer.yml) 6 | [](https://codecov.io/gh/sajya/server) 7 | [](https://packagist.org/packages/sajya/server) 8 | 9 | Sajya empowers Laravel developers to bring the full power of JSON-RPC 2.0 to life. With Sajya, you can: 10 | 11 | * **Achieve Complete JSON-RPC 2.0 Compliance:** Seamlessly implement every feature of the specification. 12 | * **Ensure Robust Validation:** Automatically validate parameters to keep your interactions safe and sound. 13 | * **Handle Batch & Notification Requests:** Easily manage multiple requests or fire-and-forget notifications. 14 | * **Set Up in a Snap:** Get your JSON-RPC server up and running with minimal fuss. 15 | 16 | It’s all about making your life easier so you can focus on building amazing applications. 17 | 18 | ## Official Documentation 19 | 20 | Discover all there is to know about Sajya on our [website](https://sajya.github.io/). You'll find everything from 21 | installation guides to deep dives into each feature. 22 | 23 | ## Changelog 24 | 25 | Curious about what’s new? Check out our [CHANGELOG](CHANGELOG.md) to keep up with the latest updates and improvements. 26 | 27 | ## Contributing 28 | 29 | We’re all about community! Whether you have a bug fix, a new feature idea, or an improvement for our docs, we’d love to 30 | hear from you. 31 | 32 | ## Maintainers 33 | 34 | Sajya is proudly maintained by [Alexandr Chernyaev](https://github.com/tabuna). Feel free to reach out or get involved! 35 | 36 | ## License 37 | 38 | Sajya is released under the MIT License. See the [License File](LICENSE.md) for more details. 39 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "sajya/server", 3 | "description": "Easy implementation of the JSON-RPC 2.0 server for the Laravel framework.", 4 | "keywords": [ 5 | "rpc", 6 | "json-prc", 7 | "api" 8 | ], 9 | "homepage": "https://sajya.github.io", 10 | "license": "MIT", 11 | "authors": [ 12 | { 13 | "name": "Alexandr Chernyaev", 14 | "email": "bliz48rus@gmail.com" 15 | } 16 | ], 17 | "require": { 18 | "ext-json": "*", 19 | "laravel/framework": "^10.0|^11.0|^12.0" 20 | }, 21 | "require-dev": { 22 | "laravel/pint": "^v1.20", 23 | "orchestra/testbench": "^8.0|^9.0|^10.0", 24 | "phpunit/phpunit": "^10.5|^11.0", 25 | "phpunit/php-code-coverage": "^10.|^11.0|^12.0", 26 | "vimeo/psalm": "^5.0 | ^6.0" 27 | }, 28 | "conflict": { 29 | "league/flysystem": "<3.0.16", 30 | "mockery/mockery": "<1.6.1", 31 | "laravel/framework": ">=10.0.0,<=10.48.27 || >11.0.0,<=11.42.0", 32 | "orchestra/testbench": ">=9.0.0,<9.8.2" 33 | }, 34 | "autoload": { 35 | "psr-4": { 36 | "Sajya\\Server\\": "src" 37 | } 38 | }, 39 | "autoload-dev": { 40 | "psr-4": { 41 | "Sajya\\Server\\Tests\\": "tests" 42 | } 43 | }, 44 | "scripts": { 45 | "test": "vendor/bin/phpunit", 46 | "test-coverage": "vendor/bin/phpunit --coverage-html coverage" 47 | }, 48 | "config": { 49 | "sort-packages": true 50 | }, 51 | "suggest": { 52 | "ext-zlib": "Required to compress the response into gzip", 53 | "sajya/client": "HTTP(S) client for JSON-RPC 2.0" 54 | }, 55 | "prefer-stable": true, 56 | "extra": { 57 | "laravel": { 58 | "providers": [ 59 | "Sajya\\Server\\ServerServiceProvider" 60 | ], 61 | "aliases": { 62 | "RPC": "Sajya\\Server\\Facades\\RPC" 63 | } 64 | } 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /config/sajya.php: -------------------------------------------------------------------------------- 1 | '@', 25 | 26 | /* 27 | |-------------------------------------------------------------------------- 28 | | Maximum Batch Size 29 | |-------------------------------------------------------------------------- 30 | | 31 | | This value defines the maximum number of JSON-RPC requests allowed in 32 | | a single batch request. Adjust this according to your application's 33 | | requirements and server capabilities. 34 | | 35 | | Example: 36 | | 37 | | If you anticipate handling large batches of requests, you may need to 38 | | increase this value. To allow an unlimited number of requests in a 39 | | single batch, you can set this value to PHP_INT_MAX. 40 | | 41 | */ 42 | 43 | 'max_batch_size' => 30, 44 | 45 | /* 46 | |-------------------------------------------------------------------------- 47 | | Encode Options 48 | |-------------------------------------------------------------------------- 49 | | 50 | | This value defines the JSON encoding options used when encoding 51 | | JSON-RPC responses. Refer to the PHP json_encode documentation 52 | | for available options. 53 | | 54 | | Example: 55 | | 56 | | If you want to format the JSON response for better readability, you can 57 | | include the JSON_PRETTY_PRINT option. This will add indentation and 58 | | line breaks to the JSON output. 59 | | 60 | */ 61 | 62 | 'encode_options' => 0, 63 | ]; 64 | -------------------------------------------------------------------------------- /src/App.php: -------------------------------------------------------------------------------- 1 | map = collect($procedures) 48 | ->each(fn (string $class) => abort_unless( 49 | is_subclass_of($class, Procedure::class), 50 | 500, 51 | "Class '$class' must extends ".Procedure::class 52 | )); 53 | $this->delimiter = $delimiter ?? config('sajya.delimiter', self::DEFAULT_DELIMITER); 54 | } 55 | 56 | /** 57 | * Terminate the application and return the JSON-RPC response. 58 | * 59 | * @param string $content 60 | * 61 | * @return Response[]|Response|null 62 | */ 63 | public function terminate(string $content = '') 64 | { 65 | return tap($this->handle($content), fn () => LaravelApplication::terminate()); 66 | } 67 | 68 | /** 69 | * Handles a JSON-RPC request or batch of requests. 70 | * 71 | * @param string $content 72 | * 73 | * @return Response[]|Response|null 74 | */ 75 | public function handle(string $content = '') 76 | { 77 | $parser = new Parser($content); 78 | 79 | if ($this->checkBatchSizeWithinLimit($parser->countBatchingRequests())) { 80 | return $this->makeResponse(new MaxBatchSizeExceededException); 81 | } 82 | 83 | $result = collect($parser->makeRequests()) 84 | ->map( 85 | fn ($request) => $request instanceof Request 86 | ? $this->handleProcedure($request, $request->isNotification()) 87 | : $this->makeResponse($request) 88 | ) 89 | ->reject(fn (Response $response) => $response->isNotification()) 90 | ->values(); 91 | 92 | return $parser->isBatch() 93 | ? $result->all() 94 | : $result->first(); 95 | } 96 | 97 | /** 98 | * Search for a procedure and execute it. 99 | * 100 | * @param Request $request 101 | * @param bool $notification 102 | * 103 | * @return Response 104 | */ 105 | public function handleProcedure(Request $request, bool $notification): Response 106 | { 107 | request()->replace($request->getParams()->toArray()); 108 | app()->bind(Request::class, fn () => $request); 109 | 110 | $procedure = $this->findProcedure($request); 111 | 112 | if ($procedure === null) { 113 | return $this->makeResponse(new MethodNotFound, $request); 114 | } 115 | 116 | $result = $notification 117 | ? HandleProcedure::dispatchAfterResponse($procedure, $request) 118 | : (new HandleProcedure($procedure, $request))->handle(); 119 | 120 | return $this->makeResponse($result, $request); 121 | } 122 | 123 | /** 124 | * Find procedure by request. 125 | * 126 | * @param Request $request 127 | * 128 | * @return null|string 129 | */ 130 | public function findProcedure(Request $request): ?string 131 | { 132 | $class = Str::beforeLast($request->getMethod(), $this->delimiter); 133 | $method = Str::afterLast($request->getMethod(), $this->delimiter); 134 | 135 | return $this->map 136 | ->filter(fn (string $procedure) => $this->getProcedureName($procedure) === $class) 137 | ->filter(fn (string $procedure) => $this->checkExistPublicMethod($procedure, $method)) 138 | ->map(fn (string $procedure) => Str::finish($procedure, self::DEFAULT_DELIMITER.$method)) 139 | ->whenEmpty(fn (Collection $collection) => $collection->push($this->findProxy($class))) 140 | ->first(); 141 | } 142 | 143 | /** 144 | * Get the name of the procedure for the given class. 145 | * 146 | * @param string $procedure 147 | * 148 | * @return string 149 | */ 150 | private function getProcedureName(string $procedure): string 151 | { 152 | return (new ReflectionClass($procedure))->getStaticPropertyValue('name'); 153 | } 154 | 155 | /** 156 | * Check if the given procedure has a public method with the given name. 157 | * 158 | * @param string $procedure 159 | * @param string $method 160 | * 161 | * @return bool 162 | */ 163 | private function checkExistPublicMethod(string $procedure, string $method): bool 164 | { 165 | return method_exists($procedure, $method) && (new ReflectionMethod($procedure, $method))->isPublic(); 166 | } 167 | 168 | /** 169 | * Fallback to the first procedure that implements the Proxy interface. 170 | * 171 | * @param string $class 172 | * 173 | * @return null|string 174 | */ 175 | public function findProxy(string $class): ?string 176 | { 177 | return $this->map 178 | ->filter(fn (string $procedure) => $this->getProcedureName($procedure) === $class) 179 | ->filter(fn (string $procedure) => is_subclass_of($procedure, Proxy::class)) 180 | ->map(fn (string $procedure) => Str::finish($procedure, self::DEFAULT_DELIMITER.'__invoke')) 181 | ->first(); 182 | } 183 | 184 | /** 185 | * Create a Response object from the given result and Request object. 186 | * 187 | * @param mixed|null $result 188 | * @param Request|null $request 189 | * 190 | * @return Response 191 | */ 192 | public function makeResponse($result = null, ?Request $request = null): Response 193 | { 194 | return Response::makeFromResult($result, $request); 195 | } 196 | 197 | /** 198 | * Checks if the number of requests in the batch exceeds the maximum allowed size. 199 | * 200 | * @param int $currentRequests 201 | * 202 | * @return bool 203 | */ 204 | public function checkBatchSizeWithinLimit(int $currentRequests): bool 205 | { 206 | $maxBatchSize = config('sajya.max_batch_size', 50); 207 | 208 | return $currentRequests > $maxBatchSize; 209 | } 210 | } 211 | -------------------------------------------------------------------------------- /src/Attributes/RpcMethod.php: -------------------------------------------------------------------------------- 1 | container = $container; 41 | } 42 | 43 | /** 44 | * Register a model binder for a wildcard. 45 | * 46 | * @param string $key 47 | * @param string $class 48 | * @param \Closure|null $callback 49 | * 50 | * @return void 51 | */ 52 | public function model(string $key, string $class, ?Closure $callback = null): void 53 | { 54 | $this->bind($key, RouteBinding::forModel($this->container, $class, $callback)); 55 | } 56 | 57 | /** 58 | * Add a new route parameter binder. 59 | * 60 | * @param string $key 61 | * @param Closure|string $binder 62 | * 63 | * @return void 64 | */ 65 | public function bind(string $key, $binder): void 66 | { 67 | $this->binders[$key] = RouteBinding::forCallback( 68 | $this->container, $binder 69 | ); 70 | } 71 | 72 | /** 73 | * Binds the values of the given parameters to their corresponding type. 74 | * 75 | * @param string $procedure 76 | * @param \Illuminate\Support\Collection $params 77 | * 78 | * @throws \ReflectionException 79 | * 80 | * @return array 81 | */ 82 | public function bindResolve(string $procedure, Collection $params): array 83 | { 84 | $class = new ReflectionClass(Str::before($procedure, '@')); 85 | $method = $class->getMethod(Str::after($procedure, '@')); 86 | 87 | return collect($method->getParameters()) 88 | ->map(fn (ReflectionParameter $parameter) => $parameter->getName()) 89 | ->mapWithKeys(function (string $key) use ($params) { 90 | $value = Arr::get($params, $key); 91 | $valueDot = Arr::get($params, Str::snake($key, '.')); 92 | 93 | return [$key => $value ?? $valueDot]; 94 | }) 95 | ->map(function ($value, string $key) { 96 | 97 | $closure = $this->binders[$key] ?? null; 98 | 99 | if (is_callable($closure)) { 100 | return app()->call($closure, [ 101 | 'value' => $value, 102 | 'route' => Route::current(), 103 | ]); 104 | } 105 | 106 | return $value; 107 | }) 108 | ->filter() 109 | ->toArray(); 110 | } 111 | } 112 | -------------------------------------------------------------------------------- /src/Commands/DocsCommand.php: -------------------------------------------------------------------------------- 1 | argument('route'); 40 | 41 | $route = Route::getRoutes()->getByName($routeName); 42 | 43 | if ($route === null) { 44 | $this->warn("Route '$routeName' not found"); 45 | 46 | return 1; 47 | } 48 | 49 | $docs = new Docs($route); 50 | 51 | $html = view('sajya::docs', [ 52 | 'title' => config('app.name'), 53 | 'uri' => route($routeName), 54 | 'procedures' => $docs->getAnnotations(), 55 | ]); 56 | 57 | Storage::disk()->put($this->option('path').$this->option('name'), $html->render()); 58 | $this->info('Documentation was generated successfully.'); 59 | 60 | return Command::SUCCESS; 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /src/Commands/ProcedureMakeCommand.php: -------------------------------------------------------------------------------- 1 | procedures = $route->defaults['procedures']; 36 | $this->delimiter = $route->defaults['delimiter'] ?? '@'; 37 | } 38 | 39 | /** 40 | * @return \Illuminate\Support\Collection 41 | */ 42 | public function getAnnotations(): Collection 43 | { 44 | return collect($this->procedures) 45 | ->map(function (string $class) { 46 | $reflectionClass = new ReflectionClass($class); 47 | $name = $reflectionClass->getProperty('name')->getValue(); 48 | 49 | return collect($reflectionClass->getMethods(ReflectionMethod::IS_PUBLIC)) 50 | ->map(function (ReflectionMethod $method) use ($name) { 51 | 52 | $attributes = $this->getMethodAnnotations($method); 53 | 54 | $request = [ 55 | 'jsonrpc' => '2.0', 56 | 'id' => 1, 57 | 'method' => $name.$this->delimiter.$method->getName(), 58 | 'params' => $attributes?->params, 59 | ]; 60 | 61 | $response = [ 62 | 'jsonrpc' => '2.0', 63 | 'id' => 1, 64 | 'result' => $attributes?->result, 65 | ]; 66 | 67 | return [ 68 | 'name' => $name, 69 | 'delimiter' => $this->delimiter, 70 | 'method' => $method->getName(), 71 | 'description' => $attributes?->description, 72 | 'params' => $attributes?->params, 73 | 'result' => $attributes?->result, 74 | 'request' => $this->highlight($request), 75 | 'response' => $this->highlight($response), 76 | ]; 77 | }); 78 | }) 79 | ->flatten(1); 80 | } 81 | 82 | private function getMethodAnnotations(ReflectionMethod $method): ?RpcMethod 83 | { 84 | $attributes = $method->getAttributes(RpcMethod::class); 85 | 86 | foreach ($attributes as $attribute) { 87 | /** @var RpcMethod $instance */ 88 | $instance = $attribute->newInstance(); 89 | 90 | return $instance; 91 | } 92 | 93 | return null; 94 | } 95 | 96 | /** 97 | * Highlights a JSON structure using HTML span tags with colors. 98 | * 99 | * @param array $value The JSON data to be highlighted. 100 | * 101 | * @throws \JsonException If encoding fails. 102 | * 103 | * @return \Illuminate\Support\Stringable The highlighted JSON as a string. 104 | */ 105 | private function highlight(array $value): Stringable 106 | { 107 | $json = json_encode($value, JSON_THROW_ON_ERROR | JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE); 108 | 109 | return Str::of($json) 110 | // Highlight keys (both string and numeric) 111 | ->replaceMatches('/"(\w+)":/i', '"$1":') 112 | ->replaceMatches('/"(\d+)":/i', '"$1":') 113 | 114 | // Highlight null values 115 | ->replaceMatches('/":\s*(null)/i', '": $1') 116 | 117 | // Highlight string values 118 | ->replaceMatches('/":\s*"([^"]*)"/', '": "$1"') 119 | 120 | // Highlight numeric values 121 | ->replaceMatches('/":\s*(\d+(\.\d+)?)/', '": $1') 122 | 123 | // Highlight boolean values (true/false) 124 | ->replaceMatches('/":\s*(true|false)/i', '": $1') 125 | 126 | ->wrap('
', ''); 127 | } 128 | } 129 | -------------------------------------------------------------------------------- /src/Exceptions/InternalErrorException.php: -------------------------------------------------------------------------------- 1 | setData($data); 19 | } 20 | 21 | /** 22 | * Internal JSON-RPC error. 23 | */ 24 | protected function getDefaultCode(): int 25 | { 26 | return -32603; 27 | } 28 | 29 | /** 30 | * A String providing a short description of the error. 31 | * The message SHOULD be limited to a concise single sentence. 32 | */ 33 | protected function getDefaultMessage(): string 34 | { 35 | return 'Internal error'; 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /src/Exceptions/InvalidParams.php: -------------------------------------------------------------------------------- 1 | setData($data); 19 | } 20 | 21 | /** 22 | * Invalid method parameter(s). 23 | */ 24 | protected function getDefaultCode(): int 25 | { 26 | return -32602; 27 | } 28 | 29 | /** 30 | * A String providing a short description of the error. 31 | * The message SHOULD be limited to a concise single sentence. 32 | */ 33 | protected function getDefaultMessage(): string 34 | { 35 | return 'Invalid params'; 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /src/Exceptions/InvalidRequestException.php: -------------------------------------------------------------------------------- 1 | setData($data); 19 | } 20 | 21 | /** 22 | * The JSON sent is not a valid Request object. 23 | */ 24 | protected function getDefaultCode(): int 25 | { 26 | return -32600; 27 | } 28 | 29 | /** 30 | * A String providing a short description of the error. 31 | * The message SHOULD be limited to a concise single sentence. 32 | */ 33 | protected function getDefaultMessage(): string 34 | { 35 | return 'Invalid Request'; 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /src/Exceptions/MaxBatchSizeExceededException.php: -------------------------------------------------------------------------------- 1 | setData($data); 19 | } 20 | 21 | /** 22 | * Retrieve the error code. 23 | */ 24 | protected function getDefaultCode(): int 25 | { 26 | return -32000; 27 | } 28 | 29 | /** 30 | * Retrieve the error message. 31 | */ 32 | protected function getDefaultMessage(): string 33 | { 34 | return 'Maximum batch size exceeded.'; 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /src/Exceptions/MethodNotFound.php: -------------------------------------------------------------------------------- 1 | setData($data); 19 | } 20 | 21 | /** 22 | * The method does not exist / is not available. 23 | */ 24 | protected function getDefaultCode(): int 25 | { 26 | return -32601; 27 | } 28 | 29 | /** 30 | * A String providing a short description of the error. 31 | * The message SHOULD be limited to a concise single sentence. 32 | */ 33 | protected function getDefaultMessage(): string 34 | { 35 | return 'Method not found'; 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /src/Exceptions/ParseErrorException.php: -------------------------------------------------------------------------------- 1 | setData($data); 19 | } 20 | 21 | /** 22 | * Invalid JSON was received by the server. 23 | * An error occurred on the server while parsing the JSON text. 24 | */ 25 | protected function getDefaultCode(): int 26 | { 27 | return -32700; 28 | } 29 | 30 | /** 31 | * A String providing a short description of the error. 32 | * The message SHOULD be limited to a concise single sentence. 33 | */ 34 | protected function getDefaultMessage(): string 35 | { 36 | return 'Parse error'; 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /src/Exceptions/RpcException.php: -------------------------------------------------------------------------------- 1 | getDefaultMessage(), 29 | $code ?? $this->getDefaultCode(), 30 | $previous 31 | ); 32 | } 33 | 34 | /** 35 | * A String providing a short description of the error. 36 | * The message SHOULD be limited to a concise single sentence. 37 | * 38 | * @return string 39 | */ 40 | abstract protected function getDefaultMessage(): string; 41 | 42 | /** 43 | * A Number that indicates the error type that occurred. 44 | * This MUST be an integer. 45 | * 46 | * @return int 47 | */ 48 | abstract protected function getDefaultCode(): int; 49 | 50 | /** 51 | * A Primitive or Structured value that contains additional information about the error. 52 | * This may be omitted. 53 | */ 54 | public function getData() 55 | { 56 | return $this->data; 57 | } 58 | 59 | /** 60 | * @param mixed|null $data 61 | * 62 | * @return $this 63 | */ 64 | public function setData($data = null): RpcException 65 | { 66 | $this->data = $data; 67 | 68 | return $this; 69 | } 70 | 71 | /** 72 | * @return array{code:int, message:?string, data:?array} 73 | */ 74 | public function jsonSerialize(): array 75 | { 76 | $message = [ 77 | 'code' => $this->getCode(), 78 | 'message' => $this->getMessage(), 79 | 'data' => $this->getData(), 80 | ]; 81 | 82 | if (config('app.debug', false)) { 83 | $message = array_merge($message, [ 84 | 'file' => $this->getFile(), 85 | 'line' => $this->getLine(), 86 | 'trace' => $this->getTraceAsString(), 87 | ]); 88 | } 89 | 90 | return $message; 91 | } 92 | 93 | /** 94 | * Report the exception. 95 | * 96 | * @return bool|null 97 | */ 98 | public function report(): ?bool 99 | { 100 | return true; 101 | } 102 | } 103 | -------------------------------------------------------------------------------- /src/Exceptions/RuntimeRpcException.php: -------------------------------------------------------------------------------- 1 | procedure = $procedure; 49 | $this->request = $request; 50 | } 51 | 52 | /** 53 | * Execute the job. 54 | */ 55 | public function handle() 56 | { 57 | try { 58 | $parameters = RPC::bindResolve($this->procedure, $this->request->getParams()); 59 | 60 | return App::call($this->procedure, $parameters); 61 | } catch (Throwable $exception) { 62 | return $this->handleException($exception); 63 | } 64 | } 65 | 66 | /** 67 | * Handle the exception into JSON-RPC. 68 | * 69 | * @param Throwable $exception 70 | * 71 | * @return string|RpcException|\Illuminate\Http\Response 72 | */ 73 | protected function handleException(Throwable $exception) 74 | { 75 | report($exception); 76 | 77 | if ($exception instanceof ValidationException) { 78 | return new InvalidParams($exception->validator->errors()->toArray()); 79 | } 80 | 81 | if ($exception instanceof RpcException) { 82 | return $exception; 83 | } 84 | 85 | $code = method_exists($exception, 'getStatusCode') 86 | ? $exception->getStatusCode() 87 | : $exception->getCode(); 88 | 89 | if ($code === 500) { 90 | return new InternalErrorException; 91 | } 92 | 93 | if (! is_int($code)) { 94 | $code = -1; 95 | } 96 | 97 | return new RuntimeRpcException($exception->getMessage(), $code); 98 | } 99 | } 100 | -------------------------------------------------------------------------------- /src/Http/Parser.php: -------------------------------------------------------------------------------- 1 | content = $content; 56 | 57 | try { 58 | $decode = json_decode($content, true, 512, JSON_THROW_ON_ERROR); 59 | 60 | $this->decode = collect($decode); 61 | $this->batching = $this->decode->isNotEmpty() && Arr::isList($this->decode->toArray()); 62 | 63 | $emptyIdRequest = $this->decode 64 | ->when(! $this->batching, fn ($request) => collect([$request])) 65 | ->first(fn ($value) => ! isset($value['id'])); 66 | 67 | $this->notification = $emptyIdRequest !== null; 68 | } catch (Exception|TypeError $e) { 69 | $this->decode = collect(); 70 | $this->isParseError = true; 71 | } 72 | } 73 | 74 | /** 75 | * Check if the response is an error. 76 | * 77 | * @return bool 78 | */ 79 | public function isError(): bool 80 | { 81 | return $this->isParseError; 82 | } 83 | 84 | /** 85 | * @return Collection 86 | */ 87 | public function getContent(): Collection 88 | { 89 | return $this->decode; 90 | } 91 | 92 | /** 93 | * @return Request[]|Exception[] 94 | */ 95 | public function makeRequests(): array 96 | { 97 | $content = $this->getContent(); 98 | 99 | if ($this->isBatch()) { 100 | return $content 101 | ->map(fn ($options) => $this->checkValidation($options)) 102 | ->whenEmpty(fn (Collection $collection) => $collection->push($this->checkValidation())) 103 | ->map(fn ($options) => $options instanceof Exception ? $options : Request::loadArray($options)) 104 | ->toArray(); 105 | } 106 | 107 | $options = $this->checkValidation($content->toArray()); 108 | 109 | return [is_array($options) ? Request::loadArray($options) : $options]; 110 | } 111 | 112 | /** 113 | * Determine whether the current request is a batch request. 114 | * 115 | * @return bool 116 | */ 117 | public function isBatch(): bool 118 | { 119 | return $this->batching; 120 | } 121 | 122 | /** 123 | * Determine whether the current request is a notification request. 124 | * 125 | * @return bool 126 | */ 127 | public function isNotification(): bool 128 | { 129 | return $this->notification; 130 | } 131 | 132 | /** 133 | * Count the number of requests in a batch request. 134 | * 135 | * @return int 136 | */ 137 | public function countBatchingRequests(): int 138 | { 139 | return $this->getContent()->count(); 140 | } 141 | 142 | /** 143 | * @param bool|string|array|int $options 144 | * 145 | * @return InvalidParams|ParseErrorException|InvalidRequestException|array 146 | */ 147 | public function checkValidation($options = []) 148 | { 149 | if ($this->isError()) { 150 | return new ParseErrorException; 151 | } 152 | 153 | if (! is_array($options) || Arr::isList($options)) { 154 | return new InvalidRequestException; 155 | } 156 | 157 | $data = $options; 158 | 159 | // skip deep parameters for validator 160 | if (isset($options['params']) && is_array($options['params'])) { 161 | $data['params'] = []; 162 | } 163 | 164 | $validation = Validator::make($data, self::rules()); 165 | 166 | return $validation->fails() 167 | ? new InvalidParams($validation->errors()->toArray()) 168 | : $options; 169 | } 170 | 171 | /** 172 | * Defines the rules for validating a JSON-RPC 2.0 request. 173 | * 174 | * @return array 175 | */ 176 | public static function rules(): array 177 | { 178 | return [ 179 | 'jsonrpc' => 'required|in:"2.0"', 180 | 'method' => 'required|string', 181 | 'params' => 'array', 182 | 'id' => new Identifier, 183 | ]; 184 | } 185 | } 186 | -------------------------------------------------------------------------------- /src/Http/Request.php: -------------------------------------------------------------------------------- 1 | params = collect(); 45 | } 46 | 47 | /** 48 | * Set request state based on array. 49 | * 50 | * @param array $collection 51 | * 52 | * @return Request 53 | */ 54 | public static function loadArray(array $collection): Request 55 | { 56 | $request = new static; 57 | $methods = get_class_methods($request); 58 | 59 | collect($collection) 60 | ->each(static function ($value, string $key) use ($request, $methods) { 61 | $method = Str::start(ucfirst($key), 'set'); 62 | 63 | if (in_array($method, $methods, true)) { 64 | $request->$method($value); 65 | } 66 | 67 | if ($key === 'jsonrpc') { 68 | $request->setVersion($value); 69 | } 70 | }); 71 | 72 | return $request; 73 | } 74 | 75 | /** 76 | * @return array 77 | */ 78 | public function jsonSerialize(): array 79 | { 80 | $jsonArray = [ 81 | 'jsonrpc' => $this->getVersion(), 82 | 'method' => $this->getMethod(), 83 | ]; 84 | 85 | if ($this->getParams()->isNotEmpty()) { 86 | $jsonArray['params'] = $this->getParams()->toArray(); 87 | } 88 | 89 | if (null !== ($id = $this->getId())) { 90 | $jsonArray['id'] = $id; 91 | } 92 | 93 | return $jsonArray; 94 | } 95 | 96 | /** 97 | * Retrieve JSON-RPC version. 98 | * 99 | * @return string 100 | */ 101 | public function getVersion(): string 102 | { 103 | return $this->version; 104 | } 105 | 106 | /** 107 | * Set JSON-RPC version. 108 | * 109 | * @param string $version 110 | * 111 | * @return Request 112 | */ 113 | public function setVersion(string $version = '2.0'): Request 114 | { 115 | $this->version = $version; 116 | 117 | return $this; 118 | } 119 | 120 | /** 121 | * Get request method name. 122 | * 123 | * @return string|null 124 | */ 125 | public function getMethod(): ?string 126 | { 127 | return $this->method; 128 | } 129 | 130 | /** 131 | * Set request method. 132 | * 133 | * @param string $name 134 | * 135 | * @return Request 136 | */ 137 | public function setMethod(string $name): Request 138 | { 139 | $this->method = $name; 140 | 141 | return $this; 142 | } 143 | 144 | /** 145 | * Retrieve parameters. 146 | * 147 | * @return Collection 148 | */ 149 | public function getParams(): Collection 150 | { 151 | return $this->params; 152 | } 153 | 154 | /** 155 | * Overwrite params. 156 | * 157 | * @param array $params 158 | * 159 | * @return Request 160 | */ 161 | public function setParams(array $params): self 162 | { 163 | $this->params = $this->params->merge($params); 164 | 165 | return $this; 166 | } 167 | 168 | /** 169 | * Retrieve request identifier. 170 | * 171 | * @return string|null|int 172 | */ 173 | public function getId() 174 | { 175 | return $this->id; 176 | } 177 | 178 | /** 179 | * Set request identifier. 180 | * 181 | * @param int|string $name 182 | * 183 | * @return Request 184 | */ 185 | public function setId($name): Request 186 | { 187 | $this->id = (string) $name; 188 | 189 | return $this; 190 | } 191 | 192 | /** 193 | * @return bool 194 | */ 195 | public function isNotification(): bool 196 | { 197 | return empty($this->getId()); 198 | } 199 | } 200 | -------------------------------------------------------------------------------- /src/Http/Response.php: -------------------------------------------------------------------------------- 1 | $response->setId($request->getId()) 46 | ->setVersion($request->getVersion()) 47 | ->setResult($result) 48 | ); 49 | } 50 | 51 | public function jsonSerialize(): array 52 | { 53 | $response = ['id' => $this->getId()]; 54 | 55 | if ($this->isError()) { 56 | $response['error'] = $this->getError(); 57 | } else { 58 | $response['result'] = $this->getResult(); 59 | } 60 | 61 | if (null !== ($version = $this->getVersion())) { 62 | $response['jsonrpc'] = $version; 63 | } 64 | 65 | return $response; 66 | } 67 | 68 | /** 69 | * Get request ID. 70 | * 71 | * @return string|int|null 72 | */ 73 | public function getId() 74 | { 75 | return $this->id; 76 | } 77 | 78 | /** 79 | * Set request ID. 80 | * 81 | * @param string|int|null $name 82 | */ 83 | public function setId($name): self 84 | { 85 | $this->id = $name; 86 | 87 | return $this; 88 | } 89 | 90 | /** 91 | * Is the response an error? 92 | */ 93 | public function isError(): bool 94 | { 95 | return $this->getError() instanceof Exception; 96 | } 97 | 98 | /** 99 | * Get response error. 100 | */ 101 | public function getError(): ?Exception 102 | { 103 | return $this->error; 104 | } 105 | 106 | /** 107 | * Get result. 108 | */ 109 | public function getResult() 110 | { 111 | return $this->result; 112 | } 113 | 114 | /** 115 | * Set result. 116 | */ 117 | public function setResult($value): self 118 | { 119 | if ($value instanceof Exception) { 120 | $this->setError($value); 121 | 122 | return $this; 123 | } 124 | 125 | $this->result = $value; 126 | 127 | return $this; 128 | } 129 | 130 | /** 131 | * Retrieve JSON-RPC version. 132 | */ 133 | public function getVersion(): ?string 134 | { 135 | return $this->version; 136 | } 137 | 138 | /** 139 | * Set JSON-RPC version. 140 | */ 141 | public function setVersion(string $version): self 142 | { 143 | $this->version = $version; 144 | 145 | return $this; 146 | } 147 | 148 | /** 149 | * Set result error. 150 | * 151 | * RPC error, if response results in fault. 152 | */ 153 | public function setError(?Exception $error = null): Response 154 | { 155 | $this->error = $error; 156 | 157 | return $this; 158 | } 159 | 160 | public function isNotification(): bool 161 | { 162 | return empty($this->getId()) && $this->getError() === null; 163 | } 164 | } 165 | -------------------------------------------------------------------------------- /src/JsonRpcController.php: -------------------------------------------------------------------------------- 1 | handle($request->getContent()); 31 | 32 | return response()->json($response, 200, [], config('sajya.encode_options', 0)); 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /src/Middleware/GzipCompress.php: -------------------------------------------------------------------------------- 1 | getEncodings(), true) 21 | ? $this->compress($response, $compress) 22 | : $response; 23 | } 24 | 25 | /** 26 | * Compresses the content of the given response using gzip compression. 27 | * 28 | * @param JsonResponse $response 29 | * 30 | * @return \Illuminate\Http\JsonResponse 31 | */ 32 | protected function compress($response, int $compress) 33 | { 34 | $content = gzencode($response->content(), $compress); 35 | 36 | return $response->setContent($content)->withHeaders([ 37 | 'Content-Encoding' => 'gzip', 38 | ]); 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /src/Procedure.php: -------------------------------------------------------------------------------- 1 | publishes([ 30 | __DIR__.'/../config/sajya.php' => config_path('sajya.php'), 31 | ]); 32 | 33 | $this->registerViews(); 34 | } 35 | 36 | /** 37 | * Register the application services. 38 | */ 39 | public function register(): void 40 | { 41 | $this->commands($this->commands); 42 | 43 | $this->mergeConfigFrom( 44 | __DIR__.'/../config/sajya.php', 'sajya' 45 | ); 46 | 47 | Route::macro('rpc', fn (string $uri, array $procedures = [], ?string $delimiter = null) => Route::post($uri, [JsonRpcController::class, '__invoke']) 48 | ->setDefaults([ 49 | 'procedures' => $procedures, 50 | 'delimiter' => $delimiter, 51 | ])); 52 | 53 | $this->app->singleton(Binding::class, fn ($container) => new Binding($container)); 54 | } 55 | 56 | /** 57 | * Register views & Publish views. 58 | * 59 | * @return $this 60 | */ 61 | public function registerViews(): self 62 | { 63 | $path = __DIR__.'/../views'; 64 | 65 | $this->loadViewsFrom($path, 'sajya'); 66 | 67 | $this->publishes([ 68 | $path => resource_path('views/vendor/sajya'), 69 | ], 'views'); 70 | 71 | return $this; 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /src/Testing/ProceduralRequests.php: -------------------------------------------------------------------------------- 1 | rpcEndpoint = route($name); 26 | 27 | return $this; 28 | } 29 | 30 | /** 31 | * @param string $url 32 | * 33 | * @return $this 34 | */ 35 | public function setRpcUrl(string $url) 36 | { 37 | $this->rpcEndpoint = $url; 38 | 39 | return $this; 40 | } 41 | 42 | /** 43 | * Call the given method procedure and return the Response. 44 | * 45 | * @param string $method 46 | * @param array $content 47 | * @param string|int|null $id 48 | * 49 | * @return \Illuminate\Testing\TestResponse 50 | */ 51 | public function callProcedure(string $method, array $content = [], $id = 1): TestResponse 52 | { 53 | return $this 54 | ->callHttpProcedure($method, $content, $id) 55 | ->assertOk() 56 | ->assertHeader('content-type', 'application/json'); 57 | } 58 | 59 | /** 60 | * @param string $method 61 | * @param array $content 62 | * @param string|int|null $id 63 | * 64 | * @return \Illuminate\Testing\TestResponse 65 | */ 66 | public function callHttpProcedure(string $method, array $content = [], $id = 1): TestResponse 67 | { 68 | return $this->json('POST', $this->rpcEndpoint, [ 69 | 'jsonrpc' => '2.0', 70 | 'id' => $id, 71 | 'method' => $method, 72 | 'params' => $content, 73 | ]); 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /stubs/procedure.stub: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 11 | 12 |
60 | {{ $procedure['description'] ?? '' }} 61 |
62 |Request:
70 | {!! $procedure['request'] ?? '' !!} 71 |Response:
75 | {!! $procedure['response'] ?? '' !!} 76 |