diff --git a/composer.json b/composer.json index 8e166d1..8ab54f3 100644 --- a/composer.json +++ b/composer.json @@ -11,11 +11,12 @@ } ], "require": { - "php": ">=7.2.5", - "symfony/http-foundation": "~5.4" + "php": ">=8.4.0", + "symfony/http-foundation": "7.2.5", + "psr/container": "2.0" }, "require-dev": { - "phpunit/phpunit": "^9.5" + "phpunit/phpunit": "12.1.2" }, "minimum-stability": "stable", "autoload": { @@ -25,4 +26,3 @@ "test": "phpunit" } } - diff --git a/index.php b/index.php new file mode 100644 index 0000000..6128911 --- /dev/null +++ b/index.php @@ -0,0 +1,10 @@ +get('hello/{name}', function ($name) { + + return 'Hello '.$name; +})->run(); \ No newline at end of file diff --git a/src/Adapter/SessionServiceAdapter.php b/src/Adapter/SessionServiceAdapter.php index f12d2b4..9f30aa4 100644 --- a/src/Adapter/SessionServiceAdapter.php +++ b/src/Adapter/SessionServiceAdapter.php @@ -29,38 +29,37 @@ * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -namespace Mabs\Adapter; +declare(strict_types=1); +namespace Mabs\Adapter; use Mabs\Container\Container; use Mabs\ServiceAdapterInterface; use Mabs\Events; use Symfony\Component\HttpFoundation\Session\Storage\Handler\NativeFileSessionHandler; use Symfony\Component\HttpFoundation\Session\Storage\NativeSessionStorage; -use Symfony\Component\HttpFoundation\Session\Storage\MockFileSessionStorage; use Symfony\Component\HttpFoundation\Session\Session; +use Psr\Container\ContainerInterface; use Symfony\Component\HttpFoundation\Cookie; class SessionServiceAdapter implements ServiceAdapterInterface { - public function load(Container $container) + public function load(ContainerInterface $container): void { - $container['session.storage.options'] = array(); + $container['session.storage.options'] = []; $container['session.default_locale'] = 'en'; $container['session.storage.save_path'] = '/tmp'; - $container['session.storage.handler'] = function (Container $c) { - return new NativeFileSessionHandler($c['session.storage.save_path']); - }; + $container['session.storage.handler'] = fn(Container $c): NativeFileSessionHandler => + new NativeFileSessionHandler($c['session.storage.save_path']); - $container['session.storage.native'] = function (Container $c) { - return new NativeSessionStorage( + $container['session.storage.native'] = fn(Container $c): NativeSessionStorage => + new NativeSessionStorage( $c['session.storage.options'], $c['session.storage.handler'] ); - }; - $container['session'] = function (Container $app) { + $container['session'] = function (Container $app): Session { if (!isset($app['session.storage'])) { $app['session.storage'] = $app['session.storage.native']; } @@ -68,12 +67,12 @@ public function load(Container $container) }; } - public function boot(Container $container) + public function boot(ContainerInterface $container): void { - $container['event_dispatcher']->register(Events::MABS_ON_BOOT, array($this, 'onMabsBoot'), 128); + $container['event_dispatcher']->register(Events::MABS_ON_BOOT, [$this, 'onMabsBoot'], 128); } - public function onMabsBoot(Container $container) + public function onMabsBoot(Container $container): void { $container['request']->setSession($container['session']); } diff --git a/src/Application.php b/src/Application.php index d394e43..caa2b09 100644 --- a/src/Application.php +++ b/src/Application.php @@ -29,113 +29,76 @@ * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ +declare(strict_types=1); + namespace Mabs; +use Closure; use Mabs\Container\Container; use Mabs\Dispatcher\EventDispatcher; use Mabs\Router\Route; -use Mabs\Router\RouteCollection; use Mabs\Router\Router; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; +use Mabs\Adapter\SessionServiceAdapter; -class Application +final class Application { - const VERSION = '2.1.1'; - - protected $container; - - protected $adapters; - - protected $debug; - - protected $loaded = false; + public const VERSION = '3.0.0'; - protected $booted = false; + private readonly Container $container; + private readonly \SplObjectStorage $adapters; + private bool $debug; + private bool $loaded = false; + private bool $booted = false; - public function __construct($debug = false) + public function __construct(bool $debug = false) { - if ($debug) { - ini_set('display_errors', 'on'); - error_reporting(E_ALL); - } else { - error_reporting(0); - } $this->debug = $debug; + + ini_set('display_errors', $debug ? 'on' : 'off'); + error_reporting($debug ? E_ALL : 0); + $this->adapters = new \SplObjectStorage(); $this->container = new Container(); $this->load(); - $this->lock(); } - /** - * check if debug mode is active - * @return bool - */ - public function isDebugMode() + public function isDebugMode(): bool { - return $this->debug === true; + return $this->debug; } - /** - * attach an action for an event - * @param string $eventName - * @param callable $callback - * @param int $priority - * @return \Mabs\EventDispatcher - */ - public function on($eventName, \Closure $callback, $priority = 0) + public function on(string $eventName, Closure $callback, int $priority = 0): EventDispatcher { return $this->container['event_dispatcher']->register($eventName, $callback, $priority); } - /** - * detach registered actions for an event - * @param string $eventName - * @return \Mabs\EventDispatcher - */ - public function detach($eventName) + public function detach(string $eventName): EventDispatcher { return $this->container['event_dispatcher']->detach($eventName); } - /** - * @param string $eventName - * @param mixed $data - * @return mixed - */ - public function dispatch($eventName, $data = null) + public function dispatch(string $eventName, mixed $data = null): mixed { return $this->container['event_dispatcher']->dispatch($eventName, $data); } - /** - * check if all component are loaded - * @return bool - */ - public function isLoaded() + public function isLoaded(): bool { - return $this->loaded === true; + return $this->loaded; } - /** - * check if all component are booted - * @return bool - */ - public function isBooted() + public function isBooted(): bool { - return $this->booted === true; + return $this->booted; } - /** - * run applicaton : handle the request and send response - */ - public function run() + public function run(): void { try { - if (!$this->isLoaded()) { $this->load(); } @@ -147,7 +110,7 @@ public function run() $response = $this->handleRequest(); $this->dispatch(Events::MABS_ON_TERMINATE, $response); - } catch (\Exception $e) { + } catch (\Throwable $e) { $this->dispatch(Events::MABS_HANDLE_EXCEPTION, $e); $response = new Response('500 internal server error', 500); } @@ -156,140 +119,72 @@ public function run() $this->dispatch(Events::MABS_ON_FINISH); } - /** - * handle a Request - * @param Request $request - * @return Response - */ - public function handleRequest(Request $request = null) + public function handleRequest(?Request $request = null): Response { - if (!$request) { - $request = $this->container['request']; - } + $request ??= $this->container['request']; $this->dispatch(Events::MABS_HANDLE_REQUEST, $request); $response = $this->container['router']->handleRequest($request); - if (! $response instanceof Response) { - $response = new Response($response, 200); - } - - return $response; + return $response instanceof Response ? $response : new Response((string)$response, 200); } - /** - * add GET route - * @param string $pattern - * @param \Closure|string $callback - * @param null|string $routeName - * @return Application - */ - public function get($pattern, $callback, $routeName = null) + public function get(string $pattern, Closure|string $callback, ?string $routeName = null): self { - return $this->mount($pattern, $callback, $routeName, array(Request::METHOD_GET)); + return $this->mount($pattern, $callback, $routeName, [Request::METHOD_GET]); } - /** - * add POST route - * @param string $pattern - * @param \Closure|string $callback - * @param null|string $routeName - * @return Application - */ - public function post($pattern, $callback, $routeName = null) + public function post(string $pattern, Closure|string $callback, ?string $routeName = null): self { - return $this->mount($pattern, $callback, $routeName, array(Request::METHOD_POST)); + return $this->mount($pattern, $callback, $routeName, [Request::METHOD_POST]); } - /** - * add PUT route - * @param string $pattern - * @param \Closure|string $callback - * @param null|string $routeName - * @return Application - */ - public function put($pattern, $callback, $routeName = null) + public function put(string $pattern, Closure|string $callback, ?string $routeName = null): self { - return $this->mount($pattern, $callback, $routeName, array(Request::METHOD_PUT)); + return $this->mount($pattern, $callback, $routeName, [Request::METHOD_PUT]); } - /** - * add DELETE route - * @param string $pattern - * @param \Closure|string $callback - * @param null|string $routeName - * @return Application - */ - public function delete($pattern, $callback, $routeName = null) + public function delete(string $pattern, Closure|string $callback, ?string $routeName = null): self { - return $this->mount($pattern, $callback, $routeName, array(Request::METHOD_DELETE)); + return $this->mount($pattern, $callback, $routeName, [Request::METHOD_DELETE]); } - /** - * add a route - * @param string $pattern - * @param \Closure|string $callback - * @param null|string $routeName - * @param array HTTP Methode - * @return Application - */ - public function mount($pattern, $callback, $routeName = null, $methodes = array()) - { - $route = new Route(); - $route->setPath($pattern) + public function mount( + string $pattern, + Closure|string $callback, + ?string $routeName = null, + array $methods = [] + ): self { + $route = (new Route()) + ->setPath($pattern) ->setName($routeName) ->setCallback($callback); - $this->container['router']->mount($route, $methodes); - + $this->container['router']->mount($route, $methods); return $this; } - /** - * get the DI container - * @return Container - */ - public function getContainer() + public function getContainer(): Container { return $this->container; } - /** - * lock the container - */ - public function lock() + public function lock(): void { $this->container->lock(true); $this->dispatch(Events::MABS_ON_LOCKED); } - /** - * list of active components - * @return array - */ - public function getAdapters() + public function getAdapters(): array { - return array( - new \Mabs\Adapter\SessionServiceAdapter(), - ); + return [new SessionServiceAdapter()]; } - /** - * load all component in the DI Container - */ - protected function load() + protected function load(): void { - $this->container['event_dispatcher'] = function (Container $container) { - return new EventDispatcher($container); - }; - - $this->container['request'] = function () { - return Request::createFromGlobals(); - }; - - $this->container['router'] = function (Container $container) { - return new Router(); - }; + $this->container['event_dispatcher'] = fn(Container $container) => new EventDispatcher($container); + $this->container['request'] = fn() => Request::createFromGlobals(); + $this->container['router'] = fn(Container $container) => new Router(); foreach ($this->getAdapters() as $adapter) { $adapter->load($this->container); @@ -299,14 +194,12 @@ protected function load() $this->loaded = true; } - /** - * initialize all components - */ - protected function boot() + protected function boot(): void { foreach ($this->adapters as $adapter) { $adapter->boot($this->container); } + $this->dispatch(Events::MABS_ON_BOOT); $this->booted = true; } diff --git a/src/Container/Container.php b/src/Container/Container.php index 24a5d46..91c1560 100644 --- a/src/Container/Container.php +++ b/src/Container/Container.php @@ -29,100 +29,165 @@ * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ +declare(strict_types=1); + namespace Mabs\Container; +use Closure; +use ArrayAccess; +use InvalidArgumentException; +use LogicException; +use Psr\Container\ContainerInterface; -class Container implements \ArrayAccess +final class Container implements ContainerInterface, ArrayAccess { + private bool $isLocked = false; - private $isLocked = false; - - protected $bag = array(); + /** @var array */ + private array $entries = []; - public function __construct($values = array()) + /** + * @param array $services Initial services. + */ + public function __construct(array $services = []) { - foreach ($values as $key => $value) { - $this->offsetSet($key, $value); + foreach ($services as $key => $value) { + $this->set($key, $value); } } /** - * @param mixed $offset - * An offset to check for. + * Retrieve a service by ID. * - * @return boolean true on success or false on failure. + * @param string $id + * @return mixed + * @throws InvalidArgumentException if service is not defined. */ - public function offsetExists($offset) : bool + public function get(string $id): mixed { - return isset($this->bag[$offset]); + if (!array_key_exists($id, $this->entries)) { + throw new InvalidArgumentException("Service \"$id\" not found."); + } + + $value = $this->entries[$id]; + + if ($value instanceof Closure) { + $this->entries[$id] = $value = $value($this); + } + + return $value; } /** - * @param mixed $offset - * The offset to retrieve. + * Check if a service exists. * - * @return mixed Can return all value types. + * @param string $id + * @return bool */ - public function offsetGet($offset) : mixed + public function has(string $id): bool { - if (!isset($this->bag[$offset])) { - throw new \InvalidArgumentException($offset.' not registred in container.'); - } - $value = $this->bag[$offset]; - if ($value instanceof \Closure) { - $this->bag[$offset] = $value($this); - } - - return $this->bag[$offset]; + return array_key_exists($id, $this->entries); } /** - * @param mixed $offset

- * The offset to assign the value to. - * @param mixed $value - * The value to set. + * Store a service in the container. * + * @param string $id + * @param mixed $value * @return void + * @throws LogicException if the container is locked. */ - public function offsetSet($offset, $value) : void + public function set(string $id, mixed $value): void { - if ($this->isLocked && isset($this->bag[$offset])) { - throw new \LogicException('Cannot edit locked container '.$offset); + if ($this->isLocked && array_key_exists($id, $this->entries)) { + throw new LogicException("Cannot modify locked container entry \"$id\"."); } - $this->bag[$offset] = $value; + + $this->entries[$id] = $value; } /** - * Offset to unset - * @param mixed $offset - * The offset to unset. + * Remove a service from the container. * + * @param string $id * @return void + * @throws LogicException if the container is locked. */ - public function offsetUnset($offset) : void + public function unset(string $id): void { if ($this->isLocked) { - throw new \LogicException('Cannot edit locked container'); - } - if ($this->offsetExists($offset)) { - unset($this->bag[$offset]); + throw new LogicException("Cannot remove from a locked container."); } + + unset($this->entries[$id]); } /** - * lock Container + * Locks the container, preventing further modifications. */ - public function lock() + public function lock(): void { $this->isLocked = true; } /** - * check if Container is locked - * @return bool + * Check if the container is locked. + */ + public function isLocked(): bool + { + return $this->isLocked; + } + + /** + * Create a new container with optional services and locking. */ - public function isLocked() + public static function create(array $services = [], bool $lock = false): self + { + $container = new self($services); + if ($lock) { + $container->lock(); + } + return $container; + } + + /** + * Clone the container with new services. + * + * @param array $services + * @return self + */ + public function with(array $services): self + { + $clone = clone $this; + $clone->isLocked = false; + $clone->entries = [...$this->entries]; + + foreach ($services as $key => $value) { + $clone->set($key, $value); + } + + return $clone; + } + + // --- ArrayAccess Implementation --- + + public function offsetExists(mixed $offset): bool + { + return $this->has((string) $offset); + } + + public function offsetGet(mixed $offset): mixed + { + return $this->get((string) $offset); + } + + public function offsetSet(mixed $offset, mixed $value): void + { + $this->set((string) $offset, $value); + } + + public function offsetUnset(mixed $offset): void { - return $this->isLocked === true; + $this->unset((string) $offset); } } diff --git a/src/Container/ContainerNotFoundException.php b/src/Container/ContainerNotFoundException.php new file mode 100644 index 0000000..d101a6d --- /dev/null +++ b/src/Container/ContainerNotFoundException.php @@ -0,0 +1,38 @@ + + * @copyright 2015 Mohamed Aymen Ben Slimane + * + * The MIT License (MIT) + * + * Copyright (c) 2015 Mohamed Aymen Ben Slimane + * + * Permission is hereby granted, free of charge, to any person obtaining + * a copy of this software and associated documentation files (the + * "Software"), to deal in the Software without restriction, including + * without limitation the rights to use, copy, modify, merge, publish, + * distribute, sublicense, and/or sell copies of the Software, and to + * permit persons to whom the Software is furnished to do so, subject to + * the following conditions: + * + * The above copyright notice and this permission notice shall be + * included in all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, + * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF + * MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND + * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE + * LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION + * OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ + + declare(strict_types=1); + + namespace Mabs\Container; + + use Psr\Container\NotFoundExceptionInterface; + +final class ContainerNotFoundException extends \Exception implements NotFoundExceptionInterface {} \ No newline at end of file diff --git a/src/Dispatcher/EventDispatcher.php b/src/Dispatcher/EventDispatcher.php index f8bbf74..8a89b17 100644 --- a/src/Dispatcher/EventDispatcher.php +++ b/src/Dispatcher/EventDispatcher.php @@ -29,109 +29,114 @@ * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -namespace Mabs\Dispatcher; - - -class EventDispatcher -{ - - private $listeners = array(); - - private $container; - - public function __construct($container) - { - $this->container = $container; - } - /** - * @param string $eventName - * @param mixed $data - * @return EventDispatcher - */ - public function dispatch($eventName, $data = null) - { - $listeners = $this->getListenersByEvent($eventName); - if (empty($listeners)) { - return $this; - } - foreach ($listeners as $event) { - call_user_func_array($event['callback'], array($this->container, $data)); - } - - return $this; - } - - /** - * - * @param string $eventName - * @return EventDispatcher - */ - final public function detach($eventName) - { - if (isset($this->listeners[$eventName])) { - unset($this->listeners[$eventName]); - } - - return $this; - } - - /** - * - * @param string $eventName - * @param mixed $callback - * @param int $priority - * @return EventDispatcher - */ - final public function register($eventName, $callback, $priority) - { - $eventName = trim($eventName); - - if (!isset($this->listeners[$eventName])) { - $this->listeners[$eventName] = array(); - } - - $event = array( - 'eventName' => $eventName, - 'callback' => $callback, - 'priority' => (int)$priority - ); - - array_push($this->listeners[$eventName], $event); - - if (count($this->listeners[$eventName]) > 1) { - usort($this->listeners[$eventName], function ($a, $b) - { - if ($a['priority'] == $b['priority']) { - return 0; - } - - return ($a['priority'] < $b['priority']) ? -1 : 1; - } - ); - } - - return $this; - } - - /** - * - * @return array - */ - public function getListeners() - { - return $this->listeners; - } - - /** - * @param $eventName - * @return array - */ - public function getListenersByEvent($eventName) - { - if (isset($this->listeners[$eventName])) { - return $this->listeners[$eventName]; - } - - return array(); - } -} + declare(strict_types=1); + + namespace Mabs\Dispatcher; + + use Closure; + use Psr\Container\ContainerInterface; + + final class EventDispatcher implements EventDispatcherInterface + { + /** @var array> */ + private array $listeners = []; + + public function __construct( + private readonly ContainerInterface $container + ) {} + + /** + * Dispatch an event to all registered listeners + * + * @param string $eventName The event to dispatch + * @param mixed $data Optional data to pass to the event listeners + * @return $this + */ + public function dispatch(string $eventName, mixed $data = null): self + { + foreach ($this->getListenersByEvent($eventName) as $event) { + $event['callback']($this->container, $data); + } + + return $this; + } + + /** + * Remove all listeners for a given event + * + * @param string $eventName The event name to clear + * @return $this + */ + final public function detach(string $eventName): self + { + unset($this->listeners[$eventName]); + + return $this; + } + + /** + * Register an event listener + * + * @param string $eventName The event to listen for + * @param callable|array|string $callback The callback to execute + * @param int $priority The priority (higher numbers execute first) + * @return $this + */ + final public function register( + string $eventName, + callable|array|string $callback, + int $priority = 0 + ): self { + $eventName = trim($eventName); + $this->listeners[$eventName] ??= []; + + $this->listeners[$eventName][] = [ + 'eventName' => $eventName, + 'callback' => $this->normalizeCallback($callback), + 'priority' => $priority + ]; + + if (count($this->listeners[$eventName]) > 1) { + usort( + $this->listeners[$eventName], + fn(array $a, array $b): int => $b['priority'] <=> $a['priority'] + ); + } + + return $this; + } + + /** + * Get all registered listeners + * + * @return array> + */ + public function getListeners(): array + { + return $this->listeners; + } + + /** + * Get listeners for a specific event + * + * @param string $eventName The event name + * @return array + */ + public function getListenersByEvent(string $eventName): array + { + return $this->listeners[$eventName] ?? []; + } + + /** + * Normalize different callback formats to a callable + */ + private function normalizeCallback(callable|array|string $callback): callable + { + return match (true) { + is_callable($callback) => $callback, + is_string($callback) && str_contains($callback, '::') => explode('::', $callback), + is_string($callback) => fn(ContainerInterface $c) => $c->get($callback), + default => throw new \InvalidArgumentException('Invalid callback type') + }; + } + } \ No newline at end of file diff --git a/src/Dispatcher/EventDispatcherInterface.php b/src/Dispatcher/EventDispatcherInterface.php new file mode 100644 index 0000000..f785843 --- /dev/null +++ b/src/Dispatcher/EventDispatcherInterface.php @@ -0,0 +1,38 @@ + + * @copyright 2015 Mohamed Aymen Ben Slimane + * + * The MIT License (MIT) + * + * Copyright (c) 2015 Mohamed Aymen Ben Slimane + * + * Permission is hereby granted, free of charge, to any person obtaining + * a copy of this software and associated documentation files (the + * "Software"), to deal in the Software without restriction, including + * without limitation the rights to use, copy, modify, merge, publish, + * distribute, sublicense, and/or sell copies of the Software, and to + * permit persons to whom the Software is furnished to do so, subject to + * the following conditions: + * + * The above copyright notice and this permission notice shall be + * included in all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, + * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF + * MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND + * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE + * LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION + * OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ + +namespace Mabs\Dispatcher; + +interface EventDispatcherInterface +{ + public function register(string $eventName, callable $callback, int $priority = 0); + public function dispatch(string $eventName, $data = null); +} diff --git a/src/Events.php b/src/Events.php index 905ef85..d89e0fe 100644 --- a/src/Events.php +++ b/src/Events.php @@ -31,15 +31,16 @@ namespace Mabs; - class Events { - const MABS_BEFORE_LOAD = 'mabs.before.load'; - const MABS_ON_LOCKED = 'mabs.on.locked'; - const MABS_ON_BOOT = 'mabs.on.boot'; - const MABS_HANDLE_EXCEPTION = 'mabs.handle.exception'; - const MABS_HANDLE_REQUEST = 'mabs.handle.request'; - const MABS_ON_TERMINATE = 'mabs.on.terminate'; - const MABS_ON_FINISH = 'mabs.on.finish'; + public const string MABS_BEFORE_LOAD = 'mabs.before.load'; + public const string MABS_ON_LOCKED = 'mabs.on.locked'; + public const string MABS_ON_BOOT = 'mabs.on.boot'; + public const string MABS_HANDLE_EXCEPTION = 'mabs.handle.exception'; + public const string MABS_HANDLE_REQUEST = 'mabs.handle.request'; + public const string MABS_ON_TERMINATE = 'mabs.on.terminate'; + public const string MABS_ON_FINISH = 'mabs.on.finish'; + + private function __construct() + {} } - \ No newline at end of file diff --git a/src/Router/Route.php b/src/Router/Route.php index 0307718..c3a70b1 100644 --- a/src/Router/Route.php +++ b/src/Router/Route.php @@ -29,95 +29,65 @@ * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -namespace Mabs\Router; +declare(strict_types=1); +namespace Mabs\Router; -class Route +final class Route { - private $path; - private $callback; - private $name; + private string $path; + private mixed $handler; + private ?string $name = null; - /** - * @return mixed - */ - public function getPath() + public function __construct(string $path, mixed $handler) { - return $this->path; + $this->path = $path; + $this->handler = $handler; } - /** - * @param mixed $path - */ - public function setPath($path) + public function path(): string { - $this->path = $path; - - return $this; + return $this->path; } - /** - * @return mixed - */ - public function getName() + public function name(): string { - if ($this->name == null) { - $this->name = spl_object_hash($this); - } - - return $this->name; + return $this->name ??= spl_object_hash($this); } - /** - * @param mixed $name - */ - public function setName($name) + public function withName(string $name): self { $this->name = $name; - return $this; } - /** - * @return mixed - */ - public function getCallback() + public function handler(): mixed { - return $this->callback; + return $this->handler; } - /** - * @param mixed $callback - */ - public function setCallback($callback) + public function withHandler(mixed $handler): self { - $this->callback = $callback; - + $this->handler = $handler; return $this; } - public function getRegularExpression() + public function toRegex(): string { - $path = $this->path; - $patterns = array('/\((\w+)\?\)/', '/\((\w+)\)/'); - $replacements = array('(\w*)', '(\w+)'); - - return preg_replace($patterns, $replacements, $path); + return preg_replace( + ['/\{(\w+)\?\}/', '/\{(\w+)\}/'], + ['(\w*)', '(\w+)'], + $this->path + ) ?? $this->path; } - public function getNamesParameters(array $matchedValues = array()) + public function extractParameters(array $matches): array { - $path = $this->path; - $pattern = '/\((\w+)\??\)/'; - - preg_match_all($pattern, $path, $matches); - $params = array(); - if (isset($matches[1]) && is_array($matches[1])) { - foreach ($matches[1] as $index => $matche) { - $params[$matche] = isset($matchedValues[$index + 1]) ? $matchedValues[$index + 1] : null; - } - } + preg_match_all('/\{(\w+)\??\}/', $this->path, $paramNames); - return $params; + return array_combine( + $paramNames[1], + array_slice($matches, 1, count($paramNames[1])) + array_fill(0, count($paramNames[1]), null) + ) ?: []; } } diff --git a/src/Router/Router.php b/src/Router/Router.php index 5214c2a..43246ae 100644 --- a/src/Router/Router.php +++ b/src/Router/Router.php @@ -29,21 +29,21 @@ * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ +declare(strict_types=1); + namespace Mabs\Router; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; -class Router +final class Router { - protected $routeCollection = array(); - - protected $routes = array(); + private array $routeCollection = []; + private array $routes = []; - public static $httpMethodes = array( - Request::METHOD_POST, - Request::METHOD_HEAD, + public const HTTP_METHODS = [ Request::METHOD_GET, + Request::METHOD_HEAD, Request::METHOD_POST, Request::METHOD_PUT, Request::METHOD_PATCH, @@ -52,114 +52,87 @@ class Router Request::METHOD_OPTIONS, Request::METHOD_TRACE, Request::METHOD_CONNECT, - ); - - /** - * mount controller for given route - * @param $route - * @param array $methodes - * @return \Mabs\Router - */ - public function mount($route, $methodes = array()) + ]; + + public function mount(Route $route, array $methods = []): self { - if (empty($methodes)) { - $methodes = self::$httpMethodes; - } - $this->routes[$route->getName()] = $route; - foreach ($methodes as $methode) { - if (!isset($this->routeCollection[$methode])) { - $this->routeCollection[$methode] = array(); - } - $this->routeCollection[$methode][$route->getName()] = $route; + $methods = $methods ?: self::HTTP_METHODS; + $this->routes[$route->name()] = $route; + + foreach ($methods as $method) { + $this->routeCollection[$method][$route->name()] = $route; } return $this; } - /** - * handle Request and get the response - * @param Request $request - * @return mixed|Response - */ - public function handleRequest(Request $request) + public function handle(Request $request): Response { - $methode = $request->getMethod(); + $method = $request->getMethod(); - if (!isset($this->routeCollection[$methode])) { + if (!isset($this->routeCollection[$method])) { return new Response('404 Not Found', 404); } - $routes = $this->routeCollection[$methode]; - foreach ($routes as $route) { + foreach ($this->routeCollection[$method] as $route) { if ($this->match($request, $route)) { - - if (isset($routes[$route->getName()])) { - return $this->executeController($routes[$route->getName()]->getCallback(), $request); - } + return $this->execute($route->handler(), $request); } } return new Response('404 Not Found', 404); } - /** - * generate Url for the given route name - * @param $routeName - * @param array $params - * @return mixed - */ - public function generateUrl($routeName, $params = array()) + public function generateUrl(string $routeName, array $params = []): string { $route = $this->getRouteByName($routeName); - $path = $route->getPath(); + $path = $route->path(); foreach ($params as $key => $value) { - $path = str_replace(array('(' . $key . ')', '(' . $key . '?)'), $value, $path); + $path = str_replace(['{' . $key . '}', '{' . $key . '?}'], $value, $path); } return $path; } - protected function executeController($controller, Request $request) + private function match(Request $request, Route $route): bool { - return call_user_func_array($controller, $request->query->all()); - } - - protected function match(Request $request, Route $route) - { - $currentPath = $this->getCurrentPath($request); - $routePath = $route->getPath(); - - $regex = $route->getRegularExpression(); - - if ($currentPath == $routePath) { + $currentPath = $this->normalizePath($request->getPathInfo()); + $routePath = $route->path(); + $regex = $route->toRegex(); + if ($currentPath === $routePath) { return true; - } else if (!empty($regex) && preg_match('#^' . $regex . '\/?$#', $currentPath, $matches)) { - $request->query->add($route->getNamesParameters($matches)); + } + if (!empty($regex) && preg_match('#^' . $regex . '/?$#', $currentPath, $matches)) { + $params = $route->extractParameters($matches); + $request->query->add($params); return true; } return false; } - private function getRouteByName($routeName) + private function execute(mixed $handler, Request $request): Response { - if (isset($this->routes[$routeName])) { - return $this->routes[$routeName]; - } + $result = is_callable($handler) + ? call_user_func_array($handler, $request->query->all()) + : $handler; - throw new \RuntimeException('route ' . $routeName . ' not found'); + return $result instanceof Response + ? $result + : new Response((string) $result); } - private function getCurrentPath(Request $request) + private function getRouteByName(string $routeName): Route { - $currentPath = ltrim($request->getPathInfo(), '/'); - if (empty($currentPath) || $currentPath[strlen($currentPath) - 1] != '/') { - $currentPath .= '/'; - } + return $this->routes[$routeName] ?? throw new \RuntimeException("Route '{$routeName}' not found."); + } - return $currentPath; + private function normalizePath(string $path): string + { + $normalized = ltrim($path, '/'); + return str_ends_with($normalized, '/') ? $normalized : $normalized . '/'; } } diff --git a/src/ServiceAdapterInterface.php b/src/ServiceAdapterInterface.php index 06762ba..4136816 100644 --- a/src/ServiceAdapterInterface.php +++ b/src/ServiceAdapterInterface.php @@ -31,11 +31,17 @@ namespace Mabs; -use Mabs\Container\Container; +use Psr\Container\ContainerInterface; interface ServiceAdapterInterface { - public function load(Container $container); - public function boot(Container $container); + /** + * Register services into the container. + */ + public function load(ContainerInterface $container): void; + + /** + * Boot services after registration. + */ + public function boot(ContainerInterface $container): void; } - \ No newline at end of file diff --git a/src/autoload.php b/src/autoload.php deleted file mode 100644 index 4981f4d..0000000 --- a/src/autoload.php +++ /dev/null @@ -1,32 +0,0 @@ -container = new Container(); + $this->adapter = new SessionServiceAdapter(); + + // Mock d'un EventDispatcher + $eventDispatcherMock = $this->createMock(\Mabs\Dispatcher\EventDispatcherInterface::class); + $this->container['event_dispatcher'] = $eventDispatcherMock; + } + + public function testLoadInitializesSessionServices(): void + { + // Charge les services de session dans le container + $this->adapter->load($this->container); + + // Vérifie que les services de session existent dans le container + $this->assertTrue(isset($this->container['session.storage.handler'])); + $this->assertTrue(isset($this->container['session.storage.native'])); + $this->assertTrue(isset($this->container['session'])); + } + + public function testSessionHandlerIsNativeFileSessionHandler(): void + { + // Charge les services de session + $this->adapter->load($this->container); + + // Vérifie que le handler de session est bien de type NativeFileSessionHandler + $handler = $this->container['session.storage.handler']; + $this->assertInstanceOf(NativeFileSessionHandler::class, $handler); + } + + public function testSessionStorageIsNativeSessionStorage(): void + { + // Charge les services de session + $this->adapter->load($this->container); + + // Vérifie que le stockage de session est bien de type NativeSessionStorage + $storage = $this->container['session.storage.native']; + $this->assertInstanceOf(NativeSessionStorage::class, $storage); + } + + public function testSessionIsCreated(): void + { + // Charge les services de session + $this->adapter->load($this->container); + + // Vérifie que la session est bien créée + $session = $this->container['session']; + $this->assertInstanceOf(Session::class, $session); + } + + public function testBootSetsSessionInRequest(): void + { + // Charge les services de session + $this->adapter->load($this->container); + + // Crée une fausse requête + $request = $this->createMock(Request::class); + + // Attache la session à la requête via le boot + $this->container['request'] = $request; + $this->adapter->boot($this->container); + + // Vérifie que la session est bien assignée à la requête + $request->expects($this->once()) + ->method('setSession') + ->with($this->container['session']); + } + + public function testOnMabsBootSetsSessionInRequest(): void + { + // Charge les services de session + $this->adapter->load($this->container); + + // Crée une fausse requête + $request = $this->createMock(Request::class); + + // Crée l'événement MABS_ON_BOOT + $this->container['request'] = $request; + $this->adapter->onMabsBoot($this->container); + + // Vérifie que la session est bien assignée + $request->expects($this->once()) + ->method('setSession') + ->with($this->container['session']); + } + + public function testBootEventRegistration(): void + { + // Test que l'événement est bien enregistré + $this->container['event_dispatcher']->expects($this->once()) + ->method('register') + ->with( + Events::MABS_ON_BOOT, + [$this->adapter, 'onMabsBoot'], + 128 + ); + + $this->adapter->boot($this->container); + } +} diff --git a/tests/ApplicationTest.php b/tests/ApplicationTest.php index ddee4d9..df05cc0 100644 --- a/tests/ApplicationTest.php +++ b/tests/ApplicationTest.php @@ -1,84 +1,75 @@ app = new Application(true); // Activer le mode debug pour les tests + $this->app = new Application(debug: true); } - public function testConstructorSetsDebugMode() + public function testAppStartsInDebugMode(): void { - $app = new Application(true); - $this->assertTrue($app->isDebugMode()); - - $app = new Application(false); - $this->assertFalse($app->isDebugMode()); + $this->assertTrue($this->app->isDebugMode()); } - public function testLoadInitializesComponents() + public function testContainerIsInstanceOfContainer(): void { $this->assertInstanceOf(Container::class, $this->app->getContainer()); } - public function testRunHandlesRequestSuccessfully() + public function testApplicationHandlesRequestAndReturnsResponse(): void { + // Mock Router + $mockRouter = $this->createMock(Router::class); + $mockResponse = new Response('Hello World', 200); + $mockRouter->method('handleRequest')->willReturn($mockResponse); - $request = Request::create('/test'); + $this->app->getContainer()['router'] = fn() => $mockRouter; - $response = $this->app->handleRequest($request); + $response = $this->app->handleRequest(); $this->assertInstanceOf(Response::class, $response); + $this->assertSame(200, $response->getStatusCode()); + $this->assertSame('Hello World', $response->getContent()); } - public function testGetMethodMountsRoute() - { - $this->app->get('example', function () { - return new Response('Hello, World!'); - }); - - $request = Request::create('/example'); - $response = $this->app->handleRequest($request); - - $this->assertEquals('Hello, World!', $response->getContent()); - } - - public function testEventDispatcher() + public function testEventDispatching(): void { - $eventCalled = false; - - $this->app->on('test.event', function () use (&$eventCalled) { - $eventCalled = true; - }); + $mockDispatcher = $this->createMock(EventDispatcher::class); + $mockDispatcher->expects($this->once()) + ->method('dispatch') + ->with('custom_event', ['key' => 'value']) + ->willReturn('dispatched'); - $this->app->dispatch('test.event'); + $this->app->getContainer()['event_dispatcher'] = fn() => $mockDispatcher; - $this->assertTrue($eventCalled); + $result = $this->app->dispatch('custom_event', ['key' => 'value']); + $this->assertSame('dispatched', $result); } - public function testDetachRemovesEventListener() + public function testRouteMounting(): void { - $eventCalled = false; - - $callback = function () use (&$eventCalled) { - $eventCalled = true; - }; - - $this->app->on('test.event', $callback); - $this->app->detach('test.event'); - - $this->app->dispatch('test.event'); - - $this->assertFalse($eventCalled); + $mockRouter = $this->createMock(Router::class); + $mockRouter->expects($this->once()) + ->method('mount') + ->with( + $this->isInstanceOf(\Mabs\Router\Route::class), + [Request::METHOD_GET] + ); + + $this->app->getContainer()['router'] = fn() => $mockRouter; + $this->app->get('/test', fn() => 'ok'); } }