A Quick API Platform Hands On Review for Symfony App Development

There are many ways of REST API implementation in a symfony app. One of them is REST API Patform. We get REST API protocol support, documentation and Swagger UI with the possibility to test endpoints out of the box.

Quick Start

We add API platform into the application.

    composer req api

Swagger UI is active by default and available at http://localhost/api:

While no end point is defined (the operation in API Platform terms), it’s simple to do it. You should only add annotation @ApiResource to the entity.

    <?php
     
    declare(strict_types=1);
     
    namespace App\Entity;
     
    ...
     
    /**
     * Customer.
     *
     * @ORM\Entity()
     *
     * @ApiResource()
     */
    class Customer
    {
        /**
    ...

A full set of CRUD operations has appeared. All the operations are divided into 2 groups:

    • collection operations — operations with the collection of elements
    • item operations — operations with a data element.

Collection operations:

    • GET - to get a list of elements;
    • POST - to add an element to the collection.

Item operations:

    • GET - to get an element by ID;
    • PUT - to change an element;
    • DELETE - to delete an element from the collection;
    • PATCH - to partly substitute the element.

Element configuration can be described in the annotations, xml or yaml files. The yaml files location is defined by the path parameter of the configuration file config/packages/api_platform.yaml

    api_platform:
        mapping:
            paths:
                - '%kernel.project_dir%/src/Entity'
                - '%kernel.project_dir%/config/api_platform'

If it’s needed, we can restrict the set of the operations with a list:

    <?php
     
    declare(strict_types=1);
     
    namespace App\Entity;
     
    ...
     
    /**
     * Customer.
     *
     * @ORM\Entity()
     *
     * @ApiResource(collectionOperations={"get","post"},itemOperations={"get","delete"})
     */
    class Customer
    {
        /**
    ...

or in a yaml file:

    # config/api_platform/customer.yaml
    App\Entity\Customer:
      itemOperations:
        get: ~
        delete: ~
      collectionOperations:
        get:
        post: ~

Per-page collection display

The elements collections support per-page layout.

Validation

If it is necessary to check the input data, it is enough to add an entity property validator — the API platform generates an error message:

    ...
        /**
         * @var string
         *
         * @ORM\Column(type="string", length=50, nullable=false)
         *
         * @Assert\NotBlank()
         * @Assert\NotNull()
         * @Assert\Length(max="50")
         */
        private $name;
    ...

with a line of more than 50 characters we’ll get an error:

    {
      "@context": "/api/contexts/ConstraintViolationList",
      "@type": "ConstraintViolationList",
      "hydra:title": "An error occurred",
      "hydra:description": "name: This value is too long. It should have 50 characters or less.",
      "violations": [
        {
          "propertyPath": "name",
          "message": "This value is too long. It should have 50 characters or less."
        }
      ]
    }

Related Objects

To provide the uniqueness of object identifiers API platform uses IRI (Internationalized Resource Identifier). Each object’s IRI coincides with the GET request for this object /api/customer/123. The same type of identifier is used for the requests of objects adding and editing.

Let’s add a new entity related to the Customer entity.

    ...
    /**
     * Message.
     *
     * @ORM\Entity()
     *
     * @ApiResource()
     */
    class Message
    {
        ...
     
        /**
         * @var Customer
         *
         * @ORM\ManyToOne(targetEntity="App\Entity\Customer", inversedBy="messages")
         * @ORM\JoinColumn(nullable=false)
         *
         * @Assert\NotBlank()
         * @Assert\Type(type="App\Entity\Customer")
         */
        private $createdBy;
        ...
    }

The body of the request to add a new item will look like this:

    {
       ...
       “createBy”:/api/customer/123,
       ...
    }

The list of the collection items will look like this:

    {
      "@context": "/api/contexts/Customer",
      "@id": "/api/customers",
      "@type": "hydra:Collection",
      "hydra:member": [
        {
          "@id": "/api/customers/1",
          "@type": "Customer",
          "id": 1,
          "name": "name 1",
          "email": "test@test.com",
          "messages": [
            "/api/messages/3",
            "/api/messages/2",
            "/api/messages/1"
          ]
        },
      ],
      "hydra:totalItems": 1
    }

As you can see the list of messages is presented as an array of IRI strings:

       "messages": [
            "/api/messages/3",
            "/api/messages/2",
            "/api/messages/1"
          ]

The request http: // localhost / api / messages / 1 will provide such result:

    {
      "@context": "/api/contexts/Message",
      "@id": "/api/messages/1",
      "@type": "Message",
      "id": 1,
      "createdBy": "/api/customers/1",
      "title": "title 1",
      "text": "message 1"
    }

Using serialization groups, we can display not only the IRI of the object, but also the data of the nested object in case of necessity. To do this, for the item operation GET, we’ll specify the serialization group:

    # config/api_platform/message.yaml
    App\Entity\Message:
      itemOperations:
        get:
          normalization_context:
            groups:
              - message
        put: ~
        delete: ~
      collectionOperations:
        get: ~
        post: ~

We’ll add the group to the properties of the Message and Customer objects. Now the result of the same query will be different.

    {
      "@context": "/api/contexts/Message",
      "@id": "/api/messages/1",
      "@type": "Message",
      "id": 1,
      "createdBy": {
        "@id": "/api/customers/1",
        "@type": "Customer",
        "id": 1,
        "name": "customer 1"
      },
      "title": "title 1",
      "text": "message 1"
    }

Filters

API platform has a built-in filter mechanism:

Doctrine ORM and Mongo ODM

    • Search filter (string fields);
    • Date filter;
    • Boolean filter;
    • Range filter (numeric fields);
    • Exists filter (nullable values);
    • Order filter (collection’s sort order changes).

Elasticsearch

    • Ordering filter;
    • Matching filter;
    • Term filter.

Serialization filters

    • Group filter (defines a list of serialization groups for the query result);
    • Property filter (defines the list of displayed properties).

There is also a possibility to define the user filters types.

Let’s add the string fields filter for example:

    ...
    /**
     * Message.
     *
     * @ORM\Entity()
     *
     * @ApiResource()
     * @ApiFilter(SearchFilter::class, properties={"title": "iend", "text": "partial"})
     */
    class Message
    {
    ...

We have added 2 filters

    • by title field — it will search for all the elements, which have the title field ending as in the search sample. The search is case independent (symbol i in the beginning of the filter type name).
    • by text field — will search for all the elements having a search sample in the text field. The search depends on case.

Data Transfer Objects (DTO) Usage

In case of necessity we can use not the entities directly, but DTOs. We have to take 3 steps for this.

  1. To describe DTOs input and output classes.
  2. Indicate the classes as input and output in our entity parameters.
  3. Describe Data Transformer classes for entity into DTO transformation and back.

Input DTO:

    ...
    /**
     * MessageInputDto.
     */
    class MessageInputDto
    {
    ...
    }
    ...

Output DTO:

    ...
    /**
     * MessageOutputDto.
     */
    class MessageOutputDto
    {
    ...
    }
    ...

Adding DTO classes into entity description:

    /**
     * Message.
     *
     * @ORM\Entity()
     *
     * @ApiResource(
     *     input=MessageInputDto::class,
     *     output=MessageOutputDto::class
     * )
     */
    class Message
    {
    ...

Data Transformer for input DTO:

    /**
     * MessageInputTransformer.
     */
    class MessageInputTransformer implements DataTransformerInterface
    {
        /**
         * {@inheritDoc}
         */
        public function transform($object, string $to, array $context = []): Message
        {
            $message = new Message();
            ... 
     
            return $message;
        }
     
        /**
         * {@inheritDoc}
         */
        public function supportsTransformation($data, string $to, array $context = []): bool
        {
            return Message::class === $to && $data instanceof MessageInputDto;
        }
    }

Data Transformer for output DTO:

    /**
     * MessageOutputTransformer.
     */
    class MessageOutputTransformer implements DataTransformerInterface
    {
        /**
         * {@inheritDoc}
         */
        public function transform($object, string $to, array $context = []): MessageOutputDto
        {
            $dto = new MessageOutputDto();
            ... 
     
            return $dto;
        }
     
        /**
         * {@inheritDoc}
         */
        public function supportsTransformation($data, string $to, array $context = []): bool
        {
            return MessageOutputDto::class === $to && $data instanceof Message;
        }
    }
     

Such an approach has a drawback – DTO validation won’t work. To correct this, you should change the transformer class input DTO.

    /**
     * MessageInputTransformer.
     */
    class MessageInputTransformer implements DataTransformerInterface
    {
        private $validator;
     
        /**
         * @param ValidatorInterface $validator
         */
        public function __construct(ValidatorInterface $validator)
        {
            $this->validator = $validator;
        }
     
        /**
         * {@inheritDoc}
         */
        public function transform($object, string $to, array $context = []): Message
        {
            $this->validator->validate($object);
     
            $message = new Message();
            ... 
     
            return $message;
        }
     
        /**
         * {@inheritDoc}
         */
        public function supportsTransformation($data, string $to, array $context = []): bool
        {
            return Message::class === $to && $data instanceof MessageInputDto;
        }
    }

User controllers

In case none of the methods described above allows us to get the desirable result, we have an opportunity to use our own controller.

Variant 1

    /**
     * CustomerAction.
     */
    class CustomerAction
    {
        private $customerService;
     
        /**
         * @param CustomerService $customerService
         */
        public function __construct(CustomerService $customerService)
        {
            $this->customerService = $customerService;
        }
     
        /**
         * @param Customer $data
         *
         * @return Customer
         */
        public function __invoke(Customer $data): Customer
        {
            $this->customerService->handle($data);
     
            return $data;
        }
    }
     

The name of the method parameter is __invoke, there should obligatory be $data. Otherwise it would be empty. Let’s add the controller into the entity configuration.

    /**
     * Customer.
     *
     * @ORM\Entity()
     *
     * @ApiResource(itemOperations={
     *     "get",
     *     "delete",
     *     "customer_action"={
     *          "method"="POST",
     *          "path"="/api/customer/{id}/action",
     *          "controller"=CustomerAction::class,
     *     }
     * })
     */
    class Customer
    {

Variant 2

    /**
     * Customer.
     *
     * @ORM\Entity()
     *
     * @ApiResource(itemOperations={
     *     "get",
     *     "delete",
     *     "customer_action"={"route_name"="customer_action_route"}
     * })
     */
    class Customer
    {

And we add the controller class:

    /**
     * CustomerController.
     */
    class CustomerController
    {
        /**
         * @Route("/api/customer/{id}/action", name="customer_action_route", methods={"POST"})
         *
         * @param Customer $customer
         *
         * @return Response
         */
        public function executeAction(Customer $customer): Response
        {
            ...
     
     
            return new Response(...);
        }
    }

User data sources

Data access with Doctrine ORM and Elasticsearch-PHP are built in as standard in the API Platform. Doctrine ORM is active immediately after installation; Elasticsearch-PHP can be switched on in the configuration file.

For the other data sources options, we can define our own data source by adding a new class. We should implement the CollectionDataProviderInterface, RestrictedDataProviderInterface interfaces for the collection provider or ItemDataProviderInterface, RestrictedDataProviderInterface for the collection item provider. Another provider is involved in data modification, it implements the ContextAwareDataPersisterInterface interface.

Impressions, findings...

API Platform is much more extensive than it was revealed in this review. We did not mention here the automated generation of documentation and json schemes, serialization management, security and access control, platform own extensions, integration with Symfony Messenger and other features. Full description can be found in the official documentation at https://api-platform.com/docs/. The purpose of this article was the hands-on review of the tool.

API Platform includes a large toolkit for writing a REST API which will allow you implement most of the tasks that arise during the REST API Backend writing with a minimum amount of code. At the same time all the actions, which are beyond CRUD should be realized independently in the form of standard controllers.