From aed89a8e1ff3fec07199f62d26cb12903544c85e Mon Sep 17 00:00:00 2001 From: rtcoder Date: Wed, 17 Jun 2026 08:24:35 +0200 Subject: [PATCH] Add validation auth and module lifecycle support --- .github/workflows/api.yml | 2 +- README.md | 43 ++++ REFACTORING.md | 17 +- application/modules/Health/Module.php | 12 ++ application/modules/Health/config.php | 5 + composer.json | 2 +- docs/index.html | 57 ++++- index.php | 1 + {Engine => src}/App.php | 47 +++- src/Auth/AuthenticatedUser.php | 12 ++ src/Auth/AuthenticatorInterface.php | 10 + src/Auth/AuthorizerInterface.php | 10 + {Engine => src}/Console/Cli.php | 0 {Engine => src}/Console/CommandInterface.php | 0 {Engine => src}/Console/Commands/Help.php | 2 +- .../Console/Commands/RouteList.php | 0 {Engine => src}/Console/Commands/Serve.php | 0 {Engine => src}/Console/Console.php | 0 {Engine => src}/Console/Shift.php | 2 +- {Engine => src}/Controller.php | 7 +- {Engine => src}/Error/ErrorHandler.php | 0 {Engine => src}/Error/HttpError.php | 0 {Engine => src}/Error/ShiftError.php | 0 .../Error/ShiftError/ErrorHighlighter.php | 0 .../Error/ShiftError/StackTrace.php | 0 src/Middleware/AuthMiddleware.php | 30 +++ src/Middleware/AuthorizationMiddleware.php | 33 +++ src/Middleware/CorsMiddleware.php | 47 ++++ .../Middleware/MiddlewareInterface.php | 0 .../Middleware/MiddlewarePipeline.php | 0 {Engine => src}/Modules/AbstractModule.php | 9 + {Engine => src}/Modules/ModuleInterface.php | 4 + {Engine => src}/Modules/ModuleLoader.php | 39 +++- {Engine => src}/Request.php | 16 ++ {Engine => src}/Response/JsonResponse.php | 0 {Engine => src}/Response/Response.php | 0 {Engine => src}/Response/ResponseEmitter.php | 0 .../Routing/AttributeRouteLoader.php | 0 {Engine => src}/Routing/Attributes/Body.php | 0 src/Routing/Attributes/BodyDto.php | 13 ++ {Engine => src}/Routing/Attributes/Delete.php | 0 {Engine => src}/Routing/Attributes/Get.php | 0 {Engine => src}/Routing/Attributes/Header.php | 0 {Engine => src}/Routing/Attributes/Patch.php | 0 .../Routing/Attributes/PathParam.php | 0 {Engine => src}/Routing/Attributes/Post.php | 0 {Engine => src}/Routing/Attributes/Put.php | 0 .../Routing/Attributes/QueryParam.php | 0 {Engine => src}/Routing/Attributes/Route.php | 0 .../Routing/Attributes/RoutePrefix.php | 0 {Engine => src}/Routing/Attributes/Status.php | 0 {Engine => src}/Routing/Router/Route.php | 0 {Engine => src}/Routing/Router/RouteMatch.php | 0 {Engine => src}/Routing/Router/Router.php | 0 {Engine => src}/Service/ServiceContainer.php | 0 {Engine => src}/Service/ServiceInterface.php | 0 {Engine => src}/Tools/ClassFinder.php | 0 {Engine => src}/Tools/Config.php | 0 {Engine => src}/Utils/Debug.php | 0 {Engine => src}/Utils/helpers.php | 0 src/Validation/RequestDto.php | 25 +++ src/Validation/ValidationException.php | 18 ++ src/Validation/Validator.php | 129 +++++++++++ tests/ApiCoreTest.php | 204 +++++++++++++++++- 64 files changed, 774 insertions(+), 22 deletions(-) create mode 100644 application/modules/Health/config.php rename {Engine => src}/App.php (84%) create mode 100644 src/Auth/AuthenticatedUser.php create mode 100644 src/Auth/AuthenticatorInterface.php create mode 100644 src/Auth/AuthorizerInterface.php rename {Engine => src}/Console/Cli.php (100%) rename {Engine => src}/Console/CommandInterface.php (100%) rename {Engine => src}/Console/Commands/Help.php (96%) rename {Engine => src}/Console/Commands/RouteList.php (100%) rename {Engine => src}/Console/Commands/Serve.php (100%) rename {Engine => src}/Console/Console.php (100%) rename {Engine => src}/Console/Shift.php (97%) rename {Engine => src}/Controller.php (85%) rename {Engine => src}/Error/ErrorHandler.php (100%) rename {Engine => src}/Error/HttpError.php (100%) rename {Engine => src}/Error/ShiftError.php (100%) rename {Engine => src}/Error/ShiftError/ErrorHighlighter.php (100%) rename {Engine => src}/Error/ShiftError/StackTrace.php (100%) create mode 100644 src/Middleware/AuthMiddleware.php create mode 100644 src/Middleware/AuthorizationMiddleware.php create mode 100644 src/Middleware/CorsMiddleware.php rename {Engine => src}/Middleware/MiddlewareInterface.php (100%) rename {Engine => src}/Middleware/MiddlewarePipeline.php (100%) rename {Engine => src}/Modules/AbstractModule.php (73%) rename {Engine => src}/Modules/ModuleInterface.php (77%) rename {Engine => src}/Modules/ModuleLoader.php (61%) rename {Engine => src}/Request.php (90%) rename {Engine => src}/Response/JsonResponse.php (100%) rename {Engine => src}/Response/Response.php (100%) rename {Engine => src}/Response/ResponseEmitter.php (100%) rename {Engine => src}/Routing/AttributeRouteLoader.php (100%) rename {Engine => src}/Routing/Attributes/Body.php (100%) create mode 100644 src/Routing/Attributes/BodyDto.php rename {Engine => src}/Routing/Attributes/Delete.php (100%) rename {Engine => src}/Routing/Attributes/Get.php (100%) rename {Engine => src}/Routing/Attributes/Header.php (100%) rename {Engine => src}/Routing/Attributes/Patch.php (100%) rename {Engine => src}/Routing/Attributes/PathParam.php (100%) rename {Engine => src}/Routing/Attributes/Post.php (100%) rename {Engine => src}/Routing/Attributes/Put.php (100%) rename {Engine => src}/Routing/Attributes/QueryParam.php (100%) rename {Engine => src}/Routing/Attributes/Route.php (100%) rename {Engine => src}/Routing/Attributes/RoutePrefix.php (100%) rename {Engine => src}/Routing/Attributes/Status.php (100%) rename {Engine => src}/Routing/Router/Route.php (100%) rename {Engine => src}/Routing/Router/RouteMatch.php (100%) rename {Engine => src}/Routing/Router/Router.php (100%) rename {Engine => src}/Service/ServiceContainer.php (100%) rename {Engine => src}/Service/ServiceInterface.php (100%) rename {Engine => src}/Tools/ClassFinder.php (100%) rename {Engine => src}/Tools/Config.php (100%) rename {Engine => src}/Utils/Debug.php (100%) rename {Engine => src}/Utils/helpers.php (100%) create mode 100644 src/Validation/RequestDto.php create mode 100644 src/Validation/ValidationException.php create mode 100644 src/Validation/Validator.php 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(),