Updating with PUT [FOSRESTBundle]
At this point we can add new Albums, get an individual Album, and also get a full list of Albums. It would be super nice if we could update existing albums. Let's get right on to that now.
In this video we will cover how to use PUT
.
A PUT
request expects us to provide a full representation of our Album, with any field changes replacing existing data of the same field.
This is more obvious when seen in a test:
Feature: Provide a consistent standard JSON API endpoint
In order to build interchangeable front ends
As a JSON API developer
I need to allow Create, Read, Update, and Delete functionality
Background:
Given there are Albums with the following details:
| title | track_count | release_date |
| some fake album name | 12 | 2020-01-08T00:00:00+00:00 |
| another great album | 9 | 2019-01-07T23:22:21+00:00 |
| now that's what I call Album vol 2 | 23 | 2018-02-06T11:10:09+00:00 |
Scenario: Can update an existing Album - PUT
Given the request body is:
"""
{
"title": "Renamed an album",
"track_count": 9,
"release_date": "2019-01-07T23:22:21+00:00"
}
"""
When I request "/album/2" using HTTP PUT
Then the response code is 204
We must send in every field, even if we aren't updating / changing that fields data.
The PUT
implementation is like a mix of the getAction
and postAction
.
First we need to lookup the existing Album in our database.
Then we use this album as the starting point for our form submission.
We take the incoming data and put (excuse the pun) this over the top of the existing data. We use the Symfony form for this.
Should validation fail, we return the form errors.
If validation passes, we save changes to the database.
Finally we return a 204
status code, which means the resource was successfully updated but we have "No Content" to send back to the API consumer.
Why no content? Well, they already knew the resource, they already sent in the new data... what else can we tell them? Nothing, that's what.
The PUT
Implementation
As you no doubt expect by now, FOSRESTBundle has a convention we can follow which will do most of the hard work for us.
We just need to implement the putAction
:
public function putAction(Request $request, string $id)
{
}
Nothing new to inject here. We have the AlbumRepository
from our getAction
and cgetAction
. And we have the EntityManagerInterface
from our postAction
. We will need both.
Checking the router:
bin/console debug:router
-------------------------- -------- -------- ------ -----------------------------------
Name Method Scheme Host Path
-------------------------- -------- -------- ------ -----------------------------------
cget_album GET ANY ANY /album
get_album GET ANY ANY /album/{id}
post_album POST ANY ANY /album
put_album PUT ANY ANY /album/{id}
-------------------------- -------- -------- ------ -----------------------------------
Cool, a new route that's locked down to PUT
requests.
Here's the new implementation:
public function putAction(Request $request, string $id)
{
$existingAlbum = $this->albumRepository->find($id);
$form = $this->createForm(AlbumType::class, $existingAlbum);
$form->submit($request->request->all());
if (false === $form->isValid()) {
return $this->view($form);
}
$this->entityManager->flush();
return $this->view(null, Response::HTTP_NO_CONTENT);
}
And this passes:
vendor/bin/behat features/album_symfony_4.feature --tags=t
Feature: Provide insight into how Symfony 4 behaves on the unhappy path
In order to eliminate bad Album data
As a JSON API developer
I need to ensure Album data meets expected criteria
Background: # features/album_symfony_4.feature:7
Given there are Albums with the following details: # FeatureContext::thereAreAlbumsWithTheFollowingDetails()
| title | track_count | release_date |
| some fake album name | 12 | 2020-01-08T00:00:00+00:00 |
| another great album | 9 | 2019-01-07T23:22:21+00:00 |
| now that's what I call Album vol 2 | 23 | 2018-02-06T11:10:09+00:00 |
@t
Scenario: Can update an existing Album - PUT # features/album_symfony_4.feature:43
Given the request body is: # Imbo\BehatApiExtension\Context\ApiContext::setRequestBody()
"""
{
"title": "Renamed an album",
"track_count": 9,
"release_date": "2019-01-07T23:22:21+00:00"
}
"""
When I request "/album/2" using HTTP PUT # Imbo\BehatApiExtension\Context\ApiContext::requestPath()
Then the response code is 204 # Imbo\BehatApiExtension\Context\ApiContext::assertResponseCodeIs()
1 scenario (1 passed)
4 steps (4 passed)
0m0.14s (9.74Mb)
All good?
Yes, on the happy path.
What happens if we try to PUT
to a non-existent ID? Let's find out.
Updating a Non Existent ID
If we update the test - or use Postman to send in a manual request - to PUT
to an ID that doesn't exist, let's see what happens:
vendor/bin/behat features/album_symfony_4.feature --tags=t
Feature: Provide insight into how Symfony 4 behaves on the unhappy path
In order to eliminate bad Album data
As a JSON API developer
I need to ensure Album data meets expected criteria
Background: # features/album_symfony_4.feature:7
Given there are Albums with the following details: # FeatureContext::thereAreAlbumsWithTheFollowingDetails()
| title | track_count | release_date |
| some fake album name | 12 | 2020-01-08T00:00:00+00:00 |
| another great album | 9 | 2019-01-07T23:22:21+00:00 |
| now that's what I call Album vol 2 | 23 | 2018-02-06T11:10:09+00:00 |
@t
Scenario: Can update an existing Album - PUT # features/album_symfony_4.feature:43
Given the request body is: # Imbo\BehatApiExtension\Context\ApiContext::setRequestBody()
"""
{
"title": "Renamed an album",
"track_count": 9,
"release_date": "2019-01-07T23:22:21+00:00"
}
"""
When I request "/album/9999999999999" using HTTP PUT # Imbo\BehatApiExtension\Context\ApiContext::requestPath()
Then the response code is 204 # Imbo\BehatApiExtension\Context\ApiContext::assertResponseCodeIs()
1 scenario (1 passed)
4 steps (4 passed)
0m0.16s (9.71Mb)
Hmm.
That's not right.
What's happening here?
Ok, our implementation again for reference:
public function putAction(Request $request, string $id)
{
$existingAlbum = $this->albumRepository->find($id);
$form = $this->createForm(AlbumType::class, $existingAlbum);
$form->submit($request->request->all());
if (false === $form->isValid()) {
return $this->view($form);
}
$this->entityManager->flush();
return $this->view(null, Response::HTTP_NO_CONTENT);
}
We try to find the Album
by ID.
Of course Album
with ID 9999999999999
doesn't exist.
We could throw
here, or alternatively, return a View
with a 404
code. I'll explain why I prefer to throw
momentarily.
As we don't throw
/ return
early / exit, so let's carry on.
At this point $existingAlbum
is null
.
If we pass in a null
to createForm
, then the AlbumType
will fall back to a new
instance of whatever data_class
we configured on that Form Type:
// src/Form/AlbumType.php
public function configureOptions(OptionsResolver $resolver)
{
$resolver->setDefaults(
[
'data_class' => Album::class,
'allow_extra_fields' => true,
]
);
}
Our PUT
data is valid. We have all the expected fields in the right formats. Therefore the form submission valid
check returns true
.
Crucially we do not need to persist
this new Album
entity.
We do flush
, but as this Album
is not managed by Doctrine (not persisted
) the flush
call does not save the data to the database. Therefore, we do not get a newly created Album in this instance.
It may be that you do want to allow PUT
requests to create new resources if the requested ID doesn't exist. I don't like this myself. I'd rather the API consumer did the check themselves. This is open to your own interpretation - do what you want, just be consistent, and document / test the process.
Finally because we return a 204
response, our test passes. Ahhh a false positive. Nice.
404
To The Floor
I'm going to add in additional logic to throw
if $this->albumRepository->find($id);
returns null
:
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
// ...
public function putAction(Request $request, string $id)
{
$existingAlbum = $this->albumRepository->find($id);
if (null === $existingAlbum) {
throw new NotFoundHttpException();
}
Why throw
, and not return a View
?
Well, I have two reasons:
- Reusability
- FOSRESTBundle will helpfully serialize our Exceptions
There are multiple locations inside this single AlbumController
where we make a call to $this->albumRepository->find($id)
.
We have one in getAction
. Another here in putAction
. We'll duplicate most of this for patchAction
, and yet another call in the forthcoming deleteAction
.
It makes sense, in my mind, to extract this into a private
method inside the AlbumController
:
public function putAction(Request $request, string $id)
{
$existingAlbum = $this->findAlbumById($id);
$form = $this->createForm(AlbumType::class, $existingAlbum);
$form->submit($request->request->all());
if (false === $form->isValid()) {
return $this->view($form);
}
$this->entityManager->flush();
return $this->view(null, Response::HTTP_NO_CONTENT);
}
/**
* @param string $id
*
* @return Album
* @throws \Symfony\Component\HttpKernel\Exception\NotFoundHttpException
*/
private function findAlbumById(string $id)
{
$existingAlbum = $this->albumRepository->find($id);
if (null === $existingAlbum) {
throw new NotFoundHttpException();
}
return $existingAlbum;
}
We either want to absolutely find the Album
, or throw
.
I'm choosing to throw
something built in to Symfony: the NotFoundHttpException
.
We could create a more specific exception class here. I can't see any benefit to this in this particularly instance, but on a larger API it's a good shout.
By throw
ing this exception, we don't need to explicitly handle a null
inside putAction
, or getAction
, or any of the other actions that will make use of this private method.
With that in mind, let's update getAction
also:
public function getAction(string $id)
{
return $this->view(
$this->findAlbumById($id)
);
}
Cool, now getAction
will either find, or throw. This is the same as the existing behaviour for that method - nothing new here. Just standardising behaviour.
There's more we could do with FOSRESTBundle exception handling. I urge you to read the docs as this is a very cool feature.
But this gets us to a working putAction
. Now, onto PATCH
.