FriendsOfSymfony / FOSRestBundle

This Bundle provides various tools to rapidly develop RESTful API's with Symfony
http://symfony.com/doc/master/bundles/FOSRestBundle/index.html
MIT License
2.79k stars 703 forks source link

Handle constructor with Symfony serializer #1946

Open soullivaneuh opened 5 years ago

soullivaneuh commented 5 years ago

I just move from JMS to Symfony serializer without touching the API itself. I have this entity:

class PowerDNSRecord
{
    private const DNS_TYPE = [
        'A' => 'A',
        'AAAA' => 'AAAA',
        'CNAME' => 'CNAME',
        'MX' => 'MX',
        'TXT' => 'TXT',
        'SPF' => 'SPF',
        'NS' => 'NS',
        'SRV' => 'SRV',
    ];

    private const DNS_TTL = [
        'ttl_300' => 300,
        'ttl_600' => 600,
        'ttl_900' => 900,
        'ttl_1800' => 1800,
        'ttl_3600' => 3600,
        'ttl_7200' => 7200,
        'ttl_18000' => 18000,
        'ttl_43200' => 43200,
        'ttl_86400' => 86400,
    ];

    private const NEED_DOUBLE_QUOTE_TYPE = [
        self::DNS_TYPE['TXT'],
        self::DNS_TYPE['SPF'],
    ];
    /**
     * @var int
     *
     * @ORM\Column(name="id", type="integer", nullable=false)
     * @ORM\Id
     * @ORM\GeneratedValue(strategy="IDENTITY")
     *
     * @Groups({"list", "details"})
     */
    private $id;

    /**
     * @var string
     *
     * @ORM\Column(name="name", type="string", length=255, nullable=true)
     * @Assert\Regex("/^((\*)|([[:alnum:]-_]+))(\.[[:alnum:]-_]+)*$/u")
     *
     * @Groups({"list", "details"})
     */
    private $name = '';

    /**
     * @var string|null
     *
     * @ORM\Column(name="type", type="string", length=6, nullable=true)
     *
     * @Groups({"list", "details"})
     */
    private $type;

    /**
     * @var string
     *
     * @ORM\Column(name="content", type="string", length=64000, nullable=true)
     * @Assert\NotBlank
     * @Assert\Length(max="64000")
     *
     * @Groups({"list", "details"})
     */
    private $content;

    /**
     * @var int
     *
     * @ORM\Column(name="ttl", type="integer", nullable=true)
     * @Assert\NotBlank
     *
     * @Groups("details")
     */
    private $ttl = 3600;

    /**
     * @var int
     *
     * @ORM\Column(name="prio", type="integer", nullable=true)
     *
     * @Groups("details")
     */
    private $prio = 0;

    /**
     * @var int|null
     *
     * @Gedmo\Timestampable(on="update")
     * @ORM\Column(name="change_date", type="integer", nullable=true)
     */
    private $changeDate;

    /**
     * @var string|null
     *
     * @ORM\Column(name="ordername", type="string", length=255, nullable=true)
     */
    private $ordername;

    /**
     * @var bool|null
     *
     * @ORM\Column(name="auth", type="boolean", nullable=true)
     */
    private $auth;

    /**
     * @var PowerDNSDomain
     *
     * @ORM\ManyToOne(targetEntity="PowerDNSBundle\Entity\PowerDNSDomain", inversedBy="records", cascade={"persist"}, fetch="EAGER")
     * @ORM\JoinColumn(name="domain_id", referencedColumnName="id", onDelete="CASCADE")
     */
    private $domain;

    public function __construct(PowerDNSDomain $domain)
    {
        $this->domain = $domain;
    }

And this error on creation POST request:

Cannot create an instance of PowerDNSBundle\Entity\PowerDNSRecord from serialized data because its constructor requires parameter "domain" to be present.

I found this serializer documentation note about this: https://symfony.com/doc/current/components/serializer.html#handling-constructor-arguments

But I don't know about FOSRest. Is it correctly handled?

soullivaneuh commented 5 years ago

More info. Here is the post action:

/**
 * @QueryParam(name="record", key=null)
 *
 * @ParamConverter("record", converter="fos_rest.request_body")
 *
 * @IsGranted("edit", subject="domain")
 */
public function postAction(PowerDNSDomain $domain, PowerDNSRecord $record, ConstraintViolationListInterface $validationErrors): View
{
    if ($validationErrors->count() > 0) {
        return $this->handleBodyValidationErrorsView($validationErrors);
    }

    $record
        ->setDomain($domain)
        ->setName($record->getName())
        ->setType($record->getType())
    ;

    $this->get('manager.dns')->saveRecord($record);

    $view = $this->view($record, 201);
    $view->getContext()->setGroups(['details']);

    return $view;
}

Also, if I try to dirty change the code on SymfonySerializerAdapter:

/**
 * {@inheritdoc}
 */
public function deserialize($data, $type, $format, Context $context)
{
    $newContext = $this->convertContext($context);

    if ($type === PowerDNSRecord::class) {
        $newContext['default_constructor_arguments'] = [
            PowerDNSRecord::class => ['domain' => new PowerDNSDomain()],
        ];
    }

    return $this->serializer->deserialize($data, $type, $format, $newContext);
}

It works.

GuilhemN commented 5 years ago

I think the issue is your input data, constructor arguments are entirely managed by symfony serializer. Did you actually put something under domain in your input?

soullivaneuh commented 5 years ago

@GuilhemN not on the body, and AFAIK I shouldn't because the URL is POST /dns/{domain_id}/records.

The domain_id is used to retrieve the domain.

GuilhemN commented 5 years ago

This is not a supported case, the serializer doesn't know about other request parameters, the only solution I see is to deserialize your input by hand (with https://symfony.com/doc/current/components/serializer.html#handling-constructor-arguments).

karousn commented 5 years ago

i have a question about your move from JMS to Symfony serializer, any raison ? or just for testing ? I have the same issue in JMS before i found a bit code that help me to fix that.