eps90 / req2cmd-bundle
Extract command from a HTTP request and send it to the command bus like Tactician
Installs: 154
Dependents: 0
Suggesters: 0
Security: 0
Stars: 2
Watchers: 2
Forks: 0
Open Issues: 5
Type:symfony-bundle
Requires
- php: >=7.1
- league/tactician-bundle: ^1.0
- symfony/framework-bundle: ~2.3|~3.0|~4.0
Requires (Dev)
- jms/serializer-bundle: ^2.0
- matthiasnoback/symfony-config-test: ^3.0
- matthiasnoback/symfony-dependency-injection-test: ^2.1
- phpunit/phpunit: ^6.2
- satooshi/php-coveralls: ^2.0@dev
- symfony/serializer: ~2.3|~3.0|~4.0
Suggests
- jms/serializer-bundle: To deserialize commands using JMS Serializer
- symfony/serializer: To deserialize commands using Symfony serializer
This package is not auto-updated.
Last update: 2024-11-10 04:07:05 UTC
README
Extract command from a HTTP request and send it to the command bus, like Tactician.
Motivation
Recently I've been writing some project with framework-agnostic code
with CQRS approach
so I could have all use cases in separate classes written in clean
and readable way. When I started to integrate it with Symfony framework
I've noticed that each controller's action looks the same: create command from request,
send to the command bus and return Response
from action.
I've created this library to facilitate converting requests to commands and automatically sending them to the command bus, like Tactician. Thanks to Symfony Router component and Symfony Event Dispatcher with kernel events listeners an application is able to recognize command from route parameters and convert to the command instance, all thanks to this bundle.
This bundle works and is ready to use. However it may need some work to be adaptable to each case.
I hope that completely framework-agnostic code will be available soon so you'll be able to use it
with whatever framework you like. There's still need to separate from Symfony's Request class and use
PSR7's RequestInterface
implementations. Support for other command buses is also a nice-to-have.
Every contribution is welcome!
Requirements
- PHP 7.1+
- Symfony Framework Bundle (or Symfony Standard Edition) - 2.3+|3.0+
Optionally, depending on usage, you may need:
- Tactician bundle 0.4+
- Symfony Serializer (bundled with Symfony Framework bundle)
Installation
Step 1: Open a command console, enter your project's root director and run following command to install the package with Composer:
composer require eps90/req2cmd-bundle
Step 2: Enable the bundle by adding it to the list of registered bundles
in the app/AppKernel.php
file:
<?php // ... class AppKernel extends Kernel { public function registerBundles(): array { $bundles = [ // ... new Eps\Req2CmdBundle\Req2CmdBundle(), // ... ]; // ... } // ... }
Usage
(Documentation in progress)
Converting a route to a command
This bundle uses the capabilities of Symfony Router
to match a route with configure command. In the happy path, all you need to do is to set
a _command_class
parameter in your route:
app.add_post: path: /add_post.{_format} methods: ['POST'] defaults: _command_class: AppBundle\Command\AddPostCommand _format: ~
In such case, an event listener will try to convert a request contents to a command instance
with CommandExtractor
(the default extractor is the Symfony Serializer).
The result command instance will be saved as _command
argument in the request.
<?php // ... final class PostController { // ... public function addPostAction(Request $request): Response { // ... $command = $request->attributes->get('_command'); // ... } }
The only requirement is to provide a requested format (with Request::setRequestFormat
) before the ExtractCommandFromRequestListener
is fired.
This can be done wih already available bundles, like FOSRestBundle
but I hope that such listener will be available soon in this bundle as well.
Action!
If you won't add a _controller
parameter to the route, your request will be automatically sent
to ApiResponderAction
which is responsible for extracting a command from a request and sending it to the command bus.
Moreover, regarding the method the request has been send with, it responds with proper status code.
For example, for successful POST
request you can expect 201 status code (201: Created).
Custom controller
Of course, you can use your own controller, with standard _controller
parameter.
The listener from this bundle won't override this param if it's alreade defined.
Deserialize a command
If your command is complex and uses some nested types, default Symfony Serializer probably won't be able to deserialize a request to your command.
This bundle comes with a denormalizer which looks up for DeserializableCommandInterface
implementations
and calls the fromArray
constructor on it.
<?php use Eps\Req2CmdBundle\Command\DeserializableCommandInterface; final class AddPost implements DeserializableCommandInterface { // ... public function __construct(PostId $postId, PostContents $contents) { $this->postId = $postId; $this->contents = $contents; } // ... getters public static function fromArray(array $commandProps): self { return new self( new PostId($commandProps['id']), new PostContents( $commandProps['title'], $commandProps['author'], $commandProps['contents'] ) ); } }
Then your command can seamlessly be deserialized with a CommandExtractor
.
Feel free to register your own denormalizer.
If you don't want to use the default denormalizer, you can disable it in the configuration:
# app/config.yml # ... req2cmd: extractor: use_cmd_denormalizer: false # ...
You can also set a JMSSerializerCommandExtractor
as your extractor and use handy class mappings for deserialization.
# src/AppBundle/Resources/config/jms_serializer/Command.AddPost.yml AppBundle\Command\AddPost: properties: postId: type: AppBundle\Identity\PostId postContents: type: AppBundle\ValueObject\PostContents
# app/config.yml # ... req2cmd: extractor: jms_serializer # ...
Attaching path parameters to a command
You can attach route parameters to command deserialization like it was sent from a client. Let's say you have a route mapped to a command like the following:
app.update_post_title: path: /posts/{id}.{_format} methods: ['PUT'] defaults: _command_class: AppBundle\Command\UpdatePostTitle
And you have that command that looks like that:
<?php final class UpdatePostTitle { private $postId; private $postTitle; public function __construct(int $postId, string $postTitle) { $this->postId = $postId; $this->postTitle = $postTitle; } // ... }
As you can see, the UpdatePost
command requires an id and some string
that should allow to update a post title.
That command, to be serialized correctly, needs both parameters in request's contents. Of course, you can send following request to send your command to the event bus:
PUT http://example.com/api/posts/4234.json
{
"id": 4234,
"title": "Updated title"
}
As you can see, the id
property exists in a path and in a request body.
To remove this duplication you can point a route parameter
to be included in deserialization:
app.update_post_title: path: /posts/{id}.{_format} defaults: _command_class: AppBundle\Command\UpdatePostTitle _command_properties: path: id: ~
Then, the id
from route will be passed on, like it's been a part of request body,
and will create your command properly. Then your request may look like that:
PUT http://example.com/api/posts/4234.json
{
"title": "Updated title"
}
And everything will work as expected.
By route parameters I mean all route parameters so if you want to attach, for example, a
_format
(yep, I know, a stupid example), you can do it in the same way.
Change route parameters names
You may want to change a parameter name before it goes to the extractor.
Given the example above, the serializer will probably need a post_id
instead
of id
in request content. The name can be changed by passing a value to parameter name
in route definition:
app.update_post_title: path: /posts/{id}.{_format} defaults: _command_class: AppBundle\Command\UpdatePostTitle _command_properties: path: id: post_id
Then the following code will work:
<?php use Eps\Req2CmdBundle\Command\DeserializableCommandInterface; final class UpdatePostTitle implements DeserializableCommandInterface { // ... public static function fromArray(array $commandProps): self { return new self($commandProps['post_id'], $commandProps['title']); } }
Required route parameters
A PathParamsMapper
can recognize whether configure parameter should be required and not empty.
To allow it, prepend a parameter name with an exclamation mark:
app.update_post_title:
path: /posts/{id}.{_format}
defaults:
_format: ~
_command_class: AppBundle\Command\UpdatePostTitle
_command_properties:
path:
!_format: requested_format
In this case, when _format
parameter will equal null
, the mapper will throw
a ParamMapperException
.
Registering custom parameter mappers
The default parameter mapper is the PathParamsMapper
class instance and it's responsible only for
extracting only route parameters. Of course you can feel free to register your own mapper,
by implement the ParamMapperInterface
.
When you're done, register it as a service and add the req2cmd.param_mapper
tag.
Optionally, you can set a priority to make sure that this mapper will be executed earlier.
The higher priority is, the more important the service is.
services: # ... app.param_mapper.my_awesome_mapper: class: AppBundle\ParamMapper\MyAwesomeMapper tags: - { name: 'req2cmd.param_mapper', priority: 128 }
But I want to use different extractor!
Sure, why not!
You need to create a class implementing the CommandExtractorInterface
interface.
This interface contains only one method, extractFromRequest
, where you can access a Request
and a command class.
For example:
<?php use Eps\Req2CmdBundle\CommandExtractor\CommandExtractorInterface; // ... class DummyExtractor implements CommandExtractorInterface { public function extractorFromRequest(Request $request, string $commandName) { // get the requested format from the Request object if ($request->getRequestFormat() === 'json') { // decode contents $contents = json_decode($request->getContents(), true); } // and return command instance return new $commandName($contents); } }
Then, register this service in service mappings:
services: # ... app.extractor.my_extractor: class: AppBundle\Extractor\DummyExtractor
And adapt project configuration by setting extractor.service_id
value:
# ... req2cmd: # ... extractor: service_id: app.extractor.my_extractor # ...
Note: Defining string value to
req2cmd.extractor
config property is only available for built-in extractors. For now onlyserializer
andjms_serializer
are allowed.
... and I want other command bus as well!
You can use whatever command bus you want.
The only condition is you need to write an adapter implementing CommandBusInterface
.
Then you can register it as a service and adapt configuration:
# app/config/config.yml # ... req2cmd: # ... command_bus: service_id: app.command_bus.my_command_bus # ... # ...
Note: Tactician is the default command bus so you don't have to configure it manually. Actually, the following configuration is equivalent to missing one:
# ... req2cmd: # Short version: command_bus: tactician # Verbose version: command_bus: service_id: eps.req2cmd.command_bus.tactician name: default # ...
Configuring command bus
The default command bus is Tactician command bus
which allows you to declare several command buses adapted to your needs.
Without touching the configuration, this bundle uses tactician.commandbus.default
command bus
which is sufficient for most cases. However, if you need to set different command bus name,
you can do it by passing a name to configuration:
# app/config/config.yml # ... req2cmd: # ... command_bus: name: 'queued' # ...
In such case the tactician.commandbus.queued
will be used.
Setting listener priority
By default, the ExtractCommandFromRequestListener
will be registered in your project
with priority 0. That means that all other listeners that have priority set to higher than 0
will be executed earlier than ExtractCommandFromRequestListener
.
Fortunatelly, you can easily change that by setting a proper value in a configuration:
# app/config/config.yml # ... req2cmd: # ... listeners: extractor: priority: 128 # ...
With such config this listener will be registered as kernel.event_listener
with priority
value of 128.
Disabling a listener
You may want to disable a listener. To do that you need to set enabled
property for the listener to false
.
# app/config/config.yml # ... req2cmd: listeners: extractor: enabled: false
or even simpler:
# app/config/config.yml # ... req2cmd: listeners: extractor: false
Note: You must be aware that if you disable
extractor
listener, somewhere in Asia one little cute panda dies. You don't want that, do you? No one does. Everyone love pandas. Keep that in mind.
Exceptions
All exceptions in this bundle implement the Req2CmdExceptionInterface
.
Currently, the following exceptions are configured:
ParamMapperException
::noParamFound
(code 101) - when required property has not been found in a request::paramEmpty
(code 102) - when required property is found but it's empty
Testing and contributing
This project is covered with PHPUnit tests. To run them, type:
bin/phpunit