I have an API Platform DataTransformer that uses an injected ValidatorInterface to verify the JSON POST body of an incoming request. It is basically a copy of the example in the docs:
<?php
namespace App\DataTransformer;
use ApiPlatform\Core\Validator\ValidatorInterface;
use ApiPlatform\Core\DataTransformer\DataTransformerInterface;
use App\Entity\Ticket;
use App\DTO\TicketInput;
class InputDataTransformer implements DataTransformerInterface
{
public function __construct(
private readonly ValidatorInterface $validator,
) {}
/**
* #param TicketInput $object
*/
public function transform($object, string $to, array $context = []): Ticket
{
// Validation
$this->validator->validate($object);
$ticket = new Ticket($object->id, $object->content);
return $ticket;
}
public function supportsTransformation($data, string $to, array $context = []): bool
{
return (Ticket::class === $to) && (($context['input']['class'] ?? null) === TicketInput::class);
}
}
Via a ContextBuilder and the supportsTransformation() I ensure that the incoming $object is of type TicketInput. This class is as easy as that:
<?php
namespace App\DTO;
class TicketInput
{
public int $id;
public ?string $content;
}
What I am wondering about: The validator validates via the TicketInput::class that $id must be an integer and is not nullable. $content must be a string and is allowed to be null.
So these are valid inputs:
{
"id": 123,
"content": null
},
{
"id": 123,
"content": "testtest"
}
And this is invalid and gets rejected (400 - Bad request):
{
"id": null,
"content": "foobar"
}
So far so good. But one of my unit tests uses this JSON as request body:
{
"content": "foobar"
}
This broke my code as $id is a required value for the resulting Ticket::class.
I am pretty surprised that the last case was able to pass the validator, as I explicitly define the $id as (not nullable) class variable in TicketInput::class. One could argue that the validator is not obliged to validate properties that are uninitialized at time of validation (which is the case here, according to a var_dump) but nevertheless I am interested in how to define that this property is mandatory and has to be part of the JSON POST body.
The best what I could come up with was the Symfony Validation Constraint #[Assert\NotBlank(allowNull: false)] (see docs), which checks against is_null() and indirectly isset(), but I can't believe that this is the solution and I have to flag every single property like that (the actual TicketInput::class is way more huge and complex than this MWE).
Isn't there any validator config flag that tells it to assure the properties in a DTO are mandatory in a JSON body?
Every simple JSON Schema validator is capable of this and I am wondering why I don't find any hints for this either in the Symfony Docs nor the API Platform docs.
Related
I use DTOs as the data_class for Symfony form types. There is one thing that does not work for me when I use typed properties (PHP 7.4) in these DTOs.
EXAMPLE:
class ProductDto
{
/*
* #Assert\NotBlank
*/
public string $title;
}
This generally seems to work quite well – in case the user submits the form with a blank title or description, the validation kicks in and the form is displayed with validation warnings.
BUT THERE IS A PROBLEM when data is added while creating a form (e.g. the edit form):
$productDto = new ProductDto();
$productDto->title = 'Foo';
$form = $this->createForm(ProductFormType::class, $productDto);
Initially the form is displayed as expected with Foo as the value for the title. When a user clears the title input form field and submits the form an exception like this is thrown:
Typed property `App\Form\Dto\ProductDto::$title` must be string, null used
As far as I can see this is caused by the fact that during Form->handleRequest() the title is set to null after it was set to "Foo" before, right?
Is there a solution for this problem?
Since PHP 7.4 introduces type-hinting for properties, it is particularly important to provide valid values for all properties, so that all properties have values that match their declared types.
A property that has never been assigned doesn't have a null value, but it is on an undefined state, which will never match any declared type. undefined !== null.
Here is an example:
<?php
class Foo
{
private int $id;
private ?string $val;
public function __construct(int $id)
{
$this->id = $id;
}
}
For the code above, if you did:
<?php
foo = new Foo(1);
$foo->getVal();
You would get:
Fatal error: Uncaught Error: Typed property Foo::$val must not be
accessed before initialization
See this post for more details https://stackoverflow.com/a/59265626/3794075
and see this bug https://bugs.php.net/bug.php?id=79620
This is what I just came up with:
DTO:
use GenericSetterTrait;
/**
* #Assert\NotBlank
*/
public string $title;
public function setTitle(?string $title): void
{
$this->set('title', $title);
}
/**
* #Assert\NotNull
*/
public Foo $foo;
public function setFoo(?Foo $foo): void
{
$this->set('foo', $foo);
}
Trait:
trait GenericSetterTrait
{
private function set(string $propertyName, $value): void
{
if ($value === null) {
unset($this->{$propertyName});
} else {
$this->{$propertyName} = $value;
}
}
}
Seems to work. What do you think? Any objections?
I have a DTO input that needs to be transformed into Entity object. DTO needs to be validated first and may be in invalid state.
There is section in manual that talks about validation of DTO https://api-platform.com/docs/core/dto/#validating-data-transfer-objects but it does not say what to to with validation result, whether it should be passed somewhere or thrown directly.
final class MyDataDransformer implements DataTransformerInterface
{
private ValidatorInterface $validator;
public function __construct(ValidatorInterface $validator)
{
$this->validator = $validator;
}
public function transform($dto, string $to, array $context = []): MyEntityObject
{
/** #var \Symfony\Component\Validator\ConstraintViolationList */
$validationResult = $this->validator->validate($dto);
if ($validationResult->count() > 0) {
// how to throw exception with validation result here?
// is this right place to throw this exception?
}
// if no validation errors, construct entity object and return as normal
}
public function supportsTransformation($data, string $to, array $context = []): bool
{
return true; // for simplicity
}
}
Api Platform has mechanism for handling validation exceptions for entity object, it will format ConstraintViolationList object and output all errors as error type response. I need same for DTO.
There's a service in api platform that throws an exception if there's violations.
It is not the ValidatorInterface from symfony , but the api platform one
use ApiPlatform\Core\Validator\ValidatorInterface;
I have this entity:
AppBundle\Entity\Ciudad
class Ciudad{
...
/**
* #ORM\ManyToOne(targetEntity="AppBundle\Entity\ComunidadAutonoma")
* #ORM\JoinColumns({
* #ORM\JoinColumn(name="id_ccaa", referencedColumnName="id")
* })
*/
private $ccaa;
....
public function getCcaa()
{
return $this->ccaa;
}
public function setCcaa(ComunidadAutonoma $ccaa)
{
$this->ccaa = $ccaa;
}
}
And the other entity is:
AppBundle\Entity\ComunidadAutonoma
class ComunidadAutonoma{
properties
getters
setters
}
In a controller, I get data from a form, and I´m triying to deserialize the data into a Ciudad entity, but is getting me allways the same error:
Expected argument of type "AppBundle\Entity\ComunidadAutonoma", "integer" given
In the form data I send to the action in the controller, the value of the comunidadautonoma is the id of the selected option in a combo:
{
parameters...
ccaa:7,
parameters...
}
In my controller I have this:
<?php
use Symfony\Component\Serializer\Serializer;
use Symfony\Component\Serializer\Encoder\XmlEncoder;
use Symfony\Component\Serializer\Encoder\JsonEncoder;
use Symfony\Component\Serializer\Normalizer\ObjectNormalizer;
use AppBundle\Entity\Ciudad;
class CiudadController extends Controller
{
public function procesarAction(Request $request)
{
$encoders = array(new XmlEncoder(), new JsonEncoder());
$normalizers = array(new ObjectNormalizer());
$this->serializer = new Serializer($normalizers, $encoders);
$ciudad= $this->serializer->deserialize($parametros['parametros'], Ciudad::class, 'json');
}
}
Am I missing something?Do I need any special configuration to deserializer an entity with a relation?
You dont have to do anything if you properly configured a type. While creating a Form Type for your entity please add class name to your type like:
public function configureOptions(OptionsResolver $resolver)
{
$resolver->setDefaults([
'data_class' => Ciudad::class,
]);
}
And please use english naming for your projects.
First of all, since you are sending form data to your controller you could use Form Type classes to leverage all the power of the Symfony Form Component that will all this job for you.
Answering your specific question (and assuming you cannot/don't want to use Symfony Form Component) this error is absolutely expected. As you can see in your setCcaa function declaration inside Ciudad class:
public function setCcaa(ComunidadAutonoma $ccaa)
Because of the type-hinting (ComunidadAutonoma $ccaa) setCcaa function expects an argument of type ComunidadAutonoma. Now when Symfony serializer tries to denormalize your json object it calls setCcaa function with argument the ccaa value provided in your json (in your example is 7) which happens to be an integer. So Symfony complains that you provide an integer instead of ComunidadAutonoma type.
In order to solve this problem you have to create and use your own normalizer so that you can transform this integer to the corresponding entity object from your database. Something like this:
class EntityNormalizer extends ObjectNormalizer
{
/**
* Entity manager
* #var EntityManagerInterface
*/
protected $em;
public function __construct(
EntityManagerInterface $em,
?ClassMetadataFactoryInterface $classMetadataFactory = null,
?NameConverterInterface $nameConverter = null,
?PropertyAccessorInterface $propertyAccessor = null,
?PropertyTypeExtractorInterface $propertyTypeExtractor = null
) {
parent::__construct($classMetadataFactory, $nameConverter, $propertyAccessor, $propertyTypeExtractor);
// Entity manager
$this->em = $em;
}
public function supportsDenormalization($data, $type, $format = null)
{
return strpos($type, 'App\\Entity\\') === 0 && (is_numeric($data) || is_string($data));
}
public function denormalize($data, $class, $format = null, array $context = [])
{
return $this->em->find($class, $data);
}
}
What this normalizer does is that it checks if your data type (in this case $ccaa) is type of an entity and if the data value provided (in this case 7) is an integer, it transforms this integer to the corresponding entity object from your database (if existing).
To get this normalizer working you should also register it in your services.yaml configuration, with the appropriate tags like this:
services:
App\Normalizer\EntityNormalizer:
public: false
autowire: true
autoconfigure: true
tags:
- { name: serializer.normalizer }
You could also set the normalizer's priority but since the default priority value is equal to 0 when Symfony's built-in normalizers' priority is by default negative, your normalizer will be used first.
You could check a fully explained example of this in this fine article.
In my admin panel created with EasyAdminBundle, my form validations only work with fields that do not have the CKEditorType. Some fields need to be edited so I implemented a WYSIWYG with FOSCKEditorBundle.
Snippet from field concerned:
- { property: 'content', type: 'FOS\CKEditorBundle\Form\Type\CKEditorType'}
When I submit the form with an empty 'content' field, I get an InvalidArgumentException with the error: Expected argument of type "string", "NULL" given. instead of a validation error like Please fill in this field.
Snippet from field concerned without CKEditor:
- { property: 'content' }
=> validation works perfectly.
My entity field:
/**
* #ORM\Column(type="text")
* #Assert\NotBlank
* #Assert\NotNull
*/
private $content;
The Symfony profiler shows that this field indeed has a required attribute.
How can enable the validations with the CKEditor field type?
It's not about ckeditor. All you need is to fix your content setter to accept NULL through the argument. Then the validation process should be fired correctly:
public function setContent(?string $content) {
$this->content = $content;
retrun $this;
}
Validation is performed after request values are set to form data (in your case entity) fields. You can find form submit flow here: https://symfony.com/doc/current/form/events.html#submitting-a-form-formevents-pre-submit-formevents-submit-and-formevents-post-submit
To overcome this by leaning on Symfony's Form builder, I've added constraint "NotBlank" to the "CKEditorField".
It looks like this at the controller:
...
use App\Admin\Field\CKEditorField;
use Symfony\Component\Validator\Constraints\NotBlank;
...
public function configureFields(string $pageName): iterable
{
return [
IdField::new('id')->hideOnForm(),
TextField::new('title')->setFormTypeOption('required',true),
CKEditorField::new('description')->setFormTypeOption('required',true)
->setFormTypeOption('constraints',[
new NotBlank(),
])
];
}
...
And the EasyAdmin field class file which is used in controller (add this to follow EasyAdmin's approach):
<?php
namespace App\Admin\Field;
use EasyCorp\Bundle\EasyAdminBundle\Contracts\Field\FieldInterface;
use EasyCorp\Bundle\EasyAdminBundle\Field\FieldTrait;
use Symfony\Component\Form\Extension\Core\Type\TextareaType;
use FOS\CKEditorBundle\Form\Type\CKEditorType;
final class CKEditorField implements FieldInterface
{
use FieldTrait;
public static function new(string $propertyName, ?string $label = null):self
{
return (new self())
->setProperty($propertyName)
->setLabel($label)
->setFormType(CKEditorType::class)
->onlyOnForms()
;
}
}
File structure
customerService.PHP
include 'vo/VOCustomer.php';
include 'mydb.php';
class customerService
{
public function createCustomer(VOCustomer $cus)
{
$db = new mydb();
$db->connect();
$query = sprintf("insert into customer (CusId, CusName, CusContact,idcompany) values ('%s','%s','%s','%s')",
mysql_real_escape_string($cus->CusId),
mysql_real_escape_string($cus->CusName),
mysql_real_escape_string($cus->CusContact),
mysql_real_escape_string($cus->idcompany));
$rs = mysql_query($query) or die ("Unable to complete query.");
return 'success';
}
}
vo/VOCustomer.php
class VOCustomer {
public $CusId;
public $CusName;
public $CusContact;
public $idcompany;
}
When importing the customerService.php to a flex zend project Its possible that the data type may not return as VOCustomer sometimes it will show Object as type
How to make the passing object as VOcustomer object ?
I'm not sure the 'Connect to PHP' wizard understands type hinting.
Even if it does Zend AMF will pass an Objet not a VOCustomer to the method.
It's safer to add a PHPDoc comment:
/**
* #param VOCustomer $cus
*/
public function createCustomer($cus)
Second add dummy function to your service that returns VOCustomer. The 'Connect to PHP' wizard generates a value object only if it's returned by a service method.
/**
* #return VOCustomer
*/
public function getCustomer() {
//Do nothing
}