diff --git a/.github/workflows/api.yml b/.github/workflows/api.yml
index 224ea88..b281221 100644
--- a/.github/workflows/api.yml
+++ b/.github/workflows/api.yml
@@ -35,7 +35,7 @@ jobs:
run: composer dump-autoload
- name: Lint PHP files
- run: find Engine application tests -name '*.php' -print0 | xargs -0 -n1 php -l
+ run: find src application tests -name '*.php' -print0 | xargs -0 -n1 php -l
- name: Run API core tests
run: composer test
diff --git a/README.md b/README.md
index a51d419..f31dcbe 100644
--- a/README.md
+++ b/README.md
@@ -12,6 +12,7 @@ The current architecture focuses on an API-only modular monolith:
- JSON responses,
- request helpers,
- middleware pipeline,
+- validation helpers and typed request DTOs,
- JSON error responses,
- a small service container.
@@ -120,6 +121,7 @@ You can use these routing attributes:
- `#[PathParam('id')]`
- `#[QueryParam('include')]`
- `#[Body]` or `#[Body('field')]`
+- `#[BodyDto]`
## Responses
@@ -147,6 +149,43 @@ $request->routeParam('id');
Malformed JSON bodies are returned as `400 Bad Request`.
+## Validation and DTOs
+
+Request DTOs extend `Shift\Validation\RequestDto` and define validation rules:
+
+```php
+use Shift\Validation\RequestDto;
+
+final class CreateUserDto extends RequestDto
+{
+ public function __construct(
+ public readonly string $email,
+ public readonly int $age
+ ) {
+ }
+
+ public static function rules(): array
+ {
+ return [
+ 'email' => 'required|string|email',
+ 'age' => 'required|int|min:18',
+ ];
+ }
+}
+```
+
+DTOs can be bound by type or with `#[BodyDto]`:
+
+```php
+#[Post('/users')]
+public function create(#[BodyDto] CreateUserDto $dto): array
+{
+ return ['email' => $dto->email, 'age' => $dto->age];
+}
+```
+
+Validation failures are returned as `422` JSON responses. Supported rules are `required`, `string`, `int`, `bool`, `array`, `email`, `min`, and `max`.
+
## Middleware
Middleware can wrap or stop request handling before the controller action runs:
@@ -185,6 +224,8 @@ $app->middleware(function (Request $request, callable $next): Response {
});
```
+Built-in middleware includes `Shift\Middleware\CorsMiddleware`, `Shift\Middleware\AuthMiddleware`, and `Shift\Middleware\AuthorizationMiddleware`. Auth middleware uses `Shift\Auth\AuthenticatorInterface`; authorization middleware uses `Shift\Auth\AuthorizerInterface`.
+
## Service Container
The container can resolve registered services and build classes with typed constructor dependencies:
@@ -275,6 +316,8 @@ class Module extends AbstractModule
Modules are loaded automatically by convention from `application/modules/*/Module.php`.
+Modules may expose config through `config.php` and `getConfig()`. Config is available from the loader with `$modules->getConfig()` and in the container under `modules.config`. Modules may also implement `boot(ServiceContainer $container)` for work that should run after service registration.
+
## Tests
Run the lightweight API core test suite:
diff --git a/REFACTORING.md b/REFACTORING.md
index 79b0437..54b2fdc 100644
--- a/REFACTORING.md
+++ b/REFACTORING.md
@@ -20,6 +20,12 @@ ShiftPHP is moving toward an API-only modular monolith. View templates, compiled
- [x] Module-owned controllers, routes, services and commands.
- [x] Middleware pipeline.
- [x] Controller autowiring through the container.
+- [x] Validation helpers and typed request DTOs.
+- [x] CORS middleware.
+- [x] Authentication and authorization middleware contracts.
+- [x] Module configuration loading.
+- [x] Module lifecycle hooks, for example `boot()` after service registration.
+- [x] Framework source moved to `src/` for package split preparation.
- [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:
@@ -133,10 +139,10 @@ Internal errors return a generic `500` message unless `display_errors` is enable
## Removed From Runtime
-- `Engine/View`
+- legacy `Engine/View`
- legacy view namespace
-- `Engine/Utils/Storage.php`
-- `Engine/Error/StorageError.php`
+- legacy `Engine/Utils/Storage.php`
+- legacy `Engine/Error/StorageError.php`
- example CSS and JS page assets
- `View\\` composer namespace
- `application/controllers`
@@ -144,12 +150,7 @@ Internal errors return a generic `500` message unless `display_errors` is enable
## Next
-- [ ] Validation helpers and typed request DTOs.
-- [ ] CORS middleware.
-- [ ] Authentication and authorization middleware contracts.
- [ ] Structured logging for exceptions.
-- [ ] Module configuration loading.
-- [ ] Module lifecycle hooks, for example `boot()` after service registration.
- [ ] Module discovery cache for production.
- [ ] CLI command namespaces and command metadata.
- [ ] Basic package-quality checks, for example static analysis and coding style.
diff --git a/application/modules/Health/Module.php b/application/modules/Health/Module.php
index 1f2de04..e55131c 100644
--- a/application/modules/Health/Module.php
+++ b/application/modules/Health/Module.php
@@ -16,6 +16,13 @@ public function getName(): string
return 'health';
}
+ public function getConfig(): array
+ {
+ return [
+ 'module' => 'health',
+ ];
+ }
+
public function registerServices(ServiceContainer $container): void
{
$container->singleton(HealthService::class, HealthService::class);
@@ -37,4 +44,9 @@ public function getCommandMappings(): array
],
];
}
+
+ public function boot(ServiceContainer $container): void
+ {
+ $container->singleton('health.booted', true);
+ }
}
diff --git a/application/modules/Health/config.php b/application/modules/Health/config.php
new file mode 100644
index 0000000..e24c384
--- /dev/null
+++ b/application/modules/Health/config.php
@@ -0,0 +1,5 @@
+ true,
+];
diff --git a/composer.json b/composer.json
index b368222..3689d3f 100644
--- a/composer.json
+++ b/composer.json
@@ -19,7 +19,7 @@
},
"autoload": {
"psr-4": {
- "Shift\\": "Engine",
+ "Shift\\": "src",
"Modules\\": "application/modules"
}
}
diff --git a/docs/index.html b/docs/index.html
index 0c48e96..4f3b310 100644
--- a/docs/index.html
+++ b/docs/index.html
@@ -22,6 +22,7 @@
Contents
Controllers
Requests
Responses
+ Validation and DTOs
Middleware
Service Container
CLI
@@ -42,7 +43,7 @@ Requirements
Architecture
- The public framework namespace is Shift\. Composer currently maps it to the Engine/ directory.
+ The public framework namespace is Shift\. Composer maps it to the src/ directory.
Application code lives under application/. Modules live under application/modules/{ModuleName} and are autoloaded with the Modules\ namespace.
Request Flow
@@ -102,7 +103,7 @@ Bootstrap
Modules
- A module owns its controllers, routes, services, and CLI commands.
+ A module owns its controllers, routes, services, config, lifecycle hooks, and CLI commands.
application/modules/Health/
|-- Module.php
@@ -149,9 +150,14 @@ Modules
],
];
}
+
+ public function boot(ServiceContainer $container): void
+ {
+ $container->singleton('health.booted', true);
+ }
}
- Shift\Modules\ModuleLoader discovers modules by convention from application/modules/*/Module.php.
+ Shift\Modules\ModuleLoader discovers modules by convention from application/modules/*/Module.php. Module config can be returned from getConfig() or from a module-level config.php file. Merged config is available through $modules->getConfig() and the container singleton modules.config.
@@ -180,6 +186,7 @@ Parameter Binding Attributes
#[QueryParam('include')] reads a query string value
#[Body] reads the decoded JSON body
#[Body('name')] reads one JSON body field
+ #[BodyDto] validates the JSON body into a request DTO
#[RoutePrefix('/users')]
@@ -278,6 +285,43 @@ Responses
JsonResponse automatically sets Content-Type: application/json.
+
+ Validation and DTOs
+ Shift\Validation\Validator validates arrays and returns validated values. Validation failures throw Shift\Validation\ValidationException, which the app emits as a 422 JSON response.
+
+ Supported rules are required, string, int, bool, array, email, min, and max.
+
+ use Shift\Validation\RequestDto;
+
+final class CreateUserDto extends RequestDto
+{
+ public function __construct(
+ public readonly string $email,
+ public readonly int $age
+ ) {
+ }
+
+ public static function rules(): array
+ {
+ return [
+ 'email' => 'required|string|email',
+ 'age' => 'required|int|min:18',
+ ];
+ }
+}
+
+ DTOs can be bound by type or with #[BodyDto]:
+
+ #[Post('/users')]
+public function create(#[BodyDto] CreateUserDto $dto): array
+{
+ return [
+ 'email' => $dto->email,
+ 'age' => $dto->age,
+ ];
+}
+
+
Middleware
Middleware runs before controller dispatch. It can continue the request by calling $next($request), modify the returned response, or return a response immediately.
@@ -316,6 +360,13 @@ Middleware
});
Middleware may be a class string, an object implementing MiddlewareInterface, or a callable. Class strings are resolved from the service container when registered there.
+
+ Built-in Middleware
+
+ Shift\Middleware\CorsMiddleware handles CORS headers and preflight requests.
+ Shift\Middleware\AuthMiddleware uses Shift\Auth\AuthenticatorInterface to authenticate a request.
+ Shift\Middleware\AuthorizationMiddleware uses Shift\Auth\AuthorizerInterface to authorize an authenticated user.
+
diff --git a/index.php b/index.php
index 57f1fb9..4cf9c45 100644
--- a/index.php
+++ b/index.php
@@ -15,5 +15,6 @@
$modules = (new ModuleLoader())->load();
$modules->registerServices($app->getContainer());
$modules->registerRoutes($app->getRouter());
+$modules->boot($app->getContainer());
$app->start();
diff --git a/Engine/App.php b/src/App.php
similarity index 84%
rename from Engine/App.php
rename to src/App.php
index 1a4bacf..150577e 100755
--- a/Engine/App.php
+++ b/src/App.php
@@ -9,12 +9,15 @@
use Shift\Response\Response;
use Shift\Response\ResponseEmitter;
use Shift\Routing\Attributes\Body;
+use Shift\Routing\Attributes\BodyDto;
use Shift\Routing\Attributes\Header;
use Shift\Routing\Attributes\PathParam;
use Shift\Routing\Attributes\QueryParam;
use Shift\Routing\Attributes\Status;
use Shift\Routing\Router\Router;
use Shift\Service\ServiceContainer;
+use Shift\Validation\RequestDto;
+use Shift\Validation\ValidationException;
use JsonException;
use ReflectionClass;
use ReflectionException;
@@ -48,6 +51,13 @@ public function start(): void
{
try {
$response = $this->handleRequest();
+ } catch (ValidationException $exception) {
+ $response = JsonResponse::error(
+ $exception->getMessage(),
+ $exception->getStatusCode(),
+ ['errors' => $exception->getErrors()],
+ $exception->getHeaders()
+ );
} catch (HttpError $exception) {
$response = JsonResponse::error(
$exception->getMessage(),
@@ -100,7 +110,7 @@ private function dispatch(Request $request): Response
throw new HttpError('Endpoint not found', 404);
}
- $controller = $this->createController($controllerClass);
+ $controller = $this->createController($controllerClass, $request);
$reflectionClass = new ReflectionClass($controller);
if (!$reflectionClass->hasMethod($methodName)) {
@@ -119,13 +129,19 @@ private function dispatch(Request $request): Response
);
}
- private function createController(string $controllerClass): object
+ private function createController(string $controllerClass, Request $request): object
{
if ($this->container->has($controllerClass)) {
return $this->container->resolve($controllerClass);
}
- return $this->container->make($controllerClass);
+ $controller = $this->container->make($controllerClass);
+
+ if ($controller instanceof Controller) {
+ $controller->setContext($request, $this->container);
+ }
+
+ return $controller;
}
/**
@@ -144,6 +160,22 @@ private function resolveMethodArguments(array $parameters, array $routeParameter
continue;
}
+ $bodyDto = $this->getParameterAttribute($parameter, BodyDto::class);
+ if ($bodyDto instanceof BodyDto) {
+ $dtoClass = $bodyDto->class ?? ($type instanceof ReflectionNamedType ? $type->getName() : null);
+ $arguments[] = $this->makeRequestDto($dtoClass, $request);
+ continue;
+ }
+
+ if (
+ $type instanceof ReflectionNamedType
+ && !$type->isBuiltin()
+ && is_subclass_of($type->getName(), RequestDto::class)
+ ) {
+ $arguments[] = $this->makeRequestDto($type->getName(), $request);
+ continue;
+ }
+
$pathParam = $this->getParameterAttribute($parameter, PathParam::class);
if ($pathParam instanceof PathParam) {
$name = $pathParam->name ?? $parameter->getName();
@@ -206,6 +238,15 @@ private function getParameterAttribute(ReflectionParameter $parameter, string $a
return $attributes[0]->newInstance();
}
+ private function makeRequestDto(?string $dtoClass, Request $request): RequestDto
+ {
+ if ($dtoClass === null || !is_subclass_of($dtoClass, RequestDto::class)) {
+ throw new HttpError('Endpoint not found', 404);
+ }
+
+ return $dtoClass::fromRequest($request);
+ }
+
private function getDefaultParameterValue(ReflectionParameter $parameter): mixed
{
if ($parameter->isDefaultValueAvailable()) {
diff --git a/src/Auth/AuthenticatedUser.php b/src/Auth/AuthenticatedUser.php
new file mode 100644
index 0000000..ec47817
--- /dev/null
+++ b/src/Auth/AuthenticatedUser.php
@@ -0,0 +1,12 @@
+ 'AppConsole\\Commands\\'
],
[
- 'dir' => APP_ROOT . '/Engine/Console/Commands/',
+ 'dir' => APP_ROOT . '/src/Console/Commands/',
'namespace' => 'Console\\Commands\\'
],
];
diff --git a/Engine/Console/Commands/RouteList.php b/src/Console/Commands/RouteList.php
similarity index 100%
rename from Engine/Console/Commands/RouteList.php
rename to src/Console/Commands/RouteList.php
diff --git a/Engine/Console/Commands/Serve.php b/src/Console/Commands/Serve.php
similarity index 100%
rename from Engine/Console/Commands/Serve.php
rename to src/Console/Commands/Serve.php
diff --git a/Engine/Console/Console.php b/src/Console/Console.php
similarity index 100%
rename from Engine/Console/Console.php
rename to src/Console/Console.php
diff --git a/Engine/Console/Shift.php b/src/Console/Shift.php
similarity index 97%
rename from Engine/Console/Shift.php
rename to src/Console/Shift.php
index 9044a0a..fc12fe8 100644
--- a/Engine/Console/Shift.php
+++ b/src/Console/Shift.php
@@ -66,7 +66,7 @@ public function run(): void
'namespace' => 'AppConsole\\Commands\\'
],
[
- 'dir' => APP_ROOT . '/Engine/Console/Commands/',
+ 'dir' => APP_ROOT . '/src/Console/Commands/',
'namespace' => 'Console\\Commands\\'
],
];
diff --git a/Engine/Controller.php b/src/Controller.php
similarity index 85%
rename from Engine/Controller.php
rename to src/Controller.php
index 631d490..3da44a9 100755
--- a/Engine/Controller.php
+++ b/src/Controller.php
@@ -17,9 +17,14 @@ abstract class Controller
protected ServiceContainer $container;
public function __construct(Request $request, ?ServiceContainer $container = null)
+ {
+ $this->setContext($request, $container ?? new ServiceContainer());
+ }
+
+ public function setContext(Request $request, ServiceContainer $container): void
{
$this->request = $request;
- $this->container = $container ?? new ServiceContainer();
+ $this->container = $container;
}
/**
diff --git a/Engine/Error/ErrorHandler.php b/src/Error/ErrorHandler.php
similarity index 100%
rename from Engine/Error/ErrorHandler.php
rename to src/Error/ErrorHandler.php
diff --git a/Engine/Error/HttpError.php b/src/Error/HttpError.php
similarity index 100%
rename from Engine/Error/HttpError.php
rename to src/Error/HttpError.php
diff --git a/Engine/Error/ShiftError.php b/src/Error/ShiftError.php
similarity index 100%
rename from Engine/Error/ShiftError.php
rename to src/Error/ShiftError.php
diff --git a/Engine/Error/ShiftError/ErrorHighlighter.php b/src/Error/ShiftError/ErrorHighlighter.php
similarity index 100%
rename from Engine/Error/ShiftError/ErrorHighlighter.php
rename to src/Error/ShiftError/ErrorHighlighter.php
diff --git a/Engine/Error/ShiftError/StackTrace.php b/src/Error/ShiftError/StackTrace.php
similarity index 100%
rename from Engine/Error/ShiftError/StackTrace.php
rename to src/Error/ShiftError/StackTrace.php
diff --git a/src/Middleware/AuthMiddleware.php b/src/Middleware/AuthMiddleware.php
new file mode 100644
index 0000000..ffa600e
--- /dev/null
+++ b/src/Middleware/AuthMiddleware.php
@@ -0,0 +1,30 @@
+authenticator->authenticate($request);
+
+ if (!$user instanceof AuthenticatedUser) {
+ return JsonResponse::error('Unauthorized', 401);
+ }
+
+ $request->setAttribute(AuthenticatedUser::class, $user);
+ $request->setAttribute('user', $user);
+
+ return $next($request);
+ }
+}
diff --git a/src/Middleware/AuthorizationMiddleware.php b/src/Middleware/AuthorizationMiddleware.php
new file mode 100644
index 0000000..6364363
--- /dev/null
+++ b/src/Middleware/AuthorizationMiddleware.php
@@ -0,0 +1,33 @@
+getAttribute(AuthenticatedUser::class) ?? $request->getAttribute('user');
+
+ if (!$user instanceof AuthenticatedUser) {
+ return JsonResponse::error('Unauthorized', 401);
+ }
+
+ if (!$this->authorizer->authorize($user, $request, $this->ability)) {
+ return JsonResponse::error('Forbidden', 403);
+ }
+
+ return $next($request);
+ }
+}
diff --git a/src/Middleware/CorsMiddleware.php b/src/Middleware/CorsMiddleware.php
new file mode 100644
index 0000000..fe29c93
--- /dev/null
+++ b/src/Middleware/CorsMiddleware.php
@@ -0,0 +1,47 @@
+getMethod() === 'OPTIONS') {
+ return new Response('', 204, $this->headersFor($request));
+ }
+
+ $response = $next($request);
+
+ return new Response(
+ $response->getContent(),
+ $response->getStatusCode(),
+ $response->getHeaders() + $this->headersFor($request)
+ );
+ }
+
+ private function headersFor(Request $request): array
+ {
+ $origin = $request->getHeader('Origin') ?? '*';
+ $allowedOrigin = in_array('*', $this->allowedOrigins, true)
+ ? '*'
+ : (in_array($origin, $this->allowedOrigins, true) ? $origin : $this->allowedOrigins[0]);
+
+ return [
+ 'Access-Control-Allow-Origin' => $allowedOrigin,
+ 'Access-Control-Allow-Methods' => implode(', ', $this->allowedMethods),
+ 'Access-Control-Allow-Headers' => implode(', ', $this->allowedHeaders),
+ 'Access-Control-Max-Age' => (string) $this->maxAge,
+ ];
+ }
+}
diff --git a/Engine/Middleware/MiddlewareInterface.php b/src/Middleware/MiddlewareInterface.php
similarity index 100%
rename from Engine/Middleware/MiddlewareInterface.php
rename to src/Middleware/MiddlewareInterface.php
diff --git a/Engine/Middleware/MiddlewarePipeline.php b/src/Middleware/MiddlewarePipeline.php
similarity index 100%
rename from Engine/Middleware/MiddlewarePipeline.php
rename to src/Middleware/MiddlewarePipeline.php
diff --git a/Engine/Modules/AbstractModule.php b/src/Modules/AbstractModule.php
similarity index 73%
rename from Engine/Modules/AbstractModule.php
rename to src/Modules/AbstractModule.php
index 172e26f..658c56b 100644
--- a/Engine/Modules/AbstractModule.php
+++ b/src/Modules/AbstractModule.php
@@ -7,6 +7,11 @@
abstract class AbstractModule implements ModuleInterface
{
+ public function getConfig(): array
+ {
+ return [];
+ }
+
public function registerServices(ServiceContainer $container): void
{
}
@@ -19,4 +24,8 @@ public function getCommandMappings(): array
{
return [];
}
+
+ public function boot(ServiceContainer $container): void
+ {
+ }
}
diff --git a/Engine/Modules/ModuleInterface.php b/src/Modules/ModuleInterface.php
similarity index 77%
rename from Engine/Modules/ModuleInterface.php
rename to src/Modules/ModuleInterface.php
index 867c010..30ffcb9 100644
--- a/Engine/Modules/ModuleInterface.php
+++ b/src/Modules/ModuleInterface.php
@@ -9,9 +9,13 @@ interface ModuleInterface
{
public function getName(): string;
+ public function getConfig(): array;
+
public function registerServices(ServiceContainer $container): void;
public function registerRoutes(Router $router): void;
+ public function boot(ServiceContainer $container): void;
+
public function getCommandMappings(): array;
}
diff --git a/Engine/Modules/ModuleLoader.php b/src/Modules/ModuleLoader.php
similarity index 61%
rename from Engine/Modules/ModuleLoader.php
rename to src/Modules/ModuleLoader.php
index 246edd5..ed8c25e 100644
--- a/Engine/Modules/ModuleLoader.php
+++ b/src/Modules/ModuleLoader.php
@@ -9,6 +9,7 @@ class ModuleLoader
{
/** @var ModuleInterface[] */
private array $modules = [];
+ private array $config = [];
public function __construct(private readonly string $modulesPath = APP_PATH . '/modules')
{
@@ -23,7 +24,8 @@ public function load(): self
foreach (glob($this->modulesPath . '/*/Module.php') ?: [] as $moduleFile) {
require_once $moduleFile;
- $moduleName = basename(dirname($moduleFile));
+ $modulePath = dirname($moduleFile);
+ $moduleName = basename($modulePath);
$moduleClass = 'Modules\\' . $moduleName . '\\Module';
if (!class_exists($moduleClass)) {
@@ -34,14 +36,33 @@ public function load(): self
if ($module instanceof ModuleInterface) {
$this->modules[] = $module;
+ $this->config[$module->getName()] = array_replace_recursive(
+ $this->loadConfigFile($modulePath),
+ $module->getConfig()
+ );
}
}
return $this;
}
+ private function loadConfigFile(string $modulePath): array
+ {
+ $configFile = $modulePath . '/config.php';
+
+ if (!is_file($configFile)) {
+ return [];
+ }
+
+ $config = require $configFile;
+
+ return is_array($config) ? $config : [];
+ }
+
public function registerServices(ServiceContainer $container): void
{
+ $container->singleton('modules.config', $this->config);
+
foreach ($this->modules as $module) {
$module->registerServices($container);
}
@@ -54,6 +75,13 @@ public function registerRoutes(Router $router): void
}
}
+ public function boot(ServiceContainer $container): void
+ {
+ foreach ($this->modules as $module) {
+ $module->boot($container);
+ }
+ }
+
public function getCommandMappings(): array
{
$mappings = [];
@@ -71,4 +99,13 @@ public function getModules(): array
{
return $this->modules;
}
+
+ public function getConfig(?string $module = null): array
+ {
+ if ($module !== null) {
+ return $this->config[$module] ?? [];
+ }
+
+ return $this->config;
+ }
}
diff --git a/Engine/Request.php b/src/Request.php
similarity index 90%
rename from Engine/Request.php
rename to src/Request.php
index f1a82c6..7d4fbb0 100755
--- a/Engine/Request.php
+++ b/src/Request.php
@@ -15,6 +15,7 @@ class Request
private array $postData;
private array $serverData;
private array $routeParams = [];
+ private array $attributes = [];
private ?array $jsonData = null;
private string $rawBody;
@@ -153,4 +154,19 @@ public function routeParam(string $key, mixed $default = null): mixed
{
return $this->routeParams[$key] ?? $default;
}
+
+ public function setAttribute(string $key, mixed $value): void
+ {
+ $this->attributes[$key] = $value;
+ }
+
+ public function getAttribute(string $key, mixed $default = null): mixed
+ {
+ return $this->attributes[$key] ?? $default;
+ }
+
+ public function getAttributes(): array
+ {
+ return $this->attributes;
+ }
}
diff --git a/Engine/Response/JsonResponse.php b/src/Response/JsonResponse.php
similarity index 100%
rename from Engine/Response/JsonResponse.php
rename to src/Response/JsonResponse.php
diff --git a/Engine/Response/Response.php b/src/Response/Response.php
similarity index 100%
rename from Engine/Response/Response.php
rename to src/Response/Response.php
diff --git a/Engine/Response/ResponseEmitter.php b/src/Response/ResponseEmitter.php
similarity index 100%
rename from Engine/Response/ResponseEmitter.php
rename to src/Response/ResponseEmitter.php
diff --git a/Engine/Routing/AttributeRouteLoader.php b/src/Routing/AttributeRouteLoader.php
similarity index 100%
rename from Engine/Routing/AttributeRouteLoader.php
rename to src/Routing/AttributeRouteLoader.php
diff --git a/Engine/Routing/Attributes/Body.php b/src/Routing/Attributes/Body.php
similarity index 100%
rename from Engine/Routing/Attributes/Body.php
rename to src/Routing/Attributes/Body.php
diff --git a/src/Routing/Attributes/BodyDto.php b/src/Routing/Attributes/BodyDto.php
new file mode 100644
index 0000000..89efbe3
--- /dev/null
+++ b/src/Routing/Attributes/BodyDto.php
@@ -0,0 +1,13 @@
+getJson());
+ }
+
+ public static function fromArray(array $data): static
+ {
+ $validated = (new Validator())->validate($data, static::rules());
+
+ return new static(...$validated);
+ }
+
+ public static function rules(): array
+ {
+ return [];
+ }
+}
diff --git a/src/Validation/ValidationException.php b/src/Validation/ValidationException.php
new file mode 100644
index 0000000..2ef46b6
--- /dev/null
+++ b/src/Validation/ValidationException.php
@@ -0,0 +1,18 @@
+errors;
+ }
+}
diff --git a/src/Validation/Validator.php b/src/Validation/Validator.php
new file mode 100644
index 0000000..9d630b6
--- /dev/null
+++ b/src/Validation/Validator.php
@@ -0,0 +1,129 @@
+ $fieldRules) {
+ $fieldRules = $this->normalizeRules($fieldRules);
+ $exists = array_key_exists($field, $data);
+ $value = $data[$field] ?? null;
+
+ if (!$exists && !in_array('required', $fieldRules, true)) {
+ continue;
+ }
+
+ foreach ($fieldRules as $rule) {
+ $error = $this->validateRule((string) $field, $value, $exists, $rule);
+
+ if ($error !== null) {
+ $errors[$field][] = $error;
+ }
+ }
+
+ if (!isset($errors[$field]) && $exists) {
+ $validated[$field] = $this->castValue($value, $fieldRules);
+ }
+ }
+
+ if ($errors !== []) {
+ throw new ValidationException($errors);
+ }
+
+ return $validated;
+ }
+
+ private function normalizeRules(string|array $rules): array
+ {
+ if (is_string($rules)) {
+ return array_values(array_filter(explode('|', $rules)));
+ }
+
+ return $rules;
+ }
+
+ private function validateRule(string $field, mixed $value, bool $exists, string $rule): ?string
+ {
+ [$name, $parameter] = array_pad(explode(':', $rule, 2), 2, null);
+
+ if ($name === 'required') {
+ return $exists && $value !== null && $value !== '' ? null : 'The field is required.';
+ }
+
+ if (!$exists || $value === null || $value === '') {
+ return null;
+ }
+
+ return match ($name) {
+ 'string' => is_string($value) ? null : 'The field must be a string.',
+ 'int' => filter_var($value, FILTER_VALIDATE_INT) !== false ? null : 'The field must be an integer.',
+ 'bool' => $this->isBooleanLike($value) ? null : 'The field must be a boolean.',
+ 'array' => is_array($value) ? null : 'The field must be an array.',
+ 'email' => filter_var($value, FILTER_VALIDATE_EMAIL) !== false ? null : 'The field must be a valid email address.',
+ 'min' => $this->passesMin($value, (float) $parameter) ? null : "The field must be at least {$parameter}.",
+ 'max' => $this->passesMax($value, (float) $parameter) ? null : "The field must be at most {$parameter}.",
+ default => "Unknown validation rule '{$name}' for '{$field}'.",
+ };
+ }
+
+ private function castValue(mixed $value, array $rules): mixed
+ {
+ if (in_array('int', $rules, true)) {
+ return (int) $value;
+ }
+
+ if (in_array('bool', $rules, true)) {
+ return filter_var($value, FILTER_VALIDATE_BOOLEAN);
+ }
+
+ if (in_array('string', $rules, true)) {
+ return (string) $value;
+ }
+
+ return $value;
+ }
+
+ private function isBooleanLike(mixed $value): bool
+ {
+ if (is_bool($value)) {
+ return true;
+ }
+
+ if (is_int($value)) {
+ return in_array($value, [0, 1], true);
+ }
+
+ return is_string($value) && in_array(strtolower($value), ['true', 'false', '1', '0'], true);
+ }
+
+ private function passesMin(mixed $value, float $minimum): bool
+ {
+ if (is_array($value)) {
+ return count($value) >= $minimum;
+ }
+
+ if (is_numeric($value)) {
+ return (float) $value >= $minimum;
+ }
+
+ return is_string($value) && strlen($value) >= $minimum;
+ }
+
+ private function passesMax(mixed $value, float $maximum): bool
+ {
+ if (is_array($value)) {
+ return count($value) <= $maximum;
+ }
+
+ if (is_numeric($value)) {
+ return (float) $value <= $maximum;
+ }
+
+ return is_string($value) && strlen($value) <= $maximum;
+ }
+}
diff --git a/tests/ApiCoreTest.php b/tests/ApiCoreTest.php
index 59651f0..ad4e645 100644
--- a/tests/ApiCoreTest.php
+++ b/tests/ApiCoreTest.php
@@ -1,8 +1,14 @@
json([
'message' => $this->service->message(),
+ 'path' => $this->getRequest()->getPath(),
+ ]);
+ }
+}
+
+final class CreateUserDto extends RequestDto
+{
+ public function __construct(
+ public readonly string $email,
+ public readonly int $age
+ ) {
+ }
+
+ public static function rules(): array
+ {
+ return [
+ 'email' => 'required|string|email',
+ 'age' => 'required|int|min:18',
+ ];
+ }
+}
+
+#[RoutePrefix('/dto')]
+final class DtoController extends Controller
+{
+ #[Post('/users')]
+ public function create(#[BodyDto] CreateUserDto $dto): array
+ {
+ return [
+ 'email' => $dto->email,
+ 'age' => $dto->age,
+ ];
+ }
+
+ #[Post('/implicit')]
+ public function implicit(CreateUserDto $dto): array
+ {
+ return [
+ 'email' => $dto->email,
+ 'age' => $dto->age,
];
}
}
+#[RoutePrefix('/auth')]
+final class AuthenticatedController extends Controller
+{
+ #[Get('/me')]
+ public function me(Request $request): array
+ {
+ /** @var AuthenticatedUser|null $user */
+ $user = $request->getAttribute(AuthenticatedUser::class);
+
+ return [
+ 'id' => $user?->id,
+ ];
+ }
+}
+
+final class HeaderAuthenticator implements AuthenticatorInterface
+{
+ public function authenticate(Request $request): ?AuthenticatedUser
+ {
+ return $request->getHeader('Authorization') === 'Bearer token'
+ ? new AuthenticatedUser('user-1')
+ : null;
+ }
+}
+
+final class AllowAuthorizer implements AuthorizerInterface
+{
+ public function authorize(AuthenticatedUser $user, Request $request, ?string $ability = null): bool
+ {
+ return $ability === 'view';
+ }
+}
+
function assertSameValue(mixed $expected, mixed $actual, string $message): void
{
if ($expected !== $actual) {
@@ -245,6 +328,7 @@ function makeRequest(string $method, string $uri, string $body = '', array $quer
assertSameValue(200, $emitter->statusCode, 'Autowired controller should emit successful status.');
assertSameValue('autowired', $payload['message'] ?? null, 'Controller dependency should be injected from the container.');
+ assertSameValue('/autowired/service', $payload['path'] ?? null, 'Autowired controller should retain base controller context.');
};
$tests['service container makes classes with typed dependencies'] = function (): void {
@@ -336,6 +420,118 @@ function makeRequest(string $method, string $uri, string $body = '', array $quer
assertSameValue('Blocked', $payload['error']['message'] ?? null, 'Short-circuit response should be emitted.');
};
+$tests['validator returns validated typed data'] = function (): void {
+ $validated = (new Validator())->validate(
+ ['email' => 'dev@example.com', 'age' => '21', 'active' => 'true'],
+ [
+ 'email' => 'required|email',
+ 'age' => 'required|int|min:18',
+ 'active' => 'bool',
+ ]
+ );
+
+ assertSameValue('dev@example.com', $validated['email'], 'Validator should keep valid email.');
+ assertSameValue(21, $validated['age'], 'Validator should cast integers.');
+ assertSameValue(true, $validated['active'], 'Validator should cast booleans.');
+};
+
+$tests['validator throws validation exception'] = function (): void {
+ try {
+ (new Validator())->validate(['email' => 'bad'], ['email' => 'required|email', 'age' => 'required|int']);
+ } catch (ValidationException $exception) {
+ assertSameValue(422, $exception->getStatusCode(), 'Validation errors should use HTTP 422.');
+ assertSameValue(true, isset($exception->getErrors()['email']), 'Validation errors should include invalid fields.');
+ assertSameValue(true, isset($exception->getErrors()['age']), 'Validation errors should include missing required fields.');
+ return;
+ }
+
+ throw new RuntimeException('Expected validation exception was not thrown.');
+};
+
+$tests['app binds body dto parameters'] = function (): void {
+ $router = new Router();
+ (new AttributeRouteLoader())->load($router, [DtoController::class]);
+ $emitter = new CapturingEmitter();
+
+ $app = new App(makeRequest('POST', '/dto/users', '{"email":"dev@example.com","age":"22"}'), $router, $emitter);
+ $app->start();
+
+ $payload = json_decode($emitter->content, true);
+
+ assertSameValue(200, $emitter->statusCode, 'Valid DTO payload should pass.');
+ assertSameValue('dev@example.com', $payload['email'] ?? null, 'DTO should expose validated email.');
+ assertSameValue(22, $payload['age'] ?? null, 'DTO should expose cast age.');
+};
+
+$tests['app auto-binds request dto parameters by type'] = function (): void {
+ $router = new Router();
+ (new AttributeRouteLoader())->load($router, [DtoController::class]);
+ $emitter = new CapturingEmitter();
+
+ $app = new App(makeRequest('POST', '/dto/implicit', '{"email":"dev@example.com","age":"22"}'), $router, $emitter);
+ $app->start();
+
+ $payload = json_decode($emitter->content, true);
+
+ assertSameValue(200, $emitter->statusCode, 'Implicit DTO payload should pass.');
+ assertSameValue(22, $payload['age'] ?? null, 'Implicit DTO should be bound by type.');
+};
+
+$tests['app emits validation errors as json'] = function (): void {
+ $router = new Router();
+ (new AttributeRouteLoader())->load($router, [DtoController::class]);
+ $emitter = new CapturingEmitter();
+
+ $app = new App(makeRequest('POST', '/dto/users', '{"email":"bad","age":15}'), $router, $emitter);
+ $app->start();
+
+ $payload = json_decode($emitter->content, true);
+
+ assertSameValue(422, $emitter->statusCode, 'Invalid DTO payload should return 422.');
+ assertSameValue('Validation failed', $payload['error']['message'] ?? null, 'Validation response should include message.');
+ assertSameValue(true, isset($payload['error']['context']['errors']['email']), 'Validation response should include field errors.');
+};
+
+$tests['cors middleware handles preflight requests'] = function (): void {
+ $router = new Router();
+ $emitter = new CapturingEmitter();
+
+ $app = new App(makeRequest('OPTIONS', '/anything'), $router, $emitter);
+ $app->middleware(new CorsMiddleware());
+ $app->start();
+
+ assertSameValue(204, $emitter->statusCode, 'CORS preflight should short-circuit with 204.');
+ assertArrayHasKeyValue('Access-Control-Allow-Origin', '*', $emitter->headers, 'CORS should expose allowed origin.');
+};
+
+$tests['auth middleware authenticates request user'] = function (): void {
+ $router = new Router();
+ (new AttributeRouteLoader())->load($router, [AuthenticatedController::class]);
+ $emitter = new CapturingEmitter();
+
+ $app = new App(makeRequest('GET', '/auth/me'), $router, $emitter);
+ $app->middleware(new AuthMiddleware(new HeaderAuthenticator()));
+ $app->middleware(new AuthorizationMiddleware(new AllowAuthorizer(), 'view'));
+ $app->start();
+
+ $payload = json_decode($emitter->content, true);
+
+ assertSameValue(200, $emitter->statusCode, 'Authenticated request should continue.');
+ assertSameValue('user-1', $payload['id'] ?? null, 'Authenticated user should be stored on the request.');
+};
+
+$tests['auth middleware rejects unauthenticated requests'] = function (): void {
+ $router = new Router();
+ (new AttributeRouteLoader())->load($router, [AuthenticatedController::class]);
+ $emitter = new CapturingEmitter();
+
+ $app = new App(new Request(['REQUEST_METHOD' => 'GET', 'REQUEST_URI' => '/auth/me']), $router, $emitter);
+ $app->middleware(new AuthMiddleware(new HeaderAuthenticator()));
+ $app->start();
+
+ assertSameValue(401, $emitter->statusCode, 'Unauthenticated request should return 401.');
+};
+
$tests['module loader registers services and routes'] = function (): void {
$loader = (new ModuleLoader())->load();
$router = new Router();
@@ -343,8 +539,12 @@ function makeRequest(string $method, string $uri, string $body = '', array $quer
$loader->registerServices($container);
$loader->registerRoutes($router);
+ $loader->boot($container);
assertSameValue(true, $container->has(HealthService::class), 'Health module service should be registered.');
+ assertSameValue(true, $container->resolve('health.booted'), 'Health module boot hook should run.');
+ assertSameValue(true, $loader->getConfig('health')['enabled'] ?? null, 'Health module config file should load.');
+ assertSameValue('health', $loader->getConfig('health')['module'] ?? null, 'Health module config method should merge.');
$routes = array_map(
static fn (Route $route): string => $route->getMethod() . ' ' . $route->getPath(),