Flag of Ukraine
SymfonyCasts stands united with the people of Ukraine
This tutorial has a new version, check it out!

Weird Endpoint: The tagline as a Resource?

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

Most of our endpoints are pretty straightforward: We create a programmer, we update a programmer, we create a battle, we get a collection of battles.

Reality check! In the wild: endpoints get weird. Learning how to handle these was one of the most frustrating parts of REST for me. So let's code through two examples.

Updating just the Tagline?

Here's the first: suppose you decide that it would be really nice to have an endpoint where your client can edit the tagline of a programmer directly.

Now, technically, that's already possible: send a PATCH request to the programmer endpoint and only send the tagline.

But remember: we're building the API for our API clients, and if they want an endpoint specifically for updating a tagline, give it to them.

Open ProgrammerControllerTest: let's design the endpoint first. Make a public function testEditTagline():

... lines 1 - 7
class ProgrammerControllerTest extends ApiTestCase
{
... lines 10 - 317
public function testEditTagline()
{
... lines 320 - 331
}
}

Scroll to the top and copy the $this->createProgrammer() line that we've been using. Give this a specific tag line: The original UnitTester:

... lines 1 - 7
class ProgrammerControllerTest extends ApiTestCase
{
... lines 10 - 317
public function testEditTagline()
{
$this->createProgrammer(array(
'nickname' => 'UnitTester',
'avatarNumber' => 3,
'tagLine' => 'The original UnitTester'
));
... lines 325 - 331
}
}

The URL Structure

Now, if we want an endpoint where the only thing you can do is edit the tagLine, how should that look?

One way to think about this is that the tagLine is a subordinate string resource of the programmer. Remember also that every URI is supposed to represent a different resource. If you put those 2 ideas together, a great URI becomes obvious: /api/programmers/UnitTester/tagline. In fact, if you think of this as its own resource, then all of a sudden, you could imagine creating a GET endpoint to fetch only the tagline or a PUT endpoint to update just the tagline. It's a cool idea!

And that's what we'll do: make an update request with $this->client->put() to this URL: /api/programmers/UnitTester/tagline:

... lines 1 - 7
class ProgrammerControllerTest extends ApiTestCase
{
... lines 10 - 317
public function testEditTagline()
{
$this->createProgrammer(array(
'nickname' => 'UnitTester',
'avatarNumber' => 3,
'tagLine' => 'The original UnitTester'
));
$response = $this->client->put('/api/programmers/UnitTester/tagline', [
... lines 327 - 328
]);
... lines 330 - 331
}
}

How to send the Data?

Send the normal Authorization header:

... lines 1 - 7
class ProgrammerControllerTest extends ApiTestCase
{
... lines 10 - 317
public function testEditTagline()
{
... lines 320 - 325
$response = $this->client->put('/api/programmers/UnitTester/tagline', [
'headers' => $this->getAuthorizedHeaders('weaverryan'),
... line 328
]);
... lines 330 - 331
}
}

But how should we pass the new tagline data? Normally, we send a json-encoded array of fields. But this resource isn't a collection of fields: it's just one string. There's nothing wrong with sending some JSON data up like before, but you could also set the body to the plain-text New Tag Line itself:

... lines 1 - 7
class ProgrammerControllerTest extends ApiTestCase
{
... lines 10 - 317
public function testEditTagline()
{
... lines 320 - 325
$response = $this->client->put('/api/programmers/UnitTester/tagline', [
'headers' => $this->getAuthorizedHeaders('weaverryan'),
'body' => 'New Tag Line'
]);
... lines 330 - 331
}
}

And I think this is pretty cool.

Finish this off with $this->assertEquals() 200 for the status code:

... lines 1 - 7
class ProgrammerControllerTest extends ApiTestCase
{
... lines 10 - 317
public function testEditTagline()
{
... lines 320 - 325
$response = $this->client->put('/api/programmers/UnitTester/tagline', [
'headers' => $this->getAuthorizedHeaders('weaverryan'),
'body' => 'New Tag Line'
]);
$this->assertEquals(200, $response->getStatusCode());
... line 331
}
}

But what should be returned? Well, whenever we edit or create a resource, we return the resource that we just edited or created. In this context, the tagline is its own resource... even though it's just a string. So instead of expecting JSON, let's look for the literal text: $this->assertEquals() that New Tag Line is equal to the string representation of $response->getBody():

... lines 1 - 329
$this->assertEquals(200, $response->getStatusCode());
$this->assertEquals('New Tag Line', (string) $response->getBody());
... lines 332 - 334

But you don't need to do it this way: you might say:

Look, we all know that you're really editing the UnitTester programmer resource, so I'm going to return that.

And that's fine! This is an interesting option for how to think about things. Just as long as you don't spend your days dreaming philosophically about your API, you'll be fine. Make a decision and feel good about it. In fact, that's good life advice.

Adding the String Resource Endpoint

Let's finish this endpoint. At the bottom of ProgrammerController, add a new public function editTaglineAction():

... lines 1 - 23
class ProgrammerController extends BaseController
{
... lines 26 - 173
public function editTagLineAction(Programmer $programmer, Request $request)
{
... lines 176 - 181
}
}

We already know that the route should be /api/programmers/{nickname}/tagline. To be super hip, add an @Method annotation: we know this should only match PUT requests:

... lines 1 - 23
class ProgrammerController extends BaseController
{
... lines 26 - 169
/**
* @Route("/api/programmers/{nickname}/tagline")
* @Method("PUT")
*/
public function editTagLineAction(Programmer $programmer, Request $request)
{
... lines 176 - 181
}
}

Like before, type-hint the Programmer argument so that Doctrine will query for it for us, using the nickname value. And, we'll also need the Request argument:

... lines 1 - 7
use AppBundle\Entity\Programmer;
... lines 9 - 16
use Symfony\Component\HttpFoundation\Request;
... lines 18 - 23
class ProgrammerController extends BaseController
{
... lines 26 - 173
public function editTagLineAction(Programmer $programmer, Request $request)
{
... lines 176 - 181
}
}

I could use a form like before... but this is just so simple: $programmer->setTagLine($request->getContent()):

... lines 1 - 23
class ProgrammerController extends BaseController
{
... lines 26 - 173
public function editTagLineAction(Programmer $programmer, Request $request)
{
$programmer->setTagLine($request->getContent());
... lines 177 - 181
}
}

Literally: read the text from the request body and set that on the programmer.

Now, save: $em = $this->getDoctrine()->getManager(), $em->persist($programmer) and $em->flush():

... lines 1 - 175
$programmer->setTagLine($request->getContent());
$em = $this->getDoctrine()->getManager();
$em->persist($programmer);
$em->flush();
... lines 180 - 184

For the return, it's not JSON! Return a plain new Response() with $programmer->getTagLine(), a 200 status code, and a Content-Type header of text/plain:

... lines 1 - 17
use Symfony\Component\HttpFoundation\Response;
... lines 19 - 23
class ProgrammerController extends BaseController
{
... lines 26 - 173
public function editTagLineAction(Programmer $programmer, Request $request)
{
... lines 176 - 180
return new Response($programmer->getTagLine());
}
}

Now, this is a good-looking, super-weird endpoint. Copy the test method name and try it out:

./vendor/bin/phpunit --filter testEditTagLine

We're green! Next, let's look at a weirder endpoint.

Leave a comment!

5
Login or Register to join the conversation
Vladimir Z. Avatar
Vladimir Z. Avatar Vladimir Z. | posted 5 years ago

Hi Ryan,

How does Doctrine know to query for the Programmer using the `nickname` value? Does it check that the nickname property is part of the Programmer class and then does findOneBy(array('nickname' => $nickname)), or does it need an existing method (findOneByNickname($nickname)) in ProgrammerRepository?

Thank you!

Reply

Hey, Vlad!

I can help you with it. This feature called <a href="http://symfony.com/doc/current/bundles/SensioFrameworkExtraBundle/annotations/converters.html&quot;&gt;ParamConverter&lt;/a&gt; in Symfony. Actually, in this case triggered the <a href="http://symfony.com/doc/current/bundles/SensioFrameworkExtraBundle/annotations/converters.html#doctrine-converter&quot;&gt;Doctrine Converter</a>. This guy do all that magic:

  1. Determine entity repository from typehinted entity class in method (Programmer in our case);
  2. Then if route has '{id}' placeholder - ParamConverter calls find() method on its repository, otherwise it try to find entity by criteria using findOneBy()('{nickname}' in our case)
  3. And inject found entity into the method or throw an NotFoundHttpException exception

So you don't need to have findOneByNickname() method in your entity repository class.

BTW, you can control mapping of route placeholders to the entity properties and even create your own converter.

Cheers!

Reply
Vladimir Z. Avatar
Vladimir Z. Avatar Vladimir Z. | Victor | posted 5 years ago

Thank you for the explanation, Victor!
How do you control mapping of route placeholders to the entity properties and even create your own converter?

Reply

Hey, Vlad!

Actually, I don't create my own converters, for something complex I manually query an entity from database. It's more quickly than create a custom converter and is more obvious for complex code.

The simple mapping controlling looks like:


/**
 * @Route("/blog/{post_id}")
 * @ParamConverter("post", class="SensioBlogBundle:Post", options={"id" = "post_id"})
 */
public function showAction(Post $post)
{
}

You should explicitly use ParamConverter annotation. In this example we have an ID property for Post entity, but placeholder's name is post_id, so we need to use a custom mapping here (options={"id" = "post_id"}).

Search for 'mapping' on <a href="http://symfony.com/doc/current/bundles/SensioFrameworkExtraBundle/annotations/converters.html#doctrine-converter&quot;&gt;Doctrine Converter</a> page to find more mapping examples.

Cheers!

Reply
Vladimir Z. Avatar
Vladimir Z. Avatar Vladimir Z. | Victor | posted 5 years ago

Thank you, Victor. I will make a not of that. Useful trick to have.

Reply
Cat in space

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

This tutorial uses an older version of Symfony. The concepts of Hypermedia & HATEOAS are still valid. But I recommend using API Platform in modern Symfony apps.

What PHP libraries does this tutorial use?

// composer.json
{
    "require": {
        "php": ">=5.5.9",
        "symfony/symfony": "3.0.*", // v3.0.3
        "doctrine/orm": "^2.5", // v2.5.4
        "doctrine/doctrine-bundle": "^1.6", // 1.6.2
        "doctrine/doctrine-cache-bundle": "^1.2", // 1.3.0
        "symfony/swiftmailer-bundle": "^2.3", // v2.3.11
        "symfony/monolog-bundle": "^2.8", // v2.10.0
        "sensio/distribution-bundle": "^5.0", // v5.0.4
        "sensio/framework-extra-bundle": "^3.0.2", // v3.0.14
        "incenteev/composer-parameter-handler": "~2.0", // v2.1.2
        "jms/serializer-bundle": "^1.1.0", // 1.1.0
        "white-october/pagerfanta-bundle": "^1.0", // v1.0.5
        "lexik/jwt-authentication-bundle": "^1.4", // v1.4.3
        "willdurand/hateoas-bundle": "^1.1" // 1.1.1
    },
    "require-dev": {
        "sensio/generator-bundle": "^3.0", // v3.0.6
        "symfony/phpunit-bridge": "^3.0", // v3.0.3
        "behat/behat": "~3.1@dev", // dev-master
        "behat/mink-extension": "~2.2.0", // v2.2
        "behat/mink-goutte-driver": "~1.2.0", // v1.2.1
        "behat/mink-selenium2-driver": "~1.3.0", // v1.3.1
        "phpunit/phpunit": "~4.6.0", // 4.6.10
        "doctrine/doctrine-fixtures-bundle": "^2.3" // 2.3.0
    }
}
userVoice