DEV Community

Hantsy Bai
Hantsy Bai

Posted on

# Building Restful APIs with Symfony 5 and PHP 8

Symfony is a full-featured modularized PHP framework which is used for building all kinds of applications, from traditional web applications to the small Microservice components.

Get your feet wet

Install PHP 8 and PHP Composer tools.

# choco php composer
Enter fullscreen mode Exit fullscreen mode

Install Symfony CLI, check the system requirements.

# symfony check:requirements

Symfony Requirements Checker
Enter fullscreen mode Exit fullscreen mode

PHP is using the following php.ini file:
C:\tools\php80\php.ini

Checking Symfony requirements:

....................WWW.........

[OK]

Your system is ready to run Symfony projects

Optional recommendations to improve your setup


 * intl extension should be available
   > Install and enable the intl extension (used for validators).

 * a PHP accelerator should be installed
   > Install and/or enable a PHP accelerator (highly recommended).

 * realpath_cache_size should be at least 5M in php.ini
   > Setting "realpath_cache_size" to e.g. "5242880" or "5M" in
   > php.ini* may improve performance on Windows significantly in some
   > cases.


Note  The command console can use a different php.ini file
~~~~  than the one used by your web server.
      Please check that both the console and the web server
      are using the same PHP version and configuration.

Enter fullscreen mode Exit fullscreen mode

According to the recommendations info, adjust your PHP configuration in the php.ini. And we will use Postgres as database in the sample application, make sure pdo_pgsql and pgsql modules are enabled.

Finally, you can confirm the enabled modules by the following command.

# php -m
Enter fullscreen mode Exit fullscreen mode

Create a new Symfony project.

# symfony new rest-sample

// a classic website application
# symfony new web-sample --full
Enter fullscreen mode Exit fullscreen mode

By default, it will create a simple Symfony skeleton project only with core kernel configuration, which is good to start a lightweight Restful API application.

Alternatively, you can create it using Composer.

# composer create-project symfony/skeleton rest-sample

//start a classic website application
# composer create-project symfony/website-skeleton web-sample
Enter fullscreen mode Exit fullscreen mode

Enter the generated project root folder, start the application.

# symfony server:start

 [WARNING] run "symfony.exe server:ca:install" first if you want to run the web server with TLS support, or use "--no-  
 tls" to avoid this warning                                                                                             

Tailing PHP-CGI log file (C:\Users\hantsy\.symfony\log\499d60b14521d4842ba7ebfce0861130efe66158\79ca75f9e90b4126a5955a33ea6a41ec5e854698.log)
Tailing Web Server log file (C:\Users\hantsy\.symfony\log\499d60b14521d4842ba7ebfce0861130efe66158.log)

 [OK] Web server listening                                                                                              
      The Web server is using PHP CGI 8.0.10                                                                            
      http://127.0.0.1:8000                                                                                             


[Web Server ] Oct  4 13:33:01 |DEBUG  | PHP    Reloading PHP versions
[Web Server ] Oct  4 13:33:01 |DEBUG  | PHP    Using PHP version 8.0.10 (from default version in $PATH)
[Web Server ] Oct  4 13:33:01 |INFO   | PHP    listening path="C:\\tools\\php80\\php-cgi.exe" php="8.0.10" port=61738

Enter fullscreen mode Exit fullscreen mode

Hello , Symfony

Create a simple class to a resource entity in the HTTP response.

class Post
{
    private ?string $id = null;

    private string $title;

    private string $content;

    //getters and setters.
}
Enter fullscreen mode Exit fullscreen mode

And use a factory to create a new Post instance.

class PostFactory
{
    public static function create(string $title, string $content): Post
    {
        $post = new Post();
        $post->setTitle($title);
        $post->setContent($content);
        return $post;
    }
}
Enter fullscreen mode Exit fullscreen mode

Let's create a simple Controller class.

To use the newest PHP 8 attributes to configure the routing rules, apply the following changes in the project configurations.

  • Open config/packages/doctrine.yaml, remove doctrine/orm/mapping/App/type or change its value to attribute
  • Open composer.json, change PHP version to >=8.0.0.

To render the response body into a JSON string, use a JsonReponse to wrap the response.

#[Route(path: "/posts", name: "posts_")]
class PostController
{

    #[Route(path: "", name: "all", methods: ["GET"])]
    function all(): Response
    {
        $post1 = PostFactory::create("test title", "test content");
        $post1->setId("1");

        $post2 = PostFactory::create("test title", "test content");
        $post2->setId("2");
        $data = [$post1->asArray(), $post2->asArray()];
        return new JsonResponse($data, 200, ["Content-Type" => "application/json"]);
        //return $this->json($data, 200, ["Content-Type" => "application/json"]);
    }
}    
Enter fullscreen mode Exit fullscreen mode

The first parameter of JsonReponse accepts an array as data, so add a function in the Post class to archive this purpose.

class Post{
    //...
    public function asArray(): array
    {
        return [
            'id' => $this->id,
            'title' => $this->title,
            'content' => $this->content
        ];
    }
}
Enter fullscreen mode Exit fullscreen mode

Run the application, use curl to test the /posts endpoint.

# curl http://localhost:8000/posts
Enter fullscreen mode Exit fullscreen mode

Symfony provides a simple AbstractController which includes several functions to simplfy the response and adopt the container and dependency injection management.

In the above controller, extends from AbstractController, simply call $this->json to render the response in JSON format, no need to transform the data to an array before rendering response.


class PostController extends AbstractController
{

    function all(): Response
    {
        //...
        return $this->json($data, 200, ["Content-Type" => "application/json"]);
    }
}  
Enter fullscreen mode Exit fullscreen mode

Connecting to Database

Doctrine is a popular ORM framework , it is highly inspired by the existing Java ORM tooling, such as JPA spec and Hibernate framework. There are two core components in Doctrine, doctrine/dbal and doctrine/orm, the former is a low level APIs for database operations, if you know Java development, consider it as the Jdbc layer. The later is the advanced ORM framework, the public APIs are similar to JPA/Hibernate.

Install Doctrine into the project.

# composer require symfony/orm-pack
# composer require --dev symfony/maker-bundle
Enter fullscreen mode Exit fullscreen mode

The pack is a virtual Symfony package, it will install a series of packages and basic configurations.

Open the .env file in the project root folder, edit the DATABASE_URL value, setup the database name, username, password to connect.

DATABASE_URL="postgresql://user:password@127.0.0.1:5432/blogdb?serverVersion=13&charset=utf8"
Enter fullscreen mode Exit fullscreen mode

Use the following command to generate a docker compose file template.

# php bin/console make:docker:database
Enter fullscreen mode Exit fullscreen mode

We change it to the following to start up a Postgres database in development.

version: "3.5" # specify docker-compose version, v3.5 is compatible with docker 17.12.0+

# Define the services/containers to be run
services:

  postgres:
    image: postgres:${POSTGRES_VERSION:-13}-alpine
    ports:
      - "5432:5432"
    environment:
      POSTGRES_DB: ${POSTGRES_DB:-blogdb}
      # You should definitely change the password in production
      POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-password}
      POSTGRES_USER: ${POSTGRES_USER:-user}
    volumes:
      - ./data/blogdb:/var/lib/postgresql/data:rw
      - ./pg-initdb.d:/docker-entrypoint-initdb.d
Enter fullscreen mode Exit fullscreen mode

We will use UUID as data type of the primary key, add a script to enable uuid-ossp extension in Postgres when it is starting up.

-- file: pg-initdb.d/ini.sql
SET search_path TO public;
DROP EXTENSION IF EXISTS "uuid-ossp";
CREATE EXTENSION "uuid-ossp" SCHEMA public;
Enter fullscreen mode Exit fullscreen mode

Open config/packages/test/doctrine.yaml, comment out dbname_suffix line. We use Docker container to bootstrap a database to ensure the application behaviors are same between the development and production.

Now startup the application and make sure there is no exception in the console, that means the database connection is successful.

symfony server:start
Enter fullscreen mode Exit fullscreen mode

Before starting the application, make sure the database is running. Run the following command to start up the Postgres in Docker.

# docker compose up postgres
# docker ps -a # to list all containers and make the postgres is running
Enter fullscreen mode Exit fullscreen mode

Building Data Models

Now we will build the Entities that will be used in the next sections. We are modeling a simple blog system, it includes the following concepts.

  • A Post presents an article post in the blog system.
  • A Comment presents the comments under a specific post.
  • The common Tag can be applied on different posts, which categorizes posts by topic, categories , etc.

You can draft your model relations in mind or through some graphic data modeling tools.

  • Post and comments is a one-to-many relation
  • Post and tag is a many-to-many relation

It is easy to convert the idea to real codes via Doctrine Entity. Run the following command to create Post, Comment and Tag entities.

In the Doctrine ORM 2.10.x and Dbal 3.x, the UUID type ID generator is deprecated. We will switch to the Uuid form symfony\uid.

Install symfony\uid firstly.

# composer require symfony/uid
Enter fullscreen mode Exit fullscreen mode

Simply, you can use the following command to create entities quickly.

# php bin/console make:entity  # following the interactive steps to create them one by one.
Enter fullscreen mode Exit fullscreen mode

Finally we got three entities in the src/Entity folder. Modify them as you expected.

// src/Entity/Post.php
#[Entity(repositoryClass: PostRepository::class)]
class Post
{
    #[Id]
    //#[GeneratedValue(strategy: "UUID")
    //#[Column(type: "string", unique: true)]
    #[Column(type: "uuid", unique: true)]
    #[GeneratedValue(strategy: "CUSTOM")]
    #[CustomIdGenerator(class: UuidGenerator::class)]
    private ?Uuid $id = null;

    #[Column(type: "string", length: 255)]
    private string $title;

    #[Column(type: "string", length: 255)]
    private string $content;

    #[Column(name: "created_at", type: "datetime", nullable: true)]
    private DateTime|null $createdAt = null;

    #[Column(name: "published_at", type: "datetime", nullable: true)]
    private DateTime|null $publishedAt = null;

    #[OneToMany(mappedBy: "post", targetEntity: Comment::class, cascade: ['persist', 'merge', "remove"], fetch: 'LAZY', orphanRemoval: true)]
    private Collection $comments;

    #[ManyToMany(targetEntity: Tag::class, mappedBy: "posts", cascade: ['persist', 'merge'], fetch: 'EAGER')]
    private Collection $tags;

    public function __construct()
    {
        $this->createdAt = new DateTime();
        $this->comments = new ArrayCollection();
        $this->tags = new ArrayCollection();
    }
    //other getters and setters
}

// src/Entity/Comment.php
#[Entity(repositoryClass: CommentRepository::class)]
class Comment
{
    #[Id]
    //#[GeneratedValue(strategy: "UUID")]
    #[Column(type: "uuid", unique: true)]
    #[GeneratedValue(strategy: "CUSTOM")]
    #[CustomIdGenerator(class: UuidGenerator::class)]
    private ?Uuid $id = null;

    #[Column(type: "string", length: 255)]
    private string $content;

    #[Column(name: "created_at", type: "datetime", nullable: true)]
    private DateTime|null $createdAt = null;

    #[ManyToOne(targetEntity: "Post", inversedBy: "comments")]
    #[JoinColumn(name: "post_id", referencedColumnName: "id")]
    private Post $post;

    public function __construct()
    {
        $this->createdAt = new DateTime();
    }
    //other getters and setters
}

//src/Entity/Tag.php
#[Entity(repositoryClass: TagRepository::class)]
class Tag
{
    #[Id]
    //#[GeneratedValue(strategy: "UUID")
    //#[Column(type: "string", unique: true)]
    #[Column(type: "uuid", unique: true)]
    #[GeneratedValue(strategy: "CUSTOM")]
    #[CustomIdGenerator(class: UuidGenerator::class)]
    private ?Uuid $id = null;

    #[Column(type: "string", length: 255)]
    private ?string $name;

    #[ManyToMany(targetEntity: Post::class, inversedBy: "tags")]
    private Collection $posts;

    public function __construct()
    {
        $this->posts = new ArrayCollection();
    }
}
Enter fullscreen mode Exit fullscreen mode

At the same time, it generated three Repository classes for these entities.

// src/Repository/PostRepsoitory.php
class PostRepository extends ServiceEntityRepository
{
    public function __construct(ManagerRegistry $registry)
    {
        parent::__construct($registry, Post::class);
    }
}

// src/Repository/CommentRepsoitory.php
class CommentRepository extends ServiceEntityRepository
{
    public function __construct(ManagerRegistry $registry)
    {
        parent::__construct($registry, Comment::class);
    }
}

//src/Repository/TagRepository.php
class TagRepository extends ServiceEntityRepository
{
    public function __construct(ManagerRegistry $registry)
    {
        parent::__construct($registry, Tag::class);
    }
}
Enter fullscreen mode Exit fullscreen mode

You can use Doctrine migration to generate a Migration file to maintain database schema in a production environment.

Run the following command to generate a Migration file.

# php bin/console make:migration
Enter fullscreen mode Exit fullscreen mode

After it is executed, a Migration file is generated in the migrations folder, its naming is like Version20211104031420. It is a simple class extended AbstractMigration, the up function is use for upgrade to this version and down function is use for downgrade to the previous version.

To apply Migrations on database automaticially.

# php bin/console doctrine:migrations:migrate

# return to prev version
# php bin/console doctrine:migrations:migrate prev

# migrate to next
# php bin/console doctrine:migrations:migrate next

# These alias are defined : first, latest, prev, current and next

# certain version fully qualified class name
# php bin/console doctrine:migrations:migrate FQCN
Enter fullscreen mode Exit fullscreen mode

Doctrine bundle also includes some command to maintain database and schema. eg.

# php bin/console doctrine:database:create
# php bin/console doctrine:database:drop

// schema create, drop, update and validate
# php bin/console doctrine:schema:create
# php bin/console doctrine:schema:drop
# php bin/console doctrine:schema:update
# php bin/console doctrine:schema:validate
Enter fullscreen mode Exit fullscreen mode

Adding Sample Data

Create a custom command to load some sample data.

# php bin/console make:command add-post
Enter fullscreen mode Exit fullscreen mode

It will generate a AddPostCommand under src/Command folder.

#[AsCommand(
    name: 'app:add-post',
    description: 'Add a short description for your command',
)]
class AddPostCommand extends Command
{


    public function __construct(private EntityManagerInterface $manager)
    {
        parent::__construct();
    }

    protected function configure(): void
    {
        $this
            ->addArgument('title', InputArgument::REQUIRED, 'Title of a post')
            ->addArgument('content', InputArgument::REQUIRED, 'Content of a post')
            //->addOption('option1', null, InputOption::VALUE_NONE, 'Option description')
        ;
    }

    protected function execute(InputInterface $input, OutputInterface $output): int
    {
        $io = new SymfonyStyle($input, $output);
        $title = $input->getArgument('title');

        if ($title) {
            $io->note(sprintf('Title: %s', $title));
        }

        $content = $input->getArgument('content');

        if ($content) {
            $io->note(sprintf('Content: %s', $content));
        }

        $entity = PostFactory::create($title, $content);
        $this ->manager->persist($entity);
        $this ->manager->flush();

//        if ($input->getOption('option1')) {
//            // ...
//        }

        $io->success('Post is saved: '.$entity);

        return Command::SUCCESS;
    }
}
Enter fullscreen mode Exit fullscreen mode

The Doctrine EntityManagerInterface is managed by Symfony Service Container, and use for data persistence operations.

Run the following command to add a post into the database.

# php bin/console app:add-post "test title" "test content"
 ! [NOTE] Title: test title                                               
 ! [NOTE] Content: test content                                                             
 [OK] Post is saved: Post: [ id =1ec3d3ec-895d-685a-b712-955865f6c134, title=test title, content=test content, createdAt=1636010040, blishedAt=] 
Enter fullscreen mode Exit fullscreen mode

Testing Repository

PHPUnit is the most popular testing framework in PHP world, Symfony integrates PHPUnit tightly.

Run the following command to install PHPUnit and Symfony test-pack. The test-pack will install all essential packages for testing Symfony components and add PHPUnit configuration, such as phpunit.xml.dist.

# composer require --dev phpunit/phpunit symfony/test-pack
Enter fullscreen mode Exit fullscreen mode

An simple test example written in pure PHPUnit.

class PostTest extends TestCase
{

    public function testPost()
    {
        $p = PostFactory::create("tests title", "tests content");

        $this->assertEquals("tests title", $p->getTitle());
        $this->assertEquals("tests content", $p->getContent());
        $this->assertNotNull( $p->getCreatedAt());
    }
}
Enter fullscreen mode Exit fullscreen mode

Symfony provides some specific base classes(KernelTestCase, WebTestCase, etc.) to simplfy the testing work in a Symfony project.

The following is an example of testing a Repository - PostRepository. The KernelTestCase contains facilities to bootstrap application kernel and provides service container.

class PostRepositoryTest extends KernelTestCase
{

    private EntityManagerInterface $entityManager;

    private PostRepository $postRepository;

    protected function setUp(): void
    {
        //(1) boot the Symfony kernel
        $kernel = self::bootKernel();
        $this->assertSame('test', $kernel->getEnvironment());
        $this->entityManager = $kernel->getContainer()
            ->get('doctrine')
            ->getManager();

        //(2) use static::getContainer() to access the service container
        $container = static::getContainer();

        //(3) get PostRepository from container.
        $this->postRepository = $container->get(PostRepository::class);
    }

    protected function tearDown(): void
    {
        parent::tearDown();
        $this->entityManager->close();
    }

    public function testCreatePost(): void
    {
        $entity = PostFactory::create("test post", "test content");
        $this->entityManager->persist($entity);
        $this->entityManager->flush();
        $this->assertNotNull($entity->getId());

        $byId = $this->postRepository->findOneBy(["id" => $entity->getId()]);
        $this->assertEquals("test post", $byId->getTitle());
        $this->assertEquals("test content", $byId->getContent());
    }

}
Enter fullscreen mode Exit fullscreen mode

In the above codes, in the setUp function, boot up the application kernel, after it is booted, a test scoped Service Container is available. Then get EntityManagerInterface and PostRepository from service container.

In the testCreatePost function, persists a Post entity, and find this post by id and verify the title and content fields.

Currently, PHPUnit does not include PHP 8 Attribute support, the testing codes are similar to the legacy JUnit 4 code style.

Creating PostController: Exposing your first Rest API

Similar to other MVC framework, we can expose RESTful APIs via Symfony Controller component. Follow the REST convention, we are planning to create the following APIs to a blog system.

  • GET /posts Get all posts.
  • GET /posts/{id} Get a single post by ID, if not found, return status 404
  • POST /posts Create a new post from request body, add the new post URI to response header Location, and return status 201
  • DELETE /posts/{id} Delete a single post by ID, return status 204. If the post was not found, return status 404 instead.
  • ...

Run the following command to create a Controller skeleton. Follow the interactive guide to create a controller named PostController.

# php bin/console make:constroller
Enter fullscreen mode Exit fullscreen mode

Open src/Controller/PostController.php in IDE.

Add Route attribute on class level and two functions: one for fetching all posts and another for getting single post by ID.

#[Route(path: "/posts", name: "posts_")]
class PostController extends AbstractController
{
    public function __construct(private PostRepository      $posts)
    {
    }

    #[Route(path: "", name: "all", methods: ["GET"])]
    function all(): Response
    {
        $data = $this->posts->findAll();
        return $this->json($data);
    }

}
Enter fullscreen mode Exit fullscreen mode

Start up the application, and try to access the http://localhost:8000/posts, it will throw a circular dependencies exception when rendering the models in JSON view directly. There are some solutions to avoid this, the simplest is break the bi-direction relations before rendering the JSON view. Add a Ignore attribute on Comment.post and Tag.posts.

//src/Entity/Comment.php
class Comment
{
    #[Ignore]
    private Post $post;
}

//src/Entity/Tag.php
class Tag
{
    #[Ignore]
    private Collection $posts;
}
Enter fullscreen mode Exit fullscreen mode

Testing Controller

As described in the previous sections, to test Controller/API, create a test class to extend WebTestCase, which provides a plenty of facilities to handle request and assert response.

Run the following command to create a test skeleton.

# php bin/console make:test
Enter fullscreen mode Exit fullscreen mode

Follow the interactive steps to create a test base on WebTestCase.

class PostControllerTest extends WebTestCase
{
    public function testGetAllPosts(): void
    {
        $client = static::createClient();
        $crawler = $client->request('GET', '/posts');

        $this->assertResponseIsSuccessful();

        //
        $response = $client->getResponse();
        $data = $response->getContent();
        //dump($data);
        $this->assertStringContainsString("Symfony and PHP", $data);
    }

}
Enter fullscreen mode Exit fullscreen mode

If you try to run the test, it will fail. At the moment, there is no any data for testing.

Preparing Data for Testing Purpose

The doctrine/doctrine-fixtures-bundle is use for populate sample data for testing purpose, and dama/doctrine-test-bundle ensures the data is restored before evey test is running.

Install doctrine/doctrine-fixtures-bundle and dama/doctrine-test-bundle.

composer require --dev doctrine/doctrine-fixtures-bundle dama/doctrine-test-bundle
Enter fullscreen mode Exit fullscreen mode

Create a new Fixture.

# php bin/console make:fixtures
Enter fullscreen mode Exit fullscreen mode

In the load fucntion, persist some data for tests.

class AppFixtures extends Fixture
{
    public function load(ObjectManager $manager): void
    {
        $data = PostFactory::create("Building Restful APIs with Symfony and PHP 8", "test content");
        $data->addTag(Tag::of( "Symfony"))
            ->addTag( Tag::of("PHP 8"))
            ->addComment(Comment::of("test comment 1"))
            ->addComment(Comment::of("test comment 2"));

        $manager->persist($data);
        $manager->flush();
    }
}
Enter fullscreen mode Exit fullscreen mode

Run the command to load the sample data into database manually.

# php bin/console doctrine:fixtures:load 
Enter fullscreen mode Exit fullscreen mode

Add the following extension configuration into the phpunit.xml.dist, thus the data will be purged and recreated for every test running.

<extensions>
    <extension class="DAMA\DoctrineTestBundle\PHPUnit\PHPUnitExtension"/>
</extensions>
Enter fullscreen mode Exit fullscreen mode

Run the following command to execute PostControllerTest.php .

# php .\vendor\bin\phpunit .\tests\Controller\PostControllerTest.php
Enter fullscreen mode Exit fullscreen mode

Paginating Result

There are a lot of web applications which provide a input field for typing keyword and paginating the search results. Assume there is a keyword provided by request to match Post title or content fields, a offset to set the offset position of the pagination, and a limit to set the limited size of the elements per page. Create a function in the PostRepository, accepts a keyword, offset and limit as arguments.

public function findByKeyword(string $q, int $offset = 0, int $limit = 20): Page
{
    $query = $this->createQueryBuilder("p")
        ->andWhere("p.title like :q or p.content like :q")
        ->setParameter('q', "%" . $q . "%")
        ->orderBy('p.createdAt', 'DESC')
        ->setMaxResults($limit)
        ->setFirstResult($offset)
        ->getQuery();

    $paginator = new Paginator($query, $fetchJoinCollection = false);
    $c = count($paginator);
    $content = new ArrayCollection();
    foreach ($paginator as $post) {
        $content->add(PostSummaryDto::of($post->getId(), $post->getTitle()));
    }
    return Page::of ($content, $c, $offset, $limit);
}
Enter fullscreen mode Exit fullscreen mode

Firstly, create a dynamic query using createQueryBuilder , then create a Doctrine Paginator instance to execute the query. The Paginator implements Countable interface, use count to get the count of total elements. Finally, we use a custom Page object to wrap the result.

class Page
{
    private Collection $content;
    private int $totalElements;
    private int $offset;
    private int $limit;

    #[Pure] public function __construct()
    {
        $this->content = new ArrayCollection();
    }


    public static function of(Collection $content, int $totalElements, int $offset = 0, int $limit = 20): Page
    {
        $page = new Page();
        $page->setContent($content)
            ->setTotalElements($totalElements)
            ->setOffset($offset)
            ->setLimit($limit);

        return $page;
    }

    //
    //getters

}    
Enter fullscreen mode Exit fullscreen mode

Customzing ArgumentResolver

In the PostController , let's improve the the function which serves the route /posts, make it accept query parameters like /posts?q=Symfony&offset=0&limit=10, and ensure the parameters are optional.

    #[Route(path: "", name: "all", methods: ["GET"])]
    function all(Request $req): Response
    {
        $keyword = $req->query->get('q')??'';
        $offset = $req->query->get('offset')??0;
        $limit = $req->query->get('limit')??10;

        $data = $this->posts->findByKeyword($keyword, $offset, $limit);
        return $this->json($data);
    }
Enter fullscreen mode Exit fullscreen mode

It works but the query parameters handling looks a little ugly. It is great if they can be handled as the route path parameters.

We can create a custom ArgumentResolver to resolve the bound query arguments.

Firstly create an Annotation/Attribute class to identify a query parameter that need to be resolved by this ArgumentResolver.

#[Attribute(Attribute::TARGET_PARAMETER)]
final class QueryParam
{
    private null|string $name;
    private bool $required;

    /**
     * @param string|null $name
     * @param bool $required
     */
    public function __construct(?string $name = null, bool $required = false)
    {
        $this->name = $name;
        $this->required = $required;
    }

    //getters and setters

}    
Enter fullscreen mode Exit fullscreen mode

Create a custom ArgumentResolver implements the built-in ArgugmentResolverInterface.

class QueryParamValueResolver implements ArgumentValueResolverInterface, LoggerAwareInterface
{
    public function __construct()
    {
    }

    private LoggerInterface $logger;

    /**
     * @inheritDoc
     */
    public function resolve(Request $request, ArgumentMetadata $argument)
    {
        $argumentName = $argument->getName();
        $this->logger->info("Found [QueryParam] annotation/attribute on argument '" . $argumentName . "', applying [QueryParamValueResolver]");
        $type = $argument->getType();
        $nullable = $argument->isNullable();
        $this->logger->debug("The method argument type: '" . $type . "' and nullable: '" . $nullable . "'");

        //read name property from QueryParam
        $attr = $argument->getAttributes(QueryParam::class)[0];// `QueryParam` is not repeatable
        $this->logger->debug("QueryParam:" . $attr);
        //if name property is not set in `QueryParam`, use the argument name instead.
        $name = $attr->getName() ?? $argumentName;
        $required = $attr->isRequired() ?? false;
        $this->logger->debug("Polished QueryParam values: name='" . $name . "', required='" . $required . "'");

        //fetch query name from request
        $value = $request->query->get($name);
        $this->logger->debug("The request query parameter value: '" . $value . "'");

        //if default value is set and query param value is not set, use default value instead.
        if (!$value && $argument->hasDefaultValue()) {
            $value = $argument->getDefaultValue();
            $this->logger->debug("After set default value: '" . $value . "'");
        }

        if ($required && !$value) {
            throw new \InvalidArgumentException("Request query parameter '" . $name . "' is required, but not set.");
        }

        $this->logger->debug("final resolved value: '" . $value . "'");

        //must return  a `yield` clause
        yield match ($type) {
            'int' => $value ? (int)$value : 0,
            'float' => $value ? (float)$value : .0,
            'bool' => (bool)$value,
            'string' => $value ? (string)$value : ($nullable ? null : ''),
            null => null
        };
    }

    public function supports(Request $request, ArgumentMetadata $argument): bool
    {
        $attrs = $argument->getAttributes(QueryParam::class);
        return count($attrs) > 0;
    }

    public function setLogger(LoggerInterface $logger)
    {
        $this->logger = $logger;
    }
}
Enter fullscreen mode Exit fullscreen mode

At runtime, it calls the supports function to check it the current request satisfy the requirement, if it is ok, then invoke the resovle funtion.

In the supports function, we check if the argument is annotated with a QueryParam, if it is existed, then resolved the argument from request query string.

Now change the function that serves /posts endpoint to the following.

#[Route(path: "", name: "all", methods: ["GET"])]
function all(#[QueryParam] $keyword,
    #[QueryParam] int $offset = 0,
    #[QueryParam] int $limit = 20): Response
    {
        $data = $this->posts->findByKeyword($keyword || '', $offset, $limit);
        return $this->json($data);
    }
Enter fullscreen mode Exit fullscreen mode

Run the application and test the /posts using curl.

# curl http://localhost:8000/posts
{
    "content":[
        {
            "id":"1ec3e1e0-17b3-6ed2-a01c-edecc112b436",
            "title":"Building Restful APIs with Symfony and PHP 8"
        }
    ],
    "totalElements":1,
    "offset":0,
    "limit":20
}
Enter fullscreen mode Exit fullscreen mode

Get Post by ID

Follow the design in the previous section, add another function to PostController to map route /posts/{id} .

class PostController extends AbstractController
{
    //other functions...

    #[Route(path: "/{id}", name: "byId", methods: ["GET"])]
    function getById(Uuid $id): Response
    {
        $data = $this->posts->findOneBy(["id" => $id]);
        if ($data) {
            return $this->json($data);
        } else {
            return $this->json(["error" => "Post was not found by id:" . $id], 404);
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

Run the application, and try to access http://localhost:8000/posts/{id}, it will throw an exception like this.

App\Controller\PostController::getById(): Argument #1 ($id) must be of type Symfony\Component\Uid\Uuid, string given, cal
led in D:\hantsylabs\symfony5-sample\rest-sample\vendor\symfony\http-kernel\HttpKernel.php on line 156

Enter fullscreen mode Exit fullscreen mode

The id in the URI is a string, can not be used as Uuid directly.

Symfony provides ParamConverter to convert the request attributes to the target type. We can create a custom ParamConverter to archive the purpose.

Customizing ParamConverter

Create a new class UuidParamCovnerter under src/Request/ folder.

class UuidParamConverter implements ParamConverterInterface
{
    public function __construct(private LoggerInterface $logger)
    {
    }


    /**
     * @inheritDoc
     */
    public function apply(Request $request, ParamConverter $configuration): bool
    {

        $param = $configuration->getName();

        if (!$request->attributes->has($param)) {
            return false;
        }

        $value = $request->attributes->get($param);
        $this->logger->info("parameter value:" . $value);
        if (!$value && $configuration->isOptional()) {
            $request->attributes->set($param, null);

            return true;
        }

        $data = Uuid::fromString($value);
        $request->attributes->set($param, $data);

        return true;
    }

    /**
     * @inheritDoc
     */
    public function supports(ParamConverter $configuration): bool
    {
        $className = $configuration->getClass();
        $this->logger->info("converting to UUID :{c}", ["c" => $className]);
        return $className && $className == Uuid::class;
    }
}
Enter fullscreen mode Exit fullscreen mode

In the above codes,

  • The supports function to check the execution environment if matching the requirements

  • The apply function to perform the conversion. if supports returns false, this conversion step will be skipped.

Creating a Post

Follow the REST convention, define the following rule to serve an endpoint to handle the request.

  • Request matches Http verbs/HTTP Method: POST
  • Request matches route endpoint: /posts
  • Set request header Content-Type value to application/json, and use request body to hold request data as JSON format
  • If successful, return a CREATED(201) Http Status code, and set the response header Location value to the URI of the new created post.
#[Route(path: "", name: "create", methods: ["POST"])]
public function create(Request $request): Response
{
    $data = $this->serializer->deserialize($request->getContent(), CreatePostDto::class, 'json');
    $entity = PostFactory::create($data->getTitle(), $data->getContent());
    $this->posts->getEntityManager()->persist($entity);

    return $this->json([], 201, ["Location" => "/posts/" . $entity->getId()]);
}
Enter fullscreen mode Exit fullscreen mode

The posts->getEntityManager() overrides parent methods to get a EntityManager from parent class, you can also inject ObjectManager or EntityManagerInterface in the PostController directly to do the persistence work. The Doctrine Repository is mainly designated to build query criteria and execute custom queries.

Create a test function to verify in the PostControllerTest file.

public function testCreatePost(): void
{
    $client = static::createClient();
    $data = CreatePostDto::of("test title", "test content");
    $crawler = $client->request(
        'POST',
        '/posts',
        [],
        [],
        [],
        $this->getContainer()->get('serializer')->serialize($data, 'json')
    );

    $this->assertResponseIsSuccessful();

    $response = $client->getResponse();
    $url = $response->headers->get('Location');
    //dump($data);
    $this->assertNotNull($url);
    $this->assertStringStartsWith("/posts/", $url);
}
Enter fullscreen mode Exit fullscreen mode

Converting Request Body

We can also use an Annotation/Attribute to erase the raw codes of handling Request object through introducing a custom ArgumentResolver.

Create a Body Attribute.

#[Attribute(Attribute::TARGET_PARAMETER)]
final class Body
{
}
Enter fullscreen mode Exit fullscreen mode

Then create a BodyValueResolver.

class BodyValueResolver implements ArgumentValueResolverInterface, LoggerAwareInterface
{
    public function __construct(private SerializerInterface $serializer)
    {
    }

    private LoggerInterface $logger;

    /**
     * @inheritDoc
     */
    public function resolve(Request $request, ArgumentMetadata $argument)
    {
        $type = $argument->getType();
        $this->logger->debug("The argument type:'" . $type . "'");
        $format = $request->getContentType() ?? 'json';
        $this->logger->debug("The request format:'" . $format . "'");

        //read request body
        $content = $request->getContent();
        $data = $this->serializer->deserialize($content, $type, $format);
       // $this->logger->debug("deserialized data:{0}", [$data]);
        yield $data;
    }

    /**
     * @inheritDoc
     */
    public function supports(Request $request, ArgumentMetadata $argument): bool
    {
        $attrs = $argument->getAttributes(Body::class);
        return count($attrs) > 0;
    }

    public function setLogger(LoggerInterface $logger)
    {
        $this->logger = $logger;
    }

Enter fullscreen mode Exit fullscreen mode

In the supports method, it simply detects if the method argument annotated with a Body attribute, then apply resolve method to deserialize the request body content to a typed object.

Run the application and test the endpoint through /posts.

curl -v http://localhost:8000/posts -H "Content-Type:application/json" -d "{\"title\":\"test title\",\"content\":\"test content\"}"
> POST /posts HTTP/1.1
> Host: localhost:8000
> User-Agent: curl/7.55.1
> Accept: */*
> Content-Type:application/json
> Content-Length: 47
>
< HTTP/1.1 201 Created
< Cache-Control: no-cache, private
< Content-Type: application/json
< Date: Sun, 21 Nov 2021 08:42:49 GMT
< Location: /posts/1ec4aa70-1b21-6bce-93f8-b39330fe328e
< X-Powered-By: PHP/8.0.10
< X-Robots-Tag: noindex
< Content-Length: 2
<
[]
Enter fullscreen mode Exit fullscreen mode

Exception Handling

Symfony kernel provides a event machoism to raise an Exception in Controller class and handle them in your custom EventListener or EventSubscriber .

For example, create a PostNotFoundException.

class PostNotFoundException extends \RuntimeException
{

    public function __construct(Uuid $uuid)
    {
        parent::__construct("Post #" . $uuid . " was not found");
    }

}
Enter fullscreen mode Exit fullscreen mode

Create a EventListener to catch this exception, and handle the exception as expected.

class ExceptionListener implements LoggerAwareInterface
{
    private LoggerInterface $logger;

    public function __construct()
    {
    }

    public function onKernelException(ExceptionEvent $event)
    {
        // You get the exception object from the received event
        $exception = $event->getThrowable();
        $data = ["error" => $exception->getMessage()];

        // Customize your response object to display the exception details
        $response = new JsonResponse($data);

        // HttpExceptionInterface is a special type of exception that
        // holds status code and header details

        if ($exception instanceof PostNotFoundException) {
            $response->setStatusCode(Response::HTTP_NOT_FOUND);
        } else if ($exception instanceof HttpExceptionInterface) {
            $response->setStatusCode($exception->getStatusCode());
            $response->headers->replace($exception->getHeaders());
        } else {
            $response->setStatusCode(Response::HTTP_INTERNAL_SERVER_ERROR);
        }

        // sends the modified response object to the event
        $event->setResponse($response);
    }

    public function setLogger(LoggerInterface $logger)
    {
        $this->logger = $logger;
    }
}
Enter fullscreen mode Exit fullscreen mode

Register this ExceptionListener in config/service.yml file.

  App\EventListener\ExceptionListener:
    tags:
      - { name: kernel.event_listener, event: kernel.exception, priority: 50 }
Enter fullscreen mode Exit fullscreen mode

It indicates it binds event.exception event to ExceptionListener, and set priority to set the order at execution time.

Run the following command to show all registered EventListener/EventSubscribers on event kernel.exception.

php bin/console debug:event-subscriber kernel.exception
Enter fullscreen mode Exit fullscreen mode

Change the getById function to the following.

#[Route(path: "/{id}", name: "byId", methods: ["GET"])]
function getById(Uuid $id): Response
{
    $data = $this->posts->findOneBy(["id" => $id]);
    if ($data) {
        return $this->json($data);
    } else {
        throw new PostNotFoundException($id);
    }
}
Enter fullscreen mode Exit fullscreen mode

Add a test to verify if the post is not found and get a 404 status code.

public function testGetANoneExistingPost(): void
{
    $client = static::createClient();
    $id = Uuid::v4();
    $crawler = $client->request('GET', '/posts/' . $id);

    //
    $response = $client->getResponse();
    $this->assertResponseStatusCodeSame(404);
    $data = $response->getContent();
    $this->assertStringContainsString("Post #" . $id . " was not found", $data);
}
Enter fullscreen mode Exit fullscreen mode

Run the application again, and try to access a single Post through a none existing id.

curl http://localhost:8000/posts/1ec3e1e0-17b3-6ed2-a01c-edecc112b438 -H "Accept: application/json" -v
> GET /posts/1ec3e1e0-17b3-6ed2-a01c-edecc112b438 HTTP/1.1
> Host: localhost:8000
> User-Agent: curl/7.55.1
> Accept: application/json
>
< HTTP/1.1 404 Not Found
< Cache-Control: no-cache, private
< Content-Type: application/json
< Date: Mon, 22 Nov 2021 03:57:51 GMT
< X-Powered-By: PHP/8.0.10
< X-Robots-Tag: noindex
< Content-Length: 69
<
{"error":"Post #1ec3e1e0-17b3-6ed2-a01c-edecc112b438 was not found."}
Enter fullscreen mode Exit fullscreen mode

Get the complete source codes from my Github.

Discussion (2)

Collapse
okmarq profile image
Joel Okoromi

This would have made more sense if you had indicated which goes where. A waste of time really.

Collapse
napestershine profile image
napestershine

Thanks for this nice article.