Symfony 4 and the API Platform Framework make it easy to create an API application with basic CRUD operations. However, in real-world applications more advanced features (e.g. authentication and custom endpoints) are needed which require quite a bit of knowledge to set-up. Therefore, in this blog article, we explore these advanced features by creating an example application that covers the following topics:

  • Step 1. Installation of API Platform and the EasyAdminBundle
  • Step 2. Creation of entities
  • Step 3. Making of demo content with DataFixtures
  • Step 4. Authentication with HTTP passwords
  • Step 5. Authentication with an API token
  • Step 6. Creation of custom API endpoints
  • Step 7. Functional smoke tests including security

I hope this article helps to reduce the time necessary to set up your own Symfony 4 API application. The example application is hosted on GitHub:

https://github.com/nielsvandermolen/example_symfony4_api/

Each step in the blog article has a own commit in the repository. For readability sake, the full source code is not included in the article but we only highlight the important parts.

Step 1. Setting up the Symfony project

We use Docker to setup the application. Please read the related blog article for the reasons why to use Docker. Please look at commit 9c5b1e9 for the necessary files. Now, when we start the Docker containers with docker-compose up -d and go to the IP of the machine that is running Docker with port 8001 (in my case localhost:8001) you should see an error:  "File not found" .

This is because we did not install Symfony. Let’s fix that issue by going into the Docker fpm (PHP) container and use Composer to install Symfony 4 in the project folder.

1
2
3
4
docker exec -it example_symfony4_api_fpm_1 bash     # Get an interactive bash shell in the fpm container
composer create-project symfony/skeleton .          # Install Symfony 4 in the project directory
composer req api admin security                     # Install the api-pack and easyadmin-bundle
composer req maker test profiler orm-fixtures --dev # Install various development packages for developing, debugging and testing.

This results in a Symfony application with all the dependencies installed. For a detailed look on what is installed check the composer.json and composer.lock files.

Step 2. Let’s create some entities

The example application consists of an article entity with comments linked to it. Let’s create the Comment entity in the Docker container with Symfony Maker:

Next, we create the Article entity and create the relationship to the Comment entity:

This should give you the two entities in project/src/Entity/:

Since we want to use these ids in other applications we do want to change the id fields of the entities to use the Doctrine UUID instead of the default auto incremental ID.

1
2
3
4
5
6
/**
* @ORM\Id()
* @ORM\GeneratedValue(strategy="UUID")
* @ORM\Column(type="guid", unique=true)
*/

private $id;

Furthermore, in order to use the admin interface we want to implement the __toString methods for both entities:

1
2
3
4
public function __toString()
{
    return (string) $this->getId();
}

Now, it is time to add some configuration. First, we have to change the DATABASE_URL environment variable in the project/.env file to mysql://root:root@db:3306/project_db. Then we want to change the project/config/packages/easy_admin.yaml file to include our new entities:

1
2
3
4
easy_admin:
    entities
:
       - App\Entity\Article
       - App\Entity\Comment

Finally, it is time to create the database:

1
2
./bin/console doctrine:database:create
./bin/console doctrine:schema:update --force

Now, we should have a fully functional admin interface on localhost:8001/admin and a working CRUD API for the entities with documentation on localhost:8001/api

Step 3. Doctrine data fixtures

To speed up development and have some content for our automated tests it is a good practice to write a DataFixture. First, create an initial class with ./bin/console make:fixtures and call it AppFixtures. In project/src/DataFixtures/AppFixtures.php we can create some articles with comments on them:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// /project/src/DataFixtures/AppFixtures.php
class AppFixtures extends Fixture
{
    public function load(ObjectManager $manager)
    {
        for ($i = 0; $i < 10; $i++) {
            $article = new Article();
            $article->setBody('This is a body of article ' . $i);

            for ($i2= 0; $i2 < $i; $i2++) {
                $comment = new Comment();
                $comment->setBody('This is the body of comment ' . $i2 . ' of article ' . $i);
                $comment->setArticle($article);
                $manager->persist($comment);
            }
            $manager->persist($article);
        }

        $manager->flush();
    }
}

The content in the Datafixture will replace the content in the current database when calling: ./bin/console doctrine:fixtures:load -n

Step 4. Making the application secure – HTTP password

Currently, there is no security applied in the Symfony application and anyone that can access the server can create and delete entities. The easiest method to make the application secure is to add an HTTP password by creating an in_memory user provider in the projects/config/packages/security.yaml:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
security:
    providers
:
        in_memory
:
            memory
:
                users
:
                    admin
:
                        password
: $2y$12$LhoRWwnWimLqBjFeXOD59ObHQlALhaZh2pqkGzzTIrXeFZs/ltSju
                        roles
: 'ROLE_ADMIN'
    role_hierarchy
:
            ROLE_ADMIN
: [ROLE_API]
    firewalls
:
        dev
:
            pattern
: ^/(_(profiler|wdt)|css|images|js)/
            security
: false
        main
:
            http_basic
: true
            provider
: in_memory
    access_control
:
        - { path
: ^/api/, roles: ROLE_API }
        - { path
: ^/admin/, roles: ROLE_ADMIN }
    encoders
:
        Symfony\Component\Security\Core\User\User
:
           algorithm
: bcrypt
           cost
: 12

The encrypted passwords can be generated with ./bin/console security:encode-password. Now, the application should be behind an HTTP password in this case: admin/admin.

Step 5. Making the application secure – Token authentication

However, we do not want to have to use an HTTP password to make a request to an API. Therefore, we want to use an authentication token that gets sent in with every request. In the example application, we follow the tutorial by Symfony where they use the Symfony Guard authentication system. The full commit for the implementation of the token authentication step is 46866ac.

The security.yaml file has these changed added:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
#...
    providers
:
        user_db_provider
:
            entity
:
                class
: App\Entity\User
                property
: apiKey
        # ...
    firewalls
:
        # ...
        api
:
            pattern
: ^/api/
            guard
:
                authenticators
:
                   - App\Security\TokenAuthenticator
            provider
: user_db_provider
#...

A new User entity has to be created with make:entity which we do not expose with the API but we do want to add it to the easy_admin.yaml file and to the DataFixture. Furthermore, after creating the fixture for the entity you have to update the database again with doctrine:fixtures:load -n

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
// project/src/Entity/User.
/**
 * @ORM\Table(name="app_users")
 * @ORM\Entity(repositoryClass="App\Repository\UserRepository")
 */

class User implements UserInterface, \Serializable
{
    /**
     * @ORM\Id
     * @ORM\Column(type="guid", unique=true)
     * @ORM\GeneratedValue(strategy="UUID")
     */

    private $id;

    /**
     * @ORM\Column(type="string", length=64, unique=true))
     */

    private $apiKey;

    /**
     * @ORM\Column(type="string", length=25)
     */

    private $username;

    /**
     * @ORM\Column(type="string", length=64)
     */

    private $password;

    public function getRoles()
    {
        return array('ROLE_API');
    }

    public function eraseCredentials()
    {
    }
    // .. Other getters and setters
}
1
2
3
4
5
6
7
8
9
// project/src/DataFixtures/AppFixtures.php
public function load(ObjectManager $manager)
{
    $user = new User();
    $user->setApiKey('test_api_key');
    $user->setUsername('test');
    $user->setPassword('test');
    $manager->persist($user);
...
1
2
3
4
5
6
# project/config/packages/easy_admin.yaml
easy_admin
:
    entities
:
       - App\Entity\Article
       - App\Entity\Comment
       - App\Entity\User

Another useful step is to add this Swagger configuration so we can use the token authentication in the API documentation. You do have to remove the role_hierarchy of the admin role or change the security configuration in order to test it with the documentation. However, it does already improve the documentation by adding the header correctly to the curl commands.

1
2
3
4
5
6
7
8
api_platform:
    mapping
:
        paths
: ['%kernel.project_dir%/src/Entity']
    swagger
:
        api_keys
:
           apiKey
:
              name
: X-AUTH-TOKEN
              type
: header

You can test the authentication by using curl on an endpoint and setting the token in the request header:

1
curl -X GET "http://localhost:8001/api/articles" -H "accept: application/json" -H "X-AUTH-TOKEN: test_api_key"
Step 6. Custom API endpoints

API Platform uses the concept of operations for creating API endpoints and there are various methods for defining custom operations. The native Symfony controller method seems to work most intuitively so that is the one that we use. First, let’s create a new service that counts the number of comments on an article:

1
2
3
4
5
6
7
8
9
// project/src/Service/ArticleService.php
use App\Entity\Article;

class ArticleService {
    public function countCommentsinArticle(Article $article) {
        $count = $article->getComments()->count();
        return $count;
    }
}

Now, we want to create a Symfony controller with make:controller and create a method that calls the service and maps it to the API operation:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
class ArticleController extends Controller
{
    /**
     * @Route(
     *     name="count_articles",
     *     path="api/article/{id}/count",
     *     methods={"GET"},
     *     defaults={
     *       "_controller"="\App\Controller\ArticleController::countCommentsinArticle",
     *       "_api_resource_class"="App\Entity\Article",
     *       "_api_item_operation_name"="countCommentsinArticle"
     *     }
     *   )
     */

    public function countCommentsinArticle(Article $data, ArticleService $articleService) {
        $commentCount = $articleService->countCommentsinArticle($data);
        return $this->json([
            'id' => $data->getId(),
            'comments_count' => $commentCount,
        ]);
    }
}

The last step is to make the operations explicit on the Article entity annotations and add the Swagger documentation:

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
/**
 * @ApiResource(
 *   itemOperations={
 *      "get"={"method"="GET"},
 *      "put"={"method"="PUT"},
 *      "delete"={"method"="DELETE"},
 *      "countCommentsinArticle"={
 *        "route_name"="count_articles",
 *        "swagger_context" = {
 *          "parameters" = {
 *            {
 *              "name" = "id",
 *              "in" = "path",
 *              "required" = "true",
 *              "type" = "string"
 *            }
 *          },
 *          "responses" = {
 *            "200" = {
 *              "description" = "The Comment count has been returned in the response"
 *            }
 *          }
 *        }
 *      }
 *   }
 * )
 * @ORM\Entity(repositoryClass="App\Repository\ArticleRepository")
 */

class Article

That’s it! Now the custom API endpoint should show up on the API documentation page (localhost:8001/api) and you should be able to use the endpoint by using the UUID of an article e.g.:

1
curl -X GET "http://localhost:8001/api/article/a389eb11-6d9d-11e8-bc52-0242ac130004/count" -H "accept: application/json" -H "X-AUTH-TOKEN: test_api_key"
Step 7. Automated testing

It is always a good idea to write some smoke test for your PHP application because they are very easy to write and they provide feedback if your application is still “compiling” and that it is secure.

First, we add a new security HTTP password for the test environment:

1
2
3
4
5
6
7
8
9
// project/config/packages/test/security.yaml
security
:
    providers
:
        in_memory
:
            memory
:
                users
:
                    test
:
                        password
: $2y$12$1sZXPUAuyLv5PSWigKfjMOtczuaB.nNG7Kou5st6blZ2UB9KBtpy6
                        roles
: 'ROLE_ADMIN'

Then we can add our tests:

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
// project/tests/SmokeFunctionalTest.php
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;

class SmokeFunctionalTest extends WebTestCase {

    /**
     * @dataProvider urlProvider
     */

    public function testPageIsSecure($url) {
        $client = self::createClient([], [
            'PHP_AUTH_USER' => 'test',
            'PHP_AUTH_PW'   => 'incorrect_pw',
        ]);
        $client->request('GET', $url);
        $this->assertFalse($client->getResponse()->isSuccessful());
    }

    /**
     * @dataProvider urlProvider
     */

    public function testPageIsSuccessful($url) {
        $client = self::createClient([], [
            'PHP_AUTH_USER' => 'test',
            'PHP_AUTH_PW'   => 'test',
        ]);
        $client->request('GET', $url);
        $this->assertTrue($client->getResponse()->isSuccessful());
    }

    public function urlProvider() {
        yield ['/api'];
        yield ['/admin/?entity=Article'];
        yield ['/admin/?entity=Comment'];
        yield ['/admin/?entity=User'];
    }

    /**
     * @dataProvider urlApiProvider
     */

    public function testAPIisSecure($url) {
        $client = self::createClient([]);
        $client->request('GET', $url, [], [], ['HTTP_X_AUTH_TOKEN' => 'incorrect_api_key', 'HTTP_ACCEPT' => 'application/json']);
        $this->assertFalse($client->getResponse()->isSuccessful());
    }

    /**
     * @dataProvider urlApiProvider
     */

    public function testAPIWorks($url) {
        $client = self::createClient([]);
        $client->request('GET', $url, [], [], ['HTTP_X_AUTH_TOKEN' => 'test_api_key', 'HTTP_ACCEPT' => 'application/json']);
        $this->assertTrue($client->getResponse()->isSuccessful());
    }

    public function urlApiProvider() {
        yield ['/api/articles'];
        yield ['/api/comments'];
    }
}

Before we can run the tests we need to add some environment variables to the phpunit.xml.dist file:

1
2
3
4
5
6
<php>
...
    <env name="CORS_ALLOW_ORIGIN" value="^http://localhost:?[0-9]*$" />
    <env name="DATABASE_URL" value="mysql://root:root@db:3306/environments" />
...
</php>

There we go, we can now execute the PHPUnit tests in the Docker container with:

1
./bin/phpunit
Discussion

Congratulations for making it to the end of the article as it had a high density of concepts which were not explained in a detailed matter. For some topics, it is most educative to see a collection of opinionated code that works. We have just touched the basics on what is possible with Symfony and the API Platform Framework we hope this will give you a headstart in developing your API application. I am in the middle of learning to develop with these technologies as well so please let me know if you think there are better ways to do things and why.

If you learned something from this work please consider leaving a comment or sharing it with your peers. Furthermore, if you want to support this blog financially please consider becoming a Patron.

 

 


1 Comment

nielsvandermolen · June 21, 2018 at 10:39 am

There is a small mistake in Step 6. Custom API endpoints. It is better when the route path starts with api/ for the authentication to work for the custom operations. Updated the article and the repository to resolve this issue.

Leave a Reply

Your email address will not be published.