In the last parts of this article we talked about:

  • The basics to create a one api endpoint which discovers what operation have to perform through a parameter in the payload.
  • How to add authentication and authorization to our api
  • How to perform operations in the background

If you haven’t read the previous parts, you can do it here:

Part 1: https://medium.com/@ict80/creating-a-one-api-endpoint-with-php-and-symfony-d5f03d3141c0

Part 2: https://medium.com/@ict80/creating-a-one-api-endpoint-with-php-and-symfony-part-2-187604ffeb67

Part 3: https://medium.com/@ict80/creating-a-one-endpoint-api-with-php-and-symfony-part-3-8955325c5101

In this fourth part, I would like to show how to serialize outputs properly. To achieve it, we will use one entitiy: BlogPost. We’ll create an operation which will list last blog posts and we will see how to send output to the client.

I’ve used doctrine ORM to create and manage the entity. For more information about how it works, you can check docs here: https://symfony.com/doc/current/doctrine.html

Serializing entity objects directly (the bad way)

First we will explore a way to send the output to the client which could generate problems. After analyzing it and seeing why it’s not a recommended method, we will refactor it.

The following is the entity we’re working with:

#[ORM\Entity(repositoryClass: BlogPostRepository::class)]
class BlogPost
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
private ?int $id = null;

#[ORM\Column(length: 255)]
private ?string $label = null;

#[ORM\Column(nullable: true)]
private array $tags = [];

#[ORM\Column(type: Types::TEXT)]
private ?string $text = null;

#[ORM\Column]
private ?\DateTimeImmutable $createdAt = null;

// Getter & Setters

}

Getters and setters have been omitted tho avoid showing too many lines.

Let’s see the operation which will list last blog posts:

class ListBlogPostsOperation implements ApiOperationInterface
{

public function __construct(private readonly EntityManagerInterface $em, private readonly ApiOperationInputHandler $apiOperationInputHandler) { }


public function perform(ApiInput $apiInput): ApiOutput
{
/**
* @var ListBlogPostsInput $inputObject
*/
$inputObject = $this->apiOperationInputHandler->denormalizeAndValidate($apiInput->getData(), $this->getInput());
$blogPosts = $this->em->getRepository(BlogPost::class)->findLastBlogPosts($inputObject->getLimit());

return new ApiOutput($blogPosts, Response::HTTP_OK);
}

public function getName(): string
{
return 'ListBlogPostsOperation';
}

public function getInput(): string
{
return ListBlogPostsInput::class;
}

public function getGroup(): ?string
{
return null;
}
}

This is the input class:

class ListBlogPostsInput
{
private ?int $limit = 100;

public function getLimit(): ?int
{
return $this->limit;
}

public function setLimit(?int $limit): void
{
$this->limit = $limit;
}

}

And this is the repository method:

public function findLastBlogPosts(?int $limit): array
{
$qb = $this->createQueryBuilder('bp');
$qb->orderBy('bp.createdAt', 'desc');

if($limit > 0){
$qb->setMaxResults($limit);
}

return $qb->getQuery()->getResult();
}

As we can see, the operation retrieve last blog posts with 100 as a default limit, but we can change it sending desired limit on the payload. When retrieved, it pass $blogPosts as first argument of ApiOutput. This is not a recommended method and have the following disadvantages:

  • Operation output is directly coupled to the database schema so each time we change database schema it will change api output. To avoid this, we could use serializer groups but, even so, developers should remember to add group for every new entity property.
  • As schema grows with more fields and relations, errors could occur due to circular references.

Creating an operation output and its builder

Instead of serializing what we get from doctrine query, let’s create custom output for the operation and its builder.

class BlogPostOutput
{
public function __construct(
public readonly string $label,
public readonly string $text,
public readonly array $tags,
public readonly string $date
) { }
}
class ListBlogPostsOperationOutputBuilder
{
/**
* @param BlogPost[] $blogPosts
* @return BlogPostOutput[]
*/
public function build(array $blogPosts): array
{
$blogPostsOutput = [];
foreach ($blogPosts as $blogPost){
$blogPostsOutput[] = new BlogPostOutput(
$blogPost->getLabel(),
$blogPost->getText(),
$blogPost->getTags(),
$blogPost->getCreatedAt()->format('Y-m-d')
);
}

return $blogPostsOutput;
}
}

ListBlogPostsOperationOutputBuilder simply receives doctrine results as a parameter, loops them and for each creates a BlogPostOutput and adds it to a new array. This new array is returned.

Let’s see how it is used in ListBlogPostsOperation

    public function __construct(
private readonly EntityManagerInterface $em,
private readonly ApiOperationInputHandler $apiOperationInputHandler,
private readonly ListBlogPostsOperationOutputBuilder $outputBuilder
) { }


public function perform(ApiInput $apiInput): ApiOutput
{
/**
* @var ListBlogPostsInput $inputObject
*/
$inputObject = $this->apiOperationInputHandler->denormalizeAndValidate($apiInput->getData(), $this->getInput());
$blogPosts = $this->em->getRepository(BlogPost::class)->findLastBlogPosts($inputObject->getLimit());

return new ApiOutput($this->outputBuilder->build($blogPosts), Response::HTTP_OK);
}

........

Now, instead of passing the doctrine results to the ApiOutput, we first build the output using ListBlogPostsOperationOutputBuilder and then pass it to ApiOutput. Following this way, we are no longer coupled to the database schema and any change on it will no affect to our output. After modifying the schema, if we would show new fields to the client, we will have to add it to BlogPostOutput and modify the builder.

Resume

In this article we’ve seen that serializing doctrine results as an api output is not a good idea. To decouple api output from doctrine we’ve created an output builder which takes doctrine results and builds a custom output. This way gives us total control about what we are sending to the client. Besides, it would we easy to add more functionality to the builder like adding or removing fields depending on the user role.

--

--