Skip to content

Instantly share code, notes, and snippets.

@webdevilopers
Last active July 25, 2020 08:09
Show Gist options
  • Save webdevilopers/93b82fedd25a9d43d2af64de2fa0c57f to your computer and use it in GitHub Desktop.
Save webdevilopers/93b82fedd25a9d43d2af64de2fa0c57f to your computer and use it in GitHub Desktop.
How not allow extra fields in Command DTO using Symfony Messenger
<?php
use Webmozart\Assert\Assert;
final class Address
{
/** @var string|null $street */
private $street;
/** @var Postcode|null $postcode */
private $postcode;
/** @var string|null $city */
private $city;
/** @var CountryCode $countryCode */
private $countryCode;
private function __construct(?string $aStreet, ?Postcode $aPostcode, ?string $aCity, CountryCode $aCountryCode)
{
Assert::nullOrNotEmpty($aStreet);
Assert::nullOrNotEmpty($aCity);
$this->street = $aStreet;
$this->postcode = $aPostcode;
$this->city = $aCity;
$this->countryCode = $aCountryCode;
}
public static function fromArray(array $array): Address
{
Assert::keyExists($array, 'street');
Assert::nullOrString($array['street']);
Assert::keyExists($array, 'postcode');
Assert::nullOrString($array['postcode']);
Assert::keyExists($array, 'city');
Assert::nullOrString($array['city']);
Assert::keyExists($array, 'countryCode');
Assert::nullOrString($array['countryCode']);
return new self(
$array['street'],
null !== $array['postcode'] ? Postcode::fromString($array['postcode']) : null,
$array['city'],
null !== $array['countryCode'] ? CountryCode::fromString($array['countryCode']) : null
);
}
public function toArray(): array
{
return [
'street' => $this->street,
'postcode' => null !== $this->postcode ? $this->postcode->toString() : null,
'city' => $this->city,
'countryCode' => null !== $this->countryCode ? $this->countryCode->toString() : null
];
}
public function street(): ?string
{
return $this->street;
}
public function postcode(): ?Postcode
{
return $this->postcode;
}
public function city(): ?string
{
return $this->city;
}
public function countryCode(): CountryCode
{
return $this->countryCode;
}
}
<?php
final class AgencyController
{
/** @var MessageBusInterface */
private $commandBus;
public function hireAction(Request $request): Response
{
$command = new HireAgency(json_decode($request->getContent(), true));
$this->commandBus->dispatch($command);
return new JsonResponse(null, Response::HTTP_CREATED);
}
}
<?php
use Symfony\Component\Validator\Constraints as Assert;
final class HireAgency
{
/**
* @var AgencyId
* @Assert\NotNull
* @Assert\Uuid
*/
private $agencyId;
/**
* @var string
* @Assert\NotNull
* @Assert\NotBlank
*/
private $name;
/**
* @var ValueAddedTaxIdentificationNumber
* @Assert\NotNull
* @Assert\NotBlank
*/
private $vatId;
/**
* @var string
* @Assert\Type(type="string")
* @Assert\NotNull
* @Assert\NotBlank
*/
private $contactPerson;
/**
* @var ContactInformation
* @Assert\NotNull
* @Assert\Type(type="array")
* @Assert\Collection(
* fields = {
* "email" = {
* @Assert\NotBlank(allowNull=true),
* @Assert\Email,
* },
* "phone" = {
* @Assert\NotBlank(allowNull=true),
* @Assert\Type(type="string")
* },
* "mobile" = {
* @Assert\NotBlank(allowNull=true),
* @Assert\Type(type="string")
* },
* "fax" = {
* @Assert\NotBlank(allowNull=true),
* @Assert\Type(type="string")
* },
* }, allowExtraFields=false
* )
*/
private $contactInformation;
/**
* @var Address
* @Assert\NotNull
* @Assert\Type(type="array")
* @Assert\Collection(
* fields = {
* "street" = {
* @Assert\NotBlank(allowNull=true),
* @Assert\Type(type="string"),
* },
* "postcode" = {
* @Assert\NotBlank(allowNull=true),
* @Assert\Type(type="string")
* },
* "city" = {
* @Assert\NotBlank(allowNull=true),
* @Assert\Type(type="string")
* },
* "countryCode" = {
* @Assert\NotBlank(allowNull=true),
* @Assert\Type(type="string")
* },
* }, allowExtraFields=false
* )
*/
private $address;
public function __construct(array $payload)
{
$this->agencyId = $payload['agencyId'];
$this->name = $payload['name'];
$this->vatId = $payload['vatId'];
$this->contactPerson = $payload['contactPerson'];
$this->contactInformation = $payload['contactInformation'];
$this->address = $payload['address'];
}
public function agencyId(): AgencyId
{
return AgencyId::fromString($this->agencyId);
}
public function name(): string
{
return $this->name;
}
public function vatId(): ValueAddedTaxIdentificationNumber
{
return ValueAddedTaxIdentificationNumber::fromString($this->vatId);
}
public function contactPerson(): string
{
return $this->contactPerson;
}
public function contactInformation(): ContactInformation
{
return ContactInformation::fromArray($this->contactInformation);
}
public function address(): Address
{
return Address::fromArray($this->address);
}
}
@webdevilopers
Copy link
Author

webdevilopers commented Jul 24, 2020

Came from:

Here is an example of a valid JSON payload request made to the controller:

{
  "agencyId": "014a933b-40ac-4683-a616-d66797165f35",
  "name": "Persona grata Zeitarbeit GmbH",
  "vatId": "DE",
  "contactPerson": "Max Mustermann",
  "contactInformation": {
		"phone": null,
		"mobile": null,
		"fax": null,
		"email": null
	},
  "address": {
    "street": "Heidkamp 40",
    "postcode": "29331",
    "city": "Lachendorf",
    "countryCode": "DE"
  }
}

Adding extra fields to address would violate the Collection constraint.

{
  "agencyId": "014a933b-40ac-4683-a616-d66797165f35",
  "name": "Persona grata Zeitarbeit GmbH",
  "vatId": "DE",
  "contactPerson": "Max Mustermann",
  "contactInformation": {
		"phone": null,
		"mobile": null,
		"fax": null,
		"email": null
	},
  "address": {
    "street": "Heidkamp 40",
    "postcode": "29331",
    "city": "Lachendorf",
    "countryCode": "DE",
    "whateverForInstanceOldApi": "iDontWantThis"
  }
}

This field was not expected.

Currently there is no danger from the client input since it will not get passed:

    public function __construct(array $payload)
    {
        $this->agencyId = $payload['agencyId'];
        $this->name = $payload['name'];
        $this->vatId = $payload['vatId'];
        $this->contactPerson = $payload['contactPerson'];
        $this->contactInformation = $payload['contactInformation'];
        $this->address = $payload['address'];
    }

But it would be nice to have this feature out of the box for the entire command when dispatching it.
This would restrict API clients to provide the correct JSON payload of the current API version and warn them otherwise.

Can the Collection constraint be applied to the complete DTO?

@weaverryan
Copy link

If I understand it correctly, you’ll like the validator to catch any extra JSON fields. If so, that’s just not possible with the validator in the normal setup. The problem is the order in which things happen:

A) you use the JSON to create the DTO
B) THEN you validate the DTO

By the time you get to step B, all you have is the finished object - the validator has no idea if there were originally extra fields in the JSON.

The only way to do this would be to turn the JSON into an array, then validate the array, and THEN turn it into a DTO. But that’s a lot of work just to prevent extra fields. For me, extra fields in an API are fine - just ignore them. If you have a reason to believe that clients might accidentally pass a specific extra field, then add that to your DTO and validate that it IS null (if it’s not null - tell them via the validation error that they are not allowed to send this field).

Cheers!

@webdevilopers
Copy link
Author

@weaverryan That makes sense. We used to structure commands differently before. We had an additional "payload" attribute that could be validated by the Collection constraint. But we wanted to simplify our DTOs. In the end you are right that this is not crucial to the actual application and maybe "over-engineering".

Thank you for your feedback!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment