Flag of Ukraine
SymfonyCasts stands united with the people of Ukraine

Validación

Keep on Learning!

If you liked what you've learned so far, dive in!
Subscribe to get access to this tutorial plus
video, code and script downloads.

Start your All-Access Pass
Buy just this tutorial for $12.00

With a Subscription, click any sentence in the script to jump to that part of the video!

Login Subscribe

Los usuarios de nuestra API pueden estropear las cosas de muchas maneras, por ejemplo con un JSON incorrecto o haciendo tonterías como introducir un número negativo en el campo value. ¡Esto es oro de dragón, no deuda de dragón!

JSON no válido

Este capítulo trata sobre cómo manejar estas cosas malas con elegancia. Prueba con la ruta POST. Enviemos algo de JSON no válido. Pulsa Ejecutar. ¡Impresionante! ¡Un error 400! Eso es lo que queremos. 400 -o cualquier código de estado que empiece por 4- significa que el cliente -el usuario de la API- ha cometido un error. en concreto, 400 significa "petición errónea".

En la respuesta, el tipo es hydra:error y dice: An error occurred y Syntax Error. Ah, y este trace sólo se muestra en el entorno de depuración: no se mostrará en producción.

¡Así que esto es muy bonito! El JSON no válido se gestiona de forma automática.

Restricciones de validación de reglas de negocio

Probemos algo diferente, como enviar JSON vacío. Esto nos da el temido error 500. Boo. Internamente, la API Platform crea un objeto DragonTreasure... pero no establece ningún dato en él. Y luego explota cuando llega a la base de datos porque algunas de las columnas son null.

Y, ¡nos lo esperábamos! Nos falta la validación. Añadir validación a nuestra API es exactamente igual que añadir validación en cualquier parte de Symfony. Por ejemplo, busca la propiedadname. Necesitamos que name sea obligatoria. Así que añade la restricción NotBlank, y pulsa tabulador. Oh, pero voy a buscar la declaración NotBlank use ... y cambiarla por Assert. Eso es opcional... pero es la forma en que los chicos guays suelen hacerlo en Symfony. Ahora di Assert\NotBlank:

... lines 1 - 19
use Symfony\Component\Validator\Constraints as Assert;
... lines 21 - 51
class DragonTreasure
{
... lines 54 - 61
#[Assert\NotBlank]
... line 63
private ?string $name = null;
... lines 65 - 188
}

A continuación, añade una más: Length. Digamos que el nombre debe tener al menos dos caracteres, max 50 caracteres... y añade un maxMessage:Describe your loot in 50 chars or less:

... lines 1 - 19
use Symfony\Component\Validator\Constraints as Assert;
... lines 21 - 51
class DragonTreasure
{
... lines 54 - 61
#[Assert\NotBlank]
#[Assert\Length(min: 2, max: 50, maxMessage: 'Describe your loot in 50 chars or less')]
private ?string $name = null;
... lines 65 - 188
}

Cómo se ven los errores en la respuesta

¡Buen comienzo! Inténtalo de nuevo. Coge ese mismo JSON vacío, pulsa Ejecutar, y ¡sí! ¡Una respuesta 422! Se trata de un código de respuesta muy común que suele significar que se ha producido un error de validación. Y ¡he aquí! El @type es ConstraintViolationList. Se trata de un tipo especial de JSON-LD añadido por API Platform. Anteriormente, lo vimos documentado en la documentación de JSON-LD.

Observa: ve a /api/docs.jsonld y busca un ConstraintViolation. ¡Ahí está! API Platform añade dos clases: ConstraintViolation yConstraintViolationList para describir el aspecto que tendrán los errores de validación. UnConstraintViolationList es básicamente una colección de ConstraintViolations... y luego describe cuáles son las propiedades de ConstraintViolation.

Podemos verlas aquí: tenemos una propiedad violations con propertyPathy luego la message debajo.

Añadir más restricciones

¡Vale! Vamos a añadir unas cuantas restricciones más. Añade NotBlank por encima de description... y GreaterThanOrEqual a 0 por encima de value para evitar los negativos. Por último, paracoolFactor utiliza GreaterThanOrEqual a 0 y también LessThanOrEqual a 10. Así que algo entre 0 y 10:

... lines 1 - 51
class DragonTreasure
{
... lines 54 - 68
#[Assert\NotBlank]
private ?string $description = null;
... lines 71 - 77
#[Assert\GreaterThanOrEqual(0)]
private ?int $value = null;
... lines 80 - 82
#[Assert\GreaterThanOrEqual(0)]
#[Assert\LessThanOrEqual(10)]
private ?int $coolFactor = null;
... lines 86 - 192
}

Y ya que estamos aquí, no necesitamos hacer esto, pero voy a inicializar$value a 0 y $coolFactor a 0. Esto hace que ambos no sean necesarios en la API: si el usuario no los envía, serán 0 por defecto:

... lines 1 - 51
class DragonTreasure
{
... lines 54 - 68
#[Assert\NotBlank]
private ?string $description = null;
... lines 71 - 77
#[Assert\GreaterThanOrEqual(0)]
private ?int $value = 0;
... lines 80 - 82
#[Assert\GreaterThanOrEqual(0)]
#[Assert\LessThanOrEqual(10)]
private ?int $coolFactor = 0;
... lines 86 - 192
}

Vale, vuelve a probar esa misma ruta. ¡Mira qué validación más bonita! Prueba también a poner coolFactor en 11. ¡Sí! Ningún tesoro mola tanto... bueno, a menos que sea un plato gigante de nachos.

Pasar tipos malos

Vale, hay una última forma de que un usuario envíe cosas malas: pasando un tipo incorrecto. Así que coolFactor: 11 fallará nuestras reglas de validación. Pero, ¿y si en su lugar pasamos un string? ¡Qué asco! Pulsa Ejecutar. Vale: un código de estado 400, eso es bueno. Aunque, no es un error de validación, tiene un tipo diferente. Pero indica al usuario lo que ha ocurrido:

el tipo del atributo coolFactor debe ser int, string dado.

¡Suficientemente bueno! Esto es gracias al método setCoolFactor(). El sistema ve el tipo int y por eso rechaza la cadena con este error.

Así que de lo único que tenemos que preocuparnos en nuestra aplicación es de escribir un buen código que utilice correctamente type y de añadir restricciones de validación: la red de seguridad que atrapa las violaciones de las reglas de negocio... como que value debe ser mayor que 0 o que descriptiones obligatorio. API Platform se encarga del resto.

A continuación: nuestra API sólo tiene un recurso: DragonTreasure. Añadimos un segundo recurso -un recurso User - para que podamos vincular qué usuario posee qué tesoro en la API.

Leave a comment!

6
Login or Register to join the conversation
Huy Avatar

hi, regarding coolFactor, is there any way we can validate that the type must be numeric so the response will have status 422 with the error message instead of 400?
I tried the Type validation with numeric type but it doesn't work

   #[Groups(['treasure:read', 'treasure:write'])]
    #[ORM\Column]
    #[GreaterThanOrEqual(0)]
    #[LessThanOrEqual(10)]
    #[NotBlank]
    #[NotNull]
    #[Type('numeric')]
    private ?int $coolFactor = 0;
Reply

Yo @Huy!

I think I understand :). In this case, coolFactor is private property, so we're setting it via the setCoolFactor() method. Because it has an int type-hint - setCoolFactor(int $coolFactor) - if the user sends apple for this, it will fail during denormalization (i.e. it will fail to even SET the value). This gives us the 400 error. It never even GETS to the validation step.

If you want to, instead, enforce this via validation, you need to allow the coolFactor property to be set to a string... which you CAN do, but it's kind of a bummer to allow such a weird value to even get set into your entity (even if you will validate it). So, you would need to:

A) Remove the ?int property type from the coolFactor property
B) Remove the int type from the setCoolFactor() method

That will allow the serializer to set the value... and THEN validation will happen. Let me know if this helps!

Cheers!

1 Reply
Huy Avatar
Huy Avatar Huy | weaverryan | posted hace 1 mes | edited

Hi @weaverryan, thank you, it works but when I pass the correct coolFactor in the request body, it returns 400 error like below
The request:

{
  "name": "test",
  "value": 12,
  "coolFactor": 2,
  "isPublished":  123,
  "description": "test"
}

The response:

{
    "title": "An error occurred",
    "detail": "The type of the \"coolFactor\" attribute must be \"string\", \"integer\" given.",
    "status": 400,
 }

Then I try to validate the $isPublished with the annotation #[Assert\Type('bool')] to make sure the isPublished must be boolean type (not number, string...). then I send the request with $isPublished = 123, although I tried the approach you suggested above like removing the ?bool int property type and bool type from setIsPublished, the response status is still 400 instead of 422.
Sample request:
POST api/treasures

{
  "name": "test",
  "value": 12,
  "coolFactor": "test",
  "isPublished":  123,
  "description": "test"
}

Response 400:

{
    "title": "An error occurred",
    "detail": "The type of the \"isPublished\" attribute must be \"string\", \"integer\" given.",
    "status": 400,
 }

This is my DragonTreasure entity

<?php

namespace App\Entity;

use ApiPlatform\Doctrine\Orm\Filter\BooleanFilter;
use ApiPlatform\Doctrine\Orm\Filter\RangeFilter;
use ApiPlatform\Doctrine\Orm\Filter\SearchFilter;
use ApiPlatform\Metadata\ApiFilter;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Delete;
use ApiPlatform\Metadata\Get;
use ApiPlatform\Metadata\GetCollection;
use ApiPlatform\Metadata\Link;
use ApiPlatform\Metadata\Patch;
use ApiPlatform\Metadata\Post;
use ApiPlatform\Metadata\Put;
use ApiPlatform\Serializer\Filter\PropertyFilter;
use App\Entity\Traits\Timestamp;
use App\Repository\DragonTreasureRepository;
use Carbon\Carbon;
use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\Mapping as ORM;
use Doctrine\ORM\Mapping\Column;
use Gedmo\Mapping\Annotation as Gedmo;
use Gedmo\Mapping\Annotation\Timestampable;
use Symfony\Component\Serializer\Annotation\Groups;
use Symfony\Component\Serializer\Annotation\SerializedName;
use Symfony\Component\Validator\Constraints\GreaterThanOrEqual;
use Symfony\Component\Validator\Constraints\LessThanOrEqual;
use Symfony\Component\Validator\Constraints\NotBlank;
use Symfony\Component\Validator\Constraints\NotNull;
use Symfony\Component\Validator\Constraints\Type;
use Symfony\Contracts\Service\Attribute\Required;
use function Symfony\Component\String\u;

#[ApiResource(
    shortName: 'Treasure',
    description: 'Rare and valuable resources',
    operations: [
        new Get(normalizationContext: ['groups' => ['treasure:read', 'treasure:item:read']]),
        new GetCollection(),
        new Post(security: 'is_granted("ROLE_TREASURE_CREATE")',),
        new Put( security: 'is_granted("ROLE_TREASURE_EDIT")',),
        new Patch( security: 'is_granted("ROLE_TREASURE_EDIT")',),
        new Delete(  security: 'is_granted("ROLE_ADMIN")',)
    ],
    formats: [
        'jsonld',
        'json',
        'html',
        'jsonhal',
        'csv' => 'text/csv'
    ],
    normalizationContext: [
        'groups' => ['treasure:read']
    ],
    denormalizationContext: [
        'groups' => ['treasure:write']
    ],
    paginationItemsPerPage: 10
)]
#[ApiResource(
    uriTemplate: 'users/{user_id}/treasures.{_format}',
    shortName: 'Treasure',
    operations: [
        new GetCollection()
    ],
    uriVariables: [
        'user_id' => new Link(
            fromProperty: 'dragonTreasures', fromClass: User::class,
//            toProperty: 'owner'
        )
    ],
    normalizationContext: [
        'groups' => ['treasure:read']
    ],
)]
#[ApiFilter(BooleanFilter::class, properties: ['isPublished'])]
#[ApiFilter(PropertyFilter::class)]
#[ORM\Entity(repositoryClass: DragonTreasureRepository::class)]
#[ApiFilter(SearchFilter::class, properties: ['owner.name' => 'partial'])]
class DragonTreasure
{
    use Timestamp;

    /**
     * @var \DateTime|null
     * @Timestampable(on="create")
     * @Column(type="datetime")
     */
    #[Timestampable(on: 'create')]
    #[Column(type: Types::DATETIME_MUTABLE)]
    #[Groups(['treasure:read'])]
    protected $createdAt;

    /**
     * @var \DateTime|null
     * @Gedmo\Timestampable(on="update")
     * @ORM\Column(type="datetime")
     */
    #[Timestampable(on: 'update')]
    #[Column(type: Types::DATETIME_MUTABLE)]
    #[Groups(['treasure:read'])]
    protected $updatedAt;
    #[ORM\Id]
    #[ORM\GeneratedValue]
    #[ORM\Column]
    #[Groups(['treasure:read'])]
    private ?int $id = null;

    #[Groups(['treasure:read', 'treasure:write', 'users:item:read'])]
    #[ORM\Column(length: 255)]
    #[ApiFilter(SearchFilter::class, strategy: SearchFilter::STRATEGY_IPARTIAL)]
    #[NotBlank]
    #[NotNull]
    private ?string $name = null;

    #[Groups(['treasure:read', 'users:item:read'])]
    #[ORM\Column(type: Types::TEXT)]
    #[ApiFilter(SearchFilter::class, strategy: SearchFilter::STRATEGY_IPARTIAL)]
    private ?string $description = null;

    /**
     * Value of the treasure
     */
    #[Groups(['treasure:read', 'treasure:write', 'users:item:read'])]
    #[ORM\Column]
    #[ApiFilter(RangeFilter::class)]
    #[GreaterThanOrEqual(0)]
    #[NotBlank]
    #[NotNull]
    #[Type('integer')]
    private ?int $value = 0;

    #[Groups(['treasure:read', 'treasure:write'])]
    #[ORM\Column]
    #[GreaterThanOrEqual(0)]
    #[LessThanOrEqual(10)]
    #[NotBlank]
    #[Type('numeric')]
    private $coolFactor;

    #[Groups(['treasure:read', 'treasure:write'])]
    #[ORM\Column]
    #[Type('bool')]
    private $isPublished = false;

    #[Groups(['treasure:read'])]
    #[ORM\ManyToOne(inversedBy: 'dragonTreasures')]
    #[ORM\JoinColumn(name: 'owner_id', onDelete: 'CASCADE')]
    #[ApiFilter(SearchFilter::class, 'exact')]
    private ?User $owner = null;

    public function __construct(string $name = null)
    {
        $this->name = $name;
    }

    public function getId(): ?int
    {
        return $this->id;
    }

    public function getName(): ?string
    {
        return $this->name;
    }

    public function setName(string $name): static
    {
        $this->name = $name;

        return $this;
    }

    #[Groups(['treasure:read'])]
    public function getShortDescription(): ?string
    {
        return u($this->description)->truncate(10, '...');
    }

    public function getDescription(): ?string
    {
        return $this->description;
    }

    #[Groups(['treasure:read'])]
    public function setDescription(string $description): static
    {
        $this->description = $description;

        return $this;
    }

    #[Groups(['treasure:read'])]
    public function getCreatedAtAgo(): ?string
    {
        return Carbon::parse($this->createdAt)->diffForHumans();
    }

    #[Groups(['treasure:write'])]
    #[SerializedName('description')]
    public function setTextDescription(string $description): static
    {
        $this->description = nl2br($description);

        return $this;
    }

    public function getValue(): ?int
    {
        return $this->value;
    }

    public function setValue(int $value): static
    {
        $this->value = $value;

        return $this;
    }

    public function getCoolFactor()
    {
        return $this->coolFactor;
    }

    public function setCoolFactor($coolFactor): static
    {
        $this->coolFactor = $coolFactor;

        return $this;
    }

    public function getIsPublished()
    {
        return $this->isPublished;
    }

    public function setIsPublished($isPublished): static
    {
        $this->isPublished = $isPublished;

        return $this;
    }

    public function getOwner(): ?User
    {
        return $this->owner;
    }

    public function setOwner(?User $owner): static
    {
        $this->owner = $owner;

        return $this;
    }
}

Could you suggest any ways to resolve this issue?

Reply

Hey @Huy!

Hmm. I have a feeling this is being caused by Doctrine. So when deserialization happens, it uses Symfony's property-info component to get the "type" of a property - e.g. coolFactor in this case. To get the type, it looks for a true PHP type on the property, a type on the argument in setCoolFactor() and it even looks at your PHPDoc - e.g. for an @var above the property. If It finds a type & the type send by the user doesn't match, it'll throw that 400 error.

However, you have NONE of that. But I still think that SOMETHING is "guessing" the type of your field. The property-info component also gets metadata from Doctrine. And, because you don't have a type in your column, this is a string field. And so, this might be telling the property info system that this is a string field. Still, it's a bit annoying, since if you send the integer 40, PHP can very easily cast that to the string '40'.

Anyway, the only way that I know of around this problem - which might actually be perfect for you - is to set ObjectNormalizer::DISABLE_TYPE_ENFORCEMENT => false in your context. So you could add a denormalizationContext option to your #[ApiResource] and set that there. Just be aware that the serializer will now ALWAYS set the value onto your properties, even if it's something crazy (e.g. the user could, I think, even send an array to the coolFactor property). If your code explodes when that is set (before validation) that would trigger a 500 error.

Anyway, with this option, you could actually, I think, re-add your property types, etc.

Cheers!

So, I'm a bit stumped as to WHY the serializer still thinks that coolFactor

1 Reply
Huy Avatar
Huy Avatar Huy | weaverryan | posted hace 1 mes | edited

Hi @weaverryan
I followed your suggestion but it still didn't work. When I pass the coolFactor as an integer, the response status still is 400.
Moreover, I also can not find the ObjectNormalizer class, it seems the class AbstractObjectNormalizer instead

The DragonTreasure entity:

<?php

namespace App\Entity;

use ApiPlatform\Doctrine\Orm\Filter\BooleanFilter;
use ApiPlatform\Doctrine\Orm\Filter\RangeFilter;
use ApiPlatform\Doctrine\Orm\Filter\SearchFilter;
use ApiPlatform\JsonLd\Serializer\ObjectNormalizer;
use ApiPlatform\Metadata\ApiFilter;
use ApiPlatform\Metadata\ApiProperty;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Delete;
use ApiPlatform\Metadata\Get;
use ApiPlatform\Metadata\GetCollection;
use ApiPlatform\Metadata\Link;
use ApiPlatform\Metadata\Patch;
use ApiPlatform\Metadata\Post;
use ApiPlatform\Metadata\Put;
use ApiPlatform\Serializer\Filter\PropertyFilter;
use App\Entity\Traits\Timestamp;
use App\Repository\DragonTreasureRepository;
use App\Validator\IsValidOwner;
use Carbon\Carbon;
use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\Mapping as ORM;
use Doctrine\ORM\Mapping\Column;
use Gedmo\Mapping\Annotation as Gedmo;
use Gedmo\Mapping\Annotation\Timestampable;
use Symfony\Component\Serializer\Annotation\Groups;
use Symfony\Component\Serializer\Annotation\SerializedName;
use Symfony\Component\Serializer\Normalizer\AbstractObjectNormalizer;
use Symfony\Component\Validator\Constraints\GreaterThanOrEqual;
use Symfony\Component\Validator\Constraints\LessThanOrEqual;
use Symfony\Component\Validator\Constraints\NotBlank;
use Symfony\Component\Validator\Constraints\NotNull;
use Symfony\Component\Validator\Constraints\Type;
use Symfony\Contracts\Service\Attribute\Required;
use function Symfony\Component\String\u;

#[ApiResource(
    shortName: 'Treasure',
    description: 'Rare and valuable resources',
    operations: [
        new Get(normalizationContext: ['groups' => ['treasure:read', 'treasure:item:read']]),
        new GetCollection(),
        new Post(security: 'is_granted("ROLE_TREASURE_CREATE")',),
//        new Put( security: 'is_granted("ROLE_TREASURE_EDIT")',),
        new Patch(
            security: 'is_granted("EDIT", object)',
//            securityPostDenormalize: 'is_granted("EDIT", object)'
        ),
        new Delete(  security: 'is_granted("ROLE_ADMIN")',)
    ],
    formats: [
        'jsonld',
        'json',
        'html',
        'jsonhal',
        'csv' => 'text/csv'
    ],
    normalizationContext: [
        'groups' => ['treasure:read']
    ],
    denormalizationContext: [
        'groups' => ['treasure:write'],
        AbstractObjectNormalizer::DISABLE_TYPE_ENFORCEMENT => false
    ],
    paginationItemsPerPage: 10
)]
#[ApiResource(
    uriTemplate: 'users/{user_id}/treasures.{_format}',
    shortName: 'Treasure',
    operations: [
        new GetCollection()
    ],
    uriVariables: [
        'user_id' => new Link(
            fromProperty: 'dragonTreasures', fromClass: User::class,
//            toProperty: 'owner'
        )
    ],
    normalizationContext: [
        'groups' => ['treasure:read']
    ],
    denormalizationContext: [
        AbstractObjectNormalizer::DISABLE_TYPE_ENFORCEMENT => false
    ],
)]
#[ApiFilter(BooleanFilter::class, properties: ['isPublished'])]
#[ApiFilter(PropertyFilter::class)]
#[ORM\Entity(repositoryClass: DragonTreasureRepository::class)]
#[ApiFilter(SearchFilter::class, properties: ['owner.name' => 'partial'])]
class DragonTreasure
{
    use Timestamp;

    /**
     * @var \DateTime|null
     * @Timestampable(on="create")
     * @Column(type="datetime")
     */
    #[Timestampable(on: 'create')]
    #[Column(type: Types::DATETIME_MUTABLE)]
    #[Groups(['treasure:read'])]
    protected $createdAt;

    /**
     * @var \DateTime|null
     * @Gedmo\Timestampable(on="update")
     * @ORM\Column(type="datetime")
     */
    #[Timestampable(on: 'update')]
    #[Column(type: Types::DATETIME_MUTABLE)]
    #[Groups(['treasure:read'])]
    protected $updatedAt;
    #[ORM\Id]
    #[ORM\GeneratedValue]
    #[ORM\Column]
    #[Groups(['treasure:read'])]
    private ?int $id = null;

    #[Groups(['treasure:read', 'treasure:write', 'users:item:read'])]
    #[ORM\Column(length: 255)]
    #[ApiFilter(SearchFilter::class, strategy: SearchFilter::STRATEGY_IPARTIAL)]
    #[NotBlank]
    #[NotNull]
    private ?string $name = null;

    #[Groups(['treasure:read', 'users:item:read'])]
    #[ORM\Column(type: Types::TEXT)]
    #[ApiFilter(SearchFilter::class, strategy: SearchFilter::STRATEGY_IPARTIAL)]
    private ?string $description = null;

    /**
     * Value of the treasure
     */
    #[Groups(['treasure:read', 'treasure:write', 'users:item:read'])]
    #[ORM\Column]
    #[ApiFilter(RangeFilter::class)]
    #[GreaterThanOrEqual(0)]
    #[NotBlank]
    #[NotNull]
    #[Type('integer')]
    private ?int $value = 0;

    #[Groups(['treasure:read', 'treasure:write'])]
    #[ORM\Column]
    #[GreaterThanOrEqual(0)]
    #[LessThanOrEqual(10)]
    #[NotBlank]
    #[Type('numeric')]
    private ?int $coolFactor;

    #[Groups(['admin:read', 'admin:write', 'owner:read'])]
    #[ORM\Column]
    #[Type('bool')]
//    #[ApiProperty(readable: false)]
//    #[ApiProperty(security: 'is_granted("EDIT", object)')] //To indicate this property should return when user can edit via DragonTreasureVoter
    private ?bool $isPublished = false;

    #[Groups(['treasure:read', 'treasure:write'])]
    #[ORM\ManyToOne(inversedBy: 'dragonTreasures')]
    #[ORM\JoinColumn(name: 'owner_id', onDelete: 'CASCADE')]
    #[ApiFilter(SearchFilter::class, 'exact')]
    #[IsValidOwner]
    private ?User $owner = null;

    public function __construct(string $name = null)
    {
        $this->name = $name;
    }

    public function getId(): ?int
    {
        return $this->id;
    }

    public function getName(): ?string
    {
        return $this->name;
    }

    public function setName(string $name): static
    {
        $this->name = $name;

        return $this;
    }

    #[Groups(['treasure:read'])]
    public function getShortDescription(): ?string
    {
        return u($this->description)->truncate(10, '...');
    }

    #[Groups(['treasure:read'])]
    public function getIsRich(): ?string
    {
        return true;
    }

    public function getDescription(): ?string
    {
        return $this->description;
    }

    #[Groups(['treasure:read'])]
    public function setDescription(string $description): static
    {
        $this->description = $description;

        return $this;
    }

    #[Groups(['treasure:read'])]
    public function getCreatedAtAgo(): ?string
    {
        return Carbon::parse($this->createdAt)->diffForHumans();
    }

    #[Groups(['treasure:write'])]
    #[SerializedName('description')]
    public function setTextDescription(string $description): static
    {
        $this->description = nl2br($description);

        return $this;
    }

    public function getValue()
    {
        return $this->value;
    }

    public function setValue($value): static
    {
        $this->value = $value;

        return $this;
    }

    public function getCoolFactor(): ?int
    {
        return $this->coolFactor;
    }

    public function setCoolFactor(?int $coolFactor): static
    {
        $this->coolFactor = $coolFactor;

        return $this;
    }

    public function getIsPublished()
    {
        return $this->isPublished;
    }

    public function setIsPublished(?bool $isPublished): static
    {
        $this->isPublished = $isPublished;

        return $this;
    }

    public function getOwner(): ?User
    {
        return $this->owner;
    }

    public function setOwner(?User $owner): static
    {
        $this->owner = $owner;

        return $this;
    }
}

The sample request payload:
PATCH api/treasures/:id

{
//   "name": "<string>",
//   "value": "sd"
  "coolFactor":2
//   "isPublished": "<boolean>",
//   "description": "<string>",
//   "owner": "api/users/1"
}
Reply

Hey @Huy!

Hmm. First, yea, AbstractObjectNormalizer is correct - ObjectNormalizer should work too (it's a sub-class), but the constant is on AbstractObjectNormalizer - so that's better anyway.

So you're still getting a response that looks like this?

{
    "title": "An error occurred",
    "detail": "The type of the \"coolFactor\" attribute must be \"string\", \"integer\" given.",
    "status": 400,
 }

I don't know if it's related, but I did misspeak in my previous message. You will still need to NOT have an argument type on setCoolFactor() and you still need to NOT have a property type on the $coolFactor property. This is because we DO need to allow the property to be set with a string type (for example) so that we can THEN run validation on that.

Cheers!

1 Reply
Cat in space

"Houston: no signs of life"
Start the conversation!

What PHP libraries does this tutorial use?

// composer.json
{
    "require": {
        "php": ">=8.1",
        "ext-ctype": "*",
        "ext-iconv": "*",
        "api-platform/core": "^3.0", // v3.0.8
        "doctrine/annotations": "^1.0", // 1.14.2
        "doctrine/doctrine-bundle": "^2.8", // 2.8.0
        "doctrine/doctrine-migrations-bundle": "^3.2", // 3.2.2
        "doctrine/orm": "^2.14", // 2.14.0
        "nelmio/cors-bundle": "^2.2", // 2.2.0
        "nesbot/carbon": "^2.64", // 2.64.1
        "phpdocumentor/reflection-docblock": "^5.3", // 5.3.0
        "phpstan/phpdoc-parser": "^1.15", // 1.15.3
        "symfony/asset": "6.2.*", // v6.2.0
        "symfony/console": "6.2.*", // v6.2.3
        "symfony/dotenv": "6.2.*", // v6.2.0
        "symfony/expression-language": "6.2.*", // v6.2.2
        "symfony/flex": "^2", // v2.2.4
        "symfony/framework-bundle": "6.2.*", // v6.2.3
        "symfony/property-access": "6.2.*", // v6.2.3
        "symfony/property-info": "6.2.*", // v6.2.3
        "symfony/runtime": "6.2.*", // v6.2.0
        "symfony/security-bundle": "6.2.*", // v6.2.3
        "symfony/serializer": "6.2.*", // v6.2.3
        "symfony/twig-bundle": "6.2.*", // v6.2.3
        "symfony/ux-react": "^2.6", // v2.6.1
        "symfony/validator": "6.2.*", // v6.2.3
        "symfony/webpack-encore-bundle": "^1.16", // v1.16.0
        "symfony/yaml": "6.2.*" // v6.2.2
    },
    "require-dev": {
        "doctrine/doctrine-fixtures-bundle": "^3.4", // 3.4.2
        "symfony/debug-bundle": "6.2.*", // v6.2.1
        "symfony/maker-bundle": "^1.48", // v1.48.0
        "symfony/monolog-bundle": "^3.0", // v3.8.0
        "symfony/stopwatch": "6.2.*", // v6.2.0
        "symfony/web-profiler-bundle": "6.2.*", // v6.2.4
        "zenstruck/foundry": "^1.26" // v1.26.0
    }
}
userVoice