API Platform

Introduction

Sometimes developers feel hesitant to use API Platform in their projects. And they are somewhat correct in that regard since they know that usage of libraries can narrow down your capabilities by placing you into the borders of the library standards. The pivotal goal of this article is to demonstrate that these conjectures are exaggerated and API Platform would serve you as a perfect tool for developing APIs regardless of the size of the application.

Noveo API Platform - a perfect tool

Installation

Installation of API Platform is no different than any other bundle. You can you Composer in order to install API Platform:

$ composer require api

You can install API Platform to either new or existing projects. Check your configuration of a database connection. I advise you to install MakerBundle for convenient creation of entities.

$ composer require symfony/maker-bundle --dev

Easy CRUD

Let us create an entity via MakerBundle. Type in terminal:

$ bin/console make:entity

After we enter the entity’s name the bundle asks us if we want to make the entity an Api Resource. Confirm it and add some custom fields such as author, createdAt, title, content, and onReview:

Class name of the entity to create or update (e.g. FierceChef):
 > blogPost

 Mark this class as an API Platform resource (expose a CRUD API for it) (yes/no) [no]:
 > yes

 created: src/Entity/BlogPost.php
 created: src/Repository/BlogPostRepository.php
 
 Entity generated! Now let's add some fields!
 You can always add more fields later manually or by re-running this command.

 New property name (press <return> to stop adding fields):
 > author

 Field type (enter ? to see all types) [string]:
 > ManyToOne

What class should this entity be related to?:
 > User

 Is the BlogPost.author property allowed to be null (nullable)? (yes/no) [yes]:
 > no
Do you want to add a new property to User so that you can access/update BlogPOst objects from it - e.g. $user->getBlogPosts()? (yes/no) [yes]:
 >

 A new property will also be added to the User class so that you can access the related BlogPost objects from it.

 New field name inside User [blogPosts]:
 >

 Do you want to activate orphanRemoval on your relationship?
 A BlogPost is "orphaned" when it is removed from its related User.
 e.g. $user->removeBlogPost($blogPost)
 
 NOTE: If a BlogPost may *change* from one User to another, answer "no".

 Do you want to automatically delete orphaned App\Entity\BlogPost objects (orphanRemoval)? (yes/no) [no]:
> yes
 updated: src/Entity/BlogPost.php

 > createdAt

 Field type (enter ? to see all types) [datetime_immutable]:
 > datetime

 Can this field be null in the database (nullable) (yes/no) [no]:
 > no

 updated: src/Entity/BlogPost.php

 Add another property? Enter the property name (or press <return> to stop adding fields):
 > title

 Field type (enter ? to see all types) [string]:
 > string

 Field length [255]:
 > 255

 Can this field be null in the database (nullable) (yes/no) [no]:
 > no

 updated: src/Entity/BlogPost.php

 Add another property? Enter the property name (or press <return> to stop adding fields):
 > content

 Field type (enter ? to see all types) [string]:
 > text

 Can this field be null in the database (nullable) (yes/no) [no]:
 > no

 updated: src/Entity/BlogPost.php

 Add another property? Enter the property name (or press <return> to stop adding fields):
 > onReview

 Field type (enter ? to see all types) [string]:
 > boolean

 Can this field be null in the database (nullable) (yes/no) [no]:
 > no

 updated: src/Entity/BlogPost.php

 Add another property? Enter the property name (or press <return> to stop adding fields):
 > 
          
 Success! 
          
 Next: When you're ready, create a migration with php bin/console make:migration

Let us check the /api endpoint on our local server. Here we see the  Swagger documentation API Platform created. Incidentally, no line of code was written yet.

Swagger documentation created by API
By the way, if you click on the spider, it shall rise up to the net ;)

You can see every API method in detail. As regards the retrieval of the blog posts collection, API Platform automatically applies pagination to the collection routes (unless configured otherwise). You are free to use Swagger documentation to make requests to your API.

Next, let us move to the configuration of available API methods. Open your BlogPost entity and add the following annotation above the class definition, thereby specifying API methods you want to use in your application.

#[ApiResource(
    collectionOperations: ['get', 'post'],
    itemOperations: ['get', 'patch', 'delete'],
)]

API Platform declares available HTTP methods as parts of collection operations and item operations. Collection operations serve for retrieving all elements from a collection or adding a new element into it. Whereas item operations provide you with means to retrieve, change and delete a resource.

We specified available methods in the attribute above. Let us update the documentation page and see what happens. The method PUT disappears.

Updated documentation page

API Platform easily supplies you with basic CRUD logic.

DTO and Data Transformers

In most cases we may want to modify output before sending a response, like adding custom fields or concealing redundant fields. The task is achieved by using DTO (Data Transfer Object) and Data Transformer classes.

Data Transfer Object is an object used to get a representation of a model that shall be used for particular requests in terms of serializing and deserializing data. Data Transformer is responsible for the processes of serialization and deserialization.

Now, let us get to the subject.

Input DTO

Let us say we want to create a Blog Post resource. We want the user to fill only “title” and “content” fields, the others should not be accessed at the creation point.

In that case the input for creating new blog post should look like the following:

{
    "title": “string”,
    "content": “string”,
}

In order to achieve this let us create a class BlogPostInputPost in the src/Dto project directory.

<?php

namespace App/Dto;

class BlogPostInputPost
{
    public string $title;

    public string $content;
}

Whereupon you are expected to create a BlogPostInputPostDataTransformer class in the src/DataTransformer directory. This class is responsible for the deserialization of the input body and creating a new resource.

All data transformers must implement DataTransformerInterface. This interface contains the method transform the implementation thereof transforms data provided in the DTO into the new resource. Additionally, the interface comes with the supportsTransformation method that specifies if the DataTransformer is suitable for transformation.

Resultingly, the BlogPostInputPostDataTransformer should contain the following code:

<?php

namespace App\DataTransformer;

use ApiPlatform\Core\DataTransformer\DataTransformerInterface;
use App\Dto\BlogPostInputPost;
use App\Entity\BlogPost;
use DateTime;

class BlogPostInputPostDataTransformer implements DataTransformerInterface
{
    public function __construct(private Security $security)
    {
    }
    public function transform($object, string $to, array $context = []): BlogPost
    {
        $blogPost = new BlogPost();

        $blogPost
            ->setCreatedAt(new DateTime())
            ->setTitle($object->title)
            ->setContent($object->content)
            ->setAuthor($this->security->getUser())
            ->setOnReview(true);
    
        return $blogPost;
    }

    public function supportsTransformation($data, string $to, array $context = []): bool
    {
               return (
                  ($context['operation_type'] === 'collection') &&
                  ($context['collection_operation_name'] === 'post') &&
                  ($context['input']['class'] === BlogPostInputPost::class) &&
                  ($to === BlogPost::class)
              );
    }
}

No more actions are required for the data transformer to work.

In order for the POST method to receive BlogPostInputPost class as the input body, you must specify it at the ApiResource attribute:

#[ApiResource(
    collectionOperations: [
            'get',
            'post' => [
                  ‘input’ => BlogPostInputPost::class
            ]
      ],
    itemOperations: ['get', 'patch', 'delete'],
)]

Let us do the same to the PATCH method. If you expect the user to change only the content field of a blog post, create BlogPostInputPatch class and write the following code:

<?php

namespace App\Dto;

class BlogPostInputPatch
{
    public string $body;
    public string $content;
}

You may inquire: “What if I want to change only one field of the resource? Do I have to pass all the values to the request body that were not changed?”.

No, you do not have to. The DataTransformerInitializerInterface should solve the issue. It comes with the initialize method that expects you to prefill the input with the current data. If the request body contains a field with new value, then it should be overwritten when it is passed to the transform method and, conversely, store the present data if no new value was provided.

In all, the contents of the BlogPostInputPatchDataTransformer are presented below:

<?php

namespace App\DataTransformer;

use ApiPlatform\Core\DataTransformer\DataTransformerInitializerInterface;
use App\Dto\BlogPostInputPatch;
use App\Entity\BlogPost;
use Symfony\Component\Serializer\Normalizer\AbstractNormalizer;

class BlogPostInputPatchDataTransformer implements DataTransformerInitializerInterface
{
    public function transform($object, string $to, array $context = []): BlogPost
    {
        $blogPost = $context[AbstractNormalizer::OBJECT_TO_POPULATE];
        $blogPost->setTitle($object->title);
        $blogPost->setContent($object->content);

        return $blogPost;
    }

    public function supportsTransformation($data, string $to, array $context = []): bool
    {
               return (
                 ($context['operation_type'] === 'item') &&
                 ($context['item_operation_name'] === 'patch') &&
                 ($context['input']['class'] === BlogPostInputPatch::class) &&
            ($to === BlogPost::class)
              );

    }

    public function initialize(string $inputClass, array $context = []): BlogPostInputPatch
    {
        $blogPost = $context[AbstractNormalizer::OBJECT_TO_POPULATE];

        $blogPostInput = new BlogPostInputPatch();
        $blogPostInput->title = $blogPost->getTitle();
        $blogPostInput->content = $blogPost->getContent();

        return $blogPostInput;
    }
}

At the same time, the ApiResource attribute should be complemented with the input key for the PATCH operation. Also, let us add the security check, so that only the author of the blog post or an admin can change the contents of the article. To do this add a security key at the target method.

#[ApiResource(
    collectionOperations: [
        'get',
        'post' => [
            'input' => BlogPostInputPost::class,
        ]
    ],
    itemOperations: [
        'get',
        'patch' => [
            'input' => BlogPostInputPatch::class,
            ‘security’ => ‘object.getAuthor() === user || is_granted(\'ROLE_ADMIN\')’
        ],
        'delete'
    ],
)]

Output DTO

Let us say you want to display only the first ten words of contents of a blog post when a user retrieves a collection of blog posts. If the user wants to read all the contents of the blog post, he should get the blog post resource by id.

Firstly, create a DTO class:

<?php

namespace App\Dto;

use DateTime;

class BlogPostOutputCollection
{
    public string $title;

    public string $content;

    public DateTime $createdAt;
}

And the DataTransformer class for the DTO respectively.

<?php

namespace App\DataTransformer;

use ApiPlatform\Core\DataTransformer\DataTransformerInterface;
use App\Dto\BlogPostOutputCollection;
use App\Entity\BlogPost;

class BlogPostOutputCollectionDataTransformer implements DataTransformerInterface
{
    private const WORDS_COUNT = 10;

    public function transform($object, string $to, array $context = []): BlogPostOutputCollection
    {
        $blogPostOutput = new BlogPostOutputCollection();

        $blogPostContentWords = explode(' ', $object->getContent());

        $blogPostContentFirstWords = (count($blogPostContentWords) > self::WORDS_COUNT) ?
            array_slice($blogPostContentWords, 0, self::WORDS_COUNT):
            $blogPostContentWords;

        $blogPostOutput->title = $object->getTitle();
        $blogPostOutput->content = implode(
            ' ',
            $blogPostContentFirstWords
        );
        $blogPostOutput->createdAt = $object->getCreatedAt();

        return $blogPostOutput;
    }

    public function supportsTransformation($data, string $to, array $context = []): bool
    {
        return ($data instanceof BlogPost) && ($to === BlogPostOutputCollection::class);
    }
}

Specify the output DTO class at the ApiResource attribute via the output key.

#[ApiResource(
    collectionOperations: [
        'get' => [
            'output' => BlogPostOutputCollection::class,
        ],
        'post' => [
            'input' => BlogPostInputPost::class,
        ]
    ],
    itemOperations: [
        'get',
        'patch' => [
            'input' => BlogPostInputPatch::class,
        ],
        'delete'
    ],
)]

Let us do the same for the retrieval of a single blog post. To do so, you need to create a DTO class for it:

<?php

namespace App\Dto;

class BlogPostOutputItem
{
    public string $title;

    public string $content;

    public DateTimeInterface $createdAt;
}

Thereafter create a DataTransformer class for transformation:

<?php

namespace App\DataTransformer;

use ApiPlatform\Core\DataTransformer\DataTransformerInterface;
use App\Dto\BlogPostOutputItem;
use App\Entity\BlogPost;

class BlogPostOutputItemDataTransformer implements DataTransformerInterface
{
    public function transform($object, string $to, array $context = []): BlogPostOutputItem
    {
        $blogPostOutputCollection = new BlogPostOutputItem();

        $blogPostOutputCollection->title = $object->getTitle();
        $blogPostOutputCollection->content = $object->getContent();
        $blogPostOutputCollection->comments = $object->getCreatedAt();
        
        return $blogPostOutputCollection;
    }

    public function supportsTransformation($data, string $to, array $context = []): bool
    {
        return ($data instanceof BlogPost) && ($to === BlogPostOutputItem::class);
    }
}

And complement the ApiResource attribute output:

#[ApiResource(
    collectionOperations: [
        'get' => [
            'output' => BlogPostOutputCollection::class,
        ],
        'post' => [
            'input' => BlogPostInputPost::class,
        ],
    ],
    itemOperations: [
        'get' ,
        'patch' => [
            'input' => BlogPostInputPatch::class,
        ],
        'delete',
    ],
    output: BlogPostOutputItem::class,
)]

To sum up, Datatransformers and DTOs are a very powerful and flexible tool in the API Platform. It allows you to perform the task of converting internal data models into external ones.

Custom Controllers

Often there are situations in which you need to take some additional steps when executing queries. For example, we can create a pageview counter for blog posts that will increase by one every time we open a blog post. Let's do that.

First, let's add a view counter for blog posts and change the data transformer and DTO so that it is displayed in queries.

Now let's add a method to the repository that will increase the number of views by 1.

public function incrementViewsCount(BlogPost $blogPost): BlogPost
    {
        $entityManager = $this->getEntityManager();

        $blogPost->setViewsCount($blogPost->getViewsCount() + 1);

        $entityManager->persist($blogPost);
        $entityManager->flush();

        return $blogPost;
    }

The controllers in the API platform are slightly different from the standard controllers in Symfony. They have to contain magic method __invoke() in order for API Platform to call it. Ours will look like this:

<?php

namespace App\Controller;

use App\Entity\BlogPost;
use App\Repository\BlogPostRepository;
use Symfony\Component\HttpKernel\Attribute\AsController;

#[AsController]
class GetBlogPostController
{
    private BlogPostRepository $repository;

    public function __construct(BlogPostRepository $repository)
    {
        $this->repository = $repository;
    }

    public function __invoke(BlogPost $data): BlogPost
    {
        return $this->repository->incrementViewsCount($data);
    }
}

Notice: It is important for the parameter to have the name $data.

Afterwards, specify the custom operation performed by the controller at the ApiResource attribute under the controller key.

#[ApiResource(
    collectionOperations: [
        'get' => [
            'output' => BlogPostOutputCollection::class,
        ],
        'post' => [
            'input' => BlogPostInputPost::class,
        ]
    ],
    itemOperations: [
        'get' => [
            'output' => BlogPostOutputItem::class,
            'controller' => GetBlogPostController::class,
        ],
        'patch' => [
            'input' => BlogPostInputPatch::class,
        ],
        'delete',
    ],
)]

Doctrine Extensions

You might have noticed that we have not used the onReview field so far. Let us fix that.

First, we must set the task as follows: we want to display only the blog posts that were reviewed. Moreover, we want the author of the blogpost to be able to see this article. Furthermore, an admin should have access to the article.

Now about extensions. Doctrine extensions allow you to modify database queries. They are usually used to modify the selection, like filtering by properties in order to omit some records or sort the output.

<?php

namespace App\Doctrine;

use ApiPlatform\Core\Bridge\Doctrine\Orm\Extension\QueryCollectionExtensionInterface;
use Doctrine\ORM\QueryBuilder;
use ApiPlatform\Core\Bridge\Doctrine\Orm\Util\QueryNameGeneratorInterface;
use App\Entity\BlogPost;

class BlogPostOnReviewExtension implements QueryCollectionExtensionInterface, QueryItemExtensionInterface
{
    public function __construct(private Security $security)
    {
    }

    public function applyToCollection(
        QueryBuilder $queryBuilder,
        QueryNameGeneratorInterface $queryNameGenerator,
        string $resourceClass,
        string $operationName = null
    ): void {
        $this->addWhere($resourceClass, $queryBuilder);
    }

    public function applyToItem(
        QueryBuilder $queryBuilder,
        QueryNameGeneratorInterface $queryNameGenerator,
        string $resourceClass,
        array $identifiers,
        string $operationName = null,
        array $context = []
    ): void {
        $this->addWhere($resourceClass, $queryBuilder);
        $queryBuilder
            ->andWhere(sprintf('%s.id = :id', $queryBuilder->getRootAliases()[0]))
            ->setParameter('id', $identifiers['id'])
        ;
    }

    public function addWhere(string $resourceClass, QueryBuilder $queryBuilder): void
    {
        if (BlogPost::class !== $resourceClass) {
            return;
        }

        if ($this->security->isGranted('ROLE_ADMIN')) {
            return;
        }

        $rootAlias = $queryBuilder->getRootAliases()[0];

        $queryBuilder->andWhere(sprintf('%s.onReview = :published', $rootAlias))
            ->orWhere(sprintf('%s.author = :author', $rootAlias))
            ->setParameter('onReview', false)
            ->setParameter('author', $this->security->getUser()->getId())
        ;
    }
}

When a user performs a GET request to retrieve a collection of blog posts, the above extension should be applied to the QueryBuilder responsible for the retrieval of the target entity. If you have autoconfiguration enabled in your project, then there is no need to configure the extension explicitly. However, if the autoconfiguration is disabled, add the following lines to the services.yaml

services:

App\Doctrine\BlogPostOnReviewExtension:
        tags:
            - { name: api_platform.doctrine.orm.query_extension.collection }
            - { name: api_platform.doctrine.orm.query_extension.item 

Conclusion

API Platform comes off as a convenient tool for developing APIs and it is suitable for either small or gargantuan projects. API Platform is still being improved to the current date and strives for flexibility and convenience for the developers’ needs.