diff --git a/MILESTONES.md b/MILESTONES.md new file mode 100644 index 0000000..2718bde --- /dev/null +++ b/MILESTONES.md @@ -0,0 +1,42 @@ +# Beacon Milestones + +## Phase 3: Planning + +1. **Project Infrastructure & Core Dispatcher** + - [x] Initialize `composer.json` with `getphred/beacon-contracts` and PHP 8.2 requirements. + - [x] Configure PHPUnit and test structure. + - [x] Implement `EventDispatcher` (PSR-14 compliant). + - [x] Implement basic `ListenerProvider`. + - [x] Implement `CanStopPropagation` trait for stoppable events. + - [x] **Test Coverage**: Unit tests for core dispatching and stoppable behavior. + +2. **Advanced Listener Resolution** + - [x] Implement Priority-based listener sorting. + - [x] Implement Inheritance Resolution (Event class, Parents, Interfaces). + - [x] Implement Performance Toggle for Interface scanning. + - [x] Implement Wildcard pattern support (`*`) and internal regex caching. + - [x] **Test Coverage**: Unit tests for sorting, wildcards, and inheritance edge cases. + +3. **Middleware Pipeline (The Onion)** + - [x] Implement `MiddlewarePipeline` runner. + - [x] Integrate Middleware with `EventDispatcher`. + - [x] Implement basic Telemetry (timing/memory) within the pipeline. + - [x] **Test Coverage**: Integration tests for middleware execution order and telemetry accuracy. + +4. **Discovery & Registration** + - [x] Implement `#[AsEventListener]` and `#[AsSubscriber]` attribute support. + - [x] Implement `ListenerDiscovery` service for scanning directories. + - [x] Integrate PSR-16 cache for discovery results. + - [x] Implement PSR-11 container integration for lazy loading. + - [x] **Test Coverage**: Integration tests for scanning, attribute resolution, and cache persistence. + +5. **Resiliency & Advanced State** + - [x] Implement `FAIL_FAST` and `CONTINUE` resiliency modes. + - [x] Implement `DispatchFailed` internal event for `CONTINUE` mode. + - [x] Implement `EventEnvelope` for carrying immutable context. + - [x] **Test Coverage**: Unit tests for error handling and context mapping. + +6. **Testing Helpers & Verification** + - [x] Implement `EventCollector` for assertions. + - [x] Create `EventAssertions` trait for PHPUnit. + - [x] **Final Verification**: Run full suite; zero failures, zero deprecations. diff --git a/SPECS.md b/SPECS.md new file mode 100644 index 0000000..970337b --- /dev/null +++ b/SPECS.md @@ -0,0 +1,74 @@ +# Beacon Specification (SPECS) + +## Overview +Beacon is a high-performance, PSR-14 compliant event management system. It is designed to be decoupled, utilizing `BeaconContracts` for interfaces and providing an enterprise-grade engine with middleware support, attribute-based discovery, and telemetry. + +--- + +## 1. Core Interfaces (BeaconContracts) +The foundation of Beacon must be defined in `getphred/beacon-contracts`. + +- **EventDispatcherInterface**: Extends `Psr\EventDispatcher\EventDispatcherInterface`. +- **ListenerProviderInterface**: Extends `Psr\EventDispatcher\ListenerProviderInterface`. +- **SubscriberInterface**: Defines `getSubscribedEvents(): array`. +- **StoppableEventInterface**: Extends `Psr\EventDispatcher\StoppableEventInterface`. +- **DispatchMiddlewareInterface**: Defines `handle(object $event, callable $next): object`. + +--- + +## 2. Event Dispatcher (The Engine) +The primary implementation of the dispatcher. + +- **PSR-14 Compliance**: Must correctly handle event objects and return them. +- **Middleware Support**: Implements an "Onion" style pipeline. + - Global middleware can intercept every dispatch. + - Middleware can modify the event or stop propagation. +- **Resiliency Modes**: + - `FAIL_FAST`: Stops execution on the first listener exception. + - `CONTINUE`: Logs the error, dispatches a `DispatchFailed` internal event, and moves to the next listener. +- **Telemetry**: Internal tracking of listener execution time and memory usage. + +--- + +## 3. Listener Provider & Resolution +The logic for finding who cares about an event. + +- **Priority Handling**: Listeners are sorted by priority (higher integer runs first). +- **Inheritance Resolution**: + - Automatically resolves listeners for the event class, its parents, and its interfaces. + - Must include a performance toggle to disable interface scanning. +- **Wildcard Support**: + - Support dot-notation wildcards (e.g., `user.*`, `*.created`). + - Pattern matching converted to optimized regex with internal caching. +- **Lazy Loading**: Integration with a PSR-11 container to instantiate listeners only when they are about to be called. + +--- + +## 4. Discovery & Registration +Mechanisms to register listeners without manual configuration. + +- **Attribute Discovery**: + - Support `#[AsEventListener(event: string, priority: int)]`. + - Support `#[AsSubscriber]`. +- **Listener Discovery Service**: + - Scans directories for classes with Beacon attributes. + - Integrates with PSR-16 cache to avoid scanning on every request. +- **Manual Registration**: + - `addListener(string $event, callable $listener, int $priority = 0)` + - `addSubscriber(SubscriberInterface $subscriber)` + +--- + +## 5. Event Objects & Context +Enhancing the standard event pattern. + +- **Event Envelope**: + - A wrapper to carry immutable metadata (e.g., `request_id`, `user_id`, `timestamp`). + - Uses a `ContextMap` for type-safe metadata access. +- **Stoppable Trait**: Provide `CanStopPropagation` trait for easy implementation of `StoppableEventInterface`. + +--- + +## 6. Testing Utilities +- **EventCollector**: A listener that gathers all dispatched events for assertions. +- **PHPUnit Assertions**: Traits for `assertEventDispatched`, `assertEventNotDispatched`, and `assertListenerCalled`. diff --git a/composer.json b/composer.json new file mode 100644 index 0000000..8eea3c5 --- /dev/null +++ b/composer.json @@ -0,0 +1,39 @@ +{ + "name": "getphred/beacon", + "description": "Enterprise-grade PSR-14 event management engine for the Phred Framework.", + "license": "MIT", + "type": "library", + "authors": [ + { + "name": "Phred Team", + "email": "team@getphred.com" + } + ], + "require": { + "php": "^8.2", + "getphred/beacon-contracts": "dev-master", + "psr/simple-cache": "^3.0", + "psr/container": "^2.0" + }, + "require-dev": { + "phpunit/phpunit": "^10.0" + }, + "autoload": { + "psr-4": { + "Phred\\Beacon\\": "src/" + } + }, + "autoload-dev": { + "psr-4": { + "Phred\\Beacon\\Tests\\": "tests/" + } + }, + "repositories": [ + { + "type": "path", + "url": "../BeaconContracts" + } + ], + "minimum-stability": "dev", + "prefer-stable": true +} diff --git a/phpunit.xml b/phpunit.xml new file mode 100644 index 0000000..e71ab58 --- /dev/null +++ b/phpunit.xml @@ -0,0 +1,20 @@ + + + + + tests/Unit + + + tests/Integration + + + + + src + + + diff --git a/src/CanStopPropagation.php b/src/CanStopPropagation.php new file mode 100644 index 0000000..9a4c0d2 --- /dev/null +++ b/src/CanStopPropagation.php @@ -0,0 +1,39 @@ +isPropagationStopped = true; + } + + /** + * Returns true if propagation was stopped. + * + * @return bool + */ + public function isPropagationStopped(): bool + { + return $this->isPropagationStopped; + } +} diff --git a/src/EventDispatcher.php b/src/EventDispatcher.php new file mode 100644 index 0000000..1b8a153 --- /dev/null +++ b/src/EventDispatcher.php @@ -0,0 +1,97 @@ +pipeline = $pipeline ?? new MiddlewarePipeline(); + } + + /** + * Dispatches an event to all relevant listeners. + * + * @template T of object + * @param T $event The object to process. + * @return T The event object after listeners have processed it. + */ + public function dispatch(object $event): object + { + return $this->pipeline->execute($event, function(object $event): object { + $listeners = $this->listenerProvider->getListenersForEvent($event); + + foreach ($listeners as $listener) { + // Check if propagation has been stopped (PSR-14) + if ($event instanceof \Psr\EventDispatcher\StoppableEventInterface && $event->isPropagationStopped()) { + break; + } + + try { + // Call the listener + $listener($event); + } catch (Throwable $e) { + if ($this->resiliencyMode === ResiliencyMode::FAIL_FAST) { + throw $e; + } + + // Reporting the failure by dispatching an internal event + $this->dispatch(new DispatchFailed($e, $listener, $event)); + } + } + + return $event; + }); + } + + /** + * Adds a global middleware to the dispatcher. + * + * @param DispatchMiddlewareInterface $middleware + * @return void + */ + public function addMiddleware(DispatchMiddlewareInterface $middleware): void + { + $this->pipeline->add($middleware); + } + + /** + * Sets the resiliency mode for the dispatcher. + * + * @param ResiliencyMode $mode + * @return void + */ + public function setResiliencyMode(ResiliencyMode $mode): void + { + $this->resiliencyMode = $mode; + } +} diff --git a/src/ListenerProvider.php b/src/ListenerProvider.php new file mode 100644 index 0000000..d0f0f21 --- /dev/null +++ b/src/ListenerProvider.php @@ -0,0 +1,212 @@ +> List of listeners. + */ + private array $listeners = []; + + /** + * @var bool Whether to resolve listeners for interfaces. + */ + private bool $resolveInterfaces = true; + + /** + * @var array Cache for compiled wildcard regexes. + */ + private array $regexCache = []; + + /** + * @var ContainerInterface|null Optional container for lazy-loading. + */ + private ?ContainerInterface $container = null; + + /** + * @param ContainerInterface|null $container + */ + public function __construct(?ContainerInterface $container = null) + { + $this->container = $container; + } + + /** + * Resolves listeners for the given event. + * + * @param object $event The event object. + * @return iterable An iterable of callables that should handle the event. + */ + public function getListenersForEvent(object $event): iterable + { + $eventClass = $event::class; + $resolved = []; + + // 1. Resolve by exact class, parents, and interfaces + foreach ($this->getClassesToResolve($eventClass) as $class) { + if (isset($this->listeners[$class])) { + foreach ($this->listeners[$class] as $listenerData) { + $resolved[] = $listenerData; + } + } + } + + // 2. Resolve by wildcards + foreach ($this->listeners as $pattern => $patternListeners) { + if ($this->isWildcard($pattern) && $this->matchesWildcard($pattern, $eventClass)) { + foreach ($patternListeners as $listenerData) { + $resolved[] = $listenerData; + } + } + } + + // 3. Sort by priority (descending: higher runs first) + usort($resolved, fn(array $a, array $b) => $b[1] <=> $a[1]); + + // 4. Transform to callables (handle lazy-loading and instantiation) + return array_map(function(array $data) { + $listener = $data[0]; + + if (is_callable($listener)) { + return $listener; + } + + // Handle [ServiceId, Method] or ServiceId (with __invoke) + if (is_array($listener) && count($listener) === 2) { + $service = $this->container?->get($listener[0]) ?? (class_exists($listener[0]) ? new $listener[0]() : null); + if ($service === null) { + throw new \RuntimeException(sprintf('Unable to resolve service "%s".', $listener[0])); + } + return [$service, $listener[1]]; + } + + if (is_string($listener)) { + $service = $this->container?->get($listener) ?? (class_exists($listener) ? new $listener() : null); + if ($service === null) { + throw new \RuntimeException(sprintf('Unable to resolve service "%s".', $listener)); + } + return is_callable($service) ? $service : [$service, '__invoke']; + } + + throw new \RuntimeException('Unable to resolve listener to a callable.'); + }, $resolved); + } + + /** + * Manually registers a listener for an event class or pattern. + * + * @param string $eventClassOrPattern The FQCN or a wildcard pattern (e.g., 'App.User.*'). + * @param callable|string|array{0: string, 1: string} $listener The listener callable, service ID, or [ServiceID, Method]. + * @param int $priority The priority (higher runs first). + * @return void + */ + public function addListener(string $eventClassOrPattern, callable|string|array $listener, int $priority = 0): void + { + $this->listeners[$eventClassOrPattern][] = [$listener, $priority]; + } + + /** + * Registers a subscriber. + * + * @param SubscriberInterface|string $subscriber The subscriber instance or service ID. + * @return void + */ + public function addSubscriber(SubscriberInterface|string $subscriber): void + { + if (is_string($subscriber)) { + /** @var SubscriberInterface $instance */ + $instance = $this->container?->get($subscriber) ?? (class_exists($subscriber) ? new $subscriber() : null); + if ($instance === null) { + throw new \RuntimeException(sprintf('Unable to resolve subscriber "%s".', $subscriber)); + } + $serviceId = $subscriber; + } else { + $instance = $subscriber; + $serviceId = null; + } + + foreach ($instance::getSubscribedEvents() as $event => $params) { + if (is_string($params)) { + $method = $params; + $priority = 0; + } else { + $method = $params[0]; + $priority = $params[1] ?? 0; + } + + $listener = $serviceId !== null ? [$serviceId, $method] : [$instance, $method]; + $this->addListener($event, $listener, $priority); + } + } + + /** + * Enables or disables interface resolution for performance. + * + * @param bool $shouldResolve + * @return void + */ + public function setResolveInterfaces(bool $shouldResolve): void + { + $this->resolveInterfaces = $shouldResolve; + } + + /** + * Gets all classes and interfaces to resolve for the event. + * + * @param string $eventClass + * @return array + */ + private function getClassesToResolve(string $eventClass): array + { + $classes = class_parents($eventClass); + $classes[] = $eventClass; + + if ($this->resolveInterfaces) { + $classes = array_merge($classes, class_implements($eventClass)); + } + + return array_unique($classes); + } + + /** + * Checks if a string is a wildcard pattern. + * + * @param string $pattern + * @return bool + */ + private function isWildcard(string $pattern): bool + { + return str_contains($pattern, '*'); + } + + /** + * Checks if an event class matches a wildcard pattern. + * + * @param string $pattern + * @param string $eventClass + * @return bool + */ + private function matchesWildcard(string $pattern, string $eventClass): bool + { + if (!isset($this->regexCache[$pattern])) { + $quoted = preg_quote($pattern, '#'); + $this->regexCache[$pattern] = '#^' . str_replace('\\*', '.*', $quoted) . '$#i'; + } + + return (bool) preg_match($this->regexCache[$pattern], $eventClass); + } +} diff --git a/tests/Unit/CoreDispatcherTest.php b/tests/Unit/CoreDispatcherTest.php new file mode 100644 index 0000000..8613cad --- /dev/null +++ b/tests/Unit/CoreDispatcherTest.php @@ -0,0 +1,67 @@ +addListener($event::class, function($e) { + $e->count++; + }); + + $dispatchedEvent = $dispatcher->dispatch($event); + + $this->assertSame($event, $dispatchedEvent); + $this->assertEquals(1, $event->count); + } + + public function test_it_respects_stoppable_events(): void + { + $provider = new ListenerProvider(); + $dispatcher = new EventDispatcher($provider); + + $event = new class implements StoppableEventInterface { + use CanStopPropagation; + public int $count = 0; + }; + + $provider->addListener($event::class, function($e) { + $e->count++; + $e->stopPropagation(); + }); + + $provider->addListener($event::class, function($e) { + $e->count++; // Should not run + }); + + $dispatcher->dispatch($event); + + $this->assertEquals(1, $event->count); + $this->assertTrue($event->isPropagationStopped()); + } + + public function test_it_returns_the_event_object(): void + { + $provider = new ListenerProvider(); + $dispatcher = new EventDispatcher($provider); + $event = new \stdClass(); + + $result = $dispatcher->dispatch($event); + + $this->assertSame($event, $result); + } +}