diff --git a/docs/source/development/api.rst b/docs/source/development/api.rst new file mode 100644 index 000000000..6905075d9 --- /dev/null +++ b/docs/source/development/api.rst @@ -0,0 +1,412 @@ +.. 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. + +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 + +.. read-also:: + + * `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 below): + +.. 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); + } + } + +.. api_full_config: + +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 + + diff --git a/src/Bundle/ChillMainBundle/CRUD/CompilerPass/CRUDControllerCompilerPass.php b/src/Bundle/ChillMainBundle/CRUD/CompilerPass/CRUDControllerCompilerPass.php new file mode 100644 index 000000000..d8a46837c --- /dev/null +++ b/src/Bundle/ChillMainBundle/CRUD/CompilerPass/CRUDControllerCompilerPass.php @@ -0,0 +1,77 @@ + + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +namespace Chill\MainBundle\CRUD\CompilerPass; + +use Symfony\Component\DependencyInjection\ContainerBuilder; +use Symfony\Component\DependencyInjection\Compiler\CompilerPassInterface; +use Symfony\Component\DependencyInjection\Reference; +use Chill\MainBundle\Routing\MenuComposer; +use Symfony\Component\DependencyInjection\Definition; + +/** + * + * + */ +class CRUDControllerCompilerPass implements CompilerPassInterface +{ + public function process(ContainerBuilder $container) + { + $crudConfig = $container->getParameter('chill_main_crud_route_loader_config'); + $apiConfig = $container->getParameter('chill_main_api_route_loader_config'); + + foreach ($crudConfig as $crudEntry) { + $this->configureCrudController($container, $crudEntry, 'crud'); + } + + foreach ($apiConfig as $crudEntry) { + $this->configureCrudController($container, $crudEntry, 'api'); + } + } + + /** + * Add a controller for each definition, and add a methodCall to inject crud configuration to controller + */ + private function configureCrudController(ContainerBuilder $container, array $crudEntry, string $apiOrCrud): void + { + $controllerClass = $crudEntry['controller']; + + $controllerServiceName = 'cs'.$apiOrCrud.'_'.$crudEntry['name'].'_controller'; + + if ($container->hasDefinition($controllerClass)) { + $controller = $container->getDefinition($controllerClass); + $container->removeDefinition($controllerClass); + $alreadyDefined = true; + } else { + $controller = new Definition($controllerClass); + $alreadyDefined = false; + } + + $controller->addTag('controller.service_arguments'); + if (FALSE === $alreadyDefined) { + $controller->setAutoconfigured(true); + $controller->setPublic(true); + } + + $param = 'chill_main_'.$apiOrCrud.'_config_'.$crudEntry['name']; + $container->setParameter($param, $crudEntry); + $controller->addMethodCall('setCrudConfig', ['%'.$param.'%']); + + $container->setDefinition($controllerServiceName, $controller); + } + +} diff --git a/src/Bundle/ChillMainBundle/CRUD/Controller/AbstractCRUDController.php b/src/Bundle/ChillMainBundle/CRUD/Controller/AbstractCRUDController.php new file mode 100644 index 000000000..5cc055f26 --- /dev/null +++ b/src/Bundle/ChillMainBundle/CRUD/Controller/AbstractCRUDController.php @@ -0,0 +1,225 @@ +getDoctrine() + ->getRepository($this->getEntityClass()) + ->find($id); + } + + /** + * Count the number of entities + * + * By default, count all entities. You can customize the query by + * using the method `customizeQuery`. + * + * @param string $action + * @param Request $request + * @return int + */ + protected function countEntities(string $action, Request $request, $_format): int + { + return $this->buildQueryEntities($action, $request) + ->select('COUNT(e)') + ->getQuery() + ->getSingleScalarResult() + ; + } + + /** + * Query the entity. + * + * By default, get all entities. You can customize the query by using the + * method `customizeQuery`. + * + * The method `orderEntity` is called internally to order entities. + * + * It returns, by default, a query builder. + * + */ + protected function queryEntities(string $action, Request $request, string $_format, PaginatorInterface $paginator) + { + $query = $this->buildQueryEntities($action, $request) + ->setFirstResult($paginator->getCurrentPage()->getFirstItemNumber()) + ->setMaxResults($paginator->getItemsPerPage()); + + // allow to order queries and return the new query + return $this->orderQuery($action, $query, $request, $paginator, $_format); + } + + /** + * Add ordering fields in the query build by self::queryEntities + * + */ + protected function orderQuery(string $action, $query, Request $request, PaginatorInterface $paginator, $_format) + { + return $query; + } + + /** + * Build the base query for listing all entities. + * + * This method is used internally by `countEntities` `queryEntities` + * + * This base query does not contains any `WHERE` or `SELECT` clauses. You + * can add some by using the method `customizeQuery`. + * + * The alias for the entity is "e". + * + * @param string $action + * @param Request $request + * @return QueryBuilder + */ + protected function buildQueryEntities(string $action, Request $request) + { + $qb = $this->getDoctrine()->getManager() + ->createQueryBuilder() + ->select('e') + ->from($this->getEntityClass(), 'e') + ; + + $this->customizeQuery($action, $request, $qb); + + return $qb; + } + + protected function customizeQuery(string $action, Request $request, $query): void {} + + /** + * Get the result of the query + */ + protected function getQueryResult(string $action, Request $request, string $_format, int $totalItems, PaginatorInterface $paginator, $query) + { + return $query->getQuery()->getResult(); + } + + protected function onPreIndex(string $action, Request $request, string $_format): ?Response + { + return null; + } + + /** + * method used by indexAction + */ + protected function onPreIndexBuildQuery(string $action, Request $request, string $_format, int $totalItems, PaginatorInterface $paginator): ?Response + { + return null; + } + + /** + * method used by indexAction + */ + protected function onPostIndexBuildQuery(string $action, Request $request, string $_format, int $totalItems, PaginatorInterface $paginator, $query): ?Response + { + return null; + } + + /** + * method used by indexAction + */ + protected function onPostIndexFetchQuery(string $action, Request $request, string $_format, int $totalItems, PaginatorInterface $paginator, $entities): ?Response + { + return null; + } + + + /** + * Get the complete FQDN of the class + * + * @return string the complete fqdn of the class + */ + protected function getEntityClass(): string + { + return $this->crudConfig['class']; + } + + /** + * called on post fetch entity + */ + protected function onPostFetchEntity(string $action, Request $request, $entity, $_format): ?Response + { + return null; + } + + /** + * Called on post check ACL + */ + protected function onPostCheckACL(string $action, Request $request, string $_format, $entity): ?Response + { + return null; + } + + /** + * check the acl. Called by every action. + * + * By default, check the role given by `getRoleFor` for the value given in + * $entity. + * + * Throw an \Symfony\Component\Security\Core\Exception\AccessDeniedHttpException + * if not accessible. + * + * @throws \Symfony\Component\Security\Core\Exception\AccessDeniedHttpException + */ + protected function checkACL(string $action, Request $request, string $_format, $entity = null) + { + $this->denyAccessUnlessGranted($this->getRoleFor($action, $request, $entity, $_format), $entity); + } + + /** + * + * @return string the crud name + */ + protected function getCrudName(): string + { + return $this->crudConfig['name']; + } + + protected function getActionConfig(string $action) + { + return $this->crudConfig['actions'][$action]; + } + + /** + * Set the crud configuration + * + * Used by the container to inject configuration for this crud. + */ + public function setCrudConfig(array $config): void + { + $this->crudConfig = $config; + } + + /** + * @return PaginatorFactory + */ + protected function getPaginatorFactory(): PaginatorFactory + { + return $this->container->get('chill_main.paginator_factory'); + } +} diff --git a/src/Bundle/ChillMainBundle/CRUD/Controller/ApiController.php b/src/Bundle/ChillMainBundle/CRUD/Controller/ApiController.php new file mode 100644 index 000000000..7643db0e9 --- /dev/null +++ b/src/Bundle/ChillMainBundle/CRUD/Controller/ApiController.php @@ -0,0 +1,220 @@ +getEntity($action, $id, $request, $_format); + + $postFetch = $this->onPostFetchEntity($action, $request, $entity, $_format); + + if ($postFetch instanceof Response) { + return $postFetch; + } + + if (NULL === $entity) { + throw $this->createNotFoundException(sprintf("The %s with id %s " + . "is not found", $this->getCrudName(), $id)); + } + + $response = $this->checkACL($action, $request, $_format, $entity); + if ($response instanceof Response) { + return $response; + } + + $response = $this->onPostCheckACL($action, $request, $_format, $entity); + if ($response instanceof Response) { + return $response; + } + + $response = $this->onBeforeSerialize($action, $request, $_format, $entity); + if ($response instanceof Response) { + return $response; + } + + if ($_format === 'json') { + $context = $this->getContextForSerialization($action, $request, $_format, $entity); + + return $this->json($entity, Response::HTTP_OK, [], $context); + } else { + throw new \Symfony\Component\HttpFoundation\Exception\BadRequestException("This format is not implemented"); + } + } + + public function onBeforeSerialize(string $action, Request $request, $_format, $entity): ?Response + { + return null; + } + + /** + * Base method for handling api action + * + * @return void + */ + public function entityApi(Request $request, $id, $_format): Response + { + switch ($request->getMethod()) { + case Request::METHOD_GET: + case REQUEST::METHOD_HEAD: + return $this->entityGet('_entity', $request, $id, $_format); + default: + throw new \Symfony\Component\HttpFoundation\Exception\BadRequestException("This method is not implemented"); + } + } + + /** + * Base action for indexing entities + */ + public function indexApi(Request $request, string $_format) + { + switch ($request->getMethod()) { + case Request::METHOD_GET: + case REQUEST::METHOD_HEAD: + return $this->indexApiAction('_index', $request, $_format); + default: + throw $this->createNotFoundException("This method is not supported"); + } + } + + /** + * Build an index page. + * + * Some steps may be overriden during this process of rendering. + * + * This method: + * + * 1. Launch `onPreIndex` + * x. check acl. If it does return a response instance, return it + * x. launch `onPostCheckACL`. If it does return a response instance, return it + * 1. count the items, using `countEntities` + * 2. build a paginator element from the the number of entities ; + * 3. Launch `onPreIndexQuery`. If it does return a response instance, return it + * 3. build a query, using `queryEntities` + * x. fetch the results, using `getQueryResult` + * x. Launch `onPostIndexFetchQuery`. If it does return a response instance, return it + * 4. Serialize the entities in a Collection, using `SerializeCollection` + * + * @param string $action + * @param Request $request + * @return type + */ + protected function indexApiAction($action, Request $request, $_format) + { + $this->onPreIndex($action, $request, $_format); + + $response = $this->checkACL($action, $request, $_format); + if ($response instanceof Response) { + return $response; + } + + if (!isset($entity)) { + $entity = ''; + } + + $response = $this->onPostCheckACL($action, $request, $_format, $entity); + if ($response instanceof Response) { + return $response; + } + + $totalItems = $this->countEntities($action, $request, $_format); + $paginator = $this->getPaginatorFactory()->create($totalItems); + + $response = $this->onPreIndexBuildQuery($action, $request, $_format, $totalItems, + $paginator); + + if ($response instanceof Response) { + return $response; + } + + $query = $this->queryEntities($action, $request, $_format, $paginator); + + $response = $this->onPostIndexBuildQuery($action, $request, $_format, $totalItems, + $paginator, $query); + + if ($response instanceof Response) { + return $response; + } + + $entities = $this->getQueryResult($action, $request, $_format, $totalItems, $paginator, $query); + + $response = $this->onPostIndexFetchQuery($action, $request, $_format, $totalItems, + $paginator, $entities); + + if ($response instanceof Response) { + return $response; + } + + return $this->serializeCollection($action, $request, $_format, $paginator, $entities); + } + + /** + * Serialize collections + * + */ + protected function serializeCollection(string $action, Request $request, string $_format, PaginatorInterface $paginator, $entities): Response + { + $model = new Collection($entities, $paginator); + + $context = $this->getContextForSerialization($action, $request, $_format, $entities); + + return $this->json($model, Response::HTTP_OK, [], $context); + } + + + protected function getContextForSerialization(string $action, Request $request, string $_format, $entity): array + { + return []; + } + + /** + * get the role given from the config. + */ + protected function getRoleFor(string $action, Request $request, $entity, $_format): string + { + $actionConfig = $this->getActionConfig($action); + + if (NULL !== $actionConfig['roles'][$request->getMethod()]) { + return $actionConfig['roles'][$request->getMethod()]; + } + + if ($this->crudConfig['base_role']) { + return $this->crudConfig['base_role']; + } + + throw new \RuntimeException(sprintf("the config does not have any role for the ". + "method %s nor a global role for the whole action. Add those to your ". + "configuration or override the required method", $request->getMethod())); + + } + + protected function getSerializer(): SerializerInterface + { + return $this->get('serializer'); + } +} diff --git a/src/Bundle/ChillMainBundle/CRUD/Controller/CRUDController.php b/src/Bundle/ChillMainBundle/CRUD/Controller/CRUDController.php index 8a8d32932..8338af8c6 100644 --- a/src/Bundle/ChillMainBundle/CRUD/Controller/CRUDController.php +++ b/src/Bundle/ChillMainBundle/CRUD/Controller/CRUDController.php @@ -34,6 +34,7 @@ use Chill\MainBundle\CRUD\Form\CRUDDeleteEntityForm; use Chill\MainBundle\Pagination\PaginatorFactory; use Chill\MainBundle\Pagination\PaginatorInterface; use Chill\MainBundle\Security\Authorization\AuthorizationHelper; +use Symfony\Component\Serializer\SerializerInterface; /** * Class CRUDController @@ -484,7 +485,7 @@ class CRUDController extends AbstractController * @param mixed $id * @return Response */ - protected function viewAction(string $action, Request $request, $id) + protected function viewAction(string $action, Request $request, $id, $_format = 'html'): Response { $entity = $this->getEntity($action, $id, $request); @@ -496,7 +497,7 @@ class CRUDController extends AbstractController if (NULL === $entity) { throw $this->createNotFoundException(sprintf("The %s with id %s " - . "is not found"), $this->getCrudName(), $id); + . "is not found", $this->getCrudName(), $id)); } $response = $this->checkACL($action, $entity); @@ -508,17 +509,36 @@ class CRUDController extends AbstractController if ($response instanceof Response) { return $response; } - - $defaultTemplateParameters = [ - 'entity' => $entity, - 'crud_name' => $this->getCrudName() - ]; - - return $this->render( - $this->getTemplateFor($action, $entity, $request), - $this->generateTemplateParameter($action, $entity, $request, $defaultTemplateParameters) + + if ($_format === 'html') { + $defaultTemplateParameters = [ + 'entity' => $entity, + 'crud_name' => $this->getCrudName() + ]; + + return $this->render( + $this->getTemplateFor($action, $entity, $request), + $this->generateTemplateParameter($action, $entity, $request, $defaultTemplateParameters) ); + } elseif ($_format === 'json') { + $context = $this->getContextForSerialization($action, $request, $entity, $_format); + + return $this->json($entity, Response::HTTP_OK, [], $context); + } else { + throw new \Symfony\Component\HttpFoundation\Exception\BadRequestException("This format is not implemented"); + } + } + + /** + * Get the context for the serialization + */ + public function getContextForSerialization(string $action, Request $request, $entity, string $_format): array + { + return []; + } + + /** * The edit action. @@ -799,7 +819,7 @@ class CRUDController extends AbstractController */ protected function getRoleFor($action) { - if (NULL !== ($this->getActionConfig($action)['role'])) { + if (\array_key_exists('role', $this->getActionConfig($action))) { return $this->getActionConfig($action)['role']; } @@ -1181,6 +1201,7 @@ class CRUDController extends AbstractController AuthorizationHelper::class => AuthorizationHelper::class, EventDispatcherInterface::class => EventDispatcherInterface::class, Resolver::class => Resolver::class, + SerializerInterface::class => SerializerInterface::class, ] ); } diff --git a/src/Bundle/ChillMainBundle/CRUD/Routing/CRUDRoutesLoader.php b/src/Bundle/ChillMainBundle/CRUD/Routing/CRUDRoutesLoader.php index 5a0eb405e..32068e518 100644 --- a/src/Bundle/ChillMainBundle/CRUD/Routing/CRUDRoutesLoader.php +++ b/src/Bundle/ChillMainBundle/CRUD/Routing/CRUDRoutesLoader.php @@ -23,6 +23,9 @@ namespace Chill\MainBundle\CRUD\Routing; use Symfony\Component\Config\Loader\Loader; use Symfony\Component\Routing\Route; use Symfony\Component\Routing\RouteCollection; +use Symfony\Component\HttpFoundation\Request; +use Chill\MainBundle\CRUD\Controller\ApiController; +use Chill\MainBundle\CRUD\Controller\CRUDController; /** * Class CRUDRoutesLoader @@ -32,24 +35,34 @@ use Symfony\Component\Routing\RouteCollection; */ class CRUDRoutesLoader extends Loader { - /** - * @var array - */ - protected $config = []; + protected array $crudConfig = []; + + protected array $apiCrudConfig = []; /** * @var bool */ private $isLoaded = false; + + private const ALL_SINGLE_METHODS = [ + Request::METHOD_GET, + Request::METHOD_POST, + Request::METHOD_PUT, + Request::METHOD_DELETE + ]; + + private const ALL_INDEX_METHODS = [ Request::METHOD_GET, Request::METHOD_HEAD ]; /** * CRUDRoutesLoader constructor. * - * @param $config + * @param $crudConfig the config from cruds + * @param $apicrudConfig the config from api_crud */ - public function __construct($config) + public function __construct(array $crudConfig, array $apiConfig) { - $this->config = $config; + $this->crudConfig = $crudConfig; + $this->apiConfig = $apiConfig; } /** @@ -63,53 +76,161 @@ class CRUDRoutesLoader extends Loader } /** - * @return RouteCollection + * Load routes for CRUD and CRUD Api */ - public function load($resource, $type = null) + public function load($resource, $type = null): RouteCollection { - if (true === $this->isLoaded) { throw new \RuntimeException('Do not add the "CRUD" loader twice'); } - + $collection = new RouteCollection(); - foreach ($this->config as $config) { - $collection->addCollection($this->loadConfig($config)); + foreach ($this->crudConfig as $crudConfig) { + $collection->addCollection($this->loadCrudConfig($crudConfig)); } - + foreach ($this->apiConfig as $crudConfig) { + $collection->addCollection($this->loadApi($crudConfig)); + } + return $collection; } /** - * @param $config + * Load routes for CRUD (without api) + * + * @param $crudConfig * @return RouteCollection */ - protected function loadConfig($config): RouteCollection + protected function loadCrudConfig($crudConfig): RouteCollection { $collection = new RouteCollection(); - foreach ($config['actions'] as $name => $action) { + $controller ='cscrud_'.$crudConfig['name'].'_controller'; + + foreach ($crudConfig['actions'] as $name => $action) { + // defaults (controller name) $defaults = [ - '_controller' => 'cscrud_'.$config['name'].'_controller'.':'.($action['controller_action'] ?? $name) + '_controller' => $controller.':'.($action['controller_action'] ?? $name) ]; if ($name === 'index') { - $path = "{_locale}".$config['base_path']; + $path = "{_locale}".$crudConfig['base_path']; $route = new Route($path, $defaults); } elseif ($name === 'new') { - $path = "{_locale}".$config['base_path'].'/'.$name; + $path = "{_locale}".$crudConfig['base_path'].'/'.$name; $route = new Route($path, $defaults); } else { - $path = "{_locale}".$config['base_path'].($action['path'] ?? '/{id}/'.$name); + $path = "{_locale}".$crudConfig['base_path'].($action['path'] ?? '/{id}/'.$name); $requirements = $action['requirements'] ?? [ '{id}' => '\d+' ]; $route = new Route($path, $defaults, $requirements); } - $collection->add('chill_crud_'.$config['name'].'_'.$name, $route); + $collection->add('chill_crud_'.$crudConfig['name'].'_'.$name, $route); } return $collection; } + + /** + * Load routes for api single + * + * @param $crudConfig + * @return RouteCollection + */ + protected function loadApi(array $crudConfig): RouteCollection + { + $collection = new RouteCollection(); + $controller ='csapi_'.$crudConfig['name'].'_controller'; + + foreach ($crudConfig['actions'] as $name => $action) { + // filter only on single actions + $singleCollection = $action['single-collection'] ?? $name === '_entity' ? 'single' : NULL; + if ('collection' === $singleCollection) { +// continue; + } + + // compute default action + switch ($name) { + case '_entity': + $controllerAction = 'entityApi'; + break; + case '_index': + $controllerAction = 'indexApi'; + break; + default: + $controllerAction = $name.'Api'; + break; + } + + $defaults = [ + '_controller' => $controller.':'.($action['controller_action'] ?? $controllerAction) + ]; + + // path are rewritten + // if name === 'default', we rewrite it to nothing :-) + $localName = \in_array($name, [ '_entity', '_index' ]) ? '' : '/'.$name; + if ('collection' === $action['single-collection'] || '_index' === $name) { + $localPath = $action['path'] ?? $localName.'.{_format}'; + } else { + $localPath = $action['path'] ?? '/{id}'.$localName.'.{_format}'; + } + $path = $crudConfig['base_path'].$localPath; + + $requirements = $action['requirements'] ?? [ '{id}' => '\d+' ]; + + $methods = \array_keys(\array_filter($action['methods'], function($value, $key) { return $value; }, + ARRAY_FILTER_USE_BOTH)); + + $route = new Route($path, $defaults, $requirements); + $route->setMethods($methods); + + $collection->add('chill_api_single_'.$crudConfig['name'].'_'.$name, $route); + } + + return $collection; + } + + /** + * Load routes for api multi + * + * @param $crudConfig + * @return RouteCollection + */ + protected function loadApiMultiConfig(array $crudConfig): RouteCollection + { + $collection = new RouteCollection(); + $controller ='csapi_'.$crudConfig['name'].'_controller'; + + foreach ($crudConfig['actions'] as $name => $action) { + // filter only on single actions + $singleCollection = $action['single-collection'] ?? $name === '_index' ? 'collection' : NULL; + if ('single' === $singleCollection) { + continue; + } + + $defaults = [ + '_controller' => $controller.':'.($action['controller_action'] ?? '_entity' === $name ? 'entityApi' : $name.'Api') + ]; + + // path are rewritten + // if name === 'default', we rewrite it to nothing :-) + $localName = '_entity' === $name ? '' : '/'.$name; + $localPath = $action['path'] ?? '/{id}'.$localName.'.{_format}'; + $path = $crudConfig['base_path'].$localPath; + + $requirements = $action['requirements'] ?? [ '{id}' => '\d+' ]; + + $methods = \array_keys(\array_filter($action['methods'], function($value, $key) { return $value; }, + ARRAY_FILTER_USE_BOTH)); + + $route = new Route($path, $defaults, $requirements); + $route->setMethods($methods); + + $collection->add('chill_api_single_'.$crudConfig['name'].'_'.$name, $route); + } + + return $collection; + } } diff --git a/src/Bundle/ChillMainBundle/ChillMainBundle.php b/src/Bundle/ChillMainBundle/ChillMainBundle.php index abfcdd6fd..51ef344f2 100644 --- a/src/Bundle/ChillMainBundle/ChillMainBundle.php +++ b/src/Bundle/ChillMainBundle/ChillMainBundle.php @@ -14,6 +14,7 @@ use Chill\MainBundle\DependencyInjection\CompilerPass\NotificationCounterCompile use Chill\MainBundle\DependencyInjection\CompilerPass\MenuCompilerPass; use Chill\MainBundle\DependencyInjection\CompilerPass\ACLFlagsCompilerPass; use Chill\MainBundle\DependencyInjection\CompilerPass\GroupingCenterCompilerPass; +use Chill\MainBundle\CRUD\CompilerPass\CRUDControllerCompilerPass; use Chill\MainBundle\Templating\Entity\CompilerPass as RenderEntityCompilerPass; @@ -33,5 +34,6 @@ class ChillMainBundle extends Bundle $container->addCompilerPass(new ACLFlagsCompilerPass()); $container->addCompilerPass(new GroupingCenterCompilerPass()); $container->addCompilerPass(new RenderEntityCompilerPass()); + $container->addCompilerPass(new CRUDControllerCompilerPass()); } } diff --git a/src/Bundle/ChillMainBundle/DependencyInjection/ChillMainExtension.php b/src/Bundle/ChillMainBundle/DependencyInjection/ChillMainExtension.php index 234e1203d..00b164303 100644 --- a/src/Bundle/ChillMainBundle/DependencyInjection/ChillMainExtension.php +++ b/src/Bundle/ChillMainBundle/DependencyInjection/ChillMainExtension.php @@ -133,7 +133,7 @@ class ChillMainExtension extends Extension implements PrependExtensionInterface, $loader->load('services/search.yaml'); $loader->load('services/serializer.yaml'); - $this->configureCruds($container, $config['cruds'], $loader); + $this->configureCruds($container, $config['cruds'], $config['apis'], $loader); } /** @@ -210,51 +210,24 @@ class ChillMainExtension extends Extension implements PrependExtensionInterface, } /** - * @param ContainerBuilder $container - * @param array $config the config under 'cruds' key - * @return null + * Load parameter for configuration and set parameters for api */ - protected function configureCruds(ContainerBuilder $container, $config, Loader\YamlFileLoader $loader) + protected function configureCruds( + ContainerBuilder $container, + array $crudConfig, + array $apiConfig, + Loader\YamlFileLoader $loader + ): void { - if (count($config) === 0) { + if (count($crudConfig) === 0) { return; } $loader->load('services/crud.yaml'); - $container->setParameter('chill_main_crud_route_loader_config', $config); + $container->setParameter('chill_main_crud_route_loader_config', $crudConfig); + $container->setParameter('chill_main_api_route_loader_config', $apiConfig); - $definition = new Definition(); - $definition - ->setClass(\Chill\MainBundle\CRUD\Routing\CRUDRoutesLoader::class) - ->addArgument('%chill_main_crud_route_loader_config%') - ; - - $container->setDefinition('chill_main_crud_route_loader', $definition); - - $alreadyExistingNames = []; - - foreach ($config as $crudEntry) { - $controller = $crudEntry['controller']; - $controllerServiceName = 'cscrud_'.$crudEntry['name'].'_controller'; - $name = $crudEntry['name']; - - // check for existing crud names - if (\in_array($name, $alreadyExistingNames)) { - throw new LogicException(sprintf("the name %s is defined twice in CRUD", $name)); - } - - if (!$container->has($controllerServiceName)) { - $controllerDefinition = new Definition($controller); - $controllerDefinition->addTag('controller.service_arguments'); - $controllerDefinition->setAutoconfigured(true); - $controllerDefinition->setClass($crudEntry['controller']); - $container->setDefinition($controllerServiceName, $controllerDefinition); - } - - $container->setParameter('chill_main_crud_config_'.$name, $crudEntry); - $container->getDefinition($controllerServiceName) - ->addMethodCall('setCrudConfig', ['%chill_main_crud_config_'.$name.'%']); - } + // Note: the controller are loaded inside compiler pass } } diff --git a/src/Bundle/ChillMainBundle/DependencyInjection/Configuration.php b/src/Bundle/ChillMainBundle/DependencyInjection/Configuration.php index d5024bf5d..c7e4c00ef 100644 --- a/src/Bundle/ChillMainBundle/DependencyInjection/Configuration.php +++ b/src/Bundle/ChillMainBundle/DependencyInjection/Configuration.php @@ -8,6 +8,7 @@ use Chill\MainBundle\DependencyInjection\Widget\Factory\WidgetFactoryInterface; use Symfony\Component\Config\Definition\Builder\ArrayNodeDefinition; use Chill\MainBundle\DependencyInjection\Widget\AddWidgetConfigurationTrait; use Symfony\Component\DependencyInjection\ContainerBuilder; +use Symfony\Component\HttpFoundation\Request; /** @@ -140,7 +141,7 @@ class Configuration implements ConfigurationInterface ->scalarNode('controller_action') ->defaultNull() ->info('the method name to call in the route. Will be set to the action name if left empty.') - ->example("'action'") + ->example("action") ->end() ->scalarNode('path') ->defaultNull() @@ -168,6 +169,78 @@ class Configuration implements ConfigurationInterface ->end() ->end() + + + ->arrayNode('apis') + ->defaultValue([]) + ->arrayPrototype() + ->children() + ->scalarNode('class')->cannotBeEmpty()->isRequired()->end() + ->scalarNode('controller') + ->cannotBeEmpty() + ->defaultValue(\Chill\MainBundle\CRUD\Controller\ApiController::class) + ->end() + ->scalarNode('name')->cannotBeEmpty()->isRequired()->end() + ->scalarNode('base_path')->cannotBeEmpty()->isRequired()->end() + ->scalarNode('base_role')->defaultNull()->end() + ->arrayNode('actions') + ->useAttributeAsKey('name') + ->arrayPrototype() + ->children() + ->scalarNode('controller_action') + ->defaultNull() + ->info('the method name to call in the controller. Will be set to the concatenation '. + 'of action name + \'Api\' if left empty.') + ->example("showApi") + ->end() + ->scalarNode('path') + ->defaultNull() + ->info('the path that will be **appended** after the base path. Do not forget to add ' . + 'arguments for the method. By default, will set to the action name, including an `{id}` '. + 'parameter. A suffix of action name will be appended, except if the action name '. + 'is "_entity".') + ->example('/{id}/my-action') + ->end() + ->arrayNode('requirements') + ->ignoreExtraKeys(false) + ->info('the requirements for the route. Will be set to `[ \'id\' => \'\d+\' ]` if left empty.') + ->end() + ->enumNode('single-collection') + ->values(['single', 'collection']) + ->defaultValue('single') + ->info('indicates if the returned object is a single element or a collection. '. + 'If the action name is `_index`, this value will always be considered as '. + '`collection`') + ->end() + ->arrayNode('methods') + ->addDefaultsIfNotSet() + ->info('the allowed methods') + ->children() + ->booleanNode(Request::METHOD_GET)->defaultTrue()->end() + ->booleanNode(Request::METHOD_HEAD)->defaultTrue()->end() + ->booleanNode(Request::METHOD_POST)->defaultFalse()->end() + ->booleanNode(Request::METHOD_DELETE)->defaultFalse()->end() + ->booleanNode(Request::METHOD_PUT)->defaultFalse()->end() + ->end() + ->end() + ->arrayNode('roles') + ->addDefaultsIfNotSet() + ->info("The role require for each http method") + ->children() + ->scalarNode(Request::METHOD_GET)->defaultNull()->end() + ->scalarNode(Request::METHOD_HEAD)->defaultNull()->end() + ->scalarNode(Request::METHOD_POST)->defaultNull()->end() + ->scalarNode(Request::METHOD_DELETE)->defaultNull()->end() + ->scalarNode(Request::METHOD_PUT)->defaultNull()->end() + ->end() + ->end() + ->end() + ->end() + ->end() + ->end() + ->end() + + ->end() ->end() // end of root/children ->end() // end of root ; diff --git a/src/Bundle/ChillMainBundle/Serializer/Model/Collection.php b/src/Bundle/ChillMainBundle/Serializer/Model/Collection.php new file mode 100644 index 000000000..9983d3595 --- /dev/null +++ b/src/Bundle/ChillMainBundle/Serializer/Model/Collection.php @@ -0,0 +1,28 @@ +items = $items; + $this->paginator = $paginator; + } + + public function getPaginator(): PaginatorInterface + { + return $this->paginator; + } + + public function getItems() + { + return $this->items; + } +} diff --git a/src/Bundle/ChillMainBundle/Serializer/Normalizer/CollectionNormalizer.php b/src/Bundle/ChillMainBundle/Serializer/Normalizer/CollectionNormalizer.php new file mode 100644 index 000000000..b21bf6326 --- /dev/null +++ b/src/Bundle/ChillMainBundle/Serializer/Normalizer/CollectionNormalizer.php @@ -0,0 +1,39 @@ +getPaginator(); + + $data['count'] = $paginator->getTotalItems(); + $data['first'] = $paginator->getCurrentPageFirstItemNumber(); + $data['items_per_page'] = $paginator->getItemsPerPage(); + $data['next'] = $paginator->hasNextPage() ? + $paginator->getNextPage()->generateUrl() : null; + $data['previous'] = $paginator->hasPreviousPage() ? + $paginator->getPreviousPage()->generateUrl() : null; + + // normalize results + $data['results'] = $this->normalizer->normalize($collection->getItems(), + $format, $context); + + return $data; + } +} diff --git a/src/Bundle/ChillMainBundle/config/services/crud.yaml b/src/Bundle/ChillMainBundle/config/services/crud.yaml index 8723d6eb1..c5c05f344 100644 --- a/src/Bundle/ChillMainBundle/config/services/crud.yaml +++ b/src/Bundle/ChillMainBundle/config/services/crud.yaml @@ -1,7 +1,8 @@ services: Chill\MainBundle\CRUD\Routing\CRUDRoutesLoader: arguments: - $config: '%chill_main_crud_route_loader_config%' + $crudConfig: '%chill_main_crud_route_loader_config%' + $apiConfig: '%chill_main_api_route_loader_config%' tags: [ routing.loader ] Chill\MainBundle\CRUD\Resolver\Resolver: @@ -13,4 +14,4 @@ services: arguments: $resolver: '@Chill\MainBundle\CRUD\Resolver\Resolver' tags: - - { name: twig.extension } \ No newline at end of file + - { name: twig.extension } diff --git a/src/Bundle/ChillMainBundle/config/services/pagination.yaml b/src/Bundle/ChillMainBundle/config/services/pagination.yaml index c7c8a89a9..f6282a39f 100644 --- a/src/Bundle/ChillMainBundle/config/services/pagination.yaml +++ b/src/Bundle/ChillMainBundle/config/services/pagination.yaml @@ -6,6 +6,7 @@ services: - "@request_stack" - "@router" - "%chill_main.pagination.item_per_page%" + Chill\MainBundle\Pagination\PaginatorFactory: '@chill_main.paginator_factory' chill_main.paginator.twig_extensions: diff --git a/src/Bundle/ChillMainBundle/config/services/serializer.yaml b/src/Bundle/ChillMainBundle/config/services/serializer.yaml index 763576a5c..fb5f57b7e 100644 --- a/src/Bundle/ChillMainBundle/config/services/serializer.yaml +++ b/src/Bundle/ChillMainBundle/config/services/serializer.yaml @@ -11,3 +11,7 @@ services: Chill\MainBundle\Serializer\Normalizer\UserNormalizer: tags: - { name: 'serializer.normalizer', priority: 64 } + + Chill\MainBundle\Serializer\Normalizer\CollectionNormalizer: + tags: + - { name: 'serializer.normalizer', priority: 64 } diff --git a/src/Bundle/ChillPersonBundle/Controller/AccompanyingCourseApiController.php b/src/Bundle/ChillPersonBundle/Controller/AccompanyingCourseApiController.php new file mode 100644 index 000000000..bbf2f399a --- /dev/null +++ b/src/Bundle/ChillPersonBundle/Controller/AccompanyingCourseApiController.php @@ -0,0 +1,79 @@ +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'); + } + + // TODO add acl + // + $this->onPostCheckACL('participation', $request, $_format, $accompanyingPeriod); + + switch ($request->getMethod()) { + case Request::METHOD_POST: + $participation = $accompanyingPeriod->addPerson($person); + break; + case Request::METHOD_DELETE: + $participation = $accompanyingPeriod->removePerson($person); + $participation->setEndDate(new \DateTimeImmutable('now')); + 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); + } + + protected function onPostCheckACL(string $action, Request $request, string $_format, $entity): ?Response + { + $this->eventDispatcher->dispatch( + AccompanyingPeriodPrivacyEvent::ACCOMPANYING_PERIOD_PRIVACY_EVENT, + new AccompanyingPeriodPrivacyEvent($entity, [ + 'action' => $action, + 'request' => $request->getMethod() + ]) + ); + + return null; + } +} diff --git a/src/Bundle/ChillPersonBundle/Controller/OpeningApiController.php b/src/Bundle/ChillPersonBundle/Controller/OpeningApiController.php new file mode 100644 index 000000000..3b44162c4 --- /dev/null +++ b/src/Bundle/ChillPersonBundle/Controller/OpeningApiController.php @@ -0,0 +1,17 @@ +where($qb->expr()->gt('e.noActiveAfter', ':now')) + ->orWhere($qb->expr()->isNull('e.noActiveAfter')); + $qb->setParameter('now', new \DateTime('now')); + } +} diff --git a/src/Bundle/ChillPersonBundle/DataFixtures/ORM/LoadPersonACL.php b/src/Bundle/ChillPersonBundle/DataFixtures/ORM/LoadPersonACL.php index a3f42ced9..c8593d1fd 100644 --- a/src/Bundle/ChillPersonBundle/DataFixtures/ORM/LoadPersonACL.php +++ b/src/Bundle/ChillPersonBundle/DataFixtures/ORM/LoadPersonACL.php @@ -25,6 +25,7 @@ use Doctrine\Persistence\ObjectManager; use Chill\MainBundle\DataFixtures\ORM\LoadPermissionsGroup; use Chill\MainBundle\Entity\RoleScope; use Chill\PersonBundle\Security\Authorization\PersonVoter; +use Chill\PersonBundle\Security\Authorization\AccompanyingPeriodVoter; /** * Add a role CHILL_PERSON_UPDATE & CHILL_PERSON_CREATE for all groups except administrative, @@ -44,6 +45,7 @@ class LoadPersonACL extends AbstractFixture implements OrderedFixtureInterface { foreach (LoadPermissionsGroup::$refs as $permissionsGroupRef) { $permissionsGroup = $this->getReference($permissionsGroupRef); + $scopeSocial = $this->getReference('scope_social'); //create permission group switch ($permissionsGroup->getName()) { @@ -51,6 +53,12 @@ class LoadPersonACL extends AbstractFixture implements OrderedFixtureInterface case 'direction': printf("Adding CHILL_PERSON_UPDATE & CHILL_PERSON_CREATE to %s permission group \n", $permissionsGroup->getName()); + $permissionsGroup->addRoleScope( + (new RoleScope()) + ->setRole(AccompanyingPeriodVoter::SEE) + ->setScope($scopeSocial) + ); + $roleScopeUpdate = (new RoleScope()) ->setRole('CHILL_PERSON_UPDATE') ->setScope(null); diff --git a/src/Bundle/ChillPersonBundle/DependencyInjection/ChillPersonExtension.php b/src/Bundle/ChillPersonBundle/DependencyInjection/ChillPersonExtension.php index 9eaa5d17f..84b8bb397 100644 --- a/src/Bundle/ChillPersonBundle/DependencyInjection/ChillPersonExtension.php +++ b/src/Bundle/ChillPersonBundle/DependencyInjection/ChillPersonExtension.php @@ -28,6 +28,7 @@ use Chill\MainBundle\DependencyInjection\MissingBundleException; use Chill\PersonBundle\Security\Authorization\PersonVoter; use Chill\MainBundle\Security\Authorization\ChillExportVoter; use Chill\PersonBundle\Doctrine\DQL\AddressPart; +use Symfony\Component\HttpFoundation\Request; /** * Class ChillPersonExtension @@ -76,6 +77,7 @@ class ChillPersonExtension extends Extension implements PrependExtensionInterfac $loader->load('services/templating.yaml'); $loader->load('services/alt_names.yaml'); $loader->load('services/serializer.yaml'); + $loader->load('services/security.yaml'); // load service advanced search only if configure if ($config['search']['search_by_phone'] != 'never') { @@ -307,6 +309,55 @@ class ChillPersonExtension extends Extension implements PrependExtensionInterfac 'template' => '@ChillPerson/MaritalStatus/edit.html.twig', ] ] + ], + ], + 'apis' => [ + [ + 'class' => \Chill\PersonBundle\Entity\AccompanyingPeriod::class, + 'name' => 'accompanying_course', + 'base_path' => '/api/1.0/person/accompanying-course', + 'controller' => \Chill\PersonBundle\Controller\AccompanyingCourseApiController::class, + 'actions' => [ + '_entity' => [ + 'roles' => [ + Request::METHOD_GET => \Chill\PersonBundle\Security\Authorization\AccompanyingPeriodVoter::SEE + ] + ], + 'participation' => [ + 'methods' => [ + Request::METHOD_POST => true, + Request::METHOD_DELETE => true, + Request::METHOD_GET => false, + Request::METHOD_HEAD => false, + ], + 'roles' => [ + Request::METHOD_POST => \Chill\PersonBundle\Security\Authorization\AccompanyingPeriodVoter::SEE, + Request::METHOD_DELETE=> \Chill\PersonBundle\Security\Authorization\AccompanyingPeriodVoter::SEE + ] + ] + + ] + ], + [ + '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 + ] + ], + ] ] ] ]); diff --git a/src/Bundle/ChillPersonBundle/Entity/AccompanyingPeriod.php b/src/Bundle/ChillPersonBundle/Entity/AccompanyingPeriod.php index 18ee535b8..9eec8e6fb 100644 --- a/src/Bundle/ChillPersonBundle/Entity/AccompanyingPeriod.php +++ b/src/Bundle/ChillPersonBundle/Entity/AccompanyingPeriod.php @@ -118,7 +118,7 @@ class AccompanyingPeriod * * @ORM\OneToMany(targetEntity=AccompanyingPeriodParticipation::class, * mappedBy="accompanyingPeriod", - * cascade={"persist", "remove", "merge", "detach"}) + * cascade={"persist", "refresh", "remove", "merge", "detach"}) */ private $participations; @@ -344,50 +344,68 @@ class AccompanyingPeriod } /** - * This private function scan Participations Collection, - * searching for a given Person + * Get the participation containing a person */ - private function participationsContainsPerson(Person $person): ?AccompanyingPeriodParticipation + public function getParticipationsContainsPerson(Person $person): Collection { - foreach ($this->participations as $participation) { - /** @var AccompanyingPeriodParticipation $participation */ - if ($person === $participation->getPerson()) { - return $participation; - }} - - return null; + return $this->getParticipations($person)->filter( + function(AccompanyingPeriodParticipation $participation) use ($person) { + if ($person === $participation->getPerson()) { + return $participation; + } + }); } /** - * This public function is the same but return only true or false + * Get the opened participation containing a person + * + * "Open" means that the closed date is NULL + */ + public function getOpenParticipationContainsPerson(Person $person): ?AccompanyingPeriodParticipation + { + $collection = $this->getParticipationsContainsPerson($person)->filter( + function(AccompanyingPeriodParticipation $participation) use ($person) { + if (NULL === $participation->getEndDate()) { + return $participation; + } + }); + + return $collection->count() > 0 ? $collection->first() : NULL; + } + + /** + * Return true if the accompanying period contains a person. + * + * **Note**: this participation can be opened or not. */ public function containsPerson(Person $person): bool { - return ($this->participationsContainsPerson($person) === null) ? false : true; + return $this->getParticipationsContainsPerson($person)->count() > 0; } /** * Add Person */ - public function addPerson(Person $person = null): self + public function addPerson(Person $person = null): AccompanyingPeriodParticipation { $participation = new AccompanyingPeriodParticipation($this, $person); $this->participations[] = $participation; - return $this; + return $participation; } /** * Remove Person */ - public function removePerson(Person $person): void + public function removePerson(Person $person): ?AccompanyingPeriodParticipation { - $participation = $this->participationsContainsPerson($person); + $participation = $this->getOpenParticipationContainsPerson($person); - if (! null === $participation) { + if ($participation instanceof AccompanyingPeriodParticipation) { $participation->setEndDate(new \DateTimeImmutable('now')); - $this->participations->removeElement($participation); } + + return $participation; } diff --git a/src/Bundle/ChillPersonBundle/Entity/AccompanyingPeriod/Origin.php b/src/Bundle/ChillPersonBundle/Entity/AccompanyingPeriod/Origin.php index 42c75efca..55857de4c 100644 --- a/src/Bundle/ChillPersonBundle/Entity/AccompanyingPeriod/Origin.php +++ b/src/Bundle/ChillPersonBundle/Entity/AccompanyingPeriod/Origin.php @@ -22,7 +22,7 @@ namespace Chill\PersonBundle\Entity\AccompanyingPeriod; -use Chill\PersonBundle\Entity\AccompanyingPeriod\OriginRepository; +use Chill\PersonBundle\Repository\AccompanyingPeriod\OriginRepository; use Doctrine\ORM\Mapping as ORM; /** @@ -53,7 +53,7 @@ class Origin return $this->id; } - public function getLabel(): ?string + public function getLabel() { return $this->label; } diff --git a/src/Bundle/ChillPersonBundle/Entity/Person.php b/src/Bundle/ChillPersonBundle/Entity/Person.php index 94e0a0ee5..3624d4c17 100644 --- a/src/Bundle/ChillPersonBundle/Entity/Person.php +++ b/src/Bundle/ChillPersonBundle/Entity/Person.php @@ -390,7 +390,7 @@ class Person implements HasCenterInterface * * @deprecated since 1.1 use `getOpenedAccompanyingPeriod instead */ - public function getCurrentAccompanyingPeriod() : AccompanyingPeriod + public function getCurrentAccompanyingPeriod() : ?AccompanyingPeriod { return $this->getOpenedAccompanyingPeriod(); } diff --git a/src/Bundle/ChillPersonBundle/Security/Authorization/AccompanyingPeriodVoter.php b/src/Bundle/ChillPersonBundle/Security/Authorization/AccompanyingPeriodVoter.php new file mode 100644 index 000000000..ca92c6d7c --- /dev/null +++ b/src/Bundle/ChillPersonBundle/Security/Authorization/AccompanyingPeriodVoter.php @@ -0,0 +1,73 @@ +helper = $helper; + } + + protected function supports($attribute, $subject) + { + return $subject instanceof AccompanyingPeriod; + } + + protected function voteOnAttribute($attribute, $subject, TokenInterface $token) + { + if (!$token->getUser() instanceof User) { + return false; + } + + // TODO take scopes into account + foreach ($subject->getPersons() as $person) { + // give access as soon as on center is reachable + if ($this->helper->userHasAccess($token->getUser(), $person->getCenter(), $attribute)) { + return true; + } + + return false; + } + } + + private function getAttributes() + { + return [ + self::SEE + ]; + } + + public function getRoles() + { + return $this->getAttributes(); + } + + public function getRolesWithoutScope() + { + return []; + } + + public function getRolesWithHierarchy() + { + return [ 'Person' => $this->getRoles() ]; + } + +} diff --git a/src/Bundle/ChillPersonBundle/Serializer/Normalizer/PersonNormalizer.php b/src/Bundle/ChillPersonBundle/Serializer/Normalizer/PersonNormalizer.php index d6bcc4e96..d88d27ddc 100644 --- a/src/Bundle/ChillPersonBundle/Serializer/Normalizer/PersonNormalizer.php +++ b/src/Bundle/ChillPersonBundle/Serializer/Normalizer/PersonNormalizer.php @@ -55,7 +55,7 @@ class PersonNormalizer implements 'id' => $person->getId(), 'firstName' => $person->getFirstName(), 'lastName' => $person->getLastName(), - 'birthdate' => $person->getBirthdate() ? $this->normalizer->normalize($person->getBirthdate()) : null, + 'birthdate' => $this->normalizer->normalize($person->getBirthdate()), 'center' => $this->normalizer->normalize($person->getCenter()) ]; } diff --git a/src/Bundle/ChillPersonBundle/Tests/Controller/AccompanyingCourseControllerTest.php b/src/Bundle/ChillPersonBundle/Tests/Controller/AccompanyingCourseApiControllerTest.php similarity index 71% rename from src/Bundle/ChillPersonBundle/Tests/Controller/AccompanyingCourseControllerTest.php rename to src/Bundle/ChillPersonBundle/Tests/Controller/AccompanyingCourseApiControllerTest.php index c45cb93d9..61b5307a4 100644 --- a/src/Bundle/ChillPersonBundle/Tests/Controller/AccompanyingCourseControllerTest.php +++ b/src/Bundle/ChillPersonBundle/Tests/Controller/AccompanyingCourseApiControllerTest.php @@ -36,7 +36,7 @@ use Symfony\Component\HttpFoundation\Request; /** * Test api for AccompanyingCourseControllerTest */ -class AccompanyingCourseControllerTest extends WebTestCase +class AccompanyingCourseApiControllerTest extends WebTestCase { protected static EntityManagerInterface $em; @@ -65,7 +65,7 @@ class AccompanyingCourseControllerTest extends WebTestCase */ public function testAccompanyingCourseShow(int $personId, AccompanyingPeriod $period) { - $this->client->request(Request::METHOD_GET, sprintf('/fr/person/api/1.0/accompanying-course/%d/show.json', $period->getId())); + $c = $this->client->request(Request::METHOD_GET, sprintf('/api/1.0/person/accompanying-course/%d.json', $period->getId())); $response = $this->client->getResponse(); $this->assertEquals(200, $response->getStatusCode(), "Test that the response of rest api has a status code ok (200)"); @@ -77,6 +77,14 @@ class AccompanyingCourseControllerTest extends WebTestCase $this->assertGreaterThan(0, $data->participations); } + public function testShow404() + { + $this->client->request(Request::METHOD_GET, sprintf('/api/1.0/person/accompanying-course/%d.json', 99999)); + $response = $this->client->getResponse(); + + $this->assertEquals(404, $response->getStatusCode(), "Test that the response of rest api has a status code 'not found' (404)"); + } + /** * * @dataProvider dataGenerateRandomAccompanyingCourse @@ -85,26 +93,55 @@ class AccompanyingCourseControllerTest extends WebTestCase { $this->client->request( Request::METHOD_POST, - sprintf('/fr/person/api/1.0/accompanying-course/%d/participation.json', $period->getId()), + sprintf('/api/1.0/person/accompanying-course/%d/participation.json', $period->getId()), [], // parameters [], // files [], // server parameters \json_encode([ 'id' => $personId ]) ); $response = $this->client->getResponse(); + $data = \json_decode($response->getContent(), true); $this->assertEquals(200, $response->getStatusCode(), "Test that the response of rest api has a status code ok (200)"); - $this->client->request(Request::METHOD_GET, sprintf('/fr/person/api/1.0/accompanying-course/%d/show.json', $period->getId())); + $this->assertArrayHasKey('id', $data); + $this->assertArrayHasKey('startDate', $data); + $this->assertNotNull($data['startDate']); + + // check by deownloading the accompanying cours + + $this->client->request(Request::METHOD_GET, sprintf('/api/1.0/person/accompanying-course/%d.json', $period->getId())); $response = $this->client->getResponse(); $data = \json_decode($response->getContent()); + // check that the person id is contained $participationsPersonsIds = \array_map( function($participation) { return $participation->person->id; }, $data->participations); $this->assertContains($personId, $participationsPersonsIds); + // check removing the participation + $this->client->request( + Request::METHOD_DELETE, + sprintf('/api/1.0/person/accompanying-course/%d/participation.json', $period->getId()), + [], // parameters + [], // files + [], // server parameters + \json_encode([ 'id' => $personId ]) + ); + $response = $this->client->getResponse(); + $data = \json_decode($response->getContent(), true); + + $this->assertEquals(200, $response->getStatusCode(), "Test that the response of rest api has a status code ok (200)"); + $this->assertArrayHasKey('id', $data); + $this->assertArrayHasKey('startDate', $data); + $this->assertNotNull($data['startDate']); + $this->assertArrayHasKey('endDate', $data); + $this->assertNotNull($data['endDate']); + + + // set to variable for tear down $this->personId = $personId; $this->period = $period; } @@ -112,6 +149,7 @@ class AccompanyingCourseControllerTest extends WebTestCase protected function tearDown() { // remove participation created during test 'testAccompanyingCourseAddParticipation' + // and if the test could not remove it $testAddParticipationName = 'testAccompanyingCourseAddParticipation'; @@ -126,8 +164,10 @@ class AccompanyingCourseControllerTest extends WebTestCase ->findOneBy(['person' => $this->personId, 'accompanyingPeriod' => $this->period]) ; - $em->remove($participation); - $em->flush(); + if (NULL !== $participation) { + $em->remove($participation); + $em->flush(); + } } public function dataGenerateRandomAccompanyingCourse() @@ -139,9 +179,9 @@ class AccompanyingCourseControllerTest extends WebTestCase // * one for getting the person, which will in turn provide his accompanying period; // * one for getting the personId to populate to the data manager // - // Ensure to keep always $maxGenerated to the double of $maxResults - $maxGenerated = 1; - $maxResults = 15 * 8; + // Ensure to keep always $maxGenerated to the double of $maxResults. x8 is a good compromize :) + $maxGenerated = 3; + $maxResults = $maxGenerated * 8; static::bootKernel(); $em = static::$container->get(EntityManagerInterface::class); diff --git a/src/Bundle/ChillPersonBundle/Tests/Entity/AccompanyingPeriodTest.php b/src/Bundle/ChillPersonBundle/Tests/Entity/AccompanyingPeriodTest.php index a31055a0d..03fddab41 100644 --- a/src/Bundle/ChillPersonBundle/Tests/Entity/AccompanyingPeriodTest.php +++ b/src/Bundle/ChillPersonBundle/Tests/Entity/AccompanyingPeriodTest.php @@ -27,7 +27,6 @@ use Chill\PersonBundle\Entity\Person; class AccompanyingPeriodTest extends \PHPUnit\Framework\TestCase { - public function testClosingIsAfterOpeningConsistency() { $datetime1 = new \DateTime('now'); @@ -77,22 +76,32 @@ class AccompanyingPeriodTest extends \PHPUnit\Framework\TestCase $this->assertFalse($period->isOpen()); } - public function testCanBeReOpened() + public function testPersonPeriod() { - $person = new Person(\DateTime::createFromFormat('Y-m-d', '2010-01-01')); - $person->close($person->getAccompanyingPeriods()[0] - ->setClosingDate(\DateTime::createFromFormat('Y-m-d', '2010-12-31'))); - - $firstAccompanygingPeriod = $person->getAccompanyingPeriodsOrdered()[0]; - - $this->assertTrue($firstAccompanygingPeriod->canBeReOpened()); - - $lastAccompanyingPeriod = (new AccompanyingPeriod(\DateTime::createFromFormat('Y-m-d', '2011-01-01'))) - ->setClosingDate(\DateTime::createFromFormat('Y-m-d', '2011-12-31')) - ; - $person->addAccompanyingPeriod($lastAccompanyingPeriod); - - $this->assertFalse($firstAccompanygingPeriod->canBeReOpened()); - } + $person = new Person(); + $person2 = new Person(); + $person3 = new Person(); + $period = new AccompanyingPeriod(new \DateTime()); + $period->addPerson($person); + $period->addPerson($person2); + $period->addPerson($person3); + + $this->assertEquals(3, $period->getParticipations()->count()); + $this->assertTrue($period->containsPerson($person)); + $this->assertFalse($period->containsPerson(new Person())); + + $participation = $period->getOpenParticipationContainsPerson($person); + $participations = $period->getParticipationsContainsPerson($person); + $this->assertNotNull($participation); + $this->assertSame($person, $participation->getPerson()); + $this->assertEquals(1, $participations->count()); + + $participationL = $period->removePerson($person); + $this->assertSame($participationL, $participation); + $this->assertTrue($participation->getEndDate() instanceof \DateTimeInterface); + + $participation = $period->getOpenParticipationContainsPerson($person); + $this->assertNull($participation); + } } diff --git a/src/Bundle/ChillPersonBundle/config/services.yaml b/src/Bundle/ChillPersonBundle/config/services.yaml index bccb69b0d..f986a4ccb 100644 --- a/src/Bundle/ChillPersonBundle/config/services.yaml +++ b/src/Bundle/ChillPersonBundle/config/services.yaml @@ -27,14 +27,6 @@ services: public: true tags: - { name: chill.timeline, context: 'person' } - - chill.person.security.authorization.person: - class: Chill\PersonBundle\Security\Authorization\PersonVoter - arguments: - - "@chill.main.security.authorization.helper" - tags: - - { name: security.voter } - - { name: chill.role } chill.person.birthdate_validation: class: Chill\PersonBundle\Validator\Constraints\BirthdateValidator diff --git a/src/Bundle/ChillPersonBundle/config/services/controller.yaml b/src/Bundle/ChillPersonBundle/config/services/controller.yaml index dc575f320..41a5ac719 100644 --- a/src/Bundle/ChillPersonBundle/config/services/controller.yaml +++ b/src/Bundle/ChillPersonBundle/config/services/controller.yaml @@ -46,3 +46,9 @@ services: $dispatcher: '@Symfony\Contracts\EventDispatcher\EventDispatcherInterface' $validator: '@Symfony\Component\Validator\Validator\ValidatorInterface' tags: ['controller.service_arguments'] + + Chill\PersonBundle\Controller\AccompanyingCourseApiController: + arguments: + $eventDispatcher: '@Symfony\Contracts\EventDispatcher\EventDispatcherInterface' + $validator: '@Symfony\Component\Validator\Validator\ValidatorInterface' + tags: ['controller.service_arguments'] diff --git a/src/Bundle/ChillPersonBundle/config/services/repository.yaml b/src/Bundle/ChillPersonBundle/config/services/repository.yaml index e899ba9e1..b99402bcf 100644 --- a/src/Bundle/ChillPersonBundle/config/services/repository.yaml +++ b/src/Bundle/ChillPersonBundle/config/services/repository.yaml @@ -23,3 +23,8 @@ services: arguments: - '@Doctrine\Persistence\ManagerRegistry' tags: [ doctrine.repository_service ] + + Chill\PersonBundle\Repository\AccompanyingPeriod\OriginRepository: + arguments: + - '@Doctrine\Persistence\ManagerRegistry' + tags: [ doctrine.repository_service ] diff --git a/src/Bundle/ChillPersonBundle/config/services/security.yaml b/src/Bundle/ChillPersonBundle/config/services/security.yaml new file mode 100644 index 000000000..21590fcda --- /dev/null +++ b/src/Bundle/ChillPersonBundle/config/services/security.yaml @@ -0,0 +1,16 @@ +services: + chill.person.security.authorization.person: + class: Chill\PersonBundle\Security\Authorization\PersonVoter + arguments: + - "@chill.main.security.authorization.helper" + tags: + - { name: security.voter } + - { name: chill.role } + + Chill\PersonBundle\Security\Authorization\AccompanyingPeriodVoter: + arguments: + - "@chill.main.security.authorization.helper" + tags: + - { name: security.voter } + - { name: chill.role } + diff --git a/src/Bundle/ChillPersonBundle/translations/messages.fr.yml b/src/Bundle/ChillPersonBundle/translations/messages.fr.yml index 1fe6e3956..ab3a5bfe6 100644 --- a/src/Bundle/ChillPersonBundle/translations/messages.fr.yml +++ b/src/Bundle/ChillPersonBundle/translations/messages.fr.yml @@ -190,6 +190,7 @@ CHILL_PERSON_CREATE: Ajouter des personnes CHILL_PERSON_STATS: Statistiques sur les personnes CHILL_PERSON_LISTS: Liste des personnes CHILL_PERSON_DUPLICATE: Gérer les doublons de personnes +CHILL_PERSON_ACCOMPANYING_PERIOD_SEE: Voir les périodes d'accompagnement #period Period closed!: Période clôturée!