Flag of Ukraine
SymfonyCasts stands united with the people of Ukraine

Filtrar la colección de relaciones

Keep on Learning!

If you liked what you've learned so far, dive in!
Subscribe to get access to this tutorial plus
video, code and script downloads.

Start your All-Access Pass
Buy just this tutorial for $12.00

With a Subscription, click any sentence in the script to jump to that part of the video!

Login Subscribe

Oye, ¡hemos creado una API bastante elegante! Tenemos unos cuantos sub-recursos y datos de relación incrustados, que se pueden leer y escribir. Todo esto es estupendo... pero seguro que aumenta la complejidad de nuestra API, sobre todo en lo que se refiere a la seguridad.

Por ejemplo, ya no podemos ver tesoros no publicados desde las rutas GET colección o GET individual. Pero aún podemos ver tesoros no publicados si obtienes un usuario y lees su campo dragonTreasures.

Escribir la prueba

Preparemos rápidamente una prueba para exponer este problema. Abre nuestro UserResourceTest. En la parte inferior, añade una función pública testUnpublishedTreasuresNotReturned(). Dentro de ella, crea un usuario con UserFactory::createOne(). A continuación, utiliza DragonTreasureFactorypara crear un tesoro que sea isPublished falso y tenga su owner establecido en$user... sólo para que sepamos quién es el propietario.

Para la acción, digamos $this->browser()... y necesitamos iniciar sesión para utilizar la ruta... pero no nos importa con quién iniciamos sesión... así que digamos actingAs()UserFactory::createOne() para iniciar sesión como otra persona.

Luego ->get() /api/users/ $user->getId() . Termina con assertJsonMatches()que el length() de dragonTreasures es cero -utilizando una función genial length() de esa sintaxis JMESPath:

... lines 1 - 8
class UserResourceTest extends ApiTestCase
{
... lines 11 - 68
public function testUnpublishedTreasuresNotReturned(): void
{
$user = UserFactory::createOne();
DragonTreasureFactory::createOne([
'isPublished' => false,
'owner' => $user,
]);
$this->browser()
->actingAs(UserFactory::createOne())
->get('/api/users/' . $user->getId())
->assertJsonMatches('length("dragonTreasures")', 0);
}
}

¡Vamos a probarlo! Copia el método... y ejecútalo con --filter= ese nombre:

symfony php bin/phpunit --filter=testUnpublishedTreasuresNotReturned

¡Vale! Esperaba que 1 fuera igual a 0 porque estamos devolviendo el tesoro no publicado... ¡pero no queremos!

Cómo se cargan las relaciones

Primero... ¿por qué se devuelve este DragonTreasure inédito? ¿No creamos clases de extensión de consultas para evitar exactamente esto?

Bueno .... algo importante que hay que entender es que estas clases de extensión de consulta se utilizan sólo para la consulta principal en una ruta. Por ejemplo, si utilizamos el endpoint GET colección para tesoros, la consulta "principal" es para esos tesoros y se llama a la extensión de consulta colección.

Pero cuando hacemos una llamada a un punto final de usuario -como GET un único User - API Platform no está haciendo una consulta para cualquier tesoro: está haciendo una consulta para ese único User. Una vez que tiene ese User, para obtener ese campo dragonTreasures, no hace otra consulta para esos, al menos no directamente. En cambio, si abre la entidad User, la API Platform -a través del serializador- simplemente llama agetDragonTreasures().

Así que consulta el User, llama a ->getDragonTreasures()... y lo que devuelva se fija en el campo dragonTreasures. Y como esto devuelve todos los tesoros relacionados, eso es lo que obtenemos: incluidos los no publicados.

Añadir un método Getter filtrado

¿Cómo podemos solucionar esto? Añadiendo un nuevo método que sólo devuelva los tesoros publicados. Digamos public function getPublishedDragonTreasures(), que devuelve unCollection. Dentro, podemos ponernos elegantes: devuelve $this->dragonTreasures->filter()pasándole una llamada de retorno con un argumento DragonTreasure $treasure. Luego, devuelve$treasure->getIsPublished():

... lines 1 - 69
class User implements UserInterface, PasswordAuthenticatedUserInterface
{
... lines 72 - 216
public function getPublishedDragonTreasures(): Collection
{
return $this->dragonTreasures->filter(static function (DragonTreasure $treasure) {
return $treasure->getIsPublished();
});
}
... lines 223 - 303
}

Es un truco ingenioso para recorrer todos los tesoros y obtener una nueva y brillante colección sólo con los publicados.

Nota al margen: una desventaja de este enfoque es que si un usuario tiene 100 tesoros... pero sólo 10 de ellos están publicados, internamente, Doctrine consultará primero los 100... aunque sólo devolvamos 10. Si tienes colecciones grandes, esto puede ser un problema de rendimiento. En nuestro tutorial de Doctrine, hablamos de solucionar esto con algo llamado Sistema de criterios. Pero con ambos enfoques, el resultado es el mismo: un método que devuelve un subconjunto de la colección.

Intercambiar el Getter en nuestra API

En este punto, el nuevo método funcionará, pero aún no forma parte de nuestra API. Desplázate hasta la propiedad dragonTreasures. Actualmente es legible y escribible en nuestra API. Haz que la propiedad sólo sea escribible:

... lines 1 - 69
class User implements UserInterface, PasswordAuthenticatedUserInterface
{
... lines 72 - 105
#[Groups(['user:write'])]
... lines 107 - 108
private Collection $dragonTreasures;
... lines 110 - 305
}

Luego, abajo en el nuevo método, añade #[Groups('user:read')] para que forme parte de nuestra API y #[SerializedName('dragonTreasures')] para darle el nombre original:

... lines 1 - 69
class User implements UserInterface, PasswordAuthenticatedUserInterface
{
... lines 72 - 216
#[Groups(['user:read'])]
#[SerializedName('dragonTreasures')]
public function getPublishedDragonTreasures(): Collection
{
... lines 221 - 223
}
... lines 225 - 305
}

¡Redoble de tambores! Haz la prueba:

symfony php bin/phpunit --filter=testUnpublishedTreasuresNotReturned

¡Explota! Porque... Tengo un error de sintaxis. Prueba de nuevo. ¡Todo verde!

Y... ¡hemos terminado! ¡Lo has conseguido! Muchas gracias por acompañarme en este gigantesco, genial y desafiante viaje por la API Platform y la seguridad. Algunas partes de este tutorial han sido bastante complejas... porque quiero que seas capaz de resolver problemas de seguridad reales y difíciles.

En el próximo tutorial, vamos a ver cosas aún más personalizadas y potentes que puedes hacer con la API Platform, incluyendo cómo utilizar clases para recursos API que no son entidades.

Mientras tanto, cuéntanos qué estás construyendo y, como siempre, estamos a tu disposición en la sección de comentarios. Muy bien amigos, ¡hasta la próxima!

Leave a comment!

2
Login or Register to join the conversation
Sebastian-K Avatar
Sebastian-K Avatar Sebastian-K | posted hace 2 meses

Wohoo. Done. When can we expect part three?

1 Reply

Hey @Sebastian-K!

Woohoo back! Nice work :). I need to get AssetMapper tutorial out first... part 3 will be the one after that (or MAYBe one other before). There's a bunch of good tutorials in the pipeline that I really want, so I'm hoping to bang through them quickly 🤞

Cheers!

1 Reply
Cat in space

"Houston: no signs of life"
Start the conversation!

What PHP libraries does this tutorial use?

// 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
    }
}
userVoice