DEV Community

Cover image for Another way for a Symfony API to ingest POST requests - No Bundle
Guillaume MOREL
Guillaume MOREL

Posted on

Another way for a Symfony API to ingest POST requests - No Bundle

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"
Enter fullscreen mode Exit fullscreen mode

There are several ways of building a RESTfull API in Symfony:

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
}
Enter fullscreen mode Exit fullscreen mode

Having a DTO request like this:

/**
 * DTO representing how API ingest JSON
 */
final class CreateProductRequestPayload
{
    public string $id;
    public string $name;
}
Enter fullscreen mode Exit fullscreen mode

It uses the Symfony Serializer Component to deserialize a JSON into an Object.

Deserialize
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
Enter fullscreen mode Exit fullscreen mode

GitHub logo gmorel / very-light-symfony-api-example

Yet another Symfony API without Bundle

GitHub 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;
Enter fullscreen mode Exit fullscreen mode

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
    );
}
Enter fullscreen mode Exit fullscreen mode

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."
Enter fullscreen mode Exit fullscreen mode

⚠️ Don't forget the @Assert\Valid annotation.

/**
 * @Assert\Valid
 */
private PriceDTO $price;
Enter fullscreen mode Exit fullscreen mode

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."
Enter fullscreen mode Exit fullscreen mode

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"
Enter fullscreen mode Exit fullscreen mode

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.

Oldest comments (1)

Collapse
 
zajca profile image
Martin Zajíc

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.