Для получения полного доступа
зарегистрируйтесь

Простое RESTful API на Symfony 3


API которое описанное в этом сниппете будет следовать следующим правилам:

  • Возвращает только JSON
  • Для любого запроса клиент должен пройти аутентификацию
  • Аутентификация производится через OAuth2, Grant Type = password.
  • Разные версии API будут храниться на поддоменах (например v1.api.example.com)

Для написания API было использовано php + Symfony 3 + следующие бандлы:

Устанавливаем SF3 и бандлы

Для начала их надо скачать, я использую Symfony Installer и Composer (установлен глобально)

symfony new api
cd api
composer require friendsofsymfony/rest-bundle
composer require jms/serializer-bundle
composer require nelmio/api-doc-bundle
composer require friendsofsymfony/user-bundle
composer require friendsofsymfony/oauth-server-bundle

Дописываем следующие строки в app/AppKernel.php для подключения новых бандлов:

// app/AppKernel.php
class AppKernel extends Kernel
{
    public function registerBundles()
    {
        $bundles = array(
            // ...
            new FOS\RestBundle\FOSRestBundle(),
            new FOS\UserBundle\FOSUserBundle(),
            new FOS\OAuthServerBundle\FOSOAuthServerBundle(),
            new JMS\SerializerBundle\JMSSerializerBundle(),
            new Nelmio\ApiDocBundle\NelmioApiDocBundle(),
        );

        // ...
    }
}

Настройка бандлов

Примечание : Классы под неймспейсом Acme\ApiBundle\Entity мы создадим чуть позже.

Конфиги

Допишем в app/config/config.yml :

# app/config/config.yml
nelmio_api_doc: ~

fos_rest:
    routing_loader:
        default_format: json                            # Все ответы должны быть в JSON
        include_format: false                           # Мы не нуждаемся в передаче формата
                                                        # Этого достаточно что бы все ответы были в JSON

fos_user:
    db_driver: orm
    firewall_name: api                                  
    user_class: Acme\ApiBundle\Entity\User

fos_oauth_server:
    db_driver:           orm
    client_class:        Acme\ApiBundle\Entity\Client
    access_token_class:  Acme\ApiBundle\Entity\AccessToken
    refresh_token_class: Acme\ApiBundle\Entity\RefreshToken
    auth_code_class:     Acme\ApiBundle\Entity\AuthCode
    service:
        user_provider: fos_user.user_manager             # Тут мы указываем кто будет отвечать за генерацию Access Token'а 

Безопасность

Допишем в app/config/security.yml :

# app/config/security.yml

security:
    encoders:
        FOS\UserBundle\Model\UserInterface: sha512

    providers:
        fos_userbundle:
            id: fos_user.user_provider.username       
    firewalls:
        oauth_token:                                   # Разрешаем доступ для получения токена
            pattern: ^/oauth/v2/token
            security: false
        api:
            pattern: ^/                                # Для всех других страниц
            fos_oauth: true                            # включаем OAuth2
            stateless: true                            # Не использовать куки
            anonymous: false                           # Анонимный доступ запрещён

Если нужно - вы можете указать другие настройки доступа тут.

Routing

Добавим немного кода в app/config/routing.yml :

# app/config/routing.yml
NelmioApiDocBundle:
    resource: "@NelmioApiDocBundle/Resources/config/routing.yml"
    prefix:   /api/doc

fos_oauth_server_token:
    resource: "@FOSOAuthServerBundle/Resources/config/routing/token.xml"

API Bundle

Примечание : этот шаг не обязательный, вы в вправе сами структурировать и оформлять архитектуру вашего проекта. Я использую один бандл для структуризации, но поступайте так как велит вам сердце ;)

Дальше нам нужно создать обьекты (entities) для обработки клиентов, access tokens, и т.д Для этих целей мы создадим бандл:

php app/console generate:bundle --namespace=Acme/ApiBundle

Теперь создаём сами обьекты.

User entity

Этот обьект обязательный при использовании FOSUserBundle а ещё он будет использоваться FOSOAuthServerBundle. Как указано в документации, можете делать в этом классе что угодно (почти). Обьект который мы будем использовать скопирован с документации к FOSUserBundle, но с небольшими изменениями:

  • Он наследуется от FOS\UserBundle\Entity\User а не от FOS\UserBundle\Model\User
  • имя таблицы изменили на @ORM\Table("users")
<?php
// src/Acme/ApiBundle/Entity/User.php

namespace Acme\ApiBundle\Entity;

use FOS\UserBundle\Entity\User as BaseUser;
use Doctrine\ORM\Mapping as ORM;

/**
 * User
 *
 * @ORM\Table("users")
 * @ORM\Entity
 */
class User extends BaseUser
{
    /**
     * @var integer
     *
     * @ORM\Column(name="id", type="integer")
     * @ORM\Id
     * @ORM\GeneratedValue(strategy="AUTO")
     */
    protected $id;


    /**
     * Get id
     *
     * @return integer
     */
    public function getId()
    {
        return $this->id;
    }
}

Другие обьекты

Эти обьекты используются в FOSOAuthServerBundle. Опять же просто копируем их из документации с заменой неймспейсов и названия испоьзуемой таблицы. Так же стоит убедиться что targetEntity в анотации @ORM\ManyToOne ведёт к обьектам, которые мы создали в предыдущих шагах:

<?php
// src/Acme/ApiBundle/Entity/Client.php

namespace Acme\ApiBundle\Entity;

use FOS\OAuthServerBundle\Entity\Client as BaseClient;
use Doctrine\ORM\Mapping as ORM;

/**
 * @ORM\Table("oauth2_clients")
 * @ORM\Entity
 */
class Client extends BaseClient
{
    /**
     * @ORM\Id
     * @ORM\Column(type="integer")
     * @ORM\GeneratedValue(strategy="AUTO")
     */
    protected $id;

    public function __construct()
    {
        parent::__construct();
    }
}
<?php
// src/Acme/ApiBundle/Entity/AccessToken.php

namespace Acme\ApiBundle\Entity;

use FOS\OAuthServerBundle\Entity\AccessToken as BaseAccessToken;
use Doctrine\ORM\Mapping as ORM;

/**
 * @ORM\Table("oauth2_access_tokens")
 * @ORM\Entity
 */
class AccessToken extends BaseAccessToken
{
    /**
     * @ORM\Id
     * @ORM\Column(type="integer")
     * @ORM\GeneratedValue(strategy="AUTO")
     */
    protected $id;

    /**
     * @ORM\ManyToOne(targetEntity="Client")
     * @ORM\JoinColumn(nullable=false)
     */
    protected $client;

    /**
     * @ORM\ManyToOne(targetEntity="User")
     */
    protected $user;
}
<?php
// src/Acme/ApiBundle/Entity/RefreshToken.php

namespace Acme\ApiBundle\Entity;

use FOS\OAuthServerBundle\Entity\RefreshToken as BaseRefreshToken;
use Doctrine\ORM\Mapping as ORM;

/**
 * @ORM\Table("oauth2_refresh_tokens")
 * @ORM\Entity
 */
class RefreshToken extends BaseRefreshToken
{
    /**
     * @ORM\Id
     * @ORM\Column(type="integer")
     * @ORM\GeneratedValue(strategy="AUTO")
     */
    protected $id;

    /**
     * @ORM\ManyToOne(targetEntity="Client")
     * @ORM\JoinColumn(nullable=false)
     */
    protected $client;

    /**
     * @ORM\ManyToOne(targetEntity="User")
     */
    protected $user;
}
<?php
// src/Acme/ApiBundle/Entity/AuthCode.php

namespace Acme\ApiBundle\Entity;

use FOS\OAuthServerBundle\Entity\AuthCode as BaseAuthCode;
use Doctrine\ORM\Mapping as ORM;

/**
 * @ORM\Table("oauth2_auth_codes")
 * @ORM\Entity
 */
class AuthCode extends BaseAuthCode
{
    /**
     * @ORM\Id
     * @ORM\Column(type="integer")
     * @ORM\GeneratedValue(strategy="AUTO")
     */
    protected $id;

    /**
     * @ORM\ManyToOne(targetEntity="Client")
     * @ORM\JoinColumn(nullable=false)
     */
    protected $client;

    /**
     * @ORM\ManyToOne(targetEntity="User")
     */
    protected $user;
}

Обновляем нашу БД:

php app/console doctrine:schema:update --force

И у вас должны создаться следующие таблицы:

mysql> describe users;
+-----------------------+--------------+------+-----+---------+----------------+
| Field                 | Type         | Null | Key | Default | Extra          |
+-----------------------+--------------+------+-----+---------+----------------+
| id                    | int(11)      | NO   | PRI | NULL    | auto_increment |
| username              | varchar(255) | NO   |     | NULL    |                |
| username_canonical    | varchar(255) | NO   | UNI | NULL    |                |
| email                 | varchar(255) | NO   |     | NULL    |                |
| email_canonical       | varchar(255) | NO   | UNI | NULL    |                |
| enabled               | tinyint(1)   | NO   |     | NULL    |                |
| salt                  | varchar(255) | NO   |     | NULL    |                |
| password              | varchar(255) | NO   |     | NULL    |                |
| last_login            | datetime     | YES  |     | NULL    |                |
| locked                | tinyint(1)   | NO   |     | NULL    |                |
| expired               | tinyint(1)   | NO   |     | NULL    |                |
| expires_at            | datetime     | YES  |     | NULL    |                |
| confirmation_token    | varchar(255) | YES  |     | NULL    |                |
| password_requested_at | datetime     | YES  |     | NULL    |                |
| roles                 | longtext     | NO   |     | NULL    |                |
| credentials_expired   | tinyint(1)   | NO   |     | NULL    |                |
| credentials_expire_at | datetime     | YES  |     | NULL    |                |
+-----------------------+--------------+------+-----+---------+----------------+
17 rows in set (0.00 sec)

mysql> describe oauth2_clients;
+---------------------+--------------+------+-----+---------+----------------+
| Field               | Type         | Null | Key | Default | Extra          |
+---------------------+--------------+------+-----+---------+----------------+
| id                  | int(11)      | NO   | PRI | NULL    | auto_increment |
| random_id           | varchar(255) | NO   |     | NULL    |                |
| redirect_uris       | longtext     | NO   |     | NULL    |                |
| secret              | varchar(255) | NO   |     | NULL    |                |
| allowed_grant_types | longtext     | NO   |     | NULL    |                |
+---------------------+--------------+------+-----+---------+----------------+
5 rows in set (0.00 sec)

mysql> describe oauth2_access_tokens;
+------------+--------------+------+-----+---------+----------------+
| Field      | Type         | Null | Key | Default | Extra          |
+------------+--------------+------+-----+---------+----------------+
| id         | int(11)      | NO   | PRI | NULL    | auto_increment |
| client_id  | int(11)      | NO   | MUL | NULL    |                |
| user_id    | int(11)      | YES  | MUL | NULL    |                |
| token      | varchar(255) | NO   | UNI | NULL    |                |
| expires_at | int(11)      | YES  |     | NULL    |                |
| scope      | varchar(255) | YES  |     | NULL    |                |
+------------+--------------+------+-----+---------+----------------+
6 rows in set (0.00 sec)

mysql> describe oauth2_auth_codes;
+--------------+--------------+------+-----+---------+----------------+
| Field        | Type         | Null | Key | Default | Extra          |
+--------------+--------------+------+-----+---------+----------------+
| id           | int(11)      | NO   | PRI | NULL    | auto_increment |
| client_id    | int(11)      | NO   | MUL | NULL    |                |
| user_id      | int(11)      | YES  | MUL | NULL    |                |
| token        | varchar(255) | NO   | UNI | NULL    |                |
| redirect_uri | longtext     | NO   |     | NULL    |                |
| expires_at   | int(11)      | YES  |     | NULL    |                |
| scope        | varchar(255) | YES  |     | NULL    |                |
+--------------+--------------+------+-----+---------+----------------+
7 rows in set (0.00 sec)

mysql> describe oauth2_refresh_tokens;
+------------+--------------+------+-----+---------+----------------+
| Field      | Type         | Null | Key | Default | Extra          |
+------------+--------------+------+-----+---------+----------------+
| id         | int(11)      | NO   | PRI | NULL    | auto_increment |
| client_id  | int(11)      | NO   | MUL | NULL    |                |
| user_id    | int(11)      | YES  | MUL | NULL    |                |
| token      | varchar(255) | NO   | UNI | NULL    |                |
| expires_at | int(11)      | YES  |     | NULL    |                |
| scope      | varchar(255) | YES  |     | NULL    |                |
+------------+--------------+------+-----+---------+----------------+
6 rows in set (0.00 sec)

Создаём Oauth2 клиента

Следующий шаг содержит инструкцию по созданию клиента. Документация не очень понятная по этому вопросу, тут часть которая отвечает за создание клиента из документации. Но в нашем случае нам нужен только один клиент, так что добавим его используя обычный SQL запрос:

INSERT INTO `oauth2_clients` VALUES (NULL, '3bcbxd9e24g0gk4swg0kwgcwg4o8k8g4g888kwc44gcc0gwwk4', 'a:0:{}', '4ok2x70rlfokc8g0wws8c8kwcokw80k44sg48goc0ok4w0so0k', 'a:1:{i:0;s:8:"password";}');

Создадим администратора

Будем использовать команду fos:user:create, которую предоставил нам FOSUserBundle :

$ php app/console fos:user:create
Please choose a username:admin
Please choose an email:admin@example.com
Please choose a password:admin
Created user admin

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

Сейчас создадим REST контроллер который будет возвращать очень простой ответ, чисто для проверки всё ли работает.

Контроллер

<?php

// src/Acme/ApiBundle/Controller/DemoController.php

namespace Acme\ApiBundle\Controller;

use FOS\RestBundle\Controller\FOSRestController;

class DemoController extends FOSRestController
{
    public function getDemosAction()
    {
        $data = array("hello" => "world");
        $view = $this->view($data);
        return $this->handleView($view);
    }
}

Настройка роутинга

# src/Acme/ApiBundle/Resources/config/routing.yml
acme_api_demos:
    type: rest
    resource: Acme\ApiBundle\Controller\DemoController

Проверим OAuth2

Примечание : следующие комманды работают благодаря HTTPie library. Убедитесь что вы уже установили эту библиотеку ранее.

Примечение 2 : так же стоит сказать что они будут работать только если Symfony запущен на своём встроенном HTTP сервере. .

$ http GET http://localhost:8000/app_dev.php/links
HTTP/1.1 401 Unauthorized
Cache-Control: no-store, private
Connection: close
Content-Type: application/json
...

{
    "error": "access_denied",
    "error_description": "OAuth2 authentication required"
}

Нам тут не рады :(

Мы должны запросить Access Token используя клиента и пользователя созданного ранее. Обратите внимание что client_id это client_id + случайная строка:

$ http POST http://localhost:8000/app_dev.php/oauth/v2/token \
    grant_type=password \
    client_id=1_3bcbxd9e24g0gk4swg0kwgcwg4o8k8g4g888kwc44gcc0gwwk4 \
    client_secret=4ok2x70rlfokc8g0wws8c8kwcokw80k44sg48goc0ok4w0so0k \
    username=admin \
    password=admin
HTTP/1.1 200 OK
Cache-Control: no-store, private
Connection: close
Content-Type: application/json
...

{
    "access_token": "MDFjZGI1MTg4MTk3YmEwOWJmMzA4NmRiMTgxNTM0ZDc1MGI3NDgzYjIwNmI3NGQ0NGE0YTQ5YTVhNmNlNDZhZQ",
    "expires_in": 3600,
    "refresh_token": "ZjYyOWY5Yzg3MTg0MDU4NWJhYzIwZWI4MDQzZTg4NWJjYzEyNzAwODUwYmQ4NjlhMDE3OGY4ZDk4N2U5OGU2Ng",
    "scope": null,
    "token_type": "bearer"
}

После получения Access Token мы уже сможем отправить запрос на любой адрес:

$ http GET http://ledzep.dev:8000/app_dev.php/links \
    "Authorization:Bearer MDFjZGI1MTg4MTk3YmEwOWJmMzA4NmRiMTgxNTM0ZDc1MGI3NDgzYjIwNmI3NGQ0NGE0YTQ5YTVhNmNlNDZhZQ"
HTTP/1.1 200 OK
Cache-Control: no-cache
Connection: close
Content-Type: application/json
...

{
    "hello": "world"
}

Пользователь

Получаем текущего аутинтефикованного пользователя

<?php

use Symfony\Component\Security\Core\Exception\AccessDeniedException;

// ...
class DemoController extends FOSRestController
{
    // ...
    public function getDemosAction()
    {
        $user = $this->get('security.context')->getToken()->getUser();

        //...
        // Делаем что то с ним
        // ...
    }
    // ...
}

Проверка прав пользователя

<?php

use Symfony\Component\Security\Core\Exception\AccessDeniedException;

// ...
class DemoController extends FOSRestController
{
    // ...
    public function getDemosAction()
    {
        if ($this->get('security.context')->isGranted('ROLE_JCVD') === FALSE) {
            throw new AccessDeniedException();
        }

        // ...
    }
    // ...
}

Чтобы увидеть комментарии, нужно быть участником сообщества

Регистрация