From 8fbad8ba2f479a1bee985b7f1a961519fc690e0f Mon Sep 17 00:00:00 2001 From: BenceSzalai Date: Tue, 6 Apr 2021 15:48:18 +0200 Subject: [PATCH 1/9] Parameter model binding to resolve method dependencies --- README.md | 1 + src/BindsParameters.php | 40 +++ src/BoundMethod.php | 82 ++++++ src/HandleProcedure.php | 3 +- .../Requests/testModelBindingCustomLogic.json | 8 + .../testModelBindingCustomLogicNullable.json | 8 + .../testModelBindingSimpleCustomKey.json | 8 + .../testModelBindingSimpleDefaultKey.json | 8 + .../testModelBindingCustomLogic.json | 5 + .../testModelBindingCustomLogicNullable.json | 5 + .../testModelBindingSimpleCustomKey.json | 5 + .../testModelBindingSimpleDefaultKey.json | 5 + tests/FixtureProcedure.php | 61 +++- tests/FixtureRequest.php | 61 ++++ tests/Unit/BindingTest.php | 274 ++++++++++++++++++ 15 files changed, 571 insertions(+), 3 deletions(-) create mode 100644 src/BindsParameters.php create mode 100644 src/BoundMethod.php create mode 100644 tests/Expected/Requests/testModelBindingCustomLogic.json create mode 100644 tests/Expected/Requests/testModelBindingCustomLogicNullable.json create mode 100644 tests/Expected/Requests/testModelBindingSimpleCustomKey.json create mode 100644 tests/Expected/Requests/testModelBindingSimpleDefaultKey.json create mode 100644 tests/Expected/Responses/testModelBindingCustomLogic.json create mode 100644 tests/Expected/Responses/testModelBindingCustomLogicNullable.json create mode 100644 tests/Expected/Responses/testModelBindingSimpleCustomKey.json create mode 100644 tests/Expected/Responses/testModelBindingSimpleDefaultKey.json create mode 100644 tests/FixtureRequest.php create mode 100644 tests/Unit/BindingTest.php 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/BindsParameters.php b/src/BindsParameters.php new file mode 100644 index 0000000..c99bf4f --- /dev/null +++ b/src/BindsParameters.php @@ -0,0 +1,40 @@ +'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. + * + */ + 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/BoundMethod.php b/src/BoundMethod.php new file mode 100644 index 0000000..93c3b4d --- /dev/null +++ b/src/BoundMethod.php @@ -0,0 +1,82 @@ +resolveParameter($parameter->getName())) !== false) { + if (is_null($maybeDependency)) { + if ($parameter->isOptional()) { + $dependencies[] = $maybeDependency; + return; + } else { + throw new BindingResolutionException('Custom resolution logic returned `null`, but parameter is not optional.', -32000); + } + } + + $paramType = Reflector::getParameterClassName($parameter); + if ($maybeDependency instanceof $paramType) { + $dependencies[] = $maybeDependency; + return; + } else { + throw new BindingResolutionException('Custom resolution logic returned a parameter with an invalid type.', -32001); + } + } + } + } + + // Attempt resolution based on parameter mapping + foreach ($dependencies as $dependency) { + if (is_object($dependency) && $dependency instanceof BindsParameters) { + $parameterMap = $dependency->getBindings(); + $paramName = $parameter->getName(); + 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::getValueAndFieldFromMapEntry($parameterMap[$paramName]); + if (!$model = $instance->resolveRouteBinding($instanceValue, $instanceField)) { + throw (new ModelNotFoundException('', -32003))->setModel(get_class($instance), [$instanceValue]); + } + $dependencies[] = $model; + return; + } + } + } + + parent::addDependencyForCallParameter($container, $parameter, $parameters, $dependencies); + } + + private static function getValueAndFieldFromMapEntry($mapEntry) + { + $entry = explode(':', $mapEntry); + return [request($entry[0]), $entry[1] ?? null]; + } +} diff --git a/src/HandleProcedure.php b/src/HandleProcedure.php index b23d43d..256cc07 100644 --- a/src/HandleProcedure.php +++ b/src/HandleProcedure.php @@ -10,7 +10,6 @@ use Illuminate\Foundation\Bus\Dispatchable; use Illuminate\Queue\InteractsWithQueue; use Illuminate\Queue\SerializesModels; -use Illuminate\Support\Facades\App; use Illuminate\Validation\ValidationException; use RuntimeException; use Sajya\Server\Exceptions\InternalErrorException; @@ -48,7 +47,7 @@ public function __construct(string $procedure) public function handle() { try { - return App::call($this->procedure); + return BoundMethod::call(app(), $this->procedure); } catch (HttpException | RuntimeException | Exception $exception) { $message = $exception->getMessage(); 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/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/testModelBindingSimpleCustomKey.json b/tests/Expected/Requests/testModelBindingSimpleCustomKey.json new file mode 100644 index 0000000..12010ea --- /dev/null +++ b/tests/Expected/Requests/testModelBindingSimpleCustomKey.json @@ -0,0 +1,8 @@ +{ + "jsonrpc": "2.0", + "method": "fixture@getUserNameCustomKey", + "params": { + "user": "test@domain.com" + }, + "id": 1 +} diff --git a/tests/Expected/Requests/testModelBindingSimpleDefaultKey.json b/tests/Expected/Requests/testModelBindingSimpleDefaultKey.json new file mode 100644 index 0000000..74e9c1e --- /dev/null +++ b/tests/Expected/Requests/testModelBindingSimpleDefaultKey.json @@ -0,0 +1,8 @@ +{ + "jsonrpc": "2.0", + "method": "fixture@getUserNameDefaultKey", + "params": { + "user": 1 + }, + "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/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/testModelBindingSimpleCustomKey.json b/tests/Expected/Responses/testModelBindingSimpleCustomKey.json new file mode 100644 index 0000000..584fafb --- /dev/null +++ b/tests/Expected/Responses/testModelBindingSimpleCustomKey.json @@ -0,0 +1,5 @@ +{ + "id": 1, + "result": "User 2", + "jsonrpc": "2.0" +} diff --git a/tests/Expected/Responses/testModelBindingSimpleDefaultKey.json b/tests/Expected/Responses/testModelBindingSimpleDefaultKey.json new file mode 100644 index 0000000..275b5fc --- /dev/null +++ b/tests/Expected/Responses/testModelBindingSimpleDefaultKey.json @@ -0,0 +1,5 @@ +{ + "id": 1, + "result": "User 1", + "jsonrpc": "2.0" +} diff --git a/tests/FixtureProcedure.php b/tests/FixtureProcedure.php index c4631de..0c505ec 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; @@ -85,7 +88,63 @@ public function validationMethod(Request $request): int return $result; } - + + /** + * @param FixtureRequest $request + * @param User $userById User resolved by the default resolution logic using ID as key. + * + * @return string + */ + public function getUserNameDefaultKey(FixtureRequest $request, User $userById): string + { + return $userById->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 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..3747ef6 --- /dev/null +++ b/tests/FixtureRequest.php @@ -0,0 +1,61 @@ + 'bail|required|max:255', + ]; + } + + /** + * @inheritDoc + */ + public function getBindings(): array + { + return [ + 'userById' => 'user', + 'userByEmail' => 'user:email', + 'wrongTypeVar'=> 'user' + ]; + } + + /** + * @inheritDoc + * + * @return null|false|\Illuminate\Database\Eloquent\Model + * @throws \Illuminate\Contracts\Container\BindingResolutionException + */ + public function resolveParameter(string $parameterName) + { + if ('userCustom' === $parameterName) { + $user = app()->make(User::class); + return $user->resolveRouteBinding($this->input('user')); + } + return false; + } +} diff --git a/tests/Unit/BindingTest.php b/tests/Unit/BindingTest.php new file mode 100644 index 0000000..c0c7369 --- /dev/null +++ b/tests/Unit/BindingTest.php @@ -0,0 +1,274 @@ +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 ['testModelBindingSimpleCustomKey', 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 ['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); + }]; + } + + /** + * @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", + ]; + $request = json_encode($request, JSON_THROW_ON_ERROR); + + $response = [ + 'id' => 1, + 'error' => [ + 'code' => -32000, + ], + "jsonrpc" => "2.0", + ]; + + return $this + ->call('POST', route('rpc.point'), [], [], [], [], $request) + ->assertOk() + ->assertHeader('content-type', 'application/json') + ->assertJson($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", + ]; + $request = json_encode($request, JSON_THROW_ON_ERROR); + + $response = [ + 'id' => 1, + 'error' => [ + 'code' => -32001, + ], + "jsonrpc" => "2.0", + ]; + + return $this + ->call('POST', route('rpc.point'), [], [], [], [], $request) + ->assertOk() + ->assertHeader('content-type', 'application/json') + ->assertJson($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", + ]; + $request = json_encode($request, JSON_THROW_ON_ERROR); + + $response = [ + 'id' => 1, + 'error' => [ + 'code' => -32002, + ], + "jsonrpc" => "2.0", + ]; + + return $this + ->call('POST', route('rpc.point'), [], [], [], [], $request) + ->assertOk() + ->assertHeader('content-type', 'application/json') + ->assertJson($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@getUserNameDefaultKey", + "params" => [ + "user" => 1, + ], + "jsonrpc" => "2.0", + ]; + $request = json_encode($request, JSON_THROW_ON_ERROR); + + $response = [ + 'id' => 1, + 'error' => [ + 'code' => -32003, + ], + "jsonrpc" => "2.0", + ]; + + return $this + ->call('POST', route('rpc.point'), [], [], [], [], $request) + ->assertOk() + ->assertHeader('content-type', 'application/json') + ->assertJson($response); + } +} From 68f02576c1be1f5ae6e165334a322753b68f4111 Mon Sep 17 00:00:00 2001 From: BenceSzalai Date: Thu, 8 Apr 2021 12:38:28 +0200 Subject: [PATCH 2/9] Support nested parameters for parameter binding --- src/Binding/HandlesRequestParameters.php | 35 ++++++++ src/BindsParameters.php | 6 +- src/BoundMethod.php | 34 ++++++-- .../testModelBindingCustomLogicNested.json | 11 +++ ...=> testModelBindingSimpleCustomField.json} | 0 ...> testModelBindingSimpleDefaultField.json} | 2 +- ...testModelBindingSimpleNestedParameter.json | 11 +++ .../testModelBindingCustomLogicNested.json | 5 ++ ...=> testModelBindingSimpleCustomField.json} | 0 ...> testModelBindingSimpleDefaultField.json} | 0 ...testModelBindingSimpleNestedParameter.json | 5 ++ tests/FixtureProcedure.php | 35 +++++++- tests/FixtureRequest.php | 10 ++- ...BindingTest.php => BindingRequestTest.php} | 81 ++++++++++++------- 14 files changed, 196 insertions(+), 39 deletions(-) create mode 100644 src/Binding/HandlesRequestParameters.php create mode 100644 tests/Expected/Requests/testModelBindingCustomLogicNested.json rename tests/Expected/Requests/{testModelBindingSimpleCustomKey.json => testModelBindingSimpleCustomField.json} (100%) rename tests/Expected/Requests/{testModelBindingSimpleDefaultKey.json => testModelBindingSimpleDefaultField.json} (61%) create mode 100644 tests/Expected/Requests/testModelBindingSimpleNestedParameter.json create mode 100644 tests/Expected/Responses/testModelBindingCustomLogicNested.json rename tests/Expected/Responses/{testModelBindingSimpleCustomKey.json => testModelBindingSimpleCustomField.json} (100%) rename tests/Expected/Responses/{testModelBindingSimpleDefaultKey.json => testModelBindingSimpleDefaultField.json} (100%) create mode 100644 tests/Expected/Responses/testModelBindingSimpleNestedParameter.json rename tests/Unit/{BindingTest.php => BindingRequestTest.php} (78%) diff --git a/src/Binding/HandlesRequestParameters.php b/src/Binding/HandlesRequestParameters.php new file mode 100644 index 0000000..cd9a485 --- /dev/null +++ b/src/Binding/HandlesRequestParameters.php @@ -0,0 +1,35 @@ +['user','id']]`. + * It is also possible to combine the custom field and nested + * parameters, e.g.: `['user'=>['user','address:email']]`. */ public function getBindings(): array; diff --git a/src/BoundMethod.php b/src/BoundMethod.php index 93c3b4d..24df583 100644 --- a/src/BoundMethod.php +++ b/src/BoundMethod.php @@ -4,20 +4,21 @@ namespace Sajya\Server; +use Illuminate\Container\Container; use Illuminate\Contracts\Container\BindingResolutionException; use Illuminate\Contracts\Routing\UrlRoutable; use Illuminate\Database\Eloquent\ModelNotFoundException; use Illuminate\Support\Reflector; +use Sajya\Server\Binding\HandlesRequestParameters; -/** - * Class BoundMethod - */ class BoundMethod extends \Illuminate\Container\BoundMethod { + use HandlesRequestParameters; + /** * Get the dependency for the given call parameter. * - * @param \Illuminate\Container\Container $container + * @param Container $container * @param \ReflectionParameter $parameter * @param array $parameters * @param array $dependencies @@ -61,7 +62,7 @@ protected static function addDependencyForCallParameter($container, $parameter, if (!$instance instanceof UrlRoutable) { throw new BindingResolutionException('Mapped parameter type must implement `UrlRoutable` interface.', -32002); } - [ $instanceValue, $instanceField ] = self::getValueAndFieldFromMapEntry($parameterMap[$paramName]); + [ $instanceValue, $instanceField ] = self::getValueAndFieldByMapEntry($parameterMap[$paramName]); if (!$model = $instance->resolveRouteBinding($instanceValue, $instanceField)) { throw (new ModelNotFoundException('', -32003))->setModel(get_class($instance), [$instanceValue]); } @@ -74,9 +75,26 @@ protected static function addDependencyForCallParameter($container, $parameter, parent::addDependencyForCallParameter($container, $parameter, $parameters, $dependencies); } - private static function getValueAndFieldFromMapEntry($mapEntry) + /** + * 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) { - $entry = explode(':', $mapEntry); - return [request($entry[0]), $entry[1] ?? null]; + 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/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/testModelBindingSimpleCustomKey.json b/tests/Expected/Requests/testModelBindingSimpleCustomField.json similarity index 100% rename from tests/Expected/Requests/testModelBindingSimpleCustomKey.json rename to tests/Expected/Requests/testModelBindingSimpleCustomField.json diff --git a/tests/Expected/Requests/testModelBindingSimpleDefaultKey.json b/tests/Expected/Requests/testModelBindingSimpleDefaultField.json similarity index 61% rename from tests/Expected/Requests/testModelBindingSimpleDefaultKey.json rename to tests/Expected/Requests/testModelBindingSimpleDefaultField.json index 74e9c1e..54b414f 100644 --- a/tests/Expected/Requests/testModelBindingSimpleDefaultKey.json +++ b/tests/Expected/Requests/testModelBindingSimpleDefaultField.json @@ -1,6 +1,6 @@ { "jsonrpc": "2.0", - "method": "fixture@getUserNameDefaultKey", + "method": "fixture@getUserNameDefaultField", "params": { "user": 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/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/testModelBindingSimpleCustomKey.json b/tests/Expected/Responses/testModelBindingSimpleCustomField.json similarity index 100% rename from tests/Expected/Responses/testModelBindingSimpleCustomKey.json rename to tests/Expected/Responses/testModelBindingSimpleCustomField.json diff --git a/tests/Expected/Responses/testModelBindingSimpleDefaultKey.json b/tests/Expected/Responses/testModelBindingSimpleDefaultField.json similarity index 100% rename from tests/Expected/Responses/testModelBindingSimpleDefaultKey.json rename to tests/Expected/Responses/testModelBindingSimpleDefaultField.json 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 0c505ec..3fba444 100644 --- a/tests/FixtureProcedure.php +++ b/tests/FixtureProcedure.php @@ -89,17 +89,39 @@ 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 getUserNameDefaultKey(FixtureRequest $request, User $userById): 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. @@ -133,6 +155,17 @@ public function getUserNameCustomLogicNullable(FixtureRequest $request, ?User $u 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 diff --git a/tests/FixtureRequest.php b/tests/FixtureRequest.php index 3747ef6..a737646 100644 --- a/tests/FixtureRequest.php +++ b/tests/FixtureRequest.php @@ -6,10 +6,13 @@ use Illuminate\Foundation\Auth\User; use Illuminate\Foundation\Http\FormRequest; +use Sajya\Server\Binding\HandlesRequestParameters; use Sajya\Server\BindsParameters; class FixtureRequest extends FormRequest implements BindsParameters { + use HandlesRequestParameters; + /** * Determine if the user is authorized to make this request. * @@ -40,7 +43,8 @@ public function getBindings(): array return [ 'userById' => 'user', 'userByEmail' => 'user:email', - 'wrongTypeVar'=> 'user' + 'wrongTypeVar'=> 'user', + 'userNestedId'=> ['user','id'] ]; } @@ -56,6 +60,10 @@ public function resolveParameter(string $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/BindingTest.php b/tests/Unit/BindingRequestTest.php similarity index 78% rename from tests/Unit/BindingTest.php rename to tests/Unit/BindingRequestTest.php index c0c7369..3a4d048 100644 --- a/tests/Unit/BindingTest.php +++ b/tests/Unit/BindingRequestTest.php @@ -10,14 +10,14 @@ use Illuminate\Testing\TestResponse; use Sajya\Server\Tests\TestCase; -class BindingTest extends TestCase +class BindingRequestTest extends TestCase { /** * @return Generator */ public function exampleCalls(): Generator { - yield ['testModelBindingSimpleDefaultKey', function () { + yield ['testModelBindingSimpleDefaultField', function () { config()->set('app.debug', true); $userMock = \Mockery::mock(User::class); $userMock->shouldReceive('resolveRouteBinding') @@ -30,7 +30,7 @@ public function exampleCalls(): Generator ->andReturn('User 1'); app()->instance(User::class, $userMock); }]; - yield ['testModelBindingSimpleCustomKey', function () { + yield ['testModelBindingSimpleCustomField', function () { config()->set('app.debug', true); $userMock = \Mockery::mock(User::class); $userMock->shouldReceive('resolveRouteBinding') @@ -43,6 +43,19 @@ public function exampleCalls(): Generator ->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); @@ -68,6 +81,19 @@ public function exampleCalls(): Generator ->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); + }]; } /** @@ -141,7 +167,6 @@ public function testCutomLogicInvalidNull() ], "jsonrpc" => "2.0", ]; - $request = json_encode($request, JSON_THROW_ON_ERROR); $response = [ 'id' => 1, @@ -151,11 +176,7 @@ public function testCutomLogicInvalidNull() "jsonrpc" => "2.0", ]; - return $this - ->call('POST', route('rpc.point'), [], [], [], [], $request) - ->assertOk() - ->assertHeader('content-type', 'application/json') - ->assertJson($response); + return $this->callRpcWith($request, $response); } /** @@ -180,7 +201,6 @@ public function testCutomLogicInvalidType() ], "jsonrpc" => "2.0", ]; - $request = json_encode($request, JSON_THROW_ON_ERROR); $response = [ 'id' => 1, @@ -189,12 +209,8 @@ public function testCutomLogicInvalidType() ], "jsonrpc" => "2.0", ]; - - return $this - ->call('POST', route('rpc.point'), [], [], [], [], $request) - ->assertOk() - ->assertHeader('content-type', 'application/json') - ->assertJson($response); + + return $this->callRpcWith($request, $response); } /** @@ -216,7 +232,6 @@ public function testDefaultInvalidType() ], "jsonrpc" => "2.0", ]; - $request = json_encode($request, JSON_THROW_ON_ERROR); $response = [ 'id' => 1, @@ -225,12 +240,8 @@ public function testDefaultInvalidType() ], "jsonrpc" => "2.0", ]; - - return $this - ->call('POST', route('rpc.point'), [], [], [], [], $request) - ->assertOk() - ->assertHeader('content-type', 'application/json') - ->assertJson($response); + + return $this->callRpcWith($request, $response); } /** @@ -249,13 +260,12 @@ public function testDefaultNotFound() $request = [ "id" => 1, - "method" => "fixture@getUserNameDefaultKey", + "method" => "fixture@getUserNameDefaultField", "params" => [ "user" => 1, ], "jsonrpc" => "2.0", ]; - $request = json_encode($request, JSON_THROW_ON_ERROR); $response = [ 'id' => 1, @@ -264,9 +274,26 @@ public function testDefaultNotFound() ], "jsonrpc" => "2.0", ]; - + + return $this->callRpcWith($request, $response); + } + + /** + * @param array|string $request + * @param array $response + * @param string $route + * + * @return TestResponse + * @throws \JsonException + */ + 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('rpc.point'), [], [], [], [], $request) + ->call('POST', route($route), [], [], [], [], $request) ->assertOk() ->assertHeader('content-type', 'application/json') ->assertJson($response); From 1237fcd60762f0526a1d9fbbbef49bd25ddcadff Mon Sep 17 00:00:00 2001 From: BenceSzalai Date: Thu, 8 Apr 2021 13:00:17 +0200 Subject: [PATCH 3/9] Facade to register parameter bindings from a global context without using custom FormRequest classes --- src/Binding/BindingServiceProvider.php | 315 ++++++++++++++ src/{ => Binding}/BindsParameters.php | 2 +- src/{ => Binding}/BoundMethod.php | 21 +- src/Facades/RPC.php | 18 + src/HandleProcedure.php | 1 + src/ServerServiceProvider.php | 6 + tests/FixtureRequest.php | 2 +- tests/Unit/BindingGlobalTest.php | 569 +++++++++++++++++++++++++ tests/Unit/BindingRequestTest.php | 1 + 9 files changed, 930 insertions(+), 5 deletions(-) create mode 100644 src/Binding/BindingServiceProvider.php rename src/{ => Binding}/BindsParameters.php (98%) rename src/{ => Binding}/BoundMethod.php (86%) create mode 100644 src/Facades/RPC.php create mode 100644 tests/Unit/BindingGlobalTest.php diff --git a/src/Binding/BindingServiceProvider.php b/src/Binding/BindingServiceProvider.php new file mode 100644 index 0000000..4868fb2 --- /dev/null +++ b/src/Binding/BindingServiceProvider.php @@ -0,0 +1,315 @@ +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 { + $key = $this->makeKey($requestParam, $scope, $procedureMethodParam); + $this->binders[$key] = RouteBinding::forCallback($this->container, $binder); + $this->scopes[$key] = $scope; + if (is_null($procedureMethodParam)) { + $procedureMethodParam = is_array($requestParam) ? end($requestParam) : $requestParam; + $procedureMethodParam = explode(':', $procedureMethodParam)[0]; + } + $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 $procedureMethodParam The parameter of the Procedure method to bind + * for. + * + * @return string + * @throws \JsonException + */ + private function makeKey($requestParam, $scope, $procedureMethodParam) + { + if (is_array($requestParam) || is_object($requestParam)) { + $requestParam = json_encode($requestParam, JSON_THROW_ON_ERROR); + } + if (is_array($scope) || is_object($scope)) { + $scope = json_encode($scope, JSON_THROW_ON_ERROR); + } + return sha1($requestParam . $scope . $procedureMethodParam); + } + + /** + * 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. + * + * @return false|mixed False if cannot resolve, the resolved instance otherwise. + * @throws BindingResolutionException + */ + 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/BindsParameters.php b/src/Binding/BindsParameters.php similarity index 98% rename from src/BindsParameters.php rename to src/Binding/BindsParameters.php index ccbbf4c..a3e406a 100644 --- a/src/BindsParameters.php +++ b/src/Binding/BindsParameters.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Sajya\Server; +namespace Sajya\Server\Binding; interface BindsParameters { diff --git a/src/BoundMethod.php b/src/Binding/BoundMethod.php similarity index 86% rename from src/BoundMethod.php rename to src/Binding/BoundMethod.php index 24df583..6776cc2 100644 --- a/src/BoundMethod.php +++ b/src/Binding/BoundMethod.php @@ -2,14 +2,13 @@ declare(strict_types=1); -namespace Sajya\Server; +namespace Sajya\Server\Binding; use Illuminate\Container\Container; use Illuminate\Contracts\Container\BindingResolutionException; use Illuminate\Contracts\Routing\UrlRoutable; use Illuminate\Database\Eloquent\ModelNotFoundException; use Illuminate\Support\Reflector; -use Sajya\Server\Binding\HandlesRequestParameters; class BoundMethod extends \Illuminate\Container\BoundMethod { @@ -53,10 +52,10 @@ protected static function addDependencyForCallParameter($container, $parameter, } // Attempt resolution based on parameter mapping + $paramName = $parameter->getName(); foreach ($dependencies as $dependency) { if (is_object($dependency) && $dependency instanceof BindsParameters) { $parameterMap = $dependency->getBindings(); - $paramName = $parameter->getName(); if (isset($parameterMap[$paramName])) { $instance = $container->make(Reflector::getParameterClassName($parameter)); if (!$instance instanceof UrlRoutable) { @@ -72,6 +71,22 @@ protected static function addDependencyForCallParameter($container, $parameter, } } + // 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); } diff --git a/src/Facades/RPC.php b/src/Facades/RPC.php new file mode 100644 index 0000000..607e824 --- /dev/null +++ b/src/Facades/RPC.php @@ -0,0 +1,18 @@ + $procedures, 'delimiter' => $delimiter, ])); + + App::singleton('sajya-rpc-binder', function () { + return new BindingServiceProvider(app()); + }); } /** diff --git a/tests/FixtureRequest.php b/tests/FixtureRequest.php index a737646..d66bf53 100644 --- a/tests/FixtureRequest.php +++ b/tests/FixtureRequest.php @@ -7,7 +7,7 @@ use Illuminate\Foundation\Auth\User; use Illuminate\Foundation\Http\FormRequest; use Sajya\Server\Binding\HandlesRequestParameters; -use Sajya\Server\BindsParameters; +use Sajya\Server\Binding\BindsParameters; class FixtureRequest extends FormRequest implements BindsParameters { diff --git a/tests/Unit/BindingGlobalTest.php b/tests/Unit/BindingGlobalTest.php new file mode 100644 index 0000000..d67dddd --- /dev/null +++ b/tests/Unit/BindingGlobalTest.php @@ -0,0 +1,569 @@ +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 + * + * @return TestResponse + * @throws \JsonException + */ + 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 index 3a4d048..db3a395 100644 --- a/tests/Unit/BindingRequestTest.php +++ b/tests/Unit/BindingRequestTest.php @@ -8,6 +8,7 @@ use Generator; use Illuminate\Foundation\Auth\User; use Illuminate\Testing\TestResponse; +use Sajya\Server\Binding\BindsParameters; use Sajya\Server\Tests\TestCase; class BindingRequestTest extends TestCase From 361ce7b01101a7c7e032472218b593d404a0a968 Mon Sep 17 00:00:00 2001 From: BenceSzalai Date: Thu, 8 Apr 2021 13:00:17 +0200 Subject: [PATCH 4/9] Facade to register parameter bindings from a global context without using custom FormRequest classes --- src/Binding/BindingServiceProvider.php | 315 +++++++++++++ src/{ => Binding}/BindsParameters.php | 2 +- src/{ => Binding}/BoundMethod.php | 21 +- src/Binding/HandlesRequestParameters.php | 2 +- src/Facades/RPC.php | 18 + src/HandleProcedure.php | 1 + src/ServerServiceProvider.php | 6 + tests/FixtureRequest.php | 2 +- tests/Unit/BindingGlobalTest.php | 569 +++++++++++++++++++++++ tests/Unit/BindingRequestTest.php | 1 + 10 files changed, 931 insertions(+), 6 deletions(-) create mode 100644 src/Binding/BindingServiceProvider.php rename src/{ => Binding}/BindsParameters.php (98%) rename src/{ => Binding}/BoundMethod.php (86%) create mode 100644 src/Facades/RPC.php create mode 100644 tests/Unit/BindingGlobalTest.php diff --git a/src/Binding/BindingServiceProvider.php b/src/Binding/BindingServiceProvider.php new file mode 100644 index 0000000..4868fb2 --- /dev/null +++ b/src/Binding/BindingServiceProvider.php @@ -0,0 +1,315 @@ +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 { + $key = $this->makeKey($requestParam, $scope, $procedureMethodParam); + $this->binders[$key] = RouteBinding::forCallback($this->container, $binder); + $this->scopes[$key] = $scope; + if (is_null($procedureMethodParam)) { + $procedureMethodParam = is_array($requestParam) ? end($requestParam) : $requestParam; + $procedureMethodParam = explode(':', $procedureMethodParam)[0]; + } + $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 $procedureMethodParam The parameter of the Procedure method to bind + * for. + * + * @return string + * @throws \JsonException + */ + private function makeKey($requestParam, $scope, $procedureMethodParam) + { + if (is_array($requestParam) || is_object($requestParam)) { + $requestParam = json_encode($requestParam, JSON_THROW_ON_ERROR); + } + if (is_array($scope) || is_object($scope)) { + $scope = json_encode($scope, JSON_THROW_ON_ERROR); + } + return sha1($requestParam . $scope . $procedureMethodParam); + } + + /** + * 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. + * + * @return false|mixed False if cannot resolve, the resolved instance otherwise. + * @throws BindingResolutionException + */ + 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/BindsParameters.php b/src/Binding/BindsParameters.php similarity index 98% rename from src/BindsParameters.php rename to src/Binding/BindsParameters.php index ccbbf4c..a3e406a 100644 --- a/src/BindsParameters.php +++ b/src/Binding/BindsParameters.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Sajya\Server; +namespace Sajya\Server\Binding; interface BindsParameters { diff --git a/src/BoundMethod.php b/src/Binding/BoundMethod.php similarity index 86% rename from src/BoundMethod.php rename to src/Binding/BoundMethod.php index 24df583..6776cc2 100644 --- a/src/BoundMethod.php +++ b/src/Binding/BoundMethod.php @@ -2,14 +2,13 @@ declare(strict_types=1); -namespace Sajya\Server; +namespace Sajya\Server\Binding; use Illuminate\Container\Container; use Illuminate\Contracts\Container\BindingResolutionException; use Illuminate\Contracts\Routing\UrlRoutable; use Illuminate\Database\Eloquent\ModelNotFoundException; use Illuminate\Support\Reflector; -use Sajya\Server\Binding\HandlesRequestParameters; class BoundMethod extends \Illuminate\Container\BoundMethod { @@ -53,10 +52,10 @@ protected static function addDependencyForCallParameter($container, $parameter, } // Attempt resolution based on parameter mapping + $paramName = $parameter->getName(); foreach ($dependencies as $dependency) { if (is_object($dependency) && $dependency instanceof BindsParameters) { $parameterMap = $dependency->getBindings(); - $paramName = $parameter->getName(); if (isset($parameterMap[$paramName])) { $instance = $container->make(Reflector::getParameterClassName($parameter)); if (!$instance instanceof UrlRoutable) { @@ -72,6 +71,22 @@ protected static function addDependencyForCallParameter($container, $parameter, } } + // 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); } diff --git a/src/Binding/HandlesRequestParameters.php b/src/Binding/HandlesRequestParameters.php index cd9a485..f014573 100644 --- a/src/Binding/HandlesRequestParameters.php +++ b/src/Binding/HandlesRequestParameters.php @@ -21,7 +21,7 @@ trait HandlesRequestParameters protected static function resolveRequestValue(array $requestParameters, $requestParam) { if (!is_array($requestParam)) { - return $requestParameters[$requestParam] ?? false; + return $requestParameters[$requestParam] ?? null; } $value = $requestParameters; foreach ($requestParam as $param) { diff --git a/src/Facades/RPC.php b/src/Facades/RPC.php new file mode 100644 index 0000000..607e824 --- /dev/null +++ b/src/Facades/RPC.php @@ -0,0 +1,18 @@ + $procedures, 'delimiter' => $delimiter, ])); + + App::singleton('sajya-rpc-binder', function () { + return new BindingServiceProvider(app()); + }); } /** diff --git a/tests/FixtureRequest.php b/tests/FixtureRequest.php index a737646..d66bf53 100644 --- a/tests/FixtureRequest.php +++ b/tests/FixtureRequest.php @@ -7,7 +7,7 @@ use Illuminate\Foundation\Auth\User; use Illuminate\Foundation\Http\FormRequest; use Sajya\Server\Binding\HandlesRequestParameters; -use Sajya\Server\BindsParameters; +use Sajya\Server\Binding\BindsParameters; class FixtureRequest extends FormRequest implements BindsParameters { diff --git a/tests/Unit/BindingGlobalTest.php b/tests/Unit/BindingGlobalTest.php new file mode 100644 index 0000000..d67dddd --- /dev/null +++ b/tests/Unit/BindingGlobalTest.php @@ -0,0 +1,569 @@ +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 + * + * @return TestResponse + * @throws \JsonException + */ + 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 index 3a4d048..db3a395 100644 --- a/tests/Unit/BindingRequestTest.php +++ b/tests/Unit/BindingRequestTest.php @@ -8,6 +8,7 @@ use Generator; use Illuminate\Foundation\Auth\User; use Illuminate\Testing\TestResponse; +use Sajya\Server\Binding\BindsParameters; use Sajya\Server\Tests\TestCase; class BindingRequestTest extends TestCase From f3f6ea09157c1c2fb8cd37f4d8c936ba46aae8d7 Mon Sep 17 00:00:00 2001 From: tabuna Date: Thu, 8 Apr 2021 15:37:50 +0300 Subject: [PATCH 5/9] refs #17 [Bind] Change key generation --- src/Binding/BindingServiceProvider.php | 48 ++++++++++++-------------- 1 file changed, 22 insertions(+), 26 deletions(-) diff --git a/src/Binding/BindingServiceProvider.php b/src/Binding/BindingServiceProvider.php index 4868fb2..dae2269 100644 --- a/src/Binding/BindingServiceProvider.php +++ b/src/Binding/BindingServiceProvider.php @@ -17,42 +17,42 @@ class BindingServiceProvider { use HandlesRequestParameters; - + /** * The IoC container instance. * * @var Container */ protected Container $container; - + /** * The registered parameter binders. * * @var array */ protected array $binders = []; - + /** * The registered binding targets. * * @var array */ protected array $scopes = []; - + /** * The registered procedure method parameters. * * @var array */ protected array $procedureMethodParams = []; - + /** * The registered request parameters. * * @var array */ protected array $requestParameters = []; - + /** * Create a new instance. * @@ -63,7 +63,7 @@ public function __construct(Container $container = null) { $this->container = $container ?: new Container; } - + /** * Register a model binder for a request parameter. * @@ -102,7 +102,7 @@ public function model( $procedureMethodParam ); } - + /** * Register a custom binder for a request parameter. * @@ -147,29 +147,25 @@ public function bind( $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 $procedureMethodParam The parameter of the Procedure method to bind + * @param string|null $procedureMethod The parameter of the Procedure method to bind * for. * * @return string * @throws \JsonException */ - private function makeKey($requestParam, $scope, $procedureMethodParam) + private function makeKey($requestParam, $scope, ?string $procedureMethod): string { - if (is_array($requestParam) || is_object($requestParam)) { - $requestParam = json_encode($requestParam, JSON_THROW_ON_ERROR); - } - if (is_array($scope) || is_object($scope)) { - $scope = json_encode($scope, JSON_THROW_ON_ERROR); - } - return sha1($requestParam . $scope . $procedureMethodParam); + $json = json_encode([$requestParam, $scope, $procedureMethod], JSON_THROW_ON_ERROR); + + return sha1($json); } - + /** * Resolves the bound instance for a Procedure method parameter. * @@ -197,7 +193,7 @@ public function resolveInstance($requestParameters, $targetParam, $targetCallabl throw new BindingResolutionException('Failed to perform binding resolution.', -32003, $e); } } - + /** * Finds the key used with the arrays for a specific Procedure method parameter. * @@ -214,7 +210,7 @@ public function findKey($targetParam, $targetCallable = '') if ($boundProcedureMethodParam !== $targetParam) { continue; } - + $maybeBoundScope = $this->scopes[$key]; if (!is_array($maybeBoundScope)) { $maybeBoundScope = [$maybeBoundScope]; @@ -227,7 +223,7 @@ public function findKey($targetParam, $targetCallable = '') } return false; } - + /** * Checks if a binding target scope contains an other, typically a specific method. * @@ -256,10 +252,10 @@ protected static function doesCallableContain($container, $contained) $container = Str::parseCallback($container); return $container === $contained; } - + $container = static::preparescopeForComparision($container); $contained = static::preparescopeForComparision($contained); - + if (false===$container || false===$contained) { return false; } @@ -273,7 +269,7 @@ protected static function doesCallableContain($container, $contained) } return true; } - + /** * Turns callable arrays and callable strings into arrays for comparison. * @@ -299,7 +295,7 @@ private static function preparescopeForComparision($scope) $scope = preg_split('/[@\\\]/', $scope); return $scope; } - + /** * Call the binding callback for the given key. * From 512d6b42edd4810c9fdbd14773f1305c9710efaf Mon Sep 17 00:00:00 2001 From: tabuna Date: Thu, 8 Apr 2021 16:10:19 +0300 Subject: [PATCH 6/9] refs #17 [Bind] Refactor "Attempt custom binding resolution" --- src/Binding/BoundMethod.php | 72 +++++++++---------- src/Exceptions/BindingResolutionException.php | 37 ++++++++++ 2 files changed, 73 insertions(+), 36 deletions(-) create mode 100644 src/Exceptions/BindingResolutionException.php diff --git a/src/Binding/BoundMethod.php b/src/Binding/BoundMethod.php index 6776cc2..a3f60c7 100644 --- a/src/Binding/BoundMethod.php +++ b/src/Binding/BoundMethod.php @@ -13,14 +13,14 @@ class BoundMethod extends \Illuminate\Container\BoundMethod { use HandlesRequestParameters; - + /** * Get the dependency for the given call parameter. * - * @param Container $container - * @param \ReflectionParameter $parameter - * @param array $parameters - * @param array $dependencies + * @param Container $container + * @param \ReflectionParameter $parameter + * @param array $parameters + * @param array $dependencies * * @return void * @throws BindingResolutionException @@ -28,29 +28,29 @@ class BoundMethod extends \Illuminate\Container\BoundMethod protected static function addDependencyForCallParameter($container, $parameter, array &$parameters, &$dependencies) { // Attempt custom binding resolution - foreach ($dependencies as $dependency) { - if (is_object($dependency) && $dependency instanceof BindsParameters) { - if (($maybeDependency = $dependency->resolveParameter($parameter->getName())) !== false) { - if (is_null($maybeDependency)) { - if ($parameter->isOptional()) { - $dependencies[] = $maybeDependency; - return; - } else { - throw new BindingResolutionException('Custom resolution logic returned `null`, but parameter is not optional.', -32000); - } - } - - $paramType = Reflector::getParameterClassName($parameter); - if ($maybeDependency instanceof $paramType) { - $dependencies[] = $maybeDependency; - return; - } else { - throw new BindingResolutionException('Custom resolution logic returned a parameter with an invalid type.', -32001); - } + collect($dependencies) + ->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); + }) + ->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) { @@ -61,7 +61,7 @@ protected static function addDependencyForCallParameter($container, $parameter, if (!$instance instanceof UrlRoutable) { throw new BindingResolutionException('Mapped parameter type must implement `UrlRoutable` interface.', -32002); } - [ $instanceValue, $instanceField ] = self::getValueAndFieldByMapEntry($parameterMap[$paramName]); + [$instanceValue, $instanceField] = self::getValueAndFieldByMapEntry($parameterMap[$paramName]); if (!$model = $instance->resolveRouteBinding($instanceValue, $instanceField)) { throw (new ModelNotFoundException('', -32003))->setModel(get_class($instance), [$instanceValue]); } @@ -70,26 +70,26 @@ protected static function addDependencyForCallParameter($container, $parameter, } } } - + // Attempt resolution using the Global bindings /** @var BindingServiceProvider $binder */ $binder = $container->make('sajya-rpc-binder'); - $procedureClass = $parameter->getDeclaringClass()->name.'@'.$parameter->getDeclaringFunction()->name; + $procedureClass = $parameter->getDeclaringClass()->name . '@' . $parameter->getDeclaringFunction()->name; $requestParameters = request()->request->all(); - + $maybeInstance = $binder->resolveInstance( $requestParameters, $paramName, $procedureClass ); - if (false!==$maybeInstance) { + 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. * @@ -102,8 +102,8 @@ 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)) { + $requestParamMapEntry[count($requestParamMapEntry) - 1] = $entry[0]; + } else if (is_string($requestParamMapEntry)) { $entry = explode(':', $requestParamMapEntry); $requestParamMapEntry = $entry[0]; } else { diff --git a/src/Exceptions/BindingResolutionException.php b/src/Exceptions/BindingResolutionException.php new file mode 100644 index 0000000..f64a49e --- /dev/null +++ b/src/Exceptions/BindingResolutionException.php @@ -0,0 +1,37 @@ +setData($data); + } + + /** + * Internal JSON-RPC error. + */ + protected function getDefaultCode(): int + { + return -32000; + } + + /** + * A String providing a short description of the error. + * The message SHOULD be limited to a concise single sentence. + */ + protected function getDefaultMessage(): string + { + return 'Custom resolution logic returned `null`, but parameter is not optional.'; + } +} From 85aa3c08db116fc35463ed59c71f7330d6cd03b2 Mon Sep 17 00:00:00 2001 From: tabuna Date: Thu, 8 Apr 2021 13:12:04 +0000 Subject: [PATCH 7/9] Fix styling --- src/Binding/BindingServiceProvider.php | 111 ++++++------ src/Binding/BindsParameters.php | 2 +- src/Binding/BoundMethod.php | 20 ++- src/Binding/HandlesRequestParameters.php | 5 +- src/ServerServiceProvider.php | 4 +- tests/FixtureProcedure.php | 24 +-- tests/FixtureRequest.php | 18 +- tests/Unit/BindingGlobalTest.php | 210 +++++++++++++---------- tests/Unit/BindingRequestTest.php | 49 +++--- 9 files changed, 245 insertions(+), 198 deletions(-) diff --git a/src/Binding/BindingServiceProvider.php b/src/Binding/BindingServiceProvider.php index dae2269..b01c4c9 100644 --- a/src/Binding/BindingServiceProvider.php +++ b/src/Binding/BindingServiceProvider.php @@ -56,32 +56,33 @@ class BindingServiceProvider /** * Create a new instance. * - * @param Container|null $container + * @param Container|null $container + * * @return void */ public function __construct(Container $container = null) { - $this->container = $container ?: new Container; + $this->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. + * @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 * @@ -106,25 +107,25 @@ public function model( /** * 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. + * @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 * @@ -151,13 +152,14 @@ public function bind( /** * 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. + * @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. * - * @return string * @throws \JsonException + * + * @return string */ private function makeKey($requestParam, $scope, ?string $procedureMethod): string { @@ -173,8 +175,9 @@ private function makeKey($requestParam, $scope, ?string $procedureMethod): strin * @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|mixed False if cannot resolve, the resolved instance otherwise. * @throws BindingResolutionException + * + * @return false|mixed False if cannot resolve, the resolved instance otherwise. */ public function resolveInstance($requestParameters, $targetParam, $targetCallable = '') { @@ -188,6 +191,7 @@ public function resolveInstance($requestParameters, $targetParam, $targetCallabl if (is_null($value)) { return false; } + return $this->performBinding($key, $value); } catch (\Throwable $e) { throw new BindingResolutionException('Failed to perform binding resolution.', -32003, $e); @@ -199,20 +203,20 @@ public function resolveInstance($requestParameters, $targetParam, $targetCallabl * * @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. + * @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) { + if ($boundProcedureMethodParam !== $targetParam) { continue; } $maybeBoundScope = $this->scopes[$key]; - if (!is_array($maybeBoundScope)) { + if (! is_array($maybeBoundScope)) { $maybeBoundScope = [$maybeBoundScope]; } foreach ($maybeBoundScope as $container) { @@ -221,6 +225,7 @@ public function findKey($targetParam, $targetCallable = '') } } } + return false; } @@ -234,7 +239,7 @@ public function findKey($targetParam, $targetCallable = '') */ protected static function doesCallableContain($container, $contained) { - if (''===$contained || '' === $container) { + if ('' === $contained || '' === $container) { return true; } // Note: php7 considers array with classname and method name callable @@ -246,20 +251,22 @@ protected static function doesCallableContain($container, $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) { + if (false === $container || false === $contained) { return false; } - if (count($container)>count($contained)) { + if (count($container) > count($contained)) { return false; } foreach ($container as $index => $part) { @@ -267,6 +274,7 @@ protected static function doesCallableContain($container, $contained) return false; } } + return true; } @@ -283,16 +291,17 @@ private static function preparescopeForComparision($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) { + if (count($scope) != 2) { return false; } $scope = implode('@', $scope); } - if (!is_string($scope)) { + if (! is_string($scope)) { return false; } // Split into comparable bits around \ and @ characters $scope = preg_split('/[@\\\]/', $scope); + return $scope; } diff --git a/src/Binding/BindsParameters.php b/src/Binding/BindsParameters.php index a3e406a..3d75bb7 100644 --- a/src/Binding/BindsParameters.php +++ b/src/Binding/BindsParameters.php @@ -31,7 +31,7 @@ interface BindsParameters * parameters, e.g.: `['user'=>['user','address:email']]`. */ public function getBindings(): array; - + /** * Makes the parameter to be injected into the Procedure method. * diff --git a/src/Binding/BoundMethod.php b/src/Binding/BoundMethod.php index a3f60c7..11ce29d 100644 --- a/src/Binding/BoundMethod.php +++ b/src/Binding/BoundMethod.php @@ -22,28 +22,31 @@ class BoundMethod extends \Illuminate\Container\BoundMethod * @param array $parameters * @param array $dependencies * - * @return void * @throws BindingResolutionException + * + * @return void */ protected static function addDependencyForCallParameter($container, $parameter, array &$parameters, &$dependencies) { // Attempt custom binding resolution collect($dependencies) ->whereInstanceOf(BindsParameters::class) - ->map(fn($dependency) => $dependency->resolveParameter($parameter->getName())) - ->filter(fn($dependency) => $dependency !== false) + ->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); + throw_if(is_null($dependency) && ! $parameter->isOptional(), BindingResolutionException::class); }) ->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; } @@ -58,14 +61,15 @@ protected static function addDependencyForCallParameter($container, $parameter, $parameterMap = $dependency->getBindings(); if (isset($parameterMap[$paramName])) { $instance = $container->make(Reflector::getParameterClassName($parameter)); - if (!$instance instanceof UrlRoutable) { + 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)) { + if (! $model = $instance->resolveRouteBinding($instanceValue, $instanceField)) { throw (new ModelNotFoundException('', -32003))->setModel(get_class($instance), [$instanceValue]); } $dependencies[] = $model; + return; } } @@ -84,6 +88,7 @@ protected static function addDependencyForCallParameter($container, $parameter, ); if (false !== $maybeInstance) { $dependencies[] = $maybeInstance; + return; } @@ -103,13 +108,14 @@ private static function getValueAndFieldByMapEntry($requestParamMapEntry) $last = end($requestParamMapEntry); $entry = explode(':', $last); $requestParamMapEntry[count($requestParamMapEntry) - 1] = $entry[0]; - } else if (is_string($requestParamMapEntry)) { + } 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 index f014573..427e769 100644 --- a/src/Binding/HandlesRequestParameters.php +++ b/src/Binding/HandlesRequestParameters.php @@ -20,16 +20,17 @@ trait HandlesRequestParameters */ protected static function resolveRequestValue(array $requestParameters, $requestParam) { - if (!is_array($requestParam)) { + if (! is_array($requestParam)) { return $requestParameters[$requestParam] ?? null; } $value = $requestParameters; foreach ($requestParam as $param) { - if (!isset($value[$param])) { + if (! isset($value[$param])) { return null; } $value = $value[$param]; } + return $value; } } diff --git a/src/ServerServiceProvider.php b/src/ServerServiceProvider.php index 0b708f9..0e050d2 100644 --- a/src/ServerServiceProvider.php +++ b/src/ServerServiceProvider.php @@ -39,12 +39,12 @@ 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/FixtureProcedure.php b/tests/FixtureProcedure.php index 3fba444..4c28095 100644 --- a/tests/FixtureProcedure.php +++ b/tests/FixtureProcedure.php @@ -88,10 +88,10 @@ public function validationMethod(Request $request): int return $result; } - + /** * @param Request $request - * @param User $user User resolved by global bindings. + * @param User $user User resolved by global bindings. * * @return string */ @@ -99,7 +99,7 @@ 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. @@ -110,7 +110,7 @@ public function getUserNameDefaultField(FixtureRequest $request, User $userById) { return $userById->getAttribute('name'); } - + /** * @param FixtureRequest $request * @param User $userNestedId User resolved by the default resolution logic using ID as key. @@ -121,7 +121,7 @@ public function getUserNameNestedParameter(FixtureRequest $request, User $userNe { return $userNestedId->getAttribute('name'); } - + /** * @param FixtureRequest $request * @param User $userByEmail User resolved by the default resolution logic using Email as key. @@ -132,7 +132,7 @@ public function getUserNameCustomKey(FixtureRequest $request, User $userByEmail) { return $userByEmail->getAttribute('name'); } - + /** * @param FixtureRequest $request * @param User $userCustom User resolved by the custom resolution logic. @@ -143,7 +143,7 @@ public function getUserNameCustomLogic(FixtureRequest $request, User $userCustom { return $userCustom->getAttribute('name'); } - + /** * @param FixtureRequest $request * @param null|User $userCustom User resolved by the custom resolution logic. @@ -154,7 +154,7 @@ public function getUserNameCustomLogicNullable(FixtureRequest $request, ?User $u { return is_null($userCustom) ? 'No user' : $userCustom->getAttribute('name'); } - + /** * @param FixtureRequest $request * @param User $customer User resolved by the custom resolution logic. @@ -165,11 +165,11 @@ public function getUserNameCustomLogicNested(FixtureRequest $request, User $cust { return $customer->getAttribute('name'); } - + /** * @param FixtureRequest $request - * @param Filesystem $wrongTypeVar Should trigger an exception, because - * it does not implement {@see UrlRoutable}. + * @param Filesystem $wrongTypeVar Should trigger an exception, because + * it does not implement {@see UrlRoutable}. * * @return string */ @@ -177,7 +177,7 @@ public function getUserNameWrong(FixtureRequest $request, Filesystem $wrongTypeV { return gettype($wrongTypeVar); } - + public function internalError(): void { abort(500); diff --git a/tests/FixtureRequest.php b/tests/FixtureRequest.php index d66bf53..f3ce2e1 100644 --- a/tests/FixtureRequest.php +++ b/tests/FixtureRequest.php @@ -6,13 +6,13 @@ use Illuminate\Foundation\Auth\User; use Illuminate\Foundation\Http\FormRequest; -use Sajya\Server\Binding\HandlesRequestParameters; use Sajya\Server\Binding\BindsParameters; +use Sajya\Server\Binding\HandlesRequestParameters; class FixtureRequest extends FormRequest implements BindsParameters { use HandlesRequestParameters; - + /** * Determine if the user is authorized to make this request. * @@ -22,7 +22,7 @@ public function authorize() { return true; } - + /** * Get the validation rules that apply to the request. * @@ -34,7 +34,7 @@ public function rules() 'user' => 'bail|required|max:255', ]; } - + /** * @inheritDoc */ @@ -44,26 +44,30 @@ public function getBindings(): array 'userById' => 'user', 'userByEmail' => 'user:email', 'wrongTypeVar'=> 'user', - 'userNestedId'=> ['user','id'] + 'userNestedId'=> ['user','id'], ]; } - + /** * @inheritDoc * - * @return null|false|\Illuminate\Database\Eloquent\Model * @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 index d67dddd..6ec176e 100644 --- a/tests/Unit/BindingGlobalTest.php +++ b/tests/Unit/BindingGlobalTest.php @@ -24,7 +24,8 @@ public function testFacadeBind() RPC::bind( 'user', /** - * @param string $parameter + * @param string $parameter + * * @return User|Authenticatable */ static function (string $parameter) { @@ -34,10 +35,11 @@ static function (string $parameter) { ->once() ->with('name') ->andReturn('Custom User'); + return $userMock; } ); - + $request = [ "id" => 1, "method" => "fixture@getUserName", @@ -46,16 +48,16 @@ static function (string $parameter) { ], "jsonrpc" => "2.0", ]; - + $response = [ - 'id' => 1, - "result" => "Custom User", + 'id' => 1, + "result" => "Custom User", "jsonrpc" => "2.0", ]; - + return $this->callRpcWith($request, $response); } - + /** * @testdox Bind using nested parameter. */ @@ -64,7 +66,8 @@ public function testFacadeBindNestedParameter() RPC::bind( ['customer','user'], /** - * @param string $parameter + * @param string $parameter + * * @return User|Authenticatable */ static function (string $parameter) { @@ -74,31 +77,32 @@ static function (string $parameter) { ->once() ->with('name') ->andReturn('Custom User'); + return $userMock; } ); - + $request = [ "id" => 1, "method" => "fixture@getUserName", "params" => [ "customer" => [ 'title' => 'Dr.', - 'user' => 6 + 'user' => 6, ], ], "jsonrpc" => "2.0", ]; - + $response = [ - 'id' => 1, - "result" => "Custom User", + '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.. */ @@ -107,7 +111,8 @@ public function testFacadeBindNestedParameter2() RPC::bind( ['user','id'], /** - * @param string $parameter + * @param string $parameter + * * @return User|Authenticatable */ static function (string $parameter) { @@ -117,33 +122,34 @@ static function (string $parameter) { ->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 + 'id' => 6, ], ], "jsonrpc" => "2.0", ]; - + $response = [ - 'id' => 1, - "result" => "Custom User", + 'id' => 1, + "result" => "Custom User", "jsonrpc" => "2.0", ]; - + return $this->callRpcWith($request, $response); } - + /** * @testdox Bind using a Procedure::method scope declared as string. */ @@ -152,7 +158,8 @@ public function testFacadeBindTargetStringClassMethod() RPC::bind( 'user', /** - * @param string $parameter + * @param string $parameter + * * @return User|Authenticatable */ static function (string $parameter) { @@ -162,11 +169,12 @@ static function (string $parameter) { ->once() ->with('name') ->andReturn('Custom User'); + return $userMock; }, 'Sajya\Server\Tests\FixtureProcedure@getUserName' ); - + $request = [ "id" => 1, "method" => "fixture@getUserName", @@ -175,16 +183,16 @@ static function (string $parameter) { ], "jsonrpc" => "2.0", ]; - + $response = [ - 'id' => 1, - "result" => "Custom User", + 'id' => 1, + "result" => "Custom User", "jsonrpc" => "2.0", ]; - + return $this->callRpcWith($request, $response); } - + /** * @testdox Bind using a Procedure scope declared as string. */ @@ -193,7 +201,8 @@ public function testFacadeBindTargetStringClass() RPC::bind( 'user', /** - * @param string $parameter + * @param string $parameter + * * @return User|Authenticatable */ static function (string $parameter) { @@ -203,11 +212,12 @@ static function (string $parameter) { ->once() ->with('name') ->andReturn('Custom User'); + return $userMock; }, 'Sajya\Server\Tests\FixtureProcedure' ); - + $request = [ "id" => 1, "method" => "fixture@getUserName", @@ -216,16 +226,16 @@ static function (string $parameter) { ], "jsonrpc" => "2.0", ]; - + $response = [ - 'id' => 1, - "result" => "Custom User", + 'id' => 1, + "result" => "Custom User", "jsonrpc" => "2.0", ]; - + return $this->callRpcWith($request, $response); } - + /** * @testdox Bind using a Procedure scope declared as string. */ @@ -234,7 +244,8 @@ public function testFacadeBindTargetNamespace() RPC::bind( 'user', /** - * @param string $parameter + * @param string $parameter + * * @return User|Authenticatable */ static function (string $parameter) { @@ -244,11 +255,12 @@ static function (string $parameter) { ->once() ->with('name') ->andReturn('Custom User'); + return $userMock; }, 'Sajya\Server\Tests' ); - + $request = [ "id" => 1, "method" => "fixture@getUserName", @@ -257,16 +269,16 @@ static function (string $parameter) { ], "jsonrpc" => "2.0", ]; - + $response = [ - 'id' => 1, - "result" => "Custom User", + 'id' => 1, + "result" => "Custom User", "jsonrpc" => "2.0", ]; - + return $this->callRpcWith($request, $response); } - + /** * @testdox Bind using a Procedure::method scope declared as PHP callable. */ @@ -275,7 +287,8 @@ public function testFacadeBindTargetCallable() RPC::bind( 'user', /** - * @param string $parameter + * @param string $parameter + * * @return User|Authenticatable */ static function (string $parameter) { @@ -285,11 +298,12 @@ static function (string $parameter) { ->once() ->with('name') ->andReturn('Custom User'); + return $userMock; }, [FixtureProcedure::class,'getUserName'] ); - + $request = [ "id" => 1, "method" => "fixture@getUserName", @@ -298,16 +312,16 @@ static function (string $parameter) { ], "jsonrpc" => "2.0", ]; - + $response = [ - 'id' => 1, - "result" => "Custom User", + 'id' => 1, + "result" => "Custom User", "jsonrpc" => "2.0", ]; - + return $this->callRpcWith($request, $response); } - + /** * @testdox Bind using multiple target scopes. */ @@ -316,7 +330,8 @@ public function testFacadeBindMultipleTarget() RPC::bind( 'user', /** - * @param string $parameter + * @param string $parameter + * * @return User|Authenticatable */ static function (string $parameter) { @@ -326,15 +341,16 @@ static function (string $parameter) { ->once() ->with('name') ->andReturn('Custom User'); + return $userMock; }, [ 'Sajya\Server\Tests\FixtureProcedure@subtract', [FixtureProcedure::class,'getUserName'], - 'Sajya\Server\Tests\FixtureProcedure@getUserNameDefaultKey' + 'Sajya\Server\Tests\FixtureProcedure@getUserNameDefaultKey', ] ); - + $request = [ "id" => 1, "method" => "fixture@getUserName", @@ -343,16 +359,16 @@ static function (string $parameter) { ], "jsonrpc" => "2.0", ]; - + $response = [ - 'id' => 1, - "result" => "Custom User", + 'id' => 1, + "result" => "Custom User", "jsonrpc" => "2.0", ]; - + return $this->callRpcWith($request, $response); } - + /** * @testdox Multiple scoped bindings. */ @@ -361,12 +377,14 @@ public function testFacadeBindMultipleBinds() RPC::bind( 'user', /** - * @param string $parameter + * @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' @@ -374,7 +392,8 @@ static function (string $parameter) { RPC::bind( 'user', /** - * @param string $parameter + * @param string $parameter + * * @return User|Authenticatable */ static function (string $parameter) { @@ -384,11 +403,12 @@ static function (string $parameter) { ->once() ->with('name') ->andReturn('Custom User'); + return $userMock; }, [FixtureProcedure::class,'getUserName'] ); - + $request = [ "id" => 1, "method" => "fixture@getUserName", @@ -397,16 +417,16 @@ static function (string $parameter) { ], "jsonrpc" => "2.0", ]; - + $response = [ - 'id' => 1, - "result" => "Custom User", + '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. */ @@ -415,7 +435,8 @@ public function testFacadeBindMultipleBindsPriority() RPC::bind( 'user', /** - * @param string $parameter + * @param string $parameter + * * @return User|Authenticatable */ static function (string $parameter) { @@ -425,6 +446,7 @@ static function (string $parameter) { ->once() ->with('name') ->andReturn('Custom User'); + return $userMock; }, '' @@ -432,17 +454,19 @@ static function (string $parameter) { RPC::bind( 'user', /** - * @param string $parameter + * @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", @@ -451,16 +475,16 @@ static function (string $parameter) { ], "jsonrpc" => "2.0", ]; - + $response = [ - 'id' => 1, - "result" => "Custom User", + 'id' => 1, + "result" => "Custom User", "jsonrpc" => "2.0", ]; - + return $this->callRpcWith($request, $response); } - + /** * @testdox Simple case of model. */ @@ -476,9 +500,9 @@ public function testFacadeModel() ->with('name') ->andReturn('User 3'); app()->instance(User::class, $userMock); - + RPC::model('user', User::class); - + $request = [ "id" => 1, "method" => "fixture@getUserName", @@ -487,16 +511,16 @@ public function testFacadeModel() ], "jsonrpc" => "2.0", ]; - + $response = [ - 'id' => 1, - "result" => "User 3", + 'id' => 1, + "result" => "User 3", "jsonrpc" => "2.0", ]; - + return $this->callRpcWith($request, $response); } - + /** * @testdox Failure callback should be called, if automatic model binding fails. */ @@ -509,7 +533,7 @@ public function testFacadeModelFailureCallback() ->andReturnFalse(); $userMock->shouldNotReceive('getAttribute'); app()->instance(User::class, $userMock); - + RPC::model( 'user', User::class, @@ -524,10 +548,11 @@ static function () { ->once() ->with('name') ->andReturn('Fallback User'); + return $userMock; } ); - + $request = [ "id" => 1, "method" => "fixture@getUserName", @@ -536,30 +561,31 @@ static function () { ], "jsonrpc" => "2.0", ]; - + $response = [ - 'id' => 1, - "result" => "Fallback User", + 'id' => 1, + "result" => "Fallback User", "jsonrpc" => "2.0", ]; - + return $this->callRpcWith($request, $response); } - + /** * @param array|string $request * @param array $response * @param string $route * - * @return TestResponse * @throws \JsonException + * + * @return TestResponse */ private function callRpcWith($request, array $response, string $route = 'rpc.point'): TestResponse { - if (!is_string($request)) { + if (! is_string($request)) { $request = json_encode($request, JSON_THROW_ON_ERROR); } - + return $this ->call('POST', route($route), [], [], [], [], $request) ->assertOk() diff --git a/tests/Unit/BindingRequestTest.php b/tests/Unit/BindingRequestTest.php index db3a395..950b61f 100644 --- a/tests/Unit/BindingRequestTest.php +++ b/tests/Unit/BindingRequestTest.php @@ -145,7 +145,7 @@ private function callRPC(string $path, string $route): TestResponse 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. @@ -159,7 +159,7 @@ public function testCutomLogicInvalidNull() ->with(5) ->andReturn(null); app()->instance(User::class, $userMock); - + $request = [ "id" => 1, "method" => "fixture@getUserNameCustomLogic", @@ -168,18 +168,18 @@ public function testCutomLogicInvalidNull() ], "jsonrpc" => "2.0", ]; - + $response = [ - 'id' => 1, + '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. @@ -193,7 +193,7 @@ public function testCutomLogicInvalidType() ->with(5) ->andReturn(new \stdClass()); app()->instance(User::class, $userMock); - + $request = [ "id" => 1, "method" => "fixture@getUserNameCustomLogic", @@ -202,18 +202,18 @@ public function testCutomLogicInvalidType() ], "jsonrpc" => "2.0", ]; - + $response = [ - 'id' => 1, + '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 @@ -224,7 +224,7 @@ public function testDefaultInvalidType() config()->set('app.debug', false); $userMock = \Mockery::mock(User::class); app()->instance(User::class, $userMock); - + $request = [ "id" => 1, "method" => "fixture@getUserNameWrong", @@ -233,18 +233,18 @@ public function testDefaultInvalidType() ], "jsonrpc" => "2.0", ]; - + $response = [ - 'id' => 1, + '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. @@ -258,7 +258,7 @@ public function testDefaultNotFound() ->once() ->with(1, null) ->andReturnFalse(); - + $request = [ "id" => 1, "method" => "fixture@getUserNameDefaultField", @@ -267,32 +267,33 @@ public function testDefaultNotFound() ], "jsonrpc" => "2.0", ]; - + $response = [ - 'id' => 1, + 'id' => 1, 'error' => [ 'code' => -32003, ], "jsonrpc" => "2.0", ]; - + return $this->callRpcWith($request, $response); } - + /** * @param array|string $request * @param array $response * @param string $route * - * @return TestResponse * @throws \JsonException + * + * @return TestResponse */ private function callRpcWith($request, array $response, string $route = 'rpc.point'): TestResponse { - if (!is_string($request)) { + if (! is_string($request)) { $request = json_encode($request, JSON_THROW_ON_ERROR); } - + return $this ->call('POST', route($route), [], [], [], [], $request) ->assertOk() From ed06cc422dc2d99cf420572b7246563baf874f60 Mon Sep 17 00:00:00 2001 From: BenceSzalai Date: Thu, 8 Apr 2021 21:24:41 +0200 Subject: [PATCH 8/9] refs #17 [Bind] Improve key generation Completely eliminates the potential issue covered in f3f6ea09. --- src/Binding/BindingServiceProvider.php | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/Binding/BindingServiceProvider.php b/src/Binding/BindingServiceProvider.php index b01c4c9..103891b 100644 --- a/src/Binding/BindingServiceProvider.php +++ b/src/Binding/BindingServiceProvider.php @@ -138,13 +138,13 @@ public function bind( $scope = '', $procedureMethodParam = null ): void { - $key = $this->makeKey($requestParam, $scope, $procedureMethodParam); - $this->binders[$key] = RouteBinding::forCallback($this->container, $binder); - $this->scopes[$key] = $scope; 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; } From b0ab3505dda040d263a110b0c0e457b59eafd710 Mon Sep 17 00:00:00 2001 From: BenceSzalai Date: Thu, 8 Apr 2021 22:36:38 +0200 Subject: [PATCH 9/9] refs #17 [Bind] Fix testCutomLogicInvalidNull caused by missing error code. Also removed the BindingResolutionException, which not correctly linked into the `BoundMethod::addDependencyForCallParameter()` method and can be replaced by adding the message and code parameters to the `throw_if()` call as additional arguments. --- src/Binding/BoundMethod.php | 7 +++- src/Exceptions/BindingResolutionException.php | 37 ------------------- 2 files changed, 6 insertions(+), 38 deletions(-) delete mode 100644 src/Exceptions/BindingResolutionException.php diff --git a/src/Binding/BoundMethod.php b/src/Binding/BoundMethod.php index 11ce29d..100a717 100644 --- a/src/Binding/BoundMethod.php +++ b/src/Binding/BoundMethod.php @@ -34,7 +34,12 @@ protected static function addDependencyForCallParameter($container, $parameter, ->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); + 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()) { diff --git a/src/Exceptions/BindingResolutionException.php b/src/Exceptions/BindingResolutionException.php deleted file mode 100644 index f64a49e..0000000 --- a/src/Exceptions/BindingResolutionException.php +++ /dev/null @@ -1,37 +0,0 @@ -setData($data); - } - - /** - * Internal JSON-RPC error. - */ - protected function getDefaultCode(): int - { - return -32000; - } - - /** - * A String providing a short description of the error. - * The message SHOULD be limited to a concise single sentence. - */ - protected function getDefaultMessage(): string - { - return 'Custom resolution logic returned `null`, but parameter is not optional.'; - } -}