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

v1.0.0-rc8 2017-12-26 15:54 UTC

This package is not auto-updated.

Last update: 2024-04-14 01:20:35 UTC


README

Extract command from a HTTP request and send it to the command bus, like Tactician.

Latest Stable Version Latest Unstable Version License

Build Status Coverage Status Scrutinizer Code Quality Codacy Badge

SensioLabsInsight

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 only serializer and jms_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