loading...
Cover image for Another way for a Symfony API to ingest POST requests - No Bundle

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

gmorel profile image Guillaume MOREL ・4 min read

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:

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.

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

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;

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.

Discussion

markdown guide
 

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.