diff --git a/README.md b/README.md index 4df507f..a20a1ba 100644 --- a/README.md +++ b/README.md @@ -19,6 +19,7 @@ Sajya is an open-source project aiming to implement the JSON-RPC 2.0 server spec - Quick and straightforward route adding - Validation of parameters and custom messages +- Parameter model binding to resolve method dependencies - Support batch requests - Support notification requests diff --git a/src/Binding/BindingServiceProvider.php b/src/Binding/BindingServiceProvider.php new file mode 100644 index 0000000..103891b --- /dev/null +++ b/src/Binding/BindingServiceProvider.php @@ -0,0 +1,320 @@ +container = $container ?: new Container(); + } + + /** + * Register a model binder for a request parameter. + * + * @param string|string[] $requestParam The parameter name in the RPC request to use for the model binding. + * If the parameter is nested, use an array, where each string + * corresponds to an attribute to look into, e.g. `['post','id']` will + * use the `id` attribute of the `post` attribute. + * The last or only attribute may also be suffixed with a colon and the + * field name to be used for the resolution, e.g.: `user:email` or + * `post:slug`. + * @param string $class The class name to resolve. + * @param string|callable|mixed[]|null $scope Optional, default: ''. + * For details see {@see RPC::bind()}. + * @param null|string $procedureMethodParam Optional, default: same as `$requestParam`. + * For details see {@see RPC::bind()}. + * @param null|\Closure $failureCallback Optional. If provided, it is called if the automatic model + * resolution fails and can be used to perform a custom resolution + * (return an instance to be used) or error handling. + * + * @return void + * + * @see \Illuminate\Routing\Router::model() + * @link https://laravel.com/docs/8.x/routing#explicit-binding + */ + public function model( + $requestParam, + string $class, + $scope = '', + $procedureMethodParam = null, + \Closure $failureCallback = null + ): void { + $this->bind( + $requestParam, + RouteBinding::forModel($this->container, $class, $failureCallback), + $scope, + $procedureMethodParam + ); + } + + /** + * Register a custom binder for a request parameter. + * + * @param string|string[] $requestParam The parameter name in the RPC request to use for the model + * binding. + * If the parameter is nested, use an array, where each string + * corresponds to an attribute to look into, + * e.g. `['post','id']` will use the `id` attribute of the + * `post` attribute. + * @param string|callable $binder The callback to perform the resolution. Should return the + * instance to be used. + * @param string|callable|mixed[]|null $scope Optional, default: ''. + * This defines where the binding will be applied: + * - Empty string: globally, for all Procedures & all methods + * - Procedure name: for all methods of the given Procedure + * - `Procedure@method`: for the given method + * - PHP callable: for the given method + * If array is provided, it may contain multiple strings and + * callables, each will be applied. + * @param null|string $procedureMethodParam Optional, default: same as `$requestParam` or last element + * Provide it, if the PHP method parameter has a different name + * than the RPC request parameter. + * + * @return void + * + * @see \Illuminate\Routing\Router::bind() + * @link https://laravel.com/docs/8.x/routing#customizing-the-resolution-logic + */ + public function bind( + $requestParam, + $binder, + $scope = '', + $procedureMethodParam = null + ): void { + if (is_null($procedureMethodParam)) { + $procedureMethodParam = is_array($requestParam) ? end($requestParam) : $requestParam; + $procedureMethodParam = explode(':', $procedureMethodParam)[0]; + } + $key = $this->makeKey($requestParam, $scope, $procedureMethodParam); + $this->binders[$key] = RouteBinding::forCallback($this->container, $binder); + $this->scopes[$key] = $scope; + $this->procedureMethodParams[$key] = $procedureMethodParam; + $this->requestParameters[$key] = $requestParam; + } + + /** + * Makes a key to be used with the arrays containing the bindings and related configuration. + * + * @param string|array $requestParam The parameter in the RPC request to bind for. + * @param string|callable|string[]|callable[] $scope See the `$bind` parameter of {@see bind()}. + * @param string|null $procedureMethod The parameter of the Procedure method to bind + * for. + * + * @throws \JsonException + * + * @return string + */ + private function makeKey($requestParam, $scope, ?string $procedureMethod): string + { + $json = json_encode([$requestParam, $scope, $procedureMethod], JSON_THROW_ON_ERROR); + + return sha1($json); + } + + /** + * Resolves the bound instance for a Procedure method parameter. + * + * @param array $requestParameters The parameters from the RPC request. + * @param string $targetParam The name of the parameter of the Procedure method to bind for. + * @param string|callable $targetCallable The target Procedure method to bind for. + * + * @throws BindingResolutionException + * + * @return false|mixed False if cannot resolve, the resolved instance otherwise. + */ + public function resolveInstance($requestParameters, $targetParam, $targetCallable = '') + { + try { + $key = $this->findKey($targetParam, $targetCallable); + if (false === $key) { + return false; + } + $requestParam = $this->requestParameters[$key]; + $value = static::resolveRequestValue($requestParameters, $requestParam); + if (is_null($value)) { + return false; + } + + return $this->performBinding($key, $value); + } catch (\Throwable $e) { + throw new BindingResolutionException('Failed to perform binding resolution.', -32003, $e); + } + } + + /** + * Finds the key used with the arrays for a specific Procedure method parameter. + * + * @see makeKey() + * + * @param string $targetParam The name of the parameter of the Procedure method to bind for. + * @param string|callable $targetCallable The target Procedure method to bind for. + * + * @return false|string False if cannot be found or the key otherwise. + */ + public function findKey($targetParam, $targetCallable = '') + { + foreach ($this->procedureMethodParams as $key => $boundProcedureMethodParam) { + if ($boundProcedureMethodParam !== $targetParam) { + continue; + } + + $maybeBoundScope = $this->scopes[$key]; + if (! is_array($maybeBoundScope)) { + $maybeBoundScope = [$maybeBoundScope]; + } + foreach ($maybeBoundScope as $container) { + if (self::doesCallableContain($container, $targetCallable)) { + return $key; + } + } + } + + return false; + } + + /** + * Checks if a binding target scope contains an other, typically a specific method. + * + * @param string|callable $container + * @param string|callable $contained + * + * @return bool + */ + protected static function doesCallableContain($container, $contained) + { + if ('' === $contained || '' === $container) { + return true; + } + // Note: php7 considers array with classname and method name callable + // but php8 only returns true for `is_callable`, if the method is static + if (is_callable($container)) { + if (is_string($contained)) { + $contained = Str::parseCallback($contained); + } + if (is_callable($contained)) { + return $container === $contained; + } + + return false; + } + if (is_callable($contained)) { + $container = Str::parseCallback($container); + + return $container === $contained; + } + + $container = static::preparescopeForComparision($container); + $contained = static::preparescopeForComparision($contained); + + if (false === $container || false === $contained) { + return false; + } + if (count($container) > count($contained)) { + return false; + } + foreach ($container as $index => $part) { + if ($part !== $contained[$index]) { + return false; + } + } + + return true; + } + + /** + * Turns callable arrays and callable strings into arrays for comparison. + * + * @param string|array $scope + * + * @return false|string[] + */ + private static function preparescopeForComparision($scope) + { + if (is_array($scope)) { + // In php8 a "callable" array pointing at a non-static method is not + // considered callable, but only a regular array, so we handle those + // here + if (count($scope) != 2) { + return false; + } + $scope = implode('@', $scope); + } + if (! is_string($scope)) { + return false; + } + // Split into comparable bits around \ and @ characters + $scope = preg_split('/[@\\\]/', $scope); + + return $scope; + } + + /** + * Call the binding callback for the given key. + * + * @param string $key + * @param string $value + * + * @return mixed The result of the binding callback. + */ + protected function performBinding($key, $value) + { + return call_user_func($this->binders[$key], $value); + } +} diff --git a/src/Binding/BindsParameters.php b/src/Binding/BindsParameters.php new file mode 100644 index 0000000..3d75bb7 --- /dev/null +++ b/src/Binding/BindsParameters.php @@ -0,0 +1,44 @@ +'user_id'] + * will ensure '$user' parameter of the Procedure method would + * receive an instance of the hinted Model type with the 'id' + * matching the 'user_id' parameter in the RPC request. + * The key to be used can also be customised the same way as + * in Route Model Binding, e.g.: ['user'=>'address:email'] + * would make an instance of the hinted type of the '$user' + * parameter of the Procedure method, where the email attribute + * of the Model is set by the 'address' parameter in the RPC + * request. + * It is also possible to use nested parameters. E.g.: if the + * request contains a `user` parameter, which contains an `id` + * parameter, it can be mapped as: `['user'=>['user','id']]`. + * It is also possible to combine the custom field and nested + * parameters, e.g.: `['user'=>['user','address:email']]`. + */ + public function getBindings(): array; + + /** + * Makes the parameter to be injected into the Procedure method. + * + * @param string $parameterName The name of the PHP method parameter to resolve. + * + * @return false|null|mixed The class instance to inject or false to use default resolution. + * For optional parameters, null can be returned as well. + */ + public function resolveParameter(string $parameterName); +} diff --git a/src/Binding/BoundMethod.php b/src/Binding/BoundMethod.php new file mode 100644 index 0000000..100a717 --- /dev/null +++ b/src/Binding/BoundMethod.php @@ -0,0 +1,126 @@ +whereInstanceOf(BindsParameters::class) + ->map(fn ($dependency) => $dependency->resolveParameter($parameter->getName())) + ->filter(fn ($dependency) => $dependency !== false) + ->each(function ($dependency) use ($parameter) { + throw_if( + is_null($dependency) && !$parameter->isOptional(), + BindingResolutionException::class, + 'Custom resolution logic returned `null`, but parameter is not optional.', + -32000 + ); + }) + ->each(function ($dependency) use ($parameter, &$dependencies) { + if (is_null($dependency) && $parameter->isOptional()) { + $dependencies[] = $dependency; + + return; + } + + $paramType = Reflector::getParameterClassName($parameter); + if ($dependency instanceof $paramType) { + $dependencies[] = $dependency; + + return; + } + + throw new BindingResolutionException('Custom resolution logic returned a parameter with an invalid type.', -32001); + }); + + + // Attempt resolution based on parameter mapping + $paramName = $parameter->getName(); + foreach ($dependencies as $dependency) { + if (is_object($dependency) && $dependency instanceof BindsParameters) { + $parameterMap = $dependency->getBindings(); + if (isset($parameterMap[$paramName])) { + $instance = $container->make(Reflector::getParameterClassName($parameter)); + if (! $instance instanceof UrlRoutable) { + throw new BindingResolutionException('Mapped parameter type must implement `UrlRoutable` interface.', -32002); + } + [$instanceValue, $instanceField] = self::getValueAndFieldByMapEntry($parameterMap[$paramName]); + if (! $model = $instance->resolveRouteBinding($instanceValue, $instanceField)) { + throw (new ModelNotFoundException('', -32003))->setModel(get_class($instance), [$instanceValue]); + } + $dependencies[] = $model; + + return; + } + } + } + + // Attempt resolution using the Global bindings + /** @var BindingServiceProvider $binder */ + $binder = $container->make('sajya-rpc-binder'); + $procedureClass = $parameter->getDeclaringClass()->name . '@' . $parameter->getDeclaringFunction()->name; + $requestParameters = request()->request->all(); + + $maybeInstance = $binder->resolveInstance( + $requestParameters, + $paramName, + $procedureClass + ); + if (false !== $maybeInstance) { + $dependencies[] = $maybeInstance; + + return; + } + + parent::addDependencyForCallParameter($container, $parameter, $parameters, $dependencies); + } + + /** + * Determines the value and the field to be used for model lookup based on the current request. + * + * @param array|string $requestParamMapEntry + * + * @return array + */ + private static function getValueAndFieldByMapEntry($requestParamMapEntry) + { + if (is_array($requestParamMapEntry)) { + $last = end($requestParamMapEntry); + $entry = explode(':', $last); + $requestParamMapEntry[count($requestParamMapEntry) - 1] = $entry[0]; + } elseif (is_string($requestParamMapEntry)) { + $entry = explode(':', $requestParamMapEntry); + $requestParamMapEntry = $entry[0]; + } else { + throw new \LogicException('$requestParamMapEntry must be an array or string.'); + } + $value = self::resolveRequestValue(request()->request->all(), $requestParamMapEntry); + + return [$value, $entry[1] ?? null]; + } +} diff --git a/src/Binding/HandlesRequestParameters.php b/src/Binding/HandlesRequestParameters.php new file mode 100644 index 0000000..427e769 --- /dev/null +++ b/src/Binding/HandlesRequestParameters.php @@ -0,0 +1,36 @@ +procedure); + return BoundMethod::call(app(), $this->procedure); } catch (HttpException | RuntimeException | Exception $exception) { $message = $exception->getMessage(); diff --git a/src/ServerServiceProvider.php b/src/ServerServiceProvider.php index f20408e..0e050d2 100644 --- a/src/ServerServiceProvider.php +++ b/src/ServerServiceProvider.php @@ -4,8 +4,10 @@ namespace Sajya\Server; +use Illuminate\Support\Facades\App; use Illuminate\Support\Facades\Route; use Illuminate\Support\ServiceProvider; +use Sajya\Server\Binding\BindingServiceProvider; use Sajya\Server\Commands\DocsCommand; use Sajya\Server\Commands\ProcedureMakeCommand; @@ -37,11 +39,15 @@ public function register(): void $this->commands($this->commands); $this->registerViews(); - Route::macro('rpc', fn(string $uri, array $procedures = [], string $delimiter = null) => Route::post($uri, [JsonRpcController::class, '__invoke']) + Route::macro('rpc', fn (string $uri, array $procedures = [], string $delimiter = null) => Route::post($uri, [JsonRpcController::class, '__invoke']) ->setDefaults([ 'procedures' => $procedures, 'delimiter' => $delimiter, ])); + + App::singleton('sajya-rpc-binder', function () { + return new BindingServiceProvider(app()); + }); } /** diff --git a/tests/Expected/Requests/testModelBindingCustomLogic.json b/tests/Expected/Requests/testModelBindingCustomLogic.json new file mode 100644 index 0000000..5bf77be --- /dev/null +++ b/tests/Expected/Requests/testModelBindingCustomLogic.json @@ -0,0 +1,8 @@ +{ + "jsonrpc": "2.0", + "method": "fixture@getUserNameCustomLogic", + "params": { + "user": 3 + }, + "id": 1 +} diff --git a/tests/Expected/Requests/testModelBindingCustomLogicNested.json b/tests/Expected/Requests/testModelBindingCustomLogicNested.json new file mode 100644 index 0000000..0f69232 --- /dev/null +++ b/tests/Expected/Requests/testModelBindingCustomLogicNested.json @@ -0,0 +1,11 @@ +{ + "jsonrpc": "2.0", + "method": "fixture@getUserNameCustomLogicNested", + "params": { + "user": { + "title": "Dr.", + "id": 3 + } + }, + "id": 1 +} diff --git a/tests/Expected/Requests/testModelBindingCustomLogicNullable.json b/tests/Expected/Requests/testModelBindingCustomLogicNullable.json new file mode 100644 index 0000000..2b35722 --- /dev/null +++ b/tests/Expected/Requests/testModelBindingCustomLogicNullable.json @@ -0,0 +1,8 @@ +{ + "jsonrpc": "2.0", + "method": "fixture@getUserNameCustomLogicNullable", + "params": { + "user": 3 + }, + "id": 1 +} diff --git a/tests/Expected/Requests/testModelBindingSimpleCustomField.json b/tests/Expected/Requests/testModelBindingSimpleCustomField.json new file mode 100644 index 0000000..12010ea --- /dev/null +++ b/tests/Expected/Requests/testModelBindingSimpleCustomField.json @@ -0,0 +1,8 @@ +{ + "jsonrpc": "2.0", + "method": "fixture@getUserNameCustomKey", + "params": { + "user": "test@domain.com" + }, + "id": 1 +} diff --git a/tests/Expected/Requests/testModelBindingSimpleDefaultField.json b/tests/Expected/Requests/testModelBindingSimpleDefaultField.json new file mode 100644 index 0000000..54b414f --- /dev/null +++ b/tests/Expected/Requests/testModelBindingSimpleDefaultField.json @@ -0,0 +1,8 @@ +{ + "jsonrpc": "2.0", + "method": "fixture@getUserNameDefaultField", + "params": { + "user": 1 + }, + "id": 1 +} diff --git a/tests/Expected/Requests/testModelBindingSimpleNestedParameter.json b/tests/Expected/Requests/testModelBindingSimpleNestedParameter.json new file mode 100644 index 0000000..f5ffd1c --- /dev/null +++ b/tests/Expected/Requests/testModelBindingSimpleNestedParameter.json @@ -0,0 +1,11 @@ +{ + "jsonrpc": "2.0", + "method": "fixture@getUserNameNestedParameter", + "params": { + "user": { + "title": "Dr.", + "id": 3 + } + }, + "id": 1 +} diff --git a/tests/Expected/Responses/testModelBindingCustomLogic.json b/tests/Expected/Responses/testModelBindingCustomLogic.json new file mode 100644 index 0000000..68cbbbf --- /dev/null +++ b/tests/Expected/Responses/testModelBindingCustomLogic.json @@ -0,0 +1,5 @@ +{ + "id": 1, + "result": "User 3", + "jsonrpc": "2.0" +} diff --git a/tests/Expected/Responses/testModelBindingCustomLogicNested.json b/tests/Expected/Responses/testModelBindingCustomLogicNested.json new file mode 100644 index 0000000..68cbbbf --- /dev/null +++ b/tests/Expected/Responses/testModelBindingCustomLogicNested.json @@ -0,0 +1,5 @@ +{ + "id": 1, + "result": "User 3", + "jsonrpc": "2.0" +} diff --git a/tests/Expected/Responses/testModelBindingCustomLogicNullable.json b/tests/Expected/Responses/testModelBindingCustomLogicNullable.json new file mode 100644 index 0000000..b9b25f0 --- /dev/null +++ b/tests/Expected/Responses/testModelBindingCustomLogicNullable.json @@ -0,0 +1,5 @@ +{ + "id": 1, + "result": "No user", + "jsonrpc": "2.0" +} diff --git a/tests/Expected/Responses/testModelBindingSimpleCustomField.json b/tests/Expected/Responses/testModelBindingSimpleCustomField.json new file mode 100644 index 0000000..584fafb --- /dev/null +++ b/tests/Expected/Responses/testModelBindingSimpleCustomField.json @@ -0,0 +1,5 @@ +{ + "id": 1, + "result": "User 2", + "jsonrpc": "2.0" +} diff --git a/tests/Expected/Responses/testModelBindingSimpleDefaultField.json b/tests/Expected/Responses/testModelBindingSimpleDefaultField.json new file mode 100644 index 0000000..275b5fc --- /dev/null +++ b/tests/Expected/Responses/testModelBindingSimpleDefaultField.json @@ -0,0 +1,5 @@ +{ + "id": 1, + "result": "User 1", + "jsonrpc": "2.0" +} diff --git a/tests/Expected/Responses/testModelBindingSimpleNestedParameter.json b/tests/Expected/Responses/testModelBindingSimpleNestedParameter.json new file mode 100644 index 0000000..68cbbbf --- /dev/null +++ b/tests/Expected/Responses/testModelBindingSimpleNestedParameter.json @@ -0,0 +1,5 @@ +{ + "id": 1, + "result": "User 3", + "jsonrpc": "2.0" +} diff --git a/tests/FixtureProcedure.php b/tests/FixtureProcedure.php index c4631de..4c28095 100644 --- a/tests/FixtureProcedure.php +++ b/tests/FixtureProcedure.php @@ -5,6 +5,9 @@ namespace Sajya\Server\Tests; use Illuminate\Config\Repository; +use Illuminate\Contracts\Filesystem\Filesystem; +use Illuminate\Contracts\Routing\UrlRoutable; +use Illuminate\Foundation\Auth\User; use Illuminate\Http\Request; use Illuminate\Support\Facades\Log; use Sajya\Server\Exceptions\RuntimeRpcException; @@ -86,6 +89,95 @@ public function validationMethod(Request $request): int return $result; } + /** + * @param Request $request + * @param User $user User resolved by global bindings. + * + * @return string + */ + public function getUserName(Request $request, User $user): string + { + return $user->getAttribute('name'); + } + + /** + * @param FixtureRequest $request + * @param User $userById User resolved by the default resolution logic using ID as key. + * + * @return string + */ + public function getUserNameDefaultField(FixtureRequest $request, User $userById): string + { + return $userById->getAttribute('name'); + } + + /** + * @param FixtureRequest $request + * @param User $userNestedId User resolved by the default resolution logic using ID as key. + * + * @return string + */ + public function getUserNameNestedParameter(FixtureRequest $request, User $userNestedId): string + { + return $userNestedId->getAttribute('name'); + } + + /** + * @param FixtureRequest $request + * @param User $userByEmail User resolved by the default resolution logic using Email as key. + * + * @return string + */ + public function getUserNameCustomKey(FixtureRequest $request, User $userByEmail): string + { + return $userByEmail->getAttribute('name'); + } + + /** + * @param FixtureRequest $request + * @param User $userCustom User resolved by the custom resolution logic. + * + * @return string + */ + public function getUserNameCustomLogic(FixtureRequest $request, User $userCustom): string + { + return $userCustom->getAttribute('name'); + } + + /** + * @param FixtureRequest $request + * @param null|User $userCustom User resolved by the custom resolution logic. + * + * @return string + */ + public function getUserNameCustomLogicNullable(FixtureRequest $request, ?User $userCustom = null): string + { + return is_null($userCustom) ? 'No user' : $userCustom->getAttribute('name'); + } + + /** + * @param FixtureRequest $request + * @param User $customer User resolved by the custom resolution logic. + * + * @return string + */ + public function getUserNameCustomLogicNested(FixtureRequest $request, User $customer): string + { + return $customer->getAttribute('name'); + } + + /** + * @param FixtureRequest $request + * @param Filesystem $wrongTypeVar Should trigger an exception, because + * it does not implement {@see UrlRoutable}. + * + * @return string + */ + public function getUserNameWrong(FixtureRequest $request, Filesystem $wrongTypeVar): string + { + return gettype($wrongTypeVar); + } + public function internalError(): void { abort(500); diff --git a/tests/FixtureRequest.php b/tests/FixtureRequest.php new file mode 100644 index 0000000..f3ce2e1 --- /dev/null +++ b/tests/FixtureRequest.php @@ -0,0 +1,73 @@ + 'bail|required|max:255', + ]; + } + + /** + * @inheritDoc + */ + public function getBindings(): array + { + return [ + 'userById' => 'user', + 'userByEmail' => 'user:email', + 'wrongTypeVar'=> 'user', + 'userNestedId'=> ['user','id'], + ]; + } + + /** + * @inheritDoc + * + * @throws \Illuminate\Contracts\Container\BindingResolutionException + * + * @return null|false|\Illuminate\Database\Eloquent\Model + */ + public function resolveParameter(string $parameterName) + { + if ('userCustom' === $parameterName) { + $user = app()->make(User::class); + + return $user->resolveRouteBinding($this->input('user')); + } + if ('customer' === $parameterName) { + $user = app()->make(User::class); + + return $user->resolveRouteBinding(static::resolveRequestValue($this->request->all(), ['user','id'])); + } + + return false; + } +} diff --git a/tests/Unit/BindingGlobalTest.php b/tests/Unit/BindingGlobalTest.php new file mode 100644 index 0000000..6ec176e --- /dev/null +++ b/tests/Unit/BindingGlobalTest.php @@ -0,0 +1,595 @@ +shouldReceive('getAttribute') + ->once() + ->with('name') + ->andReturn('Custom User'); + + return $userMock; + } + ); + + $request = [ + "id" => 1, + "method" => "fixture@getUserName", + "params" => [ + "user" => 5, + ], + "jsonrpc" => "2.0", + ]; + + $response = [ + 'id' => 1, + "result" => "Custom User", + "jsonrpc" => "2.0", + ]; + + return $this->callRpcWith($request, $response); + } + + /** + * @testdox Bind using nested parameter. + */ + public function testFacadeBindNestedParameter() + { + RPC::bind( + ['customer','user'], + /** + * @param string $parameter + * + * @return User|Authenticatable + */ + static function (string $parameter) { + self::assertEquals(6, $parameter); + $userMock = \Mockery::mock(User::class); + $userMock->shouldReceive('getAttribute') + ->once() + ->with('name') + ->andReturn('Custom User'); + + return $userMock; + } + ); + + $request = [ + "id" => 1, + "method" => "fixture@getUserName", + "params" => [ + "customer" => [ + 'title' => 'Dr.', + 'user' => 6, + ], + ], + "jsonrpc" => "2.0", + ]; + + $response = [ + 'id' => 1, + "result" => "Custom User", + "jsonrpc" => "2.0", + ]; + + return $this->callRpcWith($request, $response); + } + + /** + * @testdox Bind using nested parameter with a name other than the Procedure method parameter's name.. + */ + public function testFacadeBindNestedParameter2() + { + RPC::bind( + ['user','id'], + /** + * @param string $parameter + * + * @return User|Authenticatable + */ + static function (string $parameter) { + self::assertEquals(6, $parameter); + $userMock = \Mockery::mock(User::class); + $userMock->shouldReceive('getAttribute') + ->once() + ->with('name') + ->andReturn('Custom User'); + + return $userMock; + }, + '', + 'user' // Since now we bind the 'id', but the method parameter is called '$user' + ); + + $request = [ + "id" => 1, + "method" => "fixture@getUserName", + "params" => [ + "user" => [ + 'title' => 'Dr.', + 'id' => 6, + ], + ], + "jsonrpc" => "2.0", + ]; + + $response = [ + 'id' => 1, + "result" => "Custom User", + "jsonrpc" => "2.0", + ]; + + return $this->callRpcWith($request, $response); + } + + /** + * @testdox Bind using a Procedure::method scope declared as string. + */ + public function testFacadeBindTargetStringClassMethod() + { + RPC::bind( + 'user', + /** + * @param string $parameter + * + * @return User|Authenticatable + */ + static function (string $parameter) { + self::assertEquals(7, $parameter); + $userMock = \Mockery::mock(User::class); + $userMock->shouldReceive('getAttribute') + ->once() + ->with('name') + ->andReturn('Custom User'); + + return $userMock; + }, + 'Sajya\Server\Tests\FixtureProcedure@getUserName' + ); + + $request = [ + "id" => 1, + "method" => "fixture@getUserName", + "params" => [ + "user" => 7, + ], + "jsonrpc" => "2.0", + ]; + + $response = [ + 'id' => 1, + "result" => "Custom User", + "jsonrpc" => "2.0", + ]; + + return $this->callRpcWith($request, $response); + } + + /** + * @testdox Bind using a Procedure scope declared as string. + */ + public function testFacadeBindTargetStringClass() + { + RPC::bind( + 'user', + /** + * @param string $parameter + * + * @return User|Authenticatable + */ + static function (string $parameter) { + self::assertEquals(7, $parameter); + $userMock = \Mockery::mock(User::class); + $userMock->shouldReceive('getAttribute') + ->once() + ->with('name') + ->andReturn('Custom User'); + + return $userMock; + }, + 'Sajya\Server\Tests\FixtureProcedure' + ); + + $request = [ + "id" => 1, + "method" => "fixture@getUserName", + "params" => [ + "user" => 7, + ], + "jsonrpc" => "2.0", + ]; + + $response = [ + 'id' => 1, + "result" => "Custom User", + "jsonrpc" => "2.0", + ]; + + return $this->callRpcWith($request, $response); + } + + /** + * @testdox Bind using a Procedure scope declared as string. + */ + public function testFacadeBindTargetNamespace() + { + RPC::bind( + 'user', + /** + * @param string $parameter + * + * @return User|Authenticatable + */ + static function (string $parameter) { + self::assertEquals(7, $parameter); + $userMock = \Mockery::mock(User::class); + $userMock->shouldReceive('getAttribute') + ->once() + ->with('name') + ->andReturn('Custom User'); + + return $userMock; + }, + 'Sajya\Server\Tests' + ); + + $request = [ + "id" => 1, + "method" => "fixture@getUserName", + "params" => [ + "user" => 7, + ], + "jsonrpc" => "2.0", + ]; + + $response = [ + 'id' => 1, + "result" => "Custom User", + "jsonrpc" => "2.0", + ]; + + return $this->callRpcWith($request, $response); + } + + /** + * @testdox Bind using a Procedure::method scope declared as PHP callable. + */ + public function testFacadeBindTargetCallable() + { + RPC::bind( + 'user', + /** + * @param string $parameter + * + * @return User|Authenticatable + */ + static function (string $parameter) { + self::assertEquals(7, $parameter); + $userMock = \Mockery::mock(User::class); + $userMock->shouldReceive('getAttribute') + ->once() + ->with('name') + ->andReturn('Custom User'); + + return $userMock; + }, + [FixtureProcedure::class,'getUserName'] + ); + + $request = [ + "id" => 1, + "method" => "fixture@getUserName", + "params" => [ + "user" => 7, + ], + "jsonrpc" => "2.0", + ]; + + $response = [ + 'id' => 1, + "result" => "Custom User", + "jsonrpc" => "2.0", + ]; + + return $this->callRpcWith($request, $response); + } + + /** + * @testdox Bind using multiple target scopes. + */ + public function testFacadeBindMultipleTarget() + { + RPC::bind( + 'user', + /** + * @param string $parameter + * + * @return User|Authenticatable + */ + static function (string $parameter) { + self::assertEquals(7, $parameter); + $userMock = \Mockery::mock(User::class); + $userMock->shouldReceive('getAttribute') + ->once() + ->with('name') + ->andReturn('Custom User'); + + return $userMock; + }, + [ + 'Sajya\Server\Tests\FixtureProcedure@subtract', + [FixtureProcedure::class,'getUserName'], + 'Sajya\Server\Tests\FixtureProcedure@getUserNameDefaultKey', + ] + ); + + $request = [ + "id" => 1, + "method" => "fixture@getUserName", + "params" => [ + "user" => 7, + ], + "jsonrpc" => "2.0", + ]; + + $response = [ + 'id' => 1, + "result" => "Custom User", + "jsonrpc" => "2.0", + ]; + + return $this->callRpcWith($request, $response); + } + + /** + * @testdox Multiple scoped bindings. + */ + public function testFacadeBindMultipleBinds() + { + RPC::bind( + 'user', + /** + * @param string $parameter + * + * @return User|Authenticatable + */ + static function (string $parameter) { + $userMock = \Mockery::mock(User::class); + $userMock->shouldNotReceive('getAttribute'); + + return $userMock; + }, + 'Sajya\Server\Tests\FixtureProcedure@subtract' + ); + RPC::bind( + 'user', + /** + * @param string $parameter + * + * @return User|Authenticatable + */ + static function (string $parameter) { + self::assertEquals(7, $parameter); + $userMock = \Mockery::mock(User::class); + $userMock->shouldReceive('getAttribute') + ->once() + ->with('name') + ->andReturn('Custom User'); + + return $userMock; + }, + [FixtureProcedure::class,'getUserName'] + ); + + $request = [ + "id" => 1, + "method" => "fixture@getUserName", + "params" => [ + "user" => 7, + ], + "jsonrpc" => "2.0", + ]; + + $response = [ + 'id' => 1, + "result" => "Custom User", + "jsonrpc" => "2.0", + ]; + + return $this->callRpcWith($request, $response); + } + + /** + * @testdox Multiple scoped bindings should be applied in order they are defined. + */ + public function testFacadeBindMultipleBindsPriority() + { + RPC::bind( + 'user', + /** + * @param string $parameter + * + * @return User|Authenticatable + */ + static function (string $parameter) { + self::assertEquals(7, $parameter); + $userMock = \Mockery::mock(User::class); + $userMock->shouldReceive('getAttribute') + ->once() + ->with('name') + ->andReturn('Custom User'); + + return $userMock; + }, + '' + ); + RPC::bind( + 'user', + /** + * @param string $parameter + * + * @return User|Authenticatable + */ + static function (string $parameter) { + $userMock = \Mockery::mock(User::class); + $userMock->shouldNotReceive('getAttribute'); + + return $userMock; + }, + [FixtureProcedure::class,'getUserName'] + ); + + $request = [ + "id" => 1, + "method" => "fixture@getUserName", + "params" => [ + "user" => 7, + ], + "jsonrpc" => "2.0", + ]; + + $response = [ + 'id' => 1, + "result" => "Custom User", + "jsonrpc" => "2.0", + ]; + + return $this->callRpcWith($request, $response); + } + + /** + * @testdox Simple case of model. + */ + public function testFacadeModel() + { + $userMock = \Mockery::mock(User::class); + $userMock->shouldReceive('resolveRouteBinding') + ->once() + ->with(3) + ->andReturnSelf(); + $userMock->shouldReceive('getAttribute') + ->once() + ->with('name') + ->andReturn('User 3'); + app()->instance(User::class, $userMock); + + RPC::model('user', User::class); + + $request = [ + "id" => 1, + "method" => "fixture@getUserName", + "params" => [ + "user" => 3, + ], + "jsonrpc" => "2.0", + ]; + + $response = [ + 'id' => 1, + "result" => "User 3", + "jsonrpc" => "2.0", + ]; + + return $this->callRpcWith($request, $response); + } + + /** + * @testdox Failure callback should be called, if automatic model binding fails. + */ + public function testFacadeModelFailureCallback() + { + $userMock = \Mockery::mock(User::class); + $userMock->shouldReceive('resolveRouteBinding') + ->once() + ->with(3) + ->andReturnFalse(); + $userMock->shouldNotReceive('getAttribute'); + app()->instance(User::class, $userMock); + + RPC::model( + 'user', + User::class, + '', + 'user', + /** + * @return User|Authenticatable + */ + static function () { + $userMock = \Mockery::mock(User::class); + $userMock->shouldReceive('getAttribute') + ->once() + ->with('name') + ->andReturn('Fallback User'); + + return $userMock; + } + ); + + $request = [ + "id" => 1, + "method" => "fixture@getUserName", + "params" => [ + "user" => 3, + ], + "jsonrpc" => "2.0", + ]; + + $response = [ + 'id' => 1, + "result" => "Fallback User", + "jsonrpc" => "2.0", + ]; + + return $this->callRpcWith($request, $response); + } + + /** + * @param array|string $request + * @param array $response + * @param string $route + * + * @throws \JsonException + * + * @return TestResponse + */ + private function callRpcWith($request, array $response, string $route = 'rpc.point'): TestResponse + { + if (! is_string($request)) { + $request = json_encode($request, JSON_THROW_ON_ERROR); + } + + return $this + ->call('POST', route($route), [], [], [], [], $request) + ->assertOk() + ->assertHeader('content-type', 'application/json') + ->assertJson($response); + } +} diff --git a/tests/Unit/BindingRequestTest.php b/tests/Unit/BindingRequestTest.php new file mode 100644 index 0000000..950b61f --- /dev/null +++ b/tests/Unit/BindingRequestTest.php @@ -0,0 +1,303 @@ +set('app.debug', true); + $userMock = \Mockery::mock(User::class); + $userMock->shouldReceive('resolveRouteBinding') + ->once() + ->with(1, null) + ->andReturnSelf(); + $userMock->shouldReceive('getAttribute') + ->once() + ->with('name') + ->andReturn('User 1'); + app()->instance(User::class, $userMock); + }]; + yield ['testModelBindingSimpleCustomField', function () { + config()->set('app.debug', true); + $userMock = \Mockery::mock(User::class); + $userMock->shouldReceive('resolveRouteBinding') + ->once() + ->with('test@domain.com', 'email') + ->andReturnSelf(); + $userMock->shouldReceive('getAttribute') + ->once() + ->with('name') + ->andReturn('User 2'); + app()->instance(User::class, $userMock); + }]; + yield ['testModelBindingSimpleNestedParameter', function () { + config()->set('app.debug', true); + $userMock = \Mockery::mock(User::class); + $userMock->shouldReceive('resolveRouteBinding') + ->once() + ->with(3, null) + ->andReturnSelf(); + $userMock->shouldReceive('getAttribute') + ->once() + ->with('name') + ->andReturn('User 3'); + app()->instance(User::class, $userMock); + }]; + yield ['testModelBindingCustomLogic', function () { + config()->set('app.debug', true); + $userMock = \Mockery::mock(User::class); + $userMock->shouldReceive('resolveRouteBinding') + ->once() + ->with(3) + ->andReturnSelf(); + $userMock->shouldReceive('getAttribute') + ->once() + ->with('name') + ->andReturn('User 3'); + app()->instance(User::class, $userMock); + }]; + yield ['testModelBindingCustomLogicNullable', function () { + config()->set('app.debug', true); + $userMock = \Mockery::mock(User::class); + $userMock->shouldReceive('resolveRouteBinding') + ->once() + ->with(3) + ->andReturnNull(); + $userMock->shouldReceive('get') + ->never() + ->with('name'); + app()->instance(User::class, $userMock); + }]; + yield ['testModelBindingCustomLogicNested', function () { + config()->set('app.debug', true); + $userMock = \Mockery::mock(User::class); + $userMock->shouldReceive('resolveRouteBinding') + ->once() + ->with(3) + ->andReturnSelf(); + $userMock->shouldReceive('getAttribute') + ->once() + ->with('name') + ->andReturn('User 3'); + app()->instance(User::class, $userMock); + }]; + } + + /** + * @param string $file + * @param Closure|null $before + * @param Closure|null $after + * @param string $route + * + * @throws \JsonException + * @dataProvider exampleCalls + * + */ + public function testHasCorrectRequestResponse( + string $file, + Closure $before = null, + Closure $after = null, + string $route = 'rpc.point' + ): void { + if ($before !== null) { + $before(); + } + + $response = $this->callRPC($file, $route); + + if ($after !== null) { + $after($response); + } + } + + /** + * @param string $path + * @param string $route + * + * @throws \JsonException + * + * @return TestResponse + */ + private function callRPC(string $path, string $route): TestResponse + { + $request = file_get_contents("./tests/Expected/Requests/$path.json"); + $response = file_get_contents("./tests/Expected/Responses/$path.json"); + + return $this + ->call('POST', route($route), [], [], [], [], $request) + ->assertOk() + ->assertHeader('content-type', 'application/json') + ->assertJson( + json_decode($response, true, 512, JSON_THROW_ON_ERROR) + ); + } + + /** + * @testdox We should get an error, if null is returned by {@see BindsParameters::resolveParameter()} + * when the related Procedure method does not define the parameter as optional. + */ + public function testCutomLogicInvalidNull() + { + config()->set('app.debug', false); + $userMock = \Mockery::mock(User::class); + $userMock->shouldReceive('resolveRouteBinding') + ->once() + ->with(5) + ->andReturn(null); + app()->instance(User::class, $userMock); + + $request = [ + "id" => 1, + "method" => "fixture@getUserNameCustomLogic", + "params" => [ + "user" => 5, + ], + "jsonrpc" => "2.0", + ]; + + $response = [ + 'id' => 1, + 'error' => [ + 'code' => -32000, + ], + "jsonrpc" => "2.0", + ]; + + return $this->callRpcWith($request, $response); + } + + /** + * @testdox We should get an error, if the object returned by {@see BindsParameters::resolveParameter()} + * does not correspond to the type of object expected by the Procedure method. + */ + public function testCutomLogicInvalidType() + { + config()->set('app.debug', false); + $userMock = \Mockery::mock(User::class); + $userMock->shouldReceive('resolveRouteBinding') + ->once() + ->with(5) + ->andReturn(new \stdClass()); + app()->instance(User::class, $userMock); + + $request = [ + "id" => 1, + "method" => "fixture@getUserNameCustomLogic", + "params" => [ + "user" => 5, + ], + "jsonrpc" => "2.0", + ]; + + $response = [ + 'id' => 1, + 'error' => [ + 'code' => -32001, + ], + "jsonrpc" => "2.0", + ]; + + return $this->callRpcWith($request, $response); + } + + /** + * @testdox We should get an error, if the object expected by the Procedure method + * does not implement {@see UrlRoutable}, but is expected to be resolved + * by the default resolution logic based on {@see BindsParameters::getBindings()}. + */ + public function testDefaultInvalidType() + { + config()->set('app.debug', false); + $userMock = \Mockery::mock(User::class); + app()->instance(User::class, $userMock); + + $request = [ + "id" => 1, + "method" => "fixture@getUserNameWrong", + "params" => [ + "user" => 1, + ], + "jsonrpc" => "2.0", + ]; + + $response = [ + 'id' => 1, + 'error' => [ + 'code' => -32002, + ], + "jsonrpc" => "2.0", + ]; + + return $this->callRpcWith($request, $response); + } + + /** + * @testdox We should get an error, if the Model instance cannot be resolved + * automatically, e.g. due to invalid ID. + */ + public function testDefaultNotFound() + { + config()->set('app.debug', false); + $userMock = \Mockery::mock(User::class); + app()->instance(User::class, $userMock); + $userMock->shouldReceive('resolveRouteBinding') + ->once() + ->with(1, null) + ->andReturnFalse(); + + $request = [ + "id" => 1, + "method" => "fixture@getUserNameDefaultField", + "params" => [ + "user" => 1, + ], + "jsonrpc" => "2.0", + ]; + + $response = [ + 'id' => 1, + 'error' => [ + 'code' => -32003, + ], + "jsonrpc" => "2.0", + ]; + + return $this->callRpcWith($request, $response); + } + + /** + * @param array|string $request + * @param array $response + * @param string $route + * + * @throws \JsonException + * + * @return TestResponse + */ + private function callRpcWith($request, array $response, string $route = 'rpc.point'): TestResponse + { + if (! is_string($request)) { + $request = json_encode($request, JSON_THROW_ON_ERROR); + } + + return $this + ->call('POST', route($route), [], [], [], [], $request) + ->assertOk() + ->assertHeader('content-type', 'application/json') + ->assertJson($response); + } +}