GET a Collection of Accounts

This video is available to view for members only.

Click here to Join!

Already a member?

Login


In this video we are going to create and configure the endpoint for allowing a logged in User to access all the Accounts that they are a member of. To make this query, a User will send in a GET request to the /accounts endpoint.

We have a Behat scenario to explain this further:

// src/AppBundle/Features/account.feature

  Background:
    Given there are users with the following details:
      | uid  | username | email          | password |
      | u1   | peter    | peter@test.com | testpass |
      | u2   | john     | john@test.org  | johnpass |
      | u3   | dave     | dave@test.net  | davepass |
     And there are accounts with the following details:
      | uid | name              | users |
      | a1  | account1          | u1    |
      | a2  | test account      | u2,u1 |
      | a3  | an empty account  |       |
     And I am successfully logged in with username: "peter", and password: "testpass"
     And when consuming the endpoint I use the "headers/content-type" of "application/json"


  Scenario: User can GET a Collection of their Account objects
    When I send a "GET" request to "/accounts"
    Then the response code should be 200
     And the response header "Content-Type" should be equal to "application/json; charset=utf-8"
     And the response should contain json:
      """
      [{
          "id": "a1",
          "name": "account1",
          "users": [{
              "id": "u1",
              "username": "peter",
              "email": "peter@test.com"
          }]
      }, {
          "id": "a2",
          "name": "test account",
          "users": [{
              "id": "u1",
              "username": "peter",
              "email": "peter@test.com"
            }, {
              "id": "u2",
              "username": "john",
              "email": "john@test.org"
          }]
      }]
      """

The output shows that we expect to get back a JSON array containing two accounts. The gotcha here is that if we don't tell JMSSerializer any different, it will get itself into a funky situation where it tries to serialize a nested relation : users have accounts, and each account has users, and each user has accounts, and each account has users... and so on. Messy.

To set this feature up requires a bit of extra code. As mentioned in the [video setting up our User data], I create a separate 'context' for this, which is not Behat best practice, but it works for me:

<?php

// src/AppBundle/Features/Context/AccountSetupContext.php

namespace AppBundle\Features\Context;

use AppBundle\Entity\Account;
use AppBundle\Factory\AccountFactoryInterface;
use Behat\Behat\Context\Context;
use Behat\Behat\Context\SnippetAcceptingContext;
use Behat\Gherkin\Node\TableNode;
use Doctrine\ORM\EntityManagerInterface;
use FOS\UserBundle\Model\UserManagerInterface;

class AccountSetupContext implements Context, SnippetAcceptingContext
{
    use \Behat\Symfony2Extension\Context\KernelDictionary;

    /**
     * @var EntityManagerInterface
     */
    protected $em;
    /**
     * @var UserManagerInterface
     */
    protected $userManager;
    /**
     * @var AccountFactoryInterface
     */
    private $accountFactory;

    /**
     * AccountSetupContext constructor.
     * @param UserManagerInterface $userManager
     * @param EntityManagerInterface $em
     */
    public function __construct(
        UserManagerInterface $userManager,
        AccountFactoryInterface $accountFactory,
        EntityManagerInterface $em)
    {
        $this->userManager = $userManager;
        $this->accountFactory = $accountFactory;
        $this->em = $em;
    }

    /**
     * @Given there are accounts with the following details:
     */
    public function thereAreAccountsWithTheFollowingDetails(TableNode $accounts)
    {
        foreach ($accounts->getColumnsHash() as $key => $val) {

            $account = $this->accountFactory->create($val['name']);

            $this->em->persist($account);
            $this->em->flush();


            $this->fixIdForAccountNamed($val['uid'], $val['name']);


            $account = $this->em->getRepository('AppBundle:Account')->find($val['uid']);

            $this->addUsersToAccount($val['users'], $account);
        }

        $this->em->flush();
    }

    private function fixIdForAccountNamed($id, $accountName)
    {
        $qb = $this->em->createQueryBuilder();

        $query = $qb->update('AppBundle:Account', 'a')
            ->set('a.id', $qb->expr()->literal($id))
            ->where('a.name = :accountName')
            ->setParameters([
                'accountName' => $accountName,
            ])
            ->getQuery()
        ;

        $query->execute();
    }

    private function addUsersToAccount($userIds, Account $account)
    {
        $userIds = explode(',', $userIds);

        if (empty($userIds)) {
            return false;
        }

        foreach ($userIds as $userId) {
            /** @var $user \AppBundle\Entity\User */
            $user = $this->userManager->findUserBy(['id'=>$userId]);

            if (!$user) {
                continue;
            }

            $user->addAccount($account);
        }

        $this->em->flush();
    }
}

One interesting section in the above code is:

        if (empty($userIds)) {
            return false;
        }

This allows certain accounts not have any users at all. Without this check, the Behat background step would blow up :)

The vast majority of the logic behind the scenes is largely identical to our User workflow. We are going to have the concept of a Restricted repository, and a standard Doctrine repository. We will also have an Account Entity Repository, an Account Voter, and so on. Just as we have covered in the Users section of this tutorial series.

Because some of this logic will be duplicated between the repositories (think: save, delete, etc), I extracted the common code into a CommonDoctrineRepository:

<?php

// src/AppBundle/Repository/Doctrine/CommonDoctrineRepository.php

namespace AppBundle\Repository\Doctrine;

use Doctrine\ORM\EntityManagerInterface;

/**
 * Class CommonDoctrineRepository
 * @package AppBundle\Repository\Doctrine
 */
class CommonDoctrineRepository
{
    /**
     * @var EntityManagerInterface
     */
    protected $em;

    /**
     * DoctrineUserRepository constructor.
     * @param EntityManagerInterface $em
     */
    public function __construct(EntityManagerInterface $em)
    {
        $this->em = $em;
    }

    /**
     * @return EntityManagerInterface
     */
    public function getEntityManager()
    {
        return $this->em;
    }

    /**
     * @param mixed $object
     */
    public function refresh($object)
    {
        $this->em->refresh($object);
    }

    /**
     * @param   mixed               $object
     * @param   array               $arguments
     */
    public function save($object, array $arguments = ['flush'=>true])
    {
        $this->em->persist($object);

        if ($arguments['flush'] === true) {
            $this->em->flush();
        }
    }

    /**
     * @param   mixed               $object
     * @param   array               $arguments
     */
    public function delete($object, array $arguments = ['flush'=>true])
    {
        $this->em->remove($object);

        if ($arguments['flush'] === true) {
            $this->em->flush();
        }
    }
}

Potentially this could be an abstract class which other Doctrine Repositories inherit from, but I didn't like that idea. I'm swaying away from inheritence currently, and swaying towards composition, which is a little outside the scope of this video but is my reasoning behind that design decision.

From Database To Display

As mentioned, there's really not much difference in the code from our User example of GET'ing a single User object, to instead returning a collection (or PHP array) of Account objects.

The workflow is:

  • AccountsController
  • AccountsHandler
  • RestrictedAccountRepository
  • DoctrineAccountRepository
  • AccountEntityRepository

If you're unsure on this, please watch the three videos on Securing Our User Endpoint which explains this in much more detail.

In that instance we only needed to return one User, whereas this time we return an ArrayCollection of Account objects.

<?php

// src/AppBundle/Entity/Repository/AccountEntityRepository.php

namespace AppBundle\Entity\Repository;

use AppBundle\Model\UserInterface;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\ORM\EntityRepository;

class AccountEntityRepository extends EntityRepository
{
    /**
     * @param   UserInterface       $user
     * @return  array
     */
    public function findAllForUser(UserInterface $user)
    {
        $query = $this->getEntityManager()
            ->createQueryBuilder()
            ->select('a')
            ->from('AppBundle\Entity\Account', 'a')
            ->join('a.users', 'u')
            ->where('u.id = :userId')
            ->setParameter('userId', $user->getId())
            ->getQuery();

        return new ArrayCollection(
            $query->getResult()
        );
    }
}

Notice the wrapping of the result in an ArrayCollection. This isn't necessary, but it gives us access to all the extra functionality of Doctrine's Collection interface, which allows us to offer slicing and limiting, amongst other things, for each pagination. I put the ArrayCollection wrapper at this stage so that should anything else use the AccountEntityRepository in future, it too would get back an ArrayCollection.

With our Account data returned, we don't need to do very much from the Controller's perspective to get this returned as a response:

// src/AppBundle/Controller/AccountsController.php

use FOS\RestBundle\Controller\Annotations;
// * snip *


    /**
     * Gets a collection of the given User's Accounts.
     *
     * @ApiDoc(
     *   output = "AppBundle\Entity\Account",
     *   statusCodes = {
     *     200 = "Returned when successful",
     *     404 = "Returned when not found"
     *   }
     * )
     *
     * @Annotations\View(serializerGroups={
     *     "accounts_all",
     *     "users_summary"
     * })
     *
     * @throws NotFoundHttpException when does not exist
     *
     * @return View
     */
    public function cgetAction()
    {
        $user = $this->getUser();

        return $this->getAccountHandler()->findAllForUser($user);
    }

I am not the biggest fan of annotations, but I found this easier to use that new'ing up a View instance and setting the serialization groups in the controller. Your milage may vary :)

The key piece here is:

     * @Annotations\View(serializerGroups={
     *     "accounts_all",
     *     "users_summary"
     * })

If we don't use serializer groups then JMSSerializer is going to try and figure out what to serialize, and from experience, this likely won't be what you want unless your entities are quite simple. As mentioned we have this nested User > Account relationship going on, so let's sort out of entities to expose just what we want:

<?php

// src/AppBundle/Entity/Account.php

namespace AppBundle\Entity;

use AppBundle\Model\UserInterface;
use AppBundle\Model\FileInterface;
use AppBundle\Model\AccountInterface;
use AppBundle\Model\ScheduleInterface;
use AppBundle\Model\SocialMediaProfileInterface;
use Doctrine\ORM\Mapping as ORM;
use Doctrine\Common\Collections\ArrayCollection;
use JMS\Serializer\Annotation as JMSSerializer;

/**
 * @ORM\Entity(repositoryClass="AppBundle\Entity\Repository\AccountEntityRepository")
 * @ORM\Table(name="account")
 * @JMSSerializer\ExclusionPolicy("all")
 */
class Account implements AccountInterface, \JsonSerializable
{
    /**
     * @ORM\Column(type="guid")
     * @ORM\Id
     * @ORM\GeneratedValue(strategy="UUID")
     * @JMSSerializer\Expose
     * @JMSSerializer\Type("string")
     * @JMSSerializer\Groups({"accounts_all","accounts_summary"})
     */
    protected $id;

    /**
     * @ORM\ManyToMany(targetEntity="User", mappedBy="accounts", cascade={"persist", "remove"})
     *
     * @JMSSerializer\Expose
     * @JMSSerializer\Type("ArrayCollection<AppBundle\Entity\User>")
     * @JMSSerializer\MaxDepth(2)
     * @JMSSerializer\Groups({"accounts_all"})
     */
    private $users;

    /**
     * @ORM\OneToMany(targetEntity="SocialMediaProfile", mappedBy="account", cascade={"persist"})
     */
    private $socialMediaProfiles;

    /**
     * @ORM\Column(type="string", name="name")
     * @JMSSerializer\Expose
     * @JMSSerializer\Groups({"accounts_all","accounts_summary"})
     */
    private $name;


    /**
     * Account constructor.
     * @param $accountName
     */
    public function __construct($accountName)
    {
        $this->name = (string) $accountName;
        $this->users = new ArrayCollection();
    }

    // * snip *

And the relevant fields from the User entity:

<?php

// src/AppBundle/Entity/User.php

namespace AppBundle\Entity;

use AppBundle\Model\AccountInterface;
use AppBundle\Model\UserInterface;
use Doctrine\Common\Collections\Collection;
use Doctrine\Common\Collections\ArrayCollection;
use FOS\UserBundle\Model\User as BaseUser;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Bridge\Doctrine\Validator\Constraints\UniqueEntity;
use JMS\Serializer\Annotation as JMSSerializer;

/**
 * @ORM\Entity
 * @ORM\Table(name="fos_user")
 *
 * @UniqueEntity("email")
 * @UniqueEntity("username")
 * @JMSSerializer\ExclusionPolicy("all")
 * @JMSSerializer\AccessorOrder("custom", custom = {"id", "username", "email", "accounts"})
 */
class User extends BaseUser implements UserInterface, \JsonSerializable
{
    /**
     * @ORM\Column(type="guid")
     * @ORM\Id
     * @ORM\GeneratedValue(strategy="UUID")
     * @JMSSerializer\Expose
     * @JMSSerializer\Type("string")
     * @JMSSerializer\Groups({"users_all","users_summary"})
     */
    protected $id;

    /**
     * @ORM\ManyToMany(targetEntity="Account", inversedBy="users")
     * @ORM\JoinTable(name="users__accounts",
     *      joinColumns={@ORM\JoinColumn(name="user_id", referencedColumnName="id")},
     *      inverseJoinColumns={@ORM\JoinColumn(name="account_id", referencedColumnName="id")}
     * )
     * @JMSSerializer\Expose
     * @JMSSerializer\Type("ArrayCollection")
     * @JMSSerializer\MaxDepth(2)
     * @JMSSerializer\Groups({"users_all"})
     */
    private $accounts;

    // * snip *

The important parts are the @JMSSerializer\Groups({"users_all"}) annotations.

The way I do this is to have an _all and _summary group for each entity.

Therefore when querying /users/u1, I would get a User entity which I care about every field with the users_all group, and then for any relations, I would also specify the groups I want. I know that User and Account has a relationship, so I would likely want some of the Account fields - but not all. This way, I can stop the recursive User > Account > User > Account mess.

In this example I would use the serializer groups annotation of :

     * @Annotations\View(serializerGroups={
     *     "users_all",
     *     "accounts_summary"
     * })

If you don't get it - that's cool. It's harder to explain with just words than it is on screen with a little demo. So do watch the video as the last 2/3rds of the video explains this in more depth.

And with our serializer groups set up, we should now have our Controller returning the expected view data when GET'ing the /accounts endpoint.


Code For This Course

Get the code for this course.

Share This Episode

If you have found this video helpful, please consider sharing. I really appreciate it.


Episodes in this series

# Title Duration
1 Project Introduction 17:13
2 Setting Up Our Development Environment 05:08
3 Installing Symfony 3, Behat, and more 13:53
4 User Feature - Part 1 17:47
5 User Feature - Part 2 07:51
6 Talking English To Your Computer 11:05
7 Teaching Your Database To Forget 07:42
8 Creating User Data From Behat Background - Part 1 14:44
9 Creating User Data From Behat Background - Part 2 11:33
10 Creating A Custom RestApiContext 17:44
11 Our First Passing Behat User Scenario 12:01
12 Our Next Passing Step 13:10
13 Securing Our User Endpoint - Part 1 17:17
14 Securing Our User Endpoint - Part 2 24:27
15 Securing Our User Endpoint - Part 3 24:47
16 Log In To A Symfony API With JWTs (LexikJWTAuthenticationBundle) 11:02
17 Implementing PATCH for Users 18:17
18 Improving our API User Experience 13:59
19 GET a Collection of Accounts 12:15
20 POSTing in New Accounts 14:34
21 PUT and PATCH for Accounts 12:14
22 How To DELETE Existing Accounts 05:11
23 File Feature Overview 11:40
24 File - Using Existing Resources as Boilerplate 15:17
25 File POST 14:53
26 Fixing A Bug In POST Guided By Behat 12:50
27 Wrapping Up With File DELETE 07:47