01.07.2020 / Symfony

Создание Rest Api на Symfony 4

Полный код проекта доступен в репозитории https://github.com/symfonyst/rest

Что нужно установить?

Для удобства ниже приведена пошаговая инструкция

1. Ставим Symfony 4

        $ composer create-project symfony/website-skeleton rest-api '4.*'
    

2. Запускаем локальный сервер

        $ php -S 127.0.0.1:9001 -t public
    

При этом в браузере по запросу http://127.0.0.1:9001/ должна отобразиться страница приветсвие Symfony

3. Установка FOSRestBundle

В Symfony есть отличный бандл для работы с Rest Controller'ами, он называется FOSRestBundle
Для него есть хорошая официальная документация

        $ composer require friendsofsymfony/rest-bundle:2.5
    

Во время установки будет выведено следующее уведомление о проблеме:
Package zendframework/zend-code is abandoned, you should avoid using it. Use laminas/laminas-code instead.
Package zendframework/zend-eventmanager is abandoned, you should avoid using it. Use laminas/laminas-eventmanager instead.

Также будет предложен выбор для решения данной пробемы, выбираем yes и установка продолжается далее.

После успешного завершения установки необходимый бандл должен автоматически добавится в файл с подключенными бандлами (/config/bundles.php)

        

            return [
                Symfony\Bundle\FrameworkBundle\FrameworkBundle::class => ['all' => true],
                Symfony\Bundle\TwigBundle\TwigBundle::class => ['all' => true],
                Twig\Extra\TwigExtraBundle\TwigExtraBundle::class => ['all' => true],
                Sensio\Bundle\FrameworkExtraBundle\SensioFrameworkExtraBundle::class => ['all' => true],
                Doctrine\Bundle\DoctrineBundle\DoctrineBundle::class => ['all' => true],
                Doctrine\Bundle\MigrationsBundle\DoctrineMigrationsBundle::class => ['all' => true],
                Symfony\Bundle\SecurityBundle\SecurityBundle::class => ['all' => true],
                Symfony\Bundle\WebProfilerBundle\WebProfilerBundle::class => ['dev' => true, 'test' => true],
                Symfony\Bundle\MonologBundle\MonologBundle::class => ['all' => true],
                Symfony\Bundle\DebugBundle\DebugBundle::class => ['dev' => true, 'test' => true],
                Symfony\Bundle\MakerBundle\MakerBundle::class => ['dev' => true],
                FOS\RestBundle\FOSRestBundle::class => ['all' => true],
            ];
        
    

Установка сериалайзера, JMSSerializerBundle

Для корректной работы бандла нам также необходимо установить сериалайзер, я буду использовать JMSSerializerBundle

        $ composer require jms/serializer-bundle
    

По ходу установки также возникнет предупреждение об устаревших компонентах и предложением их заменить, соглашаемся и едем дальше.

После успешного завершения установки в файл с бандлами добавится новая строка:

        
            JMS\SerializerBundle\JMSSerializerBundle::class => ['all' => true],
        
    

Конфигурирование FOSRestBundle

Теперь нам необходимо скофигурировать бандл. За конфигурацию отвечает файл: config/packages/fos_rest.yaml
Он создается автоматически при установке бандла

Раскомментируем в нем некоторые строчки, вот какой результат получился у меня

        
# Read the documentation: https://symfony.com/doc/master/bundles/FOSRestBundle/index.html
fos_rest:
    param_fetcher_listener:  true
    allowed_methods_listener:  true
    view:
        view_response_listener:  true
    exception:
        exception_controller: 'fos_rest.exception.controller:showAction'
    format_listener:
        rules:
            - { path: ^/api, prefer_extension: false, fallback_format: json, priorities: [ json ] }
            - { path: ^/, prefer_extension: true, fallback_format: html, priorities: [ html ] }

        
    

Создание класса REST контроллера

Часто возникает необходимость отдавать данные не только на своем сайте, но и на сторонние ресурсы. Для решение данной задачи как раз и служит технология REST

Допустим мы хотим создать сервис новостей, который можно использовать на стороне клиента, с помощью JavaScript

Нам понадобится создать новый класс, который наследуется от AbstractFOSRestController

        
    namespace App\Controller\Api;

    use FOS\RestBundle\Controller\AbstractFOSRestController;
    use FOS\RestBundle\View\View;
    use Symfony\Component\HttpFoundation\Request;
    use Symfony\Component\HttpFoundation\Response;
    use FOS\RestBundle\Controller\Annotations as Rest;

    /**
     * Class NewsController
     * @package App\Controller
     * @Rest\RouteResource("news")
     */
    class NewsController extends AbstractFOSRestController
    {
        public function getAllAction(Request $request){
            $currentDate = new \DateTime();
            $secondDate = clone $currentDate;
            $data = [
                [
                    "date" => $currentDate,
                    "name" => "Тестовая новость",
                    "description" => "Описание новости",
                    "content" => "Контент",
                ],
                [
                    "date" => $secondDate->modify('2 day'),
                    "name" => "Вторая новость",
                    "description" => "Описание новости",
                    "content" => "Контент",
                ],
            ];
            return View::create($data, Response::HTTP_OK);
        }
    }
        
    

Конфигурация маршрутов

Нам также необходимо создать маршруты для нашего нового контроллера

Для этого в папке config/routes создаем файл api.yaml со следующим содержимым:

        
    app.api.news:
        type:         rest
        resource:     App\Controller\Api\NewsController
        name_prefix:  api_ # Our precious parameter
        prefix: api
        
    

Теперь давайте проверим автоматические роуты, которые были созданы

Для этого набираем в консоли:

        
             php bin/console debug:route
        

И видим в одной из строк

        
             api_get_news_all           GET      ANY      ANY    /api/news/all.{_format}
        
    

Набираем в строке браузера следующий адрес: http://127.0.0.1:9001/api/news/all

И нам отображается следующий результат

Подобным образом можно создать множество различных REST контроллеров, решив тем самым задачу передачи данных.

Далее мы создадим методы в контроллере, которые будут отвечать за создание, редактирование и удаление записей.

Но сначала нам потребуется создать класс формы новостей NewsType

        
    // src/Form/NewsType.php
    namespace App\Form;
    use App\Entity\News;
    use Symfony\Component\Form\AbstractType;
    use Symfony\Component\Form\FormBuilderInterface;
    use Symfony\Component\OptionsResolver\OptionsResolver;

    class NewsType extends AbstractType
    {
        public function buildForm(FormBuilderInterface $builder, array $options)
        {
            $builder
                ->add('active')
                ->add('name')
                ->add('anons')
                ->add('content')
            ;
        }

        public function configureOptions(OptionsResolver $resolver)
        {
            $resolver->setDefaults([
                'data_class' => News::class,
                'csrf_protection' => false,
            ]);
        }
    }
        
    

Создание записи, POST запрос.

    
    public function postAction(Request $request){
        try {
            $entity = new News();
            $form = $this->createForm(NewsType::class, $entity);
            $form->submit($request->request->all());
            if (
                $form->isValid()
            ) {
                $this->removeExtraFields($request, $form);
                $em = $this->get('doctrine')->getManager();
                $em->persist($entity);
                $em->flush();
                return $this->getSuccessResponse($entity);
            }
            throw new \InvalidArgumentException('Error! Invalid form data.');
        } catch (\Exception $exception) {
            return $this->getFailResponse($exception);
        }

    }
    

Обновление записи, методы PUT, PATCH

    
    public function putAction($id, Request $request)
    {
        return $this->patchAction($id, $request);
    }

    public function patchAction($id, Request $request)
    {
        try {
            $em = $this->get('doctrine')->getManager();
            $entity = $em->getRepository(News::class)->findOneBy([
                'id' => $id,
                'deleted' => 0
            ]);
            if (!$entity) throw new EntityNotFoundException('News not found');
            $form = $this->createForm(NewsType::class, $entity);
            $this->removeExtraFields($request, $form);
            $form->submit($request->request->all());
            if ($form->isValid()) {
                $em->persist($entity);
                $em->flush();
                return $this->getSuccessResponse($entity);
            }
            throw new \InvalidArgumentException("Invalid form");
        } catch (\Exception $exception) {
            return $this->getFailResponse($exception);
        }
    }
    

Удаление записи, метод DELETE

Удалять запись из базы мы не будем, просто проставим соответствующий флаг и сохраним дату удаления.

        
    public function deleteAction ($id, Request $request) {
        try {
            $em = $this->get('doctrine')->getManager();
            /**
             * @var News $entity
             */
            $entity = $em->getRepository(News::class)->findOneBy([
                'id' => $id,
                'deleted' => 0
            ]);
            if (!$entity) throw new EntityNotFoundException('News not found');
            $entity->setDeleted(1);
            $entity->setDeletedDate(new \DateTime());
            $em->persist($entity);
            $em->flush();
            return $this->getSuccessResponse($entity);
        } catch ( \Exception $exception) {
            return $this->getFailResponse($exception);
        }
    }
        
    

Тесты

Напишем тесты для проверки полученных методов

        
    // tests/Controller/Api/NewsControllerTest.php
    namespace App\Tests\Controller\Api;
    use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
    use Symfony\Component\HttpFoundation\Response;

    class NewsControllerTest extends WebTestCase
    {
        public function testPost(){
            static::$kernel = static::createKernel();
            static::$kernel->boot();
            $client = static::createClient();
            $client->request("POST", "/api/news.json", [
                "active" => 1,
                "name" => "Свежая новость",
                "anons" => "Анонс новости",
                "content" => "Контент новости",
            ]);
            $this->assertEquals($client->getResponse()->getStatusCode(), Response::HTTP_OK);
        }
    }
    public function testPut()
    {
        static::$kernel = static::createKernel();
        static::$kernel->boot();
        $client = static::createClient();
        $client->request("POST", "/api/news.json", [
            "active" => 1,
            "name" => "Начало учебного года",
            "anons" => "1 сентября начался учебный год",
            "content" => "С утра прошли праздничные линейки во всех школах",
        ]);
        $this->assertEquals($client->getResponse()->getStatusCode(), Response::HTTP_OK);
        $res = json_decode($client->getResponse()->getContent());
        $this->assertTrue(isset($res->data->id));
        $id = $res->data->id;
        $client->request("PUT", "/api/news/" . $id . ".json", [
            "name" => "Начало учебного года 2021",
            "anons" => "1 сентября начался учебный год, поздравляем всех!",
            "content" => "С утра прошли праздничные линейки во всех школах. Фотоотчет на сайте.",
        ]);
        $this->assertTrue($client->getResponse()->getStatusCode() == Response::HTTP_OK);
        $res = json_decode($client->getResponse()->getContent());
        $this->assertEquals($res->data->name, "Начало учебного года 2021");
        $client->request('DELETE', "/api/news/" . $id . ".json");
        $this->assertTrue($client->getResponse()->getStatusCode() == Response::HTTP_OK);
    }
        
    

Запустим тесты

    
        php bin/console doctrine:fixtures:load

         Careful, database "dev_rest_api" will be purged. Do you want to continue? (yes/no) [no]:
         > y

           > purging database
           > loading App\DataFixtures\AppFixtures
           > loading App\DataFixtures\UserFixtures

        phpunit --filter NewsControllerTest