API

Chill provides a basic framework to build REST api.

Basic configuration

Configure a route

Follow those steps to build a REST api:

  1. Create your model;
  2. Configure the API;

You can also:

  • hook into the controller to customize some steps;
  • add more route and steps

Auto-loading the routes

Ensure that those lines are present in your file app/config/routing.yml:

chill_cruds:
    resource: 'chill_main_crud_route_loader:load'
    type: service

Create your model

Create your model on the usual way:

namespace Chill\PersonBundle\Entity\AccompanyingPeriod;

use Chill\PersonBundle\Entity\AccompanyingPeriod\OriginRepository;
use Doctrine\ORM\Mapping as ORM;

/**
 * @ORM\Entity(repositoryClass=OriginRepository::class)
 * @ORM\Table(name="chill_person_accompanying_period_origin")
 */
class Origin
{
    /**
     * @ORM\Id
     * @ORM\GeneratedValue
     * @ORM\Column(type="integer")
     */
    private $id;

    /**
     * @ORM\Column(type="json")
     */
    private $label;

    /**
     * @ORM\Column(type="date_immutable", nullable=true)
     */
    private $noActiveAfter;

    // .. getters and setters

}

Configure api

Configure the api using Yaml (see the full configuration: Full configuration example):

# config/packages/chill_main.yaml
chill_main:
    apis:
        accompanying_period_origin:
            base_path: '/api/1.0/person/accompanying-period/origin'
            class: 'Chill\PersonBundle\Entity\AccompanyingPeriod\Origin'
            name: accompanying_period_origin
            base_role: 'ROLE_USER'
            actions:
                _index:
                    methods:
                        GET: true
                        HEAD: true
                _entity:
                    methods:
                        GET: true
                        HEAD: true

Note

If you are working on a shared bundle (aka “The chill bundles”), you should define your configuration inside the class ChillXXXXBundleExtension, using the “prependConfig” feature:

namespace Chill\PersonBundle\DependencyInjection;

use Symfony\Component\DependencyInjection\ContainerBuilder;
use Symfony\Component\DependencyInjection\Extension\PrependExtensionInterface;
use Symfony\Component\HttpFoundation\Request;

/**
 * Class ChillPersonExtension
 * Loads and manages your bundle configuration
 *
 * To learn more see {@link http://symfony.com/doc/current/cookbook/bundles/extension.html}
 * @package Chill\PersonBundle\DependencyInjection
 */
class ChillPersonExtension extends Extension implements PrependExtensionInterface
{
    public function prepend(ContainerBuilder $container)
    {
        $this->prependCruds($container);
    }

    /**
     * @param ContainerBuilder $container
     */
    protected function prependCruds(ContainerBuilder $container)
    {
        $container->prependExtensionConfig('chill_main', [
            'apis' => [
                [
                    'class' => \Chill\PersonBundle\Entity\AccompanyingPeriod\Origin::class,
                    'name' => 'accompanying_period_origin',
                    'base_path' => '/api/1.0/person/accompanying-period/origin',
                    'controller' => \Chill\PersonBundle\Controller\OpeningApiController::class,
                    'base_role' => 'ROLE_USER',
                    'actions' => [
                        '_index' => [
                            'methods' => [
                                Request::METHOD_GET => true,
                                Request::METHOD_HEAD => true
                            ],
                        ],
                        '_entity' => [
                            'methods' => [
                                Request::METHOD_GET => true,
                                Request::METHOD_HEAD => true
                            ]
                        ],
                    ]
                ]
            ]
        ]);
    }
}

The _index and _entity action

The _index and _entity action are default actions:

  • they will call a specific method in the default controller;
  • they will generate defined routes:
Index:

Name: chill_api_single_accompanying_period_origin__index

Path: /api/1.0/person/accompanying-period/origin.{_format}

Entity:

Name: chill_api_single_accompanying_period_origin__entity

Path: /api/1.0/person/accompanying-period/origin/{id}.{_format}

Role

By default, the key base_role is used to check ACL. Take care of creating the Voter required to take that into account.

For index action, the role will be called with NULL as $subject. The retrieved entity will be the subject for single queries.

You can also define a role for each method. In this case, this role is used for the given method, and, if any, the base role is taken into account.

# config/packages/chill_main.yaml
chill_main:
    apis:
        accompanying_period_origin:
            base_path: '/api/1.0/person/bla/bla'
            class: 'Chill\PersonBundle\Entity\Blah'
            name: bla
            actions:
                _entity:
                    methods:
                        GET: true
                        HEAD: true
                    roles:
                        GET: MY_ROLE_SEE
                        HEAD: MY ROLE_SEE

Customize the controller

You can customize the controller by hooking into the default actions. Take care of extending Chill\MainBundle\CRUD\Controller\ApiController.

namespace Chill\PersonBundle\Controller;

use Chill\MainBundle\CRUD\Controller\ApiController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;

class OpeningApiController extends ApiController
{
    protected function customizeQuery(string $action, Request $request, $qb): void
    {
        $qb->where($qb->expr()->gt('e.noActiveAfter', ':now'))
            ->orWhere($qb->expr()->isNull('e.noActiveAfter'));
        $qb->setParameter('now', new \DateTime('now'));
    }
}

And set your controller in configuration:

chill_main:
    apis:
        accompanying_period_origin:
            base_path: '/api/1.0/person/accompanying-period/origin'
            class: 'Chill\PersonBundle\Entity\AccompanyingPeriod\Origin'
            name: accompanying_period_origin
            # add a controller
            controller: 'Chill\PersonBundle\Controller\OpeningApiController'
            base_role: 'ROLE_USER'
            actions:
                _index:
                    methods:
                        GET: true
                        HEAD: true
                _entity:
                    methods:
                        GET: true
                        HEAD: true

Create your own actions

You can add your own actions:

chill_main:
    apis:
        -
            class: Chill\PersonBundle\Entity\AccompanyingPeriod
            name: accompanying_course
            base_path: /api/1.0/person/accompanying-course
            controller: Chill\PersonBundle\Controller\AccompanyingCourseApiController
            actions:
                # add a custom participation:
                participation:
                    methods:
                        POST: true
                        DELETE: true
                        GET: false
                        HEAD: false
                        PUT: false
                    roles:
                        POST: CHILL_PERSON_ACCOMPANYING_PERIOD_SEE
                        DELETE: CHILL_PERSON_ACCOMPANYING_PERIOD_SEE
                        GET: null
                        HEAD: null
                        PUT: null
                    single-collection: single

The key single-collection with value single will add a /{id}/ + "action name" (in this example, /{id}/participation) into the path, after the base path. If the value is collection, no id will be set, but the action name will be append to the path.

Then, create the corresponding action into your controller:

namespace Chill\PersonBundle\Controller;

use Chill\MainBundle\CRUD\Controller\ApiController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Chill\PersonBundle\Entity\AccompanyingPeriod;
use Symfony\Component\HttpFoundation\Exception\BadRequestException;
use Symfony\Component\EventDispatcher\EventDispatcherInterface;
use Symfony\Component\Validator\Validator\ValidatorInterface;
use Chill\PersonBundle\Privacy\AccompanyingPeriodPrivacyEvent;
use Chill\PersonBundle\Entity\Person;

class AccompanyingCourseApiController extends ApiController
{
    protected EventDispatcherInterface $eventDispatcher;

    protected ValidatorInterface $validator;

    public function __construct(EventDispatcherInterface $eventDispatcher, $validator)
    {
        $this->eventDispatcher = $eventDispatcher;
        $this->validator = $validator;
    }

    public function participationApi($id, Request $request, $_format)
    {
        /** @var AccompanyingPeriod $accompanyingPeriod */
        $accompanyingPeriod = $this->getEntity('participation', $id, $request);
        $person = $this->getSerializer()
            ->deserialize($request->getContent(), Person::class, $_format, []);

        if (NULL === $person) {
            throw new BadRequestException('person id not found');
        }

        $this->onPostCheckACL('participation', $request, $accompanyingPeriod, $_format);

        switch ($request->getMethod()) {
            case Request::METHOD_POST:
                $participation = $accompanyingPeriod->addPerson($person);
                break;
            case Request::METHOD_DELETE:
                $participation = $accompanyingPeriod->removePerson($person);
                break;
            default:
                throw new BadRequestException("This method is not supported");
        }

        $errors = $this->validator->validate($accompanyingPeriod);

        if ($errors->count() > 0) {
            // only format accepted
            return $this->json($errors);
        }

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

        return $this->json($participation);
    }
}

Managing association

ManyToOne association

In ManyToOne association, you can add associated entities using the PATCH request. By default, the serializer deserialize entities only with their id and discriminator type, if any.

Example:

curl -X 'PATCH' \
  'http://localhost:8001/api/1.0/person/accompanying-course/2668.json' \
  -H 'accept: */*' \
  -H 'Content-Type: application/json' \
   # see the data sent to the server: \
  -d '{
  "type": "accompanying_period",
  "id": 2668,
  "origin": { "id": 11 }
}'

ManyToMany associations

In OneToMany association, you can easily create route for adding and removing entities, using POST and DELETE requests.

Prepare your entity, creating the methods addYourEntity and removeYourEntity:

namespace Chill\PersonBundle\Entity;

use Chill\MainBundle\Entity\Scope;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Serializer\Annotation\Groups;
use Symfony\Component\Serializer\Annotation\DiscriminatorMap;

/**
 * AccompanyingPeriod Class
 *
 * @ORM\Entity
 * @ORM\Table(name="chill_person_accompanying_period")
 * @DiscriminatorMap(typeProperty="type", mapping={
 *  "accompanying_period"=AccompanyingPeriod::class
 *  })
 */
class AccompanyingPeriod
{
    /**
     * @var Collection
     * @ORM\ManyToMany(
     *     targetEntity=Scope::class,
     *     cascade={}
     *     )
     * @Groups({"read"})
     */
    private $scopes;

    public function addScope(Scope $scope): self
    {
        $this->scopes[] = $scope;

        return $this;
    }

    public function removeScope(Scope $scope): void
    {
        $this->scopes->removeElement($scope);
    }

Create your route into the configuration:

chill_main:
    apis:
        -
            class: Chill\PersonBundle\Entity\AccompanyingPeriod
            name: accompanying_course
            base_path: /api/1.0/person/accompanying-course
            controller: Chill\PersonBundle\Controller\AccompanyingCourseApiController
            actions:
                scope:
                    methods:
                        POST: true
                        DELETE: true
                        GET: false
                        HEAD: false
                        PUT: false
                        PATCH: false
                    roles:
                        POST: CHILL_PERSON_ACCOMPANYING_PERIOD_SEE
                        DELETE: CHILL_PERSON_ACCOMPANYING_PERIOD_SEE
                        GET: null
                        HEAD: null
                        PUT: null
                        PATCH: null
                    controller_action: null
                    path: null
                    single-collection: single

This will create a new route, which will accept two methods: DELETE and POST:

+--------------+---------------------------------------------------------------------------------------+
| Property     | Value                                                                                 |
+--------------+---------------------------------------------------------------------------------------+
| Route Name   | chill_api_single_accompanying_course_scope                                            |
| Path         | /api/1.0/person/accompanying-course/{id}/scope.{_format}                              |
| Path Regex   | {^/api/1\.0/person/accompanying\-course/(?P<id>[^/]++)/scope\.(?P<_format>[^/]++)$}sD |
| Host         | ANY                                                                                   |
| Host Regex   |                                                                                       |
| Scheme       | ANY                                                                                   |
| Method       | POST|DELETE                                                                           |
| Requirements | {id}: \d+                                                                             |
| Class        | Symfony\Component\Routing\Route                                                       |
| Defaults     | _controller: csapi_accompanying_course_controller:scopeApi                            |
| Options      | compiler_class: Symfony\Component\Routing\RouteCompiler                               |
+--------------+---------------------------------------------------------------------------------------+

Then, create the controller action. Call the method:

namespace Chill\PersonBundle\Controller;

use Chill\MainBundle\CRUD\Controller\ApiController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Chill\MainBundle\Entity\Scope;

class MyController extends ApiController
{
    public function scopeApi($id, Request $request, string $_format): Response
    {
        return $this->addRemoveSomething('scope', $id, $request, $_format, 'scope', Scope::class, [ 'groups' => [ 'read' ] ]);
    }
}

This will allow to add a scope by his id, and delete them.

Curl requests:

# add a scope with id 5
curl -X 'POST' \
  'http://localhost:8001/api/1.0/person/accompanying-course/2868/scope.json' \
  -H 'accept: */*' \
  -H 'Content-Type: application/json' \
  -d '{
  "type": "scope",
  "id": 5
}'

# remove a scope with id 5
curl -X 'DELETE' \
  'http://localhost:8001/api/1.0/person/accompanying-course/2868/scope.json' \
  -H 'accept: */*' \
  -H 'Content-Type: application/json' \
  -d '{
  "id": 5,
  "type": "scope"
}'

Deserializing an association where multiple types are allowed

Sometimes, multiples types are allowed as association to one entity:

namespace Chill\PersonBundle\Entity\AccompanyingPeriod;

use Chill\PersonBundle\Entity\Person;
use Chill\ThirdPartyBundle\Entity\ThirdParty;
use Doctrine\ORM\Mapping as ORM;

class Resource
{


    /**
     * @ORM\ManyToOne(targetEntity=ThirdParty::class)
     * @ORM\JoinColumn(nullable=true)
     */
    private $thirdParty;

    /**
     * @ORM\ManyToOne(targetEntity=Person::class)
     * @ORM\JoinColumn(nullable=true)
     */
    private $person;


    /**
     *
     * @param $resource Person|ThirdParty
     */
    public function setResource($resource): self
    {
       // ...
    }


    /**
     * @return ThirdParty|Person
     * @Groups({"read", "write"})
     */
    public function getResource()
    {
        return $this->person ?? $this->thirdParty;
    }
}

This is not well taken into account by the Symfony serializer natively.

You must, then, create your own CustomNormalizer. You can help yourself using this:

namespace Chill\PersonBundle\Serializer\Normalizer;

use Chill\PersonBundle\Entity\Person;
use Chill\ThirdPartyBundle\Entity\ThirdParty;
use Chill\PersonBundle\Entity\AccompanyingPeriod\Resource;
use Chill\PersonBundle\Repository\AccompanyingPeriod\ResourceRepository;
use Symfony\Component\Serializer\Normalizer\DenormalizerInterface;
use Symfony\Component\Serializer\Normalizer\DenormalizerAwareInterface;
use Symfony\Component\Serializer\Normalizer\DenormalizerAwareTrait;
use Symfony\Component\Serializer\Normalizer\ObjectToPopulateTrait;
use Symfony\Component\Serializer\Exception;
use Chill\MainBundle\Serializer\Normalizer\DiscriminatedObjectDenormalizer;


class AccompanyingPeriodResourceNormalizer implements DenormalizerInterface, DenormalizerAwareInterface
{
    use DenormalizerAwareTrait;
    use ObjectToPopulateTrait;

    public function __construct(ResourceRepository $repository)
    {
        $this->repository = $repository;
    }

    public function denormalize($data, string $type, string $format = null, array $context = [])
    {
        // .. snipped for brevity

        if ($resource === NULL) {
            $resource = new Resource();
        }

        if (\array_key_exists('resource', $data)) {
            $res = $this->denormalizer->denormalize(
                $data['resource'],
                // call for a "multiple type"
                DiscriminatedObjectDenormalizer::TYPE,
                $format,
                // into the context, we add the list of allowed types:
                [
                    DiscriminatedObjectDenormalizer::ALLOWED_TYPES =>
                    [
                        Person::class, ThirdParty::class
                    ]
                ]
            );

            $resource->setResource($res);
        }

        return $resource;
    }


    public function supportsDenormalization($data, string $type, string $format = null)
    {
        return $type === Resource::class;
    }
}

Serialization for collection

A specific model has been defined for returning collection:

{
    "count": 49,
    "results": [
    ],
    "pagination": {
        "more": true,
        "next": "/api/1.0/search.json&q=xxxx......&page=2",
        "previous": null,
        "first": 0,
        "items_per_page": 1
    }
}

Where this is relevant, this model should be re-used in custom controller actions.

In custom actions, this can be achieved quickly by assembling results into a Chill\MainBundle\Serializer\Model\Collection. The pagination information is given by using Paginator (see Pagination).

use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Chill\MainBundle\Pagination\PaginatorInterface;

class MyController extends AbstractController
{

    protected function serializeCollection(PaginatorInterface $paginator, $entities): Response
    {
        $model = new Collection($entities, $paginator);

        return $this->json($model, Response::HTTP_OK, [], $context);
    }
}

Full configuration example

apis:
    -
        class: Chill\PersonBundle\Entity\AccompanyingPeriod
        name: accompanying_course
        base_path: /api/1.0/person/accompanying-course
        controller: Chill\PersonBundle\Controller\AccompanyingCourseApiController
        actions:
            _entity:
                roles:
                    GET: CHILL_PERSON_ACCOMPANYING_PERIOD_SEE
                    HEAD: null
                    POST: null
                    DELETE: null
                    PUT: null
                controller_action: null
                path: null
                single-collection: single
                methods:
                    GET: true
                    HEAD: true
                    POST: false
                    DELETE: false
                    PUT: false
            participation:
                methods:
                    POST: true
                    DELETE: true
                    GET: false
                    HEAD: false
                    PUT: false
                roles:
                    POST: CHILL_PERSON_ACCOMPANYING_PERIOD_SEE
                    DELETE: CHILL_PERSON_ACCOMPANYING_PERIOD_SEE
                    GET: null
                    HEAD: null
                    PUT: null
                controller_action: null
                # the requirements for the route. Will be set to `[ 'id' => '\d+' ]` if left empty.
                requirements:         []
                path: null
                single-collection: single
        base_role: null