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
2 changes: 1 addition & 1 deletion .github/workflows/api.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
43 changes: 43 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down Expand Up @@ -120,6 +121,7 @@ You can use these routing attributes:
- `#[PathParam('id')]`
- `#[QueryParam('include')]`
- `#[Body]` or `#[Body('field')]`
- `#[BodyDto]`

## Responses

Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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:
Expand Down
17 changes: 9 additions & 8 deletions REFACTORING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -133,23 +139,18 @@ 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`
- `application/routes.php`

## 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.
12 changes: 12 additions & 0 deletions application/modules/Health/Module.php
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -37,4 +44,9 @@ public function getCommandMappings(): array
],
];
}

public function boot(ServiceContainer $container): void
{
$container->singleton('health.booted', true);
}
}
5 changes: 5 additions & 0 deletions application/modules/Health/config.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
<?php

return [
'enabled' => true,
];
2 changes: 1 addition & 1 deletion composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@
},
"autoload": {
"psr-4": {
"Shift\\": "Engine",
"Shift\\": "src",
"Modules\\": "application/modules"
}
}
Expand Down
57 changes: 54 additions & 3 deletions docs/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ <h2>Contents</h2>
<li><a href="#controllers">Controllers</a></li>
<li><a href="#requests">Requests</a></li>
<li><a href="#responses">Responses</a></li>
<li><a href="#validation">Validation and DTOs</a></li>
<li><a href="#middleware">Middleware</a></li>
<li><a href="#services">Service Container</a></li>
<li><a href="#cli">CLI</a></li>
Expand All @@ -42,7 +43,7 @@ <h2>Requirements</h2>

<section id="architecture">
<h2>Architecture</h2>
<p>The public framework namespace is <code>Shift\</code>. Composer currently maps it to the <code>Engine/</code> directory.</p>
<p>The public framework namespace is <code>Shift\</code>. Composer maps it to the <code>src/</code> directory.</p>
<p>Application code lives under <code>application/</code>. Modules live under <code>application/modules/{ModuleName}</code> and are autoloaded with the <code>Modules\</code> namespace.</p>

<h3>Request Flow</h3>
Expand Down Expand Up @@ -102,7 +103,7 @@ <h2>Bootstrap</h2>

<section id="modules">
<h2>Modules</h2>
<p>A module owns its controllers, routes, services, and CLI commands.</p>
<p>A module owns its controllers, routes, services, config, lifecycle hooks, and CLI commands.</p>

<pre><code>application/modules/Health/
|-- Module.php
Expand Down Expand Up @@ -149,9 +150,14 @@ <h2>Modules</h2>
],
];
}

public function boot(ServiceContainer $container): void
{
$container-&gt;singleton('health.booted', true);
}
}</code></pre>

<p><code>Shift\Modules\ModuleLoader</code> discovers modules by convention from <code>application/modules/*/Module.php</code>.</p>
<p><code>Shift\Modules\ModuleLoader</code> discovers modules by convention from <code>application/modules/*/Module.php</code>. Module config can be returned from <code>getConfig()</code> or from a module-level <code>config.php</code> file. Merged config is available through <code>$modules-&gt;getConfig()</code> and the container singleton <code>modules.config</code>.</p>
</section>

<section id="routing">
Expand Down Expand Up @@ -180,6 +186,7 @@ <h3>Parameter Binding Attributes</h3>
<li><code>#[QueryParam('include')]</code> reads a query string value</li>
<li><code>#[Body]</code> reads the decoded JSON body</li>
<li><code>#[Body('name')]</code> reads one JSON body field</li>
<li><code>#[BodyDto]</code> validates the JSON body into a request DTO</li>
</ul>

<pre><code>#[RoutePrefix('/users')]
Expand Down Expand Up @@ -278,6 +285,43 @@ <h2>Responses</h2>
<p><code>JsonResponse</code> automatically sets <code>Content-Type: application/json</code>.</p>
</section>

<section id="validation">
<h2>Validation and DTOs</h2>
<p><code>Shift\Validation\Validator</code> validates arrays and returns validated values. Validation failures throw <code>Shift\Validation\ValidationException</code>, which the app emits as a <code>422</code> JSON response.</p>

<p>Supported rules are <code>required</code>, <code>string</code>, <code>int</code>, <code>bool</code>, <code>array</code>, <code>email</code>, <code>min</code>, and <code>max</code>.</p>

<pre><code>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' =&gt; 'required|string|email',
'age' =&gt; 'required|int|min:18',
];
}
}</code></pre>

<p>DTOs can be bound by type or with <code>#[BodyDto]</code>:</p>

<pre><code>#[Post('/users')]
public function create(#[BodyDto] CreateUserDto $dto): array
{
return [
'email' =&gt; $dto-&gt;email,
'age' =&gt; $dto-&gt;age,
];
}</code></pre>
</section>

<section id="middleware">
<h2>Middleware</h2>
<p>Middleware runs before controller dispatch. It can continue the request by calling <code>$next($request)</code>, modify the returned response, or return a response immediately.</p>
Expand Down Expand Up @@ -316,6 +360,13 @@ <h2>Middleware</h2>
});</code></pre>

<p>Middleware may be a class string, an object implementing <code>MiddlewareInterface</code>, or a callable. Class strings are resolved from the service container when registered there.</p>

<h3>Built-in Middleware</h3>
<ul>
<li><code>Shift\Middleware\CorsMiddleware</code> handles CORS headers and preflight requests.</li>
<li><code>Shift\Middleware\AuthMiddleware</code> uses <code>Shift\Auth\AuthenticatorInterface</code> to authenticate a request.</li>
<li><code>Shift\Middleware\AuthorizationMiddleware</code> uses <code>Shift\Auth\AuthorizerInterface</code> to authorize an authenticated user.</li>
</ul>
</section>

<section id="services">
Expand Down
1 change: 1 addition & 0 deletions index.php
Original file line number Diff line number Diff line change
Expand Up @@ -15,5 +15,6 @@
$modules = (new ModuleLoader())->load();
$modules->registerServices($app->getContainer());
$modules->registerRoutes($app->getRouter());
$modules->boot($app->getContainer());

$app->start();
47 changes: 44 additions & 3 deletions Engine/App.php → src/App.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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(),
Expand Down Expand Up @@ -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)) {
Expand All @@ -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;
}

/**
Expand All @@ -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();
Expand Down Expand Up @@ -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()) {
Expand Down
12 changes: 12 additions & 0 deletions src/Auth/AuthenticatedUser.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
<?php

namespace Shift\Auth;

final class AuthenticatedUser
{
public function __construct(
public readonly string $id,
public readonly array $attributes = []
) {
}
}
Loading
Loading