From 3a953b60476feb00ea15bb629c45638bfa8a6fa5 Mon Sep 17 00:00:00 2001 From: Alexandre Gomes Gaigalas Date: Fri, 20 Mar 2026 00:22:48 -0300 Subject: [PATCH] Extract ResolvesCallbackArguments trait, enable in AuthBasic MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The duplicated resolveCallbackArguments() method in AbstractRoute and AbstractSyncedRoutine is extracted into a shared trait. AuthBasic now uses the trait so its callback can type-hint ServerRequestInterface to conditionally skip auth based on HTTP method (e.g. allow reads, require credentials for writes). Backward compatible — existing callbacks without PSR-7 hints behave identically. --- src/ResolvesCallbackArguments.php | 74 +++++++++++++++++++++++ src/Routes/AbstractRoute.php | 65 +------------------- src/Routines/AbstractSyncedRoutine.php | 60 +----------------- src/Routines/AuthBasic.php | 73 +++++++++++++++++++--- tests/Psr7InjectionTest.php | 84 ++++++++++++++++++++++++++ 5 files changed, 228 insertions(+), 128 deletions(-) create mode 100644 src/ResolvesCallbackArguments.php diff --git a/src/ResolvesCallbackArguments.php b/src/ResolvesCallbackArguments.php new file mode 100644 index 0000000..8c25fce --- /dev/null +++ b/src/ResolvesCallbackArguments.php @@ -0,0 +1,74 @@ + $params URL-extracted parameters + * + * @return array Resolved argument list + */ + protected function resolveCallbackArguments( + ReflectionFunctionAbstract $reflection, + array $params, + DispatchContext $context, + ): array { + $refParams = $reflection->getParameters(); + + // No declared parameters — pass all URL params through (supports func_get_args()) + if ($refParams === []) { + return $params; + } + + $args = []; + $paramIndex = 0; + $hasPsrInjection = false; + + foreach ($refParams as $refParam) { + $type = $refParam->getType(); + + if ($type instanceof ReflectionNamedType && !$type->isBuiltin()) { + $typeName = $type->getName(); + + if (is_a($typeName, ServerRequestInterface::class, true)) { + $args[] = $context->request; + $hasPsrInjection = true; + continue; + } + + if (is_a($typeName, ResponseInterface::class, true)) { + $args[] = $context->factory->createResponse(); + $hasPsrInjection = true; + continue; + } + } + + $default = $refParam->isDefaultValueAvailable() ? $refParam->getDefaultValue() : null; + $args[] = $params[$paramIndex] ?? $default; + $paramIndex++; + } + + // No PSR-7 injection happened — pass params directly (faster, preserves original behavior) + if (!$hasPsrInjection) { + return $params; + } + + return $args; + } +} diff --git a/src/Routes/AbstractRoute.php b/src/Routes/AbstractRoute.php index 822520c..e4be33d 100644 --- a/src/Routes/AbstractRoute.php +++ b/src/Routes/AbstractRoute.php @@ -4,12 +4,10 @@ namespace Respect\Rest\Routes; -use Psr\Http\Message\ResponseInterface; -use Psr\Http\Message\ServerRequestInterface; use ReflectionClass; use ReflectionFunctionAbstract; -use ReflectionNamedType; use Respect\Rest\DispatchContext; +use Respect\Rest\ResolvesCallbackArguments; use Respect\Rest\Routines\IgnorableFileExtension; use Respect\Rest\Routines\Routinable; use Respect\Rest\Routines\Unique; @@ -21,7 +19,6 @@ use function end; use function explode; use function implode; -use function is_a; use function is_string; use function ltrim; use function preg_match; @@ -58,6 +55,8 @@ // phpcs:ignore SlevomatCodingStandard.Classes.SuperfluousAbstractClassNaming.SuperfluousPrefix abstract class AbstractRoute { + use ResolvesCallbackArguments; + public const string CATCHALL_IDENTIFIER = '/**'; public const array CORE_METHODS = ['GET', 'HEAD', 'POST', 'PUT', 'PATCH', 'DELETE']; @@ -227,64 +226,6 @@ public function match(DispatchContext $context, array &$params = []): bool return true; } - /** - * Resolves callback arguments by inspecting parameter types via reflection. - * - * PSR-7 typed parameters (ServerRequestInterface, ResponseInterface) are - * injected automatically. All other parameters consume URL params positionally. - * - * @param array $params URL-extracted parameters - * - * @return array Resolved argument list - */ - protected function resolveCallbackArguments( - ReflectionFunctionAbstract $reflection, - array $params, - DispatchContext $context, - ): array { - $refParams = $reflection->getParameters(); - - // No declared parameters — pass all URL params through (supports func_get_args()) - if ($refParams === []) { - return $params; - } - - $args = []; - $paramIndex = 0; - $hasPsrInjection = false; - - foreach ($refParams as $refParam) { - $type = $refParam->getType(); - - if ($type instanceof ReflectionNamedType && !$type->isBuiltin()) { - $typeName = $type->getName(); - - if (is_a($typeName, ServerRequestInterface::class, true)) { - $args[] = $context->request; - $hasPsrInjection = true; - continue; - } - - if (is_a($typeName, ResponseInterface::class, true)) { - $args[] = $context->factory->createResponse(); - $hasPsrInjection = true; - continue; - } - } - - $default = $refParam->isDefaultValueAvailable() ? $refParam->getDefaultValue() : null; - $args[] = $params[$paramIndex] ?? $default; - $paramIndex++; - } - - // No PSR-7 injection happened — pass params directly (faster, preserves original behavior) - if (!$hasPsrInjection) { - return $params; - } - - return $args; - } - /** @return array{string, string} */ protected function createRegexPatterns(string $pattern): array { diff --git a/src/Routines/AbstractSyncedRoutine.php b/src/Routines/AbstractSyncedRoutine.php index 451ebad..a2ae90b 100644 --- a/src/Routines/AbstractSyncedRoutine.php +++ b/src/Routines/AbstractSyncedRoutine.php @@ -5,20 +5,17 @@ namespace Respect\Rest\Routines; use Closure; -use Psr\Http\Message\ResponseInterface; -use Psr\Http\Message\ServerRequestInterface; use ReflectionClass; use ReflectionFunction; use ReflectionFunctionAbstract; use ReflectionMethod; -use ReflectionNamedType; use ReflectionObject; use ReflectionParameter; use Reflector; use Respect\Rest\DispatchContext; +use Respect\Rest\ResolvesCallbackArguments; use function assert; -use function is_a; use function is_array; use function is_callable; use function is_string; @@ -27,6 +24,8 @@ // phpcs:ignore SlevomatCodingStandard.Classes.SuperfluousAbstractClassNaming.SuperfluousPrefix abstract class AbstractSyncedRoutine extends AbstractRoutine implements ParamSynced { + use ResolvesCallbackArguments; + protected Reflector|null $reflection = null; /** @return array */ @@ -64,59 +63,6 @@ public function execute(DispatchContext $context, array $params): mixed return $callback(...$params); } - /** - * Resolves callback arguments, injecting PSR-7 objects for type-hinted parameters. - * - * @param array $params - * - * @return array - */ - protected function resolveCallbackArguments( - ReflectionFunctionAbstract $reflection, - array $params, - DispatchContext $context, - ): array { - $refParams = $reflection->getParameters(); - - if ($refParams === []) { - return $params; - } - - $args = []; - $paramIndex = 0; - $hasPsrInjection = false; - - foreach ($refParams as $refParam) { - $type = $refParam->getType(); - - if ($type instanceof ReflectionNamedType && !$type->isBuiltin()) { - $typeName = $type->getName(); - - if (is_a($typeName, ServerRequestInterface::class, true)) { - $args[] = $context->request; - $hasPsrInjection = true; - continue; - } - - if (is_a($typeName, ResponseInterface::class, true)) { - $args[] = $context->factory->createResponse(); - $hasPsrInjection = true; - continue; - } - } - - $default = $refParam->isDefaultValueAvailable() ? $refParam->getDefaultValue() : null; - $args[] = $params[$paramIndex] ?? $default; - $paramIndex++; - } - - if (!$hasPsrInjection) { - return $params; - } - - return $args; - } - protected function getReflection(): Reflector { $callback = $this->getCallback(); diff --git a/src/Routines/AuthBasic.php b/src/Routines/AuthBasic.php index 69d6af3..ac988c7 100644 --- a/src/Routines/AuthBasic.php +++ b/src/Routines/AuthBasic.php @@ -4,16 +4,28 @@ namespace Respect\Rest\Routines; +use Closure; +use Psr\Http\Message\ResponseInterface; +use Psr\Http\Message\ServerRequestInterface; +use ReflectionFunction; +use ReflectionFunctionAbstract; +use ReflectionMethod; +use ReflectionNamedType; use Respect\Rest\DispatchContext; +use Respect\Rest\ResolvesCallbackArguments; use function array_merge; use function base64_decode; use function explode; +use function is_a; +use function is_array; use function stripos; use function substr; final class AuthBasic extends AbstractRoutine implements ProxyableBy { + use ResolvesCallbackArguments; + public function __construct(public string $realm, mixed $callback) { parent::__construct($callback); @@ -22,22 +34,65 @@ public function __construct(public string $realm, mixed $callback) /** @param array $params */ public function by(DispatchContext $context, array $params): mixed { - $callbackResponse = false; - $authorization = $context->request->getHeaderLine('Authorization'); + $hasCredentials = $authorization !== '' && stripos($authorization, 'Basic ') === 0; - if ($authorization !== '' && stripos($authorization, 'Basic ') === 0) { - $callbackResponse = ($this->callback)( - ...array_merge(explode(':', base64_decode(substr($authorization, 6))), $params), - ); + if ($hasCredentials) { + $credentials = explode(':', base64_decode(substr($authorization, 6))); + } elseif ($this->callbackAcceptsPsr7()) { + $credentials = ['', '']; + } else { + return $this->unauthorizedResponse($context); } - if ($callbackResponse === false) { - $response = $context->factory->createResponse(401); + $allParams = array_merge($credentials, $params); + $args = $this->resolveCallbackArguments( + $this->getCallbackReflection(), + $allParams, + $context, + ); - return $response->withHeader('WWW-Authenticate', 'Basic realm="' . $this->realm . '"'); + $callbackResponse = ($this->callback)(...$args); + + if ($callbackResponse === false) { + return $this->unauthorizedResponse($context); } return $callbackResponse; } + + private function unauthorizedResponse(DispatchContext $context): ResponseInterface + { + $response = $context->factory->createResponse(401); + + return $response->withHeader('WWW-Authenticate', 'Basic realm="' . $this->realm . '"'); + } + + private function callbackAcceptsPsr7(): bool + { + foreach ($this->getCallbackReflection()->getParameters() as $param) { + $type = $param->getType(); + + if (!$type instanceof ReflectionNamedType || $type->isBuiltin()) { + continue; + } + + if (is_a($type->getName(), ServerRequestInterface::class, true)) { + return true; + } + } + + return false; + } + + private function getCallbackReflection(): ReflectionFunctionAbstract + { + $callback = $this->getCallback(); + + if (is_array($callback)) { + return new ReflectionMethod($callback[0], $callback[1]); + } + + return new ReflectionFunction(Closure::fromCallable($callback)); + } } diff --git a/tests/Psr7InjectionTest.php b/tests/Psr7InjectionTest.php index 671b17b..a546c6b 100644 --- a/tests/Psr7InjectionTest.php +++ b/tests/Psr7InjectionTest.php @@ -12,8 +12,10 @@ use Psr\Http\Message\ServerRequestInterface; use Respect\Rest\Router; +use function base64_encode; use function func_get_args; use function implode; +use function in_array; use function strtoupper; /** @@ -142,6 +144,88 @@ public function routineByCallbackReceivesRequestWhenTypeHinted(): void self::assertEquals('Bearer token123', $captured); } + #[Test] + public function authBasicCallbackReceivesRequestWhenTypeHinted(): void + { + $capturedMethod = null; + $this->router->get('/protected', static function () { + return 'secret'; + })->authBasic('Test Realm', static function ( + string $user, + string $pass, + ServerRequestInterface $request, + ) use (&$capturedMethod) { + $capturedMethod = $request->getMethod(); + + return $user === 'admin' && $pass === 'secret'; + }); + + $serverRequest = (new ServerRequest('GET', '/protected')) + ->withHeader('Authorization', 'Basic ' . base64_encode('admin:secret')); + $response = $this->router->dispatch($serverRequest)->response(); + + self::assertNotNull($response); + self::assertNotEquals(401, $response->getStatusCode()); + self::assertEquals('GET', $capturedMethod); + } + + #[Test] + public function authBasicSkipsAuthForGetWhenRequestInjected(): void + { + $callback = static function ( + string $user, + string $pass, + ServerRequestInterface $request, + ) { + if (in_array($request->getMethod(), ['GET', 'HEAD'], true)) { + return true; + } + + return $user === 'admin' && $pass === 'secret'; + }; + + $this->router->any('/resource', static function () { + return 'data'; + })->authBasic('Realm', $callback); + + // GET without credentials should pass (auth skipped for reads) + $getResponse = $this->router->dispatch(new ServerRequest('GET', '/resource'))->response(); + self::assertNotNull($getResponse); + self::assertNotEquals(401, $getResponse->getStatusCode()); + self::assertEquals('data', (string) $getResponse->getBody()); + + // POST without credentials should fail + $postResponse = $this->router->dispatch(new ServerRequest('POST', '/resource'))->response(); + self::assertNotNull($postResponse); + self::assertEquals(401, $postResponse->getStatusCode()); + + // POST with valid credentials should pass + $authedPost = (new ServerRequest('POST', '/resource')) + ->withHeader('Authorization', 'Basic ' . base64_encode('admin:secret')); + $postOk = $this->router->dispatch($authedPost)->response(); + self::assertNotNull($postOk); + self::assertNotEquals(401, $postOk->getStatusCode()); + } + + #[Test] + public function authBasicWithoutTypeHintsStillPassesParams(): void + { + $captured = []; + $this->router->get('/items/*/*', static function () { + return 'ok'; + })->authBasic('Realm', static function ($user, $pass, $p1 = null, $p2 = null) use (&$captured) { + $captured = [$user, $pass, $p1, $p2]; + + return true; + }); + + $serverRequest = (new ServerRequest('GET', '/items/a/b')) + ->withHeader('Authorization', 'Basic ' . base64_encode('john:doe')); + $this->router->dispatch($serverRequest)->response(); + + self::assertEquals(['john', 'doe', 'a', 'b'], $captured); + } + #[Test] public function routineThroughCallbackReceivesResponseWhenTypeHinted(): void {