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 SubscribeHemos dejado de devolver tesoros no publicados desde el punto final de la colección de tesoros, pero aún puedes recuperarlos desde el punto final GET one. Esto se debe a que estas clases QueryCollectionExtensionInterface
sólo se invocan cuando obtenemos una colección de elementos, no cuando seleccionamos un único elemento.
Para comprobarlo, entra en nuestra prueba. Duplica la prueba de la colección, pégala y llámala testGetOneUnpublishedTreasure404s()
. Dentro, crea sólo un DragonTreasure
que no esté publicado... y haz una petición ->get()
a /api/treasures/
... ¡oh! Necesito una variable $dragonTreasure
. Eso está mejor. Ahora añade $dragonTreasure->getId()
.
En la parte inferior, afirma que el estado es 404... y no necesitamos ninguna de estas afirmaciones, ni esta variable $json
:
... lines 1 - 12 | |
class DragonTreasureResourceTest extends ApiTestCase | |
{ | |
... lines 15 - 46 | |
public function testGetOneUnpublishedTreasure404s(): void | |
{ | |
$dragonTreasure = DragonTreasureFactory::createOne([ | |
'isPublished' => false, | |
]); | |
$this->browser() | |
->get('/api/treasures/'.$dragonTreasure->getId()) | |
->assertStatus(404); | |
} | |
... lines 57 - 194 | |
} |
¡Muy sencillo! Coge ese nombre de método y, ya sabes lo que hay que hacer. Ejecuta sólo esa prueba:
symfony php bin/phpunit --filter=testGetOneUnpublishedTreasure404s
Y... ¡sí! Actualmente devuelve un código de estado 200.
¿Cómo arreglamos esto? Bueno... al igual que hay unQueryCollectionExtensionInterface
para el punto final de la colección, también hay unQueryItemExtensionInterface
que se utiliza siempre que la API Platform consulta un único elemento.
Puedes crear una clase totalmente independiente para esto... pero también puedes combinarlas. Añade una segunda interfaz para QueryItemExtensionInterface
. A continuación, desplázate hacia abajo y ve a "Código"->"Generar" -o Command
+N
en un Mac- para añadir el único método que nos falta: applyToItem()
:
... lines 1 - 5 | |
use ApiPlatform\Doctrine\Orm\Extension\QueryItemExtensionInterface; | |
... lines 7 - 11 | |
class DragonTreasureIsPublishedExtension implements QueryCollectionExtensionInterface, QueryItemExtensionInterface | |
{ | |
... lines 14 - 24 | |
public function applyToItem(QueryBuilder $queryBuilder, QueryNameGeneratorInterface $queryNameGenerator, string $resourceClass, array $identifiers, Operation $operation = null, array $context = []): void | |
{ | |
// TODO: Implement applyToItem() method. | |
} | |
} |
Sí, es casi idéntico al método de la colección .... funciona de la misma manera... ¡e incluso necesitamos la misma lógica! Así que, copia el código que necesitamos, luego ve al menú Refactorizar y di "Refactorizar esto", que también es Control
+T
en un Mac. Selecciona extraer esto a un método... y llámalo addIsPublishedWhere()
:
... lines 1 - 11 | |
class DragonTreasureIsPublishedExtension implements QueryCollectionExtensionInterface, QueryItemExtensionInterface | |
{ | |
... lines 14 - 23 | |
/** | |
* @param string $resourceClass | |
* @param QueryBuilder $queryBuilder | |
* @return void | |
*/ | |
private function addIsPublishedWhere(string $resourceClass, QueryBuilder $queryBuilder): void | |
{ | |
... lines 31 - 34 | |
$rootAlias = $queryBuilder->getRootAliases()[0]; | |
$queryBuilder->andWhere(sprintf('%s.isPublished = :isPublished', $rootAlias)) | |
->setParameter('isPublished', true); | |
} | |
} |
¡Genial! Limpiaré las cosas... y, ¿sabes qué? Debería haber añadido también esta declaraciónif
ahí dentro. Así que vamos a mover eso:
... lines 1 - 11 | |
class DragonTreasureIsPublishedExtension implements QueryCollectionExtensionInterface, QueryItemExtensionInterface | |
{ | |
... lines 14 - 28 | |
private function addIsPublishedWhere(string $resourceClass, QueryBuilder $queryBuilder): void | |
{ | |
if (DragonTreasure::class !== $resourceClass) { | |
return; | |
} | |
$rootAlias = $queryBuilder->getRootAliases()[0]; | |
$queryBuilder->andWhere(sprintf('%s.isPublished = :isPublished', $rootAlias)) | |
->setParameter('isPublished', true); | |
} | |
} |
Lo que significa que necesitamos un argumento string $resourceClass
. Arriba, pasa$resourceClass
al método:
... lines 1 - 11 | |
class DragonTreasureIsPublishedExtension implements QueryCollectionExtensionInterface, QueryItemExtensionInterface | |
{ | |
public function applyToCollection(QueryBuilder $queryBuilder, QueryNameGeneratorInterface $queryNameGenerator, string $resourceClass, Operation $operation = null, array $context = []): void | |
{ | |
$this->addIsPublishedWhere($resourceClass, $queryBuilder); | |
} | |
... lines 18 - 38 | |
} |
¡Perfecto! Ahora, en applyToItem()
, llama a ese mismo método:
... lines 1 - 11 | |
class DragonTreasureIsPublishedExtension implements QueryCollectionExtensionInterface, QueryItemExtensionInterface | |
{ | |
... lines 14 - 18 | |
public function applyToItem(QueryBuilder $queryBuilder, QueryNameGeneratorInterface $queryNameGenerator, string $resourceClass, array $identifiers, Operation $operation = null, array $context = []): void | |
{ | |
$this->addIsPublishedWhere($resourceClass, $queryBuilder); | |
} | |
... lines 23 - 38 | |
} |
Vale, ¡ya estamos listos! Prueba ahora el test:
symfony php bin/phpunit --filter=testGetOneUnpublishedTreasure404s
Y... ¡pasa!
Hemos estado retocando bastante nuestro código, así que ha llegado el momento de probarlo. Ejecuta todas las pruebas:
symfony php bin/phpunit
Y... ¡ups! 3 fallos - todos procedentes de DragonTreasureResourceTest
. El problema es que, cuando creamos tesoros en nuestras pruebas, no fuimos explícitos sobre si queríamos un tesoro publicado o no publicado... y ese valor se establece aleatoriamente en nuestra fábrica.
Para solucionarlo, podríamos ser explícitos controlando el campo isPublished
cada vez que creamos un tesoro. O... podemos ser más perezosos y, en DragonTreasureFactory
, establecerisPublished
como verdadero por defecto:
... lines 1 - 29 | |
final class DragonTreasureFactory extends ModelFactory | |
{ | |
... lines 32 - 46 | |
protected function getDefaults(): array | |
{ | |
return [ | |
... lines 50 - 51 | |
'isPublished' => true, | |
... lines 53 - 56 | |
]; | |
} | |
... lines 59 - 73 | |
} |
Ahora, para que nuestros datos de fijación sigan siendo interesantes, cuando creemos los 40 tesoros de dragón, anulemos isPublished
y añadamos manualmente algo de aleatoriedad: si un número aleatorio de 0 a 10 es mayor que 3, que se publique:
... lines 1 - 10 | |
class AppFixtures extends Fixture | |
{ | |
public function load(ObjectManager $manager): void | |
{ | |
... lines 15 - 20 | |
DragonTreasureFactory::createMany(40, function () { | |
return [ | |
... line 23 | |
'isPublished' => rand(0, 10) > 3, | |
]; | |
}); | |
... lines 27 - 32 | |
} | |
} |
Eso debería arreglar la mayoría de nuestras pruebas. Aunque busca unpublished
. Ah sí, estamos probando que un admin puede PATCH
para editar un tesoro. Creamos unDragonTreasure
no publicado... sólo para poder afirmar que estaba en la respuesta. Cambiémoslo a true
en ambos sitios:
... lines 1 - 12 | |
class DragonTreasureResourceTest extends ApiTestCase | |
{ | |
... lines 15 - 153 | |
public function testAdminCanPatchToEditTreasure(): void | |
{ | |
... line 156 | |
$treasure = DragonTreasureFactory::createOne([ | |
'isPublished' => true, | |
]); | |
$this->browser() | |
... lines 162 - 169 | |
->assertJsonMatches('isPublished', true) | |
; | |
} | |
... lines 173 - 194 | |
} |
Hay otra prueba similar: cambia aquí también isPublished
por true
:
... lines 1 - 12 | |
class DragonTreasureResourceTest extends ApiTestCase | |
{ | |
... lines 15 - 173 | |
public function testOwnerCanSeeIsPublishedAndIsMineFields(): void | |
{ | |
... line 176 | |
$treasure = DragonTreasureFactory::createOne([ | |
'isPublished' => true, | |
... line 179 | |
]); | |
$this->browser() | |
... lines 183 - 190 | |
->assertJsonMatches('isPublished', true) | |
... line 192 | |
; | |
} | |
} |
Ahora prueba las pruebas:
symfony php bin/phpunit
¡Están contentos! ¡Yo estoy contento! Bueno, sobre todo. Aún tenemos un problemilla. Busca la primera prueba de PATCH
. Estamos creando un DragonTreasure
publicado, actualizándolo... y funciona perfectamente. Copia este test entero... pégalo... pero borra la parte de abajo: sólo necesitamos la parte de arriba. Llama a este método testPatchUnpublishedWorks()
... y asegúrate de que el DragonTreasure
no está publicado:
... lines 1 - 12 | |
class DragonTreasureResourceTest extends ApiTestCase | |
{ | |
... lines 15 - 153 | |
public function testPatchUnpublishedWorks() | |
{ | |
... line 156 | |
$treasure = DragonTreasureFactory::createOne([ | |
... line 158 | |
'isPublished' => false, | |
]); | |
... lines 161 - 171 | |
} | |
... lines 173 - 215 | |
} |
Piénsalo: si tengo un DragonTreasure
con isPublished
false
, debería poder actualizarlo, ¿no? Este es mi tesoro... Yo lo creé y sigo trabajando en él. Queremos que se permita.
¿Lo estará? Probablemente puedes adivinarlo:
symfony php bin/phpunit --filter=testPatchUnpublishedWorks
¡No! ¡Obtendremos un 404! Esto es a la vez una característica... ¡y un "gotcha"! Cuando creamos un QueryCollectionExtensionInterface
, sólo se utiliza para esta única ruta de recogida. Pero cuando creamos un ItemExtensionInterface
, se utiliza siempre que obtenemos un único tesoro: incluso para las operaciones Delete
, Patch
yPut
. Así que, cuando un propietario intenta Patch
su propio DragonTreasure
, gracias a nuestra extensión de consulta, no puede encontrarlo.
Esto tiene dos soluciones. En primer lugar, en applyToItem()
, API Platform nos pasa el $operation
. Así que podríamos utilizarlo para determinar si se trata de una operación Get
,Patch
o Delete
y sólo aplicar la lógica para algunas de ellas.
Y... esto podría tener sentido. Al fin y al cabo, si se te permite editar o borrar un tesoro... eso significa que ya has pasado una comprobación de seguridad... así que no necesitamos necesariamente bloquear las cosas mediante esta extensión de consulta.
La otra solución es cambiar la consulta para permitir que los propietarios vean sus propios tesoros. Una cosa interesante de esta solución es que también permitirá que se devuelvan tesoros no publicados desde la ruta de recogida si el usuario actual es el propietario de ese tesoro.
Vamos a intentarlo. Añade el public function __construct()
... y autocablea el increíble servicio Security
:
... lines 1 - 10 | |
use Symfony\Bundle\SecurityBundle\Security; | |
class DragonTreasureIsPublishedExtension implements QueryCollectionExtensionInterface, QueryItemExtensionInterface | |
{ | |
public function __construct(private Security $security) | |
{ | |
} | |
... lines 18 - 50 | |
} |
A continuación... la vida se complica un poco. Empieza con $user = $this->security->getUser()
. Si tenemos un usuario, vamos a modificar el QueryBuilder
de forma similar... pero ligeramente diferente. En realidad, déjame subir el $rootAlias
por encima de mi sentencia if. Ahora, si el usuario está conectado, añade OR %s.owner = :owner
... luego pasa otro rootAlias
... seguido de ->setParameter('owner', $user)
.
En caso contrario, si no hay usuario, utiliza la consulta original. Y necesitamos el parámetro isPublished
en ambos casos... así que mantenlo al final:
... lines 1 - 12 | |
class DragonTreasureIsPublishedExtension implements QueryCollectionExtensionInterface, QueryItemExtensionInterface | |
{ | |
... lines 15 - 33 | |
private function addIsPublishedWhere(string $resourceClass, QueryBuilder $queryBuilder): void | |
{ | |
if (DragonTreasure::class !== $resourceClass) { | |
return; | |
} | |
$rootAlias = $queryBuilder->getRootAliases()[0]; | |
$user = $this->security->getUser(); | |
if ($user) { | |
$queryBuilder->andWhere(sprintf('%s.isPublished = :isPublished OR %s.owner = :owner', $rootAlias, $rootAlias)) | |
->setParameter('owner', $user); | |
} else { | |
$queryBuilder->andWhere(sprintf('%s.isPublished = :isPublished', $rootAlias)); | |
} | |
$queryBuilder->setParameter('isPublished', true); | |
} | |
} |
¡Creo que me gusta! Veamos qué opina el test:
symfony php bin/phpunit --filter=testPatchUnpublishedWorks
¡También le gusta! De hecho, todas nuestras pruebas parecen contentas.
Ok equipo: tema final. Cuando obtenemos un recurso de User
, devolvemos sus tesoros de dragón. ¿Esa colección incluye también tesoros inéditos? Ah... ¡sí! Hablemos de por qué y de cómo solucionarlo a continuación.
"Houston: no signs of life"
Start the conversation!
// 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
}
}