From 666731d217fe372a8fe6ea4fc312d3f339af5088 Mon Sep 17 00:00:00 2001 From: Daniel Ronkainen Date: Mon, 16 Feb 2026 21:17:14 +0100 Subject: [PATCH 01/10] feat: add config props factory --- src/AbstractConfigProps.php | 1 + src/Configs/ConfigPropsFactory.php | 47 ++++++++++++++++++++++++++++++ src/DispatchConfig.php | 4 ++- 3 files changed, 51 insertions(+), 1 deletion(-) create mode 100644 src/Configs/ConfigPropsFactory.php diff --git a/src/AbstractConfigProps.php b/src/AbstractConfigProps.php index 4c7a7e7..fdcc605 100644 --- a/src/AbstractConfigProps.php +++ b/src/AbstractConfigProps.php @@ -11,6 +11,7 @@ abstract class AbstractConfigProps implements ConfigPropsInterface { /** @var array */ public array $missingProps = []; + public ?string $path = null; public ?string $test = null; /** diff --git a/src/Configs/ConfigPropsFactory.php b/src/Configs/ConfigPropsFactory.php new file mode 100644 index 0000000..27b0517 --- /dev/null +++ b/src/Configs/ConfigPropsFactory.php @@ -0,0 +1,47 @@ +dir = realpath(dirname($path)); $this->dir = AbstractKernel::getRouterFilePath(); - $this->props = new ConfigProps($config); + $this->props = ConfigPropsFactory::create($config); } } From 3e27d9922d90bb9a37e4d11614af9364b80a9aac Mon Sep 17 00:00:00 2001 From: Daniel Ronkainen Date: Mon, 2 Mar 2026 19:46:20 +0100 Subject: [PATCH 02/10] feat: add get and has check on config props --- src/Configs/ConfigPropsFactory.php | 2 +- src/DispatchConfig.php | 24 ++++++++++++++++++++++-- 2 files changed, 23 insertions(+), 3 deletions(-) diff --git a/src/Configs/ConfigPropsFactory.php b/src/Configs/ConfigPropsFactory.php index 27b0517..3c47496 100644 --- a/src/Configs/ConfigPropsFactory.php +++ b/src/Configs/ConfigPropsFactory.php @@ -23,7 +23,7 @@ public static function create(array $props): ConfigPropsInterface } if (!class_exists($name)) { - return self::resolver(); + return self::resolver($props); } return new $name($props); diff --git a/src/DispatchConfig.php b/src/DispatchConfig.php index 288d6d5..8bdc305 100644 --- a/src/DispatchConfig.php +++ b/src/DispatchConfig.php @@ -18,10 +18,8 @@ use MaplePHP\Emitron\Configs\ConfigPropsFactory; use MaplePHP\Emitron\Contracts\ConfigPropsInterface; use MaplePHP\Emitron\Contracts\DispatchConfigInterface; -use MaplePHP\Unitary\Config\ConfigProps; use MaplePHP\Unitary\Interfaces\RouterDispatchInterface; use MaplePHP\Unitary\Interfaces\RouterInterface; -use MaplePHP\Unitary\Support\Helpers; class DispatchConfig implements DispatchConfigInterface { @@ -40,6 +38,28 @@ public function __construct(string|null|ConfigPropsInterface $props = null) } } + /** + * Get config value + * + * @param string $key + * @return mixed + */ + public function get(string $key): mixed + { + return $this->props->{$key}; + } + + /** + * Check if config prop exists + * + * @param string $key + * @return bool + */ + public function has(string $key): bool + { + return isset($this->props->{$key}); + } + /** * Get instance of ConfigProps * From 63ad2d72fd7061cd715327c9d06d672875134171 Mon Sep 17 00:00:00 2001 From: Daniel Ronkainen Date: Mon, 2 Mar 2026 23:27:18 +0100 Subject: [PATCH 03/10] feat: add Controller Request Handler and routing contracts --- src/AbstractKernel.php | 18 ++++-- src/Contracts/DispatchConfigInterface.php | 6 +- src/Contracts/RouterDispatchInterface.php | 14 +++++ src/Contracts/RouterInterface.php | 11 ++++ src/ControllerRequestHandler.php | 68 +++++++++++++++++++++++ src/DispatchConfig.php | 16 +++--- src/Emitters/HttpEmitter.php | 3 +- src/Kernel.php | 44 +++++++++------ src/RequestHandler.php | 26 ++++----- 9 files changed, 156 insertions(+), 50 deletions(-) create mode 100644 src/Contracts/RouterDispatchInterface.php create mode 100644 src/Contracts/RouterInterface.php create mode 100644 src/ControllerRequestHandler.php diff --git a/src/AbstractKernel.php b/src/AbstractKernel.php index dd22d41..6dc3af0 100644 --- a/src/AbstractKernel.php +++ b/src/AbstractKernel.php @@ -26,6 +26,7 @@ use Psr\Http\Message\ResponseInterface; use Psr\Http\Message\ServerRequestInterface; use Psr\Http\Message\StreamInterface; +use Psr\Http\Server\RequestHandlerInterface; abstract class AbstractKernel implements KernelInterface { @@ -118,6 +119,7 @@ public function getDispatchConfig(): DispatchConfigInterface * * @param ServerRequestInterface $request * @param StreamInterface $stream + * @param RequestHandlerInterface $finalHandler * @param array $middlewares * @return ResponseInterface * @throws \ReflectionException @@ -125,17 +127,21 @@ public function getDispatchConfig(): DispatchConfigInterface protected function initRequestHandler( ServerRequestInterface $request, StreamInterface $stream, + RequestHandlerInterface $finalHandler, array $middlewares = [] - ) : ResponseInterface { - $factory = new ResponseFactory($stream); + ): ResponseInterface { + $this->bindInterfaces([ - "ContainerInterface" => $this->container, "RequestInterface" => $request, - "ServerRequestInterface" => $request, "StreamInterface" => $stream, + "ContainerInterface" => $this->container, + "RequestInterface" => $request, + "ServerRequestInterface" => $request, + "StreamInterface" => $stream, ]); + $middlewares = array_merge($this->userMiddlewares, $middlewares); - $handler = new RequestHandler($middlewares, $factory); + $handler = new RequestHandler($middlewares, $finalHandler); $response = $handler->handle($request); - $this->bindInterfaces(["ResponseInterface" => $response]); + return $response; } diff --git a/src/Contracts/DispatchConfigInterface.php b/src/Contracts/DispatchConfigInterface.php index e17b440..2c867cc 100644 --- a/src/Contracts/DispatchConfigInterface.php +++ b/src/Contracts/DispatchConfigInterface.php @@ -3,7 +3,7 @@ namespace MaplePHP\Emitron\Contracts; use Exception; -use MaplePHP\Unitary\Interfaces\RouterDispatchInterface; +use MaplePHP\Unitary\Interfaces\RouterDispatchInterface as UnitaryRouterDispatchInterface; interface DispatchConfigInterface { @@ -35,9 +35,9 @@ public function setProps(array $props): self; /** * Get current exit code as int or null if not set * - * @return RouterDispatchInterface + * @return RouterDispatchInterface|UnitaryRouterDispatchInterface */ - public function getRouter(): RouterDispatchInterface; + public function getRouter(): RouterDispatchInterface|UnitaryRouterDispatchInterface; /** * Add exit after execution of the app has been completed diff --git a/src/Contracts/RouterDispatchInterface.php b/src/Contracts/RouterDispatchInterface.php new file mode 100644 index 0000000..ad33598 --- /dev/null +++ b/src/Contracts/RouterDispatchInterface.php @@ -0,0 +1,14 @@ +factory->createResponse(); + $this->appendInterfaces([ + "ResponseInterface" => $response, + ]); + + $controller = $this->controller; + if (!isset($controller[1])) { + $controller[1] = '__invoke'; + } + + if (count($controller) !== 2) { + $response->getBody()->write("ERROR: Invalid controller handler.\n"); + return $response; + } + + [$class, $method] = $controller; + + if (!method_exists($class, $method)) { + $response->getBody()->write("ERROR: Could not load Controller {$class}::{$method}().\n"); + return $response; + } + + // Your DI wiring + $reflect = new Reflection($class); + $classInst = $reflect->dependencyInjector(); + + + // This should INVOKE the method and return its result (ResponseInterface or something else) + $result = $reflect->dependencyInjector($classInst, $method); + + if ($result instanceof ResponseInterface) { + return $result; + } + + // If controller didn’t return a response, you can decide a convention: + // - treat it as “controller wrote to $response->getBody() somewhere” + // - or treat non-response as error + return $response; + } + + + protected function appendInterfaces(array $bindings) + { + Reflection::interfaceFactory(function (string $className) use ($bindings) { + return $bindings[$className] ?? null; + }); + } + +} \ No newline at end of file diff --git a/src/DispatchConfig.php b/src/DispatchConfig.php index 8bdc305..4a2a245 100644 --- a/src/DispatchConfig.php +++ b/src/DispatchConfig.php @@ -18,13 +18,15 @@ use MaplePHP\Emitron\Configs\ConfigPropsFactory; use MaplePHP\Emitron\Contracts\ConfigPropsInterface; use MaplePHP\Emitron\Contracts\DispatchConfigInterface; -use MaplePHP\Unitary\Interfaces\RouterDispatchInterface; -use MaplePHP\Unitary\Interfaces\RouterInterface; +use MaplePHP\Unitary\Interfaces\RouterDispatchInterface as UnitaryRouterDispatchInterface; +use MaplePHP\Unitary\Interfaces\RouterInterface as UnitaryRouterInterface; +use MaplePHP\Emitron\Contracts\RouterInterface; +use MaplePHP\Emitron\Contracts\RouterDispatchInterface; class DispatchConfig implements DispatchConfigInterface { private string $dir; - private ?RouterInterface $router = null; + private RouterInterface|UnitaryRouterInterface|null $router = null; protected ConfigPropsInterface $props; /** @@ -100,12 +102,12 @@ public function setProps(array $props): self /** * Get current exit code as int or null if not set * - * @return RouterDispatchInterface + * @return UnitaryRouterInterface|RouterDispatchInterface */ - public function getRouter(): RouterDispatchInterface + public function getRouter(): UnitaryRouterInterface|RouterDispatchInterface { if ($this->router === null) { - return new class () implements RouterDispatchInterface { + return new class () implements UnitaryRouterDispatchInterface, RouterDispatchInterface { public function dispatch(callable $call): bool { $call(['handler' => []], [], [], ''); @@ -127,7 +129,7 @@ public function setRouter(callable $call): self { $inst = clone $this; $inst->router = $call($this->dir); - if (!($inst->router instanceof RouterInterface)) { + if (!($inst->router instanceof RouterInterface || $inst->router instanceof UnitaryRouterInterface)) { throw new Exception('Router must implement RouterInterface and "return" a it!'); } return $inst; diff --git a/src/Emitters/HttpEmitter.php b/src/Emitters/HttpEmitter.php index b9a0841..1a8c266 100644 --- a/src/Emitters/HttpEmitter.php +++ b/src/Emitters/HttpEmitter.php @@ -22,13 +22,14 @@ class HttpEmitter implements EmitterInterface */ public function emit(ResponseInterface $response, ServerRequestInterface $request): void { + $body = $response->getBody(); $status = $response->getStatusCode(); $method = strtoupper($request->getMethod()); $skipBody = in_array($status, [204, 304]) || ($status >= 100 && $status < 200) || $method === 'HEAD'; // Default to 204 No Content if nobody was written - if (!$response->getBody()->getSize() && $this->isSuccessfulResponse()) { + if (!$response->getBody()->getSize() && $this->isSuccessfulResponse($response)) { $response = $response->withStatus(204); $response->getBody()->write("No Content\n"); } diff --git a/src/Kernel.php b/src/Kernel.php index f91bb28..3d9ad16 100644 --- a/src/Kernel.php +++ b/src/Kernel.php @@ -15,6 +15,7 @@ namespace MaplePHP\Emitron; use MaplePHP\Container\Reflection; +use MaplePHP\Http\ResponseFactory; use Psr\Http\Message\ResponseInterface; use Psr\Http\Message\ServerRequestInterface; use Psr\Http\Message\StreamInterface; @@ -33,34 +34,41 @@ public function run(ServerRequestInterface $request, ?StreamInterface $stream = { $this->dispatchConfig->getRouter()->dispatch(function ($data, $args, $middlewares) use ($request, $stream) { + [$data, $args, $middlewares] = $this->reMap($data, $args, $middlewares); + if (!isset($data['handler'])) { - throw new InvalidArgumentException("The router dispatch method arg 1 is missing the 'handler' key."); + throw new InvalidArgumentException("Missing 'handler' key."); } $this->container->set("request", $request); $this->container->set("args", $args); $this->container->set("configuration", $this->getDispatchConfig()); - $response = $this->initRequestHandler($request, $this->getBody($stream), $middlewares); + $bodyStream = $this->getBody($stream); + $factory = new ResponseFactory($bodyStream); - $controller = $data['handler']; - if (!isset($controller[1])) { - $controller[1] = '__invoke'; - } - if (count($controller) === 2) { - [$class, $method] = $controller; - if (method_exists($class, $method)) { - $reflect = new Reflection($class); - $classInst = $reflect->dependencyInjector(); - // Can replace the active Response instance through Command instance - $hasNewResponse = $reflect->dependencyInjector($classInst, $method); - $response = ($hasNewResponse instanceof ResponseInterface) ? $hasNewResponse : $response; + $finalHandler = new ControllerRequestHandler($factory, $data['handler']); + + $response = $this->initRequestHandler( + request: $request, + stream: $bodyStream, + finalHandler: $finalHandler, + middlewares: $middlewares + ); - } else { - $response->getBody()->write("\nERROR: Could not load Controller class {$class} and method {$method}()\n"); - } - } $this->createEmitter()->emit($response, $request); }); } + + + function reMap($data, $args, $middlewares) { + + if(isset($data[1]) && $middlewares instanceof ServerRequestInterface) { + $item = $data[1]; + return [ + ["handler" => $item['controller']], $_REQUEST, ($item['data'] ?? []) + ]; + } + return [$data, $args, $middlewares]; + } } \ No newline at end of file diff --git a/src/RequestHandler.php b/src/RequestHandler.php index cbdb0e7..ab9139c 100644 --- a/src/RequestHandler.php +++ b/src/RequestHandler.php @@ -3,35 +3,30 @@ namespace MaplePHP\Emitron; use MaplePHP\Container\Reflection; -use Psr\Http\Message\ResponseFactoryInterface; use Psr\Http\Message\ResponseInterface; use Psr\Http\Message\ServerRequestInterface; +use Psr\Http\Server\MiddlewareInterface; use Psr\Http\Server\RequestHandlerInterface; -class RequestHandler implements RequestHandlerInterface +final class RequestHandler implements RequestHandlerInterface { private int $index = 0; + + /** @var list */ private array $middlewareQueue; - private ResponseFactoryInterface $responseSource; - public function __construct(array $middlewares, ResponseFactoryInterface $responseFactory) - { + public function __construct( + array $middlewares, + private readonly RequestHandlerInterface $finalHandler + ) { $this->middlewareQueue = $middlewares; - $this->responseSource = $responseFactory; } - /** - * Process middlewares and middleware that is a string class then it will use the - * dependency injector in the constructor. - * - * @param ServerRequestInterface $request - * @return ResponseInterface - * @throws \ReflectionException - */ public function handle(ServerRequestInterface $request): ResponseInterface { + // End of chain -> call controller (or whatever final handler you set) if (!isset($this->middlewareQueue[$this->index])) { - return $this->responseSource->createResponse(200); + return $this->finalHandler->handle($request); } $middleware = $this->middlewareQueue[$this->index]; @@ -41,6 +36,7 @@ public function handle(ServerRequestInterface $request): ResponseInterface $reflect = new Reflection($middleware); $middleware = $reflect->dependencyInjector(); } + return $middleware->process($request, $this); } } \ No newline at end of file From a49fe3615f8cee99abca6f6d1e7f2970d2c997a0 Mon Sep 17 00:00:00 2001 From: Daniel Ronkainen Date: Wed, 4 Mar 2026 17:40:49 +0100 Subject: [PATCH 04/10] feat: implementing status control --- src/ControllerRequestHandler.php | 5 ++++- src/Kernel.php | 27 +++++++++++++++++++++------ 2 files changed, 25 insertions(+), 7 deletions(-) diff --git a/src/ControllerRequestHandler.php b/src/ControllerRequestHandler.php index 4f2f7ce..48ebf3d 100644 --- a/src/ControllerRequestHandler.php +++ b/src/ControllerRequestHandler.php @@ -2,7 +2,9 @@ namespace MaplePHP\Emitron; +use FastRoute\Dispatcher; use MaplePHP\Container\Reflection; +use MaplePHP\Core\Router\RouterDispatcher; use Psr\Http\Message\ResponseFactoryInterface; use Psr\Http\Message\ResponseInterface; use Psr\Http\Message\ServerRequestInterface; @@ -18,6 +20,7 @@ public function __construct( public function handle(ServerRequestInterface $request): ResponseInterface { $response = $this->factory->createResponse(); + $this->appendInterfaces([ "ResponseInterface" => $response, ]); @@ -51,7 +54,7 @@ public function handle(ServerRequestInterface $request): ResponseInterface return $result; } - // If controller didn’t return a response, you can decide a convention: + // If controller didn’t return a response: // - treat it as “controller wrote to $response->getBody() somewhere” // - or treat non-response as error return $response; diff --git a/src/Kernel.php b/src/Kernel.php index 3d9ad16..294e6a4 100644 --- a/src/Kernel.php +++ b/src/Kernel.php @@ -14,9 +14,9 @@ namespace MaplePHP\Emitron; -use MaplePHP\Container\Reflection; +use FastRoute\Dispatcher; +use MaplePHP\Core\Router\RouterDispatcher; use MaplePHP\Http\ResponseFactory; -use Psr\Http\Message\ResponseInterface; use Psr\Http\Message\ServerRequestInterface; use Psr\Http\Message\StreamInterface; use MaplePHP\Log\InvalidArgumentException; @@ -32,22 +32,26 @@ class Kernel extends AbstractKernel */ public function run(ServerRequestInterface $request, ?StreamInterface $stream = null): void { + $this->dispatchConfig->getRouter()->dispatch(function ($data, $args, $middlewares) use ($request, $stream) { + $dispatchCode = $data[0] ?? RouterDispatcher::FOUND; + [$data, $args, $middlewares] = $this->reMap($data, $args, $middlewares); if (!isset($data['handler'])) { throw new InvalidArgumentException("Missing 'handler' key."); } + $this->container->set("request", $request); $this->container->set("args", $args); $this->container->set("configuration", $this->getDispatchConfig()); $bodyStream = $this->getBody($stream); $factory = new ResponseFactory($bodyStream); + $finalHandler = new ControllerRequestHandler($factory, $data['handler'] ?? []); - $finalHandler = new ControllerRequestHandler($factory, $data['handler']); $response = $this->initRequestHandler( request: $request, @@ -56,19 +60,30 @@ public function run(ServerRequestInterface $request, ?StreamInterface $stream = middlewares: $middlewares ); + if ($dispatchCode === Dispatcher::NOT_FOUND) { + $response = $response->withStatus(404); + } + + if ($dispatchCode === Dispatcher::METHOD_NOT_ALLOWED) { + $response = $response->withStatus(405); + } + $this->createEmitter()->emit($response, $request); }); } - function reMap($data, $args, $middlewares) { - - if(isset($data[1]) && $middlewares instanceof ServerRequestInterface) { + function reMap($data, $args, $middlewares) + { + if (isset($data[1]) && $middlewares instanceof ServerRequestInterface) { $item = $data[1]; return [ ["handler" => $item['controller']], $_REQUEST, ($item['data'] ?? []) ]; } + if (!is_array($middlewares)) { + $middlewares = []; + } return [$data, $args, $middlewares]; } } \ No newline at end of file From c58af99989bb19502b0cd174d147bfde4b94d8a2 Mon Sep 17 00:00:00 2001 From: Daniel Ronkainen Date: Wed, 4 Mar 2026 22:33:13 +0100 Subject: [PATCH 05/10] feat: add controller support with mixed respone --- README.md | 31 +++++++- src/Contracts/ControllerResponseInterface.php | 17 ++++ src/Contracts/KernelInterface.php | 4 +- src/ControllerRequestHandler.php | 60 ++++++++++++-- src/Kernel.php | 6 +- src/Middlewares/OutputMiddleware.php | 78 ------------------- src/RequestHandler.php | 1 + tests/unitary-emitron.php | 30 ++++--- 8 files changed, 123 insertions(+), 104 deletions(-) create mode 100644 src/Contracts/ControllerResponseInterface.php delete mode 100644 src/Middlewares/OutputMiddleware.php diff --git a/README.md b/README.md index 87e97c6..aee19b5 100644 --- a/README.md +++ b/README.md @@ -32,7 +32,19 @@ composer require maplephp/emitron Emitron includes a robust request handler that executes PSR-15 middlewares in sequence, returning a fully PSR-7 compliant response. ```php -use MaplePHP\Emitron\RequestHandler;use MaplePHP\Http\Environment;use MaplePHP\Http\ServerRequest;use MaplePHP\Http\Uri; +use Psr\Http\Message\RequestInterface; +use Psr\Http\Message\ResponseInterface; +use MaplePHP\Emitron\RequestHandler; +use MaplePHP\Emitron\Emitters\HttpEmitter; +use MaplePHP\Http\Environment; +use MaplePHP\Http\ServerRequest; +use MaplePHP\Http\Uri; +use MaplePHP\Emitron\Middlewares\{ + ContentLengthMiddleware, + GzipMiddleware, + HeadRequestMiddleware +}; +use App\Controllers\MyController; // Use MaplePHP HTTP library or any other PSR-7 implementation $env = new Environment(); @@ -48,8 +60,23 @@ $middlewares = [ ]; // Run the middleware stack -$handler = new RequestHandler($middlewares, $factory); + + +$factory = new ResponseFactory($bodyStream); +// $finalHandler = new ControllerRequestHandler($factory, [MyController::class, "index"]); +$finalHandler = new ControllerRequestHandler($factory, function(RequestInterface $request, ResponseInterface $response) { + $response->getBody()->write("Lorem ipsum dolor sit amet, consectetur adipiscing elit."); + return $response; +}); + +$handler = new RequestHandler($middlewares, $finalHandler); $response = $handler->handle($request); + +// Emit the execute headers and response correctly +//$emit = new CliEmitter($response, $request); +$emit = new HttpEmitter(); +$emit->emit($response, $request); + ``` Each middleware conforms to `Psr\Http\Server\MiddlewareInterface`, allowing you to plug in your own or third-party middlewares with no additional setup. diff --git a/src/Contracts/ControllerResponseInterface.php b/src/Contracts/ControllerResponseInterface.php new file mode 100644 index 0000000..9db19a2 --- /dev/null +++ b/src/Contracts/ControllerResponseInterface.php @@ -0,0 +1,17 @@ +controller; + if(is_callable($controller)) { + return $controller($request, $response); + } + if (!isset($controller[1])) { $controller[1] = '__invoke'; } @@ -46,21 +49,62 @@ public function handle(ServerRequestInterface $request): ResponseInterface $reflect = new Reflection($class); $classInst = $reflect->dependencyInjector(); - // This should INVOKE the method and return its result (ResponseInterface or something else) $result = $reflect->dependencyInjector($classInst, $method); + return $this->createResponse($response, $result); + } + + + /** + * Will create a PSR valid Response instance form mixed result + * + * @param ResponseInterface $response + * @param mixed $result + * @return ResponseInterface + */ + protected function createResponse(ResponseInterface $response, mixed $result): ResponseInterface + { if ($result instanceof ResponseInterface) { return $result; } - // If controller didn’t return a response: - // - treat it as “controller wrote to $response->getBody() somewhere” - // - or treat non-response as error + if($result instanceof StreamInterface) { + return $response->withBody($result); + } + + if(is_array($result) || is_object($result)) { + return $this->createStream($response, json_encode($result, JSON_UNESCAPED_UNICODE)) + ->withHeader("Content-Type", "application/json"); + } + + if(is_string($result) || is_numeric($result)) { + return $this->createStream($response, $result); + } return $response; } + /** + * A helper method to create a new stream instance + * + * @param ResponseInterface $response + * @param mixed $result + * @return ResponseInterface + */ + protected function createStream(ResponseInterface $response, mixed $result): ResponseInterface + { + $streamFactory = new StreamFactory(); + $stream = $streamFactory->createStream($result); + return $response->withBody($stream); + + } + /** + * Append interface helper method + * + * @param array $bindings + * @return void + */ protected function appendInterfaces(array $bindings) { Reflection::interfaceFactory(function (string $className) use ($bindings) { diff --git a/src/Kernel.php b/src/Kernel.php index 294e6a4..545436e 100644 --- a/src/Kernel.php +++ b/src/Kernel.php @@ -14,8 +14,6 @@ namespace MaplePHP\Emitron; -use FastRoute\Dispatcher; -use MaplePHP\Core\Router\RouterDispatcher; use MaplePHP\Http\ResponseFactory; use Psr\Http\Message\ServerRequestInterface; use Psr\Http\Message\StreamInterface; @@ -35,7 +33,7 @@ public function run(ServerRequestInterface $request, ?StreamInterface $stream = $this->dispatchConfig->getRouter()->dispatch(function ($data, $args, $middlewares) use ($request, $stream) { - $dispatchCode = $data[0] ?? RouterDispatcher::FOUND; + //$dispatchCode = $data[0] ?? RouterDispatcher::FOUND; [$data, $args, $middlewares] = $this->reMap($data, $args, $middlewares); @@ -60,6 +58,7 @@ public function run(ServerRequestInterface $request, ?StreamInterface $stream = middlewares: $middlewares ); + /* if ($dispatchCode === Dispatcher::NOT_FOUND) { $response = $response->withStatus(404); } @@ -67,6 +66,7 @@ public function run(ServerRequestInterface $request, ?StreamInterface $stream = if ($dispatchCode === Dispatcher::METHOD_NOT_ALLOWED) { $response = $response->withStatus(405); } + */ $this->createEmitter()->emit($response, $request); }); diff --git a/src/Middlewares/OutputMiddleware.php b/src/Middlewares/OutputMiddleware.php deleted file mode 100644 index d2e7d4a..0000000 --- a/src/Middlewares/OutputMiddleware.php +++ /dev/null @@ -1,78 +0,0 @@ -stream = $stream; - } - /** - * Get the body content length reliably with PSR Stream. - * - * @param ServerRequestInterface $request - * @param RequestHandlerInterface $handler - * @return ResponseInterface - */ - public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface - { - - if ($this->stream === null) { - $this->stream = new Stream(Stream::TEMP); - } - - $response = $handler->handle($request); - $response = $response->withBody($this->stream); - return $response; - } - - - /** - * Will add "Accept-Encoding" into the "Vary" header - * - * Note: Ensures caches serve the correct version (compressed or not) based on the - * client's "Accept-Encoding" header. Prevents issues with shared caches. - * - * @param ResponseInterface $response - * @return ResponseInterface - */ - private function addAcceptEncodingToVary(ResponseInterface $response): ResponseInterface - { - if ($response->hasHeader('Vary')) { - $existing = $response->getHeaderLine('Vary'); - if (!str_contains($existing, 'Accept-Encoding')) { - $response = $response->withHeader('Vary', $existing . ', Accept-Encoding'); - } - } else { - $response = $response->withHeader('Vary', 'Accept-Encoding'); - } - - return $response; - } - - /** - * Check if gzip is applicable - * - * @param ServerRequestInterface $request - * @param ResponseInterface $response - * @return bool - */ - private function canUseGzip(ServerRequestInterface $request, ResponseInterface $response): bool - { - $acceptEncoding = $request->getHeaderLine('Accept-Encoding'); - $hasGzip = function_exists('gzencode') && str_contains($acceptEncoding, 'gzip'); - return $hasGzip && !$response->hasHeader('Content-Encoding') && $response->getBody()->isSeekable(); - } -} diff --git a/src/RequestHandler.php b/src/RequestHandler.php index ab9139c..6963bc4 100644 --- a/src/RequestHandler.php +++ b/src/RequestHandler.php @@ -24,6 +24,7 @@ public function __construct( public function handle(ServerRequestInterface $request): ResponseInterface { + // End of chain -> call controller (or whatever final handler you set) if (!isset($this->middlewareQueue[$this->index])) { return $this->finalHandler->handle($request); diff --git a/tests/unitary-emitron.php b/tests/unitary-emitron.php index 49bb66e..8c42b49 100755 --- a/tests/unitary-emitron.php +++ b/tests/unitary-emitron.php @@ -1,10 +1,10 @@ withName("emitron"); group($config->withSubject("Testing middleware and emitter"), function (TestCase $case) { - $stream = new Stream(Stream::TEMP); - $stream->write("Lorem ipsum dolor sit amet, consectetur adipiscing elit. Cras eleifend ligula vel diam tincidunt finibus. In dapibus dictum lectus a malesuada."); + + // It will reverse the order $middlewares = [ new ContentLengthMiddleware(), new GzipMiddleware(), - new OutputMiddleware($stream), new HeadRequestMiddleware(), ]; @@ -31,24 +32,29 @@ $uri = new Uri($env->getUriParts()); $request = new ServerRequest($uri, $env); - // Do not exist in CLI so need to mock it + // This is something that is usually set by the browser + // So this does not exist in CLI so need to mock it $request = $request->withHeader("Accept-Encoding", "gzip"); - $factory = new ResponseFactory(); - $factory->createResponse(); - $handler = new RequestHandler($middlewares, $factory); - $response = $handler->handle($request); + $stream = new Stream(Stream::TEMP); + $factory = new ResponseFactory($stream); + $factory->createResponse(200, "OK"); + $finalHandler = new ControllerRequestHandler($factory, function(RequestInterface $req, ResponseInterface $res) { + $res->getBody()->write("Lorem ipsum dolor sit amet, consectetur adipiscing elit. Cras eleifend ligula vel diam tincidunt finibus. In dapibus dictum lectus a malesuada."); + return $res; + }); + $handler = new RequestHandler($middlewares, $finalHandler); + $response = $handler->handle($request); $emit = new HttpEmitter(); ob_start(); - $emit->emit($response->withBody($stream), $request); + $emit->emit($response, $request); $out = ob_get_clean(); - $case->validate($out, function (Expect $expect) { - $expect->isLength(143); + $expect->isLength(119); }); $headers = $response->getHeaders(); From a624e24467dfe8801a28f29ecae2acc895621298 Mon Sep 17 00:00:00 2001 From: Daniel Ronkainen Date: Thu, 5 Mar 2026 22:06:05 +0100 Subject: [PATCH 06/10] refactor: Clean up and code refeactor --- src/AbstractConfigProps.php | 54 +++++++++++++++++++---- src/Emitters/HttpEmitter.php | 24 +++++----- src/Enums/DispatchCodes.php | 10 +++++ src/Kernel.php | 35 +++++++-------- src/Middlewares/HeadRequestMiddleware.php | 29 +++++------- 5 files changed, 93 insertions(+), 59 deletions(-) create mode 100644 src/Enums/DispatchCodes.php diff --git a/src/AbstractConfigProps.php b/src/AbstractConfigProps.php index fdcc605..316c5dd 100644 --- a/src/AbstractConfigProps.php +++ b/src/AbstractConfigProps.php @@ -14,6 +14,9 @@ abstract class AbstractConfigProps implements ConfigPropsInterface public ?string $path = null; public ?string $test = null; + private array $propDesc = []; + private static array $childPropCache = []; + /** * Hydrate the properties/object with expected data, and handle unexpected data * @@ -66,6 +69,32 @@ public function setProp(string $key, mixed $value): self return $this; } + + /** + * Add description to prop + * + * @param string $key + * @param string $desc + * @return $this + */ + protected function setPropDesc(string $key, string $desc): self + { + $this->propDesc[$key] = $desc; + return $this; + } + + /** + * Get description to prop + * + * @param string $key + * @param string $desc + * @return string + */ + public function getPropDesc(string $key): string + { + return $this->propDesc[$key] ?? ""; + } + /** * Set multiple config props * @@ -101,15 +130,22 @@ public function get(string $key): mixed return ($newKey !== false) ? $this->{$newKey} : null; } - /** - * Return props object as array - * - * @return array - */ - public function toArray(): array - { - return get_object_vars($this); - } + /** + * Return public properties defined on the concrete class + */ + public function toArray(): array + { + $vars = get_object_vars($this); + + if (!isset(self::$childPropCache[static::class])) { + $childDefaults = get_class_vars(static::class); + $baseDefaults = get_class_vars(self::class); + + self::$childPropCache[static::class] = array_diff_key($childDefaults, $baseDefaults); + } + + return array_intersect_key($vars, self::$childPropCache[static::class]); + } /** * Get value as bool value diff --git a/src/Emitters/HttpEmitter.php b/src/Emitters/HttpEmitter.php index 1a8c266..54444e2 100644 --- a/src/Emitters/HttpEmitter.php +++ b/src/Emitters/HttpEmitter.php @@ -22,23 +22,23 @@ class HttpEmitter implements EmitterInterface */ public function emit(ResponseInterface $response, ServerRequestInterface $request): void { - $body = $response->getBody(); $status = $response->getStatusCode(); $method = strtoupper($request->getMethod()); - $skipBody = in_array($status, [204, 304]) || ($status >= 100 && $status < 200) || $method === 'HEAD'; - - // Default to 204 No Content if nobody was written - if (!$response->getBody()->getSize() && $this->isSuccessfulResponse($response)) { - $response = $response->withStatus(204); - $response->getBody()->write("No Content\n"); - } + $skipBody = in_array($status, [204, 304], true) + || ($status >= 100 && $status < 200) + || $method === 'HEAD'; + // Create headers $this->createHeaders($response); - if (!$skipBody && $body->isSeekable()) { - $body->rewind(); - } - echo $skipBody ? '' : $body->getContents(); + + // Detach body if HEAD or other detachable status code + if (!$skipBody) { + if ($body->isSeekable()) { + $body->rewind(); + } + echo $body->getContents(); + } } /** diff --git a/src/Enums/DispatchCodes.php b/src/Enums/DispatchCodes.php new file mode 100644 index 0000000..c778896 --- /dev/null +++ b/src/Enums/DispatchCodes.php @@ -0,0 +1,10 @@ +dispatchConfig->getRouter()->dispatch(function ($data, $args, $middlewares) use ($request, $stream) { - //$dispatchCode = $data[0] ?? RouterDispatcher::FOUND; + $dispatchCode = (int)($data[0] ?? DispatchCodes::FOUND->value); + if($dispatchCode !== DispatchCodes::FOUND->value) { + $data['handler'] = function (ServerRequestInterface $req, ResponseInterface $res): ResponseInterface + { + return $res->withStatus(404); + }; + } + //$dispatchCode = $data[0] ?? RouterDispatcher::FOUND; [$data, $args, $middlewares] = $this->reMap($data, $args, $middlewares); - if (!isset($data['handler'])) { - throw new InvalidArgumentException("Missing 'handler' key."); - } - - $this->container->set("request", $request); $this->container->set("args", $args); $this->container->set("configuration", $this->getDispatchConfig()); @@ -50,7 +52,6 @@ public function run(ServerRequestInterface $request, ?StreamInterface $stream = $factory = new ResponseFactory($bodyStream); $finalHandler = new ControllerRequestHandler($factory, $data['handler'] ?? []); - $response = $this->initRequestHandler( request: $request, stream: $bodyStream, @@ -58,16 +59,6 @@ public function run(ServerRequestInterface $request, ?StreamInterface $stream = middlewares: $middlewares ); - /* - if ($dispatchCode === Dispatcher::NOT_FOUND) { - $response = $response->withStatus(404); - } - - if ($dispatchCode === Dispatcher::METHOD_NOT_ALLOWED) { - $response = $response->withStatus(405); - } - */ - $this->createEmitter()->emit($response, $request); }); } @@ -84,6 +75,10 @@ function reMap($data, $args, $middlewares) if (!is_array($middlewares)) { $middlewares = []; } + + if (!is_array($args)) { + $args = []; + } return [$data, $args, $middlewares]; } } \ No newline at end of file diff --git a/src/Middlewares/HeadRequestMiddleware.php b/src/Middlewares/HeadRequestMiddleware.php index c0ad931..80ff769 100644 --- a/src/Middlewares/HeadRequestMiddleware.php +++ b/src/Middlewares/HeadRequestMiddleware.php @@ -4,6 +4,7 @@ namespace MaplePHP\Emitron\Middlewares; +use MaplePHP\Http\StreamFactory; use Psr\Http\Server\MiddlewareInterface; use Psr\Http\Server\RequestHandlerInterface; use Psr\Http\Message\ResponseInterface; @@ -12,27 +13,19 @@ class HeadRequestMiddleware implements MiddlewareInterface { /** - * Set cache control if it does not exist - * - * Note: Clearing cache on dynamic content is a good standard to make sure that no - * sensitive content will be cached. + * Detach body on HEAD * * @param ServerRequestInterface $request * @param RequestHandlerInterface $handler * @return ResponseInterface */ - public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface - { - $response = $handler->handle($request); - - if (strtoupper($request->getMethod()) === 'HEAD') { - $body = $response->getBody(); - if ($body->isWritable() && $body->isSeekable()) { - $body->rewind(); - $body->write(''); - } - } - - return $response; - } + public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface + { + $response = $handler->handle($request); + if (strtoupper($request->getMethod()) !== 'HEAD') { + return $response; + } + $streamFactory = new StreamFactory(); + return $response->withBody($streamFactory->createStream()); + } } From 98dc3e78995482bf59d524fb5d5e784d9a379bd4 Mon Sep 17 00:00:00 2001 From: Daniel Ronkainen Date: Fri, 6 Mar 2026 19:15:22 +0100 Subject: [PATCH 07/10] fix config inheritance, refactor and add env --- src/AbstractKernel.php | 16 +++- src/Configs/ConfigPropsFactory.php | 10 +-- src/Contracts/AppInterface.php | 77 +++++++++++++++++++ src/Contracts/ConfigPropsInterface.php | 2 + src/Contracts/ControllerResponseInterface.php | 2 + src/Contracts/DispatchConfigInterface.php | 2 + src/Contracts/KernelInterface.php | 2 + src/Contracts/MiddlewareInterface.php | 3 + src/Contracts/RequestHandlerInterface.php | 2 + src/Contracts/RouterDispatchInterface.php | 2 + src/Contracts/RouterInterface.php | 2 + src/ControllerRequestHandler.php | 3 + src/DispatchConfig.php | 29 +++++-- src/Enums/DispatchCodes.php | 2 + src/Kernel.php | 8 +- src/RequestHandler.php | 2 + 16 files changed, 149 insertions(+), 15 deletions(-) create mode 100644 src/Contracts/AppInterface.php diff --git a/src/AbstractKernel.php b/src/AbstractKernel.php index 6dc3af0..8d2fc51 100644 --- a/src/AbstractKernel.php +++ b/src/AbstractKernel.php @@ -15,11 +15,13 @@ namespace MaplePHP\Emitron; use MaplePHP\Container\Reflection; +use MaplePHP\Emitron\Contracts\AppInterface; use MaplePHP\Emitron\Contracts\DispatchConfigInterface; use MaplePHP\Emitron\Contracts\EmitterInterface; use MaplePHP\Emitron\Contracts\KernelInterface; use MaplePHP\Emitron\Emitters\CliEmitter; use MaplePHP\Emitron\Emitters\HttpEmitter; +use MaplePHP\Http\Interfaces\PathInterface; use MaplePHP\Http\ResponseFactory; use MaplePHP\Http\Stream; use Psr\Container\ContainerInterface; @@ -127,6 +129,7 @@ public function getDispatchConfig(): DispatchConfigInterface protected function initRequestHandler( ServerRequestInterface $request, StreamInterface $stream, + PathInterface $path, RequestHandlerInterface $finalHandler, array $middlewares = [] ): ResponseInterface { @@ -136,11 +139,22 @@ protected function initRequestHandler( "RequestInterface" => $request, "ServerRequestInterface" => $request, "StreamInterface" => $stream, + "PathInterface" => $path ]); $middlewares = array_merge($this->userMiddlewares, $middlewares); $handler = new RequestHandler($middlewares, $finalHandler); - $response = $handler->handle($request); + $app = $this->container->has("app") ? $this->container->get("app") : null; + + ob_start(); + $response = $handler->handle($request); + $output = ob_get_clean(); + + if((string)$output !== "" && ($app instanceof AppInterface && !$app->isProd())) { + throw new \RuntimeException( + 'Unexpected output detected during request dispatch. Controllers must write to the response body instead of using echo.' + ); + } return $response; } diff --git a/src/Configs/ConfigPropsFactory.php b/src/Configs/ConfigPropsFactory.php index 3c47496..3dc2c3e 100644 --- a/src/Configs/ConfigPropsFactory.php +++ b/src/Configs/ConfigPropsFactory.php @@ -1,5 +1,7 @@ configPropClass = $configPropClass; if (!($props instanceof ConfigPropsInterface)) { $this->loadConfigFile(($props === null) ? __DIR__ . '/../emitron.config.php' : $props); } } + /** + * Get used config prop class as string + * + * @return string|null + */ + public function getConfigPropsClass(): ?string + { + return $this->configPropClass; + } + /** * Get config value * @@ -155,9 +168,9 @@ public function loadConfigFile(string $path): void if (!is_array($config)) { throw new Exception('The config file do not return a array'); } - + //$this->dir = realpath(dirname($path)); $this->dir = AbstractKernel::getRouterFilePath(); - $this->props = ConfigPropsFactory::create($config); + $this->props = ConfigPropsFactory::create($config, $this->configPropClass); } } diff --git a/src/Enums/DispatchCodes.php b/src/Enums/DispatchCodes.php index c778896..f16715c 100644 --- a/src/Enums/DispatchCodes.php +++ b/src/Enums/DispatchCodes.php @@ -1,5 +1,7 @@ dispatchConfig->getRouter()->dispatch(function ($data, $args, $middlewares) use ($request, $stream) { + $parts = isset($data[2]) && is_array($data[2]) ? $data[2] : []; $dispatchCode = (int)($data[0] ?? DispatchCodes::FOUND->value); + + if($dispatchCode !== DispatchCodes::FOUND->value) { $data['handler'] = function (ServerRequestInterface $req, ResponseInterface $res): ResponseInterface { @@ -51,14 +55,16 @@ public function run(ServerRequestInterface $request, ?StreamInterface $stream = $bodyStream = $this->getBody($stream); $factory = new ResponseFactory($bodyStream); $finalHandler = new ControllerRequestHandler($factory, $data['handler'] ?? []); + $path = new Path($parts, $request); + $response = $this->initRequestHandler( request: $request, stream: $bodyStream, + path: $path, finalHandler: $finalHandler, middlewares: $middlewares ); - $this->createEmitter()->emit($response, $request); }); } diff --git a/src/RequestHandler.php b/src/RequestHandler.php index 6963bc4..7e5b82a 100644 --- a/src/RequestHandler.php +++ b/src/RequestHandler.php @@ -1,5 +1,7 @@ Date: Mon, 9 Mar 2026 21:55:30 +0100 Subject: [PATCH 08/10] build: add unitary test to actions --- .github/workflows/php.yml | 34 ++++++++++++++++++++++++++++++++++ composer.json | 17 +++++++++++------ 2 files changed, 45 insertions(+), 6 deletions(-) create mode 100644 .github/workflows/php.yml diff --git a/.github/workflows/php.yml b/.github/workflows/php.yml new file mode 100644 index 0000000..e09beb3 --- /dev/null +++ b/.github/workflows/php.yml @@ -0,0 +1,34 @@ +name: PHP Unitary + +on: + push: + branches: ["main"] + pull_request: + branches: ["main"] + workflow_dispatch: + +permissions: + contents: read + +jobs: + build: + runs-on: ubuntu-latest + env: + COMPOSER_ROOT_VERSION: 2.x-dev + + steps: + - uses: actions/checkout@v4 + + - name: Cache Composer packages + uses: actions/cache@v3 + with: + path: vendor + key: ${{ runner.os }}-php-${{ hashFiles('**/composer.lock') }} + restore-keys: | + ${{ runner.os }}-php- + + - name: Install dependencies + run: composer install --prefer-dist --no-progress + + - name: Run test suite + run: php vendor/bin/unitary \ No newline at end of file diff --git a/composer.json b/composer.json index 23abc63..59dee55 100644 --- a/composer.json +++ b/composer.json @@ -28,6 +28,9 @@ "homepage": "https://wazabii.se" } ], + "scripts": { + "test": "php vendor/bin/unitary" + }, "require": { "php": ">=8.2", "psr/http-server-middleware": "^1.0", @@ -37,15 +40,17 @@ "require-dev": { "maplephp/unitary": "^2.0" }, - "extra": { - "branch-alias": { - "dev-main": "1.x-dev" - } - }, "autoload": { "psr-4": { "MaplePHP\\Emitron\\": "src" } }, - "minimum-stability": "dev" + "extra": { + "branch-alias": { + "dev-main": "1.x-dev", + "dev-develop": "1.x-dev" + } + }, + "minimum-stability": "dev", + "prefer-stable": true } From e1f5f836aee575f9abad63797416a6b74f94fd1e Mon Sep 17 00:00:00 2001 From: Daniel Ronkainen Date: Mon, 9 Mar 2026 21:56:44 +0100 Subject: [PATCH 09/10] build: add unitary test to actions --- .github/workflows/php.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/php.yml b/.github/workflows/php.yml index e09beb3..834c8d9 100644 --- a/.github/workflows/php.yml +++ b/.github/workflows/php.yml @@ -14,7 +14,7 @@ jobs: build: runs-on: ubuntu-latest env: - COMPOSER_ROOT_VERSION: 2.x-dev + COMPOSER_ROOT_VERSION: 1.x-dev steps: - uses: actions/checkout@v4 From fb8be0162df626a5dbaafa3bb9d5af7371c9b02b Mon Sep 17 00:00:00 2001 From: Daniel Ronkainen Date: Tue, 10 Mar 2026 21:11:22 +0100 Subject: [PATCH 10/10] chore: rename ConfigProps to CliOptions --- src/Configs/ConfigPropsFactory.php | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/src/Configs/ConfigPropsFactory.php b/src/Configs/ConfigPropsFactory.php index 3dc2c3e..ea7fa64 100644 --- a/src/Configs/ConfigPropsFactory.php +++ b/src/Configs/ConfigPropsFactory.php @@ -9,15 +9,16 @@ class ConfigPropsFactory { - /** - * Get expected instance of Config Props - * - * @param array $props - * @return ConfigPropsInterface - */ + /** + * Get expected instance of Config Props + * + * @param array $props + * @param string|null $configProps + * @return ConfigPropsInterface + */ public static function create(array $props, ?string $configProps = null): ConfigPropsInterface { - $override = ($configProps !== null) ? $configProps : '\\Configs\\ConfigProps'; + $override = ($configProps !== null) ? $configProps : '\\Configs\\CliOptions'; $default = \MaplePHP\Unitary\Config\ConfigProps::class; $name = (class_exists($override)) ? $override : $default; if (!is_subclass_of($name, ConfigPropsInterface::class)) {