feat: dispatch aggregate-DSL command groups over the command API (release 1.4.0)#32
Merged
Merged
Conversation
`CommandGroup#to_h` previously returned the NESTED per-context/per-subject form (mirroring legacy `Yes::Core::Commands::Group#to_h`). That broke round-tripping: `Class.new(cmd.to_h)` produced a group whose `payload` was nested — but the aggregate's group method expects the FLAT form. This surfaces wherever the command travels through code that reconstructs via `cmd.class.new(cmd.to_h.merge(...))`, notably: - `Yes::Core::ActiveJobSerializers::CommandGroupSerializer#serialize` → `#deserialize` round-trip, - `Yes::Command::Api::V1::CommandsController#add_metadata` round-trip for command groups. Both of those now produce a group with the correct flat `payload`. The legacy stateless `Yes::Core::Commands::Group` is untouched. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…ssor `Yes::Core::Commands::Processor#run_command` calls `aggregate.public_send(name, payload, guards:)` to invoke the aggregate's command method. Previously it always passed `cmd.to_h`, which for a `CommandGroup` returns the nested per-context/per-subject payload. Aggregate group methods expect the FLAT form (mirroring direct Ruby invocation: `aggregate.create_apprenticeship(company_id:, user_id:, …)`). Use `cmd.payload` (flat input minus reserved keys) when the command is a `CommandGroup`; keep `cmd.to_h` for regular commands (Dry::Struct's `to_h` is already the flat attribute hash). Also extracts `Processor#reinstantiate_with_reserved_keys` from the `commands.map!` step that injects origin/batch_id. The legacy `cmd.class.new(cmd.to_h.merge(...))` pattern round-tripped through the nested form for groups and produced a group whose `payload` was nested — breaking subsequent dispatch. The new helper round-trips groups through their FLAT `payload` and uses `to_h` for regular commands. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Makes HTTP `POST /v1/commands` requests targeting an aggregate-DSL `command_group` route end-to-end the same way regular commands do. Three small changes line up with the legacy stateless `Group` pattern: 1. **Deserializer** — add a `command_group_v2_class` candidate matching `<Context>::<Subject>::CommandGroups::<Name>::Command` (the path the `command_group` DSL macro generates). Tried after the V2 command path and before the legacy top-level group fallback. 2. **CommandsController#expand_commands** — also unwrap `Yes::Core::Commands::CommandGroup` (in addition to the legacy `Group`) so its sub-commands flow through `BatchAuthorizer` / `BatchValidator`. Each sub-command's existing per-command Authorizer and Validator run individually — no group-level authorizer or validator class needs to exist. The wrapped originals (`deserialize_commands`) still go to `command_bus.call`, so the Processor dispatches each group as a single atomic unit. 3. Test fixtures — add `Dummy::Activity::CommandGroups::DoTwoThings::Command` plus a method-missing path on `Dummy::Activity::Aggregate` that recognises group methods and returns a `CommandGroupResponse`. Register the dummy sub-commands in the configuration registry so `CommandGroup#sub_command_classes` can resolve them. 4. Specs — Deserializer spec exercises the new resolution candidate. Request spec posts a `DoTwoThings` action, asserts the group method is dispatched once on the aggregate with the FLAT payload (not the nested form), and that sub-command authorizers are invoked. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds a short note at the end of the Command Groups section explaining that groups can be invoked over HTTP exactly like regular commands: same request shape, group name as `command`, flat payload in `data`. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
After the earlier fix that makes `CommandGroup#to_h` return the flat form (payload merged with reserved keys, round-trippable through `Class.new(to_h)`), the `cmd.is_a?(CommandGroup)` branch in `Processor#run_command` is redundant — `cmd.to_h` already produces the shape the aggregate's group method expects. Bonus: dropping `reinstantiate_with_reserved_keys` and going back to the direct `cmd.class.new(cmd.to_h.merge(origin:, batch_id:))` means CommandGroups now ALSO get origin/batch_id propagated through the reserved-key channel — previously the helper used `cmd.payload` which stripped the reserved keys before merging, losing the original command_id, metadata, and transaction. Updates the related Processor and request-spec test descriptions to drop the now-misleading `cmd.payload` references. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
`Yes::Core::ActiveJobSerializers::CommandGroupSerializer#serialize?` only matched the legacy `Yes::Core::Commands::Group`, so the new aggregate-DSL `Yes::Core::Commands::CommandGroup` instances would fall through to the default ActiveJob serializer, which can't round-trip a non-trivial object. Match both classes. Both round-trip cleanly via the `to_h` / `Class.new(symbolized_hash)` pair the serializer already uses (the aggregate-DSL form's `to_h` is the flat payload merged with reserved keys after the earlier fix). Adds a dedicated serializer spec covering: - `#serialize?` matches both group flavours and rejects regular commands, - the aggregate-DSL group round-trips: class, payload, reserved keys, and sub-command set are all preserved. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two additional test areas suggested by code review: 1. **Reserved-key propagation through Processor#perform** for command groups. The simpler `cmd.class.new(cmd.to_h.merge(origin:, batch_id:))` re-instantiation path (introduced by the previous refactor) is supposed to preserve origin, batch_id, command_id, metadata, and transaction through to the aggregate's group method. New processor spec context exercises each of these. 2. **End-to-end ActiveJob round-trip** for command groups via the Command API. Adds a `running async (ActiveJob round-trip)` context to the aggregate-DSL command-group request spec that switches the queue adapter to `:test`, asserts a CommandGroupSerializer-tagged argument is enqueued, and uses `perform_enqueued_jobs` to actually run the queued Processor job — proving the serializer round-trips end-to-end and the dispatched group method on the (stubbed) aggregate receives the equivalent command. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The Command API dispatches an aggregate-DSL CommandGroup to the Processor un-expanded, so `ensure_guard_evaluators_exist?` checks the group itself. A group registers its guard evaluator under the `:command_group_guard_evaluator` registry type, but `Processor#guard_evaluator_exists?` consulted only `:guard_evaluator`, so a valid group raised `UnregisteredCommand` on dispatch (the async/non-stubbed path — the existing group specs stub the lookup, so it went uncaught). Add `Configuration#command_group_guard_evaluator_class` (pairing with the existing `register_command_group_guard_evaluator_class`) and route CommandGroup instances to it via a new `guard_evaluator_class_for` helper in the Processor. Covered by a Processor spec that dispatches a real DSL command_group through `perform` against the real (un-stubbed) registry, plus a Configuration unit spec. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- yes-core: resolve guard evaluators for aggregate-DSL command groups, flat CommandGroup#to_h round-trip, serialize groups for the async command queue - yes-command-api: dispatch aggregate-DSL command groups over the HTTP command API Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…he dummy group The Processor now resolves a CommandGroup's guard evaluator via Configuration#command_group_guard_evaluator_class. The request-spec dummy group is hand-rolled (registers no evaluator), so stub the new lookup the same way guard_evaluator_class is already stubbed for the dummy single commands. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Lands aggregate-DSL command-group dispatch over the HTTP command API — the fix originally written as #29, which was merged into a stranded feature branch (
feat/command-group-aggregate-dsl) instead ofmainand so never took effect. This re-applies #29's 7 commits onto currentmain, plus a missing piece #29 left out, and releases 1.4.0.What's included
Cherry-picked from #29 (7 commits):
yes-command-apideserializer resolves the<Context>::<Subject>::CommandGroups::<Name>::Commandconvention (generated by thecommand_groupmacro).expand_commandsunwraps aCommandGroupfor batch authorization/validation, while still passing the wrapped group to the bus so the Processor dispatches it as one atomic unit.CommandGroup#to_hreturns the flat payload + reserved keys (round-trips throughClass.new(to_h)).CommandGroupSerializerserializes aggregate-DSL groups (async queue path).Processor#run_commandrefactor — dropsreinstantiate_with_reserved_keysand theis_a?(CommandGroup)branch.New in this PR (the gap #29 missed):
Configuration#command_group_guard_evaluator_class+ group-awareProcessor#guard_evaluator_exists?. Acommand_groupregisters its guard evaluator under the:command_group_guard_evaluatorregistry type, but the existence check consulted only:guard_evaluator— so a real DSL group dispatched through the bus raisedUnregisteredCommand. feat(yes-command-api): dispatch aggregate-DSL command groups #29's specs missed this (they stub the guard-evaluator lookup / use plain dummy classes). Added a Processor spec that dispatches a real DSL group (Test::PersonalInfo'supdate_personal_info_group) throughperformagainst the un-stubbed registry, plus a Configuration unit spec. Verified RED→GREEN.Heads-up for consumers (authorization model)
The controller now expands an aggregate-DSL
CommandGroupfor authorization, so each sub-command is authorized individually (the legacy statelessGroupalready worked this way). A consumer exposing such a group over HTTP must ensure each sub-command's Cerbos action is granted.Verification
yes-corefull suite: 1184 examples, 0 failures (local).UnregisteredCommand) → GREEN.🤖 Generated with Claude Code