Another way for a Symfony API to ingest POST requests - No Bundle
Tags: symfony, api, architecture
Published: 2020-07-19 15:34:18

Let's begin with some [Behat](https://docs.behat.org/en/latest/quick_start.html): 
```gherkin
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](https://symfony.com/doc/master/bundles/FOSRestBundle/index.html) or [API Platform](https://api-platform.com/)
- Custom by using the [Form Component](https://medium.com/@ideneal/how-to-handle-json-requests-using-forms-on-symfony-4-and-getting-a-clean-code-67dd796f3d2f) or using [@ParamConverter](https://symfony.com/doc/current/bundles/SensioFrameworkExtraBundle/annotations/converters.html)
- 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 :seedling: to quickly be up to speed and autonomous in your project.


-------------------

## :tada: Meet the Serializer

```php
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:

```php
/**
 * DTO representing how API ingest JSON
 */
final class CreateProductRequestPayload
{
    public string $id;
    public string $name;
}
```

It uses the Symfony [Serializer Component](https://symfony.com/doc/current/components/serializer.html#deserializing-an-object) to deserialize  a JSON into an Object. 

![Deserialize](https://dev-to-uploads.s3.amazonaws.com/i/y80hh4gav9a0720c1kw0.png)
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:
```cli
composer require symfony/serializer
```

-------------------


{% github gmorel/very-light-symfony-api-example no-readme %}

![GitHub](https://dev-to-uploads.s3.amazonaws.com/i/7aeiynmf6hr8z8lbn7nn.png) Full example here: https://github.com/gmorel/very-light-symfony-api-example/blob/master/src/Product/UI/Controller/ProductController.php#L38-L67


-------------------



## Validation

```php
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](https://symfony.com/doc/current/components/validator.html) in the [Controller](https://github.com/gmorel/very-light-symfony-api-example/blob/master/src/Product/UI/Controller/ProductController.php#L51-L57).

```php
$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:

```gherkin
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."
```

:warning: Don't forget the `@Assert\Valid` annotation.

```php
/**
 * @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.
```gherkin
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.
```gherkin
Given I add "Accept-Language" header equal to "fr"
```

-------------------

##### Mixing ParamConverter + Serializer

You could also mix [ParamConverter + Serializer]( https://github.com/TBoileau/api_social_network/blob/master/src/Request/ParamConverter/PostConverter.php#L36-L45) :rocket:. This is clever. However Listeners tend to confuse new comers and juniors. Use them wisely as they bring their own [complexity](https://github.com/TBoileau/api_social_network/blob/master/config/services.yaml#L34-L36).


-------------------

##### OpenAPI documentation - Swagger

You can still generate an OpenAPI documentation by adding the relevant annotations to your RequestPayload using [SwaggerPHP](https://zircote.github.io/swagger-php/Getting-started.html#detects-values-based-on-context) or via [NelmioApiDocBundle v4](https://symfony.com/doc/current/bundles/NelmioApiDocBundle/index.html).

-------------------

> Wait a little: CreateProductRequestPayload has only public properties. Where is our user intention's `immutability` here ?
I let you read [this](https://jimmybogard.com/immutability-in-dtos/) and then decide by yourself. You might also want to try [Psalm](https://psalm.dev/articles/immutability-and-beyond).