Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 37 additions & 10 deletions Engine/App.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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)
{
Expand All @@ -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(),
Expand All @@ -59,21 +62,45 @@ public function start(): void
$this->emitter->emit($response);
}

public function middleware(MiddlewareInterface|callable|string $middleware): self
{
$this->middleware[] = $middleware;

return $this;
}

/**
* @return array<int, MiddlewareInterface|callable|class-string>
*/
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();

if (!class_exists($controllerClass)) {
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)) {
Expand All @@ -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(
Expand All @@ -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);
Expand All @@ -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;
}

Expand All @@ -122,15 +149,15 @@ 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;
}

$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));
Expand Down
11 changes: 11 additions & 0 deletions Engine/Middleware/MiddlewareInterface.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
<?php

namespace Shift\Middleware;

use Shift\Request;
use Shift\Response\Response;

interface MiddlewareInterface
{
public function handle(Request $request, callable $next): Response;
}
66 changes: 66 additions & 0 deletions Engine/Middleware/MiddlewarePipeline.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
<?php

namespace Shift\Middleware;

use InvalidArgumentException;
use Shift\Request;
use Shift\Response\Response;
use Shift\Service\ServiceContainer;

final class MiddlewarePipeline
{
public function __construct(private readonly ServiceContainer $container)
{
}

/**
* @param array<int, MiddlewareInterface|callable|class-string> $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.');
}
}
39 changes: 39 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down Expand Up @@ -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
Expand Down
3 changes: 2 additions & 1 deletion REFACTORING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -56,6 +57,7 @@ application/modules/{ModuleName}/
```text
Request
-> App
-> Middleware pipeline
-> Router
-> Controller action
-> Response
Expand Down Expand Up @@ -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.
Expand Down
67 changes: 67 additions & 0 deletions tests/ApiCoreTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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();
Expand Down
Loading