Subresources

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

At some point, an API client... which might just be our JavaScript, will probably want to get a list of all of the cheeseListings for a specific User. And... we can already do this in two different ways: search for a specific owner here via our filter... or fetch the specific User and look at its cheeseListings property.

If you think about it, a CheeseListing almost feels like a "child" resource of a User: cheese listings belong to users. And for that reason, some people might like to be able to fetch the cheese listings for a user by going to a URL like this: /api/users/4/cheeses... or something similar.

But... that doesn't work. This idea is called a "subresource". Right now, each resource has its own, sort of, base URL: /api/cheeses and /api/users. But it is possible to, kind of, "move" cheeses under users.

Here's how: in User, find the $cheeseListings property and add @ApiSubresource.

... lines 1 - 6
use ApiPlatform\Core\Annotation\ApiSubresource;
... lines 8 - 26
class User implements UserInterface
{
... lines 29 - 62
/**
... lines 64 - 66
* @ApiSubresource()
*/
private $cheeseListings;
... lines 70 - 190
}

Let's go refresh the docs! Woh! We have a new endpoint! /api/users/{id}/cheese_listings. It shows up in two places... because it's kind of related to users... and kind of related to cheese listings. The URL is cheese_listings by default, but that can be customized.

So... let's try it! Change the URL to /cheese_listings. Oh, and add the .jsonld on the end. There it is! The collection resource for all cheeses that are owned by this User.

Subresources are kinda cool! But... they're also a bit unnecessary: we already added a way to get the collection of cheese listings for a user via the SearchFilter on CheeseListing. And using subresources means that you have more endpoints to keep track of, and, when we get to security, more endpoints means more access control to think about.

So, use subresources if you want, but I don't recommend adding them everywhere, there is a cost from added complexity. Oh, and by the way, there is a ton of stuff you can customize on subresources, like normalization groups, the URL, etc. It's all in the docs and it's pretty similar to the types of customizations we've seen so far.

For our app, I'm going to remove the subresource to keep things simple.

And... we're done! Well, there is a lot more cool stuff to cover - including security! That's the topic of the next tutorial in this series. But give yourself a jumping high-five! We've already unlocked a huge amount of power! We can expose entities as API resources, customize the operations, take full control of the serializer in a bunch of different ways and a ton more. So start building your gorgeous new API, tell us about it and, as always, if you have questions, you can find us in the comments section.

Alright friends, seeya next time!

Leave a comment!

  • 2020-06-30 weaverryan

    Hey Ahmedbhs!

    Do you want to do something like /api/users/8/cheese_listings?title=cheddar? If so, I think that's already possible: the sub-resource will allow any filters that this resource *normally* has. So, if a CheeseListing can normally be filtered with a ?title=, then you can also apply that when the cheese listing is a sub-resource. And so, you just need to configure the filter like you normally would.

    Or maybe I misunderstood your question? Let me know :)

    Cheers!

  • 2020-06-29 Ahmedbhs

    Is there a way to add filters and openapi doc to subresource via yaml?

  • 2020-05-29 Diego Aguiar

    Thank you Pedro Serra for taking the initiative!

  • 2020-05-29 Pedro Serra

    Many thanks! I opened an issue on the api-platform github project as Diego Aguiar recommended, let's see if someone can take a look at it.

    Cheers!

  • 2020-05-28 Annemieke Buijs

    Hi Pedro,
    I am very sorry you have the same problem, but also glad. This proves I'm not grazy !!!
    The 'solution' for now is very very bad but it works:

    It's the vendor\api-platform\core\src\Bridge\Doctrine\Orm\SubresourceDataProvider.php file.

    Here's the code you have to replace it with and it will work again.



    /*
    * This file is part of the API Platform project.
    *
    * (c) Kévin Dunglas <dunglas@gmail.com>
    *
    * For the full copyright and license information, please view the LICENSE
    * file that was distributed with this source code.
    */

    declare(strict_types=1);

    namespace ApiPlatform\Core\Bridge\Doctrine\Orm;

    use ApiPlatform\Core\Bridge\Doctrine\Common\Util\IdentifierManagerTrait;
    use ApiPlatform\Core\Bridge\Doctrine\Orm\Extension\FilterEagerLoadingExtension;
    use ApiPlatform\Core\Bridge\Doctrine\Orm\Extension\QueryCollectionExtensionInterface;
    use ApiPlatform\Core\Bridge\Doctrine\Orm\Extension\QueryItemExtensionInterface;
    use ApiPlatform\Core\Bridge\Doctrine\Orm\Extension\QueryResultCollectionExtensionInterface;
    use ApiPlatform\Core\Bridge\Doctrine\Orm\Extension\QueryResultItemExtensionInterface;
    use ApiPlatform\Core\Bridge\Doctrine\Orm\Util\QueryNameGenerator;
    use ApiPlatform\Core\DataProvider\SubresourceDataProviderInterface;
    use ApiPlatform\Core\Exception\ResourceClassNotSupportedException;
    use ApiPlatform\Core\Exception\RuntimeException;
    use ApiPlatform\Core\Identifier\IdentifierConverterInterface;
    use ApiPlatform\Core\Metadata\Property\Factory\PropertyMetadataFactoryInterface;
    use ApiPlatform\Core\Metadata\Property\Factory\PropertyNameCollectionFactoryInterface;
    use Doctrine\Common\Persistence\ManagerRegistry;
    use Doctrine\ORM\EntityManagerInterface;
    use Doctrine\ORM\Mapping\ClassMetadataInfo;
    use Doctrine\ORM\QueryBuilder;

    /**
    * Subresource data provider for the Doctrine ORM.
    *
    * @author Antoine Bluchet <soyuka@gmail.com>
    */
    final class SubresourceDataProvider implements SubresourceDataProviderInterface
    {
    use IdentifierManagerTrait;

    private $managerRegistry;
    private $collectionExtensions;
    private $itemExtensions;

    /**
    * @param QueryCollectionExtensionInterface[] $collectionExtensions
    * @param QueryItemExtensionInterface[] $itemExtensions
    */
    public function __construct(ManagerRegistry $managerRegistry, PropertyNameCollectionFactoryInterface $propertyNameCollectionFactory, PropertyMetadataFactoryInterface $propertyMetadataFactory, iterable $collectionExtensions = [], iterable $itemExtensions = [])
    {
    $this->managerRegistry = $managerRegistry;
    $this->propertyNameCollectionFactory = $propertyNameCollectionFactory;
    $this->propertyMetadataFactory = $propertyMetadataFactory;
    $this->collectionExtensions = $collectionExtensions;
    $this->itemExtensions = $itemExtensions;
    }

    /**
    * {@inheritdoc}
    *
    * @throws RuntimeException
    */
    public function getSubresource(string $resourceClass, array $identifiers, array $context, string $operationName = null)
    {
    $manager = $this->managerRegistry->getManagerForClass($resourceClass);
    if (null === $manager) {
    throw new ResourceClassNotSupportedException(sprintf('The object manager associated with the "%s" resource class cannot be retrieved.', $resourceClass));
    }

    $repository = $manager->getRepository($resourceClass);
    if (!method_exists($repository, 'createQueryBuilder')) {
    throw new RuntimeException('The repository class must have a "createQueryBuilder" method.');
    }

    if (!isset($context['identifiers'], $context['property'])) {
    throw new ResourceClassNotSupportedException('The given resource class is not a subresource.');
    }

    $queryNameGenerator = new QueryNameGenerator();

    /*
    * The following recursively translates to this pseudo-dql:
    *
    * SELECT thirdLevel WHERE thirdLevel IN (
    * SELECT thirdLevel FROM relatedDummies WHERE relatedDummies = ? AND relatedDummies IN (
    * SELECT relatedDummies FROM Dummy WHERE Dummy = ?
    * )
    * )
    *
    * By using subqueries, we're forcing the SQL execution plan to go through indexes on doctrine identifiers.
    */
    $queryBuilder = $this->buildQuery($identifiers, $context, $queryNameGenerator, $repository->createQueryBuilder($alias = 'o'), $alias, \count($context['identifiers']));

    if (true === $context['collection']) {
    foreach ($this->collectionExtensions as $extension) {
    // We don't need this anymore because we already made sub queries to ensure correct results
    if ($extension instanceof FilterEagerLoadingExtension) {
    continue;
    }

    $extension->applyToCollection($queryBuilder, $queryNameGenerator, $resourceClass, $operationName, $context);
    if ($extension instanceof QueryResultCollectionExtensionInterface && $extension->supportsResult($resourceClass, $operationName, $context)) {
    return $extension->getResult($queryBuilder, $resourceClass, $operationName, $context);
    }
    }
    } else {
    foreach ($this->itemExtensions as $extension) {
    $extension->applyToItem($queryBuilder, $queryNameGenerator, $resourceClass, $identifiers, $operationName, $context);
    if ($extension instanceof QueryResultItemExtensionInterface && $extension->supportsResult($resourceClass, $operationName, $context)) {
    return $extension->getResult($queryBuilder, $resourceClass, $operationName, $context);
    }
    }
    }

    $query = $queryBuilder->getQuery();

    return $context['collection'] ? $query->getResult() : $query->getOneOrNullResult();
    }

    /**
    * @throws RuntimeException
    */
    private function buildQuery(array $identifiers, array $context, QueryNameGenerator $queryNameGenerator, QueryBuilder $previousQueryBuilder, string $previousAlias, int $remainingIdentifiers, QueryBuilder $topQueryBuilder = null): QueryBuilder
    {
    if ($remainingIdentifiers <= 0) {
    return $previousQueryBuilder;
    }

    $topQueryBuilder = $topQueryBuilder ?? $previousQueryBuilder;

    [$identifier, $identifierResourceClass] = $context['identifiers'][$remainingIdentifiers - 1];
    $previousAssociationProperty = $context['identifiers'][$remainingIdentifiers][0] ?? $context['property'];

    $manager = $this->managerRegistry->getManagerForClass($identifierResourceClass);

    if (!$manager instanceof EntityManagerInterface) {
    throw new RuntimeException("The manager for $identifierResourceClass must be an EntityManager.");
    }

    $classMetadata = $manager->getClassMetadata($identifierResourceClass);

    if (!$classMetadata instanceof ClassMetadataInfo) {
    throw new RuntimeException("The class metadata for $identifierResourceClass must be an instance of ClassMetadataInfo.");
    }

    $qb = $manager->createQueryBuilder();
    $alias = $queryNameGenerator->generateJoinAlias($identifier);
    $normalizedIdentifiers = [];

    if (isset($identifiers[$identifier])) {
    // if it's an array it's already normalized, the IdentifierManagerTrait is deprecated
    if ($context[IdentifierConverterInterface::HAS_IDENTIFIER_CONVERTER] ?? false) {
    $normalizedIdentifiers = $identifiers[$identifier];
    } else {
    $normalizedIdentifiers = $this->normalizeIdentifiers($identifiers[$identifier], $manager, $identifierResourceClass);
    }
    }

    if ($classMetadata->hasAssociation($previousAssociationProperty)) {
    $relationType = $classMetadata->getAssociationMapping($previousAssociationProperty)['type'];
    switch ($relationType) {
    // MANY_TO_MANY relations need an explicit join so that the identifier part can be retrieved
    case ClassMetadataInfo::MANY_TO_MANY:
    $joinAlias = $queryNameGenerator->generateJoinAlias($previousAssociationProperty);

    $qb->select($joinAlias)
    ->from($identifierResourceClass, $alias)
    ->innerJoin("$alias.$previousAssociationProperty", $joinAlias);
    break;
    case ClassMetadataInfo::ONE_TO_MANY:
    $mappedBy = $classMetadata->getAssociationMapping($previousAssociationProperty)['mappedBy'];
    $previousAlias = "$previousAlias.$mappedBy";

    $qb->select($alias)
    ->from($identifierResourceClass, $alias);
    break;
    case ClassMetadataInfo::ONE_TO_ONE:
    $association = $classMetadata->getAssociationMapping($previousAssociationProperty);
    if (!isset($association['mappedBy'])) {
    $qb->select("IDENTITY($alias.$previousAssociationProperty)")
    ->from($identifierResourceClass, $alias);
    break;
    }
    $mappedBy = $association['mappedBy'];
    $previousAlias = "$previousAlias.$mappedBy";

    $qb->select($alias)
    ->from($identifierResourceClass, $alias);
    break;
    default:
    $qb->select("IDENTITY($alias.$previousAssociationProperty)")
    ->from($identifierResourceClass, $alias);
    }
    } elseif ($classMetadata->isIdentifier($previousAssociationProperty)) {
    $qb->select($alias)
    ->from($identifierResourceClass, $alias);
    }

    // Add where clause for identifiers
    foreach ($normalizedIdentifiers as $key => $value) {
    $placeholder = $queryNameGenerator->generateParameterName($key);
    $qb->andWhere("$alias.$key = :$placeholder");
    $topQueryBuilder->setParameter($placeholder, $value, (string) $classMetadata->getTypeOfField($key));
    }
    // Recurse queries
    $qb = $this->buildQuery($identifiers, $context, $queryNameGenerator, $qb, $alias, --$remainingIdentifiers, $topQueryBuilder);

    return $previousQueryBuilder->andWhere($qb->expr()->in($previousAlias, $qb->getDQL()));
    }
    }

    Good luck!!

  • 2020-05-28 Pedro Serra

    Hi guys!

    Same problem here, after performing a "composer update", api subresources with custom identifiers stopped working too, and only for the OneToMany relations (subresources on the ManyToMany relations work fine).

    Annemieke Buijs I downgraded the api-pack to v1.2.0 but it is still not working, are there any additional steps that I need to do to make it work again?

    Many thanks in advance!

  • 2020-05-26 Diego Aguiar

    Oh really? That's the problem? Wow, it would be nice to spot the BC break change. If you want to go one step further you could open up a new issue on the ApiPlatform project https://github.com/api-plat...

    Nice job! Cheers!

  • 2020-05-26 Annemieke Buijs

    I think i found it.

    It's the vendor\api-platform\core\src\Bridge\Doctrine\Orm\SubresourceDataProvider.php. If i change it back to what it was before the composer update it all works again.

    It turns out that api-pack v1.2.2 does not work right with one to many
    I downgraded it to api-pack v1.2.0 and everything is oke again.

    Greetz,
    Annemieke

  • 2020-05-26 Annemieke Buijs

    Thank you Diego for helping me.

    The real two entities I am using are the entity 'relations' and 'assortments'.
    But the situation is the same. One relation has many records in assortments.
    It's symfony 5, i only added api platform, nothing else.

    I just now created a RelationItemDataProvider for testing.
    In there in the function getItem() i put a print_r($identifiers). The variable $identifiers has 'M2M20180621164125617383' as value. So that is oke.

    This url works /api/relations/M2M20180621164125617383 . It gives me the correct relation as response.
    If I embed the assortment, it works just fine too. It gives me the assortments related to 'relation'.

    But as soon as i use it with a subresource, it finds nothing.

    And this is the response of /api/relations


    {
    "@context": "/api/contexts/Relation",
    "@id": "/api/relations",
    "@type": "hydra:Collection",
    "hydra:member": [
    {
    "@id": "/api/relations/M2M20180621164125617383",
    "@type": "Relation",
    "id": 1,
    "maxId": "M2M20180621164125617383",
    "connId": "A28",
    "name": "sdf",
    "debtorId": "s",
    "purchaseId": "s",
    "isActive": true,

    "assortment": [
    "/api/assortments/1"
    ]

    }
    ],
    "hydra:totalItems": 1
    }

    As you can see it does have the data of the related assorment.

    Is it oke if I send you the entities?



    Relation entity


    namespace App\Entity;

    use ApiPlatform\Core\Annotation\ApiProperty;
    use ApiPlatform\Core\Annotation\ApiResource;
    use ApiPlatform\Core\Annotation\ApiSubresource;
    use Doctrine\ORM\Mapping as ORM;
    use Symfony\Bridge\Doctrine\Validator\Constraints\UniqueEntity;

    /**
    * @ApiResource()
    * @UniqueEntity(fields={"maxId"})
    *
    * @ORM\Table(name="relation")
    * @ORM\Entity()
    */
    class Relation
    {
    /**
    * @ORM\Id()
    * @ORM\GeneratedValue()
    * @ORM\Column(type="integer")
    * @ApiProperty(identifier=false)
    */
    private $id;

    /**
    * @ORM\Column(name="max_id", type="string", length=25, unique=true)
    * @ApiProperty(identifier=true)
    */
    private $maxId;

    /**
    * @ORM\Column(name="conn_id", type="string", length=15)
    */
    private $connId;

    /**
    * @ORM\Column(name="conn_desc", type="string")
    */
    private $name;

    /**
    * @ORM\Column(name="deb_id", type="string", length=15)
    */
    private $debtorId;

    /**
    * @ORM\Column(name="prch_id", type="string", length=15)
    */
    private $purchaseId;

    /**
    * @ORM\OneToMany(targetEntity="App\Entity\Assortment", mappedBy="relation")
    * @ApiSubresource()
    */
    private $assortment;

    /**
    * @ORM\Column(name="is_active", type="boolean")
    */
    private $isActive;

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

    /**
    * @return mixed
    */
    public function getMaxId()
    {
    return $this->maxId;
    }

    /**
    * @param mixed $maxId
    */
    public function setMaxId($maxId): void
    {
    $this->maxId = $maxId;
    }

    /**
    * @return mixed
    */
    public function getConnId()
    {
    return $this->connId;
    }

    /**
    * @param mixed $connId
    */
    public function setConnId($connId): void
    {
    $this->connId = $connId;
    }

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

    /**
    * @param mixed $name
    */
    public function setName($name): void
    {
    $this->name = $name;
    }

    /**
    * @return mixed
    */
    public function getDebtorId()
    {
    return $this->debtorId;
    }

    /**
    * @param mixed $debtorId
    */
    public function setDebtorId($debtorId): void
    {
    $this->debtorId = $debtorId;
    }

    /**
    * @return mixed
    */
    public function getPurchaseId()
    {
    return $this->purchaseId;
    }

    /**
    * @param mixed $purchaseId
    */
    public function setPurchaseId($purchaseId): void
    {
    $this->purchaseId = $purchaseId;
    }

    /**
    * @return mixed
    */
    public function getIsActive()
    {
    return $this->isActive;
    }

    /**
    * @param mixed $isActive
    */
    public function setIsActive($isActive): void
    {
    $this->isActive = $isActive;
    }

    /**
    * @return mixed
    */
    public function getAssortment()
    {
    return $this->assortment;
    }
    }

    Assortment entity



    namespace App\Entity;

    use ApiPlatform\Core\Annotation\ApiResource;
    use Doctrine\ORM\Mapping as ORM;

    /**
    * @ApiResource()
    *
    * @ORM\Table(name="assortment")
    *
    * @ORM\Entity()
    */
    class Assortment
    {
    /**
    * @ORM\Column(name="id", type="integer")
    * @ORM\Id
    * @ORM\GeneratedValue(strategy="AUTO")
    */
    private $id;

    /**
    * @ORM\ManyToOne(targetEntity="App\Entity\Relation", inversedBy="assortment")
    */
    private $relation;

    /**
    * @ORM\Column(name="m_prio", type="integer", nullable=true)
    */
    private $mPrio;

    /**
    * @ORM\Column(type="integer", nullable=true)
    * @var integer This is in cents, divide by 100
    */
    private $bhp;

    /**
    * @ORM\Column(name="is_active", type="boolean")
    */
    private $isActive;

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

    /**
    * @return mixed
    */
    public function getMPrio()
    {
    return $this->mPrio;
    }

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

    /**
    * @return mixed
    */
    public function getIsActive()
    {
    return $this->isActive;
    }
    }

    And maybe the composer.json is handy too.


    {
    "type": "project",
    "license": "proprietary",
    "require": {
    "php": "^7.2.5",
    "ext-ctype": "*",
    "ext-iconv": "*",
    "api-platform/api-pack": "^1.2",
    "symfony/console": "5.0.*",
    "symfony/dotenv": "5.0.*",
    "symfony/flex": "^1.3.1",
    "symfony/framework-bundle": "5.0.*",
    "symfony/yaml": "5.0.*"
    },
    "require-dev": {
    "roave/security-advisories": "dev-master"
    },
    "config": {
    "preferred-install": {
    "*": "dist"
    },
    "sort-packages": true
    },
    "autoload": {
    "psr-4": {
    "App\\": "src/"
    }
    },
    "autoload-dev": {
    "psr-4": {
    "App\\Tests\\": "tests/"
    }
    },
    "replace": {
    "paragonie/random_compat": "2.*",
    "symfony/polyfill-ctype": "*",
    "symfony/polyfill-iconv": "*",
    "symfony/polyfill-php72": "*",
    "symfony/polyfill-php71": "*",
    "symfony/polyfill-php70": "*",
    "symfony/polyfill-php56": "*"
    },
    "scripts": {
    "auto-scripts": {
    "cache:clear": "symfony-cmd",
    "assets:install %PUBLIC_DIR%": "symfony-cmd"
    },
    "post-install-cmd": [
    "@auto-scripts"
    ],
    "post-update-cmd": [
    "@auto-scripts"
    ]
    },
    "conflict": {
    "symfony/symfony": "*"
    },
    "extra": {
    "symfony": {
    "allow-contrib": false,
    "require": "5.0.*"
    }
    }
    }

    Thank you Diego! I hope we can figure this out. A customer is waiting for this to work....

    Just for testing I tried a many to many relation and that works just fine. Problem is only in one to many i think.

  • 2020-05-25 Diego Aguiar

    Hey Annemieke Buijs

    I'm not sure what's going on here, I couldn't find any related change to "custom identifiers" in the ApiPlatform changelog. Can you double check that your custom identifier is properly wired up and that it's being executed. You could add a dd() call inside the supports() method
    Here are the docs in case you need it https://api-platform.com/do...

  • 2020-05-24 Annemieke Buijs

    I found something. When i do a composer update of this course's code, it does not work anymore either.
    After the composer update i have to use the generated id e.g. 1 and i cannot use e.g. 'M133DS44' anymore.
    ???

    or in the case of cheeslistings, i have to submit the id of the user, and not the username to get the cheeselistings subresource.

    This is what symfony server says:


    Matched route "api_users_cheese_listings_get_subresource". method="GET" request_uri="https://localhost:8000/api/users/hatsieflats/cheese_listings"
    route="api_users_cheese_listings_get_subresource" route_parameters={"_api_resource_class":"App\\Entity\\CheeseListing","_api_subresource_context":{
    "collection":true,"identifiers":[["id","App\\Entity\\User",true]],
    "operationId":"api_users_cheese_listings_get_subresource","property":"cheeseListings"},
    "_api_subresource_operation_name":"api_users_cheese_listings_get_subresource",
    "_controller":"api_platform.action.get_subresource",
    "_format":null,"_route":"api_users_cheese_listings_get_subresource","id":"hatsieflats"}

    I've been looking for a solution whole weekend. Do you have any ideas? Can you reproduce this problem?
    Thank you very much in advance.

    Annemieke

    Update
    I've created a new project with symfony 5. Only added api platform to it, And two entities, namely user and cheeselisting.
    No security yet, nothing. And it still does not work.
    I'm close to giving up.

  • 2020-05-20 Diego Aguiar

    Uh, that's weird but I'm glad it's working :)
    I believe you had something misconfigured in your old entities

  • 2020-05-20 Annemieke Buijs

    Hey Diego,

    I need some help.

    1. The problem

    Subresources with 'one-to-many' relation don't work with custom identifier anymore. It won't find anything.
    If I want to show it as embedded data it does work. But as a 'subresource' it won't.

    This works: /api/relations/1/assortments (this should not work)

    This does not work anymore: /api/relations/M133DS44/assortments

    2. What i’ve tried

    - With 'normal' id it works.
    - Added an new entity with doctrines 'make:entity' to make sure it is correct. Normally i add a new entity without 'make:entity'.
    - Tested entities with many to many relation.
    They do fine.
    - Searched our git repository to find out if i changed
    anything that could have caused this.

    3. Here is some code


    /**
    * @ApiResource(
    * normalizationContext={"groups"="Relation:Read"},
    * collectionOperations={},
    * itemOperations={
    * "get"={},
    * },
    * )
    *
    * @ORM\Table(name="relation")
    * @ORM\Entity()
    */
    class Relation
    {
    /**
    * @var int
    * @ApiProperty(identifier=false)
    *
    * @ORM\Id()
    * @ORM\GeneratedValue()
    * @ORM\Column(type="integer")
    */
    private $id;

    /**
    * @ORM\Column(name="max_id", type="string", length=25, unique=true)
    * @ApiProperty(identifier=true)
    */
    private $maxId;


    /**
    * One relation has many assortment records.
    *
    * @ORM\OneToMany(targetEntity="App\Entity\Assortment", mappedBy="relation")
    * @ApiSubresource()
    */
    private $assortment;


    class Assortment
    {
    /**
    * @ORM\Column(name="id", type="integer")
    * @ORM\Id
    * @ORM\GeneratedValue(strategy="AUTO")
    * @Groups({"Assortment:Read"})
    */
    private $id;

    /**
    * @ORM\ManyToOne(targetEntity="App\Entity\Relation", inversedBy="assortment")
    * @ORM\JoinColumn(nullable=false)
    * @Groups({"Assortment:Read"})
    */
    private $relation;





    Thanks in advance !

    Update:

    When i copy enities relation and assortment to code of this course (the zipfile), it works again.
    I really don't understand, but I'm happy i've got it working.
    Maybe it has something to do with api platform config. But i cannot think of something.

  • 2020-05-19 Annemieke Buijs

    Thank you Diego. I think you're right. I will never agree to doing this again.

  • 2020-05-19 Diego Aguiar

    Hey Annemieke Buijs

    Creating a custom identifier for a subresource does not work? I think it should just work but I'm not sure if it's recommended, I believe it may slow things down because of the extra queries

    Cheers!

  • 2020-05-19 Annemieke Buijs

    Never mind, other urls do work with different identifier. Only this one does not.
    Thank you.

  • 2020-05-19 Annemieke Buijs

    Hi everybody,
    I have a very short question.
    Is it possible to get subresource with a custom identifier?
    For example i have a column 'name', and it has @ApiProperty(identifier=true)?
    The standard id has @ApiProperty(identifier=false).

    So, in stead of:
    /api/users/4/cheese_listings

    I want to use:
    /api/users/ryan/cheese_listings

    My problem is, that is does work as an embedded relation, but i can't get it to work as subresource.
    For subresource i have to give id as identifier, with name it won't find anything.

    Thank you very mutch in advance.

    Greetings,
    Annemieke

  • 2020-04-07 Diego Aguiar

    Hey mostwanted

    Did you manage to change the path? If not, here is how you can do it https://github.com/api-plat...

    Cheers!

  • 2020-04-06 mostwanted

    I tried to change the path for the Subresource to /api/users/{id}/cheeses but failed. Can you please help?

    I use these annotations in the User class:


    /**
    * @ORM\OneToMany(targetEntity="App\Entity\CheeseListing", mappedBy="owner", cascade={"persist"}, orphanRemoval=true)
    * @Groups({"user:read", "user:write"})
    * @Assert\Valid()
    * @ApiSubresource()
    */
    private $cheeseListings;

    I use these annotations in the CheeseListing class:


    /**
    * @ApiResource(
    * collectionOperations={"get", "post"},
    * itemOperations={
    * "get"={
    * "normalization_context"={
    * "groups"={
    * "cheese_listing:read",
    * "cheese_listing:item:get"
    * }
    * }
    * },
    * "put"
    * },
    * normalizationContext={
    * "groups"={"cheese_listing:read"},
    * "swagger_definition_name"="Read"
    * },
    * denormalizationContext={
    * "groups"={"cheese_listing:write"},
    * "swagger_definition_name"="Write"
    * },
    * shortName="cheeses",
    * attributes={
    * "pagination_items_per_page"=5,
    * "formats"={
    * "jsonld",
    * "json",
    * "html",
    * "jsonhal",
    * "csv"={"text/csv"}
    * }
    * },
    * subresourceOperations={
    * "api_users_cheese_listings_get_subresource"={
    * "method"="GET",
    * "path"="/api/users/{id}/cheeses"
    * },
    * }
    * )
    * @ApiFilter(
    * BooleanFilter::class,
    * properties={
    * "isPublished"
    * }
    * )
    * @ApiFilter(
    * SearchFilter::class,
    * properties={
    * "title": "partial",
    * "description": "partial",
    * "owner": "exact",
    * "owner.username": "partial"
    * }
    * )
    * @ApiFilter(
    * RangeFilter::class,
    * properties={
    * "price"
    * }
    * )
    * @ApiFilter(
    * PropertyFilter::class
    * )
    * @ORM\Entity(repositoryClass="App\Repository\CheeseListingRepository")
    */
    class CheeseListing

    Note: I found the operation name api_users_cheese_listings_get_subresource by using bin/console debug:router.

  • 2020-03-24 Diego Aguiar

    Yeah Kevin is always working on great things :)

    Cheers!

  • 2020-03-24 Jean A

    Great, as usual, Kevin Dunglas is a great developper, this library is amazing. From what I have seen, lots of features are cover on the security tutorial despite there is no direct link with security.

  • 2020-01-27 Amjed Nouira

    Hi Ryan,

    Yes, that's help very much now, thank you very much :)

    Cheers :D

  • 2020-01-27 weaverryan

    Hi Amjed Nouira !

    Ahhh, I understand now. As far as I know, there's no (simple) way to put that sub-resource endpoint URL under "users" but not also under the "cheeses" section. This is more-or-less something that is coming from "Swagger" - the actual interface of the documentation. Here's a bit more info:

    1) If you go to /api/docs.json you can see the OpenApi spec document for your API. You will find that the sub-resource operation is *not* duplicated here.

    2) Swagger uses the /api/docs.json data to build the Swagger interface.

    So then, why is there the duplicated on the UI if it's not in the OpenApi JSON document? The reason is subtle: if you look at the JSON, beneath the subresource endpoint, there is a tags key that contains User and cheeses. If you removed that cheeses part, the UI would not render it in both places:

    https://imgur.com/gzdRvYA

    So, that's the reason :). But removing it is not easy - this is not something that's configurable (and it may even be considered a best-practice, I'm not sure). You would need to create a custom normalizer that supports the ApiPlatform\Core\Documentation\Documentation class. Then you would need to call the core/normal normalizer (this is exactly what we do with our custom normalizer in the https://symfonycasts.com/sc... course) and then modify the big array to remove that tag before returning it.

    So.. possible, but far from easy. I hope that at least helps!

    Cheers!

  • 2020-01-24 Amjed Nouira

    Hi Ryan,

    Thank you for your response,

    Actually, I speak ONLY about the /api/users/{id}/cheese_listings. For me, I consider it an endpoint for the USERS resources. But, in the generated documentation, it appears in the both blocs: users and cheeses.

    Cheers!

  • 2020-01-23 weaverryan

    Hey Amjed Nouira!

    > First of all, Thank you very much for your excellent explanations ... keep going guys :) :)

    Cheers! ❤️

    > Is there a way to have the subresouce's documentation "cheese_listing" (/api/users/{id}/cheese_listings) only in the bloc of users. I don't like to have it into two places !!

    Hmm, I'm not sure. On a high level, the problem is that, when using sub-resources, you DO have multiple valid endpoints - e.g. /api/users/{id}/cheese_listings is a valid endpoint to get a collection of cheese listings AND /api/cheese_listings is also a valid endpoint for a collection of cheese listings. I think the "correct" answer would be: if you don't want the duplicated docs, then you should avoid having 2 endpoints. What I mean is, you *could* remove the "collections get" operation from the CheeseListing entity. This would remove the /api/cheese_listings endpoint (and its documentation).

    Does that help? Or did I miss the question? :).

    Cheers!

  • 2020-01-20 Amjed Nouira

    Hello Ryan,

    First of all, Thank you very much for your excellent explanations ... keep going guys :) :)

    I have the following question that I couldn't find the answer:

    Is there a way to have the subresouce's documentation "cheese_listing" (/api/users/{id}/cheese_listings) only in the bloc of users. I don't like to have it into two places !!

    Any ideas ? Thanks :)

  • 2019-11-28 Tanariel

    Thanks,
    that's very clear :)

  • 2019-11-27 weaverryan

    Yo Tanariel!

    The short answer to this is... we'll cover custom actions like this in a part 3 of this tutorial ;). But, because that's not a satisfying answer right now... I'll tell you that you have basically 2 options:

    1) Create a custom operation: https://api-platform.com/do...
    2) The nicer option: use API Platform's Messenger integration: https://api-platform.com/do... - you would have probably a "ThrowExpiredCheeses" class (the docs show a ResetPasswordRequest class as an example) and would expose this as a resource.

    Let me know if that helps!

  • 2019-11-27 Tanariel

    Hi Ryan, As usual, very nice tutorial, thanks a lot.

    I have a question regarding "custom" actions. Can i add some custom actions that would do some specific logic, let's take a silly example just for the sake of it :

    Let's say i would like to have an url with a specific action : /api/users/{id}/throwExpiredCheeses which would check if the cheeses he owns is more than 2 weeks old, and if so would mark them as "expired" or remove them from this user. This action would return the user with his updated collection of cheese like we do with the regular /api/users/{id}.

    How would i be able to do this with API platform ?
    Maybe this is already covered in the "security" tutorial ?
    Should i go for some kind of controller and give it some annotations that will say to api-platform to create a doc for it and include it somehow ?
    Or maybe it's just not a good practice and the api should be limited to some basic CRUD operations (in which case it's the api consumer who should execute this action and somehow put the updated infos) ?

  • 2019-10-23 Diego Aguiar

    If I get it right what you want is a custom field on your objects, if that's the case I recommend you to watch this chapter: https://symfonycasts.com/sc...

    Cheers!

  • 2019-10-23 william bridge

    Hi Sir. I'm talking about to send special data generated through a calculation of a value. Something like the code below with jms_serializer
    where FileManipulator is a service class which have getAbsoluteUrlOfImage method.


    namespace App\Entity;

    use JMS\Serializer\Annotation as Serializer;

    /**
    * @ORM\Entity(repositoryClass="App\Repository\ImageRepository")
    * @ORM\Table(name="mk_image")
    * @Serializer\VirtualProperty(
    * "imageName",
    * exp="service('App\\Utils\\FileManipulator').getAbsoluteUrlOfImage(object)",
    * options={@Serializer\SerializedName("url"), @Serializer\Groups({"show_image"})}
    * )
    *
    * @Serializer\ExclusionPolicy("all")
    */
    class Image
    {
    }

    Thank you

  • 2019-10-23 william bridge

    Thank you Sir for this link.

  • 2019-10-22 Diego Aguiar

    Hey william bridge

    About generating absolute URLs you can do what we do here: https://symfonycasts.com/sc...
    It's just a service that knows how to generate URLs and an env var which holds the name of your domain. About the upload process, I would go with Ramazan resource

    Cheers!

  • 2019-10-22 Ramazan

    I didn't understood your first question but about the second one try this documentation:
    https://api-platform.com/do...
    I tried it few months ago and it worked well

  • 2019-10-21 william bridge

    Hi Sir. Thank you for your great tutorial. Please I have some questions :

    - Is it possible to call a service with api-platform like in this situation when i want to send an absolute url of for example an image or file ?
    - how can i manage upload process with api-platform ?

    Thank you

  • 2019-08-27 Victor Bocharsky

    Hey Amigos,

    Yeah, it's easy to forget, I know. You only need to do it once, and then just use TImestampable in a lot of entities and over the time it seems like you just need to add annotations and that's it :) Thanks for the feedback that enabling the feature helps!

    Cheers!

  • 2019-08-23 Amigos

    I am using this bundle in other projects and completely forgot certain features needs to be enabled in config. My bad, thanks for pointing that out.

  • 2019-08-22 weaverryan

    Hey Amigos!

    GREAT question. I plan to keep covering more of these advanced cases in a future tutorial, but I'm happy to talk about them now :).

    There are sort of two parts to your question:

    > 1) How can I "hook" into the process and do something before a resource is saved

    And you've solved this nicely with an entity listener. You can also use a custom data persister (something we'll do in the security tutorial) but both basically do the same thing. And neither helps you with your real question ;)

    > 2) How can I set a custom value via the constructor

    This is a tough one, and I don't know if I have an answer or not :). But I *do* have an idea. Check out this blog post about constructor arguments: https://symfony.com/blog/ne...

    If you were using the serializer directly, you could probably easily use this to solve your issue. But, how to hook into API Platform so you can add this option? The answer (I believe) is via a context builder - https://api-platform.com/do... - something we'll talk about in the security tutorial. But basically, I believe you should be able to create one of these and then, on denormalization of your resource, calculate the value and set it on the default_constructor_arguments option.

    Let me know if that helps!

    Cheers!

  • 2019-08-22 Victor Bocharsky

    Hey Amigos,

    I believe it should work together. Does it work in the same project for entities without API resource? Are you sure you actually activated the Timestampable behavior in bundle configuration? At first, in "config/packages/stof_doctrine_extensions.yaml" you should enable the behaviors you want as:


    stof_doctrine_extensions:
    default_locale: en_US
    orm:
    default:
    timestampable: true
    # ...

    I'd recommend to read the docs more carefully, I think you just missed something important in your config.

    Cheers!

  • 2019-08-21 Amigos

    Another question: Can DoctrineExtensionsBundle be used together with Entity marked as ApiResource? I tried using Timestampable without success. While createdAt is easy to do (just set it in constructor, like in the course) - the updatedAt part would likely require to hack the value in (assuming no setter) on some doctrine event, just like I did with the other thing in my previous question.

    If the answer is no - what would be the recommended way to set both createdAt and updatedAt for entity marked as ApIResource?

  • 2019-08-21 Amigos

    Thanks for the tutorial.

    Even though the tutorial covers basics (and is already huge), my seemingly simple case seems not covered:

    I need to set a property on entity creation, which will be immutable (no setter). This property however shouldn't be set by user through API either - it will be generated by custom logic. For simplicity say it's gonna be "entry this month" field, which is created based on datebase result. That said, I need to somehow tell API Platform about this value. In normal Symfony I would obtain the value and pass it in constructor, before saving to the database. How can I achieve that here, without hacks?

    Currently I achieved it by creating EntityListener, which listens to Doctrine's PrePersist event on this entity. Once it's executed, it does that custom logic and sets this value through `ReflectionProperty` (first `setAccessible`, then `setValue`). Feels like a hack to me though.

    According to what was said in the course, API Platform checks typehints/argument names in constructor to use them. So, what if I could somehow insert that value before it tries to create a new instance of the entity, but already after obtaining data from request? I think that would be related to deserialization/denormalization. The research brought me up to API Platform's custom operation controller's action classes ( https://api-platform.com/do... ) and events ( https://api-platform.com/do... ). Do you think I could achieve what I need by doing that? If so - any example to get me started?

  • 2019-08-20 Diego Aguiar

    > Maybe bad request headers?
    That's possible, try comparing both requests (dev and prod)

    I dug for a bit and found this issue on Github: https://github.com/api-plat... it might be related to your case. Probably the reason is because you have different data on your production server than locally, there might be some data that is causing troubles to the Serializer. Give it a check and let me know if you find something useful :)

  • 2019-08-20 Roland Tacadena

    Thanks Diego.👍

  • 2019-08-20 Dirk Vanstraelen

    Hi Diego, that's the first thing I tried but to no avail. I even copied the JSON generated by SQL Server and tried it in DEV... Works fine. I also tried the curl command in production and that also works fine! So it must be something to do within the SQL request... Maybe bad request headers?

  • 2019-08-19 Diego Aguiar

    Hey Dirk Vanstraelen

    So, it works fine on dev environment? If that's the case I wonder what else changes on your project when you are on production? This may sound silly but could you clear the cache and try again?
    Just in case you didn't know already, while on production, after every change you have to clear the cache

    Cheers!

  • 2019-08-19 Dirk Vanstraelen

    Hi Ryan, thanks again for a great tutorial.

    I have a small issue when calling the api endpoint with a POST request... It works fine in dev, however in (pre)production I'm getting this error:

    Argument 2 passed to ApiPlatform\Core\Metadata\Property\Factory\CachedPropertyMetadataFactory::create() must be of the type string, integer given, called in /var/www/qual.stupvzw.be/public_html... on line 634 {"exception":"[object] (Symfony\\Component\\Debug\\Exception\\FatalThrowableError(code: 0): Argument 2 passed to ApiPlatform\\Core\\Metadata\\Property\\Factory\\CachedPropertyMetadataFactory::create() must be of the type string, integer given, called in /var/www/qual.stupvzw.be/public_html... on line 634 at /var/www/qual.stupvzw.be/public_html..."} []

    In this particular project, I need to call the api from SQL Server. The GET requests work fine, but for the POST, SQL generates the JSON (identical to dev) and this error keeps appearing in the logs. Any idea what it could be?
    I wrote this stored procedure:


    CREATE PROCEDURE [dbo].[api_post]
    @jsonText VARCHAR(8000),
    @uri VARCHAR(500)

    AS

    DECLARE @Object AS INT, @ResponseText AS VARCHAR(8000);
    EXEC sp_OACreate 'MSXML2.XMLHTTP', @Object OUT;
    EXEC sp_OAMethod @Object, 'open', NULL, 'POST', @uri, 'false'
    EXEC sp_OAMethod @Object, 'setRequestHeader', NULL, 'accept', 'application/ld+json'
    EXEC sp_OAMethod @Object, 'setRequestHeader', NULL, 'Content-Type', 'application/ld+json'
    EXEC sp_OAMethod @Object, 'send', NULL, @jsonText
    EXEC sp_OAMethod @Object, 'responseText', @ResponseText OUTPUT
    SELECT @ResponseText
    EXEC sp_OADestroy @Object

  • 2019-08-01 Diego Aguiar

    Hey Roland Tacadena

    A good use case for Subresources is when you need an elaborated form. In other words when you want to modify a Resource and a related resource, like in this case, you may want to create a new user with an initial list of cheeses. Or another example could be a Product resource that requires one or more images in order to be created, so you want to submit both resources data in the same request

    I hope this helps. Cheers!

  • 2019-07-31 Roland Tacadena

    Hi Symfonycasts team great work!

    For this specific video when do you suggest to best use subresources on what specific usecases?

    Thank you.

  • 2019-07-04 Ramazan

    Nice moove, can't wait to see that part too :) Yeah in general is good to have some vision on how to start, but for people like me, we always thing about high traffic & performance.

  • 2019-07-03 weaverryan

    I'm going to add the ElasticSearch stuff to our list - it's super interesting... and we're getting a lot of requests for ElasticSearch in *general*.

    Cheers!

  • 2019-07-02 Diego Aguiar

    Hey Sung Lee

    Yes, you are correct, the CheeseListingRepository file was auto-generated by the make:entity command. Those 2 commented methods are just examples of how you can implement your own custom repository methods, in this tutorial it wasn't needed because Api Platform takes care of all of it for us
    If you want to learn more about working with repositories, I recommend you to watch this tutorial: https://symfonycasts.com/sc...
    or a more advanced tutorial (Built on Symfony3): https://symfonycasts.com/sc...

    I hope this helps. Cheers!

  • 2019-07-02 Diego Aguiar

    Hey Arne K. Haaje

    We are happy to hear that you find our tutorials useful :)
    The second part may take us at least one month to start releasing it. At the moment we are focused on "The Messenger Component" tutorial

    Cheers!

  • 2019-07-02 Sung Lee

    I am new to API Platform and Symfony framework, so I have lots of questions. :)

    Until the end of the tutorial, there is no updates in repository files, such as CheeseListingRepository.php.
    I think it is auto-generated from ./bin/console make:entity --regenerate command.
    There are two functions commented out in the file. What are the cases we can use the function and how to use those?

    Thank you!

  • 2019-07-02 Arne K. Haaje

    Thanks for the great tutorial! I've started using this for an application API, so looking very much forward to the security part.

  • 2019-07-01 Diego Aguiar

    Hey Ramazan

    I believe it will take us at least 1 month to start releasing the security tutorial

    About your question I found that Api Platform comes with a built-in integration with ElasticSearch. Being honest, I haven't play with it but you may want to read the docs:https://api-platform.com/do...
    If you discover something useful, I would love to hear about it :)

    Cheers!

  • 2019-07-01 Diego Aguiar

    Hey Sung Lee

    That's a great question! I had to dug for awhile and found an issue about it
    - https://github.com/api-plat...
    Also read this comment from Kevin https://github.com/api-plat... and here are the recommendations they give you in the docs: https://api-platform.com/do...

    Cheers!

  • 2019-07-01 Sung Lee

    Thanks for the great tutorial!

    I was wondering how to configure API Platform to support versions.
    For example, I want to make url like this: https://example.com/api/v1/.

    Any idea? Thanks.

  • 2019-06-28 Ramazan

    Hi SymfonyCasts team, good tutorial, very clear.
    I'm waiting for the next tutorial of this series, the security part.
    In the meantime I will try to see the best practices for uploading cheeses images, or allow user to add cheeses on a favourite list and more options...

    I wonder something, what would be the process if I want to move into elasticsearch instead of mysql, will I need to redesign my entities & are the routes will change? Or maybe it can be a part of an another SymfonyCasts tutorial :)

  • 2019-06-26 Diego Aguiar

    Hey Jérôme  !

    Thanks for your kind works man :)

    Cheers!

  • 2019-06-26 Jérôme 

    Hi SymfonyCasts team! Thanks for this very good tutorial. It was crystal clear, very understandable, and having Ryan as a speaker was perfect as usual! From now on, I'll be waiting for the security tutorial! Keep up the good work!

  • 2019-06-25 Alberto

    Hey Ryan, very nice tutorial!!!
    I hope the safety part starts tomorrow...
    Good Job