Conditional Field Setup

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

So far, we've been talking about granting or denying access to something entirely. In CheeseListing, the most complex case was when we used a voter to deny access to the PUT operation unless you are the owner of this CheeseListing or an admin user.

But there are several other ways that you might need to customize access to your API. For example, what if we have a field that we only want readable, or maybe writable by certain types of users? A great example of this is in User: the $roles field. Right now, the $roles field is not part of our API at all: nobody can change this field via the API.

That certainly make sense... for most API users. But what if we create an admin section and we do need this field to be editable by admin users? How can we do that?

We'll get there. For now, let's add this to the API for all users by adding @Groups("user:write"). This creates a huge security hole... so we'll come back to this in a few minutes and make sure that only admin users can write to this field.

... lines 1 - 39
class User implements UserInterface
{
... lines 42 - 56
/**
... line 58
* @Groups({"user:write"})
*/
private $roles = [];
... lines 62 - 221
}

Adding a phoneNumber Field

Let me give you another example: suppose each User has a phoneNumber field. We want that field to be writeable by anyone that can write to that User. But, for privacy reasons, we only want this field to be readable by admin users: we don't want to expose this info when a normal API client fetches data for a user.

Let's get this set up, then talk about how to make the field conditionally part of our API depending on who is authenticated. To add the field, run:

php bin/console make:entity

Update the User entity, add a new phoneNumber field that's a string, length, how about 50 and say "yes" to nullable: the field will be optional.

... lines 1 - 39
class User implements UserInterface
{
... lines 42 - 89
/**
* @ORM\Column(type="string", length=50, nullable=true)
*/
private $phoneNumber;
... lines 94 - 227
public function getPhoneNumber(): ?string
{
return $this->phoneNumber;
}
public function setPhoneNumber(?string $phoneNumber): self
{
$this->phoneNumber = $phoneNumber;
return $this;
}
}

Cool! We now have a nice new phoneNumber property in User. Generate the migration with:

php bin/console make:migration

Let's double-check that migration file... and... it looks good: it adds the phone_number field but nothing else.

... lines 1 - 12
final class Version20190515191421 extends AbstractMigration
{
... lines 15 - 19
public function up(Schema $schema) : void
{
// this up() migration is auto-generated, please modify it to your needs
$this->abortIf($this->connection->getDatabasePlatform()->getName() !== 'mysql', 'Migration can only be executed safely on \'mysql\'.');
$this->addSql('ALTER TABLE user ADD phone_number VARCHAR(50) DEFAULT NULL');
}
... lines 27 - 34
}

Run it with:

php bin/console doctrine:migrations:migrate

That updates our normal database. But because our test environment uses a different database, we also need to update that too. Instead of worrying about migrations on the test database, update it with:

php bin/console doctrine:schema:update --force --env=test

Now that the field is in the database, let's expose it to the API. I'll steal the @Groups() from above and put this in user:read and user:write.

... lines 1 - 39
class User implements UserInterface
{
... lines 42 - 89
/**
... line 91
* @Groups({"user:read", "user:write"})
*/
private $phoneNumber;
... lines 95 - 239
}

Ok! This is a perfectly boring field that is readable and writable by everyone who has access to these operations.

Testing the Conditional Behavior

Before we jump into making that field more dynamic, let's write a test for the behavior we want. Add a new public function testGetUser() and start with the normal $client = self::createClient(). Create a user & log in with $user = $this->createUserAndLogin(), email cheeseplease@example.com, password foo and... I forgot the first argument: $client.

... lines 1 - 8
class UserResourceTest extends CustomApiTestCase
{
... lines 11 - 44
public function testGetUser()
{
$client = self::createClient();
$user = $this->createUserAndLogIn($client, 'cheeseplease@example.com', 'foo');
... lines 49 - 71
}
}

That method creates a super simple user: with just the username, email and password fields filled in. But this time, we also want to set the phoneNumber. We can do that manually with $user->setPhoneNumber('555.123.4567'), and then saving it to the database. Set the entity manager to an $em variable - we'll need it a few times - and then, because we're updating the User, all we need is $em->flush().

... lines 1 - 44
public function testGetUser()
{
... lines 47 - 49
$user->setPhoneNumber('555.123.4567');
$em = $this->getEntityManager();
$em->flush();
... lines 53 - 71
}

In this test, we're not logged in as an admin user: we're logged in by the user that we're fetching. Our goal is for the API to return the phoneNumber field only to admin users. It's a little weird, but, for now, I don't even want users to be able to see their own phone number.

Let's make a request and assert that: $client->request() to make a GET request to /api/users/ and then $user->getId(). To start, let's do a sanity check: $this->assertJsonContains() to make sure that the response contains the the username field set to cheeseplease.

... lines 1 - 44
public function testGetUser()
{
... lines 47 - 53
$client->request('GET', '/api/users/'.$user->getId());
$this->assertJsonContains([
'username' => 'cheeseplease'
]);
... lines 58 - 71
}

But what we really want assert is that the phoneNumber field is not in the response. There's no fancy assert for this so... we'll do it by hand. Start with $data = $client->getResponse()->toArray().

... lines 1 - 44
public function testGetUser()
{
... lines 47 - 58
$data = $client->getResponse()->toArray();
... lines 60 - 71
}

This handy function will see that the response is JSON and automatically json_decode() it into an array... or throw an exception if something went wrong. Now we can use $this->assertArrayNotHasKey('phoneNumber', $data).

... lines 1 - 44
public function testGetUser()
{
... lines 47 - 59
$this->assertArrayNotHasKey('phoneNumber', $data);
... lines 61 - 71
}

Boom! That's enough to make the test fail... because that field should be in the response right now. Copy the testGetUser method name and... try it:

php bin/phpunit --filter=testGetUser

Yay! Failure!

Failed asserting that array does not have the key phoneNumber.

Next, let's finish the second half of the test - asserting that an admin user can see this field. Then, we'll discuss the strategy for making the phoneNumber field conditionally available in our API.

Leave a comment!

This tutorial works great for Symfony 5 and API Platform 2.5.

What PHP libraries does this tutorial use?

// composer.json
{
    "require": {
        "php": "^7.1.3",
        "ext-ctype": "*",
        "ext-iconv": "*",
        "api-platform/api-pack": "^1.2", // v1.2.0
        "doctrine/doctrine-migrations-bundle": "^2.0", // v2.0.0
        "nesbot/carbon": "^2.17", // 2.21.3
        "symfony/console": "4.3.*", // v4.3.2
        "symfony/dotenv": "4.3.*", // v4.3.2
        "symfony/flex": "^1.1", // v1.4.5
        "symfony/framework-bundle": "4.3.*", // v4.3.2
        "symfony/http-client": "4.3.*", // v4.3.3
        "symfony/monolog-bundle": "^3.4", // v3.4.0
        "symfony/webpack-encore-bundle": "^1.6", // v1.6.2
        "symfony/yaml": "4.3.*" // v4.3.2
    },
    "require-dev": {
        "hautelook/alice-bundle": "^2.5", // v2.5.1
        "symfony/maker-bundle": "^1.11", // v1.12.0
        "symfony/profiler-pack": "^1.0", // v1.0.4
        "symfony/test-pack": "^1.0" // v1.0.6
    }
}