Symfony 4 Workshop

Apis

We have seen how to write websites that render HTML. How different is this from writing APIs?

Place this controller in src/Controller/ApiMovieController.php

And compare it side by side with our MovieController

.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
<?php
namespace App\Controller;

use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Bundle\FrameworkBundle\Controller\Controller;
use Symfony\Component\Routing\Annotation\Route;
use Sensio\Bundle\FrameworkExtraBundle\Configuration\Method;
use App\Form\SaleType;
use App\Form\EnquiryType;
use App\Entity\Movie;
use App\Entity\Sale;
use App\Entity\Enquiry;
use App\Entity\Ticket;
use App\Event\SaleEvent;
use App\Logger\SaleLogger;
use Symfony\Component\Serializer\SerializerInterface;
use Symfony\Component\HttpFoundation\JsonResponse;

class ApiMovieController extends Controller
{
    /**
     * @Route("/api/movies", name="get_movies")
     * @Method({"GET"})
     */
    public function index(SerializerInterface $serializer)
    {
        $movies = $this->getDoctrine()
            ->getRepository(Movie::class)
            ->findAll();

        return new JsonResponse($serializer->serialize($movies, 'json', ['groups' => ['movie']]), 200, [], true);
    }

    /**
     * @Route("/api/movies/{movieId}", name="get_movie")
     * @Method({"GET"})
     */
    public function movie(SerializerInterface $serializer, int $movieId)
    {
        $em = $this->getDoctrine()->getManager();
        $movie = $em->getRepository(Movie::class)->findOneWithActors($movieId);

        return new JsonResponse($serializer->serialize($movie, 'json', ['groups' => ['movie']]), 200, [], true);

    }

    /**
     * @Route("/api/movies/{movieId}/sale", name="post_booking")
     * @Method({"POST"})
     */
    public function newSale(SaleLogger $saleLogger, Request $request, $movieId)
    {
        $sale = new Sale();
        $movie = $this->getDoctrine()
            ->getRepository(Movie::class)
            ->find($movieId);

        $form = $this->createForm(SaleType::class, $sale, ['movie' => $movie, 'csrf_protection' => false]);

        $data = json_decode($request->getContent(), true);
        $form->submit($data);

        if ($form->isSubmitted() && $form->isValid()) {
            $em = $this->getDoctrine()->getManager();
            $em->persist($sale);

            $numTickets = $data['numTickets'];

            $this->get('event_dispatcher')
                ->dispatch(
                    SaleEvent::NAME,
                    new SaleEvent($sale, $movie, $numTickets)
                );

            $em->flush();

            $saleLogger->log($sale);

            // Location should be a GET request to the sale
            return new Response(null, 201, [
                'Location' => $this->generateUrl(
                    'get_movie',
                    ['movieId' => $movie->getId()]
                ),
            ]);
        }

        // Here we should serialize the errors of the form
        return new Response(null, 406);
    }
}

There are not many changes. Basically:

  • We restrict routes to its HTTP method (something that we could do in the web version).
  • We return meaningful HTTP Status codes.
  • Instead of using templating, we serialize the values.

Other parts of our code (Event Listeners, Services... remain untouched).

This is another advantage of having lean Controllers.

A big part of writing great APIs is dealing with serialization.

In this case, for instance, we are saying that we are serializing properties that are in the group "movie". We can define these groups in our Entities.

Theory

Serializer for the win.

Install it with

1
2
3

composer req serializer
        

Let's see the definition of groups in src/Entity/Movie.php

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
use Symfony\Component\Serializer\Annotation as Serializer;

        //...
        
        /**
         * @ORM\Column(type="integer")
         * @ORM\Id
         * @ORM\GeneratedValue(strategy="AUTO")
         * @Serializer\Groups({"movie", "actor"})
         */
        private $id;
    
        /**
         * @ORM\Column(type="string")
         * @Serializer\Groups({"movie"})
         */
        private $name;
    
        /**
         * @ORM\Column(type="string", length=100)
         * @Serializer\Groups({"movie"})
         */
        private $director;
    
        /**
         * @ORM\Column(type="smallint")
         * @Serializer\Groups({"movie"})
         */
        private $year;
    
        /**
         * @ORM\Column(type="string")
         * @Serializer\Groups({"movie"})
         */
        private $picture;
    
        /**
         * @ORM\ManyToMany(targetEntity="Actor", inversedBy="movies")
         * @Serializer\Groups({"movie"})
         */
        private $actors;
    
        /**
         * @ORM\OneToMany(targetEntity="Ticket", mappedBy="movie")
         */
        private $tickets;

And now in src/Entity/Actor.php

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
use Symfony\Component\Serializer\Annotation as Serializer;

    //...
    
    /**
     * @ORM\Column(type="integer")
     * @ORM\Id
     * @ORM\GeneratedValue(strategy="AUTO")
     * @Serializer\Groups({"movie", "actor"})
     */
    private $id;

    /**
     * @ORM\Column(type="string")
     * @Serializer\Groups({"actor"})
     */
    private $name;

    /**
     * @ORM\Column(type="string")
     * @Serializer\Groups({"actor"})
     */
    private $picture;

    /**
     * @ORM\ManyToMany(targetEntity="Movie", mappedBy="actors")
     * @Serializer\Groups({"actor"})
     */
    private $movies;

Let's try our API:

1
2
3

curl -X GET http://localhost:8000/api/movies
        
1
2
3

curl -X GET http://localhost:8000/api/movies/1
        
1
2
3
4
5
6

curl -X POST http://localhost:8000/api/movies/1/sale -d '{
    "numTickets": 4,
    "fullName": "Barack Obama"
}'
        

We could continue seeing examples of how to use the serializer. But let's see instead a powerful tool for building APIs.

Api Platform

Appi Platform allows us to create very complete APIs with lots of useful tools requiring minimal configuration.

To install it:

composer req api

As we have more routes, let's configure the routes of Api Platform so they are prefixed by "/apiplat":

Edit config/routes/api_platform.yaml:

1
2
3
4
5

api_platform:
    resource: .
    type: api_platform
    prefix:   '/apiplat'

We need to tell Api Platform which Entities are going to be used as resources of our API. Edit src/Entity/Movie.php:

1
2
3
4
5
6
7
8
9
10

use ApiPlatform\Core\Annotation\ApiResource;

/**
 * @ORM\Entity(repositoryClass="App\Repository\MovieRepository")
 * @ORM\Table(name="movie")
 * @ApiResource(attributes={"normalization_context"={"groups"={"movie"}}})
 */
class Movie
        

And the same in src/Entity/Actor.php:

1
2
3
4
5
6
7
8
9
10

use ApiPlatform\Core\Annotation\ApiResource;

/**
 * @ORM\Entity(repositoryClass="App\Repository\MovieRepository")
 * @ORM\Table(name="actor")
 * @ApiResource(attributes={"normalization_context"={"groups"={"actor"}}})
 */
class Actor
        

And that is it! Visit http://localhost:8000/apiplat.

Exercise

Play around a bit with API Platform, testing the different endpoints that have been automatically created for you.

Api Platform has created a fully functional Level 3 Restful API from the mapping of our entities, with documentation.

Note however that real projects are much more than a CRUD, so you will still have to implement your own custom Controllers, Events, Services and so on, using the concepts from previous sections.