POSTing in New Accounts
In this video we are going to cover the POST
method for our Accounts endpoint. POST
is used to allow our API consumers to submit brand new data to our API. The data that the user / consumer is sending in should be structurally identical (almost!) to the data that we would get back from a GET
request to the same endpoint.
Why only almost identical? Well, when we GET
, we should receive an id
field:
Scenario: User can GET an individual Account by ID
When I send a "GET" request to "/accounts/a1"
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"
}]
}
"""
But when we are initially sending in some new data, we won't have an id
field, so that can be omitted:
Scenario: User can add a new Account
When I send a "POST" request to "/accounts" with body:
"""
{
"name": "a new account name",
"users": [{
"id": "u1",
"username": "peter",
"email": "peter@test.com"
}]
}
"""
Then the response code should be 201
Technically you do not need to do this. You could have your GET
response look entirely different to the 'shape' of data that is needed by a POST
request. I'd strongly advise against this however, as anyone who has to directly interact with your API (developers... you!) will not thank you for it. Consistency is key.
Converting From ID to Data
You may have a question at this point: how does Symfony's form know how to convert the users
JSON data back into a real User?
Fortunately, FOSRESTBundle comes with a solution to this problem in the form of EntityToIdObjectTransformer
. Essentially this is a data transformer that can look at our submitted JSON, find the ID field, and then do a query against the repository to find the underlying entity.
What I found here with their default implementation is that this would give access to any entity that is requested. We've already covered in quite some depth that we don't want that to be the case in our system. As such, I have changed the provided implementation with one that better suits our needs in this instance:
<?php
// src/AppBundle/Form/Transformer/EntityToIdObjectTransformer.php
namespace AppBundle\Form\Transformer;
use AppBundle\Repository\RepositoryInterface;
use Symfony\Component\Form\DataTransformerInterface;
use Symfony\Component\Form\Exception\TransformationFailedException;
/**
* Class EntityToIdObjectTransformer
* @package AppBundle\Form\Transformer
*/
class EntityToIdObjectTransformer implements DataTransformerInterface
{
/**
* @var RepositoryInterface
*/
private $repository;
/**
* EntityToIdObjectTransformer constructor.
* @param RepositoryInterface $repository
*/
public function __construct(RepositoryInterface $repository)
{
$this->repository = $repository;
}
/**
* Do nothing.
*
* @param object|null $object
*
* @return string
*/
public function transform($object)
{
return '';
}
/**
* Transforms an array including an identifier to an object.
*
* @param array $idObject
*
* @return object|null
*
* @throws TransformationFailedException if object is not found.
*/
public function reverseTransform($idObject)
{
if (!is_array($idObject)) {
return false;
}
if ( ! array_key_exists('id', $idObject)) {
throw new TransformationFailedException('Unable to find an ID key / value pair on passed in $idObject');
}
$object = $this->repository->findOneById($idObject['id']);
if (null === $object) {
throw new TransformationFailedException(sprintf(
'A "%s" with ID "%s" does not exist!',
get_class($object),
$idObject['id']
));
}
return $object;
}
}
This way, we use our repository to do the look up, rather than a direct query in the original implementation.
Another tweak I added was to allow JSON arrays to be converted back:
<?php
// src/AppBundle/Form/Transformer/ManyEntityToIdObjectTransformer.php
namespace AppBundle\Form\Transformer;
use Symfony\Component\Form\DataTransformerInterface;
class ManyEntityToIdObjectTransformer implements DataTransformerInterface
{
/**
* @var EntityToIdObjectTransformer
*/
private $entityToIdObjectTransformer;
/**
* ManyEntityToIdObjectTransformer constructor.
* @param EntityToIdObjectTransformer $entityToIdObjectTransformer
*/
public function __construct(EntityToIdObjectTransformer $entityToIdObjectTransformer)
{
$this->entityToIdObjectTransformer = $entityToIdObjectTransformer;
}
/**
* Do nothing
*
* @param array $array
* @return array
*/
public function transform($array)
{
$transformed = [];
if (empty($array) || null === $array) {
return $transformed;
}
foreach ($array as $k => $v) {
$transformed[] = $this->entityToIdObjectTransformer->transform($v);
}
return $transformed;
}
/**
* Transforms an array of arrays including an identifier to an object.
*
* @param array $array
*
* @return array
*/
public function reverseTransform($array)
{
if (!is_array($array)) {
$array = [$array];
}
$reverseTransformed = [];
foreach ($array as $k => $v) {
$reverseTransformed[] = $this->entityToIdObjectTransformer->reverseTransform($v);
}
return $reverseTransformed;
}
}
This is useful as in the case of our /accounts
endpoint, the users
field is an array:
"users": [{
"id": "u1",
"username": "peter",
"email": "peter@test.com"
}]
By using the ManyEntityToIdObjectTransformer
, any number of passed in id
fields can be converted back to the respective entity. And because the query goes via the repository, only those entities that the current User should have access to will be accessible.
Interestingly at this point, this exposes a bug in the system. I would like to be able to POST
in a JSON array of Users who have access to the new Account. However, as we have already covered, we only have access to our own User object. When the id
field is attempted to be converted back to an object, the repository will throw a 401
at this stage. You may not need this functionality. I have a requirement here that is not yet implemented - I'd like to email any users that are requested to join a new Account.
Let's look at how a form would look with these extra transformers added:
<?php
// src/AppBundle/Form/Type/AccountType.php
namespace AppBundle\Form\Type;
use AppBundle\Repository\UserRepositoryInterface;
use AppBundle\Form\Transformer\EntityToIdObjectTransformer;
use AppBundle\Form\Transformer\ManyEntityToIdObjectTransformer;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\Form\Extension\Core\Type\TextType;
use Symfony\Component\Form\Extension\Core\Type\CollectionType;
use Symfony\Component\OptionsResolver\OptionsResolver;
/**
* Class AccountType
* @package AppBundle\Form\Type
*/
class AccountType extends AbstractType
{
/**
* @var UserRepositoryInterface
*/
private $userRepository;
/**
* AccountType constructor.
* @param UserRepositoryInterface $userRepository
*/
public function __construct(UserRepositoryInterface $userRepository)
{
$this->userRepository = $userRepository;
}
/**
* @param FormBuilderInterface $builder
* @param array $options
*/
public function buildForm(FormBuilderInterface $builder, array $options)
{
$userTransformer = new EntityToIdObjectTransformer($this->userRepository);
$userCollectionTransformer = new ManyEntityToIdObjectTransformer($userTransformer);
$builder
->add(
$builder->create('users', TextType::class)->addModelTransformer($userCollectionTransformer)
)
;
$builder
->add('name', TextType::class)
;
}
public function configureOptions(OptionsResolver $resolver)
{
$resolver->setDefaults([
'data_class' => 'AppBundle\DTO\AccountDTO',
]);
}
public function getName()
{
return 'account';
}
}
These steps are seperated as you may not always have a JSON array:
$userTransformer = new EntityToIdObjectTransformer($this->userRepository);
$userCollectionTransformer = new ManyEntityToIdObjectTransformer($userTransformer);
And then we come to the non-standard way you may used to be using Symfony forms:
public function configureOptions(OptionsResolver $resolver)
{
$resolver->setDefaults([
'data_class' => 'AppBundle\DTO\AccountDTO',
]);
}
Rather than using entities with the form, instead each form takes a DTO, or Data Transfer Object.
Why are we doing this?
Well, the entities do not necessarily have setters
for each property. This is a bit of a deal breaker for Symfony's form component. Without a setter
per property, the form will throw errors when trying to update the entity passed in to the form.
An example of this may be the Account::name
property:
// src/AppBundle/Entity/Account.php
* snip *
class Account implements AccountInterface, \JsonSerializable
{
/**
* @ORM\Column(type="string", name="name")
* @JMSSerializer\Expose
* @JMSSerializer\Groups({"accounts_all","accounts_summary"})
*/
private $name;
/**
* @return string
*/
public function getName()
{
return $this->name;
}
/**
* @param string $newName
* @return $this
*/
public function changeName($newName)
{
$this->name = (string) $newName;
return $this;
}
This effectively breaks Symfony's form component if we try to directly use this entity.
Instead, a DTO can be created which does have a setName
method available:
<?php
// src/AppBundle/DTO/AccountDTO.php
namespace AppBundle\DTO;
use AppBundle\Model\AccountInterface;
use AppBundle\Model\FileInterface;
use AppBundle\Model\ScheduleInterface;
use AppBundle\Model\SocialMediaProfileInterface;
use AppBundle\Model\UserInterface;
use Doctrine\Common\Collections\ArrayCollection;
use Symfony\Component\Validator\Constraints as Assert;
/**
* Class AccountDTO
* @package AppBundle\DTO
*/
class AccountDTO implements AccountInterface, DTOInterface, SymfonyFormDTOInterface
{
/**
* @var string
* @Assert\NotBlank()
*/
private $name;
/**
* @return mixed
*/
public function getName()
{
return $this->name;
}
/**
* @param mixed $name
* @return $this
*/
public function setName($name)
{
$this->name = $name;
return $this;
}
/**
* @param $newName
* @return mixed
*/
public function changeName($newName)
{
throw new \RuntimeException(sprintf('Should never be calling %s on an Account DTO', __METHOD__));
}
}
You may think this is totally overkill, and you are welcome to stick to the classic approach of using entities directly with your form.
Using the DTO is really no different than how you are likely very used to using the Symfony form already:
// src/AppBundle/Handler/AccountHandler.php
/**
* @param array $parameters
* @param array $options
* @return AccountInterface
*/
public function post(array $parameters, array $options = [])
{
$accountDTO = $this->formHandler->handle(
new AccountDTO(),
$parameters,
Request::METHOD_POST,
$options
);
$account = $this->factory->createFromDTO($accountDTO);
$this->repository->save($account);
return $account;
}
Rather than pass in a new instance of our Account
entity, instead we simply pass in the AccountDTO
, with all it's setters
and getters
, that the form component needs.
We can do a bit of validation here as required - checking that the Account name isn't blank in this case.
If we get to this line:
$account = $this->factory->createFromDTO($accountDTO);
We can assume everything went well in our form submission. Exceptions would have been thrown during the handle
method if not. We've already covered these in previous videos.
It could be argued that a named constructor of Account::createFromDTO(AccountDTO $accountDTO)
would be a better implementation here than using a factory:
<?php
// src/AppBundle/Factory/AccountFactory.php
namespace AppBundle\Factory;
use AppBundle\DTO\AccountDTO;
use AppBundle\Entity\Account;
class AccountFactory implements AccountFactoryInterface
{
/**
* @param string $accountName
* @return Account
*/
public function create($accountName)
{
return new Account($accountName);
}
/**
* @param AccountDTO $accountDTO
* @return Account
*/
public function createFromDTO(AccountDTO $accountDTO)
{
$account = self::create($accountDTO->getName());
foreach ($accountDTO->getUsers() as $user) { /** @var $user \AppBundle\Model\UserInterface */
$user->addAccount($account);
}
foreach ($accountDTO->getSocialMediaProfiles() as $socialMediaProfile) {
$account->addSocialMediaProfile($socialMediaProfile);
}
return $account;
}
}
If you do decide to refactor to named constructors, there's a helpful section in the PHPSpec Cookbook on testing named constructors.
We're going to use the exact same concept in the PUT
and PATCH
stages, only there we will need to take the existing data and construct a DTO from the entity before we can use the form. We'll cover that in the next video.