Abstract controller for a REST-based CRUD server

About

A common task for a REST server is to expose a CRUD (Create, Read, Update and Delete actions) interface for each of its entities.

For each entity, the server should be able to:

  • return a list of entities;
  • return a single entity given its ID;
  • create a new entity
  • update an existing entity
  • delete an existing entity

Those tasks are the same for each entity, so I think it's better to create an abstract controller with all those basic tasks and then just use it to create each single entity-oriented controller.

The code

<?php

namespace YourName\YourBundle\Controller;

use Symfony\Bundle\FrameworkBundle\Controller\Controller;
use Symfony\Component\HttpFoundation\JsonResponse;
use Doctrine\ORM\Query;
use Doctrine\ORM\NoResultException;

/**
 * Each entity controller must extends this class.
 * 
 * @abstract
 */
abstract class BaseApiController extends Controller {

    /**
     * This method should return the entity's repository.
     * 
     * @abstract
     * @return EntityRepository
     */
    abstract function getRepository();

    /**
     * This method should return a new entity instance to be used for the "create" action.
     * 
     * @abstract
     * @return Object
     */
    abstract function getNewEntity();

    /**
     * Base "list" action.
     * 
     * @return JsonResponse
     */
    protected function listAction() {
        $list = $this->getRepository()
            ->createQueryBuilder('e')
            ->getQuery()->getResult(Query::HYDRATE_ARRAY);

        return new JsonResponse($list);
    }

    /**
     * Base "read" action.
     * 
     * @param int $id
     * @return JsonResponse|NotFoundHttpException
     */
    protected function readAction($id) {
        $entityInstance = $this->getEntityForJson($id);
        if (false === $entityInstance) {
            return $this->createNotFoundException();
        }

        return new JsonResponse($entityInstance);
    }

    /**
     * Base "create" action.
     * 
     * @return JsonResponse|NotFoundHttpException
     */
    protected function createAction() {
        $json = $this->getJsonFromRequest();
        if (false === $json) {
            throw new \Exception('Invalid JSON');
        }

        $object = $this->updateEntity($this->getNewEntity(), $json);
        if (false === $object) {
            throw new \Exception('Unable to create the entity');
        }

        $em = $this->getDoctrine()->getManager();
        $em->persist($object);
        $em->flush();

        return new JsonResponse($this->getEntityForJson($object->getId()));
    }

    /**
     * Base "upload" action.
     * 
     * @return JsonResponse|NotFoundHttpException
     */
    protected function updateAction($id) {
        $object = $this->getEntity($id);
        if (false === $object) {
            return $this->createNotFoundException();
        }

        $json = $this->getJsonFromRequest();
        if (false === $json) {
            throw new \Exception('Invalid JSON');
        }

        if (false === $this->updateEntity($object, $json)) {
            throw new \Exception('Unable to update the entity');
        }

        $this->getDoctrine()->getManager()->flush($object);

        return new JsonResponse($this->getEntityForJson($object->getId()));
    }

    /**
     * Base "delete" action.
     * 
     * @return JsonResponse|NotFoundHttpException
     */
    protected function deleteAction($id) {
        $object = $this->getEntity($id);
        if (false === $object) {
            return $this->createNotFoundException();
        }

        $em = $this->getDoctrine()->getManager();
        $em->remove($object);
        $em->flush();

        return new JsonResponse(array());
    }

    /**
     * Returns an entity from its ID, or FALSE in case of error.
     * 
     * @param int $id
     * @return Object|boolean
     */
    protected function getEntity($id) {
        try {
            return $this->getRepository()->find($id);
        }
        catch (NoResultException $ex) {
            return false;
        }

        return false;
    }

    /**
     * Returns an entity from its ID as an associative array, or FALSE in case of error.
     * 
     * @param int $id
     * @return array|boolean
     */
    protected function getEntityForJson($id) {
        try {
            return $this->getRepository()->createQueryBuilder('e')
                            ->where('e.id = :id')
                            ->setParameter('id', $id)
                            ->getQuery()->getSingleResult(Query::HYDRATE_ARRAY);
        }
        catch (NoResultException $ex) {
            return false;
        }

        return false;
    }

    /**
     * Returns the request's JSON content, or FALSE in case of error.
     * 
     * @return string|boolean
     */
    protected function getJsonFromRequest() {
        $json = $this->get("request")->getContent();
        if (!$json) {
            return false;
        }

        return $json;
    }

    /**
     * Updates an entity with data from a JSON string.
     * Returns the entity, or FALSE in case of error.
     * 
     * @param Object $entity
     * @param string $json
     * @return Object|boolean
     */
    protected function updateEntity($entity, $json) {
        $data = json_decode($json);
        if ($data == null) {
            return false;
        }

        foreach ($data as $name => $value) {
            if ($name != 'id') {
                $setter = 'set' . ucfirst($name);
                if (method_exists($entity, $setter)) {
                    call_user_func_array(array($entity, $setter), array($value));
                }
            }
        }

        return $entity;
    }
}

How to use it

This is pretty simple:

  1. Create your entity controller, extending the abstract BaseApiController class
  2. Define the two abstract methods: getRepository() and getNewEntity()
  3. Override the five actions methods: listAction(), readAction(), createAction(), updateAction(), deleteAction()
  4. Define the routing (yaml file or annotations, your choice)
  5. Use it!

A simple example

In this example I'm creating the "Event" entity controller, setting up the routing by using annotations.

<?php

namespace YourName\YourBundle\Controller;

use Sensio\Bundle\FrameworkExtraBundle\Configuration\Route;
use Sensio\Bundle\FrameworkExtraBundle\Configuration\Method;
use AnotherName\AnotherBundle\Entity\Event;

/**
 * @Route("/events")
 */
class EventsApiController extends BaseApiController
{
    /**
     * @Route("", name="api_events")
     * @Method({"GET"})
     */
    public function listAction()
    {
        return parent::listAction();
    }

    /**
     * @Route("/{id}", name="api_events_read")
     * @Method({"GET"})
     */
    public function readAction($id)
    {
        return parent::readAction($id);
    }

    /**
     * @Route("", name="api_events_create")
     * @Method({"POST"})
     */
    public function createAction()
    {
        return parent::createAction();
    }

    /**
     * @see BaseApiController::getRepository()
     * @return EntityRepository
     */
    public function getRepository()
    {
        return $this->getDoctrine()->getManager()->getRepository('AnotherNameAnotherBundle:Event');
    }

    /**
     * @see BaseApiController::getNewEntity()
     * @return Object
     */
    public function getNewEntity()
    {
        return new Event();
    }
}

Todo

  • Set a routing prefix for the REST bundle (like "api" or "rest" or whatever you like)
  • Add validation during the "create" and the "update" actions
  • Secure the bundle (if needed)