diff --git a/flight/Engine.php b/flight/Engine.php index b2900c4..f75f42a 100644 --- a/flight/Engine.php +++ b/flight/Engine.php @@ -8,6 +8,7 @@ use Closure; use ErrorException; use Exception; use flight\core\Dispatcher; +use flight\core\EventDispatcher; use flight\core\Loader; use flight\net\Request; use flight\net\Response; @@ -51,6 +52,10 @@ use flight\net\Route; * @method void render(string $file, ?array<string,mixed> $data = null, ?string $key = null) Renders template * @method View view() Gets current view * + * # Events + * @method void onEvent(string $event, callable $callback) Registers a callback for an event. + * @method void triggerEvent(string $event, ...$args) Triggers an event. + * * # Request-Response * @method Request request() Gets current request * @method Response response() Gets current response @@ -79,7 +84,8 @@ class Engine private const MAPPABLE_METHODS = [ 'start', 'stop', 'route', 'halt', 'error', 'notFound', 'render', 'redirect', 'etag', 'lastModified', 'json', 'jsonHalt', 'jsonp', - 'post', 'put', 'patch', 'delete', 'group', 'getUrl', 'download', 'resource' + 'post', 'put', 'patch', 'delete', 'group', 'getUrl', 'download', 'resource', + 'onEvent', 'triggerEvent' ]; /** @var array<string, mixed> Stored variables. */ @@ -88,9 +94,12 @@ class Engine /** Class loader. */ protected Loader $loader; - /** Event dispatcher. */ + /** Method and class dispatcher. */ protected Dispatcher $dispatcher; + /** Event dispatcher. */ + protected EventDispatcher $eventDispatcher; + /** If the framework has been initialized or not. */ protected bool $initialized = false; @@ -101,6 +110,7 @@ class Engine { $this->loader = new Loader(); $this->dispatcher = new Dispatcher(); + $this->eventDispatcher = new EventDispatcher(); $this->init(); } @@ -493,6 +503,8 @@ class Engine $this->router()->reset(); } $request = $this->request(); + $this->triggerEvent('flight.request.received', $request); + $response = $this->response(); $router = $this->router(); @@ -515,6 +527,7 @@ class Engine // Route the request $failedMiddlewareCheck = false; while ($route = $router->route($request)) { + $this->triggerEvent('flight.route.matched', $route); $params = array_values($route->params); // Add route info to the parameter list @@ -548,6 +561,7 @@ class Engine $failedMiddlewareCheck = true; break; } + $this->triggerEvent('flight.route.middleware.before', $route); } $useV3OutputBuffering = @@ -563,6 +577,7 @@ class Engine $route->callback, $params ); + $this->triggerEvent('flight.route.executed', $route); if ($useV3OutputBuffering === true) { $response->write(ob_get_clean()); @@ -577,6 +592,7 @@ class Engine $failedMiddlewareCheck = true; break; } + $this->triggerEvent('flight.route.middleware.after', $route); } $dispatched = true; @@ -662,6 +678,8 @@ class Engine } $response->send(); + + $this->triggerEvent('flight.response.sent', $response); } } @@ -992,4 +1010,26 @@ class Engine { return $this->router()->getUrlByAlias($alias, $params); } + + /** + * Adds an event listener. + * + * @param string $eventName The name of the event to listen to + * @param callable $callback The callback to execute when the event is triggered + */ + public function _onEvent(string $eventName, callable $callback): void + { + $this->eventDispatcher->on($eventName, $callback); + } + + /** + * Triggers an event. + * + * @param string $eventName The name of the event to trigger + * @param mixed ...$args The arguments to pass to the event listeners + */ + public function _triggerEvent(string $eventName, ...$args): void + { + $this->eventDispatcher->trigger($eventName, ...$args); + } } diff --git a/flight/Flight.php b/flight/Flight.php index 59a9945..fb35427 100644 --- a/flight/Flight.php +++ b/flight/Flight.php @@ -46,14 +46,15 @@ require_once __DIR__ . '/autoload.php'; * Adds standardized RESTful routes for a controller. * @method static Router router() Returns Router instance. * @method static string getUrl(string $alias, array<string, mixed> $params = []) Gets a url from an alias - * * @method static void map(string $name, callable $callback) Creates a custom framework method. * + * # Filters * @method static void before(string $name, Closure(array<int, mixed> &$params, string &$output): (void|false) $callback) * Adds a filter before a framework method. * @method static void after(string $name, Closure(array<int, mixed> &$params, string &$output): (void|false) $callback) * Adds a filter after a framework method. * + * # Variables * @method static void set(string|iterable<string, mixed> $key, mixed $value) Sets a variable. * @method static mixed get(?string $key) Gets a variable. * @method static bool has(string $key) Checks if a variable is set. @@ -64,6 +65,10 @@ require_once __DIR__ . '/autoload.php'; * Renders a template file. * @method static View view() Returns View instance. * + * # Events + * @method void onEvent(string $event, callable $callback) Registers a callback for an event. + * @method void triggerEvent(string $event, ...$args) Triggers an event. + * * # Request-Response * @method static Request request() Returns Request instance. * @method static Response response() Returns Response instance. diff --git a/flight/core/EventDispatcher.php b/flight/core/EventDispatcher.php new file mode 100644 index 0000000..b80eb42 --- /dev/null +++ b/flight/core/EventDispatcher.php @@ -0,0 +1,45 @@ +<?php + +declare(strict_types=1); + +namespace flight\core; + +class EventDispatcher +{ + /** @var array<string, array<int, callable>> */ + protected array $listeners = []; + + /** + * Register a callback for an event. + * + * @param string $event Event name + * @param callable $callback Callback function + */ + public function on(string $event, callable $callback): void + { + if (isset($this->listeners[$event]) === false) { + $this->listeners[$event] = []; + } + $this->listeners[$event][] = $callback; + } + + /** + * Trigger an event with optional arguments. + * + * @param string $event Event name + * @param mixed ...$args Arguments to pass to the callbacks + */ + public function trigger(string $event, ...$args): void + { + if (isset($this->listeners[$event]) === true) { + foreach ($this->listeners[$event] as $callback) { + $result = call_user_func_array($callback, $args); + + // If you return false, it will break the loop and stop the other event listeners. + if ($result === false) { + break; // Stop executing further listeners + } + } + } + } +} diff --git a/tests/EventSystemTest.php b/tests/EventSystemTest.php new file mode 100644 index 0000000..6dba2c7 --- /dev/null +++ b/tests/EventSystemTest.php @@ -0,0 +1,227 @@ +<?php + +declare(strict_types=1); + +namespace flight\tests; + +use Flight; +use PHPUnit\Framework\TestCase; +use flight\Engine; +use TypeError; + +class EventSystemTest extends TestCase +{ + protected function setUp(): void + { + // Reset the Flight engine before each test to ensure a clean state + Flight::setEngine(new Engine()); + Flight::app()->init(); + } + + /** + * Test registering and triggering a single listener. + */ + public function testRegisterAndTriggerSingleListener() + { + $called = false; + Flight::onEvent('test.event', function () use (&$called) { + $called = true; + }); + Flight::triggerEvent('test.event'); + $this->assertTrue($called, 'Single listener should be called when event is triggered.'); + } + + /** + * Test registering multiple listeners for the same event. + */ + public function testRegisterMultipleListeners() + { + $counter = 0; + Flight::onEvent('test.event', function () use (&$counter) { + $counter++; + }); + Flight::onEvent('test.event', function () use (&$counter) { + $counter++; + }); + Flight::triggerEvent('test.event'); + $this->assertEquals(2, $counter, 'All registered listeners should be called.'); + } + + /** + * Test triggering an event with no listeners registered. + */ + public function testTriggerWithNoListeners() + { + // Should not throw any errors + Flight::triggerEvent('non.existent.event'); + $this->assertTrue(true, 'Triggering an event with no listeners should not throw an error.'); + } + + /** + * Test that a listener receives a single argument correctly. + */ + public function testListenerReceivesSingleArgument() + { + $received = null; + Flight::onEvent('test.event', function ($arg) use (&$received) { + $received = $arg; + }); + Flight::triggerEvent('test.event', 'hello'); + $this->assertEquals('hello', $received, 'Listener should receive the passed argument.'); + } + + /** + * Test that a listener receives multiple arguments correctly. + */ + public function testListenerReceivesMultipleArguments() + { + $received = []; + Flight::onEvent('test.event', function ($arg1, $arg2) use (&$received) { + $received = [$arg1, $arg2]; + }); + Flight::triggerEvent('test.event', 'first', 'second'); + $this->assertEquals(['first', 'second'], $received, 'Listener should receive all passed arguments.'); + } + + /** + * Test that listeners are called in the order they were registered. + */ + public function testListenersCalledInOrder() + { + $order = []; + Flight::onEvent('test.event', function () use (&$order) { + $order[] = 1; + }); + Flight::onEvent('test.event', function () use (&$order) { + $order[] = 2; + }); + Flight::triggerEvent('test.event'); + $this->assertEquals([1, 2], $order, 'Listeners should be called in registration order.'); + } + + /** + * Test that listeners are not called for unrelated events. + */ + public function testListenerNotCalledForOtherEvents() + { + $called = false; + Flight::onEvent('test.event1', function () use (&$called) { + $called = true; + }); + Flight::triggerEvent('test.event2'); + $this->assertFalse($called, 'Listeners should not be called for different events.'); + } + + /** + * Test overriding the onEvent method. + */ + public function testOverrideOnEvent() + { + $called = false; + Flight::map('onEvent', function ($event, $callback) use (&$called) { + $called = true; + }); + Flight::onEvent('test.event', function () { + }); + $this->assertTrue($called, 'Overridden onEvent method should be called.'); + } + + /** + * Test overriding the triggerEvent method. + */ + public function testOverrideTriggerEvent() + { + $called = false; + Flight::map('triggerEvent', function ($event, ...$args) use (&$called) { + $called = true; + }); + Flight::triggerEvent('test.event'); + $this->assertTrue($called, 'Overridden triggerEvent method should be called.'); + } + + /** + * Test that an overridden onEvent can still register listeners by calling the original method. + */ + public function testOverrideOnEventStillRegistersListener() + { + $overrideCalled = false; + Flight::map('onEvent', function ($event, $callback) use (&$overrideCalled) { + $overrideCalled = true; + // Call the original method + Flight::app()->_onEvent($event, $callback); + }); + + $listenerCalled = false; + Flight::onEvent('test.event', function () use (&$listenerCalled) { + $listenerCalled = true; + }); + + Flight::triggerEvent('test.event'); + + $this->assertTrue($overrideCalled, 'Overridden onEvent should be called.'); + $this->assertTrue($listenerCalled, 'Listener should still be triggered after override.'); + } + + /** + * Test that an overridden triggerEvent can still trigger listeners by calling the original method. + */ + public function testOverrideTriggerEventStillTriggersListeners() + { + $overrideCalled = false; + Flight::map('triggerEvent', function ($event, ...$args) use (&$overrideCalled) { + $overrideCalled = true; + // Call the original method + Flight::app()->_triggerEvent($event, ...$args); + }); + + $listenerCalled = false; + Flight::onEvent('test.event', function () use (&$listenerCalled) { + $listenerCalled = true; + }); + + Flight::triggerEvent('test.event'); + + $this->assertTrue($overrideCalled, 'Overridden triggerEvent should be called.'); + $this->assertTrue($listenerCalled, 'Listeners should still be triggered after override.'); + } + + /** + * Test that an invalid callable throws an exception (if applicable). + */ + public function testInvalidCallableThrowsException() + { + $this->expectException(TypeError::class); + // Assuming the event system validates callables + Flight::onEvent('test.event', 'not_a_callable'); + } + + /** + * Test that event propagation stops if a listener returns false. + */ + public function testStopPropagation() + { + $firstCalled = false; + $secondCalled = false; + $thirdCalled = false; + + Flight::onEvent('test.event', function () use (&$firstCalled) { + $firstCalled = true; + return true; // Continue propagation + }); + + Flight::onEvent('test.event', function () use (&$secondCalled) { + $secondCalled = true; + return false; // Stop propagation + }); + + Flight::onEvent('test.event', function () use (&$thirdCalled) { + $thirdCalled = true; + }); + + Flight::triggerEvent('test.event'); + + $this->assertTrue($firstCalled, 'First listener should be called'); + $this->assertTrue($secondCalled, 'Second listener should be called'); + $this->assertFalse($thirdCalled, 'Third listener should not be called after propagation stopped'); + } +}