Skip to content

Instantly share code, notes, and snippets.

@GrzegorzBandur
Last active January 23, 2022 09:54
  • Star 22 You must be signed in to star a gist
  • Fork 6 You must be signed in to fork a gist
Star You must be signed in to star a gist
Save GrzegorzBandur/a2a4e7f024233e1c0bb3b9a39571d68a to your computer and use it in GitHub Desktop.
RESTful API with Symfony 4.4 + FOSRestBundle + FOSOauthServerBundle + FOSUserBundle

RESTful API with Symfony 4.4 + FOSRestBundle + FOSOauthServerBundle + FOSUserBundle

To start writing RestFull API in symfony we will need bundles:

        "friendsofsymfony/oauth-server-bundle": "^1.6",
        "friendsofsymfony/rest-bundle": "^2.7",
        "friendsofsymfony/user-bundle": "^2.1",
        "jms/serializer-bundle": "^3.5",
        "nelmio/api-doc-bundle": "^3.5",
        "sensio/framework-extra-bundle": "^5.2",
        "symfony/apache-pack": "^1.0",
        "symfony/console": "^4.4",
        "symfony/dotenv": "4.4.*",
        "symfony/flex": "^1.3.1",
        "symfony/framework-bundle": "4.4.*",
        "symfony/maker-bundle": "^1.5",
        "symfony/orm-pack": "^1.0",
        "symfony/swiftmailer-bundle": "^3.2",
        "symfony/templating": "^4.4",
        "symfony/translation": "4.4.*",
        "symfony/yaml": "^4.4"

So let’s get started:

Firstly, we create a project in symfony. We will need a composer, which you can download and install from this page/website:

https://getcomposer.org/download/

We can create skeleton project in symfony using command prompt:

create-project symfony/skeleton rest

But the easier way is to use a composer.json to create a project running command: composer install

{
    "type": "project",
    "license": "proprietary",
    "require": {
        "php": "^7.3",
        "ext-ctype": "*",
        "ext-iconv": "*",
        "friendsofsymfony/oauth-server-bundle": "^1.6",
        "friendsofsymfony/rest-bundle": "^2.7",
        "friendsofsymfony/user-bundle": "^2.1",
        "jms/serializer-bundle": "^3.5",
        "nelmio/api-doc-bundle": "^3.5",
        "sensio/framework-extra-bundle": "^5.2",
        "symfony/apache-pack": "^1.0",
        "symfony/console": "^4.4",
        "symfony/dotenv": "4.4.*",
        "symfony/flex": "^1.3.1",
        "symfony/framework-bundle": "4.4.*",
        "symfony/maker-bundle": "^1.5",
        "symfony/orm-pack": "^1.0",
        "symfony/swiftmailer-bundle": "^3.2",
        "symfony/templating": "^4.4",
        "symfony/translation": "4.4.*",
        "symfony/yaml": "^4.4"
    },
    "require-dev": {
        "doctrine/doctrine-fixtures-bundle": "^3.0",
        "symfony/dotenv": "^4.4",
        "symfony/var-dumper": "^4.4"
    },
    "config": {
        "preferred-install": {
            "*": "dist"
        },
        "sort-packages": true
    },
    "autoload": {
        "psr-4": {
            "App\\": "src/"
        }
    },
    "autoload-dev": {
        "psr-4": {
            "App\\Tests\\": "tests/"
        }
    },
    "replace": {
        "symfony/polyfill-ctype": "*",
        "symfony/polyfill-iconv": "*",
        "symfony/polyfill-php71": "*",
        "symfony/polyfill-php70": "*",
        "symfony/polyfill-php56": "*"
    },
    "scripts": {
        "auto-scripts": {
            "cache:clear": "symfony-cmd",
            "assets:install %PUBLIC_DIR%": "symfony-cmd"
        },
        "post-install-cmd": [
            "@auto-scripts"
        ],
        "post-update-cmd": [
            "@auto-scripts"
        ]
    },
    "conflict": {
        "symfony/symfony": "*"
    },
    "extra": {
        "symfony": {
            "allow-contrib": false
        }
    }
}

Then we got a question Do you want to execute this recipe? Press y. After generating config files we correct them. To make it work properly you need to create some config files under the config/packages folder

fos_user.yml 
    fos_user:
      db_driver: orm # other valid values are 'mongodb' and 'couchdb'
      firewall_name: main
      user_class: App\Entity\User
      from_email:
        address: 'email@gmail.com' # for example your email
        sender_name: 'email@gmail.com' # for example your email

fos_oauth_user.yml

fos_oauth_server:
    db_driver: orm
    client_class:        App\Entity\Client
    access_token_class:  App\Entity\AccessToken
    refresh_token_class: App\Entity\RefreshToken
    auth_code_class:     App\Entity\AuthCode
    service:
        user_provider: fos_user.user_provider.username
        options:
            access_token_lifetime: 28800
    template:
        engine: twig

We use this class to be able to store the authocode and access the tokens to authorise via our API.

We also need to add to the framework.yml a templating engine:

framework:
    templating:
        engines: ['twig', 'php']

And add nelmio_api_doc.yaml

nelmio_api_doc:
    documentation:
        info:
            title: My App
            description: This is an awesome app!
            version: 1.0.0
    areas: # to filter documented areas
        path_patterns:
            - ^/api(?!/doc$) # Accepts routes under /api except /api/doc

Let’s create fos_oauth_server entity classes.

I figured out that the problem with mysql 8.0 in authentication using docker can be solved by adding a command in docker-compose.yaml

command: ['--character-set-server=utf8mb4', '--collation-server=utf8mb4_unicode_ci','--default-authentication-plugin=mysql_native_password']

The file doctrine.yaml will look like this

doctrine:
    dbal:
        # configure these for your database server
        driver: 'pdo_mysql'
        host: '%env(DB_HOST)%'
        port: '%env(DB_PORT)%'
        dbname: '%env(DB_DATABASE)%'
        user: '%env(DB_USERNAME)%'
        password: '%env(DB_PASSWORD)%'
        charset: utf8mb4
        default_table_options:
            charset: utf8mb4
            collate: utf8mb4_unicode_ci

AccessToken class:

<?php
declare(strict_types=1);

namespace App\Entity;

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

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

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

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

AuthCode class

<?php
declare(strict_types=1);

namespace App\Entity;

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

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

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

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

Client class

<?php
declare(strict_types=1);

namespace App\Entity;

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

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

    /**
     * @var string
     *
     * @ORM\Column(name="type", type="string", length=150, nullable=true)
     */
    protected $type;
}

Refresh token class

<?php
declare(strict_types=1);
namespace App\Entity;

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

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

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

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

These classes are simple. As you can see we also need to create the User class

<?php
declare(strict_types=1);

namespace App\Entity;

use FOS\UserBundle\Model\User as BaseUser;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Bridge\Doctrine\Validator\Constraints\UniqueEntity;
use Symfony\Component\Validator\Constraints as Assert;
use JMS\Serializer\Annotation as Serializer;

/**
 * User
 *
 * @ORM\Table(name="user", indexes={
 *     @ORM\Index(name="search_idx_username", columns={"username"}),
 *     @ORM\Index(name="search_idx_email", columns={"email"}),
 * })
 * @ORM\Entity(repositoryClass="App\Repository\UserRepository")
 *
 * @UniqueEntity(fields={"email"}, message="EMAIL_IS_ALREADY_IN_USE")
 *
 * @Serializer\ExclusionPolicy("all")
 */
class User extends BaseUser
{
    const ROLE_SUPER_ADMIN = "ROLE_SUPER_ADMIN";
    const ROLE_ADMIN = "ROLE_ADMIN";
    const ROLE_USER = "ROLE_USER";

    /**
     * To validate supported roles
     *
     * @var array
     */
    static public $ROLES_SUPPORTED = array(
        self::ROLE_SUPER_ADMIN => self::ROLE_SUPER_ADMIN,
        self::ROLE_ADMIN => self::ROLE_ADMIN,
        self::ROLE_USER => self::ROLE_USER,
    );

    /**
     * @var int
     *
     * @ORM\Column(name="id", type="integer")
     * @ORM\Id
     * @ORM\GeneratedValue(strategy="AUTO")
     */
    protected $id;

    /**
     * @var string
     *
     * @Assert\NotBlank(message="FIELD_CAN_NOT_BE_EMPTY")
     * @Assert\Email(
     *     message = "INCORRECT_EMAIL_ADDRESS",
     *     checkMX = true
     * )
     */
    protected $email;

    /**
     * @var string
     *
     * @ORM\Column(name="first_name", type="string", length=100, nullable=true)
     *
     * @Assert\Length(
     *      min = 1,
     *      max = 100,
     *      minMessage = "FIELD_LENGTH_TOO_SHORT",
     *      maxMessage = "FIELD_LENGTH_TOO_LONG"
     * )
     */
    private $firstName;

    /**
     * @var string
     *
     * @ORM\Column(name="last_name", type="string", length=100, nullable=true)
     *
     * @Assert\Length(
     *      min = 1,
     *      max = 100,
     *      minMessage = "FIELD_LENGTH_TOO_SHORT",
     *      maxMessage = "FIELD_LENGTH_TOO_LONG"
     * )
     */
    private $lastName;


    /**
     * @var boolean
     *
     * @ORM\Column(name="deleted", type="boolean")
     *
     * @Assert\Type(
     *     type="bool",
     *     message="FIELD_MUST_BE_BOOLEAN_TYPE"
     * )
     */
    private $deleted;

    /**
     * User constructor.
     */
    public function __construct()
    {
        parent::__construct();
        $this->deleted = false;
    }

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

    /**
     * Set firstName
     *
     * @param string $firstName
     *
     * @return User
     */
    public function setFirstName($firstName)
    {
        $this->firstName = $firstName;

        return $this;
    }

    /**
     * Get firstName
     *
     * @return string
     */
    public function getFirstName()
    {
        return $this->firstName;
    }

    /**
     * Set lastName
     *
     * @param string $lastName
     *
     * @return User
     */
    public function setLastName($lastName)
    {
        $this->lastName = $lastName;

        return $this;
    }

    /**
     * Get lastName
     *
     * @return string
     */
    public function getLastName()
    {
        return $this->lastName;
    }

    /**
     * Set deleted
     *
     * @param boolean $deleted
     *
     * @return User
     */
    public function setDeleted($deleted)
    {
        $this->deleted = $deleted;

        return $this;
    }

    /**
     * Get deleted
     *
     * @return boolean
     */
    public function getDeleted()
    {
        return $this->deleted;
    }
}

And the UserRepository can be empty :)

<?php
declare(strict_types=1);

namespace App\Repository;

use App\Entity\User;
use Doctrine\ORM\EntityRepository;

/**
 * @method User|null find($id, $lockMode = null, $lockVersion = null)
 * @method User|null findOneBy(array $criteria, array $orderBy = null)
 * @method User[]    findAll()
 * @method User[]    findBy(array $criteria, array $orderBy = null, $limit = null, $offset = null)
 */
class UserRepository extends EntityRepository
{
}

Let’s create some datafixtures

Run command : php bin/console make:fixtures
ClientData

Here we create Oauth2ClientData.

Edit the file that we’ve just created.

<?php

namespace App\DataFixtures;

use App\Entity\Client;
use Doctrine\Bundle\FixturesBundle\Fixture;
use Doctrine\Common\Persistence\ObjectManager;
use Doctrine\ORM\Id\AssignedGenerator;
use Doctrine\ORM\Mapping\ClassMetadata;

class ClientData extends Fixture
{
    public function load(ObjectManager $manager)
    {
        $oauth2Client = new Client();

        $oauth2Client->setId(1);
        $oauth2Client->setRandomId('5w8zrdasdafr4tregd454cw0c0kswcgs0oks40s');
        $oauth2Client->setRedirectUris(array());
        $oauth2Client->setSecret('sdgggskokererg4232404gc4csdgfdsgf8s8ck5s');
        $oauth2Client->setAllowedGrantTypes(array('password', 'refresh_token'));

        $manager->persist($oauth2Client);

        /** @var ClassMetadata $metadata */
        $metadata = $manager->getClassMetadata(get_class($oauth2Client));

        $metadata->setIdGeneratorType(ClassMetadata::GENERATOR_TYPE_NONE);
        $metadata->setIdGenerator(new AssignedGenerator());

        $manager->flush();
    }
}

Another one with the the user

php bin/console make:fixtures
UserData

Like in the previous one, edit the file that we’ve just created.

<?php

namespace App\DataFixtures;

use App\Entity\User;
use Doctrine\Bundle\FixturesBundle\Fixture;
use Doctrine\Common\Persistence\ObjectManager;
use FOS\UserBundle\Doctrine\UserManager;
use Symfony\Component\DependencyInjection\ContainerAwareInterface;
use Symfony\Component\DependencyInjection\ContainerInterface;

class UserData extends Fixture implements ContainerAwareInterface
{
    const USER_MANAGER = 'fos_user.user_manager';

    /**
     * @var ContainerInterface
     */
    private $container;

    /**
     * @var UserManager
     */
    private $userManager;

    /**
     * @param ContainerInterface|null $container
     */
    public function setContainer(ContainerInterface $container = null)
    {
        $this->container = $container;
        $this->userManager = $this->container->get(static::USER_MANAGER);
    }

    public function load(ObjectManager $manager)
    {
        /** @var User $user */
        $user = $this->userManager->createUser();

        $user
            ->setFirstName("Admin")
            ->setLastName("admin")
            ->setEnabled(true)
            ->setRoles(array(User::ROLE_SUPER_ADMIN))
            ->setUsername("admin")
            ->setPlainPassword("admin")
            ->setEmail("admin@gmail.com")
        ;
        $manager->persist($user);
        $manager->flush();
    }
}

We also need to define a password encoder for the entity User. We can do it in security.yaml by adding these lines:

    encoders:
        FOS\UserBundle\Model\UserInterface: sha512

After this run commands

php bin/console doctrine:database:drop --force --if-exists
php bin/console make:migration
php bin/console doctrine:migrations:migrate
php bin/console doctrine:fixtures:load

to update database with datafixtures. We should also define routes routes.yaml

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

fos_oauth_server_authorize:
  resource: "@FOSOAuthServerBundle/Resources/config/routing/authorize.xml"

After this using f.e. built in in PHPStorm HTTPClient we can request a token from our OauthServer

Create POST query to path: /oauth/v2/token giving following parameters:

grant_type:password
client_id:1_5w8zrdasdafr4tregd454cw0c0kswcgs0oks40g #notice that we add id_ before client_id which we create using datafixtures
client_secret:sdgggskokererg4232404gc4csdgfdsgf8s8ck5w
username:admin
password:admin

You should get response like this:

{"access_token":"Zjg2NGFiMWQ4YzMwOGRiMjBkZTE3NzQ0MDdiNGUyYzBhNDFhZDFhN2JmNGNjYzM4YWVlYjYyMjdkODA3OTk3OQ","expires_in":28800,"token_type":"bearer","scope":null,"refresh_token":"NzcxNWZjNTY3ZmFiY2QzMTMyZWE5NmZiOTFlZmJiODg2OTk0ZDA5YmZmODM2ODYxODcxOGI5ZmJmNWIyODg1MA"}

If you get this result, your configuration is correct.

@takouarnauld
Copy link

Hello,

Thanks for this nce tutorial.
While implementing it, i got this error, can you please help to fixe?

InvalidConfigurationException
Unrecognized options "sandbox, swagger" under "nelmio_api_doc". Available options are "areas", "documentation", "models".

@GrzegorzBandur
Copy link
Author

GrzegorzBandur commented Jun 6, 2019

Hello,

Thanks for this nce tutorial.
While implementing it, i got this error, can you please help to fixe?

InvalidConfigurationException
Unrecognized options "sandbox, swagger" under "nelmio_api_doc". Available options are "areas", "documentation", "models".

You probably use newer version of nelmio/api-doc-bundle which don't support sandbox in documentation. Try use the same like in this gist.

"nelmio/api-doc-bundle": "^2.13",

@GrzegorzBandur
Copy link
Author

Wielkie dzięki Grzesiek, świetna robota! Zaoszczędziłeś mi mnóstwo czasu.

Cieszę się, że pomogłem.

@takouarnauld
Copy link

takouarnauld commented Jun 6, 2019

Thanks for the return.
Now i'm getting this error while proceding to the request with Postman :

{
"error": "invalid_request",
"error_description": "Invalid grant_type parameter or parameter missing"
}

But using a browser, i have this :
{
"error": "invalid_client",
"error_description": "The client credentials are invalid"
}

I used the id and secret present in the "oauth2_clients" table in my database

@zarzycki100
Copy link

Thanks for the return.
Now i'm getting this error while proceding to the request with Postman :

{
"error": "invalid_request",
"error_description": "Invalid grant_type parameter or parameter missing"
}

But using a browser, i have this :
{
"error": "invalid_client",
"error_description": "The client credentials are invalid"
}

I used the id and secret present in the "oauth2_clients" table in my database

  1. By Postman you should use GET request, and set parameters by bulk edit (in example there is grant_type-password, which in my opinion isn't correct notation), it should looks like this:

grant_type:password
client_id:1_5w8zrdasdafr4tregd454cw0c0kswcgs0oks40g
client_secret:sdgggskokererg4232404gc4csdgfdsgf8s8ck5w
username:admin
password:admin

  1. If you copypaste Fixtures and parameters, double check them - there is difference in "client_id" and "client_secret".

@jessequinn
Copy link

jessequinn commented Aug 15, 2019

I needed to update the bulk values:

grant_type:password
client_id:1_5w8zrdasdafr4tregd454cw0c0kswcgs0oks40s
client_secret:sdgggskokererg4232404gc4csdgfdsgf8s8ck5s
username:admin
password:admin

However, i receive the following error afterwards:

"message": "Attempted to call an undefined method named \"findOneBy\" of class \"App\\Repository\\OAuth2\\UserRepository\".",
                "class": "Symfony\\Component\\Debug\\Exception\\UndefinedMethodException",

Resolved:

Depending on your User entity location

<?php

namespace App\Repository\OAuth2;

use App\Entity\OAuth2\User;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Symfony\Bridge\Doctrine\RegistryInterface;

/**
 * @method User|null find($id, $lockMode = null, $lockVersion = null)
 * @method User|null findOneBy(array $criteria, array $orderBy = null)
 * @method User[]    findAll()
 * @method User[]    findBy(array $criteria, array $orderBy = null, $limit = null, $offset = null)
 */
class UserRepository extends ServiceEntityRepository
{
    public function __construct(RegistryInterface $registry)
    {
        parent::__construct($registry, User::class);
    }
}

@Erjavi95
Copy link

I am new to this and I would like to know how to do everything in basic tutorial mode to perform an api rest with symfony 4 and oauth2

Thanks of advances

@sterichards
Copy link

Executing script cache:clear [KO]
 [KO]
Script cache:clear returned with error code 1
!!
!!  In RegisterMappingsPass.php line 221:
!!
!!    Could not find the manager name parameter in the container. Tried the follo
!!    wing parameter names: "fos_user.model_manager_name", "doctrine.default_enti
!!    ty_manager".
!!
!!
!!
Script @auto-scripts was called via post-install-cmd

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment