A MVP for a RESTful Service API in Symfony4

Introduction

In this post I will present a very simple RESTful service API, written in Symfony & Doctrine, SQLite will be the database, POSTMAN is our favorite client.

What would be the minimum viable product for this? Let’s consider the simplest logging service. Which things would this service expose to the world? I see:

  • A service showing the complete list of log entries.
  • A service to add an entry to the log.
  • A service to clear the log.

Given we use a server on dev.internal on port 8090, an API could look like this:

GET dev.internal:8090/log
POST dev.internal:8090/log
DELETE dev.internal:8090/log

The services should rely on JSON, thus GET would reply with a JSON array of log entries, DELETE would reply with a confirmation that everything is purged, and POST should reply with the ID of the newly created entry.

POST is different from the other services, since we should place JSON data in the message body. Like this:

{
    "content": "Message Body"
}

A simple log entry would consist of an ID, a time stamp (as a string to avoid conversion problems…) and a string as the content body.

The POSTMAN API is published here:

https://documenter.getpostman.com/view/7223799/S1TVWxZo?version=latest

Tools

  • OS: MacOs 10.14.5 (Mojave)
  • Webserver: Apache2, configured to use virtual hosts.
  • Editor: Sublime
  • Client: Postman
  • Backend framework: Symfony 4.2.9 with Doctrine
  • DB Browser for SQLite

Ad Fontes

Create the environment

Pre-requisites: a working composer installation, a working apache server with virtual hosts configured as seen above.

cd /Library/WebServer/Documents/symf
composer create-project symfony/website-skeleton demo
cd demo

We now should change the .env file so it points to the sqlite file instead of a MySQL installation. So open the file, change the DATABASE_URL line to the following line:

DATABASE_URL=sqlite:///%kernel.project_dir%/var/data.db

After this has changed, we can create the database and the log entity (for the sake of simplicity we give everybody all rights to access the file (this is not a good idea in production ideas…):

php bin/console doctrine:database:create
chmod 77777 var/data.db

Create the Entity

When the database has been created we can create the log object:

php bin/console make:entity
php bin/console make:migration
php bin/console doctrine:migrations:migrate

Within the make:entity part we assume you created a mandatory ts field of type String(30), and a mandatory content field of type String(1000).

Please check in DB Browser if everything is there.

After this looks OK, please open the file src/Entity/Log.php and check the Log class. It contains the ordinary getter/setter functions, but at the moment no constructor. We will now add it (and change the setTs() accordingly), so that it does something useful:

function __construct($content) {
    $this->content = $content;
    $this->setTs();
}
public function setTs(): self
{
    $this->ts = date('Y-m-d H:i:s');
    return $this;
}

Create the Controller

A very simple controller could look like the following:

<?php

namespace App\Controller;

use App\Entity\Log;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\Routing\Annotation\Route;

class LogController extends AbstractController {

    /**
     * @Route("/log", name="log_list", methods={"GET"})
    */
    public function index() {}

    /**
    * @Route("/log", name="log_purge", methods={"DELETE"})
    */
    public function purge() {}

    /**
    * @Route("/log", name="log_new", methods={"POST"})
    */
    public function newLog() {}

So, let’s create it and store the file under src/Controller/LogController.php

Does it work? No. A routing should return a response object, which we don’t. Since we want to respond with a JSON object, we shall make use of the JsonResponse object, thus add a use statement…

use Symfony\Component\HttpFoundation\JsonResponse;

…and then actually use it to create the correct response (in this case, a static JSON message, please add the similar two lines to all routing functions):

public function index() { 
   $return = JsonResponse::fromJsonString(
     "{'status': 'GET request handled!'");
  return $response;
}

If you now enter http://demo.internal:8090/log into your browser, you should see this message, because your browser will default to the GET method.

The GET Method

 /**
 * @Route("/log", name="log_list", methods={"GET"})
*/
public function index() {
    $repository = $this->getDoctrine()->getRepository(Log::class);
    $list = $repository->findAll();
    $serializer = new Serializer(
       [new ObjectNormalizer()], [new JsonEncoder()]
    );
    $response = JsonResponse::fromJsonString(
        $serializer->serialize($list, 'json')
    );
    return $response;
}

To give you some hints on these lines:

  • the Doctrine repository is the connection to the entity “Log”.
  • It has the findAll() method which returns an array of Log objects.
  • This array will be transformed to a JSON array and returned as a JsonResonse.

By the way: Some use statement are necessary, these will be shown in the complete class code found below.

The POST Method

/**
 * @Route("/log", name="log_new", methods={"POST"})
 */
public function newLog() {
  $request = Request::createFromGLobals();
  $content = $request->getContent();
  $arr = json_decode($content, TRUE);
  $object = new Log($arr['content']);
  $entityManager = $this->getDoctrine()->getManager();
  $entityManager->persist($object);
  $entityManager->flush();
  $id = $object->getId();
  $response = JsonResponse::fromJsonString(
            "{'status': 'ADD OK', 'num': " . $id . "}");
  return $response;
}

To give you some hints on these lines:

  • We need to get the body of the request, since this will contain the Log content.
  • This will be transformed to a PHP array and pushed into the new Log entity.
  • The object will be persisted, and the last ID will be fetched.
  • This ID will then be sent as a JSON message.

The DELETE Method

/**
 * @Route("/log", name="log_purge", methods={"DELETE"})
 */
 public function purge() {
 $entityManager = $this->getDoctrine()->getManager();
 $query = $entityManager->createQuery(
     'delete from App\Entity\Log');
 $nmDeleted = $query->execute();
 $entityManager->flush();
 $response = JsonResponse::fromJsonString(
      "{'status': 'PURGE OK', 'num': " . $numDeleted . "}");
 return $response;
 }

This does the following:

  • We get the entity manager.
  • We formulate a DQL request to delete every existing Log entity.
  • Execute the DQL.
  • Return a simple JSON response containing the number of deleted records.

The Complete LogController.php

<?php

namespace App\Controller;

use App\Entity\Log;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\Routing\Annotation\Route;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Serializer\Serializer;
use Symfony\Component\Serializer\Normalizer\ObjectNormalizer;
use Symfony\Component\Serializer\Encoder\JsonEncoder;

class LogController extends AbstractController {
    /**
     * @Route("/log", name="log_list", methods={"GET"})
    */
    public function index() {
        $repository = 
          $this->getDoctrine()->getRepository(Log::class);
        $list = $repository->findAll();
        $serializer = new Serializer(
          [new ObjectNormalizer()], [new JsonEncoder()]);
        $response = JsonResponse::fromJsonString($serializer->serialize($list, 'json'));
        return $response;
    }

/**
 * @Route("/log", name="log_purge", methods={"DELETE"})
*/
public function purge() {
    $entityManager = $this->getDoctrine()->getManager();
    $query = $entityManager->createQuery(
      'delete from App\Entity\Log');
    $numDeleted = $query->execute();
    $entityManager->flush();
    $response = JsonResponse::fromJsonString("{'status': 'PURGE OK', 'num': " . $numDeleted . "}");
    return $response;
}

/**
 * @Route("/log", name="log_new", methods={"POST"})
*/
public function newLog() {
    $request = Request::createFromGLobals();
    $content = $request->getContent();
    $arr = json_decode($content, TRUE);
    $object = new Log($arr['content']);
    $entityManager = $this->getDoctrine()->getManager();
    $entityManager->persist($object);
    $entityManager->flush();
    $id = $object->getId();
    $response = JsonResponse::fromJsonString("{'status': 'ADD OK', 'num': " . $id . "}");
    return $response;
}

}