Let's begin with some Behat:
Scenario: It creates a Product
When I send a POST request to "/v1/products.json" with body:
"""
{
"id": "fabd5e92-02e7-43f7-a962-adab8ec88e94",
"name": "Product1",
"price": {
"amount": 600,
"currency": "EUR"
}
}
"""
Then the response status code should be 201
And the header "Location" should contain "/v1/products/fabd5e92-02e7-43f7-a962-adab8ec88e94.json"
There are several ways of building a RESTfull API in Symfony:
- FOSRestBundle or API Platform
- Custom by using the Form Component or using @ParamConverter
- Or simply custom by extracting manually params one by one from a Request array
These are already proven to work great. But sometimes you just need a very simple way with minimum dependencies to ingest data from a Single Page Application or a Micro Service. Something pretty straight forward that will allow any intern/junior 🌱 to quickly be up to speed and autonomous in your project.
🎉 Meet the Serializer
private SerializerInterface $serializer;
// ...
/**
* @Route("products.json", methods={"POST"})
*/
public function new(Request $request): JsonResponse
{
/** @var CreateProductRequestPayload $requestPayload */
$requestPayload = $this->serializer->deserialize(
$request->getContent(),
CreateProductRequestPayload::class,
'json',
);
// Your Logic
}
Having a DTO request like this:
/**
* DTO representing how API ingest JSON
*/
final class CreateProductRequestPayload
{
public string $id;
public string $name;
}
It uses the Symfony Serializer Component to deserialize a JSON into an Object.
Sorry to disappoint you ... but nothing new here: FOSRestBundle and API Platform are using it internally.
The good news is you have good chances to already have this component as a part of your project dependency.
If it is not already present:
composer require symfony/serializer
gmorel / very-light-symfony-api-example
Yet another Symfony API without Bundle
Full example here: https://github.com/gmorel/very-light-symfony-api-example/blob/master/src/Product/UI/Controller/ProductController.php#L38-L67
Validation
final class CreateProductRequestPayload
{
/**
* @Assert\NotNull
* @Assert\Uuid
*/
private string $id;
/**
* @Assert\NotNull
* @Assert\NotBlank()
*/
private string $name;
Exactly like you would do with Forms. You can now validate your $requestPayload
against the Symfony Validator Component in the Controller.
$errors = $this->validator->validate($requestPayload);
if ($errors->count() > 0) {
return new JsonResponse(
$this->createErrorFromValidation($errors),
Response::HTTP_BAD_REQUEST
);
}
Deep validation
You can of course perform deep validation if your are using Value Objects:
Scenario: It fails to create a Product with bad amount and bad currency - Deep inspection:
When I send a POST request to "/v1/en/products.json" with body:
"""
{
"id": "fabd5e92-02e7-43f7-a962-adab8ec88e94",
"name": "Product 1",
"price": {
"amount": 0,
"currency": "EU"
}
}
"""
Then the response status code should be 400
And the JSON node "type" should be equal to "UI Validation"
And the JSON node "title" should be equal to "Bad Request"
And the JSON node "violations" should have 2 elements
And the JSON node "violations[0].propertyPath" should be equal to "price.amount"
And the JSON node "violations[0].message" should be equal to "This value should be positive."
And the JSON node "violations[1].propertyPath" should be equal to "price.currency"
And the JSON node "violations[1].message" should be equal to "This value is not a valid currency."
⚠️ Don't forget the @Assert\Valid
annotation.
/**
* @Assert\Valid
*/
private PriceDTO $price;
Otherwise no deep validation will be performed. And without proper test suite you have good chance to notice it too late.
Bonus: i18n validation
You can have your JS/Mobile to directly show some already translated validation errors for free.
Scenario: It fails to create a Product in French
When I send a POST request to "/v1/fr/products.json" with body:
...
And the JSON node "violations[0].message" should be equal to "Cette valeur ne doit pas être vide."
For the simplicity of the example locale is set in the URL. But I would advise to have the locale extracted from HTTP Headers. So user Browser/Phone could take care of the locale by itself.
Given I add "Accept-Language" header equal to "fr"
Mixing ParamConverter + Serializer
You could also mix ParamConverter + Serializer 🚀. This is clever. However Listeners tend to confuse new comers and juniors. Use them wisely as they bring their own complexity.
OpenAPI documentation - Swagger
You can still generate an OpenAPI documentation by adding the relevant annotations to your RequestPayload using SwaggerPHP or via NelmioApiDocBundle v4.
Wait a little: CreateProductRequestPayload has only public properties. Where is our user intention's
immutability
here ?
I let you read this and then decide by yourself. You might also want to try Psalm.
Top comments (1)
There is one issue, when payload has wrong property type, or missing property or it's null, it will end up with Type error thus 500 error. This is because you have typed props and request is mapped to object and than validated. Better is to validate raw payload and than map request to object, but it's more work and you can't use class metadata for validation.