If you liked what you've learned so far, dive in!
Subscribe to get access to this tutorial plus
video, code and script downloads.
With a Subscription, click any sentence in the script to jump to that part of the video!
Login SubscribeSeguimos teniendo un enorme problema para asegurarnos de que los tesoros no acaban siendo robados! Acabamos de cubrir el caso principal: si haces una petición POST o PUT a una ruta de tesoro, gracias a nuestra nueva validación, nos aseguramos de que te asignas el propietario a ti mismo, a menos que seas un administrador. ¡Sí!
Pero en nuestra API, al hacer POST o PUT a una ruta de usuario, se te permite enviar un campo dragonTreasures
. Esto, desgraciadamente, permite que se roben tesoros. Simplemente envía una petición PATCH
para modificar tu propio registro User
... y luego establece el campodragonTreasures
en una matriz que contenga las cadenas IRI de algunos tesoros que no te pertenezcan. ¡Vaya!
La solución más sencilla sería... hacer que el campo no sea escribible. Así, dentro deUser
, para dragonTreasures
, lo mantendríamos legible, pero eliminaríamos el grupo de escritura. Eso obligaría a todo el mundo a utilizar las rutas /api/treasures
para gestionar sus tesoros.
Si quieres mantener el campo dragonTreasures
escribible... puedes hacerlo, pero este problema tiene truco.
Pensemos: si envías un campo dragonTreasures
que contiene el IRI de un tesoro que no posees, eso debería provocar un error de validación. Vale... ¿podríamos añadir una restricción de validación sobre esta propiedad? El problema es que, para cuando se ejecuta esa validación, los tesoros enviados en el JSON ya se han establecido en esta propiedad dragonTreasures
. Y lo que es más importante, ¡el owner
de esos tesoros ya se ha actualizado a este User
!
Recuerda: cuando el serializador vea un DragonTreasure
que no sea ya propiedad de este usuario, llamará a addDragonTreasure()
... que a su vez llamará a setOwner($this)
. Así que, cuando se ejecute la validación, parecerá que somos los propietarios del tesoro... ¡aunque originalmente no lo fuéramos!
¿Qué podemos hacer? Bueno, la API Platform tiene un concepto de "datos anteriores". La API Platform clona los datos antes de deserializar el nuevo JSON sobre ellos, lo que significa que es posible obtener el aspecto original del objeto User
.
Desgraciadamente, ese clon es superficial, lo que significa que clona campos escalares -comousername
-, pero no clona ningún objeto -como los objetos DragonTreasure
. No hay forma, a través de la API Platform, de ver qué aspecto tenían originalmente.
Así que vamos a solucionarlo con la validación... pero con la ayuda de una clase especial de Doctrine llamada UnitOfWork
.
Muy bien, vamos a preparar una prueba para aclarar este molesto error. Dentro detests/Functional/
, abre UserResourceTest
. Copia la prueba anterior, pégala y llámala testTreasuresCannotBeStolen()
. Crea un segundo usuario conUserFactory::createOne()
... y necesitamos un DragonTreasure
que vamos a intentar robar. Asigna su owner
a $otherUser
:
... lines 1 - 4 | |
use App\Factory\DragonTreasureFactory; | |
... lines 6 - 8 | |
class UserResourceTest extends ApiTestCase | |
{ | |
... lines 11 - 48 | |
public function testTreasuresCannotBeStolen(): void | |
{ | |
$user = UserFactory::createOne(); | |
$otherUser = UserFactory::createOne(); | |
$dragonTreasure = DragonTreasureFactory::createOne(['owner' => $otherUser]); | |
... lines 54 - 66 | |
} | |
} |
¡Hagámoslo! Nos registramos como $user
, nos actualizamos -lo que está permitido- y luego, para el JSON, claro, quizá sigamos enviando username
... pero también enviamosdragonTreasures
configurado en un array con /api/treasures/
y$dragonTreasure->getId()
.
Al final, afirma que esto devuelve un 422:
... lines 1 - 4 | |
use App\Factory\DragonTreasureFactory; | |
... lines 6 - 8 | |
class UserResourceTest extends ApiTestCase | |
{ | |
... lines 11 - 48 | |
public function testTreasuresCannotBeStolen(): void | |
{ | |
$user = UserFactory::createOne(); | |
$otherUser = UserFactory::createOne(); | |
$dragonTreasure = DragonTreasureFactory::createOne(['owner' => $otherUser]); | |
$this->browser() | |
->actingAs($user) | |
->patch('/api/users/' . $user->getId(), [ | |
'json' => [ | |
'username' => 'changed', | |
'dragonTreasures' => [ | |
'/api/treasures/' . $dragonTreasure->getId(), | |
], | |
], | |
'headers' => ['Content-Type' => 'application/merge-patch+json'] | |
]) | |
->assertStatus(422); | |
} | |
} |
¡Vale! Copia el nombre del método. Esperamos que falle:
symfony php bin/phpunit --filter=testTreasuresCannotBeStolen
Y... ¡falla! Código de estado 200, ¡lo que significa que estamos permitiendo que nos roben el tesoro! ¡Qué susto!
Bien, vamos a crear una nueva clase validadora:
php ./bin/console make:validator
Llámala TreasuresAllowedOwnerChange
.
Utilízala inmediatamente. Sobre la propiedad dragonTreasures
, añade#[TreasuresAllowedOwnerChange]
:
... lines 1 - 15 | |
use App\Validator\TreasuresAllowedOwnerChange; | |
... lines 17 - 69 | |
class User implements UserInterface, PasswordAuthenticatedUserInterface | |
{ | |
... lines 72 - 107 | |
private Collection $dragonTreasures; | |
... lines 110 - 296 | |
} |
A continuación, en src/Validator/
, abre la clase validadora. Haremos una limpieza básica: utiliza la función assert()
para afirmar que $constraint
es una instancia de TreasuresAllowedOwnerChange
. Y también afirma que value
es una instancia deCollection
de Doctrine:
... lines 1 - 8 | |
class TreasuresAllowedOwnerChangeValidator extends ConstraintValidator | |
{ | |
public function validate($value, Constraint $constraint) | |
{ | |
assert($constraint instanceof TreasuresAllowedOwnerChange); | |
if (null === $value || '' === $value) { | |
return; | |
} | |
// meant to be used above a Collection field | |
assert($value instanceof Collection); | |
... lines 21 - 25 | |
} | |
} |
Sabemos que se utilizará sobre esta propiedad... así que será una especie de colección de DragonTreasures
.
Pero... ésta será la colección de objetos DragonTreasure
después de haber sido modificados. Tenemos que preguntar a Doctrine qué aspecto tenía cada DragonTreasure
cuando se consultó originalmente en la base de datos. Para ello, necesitamos coger un objeto interno de Doctrine llamado UnitOfWork
.
Encima, añadir un constructor, autoconectar EntityManagerInterface $entityManager
... y hacer que sea una propiedad privada:
... lines 1 - 6 | |
use Doctrine\ORM\EntityManagerInterface; | |
... lines 8 - 10 | |
class TreasuresAllowedOwnerChangeValidator extends ConstraintValidator | |
{ | |
public function __construct(private EntityManagerInterface $entityManager) | |
{ | |
} | |
... lines 16 - 40 | |
} |
Abajo, coge la unidad de trabajo con$unitOfWork = $this->entityManager->getUnitOfWork()
:
... lines 1 - 10 | |
class TreasuresAllowedOwnerChangeValidator extends ConstraintValidator | |
{ | |
... lines 13 - 16 | |
public function validate($value, Constraint $constraint) | |
{ | |
... lines 19 - 24 | |
// meant to be used above a Collection field | |
assert($value instanceof Collection); | |
$unitOfWork = $this->entityManager->getUnitOfWork(); | |
... lines 29 - 39 | |
} | |
} |
Se trata de un potente objeto que realiza un seguimiento de cómo cambian los objetos de entidad y se encarga de saber qué objetos deben insertarse, actualizarse o eliminarse de la base de datos cuando el gestor de entidades se vacía.
A continuación, foreach
sobre $value
-que será una colección- as $dragonTreasure
. Para ayudar a mi editor, afirmaré que $dragonTreasure
es una instancia deDragonTreasure
. Y ahora, obtén los datos originales:$originalData = $unitOfWork->getOriginalEntityData($dragonTreasure)
.
Muy bonito, ¿verdad? Veamos dd($dragonTreasure)
y $originalData
para ver qué aspecto tienen:
... lines 1 - 10 | |
class TreasuresAllowedOwnerChangeValidator extends ConstraintValidator | |
{ | |
... lines 13 - 16 | |
public function validate($value, Constraint $constraint) | |
{ | |
... lines 19 - 27 | |
$unitOfWork = $this->entityManager->getUnitOfWork(); | |
foreach ($value as $dragonTreasure) { | |
assert($dragonTreasure instanceof DragonTreasure); | |
$originalData = $unitOfWork->getOriginalEntityData($dragonTreasure); | |
dd($dragonTreasure, $originalData); | |
} | |
... lines 35 - 39 | |
} | |
} |
Go test go:
symfony php bin/phpunit --filter=testTreasuresCannotBeStolen
¡Sí! ¡Ha llegado al vertedero! ¡Y esto es genial! La primera parte es el objetoDragonTreasure
actualizado y su propietario tiene el id 1. No es super obvio, pero $user
será id 1 y $otherUser
será id 2. Así que el propietario era originalmente id 2, pero sí: ¡el usuario id 1 lo ha robado! Debajo, vemos los datos originales como una matriz. ¡Y su propietario era el ID 2!
Esta información nos pone en peligro. De vuelta dentro de nuestro validador, di$originalOwnerId
= originalData['owner_id']
. Y para que quede súper claro, pon$newOwnerId
a $dragonTreasure->getOwner()->getId()
.
Si no coinciden, tenemos un problema. Bueno, en realidad, si no tenemos un$originalOwnerId
, estamos creando un nuevo DragonTreasure
y no pasa nada. Así que si no hay $originalOwnerId
o el $originalOwnerId
es igual al $newOwnerId
, ¡estamos bien!
Si no... ¡está ocurriendo un saqueo! Mueve el $violationBuilder
hacia arriba, pero elimina el setParameter()
:
... lines 1 - 10 | |
class TreasuresAllowedOwnerChangeValidator extends ConstraintValidator | |
{ | |
... lines 13 - 16 | |
public function validate($value, Constraint $constraint) | |
{ | |
... lines 19 - 27 | |
$unitOfWork = $this->entityManager->getUnitOfWork(); | |
foreach ($value as $dragonTreasure) { | |
assert($dragonTreasure instanceof DragonTreasure); | |
$originalData = $unitOfWork->getOriginalEntityData($dragonTreasure); | |
$originalOwnerId = $originalData['owner_id']; | |
$newOwnerId = $dragonTreasure->getOwner()->getId(); | |
if (!$originalOwnerId || $originalOwnerId === $newOwnerId) { | |
return; | |
} | |
// the owner is being changed | |
$this->context->buildViolation($constraint->message) | |
->addViolation(); | |
} | |
} | |
} |
¡Ya está!
Pero nunca he personalizado el mensaje de error. En la clase Constraint
, dale a la propiedad $message
un mensaje por defecto mejor:
... lines 1 - 12 | |
class TreasuresAllowedOwnerChange extends Constraint | |
{ | |
... lines 15 - 18 | |
public string $message = 'One of the treasures illegally changed owners.'; | |
} |
Muy bien equipo, ¡hora de la verdad! Ejecuta la prueba:
symfony php bin/phpunit --filter=testTreasuresCannotBeStolen
¡Lo he clavado! El robo de tesoros queda oficialmente descartado. Ah, y aunque no lo he hecho, también podríamos inyectar el servicio Security
para permitir que los usuarios administradores hagan lo que quieran.
Siguiente paso: cuando creamos un DragonTreasure
, debemos enviar el campo owner
. Hagamos que por fin sea opcional. Si no pasamos el owner
, lo estableceremos en el usuario autenticado actualmente. Para ello, tenemos que engancharnos al proceso de "guardado" de la API Platform una vez más.
Hey @Michel
You're right, it won't check any other DragonTreasures, however, that's a problem only if you allow updating multiple objects at once, which we don't currently support. So, for now, it will just work :)
Cheers!
// composer.json
{
"require": {
"php": ">=8.1",
"ext-ctype": "*",
"ext-iconv": "*",
"api-platform/core": "^3.0", // v3.1.2
"doctrine/annotations": "^2.0", // 2.0.1
"doctrine/doctrine-bundle": "^2.8", // 2.8.3
"doctrine/doctrine-migrations-bundle": "^3.2", // 3.2.2
"doctrine/orm": "^2.14", // 2.14.1
"nelmio/cors-bundle": "^2.2", // 2.2.0
"nesbot/carbon": "^2.64", // 2.66.0
"phpdocumentor/reflection-docblock": "^5.3", // 5.3.0
"phpstan/phpdoc-parser": "^1.15", // 1.16.1
"symfony/asset": "6.2.*", // v6.2.5
"symfony/console": "6.2.*", // v6.2.5
"symfony/dotenv": "6.2.*", // v6.2.5
"symfony/expression-language": "6.2.*", // v6.2.5
"symfony/flex": "^2", // v2.2.4
"symfony/framework-bundle": "6.2.*", // v6.2.5
"symfony/property-access": "6.2.*", // v6.2.5
"symfony/property-info": "6.2.*", // v6.2.5
"symfony/runtime": "6.2.*", // v6.2.5
"symfony/security-bundle": "6.2.*", // v6.2.6
"symfony/serializer": "6.2.*", // v6.2.5
"symfony/twig-bundle": "6.2.*", // v6.2.5
"symfony/ux-react": "^2.6", // v2.7.1
"symfony/ux-vue": "^2.7", // v2.7.1
"symfony/validator": "6.2.*", // v6.2.5
"symfony/webpack-encore-bundle": "^1.16", // v1.16.1
"symfony/yaml": "6.2.*" // v6.2.5
},
"require-dev": {
"doctrine/doctrine-fixtures-bundle": "^3.4", // 3.4.2
"mtdowling/jmespath.php": "^2.6", // 2.6.1
"phpunit/phpunit": "^9.5", // 9.6.3
"symfony/browser-kit": "6.2.*", // v6.2.5
"symfony/css-selector": "6.2.*", // v6.2.5
"symfony/debug-bundle": "6.2.*", // v6.2.5
"symfony/maker-bundle": "^1.48", // v1.48.0
"symfony/monolog-bundle": "^3.0", // v3.8.0
"symfony/phpunit-bridge": "^6.2", // v6.2.5
"symfony/stopwatch": "6.2.*", // v6.2.5
"symfony/web-profiler-bundle": "6.2.*", // v6.2.5
"zenstruck/browser": "^1.2", // v1.2.0
"zenstruck/foundry": "^1.26" // v1.28.0
}
}
I think the
return
in the foreach loop should be acontinue
. Otherwise it will only check if the owner of the first dragon treasure is correct and if so, it will not check the other dragon treasures.