REST API в Symfony (без FosRestBundle) с использованием JWT аутентификации. Часть 1

Автор оригинала: Hicham Ben Kachoud
  • Перевод

Перевод статьи подготовлен в преддверии старта курса «Symfony Framework».





В первой части статьи мы рассмотрим самый простой способ реализации REST API в проекте Symfony без использования FosRestBundle. Во второй части, которую я опубликую следом, мы рассмотрим JWT аутентификацию. Прежде чем мы начнем, сперва мы должны понять, что на самом деле означает REST.


Что означает Rest?


REST (Representational State Transfer — передача состояния представления) — это архитектурный стиль разработки веб-сервисов, который невозможно игнорировать, потому что в современной экосистеме существует большая потребность в создании Restful-приложений. Это может быть связано с уверенным подъемом позиций JavaScript и связанных фреймворков.


REST API использует протокол HTTP. Это означает, что когда клиент делает какой-либо запрос к такому веб-сервису, он может использовать любой из стандартных HTTP-глаголов: GET, POST, DELETE и PUT. Ниже описано, что произойдет, если клиент укажет соответствующий глагол.


  • GET: будет использоваться для получения списка ресурсов или сведений о них.
  • POST: будет использоваться для создания нового ресурса.
  • PUT: будет использоваться для обновления существующего ресурса.
  • DELETE: будет использоваться для удаления существующего ресурса.

REST не имеет состояний (state), и это означает, что на стороне сервера тоже нет никаких состояний запроса. Состояния остаются на стороне клиента (пример — использование JWT для аутентификации, с помощью которого мы собираемся засекьюрить наш REST API). Таким образом, при использовании аутентификации в REST API нам нужно отправить аутентификационный заголовок, чтобы получить правильный ответ без хранения состояния.


Создание проекта Symfony:


Во-первых, мы предполагаем, что вы уже установили PHP и менеджер пакетов Сomposer для создания нового проекта Symfony. С этим всем в наличии создайте новый проект с помощью следующей команды в терминале:


composer create-project symfony/skeleton demo_rest_api


Создание проекта Symfony


Мы используем базовый скелет Symfony, который рекомендуется для микросервисов и API. Вот как выглядит структура каталогов:



Структура проекта


Config: содержит все настройки бандла и список бандлов в bundle.php.
Public: предоставляет доступ к приложению через index.php.
Src: содержит все контроллеры, модели и сервисы
Var: содержит системные логи и файлы кэша.
Vendor: содержит все внешние пакеты.


Теперь давайте установим некоторые необходимые пакеты с помощью Сomposer:


composer require symfony/orm-pack
composer require sensio/framework-extra-bundle

Мы установили sensio/framework-extra-bundle, который поможет нам упростить код, используя аннотации для определения наших маршрутов.


Нам также необходимо установить symphony/orm-pack для интеграции с Doctrine ORM, чтобы соединиться с базой данных. Ниже приведена конфигурация созданной мной базы данных, которая может быть задана в файле .env.



.env файл конфигурации


Теперь давайте создадим нашу первую сущность. Создайте новый файл с именем Post.php в папке src/Entity.


<?php
 namespace App\Entity;
 use Doctrine\ORM\Mapping as ORM;
 use Symfony\Component\Validator\Constraints as Assert;
 /**
  * @ORM\Entity
  * @ORM\Table(name="post")
  * @ORM\HasLifecycleCallbacks()
  */
 class Post implements \JsonSerializable {
  /**
   * @ORM\Column(type="integer")
   * @ORM\Id
   * @ORM\GeneratedValue(strategy="AUTO")
   */
  private $id;

  /**
   * @ORM\Column(type="string", length=100)
   *
   */
  private $name;
  /**
   * @ORM\Column(type="text")
   */
  private $description;

  /**
   * @ORM\Column(type="datetime")
   */
  private $create_date;

  /**
   * @return mixed
   */
  public function getId()
  {
   return $this->id;
  }
  /**
   * @param mixed $id
   */
  public function setId($id)
  {
   $this->id = $id;
  }
  /**
   * @return mixed
   */
  public function getName()
  {
   return $this->name;
  }
  /**
   * @param mixed $name
   */
  public function setName($name)
  {
   $this->name = $name;
  }
  /**
   * @return mixed
   */
  public function getDescription()
  {
   return $this->description;
  }
  /**
   * @param mixed $description
   */
  public function setDescription($description)
  {
   $this->description = $description;
  }

  /**
   * @return mixed
   */
  public function getCreateDate(): ?\DateTime
  {
   return $this->create_date;
  }

  /**
   * @param \DateTime $create_date
   * @return Post
   */
  public function setCreateDate(\DateTime $create_date): self
  {
   $this->create_date = $create_date;
   return $this;
  }

  /**
   * @throws \Exception
   * @ORM\PrePersist()
   */
  public function beforeSave(){

   $this->create_date = new \DateTime('now', new \DateTimeZone('Africa/Casablanca'));
  }

  /**
   * Specify data which should be serialized to JSON
   * @link https://php.net/manual/en/jsonserializable.jsonserialize.php
   * @return mixed data which can be serialized by <b>json_encode</b>,
   * which is a value of any type other than a resource.
   * @since 5.4.0
   */
  public function jsonSerialize()
  {
   return [
    "name" => $this->getName(),
    "description" => $this->getDescription()
   ];
  }
 }

И после этого выполните команду: php bin/console doctrine:schema:create для создания таблицы базы данных в соответствии с нашей сущностью Post.


Теперь давайте создадим PostController.php, куда мы добавим все методы, взаимодействующие с API. Он должен быть помещен в папку src/Controller.


<?php
 /**
  * Created by PhpStorm.
  * User: hicham benkachoud
  * Date: 02/01/2020
  * Time: 22:44
  */

 namespace App\Controller;

 use App\Entity\Post;
 use App\Repository\PostRepository;
 use Doctrine\ORM\EntityManagerInterface;
 use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
 use Symfony\Component\HttpFoundation\JsonResponse;
 use Symfony\Component\HttpFoundation\Request;
 use Symfony\Component\Routing\Annotation\Route;

 /**
  * Class PostController
  * @package App\Controller
  * @Route("/api", name="post_api")
  */
 class PostController extends AbstractController
 {
  /**
   * @param PostRepository $postRepository
   * @return JsonResponse
   * @Route("/posts", name="posts", methods={"GET"})
   */
  public function getPosts(PostRepository $postRepository){
   $data = $postRepository->findAll();
   return $this->response($data);
  }

  /**
   * @param Request $request
   * @param EntityManagerInterface $entityManager
   * @param PostRepository $postRepository
   * @return JsonResponse
   * @throws \Exception
   * @Route("/posts", name="posts_add", methods={"POST"})
   */
  public function addPost(Request $request, EntityManagerInterface $entityManager, PostRepository $postRepository){

   try{
    $request = $this->transformJsonBody($request);

    if (!$request || !$request->get('name') || !$request->request->get('description')){
     throw new \Exception();
    }

    $post = new Post();
    $post->setName($request->get('name'));
    $post->setDescription($request->get('description'));
    $entityManager->persist($post);
    $entityManager->flush();

    $data = [
     'status' => 200,
     'success' => "Post added successfully",
    ];
    return $this->response($data);

   }catch (\Exception $e){
    $data = [
     'status' => 422,
     'errors' => "Data no valid",
    ];
    return $this->response($data, 422);
   }

  }

  /**
   * @param PostRepository $postRepository
   * @param $id
   * @return JsonResponse
   * @Route("/posts/{id}", name="posts_get", methods={"GET"})
   */
  public function getPost(PostRepository $postRepository, $id){
   $post = $postRepository->find($id);

   if (!$post){
    $data = [
     'status' => 404,
     'errors' => "Post not found",
    ];
    return $this->response($data, 404);
   }
   return $this->response($post);
  }

  /**
   * @param Request $request
   * @param EntityManagerInterface $entityManager
   * @param PostRepository $postRepository
   * @param $id
   * @return JsonResponse
   * @Route("/posts/{id}", name="posts_put", methods={"PUT"})
   */
  public function updatePost(Request $request, EntityManagerInterface $entityManager, PostRepository $postRepository, $id){

   try{
    $post = $postRepository->find($id);

    if (!$post){
     $data = [
      'status' => 404,
      'errors' => "Post not found",
     ];
     return $this->response($data, 404);
    }

    $request = $this->transformJsonBody($request);

    if (!$request || !$request->get('name') || !$request->request->get('description')){
     throw new \Exception();
    }

    $post->setName($request->get('name'));
    $post->setDescription($request->get('description'));
    $entityManager->flush();

    $data = [
     'status' => 200,
     'errors' => "Post updated successfully",
    ];
    return $this->response($data);

   }catch (\Exception $e){
    $data = [
     'status' => 422,
     'errors' => "Data no valid",
    ];
    return $this->response($data, 422);
   }

  }

  /**
   * @param PostRepository $postRepository
   * @param $id
   * @return JsonResponse
   * @Route("/posts/{id}", name="posts_delete", methods={"DELETE"})
   */
  public function deletePost(EntityManagerInterface $entityManager, PostRepository $postRepository, $id){
   $post = $postRepository->find($id);

   if (!$post){
    $data = [
     'status' => 404,
     'errors' => "Post not found",
    ];
    return $this->response($data, 404);
   }

   $entityManager->remove($post);
   $entityManager->flush();
   $data = [
    'status' => 200,
    'errors' => "Post deleted successfully",
   ];
   return $this->response($data);
  }

  /**
   * Returns a JSON response
   *
   * @param array $data
   * @param $status
   * @param array $headers
   * @return JsonResponse
   */
  public function response($data, $status = 200, $headers = [])
  {
   return new JsonResponse($data, $status, $headers);
  }

  protected function transformJsonBody(\Symfony\Component\HttpFoundation\Request $request)
  {
   $data = json_decode($request->getContent(), true);

   if ($data === null) {
    return $request;
   }

   $request->request->replace($data);

   return $request;
  }

 }

Здесь мы определили пять маршрутов:


● GET /api/posts: вернет список постов.



api получения всех постов


● POST /api/posts: создаст новый пост.



api добавления нового поста


● GET /api/posts/id: вернет пост, соответствующий определенному идентификатору,



получение конкретного поста


● PUT /api/posts/id: обновит пост.



обновление поста


Это результат после обновления:



пост после обновления


● DELETE /api/posts/id: удалит пост.



удаление поста


Это результат получения всех постов после удаления поста с идентификатором 3:



все посты после удаления


Исходный код можно найти здесь


Заключение


Итак, теперь мы понимаем, что такое REST и Restful. Restful API должен быть без состояний. Мы знаем, как создать Restful-приложение, используя HTTP-глаголы. В общем, теперь мы хорошо понимаем REST и готовы профессионально создавать Restful-приложения.


В следующей статье мы рассмотрим, как обеспечить секьюрность API с помощью JWT аутентификации.




Узнать подробнее о курсе «Symfony Framework»



OTUS. Онлайн-образование
Цифровые навыки от ведущих экспертов

Комментарии 8

    +4
    PUT: будет использоваться для обновления существующего ресурса.

    Частичный PUT нарушает спецификацию HTTP. Он предназначен для полной замены ресурса, а не для обновления. В чем-то сродни операции присваивания. Для частичных обновлений подходит или POST, или PATCH из RFC 5789.

      0
      Что-то мне кажется вы все перепутали :)
      А кто-то еще и наплюсил.
        0

        Я напутал так, как напутал Филдинг, автор REST и один из архитекторов HTTP/1.1


        Oy, yes, PATCH was something I created for the initial HTTP/1.1 proposal because partial PUT is never RESTful. ;-)
        https://twitter.com/fielding/status/275471320685367296

        И так, как напутал RFC, который определяет такую семантику для PUT (которая, между прочим, обуславливает идемпотентность этого метода)


        The PUT method requests that the state of the target resource be created or replaced with the state defined by the representation enclosed in the request message payload.
        https://tools.ietf.org/html/rfc7231#section-4.3.4
          0
          То ли я прочитал не правильно, то ли сообщение исправили…
          Показалось, что наоборот было.
            0

            Я не думаю, что мое сообщение менялось с момента публикации. Если отбросить всю шелуху, то в мире победившего хайпа неверное представление некоторых людей о PUT является только вершиной айсберга ;)

      +1
      Логика в контроллерах. Как мило.
        +3
        Во-первых, нужно уже переходить на современный пхп, убрать горы типов в аннотациях и заменить всё на тайпхинты. Во-вторых, ловятся все ексепшены и конвертятся в 4xx, т.е. пятисотые не предвидятся и в лог они не попадут. Не стриктовые сравнения типа if (!post) {… } или if (!request) {… .}, советую воспользоваться phpstan со стрикт правилами. В контроллере навалено всё, и обработка реквеста, и логика работы с ентити, а если нам нужно будет тоже самое сделать через cli например, получим дублирование.
        Советую посмотреть этот пример
          +1

          POST, GET — это конечно "глаголы", но в контексте http правильнее переводить как "методы" https://developer.mozilla.org/ru/docs/Web/HTTP/Methods

          Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.

          Самое читаемое