Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

RequestDataObject as in java spring #46991

Closed
dozsan opened this issue Jul 20, 2022 · 9 comments
Closed

RequestDataObject as in java spring #46991

dozsan opened this issue Jul 20, 2022 · 9 comments
Labels

Comments

@dozsan
Copy link

dozsan commented Jul 20, 2022

Description

As it is included in FOSRest and ApiPlatfom, I would be happy if there was a native symfony component. Actually, the main point is to be able to create a RequestDataObject from the Request with the help of a serializer. Of course, it can also be projected now, but all of them are external packages.

Example

POST: /foo/bar/{barId}?status=active

{
    "id": 1,
    "name": "Name"
}

FooBarRequestData:

class FooBarRequestData {
    private int $barId;
    private string $status;
    private int $id;
    private string $name;

    /**
     * @return int
     */
    public function getBarId(): int
    {
        return $this->barId;
    }

    /**
     * @return string
     */
    public function getStatus(): string
    {
        return $this->status;
    }

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

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

Serialize an Object from the Symfony Request data

@mesilov
Copy link

mesilov commented Jul 24, 2022

I think this feature is very useful, but need add use case for validate json structure

@MatTheCat
Copy link
Contributor

MatTheCat commented Jul 24, 2022

I investigated this some times ago and it felt like it would be complicated to add to the core.

On the other hand, one could implement its own services to validate requests payload using Symfony’s validator and hydrating the corresponding object. (The reason I feel like this is that this process would be highly specific to your application.)

Add a custom value resolver and you’re done.

You may also be interested by cuyz/valinor

@Philosoft
Copy link

In my recent project I did it like this.

Define request structure

That's very simple case, but I guess it's enough to demonstrate the general idea

{
    "productId": "Integer"
}

Define request at backend

final class DeleteProductQuery
{
    #[Assert\Positive]
    public int $productId;
}

Define response at backend

In this case there are two of them

first one is empty ('coz I need only to indicate that operation was a success)

final class EmptyResponse implements ApiResponseInterface
{
}

second one for errors (generic)

final class ErrorsResponse implements ApiResponseInterface
{
    /** @param string[] $errors */
    public function __construct(public readonly array $errors)
    {
    }
}

Stich everything together in controller

try {
    /** @var DeleteProductQuery $query */
    $query = $serializer->deserialize($request->getContent(), DeleteProductQuery::class, 'json');
} catch (NotEncodableValueException $e) {
    // getSimpleErrorApiResponse does a bit of extra work, but inside relies on ErrorsResponse
    return $this->getSimpleErrorApiResponse("incorrect json: {$e->getMessage()}")
        ->setStatusCode(Response::HTTP_BAD_REQUEST);
}

$validationResponse = $this->getValidationResponseForAPI($validator->validate($query));
if ($validationResponse !== null) {
    return $validationResponse;
}

try {
    // $command is a standalone service which does all the work
    $command->execute($query, $currentUser);
} catch (GenericCommandException $e) {
    return $this->getSimpleErrorApiResponse($e->getMessage())
        ->setStatusCode(Response::HTTP_INTERNAL_SERVER_ERROR);
}

return $this->getApiResponse(new EmptyResponse());

Both getSimpleErrorApiResponse and getApiResponse inside use $this->json() from AbstractController

...

Thought about generalizing it, but never really get around to it 😅 Step to a right direction probably would be

  • interface for queries
  • value resolver based on interface

In my case that would take care of first part (creating *Query object from request) and second (validation)

p.s.

symfony/serializer is my favorite component ❤️

@MatTheCat
Copy link
Contributor

MatTheCat commented Jul 25, 2022

@Philosoft it seems you use the term “query” too broadly: in CQS terminology your DeleteProductQuery would probably be a command.

Also your example does not handle missing keys (which would result in an invalid object) neither wrong types (it would crash as you don’t catch NotNormalizableValueException).

Anyways there certainly would be way to generalize individual use cases but I feel like they would diverge too much to create a one-fit-all solution 🤔

@mesilov
Copy link

mesilov commented Jul 25, 2022

if we use CQS terminology we must think about supporting both types:

  • command (JSON send with POST request)
  • query (filter DTO for READ model from GET request and building from part of URL and query string parameters)

Examples:
command

class AddContactCommand
{
    private FullName $fullName;
    private Gender $gender;
    private ?DateTimeZone $dateTimeZone;
    private ?Birthday $birthday;
    private ?array $externalIds;
...
}

query

class ContactsFilter
{
    private ?UuidInterface $id;
    /**
     * @var array<string,string>|null
     */
    private ?array $externalSystemIds;
}    

If we talk about READ model we must also think about pagination \ filtering \ ordering elements

As it is included in ApiPlatfom

Does anybody work with ApiPlatform and can add related links with documentation \ example?

@Philosoft
Copy link

@MatTheCat just finished generalizing for my case. It's not CQS really, "query" is just the best term we come up with for generally describing request "in rigid format" from frontend 😅

I agree that my example is flawed (I was in a hurry to contribute "a great idea") and just ended up making a fool of myself 🤦‍♂️

For generalizing on "framework level" I think there will be too many opinionated choices, so it'd turn out to be form requests from laravel

@dozsan
Copy link
Author

dozsan commented Jul 27, 2022

I investigated this some times ago and it felt like it would be complicated to add to the core.

On the other hand, one could implement its own services to validate requests payload using Symfony’s validator and hydrating the corresponding object. (The reason I feel like this is that this process would be highly specific to your application.)

Add a custom value resolver and you’re done.

You may also be interested by cuyz/valinor

That's exactly what I was thinking!
https://jmsyst.com/libs/serializer/master/reference/annotations
This logic could be implemented at the symfony level to make it basic :)
I use this, and I also use the symfony Assert package!

ParamConverter:

<?php

namespace App\Request\ParamConverter;

use App\Exception\ConstraintViolationException;
use FOS\RestBundle\Context\Context;
use FOS\RestBundle\Serializer\Serializer;
use Sensio\Bundle\FrameworkExtraBundle\Configuration\ParamConverter;
use Sensio\Bundle\FrameworkExtraBundle\Request\ParamConverter\ParamConverterInterface;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Validator\Validator\ValidatorInterface;

class RequestBodyParamConverter implements ParamConverterInterface
{
    public const CONVERT_PARAMETER_NAME = 'admed.request.body.param';

    /**
     * @var Serializer
     */
    protected Serializer $serializer;

    /**
     * @var ValidatorInterface
     */
    protected ValidatorInterface $validator;

    /**
     * @param Serializer $serializer
     * @param ValidatorInterface $validator
     */
    public function __construct(
        Serializer $serializer,
        ValidatorInterface $validator
    ) {
        $this->serializer = $serializer;
        $this->validator = $validator;
    }

    /**
     * @inheritDoc
     */
    public function apply(Request $request, ParamConverter $configuration)
    {
        if ($request->request->count() > 0) { // Form-data
            $params = $request->request->all();
        } else { // Json
            $params = json_decode($request->getContent(), true);
            if (empty($params)) {
                $params = [];
            }
        }

        if (count($request->attributes->all()['_route_params']) > 0) {
            $params = array_merge(
                $params,
                $request->attributes->all()['_route_params']
            );
        }

        $object = $this->serializer->deserialize(
            json_encode($params),
            $configuration->getClass(),
            'json',
            new Context()
        );

        $errors = $this->validator->validate($object);

        if ($errors->count() > 0) {
            throw ConstraintViolationException::requestData($errors);
        }

        $request->attributes->set($configuration->getName(), $object);

        return true;
    }

    /**
     * @inheritDoc
     */
    public function supports(ParamConverter $configuration): bool
    {
        return null !== $configuration->getClass() && self::CONVERT_PARAMETER_NAME === $configuration->getConverter();
    }
}

RequestDataObject:

<?php namespace App\DataObject\Controller\Crm\Product;

use App\DataObject\Controller\RequestDataInterface;
use JMS\Serializer\Annotation as JMS;
use Symfony\Component\Validator\Constraints as Assert;

class ProductActionCloseRequestData implements RequestDataInterface
{
    /**
     * @var int
     * @Assert\NotBlank
     * @Assert\GreaterThan(0)
     * @Assert\Type("integer")
     * @JMS\Type("int")
     */
    protected $id;

    /**
     * @var \DateTime
     * @Assert\NotBlank
     * @JMS\Type("DateTime<'Y-m-d'>")
     */
    protected $endDate;

    /**
     * @var string
     * @Assert\NotBlank
     * @JMS\Type("string")
     */
    protected $contractStatus;

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

    /**
     * @return \DateTime
     */
    public function getEndDate(): \DateTime
    {
        return $this->endDate;
    }

    /**
     * @return string
     */
    public function getContractStatus(): string
    {
        return $this->contractStatus;
    }
}

ProductController::closeAction

class ProductController {
    /**
     * @Route("/products/{id}/close", name="crm_api_product_close", methods={"POST"})
     * @ParamConverter("requestData", converter="admed.request.body.param")
     * @param ProductActionCloseRequestData $requestData
     * @return JsonResponse
     */
    public function closeAction(ProductActionCloseRequestData $requestData): JsonResponse
    {
       return $this->json([]);
    }
}

I'm using this now and I thought that it would be possible to make a symfony package out of it, because if something were to happen to it, there would be no support for future symfony packages!

@y4roc
Copy link

y4roc commented Jan 2, 2023

I have implemented something similar in my small project.

In the controller I use an attribute to which I pass as parameter the class of the RequestClass. A subscriber then loads the post content and validates it using the RequestClass. Subsequently, an instance of the RequestClass, with the values of the request, is passed as an argument to the controller method.

If the validation fails, the call throws a bad request exception.

Example:

class IndexAction {
  #[RequestValidator(class: IndexRequest::class)]
  public function __invoke(IndexRequest $request) {
    ...
  }
}
class IndexRequest {
  #[Assert\NotNull]
  #[Assert\Type(type: 'string')]
  public $username

  public function getUsername(): string {
    return $this->string;
  }
}

@BASAKSemih
Copy link
Contributor

I think we can close this issue through #49138
@nicolas-grekas

@fabpot fabpot closed this as completed Jun 23, 2023
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
Projects
None yet
Development

No branches or pull requests

8 participants