Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: Add possibility to handle Event (Register/Listener) with AsListener #221

Merged
merged 2 commits into from
Jan 10, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

## Not released yet

* Add `AsListener` attribute to register a task as event listener
* Add `force` argument to `fingerprint()` method to force run the callable, even if fingerprint is same
* Allow to override `AsTask` and `AsContext` attributes
* Add `wait_for()`, `wait_for_port()`, `wait_for_url()`, `wait_for_http_status()` functions
Expand Down
2 changes: 1 addition & 1 deletion bin/generate-tests.php
Original file line number Diff line number Diff line change
Expand Up @@ -91,7 +91,7 @@

add_test(['parallel:sleep', '--sleep5', '0', '--sleep7', '0', '--sleep10', '0'], 'ParallelSleepTest');
add_test(['context:context', '--context', 'run'], 'ContextContextRunTest');
add_test(['context:context', '--context', 'my_default', '-vvv'], 'ContextContextMyDefaultTest');
add_test(['context:context', '--context', 'my_default', '-vv'], 'ContextContextMyDefaultTest');
lyrixx marked this conversation as resolved.
Show resolved Hide resolved
add_test(['context:context', '--context', 'no_no_exist'], 'ContextContextDoNotExistTest');
add_test(['context:context', '--context', 'production'], 'ContextContextProductionTest');
add_test(['context:context', '--context', 'path'], 'ContextContextPathTest');
Expand Down
39 changes: 39 additions & 0 deletions doc/15-event-listener.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
# Event Listening in PHP

Implementation for event listening using custom events and listeners. The
primary purpose is to allow custom logic to be executed at various points in the
application lifecycle.

- [Event Listening in PHP](#event-listening-in-php)
- [Listener Implementation](#listener-implementation)
- [Provided Events (ordered by dispatch priority)](#provided-events-ordered-by-dispatch-priority)

---

## Listener Implementation

The listener is implemented using the `AsListener` attribute to specify the
event it listens to and the priority.

```php
#[AsListener(event: AfterApplicationInitializationEvent::class, priority: 1)]
#[AsListener(event: AfterExecuteTaskEvent::class, priority: 1)] // Multiple events can be specified
function my_event_listener(AfterApplicationInitializationEvent|AfterExecuteTaskEvent $event): void
{
// Custom logic to handle the events
}
```

---

## Provided Events (ordered by dispatch priority)

* `Castor\Event\AfterApplicationInitializationEvent`: This event is triggered
after the application has been initialized. It provides access to the
`Application` instance and an array of `TaskDescriptor` objects;

* `Castor\Event\BeforeExecuteTaskEvent`: This event is triggered before
executing a task. It provides access to the `TaskCommand` instance;

* `Castor\Event\AfterExecuteTaskEvent`: This event is triggered after executing
a task. It provides access to the `TaskCommand` instance.
51 changes: 51 additions & 0 deletions examples/event-listener.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
<?php

namespace event_listener;

use Castor\Attribute\AsListener;
use Castor\Attribute\AsTask;
use Castor\Event\AfterExecuteTaskEvent;
use Castor\Event\BeforeExecuteTaskEvent;

use function Castor\io;

#[AsTask(description: 'Task that need ensure the environment is set')]
function my_task(): void
{
io()->writeln('Hello from task!');
}

#[AsListener(event: BeforeExecuteTaskEvent::class, priority: 1)]
function my_listener(BeforeExecuteTaskEvent $event): void
{
$taskName = $event->task->getName();

if ('event-listener:my-task' === $taskName) {
io()->writeln('Hello from listener! (lower priority)');
}
}

#[AsListener(event: BeforeExecuteTaskEvent::class, priority: 10)]
function my_listener_that_has_higher_priority(BeforeExecuteTaskEvent|AfterExecuteTaskEvent $event): void
{
$taskName = $event->task->getName();

if ('event-listener:my-task' === $taskName) {
io()->writeln('Hello from listener! (higher priority) before task execution');
}
}

#[AsListener(event: BeforeExecuteTaskEvent::class)]
#[AsListener(event: AfterExecuteTaskEvent::class)]
function my_listener_that_has_higher_priority_for_multiple_events(BeforeExecuteTaskEvent|AfterExecuteTaskEvent $event): void
{
$taskName = $event->task->getName();

if ('event-listener:my-task' === $taskName && $event instanceof BeforeExecuteTaskEvent) {
io()->writeln('Ola from listener! I am listening to multiple events but only showing only for BeforeExecuteTaskEvent');
}

if ('event-listener:my-task' === $taskName && $event instanceof AfterExecuteTaskEvent) {
io()->writeln('Ola from listener! I am listening to multiple events but only showing only for AfterExecuteTaskEvent');
}
}
13 changes: 13 additions & 0 deletions src/Attribute/AsListener.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
<?php

namespace Castor\Attribute;

#[\Attribute(\Attribute::TARGET_FUNCTION | \Attribute::IS_REPEATABLE)]
class AsListener
{
public function __construct(
public readonly string $event,
public readonly int $priority = 0,
) {
}
}
17 changes: 17 additions & 0 deletions src/Console/Application.php
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,11 @@
use Castor\Context;
use Castor\ContextDescriptor;
use Castor\ContextRegistry;
use Castor\Event\AfterApplicationInitializationEvent;
use Castor\EventDispatcher;
use Castor\FunctionFinder;
use Castor\GlobalHelper;
use Castor\ListenerDescriptor;
use Castor\Monolog\Processor\ProcessProcessor;
use Castor\PlatformUtil;
use Castor\SectionOutput;
Expand All @@ -25,6 +28,7 @@
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
use Symfony\Component\VarDumper\Cloner\AbstractCloner;
use Symfony\Contracts\Cache\ItemInterface;
use Symfony\Contracts\HttpClient\Exception\ExceptionInterface as HttpExceptionInterface;

Expand All @@ -43,13 +47,17 @@ public function __construct(
private readonly ContextRegistry $contextRegistry = new ContextRegistry(),
private readonly StubsGenerator $stubsGenerator = new StubsGenerator(),
private readonly FunctionFinder $functionFinder = new FunctionFinder(),
private readonly EventDispatcher $eventDispatcher = new EventDispatcher(),
) {
if (!class_exists(\RepackedApplication::class)) {
$this->add(new RepackCommand());
}

$this->setCatchErrors(true);

AbstractCloner::$defaultCasters[self::class] = ['Symfony\Component\VarDumper\Caster\StubCaster', 'cutInternals'];
AbstractCloner::$defaultCasters[AfterApplicationInitializationEvent::class] = ['Symfony\Component\VarDumper\Caster\StubCaster', 'cutInternals'];

parent::__construct(static::NAME, static::VERSION);
}

Expand All @@ -60,6 +68,7 @@ public function doRun(InputInterface $input, OutputInterface $output): int
$sectionOutput = new SectionOutput($output);

GlobalHelper::setApplication($this);
GlobalHelper::setEventDispatcher($this->eventDispatcher);
GlobalHelper::setInput($input);
GlobalHelper::setSectionOutput($sectionOutput);
GlobalHelper::setLogger(new Logger(
Expand Down Expand Up @@ -114,6 +123,12 @@ private function initializeApplication(InputInterface $input): array
$tasks[] = $function;
} elseif ($function instanceof ContextDescriptor) {
$this->contextRegistry->add($function);
} elseif ($function instanceof ListenerDescriptor && null !== $function->reflectionFunction->getClosure()) {
lyrixx marked this conversation as resolved.
Show resolved Hide resolved
$this->eventDispatcher->addListener(
$function->asListener->event,
$function->reflectionFunction->getClosure(),
$function->asListener->priority
);
}
}

Expand All @@ -134,6 +149,8 @@ private function initializeApplication(InputInterface $input): array
));
}

$this->eventDispatcher->dispatch(new AfterApplicationInitializationEvent($this, $tasks));

return $tasks;
}

Expand Down
13 changes: 10 additions & 3 deletions src/Console/Command/TaskCommand.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@
use Castor\Attribute\AsCommandArgument;
use Castor\Attribute\AsOption;
use Castor\Attribute\AsTask;
use Castor\Event\AfterExecuteTaskEvent;
use Castor\Event\BeforeExecuteTaskEvent;
use Castor\GlobalHelper;
use Castor\SluggerHelper;
use Symfony\Component\Console\Command\Command;
Expand All @@ -25,8 +27,8 @@ class TaskCommand extends Command implements SignalableCommandInterface
private array $argumentsMap = [];

public function __construct(
private readonly AsTask $taskAttribute,
private readonly \ReflectionFunction $function,
public readonly AsTask $taskAttribute,
public readonly \ReflectionFunction $function,
) {
$this->setDescription($taskAttribute->description);
$this->setAliases($taskAttribute->aliases);
Expand Down Expand Up @@ -143,11 +145,16 @@ protected function execute(InputInterface $input, OutputInterface $output): int
}

try {
$function = $this->function->getName();
$function = $this->function->getClosure();
lyrixx marked this conversation as resolved.
Show resolved Hide resolved
if (!\is_callable($function)) {
throw new \LogicException('The function is not a callable.');
}

GlobalHelper::getEventDispatcher()->dispatch(new BeforeExecuteTaskEvent($this));

$result = $function(...$args);

GlobalHelper::getEventDispatcher()->dispatch(new AfterExecuteTaskEvent($this, $result));
} catch (\Error $e) {
$castorFunctions = array_filter(get_defined_functions()['user'], fn (string $functionName) => str_starts_with($functionName, 'castor\\'));
$castorFunctionsWithoutNamespace = array_map(fn (string $functionName) => substr($functionName, \strlen('castor\\')), $castorFunctions);
Expand Down
16 changes: 16 additions & 0 deletions src/Event/AfterApplicationInitializationEvent.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
<?php

namespace Castor\Event;

use Castor\Console\Application;
use Castor\TaskDescriptor;

class AfterApplicationInitializationEvent
{
public function __construct(
public readonly Application $application,
/** @var array<TaskDescriptor> $tasks */
public array &$tasks,
) {
}
}
14 changes: 14 additions & 0 deletions src/Event/AfterExecuteTaskEvent.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
<?php

namespace Castor\Event;

use Castor\Console\Command\TaskCommand;

class AfterExecuteTaskEvent
{
public function __construct(
public readonly TaskCommand $task,
public readonly mixed $result,
) {
}
}
13 changes: 13 additions & 0 deletions src/Event/BeforeExecuteTaskEvent.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
<?php

namespace Castor\Event;

use Castor\Console\Command\TaskCommand;

class BeforeExecuteTaskEvent
{
public function __construct(
public readonly TaskCommand $task,
) {
}
}
68 changes: 68 additions & 0 deletions src/EventDispatcher.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
<?php

namespace Castor;

use Symfony\Component\EventDispatcher\EventDispatcher as SymfonyEventDispatcher;
use Symfony\Component\EventDispatcher\EventDispatcherInterface;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;

class EventDispatcher implements EventDispatcherInterface
{
public function __construct(
private EventDispatcherInterface $eventDispatcher = new SymfonyEventDispatcher(),
) {
}

public function dispatch(object $event, string $eventName = null): object
{
log("Dispatching event {$eventName}", 'debug', [
'event' => $event,
]);

return $this->eventDispatcher->dispatch($event, $eventName);
}

public function addListener(string $eventName, callable $listener, int $priority = 0): void
{
log("Adding listener for event {$eventName}", 'debug', [
'listener' => $listener,
'priority' => $priority,
]);

$this->eventDispatcher->addListener($eventName, $listener, $priority);
}

public function removeListener(string $eventName, callable $listener): void
{
log("Removing listener for event {$eventName}", 'debug', [
'listener' => $listener,
]);

$this->eventDispatcher->removeListener($eventName, $listener);
}

public function addSubscriber(EventSubscriberInterface $subscriber): void
{
$this->eventDispatcher->addSubscriber($subscriber);
}

public function removeSubscriber(EventSubscriberInterface $subscriber): void
{
$this->eventDispatcher->removeSubscriber($subscriber);
}

public function getListeners(string $eventName = null): array
{
return $this->eventDispatcher->getListeners($eventName);
}

public function getListenerPriority(string $eventName, callable $listener): ?int
{
return $this->eventDispatcher->getListenerPriority($eventName, $listener);
}

public function hasListeners(string $eventName = null): bool
{
return $this->eventDispatcher->hasListeners($eventName);
}
}
Loading