-
Notifications
You must be signed in to change notification settings - Fork 89
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
Abstract creating commands from requests #375
Abstract creating commands from requests #375
Conversation
f7df088
to
5336e75
Compare
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Nice work, this streamlines the process of handling an API request even further.
5336e75
to
ca3d55b
Compare
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Thanks a lot for picking this up! 👍 a few more improvements and we are ready to tag 1.0 🎉
Thanks, Mateusz! 🥇 |
$this->userEmail = $userEmail; | ||
} | ||
|
||
public function getCommand(): SetDefaultAddress | ||
public function getCommand(): object | ||
{ | ||
return new SetDefaultAddress($this->id, $this->userEmail); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think there's a design flaw in the proposed (and now merged) HTTP Request -> Command cycle:
-
Request is defined as a stateful service while it's a data structure - also makes it incompatible to work with PHP-PM or any other asynchronous platform
-
You can use
populateData
andsetUserEmail
on it separately:
$request = new SetDefaultAddressRequest();
// first action
$request->populateData($httpRequest);
$request->setUserEmail('some@email.com');
// second action in the same process
$request->populateData($httpRequest);
/* user is not logged it so skip setUserEmail
* user email is still set to 'some@email.com'
*/
-
We expose those domain-request objects in the flow only to get the validation done easier in HTTP action controllers.
-
In case there are additional methods like
setUserEmail
, we assume the returned request object from the locator would be an instance of an exact class.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
My idea would be to keep those request objects internal and abstract the way a HTTP Request is transformed into a Command:
interface CommandProvider
{
/**
* @throws InvalidRequest
*/
public function provide(string $commandIdentifier, Request $httpRequest, ?UserInterface $user): object;
}
Then a controller, eg. the one setting the default address, could transfrom from:
try {
/** @var ShopUserInterface $user */
$user = $this->loggedInUserProvider->provide();
} catch (TokenNotFoundException $exception) {
return $this->viewHandler->handle(View::create(null, Response::HTTP_UNAUTHORIZED));
}
/** @var UserEmailBasedCommandRequestInterface $setDefaultAddressRequest */
$setDefaultAddressRequest = $this->commandRequestParser->parse($request, SetDefaultAddress::class);
$setDefaultAddressRequest->setUserEmail($user->getEmail());
$validationResults = $this->validator->validate($setDefaultAddressRequest);
if (0 !== count($validationResults)) {
return $this->viewHandler->handle(
View::create($this->validationErrorViewFactory->create($validationResults), Response::HTTP_BAD_REQUEST)
);
}
if ($user->getCustomer() !== null) {
$this->bus->handle($setDefaultAddressRequest->getCommand());
return $this->viewHandler->handle(View::create(null, Response::HTTP_NO_CONTENT));
}
return $this->viewHandler->handle(View::create(['message' => 'The user is not a customer'], Response::HTTP_BAD_REQUEST));
To:
try {
/** @var ShopUserInterface $user */
$user = $this->loggedInUserProvider->provide();
} catch (TokenNotFoundException $exception) {
return $this->viewHandler->handle(View::create(null, Response::HTTP_UNAUTHORIZED));
}
try {
$this->bus->handle($this->commandProvider->provide(SetDefaultAddress::class, $request, $user));
return $this->viewHandler->handle(View::create(null, Response::HTTP_NO_CONTENT));
} catch (InvalidRequest $exception) {
// It's an exception thrown by command provider
return $this->viewHandler->handle(
View::create($this->validationErrorViewFactory->create($validationResults), Response::HTTP_BAD_REQUEST)
);
}
Or even if we change user provider to return null if none found:
try {
$this->bus->handle($this->commandProvider->provide(SetDefaultAddress::class, $request, $this->loggedInUserProvider->provide()));
return $this->viewHandler->handle(View::create(null, Response::HTTP_NO_CONTENT));
} catch (InvalidRequest $exception) {
// It's an exception thrown by command provider
return $this->viewHandler->handle(
View::create($this->validationErrorViewFactory->create($validationResults), Response::HTTP_BAD_REQUEST)
);
} catch (UnauthorizedAccess $exception) {
// It's an exception thrown by a command bus (command handler exactly)
return $this->viewHandler->handle(View::create(null, Response::HTTP_UNAUTHORIZED));
}
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This way we enforce SRP:
- Controller translates HTTP request into a command and translates the exceptions into HTTP response (HTTP-Application-HTTP communication)
- Command provider gently creates a command while providing user-friendly validation
- Command bus triggers the application logic
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I can totally agree, the decision was too hasty. We need a better solution. Nevertheless, I cannot find a validation moment in the answer you have proposed. Validation based on exception is also a bad design for me. Separate service is bad too. The core of these changes is to create a command and provide User-friendly exception if it is not possible.
But we may have:
interface CommandProvider
{
public function provide(Request $httpRequest): object;
public function validate(Request $httpRequest): ConstraintViolationListInterface;
}
Which is just an improvement of your proposal.
With the same rules as Validator provides: A list of constraint violations If the list is empty, validation succeeded
. I prefer to decouple it from Symfony Validator, but this may be the second step of transformation. For example, we may provide some DTO, which can be created super easy from Symfony Validator, but also in a different way.
Command provider will be responsible for creating and validating HTTP request, without assuming what is below. One will be aware of the user, and others may generate uuid from another service. Internally it may use some DTO for validation or raw values (https://symfony.com/doc/current/validation/raw_values.html). Providers will be kind of factories with validation feature. If one would like to change command dispatched in one of the controllers, they will need to write new provider, command, and handler.
For now, I'm reverting this PR.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Implementation proposal: #380
This is the first approach to make creating commands from requests more abstract. It still requires some love, but I think it's a good step forward for even better code structure in this, yet awesome, plugin 🐃
Features
The main change is the introduction of
CommandRequestParser
object, which is responsible for creating a command request object based on the passed request and some identifier. Right now this ID is a FQCN of command, but I believe it can be anything else or even can be passed by some request parameter. Also, all command requests implementCommandRequestInterface
now.There are still some problematic requests:
AddCouponRequest
requires some additional methods for validationPut*ItemToCartRequest
can be created from request or array 🦀EstimateShippingCostRequest
does not even result in any command xDbut I think we can take care of them in separated PR's.
Things to improve:
Future:
As lots of logic is duplicated in lots of actions, I'm sure that the natural next step is creation of some default, basic action, that would consist of parsing a request and validating it. It should allow us to reduce amount of actions drasticly.
PHP version
I've bumped a PHP version to
^7.2
as I useobject
type hint inCommandRequestInterface
. It would still be required if we decide to switch from tactician bus to Symfony Messenger, but I understand it can results in some tensions 😄I would be happy to see your opinion about this change and/or iterate over it to make
ShopApiPlugin
one step close to the stable version 🎉