From 00a64c5871b5b73a386d7062bba05e51aa4d07e5 Mon Sep 17 00:00:00 2001 From: magdev Date: Sun, 3 May 2026 20:25:26 +0200 Subject: [PATCH] v0.2.0 (7/N): make:bridge:event maker MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements PLAN.md §8's third makers-table row. Single-command path from a PHP domain event to a QML signal-handler: - src/Event/Event.php — readonly value object stub - src/EventSubscriber/Subscriber.php — listens to the event, republishes via PublisherInterface on app://event/ with op:"event" - {qml_path}/EventHandler.qml — MercureClient bound to the topic, re-emits the envelope's data as a typed signal Stub uses an `array $payload` field so the user can substitute typed properties for whatever shape they need. Subscriber example uses the PublisherInterface contract from chunk 1; QML stub uses MercureClient + BackendConnection both already shipping. Wired into services.yaml's when@dev block (autoconfigure picks up maker.command tag, same pattern as existing BridgeResourceMaker / BridgeWindowMaker). Three new snapshot baselines plus a snapshot runner extension exercising the new maker against the same Todo / TodoCompleted naming the existing baselines use. End-to-end verified locally: maker output matches baselines, dev container compiles, listing make:bridge:* shows the new command. Co-Authored-By: Claude Opus 4.7 (1M context) --- CHANGELOG.md | 1 + framework/php/config/services.yaml | 4 + framework/php/src/Maker/BridgeEventMaker.php | 140 ++++++++++++++++++ .../src/Maker/templates/EventClass.tpl.php | 22 +++ .../Maker/templates/EventHandlerQml.tpl.php | 31 ++++ .../Maker/templates/EventSubscriber.tpl.php | 39 +++++ .../php/tests/snapshot/TodoCompletedEvent.php | 21 +++ .../snapshot/TodoCompletedEventHandler.qml | 31 ++++ .../snapshot/TodoCompletedSubscriber.php | 37 +++++ framework/php/tests/snapshot/run.sh | 16 +- 10 files changed, 336 insertions(+), 6 deletions(-) create mode 100644 framework/php/src/Maker/BridgeEventMaker.php create mode 100644 framework/php/src/Maker/templates/EventClass.tpl.php create mode 100644 framework/php/src/Maker/templates/EventHandlerQml.tpl.php create mode 100644 framework/php/src/Maker/templates/EventSubscriber.tpl.php create mode 100644 framework/php/tests/snapshot/TodoCompletedEvent.php create mode 100644 framework/php/tests/snapshot/TodoCompletedEventHandler.qml create mode 100644 framework/php/tests/snapshot/TodoCompletedSubscriber.php diff --git a/CHANGELOG.md b/CHANGELOG.md index 1324261..049b4f0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,7 @@ This section tracks work landing on `dev` toward **v0.2.0** (next minor; pre-1.0 - **`Maker\Support\NameInput`** — shared interactive name prompt. All three `make:bridge:*` makers (`resource`, `command`, `window`) re-implemented the same "prompt, trim, ucfirst, reject empty" closure inline; collapsed into one call site so empty-argument and validation behaviour stay in lockstep. - **`Maker\Support\Naming`** — `camelTo($name, $separator)` helper. Replaces inline `preg_replace('/(?Dto` + `UpdateDto` under `src/Dto/` alongside the controller, and the controller dispatches via `#[MapRequestPayload]`. Closes the input-validation gap from the audit: malformed JSON, missing required fields, or `#[Assert\NotBlank]` violations now produce RFC 7807 `application/problem+json` automatically (Symfony's `RequestPayloadValueResolver`) — no more `if (isset($data['title']))` boilerplate, no silent type coercion. Update DTOs use nullable defaults so PATCH callers send only the fields they want changed. Without `--with-dto` the legacy template still ships unchanged. Maker fails loud if `symfony/validator` isn't autoloadable. Skeleton + example/todo composer.json pull `symfony/validator` so scaffolded apps work out of the box. Snapshot test exercises both modes. +- **`make:bridge:event ` maker.** Generates a domain-event class (`src/Event/Event.php`, readonly value object), a subscriber (`src/EventSubscriber/Subscriber.php`) that republishes via `PublisherInterface` on `app://event/`, and a QML stub (`{qml_path}/EventHandler.qml`) that listens via `MercureClient` and re-emits as a typed `signal`. Closes the third row of PLAN.md §8's makers table; pairs with the existing `make:bridge:resource` / `command` / `window` makers so domain events have a one-command path from PHP through to QML. ### Changed diff --git a/framework/php/config/services.yaml b/framework/php/config/services.yaml index b8aabae..79fe629 100644 --- a/framework/php/config/services.yaml +++ b/framework/php/config/services.yaml @@ -41,3 +41,7 @@ when@dev: PhpQml\Bridge\Maker\BridgeWindowMaker: arguments: $qmlPath: '%bridge.qml_path%' + + PhpQml\Bridge\Maker\BridgeEventMaker: + arguments: + $qmlPath: '%bridge.qml_path%' diff --git a/framework/php/src/Maker/BridgeEventMaker.php b/framework/php/src/Maker/BridgeEventMaker.php new file mode 100644 index 0000000..54dd5bf --- /dev/null +++ b/framework/php/src/Maker/BridgeEventMaker.php @@ -0,0 +1,140 @@ +` — generates the three files that wire a + * domain event onto the bridge's `app://event/{name}` Mercure topic: + * + * - src/Event/Event.php — readonly event value object + * - src/EventSubscriber/Subscriber.php — listens, republishes via PublisherInterface + * - {qml_path}/EventHandler.qml — QML stub that re-emits as a typed signal + * + * Topic shape per PLAN.md §4: `app://event/`. The + * generated stub publishes envelopes with `op: "event"` so QML clients + * can dispatch on op alongside `upsert` / `delete` / `replace`. + */ +final class BridgeEventMaker extends AbstractMaker +{ + public function __construct( + private readonly string $qmlPath = '../qml/', + ) { + } + + public static function getCommandName(): string + { + return 'make:bridge:event'; + } + + public static function getCommandDescription(): string + { + return 'Generate a domain event class + subscriber + QML handler stub.'; + } + + public function configureCommand(Command $command, InputConfiguration $inputConfig): void + { + $command + ->addArgument( + 'name', + InputArgument::OPTIONAL, + 'CamelCase event name (e.g. TodoCompleted, ImportFinished).', + ) + ->setHelp( + "Creates three files:\n\n" + ." • src/Event/Event.php — readonly event value object\n" + ." • src/EventSubscriber/Subscriber.php — republishes on app://event/\n" + ." • {qml_path}/EventHandler.qml — QML stub re-emitting as a typed signal\n\n" + ."Dispatch from PHP with \$dispatcher->dispatch(new Event([...])).\n" + ); + } + + public function interact(InputInterface $input, ConsoleStyle $io, Command $command): void + { + NameInput::askOrFail( + $input, + $io, + 'name', + 'Event name (CamelCase, e.g. TodoCompleted)?', + 'Event name cannot be empty.', + ); + } + + public function generate(InputInterface $input, ConsoleStyle $io, Generator $generator): void + { + $rawName = (string) $input->getArgument('name'); + $singular = ucfirst(Str::asCamelCase($rawName)); + $topic = Naming::camelTo($singular, '-'); + + $eventFqcn = $generator->createClassNameDetails( + $singular, + 'Event\\', + 'Event', + ); + $subscriberFqcn = $generator->createClassNameDetails( + $singular, + 'EventSubscriber\\', + 'Subscriber', + ); + + $vars = [ + 'singular' => $singular, + 'event_topic' => $topic, + 'event_short' => $eventFqcn->getShortName(), + 'event_fqcn' => $eventFqcn->getFullName(), + 'subscriber_short' => $subscriberFqcn->getShortName(), + 'subscriber_fqcn' => $subscriberFqcn->getFullName(), + 'handler_method' => 'on'.$singular, + 'signal_name' => lcfirst($singular), + ]; + + $generator->generateFile( + 'src/Event/'.$eventFqcn->getShortName().'.php', + __DIR__.'/templates/EventClass.tpl.php', + $vars, + ); + $generator->generateFile( + 'src/EventSubscriber/'.$subscriberFqcn->getShortName().'.php', + __DIR__.'/templates/EventSubscriber.tpl.php', + $vars, + ); + + $qmlTarget = rtrim($this->qmlPath, '/').'/'.$singular.'EventHandler.qml'; + $generator->generateFile( + $qmlTarget, + __DIR__.'/templates/EventHandlerQml.tpl.php', + $vars, + ); + + $generator->writeChanges(); + + $this->writeSuccessMessage($io); + $io->text([ + 'Next:', + " • Replace the payload array on {$eventFqcn->getShortName()} with typed properties.", + " • Dispatch via \$dispatcher->dispatch(new {$eventFqcn->getShortName()}(\$data)).", + " • Use {$singular}EventHandler.qml in your QML to receive the echo.", + ]); + } + + public function configureDependencies(DependencyBuilder $dependencies): void + { + $dependencies->addClassDependency(EventSubscriberInterface::class, 'symfony/event-dispatcher'); + $dependencies->addClassDependency(PublisherInterface::class, 'php-qml/bridge'); + } +} diff --git a/framework/php/src/Maker/templates/EventClass.tpl.php b/framework/php/src/Maker/templates/EventClass.tpl.php new file mode 100644 index 0000000..4d4bf6e --- /dev/null +++ b/framework/php/src/Maker/templates/EventClass.tpl.php @@ -0,0 +1,22 @@ + + +declare(strict_types=1); + +namespace App\Event; + +/** + * Domain event published on `app://event/` by + * . + * + * Auto-generated stub — replace the `payload` field with typed + * properties matching the event you actually fire. + */ +final readonly class + +{ + public function __construct( + /** @var array */ + public array $payload = [], + ) { + } +} diff --git a/framework/php/src/Maker/templates/EventHandlerQml.tpl.php b/framework/php/src/Maker/templates/EventHandlerQml.tpl.php new file mode 100644 index 0000000..f46524c --- /dev/null +++ b/framework/php/src/Maker/templates/EventHandlerQml.tpl.php @@ -0,0 +1,31 @@ +// Auto-generated by `bin/console make:bridge:event `. +// Listens for `app://event/` envelopes published by +// and re-emits them as a typed QML signal. +// +// Drop into a parent component and connect: +// +// EventHandler { +// on: function(payload) { console.log("hi", payload) } +// } + +import QtQuick +import PhpQml.Bridge + +Item { + id: handler + + /** Emitted when the bridge publishes app://event/. */ + signal (var payload) + + MercureClient { + baseUrl: BackendConnection.url + token: BackendConnection.token + topics: ["app://event/"] + + onUpdate: function(topic, envelope) { + if (topic === "app://event/") { + handler.(envelope.data) + } + } + } +} diff --git a/framework/php/src/Maker/templates/EventSubscriber.tpl.php b/framework/php/src/Maker/templates/EventSubscriber.tpl.php new file mode 100644 index 0000000..66ac879 --- /dev/null +++ b/framework/php/src/Maker/templates/EventSubscriber.tpl.php @@ -0,0 +1,39 @@ + + +declare(strict_types=1); + +namespace App\EventSubscriber; + +use App\Event\; +use PhpQml\Bridge\PublisherInterface; +use Symfony\Component\EventDispatcher\EventSubscriberInterface; + +/** + * Republishes on `app://event/`. + * Auto-generated alongside the event class — wire `payload` to whatever + * shape you want QML clients to receive in the envelope's `data` field. + */ +final readonly class + + implements EventSubscriberInterface +{ + public function __construct( + private PublisherInterface $publisher, + ) { + } + + public static function getSubscribedEvents(): array + { + return [ + ::class => '', + ]; + } + + public function ( $event): void + { + $this->publisher->publish('app://event/', [ + 'op' => 'event', + 'data' => $event->payload, + ]); + } +} diff --git a/framework/php/tests/snapshot/TodoCompletedEvent.php b/framework/php/tests/snapshot/TodoCompletedEvent.php new file mode 100644 index 0000000..d1ea004 --- /dev/null +++ b/framework/php/tests/snapshot/TodoCompletedEvent.php @@ -0,0 +1,21 @@ + */ + public array $payload = [], + ) { + } +} diff --git a/framework/php/tests/snapshot/TodoCompletedEventHandler.qml b/framework/php/tests/snapshot/TodoCompletedEventHandler.qml new file mode 100644 index 0000000..01653d9 --- /dev/null +++ b/framework/php/tests/snapshot/TodoCompletedEventHandler.qml @@ -0,0 +1,31 @@ +// Auto-generated by `bin/console make:bridge:event TodoCompleted`. +// Listens for `app://event/todo-completed` envelopes published by +// TodoCompletedSubscriber and re-emits them as a typed QML signal. +// +// Drop into a parent component and connect: +// +// TodoCompletedEventHandler { +// onTodoCompleted: function(payload) { console.log("hi", payload) } +// } + +import QtQuick +import PhpQml.Bridge + +Item { + id: handler + + /** Emitted when the bridge publishes app://event/todo-completed. */ + signal todoCompleted(var payload) + + MercureClient { + baseUrl: BackendConnection.url + token: BackendConnection.token + topics: ["app://event/todo-completed"] + + onUpdate: function(topic, envelope) { + if (topic === "app://event/todo-completed") { + handler.todoCompleted(envelope.data) + } + } + } +} diff --git a/framework/php/tests/snapshot/TodoCompletedSubscriber.php b/framework/php/tests/snapshot/TodoCompletedSubscriber.php new file mode 100644 index 0000000..408ae59 --- /dev/null +++ b/framework/php/tests/snapshot/TodoCompletedSubscriber.php @@ -0,0 +1,37 @@ + 'onTodoCompleted', + ]; + } + + public function onTodoCompleted(TodoCompletedEvent $event): void + { + $this->publisher->publish('app://event/todo-completed', [ + 'op' => 'event', + 'data' => $event->payload, + ]); + } +} diff --git a/framework/php/tests/snapshot/run.sh b/framework/php/tests/snapshot/run.sh index 10a9707..c227004 100755 --- a/framework/php/tests/snapshot/run.sh +++ b/framework/php/tests/snapshot/run.sh @@ -50,13 +50,17 @@ clear_outputs ( cd "$APP/symfony" \ && bin/console make:bridge:resource Todo --no-interaction >/dev/null \ && bin/console make:bridge:command MarkAllDone --no-interaction >/dev/null \ - && bin/console make:bridge:window Todo --no-interaction >/dev/null ) + && bin/console make:bridge:window Todo --no-interaction >/dev/null \ + && bin/console make:bridge:event TodoCompleted --no-interaction >/dev/null ) -check "$APP/symfony/src/Entity/Todo.php" "$SCRIPT_DIR/Todo.php" -check "$APP/symfony/src/Controller/TodoController.php" "$SCRIPT_DIR/TodoController.php" -check "$APP/qml/TodoList.qml" "$SCRIPT_DIR/TodoList.qml" -check "$APP/symfony/src/Controller/MarkAllDoneController.php" "$SCRIPT_DIR/MarkAllDoneController.php" -check "$APP/qml/TodoWindow.qml" "$SCRIPT_DIR/TodoWindow.qml" +check "$APP/symfony/src/Entity/Todo.php" "$SCRIPT_DIR/Todo.php" +check "$APP/symfony/src/Controller/TodoController.php" "$SCRIPT_DIR/TodoController.php" +check "$APP/qml/TodoList.qml" "$SCRIPT_DIR/TodoList.qml" +check "$APP/symfony/src/Controller/MarkAllDoneController.php" "$SCRIPT_DIR/MarkAllDoneController.php" +check "$APP/qml/TodoWindow.qml" "$SCRIPT_DIR/TodoWindow.qml" +check "$APP/symfony/src/Event/TodoCompletedEvent.php" "$SCRIPT_DIR/TodoCompletedEvent.php" +check "$APP/symfony/src/EventSubscriber/TodoCompletedSubscriber.php" "$SCRIPT_DIR/TodoCompletedSubscriber.php" +check "$APP/qml/TodoCompletedEventHandler.qml" "$SCRIPT_DIR/TodoCompletedEventHandler.qml" # ── Mode 2: --with-dto (re-runs make:bridge:resource only) ──────────── # The entity + QML output is byte-identical between modes; only the