Files
chill-bundles/docs/source/development/api.md

23 KiB

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:

Autoloading 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 in 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: api_full_configuration):

# 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

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 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:

   # config/packages/chill_main.yaml`
   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[^/]++)/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 adding a scope by his id and deleting 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, multiple 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