Update existing Albums with PUT [Raw Symfony 4]
Next up is PUT
. This is where we start taking data that exists in our system, and making modifications to it. There's two ways we will do this, one is a touch contentious. To update we use either PUT
, or PATCH
.
With PUT
we are expected to send in a full resource, and our implementation will apply our changes over the top of the existing entity.
With PATCH
we can send in only the fields that change, and any existing properties will be left alone.
If this doesn't make a great deal of sense, let's quickly recap the tests which illustrate this more clearly:
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
Scenario: Can update an existing Album - PATCH
Given the request body is:
"""
{
"track_count": 10
}
"""
When I request "/album/2" using HTTP PATCH
Then the response code is 204
As a heads up, PATCH
is contentious verb as it's not a true implementation of how PATCH
is supposed to work. But this isn't a true RESTful API. It's a JSON API, and it's going to be good enough for most circumstances - at least, it has been for me.
With a PUT
we will need to send in every field / property, with the exception of the id
, which we can omit. The reason we can omit this is that we don't have an id
field on our AlbumType
/ form. If you do send it in, you will get an error that you've sent in extra data. You can set an option on your form to disregard additional form fields:
{
"status": "error",
"errors": {
"errors": [
"This form should not contain extra fields."
],
"children": {
"title": [],
"release_date": [],
"track_count": []
}
}
}
To "fix" this, either don't send in extra data in the first place, or instruct Symfony to silently ignore it:
<?php
namespace App\Form;
use App\Entity\Album;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\DateTimeType;
use Symfony\Component\Form\Extension\Core\Type\NumberType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
class AlbumType extends AbstractType
{
public function buildForm(FormBuilderInterface $builder, array $options)
{
$builder
->add('title')
->add(
'release_date',
DateTimeType::class,
[
'widget' => 'single_text',
'format' => 'yyyy-MM-dd\'T\'HH:mm:ssZZZZZ',
'property_path' => 'releaseDate',
]
)
->add(
'track_count',
NumberType::class,
[
'property_path' => 'trackCount',
]
);
}
public function configureOptions(OptionsResolver $resolver)
{
$resolver->setDefaults(
[
'data_class' => Album::class,
'csrf_protection' => false,
+ 'allow_extra_fields' => true,
]
);
}
}
Either is fine, I suppose.
The PUT
Controller Method
There's a bit of work required here. It's a combination of what we did in the get
method, and what we did in the post
method.
Let's start with the method definition itself:
/**
* @Route(
* "/album/{id}",
* name="put_album",
* methods={"PUT"},
* requirements={"id"="\d+"}
* )
* @param int $id
*
* @return JsonResponse
*/
public function put(
Request $request,
$id
)
{
}
The Route annotation is interesting.
We will need to explicitly state which Album ID it is that we wish to update (PUT
).
Therefore our route will have the {id}
placeholder.
Remember we could use a ParamConvertor
to aid in taking the given ID and querying for the matching Album
entity. I leave this to you as an optional improvement. It's quite cool, I'd recommend you read the linked docs and see if you can add this in.
Make sure to give the route a unique name. I've used the imaginative put_album
.
I'm restricting this down to a PUT
request. And the id
placeholder must be a positive integer.
I want access to this $id
value in my put
controller method. This is the same as in the get
method.
I'm also injecting the $request
object, as we will need to use the form submission data here. This is the same as in the post
method.
Handling The Update
/**
* @Route(
* "/album/{id}",
* name="get_album",
* methods={"PUT"},
* requirements={"id"="\d+"}
* )
* @param int $id
*
* @return JsonResponse
*/
public function put(
Request $request,
$id
)
{
$data = json_decode(
$request->getContent(),
true
);
$existingAlbum = $this->findAlbumById($id);
$form = $this->createForm(AlbumType::class, $existingAlbum);
$form->submit($data);
Like in the post
method we start by converting the raw JSON data that is being sent in as the $request
's body content, and turning this into an associative PHP array.
Next, we want to update an existing Album
, so rather than create a new Album()
, as we did in the post
method, we instead retrieve the existing Album
that matches the given ID.
Thanks to the work we put in during the creation of the get
method, we will either find an existing Album
, or throw a 404
.
With the post
method we created our AlbumType
Symfony form with a new
/ empty Album
entity. Here we pass in the $existingAlbum
to createForm
, which means our form will be pre-populated with the existing entity data.
Finally $form->submit($data);
kick starts the Symfony Form submission process. Any changed data is going to be used for updating our existing Album
entity.
Next do three things:
if (false === $form->isValid()) {
return new JsonResponse(
[
'status' => 'error',
'errors' => $this->formErrorSerializer->convertFormToArray($form),
],
JsonResponse::HTTP_BAD_REQUEST
);
}
$this->entityManager->flush();
return new JsonResponse(
null,
JsonResponse::HTTP_NO_CONTENT
);
}
We check if the form submission is considered valid.
We already covered our entity validation. Here, those rules are checked. If any are broken, our form is considered invalid and we return a 400
/ JsonResponse::HTTP_BAD_REQUEST
error with some helpful form error messages.
If we didn't return a 400
error then by this point we're feeling pretty good about ourselves.
At this point we tell Doctrine to save / flush
any changes to the database.
We don't need to persist
here as the Album
entity is already managed by Doctrine. It is known. We only need to persist
with a new
entity instance.
Finally, we return a 204
/ JsonResponse::HTTP_NO_CONTENT
, which simply tells our JSON API consumer that their request was successful, but we have nothing interesting to tell them. After all, they submitted the data, they know the entities ID, what else do we have for them they don't already know?
The Behat test should be passing at this point:
php vendor/bin/behat --tags=t
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: # features/album.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.feature:68
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.38s (9.62Mb)
Onwards, to PATCH
!