diff --git a/src/Bundle/ChillMainBundle/CRUD/Controller/AbstractCRUDController.php b/src/Bundle/ChillMainBundle/CRUD/Controller/AbstractCRUDController.php index df7d065cb..71476fb78 100644 --- a/src/Bundle/ChillMainBundle/CRUD/Controller/AbstractCRUDController.php +++ b/src/Bundle/ChillMainBundle/CRUD/Controller/AbstractCRUDController.php @@ -40,6 +40,20 @@ class AbstractCRUDController extends AbstractController return $e; } + /** + * Create an entity. + * + * @param string $action + * @param Request $request + * @return object + */ + protected function createEntity(string $action, Request $request): object + { + $type = $this->getEntityClass(); + + return new $type; + } + /** * Count the number of entities * diff --git a/src/Bundle/ChillMainBundle/CRUD/Controller/ApiController.php b/src/Bundle/ChillMainBundle/CRUD/Controller/ApiController.php index 14b0473da..9686a7b98 100644 --- a/src/Bundle/ChillMainBundle/CRUD/Controller/ApiController.php +++ b/src/Bundle/ChillMainBundle/CRUD/Controller/ApiController.php @@ -85,11 +85,75 @@ class ApiController extends AbstractCRUDController case Request::METHOD_PUT: case Request::METHOD_PATCH: return $this->entityPut('_entity', $request, $id, $_format); + case Request::METHOD_POST: + return $this->entityPostAction('_entity', $request, $id, $_format); + default: + throw new \Symfony\Component\HttpFoundation\Exception\BadRequestException("This method is not implemented"); + } + } + public function entityPost(Request $request, $_format): Response + { + switch($request->getMethod()) { + case Request::METHOD_POST: + return $this->entityPostAction('_entity', $request, $_format); default: throw new \Symfony\Component\HttpFoundation\Exception\BadRequestException("This method is not implemented"); } } + protected function entityPostAction($action, Request $request, string $_format): Response + { + $entity = $this->createEntity($action, $request); + + try { + $entity = $this->deserialize($action, $request, $_format, $entity); + } catch (NotEncodableValueException $e) { + throw new BadRequestException("invalid json", 400, $e); + } + + $errors = $this->validate($action, $request, $_format, $entity); + + $response = $this->onAfterValidation($action, $request, $_format, $entity, $errors); + if ($response instanceof Response) { + return $response; + } + + if ($errors->count() > 0) { + $response = $this->json($errors); + $response->setStatusCode(Response::HTTP_UNPROCESSABLE_ENTITY); + + return $response; + } + + $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; + } + + $this->getDoctrine()->getManager()->persist($entity); + $this->getDoctrine()->getManager()->flush(); + + $response = $this->onAfterFlush($action, $request, $_format, $entity, $errors); + if ($response instanceof Response) { + return $response; + } + $response = $this->onBeforeSerialize($action, $request, $_format, $entity); + if ($response instanceof Response) { + return $response; + } + + return $this->json( + $entity, + Response::HTTP_OK, + [], + $this->getContextForSerializationPostAlter($action, $request, $_format, $entity) + ); + } public function entityPut($action, Request $request, $id, string $_format): Response { $entity = $this->getEntity($action, $id, $request, $_format); @@ -407,6 +471,7 @@ class ApiController extends AbstractCRUDController return [ 'groups' => [ 'read' ]]; case Request::METHOD_PUT: case Request::METHOD_PATCH: + case Request::METHOD_POST: return [ 'groups' => [ 'write' ]]; default: throw new \LogicException("get context for serialization is not implemented for this method"); diff --git a/src/Bundle/ChillMainBundle/CRUD/Routing/CRUDRoutesLoader.php b/src/Bundle/ChillMainBundle/CRUD/Routing/CRUDRoutesLoader.php index 32068e518..9d61d6233 100644 --- a/src/Bundle/ChillMainBundle/CRUD/Routing/CRUDRoutesLoader.php +++ b/src/Bundle/ChillMainBundle/CRUD/Routing/CRUDRoutesLoader.php @@ -183,48 +183,26 @@ class CRUDRoutesLoader extends Loader $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); - } + if (count($methods) === 0) { + throw new \RuntimeException("The api configuration named \"{$crudConfig['name']}\", action \"{$name}\", ". + "does not have any allowed methods. You should remove this action from the config ". + "or allow, at least, one method"); + } - return $collection; - } + if ('_entity' === $name && \in_array(Request::METHOD_POST, $methods)) { + unset($methods[\array_search(Request::METHOD_POST, $methods)]); + $entityPostRoute = $this->createEntityPostRoute($name, $crudConfig, $action, + $controller); + $collection->add("chill_api_single_{$crudConfig['name']}_{$name}_create", + $entityPostRoute); + } - /** - * 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) { + if (count($methods) === 0) { + // the only method was POST, + // continue to next 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); @@ -233,4 +211,18 @@ class CRUDRoutesLoader extends Loader return $collection; } + + private function createEntityPostRoute(string $name, $crudConfig, array $action, $controller): Route + { + $localPath = $action['path'].'.{_format}'; + $defaults = [ + '_controller' => $controller.':'.($action['controller_action'] ?? 'entityPost') + ]; + $path = $crudConfig['base_path'].$localPath; + $requirements = $action['requirements'] ?? []; + $route = new Route($path, $defaults, $requirements); + $route->setMethods([ Request::METHOD_POST ]); + + return $route; + } } diff --git a/src/Bundle/ChillMainBundle/Serializer/Normalizer/CenterNormalizer.php b/src/Bundle/ChillMainBundle/Serializer/Normalizer/CenterNormalizer.php index 26182d936..f1681c60a 100644 --- a/src/Bundle/ChillMainBundle/Serializer/Normalizer/CenterNormalizer.php +++ b/src/Bundle/ChillMainBundle/Serializer/Normalizer/CenterNormalizer.php @@ -20,19 +20,32 @@ namespace Chill\MainBundle\Serializer\Normalizer; use Chill\MainBundle\Entity\Center; +use Chill\MainBundle\Repository\CenterRepository; +use Symfony\Component\Serializer\Normalizer\DenormalizerInterface; use Symfony\Component\Serializer\Normalizer\NormalizerInterface; +use Symfony\Component\Serializer\Exception\InvalidArgumentException; +use Symfony\Component\Serializer\Exception\UnexpectedValueException; /** * * */ -class CenterNormalizer implements NormalizerInterface +class CenterNormalizer implements NormalizerInterface, DenormalizerInterface { + private CenterRepository $repository; + + + public function __construct(CenterRepository $repository) + { + $this->repository = $repository; + } + public function normalize($center, string $format = null, array $context = array()) { /** @var Center $center */ return [ 'id' => $center->getId(), + 'type' => 'center', 'name' => $center->getName() ]; } @@ -41,4 +54,30 @@ class CenterNormalizer implements NormalizerInterface { return $data instanceof Center; } + + public function denormalize($data, string $type, string $format = null, array $context = []) + { + if (FALSE === \array_key_exists('type', $data)) { + throw new InvalidArgumentException('missing "type" key in data'); + } + if ('center' !== $data['type']) { + throw new InvalidArgumentException('type should be equal to "center"'); + } + if (FALSE === \array_key_exists('id', $data)) { + throw new InvalidArgumentException('missing "id" key in data'); + } + + $center = $this->repository->find($data['id']); + + if (null === $center) { + throw new UnexpectedValueException("The type with id {$data['id']} does not exists"); + } + + return $center; + } + + public function supportsDenormalization($data, string $type, string $format = null) + { + return $type === Center::class; + } } diff --git a/src/Bundle/ChillMainBundle/chill.api.specs.yaml b/src/Bundle/ChillMainBundle/chill.api.specs.yaml index ada65b08a..68a3eb764 100644 --- a/src/Bundle/ChillMainBundle/chill.api.specs.yaml +++ b/src/Bundle/ChillMainBundle/chill.api.specs.yaml @@ -9,15 +9,14 @@ servers: description: "Your current dev server" components: - parameters: - _format: - name: _format - in: path - required: true - schema: - type: string - enum: - - json + schemas: + Center: + type: object + properties: + id: + type: integer + name: + type: string paths: /1.0/search.json: diff --git a/src/Bundle/ChillPersonBundle/Controller/ApiPersonController.php b/src/Bundle/ChillPersonBundle/Controller/ApiPersonController.php deleted file mode 100644 index bfaf22d7b..000000000 --- a/src/Bundle/ChillPersonBundle/Controller/ApiPersonController.php +++ /dev/null @@ -1,30 +0,0 @@ - - * - * 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\PersonBundle\Controller; - -use Symfony\Bundle\FrameworkBundle\Controller\Controller; -use Symfony\Component\HttpFoundation\JsonResponse; - - -class ApiPersonController extends Controller -{ - public function viewAction($id, $_format) - { - - } -} diff --git a/src/Bundle/ChillPersonBundle/Controller/PersonApiController.php b/src/Bundle/ChillPersonBundle/Controller/PersonApiController.php new file mode 100644 index 000000000..84f1ebf66 --- /dev/null +++ b/src/Bundle/ChillPersonBundle/Controller/PersonApiController.php @@ -0,0 +1,50 @@ + + * + * 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\PersonBundle\Controller; + +use Chill\PersonBundle\Security\Authorization\PersonVoter; +use Chill\MainBundle\Security\Authorization\AuthorizationHelper; +use Symfony\Component\Security\Core\Role\Role; +use Chill\MainBundle\CRUD\Controller\ApiController; +use Symfony\Component\HttpFoundation\Request; + + +class PersonApiController extends ApiController +{ + private AuthorizationHelper $authorizationHelper; + + /** + * @param AuthorizationHelper $authorizationHelper + */ + public function __construct(AuthorizationHelper $authorizationHelper) + { + $this->authorizationHelper = $authorizationHelper; + } + + protected function createEntity(string $action, Request $request): object + { + $person = parent::createEntity($action, $request); + + // TODO temporary hack to allow creation of person with fake center + $centers = $this->authorizationHelper->getReachableCenters($this->getUser(), + new Role(PersonVoter::CREATE)); + $person->setCenter($centers[0]); + + return $person; + } +} diff --git a/src/Bundle/ChillPersonBundle/DependencyInjection/ChillPersonExtension.php b/src/Bundle/ChillPersonBundle/DependencyInjection/ChillPersonExtension.php index 0d6346c01..27721012d 100644 --- a/src/Bundle/ChillPersonBundle/DependencyInjection/ChillPersonExtension.php +++ b/src/Bundle/ChillPersonBundle/DependencyInjection/ChillPersonExtension.php @@ -476,7 +476,6 @@ class ChillPersonExtension extends Extension implements PrependExtensionInterfac 'class' => \Chill\PersonBundle\Entity\SocialWork\SocialIssue::class, 'name' => 'social_work_social_issue', 'base_path' => '/api/1.0/person/social-work/social-issue', -// 'controller' => \Chill\PersonBundle\Controller\OpeningApiController::class, 'base_role' => 'ROLE_USER', 'actions' => [ '_index' => [ @@ -493,6 +492,28 @@ class ChillPersonExtension extends Extension implements PrependExtensionInterfac ], ] ], + [ + 'class' => \Chill\PersonBundle\Entity\Person::class, + 'name' => 'person', + 'base_path' => '/api/1.0/person/person', + 'base_role' => \Chill\PersonBundle\Security\Authorization\PersonVoter::SEE, + 'controller' => \Chill\PersonBundle\Controller\PersonApiController::class, + 'actions' => [ + '_entity' => [ + 'methods' => [ + Request::METHOD_GET => true, + Request::METHOD_HEAD => true, + Request::METHOD_POST=> true, + ], + 'roles' => [ + Request::METHOD_GET => \Chill\PersonBundle\Security\Authorization\PersonVoter::SEE, + Request::METHOD_HEAD => \Chill\PersonBundle\Security\Authorization\PersonVoter::SEE, + Request::METHOD_POST => \Chill\PersonBundle\Security\Authorization\PersonVoter::CREATE, + + ] + ], + ] + ], ] ]); } diff --git a/src/Bundle/ChillPersonBundle/Serializer/Normalizer/PersonNormalizer.php b/src/Bundle/ChillPersonBundle/Serializer/Normalizer/PersonNormalizer.php index 4a9f874de..98edcec14 100644 --- a/src/Bundle/ChillPersonBundle/Serializer/Normalizer/PersonNormalizer.php +++ b/src/Bundle/ChillPersonBundle/Serializer/Normalizer/PersonNormalizer.php @@ -18,7 +18,11 @@ */ namespace Chill\PersonBundle\Serializer\Normalizer; +use Chill\MainBundle\Entity\Center; use Chill\PersonBundle\Entity\Person; +use Symfony\Component\Serializer\Normalizer\DenormalizerAwareInterface; +use Symfony\Component\Serializer\Normalizer\DenormalizerAwareTrait; +use Symfony\Component\Serializer\Normalizer\NormalizerAwareTrait; use Symfony\Component\Serializer\Normalizer\NormalizerInterface; use Symfony\Component\Serializer\Normalizer\DenormalizerInterface; use Symfony\Component\Serializer\Normalizer\NormalizerAwareInterface; @@ -27,6 +31,7 @@ use Symfony\Component\Serializer\Exception\RuntimeException; use Symfony\Component\Serializer\Exception\UnexpectedValueException; use Chill\MainBundle\Templating\Entity\ChillEntityRenderExtension; use Symfony\Component\Serializer\Normalizer\AbstractNormalizer; +use Symfony\Component\Serializer\Normalizer\ObjectToPopulateTrait; /** * Serialize a Person entity @@ -34,16 +39,24 @@ use Symfony\Component\Serializer\Normalizer\AbstractNormalizer; */ class PersonNormalizer implements NormalizerInterface, - NormalizerAwareInterface + NormalizerAwareInterface, + DenormalizerInterface, + DenormalizerAwareInterface { - - protected NormalizerInterface $normalizer; - private ChillEntityRenderExtension $render; - public function __construct(ChillEntityRenderExtension $render) + private PersonRepository $repository; + + use NormalizerAwareTrait; + + use ObjectToPopulateTrait; + + use DenormalizerAwareTrait; + + public function __construct(ChillEntityRenderExtension $render, PersonRepository $repository) { $this->render = $render; + $this->repository = $repository; } public function normalize($person, string $format = null, array $context = array()) @@ -59,7 +72,9 @@ class PersonNormalizer implements 'center' => $this->normalizer->normalize($person->getCenter()), 'phonenumber' => $person->getPhonenumber(), 'mobilenumber' => $person->getMobilenumber(), - 'altNames' => $this->normalizeAltNames($person->getAltNames()) + 'altNames' => $this->normalizeAltNames($person->getAltNames()), + 'gender' => $person->getGender(), + 'gender_numeric' => $person->getGenderNumeric(), ]; } @@ -80,9 +95,50 @@ class PersonNormalizer implements return $data instanceof Person; } - - public function setNormalizer(NormalizerInterface $normalizer) + public function denormalize($data, string $type, string $format = null, array $context = []) { - $this->normalizer = $normalizer; + $person = $this->extractObjectToPopulate($type, $context); + + if (\array_key_exists('id', $data)) { + $person = $this->repository->find($data['id']); + + if (null === $person) { + throw new UnexpectedValueException("The person with id \"{$data['id']}\" does ". + "not exists"); + } + // currently, not allowed to update a person through api + // if instantiated with id + return $person; + } + + if (null === $person) { + $person = new Person(); + } + + foreach (['firstName', 'lastName', 'phonenumber', 'mobilenumber', 'gender'] + as $item) { + if (\array_key_exists($item, $data)) { + $person->{'set'.\ucfirst($item)}($data[$item]); + } + } + + foreach ([ + 'birthdate' => \DateTime::class, + 'center' => Center::class + ] as $item => $class) { + if (\array_key_exists($item, $data)) { + $object = $this->denormalizer->denormalize($data[$item], $class, $format, $context); + if ($object instanceof $class) { + $person->{'set'.\ucfirst($item)}($object); + } + } + } + + return $person; + } + + public function supportsDenormalization($data, string $type, string $format = null) + { + return $type === Person::class && ($data['type'] ?? NULL) === 'person'; } } diff --git a/src/Bundle/ChillPersonBundle/Tests/Controller/PersonApiControllerTest.php b/src/Bundle/ChillPersonBundle/Tests/Controller/PersonApiControllerTest.php new file mode 100644 index 000000000..d261f4294 --- /dev/null +++ b/src/Bundle/ChillPersonBundle/Tests/Controller/PersonApiControllerTest.php @@ -0,0 +1,83 @@ +getClientAuthenticated(); + + $client->request(Request::METHOD_GET, "/api/1.0/person/person/{$personId}.json"); + $response = $client->getResponse(); + + $this->assertEquals(403, $response->getStatusCode()); + } + + /** + * @dataProvider dataGetPersonFromCenterA + */ + public function testPersonGet($personId): void + { + $client = $this->getClientAuthenticated(); + + $client->request(Request::METHOD_GET, "/api/1.0/person/person/{$personId}.json"); + $response = $client->getResponse(); + + $this->assertResponseIsSuccessful(); + + $data = \json_decode($client->getResponse()->getContent(), true); + + $this->assertArrayHasKey('type', $data); + $this->assertArrayHasKey('id', $data); + $this->assertEquals('person', $data['type']); + $this->assertEquals($personId, $data['id']); + } + + public function dataGetPersonFromCenterA(): \Iterator + { + self::bootKernel(); + $em = self::$container->get(EntityManagerInterface::class); + $personIds= $em->createQuery("SELECT p.id FROM ".Person::class." p ". + "JOIN p.center c ". + "WHERE c.name = :center") + ->setParameter('center', 'Center A') + ->setMaxResults(100) + ->getScalarResult() + ; + + \shuffle($personIds); + + yield \array_pop($personIds); + yield \array_pop($personIds); + } + + public function dataGetPersonFromCenterB(): \Iterator + { + self::bootKernel(); + $em = self::$container->get(EntityManagerInterface::class); + $personIds= $em->createQuery("SELECT p.id FROM ".Person::class." p ". + "JOIN p.center c ". + "WHERE c.name = :center") + ->setParameter('center', 'Center B') + ->setMaxResults(100) + ->getScalarResult() + ; + + \shuffle($personIds); + + yield \array_pop($personIds); + yield \array_pop($personIds); + } +} diff --git a/src/Bundle/ChillPersonBundle/chill.api.specs.yaml b/src/Bundle/ChillPersonBundle/chill.api.specs.yaml index a5636d93e..c3067be55 100644 --- a/src/Bundle/ChillPersonBundle/chill.api.specs.yaml +++ b/src/Bundle/ChillPersonBundle/chill.api.specs.yaml @@ -41,6 +41,11 @@ components: properties: id: type: integer + readOnly: true + type: + type: string + enum: + - 'person' firstName: type: string lastName: @@ -48,12 +53,23 @@ components: text: type: string description: a canonical representation for the person name + readOnly: true birthdate: $ref: '#/components/schemas/Date' phonenumber: type: string mobilenumber: type: string + gender: + type: string + enum: + - man + - woman + - both + gender_numeric: + type: integer + description: a numerical representation of gender + readOnly: true PersonById: type: object properties: @@ -178,6 +194,53 @@ components: readOnly: true paths: + /1.0/person/person/{id}.json: + get: + tags: + - person + summary: Get a single person + parameters: + - name: id + in: path + required: true + description: The person's id + schema: + type: integer + format: integer + minimum: 1 + responses: + 200: + description: "OK" + content: + application/json: + schema: + $ref: "#/components/schemas/Person" + 403: + description: "Unauthorized" + /1.0/person/person.json: + post: + tags: + - person + summary: Create a single person + requestBody: + description: "A person" + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/Person' + responses: + 200: + description: "OK" + content: + application/json: + schema: + $ref: "#/components/schemas/Person" + 403: + description: "Unauthorized" + 422: + description: "Invalid data: the data is a valid json, could be deserialized, but does not pass validation" + /1.0/person/social-work/social-issue.json: get: tags: diff --git a/src/Bundle/ChillPersonBundle/config/services/controller.yaml b/src/Bundle/ChillPersonBundle/config/services/controller.yaml index fd5e1f952..311950883 100644 --- a/src/Bundle/ChillPersonBundle/config/services/controller.yaml +++ b/src/Bundle/ChillPersonBundle/config/services/controller.yaml @@ -53,3 +53,9 @@ services: $validator: '@Symfony\Component\Validator\Validator\ValidatorInterface' $registry: '@Symfony\Component\Workflow\Registry' tags: ['controller.service_arguments'] + + Chill\PersonBundle\Controller\PersonApiController: + arguments: + $authorizationHelper: '@Chill\MainBundle\Security\Authorization\AuthorizationHelper' + tags: ['controller.service_arguments'] +