diff --git a/Engine/App.php b/Engine/App.php index 145dd70..75f8629 100755 --- a/Engine/App.php +++ b/Engine/App.php @@ -3,6 +3,8 @@ namespace Shift; use Shift\Error\HttpError; +use Shift\Middleware\MiddlewareInterface; +use Shift\Middleware\MiddlewarePipeline; use Shift\Response\JsonResponse; use Shift\Response\Response; use Shift\Response\ResponseEmitter; @@ -31,6 +33,7 @@ final class App private ServiceContainer $container; private Router $router; private ResponseEmitter $emitter; + private array $middleware = []; public function __construct(Request $request, ?Router $router = null, ?ResponseEmitter $emitter = null) { @@ -44,7 +47,7 @@ public function __construct(Request $request, ?Router $router = null, ?ResponseE public function start(): void { try { - $response = $this->dispatch(); + $response = $this->handleRequest(); } catch (HttpError $exception) { $response = JsonResponse::error( $exception->getMessage(), @@ -59,13 +62,37 @@ public function start(): void $this->emitter->emit($response); } + public function middleware(MiddlewareInterface|callable|string $middleware): self + { + $this->middleware[] = $middleware; + + return $this; + } + + /** + * @return array + */ + public function getMiddleware(): array + { + return $this->middleware; + } + + private function handleRequest(): Response + { + return (new MiddlewarePipeline($this->container))->handle( + $this->request, + $this->middleware, + fn (Request $request): Response => $this->dispatch($request) + ); + } + /** * @throws ReflectionException */ - private function dispatch(): Response + private function dispatch(Request $request): Response { - $match = $this->router->match($this->request); - $this->request->setRouteParams($match->getParameters()); + $match = $this->router->match($request); + $request->setRouteParams($match->getParameters()); [$controllerClass, $methodName] = $match->getHandler(); @@ -73,7 +100,7 @@ private function dispatch(): Response throw new HttpError('Endpoint not found', 404); } - $controller = new $controllerClass($this->request, $this->container); + $controller = new $controllerClass($request, $this->container); $reflectionClass = new ReflectionClass($controller); if (!$reflectionClass->hasMethod($methodName)) { @@ -83,7 +110,7 @@ private function dispatch(): Response $method = $reflectionClass->getMethod($methodName); $result = $method->invokeArgs( $controller, - $this->resolveMethodArguments($method->getParameters(), $match->getParameters()) + $this->resolveMethodArguments($method->getParameters(), $match->getParameters(), $request) ); return $this->applyResponseAttributes( @@ -95,7 +122,7 @@ private function dispatch(): Response /** * @param ReflectionParameter[] $parameters */ - private function resolveMethodArguments(array $parameters, array $routeParameters): array + private function resolveMethodArguments(array $parameters, array $routeParameters, Request $request): array { $arguments = []; $orderedRouteParameters = array_values($routeParameters); @@ -104,7 +131,7 @@ private function resolveMethodArguments(array $parameters, array $routeParameter $type = $parameter->getType(); if ($type instanceof ReflectionNamedType && $type->getName() === Request::class) { - $arguments[] = $this->request; + $arguments[] = $request; continue; } @@ -122,7 +149,7 @@ private function resolveMethodArguments(array $parameters, array $routeParameter if ($queryParam instanceof QueryParam) { $name = $queryParam->name ?? $parameter->getName(); $arguments[] = $this->castParameterValue( - $this->request->query($name, $queryParam->default ?? $this->getDefaultParameterValue($parameter)), + $request->query($name, $queryParam->default ?? $this->getDefaultParameterValue($parameter)), $parameter ); continue; @@ -130,7 +157,7 @@ private function resolveMethodArguments(array $parameters, array $routeParameter $body = $this->getParameterAttribute($parameter, Body::class); if ($body instanceof Body) { - $json = $this->request->getJson(); + $json = $request->getJson(); $value = $body->key === null ? $json : ($json[$body->key] ?? $this->getDefaultParameterValue($parameter)); diff --git a/Engine/Middleware/MiddlewareInterface.php b/Engine/Middleware/MiddlewareInterface.php new file mode 100644 index 0000000..d2ced19 --- /dev/null +++ b/Engine/Middleware/MiddlewareInterface.php @@ -0,0 +1,11 @@ + $middleware + */ + public function handle(Request $request, array $middleware, callable $destination): Response + { + $pipeline = array_reduce( + array_reverse($middleware), + fn (callable $next, mixed $middleware): callable => function (Request $request) use ($middleware, $next): Response { + return $this->callMiddleware($middleware, $request, $next); + }, + $destination + ); + + $response = $pipeline($request); + + if (!$response instanceof Response) { + throw new InvalidArgumentException('Middleware pipeline must return a response.'); + } + + return $response; + } + + private function callMiddleware(mixed $middleware, Request $request, callable $next): Response + { + if (is_string($middleware)) { + if (!class_exists($middleware)) { + throw new InvalidArgumentException("Middleware class '{$middleware}' not found."); + } + + $middleware = $this->container->has($middleware) + ? $this->container->resolve($middleware) + : new $middleware(); + } + + if ($middleware instanceof MiddlewareInterface) { + return $middleware->handle($request, $next); + } + + if (is_callable($middleware)) { + $response = $middleware($request, $next); + + if (!$response instanceof Response) { + throw new InvalidArgumentException('Middleware must return a response.'); + } + + return $response; + } + + throw new InvalidArgumentException('Middleware must be a callable, class name, or MiddlewareInterface instance.'); + } +} diff --git a/README.md b/README.md index 9e4fd9e..943c620 100644 --- a/README.md +++ b/README.md @@ -11,6 +11,7 @@ The current architecture focuses on an API-only modular monolith: - controller actions, - JSON responses, - request helpers, +- middleware pipeline, - JSON error responses, - a small service container. @@ -129,6 +130,44 @@ $request->routeParam('id'); Malformed JSON bodies are returned as `400 Bad Request`. +## Middleware + +Middleware can wrap or stop request handling before the controller action runs: + +```php +use Shift\Middleware\MiddlewareInterface; +use Shift\Request; +use Shift\Response\Response; + +class AuthMiddleware implements MiddlewareInterface +{ + public function handle(Request $request, callable $next): Response + { + if ($request->getHeader('Authorization') === null) { + return new Response('Unauthorized', 401); + } + + return $next($request); + } +} + +$app->middleware(AuthMiddleware::class); +``` + +Callable middleware is also supported: + +```php +$app->middleware(function (Request $request, callable $next): Response { + $response = $next($request); + + return new Response( + $response->getContent(), + $response->getStatusCode(), + $response->getHeaders() + ['X-Api' => 'Shift'] + ); +}); +``` + ## Run Locally ```sh diff --git a/REFACTORING.md b/REFACTORING.md index 5deeaa6..9d3c08a 100644 --- a/REFACTORING.md +++ b/REFACTORING.md @@ -18,6 +18,7 @@ ShiftPHP is moving toward an API-only modular monolith. View templates, compiled - [x] PHP 8 attributes for response metadata and parameter binding. - [x] Modular monolith support through `application/modules/*/Module.php`. - [x] Module-owned controllers, routes, services and commands. +- [x] Middleware pipeline. - [x] Removal of view storage and example page assets from runtime. - [x] Removal of legacy `application/controllers` and `application/routes.php`. - [x] Domain-oriented framework namespaces: @@ -56,6 +57,7 @@ application/modules/{ModuleName}/ ```text Request -> App + -> Middleware pipeline -> Router -> Controller action -> Response @@ -141,7 +143,6 @@ Internal errors return a generic `500` message unless `display_errors` is enable ## Next -- [ ] Middleware pipeline. - [ ] Controller autowiring through the container. - [ ] Validation helpers and typed request DTOs. - [ ] CORS middleware. diff --git a/tests/ApiCoreTest.php b/tests/ApiCoreTest.php index cea9fed..ea50a1a 100644 --- a/tests/ApiCoreTest.php +++ b/tests/ApiCoreTest.php @@ -3,9 +3,11 @@ use Shift\App; use Shift\Controller; use Shift\Error\HttpError; +use Shift\Middleware\MiddlewareInterface; use Shift\Modules\ModuleLoader; use Shift\Request; use Shift\Response\JsonResponse; +use Shift\Response\Response; use Shift\Response\ResponseEmitter; use Shift\Routing\AttributeRouteLoader; use Shift\Routing\Attributes\Body; @@ -69,6 +71,20 @@ public function created(#[Body('name')] string $name): array } } +final class HeaderMiddleware implements MiddlewareInterface +{ + public function handle(Request $request, callable $next): Response + { + $response = $next($request); + + return new Response( + $response->getContent(), + $response->getStatusCode(), + $response->getHeaders() + ['X-Middleware' => 'class'] + ); + } +} + function assertSameValue(mixed $expected, mixed $actual, string $message): void { if ($expected !== $actual) { @@ -214,6 +230,57 @@ function makeRequest(string $method, string $uri, string $body = '', array $quer assertSameValue('Shift', $payload['name'] ?? null, 'Body attribute should bind JSON body key.'); }; +$tests['app runs middleware around controller dispatch'] = function (): void { + $router = new Router(); + $router->get('/test/api/{argument}', [TestAttributeController::class, 'api']); + $emitter = new CapturingEmitter(); + $events = []; + + $app = new App(makeRequest('GET', '/test/api/demo'), $router, $emitter); + $app->middleware(function (Request $request, callable $next) use (&$events): Response { + $events[] = 'before'; + $response = $next($request); + $events[] = 'after'; + + return new Response( + $response->getContent(), + $response->getStatusCode(), + $response->getHeaders() + ['X-Middleware' => 'callable'] + ); + }); + $app->start(); + + assertSameValue(['before', 'after'], $events, 'Middleware should wrap controller dispatch.'); + assertArrayHasKeyValue('X-Middleware', 'callable', $emitter->headers, 'Middleware should be able to modify response headers.'); +}; + +$tests['app supports middleware classes'] = function (): void { + $router = new Router(); + $router->get('/test/api/{argument}', [TestAttributeController::class, 'api']); + $emitter = new CapturingEmitter(); + + $app = new App(makeRequest('GET', '/test/api/demo'), $router, $emitter); + $app->middleware(HeaderMiddleware::class); + $app->start(); + + assertArrayHasKeyValue('X-Middleware', 'class', $emitter->headers, 'Middleware class should be resolved and executed.'); +}; + +$tests['middleware can short-circuit request handling'] = function (): void { + $router = new Router(); + $router->get('/test/api/{argument}', [TestAttributeController::class, 'api']); + $emitter = new CapturingEmitter(); + + $app = new App(makeRequest('GET', '/test/api/demo'), $router, $emitter); + $app->middleware(static fn (Request $request, callable $next): Response => JsonResponse::error('Blocked', 403)); + $app->start(); + + $payload = json_decode($emitter->content, true); + + assertSameValue(403, $emitter->statusCode, 'Middleware should be able to short-circuit the request.'); + assertSameValue('Blocked', $payload['error']['message'] ?? null, 'Short-circuit response should be emitted.'); +}; + $tests['module loader registers services and routes'] = function (): void { $loader = (new ModuleLoader())->load(); $router = new Router();