.. Copyright (C) 2014 Champs Libres Cooperative SCRLFS Permission is granted to copy, distribute and/or modify this document under the terms of the GNU Free Documentation License, Version 1.3 or any later version published by the Free Software Foundation; with no Invariant Sections, no Front-Cover Texts, and no Back-Cover Texts. A copy of the license is included in the section entitled "GNU Free Documentation License". .. _api: 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 .. note:: Useful links: * `How to use annotation to configure serialization `_ * `How to create your custom normalizer `_ Auto-loading the routes ======================= Ensure that those lines are present in your file `app/config/routing.yml`: .. code-block:: yaml chill_cruds: resource: 'chill_main_crud_route_loader:load' type: service Create your model ================= Create your model on the usual way: .. code-block:: php 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: :ref:`api_full_configuration`): .. code-block:: yaml # 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 :code:`ChillXXXXBundleExtension`, using the "prependConfig" feature: .. code-block:: php 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 :code:`_index` and :code:`_entity` action ********************************************* The :code:`_index` and :code:`_entity` action are default actions: * they will call a specific method in the default controller; * they will generate defined routes: Index: Name: :code:`chill_api_single_accompanying_period_origin__index` Path: :code:`/api/1.0/person/accompanying-period/origin.{_format}` Entity: Name: :code:`chill_api_single_accompanying_period_origin__entity` Path: :code:`/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 :code:`Voter` required to take that into account. For index action, the role will be called with :code:`NULL` as :code:`$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. .. code-block:: yaml # 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 :code:`Chill\MainBundle\CRUD\Controller\ApiController`. .. code-block:: php 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: .. code-block:: 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 # 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: .. code-block:: 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: # 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 :code:`single-collection` with value :code:`single` will add a :code:`/{id}/ + "action name"` (in this example, :code:`/{id}/participation`) into the path, after the base path. If the value is :code:`collection`, no id will be set, but the action name will be append to the path. Then, create the corresponding action into your controller: .. code-block:: php 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 :code:`PATCH` request. By default, the serializer deserialize entities only with their id and discriminator type, if any. Example: .. code-block:: bash 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 :code:`POST` and :code:`DELETE` requests. Prepare your entity, creating the methods :code:`addYourEntity` and :code:`removeYourEntity`: .. code-block:: php 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: .. code-block:: 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: .. code-block:: raw +--------------+---------------------------------------------------------------------------------------+ | 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: .. code-block:: php 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: .. code-block:: bash # 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: .. code-block:: php 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: .. code-block:: php 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: .. code-block:: json { "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 :code:`Chill\MainBundle\Serializer\Model\Collection`. The pagination information is given by using :code:`Paginator` (see :ref:`Pagination `). .. code-block:: php 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); } } .. _api_full_configuration: Full configuration example ************************** .. code-block:: yaml 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